Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[plugins] Load plugins on demand #11305

Open
wants to merge 72 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 56 commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
40f772c
Rough demo
Grub4K Jan 7, 2023
8c297d1
Its lazy now at least?
Grub4K Jan 16, 2023
00709d5
Fix tests and add test for reloading
Grub4K Jan 17, 2023
25b921b
Merge with 'upstream/master'
Grub4K Mar 18, 2023
3d939b6
Merge remote-tracking branch 'upstream/master' into misc/globals-and-…
coletdjnz Oct 19, 2024
9f1f2c5
fix
coletdjnz Oct 19, 2024
4266658
Get plugin overrides working
coletdjnz Oct 19, 2024
413ae76
Make globals internal-only
coletdjnz Oct 19, 2024
cb9e38a
Fix reloading
coletdjnz Oct 19, 2024
3561d2a
load plugins in youtubedl if they haven't already
coletdjnz Oct 19, 2024
6547ff4
minor refactoring
coletdjnz Oct 19, 2024
42771dd
Add test for override plugins
coletdjnz Oct 19, 2024
a19dd28
revert back to init_subclass, add guard against multiple imports of s…
coletdjnz Oct 19, 2024
9269248
add test for plugin dirs
coletdjnz Oct 19, 2024
21e13bf
Decouple plugins.py from plugin types
coletdjnz Oct 19, 2024
109c019
Add public functions to add custom external plugin paths
coletdjnz Oct 19, 2024
97684b0
Add --no-plugins
coletdjnz Oct 20, 2024
9811496
Fix lazy extractors
coletdjnz Oct 20, 2024
2723802
minor refactoring
coletdjnz Oct 20, 2024
072e680
Merge remote-tracking branch 'upstream/master' into misc/globals-and-…
coletdjnz Nov 29, 2024
2699951
Cleanup after merge
coletdjnz Nov 29, 2024
51f3740
Move away from contextvars
coletdjnz Nov 29, 2024
97088ae
may this help?
coletdjnz Nov 30, 2024
4e4bc2f
fix
coletdjnz Nov 30, 2024
d48d00d
Fix no-external
coletdjnz Nov 30, 2024
37aed89
fix no-external not including PYTHONPATH
coletdjnz Nov 30, 2024
600b2ec
Avoid searching disk for plugins when plugins are disabled
coletdjnz Nov 30, 2024
cd490ee
Improve error message when invalid directory provided
coletdjnz Nov 30, 2024
dad04bd
Fix IN_CLI import
coletdjnz Nov 30, 2024
5de3062
Update yt_dlp/extractor/extractors.py
coletdjnz Dec 8, 2024
78c09c7
revert formatter
coletdjnz Dec 8, 2024
fff5e7b
Merge remote-tracking branch 'upstream/master' into misc/globals-and-…
coletdjnz Dec 8, 2024
0210836
misc cleanup
coletdjnz Dec 8, 2024
fd23673
fix test import order
coletdjnz Dec 8, 2024
0e825cd
set plugin_dirs to empty to disable plugins
coletdjnz Feb 6, 2025
1883400
Merge remote-tracking branch 'upstream/master' into misc/globals-and-…
coletdjnz Feb 6, 2025
a6979c7
Fix --no-plugins --plugin-dirs '/path' importing from pythonpath
coletdjnz Feb 6, 2025
93aa21b
some more refactoring
coletdjnz Feb 6, 2025
74050a8
Get tests working
coletdjnz Feb 6, 2025
317e7a4
ruff
coletdjnz Feb 6, 2025
243174b
Update README.md
coletdjnz Feb 6, 2025
b5b8334
Update yt_dlp/options.py
coletdjnz Feb 7, 2025
f14e168
Update README.md
coletdjnz Feb 7, 2025
a036660
Update yt_dlp/extractor/extractors.py
coletdjnz Feb 8, 2025
8a585c0
Update yt_dlp/__init__.py
coletdjnz Feb 8, 2025
afbbb36
Update yt_dlp/globals.py
coletdjnz Feb 8, 2025
a290aa0
Update yt_dlp/plugins.py
coletdjnz Feb 8, 2025
b738e60
Update yt_dlp/plugins.py
coletdjnz Feb 8, 2025
e2883ff
cleanup from review
coletdjnz Feb 8, 2025
78bc4e7
Update yt_dlp/options.py
coletdjnz Feb 8, 2025
eb6cba8
Update README.md
coletdjnz Feb 8, 2025
b6c3d22
Rename --no-plugins to --no-plugin-dirs
coletdjnz Feb 8, 2025
68644c3
Implement _CLASS_LOOKUP in make_lazy_extractors.py
coletdjnz Feb 9, 2025
8b8d6c5
fix for older python versions
coletdjnz Feb 9, 2025
d134dc3
ruff
coletdjnz Feb 9, 2025
4e7ee9f
use repr
coletdjnz Feb 9, 2025
a78576e
Autopep8 ignore lazy_extractors.py
coletdjnz Feb 11, 2025
782b70e
fix
coletdjnz Feb 11, 2025
f31a5c2
does this work now?
coletdjnz Feb 11, 2025
c3211e4
or this?
coletdjnz Feb 11, 2025
a209cb8
Fix lazy_extractors generation
coletdjnz Feb 11, 2025
d25341f
Add some more excludes for autopep8
coletdjnz Feb 11, 2025
7b31c53
Update yt_dlp/options.py
coletdjnz Feb 11, 2025
35c779d
Update yt_dlp/plugins.py
coletdjnz Feb 11, 2025
92f0776
Update yt_dlp/plugins.py
coletdjnz Feb 11, 2025
aff93ef
Update yt_dlp/options.py
coletdjnz Feb 11, 2025
cf5bad8
Update yt_dlp/plugins.py
coletdjnz Feb 11, 2025
914d554
Update yt_dlp/plugins.py
coletdjnz Feb 11, 2025
f9ef24c
Update yt_dlp/plugins.py
coletdjnz Feb 11, 2025
635d550
Apply suggestions from code review
coletdjnz Feb 11, 2025
86138e6
formatting
coletdjnz Feb 11, 2025
2589ba4
Fix --no-config-locations
coletdjnz Feb 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,10 +338,11 @@ If you fork the project on GitHub, you can run your fork's [build workflow](.git
--plugin-dirs PATH Path to an additional directory to search
for plugins. This option can be used
multiple times to add multiple directories.
Note that this currently only works for
extractor plugins; postprocessor plugins can
only be loaded from the default plugin
directories
Use "default" to search the default plugin
directories (default)
--no-plugin-dirs Clear plugin directories to search,
including defaults and those provided by
previous --plugin-dirs
--flat-playlist Do not extract a playlist's URL result
entries; some entry metadata may be missing
and downloading may be bypassed
Expand Down
11 changes: 7 additions & 4 deletions devscripts/make_lazy_extractors.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
from inspect import getsource

from devscripts.utils import get_filename_args, read_file, write_file
from yt_dlp.extractor import import_extractors
from yt_dlp.extractor.common import InfoExtractor, SearchInfoExtractor
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Create dev release to test all is working

from yt_dlp.globals import extractors

NO_ATTR = object()
STATIC_CLASS_PROPERTIES = [
Expand Down Expand Up @@ -38,16 +41,15 @@ def main():

lazy_extractors_filename = get_filename_args(default_outfile='yt_dlp/extractor/lazy_extractors.py')

from yt_dlp.extractor.extractors import _ALL_CLASSES
from yt_dlp.extractor.common import InfoExtractor, SearchInfoExtractor
import_extractors()

DummyInfoExtractor = type('InfoExtractor', (InfoExtractor,), {'IE_NAME': NO_ATTR})
module_src = '\n'.join((
MODULE_TEMPLATE,
' _module = None',
*extra_ie_code(DummyInfoExtractor),
'\nclass LazyLoadSearchExtractor(LazyLoadExtractor):\n pass\n',
*build_ies(_ALL_CLASSES, (InfoExtractor, SearchInfoExtractor), DummyInfoExtractor),
*build_ies(list(extractors.value.values()), (InfoExtractor, SearchInfoExtractor), DummyInfoExtractor),
))

write_file(lazy_extractors_filename, f'{module_src}\n')
Expand All @@ -73,7 +75,8 @@ def build_ies(ies, bases, attr_base):
if ie in ies:
names.append(ie.__name__)

yield f'\n_ALL_CLASSES = [{", ".join(names)}]'
class_lookup_contents = ', '.join(f'{name!r}: {name}' for name in names)
yield f'\n_CLASS_LOOKUP = {{ {class_lookup_contents} }}'


def sort_ies(ies, ignored_bases):
Expand Down
8 changes: 8 additions & 0 deletions test/test_YoutubeDL.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import unittest
from unittest.mock import patch

from yt_dlp.globals import all_plugins_loaded

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))


Expand Down Expand Up @@ -1427,6 +1429,12 @@ def check_for_cookie_header(result):
self.assertFalse(result.get('cookies'), msg='Cookies set in cookies field for wrong domain')
self.assertFalse(ydl.cookiejar.get_cookie_header(fmt['url']), msg='Cookies set in cookiejar for wrong domain')

def test_load_plugins_compat(self):
# Should try to reload plugins if they haven't already been loaded
all_plugins_loaded.value = False
FakeYDL().close()
assert all_plugins_loaded.value


if __name__ == '__main__':
unittest.main()
222 changes: 201 additions & 21 deletions test/test_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,74 @@
sys.path.append(str(TEST_DATA_DIR))
importlib.invalidate_caches()

from yt_dlp.utils import Config
from yt_dlp.plugins import PACKAGE_NAME, directories, load_plugins
from yt_dlp.plugins import (
PACKAGE_NAME,
PluginSpec,
directories,
load_plugins,
load_all_plugins,
register_plugin_spec,
clear_plugins,
)

from yt_dlp.globals import (
extractors,
postprocessors,
plugin_dirs,
plugin_ies,
plugin_pps,
all_plugins_loaded,
plugin_specs,
)

from yt_dlp.utils import YoutubeDLError


EXTRACTOR_PLUGIN_SPEC = PluginSpec(
module_name='extractor',
suffix='IE',
destination=extractors,
plugin_destination=plugin_ies,
)

POSTPROCESSOR_PLUGIN_SPEC = PluginSpec(
module_name='postprocessor',
suffix='PP',
destination=postprocessors,
plugin_destination=plugin_pps,
)


def reset_plugins():
plugin_ies.value = {}
plugin_pps.value = {}
plugin_dirs.value = ['default']
plugin_specs.value = {}
all_plugins_loaded.value = False
# Clearing override plugins is probably difficult
for module_name in tuple(sys.modules):
for plugin_type in ('extractor', 'postprocessor'):
if module_name.startswith(f'{PACKAGE_NAME}.{plugin_type}.'):
del sys.modules[module_name]

importlib.invalidate_caches()


class TestPlugins(unittest.TestCase):

TEST_PLUGIN_DIR = TEST_DATA_DIR / PACKAGE_NAME

def setUp(self):
reset_plugins()

def tearDown(self):
reset_plugins()

def test_directories_containing_plugins(self):
self.assertIn(self.TEST_PLUGIN_DIR, map(Path, directories()))

def test_extractor_classes(self):
for module_name in tuple(sys.modules):
if module_name.startswith(f'{PACKAGE_NAME}.extractor'):
del sys.modules[module_name]
plugins_ie = load_plugins('extractor', 'IE')
plugins_ie = load_plugins(EXTRACTOR_PLUGIN_SPEC)

self.assertIn(f'{PACKAGE_NAME}.extractor.normal', sys.modules.keys())
self.assertIn('NormalPluginIE', plugins_ie.keys())
Expand All @@ -35,17 +87,29 @@ def test_extractor_classes(self):
f'{PACKAGE_NAME}.extractor._ignore' in sys.modules,
'loaded module beginning with underscore')
self.assertNotIn('IgnorePluginIE', plugins_ie.keys())
self.assertNotIn('IgnorePluginIE', plugin_ies.value)

# Don't load extractors with underscore prefix
self.assertNotIn('_IgnoreUnderscorePluginIE', plugins_ie.keys())
self.assertNotIn('_IgnoreUnderscorePluginIE', plugin_ies.value)

# Don't load extractors not specified in __all__ (if supplied)
self.assertNotIn('IgnoreNotInAllPluginIE', plugins_ie.keys())
self.assertNotIn('IgnoreNotInAllPluginIE', plugin_ies.value)
self.assertIn('InAllPluginIE', plugins_ie.keys())
self.assertIn('InAllPluginIE', plugin_ies.value)

# Don't load override extractors
self.assertNotIn('OverrideGenericIE', plugins_ie.keys())
self.assertNotIn('OverrideGenericIE', plugin_ies.value)
self.assertNotIn('_UnderscoreOverrideGenericIE', plugins_ie.keys())
self.assertNotIn('_UnderscoreOverrideGenericIE', plugin_ies.value)

def test_postprocessor_classes(self):
plugins_pp = load_plugins('postprocessor', 'PP')
plugins_pp = load_plugins(POSTPROCESSOR_PLUGIN_SPEC)
self.assertIn('NormalPluginPP', plugins_pp.keys())
self.assertIn(f'{PACKAGE_NAME}.postprocessor.normal', sys.modules.keys())
self.assertIn('NormalPluginPP', plugin_pps.value)

def test_importing_zipped_module(self):
zip_path = TEST_DATA_DIR / 'zipped_plugins.zip'
Expand All @@ -58,34 +122,150 @@ def test_importing_zipped_module(self):
package = importlib.import_module(f'{PACKAGE_NAME}.{plugin_type}')
self.assertIn(zip_path / PACKAGE_NAME / plugin_type, map(Path, package.__path__))

plugins_ie = load_plugins('extractor', 'IE')
plugins_ie = load_plugins(EXTRACTOR_PLUGIN_SPEC)
self.assertIn('ZippedPluginIE', plugins_ie.keys())

plugins_pp = load_plugins('postprocessor', 'PP')
plugins_pp = load_plugins(POSTPROCESSOR_PLUGIN_SPEC)
self.assertIn('ZippedPluginPP', plugins_pp.keys())

finally:
sys.path.remove(str(zip_path))
os.remove(zip_path)
importlib.invalidate_caches() # reset the import caches

def test_plugin_dirs(self):
# Internal plugin dirs hack for CLI --plugin-dirs
# To be replaced with proper system later
custom_plugin_dir = TEST_DATA_DIR / 'plugin_packages'
Config._plugin_dirs = [str(custom_plugin_dir)]
importlib.invalidate_caches() # reset the import caches
def test_reloading_plugins(self):
reload_plugins_path = TEST_DATA_DIR / 'reload_plugins'
load_plugins(EXTRACTOR_PLUGIN_SPEC)
load_plugins(POSTPROCESSOR_PLUGIN_SPEC)

# Remove default folder and add reload_plugin path
sys.path.remove(str(TEST_DATA_DIR))
sys.path.append(str(reload_plugins_path))
importlib.invalidate_caches()
try:
package = importlib.import_module(f'{PACKAGE_NAME}.extractor')
self.assertIn(custom_plugin_dir / 'testpackage' / PACKAGE_NAME / 'extractor', map(Path, package.__path__))
for plugin_type in ('extractor', 'postprocessor'):
package = importlib.import_module(f'{PACKAGE_NAME}.{plugin_type}')
self.assertIn(reload_plugins_path / PACKAGE_NAME / plugin_type, map(Path, package.__path__))

plugins_ie = load_plugins(EXTRACTOR_PLUGIN_SPEC)
self.assertIn('NormalPluginIE', plugins_ie.keys())
self.assertTrue(
plugins_ie['NormalPluginIE'].REPLACED,
msg='Reloading has not replaced original extractor plugin')
self.assertTrue(
extractors.value['NormalPluginIE'].REPLACED,
msg='Reloading has not replaced original extractor plugin globally')

plugins_ie = load_plugins('extractor', 'IE')
self.assertIn('PackagePluginIE', plugins_ie.keys())
plugins_pp = load_plugins(POSTPROCESSOR_PLUGIN_SPEC)
self.assertIn('NormalPluginPP', plugins_pp.keys())
self.assertTrue(plugins_pp['NormalPluginPP'].REPLACED,
msg='Reloading has not replaced original postprocessor plugin')
self.assertTrue(
postprocessors.value['NormalPluginPP'].REPLACED,
msg='Reloading has not replaced original postprocessor plugin globally')

finally:
Config._plugin_dirs = []
importlib.invalidate_caches() # reset the import caches
sys.path.remove(str(reload_plugins_path))
sys.path.append(str(TEST_DATA_DIR))
importlib.invalidate_caches()

def test_extractor_override_plugin(self):
load_plugins(EXTRACTOR_PLUGIN_SPEC)

from yt_dlp.extractor.generic import GenericIE

self.assertEqual(GenericIE.TEST_FIELD, 'override')
self.assertEqual(GenericIE.SECONDARY_TEST_FIELD, 'underscore-override')

self.assertEqual(GenericIE.IE_NAME, 'generic+override+underscore-override')
importlib.invalidate_caches()
# test that loading a second time doesn't wrap a second time
load_plugins(EXTRACTOR_PLUGIN_SPEC)
from yt_dlp.extractor.generic import GenericIE
self.assertEqual(GenericIE.IE_NAME, 'generic+override+underscore-override')

def test_load_all_plugin_types(self):

# no plugin specs registered
load_all_plugins()

self.assertNotIn(f'{PACKAGE_NAME}.extractor.normal', sys.modules.keys())
self.assertNotIn(f'{PACKAGE_NAME}.postprocessor.normal', sys.modules.keys())

register_plugin_spec(EXTRACTOR_PLUGIN_SPEC)
register_plugin_spec(POSTPROCESSOR_PLUGIN_SPEC)
load_all_plugins()
self.assertTrue(all_plugins_loaded.value)

self.assertIn(f'{PACKAGE_NAME}.extractor.normal', sys.modules.keys())
self.assertIn(f'{PACKAGE_NAME}.postprocessor.normal', sys.modules.keys())

def test_no_plugin_dirs(self):
register_plugin_spec(EXTRACTOR_PLUGIN_SPEC)
register_plugin_spec(POSTPROCESSOR_PLUGIN_SPEC)

plugin_dirs.value = []
load_all_plugins()

self.assertNotIn(f'{PACKAGE_NAME}.extractor.normal', sys.modules.keys())
self.assertNotIn(f'{PACKAGE_NAME}.postprocessor.normal', sys.modules.keys())

def test_set_plugin_dirs(self):
custom_plugin_dir = str(TEST_DATA_DIR / 'plugin_packages')
plugin_dirs.value = [custom_plugin_dir]

load_plugins(EXTRACTOR_PLUGIN_SPEC)

self.assertIn(f'{PACKAGE_NAME}.extractor.package', sys.modules.keys())
self.assertIn('PackagePluginIE', plugin_ies.value)

def test_invalid_plugin_dir(self):
plugin_dirs.value = ['invalid_dir']
with self.assertRaises(ValueError):
load_plugins(EXTRACTOR_PLUGIN_SPEC)

def test_append_plugin_dirs(self):
custom_plugin_dir = str(TEST_DATA_DIR / 'plugin_packages')

self.assertEqual(plugin_dirs.value, ['default'])
plugin_dirs.value.append(custom_plugin_dir)
self.assertEqual(plugin_dirs.value, ['default', custom_plugin_dir])

load_plugins(EXTRACTOR_PLUGIN_SPEC)

self.assertIn(f'{PACKAGE_NAME}.extractor.package', sys.modules.keys())
self.assertIn('PackagePluginIE', plugin_ies.value)

def test_clear_plugins(self):
clear_plugins()
ies = load_plugins(EXTRACTOR_PLUGIN_SPEC)
self.assertEqual(ies, {})
self.assertNotIn(f'{PACKAGE_NAME}.extractor.normal', sys.modules.keys())
self.assertNotIn('NormalPluginIE', plugin_ies.value)

pps = load_plugins(POSTPROCESSOR_PLUGIN_SPEC)
self.assertEqual(pps, {})
self.assertNotIn(f'{PACKAGE_NAME}.postprocessor.normal', sys.modules.keys())
self.assertNotIn('NormalPluginPP', plugin_pps.value)

def test_clear_plugins_already_loaded(self):
register_plugin_spec(EXTRACTOR_PLUGIN_SPEC)
register_plugin_spec(POSTPROCESSOR_PLUGIN_SPEC)
load_all_plugins()

with self.assertRaises(YoutubeDLError):
clear_plugins()

ies = load_plugins(EXTRACTOR_PLUGIN_SPEC)
self.assertIn('NormalPluginIE', ies)

def test_get_plugin_spec(self):
register_plugin_spec(EXTRACTOR_PLUGIN_SPEC)
register_plugin_spec(POSTPROCESSOR_PLUGIN_SPEC)

self.assertEqual(plugin_specs.value.get('extractor'), EXTRACTOR_PLUGIN_SPEC)
self.assertEqual(plugin_specs.value.get('postprocessor'), POSTPROCESSOR_PLUGIN_SPEC)
self.assertIsNone(plugin_specs.value.get('invalid'))


if __name__ == '__main__':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@


class PackagePluginIE(InfoExtractor):
_VALID_URL = 'package'
pass
10 changes: 10 additions & 0 deletions test/testdata/reload_plugins/yt_dlp_plugins/extractor/normal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from yt_dlp.extractor.common import InfoExtractor


class NormalPluginIE(InfoExtractor):
_VALID_URL = 'normal'
REPLACED = True


class _IgnoreUnderscorePluginIE(InfoExtractor):
pass
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from yt_dlp.postprocessor.common import PostProcessor


class NormalPluginPP(PostProcessor):
REPLACED = True
1 change: 1 addition & 0 deletions test/testdata/yt_dlp_plugins/extractor/ignore.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ class IgnoreNotInAllPluginIE(InfoExtractor):


class InAllPluginIE(InfoExtractor):
_VALID_URL = 'inallpluginie'
pass


Expand Down
4 changes: 3 additions & 1 deletion test/testdata/yt_dlp_plugins/extractor/normal.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@


class NormalPluginIE(InfoExtractor):
pass
_VALID_URL = 'normalpluginie'
REPLACED = False


class _IgnoreUnderscorePluginIE(InfoExtractor):
_VALID_URL = 'ignoreunderscorepluginie'
pass
Loading
Loading