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