From f00b94f18e1d82d1488cbcee6053f03423bc2f49 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Sat, 22 Jul 2017 03:02:05 +0000 Subject: Convert to click for CLI options --- src/mailman/__init__.py | 4 +- src/mailman/app/docs/hooks.rst | 7 +- src/mailman/app/subscriptions.py | 2 +- src/mailman/app/workflow.py | 2 +- src/mailman/bin/mailman.py | 142 +- src/mailman/bin/master.py | 224 ++- src/mailman/bin/runner.py | 206 +- src/mailman/bin/tests/test_mailman.py | 62 +- src/mailman/bin/tests/test_master.py | 74 +- src/mailman/commands/cli_aliases.py | 31 +- src/mailman/commands/cli_conf.py | 174 +- src/mailman/commands/cli_control.py | 276 ++- src/mailman/commands/cli_digests.py | 141 +- src/mailman/commands/cli_help.py | 26 +- src/mailman/commands/cli_import.py | 111 +- src/mailman/commands/cli_info.py | 91 +- src/mailman/commands/cli_inject.py | 135 +- src/mailman/commands/cli_lists.py | 415 ++-- src/mailman/commands/cli_members.py | 349 ++-- src/mailman/commands/cli_qfile.py | 100 +- src/mailman/commands/cli_status.py | 55 +- src/mailman/commands/cli_unshunt.py | 59 +- src/mailman/commands/cli_version.py | 23 +- src/mailman/commands/cli_withlist.py | 410 ++-- src/mailman/commands/docs/aliases.rst | 38 +- src/mailman/commands/docs/conf.rst | 20 +- src/mailman/commands/docs/control.rst | 53 +- src/mailman/commands/docs/create.rst | 131 +- src/mailman/commands/docs/import.rst | 59 +- src/mailman/commands/docs/info.rst | 17 +- src/mailman/commands/docs/inject.rst | 126 +- src/mailman/commands/docs/lists.rst | 50 +- src/mailman/commands/docs/members.rst | 166 +- src/mailman/commands/docs/qfile.rst | 20 +- src/mailman/commands/docs/remove.rst | 33 +- src/mailman/commands/docs/shell.rst | 141 ++ src/mailman/commands/docs/status.rst | 17 +- src/mailman/commands/docs/unshunt.rst | 15 +- src/mailman/commands/docs/version.rst | 9 +- src/mailman/commands/docs/withlist.rst | 161 -- src/mailman/commands/tests/data/__init__.py | 0 src/mailman/commands/tests/data/no-runners.cfg | 51 + src/mailman/commands/tests/test_cli_conf.py | 74 + src/mailman/commands/tests/test_cli_control.py | 295 +++ src/mailman/commands/tests/test_cli_create.py | 116 ++ src/mailman/commands/tests/test_cli_digests.py | 399 ++++ src/mailman/commands/tests/test_cli_import.py | 76 + src/mailman/commands/tests/test_cli_inject.py | 66 + src/mailman/commands/tests/test_cli_lists.py | 49 + src/mailman/commands/tests/test_cli_members.py | 112 ++ src/mailman/commands/tests/test_cli_qfile.py | 59 + src/mailman/commands/tests/test_cli_shell.py | 173 ++ src/mailman/commands/tests/test_cli_status.py | 64 + src/mailman/commands/tests/test_cli_unshunt.py | 45 + src/mailman/commands/tests/test_conf.py | 108 - src/mailman/commands/tests/test_confirm.py | 175 -- src/mailman/commands/tests/test_control.py | 244 --- src/mailman/commands/tests/test_create.py | 110 - src/mailman/commands/tests/test_digests.py | 451 ----- src/mailman/commands/tests/test_eml_confirm.py | 175 ++ src/mailman/commands/tests/test_eml_help.py | 62 + src/mailman/commands/tests/test_eml_membership.py | 54 + src/mailman/commands/tests/test_help.py | 62 - src/mailman/commands/tests/test_import.py | 54 - src/mailman/commands/tests/test_lists.py | 61 - src/mailman/commands/tests/test_members.py | 146 -- src/mailman/commands/tests/test_membership.py | 54 - src/mailman/commands/tests/test_shell.py | 81 - src/mailman/config/config.py | 1 + src/mailman/core/runner.py | 8 +- src/mailman/database/base.py | 2 +- src/mailman/database/tests/test_migrations.py | 21 +- src/mailman/docs/NEWS.rst | 2228 +++++++++++---------- src/mailman/docs/contribute.rst | 1 - src/mailman/interfaces/command.py | 32 +- src/mailman/rest/docs/__init__.py | 6 +- src/mailman/rest/listconf.py | 4 +- src/mailman/rules/docs/dmarc-mitigation.rst | 2 +- src/mailman/testing/documentation.py | 27 + src/mailman/testing/helpers.py | 5 +- src/mailman/utilities/i18n.py | 2 +- src/mailman/utilities/modules.py | 24 +- src/mailman/utilities/options.py | 156 +- src/mailman/utilities/tests/test_modules.py | 15 +- src/mailman/utilities/tests/test_options.py | 50 + 85 files changed, 5143 insertions(+), 5032 deletions(-) create mode 100644 src/mailman/commands/docs/shell.rst delete mode 100644 src/mailman/commands/docs/withlist.rst create mode 100644 src/mailman/commands/tests/data/__init__.py create mode 100644 src/mailman/commands/tests/data/no-runners.cfg create mode 100644 src/mailman/commands/tests/test_cli_conf.py create mode 100644 src/mailman/commands/tests/test_cli_control.py create mode 100644 src/mailman/commands/tests/test_cli_create.py create mode 100644 src/mailman/commands/tests/test_cli_digests.py create mode 100644 src/mailman/commands/tests/test_cli_import.py create mode 100644 src/mailman/commands/tests/test_cli_inject.py create mode 100644 src/mailman/commands/tests/test_cli_lists.py create mode 100644 src/mailman/commands/tests/test_cli_members.py create mode 100644 src/mailman/commands/tests/test_cli_qfile.py create mode 100644 src/mailman/commands/tests/test_cli_shell.py create mode 100644 src/mailman/commands/tests/test_cli_status.py create mode 100644 src/mailman/commands/tests/test_cli_unshunt.py delete mode 100644 src/mailman/commands/tests/test_conf.py delete mode 100644 src/mailman/commands/tests/test_confirm.py delete mode 100644 src/mailman/commands/tests/test_control.py delete mode 100644 src/mailman/commands/tests/test_create.py delete mode 100644 src/mailman/commands/tests/test_digests.py create mode 100644 src/mailman/commands/tests/test_eml_confirm.py create mode 100644 src/mailman/commands/tests/test_eml_help.py create mode 100644 src/mailman/commands/tests/test_eml_membership.py delete mode 100644 src/mailman/commands/tests/test_help.py delete mode 100644 src/mailman/commands/tests/test_import.py delete mode 100644 src/mailman/commands/tests/test_lists.py delete mode 100644 src/mailman/commands/tests/test_members.py delete mode 100644 src/mailman/commands/tests/test_membership.py delete mode 100644 src/mailman/commands/tests/test_shell.py create mode 100644 src/mailman/utilities/tests/test_options.py (limited to 'src') diff --git a/src/mailman/__init__.py b/src/mailman/__init__.py index 194350fd4..0fec711bc 100644 --- a/src/mailman/__init__.py +++ b/src/mailman/__init__.py @@ -24,7 +24,7 @@ import sys try: import pkg_resources pkg_resources.declare_namespace(__name__) -except ImportError: # pragma: no cover +except ImportError: # pragma: nocover import pkgutil __path__ = pkgutil.extend_path(__path__, __name__) @@ -35,7 +35,7 @@ except ImportError: # pragma: no cover # imported. # # Do *not* do this if we're building the documentation. -if 'build_sphinx' not in sys.argv: # pragma: no cover +if 'build_sphinx' not in sys.argv: # pragma: nocover if any('nose2' in arg for arg in sys.argv): from mailman.testing.i18n import initialize else: diff --git a/src/mailman/app/docs/hooks.rst b/src/mailman/app/docs/hooks.rst index ba9bb249e..07d0ec33c 100644 --- a/src/mailman/app/docs/hooks.rst +++ b/src/mailman/app/docs/hooks.rst @@ -53,8 +53,11 @@ script that will produce no output to force the hooks to run. >>> from mailman.testing.layers import ConfigLayer >>> def call(): ... exe = os.path.join(os.path.dirname(sys.executable), 'mailman') - ... env = dict(MAILMAN_CONFIG_FILE=config_path, - ... PYTHONPATH=config_directory) + ... 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 diff --git a/src/mailman/app/subscriptions.py b/src/mailman/app/subscriptions.py index 0d1e979f5..0d8176ffb 100644 --- a/src/mailman/app/subscriptions.py +++ b/src/mailman/app/subscriptions.py @@ -211,7 +211,7 @@ class SubscriptionWorkflow(_SubscriptionWorkflowCommon): # verified by a full coverage run, but diffcov for some reason # claims that the test added in the branch that added this code # does not cover the change. That seems like a bug in diffcov. - raise AlreadySubscribedError( # pragma: no cover + raise AlreadySubscribedError( # pragma: nocover self.mlist.fqdn_listname, self.address.email, MemberRole.member) diff --git a/src/mailman/app/workflow.py b/src/mailman/app/workflow.py index cd8909c1a..92b78c184 100644 --- a/src/mailman/app/workflow.py +++ b/src/mailman/app/workflow.py @@ -59,7 +59,7 @@ class Workflow: name = self._next.popleft() step = getattr(self, '_step_{}'.format(name)) self._count += 1 - if self.debug: # pragma: no cover + if self.debug: # pragma: nocover print('[{:02d}] -> {}'.format(self._count, name), file=sys.stderr) return name, step diff --git a/src/mailman/bin/mailman.py b/src/mailman/bin/mailman.py index 62066175e..e012237f3 100644 --- a/src/mailman/bin/mailman.py +++ b/src/mailman/bin/mailman.py @@ -17,81 +17,93 @@ """The 'mailman' command dispatcher.""" -import os -import argparse +import click -from functools import cmp_to_key +from contextlib import ExitStack +from mailman.config import config 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 find_components +from mailman.utilities.modules import add_components from mailman.version import MAILMAN_VERSION_FULL from public import public -# --help should display the subcommands by alphabetical order, except that -# 'mailman help' should be first. -def _help_sorter(command, other): - """Sorting helper.""" - if command.name == 'help': - return -1 - elif other.name == 'help': - return 1 - elif command.name < other.name: - return -1 - elif command.name == other.name: - return 0 - else: - assert command.name > other.name - return 1 +class Subcommands(click.MultiCommand): + # Handle dynamic listing and loading of `mailman` subcommands. + 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) + def list_commands(self, ctx): + return sorted(self._commands) # pragma: nocover + def get_command(self, ctx, name): + try: + return self._commands[name].command + except KeyError as error: + # Returning None here signals click to report usage information + # and a "No such command" error message. + return None + + # This is here to hook command parsing into the Mailman database + # transaction system. If the subcommand succeeds, the transaction is + # committed, otherwise it's aborted. + def invoke(self, ctx): + with ExitStack() as resources: + # If given a bogus subcommand, the database won't have been + # initialized so there's no transaction to commit. + if config.db is not None: + resources.enter_context(transaction()) + return super().invoke(ctx) + + # https://github.com/pallets/click/issues/834 + # + # Note that this only handles the case for the `mailman --help` output. + # To handle `mailman --help` we create a custom click.Command + # subclass and override this method there too. See + # src/mailman/utilities/options.py + def format_options(self, ctx, formatter): + """Writes all the options into the formatter if they exist.""" + opts = [] + for param in self.get_params(ctx): + rv = param.get_help_record(ctx) + if rv is not None: + part_a, part_b = rv + opts.append((part_a, part_b.replace('\n', ' '))) + if opts: + with formatter.section('Options'): + formatter.write_dl(opts) + + +@click.group( + cls=Subcommands, + context_settings=dict(help_option_names=['-h', '--help'])) +@click.pass_context +@click.option( + '-C', '--config', 'config_file', + envvar='MAILMAN_CONFIG_FILE', + type=click.Path(exists=True, dir_okay=False, resolve_path=True), + 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.""")) +@click.version_option(MAILMAN_VERSION_FULL, message='%(version)s') @public -def main(): - """The `mailman` command dispatcher.""" - # Create the basic parser and add all globally common options. - parser = argparse.ArgumentParser( - description=_("""\ - The GNU Mailman mailing list management system - Copyright 1998-2017 by the Free Software Foundation, Inc. - http://www.list.org - """), - formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument( - '-v', '--version', - action='version', version=MAILMAN_VERSION_FULL, - help=_('Print this version string and exit')) - parser.add_argument( - '-C', '--config', - 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.""")) - # 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. - subparser = parser.add_subparsers(title='Commands') - subcommands = [] - for command in find_components('mailman.commands', ICLISubCommand): - subcommands.append(command) - subcommands.sort(key=cmp_to_key(_help_sorter)) - for command in subcommands: - command_parser = subparser.add_parser( - command.name, help=_(command.__doc__)) - command.add(parser, command_parser) - command_parser.set_defaults(func=command.process) - args = parser.parse_args() - if len(args.__dict__) <= 1: - # No arguments or subcommands were given. - parser.print_help() - parser.exit() +def main(ctx, config_file): + # XXX https://github.com/pallets/click/issues/303 + """\ + The GNU Mailman mailing list management system + Copyright 1998-2017 by the Free Software Foundation, Inc. + http://www.list.org + """ # Initialize the system. Honor the -C flag if given. - config_path = (None if args.config is None - else os.path.abspath(os.path.expanduser(args.config))) - initialize(config_path) - # Perform the subcommand option. - with transaction(): - args.func(args) + 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 c6a8bcd2d..0b273d332 100644 --- a/src/mailman/bin/master.py +++ b/src/mailman/bin/master.py @@ -19,7 +19,7 @@ import os import sys -import errno +import click import signal import socket import logging @@ -30,8 +30,10 @@ from flufl.lock import Lock, NotLockedError, TimeOutError from lazr.config import as_boolean from mailman.config import config from mailman.core.i18n import _ +from mailman.core.initialize import initialize from mailman.core.logging import reopen -from mailman.utilities.options import Options +from mailman.utilities.options import I18nCommand, validate_runner_spec +from mailman.version import MAILMAN_VERSION_FULL from public import public @@ -44,67 +46,25 @@ SUBPROC_START_WAIT = timedelta(seconds=20) PRESERVE_ENVS = ( 'COVERAGE_PROCESS_START', 'MAILMAN_EXTRA_TESTING_CFG', + 'LANG', + 'LANGUAGE', + 'LC_CTYPE', + 'LC_NUMERIC', + 'LC_TIME', + 'LC_COLLATE', + 'LC_MONETARY', + 'LC_MESSAGES', + 'LC_PAPER', + 'LC_NAME', + 'LC_ADDRESS', + 'LC_TELEPHONE', + 'LC_MEASUREMENT', + 'LC_IDENTIFICATION', + 'LC_ALL', ) -class MasterOptions(Options): - """Options for the master watcher.""" - - usage = _("""\ -%prog [options] - -Master subprocess watcher. - -Start and watch the configured runners and ensure that they stay alive and -kicking. Each runner is forked and exec'd in turn, with the master waiting on -their process ids. When it detects a child runner has exited, it may restart -it. - -The runners respond to SIGINT, SIGTERM, SIGUSR1 and SIGHUP. SIGINT, SIGTERM -and SIGUSR1 all cause a runner to exit cleanly. The master will restart -runners that have exited due to a SIGUSR1 or some kind of other exit condition -(say because of an uncaught exception). SIGHUP causes the master and the -runners to close their log files, and reopen then upon the next printed -message. - -The master also responds to SIGINT, SIGTERM, SIGUSR1 and SIGHUP, which it -simply passes on to the runners. Note that the master will close and reopen -its own log files on receipt of a SIGHUP. The master also leaves its own -process id in the file `data/master.pid` but you normally don't need to use -this pid directly.""") - - def add_options(self): - """See `Options`.""" - self.parser.add_option( - '-n', '--no-restart', - dest='restartable', default=True, action='store_false', - help=_("""\ -Don't restart the runners when they exit because of an error or a SIGUSR1. -Use this only for debugging.""")) - self.parser.add_option( - '-f', '--force', - default=False, action='store_true', - help=_("""\ -If the master watcher finds an existing master lock, it will normally exit -with an error message. With this option,the master will perform an extra -level of checking. If a process matching the host/pid described in the lock -file is running, the master will still exit, requiring you to manually clean -up the lock. But if no matching process is found, the master will remove the -apparently stale lock and make another attempt to claim the master lock.""")) - self.parser.add_option( - '-r', '--runner', - dest='runners', action='append', default=[], - help=_("""\ -Override the default set of runners that the master will invoke, which is -typically defined in the configuration file. Multiple -r options may be -given. The values for -r are passed straight through to bin/runner.""")) - - def sanity_check(self): - """See `Options`.""" - if len(self.arguments) > 0: - self.parser.error(_('Too many arguments')) - - +@public class WatcherState(Enum): """Enum for the state of the master process watcher.""" # No lock has been acquired by any process. @@ -117,6 +77,7 @@ class WatcherState(Enum): host_mismatch = 3 +@public def master_state(lock_file=None): """Get the state of the master watcher. @@ -139,12 +100,9 @@ def master_state(lock_file=None): try: os.kill(pid, 0) return WatcherState.conflict, lock - except OSError as error: - if error.errno == errno.ESRCH: - # No matching process id. - return WatcherState.stale_lock, lock - # Some other error occurred. - raise + except ProcessLookupError: + # No matching process id. + return WatcherState.stale_lock, lock def acquire_lock_1(force, lock_file=None): @@ -211,17 +169,17 @@ Lock host: $hostname Exiting.""") else: assert status is WatcherState.none, ( - 'Invalid enum value: ${0}'.format(status)) + 'Invalid enum value: {}'.format(status)) hostname, pid, tempfile = lock.details message = _("""\ For unknown reasons, the master lock could not be acquired. - Lock file: $config.LOCK_FILE Lock host: $hostname Exiting.""") - config.options.parser.error(message) + print(message, file=sys.stderr) + sys.exit(1) class PIDWatcher: @@ -438,15 +396,12 @@ class Loop: while True: try: pid, status = os.wait() - except OSError as error: + except ChildProcessError: # No children? We're done. - if error.errno == errno.ECHILD: - break + break + except InterruptedError: # pragma: nocover # If the system call got interrupted, just restart it. - elif error.errno == errno.EINTR: - continue - else: - raise + continue # Find out why the subprocess exited by getting the signal # received or exit status. if os.WIFSIGNALED(status): @@ -497,40 +452,121 @@ Runner {0} reached maximum restart limit of {1:d}, not restarting.""", for pid in self._kids: try: os.kill(pid, signal.SIGTERM) - except OSError as error: - if error.errno == errno.ESRCH: - # The child has already exited. - log.info('ESRCH on pid: %d', pid) + except ProcessLookupError: # pragma: nocover + # The child has already exited. + log.info('ESRCH on pid: %d', pid) + except OSError: # pragma: nocover + # XXX I'm not so sure about this. It preserves the semantics + # before conversion to PEP 3151 exceptions. But is it right? + pass # Wait for all the children to go away. while self._kids: try: pid, status = os.wait() self._kids.drop(pid) - except OSError as error: - if error.errno == errno.ECHILD: - break - elif error.errno == errno.EINTR: - continue - raise + except ChildProcessError: + break + except InterruptedError: # pragma: nocover + continue +@click.command( + cls=I18nCommand, + context_settings=dict(help_option_names=['-h', '--help']), + help=_("""\ + Master subprocess watcher. + + Start and watch the configured runners, ensuring that they stay alive and + kicking. Each runner is forked and exec'd in turn, with the master waiting + on their process ids. When it detects a child runner has exited, it may + restart it. + + The runners respond to SIGINT, SIGTERM, SIGUSR1 and SIGHUP. SIGINT, + SIGTERM and SIGUSR1 all cause a runner to exit cleanly. The master will + restart runners that have exited due to a SIGUSR1 or some kind of other + exit condition (say because of an uncaught exception). SIGHUP causes the + master and the runners to close their log files, and reopen then upon the + next printed message. + + The master also responds to SIGINT, SIGTERM, SIGUSR1 and SIGHUP, which it + simply passes on to the runners. Note that the master will close and + reopen its own log files on receipt of a SIGHUP. The master also leaves + its own process id in the file specified in the configuration file but you + normally don't need to use this PID directly.""")) +@click.option( + '-C', '--config', 'config_file', + envvar='MAILMAN_CONFIG_FILE', + type=click.Path(exists=True, dir_okay=False, resolve_path=True), + 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.""")) +@click.option( + '--no-restart', '-n', 'restartable', + is_flag=True, default=True, + help=_("""\ + Don't restart the runners when they exit because of an error or a SIGUSR1. + Use this only for debugging.""")) +@click.option( + '--force', '-f', + is_flag=True, default=False, + help=_("""\ + If the master watcher finds an existing master lock, it will normally exit + with an error message. With this option,the master will perform an extra + level of checking. If a process matching the host/pid described in the + lock file is running, the master will still exit, requiring you to manually + clean up the lock. But if no matching process is found, the master will + remove the apparently stale lock and make another attempt to claim the + master lock.""")) +@click.option( + '--runners', '-r', + metavar='runner[:slice:range]', + callback=validate_runner_spec, default=None, + multiple=True, + help=_("""\ + Override the default set of runners that the master will invoke, which is + typically defined in the configuration file. Multiple -r options may be + given. The values for -r are passed straight through to bin/runner.""")) +@click.option( + '-v', '--verbose', + is_flag=True, default=False, + help=_('Display more debugging information to the log file.')) +@click.version_option(MAILMAN_VERSION_FULL) @public -def main(): - """Main process.""" - - options = MasterOptions() - options.initialize() +def main(config_file, restartable, force, runners, verbose): + # XXX https://github.com/pallets/click/issues/303 + """Master subprocess watcher. + + Start and watch the configured runners and ensure that they stay + alive and kicking. Each runner is forked and exec'd in turn, with + the master waiting on their process ids. When it detects a child + runner has exited, it may restart it. + + The runners respond to SIGINT, SIGTERM, SIGUSR1 and SIGHUP. SIGINT, + SIGTERM and SIGUSR1 all cause a runner to exit cleanly. The master + will restart runners that have exited due to a SIGUSR1 or some kind + of other exit condition (say because of an uncaught exception). + SIGHUP causes the master and the runners to close their log files, + and reopen then upon the next printed message. + + The master also responds to SIGINT, SIGTERM, SIGUSR1 and SIGHUP, + which it simply passes on to the runners. Note that the master will + close and reopen its own log files on receipt of a SIGHUP. The + master also leaves its own process id in the file `data/master.pid` + but you normally don't need to use this pid directly. + """ + initialize(config_file, verbose) # Acquire the master lock, exiting if we can't. We'll let the caller # handle any clean up or lock breaking. No `with` statement here because # Lock's constructor doesn't support a timeout. - lock = acquire_lock(options.options.force) + lock = acquire_lock(force) try: with open(config.PID_FILE, 'w') as fp: print(os.getpid(), file=fp) - loop = Loop(lock, options.options.restartable, options.options.config) + loop = Loop(lock, restartable, config.filename) loop.install_signal_handlers() try: - loop.start_runners(options.options.runners) + loop.start_runners(runners) loop.loop() finally: loop.cleanup() diff --git a/src/mailman/bin/runner.py b/src/mailman/bin/runner.py index 29b84034a..bf84bc3e5 100644 --- a/src/mailman/bin/runner.py +++ b/src/mailman/bin/runner.py @@ -19,15 +19,16 @@ import os import sys +import click import signal import logging -import argparse import traceback from mailman.config import config from mailman.core.i18n import _ from mailman.core.initialize import initialize from mailman.utilities.modules import find_name +from mailman.utilities.options import I18nCommand, validate_runner_spec from mailman.version import MAILMAN_VERSION_FULL from public import public @@ -41,44 +42,19 @@ if os.environ.get('COVERAGE_PROCESS_START') is not None: coverage.process_startup() -class ROptionAction(argparse.Action): - """Callback for -r/--runner option.""" - def __call__(self, parser, namespace, values, option_string=None): - parts = values.split(':') - if len(parts) == 1: - runner = parts[0] - rslice = rrange = 1 - elif len(parts) == 3: - runner = parts[0] - try: - rslice = int(parts[1]) - rrange = int(parts[2]) - except ValueError: - parser.error(_('Bad runner specification: $value')) - else: - parser.error(_('Bad runner specification: $value')) - setattr(namespace, self.dest, (runner, rslice, rrange)) - - def make_runner(name, slice, range, once=False): - # Several conventions for specifying the runner name are supported. It - # could be one of the shortcut names. If the name is a full module path, - # use it explicitly. If the name starts with a dot, it's a class name - # relative to the Mailman.runner package. + # The runner name must be defined in the configuration. Only runner short + # names are supported. runner_config = getattr(config, 'runner.' + name, None) - if runner_config is not None: - # It was a shortcut name. - class_path = runner_config['class'] - elif name.startswith('.'): - class_path = 'mailman.runners' + name - else: - class_path = name + if runner_config is None: + print(_('Undefined runner name: $name'), file=sys.stderr) + # Exit with SIGTERM exit code so the master won't try to restart us. + sys.exit(signal.SIGTERM) + class_path = runner_config['class'] try: runner_class = find_name(class_path) except ImportError: if os.environ.get('MAILMAN_UNDER_MASTER_CONTROL') is not None: - # Exit with SIGTERM exit code so the master watcher won't try to - # restart us. print(_('Cannot import runner module: $class_path'), file=sys.stderr) traceback.print_exc() @@ -94,82 +70,98 @@ def make_runner(name, slice, range, once=False): return runner_class(name, slice) +@click.command( + cls=I18nCommand, + context_settings=dict(help_option_names=['-h', '--help']), + help=_("""\ + Start a runner. + + The runner named on the command line is started, and it can either run + through its main loop once (for those runners that support this) or + continuously. The latter is how the master runner starts all its + subprocesses. + + -r is required unless -l or -h is given, and its argument must be one of + the names displayed by the -l switch. + + Normally, this script should be started from `mailman start`. Running it + separately or with -o is generally useful only for debugging. When run + this way, the environment variable $MAILMAN_UNDER_MASTER_CONTROL will be + set which subtly changes some error handling behavior. + """)) +@click.option( + '-C', '--config', 'config_file', + envvar='MAILMAN_CONFIG_FILE', + type=click.Path(exists=True, dir_okay=False, resolve_path=True), + 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.""")) +@click.option( + '-l', '--list', 'list_runners', + is_flag=True, is_eager=True, default=False, + help=_('List the available runner names and exit.')) +@click.option( + '-o', '--once', + is_flag=True, default=False, + help=_("""\ + Run the named runner exactly once through its main loop. Otherwise, the + runner runs indefinitely until the process receives a signal. This is not + compatible with runners that cannot be run once.""")) +@click.option( + '-r', '--runner', 'runner_spec', + metavar='runner[:slice:range]', + callback=validate_runner_spec, default=None, + help=_("""\ + + Start the named runner, which must be one of the strings returned by the -l + option. + + For runners that manage a queue directory, optional `slice:range` if given + is used to assign multiple runner processes to that queue. range is the + total number of runners for the queue while slice is the number of this + runner from [0..range). For runners that do not manage a queue, slice and + range are ignored. + + When using the `slice:range` form, you must ensure that each runner for the + queue is given the same range value. If `slice:runner` is not given, then + 1:1 is used. + """)) +@click.option( + '-v', '--verbose', + is_flag=True, default=False, + help=_('Display more debugging information to the log file.')) +@click.version_option(MAILMAN_VERSION_FULL) +@click.pass_context @public -def main(): +def main(ctx, config_file, verbose, list_runners, once, runner_spec): + # XXX https://github.com/pallets/click/issues/303 + """Start a runner. + + The runner named on the command line is started, and it can either run + through its main loop once (for those runners that support this) or + continuously. The latter is how the master runner starts all its + subprocesses. + + -r is required unless -l or -h is given, and its argument must be one + of the names displayed by the -l switch. + + Normally, this script should be started from 'mailman start'. Running + it separately or with -o is generally useful only for debugging. When + run this way, the environment variable $MAILMAN_UNDER_MASTER_CONTROL + will be set which subtly changes some error handling behavior. + """ + global log - parser = argparse.ArgumentParser( - description=_("""\ - Start a runner - - The runner named on the command line is started, and it can either run - through its main loop once (for those runners that support this) or - continuously. The latter is how the master runner starts all its - subprocesses. - - -r is required unless -l or -h is given, and its argument must be one - of the names displayed by the -l switch. - - Normally, this script should be started from 'mailman start'. Running - it separately or with -o is generally useful only for debugging. When - run this way, the environment variable $MAILMAN_UNDER_MASTER_CONTROL - will be set which subtly changes some error handling behavior. - """)) - parser.add_argument( - '--version', - action='version', version=MAILMAN_VERSION_FULL, - help=_('Print this version string and exit')) - parser.add_argument( - '-C', '--config', - 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.""")) - parser.add_argument( - '-r', '--runner', - metavar='runner[:slice:range]', dest='runner', - action=ROptionAction, default=None, - help=_("""\ - Start the named runner, which must be one of the strings - returned by the -l option. - - For runners that manage a queue directory, optional - `slice:range` if given is used to assign multiple runner - processes to that queue. range is the total number of runners - for the queue while slice is the number of this runner from - [0..range). For runners that do not manage a queue, slice and - range are ignored. - - When using the `slice:range` form, you must ensure that each - runner for the queue is given the same range value. If - `slice:runner` is not given, then 1:1 is used. - """)) - parser.add_argument( - '-o', '--once', - default=False, action='store_true', help=_("""\ - Run the named runner exactly once through its main loop. - Otherwise, the runner runs indefinitely until the process - receives a signal. This is not compatible with runners that - cannot be run once.""")) - parser.add_argument( - '-l', '--list', - default=False, action='store_true', - help=_('List the available runner names and exit.')) - parser.add_argument( - '-v', '--verbose', - default=None, action='store_true', help=_("""\ - Display more debugging information to the log file.""")) - - args = parser.parse_args() - if args.runner is None and not args.list: - parser.error(_('No runner name given.')) + if runner_spec is None and not list_runners: + ctx.fail(_('No runner name given.')) # Initialize the system. Honor the -C flag if given. - config_path = (None if args.config is None - else os.path.abspath(os.path.expanduser(args.config))) - initialize(config_path, args.verbose) + + initialize(config_file, verbose) log = logging.getLogger('mailman.runner') - if args.verbose: + if verbose: console = logging.StreamHandler(sys.stderr) formatter = logging.Formatter(config.logging.root.format, config.logging.root.datefmt) @@ -177,7 +169,7 @@ def main(): logging.getLogger().addHandler(console) logging.getLogger().setLevel(logging.DEBUG) - if args.list: + if list_runners: descriptions = {} for section in config.runner_configs: ignore, dot, shortname = section.name.rpartition('.') @@ -191,10 +183,10 @@ def main(): print(_('$name runs $classname')) sys.exit(0) - runner = make_runner(*args.runner, once=args.once) + runner = make_runner(*runner_spec, once=once) runner.set_signals() # Now start up the main loop - log.info('%s runner started.', runner.name) + log.info('%s runner started.'.format(runner.name)) runner.run() - log.info('%s runner exiting.', runner.name) + log.info('%s runner exiting.'.format(runner.name)) sys.exit(runner.status) diff --git a/src/mailman/bin/tests/test_mailman.py b/src/mailman/bin/tests/test_mailman.py index 49a64fb5c..54ee54bce 100644 --- a/src/mailman/bin/tests/test_mailman.py +++ b/src/mailman/bin/tests/test_mailman.py @@ -19,9 +19,8 @@ import unittest -from contextlib import ExitStack +from click.testing import CliRunner from datetime import timedelta -from io import StringIO from mailman.app.lifecycle import create_list from mailman.bin.mailman import main from mailman.config import config @@ -34,17 +33,31 @@ from unittest.mock import patch class TestMailmanCommand(unittest.TestCase): layer = ConfigLayer + def setUp(self): + self._command = CliRunner() + def test_mailman_command_without_subcommand_prints_help(self): # Issue #137: Running `mailman` without a subcommand raises an # AttributeError. - testargs = ['mailman'] - output = StringIO() - with patch('sys.argv', testargs), patch('sys.stdout', output): - with self.assertRaises(SystemExit): - main() - self.assertIn('usage', output.getvalue()) + result = self._command.invoke(main) + lines = result.output.splitlines() + # "main" instead of "mailman" because of the way the click runner + # works. It does actually show the correct program when run from the + # command line. + self.assertEqual(lines[0], 'Usage: main [OPTIONS] COMMAND [ARGS]...') + + def test_mailman_command_with_bad_subcommand_prints_help(self): + # Issue #137: Running `mailman` without a subcommand raises an + # AttributeError. + result = self._command.invoke(main, ('not-a-subcommand',)) + lines = result.output.splitlines() + # "main" instead of "mailman" because of the way the click runner + # works. It does actually show the correct program when run from the + # command line. + self.assertEqual(lines[0], 'Usage: main [OPTIONS] COMMAND [ARGS]...') - def test_transaction_commit_after_successful_subcommand(self): + @patch('mailman.bin.mailman.initialize') + def test_transaction_commit_after_successful_subcommand(self, mock): # Issue #223: Subcommands which change the database need to commit or # abort the transaction. with transaction(): @@ -52,40 +65,23 @@ class TestMailmanCommand(unittest.TestCase): mlist.volume = 5 mlist.next_digest_number = 3 mlist.digest_last_sent_at = now() - timedelta(days=60) - testargs = ['mailman', 'digests', '-b', '-l', 'ant@example.com'] - output = StringIO() - with ExitStack() as resources: - enter = resources.enter_context - enter(patch('sys.argv', testargs)) - enter(patch('sys.stdout', output)) - # Everything is already initialized. - enter(patch('mailman.bin.mailman.initialize')) - main() + self._command.invoke(main, ('digests', '-b', '-l', 'ant@example.com')) # Clear the current transaction to force a database reload. config.db.abort() self.assertEqual(mlist.volume, 6) self.assertEqual(mlist.next_digest_number, 1) - def test_transaction_abort_after_failing_subcommand(self): + @patch('mailman.bin.mailman.initialize') + @patch('mailman.commands.cli_digests.maybe_send_digest_now', + side_effect=RuntimeError) + def test_transaction_abort_after_failing_subcommand(self, mock1, mock2): with transaction(): mlist = create_list('ant@example.com') mlist.volume = 5 mlist.next_digest_number = 3 mlist.digest_last_sent_at = now() - timedelta(days=60) - testargs = ['mailman', 'digests', '-b', '-l', 'ant@example.com', - '--send'] - output = StringIO() - with ExitStack() as resources: - enter = resources.enter_context - enter(patch('sys.argv', testargs)) - enter(patch('sys.stdout', output)) - # Force an exception in the subcommand. - enter(patch('mailman.commands.cli_digests.maybe_send_digest_now', - side_effect=RuntimeError)) - # Everything is already initialized. - enter(patch('mailman.bin.mailman.initialize')) - with self.assertRaises(RuntimeError): - main() + self._command.invoke( + main, ('digests', '-b', '-l', 'ant@example.com', '--send')) # Clear the current transaction to force a database reload. config.db.abort() # The volume and number haven't changed. diff --git a/src/mailman/bin/tests/test_master.py b/src/mailman/bin/tests/test_master.py index fb045f58f..27ea6d559 100644 --- a/src/mailman/bin/tests/test_master.py +++ b/src/mailman/bin/tests/test_master.py @@ -21,13 +21,28 @@ import os import tempfile import unittest -from contextlib import suppress +from click.testing import CliRunner +from contextlib import ExitStack, suppress from datetime import timedelta -from flufl.lock import Lock +from flufl.lock import Lock, TimeOutError +from io import StringIO from mailman.bin import master +from mailman.config import config +from mailman.testing.layers import ConfigLayer +from pkg_resources import resource_filename +from unittest.mock import patch -class TestMasterLock(unittest.TestCase): +class FakeLock: + details = ('host.example.com', 9999, '/tmp/whatever') + + def unlock(self): + pass + + +class TestMaster(unittest.TestCase): + layer = ConfigLayer + def setUp(self): fd, self.lock_file = tempfile.mkstemp() os.close(fd) @@ -59,4 +74,55 @@ class TestMasterLock(unittest.TestCase): finally: my_lock.unlock() self.assertEqual(state, master.WatcherState.conflict) - # XXX test stale_lock and host_mismatch states. + + def test_acquire_lock_timeout_reason_unknown(self): + stderr = StringIO() + with ExitStack() as resources: + resources.enter_context(patch( + 'mailman.bin.master.acquire_lock_1', + side_effect=TimeOutError)) + resources.enter_context(patch( + 'mailman.bin.master.master_state', + return_value=(master.WatcherState.none, FakeLock()))) + resources.enter_context(patch( + 'mailman.bin.master.sys.stderr', stderr)) + with self.assertRaises(SystemExit) as cm: + master.acquire_lock(False) + self.assertEqual(cm.exception.code, 1) + self.assertEqual(stderr.getvalue(), """\ +For unknown reasons, the master lock could not be acquired. + +Lock file: {} +Lock host: host.example.com + +Exiting. +""".format(config.LOCK_FILE)) + + def test_main_cli(self): + command = CliRunner() + fake_lock = FakeLock() + with ExitStack() as resources: + config_file = resource_filename( + 'mailman.testing', 'testing.cfg') + init_mock = resources.enter_context(patch( + 'mailman.bin.master.initialize')) + lock_mock = resources.enter_context(patch( + 'mailman.bin.master.acquire_lock', + return_value=fake_lock)) + start_mock = resources.enter_context(patch.object( + master.Loop, 'start_runners')) + loop_mock = resources.enter_context(patch.object( + master.Loop, 'loop')) + command.invoke( + master.main, + ('-C', config_file, + '--no-restart', '--force', + '-r', 'in:1:1', '--verbose')) + # We got initialized with the custom configuration file and the + # verbose flag. + init_mock.assert_called_once_with(config_file, True) + # We returned a lock that was force-acquired. + lock_mock.assert_called_once_with(True) + # We created a non-restartable loop. + start_mock.assert_called_once_with([('in', 1, 1)]) + loop_mock.assert_called_once_with() diff --git a/src/mailman/commands/cli_aliases.py b/src/mailman/commands/cli_aliases.py index 3ac46a1d9..4ce41eedd 100644 --- a/src/mailman/commands/cli_aliases.py +++ b/src/mailman/commands/cli_aliases.py @@ -17,30 +17,31 @@ """Generate Mailman alias files for your MTA.""" +import click + from mailman.config import config from mailman.core.i18n import _ from mailman.interfaces.command import ICLISubCommand from mailman.utilities.modules import call_name +from mailman.utilities.options import I18nCommand from public import public from zope.interface import implementer +@click.command( + cls=I18nCommand, + help=_('Regenerate the aliases appropriate for your MTA.')) +@click.option( + '--directory', '-d', + type=click.Path(exists=True, file_okay=False, resolve_path=True, + writable=True), + help=_('An alternative directory to output the various MTA files to.')) +def aliases(directory): + call_name(config.mta.incoming).regenerate(directory) + + @public @implementer(ICLISubCommand) class Aliases: - """Regenerate the aliases appropriate for your MTA.""" - name = 'aliases' - - def add(self, parser, command_parser): - """See `ICLISubCommand`.""" - self.parser = parser - command_parser.add_argument( - '-d', '--directory', - action='store', help=_("""\ - An alternative directory to output the various MTA files to.""")) - - def process(self, args): - """See `ICLISubCommand`.""" - # Call the MTA-specific regeneration method. - call_name(config.mta.incoming).regenerate(args.directory) + command = aliases diff --git a/src/mailman/commands/cli_conf.py b/src/mailman/commands/cli_conf.py index 9875b3acd..456d95246 100644 --- a/src/mailman/commands/cli_conf.py +++ b/src/mailman/commands/cli_conf.py @@ -17,118 +17,98 @@ """Print the mailman configuration.""" -import sys +import click -from contextlib import closing from lazr.config._config import Section from mailman.config import config from mailman.core.i18n import _ from mailman.interfaces.command import ICLISubCommand +from mailman.utilities.options import I18nCommand from public import public from zope.interface import implementer -@public -@implementer(ICLISubCommand) -class Conf: - """Print the mailman configuration.""" - - name = 'conf' - - def add(self, parser, command_parser): - """See `ICLISubCommand`.""" +def _section_exists(section): + # Not all of the attributes in config are actual sections, so we have to + # check the section's type. + return ( + hasattr(config, section) and + isinstance(getattr(config, section), Section) + ) - self.parser = parser - command_parser.add_argument( - '-o', '--output', - action='store', help=_("""\ - File to send the output to. If not given, or if '-' is given, - standard output is used.""")) - command_parser.add_argument( - '-s', '--section', - action='store', help=_("""\ - Section to use for the lookup. If no key is given, all the - key-value pairs of the given section will be displayed. - """)) - command_parser.add_argument( - '-k', '--key', - action='store', help=_("""\ - Key to use for the lookup. If no section is given, all the - key-values pair from any section matching the given key will be - displayed. - """)) - def _get_value(self, section, key): - return getattr(getattr(config, section), key) +def _get_value(section, key): + return getattr(getattr(config, section), key) - def _sections(self): - return sorted(config.schema._section_schemas) - def _print_full_syntax(self, section, key, value, output): +def _print_values_for_section(section, output): + current_section = sorted(getattr(config, section)) + for key in current_section: + value = _get_value(section, key) print('[{}] {}: {}'.format(section, key, value), file=output) - def _show_key_error(self, section, key): - self.parser.error('Section {}: No such key: {}'.format(section, key)) - - def _show_section_error(self, section): - self.parser.error('No such section: {}'.format(section)) - - def _print_values_for_section(self, section, output): - current_section = sorted(getattr(config, section)) - for key in current_section: - self._print_full_syntax(section, key, - self._get_value(section, key), output) - def _section_exists(self, section): - # Not all of the attributes in config are actual sections, so we have - # to check the section's type. - return (hasattr(config, section) and - isinstance(getattr(config, section), Section)) +@click.command( + cls=I18nCommand, + help=_('Print the Mailman configuration.')) +@click.option( + '--output', '-o', + type=click.File(mode='w', encoding='utf-8', atomic=True), + help=_("""\ + File to send the output to. If not given, or if '-' is given, standard + output is used.""")) +@click.option( + '--section', '-s', + help=_("""\ + Section to use for the lookup. If no key is given, all the key-value pairs + of the given section will be displayed.""")) +@click.option( + '--key', '-k', + help=_("""\ + Key to use for the lookup. If no section is given, all the key-values pair + from any section matching the given key will be displayed.""")) +@click.pass_context +def conf(ctx, output, section, key): + # Case 1: Both section and key are given, so we can look the value up + # directly. + if section is not None and key is not None: + if not _section_exists(section): + ctx.fail('No such section: {}'.format(section)) + elif not hasattr(getattr(config, section), key): + ctx.fail('Section {}: No such key: {}'.format(section, key)) + else: + print(_get_value(section, key), file=output) + # Case 2: Section is given, key is not given. + elif section is not None and key is None: + if _section_exists(section): + _print_values_for_section(section, output) + else: + ctx.fail('No such section: {}'.format(section)) + # Case 3: Section is not given, key is given. + elif section is None and key is not None: + for current_section in sorted(config.schema._section_schemas): + # We have to ensure that the current section actually exists + # and that it contains the given key. + if (_section_exists(current_section) and + hasattr(getattr(config, current_section), key)): + value = _get_value(current_section, key) + print('[{}] {}: {}'.format( + current_section, key, value), file=output) + # Case 4: Neither section nor key are given, just display all the + # sections and their corresponding key/value pairs. + elif section is None and key is None: + for current_section in sorted(config.schema._section_schemas): + # However, we have to make sure that the current sections and + # key which are being looked up actually exist before trying + # to print them. + if _section_exists(current_section): + _print_values_for_section(current_section, output) + else: + raise AssertionError('Unexpected combination') - def _inner_process(self, args, output): - # Process the command, ignoring the closing of the output file. - section = args.section - key = args.key - # Case 1: Both section and key are given, so we can directly look up - # the value. - if section is not None and key is not None: - if not self._section_exists(section): - self._show_section_error(section) - elif not hasattr(getattr(config, section), key): - self._show_key_error(section, key) - else: - print(self._get_value(section, key), file=output) - # Case 2: Section is given, key is not given. - elif section is not None and key is None: - if self._section_exists(section): - self._print_values_for_section(section, output) - else: - self._show_section_error(section) - # Case 3: Section is not given, key is given. - elif section is None and key is not None: - for current_section in self._sections(): - # We have to ensure that the current section actually exists - # and that it contains the given key. - if (self._section_exists(current_section) and - hasattr(getattr(config, current_section), key)): - self._print_full_syntax( - current_section, key, - self._get_value(current_section, key), - output) - # Case 4: Neither section nor key are given, just display all the - # sections and their corresponding key/value pairs. - elif section is None and key is None: - for current_section in self._sections(): - # However, we have to make sure that the current sections and - # key which are being looked up actually exist before trying - # to print them. - if self._section_exists(current_section): - self._print_values_for_section(current_section, output) - def process(self, args): - """See `ICLISubCommand`.""" - if args.output is None or args.output == '-': - self._inner_process(args, sys.stdout) - else: - with closing(open(args.output, 'w')) as output: - self._inner_process(args, output) +@public +@implementer(ICLISubCommand) +class Conf: + name = 'conf' + command = conf diff --git a/src/mailman/commands/cli_control.py b/src/mailman/commands/cli_control.py index bf10ff017..6484ab63f 100644 --- a/src/mailman/commands/cli_control.py +++ b/src/mailman/commands/cli_control.py @@ -19,6 +19,7 @@ import os import sys +import click import errno import signal import logging @@ -27,6 +28,7 @@ from mailman.bin.master import WatcherState, master_state from mailman.config import config from mailman.core.i18n import _ from mailman.interfaces.command import ICLISubCommand +from mailman.utilities.options import I18nCommand from public import public from zope.interface import implementer @@ -34,112 +36,94 @@ from zope.interface import implementer qlog = logging.getLogger('mailman.runner') +@click.command( + cls=I18nCommand, + help=_('Start the Mailman master and runner processes.')) +@click.option( + '--force', '-f', + is_flag=True, default=False, + help=_("""\ + If the master watcher finds an existing master lock, it will normally exit + with an error message. With this option, the master will perform an extra + level of checking. If a process matching the host/pid described in the + lock file is running, the master will still exit, requiring you to manually + clean up the lock. But if no matching process is found, the master will + remove the apparently stale lock and make another attempt to claim the + master lock.""")) +@click.option( + '--run-as-user', '-u', + is_flag=True, default=True, + help=_("""\ + Normally, this script will refuse to run if the user id and group id are + not set to the 'mailman' user and group (as defined when you configured + Mailman). If run as root, this script will change to this user and group + before the check is made. + + This can be inconvenient for testing and debugging purposes, so the -u flag + means that the step that sets and checks the uid/gid is skipped, and the + program is run as the current user and group. This flag is not recommended + for normal production environments. + + Note though, that if you run with -u and are not in the mailman group, you + may have permission problems, such as being unable to delete a list's + archives through the web. Tough luck!""")) +@click.option( + '--quiet', '-q', + is_flag=True, default=False, + help=_("""\ + Don't print status messages. Error messages are still printed to standard + error.""")) +@click.pass_context +def start(ctx, force, run_as_user, quiet): + # Although there's a potential race condition here, it's a better user + # experience for the parent process to refuse to start twice, rather than + # having it try to start the master, which will error exit. + status, lock = master_state() + if status is WatcherState.conflict: + ctx.fail(_('GNU Mailman is already running')) + elif status in (WatcherState.stale_lock, WatcherState.host_mismatch): + if not force: + ctx.fail( + _('A previous run of GNU Mailman did not exit ' + 'cleanly ({}). Try using --force'.format(status.name))) + # Daemon process startup according to Stevens, Advanced Programming in the + # UNIX Environment, Chapter 13. + pid = os.fork() + if pid: + # parent + if not quiet: + print(_("Starting Mailman's master runner")) + return + # child: Create a new session and become the session leader, but since we + # won't be opening any terminal devices, don't do the ultra-paranoid + # suggestion of doing a second fork after the setsid() call. + os.setsid() + # Instead of cd'ing to root, cd to the Mailman runtime directory. However, + # before we do that, set an environment variable used by the subprocesses + # to calculate their path to the $VAR_DIR. + os.environ['MAILMAN_VAR_DIR'] = config.VAR_DIR + os.chdir(config.VAR_DIR) + # Exec the master watcher. + execl_args = [ + sys.executable, sys.executable, + os.path.join(config.BIN_DIR, 'master'), + ] + if force: + execl_args.append('--force') + # Always pass the configuration file path to the master process, so there's + # no confusion about which one is being used. + execl_args.extend(['-C', config.filename]) + qlog.debug('starting: %s', execl_args) + os.execl(*execl_args) + # We should never get here. + raise RuntimeError('os.execl() failed') + + @public @implementer(ICLISubCommand) class Start: - """Start the Mailman master and runner processes.""" - name = 'start' - - def add(self, parser, command_parser): - """See `ICLISubCommand`.""" - self.parser = parser - command_parser.add_argument( - '-f', '--force', - default=False, action='store_true', - help=_("""\ - If the master watcher finds an existing master lock, it will - normally exit with an error message. With this option, the master - will perform an extra level of checking. If a process matching - the host/pid described in the lock file is running, the master - will still exit, requiring you to manually clean up the lock. But - if no matching process is found, the master will remove the - apparently stale lock and make another attempt to claim the master - lock.""")) - command_parser.add_argument( - '-u', '--run-as-user', - default=True, action='store_false', - help=_("""\ - Normally, this script will refuse to run if the user id and group - id are not set to the 'mailman' user and group (as defined when - you configured Mailman). If run as root, this script will change - to this user and group before the check is made. - - This can be inconvenient for testing and debugging purposes, so - the -u flag means that the step that sets and checks the uid/gid - is skipped, and the program is run as the current user and group. - This flag is not recommended for normal production environments. - - Note though, that if you run with -u and are not in the mailman - group, you may have permission problems, such as being unable to - delete a list's archives through the web. Tough luck!""")) - command_parser.add_argument( - '-q', '--quiet', - default=False, action='store_true', - help=_("""\ - Don't print status messages. Error messages are still printed to - standard error.""")) - - def process(self, args): - """See `ICLISubCommand`.""" - # Although there's a potential race condition here, it's a better user - # experience for the parent process to refuse to start twice, rather - # than having it try to start the master, which will error exit. - status, lock = master_state() - if status is WatcherState.conflict: - self.parser.error(_('GNU Mailman is already running')) - elif status in (WatcherState.stale_lock, WatcherState.host_mismatch): - if args.force is None: - self.parser.error( - _('A previous run of GNU Mailman did not exit ' - 'cleanly. Try using --force.')) - def log(message): # noqa: E306 - if not args.quiet: - print(message) - # Try to find the path to a valid, existing configuration file, and - # refuse to start if one cannot be found. - if args.config is not None: - config_path = args.config - elif config.filename is not None: - config_path = config.filename - else: - config_path = os.path.join(config.VAR_DIR, 'etc', 'mailman.cfg') - if not os.path.exists(config_path): - print(_("""\ -No valid configuration file could be found, so Mailman will refuse to start. -Use -C/--config to specify a valid configuration file."""), file=sys.stderr) - sys.exit(1) - # Daemon process startup according to Stevens, Advanced Programming in - # the UNIX Environment, Chapter 13. - pid = os.fork() - if pid: - # parent - log(_("Starting Mailman's master runner")) - return - # child: Create a new session and become the session leader, but since - # we won't be opening any terminal devices, don't do the - # ultra-paranoid suggestion of doing a second fork after the setsid() - # call. - os.setsid() - # Instead of cd'ing to root, cd to the Mailman runtime directory. - # However, before we do that, set an environment variable used by the - # subprocesses to calculate their path to the $VAR_DIR. - os.environ['MAILMAN_VAR_DIR'] = config.VAR_DIR - os.chdir(config.VAR_DIR) - # Exec the master watcher. - execl_args = [ - sys.executable, sys.executable, - os.path.join(config.BIN_DIR, 'master'), - ] - if args.force: - execl_args.append('--force') - # Always pass the config file path to the master projects, so there's - # no confusion about which cfg is being used. - execl_args.extend(['-C', config_path]) - qlog.debug('starting: %s', execl_args) - os.execl(*execl_args) - # We should never get here. - raise RuntimeError('os.execl() failed') + command = start def kill_watcher(sig): @@ -163,53 +147,67 @@ def kill_watcher(sig): os.unlink(config.PID_FILE) -@implementer(ICLISubCommand) -class SignalCommand: - """Common base class for simple, signal sending commands.""" - - name = None - message = None - signal = None - - def add(self, parser, command_parser): - """See `ICLISubCommand`.""" - command_parser.add_argument( - '-q', '--quiet', - default=False, action='store_true', - help=_("""\ - Don't print status messages. Error messages are still printed to - standard error.""")) - - def process(self, args): - """See `ICLISubCommand`.""" - if not args.quiet: - print(_(self.message)) - kill_watcher(self.signal) +@click.command( + cls=I18nCommand, + help=_('Stop the Mailman master and runner processes.')) +@click.option( + '--quiet', '-q', + is_flag=True, default=False, + help=_("""\ + Don't print status messages. Error messages are still printed to standard + error.""")) +def stop(quiet): + if not quiet: + print(_("Shutting down Mailman's master runner")) + kill_watcher(signal.SIGTERM) @public -class Stop(SignalCommand): - """Stop the Mailman master and runner processes.""" - +@implementer(ICLISubCommand) +class Stop: name = 'stop' - message = _("Shutting down Mailman's master runner") - signal = signal.SIGTERM + command = stop -@public -class Reopen(SignalCommand): - """Signal the Mailman processes to re-open their log files.""" +@click.command( + cls=I18nCommand, + help=_('Signal the Mailman processes to re-open their log files.')) +@click.option( + '--quiet', '-q', + is_flag=True, default=False, + help=_("""\ + Don't print status messages. Error messages are still printed to standard + error.""")) +def reopen(quiet): + if not quiet: + print(_('Reopening the Mailman runners')) + kill_watcher(signal.SIGHUP) + +@public +@implementer(ICLISubCommand) +class Reopen: name = 'reopen' - message = _('Reopening the Mailman runners') - signal = signal.SIGHUP + command = reopen + + +@click.command( + cls=I18nCommand, + help=_('Stop and restart the Mailman runner subprocesses.')) +@click.option( + '--quiet', '-q', + is_flag=True, default=False, + help=_("""\ + Don't print status messages. Error messages are still printed to standard + error.""")) +def restart(quiet): + if not quiet: + print(_('Restarting the Mailman runners')) + kill_watcher(signal.SIGUSR1) @public @implementer(ICLISubCommand) -class Restart(SignalCommand): - """Stop and restart the Mailman runner subprocesses.""" - +class Restart: name = 'restart' - message = _('Restarting the Mailman runners') - signal = signal.SIGUSR1 + command = restart diff --git a/src/mailman/commands/cli_digests.py b/src/mailman/commands/cli_digests.py index 8f556beb4..57431000a 100644 --- a/src/mailman/commands/cli_digests.py +++ b/src/mailman/commands/cli_digests.py @@ -18,88 +18,91 @@ """The `send_digests` subcommand.""" import sys +import click from mailman.app.digests import ( bump_digest_number_and_volume, maybe_send_digest_now) from mailman.core.i18n import _ from mailman.interfaces.command import ICLISubCommand from mailman.interfaces.listmanager import IListManager +from mailman.utilities.options import I18nCommand from public import public from zope.component import getUtility from zope.interface import implementer -@public -@implementer(ICLISubCommand) -class Digests: - """Operate on digests.""" - - name = 'digests' - - def add(self, parser, command_parser): - """See `ICLISubCommand`.""" - - command_parser.add_argument( - '-l', '--list', - default=[], dest='lists', metavar='list', action='append', - help=_("""Operate on this mailing list. Multiple --list - options can be given. The argument can either be a List-ID - or a fully qualified list name. Without this option, - operate on the digests for all mailing lists.""")) - command_parser.add_argument( - '-s', '--send', - default=False, action='store_true', - help=_("""Send any collected digests right now, even if the size - threshold has not yet been met.""")) - command_parser.add_argument( - '-b', '--bump', - default=False, action='store_true', - help=_("""Increment the digest volume number and reset the digest - number to one. If given with --send, the volume number is - incremented before any current digests are sent.""")) - command_parser.add_argument( - '-n', '--dry-run', - default=False, action='store_true', - help=_("""Don't actually do anything, but in conjunction with - --verbose, show what would happen.""")) - command_parser.add_argument( - '-v', '--verbose', - default=False, action='store_true', - help=_("""Print some additional status.""")) - - def process(self, args): - """See `ICLISubCommand`.""" - list_manager = getUtility(IListManager) - if args.lists: - lists = [] - for spec in args.lists: - # We'll accept list-ids or fqdn list names. - if '@' in spec: - mlist = list_manager.get(spec) - else: - mlist = list_manager.get_by_list_id(spec) - if mlist is None: - print(_('No such list found: $spec'), file=sys.stderr) - else: - lists.append(mlist) - else: - lists = list(list_manager.mailing_lists) - if args.bump: - for mlist in lists: - if args.verbose: - print(_('\ +@click.command( + cls=I18nCommand, + help=_('Operate on digests.')) +@click.option( + '--list', '-l', 'list_ids', metavar='list', + multiple=True, help=_("""\ + Operate on this mailing list. Multiple --list options can be given. The + argument can either be a List-ID or a fully qualified list name. Without + this option, operate on the digests for all mailing lists.""")) +@click.option( + '--send', '-s', + is_flag=True, default=False, + help=_("""\ + Send any collected digests right now, even if the size threshold has not + yet been met.""")) +@click.option( + '--bump', '-b', + is_flag=True, default=False, + help=_("""\ + Increment the digest volume number and reset the digest number to one. If + given with --send, the volume number is incremented before any current + digests are sent.""")) +@click.option( + '--dry-run', '-n', + is_flag=True, default=False, + help=_("""\ + Don't actually do anything, but in conjunction with --verbose, show what + would happen.""")) +@click.option( + '--verbose', '-v', + is_flag=True, default=False, + help=_('Print some additional status.')) +@click.pass_context +def digests(ctx, list_ids, send, bump, dry_run, verbose): + list_manager = getUtility(IListManager) + if list_ids: + lists = [] + for spec in list_ids: + # We'll accept list-ids or fqdn list names. + if '@' in spec: + mlist = list_manager.get(spec) + else: + mlist = list_manager.get_by_list_id(spec) + if mlist is None: + print(_('No such list found: $spec'), file=sys.stderr) + else: + lists.append(mlist) + else: + lists = list(list_manager.mailing_lists) + if bump: + for mlist in lists: + if verbose: + print(_('\ $mlist.list_id is at volume $mlist.volume, number \ ${mlist.next_digest_number}')) - if not args.dry_run: - bump_digest_number_and_volume(mlist) - if args.verbose: - print(_('\ + if not dry_run: + bump_digest_number_and_volume(mlist) + if verbose: + print(_('\ $mlist.list_id bumped to volume $mlist.volume, number \ ${mlist.next_digest_number}')) - if args.send: - for mlist in lists: - if args.verbose: - print(_('\ + if send: + for mlist in lists: + if verbose: + print(_('\ $mlist.list_id sent volume $mlist.volume, number ${mlist.next_digest_number}')) - if not args.dry_run: - maybe_send_digest_now(mlist, force=True) + if not dry_run: + maybe_send_digest_now(mlist, force=True) + + +@public +@implementer(ICLISubCommand) +class Digests: + name = 'digests' + command = digests diff --git a/src/mailman/commands/cli_help.py b/src/mailman/commands/cli_help.py index 016fc1e23..af0108346 100644 --- a/src/mailman/commands/cli_help.py +++ b/src/mailman/commands/cli_help.py @@ -17,23 +17,27 @@ """The 'help' subcommand.""" +import click + +from mailman.core.i18n import _ from mailman.interfaces.command import ICLISubCommand +from mailman.utilities.options import I18nCommand from public import public from zope.interface import implementer +@click.command( + cls=I18nCommand, + help=_('Show this help message and exit.')) +@click.pass_context +# https://github.com/pallets/click/issues/832 +def help(ctx): # pragma: nocover + click.echo(ctx.parent.get_help(), color=ctx.color) + ctx.exit() + + @public @implementer(ICLISubCommand) class Help: - # Lowercase, to match argparse's default --help text. - """Show this help message and exit.""" - name = 'help' - - def add(self, parser, command_parser): - """See `ICLISubCommand`.""" - self.parser = parser - - def process(self, args): - """See `ICLISubCommand`.""" - self.parser.print_help() + command = help diff --git a/src/mailman/commands/cli_import.py b/src/mailman/commands/cli_import.py index f90364e99..ffd0f264f 100644 --- a/src/mailman/commands/cli_import.py +++ b/src/mailman/commands/cli_import.py @@ -18,14 +18,17 @@ """Importing list data into Mailman 3.""" import sys +import click import pickle -from contextlib import ExitStack, contextmanager +from contextlib import ExitStack from mailman.core.i18n import _ -from mailman.database.transaction import transactional +from mailman.database.transaction import transaction from mailman.interfaces.command import ICLISubCommand from mailman.interfaces.listmanager import IListManager from mailman.utilities.importer import Import21Error, import_config_pck +from mailman.utilities.modules import hacked_sys_modules +from mailman.utilities.options import I18nCommand from public import public from zope.component import getUtility from zope.interface import implementer @@ -38,76 +41,46 @@ class Bouncer: pass -@contextmanager -def hacked_sys_modules(): - assert 'Mailman.Bouncer' not in sys.modules - sys.modules['Mailman.Bouncer'] = Bouncer - try: - yield - finally: - del sys.modules['Mailman.Bouncer'] +@click.command( + cls=I18nCommand, + help=_("""\ + Import Mailman 2.1 list data'. Requires the fully-qualified name of the + list to import and the path to the Mailman 2.1 pickle file.""")) +@click.argument('listspec') +@click.argument( + 'pickle_file', metavar='PICKLE_FILE', + type=click.File(mode='rb')) +@click.pass_context +def import21(ctx, listspec, pickle_file): + mlist = getUtility(IListManager).get(listspec) + if mlist is None: + ctx.fail(_('No such list: $listspec')) + with ExitStack() as resources: + resources.enter_context(hacked_sys_modules('Mailman.Bouncer', Bouncer)) + resources.enter_context(transaction()) + while True: + try: + config_dict = pickle.load( + pickle_file, encoding='utf-8', errors='ignore') + except EOFError: + break + except pickle.UnpicklingError: + ctx.fail( + _('Not a Mailman 2.1 configuration file: $pickle_file')) + else: + if not isinstance(config_dict, dict): + print(_('Ignoring non-dictionary: {0!r}').format( + config_dict), file=sys.stderr) + continue + try: + import_config_pck(mlist, config_dict) + except Import21Error as error: + print(error, file=sys.stderr) + sys.exit(1) @public @implementer(ICLISubCommand) class Import21: - """Import Mailman 2.1 list data.""" - name = 'import21' - - def add(self, parser, command_parser): - """See `ICLISubCommand`.""" - self.parser = parser - # Required positional arguments. - command_parser.add_argument( - 'listname', metavar='LISTNAME', nargs=1, - help=_("""\ - The 'fully qualified list name', i.e. the posting address of the - mailing list to inject the message into.""")) - command_parser.add_argument( - 'pickle_file', metavar='FILENAME', nargs=1, - help=_('The path to the config.pck file to import.')) - - @transactional - def process(self, args): - """See `ICLISubCommand`.""" - # Could be None or sequence of length 0. - if args.listname is None: - self.parser.error(_('List name is required')) - return - assert len(args.listname) == 1, ( - 'Unexpected positional arguments: %s' % args.listname) - fqdn_listname = args.listname[0] - mlist = getUtility(IListManager).get(fqdn_listname) - if mlist is None: - self.parser.error(_('No such list: $fqdn_listname')) - return - if args.pickle_file is None: - self.parser.error(_('config.pck file is required')) - return - assert len(args.pickle_file) == 1, ( - 'Unexpected positional arguments: %s' % args.pickle_file) - filename = args.pickle_file[0] - with ExitStack() as resources: - fp = resources.enter_context(open(filename, 'rb')) - resources.enter_context(hacked_sys_modules()) - while True: - try: - config_dict = pickle.load( - fp, encoding='utf-8', errors='ignore') - except EOFError: - break - except pickle.UnpicklingError: - self.parser.error( - _('Not a Mailman 2.1 configuration file: $filename')) - return - else: - if not isinstance(config_dict, dict): - print(_('Ignoring non-dictionary: {0!r}').format( - config_dict), file=sys.stderr) - continue - try: - import_config_pck(mlist, config_dict) - except Import21Error as error: - print(error, file=sys.stderr) - sys.exit(1) + command = import21 diff --git a/src/mailman/commands/cli_info.py b/src/mailman/commands/cli_info.py index dbbf8a287..4f8faace6 100644 --- a/src/mailman/commands/cli_info.py +++ b/src/mailman/commands/cli_info.py @@ -18,65 +18,62 @@ """Information about this Mailman instance.""" import sys +import click from lazr.config import as_boolean from mailman.config import config from mailman.core.api import API30, API31 from mailman.core.i18n import _ from mailman.interfaces.command import ICLISubCommand +from mailman.utilities.options import I18nCommand from mailman.version import MAILMAN_VERSION_FULL from public import public from zope.interface import implementer +@click.command( + cls=I18nCommand, + help=_('Information about this Mailman instance.')) +@click.option( + '--output', '-o', + type=click.File(mode='w', encoding='utf-8', atomic=True), + help=_("""\ + File to send the output to. If not given, standard output is used.""")) +@click.option( + '--verbose', '-v', + is_flag=True, default=False, + help=_("""\ + A more verbose output including the file system paths that Mailman is + using.""")) +def info(output, verbose): + """See `ICLISubCommand`.""" + print(MAILMAN_VERSION_FULL, file=output) + print('Python', sys.version, file=output) + print('config file:', config.filename, file=output) + print('db url:', config.db.url, file=output) + print('devmode:', + 'ENABLED' if as_boolean(config.devmode.enabled) else 'DISABLED', + file=output) + api = (API30 if config.webservice.api_version == '3.0' else API31) + print('REST root url:', api.path_to('/'), file=output) + print('REST credentials: {}:{}'.format( + config.webservice.admin_user, config.webservice.admin_pass), + file=output) + if verbose: + print('File system paths:', file=output) + longest = 0 + paths = {} + for attribute in dir(config): + if attribute.endswith('_DIR') or attribute.endswith('_FILE'): + paths[attribute] = getattr(config, attribute) + longest = max(longest, len(attribute)) + for attribute in sorted(paths): + print(' {0:{2}} = {1}'.format( + attribute, paths[attribute], longest)) + + @public @implementer(ICLISubCommand) class Info: - """Information about this Mailman instance.""" - name = 'info' - - def add(self, parser, command_parser): - """See `ICLISubCommand`.""" - command_parser.add_argument( - '-o', '--output', - action='store', help=_("""\ - File to send the output to. If not given, standard output is - used.""")) - command_parser.add_argument( - '-v', '--verbose', - action='store_true', help=_("""\ - A more verbose output including the file system paths that Mailman - is using.""")) - - def process(self, args): - """See `ICLISubCommand`.""" - if args.output is None: - output = sys.stdout - else: - # We don't need to close output because that will happen - # automatically when the script exits. - output = open(args.output, 'w') - print(MAILMAN_VERSION_FULL, file=output) - print('Python', sys.version, file=output) - print('config file:', config.filename, file=output) - print('db url:', config.db.url, file=output) - print('devmode:', - 'ENABLED' if as_boolean(config.devmode.enabled) else 'DISABLED', - file=output) - api = (API30 if config.webservice.api_version == '3.0' else API31) - print('REST root url:', api.path_to('/'), file=output) - print('REST credentials: {}:{}'.format( - config.webservice.admin_user, config.webservice.admin_pass), - file=output) - if args.verbose: - print('File system paths:', file=output) - longest = 0 - paths = {} - for attribute in dir(config): - if attribute.endswith('_DIR') or attribute.endswith('_FILE'): - paths[attribute] = getattr(config, attribute) - longest = max(longest, len(attribute)) - for attribute in sorted(paths): - print(' {0:{2}} = {1}'.format( - attribute, paths[attribute], longest)) + command = info diff --git a/src/mailman/commands/cli_inject.py b/src/mailman/commands/cli_inject.py index 0203a0c09..5c68b58b6 100644 --- a/src/mailman/commands/cli_inject.py +++ b/src/mailman/commands/cli_inject.py @@ -18,92 +18,79 @@ """The `mailman inject` subcommand.""" import sys +import click from mailman.app.inject import inject_text from mailman.config import config from mailman.core.i18n import _ from mailman.interfaces.command import ICLISubCommand from mailman.interfaces.listmanager import IListManager +from mailman.utilities.options import I18nCommand from public import public from zope.component import getUtility from zope.interface import implementer +def show_queues(ctx, param, value): + if value: + print('Available queues:') + for switchboard in sorted(config.switchboards): + print(' ', switchboard) + sys.exit(0) + # Returning None tells click to process the rest of the command line. + + +@click.command( + cls=I18nCommand, + help=_("Inject a message from a file into a mailing list's queue.")) +@click.option( + '--queue', '-q', + help=_("""\ + The name of the queue to inject the message to. QUEUE must be one of the + directories inside the qfiles directory. If omitted, the incoming queue is + used.""")) +@click.option( + '--show', '-s', + is_flag=True, default=False, is_eager=True, expose_value=False, + callback=show_queues, + help=_('Show a list of all available queue names and exit.')) +@click.option( + '--filename', '-f', 'message_file', + type=click.File(encoding='utf-8'), + help=_("""\ + Name of file containing the message to inject. If not given, or + '-' (without the quotes) standard input is used.""")) +@click.option( + '--metadata', '-m', 'keywords', + multiple=True, metavar='KEY=VALUE', + help=_("""\ + Additional metadata key/value pairs to add to the message metadata + dictionary. Use the format key=value. Multiple -m options are + allowed.""")) +@click.argument('listspec') +@click.pass_context +def inject(ctx, queue, message_file, keywords, listspec): + mlist = getUtility(IListManager).get(listspec) + if mlist is None: + ctx.fail(_('No such list: $listspec')) + queue_name = ('in' if queue is None else queue) + switchboard = config.switchboards.get(queue_name) + if switchboard is None: + ctx.fail(_('No such queue: $queue')) + try: + message_text = message_file.read() + except KeyboardInterrupt: + print('Interrupted') + sys.exit(1) + kws = {} + for keyvalue in keywords: + key, equals, value = keyvalue.partition('=') + kws[key] = value + inject_text(mlist, message_text, switchboard=queue, **kws) + + @public @implementer(ICLISubCommand) class Inject: - """Inject a message from a file into a mailing list's queue.""" - name = 'inject' - - def add(self, parser, command_parser): - """See `ICLISubCommand`.""" - self.parser = parser - command_parser.add_argument( - '-q', '--queue', - help=_(""" - The name of the queue to inject the message to. QUEUE must be one - of the directories inside the qfiles directory. If omitted, the - incoming queue is used.""")) - command_parser.add_argument( - '-s', '--show', - action='store_true', default=False, - help=_('Show a list of all available queue names and exit.')) - command_parser.add_argument( - '-f', '--filename', - help=_(""" - Name of file containing the message to inject. If not given, or - '-' (without the quotes) standard input is used.""")) - # Required positional argument. - command_parser.add_argument( - 'listname', metavar='LISTNAME', nargs=1, - help=_(""" - The 'fully qualified list name', i.e. the posting address of the - mailing list to inject the message into.""")) - command_parser.add_argument( - '-m', '--metadata', - dest='keywords', action='append', default=[], metavar='KEY=VALUE', - help=_(""" - Additional metadata key/value pairs to add to the message metadata - dictionary. Use the format key=value. Multiple -m options are - allowed.""")) - - def process(self, args): - """See `ICLISubCommand`.""" - # Process --show first; if given, print output and exit, ignoring all - # other command line switches. - if args.show: - print('Available queues:') - for switchboard in sorted(config.switchboards): - print(' ', switchboard) - return - # Could be None or sequence of length 0. - if args.listname is None: - self.parser.error(_('List name is required')) - return - assert len(args.listname) == 1, ( - 'Unexpected positional arguments: %s' % args.listname) - fqdn_listname = args.listname[0] - mlist = getUtility(IListManager).get(fqdn_listname) - if mlist is None: - self.parser.error(_('No such list: $fqdn_listname')) - return - queue = ('in' if args.queue is None else args.queue) - switchboard = config.switchboards.get(queue) - if switchboard is None: - self.parser.error(_('No such queue: $queue')) - return - if args.filename in (None, '-'): - try: - message_text = sys.stdin.read() - except KeyboardInterrupt: - print('Interrupted') - sys.exit(1) - else: - with open(args.filename) as fp: - message_text = fp.read() - keywords = {} - for keyvalue in args.keywords: - key, equals, value = keyvalue.partition('=') - keywords[key] = value - inject_text(mlist, message_text, switchboard=queue, **keywords) + command = inject diff --git a/src/mailman/commands/cli_lists.py b/src/mailman/commands/cli_lists.py index fdffce16d..aa1b49b32 100644 --- a/src/mailman/commands/cli_lists.py +++ b/src/mailman/commands/cli_lists.py @@ -17,10 +17,13 @@ """The 'lists' subcommand.""" +import sys +import click + from mailman.app.lifecycle import create_list, remove_list from mailman.core.constants import system_preferences from mailman.core.i18n import _ -from mailman.database.transaction import transaction, transactional +from mailman.database.transaction import transaction from mailman.email.message import UserNotification from mailman.interfaces.address import ( IEmailValidator, InvalidEmailAddressError) @@ -30,7 +33,9 @@ from mailman.interfaces.domain import ( from mailman.interfaces.languages import ILanguageManager from mailman.interfaces.listmanager import IListManager, ListAlreadyExistsError from mailman.interfaces.template import ITemplateLoader +from mailman.utilities.options import I18nCommand from mailman.utilities.string import expand, wrap +from operator import attrgetter from public import public from zope.component import getUtility from zope.interface import implementer @@ -39,240 +44,206 @@ from zope.interface import implementer COMMASPACE = ', ' +@click.command( + cls=I18nCommand, + help=_('List all mailing lists.')) +@click.option( + '--advertised', '-a', + is_flag=True, default=False, + help=_('List only those mailing lists that are publicly advertised')) +@click.option( + '--names/--no-names', '-n/-N', + is_flag=True, default=False, + help=_('Show also the list names')) +@click.option( + '--descriptions/--no-descriptions', '-d/-D', + is_flag=True, default=False, + help=_('Show also the list descriptions')) +@click.option( + '--quiet', '-q', + is_flag=True, default=False, + help=_('Less verbosity')) +@click.option( + '--domain', 'domains', + multiple=True, metavar='DOMAIN', + help=_("""\ + List only those mailing lists hosted on the given domain, which + must be the email host name. Multiple -d options may be given. + """)) +@click.pass_context +def lists(ctx, advertised, names, descriptions, quiet, domains): + mailing_lists = set() + list_manager = getUtility(IListManager) + # Gather the matching mailing lists. + for mlist in list_manager.mailing_lists: + if advertised and not mlist.advertised: + continue + if len(domains) > 0 and mlist.mail_host not in domains: + continue + mailing_lists.add(mlist) + # Maybe no mailing lists matched. + if len(mailing_lists) == 0: + if not quiet: + print(_('No matching mailing lists found')) + ctx.exit() + count = len(mailing_lists) # noqa: F841 + if not quiet: + print(_('$count matching mailing lists found:')) + # Calculate the longest identifier. + longest = 0 + output = [] + for mlist in sorted(mailing_lists, key=attrgetter('list_id')): + if names: + identifier = '{} [{}]'.format( + mlist.fqdn_listname, mlist.display_name) + else: + identifier = mlist.fqdn_listname + longest = max(len(identifier), longest) + output.append((identifier, mlist.description)) + # Print it out. + if descriptions: + format_string = '{0:{2}} - {1:{3}}' + else: + format_string = '{0:{2}}' + for identifier, description in output: + print(format_string.format( + identifier, description, longest, 70 - longest)) + + @public @implementer(ICLISubCommand) class Lists: - """List all mailing lists""" - name = 'lists' - - def add(self, parser, command_parser): - """See `ICLISubCommand`.""" - command_parser.add_argument( - '-a', '--advertised', - default=False, action='store_true', - help=_( - 'List only those mailing lists that are publicly advertised')) - command_parser.add_argument( - '-n', '--names', - default=False, action='store_true', - help=_('Show also the list names')) - command_parser.add_argument( - '-d', '--descriptions', - default=False, action='store_true', - help=_('Show also the list descriptions')) - command_parser.add_argument( - '-q', '--quiet', - default=False, action='store_true', - help=_('Less verbosity')) - command_parser.add_argument( - '--domain', - action='append', help=_("""\ - List only those mailing lists hosted on the given domain, which - must be the email host name. Multiple -d options may be given. - """)) - - def process(self, args): - """See `ICLISubCommand`.""" - mailing_lists = [] - list_manager = getUtility(IListManager) - # Gather the matching mailing lists. - for fqdn_name in sorted(list_manager.names): - mlist = list_manager.get(fqdn_name) - if args.advertised and not mlist.advertised: - continue - domains = getattr(args, 'domain', None) - if domains and mlist.mail_host not in domains: - continue - mailing_lists.append(mlist) - # Maybe no mailing lists matched. - if len(mailing_lists) == 0: - if not args.quiet: - print(_('No matching mailing lists found')) - return - count = len(mailing_lists) # noqa: F841 - if not args.quiet: - print(_('$count matching mailing lists found:')) - # Calculate the longest identifier. - longest = 0 - output = [] - for mlist in mailing_lists: - if args.names: - identifier = '{} [{}]'.format( - mlist.fqdn_listname, mlist.display_name) - else: - identifier = mlist.fqdn_listname - longest = max(len(identifier), longest) - output.append((identifier, mlist.description)) - # Print it out. - if args.descriptions: - format_string = '{0:{2}} - {1:{3}}' - else: - format_string = '{0:{2}}' - for identifier, description in output: - print(format_string.format( - identifier, description, longest, 70 - longest)) + command = lists + + +@click.command( + cls=I18nCommand, + help=_("""\ + Create a mailing list. + + The 'fully qualified list name', i.e. the posting address of the mailing + list is required. It must be a valid email address and the domain must be + registered with Mailman. List names are forced to lower case.""")) +@click.option( + '--language', metavar='CODE', + help=_("""\ + Set the list's preferred language to CODE, which must be a registered two + letter language code.""")) +@click.option( + '--owner', '-o', 'owners', + multiple=True, metavar='OWNER', + help=_("""\ + Specify a list owner email address. If the address is not currently + registered with Mailman, the address is registered and linked to a user. + Mailman will send a confirmation message to the address, but it will also + send a list creation notice to the address. More than one owner can be + specified.""")) +@click.option( + '--notify/-no-notify', '-n/-N', + default=False, + help=_("""\ + Notify the list owner by email that their mailing list has been + created.""")) +@click.option( + '--quiet', '-q', + is_flag=True, default=False, + help=_('Print less output.')) +@click.option( + '--domain/--no-domain', '-d/-D', 'create_domain', + default=True, + help=_("""\ + Register the mailing list's domain if not yet registered. This is + the default behavior, but these options are provided for backward + compatibility. With -D do not register the mailing list's domain.""")) +@click.argument('fqdn_listname', metavar='LISTNAME') +@click.pass_context +def create(ctx, language, owners, notify, quiet, create_domain, fqdn_listname): + language_code = (language if language is not None + else system_preferences.preferred_language.code) + # Make sure that the selected language code is known. + if language_code not in getUtility(ILanguageManager).codes: + ctx.fail(_('Invalid language code: $language_code')) + # Check to see if the domain exists or not. + listname, at, domain = fqdn_listname.partition('@') + domain_manager = getUtility(IDomainManager) + if domain_manager.get(domain) is None and create_domain: + domain_manager.add(domain) + # Validate the owner email addresses. The problem with doing this check in + # create_list() is that you wouldn't be able to distinguish between an + # InvalidEmailAddressError for the list name or the owners. I suppose we + # could subclass that exception though. + if len(owners) > 0: + validator = getUtility(IEmailValidator) + invalid_owners = [owner for owner in owners + if not validator.is_valid(owner)] + if invalid_owners: + invalid = COMMASPACE.join(sorted(invalid_owners)) # noqa: F841 + ctx.fail(_('Illegal owner addresses: $invalid')) + try: + mlist = create_list(fqdn_listname, owners) + except InvalidEmailAddressError: + ctx.fail(_('Illegal list name: $fqdn_listname')) + except ListAlreadyExistsError: + ctx.fail(_('List already exists: $fqdn_listname')) + except BadDomainSpecificationError as domain: + ctx.fail(_('Undefined domain: $domain')) + # Find the language associated with the code, then set the mailing list's + # preferred language to that. + language_manager = getUtility(ILanguageManager) + with transaction(): + mlist.preferred_language = language_manager[language_code] + # Do the notification. + if not quiet: + print(_('Created mailing list: $mlist.fqdn_listname')) + if notify: + template = getUtility(ITemplateLoader).get( + 'domain:admin:notice:new-list', mlist) + text = wrap(expand(template, mlist, dict( + # For backward compatibility. + requestaddr=mlist.request_address, + siteowner=mlist.no_reply_address, + ))) + # Set the I18N language to the list's preferred language so the header + # will match the template language. Stashing and restoring the old + # translation context is just (healthy? :) paranoia. + with _.using(mlist.preferred_language.code): + msg = UserNotification( + owners, mlist.no_reply_address, + _('Your new mailing list: $fqdn_listname'), + text, mlist.preferred_language) + msg.send(mlist) @public @implementer(ICLISubCommand) class Create: - """Create a mailing list.""" - name = 'create' - - def add(self, parser, command_parser): - """See `ICLISubCommand`.""" - self.parser = parser - command_parser.add_argument( - '--language', - metavar='CODE', help=_("""\ - Set the list's preferred language to CODE, which must be a - registered two letter language code.""")) - command_parser.add_argument( - '-o', '--owner', - action='append', default=[], - dest='owners', metavar='OWNER', help=_("""\ - Specify a listowner email address. If the address is not - currently registered with Mailman, the address is registered and - linked to a user. Mailman will send a confirmation message to the - address, but it will also send a list creation notice to the - address. More than one owner can be specified.""")) - command_parser.add_argument( - '-n', '--notify', - default=False, action='store_true', - help=_("""\ - Notify the list owner by email that their mailing list has been - created.""")) - command_parser.add_argument( - '-q', '--quiet', - default=False, action='store_true', - help=_('Print less output.')) - domain_options = command_parser.add_mutually_exclusive_group() - domain_options.add_argument( - '-d', '--domain', - dest='domain', - default=True, action='store_true', - help=_("""\ - Register the mailing list's domain if not yet registered. This is - the default behavior, but these options are provided for backward - compatibility.""")) - domain_options.add_argument( - '-D', '--no-domain', - dest='domain', - default=False, action='store_false', - help=_("""\ - Do not register the mailing list's domain if not already - registered.""")) - # Required positional argument. - command_parser.add_argument( - 'listname', metavar='LISTNAME', nargs=1, - help=_("""\ - The 'fully qualified list name', i.e. the posting address of the - mailing list. It must be a valid email address and the domain - must be registered with Mailman. List names are forced to lower - case.""")) - - def process(self, args): - """See `ICLISubCommand`.""" - language_code = (args.language - if args.language is not None - else system_preferences.preferred_language.code) - # Make sure that the selected language code is known. - if language_code not in getUtility(ILanguageManager).codes: - self.parser.error(_('Invalid language code: $language_code')) - return - assert len(args.listname) == 1, ( - 'Unexpected positional arguments: %s' % args.listname) - # Check to see if the domain exists or not. - fqdn_listname = args.listname[0] - listname, at, domain = fqdn_listname.partition('@') - domain_manager = getUtility(IDomainManager) - if domain_manager.get(domain) is None and args.domain: - domain_manager.add(domain) - # Validate the owner email addresses. The problem with doing this - # check in create_list() is that you wouldn't be able to distinguish - # between an InvalidEmailAddressError for the list name or the - # owners. I suppose we could subclass that exception though. - if args.owners: - validator = getUtility(IEmailValidator) - invalid_owners = [owner for owner in args.owners - if not validator.is_valid(owner)] - if invalid_owners: - invalid = COMMASPACE.join(sorted(invalid_owners)) # noqa: F841 - self.parser.error(_('Illegal owner addresses: $invalid')) - return - try: - mlist = create_list(fqdn_listname, args.owners) - except InvalidEmailAddressError: - self.parser.error(_('Illegal list name: $fqdn_listname')) - return - except ListAlreadyExistsError: - self.parser.error(_('List already exists: $fqdn_listname')) - return - except BadDomainSpecificationError as domain: - self.parser.error(_('Undefined domain: $domain')) - return - # Find the language associated with the code, then set the mailing - # list's preferred language to that. - language_manager = getUtility(ILanguageManager) - with transaction(): - mlist.preferred_language = language_manager[language_code] - # Do the notification. - if not args.quiet: - print(_('Created mailing list: $mlist.fqdn_listname')) - if args.notify: - template = getUtility(ITemplateLoader).get( - 'domain:admin:notice:new-list', mlist) - text = wrap(expand(template, mlist, dict( - # For backward compatibility. - requestaddr=mlist.request_address, - siteowner=mlist.no_reply_address, - ))) - # Set the I18N language to the list's preferred language so the - # header will match the template language. Stashing and restoring - # the old translation context is just (healthy? :) paranoia. - with _.using(mlist.preferred_language.code): - msg = UserNotification( - args.owners, mlist.no_reply_address, - _('Your new mailing list: $fqdn_listname'), - text, mlist.preferred_language) - msg.send(mlist) + command = create + + +@click.command( + cls=I18nCommand, + help=_('Remove a mailing list.')) +@click.option( + '--quiet', '-q', + is_flag=True, default=False, + help=_('Suppress status messages')) +@click.argument('listspec') +def remove(quiet, listspec): + mlist = getUtility(IListManager).get(listspec) + if mlist is None: + if not quiet: + print(_('No such list matching spec: $listspec')) + sys.exit(0) + with transaction(): + remove_list(mlist) + if not quiet: + print(_('Removed list: $listspec')) @public @implementer(ICLISubCommand) class Remove: - """Remove a mailing list.""" - name = 'remove' - - def add(self, parser, command_parser): - """See `ICLISubCommand`.""" - command_parser.add_argument( - '-q', '--quiet', - default=False, action='store_true', - help=_('Suppress status messages')) - # Required positional argument. - command_parser.add_argument( - 'listname', metavar='LISTNAME', nargs=1, - help=_("""\ - The 'fully qualified list name', i.e. the posting address of the - mailing list.""")) - - @transactional - def process(self, args): - """See `ICLISubCommand`.""" - def log(message): - if not args.quiet: - print(message) - assert len(args.listname) == 1, ( - 'Unexpected positional arguments: %s' % args.listname) - fqdn_listname = args.listname[0] - mlist = getUtility(IListManager).get(fqdn_listname) - if mlist is None: - log(_('No such list: $fqdn_listname')) - return - else: - log(_('Removed list: $fqdn_listname')) - remove_list(mlist) + command = remove diff --git a/src/mailman/commands/cli_members.py b/src/mailman/commands/cli_members.py index a3e4be980..99df11488 100644 --- a/src/mailman/commands/cli_members.py +++ b/src/mailman/commands/cli_members.py @@ -17,9 +17,8 @@ """The 'members' subcommand.""" -import sys +import click -from contextlib import ExitStack from email.utils import formataddr, parseaddr from mailman.app.membership import add_member from mailman.core.i18n import _ @@ -29,208 +28,168 @@ from mailman.interfaces.listmanager import IListManager from mailman.interfaces.member import ( AlreadySubscribedError, DeliveryMode, DeliveryStatus, MemberRole) from mailman.interfaces.subscriptions import RequestRecord +from mailman.utilities.options import I18nCommand from operator import attrgetter from public import public from zope.component import getUtility from zope.interface import implementer -@public -@implementer(ICLISubCommand) -class Members: - """Manage list memberships. With no arguments, list all members.""" - - name = 'members' - - def add(self, parser, command_parser): - """See `ICLISubCommand`.""" - self.parser = parser - command_parser.add_argument( - '-a', '--add', - dest='input_filename', metavar='FILENAME', - help=_("""\ - Add all member addresses in FILENAME. FILENAME can be '-' to - indicate standard input. Blank lines and lines That start with a - '#' are ignored. Without this option, this command displays - mailing list members.""")) - command_parser.add_argument( - '-o', '--output', - dest='output_filename', metavar='FILENAME', - help=_("""Display output to FILENAME instead of stdout. FILENAME - can be '-' to indicate standard output.""")) - command_parser.add_argument( - '-R', '--role', - default=None, metavar='ROLE', - choices=('any', 'owner', 'moderator', 'nonmember', 'member', - 'administrator'), - help=_("""Display only members with a given ROLE. The role may be - 'any', 'member', 'nonmember', 'owner', 'moderator', or - 'administrator' (i.e. owners and moderators). If not - given, then delivery members are used. """)) - command_parser.add_argument( - '-r', '--regular', - default=None, action='store_true', - help=_('Display only regular delivery members.')) - command_parser.add_argument( - '-d', '--digest', - default=None, metavar='KIND', - # BAW 2010-01-23 summary digests are not really supported yet. - choices=('any', 'plaintext', 'mime'), - help=_("""Display only digest members of KIND. 'any' means any - digest type, 'plaintext' means only plain text (RFC 1153) type - digests, 'mime' means MIME type digests.""")) - command_parser.add_argument( - '-n', '--nomail', - default=None, metavar='WHY', - choices=('enabled', 'any', 'unknown' - 'byadmin', 'byuser', 'bybounces'), - help=_("""Display only members with a given delivery - status. 'enabled' means all members whose delivery is enabled, - 'any' means members whose delivery is disabled for any reason, - 'byuser' means that the member disabled their own delivery, - 'bybounces' means that delivery was disabled by the automated - bounce processor, 'byadmin' means delivery was disabled by the - list administrator or moderator, and 'unknown' means that delivery - was disabled for unknown (legacy) reasons.""")) - # Required positional argument. - command_parser.add_argument( - 'list', metavar='LIST', nargs=1, - help=_("""\ - The list to operate on. This can be the fully qualified list - name', i.e. the posting address of the mailing list or the - List-ID.""")) - command_parser.epilog = _( - """Display a mailing list's members, with filtering along various - criteria.""") - - def process(self, args): - """See `ICLISubCommand`.""" - assert len(args.list) == 1, 'Missing mailing list name' - list_spec = args.list[0] - list_manager = getUtility(IListManager) - if '@' in list_spec: - mlist = list_manager.get(list_spec) - else: - mlist = list_manager.get_by_list_id(list_spec) - if mlist is None: - self.parser.error(_('No such list: $list_spec')) - if args.input_filename is None: - self.display_members(mlist, args) - else: - self.add_members(mlist, args) - - def display_members(self, mlist, args): - """Display the members of a mailing list. +def display_members(ctx, mlist, role, regular, digest, nomail, outfp): + # Which type of digest recipients should we display? + if digest == 'any': + digest_types = [ + DeliveryMode.plaintext_digests, + DeliveryMode.mime_digests, + DeliveryMode.summary_digests, + ] + elif digest is not None: + digest_types = [DeliveryMode[digest + '_digests']] + else: + # Don't filter on digest type. + pass + # Which members with delivery disabled should we display? + if nomail is None: + # Don't filter on delivery status. + pass + elif nomail == 'byadmin': + status_types = [DeliveryStatus.by_moderator] + elif nomail.startswith('by'): + status_types = [DeliveryStatus['by_' + nomail[2:]]] + elif nomail == 'enabled': + status_types = [DeliveryStatus.enabled] + elif nomail == 'unknown': + status_types = [DeliveryStatus.unknown] + elif nomail == 'any': + status_types = [ + DeliveryStatus.by_user, + DeliveryStatus.by_bounces, + DeliveryStatus.by_moderator, + DeliveryStatus.unknown, + ] + else: # pragma: nocover + # click should enforce a valid nomail option. + raise AssertionError(nomail) + # Which roles should we display? + if role is None: + # By default, filter on members. + roster = mlist.members + elif role == 'administrator': + roster = mlist.administrators + elif role == 'any': + roster = mlist.subscribers + else: + # click should enforce a valid member role. + roster = mlist.get_roster(MemberRole[role]) + # Print; outfp will be either the file or stdout to print to. + addresses = list(roster.addresses) + if len(addresses) == 0: + print(_('$mlist.list_id has no members'), file=outfp) + return + for address in sorted(addresses, key=attrgetter('email')): + if regular: + member = roster.get_member(address.email) + if member.delivery_mode != DeliveryMode.regular: + continue + if digest is not None: + member = roster.get_member(address.email) + if member.delivery_mode not in digest_types: + continue + if nomail is not None: + member = roster.get_member(address.email) + if member.delivery_status not in status_types: + continue + print(formataddr((address.display_name, address.original_email)), + file=outfp) - :param mlist: The mailing list to operate on. - :type mlist: `IMailingList` - :param args: The command line arguments. - :type args: `argparse.Namespace` - """ - if args.digest == 'any': - digest_types = [ - DeliveryMode.plaintext_digests, - DeliveryMode.mime_digests, - DeliveryMode.summary_digests, - ] - elif args.digest is not None: - digest_types = [DeliveryMode[args.digest + '_digests']] - else: - # Don't filter on digest type. - pass - if args.nomail is None: - # Don't filter on delivery status. - pass - elif args.nomail == 'byadmin': - status_types = [DeliveryStatus.by_moderator] - elif args.nomail.startswith('by'): - status_types = [DeliveryStatus['by_' + args.nomail[2:]]] - elif args.nomail == 'enabled': - status_types = [DeliveryStatus.enabled] - elif args.nomail == 'unknown': - status_types = [DeliveryStatus.unknown] - elif args.nomail == 'any': - status_types = [ - DeliveryStatus.by_user, - DeliveryStatus.by_bounces, - DeliveryStatus.by_moderator, - DeliveryStatus.unknown, - ] - else: - self.parser.error(_('Unknown delivery status: $args.nomail')) +@transactional +def add_members(mlist, infp): + for line in infp: + # Ignore blank lines and lines that start with a '#'. + if line.startswith('#') or len(line.strip()) == 0: + continue + # Parse the line and ensure that the values are unicodes. + display_name, email = parseaddr(line) + try: + add_member(mlist, + RequestRecord(email, display_name, + DeliveryMode.regular, + mlist.preferred_language.code)) + except AlreadySubscribedError: + # It's okay if the address is already subscribed, just print a + # warning and continue. + if not display_name: + print(_('Already subscribed (skipping): $email')) + else: + print(_('Already subscribed (skipping): ' + '$display_name <$email>')) - if args.role is None: - # By default, filter on members. - roster = mlist.members - elif args.role == 'administrator': - roster = mlist.administrators - elif args.role == 'any': - roster = mlist.subscribers - else: - try: - roster = mlist.get_roster(MemberRole[args.role]) - except KeyError: - self.parser.error(_('Unknown member role: $args.role')) - with ExitStack() as resources: - if args.output_filename == '-' or args.output_filename is None: - fp = sys.stdout - else: - fp = resources.enter_context( - open(args.output_filename, 'w', encoding='utf-8')) - addresses = list(roster.addresses) - if len(addresses) == 0: - print(_('$mlist.list_id has no members'), file=fp) - return - for address in sorted(addresses, key=attrgetter('email')): - if args.regular: - member = roster.get_member(address.email) - if member.delivery_mode != DeliveryMode.regular: - continue - if args.digest is not None: - member = roster.get_member(address.email) - if member.delivery_mode not in digest_types: - continue - if args.nomail is not None: - member = roster.get_member(address.email) - if member.delivery_status not in status_types: - continue - print( - formataddr((address.display_name, address.original_email)), - file=fp) +@click.command( + cls=I18nCommand, + help=_("""\ + Display a mailing list's members, with filtering along various criteria. + """)) +@click.option( + '--add', '-a', 'infp', metavar='FILENAME', + type=click.File(encoding='utf-8'), + help=_("""\ + Add all member addresses in FILENAME. FILENAME can be '-' to + indicate standard input. Blank lines and lines That start with a + '#' are ignored. Without this option, this command displays + mailing list members.""")) +@click.option( + '--output', '-o', 'outfp', metavar='FILENAME', + type=click.File(mode='w', encoding='utf-8', atomic=True), + help=_("""Display output to FILENAME instead of stdout. FILENAME + can be '-' to indicate standard output.""")) +@click.option( + '--role', '-R', + type=click.Choice(('any', 'owner', 'moderator', 'nonmember', 'member', + 'administrator')), + help=_("""\ + Display only members with a given ROLE. The role may be 'any', 'member', + 'nonmember', 'owner', 'moderator', or 'administrator' (i.e. owners and + moderators). If not given, then delivery members are used. """)) +@click.option( + '--regular', '-r', + is_flag=True, default=False, + help=_('Display only regular delivery members.')) +@click.option( + '--digest', '-d', metavar='kind', + # baw 2010-01-23 summary digests are not really supported yet. + type=click.Choice(('any', 'plaintext', 'mime')), + help=_("""\ + Display only digest members of kind. 'any' means any digest type, + 'plaintext' means only plain text (rfc 1153) type digests, 'mime' means + mime type digests.""")) +@click.option( + '--nomail', '-n', metavar='WHY', + type=click.Choice(('enabled', 'any', 'unknown', + 'byadmin', 'byuser', 'bybounces')), + help=_("""\ + Display only members with a given delivery status. 'enabled' means all + members whose delivery is enabled, 'any' means members whose delivery is + disabled for any reason, 'byuser' means that the member disabled their own + delivery, 'bybounces' means that delivery was disabled by the automated + bounce processor, 'byadmin' means delivery was disabled by the list + administrator or moderator, and 'unknown' means that delivery was disabled + for unknown (legacy) reasons.""")) +@click.argument('listspec') +@click.pass_context +def members(ctx, infp, outfp, role, regular, digest, nomail, listspec): + mlist = getUtility(IListManager).get(listspec) + if mlist is None: + ctx.fail(_('No such list: $listspec')) + if infp is None: + display_members(ctx, mlist, role, regular, digest, nomail, outfp) + else: + add_members(mlist, infp) - @transactional - def add_members(self, mlist, args): - """Add the members in a file to a mailing list. - :param mlist: The mailing list to operate on. - :type mlist: `IMailingList` - :param args: The command line arguments. - :type args: `argparse.Namespace` - """ - with ExitStack() as resources: - if args.input_filename == '-': - fp = sys.stdin - else: - fp = resources.enter_context( - open(args.input_filename, 'r', encoding='utf-8')) - for line in fp: - # Ignore blank lines and lines that start with a '#'. - if line.startswith('#') or len(line.strip()) == 0: - continue - # Parse the line and ensure that the values are unicodes. - display_name, email = parseaddr(line) - try: - add_member(mlist, - RequestRecord(email, display_name, - DeliveryMode.regular, - mlist.preferred_language.code)) - except AlreadySubscribedError: - # It's okay if the address is already subscribed, just - # print a warning and continue. - if not display_name: - print(_('Already subscribed (skipping): $email')) - else: - print(_('Already subscribed (skipping): ' - '$display_name <$email>')) +@public +@implementer(ICLISubCommand) +class Members: + name = 'members' + command = members diff --git a/src/mailman/commands/cli_qfile.py b/src/mailman/commands/cli_qfile.py index b6702643c..f7fc0b338 100644 --- a/src/mailman/commands/cli_qfile.py +++ b/src/mailman/commands/cli_qfile.py @@ -17,70 +17,70 @@ """Getting information out of a qfile.""" +import click import pickle from mailman.core.i18n import _ from mailman.interfaces.command import ICLISubCommand from mailman.utilities.interact import interact +from mailman.utilities.options import I18nCommand from pprint import PrettyPrinter from public import public from zope.interface import implementer # This is deliberately called 'm' for use with --interactive. -m = [] +m = None + + +@click.command( + cls=I18nCommand, + help=_('Get information out of a queue file.')) +@click.option( + '--print/--no-print', '-p/-n', 'doprint', + default=True, + help=_("""\ + Don't attempt to pretty print the object. This is useful if there is some + problem with the object and you just want to get an unpickled + representation. Useful with 'bin/dumpdb -i '. In that case, the + list of unpickled objects will be left in a variable called 'm'.""")) +@click.option( + '--interactive', '-i', + is_flag=True, default=False, + help=_("""\ + Start an interactive Python session, with a variable called 'm' + containing the list of unpickled objects.""")) +@click.argument('qfile') +def qfile(doprint, interactive, qfile): + global m + # Reinitialize 'm' every time this command is run. This isn't normally + # needed for command line use, but is important for the test suite. + m = [] + printer = PrettyPrinter(indent=4) + with open(qfile, 'rb') as fp: + while True: + try: + m.append(pickle.load(fp)) + except EOFError: + break + if doprint: + print(_('[----- start pickle -----]')) + for i, obj in enumerate(m): + count = i + 1 + print(_('<----- start object $count ----->')) + if isinstance(obj, (bytes, str)): + print(obj) + else: + printer.pprint(obj) + print(_('[----- end pickle -----]')) + count = len(m) # noqa: F841 + banner = _("Number of objects found (see the variable 'm'): $count") + if interactive: + interact(banner=banner) @public @implementer(ICLISubCommand) class QFile: - """Get information out of a queue file.""" - name = 'qfile' - - def add(self, parser, command_parser): - """See `ICLISubCommand`.""" - self.parser = parser - command_parser.add_argument( - '-n', '--noprint', - dest='doprint', default=True, action='store_false', - help=_("""\ - Don't attempt to pretty print the object. This is useful if there - is some problem with the object and you just want to get an - unpickled representation. Useful with 'bin/dumpdb -i '. In - that case, the list of unpickled objects will be left in a - variable called 'm'.""")) - command_parser.add_argument( - '-i', '--interactive', - default=False, action='store_true', - help=_("""\ - Start an interactive Python session, with a variable called 'm' - containing the list of unpickled objects.""")) - command_parser.add_argument( - 'qfile', metavar='FILENAME', nargs=1, - help=_('The queue file to dump.')) - - def process(self, args): - """See `ICLISubCommand`.""" - printer = PrettyPrinter(indent=4) - assert len(args.qfile) == 1, 'Wrong number of positional arguments' - with open(args.qfile[0], 'rb') as fp: - while True: - try: - m.append(pickle.load(fp)) - except EOFError: - break - if args.doprint: - print(_('[----- start pickle -----]')) - for i, obj in enumerate(m): - count = i + 1 - print(_('<----- start object $count ----->')) - if isinstance(obj, (bytes, str)): - print(obj) - else: - printer.pprint(obj) - print(_('[----- end pickle -----]')) - count = len(m) # noqa: F841 - banner = _("The variable 'm' contains $count objects") - if args.interactive: - interact(banner=banner) + command = qfile diff --git a/src/mailman/commands/cli_status.py b/src/mailman/commands/cli_status.py index 8d29b058d..84b3ed6ab 100644 --- a/src/mailman/commands/cli_status.py +++ b/src/mailman/commands/cli_status.py @@ -17,43 +17,44 @@ """The `mailman status` subcommand.""" +import sys +import click import socket from mailman.bin.master import WatcherState, master_state from mailman.core.i18n import _ from mailman.interfaces.command import ICLISubCommand +from mailman.utilities.options import I18nCommand from public import public from zope.interface import implementer +@click.command( + cls=I18nCommand, + help=_('Show the current running status of the Mailman system.')) +def status(): + status, lock = master_state() + if status is WatcherState.none: + message = _('GNU Mailman is not running') + elif status is WatcherState.conflict: + hostname, pid, tempfile = lock.details + message = _('GNU Mailman is running (master pid: $pid)') + elif status is WatcherState.stale_lock: + hostname, pid, tempfile = lock.details + message = _('GNU Mailman is stopped (stale pid: $pid)') + else: + hostname, pid, tempfile = lock.details + fqdn_name = socket.getfqdn() # noqa: F841 + assert status is WatcherState.host_mismatch, ( + 'Invalid enum value: %s' % status) + message = _('GNU Mailman is in an unexpected state ' + '($hostname != $fqdn_name)') + print(message) + sys.exit(status.value) + + @public @implementer(ICLISubCommand) class Status: - """Status of the Mailman system.""" - name = 'status' - - def add(self, parser, command_parser): - """See `ICLISubCommand`.""" - pass - - def process(self, args): - """See `ICLISubCommand`.""" - status, lock = master_state() - if status is WatcherState.none: - message = _('GNU Mailman is not running') - elif status is WatcherState.conflict: - hostname, pid, tempfile = lock.details - message = _('GNU Mailman is running (master pid: $pid)') - elif status is WatcherState.stale_lock: - hostname, pid, tempfile = lock.details - message = _('GNU Mailman is stopped (stale pid: $pid)') - else: - hostname, pid, tempfile = lock.details - fqdn_name = socket.getfqdn() # noqa: F841 - assert status is WatcherState.host_mismatch, ( - 'Invalid enum value: %s' % status) - message = _('GNU Mailman is in an unexpected state ' - '($hostname != $fqdn_name)') - print(message) - return status.value + command = status diff --git a/src/mailman/commands/cli_unshunt.py b/src/mailman/commands/cli_unshunt.py index 0c1d0a0dd..98f57b838 100644 --- a/src/mailman/commands/cli_unshunt.py +++ b/src/mailman/commands/cli_unshunt.py @@ -18,45 +18,44 @@ """The 'unshunt' command.""" import sys +import click from mailman.config import config from mailman.core.i18n import _ from mailman.interfaces.command import ICLISubCommand +from mailman.utilities.options import I18nCommand from public import public from zope.interface import implementer +@click.command( + cls=I18nCommand, + help=_('Unshunt messages.')) +@click.option( + '--discard', '-d', + is_flag=True, default=False, + help=_("""\ + Discard all shunted messages instead of moving them back to their original + queue.""")) +def unshunt(discard): + shunt_queue = config.switchboards['shunt'] + shunt_queue.recover_backup_files() + for filebase in shunt_queue.files: + try: + msg, msgdata = shunt_queue.dequeue(filebase) + which_queue = msgdata.get('whichq', 'in') + if not discard: + config.switchboards[which_queue].enqueue(msg, msgdata) + except Exception as error: + print(_('Cannot unshunt message $filebase, skipping:\n$error'), + file=sys.stderr) + else: + # Unlink the .bak file left by dequeue() + shunt_queue.finish(filebase) + + @public @implementer(ICLISubCommand) class Unshunt: - """Unshunt messages.""" - name = 'unshunt' - - def add(self, parser, command_parser): - """See `ICLISubCommand`.""" - self.parser = parser - command_parser.add_argument( - '-d', '--discard', - default=False, action='store_true', - help=_("""\ - Discard all shunted messages instead of moving them back to their - original queue.""")) - - def process(self, args): - """See `ICLISubCommand`.""" - shunt_queue = config.switchboards['shunt'] - shunt_queue.recover_backup_files() - - for filebase in shunt_queue.files: - try: - msg, msgdata = shunt_queue.dequeue(filebase) - which_queue = msgdata.get('whichq', 'in') - if not args.discard: - config.switchboards[which_queue].enqueue(msg, msgdata) - except Exception: - print(_('Cannot unshunt message $filebase, skipping:\n$error'), - file=sys.stderr) - else: - # Unlink the .bak file left by dequeue() - shunt_queue.finish(filebase) + command = unshunt diff --git a/src/mailman/commands/cli_version.py b/src/mailman/commands/cli_version.py index 6f6ed621a..2f59ea197 100644 --- a/src/mailman/commands/cli_version.py +++ b/src/mailman/commands/cli_version.py @@ -17,24 +17,25 @@ """The Mailman version.""" +import click + +from mailman.core.i18n import _ from mailman.interfaces.command import ICLISubCommand +from mailman.utilities.options import I18nCommand from mailman.version import MAILMAN_VERSION_FULL from public import public from zope.interface import implementer +@click.command( + cls=I18nCommand, + help=_("Display Mailman's version.")) +def version(): + print(MAILMAN_VERSION_FULL) + + @public @implementer(ICLISubCommand) class Version: - """Mailman's version.""" - name = 'version' - - def add(self, parser, command_parser): - """See `ICLISubCommand`.""" - # No extra options. - pass - - def process(self, args): - """See `ICLISubCommand`.""" - print(MAILMAN_VERSION_FULL) + command = version diff --git a/src/mailman/commands/cli_withlist.py b/src/mailman/commands/cli_withlist.py index 0644e40f3..b93d9b153 100644 --- a/src/mailman/commands/cli_withlist.py +++ b/src/mailman/commands/cli_withlist.py @@ -19,6 +19,7 @@ import re import sys +import click from contextlib import ExitStack, suppress from functools import partial @@ -29,6 +30,7 @@ from mailman.interfaces.command import ICLISubCommand from mailman.interfaces.listmanager import IListManager from mailman.utilities.interact import DEFAULT_BANNER, interact from mailman.utilities.modules import call_name +from mailman.utilities.options import I18nCommand from public import public from string import Template from traceback import print_exc @@ -42,7 +44,7 @@ m = None r = None -def _start_ipython1(overrides, banner, *, debug=False): +def start_ipython1(overrides, banner, *, debug=False): try: from IPython.frontend.terminal.embed import InteractiveShellEmbed except ImportError: @@ -52,7 +54,7 @@ def _start_ipython1(overrides, banner, *, debug=False): return InteractiveShellEmbed.instance(banner1=banner, user_ns=overrides) -def _start_ipython4(overrides, banner, *, debug=False): +def start_ipython4(overrides, banner, *, debug=False): try: from IPython.terminal.embed import InteractiveShellEmbed shell = InteractiveShellEmbed.instance() @@ -63,239 +65,245 @@ def _start_ipython4(overrides, banner, *, debug=False): return partial(shell.mainloop, local_ns=overrides, display_banner=banner) -@public -@implementer(ICLISubCommand) -class Withlist: - """Operate on a mailing list. +def start_ipython(overrides, banner, debug): + shell = None + for starter in (start_ipython4, start_ipython1): + shell = starter(overrides, banner, debug=debug) + if shell is not None: + shell() + break + else: + print(_('ipython is not available, set use_ipython to no')) - For detailed help, see --details - """ - name = 'withlist' +def start_python(overrides, banner): + # Set the tab completion. + with ExitStack() as resources: + try: # pragma: nocover + import readline, rlcompleter # noqa: F401, E401 + except ImportError: # pragma: nocover + print(_('readline not available'), file=sys.stderr) + pass + else: + readline.parse_and_bind('tab: complete') + history_file_template = config.shell.history_file.strip() + if len(history_file_template) > 0: + # Expand substitutions. + substitutions = { + key.lower(): value + for key, value in config.paths.items() + } + history_file = Template( + history_file_template).safe_substitute(substitutions) + with suppress(FileNotFoundError): + readline.read_history_file(history_file) + resources.callback( + readline.write_history_file, + history_file) + sys.ps1 = config.shell.prompt + ' ' + interact(upframe=False, banner=banner, overrides=overrides) - def add(self, parser, command_parser): - """See `ICLISubCommand`.""" - self.parser = parser - command_parser.add_argument( - '-i', '--interactive', - default=None, action='store_true', help=_("""\ - Leaves you at an interactive prompt after all other processing is - complete. This is the default unless the --run option is - given.""")) - command_parser.add_argument( - '-r', '--run', - 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. - """)) - command_parser.add_argument( - '--details', - default=False, action='store_true', - help=_('Print detailed instructions on using this command.')) - # Optional positional argument. - command_parser.add_argument( - 'listname', metavar='LISTNAME', nargs='?', - help=_("""\ - The 'fully qualified list name', i.e. the posting address of the - mailing list to inject the message into. This can be a Python - regular expression, in which case all mailing lists whose posting - address matches will be processed. To use a regular expression, - LISTNAME must start with a ^ (and the matching is done with - re.match(). LISTNAME cannot be a regular expression unless --run - is given.""")) - - def process(self, args): - """See `ICLISubCommand`.""" - global m, r - banner = DEFAULT_BANNER - # Detailed help wanted? - if args.details: - self._details() - return - # Interactive is the default unless --run was given. - if args.interactive is None: - interactive = (args.run is None) + +def do_interactive(ctx, banner): + global m, r + overrides = dict( + m=m, + commit=config.db.commit, + abort=config.db.abort, + config=config, + getUtility=getUtility + ) + # Bootstrap some useful names into the namespace, mostly to make + # the component architecture and interfaces easily available. + for module_name in sys.modules: + if not module_name.startswith('mailman.interfaces.'): + continue + module = sys.modules[module_name] + for name in module.__all__: + overrides[name] = getattr(module, name) + banner = config.shell.banner + '\n' + ( + banner if isinstance(banner, str) else '') + try: + use_ipython = as_boolean(config.shell.use_ipython) + except ValueError: + if config.shell.use_ipython == 'debug': + use_ipython = True + debug = True else: - interactive = args.interactive - # List name cannot be a regular expression if --run is not given. - if args.listname and args.listname.startswith('^') and not args.run: - self.parser.error(_('Regular expression requires --run')) + print(_('Invalid value for [shell]use_python: {}').format( + config.shell.use_ipython), file=sys.stderr) return - # Handle --run. - list_manager = getUtility(IListManager) - if args.run: - # When the module and the callable have the same name, a shorthand - # without the dot is allowed. - dotted_name = (args.run if '.' in args.run - else '{0}.{0}'.format(args.run)) - if args.listname is None: - self.parser.error(_('--run requires a mailing list name')) - return - elif args.listname.startswith('^'): - r = {} - cre = re.compile(args.listname, re.IGNORECASE) - for mailing_list in list_manager.mailing_lists: - if cre.match(mailing_list.fqdn_listname): - results = call_name(dotted_name, mailing_list) - r[mailing_list.fqdn_listname] = results - else: - fqdn_listname = args.listname - m = list_manager.get(fqdn_listname) - if m is None: - self.parser.error(_('No such list: $fqdn_listname')) - return - r = call_name(dotted_name, m) - else: - # Not --run. - if args.listname is not None: - fqdn_listname = args.listname - m = list_manager.get(fqdn_listname) - if m is None: - self.parser.error(_('No such list: $fqdn_listname')) - return - banner = _( - "The variable 'm' is the $fqdn_listname mailing list") - # All other processing is finished; maybe go into interactive mode. - if interactive: - overrides = dict( - m=m, - commit=config.db.commit, - abort=config.db.abort, - config=config, - getUtility=getUtility - ) - # Bootstrap some useful names into the namespace, mostly to make - # the component architecture and interfaces easily available. - for module_name in sys.modules: - if not module_name.startswith('mailman.interfaces.'): - continue - module = sys.modules[module_name] - for name in module.__all__: - overrides[name] = getattr(module, name) - banner = config.shell.banner + '\n' + ( - banner if isinstance(banner, str) else '') - try: - use_ipython = as_boolean(config.shell.use_ipython) - except ValueError: - if config.shell.use_ipython == 'debug': - use_ipython = True - debug = True - else: - raise - else: - debug = False - if use_ipython: - self._start_ipython(overrides, banner, debug) - else: - self._start_python(overrides, banner) - - def _start_ipython(self, overrides, banner, debug): - shell = None - for starter in (_start_ipython4, _start_ipython1): - shell = starter(overrides, banner, debug=debug) - if shell is not None: - shell() - break - else: - print(_('ipython is not available, set use_ipython to no')) - - def _start_python(self, overrides, banner): - # Set the tab completion. - with ExitStack() as resources: - try: # pragma: no cover - import readline, rlcompleter # noqa: F401, E401 - except ImportError: # pragma: no cover - print(_('readline not available'), file=sys.stderr) - pass - else: - readline.parse_and_bind('tab: complete') - history_file_template = config.shell.history_file.strip() - if len(history_file_template) > 0: - # Expand substitutions. - substitutions = { - key.lower(): value - for key, value in config.paths.items() - } - history_file = Template( - history_file_template).safe_substitute(substitutions) - with suppress(FileNotFoundError): - readline.read_history_file(history_file) - resources.callback( - readline.write_history_file, - history_file) - sys.ps1 = config.shell.prompt + ' ' - interact(upframe=False, banner=banner, overrides=overrides) - - def _details(self): - """Print detailed usage.""" - # Split this up into paragraphs for easier translation. - print(_("""\ + else: + debug = False + if use_ipython: + start_ipython(overrides, banner, debug) + else: + start_python(overrides, banner) + + +def show_detailed_help(ctx, param, value): + if not value: + # Returning None tells click to process the rest of the command line. + return + # Split this up into paragraphs for easier translation. + print(_("""\ This script provides you with a general framework for interacting with a mailing list.""")) - print() - print(_("""\ + print() + print(_("""\ There are two ways to use this script: interactively or programmatically. Using it interactively allows you to play with, examine and modify a mailing list from Python's interactive interpreter. When running interactively, the variable 'm' will be available in the global namespace. It will reference the mailing list object.""")) - print() - print(_("""\ + print() + print(_("""\ Programmatically, you can write a function to operate on a mailing list, and this script will take care of the housekeeping (see below for examples). In that case, the general usage syntax is: - % mailman withlist [options] listname [args ...]""")) - print() - print(_("""\ +% mailman withlist [options] -l listspec [args ...] + +where `listspec` is either the posting address of the mailing list +(e.g. ant@example.com), or the List-ID (e.g. ant.example.com).""")) + print() + print(_("""\ Here's an example of how to use the --run option. Say you have a file in the Mailman installation directory called 'listaddr.py', with the following two functions: - def listaddr(mlist): - print mlist.posting_address +def listaddr(mlist): + print(mlist.posting_address) - def requestaddr(mlist): - print mlist.request_address""")) - print() - print(_("""\ +def requestaddr(mlist): + print(mlist.request_address) + +All 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.""")) + print() + print(_("""\ You can print the list's posting address by running the following from the command line: - % mailman withlist -r listaddr mylist@example.com - Importing listaddr ... - Running listaddr.listaddr() ... - mylist@example.com""")) - print() - print(_("""\ +% mailman withlist -r listaddr -l ant@example.com +Importing listaddr ... +Running listaddr.listaddr() ... +ant@example.com""")) + print() + print(_("""\ And you can print the list's request address by running: - % mailman withlist -r listaddr.requestaddr mylist - Importing listaddr ... - Running listaddr.requestaddr() ... - mylist-request@example.com""")) - print() - print(_("""\ +% mailman withlist -r listaddr.requestaddr -l ant@example.com +Importing listaddr ... +Running listaddr.requestaddr() ... +ant-request@example.com""")) + print() + print(_("""\ As another example, say you wanted to change the display name for a particular mailing list. You could put the following function in a file called -'change.pw': +`change.py`: - def change(mlist, display_name): - mlist.display_name = display_name - # Required to save changes to the database. - commit() +def change(mlist, display_name): + mlist.display_name = display_name and run this from the command line: - % mailman withlist -r change mylist@example.com 'My List'""")) +% mailman withlist -r change -l ant@example.com 'My List' + +Note that you do not have to explicitly commit any database transactions, as +Mailman will do this for you (assuming no errors occured).""")) + sys.exit(0) + + +@click.command( + cls=I18nCommand, + help=_("""\ + Operate on a mailing list. + + For detailed help, see --details + """)) +@click.option( + '--interactive', '-i', + is_flag=True, default=None, + help=_("""\ + Leaves you at an interactive prompt after all other processing is complete. + This is the default unless the --run option is given.""")) +@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. + """)) +@click.option( + '--details', + is_flag=True, default=False, is_eager=True, expose_value=False, + callback=show_detailed_help, + help=_('Print detailed instructions and exit.')) +# Optional positional argument. +@click.option( + '--listspec', '-l', + help=_("""\ + A specification of the mailing list to operate on. This may be the posting + address of the list, or its List-ID. The argument can also be a Python + regular expression, in which case it is matched against both the posting + address and List-ID of all mailing lists. To use a regular expression, + LISTSPEC must start with a ^ (and the matching is done with re.match(). + LISTSPEC cannot be a regular expression unless --run is given.""")) +@click.argument('run_args', nargs=-1) +@click.pass_context +def shell(ctx, interactive, run, listspec, run_args): + global m, r + banner = DEFAULT_BANNER + # Interactive is the default unless --run was given. + interactive = (run is None) if interactive is None else interactive + # List name cannot be a regular expression if --run is not given. + if listspec and listspec.startswith('^') and not run: + ctx.fail(_('Regular expression requires --run')) + # Handle --run. + list_manager = getUtility(IListManager) + if run: + # When the module and the callable have the same name, a shorthand + # 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 = {} + cre = re.compile(listspec, re.IGNORECASE) + for mlist in list_manager.mailing_lists: + if cre.match(mlist.fqdn_listname) or cre.match(mlist.list_id): + results = call_name(dotted_name, mlist, *run_args) + r[mlist.list_id] = results + else: + m = list_manager.get(listspec) + if m is None: + ctx.fail(_('No such list: $listspec')) + r = call_name(dotted_name, m, *run_args) + else: + # Not --run. + if listspec is not None: + m = list_manager.get(listspec) + if m is None: + ctx.fail(_('No such list: $listspec')) + banner = _("The variable 'm' is the $listspec mailing list") + # All other processing is finished; maybe go into interactive mode. + if interactive: + do_interactive(ctx, banner) + + +@public +@implementer(ICLISubCommand) +class Withlist: + name = 'withlist' + command = shell @public class Shell(Withlist): - """An alias for `withlist`.""" - name = 'shell' + command = shell diff --git a/src/mailman/commands/docs/aliases.rst b/src/mailman/commands/docs/aliases.rst index 31fda21e4..bea1e311a 100644 --- a/src/mailman/commands/docs/aliases.rst +++ b/src/mailman/commands/docs/aliases.rst @@ -8,10 +8,7 @@ server. Generally these files are automatically kept up-to-date when mailing lists are created or removed, but you might occasionally need to manually regenerate the file. The ``mailman aliases`` command does this. - >>> class FakeArgs: - ... directory = None - >>> from mailman.commands.cli_aliases import Aliases - >>> command = Aliases() + >>> command = cli('mailman.commands.cli_aliases.aliases') For example, connecting Mailman to Postfix is generally done through the LMTP protocol. Mailman starts an LMTP server and Postfix delivers messages to @@ -28,17 +25,20 @@ generation. ... lmtp_port: 24 ... """) +.. + Clean up. + >>> ignore = cleanups.callback(config.pop, 'postfix') + Let's create a mailing list and then display the transport map for it. We'll write the appropriate files to a temporary directory. :: + >>> mlist = create_list('ant@example.com') + >>> import os, shutil, tempfile >>> output_directory = tempfile.mkdtemp() >>> ignore = cleanups.callback(shutil.rmtree, output_directory) - - >>> FakeArgs.directory = output_directory - >>> mlist = create_list('test@example.com') - >>> command.process(FakeArgs) + >>> command('mailman aliases --directory ' + output_directory) For Postfix, there are two files in the output directory. @@ -54,15 +54,15 @@ The transport map file contains all the aliases for the mailing list. ... print(fp.read()) # AUTOMATICALLY GENERATED BY MAILMAN ON ... ... - test@example.com lmtp:[lmtp.example.com]:24 - test-bounces@example.com lmtp:[lmtp.example.com]:24 - test-confirm@example.com lmtp:[lmtp.example.com]:24 - test-join@example.com lmtp:[lmtp.example.com]:24 - test-leave@example.com lmtp:[lmtp.example.com]:24 - test-owner@example.com lmtp:[lmtp.example.com]:24 - test-request@example.com lmtp:[lmtp.example.com]:24 - test-subscribe@example.com lmtp:[lmtp.example.com]:24 - test-unsubscribe@example.com lmtp:[lmtp.example.com]:24 + ant@example.com lmtp:[lmtp.example.com]:24 + ant-bounces@example.com lmtp:[lmtp.example.com]:24 + ant-confirm@example.com lmtp:[lmtp.example.com]:24 + ant-join@example.com lmtp:[lmtp.example.com]:24 + ant-leave@example.com lmtp:[lmtp.example.com]:24 + ant-owner@example.com lmtp:[lmtp.example.com]:24 + ant-request@example.com lmtp:[lmtp.example.com]:24 + ant-subscribe@example.com lmtp:[lmtp.example.com]:24 + ant-unsubscribe@example.com lmtp:[lmtp.example.com]:24 The relay domains file contains a list of all the domains. @@ -72,7 +72,3 @@ The relay domains file contains a list of all the domains. # AUTOMATICALLY GENERATED BY MAILMAN ON ... ... example.com example.com - -.. - Clean up. - >>> config.pop('postfix') diff --git a/src/mailman/commands/docs/conf.rst b/src/mailman/commands/docs/conf.rst index f465aab53..1a6a4679d 100644 --- a/src/mailman/commands/docs/conf.rst +++ b/src/mailman/commands/docs/conf.rst @@ -10,17 +10,12 @@ Mailman's configuration is divided in multiple sections which contain multiple key-value pairs. The ``mailman conf`` command allows you to display a specific key-value pair, or several key-value pairs. - >>> class FakeArgs: - ... key = None - ... section = None - ... output = None - >>> from mailman.commands.cli_conf import Conf - >>> command = Conf() + >>> command = cli('mailman.commands.cli_conf.conf') To get a list of all key-value pairs of any section, you need to call the command without any options. - >>> command.process(FakeArgs) + >>> command('mailman conf') [antispam] header_checks: ... [logging.bounce] level: info @@ -30,8 +25,7 @@ command without any options. You can list all the key-value pairs of a specific section. - >>> FakeArgs.section = 'shell' - >>> command.process(FakeArgs) + >>> command('mailman conf --section shell') [shell] banner: Welcome to the GNU Mailman shell [shell] history_file: [shell] prompt: >>> @@ -40,9 +34,7 @@ You can list all the key-value pairs of a specific section. You can also pass a key and display all key-value pairs matching the given key, along with the names of the corresponding sections. - >>> FakeArgs.section = None - >>> FakeArgs.key = 'path' - >>> command.process(FakeArgs) + >>> command('mailman conf --key path') [logging.archiver] path: mailman.log [logging.bounce] path: bounce.log [logging.config] path: mailman.log @@ -61,9 +53,7 @@ key, along with the names of the corresponding sections. If you specify both a section and a key, you will get the corresponding value. - >>> FakeArgs.section = 'mailman' - >>> FakeArgs.key = 'site_owner' - >>> command.process(FakeArgs) + >>> command('mailman conf --section mailman --key site_owner') noreply@example.com diff --git a/src/mailman/commands/docs/control.rst b/src/mailman/commands/docs/control.rst index b268b50a4..446c3652f 100644 --- a/src/mailman/commands/docs/control.rst +++ b/src/mailman/commands/docs/control.rst @@ -13,29 +13,22 @@ All we care about is the master process; normally it starts a bunch of runners, but we don't care about any of them, so write a test configuration file for the master that disables all the runners. - >>> from mailman.commands.tests.test_control import make_config + >>> from mailman.commands.tests.test_cli_control import make_config + >>> make_config(cleanups) Starting ======== - >>> from mailman.commands.cli_control import Start - >>> start = Start() - - >>> class FakeArgs: - ... force = False - ... run_as_user = True - ... quiet = False - ... config = make_config() - >>> args = FakeArgs() + >>> command = cli('mailman.commands.cli_control.start') Starting the daemons prints a useful message and starts the master watcher process in the background. - >>> start.process(args) + >>> command('mailman start') Starting Mailman's master runner - >>> from mailman.commands.tests.test_control import find_master + >>> from mailman.commands.tests.test_cli_control import find_master The process exists, and its pid is available in a run time file. @@ -51,34 +44,12 @@ You can also stop the master watcher process from the command line, which stops all the child processes too. :: - >>> from mailman.commands.cli_control import Stop - >>> stop = Stop() - >>> stop.process(args) + >>> command = cli('mailman.commands.cli_control.stop') + >>> command('mailman stop') Shutting down Mailman's master runner - >>> from datetime import datetime, timedelta - >>> import os - >>> import time - >>> import errno - >>> def bury_master(): - ... until = timedelta(seconds=2) + datetime.now() - ... while datetime.now() < until: - ... time.sleep(0.1) - ... try: - ... os.kill(pid, 0) - ... os.waitpid(pid, os.WNOHANG) - ... except OSError as error: - ... if error.errno == errno.ESRCH: - ... # The process has exited. - ... print('Master process went bye bye') - ... return - ... else: - ... raise - ... else: - ... raise AssertionError('Master process lingered') - - >>> bury_master() - Master process went bye bye - - -XXX We need tests for restart (SIGUSR1) and reopen (SIGHUP). +.. + # Clean up. + >>> from mailman.commands.tests.test_cli_control import ( + ... kill_with_extreme_prejudice) + >>> kill_with_extreme_prejudice(pid) diff --git a/src/mailman/commands/docs/create.rst b/src/mailman/commands/docs/create.rst index 12e3a1c95..c80ff7a3d 100644 --- a/src/mailman/commands/docs/create.rst +++ b/src/mailman/commands/docs/create.rst @@ -4,34 +4,19 @@ Command line list creation A system administrator can create mailing lists by the command line. - >>> class FakeArgs: - ... language = None - ... owners = [] - ... quiet = False - ... domain = True - ... listname = None - ... notify = False + >>> command = cli('mailman.commands.cli_lists.create') -You cannot create a mailing list in an unknown domain. +You can prevent creation of a mailing list in an unknown domain. - >>> from mailman.commands.cli_lists import Create - >>> command = Create() - - >>> class FakeParser: - ... def error(self, message): - ... print(message) - >>> command.parser = FakeParser() - - >>> FakeArgs.domain = False - >>> FakeArgs.listname = ['test@example.xx'] - >>> command.process(FakeArgs) - Undefined domain: example.xx + >>> command('mailman create --no-domain ant@example.xx') + Usage: create [OPTIONS] LISTNAME + + Error: Undefined domain: example.xx By default, Mailman will create the domain if it doesn't exist. - >>> FakeArgs.domain = True - >>> command.process(FakeArgs) - Created mailing list: test@example.xx + >>> command('mailman create ant@example.xx') + Created mailing list: ant@example.xx Now both the domain and the mailing list exist in the database. :: @@ -39,36 +24,20 @@ Now both the domain and the mailing list exist in the database. >>> from mailman.interfaces.listmanager import IListManager >>> from zope.component import getUtility >>> list_manager = getUtility(IListManager) - >>> list_manager.get('test@example.xx') - + >>> list_manager.get('ant@example.xx') + >>> from mailman.interfaces.domain import IDomainManager >>> getUtility(IDomainManager).get('example.xx') -You can prevent the creation of the domain in existing domains by using the -``-D`` or ``--no-domain`` flag. Although the ``--no-domain`` flag is not -required when domain already exists it can be used to force an error when -domain doesn't exist. - - >>> FakeArgs.domain = False - >>> FakeArgs.listname = ['test1@example.com'] - >>> command.process(FakeArgs) - Created mailing list: test1@example.com - - >>> list_manager.get('test1@example.com') - - The command can also operate quietly. :: - >>> FakeArgs.quiet = True - >>> FakeArgs.listname = ['test2@example.com'] - >>> command.process(FakeArgs) - - >>> mlist = list_manager.get('test2@example.com') + >>> command('mailman create --quiet bee@example.com') + >>> mlist = list_manager.get('bee@example.com') >>> mlist - + Setting the owner @@ -83,32 +52,29 @@ But you can specify an owner address on the command line when you create the mailing list. :: - >>> FakeArgs.quiet = False - >>> FakeArgs.listname = ['test4@example.com'] - >>> FakeArgs.owners = ['foo@example.org'] - >>> command.process(FakeArgs) - Created mailing list: test4@example.com + >>> command('mailman create --owner anne@example.com cat@example.com') + Created mailing list: cat@example.com - >>> mlist = list_manager.get('test4@example.com') + >>> mlist = list_manager.get('cat@example.com') >>> dump_list(repr(address) for address in mlist.owners.addresses) - + You can even specify more than one address for the owners. :: - >>> FakeArgs.owners = ['foo@example.net', - ... 'bar@example.net', - ... 'baz@example.net'] - >>> FakeArgs.listname = ['test5@example.com'] - >>> command.process(FakeArgs) - Created mailing list: test5@example.com + >>> command('mailman create ' + ... '--owner anne@example.com ' + ... '--owner bart@example.com ' + ... '--owner cate@example.com ' + ... 'dog@example.com') + Created mailing list: dog@example.com - >>> mlist = list_manager.get('test5@example.com') + >>> mlist = list_manager.get('dog@example.com') >>> from operator import attrgetter >>> dump_list(repr(address) for address in mlist.owners.addresses) - - - + + + Setting the language @@ -118,25 +84,21 @@ You can set the default language for the new mailing list when you create it. The language must be known to Mailman. :: - >>> FakeArgs.listname = ['test3@example.com'] - >>> FakeArgs.language = 'ee' - >>> command.process(FakeArgs) - Invalid language code: ee + >>> command('mailman create --language xx ewe@example.com') + Usage: create [OPTIONS] LISTNAME + + Error: Invalid language code: xx >>> from mailman.interfaces.languages import ILanguageManager - >>> getUtility(ILanguageManager).add('ee', 'iso-8859-1', 'Freedonian') - + >>> getUtility(ILanguageManager).add('xx', 'iso-8859-1', 'Freedonian') + - >>> FakeArgs.quiet = False - >>> FakeArgs.listname = ['test3@example.com'] - >>> FakeArgs.language = 'fr' - >>> command.process(FakeArgs) - Created mailing list: test3@example.com + >>> command('mailman create --language xx ewe@example.com') + Created mailing list: ewe@example.com - >>> mlist = list_manager.get('test3@example.com') + >>> mlist = list_manager.get('ewe@example.com') >>> print(mlist.preferred_language) - - >>> FakeArgs.language = None + Notifications @@ -144,10 +106,13 @@ Notifications When told to, Mailman will notify the list owners of their new mailing list. - >>> FakeArgs.listname = ['test6@example.com'] - >>> FakeArgs.notify = True - >>> command.process(FakeArgs) - Created mailing list: test6@example.com + >>> command('mailman create ' + ... '--notify ' + ... '--owner anne@example.com ' + ... '--owner bart@example.com ' + ... '--owner cate@example.com ' + ... 'fly@example.com') + Created mailing list: fly@example.com The notification message is in the virgin queue. :: @@ -161,19 +126,19 @@ The notification message is in the virgin queue. ... print(message.msg.as_string()) MIME-Version: 1.0 ... - Subject: Your new mailing list: test6@example.com + Subject: Your new mailing list: fly@example.com From: noreply@example.com - To: foo@example.net, bar@example.net, baz@example.net + To: anne@example.com, bart@example.com, cate@example.com ... - The mailing list 'test6@example.com' has just been created for you. + The mailing list 'fly@example.com' has just been created for you. The following is some basic information about your mailing list. There is an email-based interface for users (not administrators) of your list; you can get info about using it by sending a message with just the word 'help' as subject or in the body, to: - test6-request@example.com + fly-request@example.com Please address all questions to noreply@example.com. diff --git a/src/mailman/commands/docs/import.rst b/src/mailman/commands/docs/import.rst index 86a31d6ff..5c5883921 100644 --- a/src/mailman/commands/docs/import.rst +++ b/src/mailman/commands/docs/import.rst @@ -2,55 +2,52 @@ Importing list data =================== -If you have the config.pck file for a version 2.1 mailing list, you can import -that into an existing mailing list in Mailman 3.0. -:: +If you have the ``config.pck`` file for a version 2.1 mailing list, you can +import that into an existing mailing list in Mailman 3.0. - >>> from mailman.commands.cli_import import Import21 - >>> command = Import21() + >>> command = cli('mailman.commands.cli_import.import21') - >>> class FakeArgs: - ... listname = None - ... pickle_file = None +You must specify the mailing list you are importing into, and it must exist. - >>> class FakeParser: - ... def error(self, message): - ... print(message) - >>> command.parser = FakeParser() + >>> command('mailman import21') + Usage: ... [OPTIONS] LISTSPEC PICKLE_FILE + + Error: Missing argument "listspec". -You must specify the mailing list you are importing into, and it must exist. -:: +You must also specify a pickle file to import. - >>> command.process(FakeArgs) - List name is required + >>> command('mailman import21 import@example.com') + Usage: ... [OPTIONS] LISTSPEC PICKLE_FILE + + Error: Missing argument "pickle_file". - >>> FakeArgs.listname = ['import@example.com'] - >>> command.process(FakeArgs) - No such list: import@example.com +Too bad the list doesn't exist. + + >>> from pkg_resources import resource_filename + >>> pickle_file = resource_filename('mailman.testing', 'config.pck') + >>> command('mailman import21 import@example.com ' + pickle_file) + Usage: ... [OPTIONS] LISTSPEC PICKLE_FILE + + Error: No such list: import@example.com When the mailing list exists, you must specify a real pickle file to import from. :: >>> mlist = create_list('import@example.com') - >>> command.process(FakeArgs) - config.pck file is required - - >>> FakeArgs.pickle_file = [__file__] - >>> command.process(FakeArgs) - Not a Mailman 2.1 configuration file: .../import.rst + >>> transaction.commit() + >>> command('mailman import21 import@example.com ' + __file__) + Usage: ... [OPTIONS] LISTSPEC PICKLE_FILE + + Error: Not a Mailman 2.1 configuration file: .../import.rst'... Now we can import the test pickle file. As a simple illustration of the -import, the mailing list's 'real name' has changed. +import, the mailing list's "real name" will change. :: - >>> from pkg_resources import resource_filename - >>> FakeArgs.pickle_file = [ - ... resource_filename('mailman.testing', 'config.pck')] - >>> print(mlist.display_name) Import - >>> command.process(FakeArgs) + >>> command('mailman import21 import@example.com ' + pickle_file) >>> print(mlist.display_name) Test diff --git a/src/mailman/commands/docs/info.rst b/src/mailman/commands/docs/info.rst index 6fce70783..219143cab 100644 --- a/src/mailman/commands/docs/info.rst +++ b/src/mailman/commands/docs/info.rst @@ -6,15 +6,9 @@ You can get information about Mailman's environment by using the command line script ``mailman info``. By default, the info is printed to standard output. :: - >>> from mailman.commands.cli_info import Info - >>> command = Info() + >>> command = cli('mailman.commands.cli_info.info') - >>> class FakeArgs: - ... output = None - ... verbose = None - >>> args = FakeArgs() - - >>> command.process(args) + >>> command('mailman info') GNU Mailman 3... Python ... ... @@ -28,8 +22,7 @@ By passing in the ``-o/--output`` option, you can print the info to a file. >>> from mailman.config import config >>> import os >>> output_path = os.path.join(config.VAR_DIR, 'output.txt') - >>> args.output = output_path - >>> command.process(args) + >>> command('mailman info -o ' + output_path) >>> with open(output_path) as fp: ... print(fp.read()) GNU Mailman 3... @@ -44,8 +37,6 @@ By passing in the ``-o/--output`` option, you can print the info to a file. You can also get more verbose information, which contains a list of the file system paths that Mailman is using. - >>> args.output = None - >>> args.verbose = True >>> config.create_paths = False >>> config.push('fhs', """ ... [mailman] @@ -57,7 +48,7 @@ system paths that Mailman is using. The `Filesystem Hierarchy Standard`_ layout is the same everywhere by definition. - >>> command.process(args) + >>> command('mailman info --verbose') GNU Mailman 3... Python ... ... diff --git a/src/mailman/commands/docs/inject.rst b/src/mailman/commands/docs/inject.rst index 163f62886..3db22013a 100644 --- a/src/mailman/commands/docs/inject.rst +++ b/src/mailman/commands/docs/inject.rst @@ -4,29 +4,12 @@ Command line message injection You can inject a message directly into a queue directory via the command line. -:: - - >>> from mailman.commands.cli_inject import Inject - >>> command = Inject() - - >>> class FakeArgs: - ... queue = None - ... show = False - ... filename = None - ... listname = None - ... keywords = [] - >>> args = FakeArgs() - >>> class FakeParser: - ... def error(self, message): - ... print(message) - >>> command.parser = FakeParser() + >>> command = cli('mailman.commands.cli_inject.inject') It's easy to find out which queues are available. -:: - >>> args.show = True - >>> command.process(args) + >>> command('mailman inject --show') Available queues: archive bad @@ -41,49 +24,40 @@ It's easy to find out which queues are available. shunt virgin - >>> args.show = False - Usually, the text of the message to inject is in a file. - >>> import os, tempfile - >>> fd, filename = tempfile.mkstemp() - >>> with os.fdopen(fd, 'w') as fp: + >>> from tempfile import NamedTemporaryFile + >>> filename = cleanups.enter_context(NamedTemporaryFile()).name + >>> with open(filename, 'w', encoding='utf-8') as fp: ... print("""\ ... From: aperson@example.com - ... To: test@example.com + ... To: ant@example.com ... Subject: testing ... Message-ID: ... ... This is a test message. ... """, file=fp) -However, the mailing list name is always required. +Create a mailing list to inject this message into. - >>> args.filename = filename - >>> command.process(args) - List name is required + >>> mlist = create_list('ant@example.com') + >>> transaction.commit() -Let's provide a list name and try again. -:: +The mailing list's incoming queue is empty. - >>> mlist = create_list('test@example.com') - >>> transaction.commit() >>> from mailman.testing.helpers import get_queue_messages - >>> get_queue_messages('in') [] - >>> args.listname = ['test@example.com'] - >>> command.process(args) -By default, the incoming queue is used. -:: +By default, messages are injected into the incoming queue. + >>> command('mailman inject --filename ' + filename + ' ant@example.com') >>> items = get_queue_messages('in') >>> len(items) 1 >>> print(items[0].msg.as_string()) From: aperson@example.com - To: test@example.com + To: ant@example.com Subject: testing Message-ID: ... Date: ... @@ -92,17 +66,19 @@ By default, the incoming queue is used. +And the message is destined for ant@example.com. + >>> dump_msgdata(items[0].msgdata) _parsemsg : False - listid : test.example.com - original_size: 253 + listid : ant.example.com + original_size: 252 version : 3 But a different queue can be specified on the command line. :: - >>> args.queue = 'virgin' - >>> command.process(args) + >>> command('mailman inject --queue virgin --filename ' + + ... filename + ' ant@example.com') >>> get_queue_messages('in') [] @@ -111,7 +87,7 @@ But a different queue can be specified on the command line. 1 >>> print(items[0].msg.as_string()) From: aperson@example.com - To: test@example.com + To: ant@example.com Subject: testing Message-ID: ... Date: ... @@ -122,8 +98,8 @@ But a different queue can be specified on the command line. >>> dump_msgdata(items[0].msgdata) _parsemsg : False - listid : test.example.com - original_size: 253 + listid : ant.example.com + original_size: 252 version : 3 @@ -133,29 +109,22 @@ Standard input The message text can also be provided on standard input. :: - >>> from io import StringIO - - >>> standard_in = StringIO(str("""\ + >>> stdin = """\ ... From: bperson@example.com - ... To: test@example.com + ... To: ant@example.com ... Subject: another test ... Message-ID: ... ... This is another test message. - ... """)) - - >>> import sys - >>> sys.stdin = standard_in - >>> args.filename = '-' - >>> args.queue = None + ... """ - >>> command.process(args) + >>> command('mailman inject --filename - ant@example.com', input=stdin) >>> items = get_queue_messages('in') >>> len(items) 1 >>> print(items[0].msg.as_string()) From: bperson@example.com - To: test@example.com + To: ant@example.com Subject: another test Message-ID: ... Date: ... @@ -166,14 +135,10 @@ The message text can also be provided on standard input. >>> dump_msgdata(items[0].msgdata) _parsemsg : False - listid : test.example.com - original_size: 261 + listid : ant.example.com + original_size: 260 version : 3 -.. Clean up. - >>> sys.stdin = sys.__stdin__ - >>> args.filename = filename - Metadata ======== @@ -183,39 +148,14 @@ pairs get added to the message metadata dictionary when the message is injected. :: - >>> args = FakeArgs() - >>> args.filename = filename - >>> args.listname = ['test@example.com'] - >>> args.keywords = ['foo=one', 'bar=two'] - >>> command.process(args) + >>> command('mailman inject --filename ' + filename + + ... ' -m foo=one -m bar=two ant@example.com') >>> items = get_queue_messages('in') >>> dump_msgdata(items[0].msgdata) _parsemsg : False bar : two foo : one - listid : test.example.com - original_size: 253 + listid : ant.example.com + original_size: 252 version : 3 - - -Errors -====== - -It is an error to specify a queue that doesn't exist. - - >>> args.queue = 'xxbogusxx' - >>> command.process(args) - No such queue: xxbogusxx - -It is also an error to specify a mailing list that doesn't exist. - - >>> args.queue = None - >>> args.listname = ['bogus'] - >>> command.process(args) - No such list: bogus - - -.. - # Clean up the tempfile. - >>> os.remove(filename) diff --git a/src/mailman/commands/docs/lists.rst b/src/mailman/commands/docs/lists.rst index 04e0d744d..317d06930 100644 --- a/src/mailman/commands/docs/lists.rst +++ b/src/mailman/commands/docs/lists.rst @@ -6,16 +6,8 @@ A system administrator can display all the mailing lists via the command line. When there are no mailing lists, a helpful message is displayed. :: - >>> class FakeArgs: - ... advertised = False - ... names = False - ... descriptions = False - ... quiet = False - ... domains = None - - >>> from mailman.commands.cli_lists import Lists - >>> command = Lists() - >>> command.process(FakeArgs) + >>> command = cli('mailman.commands.cli_lists.lists') + >>> command('mailman lists') No matching mailing lists found When there are a few mailing lists, they are shown in alphabetical order by @@ -36,7 +28,7 @@ their fully qualified list names, with a description. >>> mlist_3 = create_list('list-one@example.net') >>> mlist_3.description = 'List One in Example.Net' - >>> command.process(FakeArgs) + >>> command('mailman lists') 3 matching mailing lists found: list-one@example.com list-one@example.net @@ -49,8 +41,7 @@ Names You can display the mailing list names with their posting addresses, using the ``--names/-n`` switch. - >>> FakeArgs.names = True - >>> command.process(FakeArgs) + >>> command('mailman lists --names') 3 matching mailing lists found: list-one@example.com [List-one] list-one@example.net [List-one] @@ -63,8 +54,7 @@ Descriptions You can also display the mailing list descriptions, using the ``--descriptions/-d`` option. - >>> FakeArgs.descriptions = True - >>> command.process(FakeArgs) + >>> command('mailman lists --descriptions --names') 3 matching mailing lists found: list-one@example.com [List-one] - List One list-one@example.net [List-one] - List One in Example.Net @@ -72,8 +62,7 @@ You can also display the mailing list descriptions, using the Maybe you want the descriptions but not the names. - >>> FakeArgs.names = False - >>> command.process(FakeArgs) + >>> command('mailman lists --descriptions --no-names') 3 matching mailing lists found: list-one@example.com - List One list-one@example.net - List One in Example.Net @@ -85,9 +74,7 @@ Less verbosity There's also a ``--quiet/-q`` switch which reduces the verbosity a bit. - >>> FakeArgs.quiet = True - >>> FakeArgs.descriptions = False - >>> command.process(FakeArgs) + >>> command('mailman lists --quiet') list-one@example.com list-one@example.net list-two@example.com @@ -99,24 +86,20 @@ Specific domain You can narrow the search down to a specific domain with the --domain option. A helpful message is displayed if no matching domains are given. - >>> FakeArgs.quiet = False - >>> FakeArgs.domain = ['example.org'] - >>> command.process(FakeArgs) + >>> command('mailman lists --domain example.org') No matching mailing lists found But if a matching domain is given, only mailing lists in that domain are shown. - >>> FakeArgs.domain = ['example.net'] - >>> command.process(FakeArgs) + >>> command('mailman lists --domain example.net') 1 matching mailing lists found: list-one@example.net -More than one --domain argument can be given; then all mailing lists in +More than one ``--domain`` argument can be given; then all mailing lists in matching domains are shown. - >>> FakeArgs.domain = ['example.com', 'example.net'] - >>> command.process(FakeArgs) + >>> command('mailman lists --domain example.com --domain example.net') 3 matching mailing lists found: list-one@example.com list-one@example.net @@ -126,16 +109,13 @@ matching domains are shown. Advertised lists ================ -Mailing lists can be 'advertised' meaning their existence is public -knowledge. Non-advertised lists are considered private. Display through the -command line can select on this attribute. +Mailing lists can be "advertised" meaning their existence is public knowledge. +Non-advertised lists are considered private. Display through the command line +can select on this attribute. :: - >>> FakeArgs.domain = [] - >>> FakeArgs.advertised = True >>> mlist_1.advertised = False - - >>> command.process(FakeArgs) + >>> command('mailman lists --advertised') 2 matching mailing lists found: list-one@example.net list-two@example.com diff --git a/src/mailman/commands/docs/members.rst b/src/mailman/commands/docs/members.rst index c40afb122..4c266d63f 100644 --- a/src/mailman/commands/docs/members.rst +++ b/src/mailman/commands/docs/members.rst @@ -4,22 +4,8 @@ Managing members The ``mailman members`` command allows a site administrator to display, add, and remove members from a mailing list. -:: - - >>> ant = create_list('ant@example.com') - >>> class FakeArgs: - ... input_filename = None - ... output_filename = None - ... list = [] - ... regular = False - ... digest = None - ... nomail = None - ... role = None - >>> args = FakeArgs() - - >>> from mailman.commands.cli_members import Members - >>> command = Members() + >>> command = cli('mailman.commands.cli_members.members') Listing members @@ -28,11 +14,12 @@ Listing members You can list all the members of a mailing list by calling the command with no options. To start with, there are no members of the mailing list. - >>> args.list = ['ant.example.com'] - >>> command.process(args) + >>> ant = create_list('ant@example.com') + >>> command('mailman members ant.example.com') ant.example.com has no members Once the mailing list add some members, they will be displayed. +:: >>> from mailman.testing.helpers import subscribe >>> subscribe(ant, 'Anne', email='anne@example.com') @@ -41,7 +28,8 @@ Once the mailing list add some members, they will be displayed. >>> subscribe(ant, 'Bart', email='bart@example.com') on ant@example.com as MemberRole.member> - >>> command.process(args) + + >>> command('mailman members ant.example.com') Anne Person Bart Person @@ -51,74 +39,69 @@ Members are displayed in alphabetical order based on their address. >>> subscribe(ant, 'Anne', email='anne@aaaxample.com') on ant@example.com as MemberRole.member> - >>> command.process(args) + + >>> command('mailman members ant.example.com') Anne Person Anne Person Bart Person You can also output this list to a file. +:: >>> from tempfile import NamedTemporaryFile - >>> with NamedTemporaryFile() as outfp: - ... args.output_filename = outfp.name - ... command.process(args) - ... with open(args.output_filename) as infp: - ... print(infp.read()) + >>> filename = cleanups.enter_context(NamedTemporaryFile()).name + + >>> command('mailman members -o ' + filename + ' ant.example.com') + >>> with open(filename, 'r', encoding='utf-8') as fp: + ... print(fp.read()) Anne Person Anne Person Bart Person - >>> args.output_filename = None The output file can also be standard out. - >>> args.output_filename = '-' - >>> command.process(args) + >>> command('mailman members -o - ant.example.com') Anne Person Anne Person Bart Person - >>> args.output_filename = None Filtering on delivery mode -------------------------- You can limit output to just the regular non-digest members... +:: - >>> from mailman.interfaces.member import DeliveryMode - >>> args.regular = True >>> member = ant.members.get_member('anne@example.com') + >>> from mailman.interfaces.member import DeliveryMode >>> member.preferences.delivery_mode = DeliveryMode.plaintext_digests - >>> command.process(args) + + >>> command('mailman members --regular ant.example.com') Anne Person Bart Person ...or just the digest members. Furthermore, you can either display all digest members... +:: >>> member = ant.members.get_member('anne@aaaxample.com') >>> member.preferences.delivery_mode = DeliveryMode.mime_digests - >>> args.regular = False - >>> args.digest = 'any' - >>> command.process(args) + + >>> command('mailman members --digest any ant.example.com') Anne Person Anne Person ...just plain text digest members... - >>> args.digest = 'plaintext' - >>> command.process(args) + >>> command('mailman members --digest plaintext ant.example.com') Anne Person -...just MIME digest members. +...or just MIME digest members. :: - >>> args.digest = 'mime' - >>> command.process(args) + >>> command('mailman members --digest mime ant.example.com') Anne Person - # Reset for following tests. - >>> args.digest = None - Filtering on delivery status ---------------------------- @@ -142,48 +125,39 @@ status is enabled... >>> member = subscribe(ant, 'Elle', email='elle@example.com') >>> member.preferences.delivery_status = DeliveryStatus.by_bounces - >>> args.nomail = 'enabled' - >>> command.process(args) + >>> command('mailman members --nomail enabled ant.example.com') Anne Person Dave Person ...or disabled by the user... - >>> args.nomail = 'byuser' - >>> command.process(args) + >>> command('mailman members --nomail byuser ant.example.com') Bart Person ...or disabled by the list administrator (or moderator)... - >>> args.nomail = 'byadmin' - >>> command.process(args) + >>> command('mailman members --nomail byadmin ant.example.com') Anne Person ...or by the bounce processor... - >>> args.nomail = 'bybounces' - >>> command.process(args) + >>> command('mailman members --nomail bybounces ant.example.com') Elle Person ...or for unknown (legacy) reasons. - >>> args.nomail = 'unknown' - >>> command.process(args) + >>> command('mailman members --nomail unknown ant.example.com') Cris Person You can also display all members who have delivery disabled for any reason. :: - >>> args.nomail = 'any' - >>> command.process(args) + >>> command('mailman members --nomail any ant.example.com') Anne Person Bart Person Cris Person Elle Person - # Reset for following tests. - >>> args.nomail = None - Adding members ============== @@ -194,16 +168,14 @@ need a file containing email addresses and full names that can be parsed by :: >>> bee = create_list('bee@example.com') - >>> with NamedTemporaryFile('w', buffering=1, encoding='utf-8') as fp: - ... for address in ('aperson@example.com', - ... 'Bart Person ', - ... 'cperson@example.com (Cate Person)', - ... ): - ... print(address, file=fp) - ... fp.flush() - ... args.input_filename = fp.name - ... args.list = ['bee.example.com'] - ... command.process(args) + >>> with open(filename, 'w', encoding='utf-8') as fp: + ... print("""\ + ... aperson@example.com + ... Bart Person + ... cperson@example.com (Cate Person) + ... """, file=fp) + + >>> command('mailman members --add ' + filename + ' bee.example.com') >>> from operator import attrgetter >>> dump_list(bee.members.addresses, key=attrgetter('email')) @@ -215,22 +187,12 @@ You can also specify ``-`` as the filename, in which case the addresses are taken from standard input. :: - >>> from io import StringIO - >>> fp = StringIO() - >>> for address in ('dperson@example.com', - ... 'Elly Person ', - ... 'fperson@example.com (Fred Person)', - ... ): - ... print(address, file=fp) - >>> args.input_filename = '-' - >>> filepos = fp.seek(0) - >>> import sys - >>> try: - ... stdin = sys.stdin - ... sys.stdin = fp - ... command.process(args) - ... finally: - ... sys.stdin = stdin + >>> stdin = """\ + ... dperson@example.com + ... Elly Person + ... fperson@example.com (Fred Person) + ... """ + >>> command('mailman members --add - bee.example.com', input=stdin) >>> dump_list(bee.members.addresses, key=attrgetter('email')) aperson@example.com @@ -243,16 +205,15 @@ taken from standard input. Blank lines and lines that begin with '#' are ignored. :: - >>> with NamedTemporaryFile('w', buffering=1, encoding='utf-8') as fp: - ... for address in ('gperson@example.com', - ... '# hperson@example.com', - ... ' ', - ... '', - ... 'iperson@example.com', - ... ): - ... print(address, file=fp) - ... args.input_filename = fp.name - ... command.process(args) + >>> with open(filename, 'w', encoding='utf-8') as fp: + ... print("""\ + ... gperson@example.com + ... # hperson@example.com + ... + ... iperson@example.com + ... """, file=fp) + + >>> command('mailman members --add ' + filename + ' bee.example.com') >>> dump_list(bee.members.addresses, key=attrgetter('email')) aperson@example.com @@ -268,14 +229,14 @@ Addresses which are already subscribed are ignored, although a warning is printed. :: - >>> with NamedTemporaryFile('w', buffering=1, encoding='utf-8') as fp: - ... for address in ('gperson@example.com', - ... 'aperson@example.com', - ... 'jperson@example.com', - ... ): - ... print(address, file=fp) - ... args.input_filename = fp.name - ... command.process(args) + >>> with open(filename, 'w', encoding='utf-8') as fp: + ... print("""\ + ... gperson@example.com + ... aperson@example.com + ... jperson@example.com + ... """, file=fp) + + >>> command('mailman members --add ' + filename + ' bee.example.com') Already subscribed (skipping): gperson@example.com Already subscribed (skipping): aperson@example.com @@ -296,8 +257,7 @@ Displaying members With no arguments, the command displays all members of the list. - >>> args.input_filename = None - >>> command.process(args) + >>> command('mailman members bee.example.com') aperson@example.com Bart Person Cate Person diff --git a/src/mailman/commands/docs/qfile.rst b/src/mailman/commands/docs/qfile.rst index e097ebf97..4b0d887b7 100644 --- a/src/mailman/commands/docs/qfile.rst +++ b/src/mailman/commands/docs/qfile.rst @@ -5,8 +5,6 @@ Dumping queue files The ``qfile`` command dumps the contents of a queue pickle file. This is especially useful when you have shunt files you want to inspect. -XXX Test the interactive operation of qfile - Pretty printing =============== @@ -15,20 +13,14 @@ By default, the ``qfile`` command pretty prints the contents of a queue pickle file to standard output. :: - >>> from mailman.commands.cli_qfile import QFile - >>> command = QFile() - - >>> class FakeArgs: - ... interactive = False - ... doprint = True - ... qfile = [] + >>> command = cli('mailman.commands.cli_qfile.qfile') Let's say Mailman shunted a message file. :: >>> msg = message_from_string("""\ ... From: aperson@example.com - ... To: test@example.com + ... To: ant@example.com ... Subject: Uh oh ... ... I borkeded Mailman. @@ -43,12 +35,11 @@ Once we've figured out the file name of the shunted message, we can print it. >>> from os.path import join >>> qfile = join(shuntq.queue_directory, basename + '.pck') - >>> FakeArgs.qfile = [qfile] - >>> command.process(FakeArgs) + >>> command('mailman qfile ' + qfile) [----- start pickle -----] <----- start object 1 -----> From: aperson@example.com - To: test@example.com + To: ant@example.com Subject: Uh oh I borkeded Mailman. @@ -60,5 +51,4 @@ Once we've figured out the file name of the shunted message, we can print it. Maybe we don't want to print the contents of the file though, in case we want to enter the interactive prompt. - >>> FakeArgs.doprint = False - >>> command.process(FakeArgs) + >>> command('mailman qfile --no-print ' + qfile) diff --git a/src/mailman/commands/docs/remove.rst b/src/mailman/commands/docs/remove.rst index c534741f3..f13d8cacd 100644 --- a/src/mailman/commands/docs/remove.rst +++ b/src/mailman/commands/docs/remove.rst @@ -5,37 +5,26 @@ Command line list removal A system administrator can remove mailing lists by the command line. :: - >>> create_list('test@example.com') - + >>> create_list('ant@example.com') + + + >>> command = cli('mailman.commands.cli_lists.remove') + >>> command('mailman remove ant@example.com') + Removed list: ant@example.com >>> from mailman.interfaces.listmanager import IListManager >>> from zope.component import getUtility >>> list_manager = getUtility(IListManager) - >>> list_manager.get('test@example.com') - - - >>> class FakeArgs: - ... quiet = False - ... archives = False - ... listname = ['test@example.com'] - >>> args = FakeArgs() - - >>> from mailman.commands.cli_lists import Remove - >>> command = Remove() - >>> command.process(args) - Removed list: test@example.com - - >>> print(list_manager.get('test@example.com')) + >>> print(list_manager.get('ant@example.com')) None You can also remove lists quietly. :: - >>> create_list('test@example.com') - + >>> create_list('ant@example.com') + - >>> args.quiet = True - >>> command.process(args) + >>> command('mailman remove ant@example.com --quiet') - >>> print(list_manager.get('test@example.com')) + >>> print(list_manager.get('ant@example.com')) None diff --git a/src/mailman/commands/docs/shell.rst b/src/mailman/commands/docs/shell.rst new file mode 100644 index 000000000..545376e51 --- /dev/null +++ b/src/mailman/commands/docs/shell.rst @@ -0,0 +1,141 @@ +========================== +Operating on mailing lists +========================== + +The ``shell`` (alias: ``withlist``) command is a pretty powerful way to +operate on mailing lists from the command line. This command allows you to +interact with a list at a Python prompt, or process one or more mailing lists +through custom made Python functions. + + +Getting detailed help +===================== + +Because ``shell`` is so complex, you might want to read the detailed help. +:: + + >>> command = cli('mailman.commands.cli_withlist.shell') + + >>> command('mailman shell --details') + This script provides you with a general framework for interacting with a + mailing list. + ... + + +Running a function +================== + +By putting a Python function somewhere on your ``sys.path``, you can have +``shell`` call that function on a given mailing list. + + >>> import os, sys + >>> old_path = sys.path[:] + >>> sys.path.insert(0, config.VAR_DIR) + +.. cleanup + >>> ignore = cleanups.callback(setattr, sys, 'path', old_path) + +The function takes at least a single argument, the mailing list. +:: + + >>> with open(os.path.join(config.VAR_DIR, 'showme.py'), 'w') as fp: + ... print("""\ + ... def showme(mlist): + ... print("The list's name is", mlist.fqdn_listname) + ... + ... def displayname(mlist): + ... print("The list's display name is", mlist.display_name) + ... + ... def changeme(mlist, display_name): + ... mlist.display_name = display_name + ... """, file=fp) + +If the name of the function is the same as the module, then you only need to +name the function once. + + >>> mlist = create_list('ant@example.com') + >>> command('mailman shell -l ant@example.com --run showme') + The list's name is ant@example.com + +The function's name can also be different than the modules name. In that +case, just give the full module path name to the function you want to call. + + >>> command('mailman shell -l ant@example.com --run showme.displayname') + The list's display name is Ant + + +Passing arguments +================= + +Your function can also accept an arbitrary number of arguments. Every command +line argument after the callable name is passed as a positional argument to +the function. For example, to change the mailing list's display name, you can +do this:: + + >>> command('mailman shell -l ant@example.com --run showme.changeme ANT!') + >>> print(mlist.display_name) + ANT! + + +Multiple lists +============== + +You can run a command over more than one list by using a regular expression in +the ``listname`` argument. To indicate a regular expression is used, the +string must start with a caret. +:: + + >>> mlist_2 = create_list('badger@example.com') + >>> mlist_3 = create_list('badboys@example.com') + + >>> command('mailman shell --run showme.displayname -l ^.*example.com') + The list's display name is ANT! + The list's display name is Badboys + The list's display name is Badger + + >>> command('mailman shell --run showme.displayname -l ^bad.*') + The list's display name is Badboys + The list's display name is Badger + + >>> command('mailman shell --run showme.displayname -l ^foo') + + +Interactive use +=============== + +You can also get an interactive prompt which allows you to inspect a live +Mailman system directly. Through the ``mailman.cfg`` file, you can set the +prompt and banner, and you can choose between the standard Python REPL_ or +IPython. + +If the `GNU readline`_ library is available, it will be enabled automatically, +giving you command line editing and other features. You can also set the +``[shell]history_file`` variable in the ``mailman.cfg`` file and when the +normal Python REPL is used, your interactive commands will be written to and +read from this file. + +Note that the ``$PYTHONSTARTUP`` environment variable will also be honored if +set, and any file named by this variable will be read at start up time. It's +common practice to *also* enable GNU readline history in a ``$PYTHONSTARTUP`` +file and if you do this, be aware that it will interact badly with +``[shell]history_file``, causing your history to be written twice. To disable +this when using the interactive ``shell`` command, do something like:: + + $ PYTHONSTARTUP= mailman shell + +to temporarily unset the environment variable. + + +IPython +------- + +You can use IPython_ as the interactive shell by setting the +``[shell]use_ipython`` variables in your `mailman.cfg` file to ``yes``. +IPython must be installed and available on your system + +When using IPython, the ``[shell]history_file`` is not used. + + +.. _IPython: http://ipython.org/ +.. _REPL: https://en.wikipedia.org/wiki/REPL +.. _`GNU readline`: https://docs.python.org/3/library/readline.html diff --git a/src/mailman/commands/docs/status.rst b/src/mailman/commands/docs/status.rst index 7587157bc..1824e1717 100644 --- a/src/mailman/commands/docs/status.rst +++ b/src/mailman/commands/docs/status.rst @@ -6,32 +6,27 @@ The status of the Mailman master process can be queried from the command line. It's clear at this point that nothing is running. :: - >>> from mailman.commands.cli_status import Status - >>> status = Status() - - >>> class FakeArgs: - ... pass + >>> command = cli('mailman.commands.cli_status.status') The status is printed to stdout and a status code is returned. - >>> status.process(FakeArgs) + >>> command('mailman status') GNU Mailman is not running - 0 We can simulate the master starting up by acquiring its lock. >>> from flufl.lock import Lock >>> lock = Lock(config.LOCK_FILE) >>> lock.lock() + >>> ignore = cleanups.callback(lock.unlock, unconditionally=True) Getting the status confirms that the master is running. - >>> status.process(FakeArgs) + >>> command('mailman status') GNU Mailman is running (master pid: ... We shut down the master and confirm the status. - >>> lock.unlock() - >>> status.process(FakeArgs) + >>> lock.unlock(unconditionally=True) + >>> command('mailman status') GNU Mailman is not running - 0 diff --git a/src/mailman/commands/docs/unshunt.rst b/src/mailman/commands/docs/unshunt.rst index 9532ae029..fb2a4b602 100644 --- a/src/mailman/commands/docs/unshunt.rst +++ b/src/mailman/commands/docs/unshunt.rst @@ -7,11 +7,7 @@ the ``shunt`` queue. The ``unshunt`` command allows system administrators to manage the shunt queue. :: - >>> from mailman.commands.cli_unshunt import Unshunt - >>> command = Unshunt() - - >>> class FakeArgs: - ... discard = False + >>> command = cli('mailman.commands.cli_unshunt.unshunt') Let's say there is a message in the shunt queue. :: @@ -39,7 +35,7 @@ queue. >>> len(list(inq.files)) 0 - >>> command.process(FakeArgs) + >>> command('mailman unshunt') >>> from mailman.testing.helpers import get_queue_messages >>> items = get_queue_messages('in') @@ -77,7 +73,7 @@ queue. >>> len(list(shuntq.files)) 2 - >>> command.process(FakeArgs) + >>> command('mailman unshunt') >>> items = get_queue_messages('in') >>> len(items) 2 @@ -114,7 +110,7 @@ The queue that the message comes from is in message metadata. The message is automatically re-queued to the bounces queue. :: - >>> command.process(FakeArgs) + >>> command('mailman unshunt') >>> len(list(shuntq.files)) 0 >>> items = get_queue_messages('bounces') @@ -145,8 +141,7 @@ If you don't care about the shunted messages, just discard them. ... """) >>> base_name = shuntq.enqueue(msg, {}) - >>> FakeArgs.discard = True - >>> command.process(FakeArgs) + >>> command('mailman unshunt --discard') The messages are now gone. diff --git a/src/mailman/commands/docs/version.rst b/src/mailman/commands/docs/version.rst index 8032df20a..47667ac4a 100644 --- a/src/mailman/commands/docs/version.rst +++ b/src/mailman/commands/docs/version.rst @@ -2,11 +2,8 @@ Printing the version ==================== -You can print the Mailman version number. -:: +You can print the Mailman version number by invoking the ``version`` command. - >>> from mailman.commands.cli_version import Version - >>> command = Version() - - >>> command.process(None) + >>> command = cli('mailman.commands.cli_version.version') + >>> command('mailman version') GNU Mailman 3... diff --git a/src/mailman/commands/docs/withlist.rst b/src/mailman/commands/docs/withlist.rst deleted file mode 100644 index d551cb9d6..000000000 --- a/src/mailman/commands/docs/withlist.rst +++ /dev/null @@ -1,161 +0,0 @@ -========================== -Operating on mailing lists -========================== - -The ``shell`` (alias: ``withlist``) command is a pretty powerful way to -operate on mailing lists from the command line. This command allows you to -interact with a list at a Python prompt, or process one or more mailing lists -through custom made Python functions. - - -Getting detailed help -===================== - -Because ``withlist`` is so complex, you need to request detailed help. -:: - - >>> from mailman.commands.cli_withlist import Withlist - >>> command = Withlist() - - >>> class FakeArgs: - ... interactive = False - ... run = None - ... details = True - ... listname = [] - - >>> class FakeParser: - ... def error(self, message): - ... print(message) - >>> command.parser = FakeParser() - - >>> args = FakeArgs() - >>> command.process(args) - This script provides you with a general framework for interacting with a - mailing list. - ... - - -Running a command -================= - -By putting a Python function somewhere on your ``sys.path``, you can have -``withlist`` call that function on a given mailing list. The function takes a -single argument, the mailing list. -:: - - >>> import os, sys - >>> old_path = sys.path[:] - >>> sys.path.insert(0, config.VAR_DIR) - - >>> with open(os.path.join(config.VAR_DIR, 'showme.py'), 'w') as fp: - ... print("""\ - ... def showme(mailing_list): - ... print("The list's name is", mailing_list.fqdn_listname) - ... - ... def displayname(mailing_list): - ... print("The list's display name is", mailing_list.display_name) - ... """, file=fp) - -If the name of the function is the same as the module, then you only need to -name the function once. - - >>> mlist = create_list('aardvark@example.com') - >>> args.details = False - >>> args.run = 'showme' - >>> args.listname = 'aardvark@example.com' - >>> command.process(args) - The list's name is aardvark@example.com - -The function's name can also be different than the modules name. In that -case, just give the full module path name to the function you want to call. - - >>> args.run = 'showme.displayname' - >>> command.process(args) - The list's display name is Aardvark - - -Multiple lists -============== - -You can run a command over more than one list by using a regular expression in -the `listname` argument. To indicate a regular expression is used, the string -must start with a caret. -:: - - >>> mlist_2 = create_list('badger@example.com') - >>> mlist_3 = create_list('badboys@example.com') - - >>> args.listname = '^.*example.com' - >>> command.process(args) - The list's display name is Aardvark - The list's display name is Badboys - The list's display name is Badger - - >>> args.listname = '^bad.*' - >>> command.process(args) - The list's display name is Badboys - The list's display name is Badger - - >>> args.listname = '^foo' - >>> command.process(args) - - -Error handling -============== - -You get an error if you try to run a function over a non-existent mailing -list. - - >>> args.listname = 'mystery@example.com' - >>> command.process(args) - No such list: mystery@example.com - -You also get an error if no mailing list is named. - - >>> args.listname = None - >>> command.process(args) - --run requires a mailing list name - - -Interactive use -=============== - -You can also get an interactive prompt which allows you to inspect a live -Mailman system directly. Through the ``mailman.cfg`` file, you can set the -prompt and banner, and you can choose between the standard Python REPL_ or -IPython. - -If the `GNU readline`_ library is available, it will be enabled automatically, -giving you command line editing and other features. You can also set the -``[shell]history_file`` variable in the ``mailman.cfg`` file and when the -normal Python REPL is used, your interactive commands will be written to and -read from this file. - -Note that the ``$PYTHONSTARTUP`` environment variable will also be honored if -set, and any file named by this variable will be read at start up time. It's -common practice to *also* enable GNU readline history in a ``$PYTHONSTARTUP`` -file and if you do this, be aware that it will interact badly with -``[shell]history_file``, causing your history to be written twice. To disable -this when using the interactive ``shell`` command, do something like:: - - $ PYTHONSTARTUP= mailman shell - -to temporarily unset the environment variable. - - -IPython -------- - -You can use IPython_ as the interactive shell by setting the -``[shell]use_ipython`` variables in your `mailman.cfg` file to ``yes``. -IPython must be installed and available on your system - -When using IPython, the ``[shell]history_file`` is not used. - - -.. Clean up - >>> sys.path = old_path - -.. _IPython: http://ipython.org/ -.. _REPL: https://en.wikipedia.org/wiki/REPL -.. _`GNU readline`: https://docs.python.org/3/library/readline.html diff --git a/src/mailman/commands/tests/data/__init__.py b/src/mailman/commands/tests/data/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/mailman/commands/tests/data/no-runners.cfg b/src/mailman/commands/tests/data/no-runners.cfg new file mode 100644 index 000000000..8f423460c --- /dev/null +++ b/src/mailman/commands/tests/data/no-runners.cfg @@ -0,0 +1,51 @@ +# 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 . + +# Disable all runners. + +[runner.archive] +start: no + +[runner.bounces] +start: no + +[runner.command] +start: no + +[runner.in] +start: no + +[runner.lmtp] +start: no + +[runner.nntp] +start: no + +[runner.out] +start: no + +[runner.pipeline] +start: no + +[runner.retry] +start: no + +[runner.shunt] +start: no + +[runner.virgin] +start: no diff --git a/src/mailman/commands/tests/test_cli_conf.py b/src/mailman/commands/tests/test_cli_conf.py new file mode 100644 index 000000000..27fca27ac --- /dev/null +++ b/src/mailman/commands/tests/test_cli_conf.py @@ -0,0 +1,74 @@ +# Copyright (C) 2013-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 . + +"""Test the conf subcommand.""" + +import unittest + +from click.testing import CliRunner +from mailman.commands.cli_conf import conf +from mailman.testing.layers import ConfigLayer +from tempfile import NamedTemporaryFile + + +class TestConf(unittest.TestCase): + """Test the conf subcommand.""" + + layer = ConfigLayer + + def setUp(self): + self._command = CliRunner() + + def test_cannot_access_nonexistent_section(self): + result = self._command.invoke(conf, ('-s', 'thissectiondoesnotexist')) + self.assertEqual(result.exit_code, 2) + self.assertEqual( + result.output, + 'Usage: conf [OPTIONS]\n\n' + 'Error: No such section: thissectiondoesnotexist\n') + + def test_cannot_access_nonexistent_section_and_key(self): + result = self._command.invoke( + conf, ('-s', 'thissectiondoesnotexist', '-k', 'nosuchkey')) + self.assertEqual(result.exit_code, 2) + self.assertEqual( + result.output, + 'Usage: conf [OPTIONS]\n\n' + 'Error: No such section: thissectiondoesnotexist\n') + + def test_cannot_access_nonexistent_key(self): + result = self._command.invoke( + conf, ('-s', 'mailman', '-k', 'thiskeydoesnotexist')) + self.assertEqual(result.exit_code, 2) + self.assertEqual( + result.output, + 'Usage: conf [OPTIONS]\n\n' + 'Error: Section mailman: No such key: thiskeydoesnotexist\n') + + def test_output_to_explicit_stdout(self): + result = self._command.invoke( + conf, ('-o', '-', '-s', 'shell', '-k', 'use_ipython')) + self.assertEqual(result.exit_code, 0) + self.assertEqual(result.output, 'no\n') + + def test_output_to_file(self): + with NamedTemporaryFile() as outfp: + result = self._command.invoke( + conf, ('-o', outfp.name, '-s', 'shell', '-k', 'use_ipython')) + self.assertEqual(result.exit_code, 0) + with open(outfp.name, 'r', encoding='utf-8') as infp: + self.assertEqual(infp.read(), 'no\n') diff --git a/src/mailman/commands/tests/test_cli_control.py b/src/mailman/commands/tests/test_cli_control.py new file mode 100644 index 000000000..642450167 --- /dev/null +++ b/src/mailman/commands/tests/test_cli_control.py @@ -0,0 +1,295 @@ +# Copyright (C) 2011-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 . + +"""Test some additional corner cases for starting/stopping.""" + +import os +import sys +import time +import shutil +import signal +import socket +import unittest + +from click.testing import CliRunner +from contextlib import ExitStack, suppress +from datetime import datetime, timedelta +from flufl.lock import SEP +from mailman.bin.master import WatcherState +from mailman.commands.cli_control import reopen, restart, start +from mailman.config import config +from mailman.testing.helpers import configuration +from mailman.testing.layers import ConfigLayer +from pkg_resources import resource_filename +from public import public +from tempfile import TemporaryDirectory +from unittest.mock import patch + + +# For ../docs/control.rst +@public +def make_config(resources): + cfg_path = resource_filename( + 'mailman.commands.tests.data', 'no-runners.cfg') + # We have to patch the global config's filename attribute. The problem + # here is that click does not support setting the -C option on the + # parent command (i.e. `master`). + # https://github.com/pallets/click/issues/831 + resources.enter_context(patch.object(config, 'filename', cfg_path)) + + +# For ../docs/control.rst +@public +def find_master(): + # See if the master process is still running. + until = timedelta(seconds=10) + datetime.now() + while datetime.now() < until: + time.sleep(0.1) + with suppress(FileNotFoundError, ValueError, ProcessLookupError): + with open(config.PID_FILE) as fp: + pid = int(fp.read().strip()) + os.kill(pid, 0) + return pid + return None + + +@public +def claim_lock(): + # Fake an acquisition of the master lock by another process, which + # subsequently goes stale. Start by finding a free process id. Yes, + # this could race, but given that we're starting with our own PID and + # searching downward, it's less likely. + fake_pid = os.getpid() - 1 + while fake_pid > 1: + try: + os.kill(fake_pid, 0) + except ProcessLookupError: + break + fake_pid -= 1 + else: + raise RuntimeError('Cannot find free PID') + # Lock acquisition logic taken from flufl.lock. + claim_file = SEP.join(( + config.LOCK_FILE, + socket.getfqdn(), + str(fake_pid), + '0')) + with open(config.LOCK_FILE, 'w') as fp: + fp.write(claim_file) + os.link(config.LOCK_FILE, claim_file) + expiration_date = datetime.now() - timedelta(minutes=5) + t = time.mktime(expiration_date.timetuple()) + os.utime(claim_file, (t, t)) + return claim_file + + +@public +def kill_with_extreme_prejudice(pid_or_pidfile=None): + # 2016-12-03 barry: We have intermittent hangs during both local and CI + # test suite runs where killing a runner or master process doesn't + # terminate the process. In those cases, wait()ing on the child can + # suspend the test process indefinitely. Locally, you have to C-c the + # test process, but that still doesn't kill it; the process continues to + # run in the background. If you then search for the process's pid and + # SIGTERM it, it will usually exit, which is why I don't understand why + # the above SIGTERM doesn't kill it sometimes. However, when run under + # CI, the test suite will just hang until the CI runner times it out. It + # would be better to figure out the underlying cause, because we have + # definitely seen other situations where a runner process won't exit, but + # for testing purposes we're just trying to clean up some resources so + # after a brief attempt at SIGTERMing it, let's SIGKILL it and warn. + if isinstance(pid_or_pidfile, str): + try: + with open(pid_or_pidfile, 'r') as fp: + pid = int(fp.read()) + except FileNotFoundError: + # There's nothing to kill. + return + else: + pid = pid_or_pidfile + if pid is not None: + os.kill(pid, signal.SIGTERM) + until = timedelta(seconds=10) + datetime.now() + while datetime.now() < until: + try: + if pid is None: + os.wait3(os.WNOHANG) + else: + os.waitpid(pid, os.WNOHANG) + except ChildProcessError: + # This basically means we went one too many times around the + # loop. The previous iteration successfully reaped the child. + # Because the return status of wait3() and waitpid() are different + # in those cases, it's easier just to catch the exception for + # either call and exit. + return + time.sleep(0.1) + else: + if pid is None: + # There's really not much more we can do because we have no pid to + # SIGKILL. Just report the problem and continue. + print('WARNING: NO CHANGE IN CHILD PROCESS STATES', + file=sys.stderr) + return + print('WARNING: SIGTERM DID NOT EXIT PROCESS; SIGKILLing', + file=sys.stderr) + if pid is not None: + os.kill(pid, signal.SIGKILL) + until = timedelta(seconds=10) + datetime.now() + while datetime.now() < until: + status = os.waitpid(pid, os.WNOHANG) + if status == (0, 0): + # The child was reaped. + return + time.sleep(0.1) + else: + print('WARNING: SIGKILL DID NOT EXIT PROCESS!', file=sys.stderr) + + +class TestControl(unittest.TestCase): + layer = ConfigLayer + maxDiff = None + + def setUp(self): + self._command = CliRunner() + self._tmpdir = TemporaryDirectory() + self.addCleanup(self._tmpdir.cleanup) + # Specify where to put the pid file; and make sure that the master + # gets killed regardless of whether it gets started or not. + self._pid_file = os.path.join(self._tmpdir.name, 'master-test.pid') + self.addCleanup(kill_with_extreme_prejudice, self._pid_file) + # Patch cli_control so that 1) it doesn't actually do a fork, since + # that makes it impossible to avoid race conditions in the test; 2) + # doesn't actually os.execl(). + with ExitStack() as resources: + resources.enter_context(patch( + 'mailman.commands.cli_control.os.fork', + # Pretend to be the child. + return_value=0 + )) + self._execl = resources.enter_context(patch( + 'mailman.commands.cli_control.os.execl')) + resources.enter_context(patch( + 'mailman.commands.cli_control.os.setsid')) + resources.enter_context(patch( + 'mailman.commands.cli_control.os.chdir')) + resources.enter_context(patch( + 'mailman.commands.cli_control.os.environ', + os.environ.copy())) + # Arrange for the mocks to be reverted when the test is over. + self.addCleanup(resources.pop_all().close) + + def test_master_is_elsewhere_and_missing(self): + with ExitStack() as resources: + bin_dir = resources.enter_context(TemporaryDirectory()) + old_master = os.path.join(config.BIN_DIR, 'master') + new_master = os.path.join(bin_dir, 'master') + shutil.move(old_master, new_master) + resources.callback(shutil.move, new_master, old_master) + results = self._command.invoke(start) + # Argument #2 to the execl() call should be the path to the master + # program, and the path should not exist. + self.assertEqual( + len(self._execl.call_args_list), 1, results.output) + posargs, kws = self._execl.call_args_list[0] + master_path = posargs[2] + self.assertEqual(os.path.basename(master_path), 'master') + self.assertFalse(os.path.exists(master_path), master_path) + + def test_master_is_elsewhere_and_findable(self): + with ExitStack() as resources: + bin_dir = resources.enter_context(TemporaryDirectory()) + old_master = os.path.join(config.BIN_DIR, 'master') + new_master = os.path.join(bin_dir, 'master') + shutil.move(old_master, new_master) + resources.callback(shutil.move, new_master, old_master) + with configuration('paths.testing', bin_dir=bin_dir): + results = self._command.invoke(start) + # Argument #2 to the execl() call should be the path to the master + # program, and the path should exist. + self.assertEqual( + len(self._execl.call_args_list), 1, results.output) + posargs, kws = self._execl.call_args_list[0] + master_path = posargs[2] + self.assertEqual(os.path.basename(master_path), 'master') + self.assertTrue(os.path.exists(master_path), master_path) + + def test_stale_lock_no_force(self): + claim_file = claim_lock() + self.addCleanup(os.remove, claim_file) + self.addCleanup(os.remove, config.LOCK_FILE) + result = self._command.invoke(start) + self.assertEqual(result.exit_code, 2) + self.assertEqual( + result.output, + 'Usage: start [OPTIONS]\n\n' + 'Error: A previous run of GNU Mailman did not exit cleanly ' + '(stale_lock). Try using --force\n') + + def test_stale_lock_force(self): + claim_file = claim_lock() + self.addCleanup(os.remove, claim_file) + self.addCleanup(os.remove, config.LOCK_FILE) + # Don't test the results of this command. Because we're mocking + # os.execl(), we'll end up raising the RuntimeError at the end of the + # start() method, child branch. + self._command.invoke(start, ('--force',)) + self.assertEqual(len(self._execl.call_args_list), 1) + posargs, kws = self._execl.call_args_list[0] + self.assertIn('--force', posargs) + + +class TestControlSimple(unittest.TestCase): + layer = ConfigLayer + maxDiff = None + + def setUp(self): + self._command = CliRunner() + + def test_watcher_state_conflict(self): + with patch('mailman.commands.cli_control.master_state', + return_value=(WatcherState.conflict, object())): + results = self._command.invoke(start) + self.assertEqual(results.exit_code, 2) + self.assertEqual( + results.output, + 'Usage: start [OPTIONS]\n\n' + 'Error: GNU Mailman is already running\n') + + def test_reopen(self): + with patch('mailman.commands.cli_control.kill_watcher') as mock: + result = self._command.invoke(reopen) + mock.assert_called_once_with(signal.SIGHUP) + self.assertEqual(result.output, 'Reopening the Mailman runners\n') + + def test_reopen_quiet(self): + with patch('mailman.commands.cli_control.kill_watcher') as mock: + result = self._command.invoke(reopen, ('--quiet',)) + mock.assert_called_once_with(signal.SIGHUP) + self.assertEqual(result.output, '') + + def test_restart(self): + with patch('mailman.commands.cli_control.kill_watcher') as mock: + result = self._command.invoke(restart) + mock.assert_called_once_with(signal.SIGUSR1) + self.assertEqual(result.output, 'Restarting the Mailman runners\n') + + def test_restart_quiet(self): + with patch('mailman.commands.cli_control.kill_watcher') as mock: + result = self._command.invoke(restart, ('--quiet',)) + mock.assert_called_once_with(signal.SIGUSR1) + self.assertEqual(result.output, '') diff --git a/src/mailman/commands/tests/test_cli_create.py b/src/mailman/commands/tests/test_cli_create.py new file mode 100644 index 000000000..364018d92 --- /dev/null +++ b/src/mailman/commands/tests/test_cli_create.py @@ -0,0 +1,116 @@ +# Copyright (C) 2011-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 . + +"""Test the `mailman create` subcommand.""" + +import unittest + +from click.testing import CliRunner +from mailman.app.lifecycle import create_list +from mailman.commands.cli_lists import create, remove +from mailman.interfaces.domain import IDomainManager +from mailman.testing.layers import ConfigLayer +from zope.component import getUtility + + +class TestCreate(unittest.TestCase): + layer = ConfigLayer + + def setUp(self): + self._command = CliRunner() + + def test_cannot_create_duplicate_list(self): + # Cannot create a mailing list if it already exists. + create_list('ant@example.com') + result = self._command.invoke(create, ('ant@example.com',)) + self.assertEqual(result.exit_code, 2) + self.assertEqual( + result.output, + 'Usage: create [OPTIONS] LISTNAME\n\n' + 'Error: List already exists: ant@example.com\n') + + def test_invalid_posting_address(self): + # Cannot create a mailing list with an invalid posting address. + result = self._command.invoke(create, ('foo',)) + self.assertEqual(result.exit_code, 2) + self.assertEqual( + result.output, + 'Usage: create [OPTIONS] LISTNAME\n\n' + 'Error: Illegal list name: foo\n') + + def test_invalid_owner_addresses(self): + # Cannot create a list with invalid owner addresses. LP: #778687 + result = self._command.invoke( + create, ('-o', 'invalid', 'ant@example.com')) + self.assertEqual(result.exit_code, 2) + self.assertEqual( + result.output, + 'Usage: create [OPTIONS] LISTNAME\n\n' + 'Error: Illegal owner addresses: invalid\n') + + def test_create_without_domain_option(self): + # The domain will be created if no domain options are specified. Use + # the example.org domain since example.com is created by the test + # suite so it would always already exist. + result = self._command.invoke(create, ('ant@example.org',)) + self.assertEqual(result.exit_code, 0) + domain = getUtility(IDomainManager)['example.org'] + self.assertEqual(domain.mail_host, 'example.org') + + def test_create_with_d(self): + result = self._command.invoke(create, ('ant@example.org', '-d')) + self.assertEqual(result.exit_code, 0) + domain = getUtility(IDomainManager)['example.org'] + self.assertEqual(domain.mail_host, 'example.org') + + def test_create_with_domain(self): + result = self._command.invoke(create, ('ant@example.org', '--domain')) + self.assertEqual(result.exit_code, 0) + domain = getUtility(IDomainManager)['example.org'] + self.assertEqual(domain.mail_host, 'example.org') + + def test_create_with_D(self): + result = self._command.invoke(create, ('ant@example.org', '-D')) + self.assertEqual(result.exit_code, 2) + self.assertEqual( + result.output, + 'Usage: create [OPTIONS] LISTNAME\n\n' + 'Error: Undefined domain: example.org\n') + + def test_create_with_nodomain(self): + result = self._command.invoke( + create, ('ant@example.org', '--no-domain')) + self.assertEqual(result.exit_code, 2) + self.assertEqual( + result.output, + 'Usage: create [OPTIONS] LISTNAME\n\n' + 'Error: Undefined domain: example.org\n') + + +class TestRemove(unittest.TestCase): + layer = ConfigLayer + + def setUp(self): + self._command = CliRunner() + + def test_remove_not_quiet_no_such_list(self): + results = self._command.invoke(remove, ('ant@example.com',)) + # It's not an error to try to remove a nonexistent list. + self.assertEqual(results.exit_code, 0) + self.assertEqual( + results.output, + 'No such list matching spec: ant@example.com\n') diff --git a/src/mailman/commands/tests/test_cli_digests.py b/src/mailman/commands/tests/test_cli_digests.py new file mode 100644 index 000000000..3c033d60d --- /dev/null +++ b/src/mailman/commands/tests/test_cli_digests.py @@ -0,0 +1,399 @@ +# Copyright (C) 2015-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 . + +"""Test the send-digests subcommand.""" + +import os +import unittest + +from click.testing import CliRunner +from datetime import timedelta +from mailman.app.lifecycle import create_list +from mailman.commands.cli_digests import digests +from mailman.config import config +from mailman.interfaces.digests import DigestFrequency +from mailman.interfaces.member import DeliveryMode +from mailman.runners.digest import DigestRunner +from mailman.testing.helpers import ( + get_queue_messages, make_testable_runner, + specialized_message_from_string as mfs, subscribe) +from mailman.testing.layers import ConfigLayer +from mailman.utilities.datetime import now as right_now + + +class TestSendDigests(unittest.TestCase): + layer = ConfigLayer + + def setUp(self): + self._mlist = create_list('ant@example.com') + self._mlist.digests_enabled = True + self._mlist.digest_size_threshold = 100000 + self._mlist.send_welcome_message = False + self._command = CliRunner() + self._handler = config.handlers['to-digest'] + self._runner = make_testable_runner(DigestRunner, 'digest') + # The mailing list needs at least one digest recipient. + member = subscribe(self._mlist, 'Anne') + member.preferences.delivery_mode = DeliveryMode.plaintext_digests + + def test_send_one_digest_by_list_id(self): + msg = mfs("""\ +To: ant@example.com +From: anne@example.com +Subject: message 1 + +""") + self._handler.process(self._mlist, msg, {}) + del msg['subject'] + msg['subject'] = 'message 2' + self._handler.process(self._mlist, msg, {}) + # There are no digests already being sent, but the ant mailing list + # does have a digest mbox collecting messages. + get_queue_messages('digest', expected_count=0) + mailbox_path = os.path.join(self._mlist.data_path, 'digest.mmdf') + self.assertGreater(os.path.getsize(mailbox_path), 0) + self._command.invoke(digests, ('-s', '-l', 'ant.example.com')) + self._runner.run() + # Now, there's no digest mbox and there's a plaintext digest in the + # outgoing queue. + self.assertFalse(os.path.exists(mailbox_path)) + items = get_queue_messages('virgin', expected_count=1) + digest_contents = str(items[0].msg) + self.assertIn('Subject: message 1', digest_contents) + self.assertIn('Subject: message 2', digest_contents) + + def test_send_one_digest_by_fqdn_listname(self): + msg = mfs("""\ +To: ant@example.com +From: anne@example.com +Subject: message 1 + +""") + self._handler.process(self._mlist, msg, {}) + del msg['subject'] + msg['subject'] = 'message 2' + self._handler.process(self._mlist, msg, {}) + # There are no digests already being sent, but the ant mailing list + # does have a digest mbox collecting messages. + get_queue_messages('digest', expected_count=0) + mailbox_path = os.path.join(self._mlist.data_path, 'digest.mmdf') + self.assertGreater(os.path.getsize(mailbox_path), 0) + self._command.invoke(digests, ('-s', '-l', 'ant@example.com')) + self._runner.run() + # Now, there's no digest mbox and there's a plaintext digest in the + # outgoing queue. + self.assertFalse(os.path.exists(mailbox_path)) + items = get_queue_messages('virgin', expected_count=1) + digest_contents = str(items[0].msg) + self.assertIn('Subject: message 1', digest_contents) + self.assertIn('Subject: message 2', digest_contents) + + def test_send_one_digest_to_missing_list_id(self): + msg = mfs("""\ +To: ant@example.com +From: anne@example.com +Subject: message 1 + +""") + self._handler.process(self._mlist, msg, {}) + del msg['subject'] + msg['subject'] = 'message 2' + self._handler.process(self._mlist, msg, {}) + # There are no digests already being sent, but the ant mailing list + # does have a digest mbox collecting messages. + get_queue_messages('digest', expected_count=0) + mailbox_path = os.path.join(self._mlist.data_path, 'digest.mmdf') + self.assertGreater(os.path.getsize(mailbox_path), 0) + result = self._command.invoke(digests, ('-s', '-l', 'bee.example.com')) + self.assertEqual(result.exit_code, 0) + self.assertEqual( + result.output, + 'No such list found: bee.example.com\n') + self._runner.run() + # And no digest was prepared. + self.assertGreater(os.path.getsize(mailbox_path), 0) + get_queue_messages('virgin', expected_count=0) + + def test_send_one_digest_to_missing_fqdn_listname(self): + msg = mfs("""\ +To: ant@example.com +From: anne@example.com +Subject: message 1 + +""") + self._handler.process(self._mlist, msg, {}) + del msg['subject'] + msg['subject'] = 'message 2' + self._handler.process(self._mlist, msg, {}) + # There are no digests already being sent, but the ant mailing list + # does have a digest mbox collecting messages. + get_queue_messages('digest', expected_count=0) + mailbox_path = os.path.join(self._mlist.data_path, 'digest.mmdf') + self.assertGreater(os.path.getsize(mailbox_path), 0) + result = self._command.invoke(digests, ('-s', '-l', 'bee@example.com')) + self.assertEqual(result.exit_code, 0) + self.assertEqual( + result.output, + 'No such list found: bee@example.com\n') + self._runner.run() + # And no digest was prepared. + self.assertGreater(os.path.getsize(mailbox_path), 0) + get_queue_messages('virgin', expected_count=0) + + def test_send_digest_to_one_missing_and_one_existing_list(self): + msg = mfs("""\ +To: ant@example.com +From: anne@example.com +Subject: message 1 + +""") + self._handler.process(self._mlist, msg, {}) + del msg['subject'] + msg['subject'] = 'message 2' + self._handler.process(self._mlist, msg, {}) + # There are no digests already being sent, but the ant mailing list + # does have a digest mbox collecting messages. + get_queue_messages('digest', expected_count=0) + mailbox_path = os.path.join(self._mlist.data_path, 'digest.mmdf') + self.assertGreater(os.path.getsize(mailbox_path), 0) + result = self._command.invoke( + digests, + ('-s', '-l', 'ant.example.com', '-l', 'bee.example.com')) + self.assertEqual(result.exit_code, 0) + self.assertEqual( + result.output, + 'No such list found: bee.example.com\n') + self._runner.run() + # But ant's digest was still prepared. + self.assertFalse(os.path.exists(mailbox_path)) + items = get_queue_messages('virgin', expected_count=1) + digest_contents = str(items[0].msg) + self.assertIn('Subject: message 1', digest_contents) + self.assertIn('Subject: message 2', digest_contents) + + def test_send_digests_for_two_lists(self): + # Populate ant's digest. + msg = mfs("""\ +To: ant@example.com +From: anne@example.com +Subject: message 1 + +""") + self._handler.process(self._mlist, msg, {}) + del msg['subject'] + msg['subject'] = 'message 2' + self._handler.process(self._mlist, msg, {}) + # Create the second list. + bee = create_list('bee@example.com') + bee.digests_enabled = True + bee.digest_size_threshold = 100000 + bee.send_welcome_message = False + member = subscribe(bee, 'Bart') + member.preferences.delivery_mode = DeliveryMode.plaintext_digests + # Populate bee's digest. + msg = mfs("""\ +To: bee@example.com +From: bart@example.com +Subject: message 3 + +""") + self._handler.process(bee, msg, {}) + del msg['subject'] + msg['subject'] = 'message 4' + self._handler.process(bee, msg, {}) + # There are no digests for either list already being sent, but the + # mailing lists do have a digest mbox collecting messages. + ant_mailbox_path = os.path.join(self._mlist.data_path, 'digest.mmdf') + self.assertGreater(os.path.getsize(ant_mailbox_path), 0) + # Check bee's digest. + bee_mailbox_path = os.path.join(bee.data_path, 'digest.mmdf') + self.assertGreater(os.path.getsize(bee_mailbox_path), 0) + # Both. + get_queue_messages('digest', expected_count=0) + # Process both list's digests. + self._command.invoke( + digests, ('-s', '-l', 'ant.example.com', '-l', 'bee@example.com')) + self._runner.run() + # Now, neither list has a digest mbox and but there are plaintext + # digest in the outgoing queue for both. + self.assertFalse(os.path.exists(ant_mailbox_path)) + self.assertFalse(os.path.exists(bee_mailbox_path)) + items = get_queue_messages('virgin', expected_count=2) + # Figure out which digest is going to ant and which to bee. + if items[0].msg['to'] == 'ant@example.com': + ant = items[0].msg + bee = items[1].msg + else: + assert items[0].msg['to'] == 'bee@example.com' + ant = items[1].msg + bee = items[0].msg + # Check ant's digest. + digest_contents = str(ant) + self.assertIn('Subject: message 1', digest_contents) + self.assertIn('Subject: message 2', digest_contents) + # Check bee's digest. + digest_contents = str(bee) + self.assertIn('Subject: message 3', digest_contents) + self.assertIn('Subject: message 4', digest_contents) + + def test_send_digests_for_all_lists(self): + # Populate ant's digest. + msg = mfs("""\ +To: ant@example.com +From: anne@example.com +Subject: message 1 + +""") + self._handler.process(self._mlist, msg, {}) + del msg['subject'] + msg['subject'] = 'message 2' + self._handler.process(self._mlist, msg, {}) + # Create the second list. + bee = create_list('bee@example.com') + bee.digests_enabled = True + bee.digest_size_threshold = 100000 + bee.send_welcome_message = False + member = subscribe(bee, 'Bart') + member.preferences.delivery_mode = DeliveryMode.plaintext_digests + # Populate bee's digest. + msg = mfs("""\ +To: bee@example.com +From: bart@example.com +Subject: message 3 + +""") + self._handler.process(bee, msg, {}) + del msg['subject'] + msg['subject'] = 'message 4' + self._handler.process(bee, msg, {}) + # There are no digests for either list already being sent, but the + # mailing lists do have a digest mbox collecting messages. + ant_mailbox_path = os.path.join(self._mlist.data_path, 'digest.mmdf') + self.assertGreater(os.path.getsize(ant_mailbox_path), 0) + # Check bee's digest. + bee_mailbox_path = os.path.join(bee.data_path, 'digest.mmdf') + self.assertGreater(os.path.getsize(bee_mailbox_path), 0) + # Both. + get_queue_messages('digest', expected_count=0) + # Process all mailing list digests by not setting any arguments. + self._command.invoke(digests, ('-s',)) + self._runner.run() + # Now, neither list has a digest mbox and but there are plaintext + # digest in the outgoing queue for both. + self.assertFalse(os.path.exists(ant_mailbox_path)) + self.assertFalse(os.path.exists(bee_mailbox_path)) + items = get_queue_messages('virgin', expected_count=2) + # Figure out which digest is going to ant and which to bee. + if items[0].msg['to'] == 'ant@example.com': + ant = items[0].msg + bee = items[1].msg + else: + assert items[0].msg['to'] == 'bee@example.com' + ant = items[1].msg + bee = items[0].msg + # Check ant's digest. + digest_contents = str(ant) + self.assertIn('Subject: message 1', digest_contents) + self.assertIn('Subject: message 2', digest_contents) + # Check bee's digest. + digest_contents = str(bee) + self.assertIn('Subject: message 3', digest_contents) + self.assertIn('Subject: message 4', digest_contents) + + def test_send_no_digest_ready(self): + # If no messages have been sent through the mailing list, no digest + # can be sent. + mailbox_path = os.path.join(self._mlist.data_path, 'digest.mmdf') + self.assertFalse(os.path.exists(mailbox_path)) + self._command.invoke(digests, ('-s', '-l', 'ant.example.com')) + self._runner.run() + get_queue_messages('virgin', expected_count=0) + + def test_bump_before_send(self): + self._mlist.digest_volume_frequency = DigestFrequency.monthly + self._mlist.volume = 7 + self._mlist.next_digest_number = 4 + self._mlist.digest_last_sent_at = right_now() + timedelta( + days=-32) + msg = mfs("""\ +To: ant@example.com +From: anne@example.com +Subject: message 1 + +""") + self._handler.process(self._mlist, msg, {}) + self._command.invoke( + digests, ('-s', '--bump', '-l', 'ant.example.com')) + self._runner.run() + # The volume is 8 and the digest number is 2 because a digest was sent + # after the volume/number was bumped. + self.assertEqual(self._mlist.volume, 8) + self.assertEqual(self._mlist.next_digest_number, 2) + self.assertEqual(self._mlist.digest_last_sent_at, right_now()) + items = get_queue_messages('virgin', expected_count=1) + self.assertEqual(items[0].msg['subject'], 'Ant Digest, Vol 8, Issue 1') + + +class TestBumpVolume(unittest.TestCase): + layer = ConfigLayer + + def setUp(self): + self._mlist = create_list('ant@example.com') + self._mlist.digest_volume_frequency = DigestFrequency.monthly + self._mlist.volume = 7 + self._mlist.next_digest_number = 4 + self.right_now = right_now() + self._command = CliRunner() + + def test_bump_one_list(self): + self._mlist.digest_last_sent_at = self.right_now + timedelta( + days=-32) + self._command.invoke(digests, ('-b', '-l', 'ant.example.com')) + self.assertEqual(self._mlist.volume, 8) + self.assertEqual(self._mlist.next_digest_number, 1) + self.assertEqual(self._mlist.digest_last_sent_at, self.right_now) + + def test_bump_two_lists(self): + self._mlist.digest_last_sent_at = self.right_now + timedelta( + days=-32) + # Create the second list. + bee = create_list('bee@example.com') + bee.digest_volume_frequency = DigestFrequency.monthly + bee.volume = 7 + bee.next_digest_number = 4 + bee.digest_last_sent_at = self.right_now + timedelta( + days=-32) + self._command.invoke( + digests, ('-b', '-l', 'ant.example.com', '-l', 'bee.example.com')) + self.assertEqual(self._mlist.volume, 8) + self.assertEqual(self._mlist.next_digest_number, 1) + self.assertEqual(self._mlist.digest_last_sent_at, self.right_now) + + def test_bump_verbose(self): + result = self._command.invoke( + digests, ('-v', '-b', '-l', 'ant.example.com')) + self.assertMultiLineEqual(result.output, """\ +ant.example.com is at volume 7, number 4 +ant.example.com bumped to volume 7, number 5 +""") + + def test_send_verbose(self): + result = self._command.invoke( + digests, ('-v', '-s', '-n', '-l', 'ant.example.com')) + self.assertMultiLineEqual(result.output, """\ +ant.example.com sent volume 7, number 4 +""") diff --git a/src/mailman/commands/tests/test_cli_import.py b/src/mailman/commands/tests/test_cli_import.py new file mode 100644 index 000000000..5f97df294 --- /dev/null +++ b/src/mailman/commands/tests/test_cli_import.py @@ -0,0 +1,76 @@ +# Copyright (C) 2015-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 . + +"""Test the `mailman import21` subcommand.""" + +import unittest + +from click.testing import CliRunner +from mailman.app.lifecycle import create_list +from mailman.commands.cli_import import import21 +from mailman.testing.layers import ConfigLayer +from mailman.utilities.importer import Import21Error +from pickle import dump +from pkg_resources import resource_filename +from tempfile import NamedTemporaryFile +from unittest.mock import patch + + +class TestImport(unittest.TestCase): + layer = ConfigLayer + + def setUp(self): + self._command = CliRunner() + self.mlist = create_list('ant@example.com') + + @patch('mailman.commands.cli_import.import_config_pck') + def test_process_pickle_with_bounce_info(self, import_config_pck): + # The sample data contains Mailman 2 bounce info, represented as + # _BounceInfo instances. We throw these away when importing to + # Mailman 3, but we have to fake the instance's classes, otherwise + # unpickling the dictionaries will fail. + pckfile = resource_filename( + 'mailman.testing', 'config-with-instances.pck') + try: + self._command.invoke(import21, ('ant.example.com', pckfile)) + except ImportError as error: + self.fail('The pickle failed loading: {}'.format(error)) + self.assertTrue(import_config_pck.called) + + def test_missing_list_spec(self): + result = self._command.invoke(import21) + self.assertEqual(result.exit_code, 2, result.output) + self.assertEqual( + result.output, + 'Usage: import21 [OPTIONS] LISTSPEC PICKLE_FILE\n\n' + 'Error: Missing argument "listspec".\n') + + def test_pickle_with_nondict(self): + with NamedTemporaryFile() as pckfile: + with open(pckfile.name, 'wb') as fp: + dump(['not', 'a', 'dict'], fp) + result = self._command.invoke( + import21, ('ant.example.com', pckfile.name)) + self.assertIn('Ignoring non-dictionary', result.output) + + def test_pickle_with_bad_language(self): + pckfile = resource_filename('mailman.testing', 'config.pck') + with patch('mailman.utilities.importer.check_language_code', + side_effect=Import21Error('Fake bad language code')): + result = self._command.invoke( + import21, ('ant.example.com', pckfile)) + self.assertIn('Fake bad language code', result.output) diff --git a/src/mailman/commands/tests/test_cli_inject.py b/src/mailman/commands/tests/test_cli_inject.py new file mode 100644 index 000000000..890ce3a54 --- /dev/null +++ b/src/mailman/commands/tests/test_cli_inject.py @@ -0,0 +1,66 @@ +# 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 . + +"""Test the `inject` command.""" + +import unittest + +from click.testing import CliRunner +from io import StringIO +from mailman.app.lifecycle import create_list +from mailman.commands.cli_inject import inject +from mailman.testing.layers import ConfigLayer + + +class InterruptRaisingReader(StringIO): + def read(self, count=None): + # Fake enough of the API so click returns this instance unchanged. + if count is None: + raise KeyboardInterrupt + return b'' + + +class TestInject(unittest.TestCase): + layer = ConfigLayer + + def setUp(self): + self._command = CliRunner() + create_list('ant@example.com') + + def test_inject_keyboard_interrupt(self): + results = self._command.invoke( + inject, ('-f', '-', 'ant.example.com'), + input=InterruptRaisingReader()) + self.assertEqual(results.exit_code, 1) + self.assertEqual(results.output, 'Interrupted\n') + + def test_inject_no_such_list(self): + result = self._command.invoke(inject, ('bee.example.com',)) + self.assertEqual(result.exit_code, 2) + self.assertEqual( + result.output, + 'Usage: inject [OPTIONS] LISTSPEC\n\n' + 'Error: No such list: bee.example.com\n') + + def test_inject_no_such_queue(self): + result = self._command.invoke( + inject, ('--queue', 'bogus', 'ant.example.com')) + self.assertEqual(result.exit_code, 2) + self.assertEqual( + result.output, + 'Usage: inject [OPTIONS] LISTSPEC\n\n' + 'Error: No such queue: bogus\n') diff --git a/src/mailman/commands/tests/test_cli_lists.py b/src/mailman/commands/tests/test_cli_lists.py new file mode 100644 index 000000000..6b0ff5cf3 --- /dev/null +++ b/src/mailman/commands/tests/test_cli_lists.py @@ -0,0 +1,49 @@ +# Copyright (C) 2015-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 . + +"""Additional tests for the `lists` command line subcommand.""" + +import unittest + +from click.testing import CliRunner +from mailman.app.lifecycle import create_list +from mailman.commands.cli_lists import lists +from mailman.interfaces.domain import IDomainManager +from mailman.testing.layers import ConfigLayer +from zope.component import getUtility + + +class TestLists(unittest.TestCase): + layer = ConfigLayer + + def setUp(self): + self._command = CliRunner() + + def test_lists_with_domain_option(self): + # LP: #1166911 - non-matching lists were returned. + getUtility(IDomainManager).add( + 'example.net', 'An example domain.') + create_list('test1@example.com') + create_list('test2@example.com') + # Only this one should show up. + create_list('test3@example.net') + create_list('test4@example.com') + result = self._command.invoke(lists, ('--domain', 'example.net')) + self.assertEqual(result.exit_code, 0) + self.assertEqual( + result.output, + '1 matching mailing lists found:\ntest3@example.net\n') diff --git a/src/mailman/commands/tests/test_cli_members.py b/src/mailman/commands/tests/test_cli_members.py new file mode 100644 index 000000000..9c72c61ed --- /dev/null +++ b/src/mailman/commands/tests/test_cli_members.py @@ -0,0 +1,112 @@ +# Copyright (C) 2015-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 . + +"""Test the `mailman members` command.""" + +import unittest + +from click.testing import CliRunner +from mailman.app.lifecycle import create_list +from mailman.commands.cli_members import members +from mailman.interfaces.member import MemberRole +from mailman.testing.helpers import subscribe +from mailman.testing.layers import ConfigLayer +from tempfile import NamedTemporaryFile + + +class TestCLIMembers(unittest.TestCase): + layer = ConfigLayer + + def setUp(self): + self._mlist = create_list('ant@example.com') + self._command = CliRunner() + + def test_no_such_list(self): + result = self._command.invoke(members, ('bee.example.com',)) + self.assertEqual(result.exit_code, 2) + self.assertEqual( + result.output, + 'Usage: members [OPTIONS] LISTSPEC\n\n' + 'Error: No such list: bee.example.com\n') + + def test_role_administrator(self): + subscribe(self._mlist, 'Anne', role=MemberRole.owner) + subscribe(self._mlist, 'Bart', role=MemberRole.moderator) + subscribe(self._mlist, 'Cate', role=MemberRole.nonmember) + subscribe(self._mlist, 'Dave', role=MemberRole.member) + with NamedTemporaryFile('w', encoding='utf-8') as outfp: + self._command.invoke(members, ( + '--role', 'administrator', '-o', outfp.name, + 'ant.example.com')) + with open(outfp.name, 'r', encoding='utf-8') as infp: + lines = infp.readlines() + self.assertEqual(len(lines), 2) + self.assertEqual(lines[0], 'Anne Person \n') + self.assertEqual(lines[1], 'Bart Person \n') + + def test_role_any(self): + subscribe(self._mlist, 'Anne', role=MemberRole.owner) + subscribe(self._mlist, 'Bart', role=MemberRole.moderator) + subscribe(self._mlist, 'Cate', role=MemberRole.nonmember) + subscribe(self._mlist, 'Dave', role=MemberRole.member) + with NamedTemporaryFile('w', encoding='utf-8') as outfp: + self._command.invoke(members, ( + '--role', 'any', '-o', outfp.name, 'ant.example.com')) + with open(outfp.name, 'r', encoding='utf-8') as infp: + lines = infp.readlines() + self.assertEqual(len(lines), 4) + self.assertEqual(lines[0], 'Anne Person \n') + self.assertEqual(lines[1], 'Bart Person \n') + self.assertEqual(lines[2], 'Cate Person \n') + self.assertEqual(lines[3], 'Dave Person \n') + + def test_role_moderator(self): + subscribe(self._mlist, 'Anne', role=MemberRole.owner) + subscribe(self._mlist, 'Bart', role=MemberRole.moderator) + subscribe(self._mlist, 'Cate', role=MemberRole.nonmember) + subscribe(self._mlist, 'Dave', role=MemberRole.member) + with NamedTemporaryFile('w', encoding='utf-8') as outfp: + self._command.invoke(members, ( + '--role', 'moderator', '-o', outfp.name, 'ant.example.com')) + with open(outfp.name, 'r', encoding='utf-8') as infp: + lines = infp.readlines() + self.assertEqual(len(lines), 1) + self.assertEqual(lines[0], 'Bart Person \n') + + def test_role_nonmember(self): + subscribe(self._mlist, 'Anne', role=MemberRole.owner) + subscribe(self._mlist, 'Bart', role=MemberRole.moderator) + subscribe(self._mlist, 'Cate', role=MemberRole.nonmember) + subscribe(self._mlist, 'Dave', role=MemberRole.member) + with NamedTemporaryFile('w', encoding='utf-8') as outfp: + self._command.invoke(members, ( + '--role', 'nonmember', '-o', outfp.name, 'ant.example.com')) + with open(outfp.name, 'r', encoding='utf-8') as infp: + lines = infp.readlines() + self.assertEqual(len(lines), 1) + self.assertEqual(lines[0], 'Cate Person \n') + + def test_already_subscribed_with_display_name(self): + subscribe(self._mlist, 'Anne') + with NamedTemporaryFile('w', buffering=1, encoding='utf-8') as infp: + print('Anne Person ', file=infp) + result = self._command.invoke(members, ( + '--add', infp.name, 'ant.example.com')) + self.assertEqual( + result.output, + 'Already subscribed (skipping): Anne Person \n' + ) diff --git a/src/mailman/commands/tests/test_cli_qfile.py b/src/mailman/commands/tests/test_cli_qfile.py new file mode 100644 index 000000000..b907abcc6 --- /dev/null +++ b/src/mailman/commands/tests/test_cli_qfile.py @@ -0,0 +1,59 @@ +# 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 . + +"""Test the qfile command.""" + +import unittest + +from click.testing import CliRunner +from contextlib import ExitStack +from mailman.commands.cli_qfile import qfile +from mailman.testing.layers import ConfigLayer +from pickle import dump +from tempfile import NamedTemporaryFile +from unittest.mock import patch + + +class TestUnshunt(unittest.TestCase): + layer = ConfigLayer + maxDiff = None + + def setUp(self): + self._command = CliRunner() + + def test_print_str(self): + with NamedTemporaryFile() as tmp_qfile: + with open(tmp_qfile.name, 'wb') as fp: + dump('a simple string', fp) + results = self._command.invoke(qfile, (tmp_qfile.name,)) + self.assertEqual(results.output, """\ +[----- start pickle -----] +<----- start object 1 -----> +a simple string +[----- end pickle -----] +""", results.output) + + def test_interactive(self): + with ExitStack() as resources: + tmp_qfile = resources.enter_context(NamedTemporaryFile()) + mock = resources.enter_context(patch( + 'mailman.commands.cli_qfile.interact')) + with open(tmp_qfile.name, 'wb') as fp: + dump('a simple string', fp) + self._command.invoke(qfile, (tmp_qfile.name, '-i')) + mock.assert_called_once_with( + banner="Number of objects found (see the variable 'm'): 1") diff --git a/src/mailman/commands/tests/test_cli_shell.py b/src/mailman/commands/tests/test_cli_shell.py new file mode 100644 index 000000000..ffcf43803 --- /dev/null +++ b/src/mailman/commands/tests/test_cli_shell.py @@ -0,0 +1,173 @@ +# Copyright (C) 2016-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 . + +"""Test the withlist/shell command.""" + +import os +import unittest + +from click.testing import CliRunner +from contextlib import ExitStack +from mailman.app.lifecycle import create_list +from mailman.commands.cli_withlist import shell +from mailman.config import config +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 unittest.mock import MagicMock, patch + +try: + import readline # noqa: F401 + has_readline = True +except ImportError: + has_readline = False + + +class TestShell(unittest.TestCase): + layer = ConfigLayer + + def setUp(self): + self._command = CliRunner() + + def test_namespace(self): + with patch('mailman.commands.cli_withlist.start_python') as mock: + self._command.invoke(shell, ('--interactive',)) + self.assertEqual(mock.call_count, 1) + # Don't test that all names are available, just a few choice ones. + positional, keywords = mock.call_args + namespace = positional[0] + self.assertIn('getUtility', namespace) + self.assertIn('IArchiver', namespace) + self.assertEqual(namespace['IUserManager'], IUserManager) + + @configuration('shell', banner='my banner') + def test_banner(self): + with patch('mailman.commands.cli_withlist.interact') as mock: + self._command.invoke(shell, ('--interactive',)) + self.assertEqual(mock.call_count, 1) + positional, keywords = mock.call_args + self.assertEqual(keywords['banner'], 'my banner\n') + + @unittest.skipUnless(has_readline, 'readline module is not available') + @configuration('shell', history_file='$var_dir/history.py') + def test_history_file(self): + with patch('mailman.commands.cli_withlist.interact'): + self._command.invoke(shell, ('--interactive',)) + history_file = os.path.join(config.VAR_DIR, 'history.py') + self.assertTrue(os.path.exists(history_file)) + + @configuration('shell', use_ipython='yes') + def test_start_ipython4(self): + mock = MagicMock() + with hacked_sys_modules('IPython.terminal.embed', mock): + self._command.invoke(shell, ('--interactive',)) + posargs, kws = mock.InteractiveShellEmbed.instance().mainloop.call_args + self.assertEqual( + kws['display_banner'], 'Welcome to the GNU Mailman shell\n') + + @configuration('shell', use_ipython='yes') + def test_start_ipython1(self): + mock = MagicMock() + with hacked_sys_modules('IPython.frontend.terminal.embed', mock): + self._command.invoke(shell, ('--interactive',)) + posargs, kws = mock.InteractiveShellEmbed.instance.call_args + self.assertEqual( + kws['banner1'], 'Welcome to the GNU Mailman shell\n') + + @configuration('shell', use_ipython='debug') + def test_start_ipython_debug(self): + mock = MagicMock() + with hacked_sys_modules('IPython.terminal.embed', mock): + self._command.invoke(shell, ('--interactive',)) + posargs, kws = mock.InteractiveShellEmbed.instance().mainloop.call_args + self.assertEqual( + kws['display_banner'], 'Welcome to the GNU Mailman shell\n') + + @configuration('shell', use_ipython='oops') + def test_start_ipython_invalid(self): + mock = MagicMock() + with hacked_sys_modules('IPython.terminal.embed', mock): + results = self._command.invoke(shell, ('--interactive',)) + self.assertEqual( + results.output, + 'Invalid value for [shell]use_python: oops\n') + # mainloop() never got called. + self.assertIsNone( + mock.InteractiveShellEmbed.instance().mainloop.call_args) + + @configuration('shell', use_ipython='yes') + def test_start_ipython_uninstalled(self): + with ExitStack() as resources: + # Pretend iPython isn't available at all. + resources.enter_context(patch( + 'mailman.commands.cli_withlist.start_ipython1', + return_value=None)) + resources.enter_context(patch( + 'mailman.commands.cli_withlist.start_ipython4', + return_value=None)) + results = self._command.invoke(shell, ('--interactive',)) + self.assertEqual( + results.output, + 'ipython is not available, set use_ipython to no\n') + + def test_regex_without_run(self): + results = self._command.invoke(shell, ('-l', '^.*example.com')) + self.assertEqual(results.exit_code, 2) + self.assertEqual( + results.output, + 'Usage: shell [OPTIONS] [RUN_ARGS]...\n\n' + 'Error: Regular expression requires --run\n') + + def test_listspec_without_run(self): + create_list('ant@example.com') + mock = MagicMock() + with ExitStack() as resources: + resources.enter_context( + hacked_sys_modules('IPython.terminal.embed', mock)) + interactive_mock = resources.enter_context(patch( + 'mailman.commands.cli_withlist.do_interactive')) + self._command.invoke(shell, ('-l', 'ant.example.com')) + posargs, kws = interactive_mock.call_args + self.assertEqual( + posargs[1], + "The variable 'm' is the ant.example.com mailing list") + + def test_listspec_without_run_no_such_list(self): + results = self._command.invoke(shell, ('-l', 'ant.example.com')) + self.assertEqual(results.exit_code, 2) + self.assertEqual( + results.output, + 'Usage: shell [OPTIONS] [RUN_ARGS]...\n\n' + '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') + + def test_run_bogus_listspec(self): + results = self._command.invoke( + shell, ('-l', 'bee.example.com', '--run', 'something')) + self.assertEqual(results.exit_code, 2) + self.assertEqual( + results.output, + 'Usage: shell [OPTIONS] [RUN_ARGS]...\n\n' + 'Error: No such list: bee.example.com\n') diff --git a/src/mailman/commands/tests/test_cli_status.py b/src/mailman/commands/tests/test_cli_status.py new file mode 100644 index 000000000..4e9e3080e --- /dev/null +++ b/src/mailman/commands/tests/test_cli_status.py @@ -0,0 +1,64 @@ +# 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 . + +"""Test the status command.""" + +import socket +import unittest + +from click.testing import CliRunner +from mailman.bin.master import WatcherState +from mailman.commands.cli_status import status +from mailman.testing.layers import ConfigLayer +from unittest.mock import patch + + +class FakeLock: + details = ('localhost', 9999, None) + + +class TestStatus(unittest.TestCase): + layer = ConfigLayer + maxDiff = None + + def setUp(self): + self._command = CliRunner() + + def test_stale_lock(self): + with patch('mailman.commands.cli_status.master_state', + return_value=(WatcherState.stale_lock, FakeLock())): + results = self._command.invoke(status) + self.assertEqual(results.exit_code, + WatcherState.stale_lock.value, + results.output) + self.assertEqual( + results.output, + 'GNU Mailman is stopped (stale pid: 9999)\n', + results.output) + + def test_unknown_state(self): + with patch('mailman.commands.cli_status.master_state', + return_value=(WatcherState.host_mismatch, FakeLock())): + results = self._command.invoke(status) + self.assertEqual(results.exit_code, + WatcherState.host_mismatch.value, + results.output) + self.assertEqual( + results.output, + 'GNU Mailman is in an unexpected state ' + '(localhost != {})\n'.format(socket.getfqdn()), + results.output) diff --git a/src/mailman/commands/tests/test_cli_unshunt.py b/src/mailman/commands/tests/test_cli_unshunt.py new file mode 100644 index 000000000..70303e1b4 --- /dev/null +++ b/src/mailman/commands/tests/test_cli_unshunt.py @@ -0,0 +1,45 @@ +# 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 . + +"""Test the `unshunt` command.""" + +import unittest + +from click.testing import CliRunner +from mailman.commands.cli_unshunt import unshunt +from mailman.config import config +from mailman.email.message import Message +from mailman.testing.layers import ConfigLayer +from unittest.mock import patch + + +class TestUnshunt(unittest.TestCase): + layer = ConfigLayer + maxDiff = None + + def setUp(self): + self._command = CliRunner() + self._queue = config.switchboards['shunt'] + + def test_dequeue_fails(self): + filebase = self._queue.enqueue(Message(), {}) + with patch.object(self._queue, 'dequeue', + side_effect=RuntimeError('oops!')): + results = self._command.invoke(unshunt) + self.assertEqual( + results.output, + 'Cannot unshunt message {}, skipping:\noops!\n'.format(filebase)) diff --git a/src/mailman/commands/tests/test_conf.py b/src/mailman/commands/tests/test_conf.py deleted file mode 100644 index 94ca0a979..000000000 --- a/src/mailman/commands/tests/test_conf.py +++ /dev/null @@ -1,108 +0,0 @@ -# Copyright (C) 2013-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 . - -"""Test the conf subcommand.""" - -import os -import sys -import tempfile -import unittest - -from io import StringIO -from mailman.commands.cli_conf import Conf -from mailman.testing.layers import ConfigLayer -from unittest import mock - - -class FakeArgs: - section = None - key = None - output = None - sort = False - - -class FakeParser: - def __init__(self): - self.message = None - - def error(self, message): - self.message = message - sys.exit(1) - - -class TestConf(unittest.TestCase): - """Test the conf subcommand.""" - - layer = ConfigLayer - - def setUp(self): - self.command = Conf() - self.command.parser = FakeParser() - self.args = FakeArgs() - - def test_cannot_access_nonexistent_section(self): - self.args.section = 'thissectiondoesnotexist' - self.args.key = None - with self.assertRaises(SystemExit): - self.command.process(self.args) - self.assertEqual(self.command.parser.message, - 'No such section: thissectiondoesnotexist') - - def test_cannot_access_nonexistent_key(self): - self.args.section = "mailman" - self.args.key = 'thiskeydoesnotexist' - with self.assertRaises(SystemExit): - self.command.process(self.args) - self.assertEqual(self.command.parser.message, - 'Section mailman: No such key: thiskeydoesnotexist') - - def test_output_to_explicit_stdout(self): - self.args.output = '-' - self.args.section = 'shell' - self.args.key = 'use_ipython' - with mock.patch('sys.stdout') as mock_object: - self.command.process(self.args) - mock_object.write.assert_has_calls( - [mock.call('no'), mock.call('\n')]) - - def test_output_to_file(self): - self.args.section = 'shell' - self.args.key = 'use_ipython' - fd, filename = tempfile.mkstemp() - try: - self.args.output = filename - self.command.process(self.args) - with open(filename, 'r') as fp: - contents = fp.read() - finally: - os.remove(filename) - self.assertEqual(contents, 'no\n') - - def test_sort_by_section(self): - self.args.output = '-' - self.args.sort = True - output = StringIO() - with mock.patch('sys.stdout', output): - self.command.process(self.args) - last_line = '' - for line in output.getvalue().splitlines(): - if not line.startswith('['): - # This is a continuation line. --sort doesn't sort these. - continue - self.assertTrue(line > last_line, - '{} !> {}'.format(line, last_line)) - last_line = line diff --git a/src/mailman/commands/tests/test_confirm.py b/src/mailman/commands/tests/test_confirm.py deleted file mode 100644 index 1ef392b76..000000000 --- a/src/mailman/commands/tests/test_confirm.py +++ /dev/null @@ -1,175 +0,0 @@ -# Copyright (C) 2012-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 . - -"""Test the `confirm` command.""" - -import unittest - -from mailman.app.lifecycle import create_list -from mailman.commands.eml_confirm import Confirm -from mailman.config import config -from mailman.email.message import Message -from mailman.interfaces.command import ContinueProcessing -from mailman.interfaces.mailinglist import SubscriptionPolicy -from mailman.interfaces.subscriptions import ISubscriptionManager -from mailman.interfaces.usermanager import IUserManager -from mailman.runners.command import CommandRunner, Results -from mailman.testing.helpers import ( - get_queue_messages, make_testable_runner, - specialized_message_from_string as mfs, subscribe) -from mailman.testing.layers import ConfigLayer -from zope.component import getUtility - - -class TestConfirmJoin(unittest.TestCase): - """Test the `confirm` command when joining a mailing list.""" - - layer = ConfigLayer - - def setUp(self): - self._mlist = create_list('test@example.com') - anne = getUtility(IUserManager).create_address( - 'anne@example.com', 'Anne Person') - self._token, token_owner, member = ISubscriptionManager( - self._mlist).register(anne) - self._command = Confirm() - # Clear the virgin queue. - get_queue_messages('virgin') - - def test_welcome_message(self): - # A confirmation causes a welcome message to be sent to the member, if - # enabled by the mailing list. - status = self._command.process( - self._mlist, Message(), {}, (self._token,), Results()) - self.assertEqual(status, ContinueProcessing.yes) - # There should be one messages in the queue; the welcome message. - items = get_queue_messages('virgin', expected_count=1) - # Grab the welcome message. - welcome = items[0].msg - self.assertEqual(welcome['subject'], - 'Welcome to the "Test" mailing list') - self.assertEqual(welcome['to'], 'Anne Person ') - - def test_no_welcome_message(self): - # When configured not to send a welcome message, none is sent. - self._mlist.send_welcome_message = False - status = self._command.process( - self._mlist, Message(), {}, (self._token,), Results()) - self.assertEqual(status, ContinueProcessing.yes) - # There will be no messages in the queue. - get_queue_messages('virgin', expected_count=0) - - -class TestConfirmLeave(unittest.TestCase): - """Test the `confirm` command when leaving a mailing list.""" - - layer = ConfigLayer - - def setUp(self): - self._mlist = create_list('test@example.com') - anne = subscribe(self._mlist, 'Anne', email='anne@example.com') - self._token, token_owner, member = ISubscriptionManager( - self._mlist).unregister(anne.address) - - def test_confirm_leave(self): - msg = mfs("""\ -From: Anne Person -To: test-confirm+{token}@example.com -Subject: Re: confirm {token} - -""".format(token=self._token)) - Confirm().process(self._mlist, msg, {}, (self._token,), Results()) - # Anne is no longer a member of the mailing list. - member = self._mlist.members.get_member('anne@example.com') - self.assertIsNone(member) - - -class TestEmailResponses(unittest.TestCase): - """Test the `confirm` command through the command runner.""" - - layer = ConfigLayer - - def setUp(self): - self._mlist = create_list('test@example.com') - - def test_confirm_then_moderate_workflow(self): - # Issue #114 describes a problem when confirming the moderation email. - self._mlist.subscription_policy = ( - SubscriptionPolicy.confirm_then_moderate) - bart = getUtility(IUserManager).create_address( - 'bart@example.com', 'Bart Person') - # Clear any previously queued confirmation messages. - get_queue_messages('virgin') - self._token, token_owner, member = ISubscriptionManager( - self._mlist).register(bart) - # There should now be one email message in the virgin queue, i.e. the - # confirmation message sent to Bart. - items = get_queue_messages('virgin', expected_count=1) - msg = items[0].msg - # Confirmations come first, so this one goes to the subscriber. - self.assertEqual(msg['to'], 'bart@example.com') - confirm, token = str(msg['subject']).split() - self.assertEqual(confirm, 'confirm') - self.assertEqual(token, self._token) - # Craft a confirmation response with the expected tokens. - user_response = Message() - user_response['From'] = 'bart@example.com' - user_response['To'] = 'test-confirm+{}@example.com'.format(token) - user_response['Subject'] = 'Re: confirm {}'.format(token) - user_response.set_payload('') - # Process the message through the command runner. - config.switchboards['command'].enqueue( - user_response, listid='test.example.com') - make_testable_runner(CommandRunner, 'command').run() - # There are now two messages in the virgin queue. One is going to the - # subscriber containing the results of their confirmation message, and - # the other is to the moderators informing them that they need to - # handle the moderation queue. - items = get_queue_messages('virgin', expected_count=2) - if items[0].msg['to'] == 'bart@example.com': - results = items[0].msg - moderator_msg = items[1].msg - else: - results = items[1].msg - moderator_msg = items[0].msg - # Check the moderator message first. - self.assertEqual(moderator_msg['to'], 'test-owner@example.com') - self.assertEqual( - moderator_msg['subject'], - 'New subscription request to Test from bart@example.com') - lines = moderator_msg.get_payload().splitlines() - self.assertEqual( - lines[-2].strip(), - 'For: Bart Person ') - self.assertEqual(lines[-1].strip(), 'List: test@example.com') - # Now check the results message. - self.assertEqual( - str(results['subject']), 'The results of your email commands') - self.assertMultiLineEqual(results.get_payload(), """\ -The results of your email command are provided below. - -- Original message details: -From: bart@example.com -Subject: Re: confirm {} -Date: n/a -Message-ID: n/a - -- Results: -Confirmed - -- Done. -""".format(token)) diff --git a/src/mailman/commands/tests/test_control.py b/src/mailman/commands/tests/test_control.py deleted file mode 100644 index 473e78363..000000000 --- a/src/mailman/commands/tests/test_control.py +++ /dev/null @@ -1,244 +0,0 @@ -# Copyright (C) 2011-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 . - -"""Test some additional corner cases for starting/stopping.""" - -import os -import sys -import time -import errno -import shutil -import signal -import socket -import unittest - -from contextlib import ExitStack, suppress -from datetime import datetime, timedelta -from mailman.commands.cli_control import Start, kill_watcher -from mailman.config import Configuration, config -from mailman.testing.helpers import configuration -from mailman.testing.layers import ConfigLayer -from tempfile import TemporaryDirectory - - -SEP = '|' - - -def make_config(): - # All we care about is the master process; normally it starts a bunch of - # runners, but we don't care about any of them, so write a test - # configuration file for the master that disables all the runners. - new_config = 'no-runners.cfg' - config_file = os.path.join(os.path.dirname(config.filename), new_config) - shutil.copyfile(config.filename, config_file) - with open(config_file, 'a') as fp: - for runner_config in config.runner_configs: - print('[{}]\nstart:no\n'.format(runner_config.name), file=fp) - return config_file - - -def find_master(): - # See if the master process is still running. - until = timedelta(seconds=10) + datetime.now() - while datetime.now() < until: - time.sleep(0.1) - with suppress(FileNotFoundError, ValueError, ProcessLookupError): - with open(config.PID_FILE) as fp: - pid = int(fp.read().strip()) - os.kill(pid, 0) - return pid - return None - - -def kill_with_extreme_prejudice(pid=None): - # 2016-12-03 barry: We have intermittent hangs during both local and CI - # test suite runs where killing a runner or master process doesn't - # terminate the process. In those cases, wait()ing on the child can - # suspend the test process indefinitely. Locally, you have to C-c the - # test process, but that still doesn't kill it; the process continues to - # run in the background. If you then search for the process's pid and - # SIGTERM it, it will usually exit, which is why I don't understand why - # the above SIGTERM doesn't kill it sometimes. However, when run under - # CI, the test suite will just hang until the CI runner times it out. It - # would be better to figure out the underlying cause, because we have - # definitely seen other situations where a runner process won't exit, but - # for testing purposes we're just trying to clean up some resources so - # after a brief attempt at SIGTERMing it, let's SIGKILL it and warn. - if pid is not None: - os.kill(pid, signal.SIGTERM) - until = timedelta(seconds=10) + datetime.now() - while datetime.now() < until: - try: - if pid is None: - os.wait3(os.WNOHANG) - else: - os.waitpid(pid, os.WNOHANG) - except ChildProcessError: - # This basically means we went one too many times around the - # loop. The previous iteration successfully reaped the child. - # Because the return status of wait3() and waitpid() are different - # in those cases, it's easier just to catch the exception for - # either call and exit. - return - time.sleep(0.1) - else: - if pid is None: - # There's really not much more we can do because we have no pid to - # SIGKILL. Just report the problem and continue. - print('WARNING: NO CHANGE IN CHILD PROCESS STATES', - file=sys.stderr) - return - print('WARNING: SIGTERM DID NOT EXIT PROCESS; SIGKILLing', - file=sys.stderr) - if pid is not None: - os.kill(pid, signal.SIGKILL) - until = timedelta(seconds=10) + datetime.now() - while datetime.now() < until: - status = os.waitpid(pid, os.WNOHANG) - if status == (0, 0): - # The child was reaped. - return - time.sleep(0.1) - else: - print('WARNING: SIGKILL DID NOT EXIT PROCESS!', file=sys.stderr) - - -class FakeArgs: - force = None - run_as_user = None - quiet = True - config = None - - -class FakeParser: - def __init__(self): - self.message = None - - def error(self, message): - self.message = message - sys.exit(1) - - -class TestStart(unittest.TestCase): - """Test various starting scenarios.""" - - layer = ConfigLayer - - def setUp(self): - self.command = Start() - self.command.parser = FakeParser() - self.args = FakeArgs() - self.args.config = make_config() - - def tearDown(self): - try: - with open(config.PID_FILE) as fp: - master_pid = int(fp.read()) - except OSError as error: - if error.errno != errno.ENOENT: - raise - # There is no master, so just ignore this. - return - kill_watcher(signal.SIGTERM) - os.waitpid(master_pid, 0) - - def test_force_stale_lock(self): - # Fake an acquisition of the master lock by another process, which - # subsequently goes stale. Start by finding a free process id. Yes, - # this could race, but given that we're starting with our own PID and - # searching downward, it's less likely. - fake_pid = os.getpid() - 1 - while fake_pid > 1: - try: - os.kill(fake_pid, 0) - except OSError as error: - if error.errno == errno.ESRCH: - break - fake_pid -= 1 - else: - raise RuntimeError('Cannot find free PID') - # Lock acquisition logic taken from flufl.lock. - claim_file = SEP.join(( - config.LOCK_FILE, - socket.getfqdn(), - str(fake_pid), - '0')) - with open(config.LOCK_FILE, 'w') as fp: - fp.write(claim_file) - os.link(config.LOCK_FILE, claim_file) - expiration_date = datetime.now() - timedelta(minutes=60) - t = time.mktime(expiration_date.timetuple()) - os.utime(claim_file, (t, t)) - # Start without --force; no master will be running. - with suppress(SystemExit): - self.command.process(self.args) - self.assertIsNone(find_master()) - self.assertIn('--force', self.command.parser.message) - # Start again, this time with --force. - self.args.force = True - self.command.process(self.args) - pid = find_master() - self.assertIsNotNone(pid) - - -class TestBinDir(unittest.TestCase): - """Test issues related to bin_dir, e.g. issue #3""" - - layer = ConfigLayer - - def setUp(self): - self.command = Start() - self.command.parser = FakeParser() - self.args = FakeArgs() - self.args.config = make_config() - - def test_master_is_elsewhere(self): - with ExitStack() as resources: - # Patch os.fork() so that we can record the failing child process's - # id. We need to wait on the child exiting in either case, and - # when it fails, no master.pid will be written. - bin_dir = resources.enter_context(TemporaryDirectory()) - old_master = os.path.join(config.BIN_DIR, 'master') - new_master = os.path.join(bin_dir, 'master') - shutil.move(old_master, new_master) - resources.callback(shutil.move, new_master, old_master) - # Starting mailman should fail because 'master' can't be found. - # XXX This will print Errno 2 on the console because we're not - # silencing the child process's stderr. - self.command.process(self.args) - # There should be no pid file. - args_config = Configuration() - args_config.load(self.args.config) - self.assertFalse(os.path.exists(args_config.PID_FILE)) - kill_with_extreme_prejudice() - - def test_master_is_elsewhere_and_findable(self): - with ExitStack() as resources: - bin_dir = resources.enter_context(TemporaryDirectory()) - old_master = os.path.join(config.BIN_DIR, 'master') - new_master = os.path.join(bin_dir, 'master') - shutil.move(old_master, new_master) - resources.enter_context( - configuration('paths.testing', bin_dir=bin_dir)) - resources.callback(shutil.move, new_master, old_master) - # Starting mailman should find master in the new bin_dir. - self.command.process(self.args) - # There should a pid file and the process it describes should be - # killable. We might have to wait until the process has started. - master_pid = find_master() - self.assertIsNotNone(master_pid, 'master did not start') - kill_with_extreme_prejudice(master_pid) diff --git a/src/mailman/commands/tests/test_create.py b/src/mailman/commands/tests/test_create.py deleted file mode 100644 index f2232eb6f..000000000 --- a/src/mailman/commands/tests/test_create.py +++ /dev/null @@ -1,110 +0,0 @@ -# Copyright (C) 2011-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 . - -"""Test the `mailman create` subcommand.""" - -import sys -import unittest - -from argparse import ArgumentParser -from contextlib import suppress -from mailman.app.lifecycle import create_list -from mailman.commands.cli_lists import Create -from mailman.testing.layers import ConfigLayer - - -class FakeArgs: - language = None - owners = [] - quiet = False - domain = None - listname = None - notify = False - - -class FakeParser: - def __init__(self): - self.message = None - - def error(self, message): - self.message = message - sys.exit(1) - - -class TestCreate(unittest.TestCase): - layer = ConfigLayer - - def setUp(self): - self.command = Create() - self.command.parser = FakeParser() - self.args = FakeArgs() - - def test_cannot_create_duplicate_list(self): - # Cannot create a mailing list if it already exists. - create_list('test@example.com') - self.args.listname = ['test@example.com'] - with suppress(SystemExit): - self.command.process(self.args) - self.assertEqual(self.command.parser.message, - 'List already exists: test@example.com') - - def test_invalid_posting_address(self): - # Cannot create a mailing list with an invalid posting address. - self.args.listname = ['foo'] - with suppress(SystemExit): - self.command.process(self.args) - self.assertEqual(self.command.parser.message, - 'Illegal list name: foo') - - def test_invalid_owner_addresses(self): - # Cannot create a list with invalid owner addresses. LP: #778687 - self.args.listname = ['test@example.com'] - self.args.owners = ['main=True'] - with suppress(SystemExit): - self.command.process(self.args) - self.assertEqual(self.command.parser.message, - 'Illegal owner addresses: main=True') - - def test_without_domain_option(self): - # The domain will be created if no domain options are specified. - parser = ArgumentParser() - self.command.add(FakeParser(), parser) - args = parser.parse_args('test@example.org'.split()) - self.assertTrue(args.domain) - - def test_with_domain_option(self): - # The domain will be created if -d is given explicitly. - parser = ArgumentParser() - self.command.add(FakeParser(), parser) - args = parser.parse_args('-d test@example.org'.split()) - self.assertTrue(args.domain) - - def test_with_nodomain_option(self): - # The domain will not be created if --no-domain is given. - parser = ArgumentParser() - self.command.add(FakeParser(), parser) - args = parser.parse_args('-D test@example.net'.split()) - self.assertFalse(args.domain) - - def test_error_when_not_creating_domain(self): - self.args.domain = False - self.args.listname = ['test@example.org'] - with self.assertRaises(SystemExit) as cm: - self.command.process(self.args) - self.assertEqual(cm.exception.code, 1) - self.assertEqual(self.command.parser.message, - 'Undefined domain: example.org') diff --git a/src/mailman/commands/tests/test_digests.py b/src/mailman/commands/tests/test_digests.py deleted file mode 100644 index 0a3ea5226..000000000 --- a/src/mailman/commands/tests/test_digests.py +++ /dev/null @@ -1,451 +0,0 @@ -# Copyright (C) 2015-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 . - -"""Test the send-digests subcommand.""" - -import os -import unittest - -from datetime import timedelta -from io import StringIO -from mailman.app.lifecycle import create_list -from mailman.commands.cli_digests import Digests -from mailman.config import config -from mailman.interfaces.digests import DigestFrequency -from mailman.interfaces.member import DeliveryMode -from mailman.runners.digest import DigestRunner -from mailman.testing.helpers import ( - get_queue_messages, make_testable_runner, - specialized_message_from_string as mfs, subscribe) -from mailman.testing.layers import ConfigLayer -from mailman.utilities.datetime import now as right_now -from unittest.mock import patch - - -class FakeArgs: - def __init__(self): - self.lists = [] - self.send = False - self.bump = False - self.dry_run = False - self.verbose = False - - -class TestSendDigests(unittest.TestCase): - layer = ConfigLayer - - def setUp(self): - self._mlist = create_list('ant@example.com') - self._mlist.digests_enabled = True - self._mlist.digest_size_threshold = 100000 - self._mlist.send_welcome_message = False - self._command = Digests() - self._handler = config.handlers['to-digest'] - self._runner = make_testable_runner(DigestRunner, 'digest') - # The mailing list needs at least one digest recipient. - member = subscribe(self._mlist, 'Anne') - member.preferences.delivery_mode = DeliveryMode.plaintext_digests - - def test_send_one_digest_by_list_id(self): - msg = mfs("""\ -To: ant@example.com -From: anne@example.com -Subject: message 1 - -""") - self._handler.process(self._mlist, msg, {}) - del msg['subject'] - msg['subject'] = 'message 2' - self._handler.process(self._mlist, msg, {}) - # There are no digests already being sent, but the ant mailing list - # does have a digest mbox collecting messages. - get_queue_messages('digest', expected_count=0) - mailbox_path = os.path.join(self._mlist.data_path, 'digest.mmdf') - self.assertGreater(os.path.getsize(mailbox_path), 0) - args = FakeArgs() - args.send = True - args.lists.append('ant.example.com') - self._command.process(args) - self._runner.run() - # Now, there's no digest mbox and there's a plaintext digest in the - # outgoing queue. - self.assertFalse(os.path.exists(mailbox_path)) - items = get_queue_messages('virgin', expected_count=1) - digest_contents = str(items[0].msg) - self.assertIn('Subject: message 1', digest_contents) - self.assertIn('Subject: message 2', digest_contents) - - def test_send_one_digest_by_fqdn_listname(self): - msg = mfs("""\ -To: ant@example.com -From: anne@example.com -Subject: message 1 - -""") - self._handler.process(self._mlist, msg, {}) - del msg['subject'] - msg['subject'] = 'message 2' - self._handler.process(self._mlist, msg, {}) - # There are no digests already being sent, but the ant mailing list - # does have a digest mbox collecting messages. - get_queue_messages('digest', expected_count=0) - mailbox_path = os.path.join(self._mlist.data_path, 'digest.mmdf') - self.assertGreater(os.path.getsize(mailbox_path), 0) - args = FakeArgs() - args.send = True - args.lists.append('ant@example.com') - self._command.process(args) - self._runner.run() - # Now, there's no digest mbox and there's a plaintext digest in the - # outgoing queue. - self.assertFalse(os.path.exists(mailbox_path)) - items = get_queue_messages('virgin', expected_count=1) - digest_contents = str(items[0].msg) - self.assertIn('Subject: message 1', digest_contents) - self.assertIn('Subject: message 2', digest_contents) - - def test_send_one_digest_to_missing_list_id(self): - msg = mfs("""\ -To: ant@example.com -From: anne@example.com -Subject: message 1 - -""") - self._handler.process(self._mlist, msg, {}) - del msg['subject'] - msg['subject'] = 'message 2' - self._handler.process(self._mlist, msg, {}) - # There are no digests already being sent, but the ant mailing list - # does have a digest mbox collecting messages. - get_queue_messages('digest', expected_count=0) - mailbox_path = os.path.join(self._mlist.data_path, 'digest.mmdf') - self.assertGreater(os.path.getsize(mailbox_path), 0) - args = FakeArgs() - args.send = True - args.lists.append('bee.example.com') - stderr = StringIO() - with patch('mailman.commands.cli_digests.sys.stderr', stderr): - self._command.process(args) - self._runner.run() - # The warning was printed to stderr. - self.assertEqual(stderr.getvalue(), - 'No such list found: bee.example.com\n') - # And no digest was prepared. - self.assertGreater(os.path.getsize(mailbox_path), 0) - get_queue_messages('virgin', expected_count=0) - - def test_send_one_digest_to_missing_fqdn_listname(self): - msg = mfs("""\ -To: ant@example.com -From: anne@example.com -Subject: message 1 - -""") - self._handler.process(self._mlist, msg, {}) - del msg['subject'] - msg['subject'] = 'message 2' - self._handler.process(self._mlist, msg, {}) - # There are no digests already being sent, but the ant mailing list - # does have a digest mbox collecting messages. - get_queue_messages('digest', expected_count=0) - mailbox_path = os.path.join(self._mlist.data_path, 'digest.mmdf') - self.assertGreater(os.path.getsize(mailbox_path), 0) - args = FakeArgs() - args.send = True - args.lists.append('bee@example.com') - stderr = StringIO() - with patch('mailman.commands.cli_digests.sys.stderr', stderr): - self._command.process(args) - self._runner.run() - # The warning was printed to stderr. - self.assertEqual(stderr.getvalue(), - 'No such list found: bee@example.com\n') - # And no digest was prepared. - self.assertGreater(os.path.getsize(mailbox_path), 0) - get_queue_messages('virgin', expected_count=0) - - def test_send_digest_to_one_missing_and_one_existing_list(self): - msg = mfs("""\ -To: ant@example.com -From: anne@example.com -Subject: message 1 - -""") - self._handler.process(self._mlist, msg, {}) - del msg['subject'] - msg['subject'] = 'message 2' - self._handler.process(self._mlist, msg, {}) - # There are no digests already being sent, but the ant mailing list - # does have a digest mbox collecting messages. - get_queue_messages('digest', expected_count=0) - mailbox_path = os.path.join(self._mlist.data_path, 'digest.mmdf') - self.assertGreater(os.path.getsize(mailbox_path), 0) - args = FakeArgs() - args.send = True - args.lists.extend(('ant.example.com', 'bee.example.com')) - stderr = StringIO() - with patch('mailman.commands.cli_digests.sys.stderr', stderr): - self._command.process(args) - self._runner.run() - # The warning was printed to stderr. - self.assertEqual(stderr.getvalue(), - 'No such list found: bee.example.com\n') - # But ant's digest was still prepared. - self.assertFalse(os.path.exists(mailbox_path)) - items = get_queue_messages('virgin', expected_count=1) - digest_contents = str(items[0].msg) - self.assertIn('Subject: message 1', digest_contents) - self.assertIn('Subject: message 2', digest_contents) - - def test_send_digests_for_two_lists(self): - # Populate ant's digest. - msg = mfs("""\ -To: ant@example.com -From: anne@example.com -Subject: message 1 - -""") - self._handler.process(self._mlist, msg, {}) - del msg['subject'] - msg['subject'] = 'message 2' - self._handler.process(self._mlist, msg, {}) - # Create the second list. - bee = create_list('bee@example.com') - bee.digests_enabled = True - bee.digest_size_threshold = 100000 - bee.send_welcome_message = False - member = subscribe(bee, 'Bart') - member.preferences.delivery_mode = DeliveryMode.plaintext_digests - # Populate bee's digest. - msg = mfs("""\ -To: bee@example.com -From: bart@example.com -Subject: message 3 - -""") - self._handler.process(bee, msg, {}) - del msg['subject'] - msg['subject'] = 'message 4' - self._handler.process(bee, msg, {}) - # There are no digests for either list already being sent, but the - # mailing lists do have a digest mbox collecting messages. - ant_mailbox_path = os.path.join(self._mlist.data_path, 'digest.mmdf') - self.assertGreater(os.path.getsize(ant_mailbox_path), 0) - # Check bee's digest. - bee_mailbox_path = os.path.join(bee.data_path, 'digest.mmdf') - self.assertGreater(os.path.getsize(bee_mailbox_path), 0) - # Both. - get_queue_messages('digest', expected_count=0) - # Process both list's digests. - args = FakeArgs() - args.send = True - args.lists.extend(('ant.example.com', 'bee@example.com')) - self._command.process(args) - self._runner.run() - # Now, neither list has a digest mbox and but there are plaintext - # digest in the outgoing queue for both. - self.assertFalse(os.path.exists(ant_mailbox_path)) - self.assertFalse(os.path.exists(bee_mailbox_path)) - items = get_queue_messages('virgin', expected_count=2) - # Figure out which digest is going to ant and which to bee. - if items[0].msg['to'] == 'ant@example.com': - ant = items[0].msg - bee = items[1].msg - else: - assert items[0].msg['to'] == 'bee@example.com' - ant = items[1].msg - bee = items[0].msg - # Check ant's digest. - digest_contents = str(ant) - self.assertIn('Subject: message 1', digest_contents) - self.assertIn('Subject: message 2', digest_contents) - # Check bee's digest. - digest_contents = str(bee) - self.assertIn('Subject: message 3', digest_contents) - self.assertIn('Subject: message 4', digest_contents) - - def test_send_digests_for_all_lists(self): - # Populate ant's digest. - msg = mfs("""\ -To: ant@example.com -From: anne@example.com -Subject: message 1 - -""") - self._handler.process(self._mlist, msg, {}) - del msg['subject'] - msg['subject'] = 'message 2' - self._handler.process(self._mlist, msg, {}) - # Create the second list. - bee = create_list('bee@example.com') - bee.digests_enabled = True - bee.digest_size_threshold = 100000 - bee.send_welcome_message = False - member = subscribe(bee, 'Bart') - member.preferences.delivery_mode = DeliveryMode.plaintext_digests - # Populate bee's digest. - msg = mfs("""\ -To: bee@example.com -From: bart@example.com -Subject: message 3 - -""") - self._handler.process(bee, msg, {}) - del msg['subject'] - msg['subject'] = 'message 4' - self._handler.process(bee, msg, {}) - # There are no digests for either list already being sent, but the - # mailing lists do have a digest mbox collecting messages. - ant_mailbox_path = os.path.join(self._mlist.data_path, 'digest.mmdf') - self.assertGreater(os.path.getsize(ant_mailbox_path), 0) - # Check bee's digest. - bee_mailbox_path = os.path.join(bee.data_path, 'digest.mmdf') - self.assertGreater(os.path.getsize(bee_mailbox_path), 0) - # Both. - get_queue_messages('digest', expected_count=0) - # Process all mailing list digests by not setting any arguments. - args = FakeArgs() - args.send = True - self._command.process(args) - self._runner.run() - # Now, neither list has a digest mbox and but there are plaintext - # digest in the outgoing queue for both. - self.assertFalse(os.path.exists(ant_mailbox_path)) - self.assertFalse(os.path.exists(bee_mailbox_path)) - items = get_queue_messages('virgin', expected_count=2) - # Figure out which digest is going to ant and which to bee. - if items[0].msg['to'] == 'ant@example.com': - ant = items[0].msg - bee = items[1].msg - else: - assert items[0].msg['to'] == 'bee@example.com' - ant = items[1].msg - bee = items[0].msg - # Check ant's digest. - digest_contents = str(ant) - self.assertIn('Subject: message 1', digest_contents) - self.assertIn('Subject: message 2', digest_contents) - # Check bee's digest. - digest_contents = str(bee) - self.assertIn('Subject: message 3', digest_contents) - self.assertIn('Subject: message 4', digest_contents) - - def test_send_no_digest_ready(self): - # If no messages have been sent through the mailing list, no digest - # can be sent. - mailbox_path = os.path.join(self._mlist.data_path, 'digest.mmdf') - self.assertFalse(os.path.exists(mailbox_path)) - args = FakeArgs() - args.send = True - args.lists.append('ant.example.com') - self._command.process(args) - self._runner.run() - get_queue_messages('virgin', expected_count=0) - - def test_bump_before_send(self): - self._mlist.digest_volume_frequency = DigestFrequency.monthly - self._mlist.volume = 7 - self._mlist.next_digest_number = 4 - self._mlist.digest_last_sent_at = right_now() + timedelta( - days=-32) - msg = mfs("""\ -To: ant@example.com -From: anne@example.com -Subject: message 1 - -""") - self._handler.process(self._mlist, msg, {}) - args = FakeArgs() - args.bump = True - args.send = True - args.lists.append('ant.example.com') - self._command.process(args) - self._runner.run() - # The volume is 8 and the digest number is 2 because a digest was sent - # after the volume/number was bumped. - self.assertEqual(self._mlist.volume, 8) - self.assertEqual(self._mlist.next_digest_number, 2) - self.assertEqual(self._mlist.digest_last_sent_at, right_now()) - items = get_queue_messages('virgin', expected_count=1) - self.assertEqual(items[0].msg['subject'], 'Ant Digest, Vol 8, Issue 1') - - -class TestBumpVolume(unittest.TestCase): - layer = ConfigLayer - - def setUp(self): - self._mlist = create_list('ant@example.com') - self._mlist.digest_volume_frequency = DigestFrequency.monthly - self._mlist.volume = 7 - self._mlist.next_digest_number = 4 - self.right_now = right_now() - self._command = Digests() - - def test_bump_one_list(self): - self._mlist.digest_last_sent_at = self.right_now + timedelta( - days=-32) - args = FakeArgs() - args.bump = True - args.lists.append('ant.example.com') - self._command.process(args) - self.assertEqual(self._mlist.volume, 8) - self.assertEqual(self._mlist.next_digest_number, 1) - self.assertEqual(self._mlist.digest_last_sent_at, self.right_now) - - def test_bump_two_lists(self): - self._mlist.digest_last_sent_at = self.right_now + timedelta( - days=-32) - # Create the second list. - bee = create_list('bee@example.com') - bee.digest_volume_frequency = DigestFrequency.monthly - bee.volume = 7 - bee.next_digest_number = 4 - bee.digest_last_sent_at = self.right_now + timedelta( - days=-32) - args = FakeArgs() - args.bump = True - args.lists.extend(('ant.example.com', 'bee.example.com')) - self._command.process(args) - self.assertEqual(self._mlist.volume, 8) - self.assertEqual(self._mlist.next_digest_number, 1) - self.assertEqual(self._mlist.digest_last_sent_at, self.right_now) - - def test_bump_verbose(self): - args = FakeArgs() - args.bump = True - args.verbose = True - args.lists.append('ant.example.com') - output = StringIO() - with patch('sys.stdout', output): - self._command.process(args) - self.assertMultiLineEqual(output.getvalue(), """\ -ant.example.com is at volume 7, number 4 -ant.example.com bumped to volume 7, number 5 -""") - - def test_send_verbose(self): - args = FakeArgs() - args.send = True - args.verbose = True - args.dry_run = True - args.lists.append('ant.example.com') - output = StringIO() - with patch('sys.stdout', output): - self._command.process(args) - self.assertMultiLineEqual(output.getvalue(), """\ -ant.example.com sent volume 7, number 4 -""") diff --git a/src/mailman/commands/tests/test_eml_confirm.py b/src/mailman/commands/tests/test_eml_confirm.py new file mode 100644 index 000000000..1ef392b76 --- /dev/null +++ b/src/mailman/commands/tests/test_eml_confirm.py @@ -0,0 +1,175 @@ +# Copyright (C) 2012-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 . + +"""Test the `confirm` command.""" + +import unittest + +from mailman.app.lifecycle import create_list +from mailman.commands.eml_confirm import Confirm +from mailman.config import config +from mailman.email.message import Message +from mailman.interfaces.command import ContinueProcessing +from mailman.interfaces.mailinglist import SubscriptionPolicy +from mailman.interfaces.subscriptions import ISubscriptionManager +from mailman.interfaces.usermanager import IUserManager +from mailman.runners.command import CommandRunner, Results +from mailman.testing.helpers import ( + get_queue_messages, make_testable_runner, + specialized_message_from_string as mfs, subscribe) +from mailman.testing.layers import ConfigLayer +from zope.component import getUtility + + +class TestConfirmJoin(unittest.TestCase): + """Test the `confirm` command when joining a mailing list.""" + + layer = ConfigLayer + + def setUp(self): + self._mlist = create_list('test@example.com') + anne = getUtility(IUserManager).create_address( + 'anne@example.com', 'Anne Person') + self._token, token_owner, member = ISubscriptionManager( + self._mlist).register(anne) + self._command = Confirm() + # Clear the virgin queue. + get_queue_messages('virgin') + + def test_welcome_message(self): + # A confirmation causes a welcome message to be sent to the member, if + # enabled by the mailing list. + status = self._command.process( + self._mlist, Message(), {}, (self._token,), Results()) + self.assertEqual(status, ContinueProcessing.yes) + # There should be one messages in the queue; the welcome message. + items = get_queue_messages('virgin', expected_count=1) + # Grab the welcome message. + welcome = items[0].msg + self.assertEqual(welcome['subject'], + 'Welcome to the "Test" mailing list') + self.assertEqual(welcome['to'], 'Anne Person ') + + def test_no_welcome_message(self): + # When configured not to send a welcome message, none is sent. + self._mlist.send_welcome_message = False + status = self._command.process( + self._mlist, Message(), {}, (self._token,), Results()) + self.assertEqual(status, ContinueProcessing.yes) + # There will be no messages in the queue. + get_queue_messages('virgin', expected_count=0) + + +class TestConfirmLeave(unittest.TestCase): + """Test the `confirm` command when leaving a mailing list.""" + + layer = ConfigLayer + + def setUp(self): + self._mlist = create_list('test@example.com') + anne = subscribe(self._mlist, 'Anne', email='anne@example.com') + self._token, token_owner, member = ISubscriptionManager( + self._mlist).unregister(anne.address) + + def test_confirm_leave(self): + msg = mfs("""\ +From: Anne Person +To: test-confirm+{token}@example.com +Subject: Re: confirm {token} + +""".format(token=self._token)) + Confirm().process(self._mlist, msg, {}, (self._token,), Results()) + # Anne is no longer a member of the mailing list. + member = self._mlist.members.get_member('anne@example.com') + self.assertIsNone(member) + + +class TestEmailResponses(unittest.TestCase): + """Test the `confirm` command through the command runner.""" + + layer = ConfigLayer + + def setUp(self): + self._mlist = create_list('test@example.com') + + def test_confirm_then_moderate_workflow(self): + # Issue #114 describes a problem when confirming the moderation email. + self._mlist.subscription_policy = ( + SubscriptionPolicy.confirm_then_moderate) + bart = getUtility(IUserManager).create_address( + 'bart@example.com', 'Bart Person') + # Clear any previously queued confirmation messages. + get_queue_messages('virgin') + self._token, token_owner, member = ISubscriptionManager( + self._mlist).register(bart) + # There should now be one email message in the virgin queue, i.e. the + # confirmation message sent to Bart. + items = get_queue_messages('virgin', expected_count=1) + msg = items[0].msg + # Confirmations come first, so this one goes to the subscriber. + self.assertEqual(msg['to'], 'bart@example.com') + confirm, token = str(msg['subject']).split() + self.assertEqual(confirm, 'confirm') + self.assertEqual(token, self._token) + # Craft a confirmation response with the expected tokens. + user_response = Message() + user_response['From'] = 'bart@example.com' + user_response['To'] = 'test-confirm+{}@example.com'.format(token) + user_response['Subject'] = 'Re: confirm {}'.format(token) + user_response.set_payload('') + # Process the message through the command runner. + config.switchboards['command'].enqueue( + user_response, listid='test.example.com') + make_testable_runner(CommandRunner, 'command').run() + # There are now two messages in the virgin queue. One is going to the + # subscriber containing the results of their confirmation message, and + # the other is to the moderators informing them that they need to + # handle the moderation queue. + items = get_queue_messages('virgin', expected_count=2) + if items[0].msg['to'] == 'bart@example.com': + results = items[0].msg + moderator_msg = items[1].msg + else: + results = items[1].msg + moderator_msg = items[0].msg + # Check the moderator message first. + self.assertEqual(moderator_msg['to'], 'test-owner@example.com') + self.assertEqual( + moderator_msg['subject'], + 'New subscription request to Test from bart@example.com') + lines = moderator_msg.get_payload().splitlines() + self.assertEqual( + lines[-2].strip(), + 'For: Bart Person ') + self.assertEqual(lines[-1].strip(), 'List: test@example.com') + # Now check the results message. + self.assertEqual( + str(results['subject']), 'The results of your email commands') + self.assertMultiLineEqual(results.get_payload(), """\ +The results of your email command are provided below. + +- Original message details: +From: bart@example.com +Subject: Re: confirm {} +Date: n/a +Message-ID: n/a + +- Results: +Confirmed + +- Done. +""".format(token)) diff --git a/src/mailman/commands/tests/test_eml_help.py b/src/mailman/commands/tests/test_eml_help.py new file mode 100644 index 000000000..3090de9cc --- /dev/null +++ b/src/mailman/commands/tests/test_eml_help.py @@ -0,0 +1,62 @@ +# Copyright (C) 2012-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 . + +"""Additional tests for the `help` email command.""" + +import unittest + +from mailman.app.lifecycle import create_list +from mailman.commands.eml_help import Help +from mailman.email.message import Message +from mailman.interfaces.command import ContinueProcessing +from mailman.runners.command import Results +from mailman.testing.layers import ConfigLayer + + +class TestHelp(unittest.TestCase): + """Test email help.""" + + layer = ConfigLayer + + def setUp(self): + self._mlist = create_list('test@example.com') + self._help = Help() + + def test_too_many_arguments(self): + # Error message when too many help arguments are given. + results = Results() + status = self._help.process(self._mlist, Message(), {}, + ('more', 'than', 'one'), + results) + self.assertEqual(status, ContinueProcessing.no) + self.assertEqual(str(results), """\ +The results of your email command are provided below. + +help: too many arguments: more than one +""") + + def test_no_such_command(self): + # Error message when asking for help on an existent command. + results = Results() + status = self._help.process(self._mlist, Message(), {}, + ('doesnotexist',), results) + self.assertEqual(status, ContinueProcessing.no) + self.assertEqual(str(results), """\ +The results of your email command are provided below. + +help: no such command: doesnotexist +""") diff --git a/src/mailman/commands/tests/test_eml_membership.py b/src/mailman/commands/tests/test_eml_membership.py new file mode 100644 index 000000000..6cf4802c6 --- /dev/null +++ b/src/mailman/commands/tests/test_eml_membership.py @@ -0,0 +1,54 @@ +# Copyright (C) 2016-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 . + +"""Test the Leave command.""" + +import unittest + +from mailman.app.lifecycle import create_list +from mailman.commands.eml_membership import Leave +from mailman.email.message import Message +from mailman.interfaces.mailinglist import SubscriptionPolicy +from mailman.interfaces.usermanager import IUserManager +from mailman.runners.command import Results +from mailman.testing.helpers import set_preferred +from mailman.testing.layers import ConfigLayer +from zope.component import getUtility + + +class TestLeave(unittest.TestCase): + layer = ConfigLayer + + def setUp(self): + self._mlist = create_list('ant@example.com') + self._command = Leave() + + def test_confirm_leave_not_a_member(self): + self._mlist.unsubscription_policy = SubscriptionPolicy.confirm + # Try to unsubscribe someone who is not a member. Anne is a real + # user, with a validated address, but she is not a member of the + # mailing list. + anne = getUtility(IUserManager).create_user('anne@example.com') + set_preferred(anne) + # Initiate an unsubscription. + msg = Message() + msg['From'] = 'anne@example.com' + results = Results() + self._command.process(self._mlist, msg, {}, (), results) + self.assertEqual( + str(results).splitlines()[-1], + 'leave: anne@example.com is not a member of ant@example.com') diff --git a/src/mailman/commands/tests/test_help.py b/src/mailman/commands/tests/test_help.py deleted file mode 100644 index 3090de9cc..000000000 --- a/src/mailman/commands/tests/test_help.py +++ /dev/null @@ -1,62 +0,0 @@ -# Copyright (C) 2012-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 . - -"""Additional tests for the `help` email command.""" - -import unittest - -from mailman.app.lifecycle import create_list -from mailman.commands.eml_help import Help -from mailman.email.message import Message -from mailman.interfaces.command import ContinueProcessing -from mailman.runners.command import Results -from mailman.testing.layers import ConfigLayer - - -class TestHelp(unittest.TestCase): - """Test email help.""" - - layer = ConfigLayer - - def setUp(self): - self._mlist = create_list('test@example.com') - self._help = Help() - - def test_too_many_arguments(self): - # Error message when too many help arguments are given. - results = Results() - status = self._help.process(self._mlist, Message(), {}, - ('more', 'than', 'one'), - results) - self.assertEqual(status, ContinueProcessing.no) - self.assertEqual(str(results), """\ -The results of your email command are provided below. - -help: too many arguments: more than one -""") - - def test_no_such_command(self): - # Error message when asking for help on an existent command. - results = Results() - status = self._help.process(self._mlist, Message(), {}, - ('doesnotexist',), results) - self.assertEqual(status, ContinueProcessing.no) - self.assertEqual(str(results), """\ -The results of your email command are provided below. - -help: no such command: doesnotexist -""") diff --git a/src/mailman/commands/tests/test_import.py b/src/mailman/commands/tests/test_import.py deleted file mode 100644 index e210889fc..000000000 --- a/src/mailman/commands/tests/test_import.py +++ /dev/null @@ -1,54 +0,0 @@ -# Copyright (C) 2015-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 . - -"""Test the `mailman import21` subcommand.""" - -import unittest - -from mailman.app.lifecycle import create_list -from mailman.commands.cli_import import Import21 -from mailman.testing.layers import ConfigLayer -from pkg_resources import resource_filename -from unittest.mock import patch - - -class FakeArgs: - listname = ['test@example.com'] - pickle_file = [ - resource_filename('mailman.testing', 'config-with-instances.pck'), - ] - - -class TestImport(unittest.TestCase): - layer = ConfigLayer - - def setUp(self): - self.command = Import21() - self.args = FakeArgs() - self.mlist = create_list('test@example.com') - - @patch('mailman.commands.cli_import.import_config_pck') - def test_process_pickle_with_bounce_info(self, import_config_pck): - # The sample data contains Mailman 2 bounce info, represented as - # _BounceInfo instances. We throw these away when importing to - # Mailman 3, but we have to fake the instance's classes, otherwise - # unpickling the dictionaries will fail. - try: - self.command.process(self.args) - except ImportError as error: - self.fail('The pickle failed loading: {}'.format(error)) - self.assertTrue(import_config_pck.called) diff --git a/src/mailman/commands/tests/test_lists.py b/src/mailman/commands/tests/test_lists.py deleted file mode 100644 index 8463b639f..000000000 --- a/src/mailman/commands/tests/test_lists.py +++ /dev/null @@ -1,61 +0,0 @@ -# Copyright (C) 2015-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 . - -"""Additional tests for the `lists` command line subcommand.""" - -import unittest - -from io import StringIO -from mailman.app.lifecycle import create_list -from mailman.commands.cli_lists import Lists -from mailman.interfaces.domain import IDomainManager -from mailman.testing.layers import ConfigLayer -from unittest.mock import patch -from zope.component import getUtility - - -class FakeArgs: - advertised = False - names = False - descriptions = False - quiet = False - domain = [] - - -class TestLists(unittest.TestCase): - layer = ConfigLayer - - def test_lists_with_domain_option(self): - # LP: #1166911 - non-matching lists were returned. - getUtility(IDomainManager).add( - 'example.net', 'An example domain.') - create_list('test1@example.com') - create_list('test2@example.com') - # Only this one should show up. - create_list('test3@example.net') - create_list('test4@example.com') - command = Lists() - args = FakeArgs() - args.domain.append('example.net') - output = StringIO() - with patch('sys.stdout', output): - command.process(args) - lines = output.getvalue().splitlines() - # The first line is the heading, so skip that. - lines.pop(0) - self.assertEqual(len(lines), 1, lines) - self.assertEqual(lines[0], 'test3@example.net') diff --git a/src/mailman/commands/tests/test_members.py b/src/mailman/commands/tests/test_members.py deleted file mode 100644 index 74c7414f6..000000000 --- a/src/mailman/commands/tests/test_members.py +++ /dev/null @@ -1,146 +0,0 @@ -# Copyright (C) 2015-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 . - -"""Test the `mailman members` command.""" - -import sys -import unittest - -from functools import partial -from io import StringIO -from mailman.app.lifecycle import create_list -from mailman.commands.cli_members import Members -from mailman.interfaces.member import MemberRole -from mailman.testing.helpers import subscribe -from mailman.testing.layers import ConfigLayer -from tempfile import NamedTemporaryFile -from unittest.mock import patch - - -class FakeArgs: - input_filename = None - output_filename = None - role = None - regular = None - digest = None - nomail = None - list = None - - -class FakeParser: - def __init__(self): - self.message = None - - def error(self, message): - self.message = message - sys.exit(1) - - -class TestCLIMembers(unittest.TestCase): - layer = ConfigLayer - - def setUp(self): - self._mlist = create_list('ant@example.com') - self.command = Members() - self.command.parser = FakeParser() - self.args = FakeArgs() - - def test_no_such_list(self): - self.args.list = ['bee.example.com'] - with self.assertRaises(SystemExit): - self.command.process(self.args) - self.assertEqual(self.command.parser.message, - 'No such list: bee.example.com') - - def test_bad_delivery_status(self): - self.args.list = ['ant.example.com'] - self.args.nomail = 'bogus' - with self.assertRaises(SystemExit): - self.command.process(self.args) - self.assertEqual(self.command.parser.message, - 'Unknown delivery status: bogus') - - def test_role_administrator(self): - subscribe(self._mlist, 'Anne', role=MemberRole.owner) - subscribe(self._mlist, 'Bart', role=MemberRole.moderator) - subscribe(self._mlist, 'Cate', role=MemberRole.nonmember) - subscribe(self._mlist, 'Dave', role=MemberRole.member) - self.args.list = ['ant.example.com'] - self.args.role = 'administrator' - with NamedTemporaryFile('w', encoding='utf-8') as outfp: - self.args.output_filename = outfp.name - self.command.process(self.args) - with open(outfp.name, 'r', encoding='utf-8') as infp: - lines = infp.readlines() - self.assertEqual(len(lines), 2) - self.assertEqual(lines[0], 'Anne Person \n') - self.assertEqual(lines[1], 'Bart Person \n') - - def test_role_any(self): - subscribe(self._mlist, 'Anne', role=MemberRole.owner) - subscribe(self._mlist, 'Bart', role=MemberRole.moderator) - subscribe(self._mlist, 'Cate', role=MemberRole.nonmember) - subscribe(self._mlist, 'Dave', role=MemberRole.member) - self.args.list = ['ant.example.com'] - self.args.role = 'any' - with NamedTemporaryFile('w', encoding='utf-8') as outfp: - self.args.output_filename = outfp.name - self.command.process(self.args) - with open(outfp.name, 'r', encoding='utf-8') as infp: - lines = infp.readlines() - self.assertEqual(len(lines), 4) - self.assertEqual(lines[0], 'Anne Person \n') - self.assertEqual(lines[1], 'Bart Person \n') - self.assertEqual(lines[2], 'Cate Person \n') - self.assertEqual(lines[3], 'Dave Person \n') - - def test_role_moderator(self): - subscribe(self._mlist, 'Anne', role=MemberRole.owner) - subscribe(self._mlist, 'Bart', role=MemberRole.moderator) - subscribe(self._mlist, 'Cate', role=MemberRole.nonmember) - subscribe(self._mlist, 'Dave', role=MemberRole.member) - self.args.list = ['ant.example.com'] - self.args.role = 'moderator' - with NamedTemporaryFile('w', encoding='utf-8') as outfp: - self.args.output_filename = outfp.name - self.command.process(self.args) - with open(outfp.name, 'r', encoding='utf-8') as infp: - lines = infp.readlines() - self.assertEqual(len(lines), 1) - self.assertEqual(lines[0], 'Bart Person \n') - - def test_bad_role(self): - self.args.list = ['ant.example.com'] - self.args.role = 'bogus' - with self.assertRaises(SystemExit): - self.command.process(self.args) - self.assertEqual(self.command.parser.message, - 'Unknown member role: bogus') - - def test_already_subscribed_with_display_name(self): - subscribe(self._mlist, 'Anne') - outfp = StringIO() - with NamedTemporaryFile('w', buffering=1, encoding='utf-8') as infp: - print('Anne Person ', file=infp) - self.args.list = ['ant.example.com'] - self.args.input_filename = infp.name - with patch('builtins.print', partial(print, file=outfp)): - self.command.process(self.args) - self.assertEqual( - outfp.getvalue(), - 'Already subscribed (skipping): Anne Person \n' - ) diff --git a/src/mailman/commands/tests/test_membership.py b/src/mailman/commands/tests/test_membership.py deleted file mode 100644 index 6cf4802c6..000000000 --- a/src/mailman/commands/tests/test_membership.py +++ /dev/null @@ -1,54 +0,0 @@ -# Copyright (C) 2016-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 . - -"""Test the Leave command.""" - -import unittest - -from mailman.app.lifecycle import create_list -from mailman.commands.eml_membership import Leave -from mailman.email.message import Message -from mailman.interfaces.mailinglist import SubscriptionPolicy -from mailman.interfaces.usermanager import IUserManager -from mailman.runners.command import Results -from mailman.testing.helpers import set_preferred -from mailman.testing.layers import ConfigLayer -from zope.component import getUtility - - -class TestLeave(unittest.TestCase): - layer = ConfigLayer - - def setUp(self): - self._mlist = create_list('ant@example.com') - self._command = Leave() - - def test_confirm_leave_not_a_member(self): - self._mlist.unsubscription_policy = SubscriptionPolicy.confirm - # Try to unsubscribe someone who is not a member. Anne is a real - # user, with a validated address, but she is not a member of the - # mailing list. - anne = getUtility(IUserManager).create_user('anne@example.com') - set_preferred(anne) - # Initiate an unsubscription. - msg = Message() - msg['From'] = 'anne@example.com' - results = Results() - self._command.process(self._mlist, msg, {}, (), results) - self.assertEqual( - str(results).splitlines()[-1], - 'leave: anne@example.com is not a member of ant@example.com') diff --git a/src/mailman/commands/tests/test_shell.py b/src/mailman/commands/tests/test_shell.py deleted file mode 100644 index 2f7998f9f..000000000 --- a/src/mailman/commands/tests/test_shell.py +++ /dev/null @@ -1,81 +0,0 @@ -# Copyright (C) 2016-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 . - -"""Test the withlist/shell command.""" - -import os -import unittest - -from mailman.commands.cli_withlist import Withlist -from mailman.config import config -from mailman.interfaces.usermanager import IUserManager -from mailman.testing.helpers import configuration -from mailman.testing.layers import ConfigLayer -from unittest.mock import patch - -try: - import readline # noqa: F401 - has_readline = True -except ImportError: - has_readline = False - - -class FakeArgs: - interactive = None - run = None - details = False - listname = None - - -class TestShell(unittest.TestCase): - layer = ConfigLayer - - def setUp(self): - self._shell = Withlist() - - def test_namespace(self): - args = FakeArgs() - args.interactive = True - with patch.object(self._shell, '_start_python') as mock: - self._shell.process(args) - self.assertEqual(mock.call_count, 1) - # Don't test that all names are available, just a few choice ones. - positional, keywords = mock.call_args - namespace = positional[0] - self.assertIn('getUtility', namespace) - self.assertIn('IArchiver', namespace) - self.assertEqual(namespace['IUserManager'], IUserManager) - - @configuration('shell', banner='my banner') - def test_banner(self): - args = FakeArgs() - args.interactive = True - with patch('mailman.commands.cli_withlist.interact') as mock: - self._shell.process(args) - self.assertEqual(mock.call_count, 1) - positional, keywords = mock.call_args - self.assertEqual(keywords['banner'], 'my banner\n') - - @unittest.skipUnless(has_readline, 'readline module is not available') - @configuration('shell', history_file='$var_dir/history.py') - def test_history_file(self): - args = FakeArgs() - args.interactive = True - with patch('mailman.commands.cli_withlist.interact'): - self._shell.process(args) - history_file = os.path.join(config.VAR_DIR, 'history.py') - self.assertTrue(os.path.exists(history_file)) diff --git a/src/mailman/config/config.py b/src/mailman/config/config.py index bab041a05..61c9fe6ed 100644 --- a/src/mailman/config/config.py +++ b/src/mailman/config/config.py @@ -77,6 +77,7 @@ class Configuration: self.pipelines = {} self.commands = {} self.password_context = None + self.db = None def _clear(self): """Clear the cached configuration variables.""" diff --git a/src/mailman/core/runner.py b/src/mailman/core/runner.py index 707ad5e07..2f7e991dc 100644 --- a/src/mailman/core/runner.py +++ b/src/mailman/core/runner.py @@ -86,7 +86,7 @@ class Runner: def __repr__(self): return '<{} at {:#x}>'.format(self.__class__.__name__, id(self)) - def signal_handler(self, signum, frame): + def signal_handler(self, signum, frame): # pragma: nocover signame = { signal.SIGTERM: 'SIGTERM', signal.SIGINT: 'SIGINT', @@ -94,11 +94,13 @@ class Runner: }.get(signum, signum) if signum == signal.SIGHUP: reopen() - rlog.info('%s runner caught SIGHUP. Reopening logs.', self.name) + rlog.info('{} runner caught SIGHUP. Reopening logs.'.format( + self.name)) elif signum in (signal.SIGTERM, signal.SIGINT, signal.SIGUSR1): self.stop() self.status = signum - rlog.info('%s runner caught %s. Stopping.', self.name, signame) + rlog.info('{} runner caught {}. Stopping.'.format( + self.name, signame)) # As of Python 3.5, PEP 475 gets in our way. Runners with long # time.sleep()'s in their _snooze() method (e.g. the retry runner) # will have their system call implemented time.sleep() diff --git a/src/mailman/database/base.py b/src/mailman/database/base.py index 934e62411..881129093 100644 --- a/src/mailman/database/base.py +++ b/src/mailman/database/base.py @@ -115,7 +115,7 @@ class SABaseDatabase: # XXX This can all be removed once SQLAlchemy 1.2 is released, and we # pass `pool_pre_ping=True` to create_engine(). @event.listens_for(self.engine, 'engine_connect') # noqa: E306 - def ping_connection(connection, branch): # pragma: no cover + def ping_connection(connection, branch): # pragma: nocover if branch: # "branch" refers to a sub-connection of a connection; # we don't want to bother pinging on these. diff --git a/src/mailman/database/tests/test_migrations.py b/src/mailman/database/tests/test_migrations.py index b5d17d6db..ce1bc101b 100644 --- a/src/mailman/database/tests/test_migrations.py +++ b/src/mailman/database/tests/test_migrations.py @@ -35,6 +35,7 @@ from mailman.interfaces.member import MemberRole from mailman.interfaces.template import ITemplateManager from mailman.interfaces.usermanager import IUserManager from mailman.testing.layers import ConfigLayer +from warnings import catch_warnings, simplefilter from zope.component import getUtility @@ -63,11 +64,21 @@ class TestMigrations(unittest.TestCase): def test_all_migrations(self): script_dir = alembic.script.ScriptDirectory.from_config(alembic_cfg) revisions = [sc.revision for sc in script_dir.walk_revisions()] - for revision in revisions: - alembic.command.downgrade(alembic_cfg, revision) - revisions.reverse() - for revision in revisions: - alembic.command.upgrade(alembic_cfg, revision) + with catch_warnings(): + simplefilter('ignore', UserWarning) + # Alembic/SQLite does not like something about these migrations. + # They're more or less inconsequential in practice (since users + # will rarely if ever downgrade their database), but it does + # clutter up the test output, so just suppress the warning. + # + # E.g. + # alembic/util/messaging.py:69: UserWarning: + # Skipping unsupported ALTER for creation of implicit constraint + for revision in revisions: + alembic.command.downgrade(alembic_cfg, revision) + revisions.reverse() + for revision in revisions: + alembic.command.upgrade(alembic_cfg, revision) def test_42756496720_header_matches(self): test_header_matches = [ diff --git a/src/mailman/docs/NEWS.rst b/src/mailman/docs/NEWS.rst index da6e95856..f61233569 100644 --- a/src/mailman/docs/NEWS.rst +++ b/src/mailman/docs/NEWS.rst @@ -14,24 +14,34 @@ Here is a history of user visible changes to Mailman. Bugs ---- - * A missing html_to_plain_text_command is now properly detected and logged. - (Closes #345) - * Syntactically invalid sender addresses are now ignored. (Closes #229) - * An AttributeError: 'str' object has no attribute 'decode' exception in - subject prefixing is fixed. (Closes #359) - * Messages with no syntactically valid senders are now automatically - discarded. (Closes #369) +* A missing html_to_plain_text_command is now properly detected and logged. + (Closes #345) +* Syntactically invalid sender addresses are now ignored. (Closes #229) +* An AttributeError: 'str' object has no attribute 'decode' exception in + subject prefixing is fixed. (Closes #359) +* Messages with no syntactically valid senders are now automatically + discarded. (Closes #369) + +Command line +------------ +* Adopt the ``click`` package for command line parsing. This makes the + command line more consistent and pluggable. Also, many CLIs that accepted a + "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) +* Fix ``mailman withlist`` command parsing. (Closes #319) Interfaces ---------- - * Broaden the semantics for ``IListManager.get()``. This API now accepts - both ``List-ID``s and fully qualified list names, since that's the most - common use case. There's now a separate ``.get_by_fqdn()`` which only - accepts the latter and mirrors the already existing ``.get_by_list_id()``. +* Broaden the semantics for ``IListManager.get()``. This API now accepts + both ``List-ID``s and fully qualified list names, since that's the most + common use case. There's now a separate ``.get_by_fqdn()`` which only + accepts the latter and mirrors the already existing ``.get_by_list_id()``. Other ----- - * Drop support for Python 3.4. (Closes #373) +* Drop support for Python 3.4. (Closes #373) +* Bump minimum requirements for aiosmtpd (>= 1.1) and flufl.lock (>= 3.1). 3.1.0 -- "Between The Wheels" @@ -40,277 +50,277 @@ Other Bugs ---- - * When the mailing list's ``admin_notify_mchanges`` is True, the list owners - now get the subscription notification. (Closes: #1) - * Fix the traceback that occurred when trying to convert a ``text/html`` - subpart to plaintext via the ``mimedel`` handler. Now, a configuration - variable ``[mailman]html_to_plain_text_command`` in the ``mailman.cfg`` file - defines the command to use. It defaults to ``lynx``. (Closes: #109) - * Confirmation messages should not be ``Precedence: bulk``. (Closes #75) - * Fix constraint violations on mailing list deletes affecting PostgreSQL. - Given by Abhilash Raj. (Closes #115) - * ``mailman`` command with no subcommand now prints the help text. Given by - Abhilash Raj. (Closes #137) - * The MHonArc archiver must set stdin=PIPE when calling the subprocess. - Given by Walter Doekes. - * For now, treat ``DeliveryMode.summary_digests`` the same as - ``.mime_digests``. - (Closes #141). Also, don't enqueue a particular digest if there are no - recipients for that digest. - * For Python versions earlier than 3.5, use a compatibility layer for a - backported smtpd module which can accept non-UTF-8 data. (Closes #140) - * Bulk emails are now decorated with headers and footers. Given by Aurélien - Bompard. (Closes #145) - * Core no longer depends on the standalone ``mock`` module. (Closes: #146) - * The logging of moderation reasons has been fixed. Given by Aurélien - Bompard. - * Collapse multiple ``Re:`` in Subject headers. Given by Mark Sapiro. - (Closes: #147) - * Added Trove classifiers to setup.py. (Closes: #152) - * Fix the processing of subscription confirmation messages when the mailing - list is set to confirm-then-moderate. (Closes #114) - * Fix ``UnicodeEncodeError`` in the hold chain when sending the authorization - email to the mailing list moderators. (Closes: #144) - * Fix traceback in approved handler when the moderator password is None. - Given by Aurélien Bompard. - * Fix IntegrityErrors raised under PostreSQL when deleting users and - addresses. Given by Aurélien Bompard. - * Allow mailing lists to have localhost names with a suffix matching the - subcommand extensions. Given by Aurélien Bompard. (Closes: #168) - * Don't traceback if a nonexistent message-id is deleted from the message - store. Given by Aurélien Bompard, tweaked by Barry Warsaw. (Closes: #167) - * Fix a bug in ``SubscriptionService.find_members()`` when searching for a - subscribed address that is not linked to a user. Given by Aurélien Bompard. - * Fix a REST server crash when trying to subscribe a user without a preferred - address. (Closes #185) - * Fix membership query when multiple users are subscribed to a mailing list. - Reported by Darrell Kresge. (Closes: #190) - * Prevent moderation of messages held for a different list. (Closes: #161) - * When approving a subscription request via the REST API, for a user who is - already a member, return an HTTP 409 Conflict code instead of the previous - server traceback (and resulting HTTP 500 code). (Closes: #193) - * In decoration URIs (e.g. ``IMailingList.header_uri`` and ``.footer_uri``) - you should now use the mailing list's List-ID instead of the - fqdn-listname. The latter is deprecated. (Closes #196) - * Trying to subscribe an address as a list owner (or moderator or nonmember) - which is already subscribed with that role produces a server error. - Originally given by Anirudh Dahiya. (Closes #198) - * Cross-posting messages held on both lists no longer fails. (Closes #176) - * Don't let unknown charsets crash the "approved" rule. Given by Aurélien - Bompard. (Closes #203) - * Don't let crashes in IArchiver plugins break handlers or runners. - (Closes #208) - * Fix "None" as display name in welcome message. Given by Aditya Divekar. - (Closes #194) - * Fix ``mailman shell`` processing of ``$PYTHONSTARTUP``. (Closes #224) - * Fix query bug for ``SubscriptionService.find_members()`` leading to the - incorrect number of members being returned. Given by Aurélien Bompard. - (Closes #227) - * Fix header match rule suffix inflation. Given by Aurélien Bompard. - (Closes #226) - * MIME digests now put the individual message/rfc822 messages inside a - multipart/digest subpart. (Closes #234) - * Nonmember subscriptions are removed when one of the addresses controlled by - a user is subscribed as a member. Given by Aditya Divekar. (Closes #237) - * Email address validation is now more compliant with RFC 5321. (Closes #266) - * A mailing list's ``description`` must not contain newlines. Given by - Aurélien Bompard. (Closes: #273) - * Allow MailingList.info to be set using the REST API. Given by Aurélien - Bompard. - * Extend header filters to also check sub-part headers. (Closes #280) - * Allow REST API to PUT and PATCH domain attributes. Allows Postorius domain - edit to work. (Closes: #290) - * Prevent posting from banned addresses. Given by Aurélien Bompard. - (Closes: #283) - * Remove the digest mbox files after the digests are sent. Given by Aurélien - Bompard. (Closes: #259) - * Transmit the moderation reason and expose it in the REST API as the - ``reason`` attribute. Given by Aurélien Bompard. - * Don't return a 500 error from the REST API when trying to handle a held - message with defective content. Given by Abhilash Raj. (Closes: #256) - * Delete subscription requests when a mailing list is deleted. Given by - Abhilash Raj. (Closes: #214) - * Messages were shunted when non-ASCII characters appeared in a mailing - list's description. Given by Mark Sapiro. (Closes: #215) - * Fix confirmation of unsubscription requests. (Closes: #294) - * Fix ``mailman stop`` not stopping some runners due to PEP 475 interaction. - (Closes: #255) - * Update documentation links for ``config.cfg`` settings. (Closes: #306) - * Disallow problematic characters in listnames. (Closes: #311) - * Forward port several content filtering fixes from the 2.1 branch. - (Closes: #330, #331, #332 and #334) +* When the mailing list's ``admin_notify_mchanges`` is True, the list owners + now get the subscription notification. (Closes: #1) +* Fix the traceback that occurred when trying to convert a ``text/html`` + subpart to plaintext via the ``mimedel`` handler. Now, a configuration + variable ``[mailman]html_to_plain_text_command`` in the ``mailman.cfg`` file + defines the command to use. It defaults to ``lynx``. (Closes: #109) +* Confirmation messages should not be ``Precedence: bulk``. (Closes #75) +* Fix constraint violations on mailing list deletes affecting PostgreSQL. + Given by Abhilash Raj. (Closes #115) +* ``mailman`` command with no subcommand now prints the help text. Given by + Abhilash Raj. (Closes #137) +* The MHonArc archiver must set stdin=PIPE when calling the subprocess. + Given by Walter Doekes. +* For now, treat ``DeliveryMode.summary_digests`` the same as + ``.mime_digests``. + (Closes #141). Also, don't enqueue a particular digest if there are no + recipients for that digest. +* For Python versions earlier than 3.5, use a compatibility layer for a + backported smtpd module which can accept non-UTF-8 data. (Closes #140) +* Bulk emails are now decorated with headers and footers. Given by Aurélien + Bompard. (Closes #145) +* Core no longer depends on the standalone ``mock`` module. (Closes: #146) +* The logging of moderation reasons has been fixed. Given by Aurélien + Bompard. +* Collapse multiple ``Re:`` in Subject headers. Given by Mark Sapiro. + (Closes: #147) +* Added Trove classifiers to setup.py. (Closes: #152) +* Fix the processing of subscription confirmation messages when the mailing + list is set to confirm-then-moderate. (Closes #114) +* Fix ``UnicodeEncodeError`` in the hold chain when sending the authorization + email to the mailing list moderators. (Closes: #144) +* Fix traceback in approved handler when the moderator password is None. + Given by Aurélien Bompard. +* Fix IntegrityErrors raised under PostreSQL when deleting users and + addresses. Given by Aurélien Bompard. +* Allow mailing lists to have localhost names with a suffix matching the + subcommand extensions. Given by Aurélien Bompard. (Closes: #168) +* Don't traceback if a nonexistent message-id is deleted from the message + store. Given by Aurélien Bompard, tweaked by Barry Warsaw. (Closes: #167) +* Fix a bug in ``SubscriptionService.find_members()`` when searching for a + subscribed address that is not linked to a user. Given by Aurélien Bompard. +* Fix a REST server crash when trying to subscribe a user without a preferred + address. (Closes #185) +* Fix membership query when multiple users are subscribed to a mailing list. + Reported by Darrell Kresge. (Closes: #190) +* Prevent moderation of messages held for a different list. (Closes: #161) +* When approving a subscription request via the REST API, for a user who is + already a member, return an HTTP 409 Conflict code instead of the previous + server traceback (and resulting HTTP 500 code). (Closes: #193) +* In decoration URIs (e.g. ``IMailingList.header_uri`` and ``.footer_uri``) + you should now use the mailing list's List-ID instead of the + fqdn-listname. The latter is deprecated. (Closes #196) +* Trying to subscribe an address as a list owner (or moderator or nonmember) + which is already subscribed with that role produces a server error. + Originally given by Anirudh Dahiya. (Closes #198) +* Cross-posting messages held on both lists no longer fails. (Closes #176) +* Don't let unknown charsets crash the "approved" rule. Given by Aurélien + Bompard. (Closes #203) +* Don't let crashes in IArchiver plugins break handlers or runners. + (Closes #208) +* Fix "None" as display name in welcome message. Given by Aditya Divekar. + (Closes #194) +* Fix ``mailman shell`` processing of ``$PYTHONSTARTUP``. (Closes #224) +* Fix query bug for ``SubscriptionService.find_members()`` leading to the + incorrect number of members being returned. Given by Aurélien Bompard. + (Closes #227) +* Fix header match rule suffix inflation. Given by Aurélien Bompard. + (Closes #226) +* MIME digests now put the individual message/rfc822 messages inside a + multipart/digest subpart. (Closes #234) +* Nonmember subscriptions are removed when one of the addresses controlled by + a user is subscribed as a member. Given by Aditya Divekar. (Closes #237) +* Email address validation is now more compliant with RFC 5321. (Closes #266) +* A mailing list's ``description`` must not contain newlines. Given by + Aurélien Bompard. (Closes: #273) +* Allow MailingList.info to be set using the REST API. Given by Aurélien + Bompard. +* Extend header filters to also check sub-part headers. (Closes #280) +* Allow REST API to PUT and PATCH domain attributes. Allows Postorius domain + edit to work. (Closes: #290) +* Prevent posting from banned addresses. Given by Aurélien Bompard. + (Closes: #283) +* Remove the digest mbox files after the digests are sent. Given by Aurélien + Bompard. (Closes: #259) +* Transmit the moderation reason and expose it in the REST API as the + ``reason`` attribute. Given by Aurélien Bompard. +* Don't return a 500 error from the REST API when trying to handle a held + message with defective content. Given by Abhilash Raj. (Closes: #256) +* Delete subscription requests when a mailing list is deleted. Given by + Abhilash Raj. (Closes: #214) +* Messages were shunted when non-ASCII characters appeared in a mailing + list's description. Given by Mark Sapiro. (Closes: #215) +* Fix confirmation of unsubscription requests. (Closes: #294) +* Fix ``mailman stop`` not stopping some runners due to PEP 475 interaction. + (Closes: #255) +* Update documentation links for ``config.cfg`` settings. (Closes: #306) +* Disallow problematic characters in listnames. (Closes: #311) +* Forward port several content filtering fixes from the 2.1 branch. + (Closes: #330, #331, #332 and #334) Configuration ------------- - * Mailing lists can now have their own header matching rules, although - site-defined rules still take precedence. Importing a Mailman 2.1 list - with header matching rules defined will create them in Mailman 3, albeit - with a few unsupported corner cases. Definition of new header matching - rules is not yet exposed through the REST API. Given by Aurélien Bompard. - * The default languages from Mailman 2.1 have been ported over. Given by - Aurélien Bompard. - * There is now a configuration setting to limit the characters that can be - used in list names. +* Mailing lists can now have their own header matching rules, although + site-defined rules still take precedence. Importing a Mailman 2.1 list + with header matching rules defined will create them in Mailman 3, albeit + with a few unsupported corner cases. Definition of new header matching + rules is not yet exposed through the REST API. Given by Aurélien Bompard. +* The default languages from Mailman 2.1 have been ported over. Given by + Aurélien Bompard. +* There is now a configuration setting to limit the characters that can be + used in list names. Command line ------------ - * ``mailman create `` will now create missing domains - by default. The ``-d``/``--domain`` option is kept for backward - compatibility, but now there is a ``-D``/``--no-domain`` option to prevent - missing domains from being create, forcing an error in those cases. - Given by Gurkirpal Singh. (Closes #39) - * ``mailman`` subcommands now properly commit any outstanding transactions. - (Closes #223) - * ``mailman digests`` has grown ``--verbose`` and ``-dry-run`` options. - * ``mailman shell`` now supports readline history if you set the - ``[shell]history_file`` variable in mailman.cfg. Also, many useful names - are pre-populated in the namespace of the shell. (Closes: #228) +* ``mailman create `` will now create missing domains + by default. The ``-d``/``--domain`` option is kept for backward + compatibility, but now there is a ``-D``/``--no-domain`` option to prevent + missing domains from being create, forcing an error in those cases. + Given by Gurkirpal Singh. (Closes #39) +* ``mailman`` subcommands now properly commit any outstanding transactions. + (Closes #223) +* ``mailman digests`` has grown ``--verbose`` and ``-dry-run`` options. +* ``mailman shell`` now supports readline history if you set the + ``[shell]history_file`` variable in mailman.cfg. Also, many useful names + are pre-populated in the namespace of the shell. (Closes: #228) Database -------- - * MySQL is now an officially supported database. Given by Abhilash Raj. - * Fix a problem with tracebacks when a PostgreSQL database is power cycled - while Mailman is still running. This ports an upstream SQLAlchemy fix to - Mailman in lieu of a future SQLAlchemy 1.2 release. (Closes: #313) +* MySQL is now an officially supported database. Given by Abhilash Raj. +* Fix a problem with tracebacks when a PostgreSQL database is power cycled + while Mailman is still running. This ports an upstream SQLAlchemy fix to + Mailman in lieu of a future SQLAlchemy 1.2 release. (Closes: #313) Interfaces ---------- - * Implement reasons for why a message is being held for moderator approval. - Given by Aurélien Bompard, tweaked by Barry Warsaw. - * The default ``postauth.txt`` and ``postheld.txt`` templates now no longer - include the inaccurate admindb and confirmation urls. - * Messages now include a ``Message-ID-Hash`` as the replacement for - ``X-Message-ID-Hash`` although the latter is still included for backward - compatibility. Also be sure that all places which add the header use the - same algorithm. (Closes #118) - * ``IMessageStore.delete_message()`` no longer raises a ``LookupError`` when - you attempt to delete a nonexistent message from the message store. - * ``ISubscriptionService.find_members()`` accepts asterisks as wildcards in - the ``subscriber`` argument string. Given by Aurélien Bompard. - * ``ISubscriptionService`` now supports mass unsubscribes. Given by Harshit - Bansal. +* Implement reasons for why a message is being held for moderator approval. + Given by Aurélien Bompard, tweaked by Barry Warsaw. +* The default ``postauth.txt`` and ``postheld.txt`` templates now no longer + include the inaccurate admindb and confirmation urls. +* Messages now include a ``Message-ID-Hash`` as the replacement for + ``X-Message-ID-Hash`` although the latter is still included for backward + compatibility. Also be sure that all places which add the header use the + same algorithm. (Closes #118) +* ``IMessageStore.delete_message()`` no longer raises a ``LookupError`` when + you attempt to delete a nonexistent message from the message store. +* ``ISubscriptionService.find_members()`` accepts asterisks as wildcards in + the ``subscriber`` argument string. Given by Aurélien Bompard. +* ``ISubscriptionService`` now supports mass unsubscribes. Given by Harshit + Bansal. Message handling ---------------- - * New DMARC mitigations have been added. Given by Mark Sapiro. (Closes #247) - * New placeholders have been added for message headers and footers. You can - use a placeholder of the format ``$_url`` to insert the - permalink to the message in the named archiver, for any archiver enabled - for the mailing list. Given by Abhilash Raj. - * The default posting chain has been modified so that the header-match chain - and nonmember-moderation rule are processed before "hold" rules are - processed. This allows for better anti-spam defenses and rejecting - non-member posts instead of always holding them for moderator review. - Given by Aurélien Bompard. (Closes #163) - * Bounces can now contain rejection messages. Given by Aurélien Bompard. - * The ``moderation_action`` for members and nonmember can now be ``None`` - which signals falling back to the appropriate list default action, - e.g. ``default_member_action`` and ``default_nonmember_action``. Given by - Aurélien Bompard. (Closes #189) - * Ensure that postings from alternative emails aren't held for moderator - approval. For example, if a user is subscribed with one email but posts - with a second email that they control, the message should be processed as - a posting from a member. Given by Aditya Divekar. (Closes #222) - * The default message footer has been improved to include a way to - unsubscribe via the ``-leave`` address. Given by Francesco Ariis. +* New DMARC mitigations have been added. Given by Mark Sapiro. (Closes #247) +* New placeholders have been added for message headers and footers. You can + use a placeholder of the format ``$_url`` to insert the + permalink to the message in the named archiver, for any archiver enabled + for the mailing list. Given by Abhilash Raj. +* The default posting chain has been modified so that the header-match chain + and nonmember-moderation rule are processed before "hold" rules are + processed. This allows for better anti-spam defenses and rejecting + non-member posts instead of always holding them for moderator review. + Given by Aurélien Bompard. (Closes #163) +* Bounces can now contain rejection messages. Given by Aurélien Bompard. +* The ``moderation_action`` for members and nonmember can now be ``None`` + which signals falling back to the appropriate list default action, + e.g. ``default_member_action`` and ``default_nonmember_action``. Given by + Aurélien Bompard. (Closes #189) +* Ensure that postings from alternative emails aren't held for moderator + approval. For example, if a user is subscribed with one email but posts + with a second email that they control, the message should be processed as + a posting from a member. Given by Aditya Divekar. (Closes #222) +* The default message footer has been improved to include a way to + unsubscribe via the ``-leave`` address. Given by Francesco Ariis. REST ---- - * REST API version 3.1 introduced. Mostly backward compatible with version - 3.0 except that UUIDs are represented as hex strings instead of 128-bit - integers, since the latter are not compatible with all versions of - JavaScript. (Closes #121) - * REST clients must minimally support HTTP/1.1. (Closes #288) - * Experimental Gunicorn support. See ``contrib/gunicorn.py`` docstring for - details. With assistance from Eric Searcy. (Closes #287) - * The new template system is introduced for API 3.1. See - ``src/mailman/rest/docs/templates.rst`` for details. (Closes #249) - * When creating a user via REST using an address that already exists, but - isn't linked, the address is linked to the new user. Given by Aurélien - Bompard. - * The REST API incorrectly parsed ``is_server_owner`` values when given - explicitly in the POST that creates a user. (Closes #136) - * A new top-level resource ``/owners`` can be used to get the list of - server owners as ``IUser`` s. (Closes #135) - * By POSTing to a user resource with an existing unlinked address, you can - link the address to the user. Given by Abhilash Raj. - * Fix pagination values ``start`` and ``total_size`` in the REST API. Given - by Aurélien Bompard. (Closes: #154) - * JSON representations for held message now include a ``self_link``. - * When ``[devmode]enabled`` is set, the JSON output is sorted. Given by - Aurélien Bompard. - * A member's moderation action can be changed via the REST API. Given by - Aurélien Bompard. - * Fixed a number of corner cases for the return codes when PUTing or PATCHing - list configuration variables. (Closes: #182) - * Expose ``digest_send_periodic``, ``digest_volume_frequency``, and - ``digests_enabled`` (renamed from ``digestable``) to the REST API. - (Closes: #159) - * Expose the "bump digest" and "send digest" functionality though the REST - API via the ``/lists//digest`` end-point. GETting this - resource returns the ``next_digest_number`` and ``volume`` as the same - values accessible through the list's configuraiton resource. POSTing to - the resource with either ``send=True``, ``bump=True``, or both invokes the - given action. - * Global and list-centric bans can now be managed through the REST API. - Given by Aurélien Bompard. - * ``/members/find`` accepts GET query parameters in addition to POST - arguments. Given by Aurélien Bompard. - * Header match rules for individual mailing lists are now exposed in the REST - API. Given by Aurélien Bompard. (Closes: #192) - * Expose ``goodbye_message_uri`` in the REST API. Given by Harshit Bansal. - * New subscription requests are rejected if there is already one pending. - With thanks to Anirudh Dahiya. (Closes #199) - * Expose the system pipelines and chains via ``/system/pipelines`` and - ``/system/chains`` respectively. Given by Simon Hanna. (Closes #66) - * Support mass unsubscription of members via ``DELETE`` on the - ``/lists//roster/member`` resource. Given by Harshit - Bansal. (Closes #171) - * It is now possible to merge users when creating them via REST. When you - POST to ``/users/
/addresses`` and the address given in the - ``email`` parameter already exists, instead of getting a 400 error, if you - set ``absorb_existing=True`` in the POST data, the existing user will be - merged into the newly created on. Given by Aurélien Bompard. - * Port to Falcon 1.0 (Closes #20) - * A member's ``moderation_action`` can be reset, allowing fallback to the - list's ``default_member_action`` by setting the attribute to the empty - string in the REST API. Given by Aurélien Bompard. - * A list's ``moderator_password`` can be set via the REST API. Given by - Andrew Breksa. (Closes #207) - * The ban manager now returns a pageable, sorted sequence. Given by Amit and - Aurélien Bompard. (Closes #284) - * Query parameters now allow you to filter mailing lists by the - ``advertised`` boolean parameter. Given by Aurélien Bompard. - * Only the system-enabled archivers are returned in the REST API. Given by - Aurélien Bompard. - * **Backward incompatibility: mild** Held message resources now have an - ``original_subject`` key which is the raw value of the ``Subject:`` header - (i.e. without any RFC 2047 decoding). The ``subject`` key is RFC 2047 - decoded. Given by Simon Hanna. (Closes #219) +* REST API version 3.1 introduced. Mostly backward compatible with version + 3.0 except that UUIDs are represented as hex strings instead of 128-bit + integers, since the latter are not compatible with all versions of + JavaScript. (Closes #121) +* REST clients must minimally support HTTP/1.1. (Closes #288) +* Experimental Gunicorn support. See ``contrib/gunicorn.py`` docstring for + details. With assistance from Eric Searcy. (Closes #287) +* The new template system is introduced for API 3.1. See + ``src/mailman/rest/docs/templates.rst`` for details. (Closes #249) +* When creating a user via REST using an address that already exists, but + isn't linked, the address is linked to the new user. Given by Aurélien + Bompard. +* The REST API incorrectly parsed ``is_server_owner`` values when given + explicitly in the POST that creates a user. (Closes #136) +* A new top-level resource ``/owners`` can be used to get the list of + server owners as ``IUser`` s. (Closes #135) +* By POSTing to a user resource with an existing unlinked address, you can + link the address to the user. Given by Abhilash Raj. +* Fix pagination values ``start`` and ``total_size`` in the REST API. Given + by Aurélien Bompard. (Closes: #154) +* JSON representations for held message now include a ``self_link``. +* When ``[devmode]enabled`` is set, the JSON output is sorted. Given by + Aurélien Bompard. +* A member's moderation action can be changed via the REST API. Given by + Aurélien Bompard. +* Fixed a number of corner cases for the return codes when PUTing or PATCHing + list configuration variables. (Closes: #182) +* Expose ``digest_send_periodic``, ``digest_volume_frequency``, and + ``digests_enabled`` (renamed from ``digestable``) to the REST API. + (Closes: #159) +* Expose the "bump digest" and "send digest" functionality though the REST + API via the ``/lists//digest`` end-point. GETting this + resource returns the ``next_digest_number`` and ``volume`` as the same + values accessible through the list's configuraiton resource. POSTing to + the resource with either ``send=True``, ``bump=True``, or both invokes the + given action. +* Global and list-centric bans can now be managed through the REST API. + Given by Aurélien Bompard. +* ``/members/find`` accepts GET query parameters in addition to POST + arguments. Given by Aurélien Bompard. +* Header match rules for individual mailing lists are now exposed in the REST + API. Given by Aurélien Bompard. (Closes: #192) +* Expose ``goodbye_message_uri`` in the REST API. Given by Harshit Bansal. +* New subscription requests are rejected if there is already one pending. + With thanks to Anirudh Dahiya. (Closes #199) +* Expose the system pipelines and chains via ``/system/pipelines`` and + ``/system/chains`` respectively. Given by Simon Hanna. (Closes #66) +* Support mass unsubscription of members via ``DELETE`` on the + ``/lists//roster/member`` resource. Given by Harshit + Bansal. (Closes #171) +* It is now possible to merge users when creating them via REST. When you + POST to ``/users/
/addresses`` and the address given in the + ``email`` parameter already exists, instead of getting a 400 error, if you + set ``absorb_existing=True`` in the POST data, the existing user will be + merged into the newly created on. Given by Aurélien Bompard. +* Port to Falcon 1.0 (Closes #20) +* A member's ``moderation_action`` can be reset, allowing fallback to the + list's ``default_member_action`` by setting the attribute to the empty + string in the REST API. Given by Aurélien Bompard. +* A list's ``moderator_password`` can be set via the REST API. Given by + Andrew Breksa. (Closes #207) +* The ban manager now returns a pageable, sorted sequence. Given by Amit and + Aurélien Bompard. (Closes #284) +* Query parameters now allow you to filter mailing lists by the + ``advertised`` boolean parameter. Given by Aurélien Bompard. +* Only the system-enabled archivers are returned in the REST API. Given by + Aurélien Bompard. +* **Backward incompatibility: mild** Held message resources now have an + ``original_subject`` key which is the raw value of the ``Subject:`` header + (i.e. without any RFC 2047 decoding). The ``subject`` key is RFC 2047 + decoded. Given by Simon Hanna. (Closes #219) Other ----- - * Add official support for Python 3.5 and 3.6. (Closes #295) - * A handful of unused legacy exceptions have been removed. The redundant - ``MailmanException`` has been removed; use ``MailmanError`` everywhere. - * Drop the use of the ``lazr.smtptest`` library, which is based on the - asynchat/asyncore-based smtpd.py stdlib module. Instead, use the - asyncio-based `aiosmtpd `_ package. - * Improvements in importing Mailman 2.1 lists, given by Aurélien Bompard. - * The ``prototype`` archiver is not web accessible so it does not have a - ``list_url`` or permalink. Given by Aurélien Bompard. - * Large performance improvement in ``SubscriptionService.find_members()``. - Given by Aurélien Bompard. - * Rework the digest machinery, and add a new ``digests`` subcommand, which - can be used from the command line or cron to immediately send out any - partially collected digests, or bump the digest and volume numbers. - * The mailing list "data directory" has been renamed. Instead of using the - fqdn listname, the subdirectory inside ``[paths]list_data_dir`` now uses - the List-ID. - * The ``mailman members`` command can now be used to display members based on - subscription roles. Also, the positional "list" argument can now accept - list names or list-ids. - * Unsubscriptions can now be confirmed and/or moderated. (Closes #213) +* Add official support for Python 3.5 and 3.6. (Closes #295) +* A handful of unused legacy exceptions have been removed. The redundant + ``MailmanException`` has been removed; use ``MailmanError`` everywhere. +* Drop the use of the ``lazr.smtptest`` library, which is based on the + asynchat/asyncore-based smtpd.py stdlib module. Instead, use the + asyncio-based `aiosmtpd `_ package. +* Improvements in importing Mailman 2.1 lists, given by Aurélien Bompard. +* The ``prototype`` archiver is not web accessible so it does not have a + ``list_url`` or permalink. Given by Aurélien Bompard. +* Large performance improvement in ``SubscriptionService.find_members()``. + Given by Aurélien Bompard. +* Rework the digest machinery, and add a new ``digests`` subcommand, which + can be used from the command line or cron to immediately send out any + partially collected digests, or bump the digest and volume numbers. +* The mailing list "data directory" has been renamed. Instead of using the + fqdn listname, the subdirectory inside ``[paths]list_data_dir`` now uses + the List-ID. +* The ``mailman members`` command can now be used to display members based on + subscription roles. Also, the positional "list" argument can now accept + list names or list-ids. +* Unsubscriptions can now be confirmed and/or moderated. (Closes #213) 3.0.0 -- "Show Don't Tell" @@ -319,77 +329,77 @@ Other Architecture ------------ - * Domains now have a list of owners, which are ``IUser`` objects, instead of - the single ``contact_address`` they used to have. ``IUser`` objects now - also have a ``is_server_owner`` flag (defaulting to False) to indicate - whether they have superuser privileges. Give by Abhliash Raj, with fixes - and refinements by Barry Warsaw. (LP: #1423756) - * Mailing list subscription policy work flow has been completely rewritten. - It now properly supports email verification and subscription confirmation - by the user, and approval by the moderator using unique tokens. - ``IMailingList`` objects now have a ``subscription_policy`` attribute. - (LP: #1095552) - * Port the REST machinery to Falcon 0.3. (LP: #1446881) +* Domains now have a list of owners, which are ``IUser`` objects, instead of + the single ``contact_address`` they used to have. ``IUser`` objects now + also have a ``is_server_owner`` flag (defaulting to False) to indicate + whether they have superuser privileges. Give by Abhliash Raj, with fixes + and refinements by Barry Warsaw. (LP: #1423756) +* Mailing list subscription policy work flow has been completely rewritten. + It now properly supports email verification and subscription confirmation + by the user, and approval by the moderator using unique tokens. + ``IMailingList`` objects now have a ``subscription_policy`` attribute. + (LP: #1095552) +* Port the REST machinery to Falcon 0.3. (LP: #1446881) Bugs ---- - * Fix calculation of default configuration file to use when the ``$var_dir`` - is created by ``mailman start``. (LP: #1411435) - * When creating a user with an email address, do not create the user record - if the email address already exists. Given by Andrew Stuart. - (LP: #1418280) - * When deleting a user via REST, make sure all linked addresses are deleted. - Found by Andrew Stuart. (LP: #1419519) - * When trying to subscribe an address to a mailing list through the REST API - where a case-differing version of the address is already subscribed, return - a 409 error instead of a 500 error. Found by Ankush Sharma. (LP: #1425359) - * ``mailman lists --domain`` was not properly handling its arguments. Given - by Manish Gill. (LP: #1166911) - * When deleting a user object, make sure their preferences are also deleted. - Given by Abhishek. (LP: #1418276) - * Be sure a mailing list's acceptable aliases are deleted when the mailing - list itself is deleted. (LP: #1432239) - * The built-in example ``IArchiver`` implementations now explicitly return - None. (LP: #1203359) - * The test suite now runs successfully again with PostgreSQL. Given by - Aurélien Bompard. (LP: #1435941) +* Fix calculation of default configuration file to use when the ``$var_dir`` + is created by ``mailman start``. (LP: #1411435) +* When creating a user with an email address, do not create the user record + if the email address already exists. Given by Andrew Stuart. + (LP: #1418280) +* When deleting a user via REST, make sure all linked addresses are deleted. + Found by Andrew Stuart. (LP: #1419519) +* When trying to subscribe an address to a mailing list through the REST API + where a case-differing version of the address is already subscribed, return + a 409 error instead of a 500 error. Found by Ankush Sharma. (LP: #1425359) +* ``mailman lists --domain`` was not properly handling its arguments. Given + by Manish Gill. (LP: #1166911) +* When deleting a user object, make sure their preferences are also deleted. + Given by Abhishek. (LP: #1418276) +* Be sure a mailing list's acceptable aliases are deleted when the mailing + list itself is deleted. (LP: #1432239) +* The built-in example ``IArchiver`` implementations now explicitly return + None. (LP: #1203359) +* The test suite now runs successfully again with PostgreSQL. Given by + Aurélien Bompard. (LP: #1435941) Configuration ------------- - * When specifying a file system path in the [paths.*] section, $cfg_file can - be used to expand into the path of the ``-C`` option if given. In the - default ``[paths.dev]`` section, ``$var_dir`` is now specified relative to - ``$cfg_file`` so that it won't accidentally be relative to the current - working directory, if ``-C`` is given. - * ``$cwd`` is now an additional substitution variable for the ``mailman.cfg`` - file's ``[paths.*]`` sections. A new ``[paths.here]`` section is added, - which puts the ``var_dir`` in ``$cwd``. It is made the default layout. +* When specifying a file system path in the [paths.*] section, $cfg_file can + be used to expand into the path of the ``-C`` option if given. In the + default ``[paths.dev]`` section, ``$var_dir`` is now specified relative to + ``$cfg_file`` so that it won't accidentally be relative to the current + working directory, if ``-C`` is given. +* ``$cwd`` is now an additional substitution variable for the ``mailman.cfg`` + file's ``[paths.*]`` sections. A new ``[paths.here]`` section is added, + which puts the ``var_dir`` in ``$cwd``. It is made the default layout. Documentation ------------- - * Improve the documentation describing how to run Alembic to add new schema - migrations. Given by Abhilash Raj. +* Improve the documentation describing how to run Alembic to add new schema + migrations. Given by Abhilash Raj. REST ---- - * **Backward incompatible change**: The JSON representation for pending - mailing list subscription hold now no longer includes the ``password`` - key. Also, the ``address`` key has been renamed ``email`` for consistent - terminology and other usage. - * You can now view the contents of, inject messages into, and delete messages - from the various queue directories via the ``/queues`` resource. - * You can now DELETE an address. If the address is linked to a user, the - user is not delete, it is just unlinked. - * A new API is provided to support non-production testing infrastructures, - allowing a client to cull all orphaned UIDs via ``DELETE`` on - ``/reserved/uids/orphans``. Note that *no guarantees* of API - stability will ever be made for resources under ``reserved``. - (LP: #1420083) - * Domains can now optionally be created with owners; domain owners can be - added after the fact; domain owners can be deleted. Also, users now have - an ``is_server_owner`` flag as part of their representation, which defaults - to False, and can be PUT and PATCH'd. Given by Abhilash Raj, with fixes - and refinements by Barry Warsaw. (LP: #1423756) +* **Backward incompatible change**: The JSON representation for pending + mailing list subscription hold now no longer includes the ``password`` + key. Also, the ``address`` key has been renamed ``email`` for consistent + terminology and other usage. +* You can now view the contents of, inject messages into, and delete messages + from the various queue directories via the ``/queues`` resource. +* You can now DELETE an address. If the address is linked to a user, the + user is not delete, it is just unlinked. +* A new API is provided to support non-production testing infrastructures, + allowing a client to cull all orphaned UIDs via ``DELETE`` on + ``/reserved/uids/orphans``. Note that *no guarantees* of API + stability will ever be made for resources under ``reserved``. + (LP: #1420083) +* Domains can now optionally be created with owners; domain owners can be + added after the fact; domain owners can be deleted. Also, users now have + an ``is_server_owner`` flag as part of their representation, which defaults + to False, and can be PUT and PATCH'd. Given by Abhilash Raj, with fixes + and refinements by Barry Warsaw. (LP: #1423756) 3.0 beta 5 -- "Carve Away The Stone" @@ -398,77 +408,77 @@ REST Bugs ---- - * Fixed Unicode errors in the digest runner and when sending messages to the - site owner as a fallback. Given by Aurélien Bompard. (LP: #1130957). - * Fixed Unicode errors when a message being added to the digest has non-ascii - characters in its payload, but no Content-Type header defining a charset. - Given by Aurélien Bompard. (LP: #1170347) - * Fixed messages without a `text/plain` part crashing the `Approved` rule. - Given by Aurélien Bompard. (LP: #1158721) - * Fixed getting non-ASCII filenames from RFC 2231 i18n'd messages. Given by - Aurélien Bompard. (LP: #1060951) - * Fixed `AttributeError` on MIME digest messages. Given by Aurélien Bompard. - (LP: #1130696) +* Fixed Unicode errors in the digest runner and when sending messages to the + site owner as a fallback. Given by Aurélien Bompard. (LP: #1130957). +* Fixed Unicode errors when a message being added to the digest has non-ascii + characters in its payload, but no Content-Type header defining a charset. + Given by Aurélien Bompard. (LP: #1170347) +* Fixed messages without a `text/plain` part crashing the `Approved` rule. + Given by Aurélien Bompard. (LP: #1158721) +* Fixed getting non-ASCII filenames from RFC 2231 i18n'd messages. Given by + Aurélien Bompard. (LP: #1060951) +* Fixed `AttributeError` on MIME digest messages. Given by Aurélien Bompard. + (LP: #1130696) Commands -------- - * The `mailman conf` command no longer takes the `-t/--sort` option; the - output is always sorted. +* The `mailman conf` command no longer takes the `-t/--sort` option; the + output is always sorted. Configuration ------------- - * The ``[database]migrations_path`` setting is removed. +* The ``[database]migrations_path`` setting is removed. Database -------- - * The ORM layer, previously implemented with Storm, has been replaced by - SQLAlchemy, thanks to the fantastic work by Abhilash Raj and Aurélien - Bompard. Alembic is now used for all database schema migrations. - * The new logger `mailman.database` logs any errors at the database layer. +* The ORM layer, previously implemented with Storm, has been replaced by + SQLAlchemy, thanks to the fantastic work by Abhilash Raj and Aurélien + Bompard. Alembic is now used for all database schema migrations. +* The new logger `mailman.database` logs any errors at the database layer. Development ----------- - * Python 3.4 is now the minimum requirement. - * You no longer have to create a virtual environment separately when running - the test suite. Just use `tox`. - * You no longer have to edit `src/mailman/testing/testing.cfg` to run the - test suite against PostgreSQL. See `src/mailman/docs/START.rst` for - details. +* Python 3.4 is now the minimum requirement. +* You no longer have to create a virtual environment separately when running + the test suite. Just use `tox`. +* You no longer have to edit `src/mailman/testing/testing.cfg` to run the + test suite against PostgreSQL. See `src/mailman/docs/START.rst` for + details. Interfaces ---------- - * The RFC 2369 headers added to outgoing messages are now added in sorted - order. - * Several changes to the internal API: +* The RFC 2369 headers added to outgoing messages are now added in sorted + order. +* Several changes to the internal API: - - `IListManager.mailing_lists` is guaranteed to be sorted in List-ID order. - - `IDomains.mailing_lists` is guaranteed to be sorted in List-ID order. - - Iteration over domains via the `IDomainManager` is guaranteed to be sorted - by `IDomain.mail_host` order. - - `ITemporaryDatabase` interface and all implementations are removed. + - `IListManager.mailing_lists` is guaranteed to be sorted in List-ID order. + - `IDomains.mailing_lists` is guaranteed to be sorted in List-ID order. + - Iteration over domains via the `IDomainManager` is guaranteed to be sorted + by `IDomain.mail_host` order. + - `ITemporaryDatabase` interface and all implementations are removed. REST ---- - * The Falcon Framework has replaced restish as the REST layer. This is an - internal change only. - * The JSON representation `http_etag` key uses an algorithm that is - insensitive to Python's dictionary sort order. - * The address resource now has an additional '/user' sub-resource which can - be used to GET the address's linked user if there is one. This - sub-resource also supports POST to link an unlinked address (with an - optional 'auto_create' flag), and PUT to link the address to a different - user. It also supports DELETE to unlink the address. (LP: #1312884) - Given by Aurélien Bompard based on work by Nicolas Karageuzian. - * The ``/3.0/system`` path is deprecated; use ``/3.0/system/versions`` to get - the system version information. - * You can access the system configuration via the resource path - ``/3.0/system/configuration/
``. This returns a dictionary with - the keys being the section's variables and the values being their value - from ``mailman.cfg`` as verbatim strings. You can get a list of all - section names via ``/3.0/system/configuration`` which returns a dictionary - containing the ``http_etag`` and the section names as a sorted list under - the ``sections`` key. The system configuration resource is read-only. - * Member resource JSON now include the ``member_id`` as a separate key. +* The Falcon Framework has replaced restish as the REST layer. This is an + internal change only. +* The JSON representation `http_etag` key uses an algorithm that is + insensitive to Python's dictionary sort order. +* The address resource now has an additional '/user' sub-resource which can + be used to GET the address's linked user if there is one. This + sub-resource also supports POST to link an unlinked address (with an + optional 'auto_create' flag), and PUT to link the address to a different + user. It also supports DELETE to unlink the address. (LP: #1312884) + Given by Aurélien Bompard based on work by Nicolas Karageuzian. +* The ``/3.0/system`` path is deprecated; use ``/3.0/system/versions`` to get + the system version information. +* You can access the system configuration via the resource path + ``/3.0/system/configuration/
``. This returns a dictionary with + the keys being the section's variables and the values being their value + from ``mailman.cfg`` as verbatim strings. You can get a list of all + section names via ``/3.0/system/configuration`` which returns a dictionary + containing the ``http_etag`` and the section names as a sorted list under + the ``sections`` key. The system configuration resource is read-only. +* Member resource JSON now include the ``member_id`` as a separate key. 3.0 beta 4 -- "Time and Motion" @@ -477,73 +487,73 @@ REST Development ----------- - * Mailman 3 no longer uses ``zc.buildout`` and tests are now run by the - ``nose2`` test runner. See ``src/mailman/docs/START.rst`` for details on - how to build Mailman and run the test suite. Also, use ``-P`` to select a - test pattern and ``-E`` to enable stderr debugging in runners. - * Use the ``enum34`` package instead of ``flufl.enum``. - * Use ``setuptools`` instead of ``distribute``, since the latter is defunct. +* Mailman 3 no longer uses ``zc.buildout`` and tests are now run by the + ``nose2`` test runner. See ``src/mailman/docs/START.rst`` for details on + how to build Mailman and run the test suite. Also, use ``-P`` to select a + test pattern and ``-E`` to enable stderr debugging in runners. +* Use the ``enum34`` package instead of ``flufl.enum``. +* Use ``setuptools`` instead of ``distribute``, since the latter is defunct. REST ---- - * Add ``reply_to_address`` and ``first_strip_reply_to`` as writable - attributes of a mailing list's configuration. (LP: #1157881) - * Support pagination of some large collections (lists, users, members). - [Florian Fuchs] (LP: #1156529) - * Expose ``hide_address`` to the ``.../preferences`` REST API. - [Sneha Priscilla.] (LP: #1203519) - * Mailing lists can now individually enable or disable any archiver available - site-wide. [Joanna Skrzeszewska] (LP: #1158040) - * Addresses can be added to existing users, including display names, via the - REST API. [Florian Fuchs] - * Fixed a crash in the REST server when searching for nonmembers via - ``/find`` which we've never seen before, because those members only have an - address record, not a user record. This requires a small change in the API - where the JSON response's ``address`` key now contains the URL to the - address resource, the new ``email`` key contains the email address as a - string, and the ``user`` key is optional. +* Add ``reply_to_address`` and ``first_strip_reply_to`` as writable + attributes of a mailing list's configuration. (LP: #1157881) +* Support pagination of some large collections (lists, users, members). + [Florian Fuchs] (LP: #1156529) +* Expose ``hide_address`` to the ``.../preferences`` REST API. + [Sneha Priscilla.] (LP: #1203519) +* Mailing lists can now individually enable or disable any archiver available + site-wide. [Joanna Skrzeszewska] (LP: #1158040) +* Addresses can be added to existing users, including display names, via the + REST API. [Florian Fuchs] +* Fixed a crash in the REST server when searching for nonmembers via + ``/find`` which we've never seen before, because those members only have an + address record, not a user record. This requires a small change in the API + where the JSON response's ``address`` key now contains the URL to the + address resource, the new ``email`` key contains the email address as a + string, and the ``user`` key is optional. Commands -------- - * `mailman conf` now has a `-t/--sort` flag which sorts the output by section - and then key. [Karl-Aksel Puulmann and David Soto] (LP: 1162492) - * Greatly improve the fidelity of the Mailman 2.1 list importer functionality - (i.e. ``mailman import21``). [Aurélien Bompard]. +* `mailman conf` now has a `-t/--sort` flag which sorts the output by section + and then key. [Karl-Aksel Puulmann and David Soto] (LP: 1162492) +* Greatly improve the fidelity of the Mailman 2.1 list importer functionality + (i.e. ``mailman import21``). [Aurélien Bompard]. Configuration ------------- - * Add support for the Exim 4 MTA. [Stephen Turnbull] - * When creating the initial file system layout in ``var``, e.g. via - ``bin/mailman info``, add an ``var/etc/mailman.cfg`` file if one does not - already exist. Also, when initializing the system, look for that file as - the configuration file, just after ``./mailman.cfg`` and before - ``~/.mailman.cfg``. (LP: #1157861) +* Add support for the Exim 4 MTA. [Stephen Turnbull] +* When creating the initial file system layout in ``var``, e.g. via + ``bin/mailman info``, add an ``var/etc/mailman.cfg`` file if one does not + already exist. Also, when initializing the system, look for that file as + the configuration file, just after ``./mailman.cfg`` and before + ``~/.mailman.cfg``. (LP: #1157861) Database -------- - * The `bounceevent` table now uses list-ids to cross-reference the mailing - list, to match other tables. Similarly for the `IBounceEvent` interface. - * Added a `listarchiver` table to support list-specific archivers. +* The `bounceevent` table now uses list-ids to cross-reference the mailing + list, to match other tables. Similarly for the `IBounceEvent` interface. +* Added a `listarchiver` table to support list-specific archivers. Bugs ---- - * Non-queue runners should not create ``var/queue`` subdirectories. - [Sandesh Kumar Agrawal] (LP: #1095422) - * Creation of lists with upper case names should be coerced to lower case. - (LP: #1117176) - * Fix REST server crash on `mailman reopen` due to no interception of - signals. (LP: #1184376) - * Add `subject_prefix` to the `IMailingList` interface, and clarify the - docstring for `display_name`. (LP: #1181498) - * Fix importation from MM2.1 to MM3 of the archive policy. - [Aurélien Bompard] (LP: #1227658) - * Fix non-member moderation rule to prefer a member sender if both members - and non-members are in the message's sender list. [Aurélien Bompard] - (LP: #1291452) - * Fix IntegrityError (against PostgreSQL) when deleting a list with content - filters. [Aurélien Bompard] (LP: #1117174) - * Fix test isolation bug in ``languages.rst``. - [Piotr Kasprzyk] (LP: #1308769) +* Non-queue runners should not create ``var/queue`` subdirectories. + [Sandesh Kumar Agrawal] (LP: #1095422) +* Creation of lists with upper case names should be coerced to lower case. + (LP: #1117176) +* Fix REST server crash on `mailman reopen` due to no interception of + signals. (LP: #1184376) +* Add `subject_prefix` to the `IMailingList` interface, and clarify the + docstring for `display_name`. (LP: #1181498) +* Fix importation from MM2.1 to MM3 of the archive policy. + [Aurélien Bompard] (LP: #1227658) +* Fix non-member moderation rule to prefer a member sender if both members + and non-members are in the message's sender list. [Aurélien Bompard] + (LP: #1291452) +* Fix IntegrityError (against PostgreSQL) when deleting a list with content + filters. [Aurélien Bompard] (LP: #1117174) +* Fix test isolation bug in ``languages.rst``. + [Piotr Kasprzyk] (LP: #1308769) 3.0 beta 3 -- "Here Again" @@ -552,110 +562,110 @@ Bugs Compatibility ------------- - * Python 2.7 is now required. Python 2.6 is no longer officially supported. - The code base is now also `python2.7 -3` clean, although there are still - some warnings in 3rd party dependencies. (LP: #1073506) +* Python 2.7 is now required. Python 2.6 is no longer officially supported. + The code base is now also `python2.7 -3` clean, although there are still + some warnings in 3rd party dependencies. (LP: #1073506) REST ---- - * **API change**: The JSON representation for held messages no longer - includes the `data` key. The values in this dictionary are flatted into - the top-level JSON representation. The `key` key is remove since it's - redundant. Use `message_id` for held messages, and `address` for held - subscriptions/unsubscriptions. The following `_mod_*` keys are inserted - without the `_mod_` prefix: - - - `_mod_subject` -> `subject` - - `_mod_hold_date` -> `hold_date` - - `_mod_reason` -> `reason` - - `_mod_sender` -> `sender` - - `_mod_message_id` -> `message_id` - - * List styles are supported through the REST API. Get the list of available - styles (by name) via `.../lists/styles`. Create a list in a specific style - by using POST data `style_name=