summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.rst1
-rw-r--r--src/mailman/app/commands.py2
-rw-r--r--src/mailman/app/docs/hooks.rst121
-rw-r--r--src/mailman/bin/mailman.py39
-rw-r--r--src/mailman/bin/master.py42
-rw-r--r--src/mailman/bin/runner.py2
-rw-r--r--src/mailman/bin/tests/test_mailman.py20
-rw-r--r--src/mailman/commands/cli_withlist.py27
-rw-r--r--src/mailman/commands/docs/conf.rst1
-rw-r--r--src/mailman/commands/docs/info.rst1
-rw-r--r--src/mailman/commands/tests/test_cli_shell.py14
-rw-r--r--src/mailman/config/config.py46
-rw-r--r--src/mailman/config/schema.cfg64
-rw-r--r--src/mailman/core/chains.py3
-rw-r--r--src/mailman/core/initialize.py48
-rw-r--r--src/mailman/core/pipelines.py4
-rw-r--r--src/mailman/core/rules.py2
-rw-r--r--src/mailman/docs/NEWS.rst15
-rw-r--r--src/mailman/interfaces/plugin.py51
-rw-r--r--src/mailman/model/docs/listmanager.rst2
-rw-r--r--src/mailman/plugins/__init__.py0
-rw-r--r--src/mailman/plugins/docs/__init__.py25
-rw-r--r--src/mailman/plugins/docs/intro.rst207
-rw-r--r--src/mailman/plugins/initialize.py54
-rw-r--r--src/mailman/plugins/testing/__init__.py0
-rw-r--r--src/mailman/plugins/testing/alternate.cfg7
-rw-r--r--src/mailman/plugins/testing/alternate/__init__.py0
-rw-r--r--src/mailman/plugins/testing/alternate/rules/__init__.py0
-rw-r--r--src/mailman/plugins/testing/alternate/rules/rules.py14
-rw-r--r--src/mailman/plugins/testing/example/__init__.py0
-rw-r--r--src/mailman/plugins/testing/example/hooks.py21
-rw-r--r--src/mailman/plugins/testing/example/rest.py79
-rw-r--r--src/mailman/plugins/testing/example/rules/__init__.py0
-rw-r--r--src/mailman/plugins/testing/example/rules/rules.py14
-rw-r--r--src/mailman/plugins/testing/hooks.cfg3
-rw-r--r--src/mailman/plugins/testing/layer.py53
-rw-r--r--src/mailman/plugins/testing/rest.cfg6
-rw-r--r--src/mailman/plugins/testing/showrules.py6
-rw-r--r--src/mailman/plugins/tests/__init__.py0
-rw-r--r--src/mailman/plugins/tests/test_plugins.py146
-rw-r--r--src/mailman/rest/docs/__init__.py12
-rw-r--r--src/mailman/rest/plugins.py76
-rw-r--r--src/mailman/rest/root.py15
-rw-r--r--src/mailman/rest/tests/test_systemconf.py2
-rw-r--r--src/mailman/styles/manager.py8
-rw-r--r--src/mailman/testing/documentation.py31
-rw-r--r--src/mailman/testing/helpers.py4
-rw-r--r--src/mailman/tests/test_hook_deprecations.py69
-rw-r--r--src/mailman/utilities/modules.py117
-rw-r--r--src/mailman/utilities/tests/test_modules.py121
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})