4 import importlib
.machinery
13 from pathlib
import Path
14 from zipfile
import ZipFile
16 from .compat
import functools
# isort: split
17 from .compat
import compat_expanduser
20 get_system_config_dirs
,
25 PACKAGE_NAME
= 'yt_dlp_plugins'
26 COMPAT_PACKAGE_NAME
= 'ytdlp_plugins'
29 class PluginLoader(importlib
.abc
.Loader
):
30 """Dummy loader for virtual namespace packages"""
32 def exec_module(self
, module
):
37 def dirs_in_zip(archive
):
38 with ZipFile(archive
) as zip:
39 return set(itertools
.chain
.from_iterable(
40 Path(file).parents
for file in zip.namelist()))
43 class PluginFinder(importlib
.abc
.MetaPathFinder
):
45 This class provides one or multiple namespace packages.
46 It searches in sys.path and yt-dlp config folders for
47 the existing subdirectories from which the modules can be imported
50 def __init__(self
, *packages
):
51 self
._zip
_content
_cache
= {}
52 self
.packages
= set(itertools
.chain
.from_iterable(
53 itertools
.accumulate(name
.split('.'), lambda a
, b
: '.'.join((a
, b
)))
54 for name
in packages
))
56 def search_locations(self
, fullname
):
57 candidate_locations
= []
59 def _get_package_paths(*root_paths
, containing_folder
='plugins'):
60 for config_dir
in map(Path
, root_paths
):
61 plugin_dir
= config_dir
/ containing_folder
62 if not plugin_dir
.is_dir():
64 yield from plugin_dir
.iterdir()
66 # Load from yt-dlp config folders
67 candidate_locations
.extend(_get_package_paths(
68 *get_user_config_dirs('yt-dlp'), *get_system_config_dirs('yt-dlp'),
69 containing_folder
='plugins'))
71 # Load from yt-dlp-plugins folders
72 candidate_locations
.extend(_get_package_paths(
73 get_executable_path(),
74 compat_expanduser('~'),
76 os
.getenv('XDG_CONFIG_HOME') or compat_expanduser('~/.config'),
77 containing_folder
='yt-dlp-plugins'))
79 candidate_locations
.extend(map(Path
, sys
.path
)) # PYTHONPATH
81 parts
= Path(*fullname
.split('.'))
83 for path
in dict.fromkeys(candidate_locations
):
84 candidate
= path
/ parts
85 if candidate
.is_dir():
86 locations
.add(str(candidate
))
87 elif path
.name
and any(path
.with_suffix(suffix
).is_file() for suffix
in {'.zip', '.egg', '.whl'}
):
88 with contextlib
.suppress(FileNotFoundError
):
89 if parts
in dirs_in_zip(path
):
90 locations
.add(str(candidate
))
93 def find_spec(self
, fullname
, path
=None, target
=None):
94 if fullname
not in self
.packages
:
97 search_locations
= self
.search_locations(fullname
)
98 if not search_locations
:
101 spec
= importlib
.machinery
.ModuleSpec(fullname
, PluginLoader(), is_package
=True)
102 spec
.submodule_search_locations
= search_locations
105 def invalidate_caches(self
):
106 dirs_in_zip
.cache_clear()
107 for package
in self
.packages
:
108 if package
in sys
.modules
:
109 del sys
.modules
[package
]
113 spec
= importlib
.util
.find_spec(PACKAGE_NAME
)
114 return spec
.submodule_search_locations
if spec
else []
117 def iter_modules(subpackage
):
118 fullname
= f
'{PACKAGE_NAME}.{subpackage}'
119 with contextlib
.suppress(ModuleNotFoundError
):
120 pkg
= importlib
.import_module(fullname
)
121 yield from pkgutil
.iter_modules(path
=pkg
.__path
__, prefix
=f
'{fullname}.')
124 def load_module(module
, module_name
, suffix
):
125 return inspect
.getmembers(module
, lambda obj
: (
127 and obj
.__name
__.endswith(suffix
)
128 and obj
.__module
__.startswith(module_name
)
129 and not obj
.__name
__.startswith('_')
130 and obj
.__name
__ in getattr(module
, '__all__', [obj
.__name
__])))
133 def load_plugins(name
, suffix
):
136 for finder
, module_name
, _
in iter_modules(name
):
137 if any(x
.startswith('_') for x
in module_name
.split('.')):
140 if sys
.version_info
< (3, 10) and isinstance(finder
, zipimport
.zipimporter
):
141 # zipimporter.load_module() is deprecated in 3.10 and removed in 3.12
142 # The exec_module branch below is the replacement for >= 3.10
143 # See: https://docs.python.org/3/library/zipimport.html#zipimport.zipimporter.exec_module
144 module
= finder
.load_module(module_name
)
146 spec
= finder
.find_spec(module_name
)
147 module
= importlib
.util
.module_from_spec(spec
)
148 sys
.modules
[module_name
] = module
149 spec
.loader
.exec_module(module
)
151 write_string(f
'Error while importing module {module_name!r}\n{traceback.format_exc(limit=-1)}')
153 classes
.update(load_module(module
, module_name
, suffix
))
155 # Compat: old plugin system using __init__.py
156 # Note: plugins imported this way do not show up in directories()
157 # nor are considered part of the yt_dlp_plugins namespace package
158 with contextlib
.suppress(FileNotFoundError
):
159 spec
= importlib
.util
.spec_from_file_location(
160 name
, Path(get_executable_path(), COMPAT_PACKAGE_NAME
, name
, '__init__.py'))
161 plugins
= importlib
.util
.module_from_spec(spec
)
162 sys
.modules
[spec
.name
] = plugins
163 spec
.loader
.exec_module(plugins
)
164 classes
.update(load_module(plugins
, spec
.name
, suffix
))
169 sys
.meta_path
.insert(0, PluginFinder(f
'{PACKAGE_NAME}.extractor', f
'{PACKAGE_NAME}.postprocessor'))
171 __all__
= ['directories', 'load_plugins', 'PACKAGE_NAME', 'COMPAT_PACKAGE_NAME']