diff options
Diffstat (limited to 'src/mailman/utilities')
| -rw-r--r-- | src/mailman/utilities/modules.py | 117 | ||||
| -rw-r--r-- | src/mailman/utilities/tests/test_modules.py | 121 |
2 files changed, 108 insertions, 130 deletions
diff --git a/src/mailman/utilities/modules.py b/src/mailman/utilities/modules.py index 155a34ed2..48cee1a0e 100644 --- a/src/mailman/utilities/modules.py +++ b/src/mailman/utilities/modules.py @@ -22,7 +22,7 @@ import sys from contextlib import contextmanager from importlib import import_module -from pkg_resources import resource_filename, resource_listdir +from pkg_resources import resource_filename, resource_isdir, resource_listdir from public import public @@ -70,6 +70,32 @@ def call_name(dotted_name, *args, **kws): return named_callable(*args, **kws) +@public +def expand_path(url): + """Expand a python: path, returning the absolute file system path.""" + # Is the context coming from a file system or Python path? + if url.startswith('python:'): + resource_path = url[7:] + package, dot, resource = resource_path.rpartition('.') + return resource_filename(package, resource + '.cfg') + else: + return url + + +@public +@contextmanager +def hacked_sys_modules(name, module): + old_module = sys.modules.get(name) + sys.modules[name] = module + try: + yield + finally: + if old_module is None: + del sys.modules[name] + else: + sys.modules[name] = old_module + + def scan_module(module, interface): """Return all the object in a module that conform to an interface. @@ -102,7 +128,6 @@ def scan_module(module, interface): yield component() -@public def find_components(package, interface): """Find components which conform to a given interface. @@ -122,25 +147,63 @@ def find_components(package, interface): if extension != '.py' or basename.startswith('.'): continue module_name = '{}.{}'.format(package, basename) - __import__(module_name, fromlist='*') - module = sys.modules[module_name] + module = import_module(module_name) if not hasattr(module, '__all__'): continue yield from scan_module(module, interface) +def find_pluggable_components(subpackage, interface): + """Find components which conform to a given interface. + + This finds components which can be implemented in a plugin. It will + search for the interface in the named subpackage, where the Python import + path of the subpackage will be prepended by `mailman` for system + components, and the various plugin names for any external components. + + :param subpackage: The subpackage to search. This is prepended by + 'mailman' to search for system components, and each enabled plugin for + external components. + :type subpackage: str + :param interface: The interface that returned objects must conform to. + :type interface: `Interface` + :return: The sequence of matching components. + :rtype: Objects implementing `interface` + """ + # This can't be imported at module level because of circular imports. + from mailman.config import config + # Return the system components first. + yield from find_components('mailman.' + subpackage, interface) + # Return all the matching components in all the subpackages of all enabled + # plugins. Only enabled and existing plugins will appear in this + # dictionary. + for name, plugin_config in config.plugin_configs: + # If the plugin's configuration defines a components package, use + # that, falling back to the plugin's name. + package = plugin_config['component_package'].strip() + if len(package) == 0: + package = name + # It's possible that the plugin doesn't include the directory for this + # subpackage. That's fine. + if resource_isdir(package, subpackage): + plugin_package = '{}.{}'.format(package, subpackage) + yield from find_components(plugin_package, interface) + + @public -def add_components(package, interface, mapping): +def add_components(subpackage, interface, mapping): """Add components to a given mapping. - Similarly to `find_components()` this inspects all modules in a given - package looking for objects that conform to a given interface. All such - found objects (unless decorated with `@abstract_component`) are added to - the given mapping, keyed by the object's `.name` attribute, which is - required. It is a fatal error if that key already exists in the mapping. + Similarly to `find_pluggable_components()` this inspects all modules + in the given subpackage, relative to the 'mailman' parent package, + and all the plugin names, that match the given interface. All such + found objects (unless decorated with `@abstract_component`) are + added to the given mapping, keyed by the object's `.name` attribute, + which is required. It is a fatal error if that key already exists + in the mapping. - :param package: The package path to search. - :type package: string + :param subpackage: The subpackage path to search. + :type subpackage: str :param interface: The interface that returned objects must conform to. Objects found must have a `.name` attribute containing a unique string. @@ -150,35 +213,9 @@ def add_components(package, interface, mapping): containment tests (e.g. `in` and `not in`) and `__setitem__()`. :raises RuntimeError: when a duplicate key is found. """ - for component in find_components(package, interface): + for component in find_pluggable_components(subpackage, interface): if component.name in mapping: - raise RuntimeError( + raise RuntimeError( # pragma: nocover 'Duplicate key "{}" found in {}; previously {}'.format( component.name, component, mapping[component.name])) mapping[component.name] = component - - -@public -def expand_path(url): - """Expand a python: path, returning the absolute file system path.""" - # Is the context coming from a file system or Python path? - if url.startswith('python:'): - resource_path = url[7:] - package, dot, resource = resource_path.rpartition('.') - return resource_filename(package, resource + '.cfg') - else: - return url - - -@public -@contextmanager -def hacked_sys_modules(name, module): - old_module = sys.modules.get(name) - sys.modules[name] = module - try: - yield - finally: - if old_module is None: - del sys.modules[name] - else: - sys.modules[name] = old_module diff --git a/src/mailman/utilities/tests/test_modules.py b/src/mailman/utilities/tests/test_modules.py index 82d44cff4..5ea2e5d23 100644 --- a/src/mailman/utilities/tests/test_modules.py +++ b/src/mailman/utilities/tests/test_modules.py @@ -22,10 +22,14 @@ import sys import unittest from contextlib import ExitStack, contextmanager +from mailman.interfaces.rules import IRule from mailman.interfaces.styles import IStyle +from mailman.testing.helpers import configuration +from mailman.testing.layers import ConfigLayer from mailman.utilities.modules import ( - add_components, find_components, hacked_sys_modules) + find_components, find_pluggable_components, hacked_sys_modules) from pathlib import Path +from pkg_resources import resource_filename from tempfile import TemporaryDirectory @@ -48,6 +52,8 @@ def clean_mypackage(): class TestModuleImports(unittest.TestCase): + layer = ConfigLayer + def test_find_modules_with_dotfiles(self): # Emacs creates lock files when a single file is opened by more than # one user. These files look like .#<filename>.py because of which @@ -141,95 +147,6 @@ class AbstractStyle: in find_components('mypackage', IStyle)] self.assertEqual(names, ['concrete-style']) - def test_add_components(self): - with ExitStack() as resources: - # Creating a temporary directory and adding it to sys.path. - temp_package = resources.enter_context(TemporaryDirectory()) - resources.enter_context(hack_syspath(0, temp_package)) - resources.callback(clean_mypackage) - # Create a module inside the above package along with an - # __init__.py file so that we can import from it. - module_path = os.path.join(temp_package, 'mypackage') - os.mkdir(module_path) - init_file = os.path.join(module_path, '__init__.py') - Path(init_file).touch() - component_file = os.path.join(module_path, 'components.py') - with open(component_file, 'w', encoding='utf-8') as fp: - print("""\ -from mailman.interfaces.styles import IStyle -from mailman.utilities.modules import abstract_component -from public import public -from zope.interface import implementer - -@public -@implementer(IStyle) -class StyleA: - name = 'styleA' - def apply(self): - pass - -@public -@implementer(IStyle) -@abstract_component -class StyleB: - name = 'styleB' - def apply(self): - pass - -@public -@implementer(IStyle) -class StyleC: - name = 'styleC' - def apply(self): - pass -""", file=fp) - styles = {} - add_components('mypackage', IStyle, styles) - self.assertEqual(set(styles), {'styleA', 'styleC'}) - - def test_add_components_duplicates(self): - # A duplicate name exists, so a RuntimeError is raised. - with ExitStack() as resources: - # Creating a temporary directory and adding it to sys.path. - temp_package = resources.enter_context(TemporaryDirectory()) - resources.enter_context(hack_syspath(0, temp_package)) - resources.callback(clean_mypackage) - # Create a module inside the above package along with an - # __init__.py file so that we can import from it. - module_path = os.path.join(temp_package, 'mypackage') - os.mkdir(module_path) - init_file = os.path.join(module_path, '__init__.py') - Path(init_file).touch() - component_file = os.path.join(module_path, 'components.py') - with open(component_file, 'w', encoding='utf-8') as fp: - print("""\ -from mailman.interfaces.styles import IStyle -from mailman.utilities.modules import abstract_component -from public import public -from zope.interface import implementer - -@public -@implementer(IStyle) -class StyleA1: - name = 'styleA' - def apply(self): - pass - -@public -@implementer(IStyle) -class StyleA2: - name = 'styleA' - def apply(self): - pass -""", file=fp) - with self.assertRaisesRegex( - RuntimeError, - 'Duplicate key "styleA" found in ' - '<mypackage.components.StyleA2 object at .*>; ' - 'previously <mypackage.components.StyleA1 object at ' - '.*>'): - add_components('mypackage', IStyle, {}) - def test_hacked_sys_modules(self): self.assertIsNone(sys.modules.get('mailman.not_a_module')) with hacked_sys_modules('mailman.not_a_module', object()): @@ -241,3 +158,27 @@ class StyleA2: with hacked_sys_modules('email', sentinel): self.assertEqual(sys.modules.get('email'), sentinel) self.assertEqual(sys.modules.get('email'), email_package) + + def test_find_pluggable_components_by_plugin_name(self): + path = resource_filename('mailman.plugins.testing', '') + with ExitStack() as resources: + resources.enter_context(hack_syspath(0, path)) + resources.enter_context(configuration('plugin.example', **{ + 'class': 'example.hooks.ExamplePlugin', + 'enabled': 'yes', + })) + components = list(find_pluggable_components('rules', IRule)) + self.assertIn('example-rule', {rule.name for rule in components}) + + def test_find_pluggable_components_by_component_package(self): + path = resource_filename('mailman.plugins.testing', '') + with ExitStack() as resources: + resources.enter_context(hack_syspath(0, path)) + resources.enter_context(configuration('plugin.example', **{ + 'class': 'example.hooks.ExamplePlugin', + 'enabled': 'yes', + 'component_package': 'alternate', + })) + components = list(find_pluggable_components('rules', IRule)) + self.assertNotIn('example-rule', {rule.name for rule in components}) + self.assertIn('alternate-rule', {rule.name for rule in components}) |
