1
0
Fork 0
mirror of https://github.com/yt-dlp/yt-dlp.git synced 2025-03-09 12:50:23 -05:00
yt-dlp/yt_dlp/plugins.py

291 lines
9.2 KiB
Python
Raw Normal View History

import contextlib
2024-10-18 20:58:33 -05:00
import dataclasses
import importlib
import importlib.abc
import importlib.machinery
import importlib.util
import inspect
import itertools
import os
import pkgutil
import sys
import traceback
import zipimport
2024-11-29 17:57:30 -06:00
import functools
from pathlib import Path
from zipfile import ZipFile
2024-10-18 22:25:36 -05:00
from ._globals import (
2023-01-07 01:19:13 -06:00
plugin_dirs,
2024-10-19 17:59:29 -05:00
all_plugins_loaded,
plugin_specs,
2024-10-19 20:06:33 -05:00
plugins_enabled,
2024-11-29 17:57:30 -06:00
Indirect,
2023-01-07 01:19:13 -06:00
)
from .utils import (
get_executable_path,
get_system_config_dirs,
get_user_config_dirs,
2023-01-07 01:19:13 -06:00
merge_dicts,
2023-01-15 21:53:41 -06:00
orderedSet,
write_string,
YoutubeDLError,
)
PACKAGE_NAME = 'yt_dlp_plugins'
COMPAT_PACKAGE_NAME = 'ytdlp_plugins'
2023-01-15 21:53:41 -06:00
_BASE_PACKAGE_PATH = Path(__file__).parent
# Public APIs
# Anything else is NOT public and no backwards compatibility is guaranteed
__all__ = [
'directories',
'load_plugins',
'load_all_plugins',
'register_plugin_spec',
'add_plugin_dirs',
'set_plugin_dirs',
2024-10-19 20:06:33 -05:00
'disable_plugins',
'get_plugin_spec',
'PACKAGE_NAME',
'COMPAT_PACKAGE_NAME',
]
2024-10-19 17:59:29 -05:00
@dataclasses.dataclass
class PluginSpec:
module_name: str
suffix: str
2024-11-29 17:57:30 -06:00
destination: Indirect
plugin_destination: Indirect
2023-01-07 01:19:13 -06:00
class PluginLoader(importlib.abc.Loader):
"""Dummy loader for virtual namespace packages"""
def exec_module(self, module):
return None
@functools.cache
def dirs_in_zip(archive):
2023-01-15 21:53:41 -06:00
try:
with ZipFile(archive) as zip_:
2023-03-18 10:59:11 -05:00
return set(
itertools.chain.from_iterable(
Path(file).parents for file in zip_.namelist()
),
2023-03-18 10:59:11 -05:00
)
2023-01-15 21:53:41 -06:00
except FileNotFoundError:
pass
except Exception as e:
write_string(f'WARNING: Could not read zip file {archive}: {e}\n')
2023-01-07 01:19:13 -06:00
return ()
2024-10-19 17:59:29 -05:00
def external_plugin_paths():
2023-01-15 21:53:41 -06:00
def _get_package_paths(*root_paths, containing_folder):
for config_dir in orderedSet(map(Path, root_paths), lazy=True):
# We need to filter the base path added when running __main__.py directly
if config_dir == _BASE_PACKAGE_PATH:
2023-01-07 01:19:13 -06:00
continue
2023-01-15 21:53:41 -06:00
with contextlib.suppress(OSError):
2023-03-18 10:59:11 -05:00
yield from (config_dir / containing_folder).iterdir()
2023-01-07 01:19:13 -06:00
# Load from yt-dlp config folders
2023-01-15 21:53:41 -06:00
yield from _get_package_paths(
*get_user_config_dirs('yt-dlp'),
*get_system_config_dirs('yt-dlp'),
containing_folder='plugins',
2023-03-18 10:59:11 -05:00
)
2023-01-07 01:19:13 -06:00
# Load from yt-dlp-plugins folders
2023-01-15 21:53:41 -06:00
yield from _get_package_paths(
2023-01-07 01:19:13 -06:00
get_executable_path(),
*get_user_config_dirs(''),
*get_system_config_dirs(''),
containing_folder='yt-dlp-plugins',
2023-03-18 10:59:11 -05:00
)
2023-01-07 01:19:13 -06:00
yield from internal_plugin_paths()
def internal_plugin_paths():
# Always try load from PYTHONPATH folders
2023-01-15 21:53:41 -06:00
yield from (path for path in map(Path, sys.path) if path != _BASE_PACKAGE_PATH)
def candidate_plugin_paths(candidate):
yield from Path(candidate).iterdir()
yield from internal_plugin_paths()
class PluginFinder(importlib.abc.MetaPathFinder):
"""
This class provides one or multiple namespace packages.
It searches in sys.path and yt-dlp config folders for
the existing subdirectories from which the modules can be imported
"""
def __init__(self, *packages):
self._zip_content_cache = {}
2023-03-18 10:59:11 -05:00
self.packages = set(
itertools.chain.from_iterable(
itertools.accumulate(name.split('.'), lambda a, b: '.'.join((a, b)))
2023-03-18 10:59:11 -05:00
for name in packages
),
2023-03-18 10:59:11 -05:00
)
def search_locations(self, fullname):
2023-01-07 01:19:13 -06:00
candidate_locations = itertools.chain.from_iterable(
external_plugin_paths() if candidate == 'external' else candidate_plugin_paths(candidate)
2024-11-29 17:57:30 -06:00
for candidate in plugin_dirs.value
2023-03-18 10:59:11 -05:00
)
parts = Path(*fullname.split('.'))
2023-01-15 21:53:41 -06:00
for path in orderedSet(candidate_locations, lazy=True):
candidate = path / parts
try:
if candidate.is_dir():
yield candidate
elif path.suffix in ('.zip', '.egg', '.whl') and path.is_file():
if parts in dirs_in_zip(path):
yield candidate
except PermissionError as e:
write_string(f'Permission error while accessing modules in "{e.filename}"\n')
def find_spec(self, fullname, path=None, target=None):
if not plugins_enabled.value:
return None
if fullname not in self.packages:
return None
2023-01-15 21:53:41 -06:00
search_locations = list(map(str, self.search_locations(fullname)))
if not search_locations:
return None
spec = importlib.machinery.ModuleSpec(fullname, PluginLoader(), is_package=True)
spec.submodule_search_locations = search_locations
return spec
def invalidate_caches(self):
dirs_in_zip.cache_clear()
for package in self.packages:
if package in sys.modules:
del sys.modules[package]
def directories():
spec = importlib.util.find_spec(PACKAGE_NAME)
2024-11-29 18:52:09 -06:00
return list(spec.submodule_search_locations) if spec else []
def iter_modules(subpackage):
fullname = f'{PACKAGE_NAME}.{subpackage}'
with contextlib.suppress(ModuleNotFoundError):
pkg = importlib.import_module(fullname)
yield from pkgutil.iter_modules(path=pkg.__path__, prefix=f'{fullname}.')
2024-10-18 22:55:23 -05:00
def get_regular_classes(module, module_name, suffix):
2024-10-18 20:58:33 -05:00
# Find standard public plugin classes (not overrides)
return inspect.getmembers(module, lambda obj: (
2024-10-18 20:05:58 -05:00
inspect.isclass(obj)
and obj.__name__.endswith(suffix)
and obj.__module__.startswith(module_name)
and not obj.__name__.startswith('_')
2024-10-18 20:58:33 -05:00
and obj.__name__ in getattr(module, '__all__', [obj.__name__])
and getattr(obj, 'PLUGIN_NAME', None) is None
2024-10-18 22:25:36 -05:00
))
2024-10-18 20:58:33 -05:00
2024-10-19 17:59:29 -05:00
def load_plugins(plugin_spec: PluginSpec):
name, suffix = plugin_spec.module_name, plugin_spec.suffix
2024-10-18 20:58:33 -05:00
regular_classes = {}
2024-11-29 17:57:30 -06:00
if os.environ.get('YTDLP_NO_PLUGINS') or plugins_enabled.value is False:
2024-10-18 20:58:33 -05:00
return regular_classes
for finder, module_name, _ in iter_modules(name):
if any(x.startswith('_') for x in module_name.split('.')):
continue
try:
if sys.version_info < (3, 10) and isinstance(finder, zipimport.zipimporter):
# zipimporter.load_module() is deprecated in 3.10 and removed in 3.12
# The exec_module branch below is the replacement for >= 3.10
# See: https://docs.python.org/3/library/zipimport.html#zipimport.zipimporter.exec_module
module = finder.load_module(module_name)
else:
spec = finder.find_spec(module_name)
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
except Exception:
2023-03-18 10:59:11 -05:00
write_string(
f'Error while importing module {module_name!r}\n{traceback.format_exc(limit=-1)}',
2023-03-18 10:59:11 -05:00
)
continue
2024-10-18 22:55:23 -05:00
regular_classes.update(get_regular_classes(module, module_name, suffix))
# Compat: old plugin system using __init__.py
# Note: plugins imported this way do not show up in directories()
# nor are considered part of the yt_dlp_plugins namespace package
2024-11-29 17:57:30 -06:00
if 'external' in plugin_dirs.value:
2023-01-07 01:19:13 -06:00
with contextlib.suppress(FileNotFoundError):
spec = importlib.util.spec_from_file_location(
2023-03-18 10:59:11 -05:00
name,
Path(get_executable_path(), COMPAT_PACKAGE_NAME, name, '__init__.py'),
2023-03-18 10:59:11 -05:00
)
2023-01-07 01:19:13 -06:00
plugins = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = plugins
spec.loader.exec_module(plugins)
2024-10-18 22:55:23 -05:00
regular_classes.update(get_regular_classes(plugins, spec.name, suffix))
2023-01-07 01:19:13 -06:00
2024-10-18 20:58:33 -05:00
# Add the classes into the global plugin lookup for that type
2024-11-29 17:57:30 -06:00
plugin_spec.plugin_destination.value = regular_classes
2024-10-18 20:58:33 -05:00
# We want to prepend to the main lookup for that type
2024-11-29 17:57:30 -06:00
plugin_spec.destination.value = merge_dicts(regular_classes, plugin_spec.destination.value)
2024-10-18 20:58:33 -05:00
return regular_classes
2023-01-07 01:19:13 -06:00
2024-10-19 17:59:29 -05:00
def load_all_plugins():
2024-11-29 17:57:30 -06:00
for plugin_spec in plugin_specs.value.values():
2024-10-19 17:59:29 -05:00
load_plugins(plugin_spec)
2024-11-29 17:57:30 -06:00
all_plugins_loaded.value = True
2024-10-19 17:59:29 -05:00
def register_plugin_spec(plugin_spec: PluginSpec):
# If the plugin spec for a module is already registered, it will not be added again
2024-11-29 17:57:30 -06:00
if plugin_spec.module_name not in plugin_specs.value:
plugin_specs.value[plugin_spec.module_name] = plugin_spec
2024-10-19 17:59:29 -05:00
sys.meta_path.insert(0, PluginFinder(f'{PACKAGE_NAME}.{plugin_spec.module_name}'))
2023-01-07 01:19:13 -06:00
def add_plugin_dirs(*paths):
"""Add external plugin dirs to the existing ones"""
2024-11-29 17:57:30 -06:00
plugin_dirs.value.extend(paths)
2024-10-18 22:25:36 -05:00
def set_plugin_dirs(*paths):
"""Set external plugin dirs, overriding the default ones"""
2024-11-29 17:57:30 -06:00
plugin_dirs.value = list(paths)
def get_plugin_spec(module_name):
2024-11-29 17:57:30 -06:00
return plugin_specs.value.get(module_name)
2024-10-19 20:06:33 -05:00
def disable_plugins():
if (
2024-11-29 17:57:30 -06:00
all_plugins_loaded.value
or any(len(plugin_spec.plugin_destination.value) != 0 for plugin_spec in plugin_specs.value.values())
2024-10-19 20:06:33 -05:00
):
# note: we can't detect all cases when plugins are loaded (e.g. if spec isn't registered)
raise YoutubeDLError('Plugins have already been loaded. Cannot disable plugins after loading plugins.')
2024-11-29 17:57:30 -06:00
plugins_enabled.value = False