diff options
Diffstat (limited to 'src/mailman')
49 files changed, 1235 insertions, 359 deletions
diff --git a/src/mailman/app/commands.py b/src/mailman/app/commands.py index 7cf3bc4c5..32e5438ea 100644 --- a/src/mailman/app/commands.py +++ b/src/mailman/app/commands.py @@ -26,4 +26,4 @@ from public import public @public def initialize(): """Initialize the email commands.""" - add_components('mailman.commands', IEmailCommand, config.commands) + add_components('commands', IEmailCommand, config.commands) diff --git a/src/mailman/app/docs/hooks.rst b/src/mailman/app/docs/hooks.rst deleted file mode 100644 index 07d0ec33c..000000000 --- a/src/mailman/app/docs/hooks.rst +++ /dev/null @@ -1,121 +0,0 @@ -===== -Hooks -===== - -Mailman defines two initialization hooks, one which is run early in the -initialization process and the other run late in the initialization process. -Hooks name an importable callable so it must be accessible on ``sys.path``. -:: - - >>> import os, sys - >>> from mailman.config import config - >>> config_directory = os.path.dirname(config.filename) - >>> sys.path.insert(0, config_directory) - - >>> hook_path = os.path.join(config_directory, 'hooks.py') - >>> with open(hook_path, 'w') as fp: - ... print("""\ - ... counter = 1 - ... def pre_hook(): - ... global counter - ... print('pre-hook:', counter) - ... counter += 1 - ... - ... def post_hook(): - ... global counter - ... print('post-hook:', counter) - ... counter += 1 - ... """, file=fp) - >>> fp.close() - - -Pre-hook -======== - -We can set the pre-hook in the configuration file. - - >>> config_path = os.path.join(config_directory, 'hooks.cfg') - >>> with open(config_path, 'w') as fp: - ... print("""\ - ... [meta] - ... extends: test.cfg - ... - ... [mailman] - ... pre_hook: hooks.pre_hook - ... """, file=fp) - -The hooks are run in the second and third steps of initialization. However, -we can't run those initialization steps in process, so call a command line -script that will produce no output to force the hooks to run. -:: - - >>> import subprocess - >>> from mailman.testing.layers import ConfigLayer - >>> def call(): - ... exe = os.path.join(os.path.dirname(sys.executable), 'mailman') - ... env = os.environ.copy() - ... env.update( - ... MAILMAN_CONFIG_FILE=config_path, - ... PYTHONPATH=config_directory, - ... ) - ... test_cfg = os.environ.get('MAILMAN_EXTRA_TESTING_CFG') - ... if test_cfg is not None: - ... env['MAILMAN_EXTRA_TESTING_CFG'] = test_cfg - ... proc = subprocess.Popen( - ... [exe, 'lists', '--domain', 'ignore', '-q'], - ... cwd=ConfigLayer.root_directory, env=env, - ... universal_newlines=True, - ... stdout=subprocess.PIPE, stderr=subprocess.PIPE) - ... stdout, stderr = proc.communicate() - ... assert proc.returncode == 0, stderr - ... print(stdout) - - >>> call() - pre-hook: 1 - <BLANKLINE> - - >>> os.remove(config_path) - - -Post-hook -========= - -We can set the post-hook in the configuration file. -:: - - >>> with open(config_path, 'w') as fp: - ... print("""\ - ... [meta] - ... extends: test.cfg - ... - ... [mailman] - ... post_hook: hooks.post_hook - ... """, file=fp) - - >>> call() - post-hook: 1 - <BLANKLINE> - - >>> os.remove(config_path) - - -Running both hooks -================== - -We can set the pre- and post-hooks in the configuration file. -:: - - >>> with open(config_path, 'w') as fp: - ... print("""\ - ... [meta] - ... extends: test.cfg - ... - ... [mailman] - ... pre_hook: hooks.pre_hook - ... post_hook: hooks.post_hook - ... """, file=fp) - - >>> call() - pre-hook: 1 - post-hook: 2 - <BLANKLINE> diff --git a/src/mailman/bin/mailman.py b/src/mailman/bin/mailman.py index 6b8cb26da..0ea29edbb 100644 --- a/src/mailman/bin/mailman.py +++ b/src/mailman/bin/mailman.py @@ -16,7 +16,6 @@ # GNU Mailman. If not, see <http://www.gnu.org/licenses/>. """The 'mailman' command dispatcher.""" - import click from contextlib import ExitStack @@ -35,17 +34,21 @@ class Subcommands(click.MultiCommand): def __init__(self, *args, **kws): super().__init__(*args, **kws) self._commands = {} - # Look at all modules in the mailman.bin package and if they are - # prepared to add a subcommand, let them do so. I'm still undecided as - # to whether this should be pluggable or not. If so, then we'll - # probably have to partially parse the arguments now, then initialize - # the system, then find the plugins. Punt on this for now. - add_components('mailman.commands', ICLISubCommand, self._commands) + self._loaded = False + + def _load(self): + # Load commands lazily as commands in plugins can only be found after + # the configuration file is loaded. + if not self._loaded: + add_components('commands', ICLISubCommand, self._commands) + self._loaded = True - def list_commands(self, ctx): - return sorted(self._commands) # pragma: nocover + def list_commands(self, ctx): # pragma: nocover + self._load() + return sorted(self._commands) def get_command(self, ctx, name): + self._load() try: return self._commands[name].command except KeyError as error: @@ -85,10 +88,11 @@ class Subcommands(click.MultiCommand): super().format_commands(ctx, formatter) -@click.group( - cls=Subcommands, - context_settings=dict(help_option_names=['-h', '--help'])) -@click.pass_context +def initialize_config(ctx, param, value): + if not ctx.resilient_parsing: + initialize(value) + + @click.option( '-C', '--config', 'config_file', envvar='MAILMAN_CONFIG_FILE', @@ -96,7 +100,12 @@ class Subcommands(click.MultiCommand): help=_("""\ Configuration file to use. If not given, the environment variable MAILMAN_CONFIG_FILE is consulted and used if set. If neither are given, a - default configuration file is loaded.""")) + default configuration file is loaded."""), + is_eager=True, callback=initialize_config) +@click.group( + cls=Subcommands, + context_settings=dict(help_option_names=['-h', '--help'])) +@click.pass_context @click.version_option(MAILMAN_VERSION_FULL, message='%(version)s') @public def main(ctx, config_file): @@ -106,6 +115,4 @@ def main(ctx, config_file): Copyright 1998-2017 by the Free Software Foundation, Inc. http://www.list.org """ - # Initialize the system. Honor the -C flag if given. - initialize(config_file) # click handles dispatching to the subcommand via the Subcommands class. diff --git a/src/mailman/bin/master.py b/src/mailman/bin/master.py index 0b273d332..8339f0fc2 100644 --- a/src/mailman/bin/master.py +++ b/src/mailman/bin/master.py @@ -45,22 +45,23 @@ SUBPROC_START_WAIT = timedelta(seconds=20) # Environment variables to forward into subprocesses. PRESERVE_ENVS = ( 'COVERAGE_PROCESS_START', - 'MAILMAN_EXTRA_TESTING_CFG', 'LANG', 'LANGUAGE', - 'LC_CTYPE', - 'LC_NUMERIC', - 'LC_TIME', + 'LC_ADDRESS', + 'LC_ALL', 'LC_COLLATE', - 'LC_MONETARY', + 'LC_CTYPE', + 'LC_IDENTIFICATION', + 'LC_MEASUREMENT', 'LC_MESSAGES', - 'LC_PAPER', + 'LC_MONETARY', 'LC_NAME', - 'LC_ADDRESS', + 'LC_NUMERIC', + 'LC_PAPER', 'LC_TELEPHONE', - 'LC_MEASUREMENT', - 'LC_IDENTIFICATION', - 'LC_ALL', + 'LC_TIME', + 'MAILMAN_EXTRA_TESTING_CFG', + 'PYTHONPATH', ) @@ -188,6 +189,9 @@ class PIDWatcher: def __init__(self): self._pids = {} + def __contains__(self, pid): + return pid in self._pids.keys() + def __iter__(self): # Safely iterate over all the keys in the dictionary. Because # asynchronous signals are involved, the dictionary's size could @@ -312,16 +316,16 @@ class Loop: env = {'MAILMAN_UNDER_MASTER_CONTROL': '1'} # Craft the command line arguments for the exec() call. rswitch = '--runner=' + spec - # Wherever master lives, so too must live the runner script. - exe = os.path.join(config.BIN_DIR, 'runner') - # config.PYTHON, which is the absolute path to the Python interpreter, - # must be given as argv[0] due to Python's library search algorithm. - args = [sys.executable, sys.executable, exe, rswitch] # Always pass the explicit path to the configuration file to the # sub-runners. This avoids any debate about which cfg file is used. config_file = (config.filename if self._config_file is None else self._config_file) - args.extend(['-C', config_file]) + # Wherever master lives, so too must live the runner script. + exe = os.path.join(config.BIN_DIR, 'runner') # pragma: nocover + # config.PYTHON, which is the absolute path to the Python interpreter, + # must be given as argv[0] due to Python's library search algorithm. + args = [sys.executable, sys.executable, exe, # pragma: nocover + '-C', config_file, rswitch] log = logging.getLogger('mailman.runner') log.debug('starting: %s', args) # We must pass this environment variable through if it's set, @@ -399,9 +403,13 @@ class Loop: except ChildProcessError: # No children? We're done. break - except InterruptedError: # pragma: nocover + except InterruptedError: # pragma: nocover # If the system call got interrupted, just restart it. continue + if pid not in self._kids: # pragma: nocover + # This is not a runner subprocess that we own. E.g. maybe a + # plugin started it. + continue # Find out why the subprocess exited by getting the signal # received or exit status. if os.WIFSIGNALED(status): diff --git a/src/mailman/bin/runner.py b/src/mailman/bin/runner.py index bf84bc3e5..b4780dcfb 100644 --- a/src/mailman/bin/runner.py +++ b/src/mailman/bin/runner.py @@ -151,14 +151,12 @@ def main(ctx, config_file, verbose, list_runners, once, runner_spec): run this way, the environment variable $MAILMAN_UNDER_MASTER_CONTROL will be set which subtly changes some error handling behavior. """ - global log if runner_spec is None and not list_runners: ctx.fail(_('No runner name given.')) # Initialize the system. Honor the -C flag if given. - initialize(config_file, verbose) log = logging.getLogger('mailman.runner') if verbose: diff --git a/src/mailman/bin/tests/test_mailman.py b/src/mailman/bin/tests/test_mailman.py index 73941d468..db13166c7 100644 --- a/src/mailman/bin/tests/test_mailman.py +++ b/src/mailman/bin/tests/test_mailman.py @@ -29,6 +29,7 @@ from mailman.interfaces.command import ICLISubCommand from mailman.testing.layers import ConfigLayer from mailman.utilities.datetime import now from mailman.utilities.modules import add_components +from pkg_resources import resource_filename from unittest.mock import patch @@ -38,7 +39,19 @@ class TestMailmanCommand(unittest.TestCase): def setUp(self): self._command = CliRunner() - def test_mailman_command_without_subcommand_prints_help(self): + def test_mailman_command_config(self): + config_path = resource_filename('mailman.testing', 'testing.cfg') + with patch('mailman.bin.mailman.initialize') as init: + self._command.invoke(main, ('-C', config_path, 'info')) + init.assert_called_once_with(config_path) + + def test_mailman_command_no_config(self): + with patch('mailman.bin.mailman.initialize') as init: + self._command.invoke(main, ('info',)) + init.assert_called_once_with(None) + + @patch('mailman.bin.mailman.initialize') + def test_mailman_command_without_subcommand_prints_help(self, mock): # Issue #137: Running `mailman` without a subcommand raises an # AttributeError. result = self._command.invoke(main) @@ -49,14 +62,15 @@ class TestMailmanCommand(unittest.TestCase): self.assertEqual(lines[0], 'Usage: main [OPTIONS] COMMAND [ARGS]...') # The help output includes a list of subcommands, in sorted order. commands = {} - add_components('mailman.commands', ICLISubCommand, commands) + add_components('commands', ICLISubCommand, commands) help_commands = list( line.split()[0].strip() for line in lines[-len(commands):] ) self.assertEqual(sorted(commands), help_commands) - def test_mailman_command_with_bad_subcommand_prints_help(self): + @patch('mailman.bin.mailman.initialize') + def test_mailman_command_with_bad_subcommand_prints_help(self, mock): # Issue #137: Running `mailman` without a subcommand raises an # AttributeError. result = self._command.invoke(main, ('not-a-subcommand',)) diff --git a/src/mailman/commands/cli_withlist.py b/src/mailman/commands/cli_withlist.py index d2b706e34..202b61584 100644 --- a/src/mailman/commands/cli_withlist.py +++ b/src/mailman/commands/cli_withlist.py @@ -178,9 +178,12 @@ def listaddr(mlist): def requestaddr(mlist): print(mlist.request_address) -All run methods take at least one argument, the mailing list object to operate +Run methods take at least one argument, the mailing list object to operate on. Any additional arguments given on the command line are passed as -positional arguments to the callable.""")) +positional arguments to the callable. + +If -l is not given then you can run a function that takes no arguments. +""")) print() print(_("""\ You can print the list's posting address by running the following from the @@ -232,11 +235,16 @@ Mailman will do this for you (assuming no errors occured).""")) @click.option( '--run', '-r', help=_("""\ - Run a script on a mailing list. The argument is the module path to a - callable. This callable will be imported and then called with the mailing - list as the first argument. If additional arguments are given at the end - of the command line, they are passed as subsequent positional arguments to - the callable. For additional help, see --details. + + Run a script. The argument is the module path to a callable. This + callable will be imported and then, if --listspec/-l is also given, is + called with the mailing list as the first argument. If additional + arguments are given at the end of the command line, they are passed as + subsequent positional arguments to the callable. For additional help, see + --details. + + If no --listspec/-l argument is given, the script function being called is + called with no arguments. """)) @click.option( '--details', @@ -270,9 +278,8 @@ def shell(ctx, interactive, run, listspec, run_args): # without the dot is allowed. dotted_name = (run if '.' in run else '{0}.{0}'.format(run)) if listspec is None: - ctx.fail(_('--run requires a mailing list')) - # Parse the run arguments so we can pass them into the run method. - if listspec.startswith('^'): + r = call_name(dotted_name, *run_args) + elif listspec.startswith('^'): r = {} cre = re.compile(listspec, re.IGNORECASE) for mlist in list_manager.mailing_lists: diff --git a/src/mailman/commands/docs/conf.rst b/src/mailman/commands/docs/conf.rst index 1a6a4679d..5e548ec0b 100644 --- a/src/mailman/commands/docs/conf.rst +++ b/src/mailman/commands/docs/conf.rst @@ -45,6 +45,7 @@ key, along with the names of the corresponding sections. [logging.http] path: mailman.log [logging.locks] path: mailman.log [logging.mischief] path: mailman.log + [logging.plugins] path: plugins.log [logging.root] path: mailman.log [logging.runner] path: mailman.log [logging.smtp] path: smtp.log diff --git a/src/mailman/commands/docs/info.rst b/src/mailman/commands/docs/info.rst index 219143cab..40784a98b 100644 --- a/src/mailman/commands/docs/info.rst +++ b/src/mailman/commands/docs/info.rst @@ -59,7 +59,6 @@ definition. CFG_FILE = .../test.cfg DATA_DIR = /var/lib/mailman/data ETC_DIR = /etc - EXT_DIR = /etc/mailman.d LIST_DATA_DIR = /var/lib/mailman/lists LOCK_DIR = /var/lock/mailman LOCK_FILE = /var/lock/mailman/master.lck diff --git a/src/mailman/commands/tests/test_cli_shell.py b/src/mailman/commands/tests/test_cli_shell.py index ffcf43803..74abd64a3 100644 --- a/src/mailman/commands/tests/test_cli_shell.py +++ b/src/mailman/commands/tests/test_cli_shell.py @@ -29,6 +29,7 @@ from mailman.interfaces.usermanager import IUserManager from mailman.testing.helpers import configuration from mailman.testing.layers import ConfigLayer from mailman.utilities.modules import hacked_sys_modules +from types import ModuleType from unittest.mock import MagicMock, patch try: @@ -40,6 +41,7 @@ except ImportError: class TestShell(unittest.TestCase): layer = ConfigLayer + maxDiff = None def setUp(self): self._command = CliRunner() @@ -156,12 +158,12 @@ class TestShell(unittest.TestCase): 'Error: No such list: ant.example.com\n') def test_run_without_listspec(self): - results = self._command.invoke(shell, ('--run', 'something')) - self.assertEqual(results.exit_code, 2) - self.assertEqual( - results.output, - 'Usage: shell [OPTIONS] [RUN_ARGS]...\n\n' - 'Error: --run requires a mailing list\n') + something = ModuleType('something') + something.something = lambda: print('I am a something!') + with hacked_sys_modules('something', something): + results = self._command.invoke(shell, ('--run', 'something')) + self.assertEqual(results.exit_code, 0) + self.assertEqual(results.output, 'I am a something!\n') def test_run_bogus_listspec(self): results = self._command.invoke( diff --git a/src/mailman/config/config.py b/src/mailman/config/config.py index 61c9fe6ed..c004d6e63 100644 --- a/src/mailman/config/config.py +++ b/src/mailman/config/config.py @@ -40,6 +40,19 @@ from zope.interface import implementer SPACE = ' ' SPACERS = '\n' +DIR_NAMES = ( + 'archive', + 'bin', + 'cache', + 'data', + 'etc', + 'list_data', + 'lock', + 'log', + 'messages', + 'queue', + ) + MAILMAN_CFG_TEMPLATE = """\ # AUTOMATICALLY GENERATED BY MAILMAN ON {} UTC @@ -76,6 +89,7 @@ class Configuration: self.handlers = {} self.pipelines = {} self.commands = {} + self.plugins = {} self.password_context = None self.db = None @@ -158,8 +172,7 @@ class Configuration: else category.template_dir), ) # Directories. - for name in ('archive', 'bin', 'cache', 'data', 'etc', 'ext', - 'list_data', 'lock', 'log', 'messages', 'queue'): + for name in DIR_NAMES: key = '{}_dir'.format(name) substitutions[key] = getattr(category, key) # Files. @@ -248,6 +261,35 @@ class Configuration: yield archiver @property + def plugin_configs(self): + """Return all the plugin configuration sections.""" + plugin_sections = self._config.getByCategory('plugin', []) + for section in plugin_sections: + # 2017-08-27 barry: There's a fundamental constraint imposed by + # lazr.config, namely that we have to use a .master section instead + # of a .template section in the schema.cfg, or user supplied + # configuration files cannot define new [plugin.*] sections. See + # https://bugs.launchpad.net/lazr.config/+bug/310619 for + # additional details. + # + # However, this means that [plugin.master] will show up in the + # categories retrieved above. But 'master' is not a real plugin, + # so we need to skip it (e.g. otherwise we'll get log warnings + # about plugin.master being disabled, etc.). This imposes an + # additional limitation though in that users cannot define a + # plugin named 'master' because you can't override a master + # section with a real section. There's no good way around this so + # we just have to live with this limitation. + if section.name == 'plugin.master': + continue + # The section.name will be something like 'plugin.example', but we + # only want the 'example' part as the name of the plugin. We + # could split on dots, but lazr.config gives us a different way. + # `category_and_section_names` is a 2-tuple of e.g. + # ('plugin', 'example'), so just grab the last element. + yield section.category_and_section_names[1], section + + @property def language_configs(self): """Iterate over all the language configuration sections.""" yield from self._config.getByCategory('language', []) diff --git a/src/mailman/config/schema.cfg b/src/mailman/config/schema.cfg index 9568694be..b6f99c61d 100644 --- a/src/mailman/config/schema.cfg +++ b/src/mailman/config/schema.cfg @@ -54,14 +54,6 @@ pending_request_life: 3d # How long should files be saved before they are evicted from the cache? cache_life: 7d -# A callable to run with no arguments early in the initialization process. -# This runs before database initialization. -pre_hook: - -# A callable to run with no arguments late in the initialization process. -# This runs after adapters are initialized. -post_hook: - # Which paths.* file system layout to use. layout: here @@ -82,6 +74,51 @@ html_to_plain_text_command: /usr/bin/lynx -dump $filename # unpredictable. listname_chars: [-_.0-9a-z] +# These hooks are deprecated, but are kept here so as not to break existing +# configuration files. However, these hooks are not run. Define a plugin +# instead. +pre_hook: +post_hook: + + +# Plugin configuration section template. +# +# To add a plugin, instantiate this section (changing `master` to whatever +# your plugin's name is), and define at least a `path` and a `class`. When +# the plugin is loaded, its subpackages will be search for components matching +# the following interfaces: +# +# - IChain for new chains +# - ICliSubCommand - `mailman` subcommands +# - IEmailCommand - new email commands +# - IHandler for new handlers +# - IPipeline for new pipelines +# - IRule for new rules +# - IStyle for new styles. +# +# See the IPlugin interface for more details. +[plugin.master] + +# The full Python import path for you IPlugin implementing class. It is +# required to provide this. +class: + +# Whether to enable this plugin or not. +enabled: no + +# Additional configuration file for this plugin. If the value starts with +# `python:` it is a Python import path, in which case the value should not +# include the trailing .cfg (although the file is required to have this +# suffix). Without `python:`, it is a file system path, and must be an +# absolute path, since no guarantees are made about the current working +# directory. +configuration: + +# Package (as a dotted Python import path) to search for components that this +# plugin wants to add, such as ISTyles, IRules, etc. If not given, the +# plugin's name is used. +component_package: + [shell] # `mailman shell` (also `withlist`) gives you an interactive prompt that you @@ -138,8 +175,6 @@ data_dir: $var_dir/data cache_dir: $var_dir/cache # Directory for configuration files and such. etc_dir: $var_dir/etc -# Directory containing Mailman plugins. -ext_dir: $var_dir/ext # Directory where the default IMessageStore puts its messages. messages_dir: $var_dir/messages # Directory for archive backends to store their messages in. Archivers should @@ -261,6 +296,7 @@ debug: no # - http -- Internal wsgi-based web interface # - locks -- Lock state changes # - mischief -- Various types of hostile activity +# - plugins -- Plugin logs # - runner -- Runner process start/stops # - smtp -- Successful SMTP activity # - smtp-failure -- Unsuccessful SMTP activity @@ -298,6 +334,9 @@ level: info [logging.mischief] +[logging.plugins] +path: plugins.log + [logging.runner] [logging.smtp] @@ -797,11 +836,6 @@ class: mailman.archiving.prototype.Prototype [styles] -# Python import paths inside which components are searched for which implement -# the IStyle interface. Use one path per line. -paths: - mailman.styles - # The default style to apply if nothing else was requested. The value is the # name of an existing style. If no such style exists, no style will be # applied. diff --git a/src/mailman/core/chains.py b/src/mailman/core/chains.py index c251d38bc..ebd0a5739 100644 --- a/src/mailman/core/chains.py +++ b/src/mailman/core/chains.py @@ -89,5 +89,4 @@ def process(mlist, msg, msgdata, start_chain='default-posting-chain'): @public def initialize(): """Set up chains, both built-in and from the database.""" - add_components('mailman.chains', IChain, config.chains) - # XXX Read chains from the database and initialize them. + add_components('chains', IChain, config.chains) diff --git a/src/mailman/core/initialize.py b/src/mailman/core/initialize.py index dc67e9a68..5696cc3bb 100644 --- a/src/mailman/core/initialize.py +++ b/src/mailman/core/initialize.py @@ -26,11 +26,11 @@ by the command line arguments. import os import sys +import logging import mailman.config.config import mailman.core.logging from mailman.interfaces.database import IDatabaseFactory -from mailman.utilities.modules import call_name from pkg_resources import resource_string as resource_bytes from public import public from zope.component import getUtility @@ -133,7 +133,7 @@ def initialize_2(debug=False, propagate_logs=None, testing=False): * Database * Logging - * Pre-hook + * Plugin pre_hook()'s * Rules * Chains * Pipelines @@ -146,10 +146,28 @@ def initialize_2(debug=False, propagate_logs=None, testing=False): """ # Create the queue and log directories if they don't already exist. mailman.core.logging.initialize(propagate_logs) - # Run the pre-hook if there is one. + # Initialize plugins + from mailman.plugins.initialize import initialize as initialize_plugins + initialize_plugins() + # Check for deprecated features in config. config = mailman.config.config - if config.mailman.pre_hook: - call_name(config.mailman.pre_hook) + if len(config.mailman.pre_hook) > 0: # pragma: nocover + log = logging.getLogger('mailman.plugins') + log.warn( + 'The [mailman]pre_hook configuration value has been replaced ' + "by the plugins infrastructure, and won't be called.") + # Run the plugin pre_hooks, if one fails, disable the offending plugin. + for name in config.plugins: # pragma: nocover + plugin = config.plugins[name] + if hasattr(plugin, 'pre_hook'): + try: + plugin.pre_hook() + except Exception: # pragma: nocover + log = logging.getLogger('mailman.plugins') + log.exception('Plugin failed to run its pre_hook: {}' + 'It will be disabled and its components ' + "won't be loaded.".format(name)) + del config.plugins[name] # Instantiate the database class, ensure that it's of the right type, and # initialize it. Then stash the object on our configuration object. utility_name = ('testing' if testing else 'production') @@ -171,12 +189,24 @@ def initialize_2(debug=False, propagate_logs=None, testing=False): def initialize_3(): """Third initialization step. - * Post-hook + * Plugin post_hook()'s. """ - # Run the post-hook if there is one. + # Run the plugin post_hooks. config = mailman.config.config - if config.mailman.post_hook: - call_name(config.mailman.post_hook) + log = logging.getLogger('mailman.plugins') + if len(config.mailman.post_hook) > 0: # pragma: nocover + log.warn( + 'The [mailman]post_hook configuration value has been replaced ' + "by the plugins infrastructure, and won't be called.") + for plugin in config.plugins.values(): # pragma: nocover + try: + plugin.post_hook() + except Exception: # pragma: nocover + # A post_hook may fail, here we just hope for the best that the + # plugin can work even if it post_hook failed as it's components + # are already loaded. + log.exception( + 'Plugin failed to run its post_hook: {}'.format(plugin.name)) @public diff --git a/src/mailman/core/pipelines.py b/src/mailman/core/pipelines.py index 4265d24d1..c62c7dacd 100644 --- a/src/mailman/core/pipelines.py +++ b/src/mailman/core/pipelines.py @@ -63,6 +63,6 @@ def process(mlist, msg, msgdata, pipeline_name='built-in'): def initialize(): """Initialize the pipelines.""" # Find all handlers in the registered plugins. - add_components('mailman.handlers', IHandler, config.handlers) + add_components('handlers', IHandler, config.handlers) # Set up some pipelines. - add_components('mailman.pipelines', IPipeline, config.pipelines) + add_components('pipelines', IPipeline, config.pipelines) diff --git a/src/mailman/core/rules.py b/src/mailman/core/rules.py index 8e0d9197c..78dd13971 100644 --- a/src/mailman/core/rules.py +++ b/src/mailman/core/rules.py @@ -27,4 +27,4 @@ from public import public def initialize(): """Find and register all rules in all plugins.""" # Find rules in plugins. - add_components('mailman.rules', IRule, config.rules) + add_components('rules', IRule, config.rules) diff --git a/src/mailman/docs/NEWS.rst b/src/mailman/docs/NEWS.rst index 4000c135b..328699371 100644 --- a/src/mailman/docs/NEWS.rst +++ b/src/mailman/docs/NEWS.rst @@ -32,8 +32,20 @@ Command line "fqdn list name" (i.e. the posting address of a mailing list), now also accept a ``List-ID``. Every attempt has been made to keep the CLI backward compatible, but there may be subtle differences. (Closes #346) +* If no listname is given, running ``mailman withlist -r`` must name a + function taking no arguments. This can be used to introspect Mailman + outside of the context of a mailing list. * Fix ``mailman withlist`` command parsing. (Closes #319) +Configuration +------------- +* The ``[mailman]pre_hook`` and ``[mailman]post_hook`` variables are + deprecated. They can still be specified but they will not be run. +* The ``[paths.*]ext_dir`` variable has been removed. +* A new logger has been added called ``logging.plugins``. +* The ``[styles]paths`` variable has been removed; you can now specify + additional styles using the new plugin architecture. + Interfaces ---------- * Broaden the semantics for ``IListManager.get()``. This API now accepts @@ -45,6 +57,9 @@ Interfaces Other ----- +* Add a new plugin architecture, which allows third parties to add + initialization hooks, REST endpoints, and additional components. Given by + Jan Jancar. * Drop support for Python 3.4. (Closes #373) * Bump minimum requirements for aiosmtpd (>= 1.1) and flufl.lock (>= 3.1). diff --git a/src/mailman/interfaces/plugin.py b/src/mailman/interfaces/plugin.py new file mode 100644 index 000000000..10c26d4c5 --- /dev/null +++ b/src/mailman/interfaces/plugin.py @@ -0,0 +1,51 @@ +# Copyright (C) 2017 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Interfaces for plugins.""" + +from public import public +from zope.interface import Attribute, Interface + + +@public +class IPlugin(Interface): + """A plugin providing components and hooks.""" + + def pre_hook(): + """A plugin hook called in the first initialization step. + + This is called before the database is initialized. + """ + + def post_hook(): + """A plugin hook called in the second initialization step. + + This is called after the database is initialized. + """ + + resource = Attribute("""\ + The object for use as the root of this plugin's REST resources. + + This is the resource which will be hooked up to the REST API, and + served at the /<api>/plugins/<plugin.name>/ location. All parsing + below that location is up to the plugin. + + This attribute should be None if the plugin doesn't provide a REST + resource. + + The resource must support getattr() and dir(). + """) diff --git a/src/mailman/model/docs/listmanager.rst b/src/mailman/model/docs/listmanager.rst index 234394ac6..d5d3a5dbe 100644 --- a/src/mailman/model/docs/listmanager.rst +++ b/src/mailman/model/docs/listmanager.rst @@ -60,7 +60,7 @@ always get the same object back. >>> list_manager.get('ant@example.com') <mailing list "ant@example.com" at ...> -The ``.get()`` method is ambidextrous, so it also accepts ``List-ID``s. +The ``.get()`` method is ambidextrous, so it also accepts ``List-ID``'s. >>> list_manager.get('ant.example.com') <mailing list "ant@example.com" at ...> diff --git a/src/mailman/plugins/__init__.py b/src/mailman/plugins/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/mailman/plugins/__init__.py diff --git a/src/mailman/plugins/docs/__init__.py b/src/mailman/plugins/docs/__init__.py new file mode 100644 index 000000000..0b3957648 --- /dev/null +++ b/src/mailman/plugins/docs/__init__.py @@ -0,0 +1,25 @@ +# Copyright (C) 2017 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""REST layer for plugins.""" + + +from mailman.plugins.testing.layer import PluginRESTLayer +from public import public + +# For flufl.testing. +public(layer=PluginRESTLayer) diff --git a/src/mailman/plugins/docs/intro.rst b/src/mailman/plugins/docs/intro.rst new file mode 100644 index 000000000..be6fb365b --- /dev/null +++ b/src/mailman/plugins/docs/intro.rst @@ -0,0 +1,207 @@ +========= + Plugins +========= + +Mailman defines a plugin as a Python package on ``sys.path`` that provides +components matching the ``IPlugin`` interface. ``IPlugin`` implementations +can define a *pre-hook*, a *post-hook*, and a *REST resource*. Plugins are +enabled by adding a section to your ``mailman.cfg`` file, such as: + +.. literalinclude:: ../testing/hooks.cfg + +.. note:: + Because of a `design limitation`_ in the underlying configuration library, + you cannot name a plugin "master". Specifically you cannot define a + section in your ``mailman.cfg`` file named ``[plugin.master]``. + +We have such a configuration file handy. + + >>> from pkg_resources import resource_filename + >>> config_file = resource_filename('mailman.plugins.testing', 'hooks.cfg') + +The section must at least define the class implementing the ``IPlugin`` +interface, using a Python dotted-name import path. For the import to work, +you must include the top-level directory on ``sys.path``. + + >>> import os + >>> from pkg_resources import resource_filename + >>> plugin_path = os.path.join(os.path.dirname( + ... resource_filename('mailman.plugins', '__init__.py')), + ... 'testing') + + +Hooks +===== + +Plugins can add initialization hooks, which will be run at two stages in the +initialization process - one before the database is initialized and one after. +These correspond to methods the plugin defines, a ``pre_hook()`` method and a +``post_hook()`` method. Each of these methods are optional. + +Here is a plugin that defines these hooks: + +.. literalinclude:: ../testing/example/hooks.py + +To illustrate how the hooks work, we'll invoke a simple Mailman command to be +run in a subprocess. The plugin itself supports debugging hooking invocation +when an environment variable is set. + + >>> proc = run(['-C', config_file, 'info'], + ... DEBUG_HOOKS='1', + ... PYTHONPATH=plugin_path) + >>> print(proc.stdout) + I'm in my pre-hook + I'm in my post-hook + ... + + +Components +========== + +Plugins can also add components such as rules, chains, list styles, etc. By +default, components are searched for in the package matching the plugin's +name. So in the case above, the plugin is named ``example`` (because the +section is called ``[plugin.example]``, and there is a subpackage called +``rules`` under the ``example`` package. The file system layout looks like +this:: + + example/ + __init__.py + hooks.py + rules/ + __init__.py + rules.py + +And the contents of ``rules.py`` looks like: + +.. literalinclude:: ../testing/example/rules/rules.py + +To see that the plugin's rule get added, we invoke Mailman as an external +process, running a script that prints out all the defined rule names, +including our plugin's ``example-rule``. + + >>> proc = run(['-C', config_file, 'withlist', '-r', 'showrules'], + ... PYTHONPATH=plugin_path) + >>> print(proc.stdout) + administrivia + ... + example-rule + ... + +Component directories can live under any importable path, not just one named +after the plugin. By adding a ``component_package`` section to your plugin's +configuration, you can name an alternative location to search for components. + +.. literalinclude:: ../testing/alternate.cfg + +We use this configuration file and the following file system layout:: + + example/ + __init__.py + hooks.py + alternate/ + rules/ + __init__.py + rules.py + +Here, ``rules.py`` likes like: + +.. literalinclude:: ../testing/alternate/rules/rules.py + +You can see that this rule has a different name. If we use the +``alternate.cfg`` configuration file from above:: + + >>> config_file = resource_filename( + ... 'mailman.plugins.testing', 'alternate.cfg') + +we'll pick up the alternate rule when we print them out. + + >>> proc = run(['-C', config_file, 'withlist', '-r', 'showrules'], + ... PYTHONPATH=plugin_path) + >>> print(proc.stdout) + administrivia + alternate-rule + ... + + +REST +==== + +Plugins can also supply REST routes. Let's say we have a plugin defined like +so: + +.. literalinclude:: ../testing/example/rest.py + +which we can enable with the following configuration file: + +.. literalinclude:: ../testing/rest.cfg + +The plugin defines a ``resource`` attribute that exposes the root of the +plugin's resource tree. The plugin will show up when we navigate to the +``plugin`` resource. +:: + + >>> dump_json('http://localhost:9001/3.1/plugins') + entry 0: + class: example.rest.ExamplePlugin + enabled: True + http_etag: "..." + name: example + http_etag: "..." + start: 0 + total_size: 1 + +The plugin may provide a ``GET`` on the resource itself. +:: + + >>> dump_json('http://localhost:9001/3.1/plugins/example') + http_etag: "..." + my-child-resources: yes, no, echo + my-name: example-plugin + +And it may provide child resources. +:: + + >>> dump_json('http://localhost:9001/3.1/plugins/example/yes') + http_etag: "..." + yes: True + +Plugins and their child resources can support any HTTP method, such as +``GET``... +:: + + >>> dump_json('http://localhost:9001/3.1/plugins/example/echo') + http_etag: "..." + number: 0 + +... or ``POST`` ... +:: + + >>> dump_json('http://localhost:9001/3.1/plugins/example/echo', + ... dict(number=7)) + content-length: 0 + date: ... + server: ... + status: 204 + + >>> dump_json('http://localhost:9001/3.1/plugins/example/echo') + http_etag: "..." + number: 7 + +... or ``DELETE``. + + >>> dump_json('http://localhost:9001/3.1/plugins/example/echo', + ... method='DELETE') + content-length: 0 + date: ... + server: ... + status: 204 + + >>> dump_json('http://localhost:9001/3.1/plugins/example/echo') + http_etag: "..." + number: 0 + +It's up to the plugin of course. + + +.. _`design limitation`: https://bugs.launchpad.net/lazr.config/+bug/310619 diff --git a/src/mailman/plugins/initialize.py b/src/mailman/plugins/initialize.py new file mode 100644 index 000000000..431f69a6f --- /dev/null +++ b/src/mailman/plugins/initialize.py @@ -0,0 +1,54 @@ +# Copyright (C) 2017 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Initialize the plugins.""" + +import logging + +from lazr.config import as_boolean +from mailman.config import config +from mailman.interfaces.plugin import IPlugin +from mailman.utilities.modules import call_name +from public import public +from zope.interface.exceptions import DoesNotImplement +from zope.interface.verify import verifyObject + + +log = logging.getLogger('mailman.plugins') + + +@public +def initialize(): + """Initialize all enabled plugins.""" + for name, plugin_config in config.plugin_configs: + class_path = plugin_config['class'].strip() + if not as_boolean(plugin_config['enabled']) or len(class_path) == 0: + log.info('Plugin not enabled, or empty class path: {}'.format( + name)) + continue + if name in config.plugins: + log.error('Duplicate plugin name: {}'.format(name)) + continue + plugin = call_name(class_path) + try: + verifyObject(IPlugin, plugin) + except DoesNotImplement: + log.error('Plugin class does not implement IPlugin: {}'.format( + class_path)) + continue + plugin.name = name + config.plugins[name] = plugin diff --git a/src/mailman/plugins/testing/__init__.py b/src/mailman/plugins/testing/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/mailman/plugins/testing/__init__.py diff --git a/src/mailman/plugins/testing/alternate.cfg b/src/mailman/plugins/testing/alternate.cfg new file mode 100644 index 000000000..6cf6ea4f3 --- /dev/null +++ b/src/mailman/plugins/testing/alternate.cfg @@ -0,0 +1,7 @@ +[plugin.example] +class: example.hooks.ExamplePlugin +enabled: yes +component_package: alternate + +[logging.plugins] +propagate: yes diff --git a/src/mailman/plugins/testing/alternate/__init__.py b/src/mailman/plugins/testing/alternate/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/mailman/plugins/testing/alternate/__init__.py diff --git a/src/mailman/plugins/testing/alternate/rules/__init__.py b/src/mailman/plugins/testing/alternate/rules/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/mailman/plugins/testing/alternate/rules/__init__.py diff --git a/src/mailman/plugins/testing/alternate/rules/rules.py b/src/mailman/plugins/testing/alternate/rules/rules.py new file mode 100644 index 000000000..f8b8d725c --- /dev/null +++ b/src/mailman/plugins/testing/alternate/rules/rules.py @@ -0,0 +1,14 @@ +from mailman.interfaces.rules import IRule +from public import public +from zope.interface import implementer + + +@public +@implementer(IRule) +class AlternateRule: + name = 'alternate-rule' + description = 'An alternate rule.' + record = True + + def check(self, mlist, msg, msgdata): + return 'alternate' in msgdata diff --git a/src/mailman/plugins/testing/example/__init__.py b/src/mailman/plugins/testing/example/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/mailman/plugins/testing/example/__init__.py diff --git a/src/mailman/plugins/testing/example/hooks.py b/src/mailman/plugins/testing/example/hooks.py new file mode 100644 index 000000000..55c09c4a5 --- /dev/null +++ b/src/mailman/plugins/testing/example/hooks.py @@ -0,0 +1,21 @@ +import os + +from mailman.interfaces.plugin import IPlugin +from public import public +from zope.interface import implementer + + +@public +@implementer(IPlugin) +class ExamplePlugin: + def pre_hook(self): + if os.environ.get('DEBUG_HOOKS'): + print("I'm in my pre-hook") + + def post_hook(self): + if os.environ.get('DEBUG_HOOKS'): + print("I'm in my post-hook") + + @property + def resource(self): + return None diff --git a/src/mailman/plugins/testing/example/rest.py b/src/mailman/plugins/testing/example/rest.py new file mode 100644 index 000000000..1d83ce2cd --- /dev/null +++ b/src/mailman/plugins/testing/example/rest.py @@ -0,0 +1,79 @@ +from mailman.config import config +from mailman.interfaces.plugin import IPlugin +from mailman.rest.helpers import bad_request, child, etag, no_content, okay +from mailman.rest.validator import Validator +from public import public +from zope.interface import implementer + + +@public +class Yes: + def on_get(self, request, response): + okay(response, etag(dict(yes=True))) + + +@public +class No: + def on_get(self, request, response): + bad_request(response, etag(dict(no=False))) + + +@public +class NumberEcho: + def __init__(self): + self._plugin = config.plugins['example'] + + def on_get(self, request, response): + okay(response, etag(dict(number=self._plugin.number))) + + def on_post(self, request, response): + try: + resource = Validator(number=int)(request) + self._plugin.number = resource['number'] + except ValueError as error: + bad_request(response, str(error)) + else: + no_content(response) + + def on_delete(self, request, response): + self._plugin.number = 0 + no_content(response) + + +@public +class RESTExample: + def on_get(self, request, response): + resource = { + 'my-name': 'example-plugin', + 'my-child-resources': 'yes, no, echo', + } + okay(response, etag(resource)) + + @child() + def yes(self, context, segments): + return Yes(), [] + + @child() + def no(self, context, segments): + return No(), [] + + @child() + def echo(self, context, segments): + return NumberEcho(), [] + + +@public +@implementer(IPlugin) +class ExamplePlugin: + def __init__(self): + self.number = 0 + + def pre_hook(self): + pass + + def post_hook(self): + pass + + @property + def resource(self): + return RESTExample() diff --git a/src/mailman/plugins/testing/example/rules/__init__.py b/src/mailman/plugins/testing/example/rules/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/mailman/plugins/testing/example/rules/__init__.py diff --git a/src/mailman/plugins/testing/example/rules/rules.py b/src/mailman/plugins/testing/example/rules/rules.py new file mode 100644 index 000000000..7e9ba6ce7 --- /dev/null +++ b/src/mailman/plugins/testing/example/rules/rules.py @@ -0,0 +1,14 @@ +from mailman.interfaces.rules import IRule +from public import public +from zope.interface import implementer + + +@public +@implementer(IRule) +class ExampleRule: + name = 'example-rule' + description = 'An example rule.' + record = True + + def check(self, mlist, msg, msgdata): + return 'example' in msgdata diff --git a/src/mailman/plugins/testing/hooks.cfg b/src/mailman/plugins/testing/hooks.cfg new file mode 100644 index 000000000..15fb31815 --- /dev/null +++ b/src/mailman/plugins/testing/hooks.cfg @@ -0,0 +1,3 @@ +[plugin.example] +class: example.hooks.ExamplePlugin +enabled: yes diff --git a/src/mailman/plugins/testing/layer.py b/src/mailman/plugins/testing/layer.py new file mode 100644 index 000000000..311a419f4 --- /dev/null +++ b/src/mailman/plugins/testing/layer.py @@ -0,0 +1,53 @@ +# Copyright (C) 2017 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""REST layer for plugins.""" + +import os + +from contextlib import ExitStack +from mailman.testing.helpers import ( + TestableMaster, hackenv, wait_for_webservice) +from mailman.testing.layers import SMTPLayer +from pkg_resources import resource_filename +from public import public + + +# Don't inherit from RESTLayer since layers get run in bottom up order, +# meaning RESTLayer will get setUp() before this layer does, and that won't +# use the configuration file we need it to use. +@public +class PluginRESTLayer(SMTPLayer): + @classmethod + def setUp(cls): + cls.resources = ExitStack() + plugin_path = os.path.join( + os.path.dirname( + resource_filename('mailman.plugins', '__init__.py')), + 'testing') + config_file = resource_filename('mailman.plugins.testing', 'rest.cfg') + cls.resources.enter_context( + hackenv('MAILMAN_CONFIG_FILE', config_file)) + cls.resources.enter_context( + hackenv('PYTHONPATH', plugin_path)) + cls.server = TestableMaster(wait_for_webservice) + cls.server.start('rest') + cls.resources.callback(cls.server.stop) + + @classmethod + def tearDown(cls): + cls.resources.close() diff --git a/src/mailman/plugins/testing/rest.cfg b/src/mailman/plugins/testing/rest.cfg new file mode 100644 index 000000000..a9b4b9bd9 --- /dev/null +++ b/src/mailman/plugins/testing/rest.cfg @@ -0,0 +1,6 @@ +[plugin.example] +class: example.rest.ExamplePlugin +enabled: yes + +[webservice] +port: 9001 diff --git a/src/mailman/plugins/testing/showrules.py b/src/mailman/plugins/testing/showrules.py new file mode 100644 index 000000000..926ef0f70 --- /dev/null +++ b/src/mailman/plugins/testing/showrules.py @@ -0,0 +1,6 @@ +from mailman.config import config + + +def showrules(): + for name in sorted(config.rules): + print(name) diff --git a/src/mailman/plugins/tests/__init__.py b/src/mailman/plugins/tests/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/mailman/plugins/tests/__init__.py diff --git a/src/mailman/plugins/tests/test_plugins.py b/src/mailman/plugins/tests/test_plugins.py new file mode 100644 index 000000000..4b432f603 --- /dev/null +++ b/src/mailman/plugins/tests/test_plugins.py @@ -0,0 +1,146 @@ +# Copyright (C) 2017 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Test some additional plugin stuff.""" + +import sys +import unittest + +from contextlib import ExitStack +from mailman.interfaces.plugin import IPlugin +from mailman.plugins.initialize import initialize +from mailman.plugins.testing.layer import PluginRESTLayer +from mailman.testing.helpers import call_api +from mailman.testing.layers import ConfigLayer +from tempfile import TemporaryDirectory +from types import SimpleNamespace +from unittest.mock import patch +from urllib.error import HTTPError +from zope.interface import implementer + + +class TestRESTPlugin(unittest.TestCase): + layer = PluginRESTLayer + + def test_plugin_raises_exception(self): + with self.assertRaises(HTTPError) as cm: + call_api('http://localhost:9001/3.1/plugins/example/no') + self.assertEqual(cm.exception.code, 400) + + +@implementer(IPlugin) +class TestablePlugin: + def pre_hook(self): + pass + + def post_hook(self): + pass + + resource = None + + +class TestInitializePlugins(unittest.TestCase): + layer = ConfigLayer + + def test_duplicate_plugin_name(self): + with ExitStack() as resources: + system_path = resources.enter_context(TemporaryDirectory()) + fake_plugin_config = { + 'path': system_path, + 'enabled': 'yes', + 'class': 'ExamplePlugin', + } + log_mock = resources.enter_context( + patch('mailman.plugins.initialize.log')) + fake_mailman_config = SimpleNamespace( + plugin_configs=[('example', fake_plugin_config)], + plugins=['example'], + ) + resources.enter_context(patch( + 'mailman.plugins.initialize.config', fake_mailman_config)) + initialize() + log_mock.error.assert_called_once_with( + 'Duplicate plugin name: example') + + def test_does_not_implement(self): + with ExitStack() as resources: + system_path = resources.enter_context(TemporaryDirectory()) + fake_plugin_config = { + 'path': system_path, + 'enabled': 'yes', + 'class': 'ExamplePlugin', + } + log_mock = resources.enter_context( + patch('mailman.plugins.initialize.log')) + fake_mailman_config = SimpleNamespace( + plugin_configs=[('example', fake_plugin_config)], + plugins=[], + ) + resources.enter_context(patch( + 'mailman.plugins.initialize.config', fake_mailman_config)) + resources.enter_context(patch( + 'mailman.plugins.initialize.call_name', + # object() does not implement IPlugin. + return_value=object())) + initialize() + log_mock.error.assert_called_once_with( + 'Plugin class does not implement IPlugin: ExamplePlugin') + self.assertNotIn(system_path, sys.path) + + def test_adds_plugins_to_config(self): + with ExitStack() as resources: + system_path = resources.enter_context(TemporaryDirectory()) + fake_plugin_config = { + 'path': system_path, + 'enabled': 'yes', + 'class': 'ExamplePlugin', + } + fake_mailman_config = SimpleNamespace( + plugin_configs=[('example', fake_plugin_config)], + plugins={}, + ) + resources.enter_context(patch( + 'mailman.plugins.initialize.config', fake_mailman_config)) + testable_plugin = TestablePlugin() + resources.enter_context(patch( + 'mailman.plugins.initialize.call_name', + # object() does not implement IPlugin. + return_value=testable_plugin)) + initialize() + self.assertIn('example', fake_mailman_config.plugins) + self.assertEqual( + fake_mailman_config.plugins['example'], + testable_plugin) + + def test_not_enabled(self): + with ExitStack() as resources: + fake_plugin_config = { + 'path': '/does/not/exist', + 'enabled': 'no', + 'class': 'ExamplePlugin', + } + log_mock = resources.enter_context( + patch('mailman.plugins.initialize.log')) + fake_mailman_config = SimpleNamespace( + plugin_configs=[('example', fake_plugin_config)], + plugins={}, + ) + resources.enter_context(patch( + 'mailman.plugins.initialize.config', fake_mailman_config)) + initialize() + log_mock.info.assert_called_once_with( + 'Plugin not enabled, or empty class path: example') diff --git a/src/mailman/rest/docs/__init__.py b/src/mailman/rest/docs/__init__.py index 350a6b83e..671ea38d6 100644 --- a/src/mailman/rest/docs/__init__.py +++ b/src/mailman/rest/docs/__init__.py @@ -19,22 +19,13 @@ import threading +from http import HTTPStatus from http.server import BaseHTTPRequestHandler, HTTPServer from mailman.testing.helpers import wait_for_webservice from mailman.testing.layers import RESTLayer from public import public -# New in Python 3.5. -try: - from http import HTTPStatus -except ImportError: # pragma: nocover - class HTTPStatus: - FORBIDDEN = 403 - NOT_FOUND = 404 - OK = 200 - - # We need a web server to vend non-mailman: urls. class TestableHandler(BaseHTTPRequestHandler): # Be quiet. @@ -78,6 +69,7 @@ class HTTPLayer(RESTLayer): cls._thread.join() +# For flufl.testing -- define this doctest's layer. public(layer=HTTPLayer) diff --git a/src/mailman/rest/plugins.py b/src/mailman/rest/plugins.py new file mode 100644 index 000000000..2d61d881c --- /dev/null +++ b/src/mailman/rest/plugins.py @@ -0,0 +1,76 @@ +# Copyright (C) 2010-2017 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""REST for plugins, dynamically proxies requests to plugin's rest_object.""" + +from lazr.config import as_boolean +from mailman.config import config +from mailman.rest.helpers import CollectionMixin, NotFound, etag, okay +from operator import itemgetter +from public import public + + +@public +class AllPlugins(CollectionMixin): + """Read-only list of all plugin configs.""" + + def _resource_as_dict(self, plugin_config): + """See `CollectionMixin`.""" + name, plugin_section = plugin_config + resource = { + 'name': name, + 'class': plugin_section['class'], + 'enabled': as_boolean(plugin_section['enabled']), + } + # Add the path to the plugin's own configuration file, if one was + # given. + plugin_config = plugin_section['configuration'].strip() + if len(plugin_config) > 0: + resource['configuration'] = plugin_config + return resource + + def _get_collection(self, request): + """See `CollectionMixin`.""" + # plugin_configs returns a 2-tuple of (name, section), so sort + # alphabetically on the plugin name. + return sorted(config.plugin_configs, key=itemgetter(0)) + + def on_get(self, request, response): + """/plugins""" + resource = self._make_collection(request) + okay(response, etag(resource)) + + +@public +class APlugin: + """REST proxy to the plugin's rest_object.""" + + def __init__(self, plugin_name): + self._resource = None + if plugin_name in config.plugins: + plugin = config.plugins[plugin_name] + self._resource = plugin.resource + # If the plugin doesn't exist or doesn't provide a resource, just proxy + # to NotFound. + if self._resource is None: + self._resource = NotFound() + + def __getattr__(self, attrib): + return getattr(self._resource, attrib) + + def __dir__(self): + return dir(self._resource) diff --git a/src/mailman/rest/root.py b/src/mailman/rest/root.py index 76289078b..46116aba6 100644 --- a/src/mailman/rest/root.py +++ b/src/mailman/rest/root.py @@ -30,6 +30,7 @@ from mailman.rest.helpers import ( BadRequest, NotFound, child, etag, no_content, not_found, okay) from mailman.rest.lists import AList, AllLists, Styles from mailman.rest.members import AMember, AllMembers, FindMembers +from mailman.rest.plugins import APlugin, AllPlugins from mailman.rest.preferences import ReadOnlyPreferences from mailman.rest.queues import AQueue, AQueueFile, AllQueues from mailman.rest.templates import TemplateFinder @@ -308,6 +309,20 @@ class TopLevel: return BadRequest(), [] @child() + def plugins(self, context, segments): + """/<api>/plugins + /<api>/plugins/<plugin_name> + /<api>/plugins/<plugin_name>/... + """ + if self.api.version_info < (3, 1): + return NotFound(), [] + if len(segments) == 0: + return AllPlugins(), [] + else: + plugin_name = segments.pop(0) + return APlugin(plugin_name), segments + + @child() def bans(self, context, segments): """/<api>/bans /<api>/bans/<email> diff --git a/src/mailman/rest/tests/test_systemconf.py b/src/mailman/rest/tests/test_systemconf.py index f9fe9caa0..91ee9befa 100644 --- a/src/mailman/rest/tests/test_systemconf.py +++ b/src/mailman/rest/tests/test_systemconf.py @@ -168,6 +168,7 @@ class TestSystemConfiguration(unittest.TestCase): 'logging.http', 'logging.locks', 'logging.mischief', + 'logging.plugins', 'logging.root', 'logging.runner', 'logging.smtp', @@ -182,6 +183,7 @@ class TestSystemConfiguration(unittest.TestCase): 'paths.here', 'paths.local', 'paths.testing', + 'plugin.master', 'runner.archive', 'runner.bad', 'runner.bounces', diff --git a/src/mailman/styles/manager.py b/src/mailman/styles/manager.py index 1ec83b2a9..a7da45fba 100644 --- a/src/mailman/styles/manager.py +++ b/src/mailman/styles/manager.py @@ -38,13 +38,7 @@ class StyleManager: def populate(self): self._styles.clear() - # Avoid circular imports. - from mailman.config import config - # Calculate the Python import paths to search. - paths = filter(None, (path.strip() - for path in config.styles.paths.splitlines())) - for path in paths: - add_components(path, IStyle, self._styles) + add_components('styles', IStyle, self._styles) def get(self, name): """See `IStyleManager`.""" diff --git a/src/mailman/testing/documentation.py b/src/mailman/testing/documentation.py index d460be89f..ee37aa1ed 100644 --- a/src/mailman/testing/documentation.py +++ b/src/mailman/testing/documentation.py @@ -21,6 +21,9 @@ Note that doctest extraction does not currently work for zip file distributions. doctest discovery currently requires file system traversal. """ +import os +import sys + from click.testing import CliRunner from contextlib import ExitStack from importlib import import_module @@ -30,6 +33,7 @@ from mailman.testing.helpers import ( call_api, get_queue_messages, specialized_message_from_string, subscribe) from mailman.testing.layers import SMTPLayer from public import public +from subprocess import PIPE, STDOUT, run DOT = '.' @@ -109,6 +113,13 @@ def call_http(url, data=None, method=None, username=None, password=None): return content +def _print_dict(data, depth=0): + for item, value in sorted(data.items()): + if isinstance(value, dict): + _print_dict(value, depth+1) + print(' ' * depth + '{}: {}'.format(item, value)) + + def dump_json(url, data=None, method=None, username=None, password=None): """Print the JSON dictionary read from a URL. @@ -140,6 +151,9 @@ def dump_json(url, data=None, method=None, username=None, password=None): printable_value = COMMASPACE.join( "'{}'".format(s) for s in sorted(value)) print('{}: [{}]'.format(key, printable_value)) + elif isinstance(value, dict): + print('{}:'.format(key)) + _print_dict(value, 1) else: print('{}: {}'.format(key, value)) @@ -169,6 +183,18 @@ def cli(command_path): @public +def run_mailman(args, **overrides): + exe = os.path.join(os.path.dirname(sys.executable), 'mailman') + env = os.environ.copy() + env.update(overrides) + run_args = [exe] + run_args.extend(args) + proc = run( + run_args, env=env, stdout=PIPE, stderr=STDOUT, universal_newlines=True) + return proc + + +@public def setup(testobj): """Test setup.""" # In general, I don't like adding convenience functions, since I think @@ -176,18 +202,19 @@ def setup(testobj): # documentation that way. However, a few are really useful, or help to # hide some icky test implementation details. testobj.globs['call_http'] = call_http + testobj.globs['cli'] = cli testobj.globs['config'] = config testobj.globs['create_list'] = create_list testobj.globs['dump_json'] = dump_json - testobj.globs['dump_msgdata'] = dump_msgdata testobj.globs['dump_list'] = dump_list + testobj.globs['dump_msgdata'] = dump_msgdata testobj.globs['get_queue_messages'] = get_queue_messages testobj.globs['message_from_string'] = specialized_message_from_string + testobj.globs['run'] = run_mailman testobj.globs['smtpd'] = SMTPLayer.smtpd testobj.globs['stop'] = stop testobj.globs['subscribe'] = subscribe testobj.globs['transaction'] = config.db - testobj.globs['cli'] = cli # Add this so that cleanups can be automatically added by the doctest. testobj.globs['cleanups'] = ExitStack() diff --git a/src/mailman/testing/helpers.py b/src/mailman/testing/helpers.py index 8bb8e8bdd..f3f6b11aa 100644 --- a/src/mailman/testing/helpers.py +++ b/src/mailman/testing/helpers.py @@ -151,7 +151,9 @@ class TestableMaster(Master): until the pass condition is set. :type start_check: Callable taking no arguments, returning nothing. """ - super().__init__(restartable=False, config_file=config.filename) + super().__init__( + restartable=False, + config_file=os.environ.get('MAILMAN_CONFIG_FILE', config.filename)) self.start_check = start_check self.event = threading.Event() self.thread = threading.Thread(target=self.loop) diff --git a/src/mailman/tests/test_hook_deprecations.py b/src/mailman/tests/test_hook_deprecations.py new file mode 100644 index 000000000..1e15e0d3a --- /dev/null +++ b/src/mailman/tests/test_hook_deprecations.py @@ -0,0 +1,69 @@ +# Copyright (C) 2017 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Test some plugin behavior.""" + +import unittest + +from contextlib import ExitStack +from mailman.testing.documentation import run_mailman +from mailman.testing.layers import ConfigLayer +from tempfile import NamedTemporaryFile + + +class TestExternalHooks(unittest.TestCase): + layer = ConfigLayer + maxDiff = None + + def setUp(self): + self.resources = ExitStack() + self.addCleanup(self.resources.close) + self.config_file = self.resources.enter_context(NamedTemporaryFile()) + + def test_pre_hook_deprecated(self): + with open(self.config_file.name, 'w', encoding='utf-8') as fp: + print("""\ +[mailman] +pre_hook: sys.exit + +[logging.plugins] +propagate: yes +""", file=fp) + proc = run_mailman(['-C', self.config_file.name, 'info']) + # We only care about the log warning printed to stdout. + warning = proc.stdout.splitlines()[0] + self.assertEqual( + warning[-111:], + 'The [mailman]pre_hook configuration value has been replaced ' + "by the plugins infrastructure, and won't be called.") + + def test_post_hook_deprecated(self): + with open(self.config_file.name, 'w', encoding='utf-8') as fp: + print("""\ +[mailman] +post_hook: sys.exit + +[logging.plugins] +propagate: yes +""", file=fp) + proc = run_mailman(['-C', self.config_file.name, 'info']) + # We only care about the log warning printed to stdout. + warning = proc.stdout.splitlines()[0] + self.assertEqual( + warning[-112:], + 'The [mailman]post_hook configuration value has been replaced ' + "by the plugins infrastructure, and won't be called.") 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}) |
