summaryrefslogtreecommitdiff
path: root/src/mailman/utilities
diff options
context:
space:
mode:
Diffstat (limited to 'src/mailman/utilities')
-rw-r--r--src/mailman/utilities/modules.py117
-rw-r--r--src/mailman/utilities/tests/test_modules.py121
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})