diff options
| author | Barry Warsaw | 2017-08-29 14:07:55 +0000 |
|---|---|---|
| committer | Barry Warsaw | 2017-08-29 14:07:55 +0000 |
| commit | de8c204fa40f0c4677a1b73b2ee7e3e86ce93a9c (patch) | |
| tree | 6fd2038427fbb36d8173fe338d277351cd19727b | |
| parent | f847e15407bfbf824236547bdf728a1ae00bd405 (diff) | |
| parent | ae0042a90220119414f61aeb20c6b58bfacb8af2 (diff) | |
| download | mailman-de8c204fa40f0c4677a1b73b2ee7e3e86ce93a9c.tar.gz mailman-de8c204fa40f0c4677a1b73b2ee7e3e86ce93a9c.tar.zst mailman-de8c204fa40f0c4677a1b73b2ee7e3e86ce93a9c.zip | |
Add a new plugin architecture.
This allows third parties to add initialization hooks, REST endpoints,
and additional components. Given by Jan Jancar.
Diffstat (limited to '')
50 files changed, 1236 insertions, 359 deletions
diff --git a/README.rst b/README.rst index e58d18297..eae6b73cc 100644 --- a/README.rst +++ b/README.rst @@ -60,6 +60,7 @@ Table of Contents src/mailman/docs/mta src/mailman/docs/postorius src/mailman/docs/hyperkitty + src/mailman/plugins/docs/intro src/mailman/docs/contribute src/mailman/docs/STYLEGUIDE src/mailman/docs/internationalization 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}) |
