]> jfr.im git - yt-dlp.git/blame_incremental - yt_dlp/plugins.py
[ie] Migrate commonly plural fields to lists (#8917)
[yt-dlp.git] / yt_dlp / plugins.py
... / ...
CommitLineData
1import contextlib
2import importlib
3import importlib.abc
4import importlib.machinery
5import importlib.util
6import inspect
7import itertools
8import pkgutil
9import sys
10import traceback
11import zipimport
12from pathlib import Path
13from zipfile import ZipFile
14
15from .compat import functools # isort: split
16from .utils import (
17 get_executable_path,
18 get_system_config_dirs,
19 get_user_config_dirs,
20 orderedSet,
21 write_string,
22)
23
24PACKAGE_NAME = 'yt_dlp_plugins'
25COMPAT_PACKAGE_NAME = 'ytdlp_plugins'
26
27
28class PluginLoader(importlib.abc.Loader):
29 """Dummy loader for virtual namespace packages"""
30
31 def exec_module(self, module):
32 return None
33
34
35@functools.cache
36def dirs_in_zip(archive):
37 try:
38 with ZipFile(archive) as zip_:
39 return set(itertools.chain.from_iterable(
40 Path(file).parents for file in zip_.namelist()))
41 except FileNotFoundError:
42 pass
43 except Exception as e:
44 write_string(f'WARNING: Could not read zip file {archive}: {e}\n')
45 return set()
46
47
48class PluginFinder(importlib.abc.MetaPathFinder):
49 """
50 This class provides one or multiple namespace packages.
51 It searches in sys.path and yt-dlp config folders for
52 the existing subdirectories from which the modules can be imported
53 """
54
55 def __init__(self, *packages):
56 self._zip_content_cache = {}
57 self.packages = set(itertools.chain.from_iterable(
58 itertools.accumulate(name.split('.'), lambda a, b: '.'.join((a, b)))
59 for name in packages))
60
61 def search_locations(self, fullname):
62 candidate_locations = []
63
64 def _get_package_paths(*root_paths, containing_folder='plugins'):
65 for config_dir in orderedSet(map(Path, root_paths), lazy=True):
66 with contextlib.suppress(OSError):
67 yield from (config_dir / containing_folder).iterdir()
68
69 # Load from yt-dlp config folders
70 candidate_locations.extend(_get_package_paths(
71 *get_user_config_dirs('yt-dlp'),
72 *get_system_config_dirs('yt-dlp'),
73 containing_folder='plugins'))
74
75 # Load from yt-dlp-plugins folders
76 candidate_locations.extend(_get_package_paths(
77 get_executable_path(),
78 *get_user_config_dirs(''),
79 *get_system_config_dirs(''),
80 containing_folder='yt-dlp-plugins'))
81
82 candidate_locations.extend(map(Path, sys.path)) # PYTHONPATH
83 with contextlib.suppress(ValueError): # Added when running __main__.py directly
84 candidate_locations.remove(Path(__file__).parent)
85
86 parts = Path(*fullname.split('.'))
87 for path in orderedSet(candidate_locations, lazy=True):
88 candidate = path / parts
89 if candidate.is_dir():
90 yield candidate
91 elif path.suffix in ('.zip', '.egg', '.whl') and path.is_file():
92 if parts in dirs_in_zip(path):
93 yield candidate
94
95 def find_spec(self, fullname, path=None, target=None):
96 if fullname not in self.packages:
97 return None
98
99 search_locations = list(map(str, self.search_locations(fullname)))
100 if not search_locations:
101 return None
102
103 spec = importlib.machinery.ModuleSpec(fullname, PluginLoader(), is_package=True)
104 spec.submodule_search_locations = search_locations
105 return spec
106
107 def invalidate_caches(self):
108 dirs_in_zip.cache_clear()
109 for package in self.packages:
110 if package in sys.modules:
111 del sys.modules[package]
112
113
114def directories():
115 spec = importlib.util.find_spec(PACKAGE_NAME)
116 return spec.submodule_search_locations if spec else []
117
118
119def iter_modules(subpackage):
120 fullname = f'{PACKAGE_NAME}.{subpackage}'
121 with contextlib.suppress(ModuleNotFoundError):
122 pkg = importlib.import_module(fullname)
123 yield from pkgutil.iter_modules(path=pkg.__path__, prefix=f'{fullname}.')
124
125
126def load_module(module, module_name, suffix):
127 return inspect.getmembers(module, lambda obj: (
128 inspect.isclass(obj)
129 and obj.__name__.endswith(suffix)
130 and obj.__module__.startswith(module_name)
131 and not obj.__name__.startswith('_')
132 and obj.__name__ in getattr(module, '__all__', [obj.__name__])))
133
134
135def load_plugins(name, suffix):
136 classes = {}
137
138 for finder, module_name, _ in iter_modules(name):
139 if any(x.startswith('_') for x in module_name.split('.')):
140 continue
141 try:
142 if sys.version_info < (3, 10) and isinstance(finder, zipimport.zipimporter):
143 # zipimporter.load_module() is deprecated in 3.10 and removed in 3.12
144 # The exec_module branch below is the replacement for >= 3.10
145 # See: https://docs.python.org/3/library/zipimport.html#zipimport.zipimporter.exec_module
146 module = finder.load_module(module_name)
147 else:
148 spec = finder.find_spec(module_name)
149 module = importlib.util.module_from_spec(spec)
150 sys.modules[module_name] = module
151 spec.loader.exec_module(module)
152 except Exception:
153 write_string(f'Error while importing module {module_name!r}\n{traceback.format_exc(limit=-1)}')
154 continue
155 classes.update(load_module(module, module_name, suffix))
156
157 # Compat: old plugin system using __init__.py
158 # Note: plugins imported this way do not show up in directories()
159 # nor are considered part of the yt_dlp_plugins namespace package
160 with contextlib.suppress(FileNotFoundError):
161 spec = importlib.util.spec_from_file_location(
162 name, Path(get_executable_path(), COMPAT_PACKAGE_NAME, name, '__init__.py'))
163 plugins = importlib.util.module_from_spec(spec)
164 sys.modules[spec.name] = plugins
165 spec.loader.exec_module(plugins)
166 classes.update(load_module(plugins, spec.name, suffix))
167
168 return classes
169
170
171sys.meta_path.insert(0, PluginFinder(f'{PACKAGE_NAME}.extractor', f'{PACKAGE_NAME}.postprocessor'))
172
173__all__ = ['directories', 'load_plugins', 'PACKAGE_NAME', 'COMPAT_PACKAGE_NAME']