plugins.py 6.5 KB

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