summaryrefslogtreecommitdiff
path: root/src/mailman
diff options
context:
space:
mode:
Diffstat (limited to 'src/mailman')
-rw-r--r--src/mailman/app/commands.py4
-rw-r--r--src/mailman/app/docs/hooks.rst121
-rw-r--r--src/mailman/app/docs/plugins.rst210
-rw-r--r--src/mailman/bin/mailman.py43
-rw-r--r--src/mailman/bin/master.py7
-rw-r--r--src/mailman/bin/tests/test_mailman.py18
-rw-r--r--src/mailman/commands/docs/conf.rst1
-rw-r--r--src/mailman/commands/docs/info.rst1
-rw-r--r--src/mailman/config/config.py10
-rw-r--r--src/mailman/config/schema.cfg51
-rw-r--r--src/mailman/core/chains.py5
-rw-r--r--src/mailman/core/initialize.py50
-rw-r--r--src/mailman/core/pipelines.py6
-rw-r--r--src/mailman/core/plugins.py41
-rw-r--r--src/mailman/core/rules.py4
-rw-r--r--src/mailman/core/workflows.py4
-rw-r--r--src/mailman/interfaces/plugin.py38
-rw-r--r--src/mailman/interfaces/styles.py3
-rw-r--r--src/mailman/rest/docs/lists.rst11
-rw-r--r--src/mailman/rest/docs/plugins.rst60
-rw-r--r--src/mailman/rest/lists.py5
-rw-r--r--src/mailman/rest/plugins.py70
-rw-r--r--src/mailman/rest/root.py15
-rw-r--r--src/mailman/rest/tests/test_lists.py21
-rw-r--r--src/mailman/rest/tests/test_plugins.py105
-rw-r--r--src/mailman/rest/tests/test_systemconf.py2
-rw-r--r--src/mailman/runners/tests/test_bounce.py1
-rw-r--r--src/mailman/styles/default.py3
-rw-r--r--src/mailman/styles/docs/styles.rst2
-rw-r--r--src/mailman/styles/manager.py10
-rw-r--r--src/mailman/styles/tests/test_styles.py5
-rw-r--r--src/mailman/testing/documentation.py10
-rw-r--r--src/mailman/testing/plugin.py101
-rw-r--r--src/mailman/testing/testing.cfg4
-rw-r--r--src/mailman/utilities/plugins.py77
35 files changed, 926 insertions, 193 deletions
diff --git a/src/mailman/app/commands.py b/src/mailman/app/commands.py
index 7cf3bc4c5..ca257ffda 100644
--- a/src/mailman/app/commands.py
+++ b/src/mailman/app/commands.py
@@ -19,11 +19,11 @@
from mailman.config import config
from mailman.interfaces.command import IEmailCommand
-from mailman.utilities.modules import add_components
+from mailman.utilities.plugins import add_pluggable_components
from public import public
@public
def initialize():
"""Initialize the email commands."""
- add_components('mailman.commands', IEmailCommand, config.commands)
+ add_pluggable_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/app/docs/plugins.rst b/src/mailman/app/docs/plugins.rst
new file mode 100644
index 000000000..4430179ae
--- /dev/null
+++ b/src/mailman/app/docs/plugins.rst
@@ -0,0 +1,210 @@
+=======
+Plugins
+=======
+
+Mailman defines a plugin as a package accessible on ``sys.path`` that provides
+components Mailman will use. Such add handlers, rules, chains, etc...
+First we create an example plugin that provides one additional rule:
+::
+
+ >>> import os, sys
+ >>> from mailman.config import config
+ >>> config_directory = os.path.dirname(config.filename)
+ >>> sys.path.insert(0, config_directory)
+
+ >>> example_plugin_path = os.path.join(config_directory, 'example_plugin')
+ >>> rules_path = os.path.join(example_plugin_path, 'rules')
+ >>> os.makedirs(rules_path)
+ >>> open(os.path.join(example_plugin_path, '__init__.py'), 'a').close()
+ >>> open(os.path.join(rules_path, '__init__.py'), 'a').close()
+ >>> rule_path = os.path.join(rules_path, 'example_rule.py')
+ >>> with open(rule_path, 'w') as fp:
+ ... print("""\
+ ... from mailman.interfaces.rules import IRule
+ ... from public import public
+ ... from zope.interface import implementer
+ ...
+ ... @public
+ ... @implementer(IRule)
+ ... class ExampleRule:
+ ...
+ ... name = 'example'
+ ... description = 'Example rule to show pluggable components.'
+ ... record = True
+ ...
+ ... def check(self, mlist, msg, msgdata):
+ ... return msg.original_size > 1024
+ ... """, file=fp)
+ >>> fp.close()
+
+Then enable the example plugin in config.
+::
+
+ >>> example_config = """\
+ ... [plugin.example]
+ ... path: example_plugin
+ ... enable: yes
+ ... """
+ >>> config.push('example_cfg', example_config)
+
+Now the `example` rule can be seen as an ``IRule`` component and will be used
+when any chain uses a link called `example`.
+::
+
+ >>> from mailman.interfaces.rules import IRule
+ >>> from mailman.utilities.plugins import find_pluggable_components
+ >>> rules = sorted([rule.name
+ ... for rule in find_pluggable_components('rules', IRule)])
+ >>> for rule in rules:
+ ... print(rule)
+ administrivia
+ any
+ approved
+ banned-address
+ dmarc-mitigation
+ emergency
+ example
+ implicit-dest
+ loop
+ max-recipients
+ max-size
+ member-moderation
+ news-moderation
+ no-senders
+ no-subject
+ nonmember-moderation
+ suspicious-header
+ truth
+
+
+ >>> config.pop('example_cfg')
+ >>> from shutil import rmtree
+ >>> rmtree(example_plugin_path)
+
+
+Hooks
+=====
+
+Plugins can also add initialization hooks, which will be run during the
+initialization process. By creating a class implementing the IPlugin interface.
+One which is run early in the process and the other run late in the
+initialization process.
+::
+
+ >>> hook_path = os.path.join(config_directory, 'hooks.py')
+ >>> with open(hook_path, 'w') as fp:
+ ... print("""\
+ ... from mailman.interfaces.plugin import IPlugin
+ ... from public import public
+ ... from zope.interface import implementer
+ ...
+ ... counter = 1
+ ...
+ ... @public
+ ... @implementer(IPlugin)
+ ... class ExamplePlugin:
+ ...
+ ... def pre_hook(self):
+ ... global counter
+ ... print('pre-hook:', counter)
+ ... counter += 1
+ ...
+ ... def post_hook(self):
+ ... global counter
+ ... print('post-hook:', counter)
+ ... counter += 1
+ ...
+ ... def rest_object(self):
+ ... pass
+ ... """, file=fp)
+
+Running the hooks
+-----------------
+
+We can set the plugin class in the config file.
+::
+
+ >>> config_path = os.path.join(config_directory, 'hooks.cfg')
+ >>> with open(config_path, 'w') as fp:
+ ... print("""\
+ ... [meta]
+ ... extends: test.cfg
+ ...
+ ... [plugin.hook_example]
+ ... class: hooks.ExamplePlugin
+ ... enable: yes
+ ... """, 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(cfg_path, python_path):
+ ... exe = os.path.join(os.path.dirname(sys.executable), 'mailman')
+ ... env = os.environ.copy()
+ ... env.update(
+ ... MAILMAN_CONFIG_FILE=cfg_path,
+ ... PYTHONPATH=python_path,
+ ... )
+ ... 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)
+ ... print(stderr)
+
+ >>> call(config_path, config_directory)
+ pre-hook: 1
+ post-hook: 2
+ <BLANKLINE>
+ <BLANKLINE>
+
+ >>> os.remove(config_path)
+
+Deprecated hooks
+----------------
+
+The old-style `pre_hook` and `post_hook` callables are deprecated and are no
+longer called upon startup.
+::
+
+ >>> deprecated_hook_path = os.path.join(config_directory, 'deprecated_hooks.py')
+ >>> with open(deprecated_hook_path, 'w') as fp:
+ ... print("""\
+ ... def do_something():
+ ... print("does something")
+ ...
+ ... def do_something_else():
+ ... print("does something else")
+ ...
+ ... """, file=fp)
+
+ >>> deprecated_config_path = os.path.join(config_directory, 'deprecated.cfg')
+ >>> with open(deprecated_config_path, 'w') as fp:
+ ... print("""\
+ ... [meta]
+ ... extends: test.cfg
+ ...
+ ... [mailman]
+ ... pre_hook: deprecated_hooks.do_something
+ ... post_hook: deprecated_hooks.do_something_else
+ ... """, file=fp)
+
+ >>> call(deprecated_config_path, config_directory)
+ does something
+ does something else
+ <BLANKLINE>
+ ... UserWarning: The pre_hook configuration value has been replaced by the plugins infrastructure. ...
+ ... UserWarning: The post_hook configuration value has been replaced by the plugins infrastructure. ...
+ <BLANKLINE>
+
+ >>> os.remove(deprecated_config_path)
diff --git a/src/mailman/bin/mailman.py b/src/mailman/bin/mailman.py
index e012237f3..f8006218d 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
@@ -25,7 +24,7 @@ from mailman.core.i18n import _
from mailman.core.initialize import initialize
from mailman.database.transaction import transaction
from mailman.interfaces.command import ICLISubCommand
-from mailman.utilities.modules import add_components
+from mailman.utilities.plugins import add_pluggable_components
from mailman.version import MAILMAN_VERSION_FULL
from public import public
@@ -35,17 +34,22 @@ 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 pluggable commands need a parsed config to
+ # find plugins.
+ if not self._loaded:
+ add_pluggable_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:
@@ -83,10 +87,12 @@ class Subcommands(click.MultiCommand):
formatter.write_dl(opts)
-@click.group(
- cls=Subcommands,
- context_settings=dict(help_option_names=['-h', '--help']))
-@click.pass_context
+def initialize_config(ctx, param, value):
+ if ctx.resilient_parsing: # pragma: nocover
+ return
+ initialize(value)
+
+
@click.option(
'-C', '--config', 'config_file',
envvar='MAILMAN_CONFIG_FILE',
@@ -94,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):
@@ -104,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..ccaa16398 100644
--- a/src/mailman/bin/master.py
+++ b/src/mailman/bin/master.py
@@ -188,6 +188,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
@@ -402,6 +405,10 @@ class Loop:
except InterruptedError: # pragma: nocover
# If the system call got interrupted, just restart it.
continue
+ if pid not in self._kids: # pragma: nocover
+ # Not a runner subprocess, maybe a plugin started one
+ # ignore 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/tests/test_mailman.py b/src/mailman/bin/tests/test_mailman.py
index 54ee54bce..196437291 100644
--- a/src/mailman/bin/tests/test_mailman.py
+++ b/src/mailman/bin/tests/test_mailman.py
@@ -27,6 +27,7 @@ from mailman.config import config
from mailman.database.transaction import transaction
from mailman.testing.layers import ConfigLayer
from mailman.utilities.datetime import now
+from pkg_resources import resource_filename
from unittest.mock import patch
@@ -36,7 +37,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)
@@ -46,7 +59,8 @@ class TestMailmanCommand(unittest.TestCase):
# command line.
self.assertEqual(lines[0], 'Usage: main [OPTIONS] COMMAND [ARGS]...')
- 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/docs/conf.rst b/src/mailman/commands/docs/conf.rst
index 1a6a4679d..9981b71ed 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.plugin] path: plugin.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/config/config.py b/src/mailman/config/config.py
index 4a4eadff5..95e593fb8 100644
--- a/src/mailman/config/config.py
+++ b/src/mailman/config/config.py
@@ -76,6 +76,7 @@ class Configuration:
self.handlers = {}
self.pipelines = {}
self.commands = {}
+ self.plugins = {}
self.workflows = {}
self.password_context = None
self.db = None
@@ -159,7 +160,7 @@ class Configuration:
else category.template_dir),
)
# Directories.
- for name in ('archive', 'bin', 'cache', 'data', 'etc', 'ext',
+ for name in ('archive', 'bin', 'cache', 'data', 'etc',
'list_data', 'lock', 'log', 'messages', 'queue'):
key = '{}_dir'.format(name)
substitutions[key] = getattr(category, key)
@@ -249,6 +250,13 @@ 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:
+ 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..742bceae1 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,38 @@ html_to_plain_text_command: /usr/bin/lynx -dump $filename
# unpredictable.
listname_chars: [-_.0-9a-z]
+# Deprecated, callable run before DB initialization.
+pre_hook:
+
+# Deprecated, callable run after DB initialization.
+post_hook:
+
+
+[plugin.master]
+# Plugin package
+# It's sub packages will be searched for components.
+# - commands for IEmailCommand and ICliSubCommand
+# - chains for IChain
+# - rules for IRule
+# - pipelines for IPipeline
+# - handlers for IHandler
+# - styles for IStyle
+path:
+
+# The full import path for the class implementing IPlugin.
+class:
+
+# Whether to enable this plugin or not.
+enable: no
+
+# Additional config for this plugin. The path can be either a file system path
+# or a Python import path. If the value starts with python: then it is
+# a Python import path, otherwise it is a file system path. File system paths
+# must be absolute since no guarantees are made about the current working
+# directory. Python paths should not include the trailing .cfg, which the file
+# must end with.
+configuration:
+
[shell]
# `mailman shell` (also `withlist`) gives you an interactive prompt that you
@@ -138,8 +162,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 +283,7 @@ debug: no
# - http -- Internal wsgi-based web interface
# - locks -- Lock state changes
# - mischief -- Various types of hostile activity
+# - plugin -- Plugin logs
# - runner -- Runner process start/stops
# - smtp -- Successful SMTP activity
# - smtp-failure -- Unsuccessful SMTP activity
@@ -298,6 +321,9 @@ level: info
[logging.mischief]
+[logging.plugin]
+path: plugin.log
+
[logging.runner]
[logging.smtp]
@@ -797,11 +823,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..48ed82d63 100644
--- a/src/mailman/core/chains.py
+++ b/src/mailman/core/chains.py
@@ -19,7 +19,7 @@
from mailman.config import config
from mailman.interfaces.chain import IChain, LinkAction
-from mailman.utilities.modules import add_components
+from mailman.utilities.plugins import add_pluggable_components
from public import public
@@ -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_pluggable_components('chains', IChain, config.chains)
diff --git a/src/mailman/core/initialize.py b/src/mailman/core/initialize.py
index dfa365448..ae9ff623e 100644
--- a/src/mailman/core/initialize.py
+++ b/src/mailman/core/initialize.py
@@ -26,9 +26,12 @@ by the command line arguments.
import os
import sys
+import warnings
+import traceback
import mailman.config.config
import mailman.core.logging
+from mailman.core.i18n import _
from mailman.interfaces.database import IDatabaseFactory
from mailman.utilities.modules import call_name
from pkg_resources import resource_string as resource_bytes
@@ -133,7 +136,7 @@ def initialize_2(debug=False, propagate_logs=None, testing=False):
* Database
* Logging
- * Pre-hook
+ * Plugin's pre_hook
* Rules
* Chains
* Pipelines
@@ -146,10 +149,34 @@ 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.core.plugins import initialize as initialize_plugins
+ initialize_plugins()
+ # Check for deprecated features in config.
config = mailman.config.config
- if config.mailman.pre_hook:
+ if config.mailman.pre_hook: # pragma: nocover
+ warnings.warn(
+ _('The pre_hook configuration value has been replaced by the '
+ 'plugins infrastructure.'), UserWarning)
call_name(config.mailman.pre_hook)
+ # Run the plugin pre_hooks, if one fails, disable the offending plugin.
+ for name in list(config.plugins.keys()):
+ plugin = config.plugins[name]
+ try:
+ plugin.pre_hook()
+ except: # pragma: nocover
+ traceback.print_exc()
+ warnings.warn(_('Plugin $name failed to run its pre_hook,'
+ 'it will be disabled and its components'
+ 'wont be loaded.'), RuntimeWarning)
+ # It failed, push disabling overlay to config. This will stop
+ # components from being loaded.
+ config.push(name + '_error', """\
+[plugin.{}]
+enable: no
+ """.format(name))
+ # And forget about it. This will stop running its post_hook.
+ 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')
@@ -173,12 +200,23 @@ def initialize_2(debug=False, propagate_logs=None, testing=False):
def initialize_3():
"""Third initialization step.
- * Post-hook
+ * Plugin's post_hook
"""
- # Run the post-hook if there is one.
+ # Run the plugin post_hooks
config = mailman.config.config
- if config.mailman.post_hook:
+ if config.mailman.post_hook: # pragma: nocover
+ warnings.warn(
+ _('The post_hook configuration value has been replaced by the '
+ 'plugins infrastructure.'), UserWarning)
call_name(config.mailman.post_hook)
+ for plugin in config.plugins.values():
+ try:
+ plugin.post_hook()
+ except: # 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.
+ pass
@public
diff --git a/src/mailman/core/pipelines.py b/src/mailman/core/pipelines.py
index 4265d24d1..6e2ad4a27 100644
--- a/src/mailman/core/pipelines.py
+++ b/src/mailman/core/pipelines.py
@@ -24,7 +24,7 @@ from mailman.config import config
from mailman.interfaces.handler import IHandler
from mailman.interfaces.pipeline import (
DiscardMessage, IPipeline, RejectMessage)
-from mailman.utilities.modules import add_components
+from mailman.utilities.plugins import add_pluggable_components
from public import public
@@ -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_pluggable_components('handlers', IHandler, config.handlers)
# Set up some pipelines.
- add_components('mailman.pipelines', IPipeline, config.pipelines)
+ add_pluggable_components('pipelines', IPipeline, config.pipelines)
diff --git a/src/mailman/core/plugins.py b/src/mailman/core/plugins.py
new file mode 100644
index 000000000..e891f380d
--- /dev/null
+++ b/src/mailman/core/plugins.py
@@ -0,0 +1,41 @@
+# Copyright (C) 2008-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/>.
+
+"""Various plugin helpers."""
+
+from lazr.config import as_boolean
+from mailman.config import config
+from mailman.interfaces.plugin import IPlugin
+from mailman.utilities.modules import find_name
+from public import public
+from zope.interface.verify import verifyObject
+
+
+@public
+def initialize():
+ """Initialize all enabled plugins."""
+ for name, plugin_config in config.plugin_configs:
+ plugin_class_path = plugin_config['class']
+ if as_boolean(plugin_config.enable) and plugin_class_path:
+ plugin_class = find_name(plugin_class_path)
+ plugin = plugin_class()
+ verifyObject(IPlugin, plugin)
+ plugin.name = name
+ assert plugin.name not in config.plugins, (
+ 'Duplicate plugin "{}" found in {}'.format(
+ plugin.name, plugin_class))
+ config.plugins[plugin.name] = plugin
diff --git a/src/mailman/core/rules.py b/src/mailman/core/rules.py
index 8e0d9197c..74934eb99 100644
--- a/src/mailman/core/rules.py
+++ b/src/mailman/core/rules.py
@@ -19,7 +19,7 @@
from mailman.config import config
from mailman.interfaces.rules import IRule
-from mailman.utilities.modules import add_components
+from mailman.utilities.plugins import add_pluggable_components
from public import public
@@ -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_pluggable_components('rules', IRule, config.rules)
diff --git a/src/mailman/core/workflows.py b/src/mailman/core/workflows.py
index b16da7df9..4581956d2 100644
--- a/src/mailman/core/workflows.py
+++ b/src/mailman/core/workflows.py
@@ -19,14 +19,14 @@
from mailman.config import config
from mailman.interfaces.workflows import IWorkflow
-from mailman.utilities.modules import find_components
+from mailman.utilities.plugins import find_pluggable_components
from public import public
@public
def initialize():
"""Find and register all workflows in all plugins."""
- for workflow in find_components('mailman.workflows', IWorkflow):
+ for workflow in find_pluggable_components('workflows', IWorkflow):
assert workflow.name not in config.workflows, (
'Duplicate key "{}" found in {}: "{}"'.format(
workflow.name, config.workflows,
diff --git a/src/mailman/interfaces/plugin.py b/src/mailman/interfaces/plugin.py
new file mode 100644
index 000000000..f1b7212f0
--- /dev/null
+++ b/src/mailman/interfaces/plugin.py
@@ -0,0 +1,38 @@
+# Copyright (C) 2007-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 Interface
+
+
+@public
+class IPlugin(Interface):
+ """A plugin providing components and hooks."""
+
+ def pre_hook():
+ """A plugin hook called in the first initialization step. Before DB."""
+
+ def post_hook():
+ """A plugin hook called in the second initialization step. After DB."""
+
+ def rest_object():
+ """Return the REST object which will be hooked up to the REST api.
+
+ This object will be served at the /<api>/plugins/<plugin.name>/ root.
+ """
diff --git a/src/mailman/interfaces/styles.py b/src/mailman/interfaces/styles.py
index 9c7d13479..b36547b40 100644
--- a/src/mailman/interfaces/styles.py
+++ b/src/mailman/interfaces/styles.py
@@ -34,6 +34,9 @@ class IStyle(Interface):
name = Attribute(
"""The name of this style. Must be unique.""")
+ description = Attribute(
+ """A short description of this list style.""")
+
def apply(mailing_list):
"""Apply the style to the mailing list.
diff --git a/src/mailman/rest/docs/lists.rst b/src/mailman/rest/docs/lists.rst
index 6a034df94..67cbb697a 100644
--- a/src/mailman/rest/docs/lists.rst
+++ b/src/mailman/rest/docs/lists.rst
@@ -191,10 +191,13 @@ Apply a style at list creation time
of a particular type, e.g. discussion lists. We can see which styles are
available, and which is the default style.
- >>> dump_json('http://localhost:9001/3.0/lists/styles')
- default: legacy-default
- http_etag: "..."
- style_names: ['legacy-announce', 'legacy-default']
+ >>> json = call_http('http://localhost:9001/3.0/lists/styles')
+ >>> json['default']
+ 'legacy-default'
+ >>> for style in json['styles']:
+ ... print('{}: {}'.format(style['name'], style['description']))
+ legacy-announce: Announce only mailing list style.
+ legacy-default: Ordinary discussion mailing list style.
When creating a list, if we don't specify a style to apply, the default style
is used. However, we can provide a style name in the POST data to choose a
diff --git a/src/mailman/rest/docs/plugins.rst b/src/mailman/rest/docs/plugins.rst
new file mode 100644
index 000000000..737a911c0
--- /dev/null
+++ b/src/mailman/rest/docs/plugins.rst
@@ -0,0 +1,60 @@
+=========
+ Plugins
+=========
+
+Plugins can supply REST routes.
+::
+
+ >>> dump_json('http://localhost:9001/3.1/plugins')
+ entry 0:
+ class: mailman.testing.plugin.ExamplePlugin
+ configuration:
+ enable: True
+ http_etag: "..."
+ name: example
+ path:
+ http_etag: "..."
+ start: 0
+ total_size: 1
+
+ >>> dump_json('http://localhost:9001/3.1/plugins/example')
+ example-plugin-reply: yes
+ http_etag: "..."
+
+ >>> dump_json('http://localhost:9001/3.1/plugins/example/good')
+ good: True
+ http_etag: "..."
+
+ >>> dump_json('http://localhost:9001/3.1/plugins/example/bad')
+ Traceback (most recent call last):
+ ...
+ urllib.error.HTTPError: HTTP Error 400: ...
+
+For example this route that presents a counter.
+::
+
+ >>> dump_json('http://localhost:9001/3.1/plugins/example/count')
+ count: 0
+ http_etag: "..."
+
+ >>> call_http('http://localhost:9001/3.1/plugins/example/count',
+ ... {'count':3}, method='POST')
+ content-length: 0
+ date: ...
+ server: ...
+ status: 204
+
+ >>> dump_json('http://localhost:9001/3.1/plugins/example/count')
+ count: 3
+ http_etag: "..."
+
+ >>> call_http('http://localhost:9001/3.1/plugins/example/count',
+ ... method='DELETE')
+ content-length: 0
+ date: ...
+ server: ...
+ status: 204
+
+ >>> dump_json('http://localhost:9001/3.1/plugins/example/count')
+ count: 0
+ http_etag: "..."
diff --git a/src/mailman/rest/lists.py b/src/mailman/rest/lists.py
index 4b467afb3..482d3e2d7 100644
--- a/src/mailman/rest/lists.py
+++ b/src/mailman/rest/lists.py
@@ -414,9 +414,10 @@ class Styles:
def __init__(self):
manager = getUtility(IStyleManager)
- style_names = sorted(style.name for style in manager.styles)
+ styles = [dict(name=style.name, description=style.description)
+ for style in manager.styles]
self._resource = dict(
- style_names=style_names,
+ styles=styles,
default=config.styles.default)
def on_get(self, request, response):
diff --git a/src/mailman/rest/plugins.py b/src/mailman/rest/plugins.py
new file mode 100644
index 000000000..fa2605f24
--- /dev/null
+++ b/src/mailman/rest/plugins.py
@@ -0,0 +1,70 @@
+# 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 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'],
+ 'enable': as_boolean(plugin_section['enable']),
+ 'path': plugin_section['path'],
+ 'configuration': plugin_section['configuration']
+ }
+ return resource
+
+ def _get_collection(self, request):
+ """See `CollectionMixin`."""
+ return sorted(config.plugin_configs)
+
+ def on_get(self, request, response):
+ """/plugins"""
+ resource = self._make_collection(request)
+ okay(response, etag(resource))
+
+
+@public
+class APlugin:
+ """REST proxy to the plugins rest_object."""
+
+ def __init__(self, plugin_name):
+ self.__rest_object = None
+ if plugin_name in config.plugins.keys():
+ plugin = config.plugins[plugin_name]
+ self.__rest_object = plugin.rest_object()
+ # If the plugin doesn't exist or doesn't provide a rest_object,
+ # just proxy to NotFound
+ if self.__rest_object is None:
+ self.__rest_object = NotFound()
+
+ def __getattr__(self, attrib):
+ return getattr(self.__rest_object, attrib)
+
+ def __dir__(self):
+ return dir(self.__rest_object)
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_lists.py b/src/mailman/rest/tests/test_lists.py
index 0f3b99393..44446b289 100644
--- a/src/mailman/rest/tests/test_lists.py
+++ b/src/mailman/rest/tests/test_lists.py
@@ -347,6 +347,27 @@ class TestLists(unittest.TestCase):
self.assertEqual(cm.exception.reason, 'Missing parameters: emails')
+class TestListStyles(unittest.TestCase):
+ """Test /lists/styles."""
+
+ layer = RESTLayer
+
+ def test_styles(self):
+ json, response = call_api('http://localhost:9001/3.0/lists/styles')
+ self.assertEqual(response.status_code, 200)
+ # Remove the variable data.
+ json.pop('http_etag')
+ self.assertEqual(json, {
+ 'styles': [
+ {'name': 'legacy-announce',
+ 'description': 'Announce only mailing list style.'},
+ {'name': 'legacy-default',
+ 'description': 'Ordinary discussion mailing list style.'}
+ ],
+ 'default': 'legacy-default'
+ })
+
+
class TestListArchivers(unittest.TestCase):
"""Test corner cases for list archivers."""
diff --git a/src/mailman/rest/tests/test_plugins.py b/src/mailman/rest/tests/test_plugins.py
new file mode 100644
index 000000000..9c12e1160
--- /dev/null
+++ b/src/mailman/rest/tests/test_plugins.py
@@ -0,0 +1,105 @@
+# 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/>.
+
+"""Test RESTability of plugins."""
+
+import unittest
+
+from mailman.testing.helpers import call_api
+from mailman.testing.layers import RESTLayer
+from urllib.error import HTTPError
+
+
+class TestAPI31Plugins(unittest.TestCase):
+ layer = RESTLayer
+
+ def test_list_plugins(self):
+ json, response = call_api('http://localhost:9001/3.1/plugins')
+ self.assertEqual(response.status_code, 200)
+ json.pop('http_etag')
+ self.assertEqual(len(json.keys()), 3)
+ self.assertIn('entries', json.keys())
+ entries = json['entries']
+ self.assertEqual(len(entries), 1)
+ plugin_conf = entries[0]
+ self.assertEqual(plugin_conf['name'], 'example')
+ self.assertEqual(plugin_conf['enable'], True)
+ self.assertEqual(plugin_conf['class'],
+ 'mailman.testing.plugin.ExamplePlugin')
+ self.assertEqual(plugin_conf['path'], '')
+ self.assertEqual(plugin_conf['configuration'], '')
+
+ def test_non_existent_plugin(self):
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.1/plugins/does-not-exist')
+ self.assertEqual(cm.exception.code, 404)
+
+ def test_example_plugin(self):
+ json, response = call_api('http://localhost:9001/3.1/plugins/example')
+ self.assertEqual(response.status_code, 200)
+ json.pop('http_etag')
+ self.assertEqual(len(json.keys()), 1)
+ self.assertEqual(json['example-plugin-reply'], 'yes')
+
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.1/plugins/example',
+ method='POST')
+ self.assertEqual(cm.exception.code, 400)
+
+ def test_example_plugin_child(self):
+ json, response = \
+ call_api('http://localhost:9001/3.1/plugins/example/good')
+ self.assertEqual(response.status_code, 200)
+ json.pop('http_etag')
+ self.assertEqual(len(json.keys()), 1)
+ self.assertEqual(json['good'], True)
+
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.1/plugins/example/bad')
+ self.assertEqual(cm.exception.code, 400)
+
+ def test_example_plugin_counter(self):
+ json, response = \
+ call_api('http://localhost:9001/3.1/plugins/example/count')
+ self.assertEqual(response.status_code, 200)
+ json.pop('http_etag')
+ self.assertEqual(len(json.keys()), 1)
+ self.assertEqual(json['count'], 0)
+
+ json, response = \
+ call_api('http://localhost:9001/3.1/plugins/example/count',
+ method='POST', data={'count': 5})
+ self.assertEqual(response.status_code, 204)
+
+ json, response = \
+ call_api('http://localhost:9001/3.1/plugins/example/count')
+ self.assertEqual(response.status_code, 200)
+ json.pop('http_etag')
+ self.assertEqual(len(json.keys()), 1)
+ self.assertEqual(json['count'], 5)
+
+ json, response = \
+ call_api('http://localhost:9001/3.1/plugins/example/count',
+ method='DELETE')
+ self.assertEqual(response.status_code, 204)
+
+ json, response = \
+ call_api('http://localhost:9001/3.1/plugins/example/count')
+ self.assertEqual(response.status_code, 200)
+ json.pop('http_etag')
+ self.assertEqual(len(json.keys()), 1)
+ self.assertEqual(json['count'], 0)
diff --git a/src/mailman/rest/tests/test_systemconf.py b/src/mailman/rest/tests/test_systemconf.py
index f9fe9caa0..59f0619a3 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.plugin',
'logging.root',
'logging.runner',
'logging.smtp',
@@ -182,6 +183,7 @@ class TestSystemConfiguration(unittest.TestCase):
'paths.here',
'paths.local',
'paths.testing',
+ 'plugin.example',
'runner.archive',
'runner.bad',
'runner.bounces',
diff --git a/src/mailman/runners/tests/test_bounce.py b/src/mailman/runners/tests/test_bounce.py
index 69198d881..4a21e0e52 100644
--- a/src/mailman/runners/tests/test_bounce.py
+++ b/src/mailman/runners/tests/test_bounce.py
@@ -227,6 +227,7 @@ class TestStyle:
"""See `IStyle`."""
name = 'test'
+ description = 'A test style.'
def apply(self, mailing_list):
"""See `IStyle`."""
diff --git a/src/mailman/styles/default.py b/src/mailman/styles/default.py
index 01b6764da..97c57a94d 100644
--- a/src/mailman/styles/default.py
+++ b/src/mailman/styles/default.py
@@ -17,6 +17,7 @@
"""Application of list styles to new and existing lists."""
+from mailman.core.i18n import _
from mailman.interfaces.styles import IStyle
from mailman.styles.base import (
Announcement, BasicOperation, Bounces, Discussion, Identity, Moderation,
@@ -33,6 +34,7 @@ class LegacyDefaultStyle(
"""The legacy default style."""
name = 'legacy-default'
+ description = _('Ordinary discussion mailing list style.')
def apply(self, mailing_list):
"""See `IStyle`."""
@@ -52,6 +54,7 @@ class LegacyAnnounceOnly(
"""Similar to the legacy-default style, but for announce-only lists."""
name = 'legacy-announce'
+ description = _('Announce only mailing list style.')
def apply(self, mailing_list):
"""See `IStyle`."""
diff --git a/src/mailman/styles/docs/styles.rst b/src/mailman/styles/docs/styles.rst
index 270b0cdcc..a296bab54 100644
--- a/src/mailman/styles/docs/styles.rst
+++ b/src/mailman/styles/docs/styles.rst
@@ -49,6 +49,7 @@ New styles must implement the ``IStyle`` interface.
>>> @implementer(IStyle)
... class TestStyle:
... name = 'a-test-style'
+ ... description = 'Testing mailing list style.'
... def apply(self, mailing_list):
... # Just does something very simple.
... mailing_list.display_name = 'TEST STYLE LIST'
@@ -115,6 +116,7 @@ If no style name is provided when creating the list, the system default style
>>> @implementer(IStyle)
... class AnotherStyle:
... name = 'another-style'
+ ... description = 'Another testing mailing list style.'
... def apply(self, mailing_list):
... # Just does something very simple.
... mailing_list.display_name = 'ANOTHER STYLE LIST'
diff --git a/src/mailman/styles/manager.py b/src/mailman/styles/manager.py
index 1ec83b2a9..1969d4484 100644
--- a/src/mailman/styles/manager.py
+++ b/src/mailman/styles/manager.py
@@ -20,7 +20,7 @@
from mailman.interfaces.configuration import ConfigurationUpdatedEvent
from mailman.interfaces.styles import (
DuplicateStyleError, IStyle, IStyleManager)
-from mailman.utilities.modules import add_components
+from mailman.utilities.plugins import add_pluggable_components
from public import public
from zope.component import getUtility
from zope.interface import implementer
@@ -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_pluggable_components('styles', IStyle, self._styles)
def get(self, name):
"""See `IStyleManager`."""
diff --git a/src/mailman/styles/tests/test_styles.py b/src/mailman/styles/tests/test_styles.py
index a763082bf..a09967eb6 100644
--- a/src/mailman/styles/tests/test_styles.py
+++ b/src/mailman/styles/tests/test_styles.py
@@ -31,14 +31,11 @@ from zope.interface.exceptions import DoesNotImplement
class DummyStyle:
name = 'dummy'
- priority = 1
+ description = 'A dummy style.'
def apply(self, mlist):
pass
- def match(self, mlist, styles):
- styles.append(self)
-
class TestStyle(unittest.TestCase):
"""Test styles."""
diff --git a/src/mailman/testing/documentation.py b/src/mailman/testing/documentation.py
index d460be89f..421a272f4 100644
--- a/src/mailman/testing/documentation.py
+++ b/src/mailman/testing/documentation.py
@@ -109,6 +109,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 +147,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))
diff --git a/src/mailman/testing/plugin.py b/src/mailman/testing/plugin.py
new file mode 100644
index 000000000..a8b001406
--- /dev/null
+++ b/src/mailman/testing/plugin.py
@@ -0,0 +1,101 @@
+# Copyright (C) 2009-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/>.
+
+"""Example plugin with hooks and REST for testing purposes."""
+
+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 GoodREST:
+
+ def on_get(self, request, response):
+ okay(response, etag({'good': True}))
+
+
+@public
+class BadREST:
+
+ def on_get(self, request, response):
+ bad_request(response, etag({'good': False}))
+
+
+@public
+class CountingREST:
+
+ def __init__(self):
+ self._plugin = config.plugins['example']
+
+ def on_get(self, request, response):
+ okay(response, etag({'count': self._plugin.counter}))
+
+ def on_post(self, request, response):
+ try:
+ counter = Validator(count=int)(request)
+ self._plugin.counter = counter['count']
+ except ValueError as error:
+ bad_request(response, str(error))
+ else:
+ no_content(response)
+
+ def on_delete(self, request, response):
+ self._plugin.counter = 0
+ no_content(response)
+
+
+@public
+class RESTExample:
+
+ def on_get(self, request, response):
+ okay(response, etag({'example-plugin-reply': 'yes'}))
+
+ def on_post(self, request, response):
+ bad_request(response)
+
+ @child()
+ def good(self, context, segments):
+ return GoodREST(), []
+
+ @child()
+ def bad(self, context, segments):
+ return BadREST(), []
+
+ @child()
+ def count(self, context, segments):
+ return CountingREST(), []
+
+
+@public
+@implementer(IPlugin)
+class ExamplePlugin:
+
+ def __init__(self):
+ self.counter = 0
+
+ def pre_hook(self):
+ pass
+
+ def post_hook(self):
+ pass
+
+ def rest_object(self):
+ return RESTExample()
diff --git a/src/mailman/testing/testing.cfg b/src/mailman/testing/testing.cfg
index 94b4c07b9..9e17015ef 100644
--- a/src/mailman/testing/testing.cfg
+++ b/src/mailman/testing/testing.cfg
@@ -28,6 +28,10 @@ incoming: mailman.testing.mta.FakeMTA
[passwords]
configuration: python:mailman.testing.passlib
+[plugin.example]
+enable: yes
+class: mailman.testing.plugin.ExamplePlugin
+
[webservice]
port: 9001
diff --git a/src/mailman/utilities/plugins.py b/src/mailman/utilities/plugins.py
new file mode 100644
index 000000000..1a8c6d461
--- /dev/null
+++ b/src/mailman/utilities/plugins.py
@@ -0,0 +1,77 @@
+# Copyright (C) 2009-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/>.
+
+"""Plugin utilities."""
+
+from lazr.config import as_boolean
+from mailman.config import config
+from mailman.utilities.modules import find_components
+from pkg_resources import resource_isdir
+from public import public
+
+
+@public
+def find_pluggable_components(subpackage, interface):
+ """Find components which conform to a given interface, in subpackage.
+
+ :param subpackage: Mailman subpackage to search. The search is also
+ done inside the corresponding plugins subpackages.
+ :type subpackage: string
+ :param interface: The interface that returned objects must conform to.
+ :type interface: `Interface`
+ :return: The sequence of matching components.
+ :rtype: objects implementing `interface`
+ """
+ yield from find_components('mailman.' + subpackage, interface)
+ package_roots = [plugin.path
+ for name, plugin in config.plugin_configs
+ if as_boolean(plugin.enable) and plugin.path]
+ for package_root in package_roots:
+ package = package_root + '.' + subpackage
+ if resource_isdir(package_root, subpackage):
+ yield from find_components(package, interface)
+
+
+@public
+def add_pluggable_components(subpackage, interface, mapping):
+ """Add components to a given mapping.
+
+ Similarly to `find_pluggable_components()` this inspects all modules in a
+ given mailman and plugin's subpackage 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.
+
+ :param subpackage: The subpackage path to search.
+ :type subpackage: string
+ :param interface: The interface that returned objects must conform to.
+ Objects found must have a `.name` attribute containing a unique
+ string.
+ :type interface: `Interface`
+ :param mapping: The mapping to add the found components to.
+ :type mapping: A dict-like mapping. This only needs to support
+ containment tests (e.g. `in` and `not in`) and `__setitem__()`.
+ :raises RuntimeError: when a duplicate key is found.
+ """
+ for component_class in find_pluggable_components(subpackage, interface):
+ component = component_class()
+ if component.name in mapping:
+ raise RuntimeError( # pragma: nocover
+ 'Duplicate key "{}" found in {}; previously {}'.format(
+ component.name, component, mapping[component.name]))
+ mapping[component.name] = component