diff options
Diffstat (limited to 'src/mailman')
77 files changed, 4206 insertions, 4095 deletions
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 <subcommand> --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.""" +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. - options = MasterOptions() - options.initialize() + 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) -@public -def main(): - global log +@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. - parser = argparse.ArgumentParser( - description=_("""\ - Start a runner + -r is required unless -l or -h is given, and its argument must be one of + the names displayed by the -l switch. - 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. + 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=_("""\ - -r is required unless -l or -h is given, and its argument must be one - of the names displayed by the -l switch. + Start the named runner, which must be one of the strings returned by the -l + option. - 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. - 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(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. - 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.""")) + 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. + """ - args = parser.parse_args() - if args.runner is None and not args.list: - parser.error(_('No runner name given.')) + global log + + if runner_spec is None and not list_runners: + ctx.fail(_('No runner name given.')) # Initialize the system. Honor the -C flag if given. - 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') -@public -@implementer(ICLISubCommand) -class Start: - """Start the Mailman master and runner processes.""" +@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. - 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. - 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') - 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') +@public +@implementer(ICLISubCommand) +class Start: + name = 'start' + 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' + command = 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)) +@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' + command = 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) +@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 <file>'. 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 <file>'. 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')) + else: + debug = False + if use_ipython: + start_ipython(overrides, banner, debug) + else: + start_python(overrides, banner) - 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(_("""\ +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) - def requestaddr(mlist): - print mlist.request_address""")) - print() - print(_("""\ +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 -class Shell(Withlist): - """An alias for `withlist`.""" +@implementer(ICLISubCommand) +class Withlist: + name = 'withlist' + command = shell + +@public +class Shell(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 <BLANKLINE> 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 + <BLANKLINE> + 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') - <mailing list "test@example.xx" at ...> + >>> list_manager.get('ant@example.xx') + <mailing list "ant@example.xx" at ...> >>> from mailman.interfaces.domain import IDomainManager >>> getUtility(IDomainManager).get('example.xx') <Domain 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') - <mailing list "test1@example.com" at ...> - 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 - <mailing list "test2@example.com" at ...> + <mailing list "bee@example.com" at ...> 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) - <Address: foo@example.org [not verified] at ...> + <Address: anne@example.com [not verified] at ...> 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) - <Address: bar@example.net [not verified] at ...> - <Address: baz@example.net [not verified] at ...> - <Address: foo@example.net [not verified] at ...> + <Address: anne@example.com [not verified] at ...> + <Address: bart@example.com [not verified] at ...> + <Address: cate@example.com [not verified] at ...> 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 + <BLANKLINE> + Error: Invalid language code: xx >>> from mailman.interfaces.languages import ILanguageManager - >>> getUtility(ILanguageManager).add('ee', 'iso-8859-1', 'Freedonian') - <Language [ee] Freedonian> + >>> getUtility(ILanguageManager).add('xx', 'iso-8859-1', 'Freedonian') + <Language [xx] 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) - <Language [fr] French> - >>> FakeArgs.language = None + <Language [xx] Freedonian> 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 ... <BLANKLINE> - 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. <BLANKLINE> 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: <BLANKLINE> - test6-request@example.com + fly-request@example.com <BLANKLINE> Please address all questions to noreply@example.com. <BLANKLINE> 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 + <BLANKLINE> + 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 + <BLANKLINE> + 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 + <BLANKLINE> + 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 + <BLANKLINE> + 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: <aardvark> ... ... 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. <BLANKLINE> <BLANKLINE> +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: <badger> ... ... 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') <Member: Bart Person <bart@example.com> on ant@example.com as MemberRole.member> - >>> command.process(args) + + >>> command('mailman members ant.example.com') Anne Person <anne@example.com> Bart Person <bart@example.com> @@ -51,74 +39,69 @@ Members are displayed in alphabetical order based on their address. >>> subscribe(ant, 'Anne', email='anne@aaaxample.com') <Member: Anne Person <anne@aaaxample.com> on ant@example.com as MemberRole.member> - >>> command.process(args) + + >>> command('mailman members ant.example.com') Anne Person <anne@aaaxample.com> Anne Person <anne@example.com> Bart Person <bart@example.com> 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@aaaxample.com> Anne Person <anne@example.com> Bart Person <bart@example.com> - >>> 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@aaaxample.com> Anne Person <anne@example.com> Bart Person <bart@example.com> - >>> 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 <anne@aaaxample.com> Bart Person <bart@example.com> ...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@aaaxample.com> Anne Person <anne@example.com> ...just plain text digest members... - >>> args.digest = 'plaintext' - >>> command.process(args) + >>> command('mailman members --digest plaintext ant.example.com') Anne Person <anne@example.com> -...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 <anne@aaaxample.com> - # 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 <anne@example.com> Dave Person <dave@example.com> ...or disabled by the user... - >>> args.nomail = 'byuser' - >>> command.process(args) + >>> command('mailman members --nomail byuser ant.example.com') Bart Person <bart@example.com> ...or disabled by the list administrator (or moderator)... - >>> args.nomail = 'byadmin' - >>> command.process(args) + >>> command('mailman members --nomail byadmin ant.example.com') Anne Person <anne@aaaxample.com> ...or by the bounce processor... - >>> args.nomail = 'bybounces' - >>> command.process(args) + >>> command('mailman members --nomail bybounces ant.example.com') Elle Person <elle@example.com> ...or for unknown (legacy) reasons. - >>> args.nomail = 'unknown' - >>> command.process(args) + >>> command('mailman members --nomail unknown ant.example.com') Cris Person <cris@example.com> 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 <anne@aaaxample.com> Bart Person <bart@example.com> Cris Person <cris@example.com> Elle Person <elle@example.com> - # 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 <bperson@example.com>', - ... '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 <bperson@example.com> + ... 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 <eperson@example.com>', - ... '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 <eperson@example.com> + ... 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 <bperson@example.com> Cate Person <cperson@example.com> 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 <BLANKLINE> 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') - <mailing list "test@example.com" at ...> + >>> create_list('ant@example.com') + <mailing list "ant@example.com" at ...> + + >>> 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') - <mailing list "test@example.com" at ...> - - >>> 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') - <mailing list "test@example.com" at ...> + >>> create_list('ant@example.com') + <mailing list "ant@example.com" at ...> - >>> 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/withlist.rst b/src/mailman/commands/docs/shell.rst index d551cb9d6..545376e51 100644 --- a/src/mailman/commands/docs/withlist.rst +++ b/src/mailman/commands/docs/shell.rst @@ -11,110 +11,93 @@ through custom made Python functions. Getting detailed help ===================== -Because ``withlist`` is so complex, you need to request detailed help. +Because ``shell`` is so complex, you might want to read the detailed help. :: - >>> from mailman.commands.cli_withlist import Withlist - >>> command = Withlist() + >>> command = cli('mailman.commands.cli_withlist.shell') - >>> 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) + >>> command('mailman shell --details') This script provides you with a general framework for interacting with a mailing list. ... -Running a command -================= +Running a function +================== 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. -:: +``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(mailing_list): - ... print("The list's name is", mailing_list.fqdn_listname) + ... 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 displayname(mailing_list): - ... print("The list's display name is", mailing_list.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('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 + >>> 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. - >>> args.run = 'showme.displayname' - >>> command.process(args) - The list's display name is Aardvark + >>> 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. +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 + >>> 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 - >>> args.listname = '^bad.*' - >>> command.process(args) + >>> command('mailman shell --run showme.displayname -l ^bad.*') 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 + >>> command('mailman shell --run showme.displayname -l ^foo') Interactive use @@ -153,9 +136,6 @@ 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/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/tests/data/__init__.py b/src/mailman/commands/tests/data/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/mailman/commands/tests/data/__init__.py 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 <http://www.gnu.org/licenses/>. + +# 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 <http://www.gnu.org/licenses/>. + +"""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 <http://www.gnu.org/licenses/>. + +"""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 <http://www.gnu.org/licenses/>. + +"""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_digests.py b/src/mailman/commands/tests/test_cli_digests.py index 0a3ea5226..3c033d60d 100644 --- a/src/mailman/commands/tests/test_digests.py +++ b/src/mailman/commands/tests/test_cli_digests.py @@ -20,10 +20,10 @@ import os import unittest +from click.testing import CliRunner from datetime import timedelta -from io import StringIO from mailman.app.lifecycle import create_list -from mailman.commands.cli_digests import Digests +from mailman.commands.cli_digests import digests from mailman.config import config from mailman.interfaces.digests import DigestFrequency from mailman.interfaces.member import DeliveryMode @@ -33,16 +33,6 @@ from mailman.testing.helpers import ( 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): @@ -53,7 +43,7 @@ class TestSendDigests(unittest.TestCase): self._mlist.digests_enabled = True self._mlist.digest_size_threshold = 100000 self._mlist.send_welcome_message = False - self._command = Digests() + 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. @@ -76,10 +66,7 @@ Subject: message 1 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._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. @@ -105,10 +92,7 @@ Subject: message 1 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._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. @@ -134,16 +118,12 @@ Subject: message 1 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) + 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() - # 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) @@ -164,16 +144,12 @@ Subject: message 1 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) + 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() - # 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) @@ -194,16 +170,14 @@ Subject: message 1 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) + 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() - # 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) @@ -251,10 +225,8 @@ Subject: message 3 # 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._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. @@ -318,9 +290,7 @@ Subject: message 3 # 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._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. @@ -349,10 +319,7 @@ Subject: message 3 # 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._command.invoke(digests, ('-s', '-l', 'ant.example.com')) self._runner.run() get_queue_messages('virgin', expected_count=0) @@ -369,11 +336,8 @@ 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._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. @@ -393,15 +357,12 @@ class TestBumpVolume(unittest.TestCase): self._mlist.volume = 7 self._mlist.next_digest_number = 4 self.right_now = right_now() - self._command = Digests() + self._command = CliRunner() 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._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) @@ -416,36 +377,23 @@ class TestBumpVolume(unittest.TestCase): 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._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): - 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(), """\ + 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): - 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(), """\ + 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_import.py b/src/mailman/commands/tests/test_cli_import.py index e210889fc..5f97df294 100644 --- a/src/mailman/commands/tests/test_import.py +++ b/src/mailman/commands/tests/test_cli_import.py @@ -19,27 +19,23 @@ import unittest +from click.testing import CliRunner from mailman.app.lifecycle import create_list -from mailman.commands.cli_import import Import21 +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 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') + 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): @@ -47,8 +43,34 @@ class TestImport(unittest.TestCase): # _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.process(self.args) + 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 <http://www.gnu.org/licenses/>. + +"""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_lists.py b/src/mailman/commands/tests/test_cli_lists.py index 8463b639f..6b0ff5cf3 100644 --- a/src/mailman/commands/tests/test_lists.py +++ b/src/mailman/commands/tests/test_cli_lists.py @@ -19,26 +19,20 @@ import unittest -from io import StringIO +from click.testing import CliRunner from mailman.app.lifecycle import create_list -from mailman.commands.cli_lists import Lists +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 setUp(self): + self._command = CliRunner() + def test_lists_with_domain_option(self): # LP: #1166911 - non-matching lists were returned. getUtility(IDomainManager).add( @@ -48,14 +42,8 @@ class TestLists(unittest.TestCase): # 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') + 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_members.py b/src/mailman/commands/tests/test_cli_members.py index 74c7414f6..9c72c61ed 100644 --- a/src/mailman/commands/tests/test_members.py +++ b/src/mailman/commands/tests/test_cli_members.py @@ -17,37 +17,15 @@ """Test the `mailman members` command.""" -import sys import unittest -from functools import partial -from io import StringIO +from click.testing import CliRunner from mailman.app.lifecycle import create_list -from mailman.commands.cli_members import Members +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): @@ -55,35 +33,25 @@ class TestCLIMembers(unittest.TestCase): def setUp(self): self._mlist = create_list('ant@example.com') - self.command = Members() - self.command.parser = FakeParser() - self.args = FakeArgs() + self._command = CliRunner() 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') + 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) - 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) + 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) @@ -95,11 +63,9 @@ class TestCLIMembers(unittest.TestCase): 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) + 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) @@ -113,34 +79,34 @@ class TestCLIMembers(unittest.TestCase): 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) + 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 <bperson@example.com>\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_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 <cperson@example.com>\n') 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 <aperson@example.com>', 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) + result = self._command.invoke(members, ( + '--add', infp.name, 'ant.example.com')) self.assertEqual( - outfp.getvalue(), + result.output, 'Already subscribed (skipping): Anne Person <aperson@example.com>\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 <http://www.gnu.org/licenses/>. + +"""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 <http://www.gnu.org/licenses/>. + +"""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 <http://www.gnu.org/licenses/>. + +"""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 <http://www.gnu.org/licenses/>. + +"""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 <http://www.gnu.org/licenses/>. - -"""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_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 <http://www.gnu.org/licenses/>. - -"""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 <http://www.gnu.org/licenses/>. - -"""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_confirm.py b/src/mailman/commands/tests/test_eml_confirm.py index 1ef392b76..1ef392b76 100644 --- a/src/mailman/commands/tests/test_confirm.py +++ b/src/mailman/commands/tests/test_eml_confirm.py diff --git a/src/mailman/commands/tests/test_help.py b/src/mailman/commands/tests/test_eml_help.py index 3090de9cc..3090de9cc 100644 --- a/src/mailman/commands/tests/test_help.py +++ b/src/mailman/commands/tests/test_eml_help.py diff --git a/src/mailman/commands/tests/test_membership.py b/src/mailman/commands/tests/test_eml_membership.py index 6cf4802c6..6cf4802c6 100644 --- a/src/mailman/commands/tests/test_membership.py +++ b/src/mailman/commands/tests/test_eml_membership.py 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 <http://www.gnu.org/licenses/>. - -"""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 <listname@dom.ain>`` 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 <listname@dom.ain>`` 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 ``$<archiver-name>_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 ``$<archiver-name>_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 ``<api>/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 ``<api>/lists/<list-id>/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. - * ``<api>/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 ``<api>/system/pipelines`` and - ``<api>/system/chains`` respectively. Given by Simon Hanna. (Closes #66) - * Support mass unsubscription of members via ``DELETE`` on the - ``<api>/lists/<list-id>/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 ``<api>/users/<address>/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 ``<api>/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 ``<api>/lists/<list-id>/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. +* ``<api>/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 ``<api>/system/pipelines`` and + ``<api>/system/chains`` respectively. Given by Simon Hanna. (Closes #66) +* Support mass unsubscription of members via ``DELETE`` on the + ``<api>/lists/<list-id>/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 ``<api>/users/<address>/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 <http://aiosmtpd.readthedocs.io/>`_ 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 <http://aiosmtpd.readthedocs.io/>`_ 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 ``<api>/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 - ``<api>/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 ``<api>/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 + ``<api>/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/<section>``. 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/<section>``. 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: +* **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` + - `_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=<style>`. (LP: #975692) - * Allow the getting/setting of IMailingList.subject_prefix via the REST API - (given by Terri Oda). (LP: #1062893) - * Expose a REST API for membership change (subscriptions and unsubscriptions) - moderation. (LP: #1090753) - * Add list_id to JSON representation for a mailing list (given by Jimmy - Bergman). - * The canonical resource for a mailing list (and thus its self_link) is now - the URL with the list-id. To reference a mailing list, the list-id url is - preferred, but for backward compatibility, the posting address is still - accepted. - * You can now PUT and PATCH on user resources to change the user's display - name or password. For passwords, you pass in the clear text password and - Mailman will hash it before storing. - * You can now verify and unverify an email address through the REST API. - POST to .../addresses/<email>/verify and .../addresses/<email>/unverify - respectively. The POST data is ignored. It is not an error to verify or - unverify an address more than once, but verifying an already verified - address does not change its `.verified_on` date. (LP: #1054730) - * Deleting a user through the REST API also deletes all the user's linked - addresses and memberships. (LP: #1074374) - * A user's password can be verified by POSTing to .../user/<id>/login. The - data must contain a single parameter `cleartext_password` and if this - matches, a 204 (No Content) will be returned, otherwise a 403 (Forbidden) - is returned. (LP: #1065447) +* 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=<style>`. (LP: #975692) +* Allow the getting/setting of IMailingList.subject_prefix via the REST API + (given by Terri Oda). (LP: #1062893) +* Expose a REST API for membership change (subscriptions and unsubscriptions) + moderation. (LP: #1090753) +* Add list_id to JSON representation for a mailing list (given by Jimmy + Bergman). +* The canonical resource for a mailing list (and thus its self_link) is now + the URL with the list-id. To reference a mailing list, the list-id url is + preferred, but for backward compatibility, the posting address is still + accepted. +* You can now PUT and PATCH on user resources to change the user's display + name or password. For passwords, you pass in the clear text password and + Mailman will hash it before storing. +* You can now verify and unverify an email address through the REST API. + POST to .../addresses/<email>/verify and .../addresses/<email>/unverify + respectively. The POST data is ignored. It is not an error to verify or + unverify an address more than once, but verifying an already verified + address does not change its `.verified_on` date. (LP: #1054730) +* Deleting a user through the REST API also deletes all the user's linked + addresses and memberships. (LP: #1074374) +* A user's password can be verified by POSTing to .../user/<id>/login. The + data must contain a single parameter `cleartext_password` and if this + matches, a 204 (No Content) will be returned, otherwise a 403 (Forbidden) + is returned. (LP: #1065447) Configuration ------------- - * `[passlib]path` configuration variable renamed to `[passlib]configuration`. - * Postfix-specific configurations in the `[mta]` section are moved to a - separate file, named by the `[mta]configuration` variable. - * In the new `postfix.cfg` file, `postfix_map_cmd` is renamed to - `postmap_command`. - * The default list style is renamed to `legacy-default` and a new - `legacy-announce` style is added. This is similar to the `legacy-default` - except set up for announce-only lists. +* `[passlib]path` configuration variable renamed to `[passlib]configuration`. +* Postfix-specific configurations in the `[mta]` section are moved to a + separate file, named by the `[mta]configuration` variable. +* In the new `postfix.cfg` file, `postfix_map_cmd` is renamed to + `postmap_command`. +* The default list style is renamed to `legacy-default` and a new + `legacy-announce` style is added. This is similar to the `legacy-default` + except set up for announce-only lists. Database -------- - * The `ban` table now uses list-ids to cross-reference the mailing list, - since these cannot change even if the mailing list is moved or renamed. - * The following columns were unused and have been removed: +* The `ban` table now uses list-ids to cross-reference the mailing list, + since these cannot change even if the mailing list is moved or renamed. +* The following columns were unused and have been removed: - - `mailinglist.new_member_options` - - `mailinglist.send_reminders` - - `mailinglist.subscribe_policy` - - `mailinglist.unsubscribe_policy` - - `mailinglist.subscribe_auto_approval` - - `mailinglist.private_roster` - - `mailinglist.admin_member_chunksize` + - `mailinglist.new_member_options` + - `mailinglist.send_reminders` + - `mailinglist.subscribe_policy` + - `mailinglist.unsubscribe_policy` + - `mailinglist.subscribe_auto_approval` + - `mailinglist.private_roster` + - `mailinglist.admin_member_chunksize` Interfaces ---------- - * The `IBanManager` is no longer a global utility. Instead, you adapt an - `IMailingList` to an `IBanManager` to manage the bans for a specific - mailing list. To manage the global bans, adapt ``None``. +* The `IBanManager` is no longer a global utility. Instead, you adapt an + `IMailingList` to an `IBanManager` to manage the bans for a specific + mailing list. To manage the global bans, adapt ``None``. Commands -------- - * `bin/mailman aliases` loses the `--output`, `--format`, and `--simple` - arguments, and adds a `--directory` argument. This is necessary to support - the Postfix `relay_domains` support. - * `bin/mailman start` was passing the wrong relative path to its runner - subprocesses when -C was given. (LP: #982551) - * `bin/runner` command has been simplified and its command line options - reduced. Now, only one `-r/--runner` option may be provided and the - round-robin feature has been removed. +* `bin/mailman aliases` loses the `--output`, `--format`, and `--simple` + arguments, and adds a `--directory` argument. This is necessary to support + the Postfix `relay_domains` support. +* `bin/mailman start` was passing the wrong relative path to its runner + subprocesses when -C was given. (LP: #982551) +* `bin/runner` command has been simplified and its command line options + reduced. Now, only one `-r/--runner` option may be provided and the + round-robin feature has been removed. Other ----- - * Added support for Postfix `relay_domains` setting for better virtual domain - support. [Jimmy Bergman]. - * Two new events are triggered on membership changes: `SubscriptionEvent` - when a new member joins a mailing list, and an `UnsubscriptionEvent` when a - member leaves a mailing list. (LP: #1047286) - * Improve the --help text for the `start`, `stop`, `restart`, and `reopen` - subcommands. (LP: #1035033) +* Added support for Postfix `relay_domains` setting for better virtual domain + support. [Jimmy Bergman]. +* Two new events are triggered on membership changes: `SubscriptionEvent` + when a new member joins a mailing list, and an `UnsubscriptionEvent` when a + member leaves a mailing list. (LP: #1047286) +* Improve the --help text for the `start`, `stop`, `restart`, and `reopen` + subcommands. (LP: #1035033) Bugs ---- - * Fixed `send_goodbye_message()`. (LP: #1091321) - * Fixed REST server crash on `reopen` command. Identification and test - provided by Aurélien Bompard. (LP: #1184376) +* Fixed `send_goodbye_message()`. (LP: #1091321) +* Fixed REST server crash on `reopen` command. Identification and test + provided by Aurélien Bompard. (LP: #1184376) 3.0 beta 2 -- "Freeze" @@ -664,123 +674,123 @@ Bugs Architecture ------------ - * The link between members and the mailing lists they are subscribed to, is - now via the RFC 2369 `list_id` instead of the fqdn listname (i.e. posting - address). This is because while the posting address can change if the - mailing list is moved to a new server, the list id is fixed. - (LP: #1024509) +* The link between members and the mailing lists they are subscribed to, is + now via the RFC 2369 `list_id` instead of the fqdn listname (i.e. posting + address). This is because while the posting address can change if the + mailing list is moved to a new server, the list id is fixed. + (LP: #1024509) - - IListManager.get_by_list_id() added. - - IListManager.list_ids added. - - IMailingList.list_id added. - - Several internal APIs that accepted fqdn list names now require list ids, - e.g. ISubscriptionService.join() and .find_members(). - - IMember.list_id attribute added; .mailing_list is now an alias that - retrieves and returns the IMailingList. + - IListManager.get_by_list_id() added. + - IListManager.list_ids added. + - IMailingList.list_id added. + - Several internal APIs that accepted fqdn list names now require list ids, + e.g. ISubscriptionService.join() and .find_members(). + - IMember.list_id attribute added; .mailing_list is now an alias that + retrieves and returns the IMailingList. - * `passlib`_ is now used for all password hashing instead of flufl.password. - The default hash is `sha512_crypt`. (LP: #1015758) - * Internally, all datetimes are kept in the UTC timezone, however because of - LP: #280708, they are stored in the database in naive format. - * `received_time` is now added to the message metadata by the LMTP runner - instead of by `Switchboard.enqueue()`. This latter no longer depends on - `received_time` in the metadata. - * The `ArchiveRunner` no longer acquires a lock before it calls the - individual archiver implementations, since not all of them need a lock. If - they do, the implementations must acquire said lock themselves. - * The `news` runner and queue has been renamed to the more accurate `nntp`. - The runner has also been ported to Mailman 3 (LP: #967409). Beta testers - can safely remove `$var_dir/queue/news`. - * A mailing list's *moderator password* is no longer stored in the clear; it - is hashed with the currently selected scheme. - * An `AddressVerificationEvent` is triggered when an `IAddress` is verified - or unverified. (LP: #975698) - * A `PasswordChangeEvent` is triggered when an `IUser`'s password changes. - (LP: #975700) - * When a queue runner gets an exception in its _dispose() method, a - `RunnerCrashEvent` is triggered, which contains references to the queue - runner, mailing list, message, metadata, and exception. Interested parties - can subscribe to that `zope.event` for notification. - * Events renamed and moved: - * `mailman.chains.accept.AcceptNotification` - * `mailman.chains.base.ChainNotification` - * `mailman.chains.discard.DiscardNotification` - * `mailman.chains.hold.HoldNotification` - * `mailman.chains.owner.OwnerNotification` - * `mailman.chains.reject.RejectNotification` - changed to (respectively): - * `mailman.interfaces.chains.AcceptEvent` - * `mailman.interfaces.chains.ChainEvent` - * `mailman.interfaces.chains.DiscardEvent` - * `mailman.interfaces.chains.HoldEvent` - * `mailman.interfaces.chains.AcceptOwnerEvent` - * `mailman.interfaces.chains.RejectEvent` - * A `ConfigurationUpdatedEvent` is triggered when the system-wide global - configuration stack is pushed or popped. - * The policy for archiving has now been collapsed into a single enum, called - ArchivePolicy. This describes the three states of never archive, archive - privately, and archive_publicly. (LP: #967238) +* `passlib`_ is now used for all password hashing instead of flufl.password. + The default hash is `sha512_crypt`. (LP: #1015758) +* Internally, all datetimes are kept in the UTC timezone, however because of + LP: #280708, they are stored in the database in naive format. +* `received_time` is now added to the message metadata by the LMTP runner + instead of by `Switchboard.enqueue()`. This latter no longer depends on + `received_time` in the metadata. +* The `ArchiveRunner` no longer acquires a lock before it calls the + individual archiver implementations, since not all of them need a lock. If + they do, the implementations must acquire said lock themselves. +* The `news` runner and queue has been renamed to the more accurate `nntp`. + The runner has also been ported to Mailman 3 (LP: #967409). Beta testers + can safely remove `$var_dir/queue/news`. +* A mailing list's *moderator password* is no longer stored in the clear; it + is hashed with the currently selected scheme. +* An `AddressVerificationEvent` is triggered when an `IAddress` is verified + or unverified. (LP: #975698) +* A `PasswordChangeEvent` is triggered when an `IUser`'s password changes. + (LP: #975700) +* When a queue runner gets an exception in its _dispose() method, a + `RunnerCrashEvent` is triggered, which contains references to the queue + runner, mailing list, message, metadata, and exception. Interested parties + can subscribe to that `zope.event` for notification. +* Events renamed and moved: + * `mailman.chains.accept.AcceptNotification` + * `mailman.chains.base.ChainNotification` + * `mailman.chains.discard.DiscardNotification` + * `mailman.chains.hold.HoldNotification` + * `mailman.chains.owner.OwnerNotification` + * `mailman.chains.reject.RejectNotification` + changed to (respectively): + * `mailman.interfaces.chains.AcceptEvent` + * `mailman.interfaces.chains.ChainEvent` + * `mailman.interfaces.chains.DiscardEvent` + * `mailman.interfaces.chains.HoldEvent` + * `mailman.interfaces.chains.AcceptOwnerEvent` + * `mailman.interfaces.chains.RejectEvent` +* A `ConfigurationUpdatedEvent` is triggered when the system-wide global + configuration stack is pushed or popped. +* The policy for archiving has now been collapsed into a single enum, called + ArchivePolicy. This describes the three states of never archive, archive + privately, and archive_publicly. (LP: #967238) Database -------- - * Schema migrations (LP: #971013) +* Schema migrations (LP: #971013) - - mailinglist.include_list_post_header -> allow_list_posts - - mailinglist.news_prefix_subject_too -> nntp_prefix_subject_too - - mailinglist.news_moderation -> newsgroup_moderation - - mailinglist.archive and mailinglist.archive_private have been collapsed - into archive_policy. - - mailinglist.nntp_host has been removed. - - mailinglist.generic_nonmember_action has been removed (LP: #975696) + - mailinglist.include_list_post_header -> allow_list_posts + - mailinglist.news_prefix_subject_too -> nntp_prefix_subject_too + - mailinglist.news_moderation -> newsgroup_moderation + - mailinglist.archive and mailinglist.archive_private have been collapsed + into archive_policy. + - mailinglist.nntp_host has been removed. + - mailinglist.generic_nonmember_action has been removed (LP: #975696) - * Schema migrations (LP: #1024509) - - member.mailing_list -> list_id - * The PostgreSQL port of the schema accidentally added a moderation_callback - column to the mailinglist table. Since this is unused in Mailman, it was - simply commented out of the base schema for PostgreSQL. +* Schema migrations (LP: #1024509) + - member.mailing_list -> list_id +* The PostgreSQL port of the schema accidentally added a moderation_callback + column to the mailinglist table. Since this is unused in Mailman, it was + simply commented out of the base schema for PostgreSQL. REST ---- - * Expose `archive_policy` in the REST API. Contributed by Alexander - Sulfrian. (LP: #1039129) +* Expose `archive_policy` in the REST API. Contributed by Alexander + Sulfrian. (LP: #1039129) Configuration ------------- - * New configuration variables `clobber_date` and `clobber_skew` supported in - every `[archiver.<name>]` section. These are used to determine under what - circumstances a message destined for a specific archiver should have its - `Date:` header clobbered. (LP: #963612) - * With the switch to `passlib`_, `[passwords]password_scheme` has been - removed. Instead use `[passwords]path` to specify where to find the - `passlib.cfg` file. See the comments in `schema.cfg` for details. - * Configuration schema variable changes: - * [nntp]username -> [nntp]user - * [nntp]port (added) - * Header check specifications in the `mailman.cfg` file have changed quite - bit. The previous `[spam.header.foo]` sections have been removed. - Instead, there's a new `[antispam]` section that contains a `header_checks` - variable. This variable takes multiple lines of `Header: regexp` values, - one per line. There is also a new `jump_chain` variable which names the - chain to jump to should any of the header checks (including the - list-specific, and programmatically added ones) match. +* New configuration variables `clobber_date` and `clobber_skew` supported in + every `[archiver.<name>]` section. These are used to determine under what + circumstances a message destined for a specific archiver should have its + `Date:` header clobbered. (LP: #963612) +* With the switch to `passlib`_, `[passwords]password_scheme` has been + removed. Instead use `[passwords]path` to specify where to find the + `passlib.cfg` file. See the comments in `schema.cfg` for details. +* Configuration schema variable changes: + * [nntp]username -> [nntp]user + * [nntp]port (added) +* Header check specifications in the `mailman.cfg` file have changed quite + bit. The previous `[spam.header.foo]` sections have been removed. + Instead, there's a new `[antispam]` section that contains a `header_checks` + variable. This variable takes multiple lines of `Header: regexp` values, + one per line. There is also a new `jump_chain` variable which names the + chain to jump to should any of the header checks (including the + list-specific, and programmatically added ones) match. Documentation ------------- - * Some additional documentation on related components such as Postorius and - hyperkitty have been added, given by Stephen J Turnbull. +* Some additional documentation on related components such as Postorius and + hyperkitty have been added, given by Stephen J Turnbull. Bug fixes --------- - * Fixed the RFC 1153 digest footer to be compliant. (LP: #887610) - * Fixed a UnicodeError with non-ascii message bodies in the `approved` rule, - given by Mark Sapiro. (LP: #949924) - * Fixed a typo when returning the configuration file's header match checks. - (LP: #953497) - * List-Post should be NO when posting is not allowed. (LP: #987563) - * Non-unicode values in msgdata broke pending requests. (LP: #1031391) - * Show devmode in `bin/mailman info` output. (LP: #1035028) - * Fix residual references to the old `IMailingList` archive variables. - (LP: #1031393) +* Fixed the RFC 1153 digest footer to be compliant. (LP: #887610) +* Fixed a UnicodeError with non-ascii message bodies in the `approved` rule, + given by Mark Sapiro. (LP: #949924) +* Fixed a typo when returning the configuration file's header match checks. + (LP: #953497) +* List-Post should be NO when posting is not allowed. (LP: #987563) +* Non-unicode values in msgdata broke pending requests. (LP: #1031391) +* Show devmode in `bin/mailman info` output. (LP: #1035028) +* Fix residual references to the old `IMailingList` archive variables. + (LP: #1031393) .. _`passlib`: http://packages.python.org/passlib/index.html @@ -791,128 +801,128 @@ Bug fixes Architecture ------------ - * Schema migrations have been implemented. - * Implement the style manager as a utility instead of an attribute hanging - off the `mailman.config.config` object. - * PostgreSQL support contributed by Stephen A. Goss. (LP: #860159) - * Separate out the RFC 2369 header adding handler. - * Dynamically calculate the `List-Id` header instead of storing it in the - database. This means it cannot be changed. - * Major redesign of the template search system, fixing LP: #788309. $var_dir - is now used when search for all template overrides, site, domain, or - mailing list. The in-tree English templates are used only as a last - fallback. - * Support downloading templates by URI, including mailman:// URIs. This is - used in welcome and goodbye messages, as well as regular and digest headers - and footers, and supports both language and mailing list specifications. - E.g. mailman:///test@example.com/it/welcome.txt - * $user_password is no longer supported as a placeholder in headers and - footers. - * Mailing lists get multiple chains and pipelines. For example, normal - postings go through the `posting_chain` while messages to owners to through - `owners_chain`. The default `built-in` chain is renamed to - `default-posting-chain` while the `built-in` pipeline is renamed - `default-posting-pipeline`. - * The experimental `maildir` runner is removed. Use LMTP. - * The LMTP server now requires that the incoming message have a `Message-ID`, - otherwise it rejects the message with a 550 error. Also, the LMTP server - adds the `X-Message-ID-Hash` header automatically. The `inject` cli - command will also add the `X-Message-ID-Hash` header, but it will craft a - `Message-ID` header first if one is missing from the injected text. Also, - `inject` will always set the correct value for the `original_size` - attribute on the message object, instead of trusting a possibly incorrect - value if it's already set. The individual `IArchiver` implementations no - longer set the `X-Message-ID-Hash` header. - * The Prototype archiver now stores its files in maildir format inside of - `$var_dir/archives/prototype`, given by Toshio Kuratomi. - * Improved "8 mile high" document distilled by Stephen J Turnbull from the - Pycon 2012 Mailman 3 sprint. Also improvements to the Sphinx build given - by Andrea Crotti (LP: #954718). - * Pipermail has been eradicated. - * Configuration variable `[mailman]filtered_messages_are_preservable` - controls whether messages which have their top-level `Content-Type` - filtered out can be preserved in the `bad` queue by list owners. - * Configuration section `[scrubber]` removed, as is the scrubber handler. - This handler was essentially incompatible with Mailman 3 since it required - coordination with Pipermail to store attachments on disk. +* Schema migrations have been implemented. +* Implement the style manager as a utility instead of an attribute hanging + off the `mailman.config.config` object. +* PostgreSQL support contributed by Stephen A. Goss. (LP: #860159) +* Separate out the RFC 2369 header adding handler. +* Dynamically calculate the `List-Id` header instead of storing it in the + database. This means it cannot be changed. +* Major redesign of the template search system, fixing LP: #788309. $var_dir + is now used when search for all template overrides, site, domain, or + mailing list. The in-tree English templates are used only as a last + fallback. +* Support downloading templates by URI, including mailman:// URIs. This is + used in welcome and goodbye messages, as well as regular and digest headers + and footers, and supports both language and mailing list specifications. + E.g. mailman:///test@example.com/it/welcome.txt +* $user_password is no longer supported as a placeholder in headers and + footers. +* Mailing lists get multiple chains and pipelines. For example, normal + postings go through the `posting_chain` while messages to owners to through + `owners_chain`. The default `built-in` chain is renamed to + `default-posting-chain` while the `built-in` pipeline is renamed + `default-posting-pipeline`. +* The experimental `maildir` runner is removed. Use LMTP. +* The LMTP server now requires that the incoming message have a `Message-ID`, + otherwise it rejects the message with a 550 error. Also, the LMTP server + adds the `X-Message-ID-Hash` header automatically. The `inject` cli + command will also add the `X-Message-ID-Hash` header, but it will craft a + `Message-ID` header first if one is missing from the injected text. Also, + `inject` will always set the correct value for the `original_size` + attribute on the message object, instead of trusting a possibly incorrect + value if it's already set. The individual `IArchiver` implementations no + longer set the `X-Message-ID-Hash` header. +* The Prototype archiver now stores its files in maildir format inside of + `$var_dir/archives/prototype`, given by Toshio Kuratomi. +* Improved "8 mile high" document distilled by Stephen J Turnbull from the + Pycon 2012 Mailman 3 sprint. Also improvements to the Sphinx build given + by Andrea Crotti (LP: #954718). +* Pipermail has been eradicated. +* Configuration variable `[mailman]filtered_messages_are_preservable` + controls whether messages which have their top-level `Content-Type` + filtered out can be preserved in the `bad` queue by list owners. +* Configuration section `[scrubber]` removed, as is the scrubber handler. + This handler was essentially incompatible with Mailman 3 since it required + coordination with Pipermail to store attachments on disk. Database -------- - * Schema changes: - - welcome_msg -> welcome_message_uri - - goodbye_msg -> goodbye_message_uri - - send_welcome_msg -> send_welcome_message - - send_goodbye_msg -> send_goodbye_message - - msg_header -> header_uri - - msg_footer -> footer_uri - - digest_header -> digest_header_uri - - digest_footer -> digest_footer_uri - - start_chain -> posting_chain - - pipeline -> posting_pipeline - - real_name -> display_name (mailinglist, user, address) - * Schema additions: - - mailinglist.filter_action - - mailinglist.owner_chain - - mailinglist.owner_pipeline +* Schema changes: + - welcome_msg -> welcome_message_uri + - goodbye_msg -> goodbye_message_uri + - send_welcome_msg -> send_welcome_message + - send_goodbye_msg -> send_goodbye_message + - msg_header -> header_uri + - msg_footer -> footer_uri + - digest_header -> digest_header_uri + - digest_footer -> digest_footer_uri + - start_chain -> posting_chain + - pipeline -> posting_pipeline + - real_name -> display_name (mailinglist, user, address) +* Schema additions: + - mailinglist.filter_action + - mailinglist.owner_chain + - mailinglist.owner_pipeline REST ---- - * Held messages can now be moderated through the REST API. Mailing list - resources now accept a `held` path component. GETing this returns all held - messages for the mailing list. POSTing to a specific request id under this - url can dispose of the message using `Action` enums. - * Mailing list resources now have a `member_count` attribute which gives the - number of subscribed members. Given by Toshio Kuratomi. +* Held messages can now be moderated through the REST API. Mailing list + resources now accept a `held` path component. GETing this returns all held + messages for the mailing list. POSTing to a specific request id under this + url can dispose of the message using `Action` enums. +* Mailing list resources now have a `member_count` attribute which gives the + number of subscribed members. Given by Toshio Kuratomi. Interfaces ---------- - * Add property `IUserManager.members` to return all `IMembers` in the system. - * Add property `IListmanager.name_components` which returns 2-tuples for - every mailing list as (list_name, mail_host). - * Remove previously deprecated `IListManager.get_mailing_lists()`. - * `IMailTransportAgentAliases` now explicitly accepts duck-typed arguments. - * `IRequests` interface is removed. Now just use adaptation from - `IListRequests` directly (which takes an `IMailingList` object). - * `handle_message()` now allows for `Action.hold` which is synonymous with - `Action.defer` (since the message is already being held). - * `IListRequests.get_request()` now takes an optional `request_type` - argument to narrow the search for the given request. - * New `ITemplateLoader` utility. - * `ILanguageManager.add()` returns the `ILanguage` object just created. - * `IMailinglist.decorators` removed; it was unused - * `IMailingList.real_name` -> `IMailingList.display_name` - * `IUser.real_name` -> `IUser.display_name` - * `IAddress.real_name` -> `IAddress.display_name` - * Add property `IRoster.member_count`. +* Add property `IUserManager.members` to return all `IMembers` in the system. +* Add property `IListmanager.name_components` which returns 2-tuples for + every mailing list as (list_name, mail_host). +* Remove previously deprecated `IListManager.get_mailing_lists()`. +* `IMailTransportAgentAliases` now explicitly accepts duck-typed arguments. +* `IRequests` interface is removed. Now just use adaptation from + `IListRequests` directly (which takes an `IMailingList` object). +* `handle_message()` now allows for `Action.hold` which is synonymous with + `Action.defer` (since the message is already being held). +* `IListRequests.get_request()` now takes an optional `request_type` + argument to narrow the search for the given request. +* New `ITemplateLoader` utility. +* `ILanguageManager.add()` returns the `ILanguage` object just created. +* `IMailinglist.decorators` removed; it was unused +* `IMailingList.real_name` -> `IMailingList.display_name` +* `IUser.real_name` -> `IUser.display_name` +* `IAddress.real_name` -> `IAddress.display_name` +* Add property `IRoster.member_count`. Commands -------- - * IPython support in `bin/mailman shell` contributed by Andrea Crotti. - (LP: #949926). - * The `mailman.cfg` configuration file will now automatically be detected if - it exists in an `etc` directory which is a sibling of argv0. - * `bin/mailman shell` is an alias for `withlist`. - * The `confirm` email command now properly handles `Re:`-like prefixes, even - if they contain non-ASCII characters. (LP: #685261) - * The `join` email command no longer accepts an `address=` argument. Its - `digest=` argument now accepts the following values: `no` (for regular - delivery), `mime`, or `plain`. - * Added a `help` email command. - * A welcome message is sent when the user confirms their subscription via - email. - * Global ``-C`` option now accepts an absolute path to the configuration - file. Given by Andrea Crotti. (LP: #953707) +* IPython support in `bin/mailman shell` contributed by Andrea Crotti. + (LP: #949926). +* The `mailman.cfg` configuration file will now automatically be detected if + it exists in an `etc` directory which is a sibling of argv0. +* `bin/mailman shell` is an alias for `withlist`. +* The `confirm` email command now properly handles `Re:`-like prefixes, even + if they contain non-ASCII characters. (LP: #685261) +* The `join` email command no longer accepts an `address=` argument. Its + `digest=` argument now accepts the following values: `no` (for regular + delivery), `mime`, or `plain`. +* Added a `help` email command. +* A welcome message is sent when the user confirms their subscription via + email. +* Global ``-C`` option now accepts an absolute path to the configuration + file. Given by Andrea Crotti. (LP: #953707) Bug fixes --------- - * Subscription disabled probe warning notification messages are now sent - without a `Precedence:` header. Given by Mark Sapiro. (LP: #808821) - * Fixed KeyError in retry runner, contributed by Stephen A. Goss. - (LP: #872391) - * Fixed bogus use of `bounce_processing` attribute (should have been - `process_bounces`, with thanks to Vincent Fretin. (LP: #876774) - * Fix `test_moderation` for timezones east of UTC+0000, given by blacktav. - (LP: #890675) +* Subscription disabled probe warning notification messages are now sent + without a `Precedence:` header. Given by Mark Sapiro. (LP: #808821) +* Fixed KeyError in retry runner, contributed by Stephen A. Goss. + (LP: #872391) +* Fixed bogus use of `bounce_processing` attribute (should have been + `process_bounces`, with thanks to Vincent Fretin. (LP: #876774) +* Fix `test_moderation` for timezones east of UTC+0000, given by blacktav. + (LP: #890675) 3.0 alpha 8 -- "Where's My Thing?" @@ -921,93 +931,93 @@ Bug fixes Architecture ------------ - * Factor out bounce detection to `flufl.bounce`. - * Unrecognized bounces can now also be forwarded to the site owner. - * mailman.qrunner log is renamed to mailman.runner - * master-qrunner.lck -> master.lck - * master-qrunner.pid -> master.pid - * Four new events are created, and notifications are sent during mailing list - lifecycle changes: - - ListCreatingEvent - sent before the mailing list is created - - ListCreatedEvent - sent after the mailing list is created - - ListDeletingEvent - sent before the mailing list is deleted - - ListDeletedEvent - sent after the mailing list is deleted - * Four new events are created, and notifications are sent during domain - lifecycle changes: - - DomainCreatingEvent - sent before the domain is created - - DomainCreatedEvent - sent after the domain is created - - DomainDeletingEvent - sent before the domain is deleted - - DomainDeletedEvent - sent after the domain is deleted - * Using the above events, when a domain is deleted, associated mailing lists - are deleted. (LP: #837526) - * IDomain.email_host -> .mail_host (LP: #831660) - * User and Member ids are now proper UUIDs. - * Improved the way enums are stored in the database, so that they are more - explicitly expressed in the code, and more database efficient. +* Factor out bounce detection to `flufl.bounce`. +* Unrecognized bounces can now also be forwarded to the site owner. +* mailman.qrunner log is renamed to mailman.runner +* master-qrunner.lck -> master.lck +* master-qrunner.pid -> master.pid +* Four new events are created, and notifications are sent during mailing list + lifecycle changes: + - ListCreatingEvent - sent before the mailing list is created + - ListCreatedEvent - sent after the mailing list is created + - ListDeletingEvent - sent before the mailing list is deleted + - ListDeletedEvent - sent after the mailing list is deleted +* Four new events are created, and notifications are sent during domain + lifecycle changes: + - DomainCreatingEvent - sent before the domain is created + - DomainCreatedEvent - sent after the domain is created + - DomainDeletingEvent - sent before the domain is deleted + - DomainDeletedEvent - sent after the domain is deleted +* Using the above events, when a domain is deleted, associated mailing lists + are deleted. (LP: #837526) +* IDomain.email_host -> .mail_host (LP: #831660) +* User and Member ids are now proper UUIDs. +* Improved the way enums are stored in the database, so that they are more + explicitly expressed in the code, and more database efficient. REST ---- - * Preferences for addresses, users, and members can be accessed, changed, and - deleted through the REST interface. Hierarchical, combined preferences for - members, and system preferences can be read through the REST interface. - (LP: #821438) - * The IMailingList attribute ``host_name`` has been renamed to ``mail_host`` - for consistency. This changes the REST API for mailing list - resources. (LP: #787599) - * New REST resource http://.../members/find can be POSTed to in order to find - member records. Optional arguments are `subscriber` (email address to - search for), `fqdn_listname`, and `role` (i.e. MemberRole). (LP: #799612) - * You can now query or change a member's `delivery_mode` attribute through - the REST API (LP: #833132). Given by Stephen A. Goss. - * New REST resource http://.../<domain>/lists can be GETed in order to find - all the mailing lists in a specific domain (LP: #829765). Given by - Stephen A. Goss. - * Fixed /lists/<fqdn_listname>/<role>/<email> (LP: #825570) - * Remove role plurals from /lists/<fqdn_listname/rosters/<role> - * Fixed incorrect error code for /members/<bogus> (LP: #821020). Given by - Stephen A. Goss. - * DELETE users via the REST API. (LP: #820660) - * Moderators and owners can be added via REST (LP: #834130). Given by - Stephen A. Goss. - * Getting the roster or configuration of a nonexistent list did not give a - 404 error (LP: #837676). Given by Stephen A. Goss. - * PATCHing an invalid attribute on a member did not give a 400 error - (LP: #833376). Given by Stephen A. Goss. - * Getting the memberships for a non-existent address did not give a 404 error - (LP: #848103). Given by Stephen A. Goss. +* Preferences for addresses, users, and members can be accessed, changed, and + deleted through the REST interface. Hierarchical, combined preferences for + members, and system preferences can be read through the REST interface. + (LP: #821438) +* The IMailingList attribute ``host_name`` has been renamed to ``mail_host`` + for consistency. This changes the REST API for mailing list + resources. (LP: #787599) +* New REST resource http://.../members/find can be POSTed to in order to find + member records. Optional arguments are `subscriber` (email address to + search for), `fqdn_listname`, and `role` (i.e. MemberRole). (LP: #799612) +* You can now query or change a member's `delivery_mode` attribute through + the REST API (LP: #833132). Given by Stephen A. Goss. +* New REST resource http://.../<domain>/lists can be GETed in order to find + all the mailing lists in a specific domain (LP: #829765). Given by + Stephen A. Goss. +* Fixed /lists/<fqdn_listname>/<role>/<email> (LP: #825570) +* Remove role plurals from /lists/<fqdn_listname/rosters/<role> +* Fixed incorrect error code for /members/<bogus> (LP: #821020). Given by + Stephen A. Goss. +* DELETE users via the REST API. (LP: #820660) +* Moderators and owners can be added via REST (LP: #834130). Given by + Stephen A. Goss. +* Getting the roster or configuration of a nonexistent list did not give a + 404 error (LP: #837676). Given by Stephen A. Goss. +* PATCHing an invalid attribute on a member did not give a 400 error + (LP: #833376). Given by Stephen A. Goss. +* Getting the memberships for a non-existent address did not give a 404 error + (LP: #848103). Given by Stephen A. Goss. Commands -------- - * `bin/qrunner` is renamed to `bin/runner`. - * `bin/mailman aliases` gains `-f` and `-s` options. - * `bin/mailman create` no longer allows a list to be created with bogus owner - addresses. (LP: #778687) - * `bin/mailman start --force` option is fixed. (LP: #869317) +* `bin/qrunner` is renamed to `bin/runner`. +* `bin/mailman aliases` gains `-f` and `-s` options. +* `bin/mailman create` no longer allows a list to be created with bogus owner + addresses. (LP: #778687) +* `bin/mailman start --force` option is fixed. (LP: #869317) Documentation ------------- - * Update the COPYING file to contain the GPLv3. (LP: #790994) - * Major terminology change: ban the terms "queue runners" and "qrunners" since - not all runners manage queue directories. Just call them "runners". Also, - the master is now just called "the master runner". +* Update the COPYING file to contain the GPLv3. (LP: #790994) +* Major terminology change: ban the terms "queue runners" and "qrunners" since + not all runners manage queue directories. Just call them "runners". Also, + the master is now just called "the master runner". Testing ------- - * New configuration variable in [devmode] section, called `wait` which sets - the timeout value used in the test suite for starting up subprocesses. - * Handle SIGTERM in the REST server so that the test suite always shuts down - correctly. (LP: #770328) +* New configuration variable in [devmode] section, called `wait` which sets + the timeout value used in the test suite for starting up subprocesses. +* Handle SIGTERM in the REST server so that the test suite always shuts down + correctly. (LP: #770328) Other bugs and changes ---------------------- - * Moderating a message with Action.accept now sends the message. (LP: #827697) - * Fix AttributeError triggered by i18n call in autorespond_to_sender() - (LP: #827060) - * Local timezone in X-Mailman-Approved-At caused test failure. (LP: #832404) - * InvalidEmailAddressError no longer repr()'s its value. - * Rewrote a test for compatibility between Python 2.6 and 2.7. (LP: #833208) - * Fixed Postfix alias file generation when more than one mailing list - exists. (LP: #874929). Given by Vincent Fretin. +* Moderating a message with Action.accept now sends the message. (LP: #827697) +* Fix AttributeError triggered by i18n call in autorespond_to_sender() + (LP: #827060) +* Local timezone in X-Mailman-Approved-At caused test failure. (LP: #832404) +* InvalidEmailAddressError no longer repr()'s its value. +* Rewrote a test for compatibility between Python 2.6 and 2.7. (LP: #833208) +* Fixed Postfix alias file generation when more than one mailing list + exists. (LP: #874929). Given by Vincent Fretin. 3.0 alpha 7 -- "Mission" @@ -1016,85 +1026,85 @@ Other bugs and changes Architecture ------------ - * Significant updates to the subscription model. Members can now subscribe - with a preferred address, and changes to that will be immediately reflected - in mailing list subscriptions. Users who subscribe with an explicit - address can easily change to a different address, as long as that address - is verified. (LP: #643949) - * IUsers and IMembers are now assigned a unique, random, immutable id. - * IUsers now have created_on and .preferred_address properties. - * IMembers now have a .user attribute for easy access to the subscribed user. - * When created with add_member(), passwords are always stored encrypted. - * In all interfaces, "email" refers to the textual email address while - "address" refers to the `IAddress` object. - * mailman.chains.base.Chain no longer self registers. - * New member and nonmember moderation rules and chains. This effectively - ports moderation rules from Mailman 2 and replaces attributes such as - member_moderation_action, default_member_moderation, and - generic_nonmember_action. Now, nonmembers exist as subscriptions on a - mailing list and members have a moderation_action attribute which describes - the disposition for postings from that address. - * Member.is_moderated was removed because of the above change. - * default_member_action and default_nonmember_action were added to mailing - lists. - * All sender addresses are registered (unverified) with the user manager by - the incoming queue runner. This way, nonmember moderation rules will - always have an IAddress that they can subscribe to the list (as - MemberRole.nonmember). - * Support for SMTP AUTH added via smtp_user and smtp_pass configuration - variables in the [mta] section. (LP: #490044) - * IEmailValidator interface for pluggable validation of email addresses. - * .subscribe() is moved from the IAddress to the IMailingList - * IAddresses get their registered_on attribute set when the object is created. +* Significant updates to the subscription model. Members can now subscribe + with a preferred address, and changes to that will be immediately reflected + in mailing list subscriptions. Users who subscribe with an explicit + address can easily change to a different address, as long as that address + is verified. (LP: #643949) +* IUsers and IMembers are now assigned a unique, random, immutable id. +* IUsers now have created_on and .preferred_address properties. +* IMembers now have a .user attribute for easy access to the subscribed user. +* When created with add_member(), passwords are always stored encrypted. +* In all interfaces, "email" refers to the textual email address while + "address" refers to the `IAddress` object. +* mailman.chains.base.Chain no longer self registers. +* New member and nonmember moderation rules and chains. This effectively + ports moderation rules from Mailman 2 and replaces attributes such as + member_moderation_action, default_member_moderation, and + generic_nonmember_action. Now, nonmembers exist as subscriptions on a + mailing list and members have a moderation_action attribute which describes + the disposition for postings from that address. +* Member.is_moderated was removed because of the above change. +* default_member_action and default_nonmember_action were added to mailing + lists. +* All sender addresses are registered (unverified) with the user manager by + the incoming queue runner. This way, nonmember moderation rules will + always have an IAddress that they can subscribe to the list (as + MemberRole.nonmember). +* Support for SMTP AUTH added via smtp_user and smtp_pass configuration + variables in the [mta] section. (LP: #490044) +* IEmailValidator interface for pluggable validation of email addresses. +* .subscribe() is moved from the IAddress to the IMailingList +* IAddresses get their registered_on attribute set when the object is created. Configuration ------------- - * [devmode] section gets a new 'testing' variable. - * Added password_scheme and password_length settings for defining the - default password encryption scheme. - * creator_pw_file and site_pw_file are removed. +* [devmode] section gets a new 'testing' variable. +* Added password_scheme and password_length settings for defining the + default password encryption scheme. +* creator_pw_file and site_pw_file are removed. Commands -------- - * 'bin/mailman start' does a better job of producing an error when Mailman is - already running. - * 'bin/mailman status' added for providing command line status on the master - queue runner watcher process. - * 'bin/mailman info' now prints the REST root url and credentials. - * mmsitepass removed; there is no more site password. +* 'bin/mailman start' does a better job of producing an error when Mailman is + already running. +* 'bin/mailman status' added for providing command line status on the master + queue runner watcher process. +* 'bin/mailman info' now prints the REST root url and credentials. +* mmsitepass removed; there is no more site password. REST ---- - * Add Basic Auth support for REST API security. (Jimmy Bergman) - * Include the fqdn_listname and email address in the member JSON - representation. - * Added reply_goes_to_list, send_welcome_msg, welcome_msg, - default_member_moderation to the mailing list's writable attributes in the - REST service. (Jimmy Bergman) - * Expose the new membership model to the REST API. Canonical member resource - URLs are now much shorter and live in their own top-level namespace instead - of within the mailing list's namespace. - * /addresses/<email>/memberships gets all the memberships for a given email - address. - * /users is a new top-level URL under which user information can be - accessed. Posting to this creates new users. - * Users can subscribe to mailing lists through the REST API. - * Domains can be deleted via the REST API. - * PUT and PATCH to a list configuration now returns a 204 (No Content). +* Add Basic Auth support for REST API security. (Jimmy Bergman) +* Include the fqdn_listname and email address in the member JSON + representation. +* Added reply_goes_to_list, send_welcome_msg, welcome_msg, + default_member_moderation to the mailing list's writable attributes in the + REST service. (Jimmy Bergman) +* Expose the new membership model to the REST API. Canonical member resource + URLs are now much shorter and live in their own top-level namespace instead + of within the mailing list's namespace. +* /addresses/<email>/memberships gets all the memberships for a given email + address. +* /users is a new top-level URL under which user information can be + accessed. Posting to this creates new users. +* Users can subscribe to mailing lists through the REST API. +* Domains can be deleted via the REST API. +* PUT and PATCH to a list configuration now returns a 204 (No Content). Build ----- - * Support Python 2.7. (LP: #667472) - * Disable site-packages in buildout.cfg because of LP: #659231. - * Don't include eggs/ or parts/ in the source tarball. (LP: #656946) - * flufl.lock is now required instead of locknix. +* Support Python 2.7. (LP: #667472) +* Disable site-packages in buildout.cfg because of LP: #659231. +* Don't include eggs/ or parts/ in the source tarball. (LP: #656946) +* flufl.lock is now required instead of locknix. Bugs fixed ---------- - * Typo in scan_message(). (LP: #645897) - * Typo in add_member(). (LP: #710182) (Florian Fuchs) - * Re-enable bounce detectors. (LP: #756943) - * Clean up many pyflakes problems; ditching pylint. +* Typo in scan_message(). (LP: #645897) +* Typo in add_member(). (LP: #710182) (Florian Fuchs) +* Re-enable bounce detectors. (LP: #756943) +* Clean up many pyflakes problems; ditching pylint. 3.0 alpha 6 -- "Cut to the Chase" @@ -1103,53 +1113,53 @@ Bugs fixed Commands -------- - * The functionality of 'bin/list_members' has been moved to - 'bin/mailman members'. - * 'bin/mailman info' -v/--verbose output displays the file system - layout paths Mailman is currently configured to use. +* The functionality of 'bin/list_members' has been moved to + 'bin/mailman members'. +* 'bin/mailman info' -v/--verbose output displays the file system + layout paths Mailman is currently configured to use. Configuration ------------- - * You can now configure the paths Mailman uses for queue files, lock files, - data files, etc. via the configuration file. Define a file system 'layout' - and then select that layout in the [mailman] section. Default layouts - include 'local' for putting everything in /var/tmp/mailman, 'dev' for local - development, and 'fhs' for Filesystem Hierarchy Standard 2.3 (LP #490144). - * Queue file directories now live in $var_dir/queues. +* You can now configure the paths Mailman uses for queue files, lock files, + data files, etc. via the configuration file. Define a file system 'layout' + and then select that layout in the [mailman] section. Default layouts + include 'local' for putting everything in /var/tmp/mailman, 'dev' for local + development, and 'fhs' for Filesystem Hierarchy Standard 2.3 (LP #490144). +* Queue file directories now live in $var_dir/queues. REST ---- - * lazr.restful has been replaced by restish as the REST publishing technology - used by Mailman. - * New REST API for getting all the members of a roster for a specific mailing - list. - * New REST API for getting and setting a mailing list's configuration. GET - and PUT are supported to retrieve the current configuration, and set all - the list's writable attributes in one request. PATCH is supported to - partially update a mailing list's configuration. Individual options can be - set and retrieved by using subpaths. - * Subscribing an already subscribed member via REST now returns a 409 HTTP - error. LP: #552917 - * Fixed a bug when deleting a list via the REST API. LP: #601899 +* lazr.restful has been replaced by restish as the REST publishing technology + used by Mailman. +* New REST API for getting all the members of a roster for a specific mailing + list. +* New REST API for getting and setting a mailing list's configuration. GET + and PUT are supported to retrieve the current configuration, and set all + the list's writable attributes in one request. PATCH is supported to + partially update a mailing list's configuration. Individual options can be + set and retrieved by using subpaths. +* Subscribing an already subscribed member via REST now returns a 409 HTTP + error. LP: #552917 +* Fixed a bug when deleting a list via the REST API. LP: #601899 Architecture ------------ - * X-BeenThere header is removed. - * Mailman no longer touches the Sender or Errors-To headers. - * Chain actions can now fire Zope events in their _process() - implementations. - * Environment variable $MAILMAN_VAR_DIR can be used to control the var/ - directory for Mailman's runtime files. New environment variable - $MAILMAN_UNDER_MASTER_CONTROL is used instead of the qrunner's --subproc/-s - option. +* X-BeenThere header is removed. +* Mailman no longer touches the Sender or Errors-To headers. +* Chain actions can now fire Zope events in their _process() + implementations. +* Environment variable $MAILMAN_VAR_DIR can be used to control the var/ + directory for Mailman's runtime files. New environment variable + $MAILMAN_UNDER_MASTER_CONTROL is used instead of the qrunner's --subproc/-s + option. Miscellaneous ------------- - * Allow X-Approved and X-Approve headers, equivalent to Approved and - Approve. LP: #557750 - * Various test failure fixes. LP: #543618, LP: #544477 - * List-Post header is retained in MIME digest messages. LP: #526143 - * Importing from a Mailman 2.1.x list is partially supported. +* Allow X-Approved and X-Approve headers, equivalent to Approved and + Approve. LP: #557750 +* Various test failure fixes. LP: #543618, LP: #544477 +* List-Post header is retained in MIME digest messages. LP: #526143 +* Importing from a Mailman 2.1.x list is partially supported. 3.0 alpha 5 -- "Distant Early Warning" @@ -1158,31 +1168,31 @@ Miscellaneous REST ---- - * Add REST API for subscription services. You can now: +* Add REST API for subscription services. You can now: - - list all members in all mailing lists - - subscribe (and possibly register) an address to a mailing list - - unsubscribe an address from mailing list + - list all members in all mailing lists + - subscribe (and possibly register) an address to a mailing list + - unsubscribe an address from mailing list Commands -------- - * 'bin/dumpdb' is now 'bin/mailman qfile' - * 'bin/unshunt' is now 'bin/mailman unshunt' - * Mailman now properly handles the '-join', '-leave', and '-confirm' email - commands and sub-addresses. '-subscribe' and '-unsubscribe' are aliases - for '-join' and '-leave' respectively. +* 'bin/dumpdb' is now 'bin/mailman qfile' +* 'bin/unshunt' is now 'bin/mailman unshunt' +* Mailman now properly handles the '-join', '-leave', and '-confirm' email + commands and sub-addresses. '-subscribe' and '-unsubscribe' are aliases + for '-join' and '-leave' respectively. Configuration ------------- - * devmode settings now live in their own [devmode] section. - * Mailman now searches for a configuration file using this search order. The - first file that exists is used. +* devmode settings now live in their own [devmode] section. +* Mailman now searches for a configuration file using this search order. The + first file that exists is used. - - -C config command line argument - - $MAILMAN_CONFIG_FILE environment variable - - ./mailman.cfg - - ~/.mailman.cfg - - /etc/mailman.cfg + - -C config command line argument + - $MAILMAN_CONFIG_FILE environment variable + - ./mailman.cfg + - ~/.mailman.cfg + - /etc/mailman.cfg 3.0 alpha 4 -- "Vital Signs" @@ -1191,37 +1201,37 @@ Configuration Commands -------- - * 'bin/inject' is now 'bin/mailman inject', with some changes - * 'bin/mailmanctl' is now 'bin/mailman start|stop|reopen|restart' - * 'bin/mailman version' is added (output same as 'bin/mailman --version') - * 'bin/mailman members' command line arguments have changed. It also - now ignores blank lines and lines that start with #. It also no longer - quits when it sees an address that's already subscribed. - * 'bin/withlist' is now 'bin/mailman withlist', and its command line - arguments have changed. - * 'bin/mailman lists' command line arguments have changed. - * 'bin/genaliases' is now 'bin/mailman aliases' +* 'bin/inject' is now 'bin/mailman inject', with some changes +* 'bin/mailmanctl' is now 'bin/mailman start|stop|reopen|restart' +* 'bin/mailman version' is added (output same as 'bin/mailman --version') +* 'bin/mailman members' command line arguments have changed. It also + now ignores blank lines and lines that start with #. It also no longer + quits when it sees an address that's already subscribed. +* 'bin/withlist' is now 'bin/mailman withlist', and its command line + arguments have changed. +* 'bin/mailman lists' command line arguments have changed. +* 'bin/genaliases' is now 'bin/mailman aliases' Architecture ------------ - * A near complete rewrite of the low-level SMTP delivery machinery. This - greatly improves readability, testability, reuse and extensibility. Almost - all the old functionality has been retained. The smtp_direct.py handler is - gone. - * Refactor model objects into the mailman.model subpackage. - * Refactor most of the i18n infrastructure into a separate flufl.i18n package. - * Switch from setuptools to distribute. - * Remove the dependency on setuptools_bzr - * Do not create the .mo files during setup. +* A near complete rewrite of the low-level SMTP delivery machinery. This + greatly improves readability, testability, reuse and extensibility. Almost + all the old functionality has been retained. The smtp_direct.py handler is + gone. +* Refactor model objects into the mailman.model subpackage. +* Refactor most of the i18n infrastructure into a separate flufl.i18n package. +* Switch from setuptools to distribute. +* Remove the dependency on setuptools_bzr +* Do not create the .mo files during setup. Configuration ------------- - * All log files now have a '.log' suffix by default. - * The substitution placeholders in the verp_format configuration variable - have been renamed. - * Add a devmode configuration variable that changes some basic behavior. - Most importantly, it allows you to set a low-level SMTP recipient for all - mail for testing purposes. See also devmode_recipient. +* All log files now have a '.log' suffix by default. +* The substitution placeholders in the verp_format configuration variable + have been renamed. +* Add a devmode configuration variable that changes some basic behavior. + Most importantly, it allows you to set a low-level SMTP recipient for all + mail for testing purposes. See also devmode_recipient. 3.0 alpha 3 -- "Working Man" @@ -1230,37 +1240,37 @@ Configuration Configuration ------------- - * Configuration is now done through lazr.config. Defaults.py is - dead. lazr.config files are essentially hierarchical ini files. - * Domains are now stored in the database instead of in the configuration file. - * pre- and post- initialization hooks are now available to plugins. Specify - additional hooks to run in the configuration file. - * Add the environment variable $MAILMAN_CONFIG_FILE which overrides the -C - command line option. - * Make LMTP more compliant with Postfix docs (Patrick Koetter) - * Added a NullMTA for mail servers like Exim which just work automatically. +* Configuration is now done through lazr.config. Defaults.py is + dead. lazr.config files are essentially hierarchical ini files. +* Domains are now stored in the database instead of in the configuration file. +* pre- and post- initialization hooks are now available to plugins. Specify + additional hooks to run in the configuration file. +* Add the environment variable $MAILMAN_CONFIG_FILE which overrides the -C + command line option. +* Make LMTP more compliant with Postfix docs (Patrick Koetter) +* Added a NullMTA for mail servers like Exim which just work automatically. Architecture ------------ - * 'bin/mailman' is a new super-command for managing Mailman from the command - line. Some older bin scripts have been converted, with more to come. - * Mailman now has an administrative REST interface which can be used to get - information from and manage Mailman remotely. - * Back port of Mailman 2.1's limit on .bak file restoration. After 3 - restores, the file is moved to the bad queue, with a .psv extension. (Mark - Sapiro) - * Digest creation is moved into a new queue runner so it doesn't block main - message processing. +* 'bin/mailman' is a new super-command for managing Mailman from the command + line. Some older bin scripts have been converted, with more to come. +* Mailman now has an administrative REST interface which can be used to get + information from and manage Mailman remotely. +* Back port of Mailman 2.1's limit on .bak file restoration. After 3 + restores, the file is moved to the bad queue, with a .psv extension. (Mark + Sapiro) +* Digest creation is moved into a new queue runner so it doesn't block main + message processing. Other changes ------------- - * bin/make_instance is no longer necessary, and removed - * The debug log is turned up to info by default to reduce log file spam. +* bin/make_instance is no longer necessary, and removed +* The debug log is turned up to info by default to reduce log file spam. Building and installation ------------------------- - * All doc tests can now be turned into documentation, via Sphinx. Just run - bin/docs after bin/buildout. +* All doc tests can now be turned into documentation, via Sphinx. Just run + bin/docs after bin/buildout. 3.0 alpha 2 -- "Grand Designs" @@ -1270,59 +1280,59 @@ Building and installation Licensing --------- - * Mailman 3 is now licensed under the GPLv3. +* Mailman 3 is now licensed under the GPLv3. Bug fixes --------- - * Changed bin/arch to attempt to open the mbox before wiping the old - archive. Launchpad bug #280418. +* Changed bin/arch to attempt to open the mbox before wiping the old + archive. Launchpad bug #280418. - * Added digest.mbox and pending.pck to the 'list' files checked by - check_perms. Launchpad bug #284802. +* Added digest.mbox and pending.pck to the 'list' files checked by + check_perms. Launchpad bug #284802. Architecture ------------ - * Converted to using zope.testing as the test infrastructure. Use bin/test - now to run the full test suite. - <http://pypi.python.org/pypi/zope.testing/3.7.1> - * Partially converted to using lazr.config as the new configuration - regime. Not everything has been converted yet, so some manual editing - of mailman/Defaults.py is required. This will be rectified in future - versions. <http://launchpad.net/lazr.config> - * All web-related stuff is moved to its own directory, effectively moving - it out of the way for now. - * The email command infrastructure has been reworked to play more nicely - with the plug-in architecture. Not all commands have yet been - converted. +* Converted to using zope.testing as the test infrastructure. Use bin/test + now to run the full test suite. + <http://pypi.python.org/pypi/zope.testing/3.7.1> +* Partially converted to using lazr.config as the new configuration + regime. Not everything has been converted yet, so some manual editing + of mailman/Defaults.py is required. This will be rectified in future + versions. <http://launchpad.net/lazr.config> +* All web-related stuff is moved to its own directory, effectively moving + it out of the way for now. +* The email command infrastructure has been reworked to play more nicely + with the plug-in architecture. Not all commands have yet been + converted. Other changes ------------- - * The LMTP server now properly calculates the message's original size. - * For command line scripts, -C names the configuration file to use. For - convenient testing, if -C is not given, then the environment variable - MAILMAN_CONFIG_FILE is consulted. - * Support added for a local MHonArc archiver, as well as archiving - automatically in the remote Mail-Archive.com service. - * The permalink proposal for supporting RFC 5064 has been adopted. - * Mailing lists no longer have a .web_page_url attribute; this is taken from - the mailing list's domain's base_url attribute. - * Incoming MTA selection is now taken from the config file instead of - plugins. An MTA for Postfix+LMTP is added. bin/genaliases works again. - * If a message has no Message-ID, the stock archivers will return None for - the permalink now instead of raising an assertion. - * IArchiver no longer has an is_enabled property; this is taken from the - configuration file now. +* The LMTP server now properly calculates the message's original size. +* For command line scripts, -C names the configuration file to use. For + convenient testing, if -C is not given, then the environment variable + MAILMAN_CONFIG_FILE is consulted. +* Support added for a local MHonArc archiver, as well as archiving + automatically in the remote Mail-Archive.com service. +* The permalink proposal for supporting RFC 5064 has been adopted. +* Mailing lists no longer have a .web_page_url attribute; this is taken from + the mailing list's domain's base_url attribute. +* Incoming MTA selection is now taken from the config file instead of + plugins. An MTA for Postfix+LMTP is added. bin/genaliases works again. +* If a message has no Message-ID, the stock archivers will return None for + the permalink now instead of raising an assertion. +* IArchiver no longer has an is_enabled property; this is taken from the + configuration file now. Installation ------------ - * Python 2.6 is the minimal requirement. - * Converted to using zc.buildout as the build infrastructure. See - docs/ALPHA.txt for details. - <http://pypi.python.org/pypi/zc.buildout/1.1.1> +* Python 2.6 is the minimal requirement. +* Converted to using zc.buildout as the build infrastructure. See + docs/ALPHA.txt for details. + <http://pypi.python.org/pypi/zc.buildout/1.1.1> 3.0 alpha 1 -- "Leave That Thing Alone" @@ -1332,148 +1342,148 @@ Installation User visible changes -------------------- - * So called 'new style' subject prefixing is the default now, and the only - option. When a list's subject prefix is added, it's always done so before - any Re: tag, not after. E.g. '[My List] Re: The subject'. - * RFC 2369 headers List-Subscribe and List-Unsubscribe now use the preferred - -join and -leave addresses instead of the -request address with a subject - value. +* So called 'new style' subject prefixing is the default now, and the only + option. When a list's subject prefix is added, it's always done so before + any Re: tag, not after. E.g. '[My List] Re: The subject'. +* RFC 2369 headers List-Subscribe and List-Unsubscribe now use the preferred + -join and -leave addresses instead of the -request address with a subject + value. Configuration ------------- - * There is no more separate configure; make; make install step. Mailman 3.0 - is a setuptools package. - * Mailman can now be configured via a 'mailman.cfg' file which lives in - $VAR_PREFIX/etc. This is used to separate the configuration from the - source directory. Alternative configuration files can be specified via - -C/--config for most command line scripts. mailman.cfg contains Python - code. mm_cfg.py is no more. You do not need to import Defaults.py in - etc/mailman.cfg. You should still consult Defaults.py for the list of site - configuration variables available to you. +* There is no more separate configure; make; make install step. Mailman 3.0 + is a setuptools package. +* Mailman can now be configured via a 'mailman.cfg' file which lives in + $VAR_PREFIX/etc. This is used to separate the configuration from the + source directory. Alternative configuration files can be specified via + -C/--config for most command line scripts. mailman.cfg contains Python + code. mm_cfg.py is no more. You do not need to import Defaults.py in + etc/mailman.cfg. You should still consult Defaults.py for the list of site + configuration variables available to you. - See the etc/mailman.cfg.sample file. - * PUBLIC_ARCHIVE_URL and DEFAULT_SUBJECT_PREFIX now takes $-string - substitutions instead of %-string substitutions. See documentation in - Defaults.py.in for details. - * Message headers and footers now only accept $-string substitutions; - %-strings are no longer supported. The substitution variable - '_internal_name' has been removed; use $list_name or $real_name - instead. The substitution variable $fqdn_listname has been added. - DEFAULT_MSG_FOOTER in Defaults.py.in has been updated accordingly. - * The KNOWN_SPAMMERS global variable is replaced with HEADER_MATCHES. The - mailing list's header_filter_rules variable is replaced with header_matches - which has the same semantics as HEADER_MATCHES, but is list-specific. - * DEFAULT_MAIL_COMMANDS_MAX_LINES -> EMAIL_COMMANDS_MAX_LINES - * All SMTP_LOG_* templates use $-strings and all consistently write the - Message-ID as the first item in the log entry. - * DELIVERY_MODULE now names a handler, not a module (yes, this is a - misnomer, but it will likely change again before the final release). + See the etc/mailman.cfg.sample file. +* PUBLIC_ARCHIVE_URL and DEFAULT_SUBJECT_PREFIX now takes $-string + substitutions instead of %-string substitutions. See documentation in + Defaults.py.in for details. +* Message headers and footers now only accept $-string substitutions; + %-strings are no longer supported. The substitution variable + '_internal_name' has been removed; use $list_name or $real_name + instead. The substitution variable $fqdn_listname has been added. + DEFAULT_MSG_FOOTER in Defaults.py.in has been updated accordingly. +* The KNOWN_SPAMMERS global variable is replaced with HEADER_MATCHES. The + mailing list's header_filter_rules variable is replaced with header_matches + which has the same semantics as HEADER_MATCHES, but is list-specific. +* DEFAULT_MAIL_COMMANDS_MAX_LINES -> EMAIL_COMMANDS_MAX_LINES +* All SMTP_LOG_* templates use $-strings and all consistently write the + Message-ID as the first item in the log entry. +* DELIVERY_MODULE now names a handler, not a module (yes, this is a + misnomer, but it will likely change again before the final release). Architecture ------------ - * Internally, all strings are Unicodes. - * Implementation of a chain-of-rules based approach for deciding whether a - message should initially be accepted, held for approval, rejected/bounced, - or discarded. This replaces most of the disposition handlers in the - pipeline. The IncomingRunner now only processes message through the rule - chains, and once accepted, places the message in a new queue processed by - the PipelineRunner. - * Substantially reworked the entire queue runner process management, - including mailmanctl, a new master script, and the qrunners. This should - be much more robust and reliable now. - * The Storm ORM is used for data storage, with the SQLite backend as the - default relational database. - * Zope interfaces are used to describe the major components. - * Users are now stored in a unified database, and shared across all mailing - lists. - * Mailman's web interface is now WSGI compliant. WSGI is a Python standard - (PEP 333) allowing web applications to be (more) easily integrated with any - number of existing Python web application frameworks. For more information - see: +* Internally, all strings are Unicodes. +* Implementation of a chain-of-rules based approach for deciding whether a + message should initially be accepted, held for approval, rejected/bounced, + or discarded. This replaces most of the disposition handlers in the + pipeline. The IncomingRunner now only processes message through the rule + chains, and once accepted, places the message in a new queue processed by + the PipelineRunner. +* Substantially reworked the entire queue runner process management, + including mailmanctl, a new master script, and the qrunners. This should + be much more robust and reliable now. +* The Storm ORM is used for data storage, with the SQLite backend as the + default relational database. +* Zope interfaces are used to describe the major components. +* Users are now stored in a unified database, and shared across all mailing + lists. +* Mailman's web interface is now WSGI compliant. WSGI is a Python standard + (PEP 333) allowing web applications to be (more) easily integrated with any + number of existing Python web application frameworks. For more information + see: - http://www.wsgi.org/wsgi - http://www.python.org/dev/peps/pep-0333/ + http://www.wsgi.org/wsgi + http://www.python.org/dev/peps/pep-0333/ - Mailman can still be run as a traditional CGI program of course. - * Mailman now provides an LMTP server for more efficient integration with - supporting mail servers (e.g. Postfix, Sendmail). The Local Mail Transport - Protocol is defined in RFC 2033: + Mailman can still be run as a traditional CGI program of course. +* Mailman now provides an LMTP server for more efficient integration with + supporting mail servers (e.g. Postfix, Sendmail). The Local Mail Transport + Protocol is defined in RFC 2033: - http://www.faqs.org/rfcs/rfc2033.html - * Virtual domains are now fully supported in that mailing lists of the same - name can exist in more than one domain. This is accomplished by renaming - the lists/ and archives/ subdirectories after the list's posting address. - For example, data for list foo in example.com and list foo in example.org - will be stored in lists/foo@example.com and lists/foo@example.org. + http://www.faqs.org/rfcs/rfc2033.html +* Virtual domains are now fully supported in that mailing lists of the same + name can exist in more than one domain. This is accomplished by renaming + the lists/ and archives/ subdirectories after the list's posting address. + For example, data for list foo in example.com and list foo in example.org + will be stored in lists/foo@example.com and lists/foo@example.org. - For Postfix or manual MTA users, you will need to regenerate your mail - aliases. Use bin/genaliases. + For Postfix or manual MTA users, you will need to regenerate your mail + aliases. Use bin/genaliases. - VIRTUAL_HOST_OVERVIEW has been removed, effectively Mailman now operates - as if it were always enabled. If your site has more than one domain, - you must configure all domains by using add_domain() in your - etc/mailman.cfg flie (see below -- add_virtual() has been removed). - * If you had customizations based on Site.py, you will need to re-implement - them. Site.py has been removed. - * The site list is no more. You can remove your 'mailman' site list unless - you want to retain it for other purposes, but it is no longer used (or - required) by Mailman. You should set NO_REPLY_ADDRESS to an address that - throws away replies, and you should set SITE_OWNER_ADDRESS to an email - address that reaches the person ultimately responsible for the Mailman - installation. The MAILMAN_SITE_LIST variable has been removed. - * qrunners no longer restart on SIGINT; SIGUSR1 is used for that now. + VIRTUAL_HOST_OVERVIEW has been removed, effectively Mailman now operates + as if it were always enabled. If your site has more than one domain, + you must configure all domains by using add_domain() in your + etc/mailman.cfg flie (see below -- add_virtual() has been removed). +* If you had customizations based on Site.py, you will need to re-implement + them. Site.py has been removed. +* The site list is no more. You can remove your 'mailman' site list unless + you want to retain it for other purposes, but it is no longer used (or + required) by Mailman. You should set NO_REPLY_ADDRESS to an address that + throws away replies, and you should set SITE_OWNER_ADDRESS to an email + address that reaches the person ultimately responsible for the Mailman + installation. The MAILMAN_SITE_LIST variable has been removed. +* qrunners no longer restart on SIGINT; SIGUSR1 is used for that now. Internationalization Big Changes -------------------------------- - * Translators should work only on messages/<lang>/LC_MESSAGES/mailman.po. - Templates files are generated from mailman.po during the build process. +* Translators should work only on messages/<lang>/LC_MESSAGES/mailman.po. + Templates files are generated from mailman.po during the build process. New Features ------------ - * Confirmed member change of address is logged in the 'subscribe' log, and if - admin_notify_mchanges is true, a notice is sent to the list owner using a - new adminaddrchgack.txt template. - * There is a new list attribute 'subscribe_auto_approval' which is a list of - email addresses and regular expressions matching email addresses whose - subscriptions are exempt from admin approval. RFE 403066. +* Confirmed member change of address is logged in the 'subscribe' log, and if + admin_notify_mchanges is true, a notice is sent to the list owner using a + new adminaddrchgack.txt template. +* There is a new list attribute 'subscribe_auto_approval' which is a list of + email addresses and regular expressions matching email addresses whose + subscriptions are exempt from admin approval. RFE 403066. Command line scripts -------------------- - * Most scripts have grown a -C/--config flag to allow you to specify a - different configuration file. Without this, the default etc/mailman.cfg - file will be used. - * the -V/--virtual-host-overview switch in list_lists has been removed, while - -d/--domain and -f/--full have been added. - * bin/newlist is renamed bin/create_list and bin/rmlist is renamed - bin/remove_list. Both take fully-qualified list names now (i.e. the list's - posting address), but also accept short names, in which case the default - domain is used. newlist's -u/--urlhost and -e/--emailhost switches have - been removed. The domain that the list is being added to must already - exist. - * Backport the ability to specify additional footer interpolation variables - by the message metadata 'decoration-data' key. +* Most scripts have grown a -C/--config flag to allow you to specify a + different configuration file. Without this, the default etc/mailman.cfg + file will be used. +* the -V/--virtual-host-overview switch in list_lists has been removed, while + -d/--domain and -f/--full have been added. +* bin/newlist is renamed bin/create_list and bin/rmlist is renamed + bin/remove_list. Both take fully-qualified list names now (i.e. the list's + posting address), but also accept short names, in which case the default + domain is used. newlist's -u/--urlhost and -e/--emailhost switches have + been removed. The domain that the list is being added to must already + exist. +* Backport the ability to specify additional footer interpolation variables + by the message metadata 'decoration-data' key. Bug fixes and other patches --------------------------- - * Removal of DomainKey/DKIM signatures is now controlled by Defaults.py - mm_cfg.py variable REMOVE_DKIM_HEADERS (default = No). - * Queue runner processing is improved to log and preserve for analysis in the - shunt queue certain bad queue entries that were previously logged but lost. - Also, entries are preserved when an attempt to shunt throws an exception - (1656289). - * The processing of Topics regular expressions has changed. Previously the - Topics regexp was compiled in verbose mode but not documented as such which - caused some confusion. Also, the documentation indicated that topic - keywords could be entered one per line, but these entries were not handled - properly. Topics regexps are now compiled in non-verbose mode and multi- - line entries are 'ored'. Existing Topics regexps will be converted when - the list is updated so they will continue to work. - * The List-Help, List-Subscribe, and List-Unsubscribe headers were - incorrectly suppressed in messages that Mailman sends directly to users. - * The 'adminapproved' metadata key is renamed 'moderator_approved'. +* Removal of DomainKey/DKIM signatures is now controlled by Defaults.py + mm_cfg.py variable REMOVE_DKIM_HEADERS (default = No). +* Queue runner processing is improved to log and preserve for analysis in the + shunt queue certain bad queue entries that were previously logged but lost. + Also, entries are preserved when an attempt to shunt throws an exception + (1656289). +* The processing of Topics regular expressions has changed. Previously the + Topics regexp was compiled in verbose mode but not documented as such which + caused some confusion. Also, the documentation indicated that topic + keywords could be entered one per line, but these entries were not handled + properly. Topics regexps are now compiled in non-verbose mode and multi- + line entries are 'ored'. Existing Topics regexps will be converted when + the list is updated so they will continue to work. +* The List-Help, List-Subscribe, and List-Unsubscribe headers were + incorrectly suppressed in messages that Mailman sends directly to users. +* The 'adminapproved' metadata key is renamed 'moderator_approved'. diff --git a/src/mailman/docs/contribute.rst b/src/mailman/docs/contribute.rst index 136a4460d..6db6fffa5 100644 --- a/src/mailman/docs/contribute.rst +++ b/src/mailman/docs/contribute.rst @@ -62,7 +62,6 @@ of these command, depending on which version of Python 3 you have:: $ tox -e py36-nocov $ tox -e py35-nocov - $ tox -e py34-nocov You can run individual tests in any given environment by providing additional positional arguments. For example, to run only the tests that match a diff --git a/src/mailman/interfaces/command.py b/src/mailman/interfaces/command.py index 4bf0d5e4a..760e5d440 100644 --- a/src/mailman/interfaces/command.py +++ b/src/mailman/interfaces/command.py @@ -61,24 +61,22 @@ class IEmailCommand(Interface): @public class ICLISubCommand(Interface): - """A command line interface subcommand.""" + """A command line interface subcommand. - name = Attribute('The command name; must be unique') + Subcommands are implemented using the `click` package. See + http://click.pocoo.org/ for details. + """ + name = Attribute( + """The subcommand name as it will show up in `mailman --help`. - __doc__ = Attribute('The command short help') + This must be unique; it is a runtime error if any plugin provides a + subcommand with a clashing name. + """) - def add(parser, command_parser): - """Add the subcommand to the subparser. + command = Attribute( + """The click command to run for this subcommand. - :param parser: The argument parser. - :type parser: `argparse.ArgumentParser` - :param command_parser: The command subparser. - :type command_parser: `argparse.ArgumentParser` - """ - - def process(args): - """Process the subcommand. - - :param args: The namespace, as passed in by argparse. - :type args: `argparse.Namespace` - """ + This must be a function decorated with at least the @click.command() + decorator. The function may also be decorated with other arguments as + needed. + """) diff --git a/src/mailman/rest/docs/__init__.py b/src/mailman/rest/docs/__init__.py index 81c6d11b4..350a6b83e 100644 --- a/src/mailman/rest/docs/__init__.py +++ b/src/mailman/rest/docs/__init__.py @@ -28,7 +28,7 @@ from public import public # New in Python 3.5. try: from http import HTTPStatus -except ImportError: # pragma: no cover +except ImportError: # pragma: nocover class HTTPStatus: FORBIDDEN = 403 NOT_FOUND = 404 @@ -43,14 +43,14 @@ class TestableHandler(BaseHTTPRequestHandler): log_error = log_request - def do_GET(self): # pragma: no cover + def do_GET(self): # pragma: nocover if self.path == '/welcome_2.txt': if self.headers['Authorization'] != 'Basic YW5uZTppcyBzcGVjaWFs': self.send_error(HTTPStatus.FORBIDDEN) return response = TEXTS.get(self.path) if response is None: - self.send_error(HTTPStatus.NOT_FOUND) # pragma: no cover + self.send_error(HTTPStatus.NOT_FOUND) # pragma: nocover return self.send_response(HTTPStatus.OK) self.send_header('Content-Type', 'UTF-8') diff --git a/src/mailman/rest/listconf.py b/src/mailman/rest/listconf.py index da7111d0f..b62d34529 100644 --- a/src/mailman/rest/listconf.py +++ b/src/mailman/rest/listconf.py @@ -42,7 +42,7 @@ class AcceptableAliases(GetterSetter): def get(self, mlist, attribute): """Return the mailing list's acceptable aliases.""" assert attribute == 'acceptable_aliases', ( - 'Unexpected attribute: {}'.format(attribute)) # pragma: no cover + 'Unexpected attribute: {}'.format(attribute)) # pragma: nocover aliases = IAcceptableAliasSet(mlist) return sorted(aliases.aliases) @@ -54,7 +54,7 @@ class AcceptableAliases(GetterSetter): ignored. """ assert attribute == 'acceptable_aliases', ( - 'Unexpected attribute: {}'.format(attribute)) # pragma: no cover + 'Unexpected attribute: {}'.format(attribute)) # pragma: nocover alias_set = IAcceptableAliasSet(mlist) alias_set.clear() for alias in value: diff --git a/src/mailman/rules/docs/dmarc-mitigation.rst b/src/mailman/rules/docs/dmarc-mitigation.rst index c3c57e8a5..bee7b3a55 100644 --- a/src/mailman/rules/docs/dmarc-mitigation.rst +++ b/src/mailman/rules/docs/dmarc-mitigation.rst @@ -23,7 +23,7 @@ returns ``p=reject`` for the ``example.biz`` domain and not for any others. Use test data for the organizational domain suffixes. >>> from mailman.rules.tests.test_dmarc import use_test_organizational_data - >>> cleanups.enter_context(use_test_organizational_data()) + >>> ignore = cleanups.enter_context(use_test_organizational_data()) A message ``From:`` a domain without a DMARC policy does not set any flags. diff --git a/src/mailman/testing/documentation.py b/src/mailman/testing/documentation.py index fc75e7a93..d460be89f 100644 --- a/src/mailman/testing/documentation.py +++ b/src/mailman/testing/documentation.py @@ -21,7 +21,9 @@ Note that doctest extraction does not currently work for zip file distributions. doctest discovery currently requires file system traversal. """ +from click.testing import CliRunner from contextlib import ExitStack +from importlib import import_module from mailman.app.lifecycle import create_list from mailman.config import config from mailman.testing.helpers import ( @@ -143,6 +145,30 @@ def dump_json(url, data=None, method=None, username=None, password=None): @public +def cli(command_path): + # Use this to invoke click commands in doctests. This returns a partial + # that accepts a sequence of command line options, invokes the click + # command, and returns the results (unless the keyword argument 'quiet') + # is True. + package_path, dot, name = command_path.rpartition('.') + command = getattr(import_module(package_path), name) + def inner(command_string, quiet=False, input=None): # noqa: E306 + args = command_string.split() + assert args[0] == 'mailman', args + assert args[1] == command.name, args + # The first two will be `mailman <command>`. That's just for + # documentation purposes, and aren't useful for the test. + result = CliRunner().invoke(command, args[2:], input=input) + if not quiet: + # Print the output, with any trailing newlines stripped, unless + # the quiet flag is set. The extra newlines just make the + # doctests uglier and usually all we care about is the stdout + # text. + print(result.output.rstrip('\n')) + return inner + + +@public def setup(testobj): """Test setup.""" # In general, I don't like adding convenience functions, since I think @@ -161,6 +187,7 @@ def setup(testobj): testobj.globs['stop'] = stop testobj.globs['subscribe'] = subscribe testobj.globs['transaction'] = config.db + testobj.globs['cli'] = cli # Add this so that cleanups can be automatically added by the doctest. testobj.globs['cleanups'] = ExitStack() diff --git a/src/mailman/testing/helpers.py b/src/mailman/testing/helpers.py index af844d214..8bb8e8bdd 100644 --- a/src/mailman/testing/helpers.py +++ b/src/mailman/testing/helpers.py @@ -345,9 +345,9 @@ class configuration: self._values = kws.copy() def _apply(self): - lines = ['[{0}]'.format(self._section)] + lines = ['[{}]'.format(self._section)] for key, value in self._values.items(): - lines.append('{0}: {1}'.format(key, value)) + lines.append('{}: {}'.format(key, value)) config.push(self._uuid, NL.join(lines)) def _remove(self): @@ -355,6 +355,7 @@ class configuration: def __enter__(self): self._apply() + return self def __exit__(self, *exc_info): self._remove() diff --git a/src/mailman/utilities/i18n.py b/src/mailman/utilities/i18n.py index f3c239d51..31c371172 100644 --- a/src/mailman/utilities/i18n.py +++ b/src/mailman/utilities/i18n.py @@ -35,7 +35,7 @@ class TemplateNotFoundError(MailmanError): def __init__(self, template_file): self.template_file = template_file - def __str__(self): # pragma: no cover + def __str__(self): # pragma: nocover return self.template_file diff --git a/src/mailman/utilities/modules.py b/src/mailman/utilities/modules.py index 83a4384b7..155a34ed2 100644 --- a/src/mailman/utilities/modules.py +++ b/src/mailman/utilities/modules.py @@ -20,6 +20,8 @@ import os import sys +from contextlib import contextmanager +from importlib import import_module from pkg_resources import resource_filename, resource_listdir from public import public @@ -46,9 +48,9 @@ def find_name(dotted_name): :return: The object. :rtype: object """ - package_path, dot, object_name = dotted_name.rpartition('.') - __import__(package_path) - return getattr(sys.modules[package_path], object_name) + module_path, dot, object_name = dotted_name.rpartition('.') + module = import_module(module_path) + return getattr(module, object_name) @public @@ -87,7 +89,7 @@ def scan_module(module, interface): for name in module.__all__: component = getattr(module, name, missing) assert component is not missing, ( - '%s has bad __all__: %s' % (module, name)) # pragma: no cover + '%s has bad __all__: %s' % (module, name)) # pragma: nocover if (interface.implementedBy(component) # We cannot use getattr() here because that will return True # for all subclasses. __abstract_component__ should *not* be @@ -166,3 +168,17 @@ def expand_path(url): return resource_filename(package, resource + '.cfg') else: return url + + +@public +@contextmanager +def hacked_sys_modules(name, module): + old_module = sys.modules.get(name) + sys.modules[name] = module + try: + yield + finally: + if old_module is None: + del sys.modules[name] + else: + sys.modules[name] = old_module diff --git a/src/mailman/utilities/options.py b/src/mailman/utilities/options.py index 3d8392486..7f1f5a1c5 100644 --- a/src/mailman/utilities/options.py +++ b/src/mailman/utilities/options.py @@ -15,123 +15,59 @@ # You should have received a copy of the GNU General Public License along with # GNU Mailman. If not, see <http://www.gnu.org/licenses/>. -"""Common argument parsing.""" +"""Argument parsing utilities.""" -import os -import sys +import click -from copy import copy -from mailman.config import config from mailman.core.i18n import _ -from mailman.core.initialize import initialize -from mailman.version import MAILMAN_VERSION -from optparse import Option, OptionParser, OptionValueError from public import public -def check_unicode(option, opt, value): - """Check that the value is a unicode string.""" - if not isinstance(value, bytes): +@public +def validate_runner_spec(ctx, param, value): + # This validator handles two cases. First, for the runner script where + # only a single --runner option is allowed, and second the master script + # where multiple --runner options are allowed. When no --runner options + # are given, we'll either get None or the empty tuple. That's why we use + # false-iness here. + if not value: return value - try: - return value.decode(sys.getdefaultencoding()) - except UnicodeDecodeError: - raise OptionValueError( - 'option {}: Cannot decode: {}'.format(opt, value)) - - -def check_yesno(option, opt, value): - """Check that the value is 'yes' or 'no'.""" - value = value.lower() - if value not in ('yes', 'no', 'y', 'n'): - raise OptionValueError('option {}: invalid: {}'.format(opt, value)) - return value[0] == 'y' - - -class MailmanOption(Option): - """Extension types for unicode options.""" - TYPES = Option.TYPES + ('unicode', 'yesno') - TYPE_CHECKER = copy(Option.TYPE_CHECKER) - TYPE_CHECKER['unicode'] = check_unicode - TYPE_CHECKER['yesno'] = check_yesno - - -class SafeOptionParser(OptionParser): - """A unicode-compatible `OptionParser`. - - Python's standard option parser does not accept unicode options. Rather - than try to fix that, this class wraps the add_option() method and saves - having to wrap the options in str() calls. - """ - def add_option(self, *args, **kwargs): - """See `OptionParser`.""" - # Check to see if the first or first two options are unicodes and turn - # them into 8-bit strings before calling the superclass's method. - if len(args) == 0: - return OptionParser.add_option(self, *args, **kwargs) - old_args = list(args) - new_args = [] - arg0 = old_args.pop(0) - new_args.append(str(arg0)) - if len(old_args) > 0: - arg1 = old_args.pop(0) - new_args.append(str(arg1)) - new_args.extend(old_args) - return OptionParser.add_option(self, *new_args, **kwargs) + specs = [] + for spec in ([value] if isinstance(value, str) else value): + parts = spec.split(':') + if len(parts) == 1: + specs.append((parts[0], 1, 1)) + elif len(parts) == 3: + runner = parts[0] + try: + rslice = int(parts[1]) + rrange = int(parts[2]) + except ValueError: + raise click.BadParameter( + _('slice and range must be integers: $value')) + specs.append((runner, rslice, rrange)) + else: + raise click.UsageError(_('Bad runner spec: $value')) + return specs[0] if isinstance(value, str) else specs @public -class Options: - """Common argument parser.""" - - # Subclasses should override. - usage = None - - def __init__(self): - self.parser = SafeOptionParser( - version=MAILMAN_VERSION, - option_class=MailmanOption, - usage=self.usage) - self.add_common_options() - self.add_options() - options, arguments = self.parser.parse_args() - self.options = options - self.arguments = arguments - # Also, for convenience, place the options in the configuration file - # because occasional global uses are necessary. - config.options = self - - def add_options(self): - """Allow the subclass to add its own specific arguments.""" - pass - - def sanity_check(self): - """Allow subclasses to do sanity checking of arguments.""" - pass - - def add_common_options(self): - """Add options common to all scripts.""" - # Python requires str types here. - self.parser.add_option( - '-C', '--config', - help=_('Alternative configuration file to use')) - - def initialize(self, propagate_logs=None): - """Initialize the configuration system. - - After initialization of the configuration system, perform sanity - checks. We do it in this order because some sanity checks require the - configuration to be initialized. - - :param propagate_logs: Optional flag specifying whether log messages - in sub-loggers should be propagated to the master logger (and - hence to the root logger). If not given, propagation is taken - from the configuration files. - :type propagate_logs: bool or None. - """ - # Fall back to using the environment variable if -C is not given. - config_file = (os.getenv('MAILMAN_CONFIG_FILE') - if self.options.config is None - else self.options.config) - initialize(config_file, propagate_logs=propagate_logs) - self.sanity_check() +class I18nCommand(click.Command): # pragma: nocover + # https://github.com/pallets/click/issues/834 + # + # Note that this handles the case for the `mailman <subcommand> --help` + # output. To handle `mailman --help` we override the same method in the + # `Subcommands` subclass over in src/mailman/bin/mailman.py. The test + # suite doesn't cover *this* copy of the method but who cares, since it + # will hopefully go away some day. + 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) diff --git a/src/mailman/utilities/tests/test_modules.py b/src/mailman/utilities/tests/test_modules.py index 20735486c..82d44cff4 100644 --- a/src/mailman/utilities/tests/test_modules.py +++ b/src/mailman/utilities/tests/test_modules.py @@ -23,7 +23,8 @@ import unittest from contextlib import ExitStack, contextmanager from mailman.interfaces.styles import IStyle -from mailman.utilities.modules import add_components, find_components +from mailman.utilities.modules import ( + add_components, find_components, hacked_sys_modules) from pathlib import Path from tempfile import TemporaryDirectory @@ -228,3 +229,15 @@ class StyleA2: 'previously <mypackage.components.StyleA1 object at ' '.*>'): add_components('mypackage', IStyle, {}) + + def test_hacked_sys_modules(self): + self.assertIsNone(sys.modules.get('mailman.not_a_module')) + with hacked_sys_modules('mailman.not_a_module', object()): + self.assertIsNotNone(sys.modules.get('mailman.not_a_module')) + + def test_hacked_sys_modules_restore(self): + email_package = sys.modules['email'] + sentinel = object() + with hacked_sys_modules('email', sentinel): + self.assertEqual(sys.modules.get('email'), sentinel) + self.assertEqual(sys.modules.get('email'), email_package) diff --git a/src/mailman/utilities/tests/test_options.py b/src/mailman/utilities/tests/test_options.py new file mode 100644 index 000000000..5885972fa --- /dev/null +++ b/src/mailman/utilities/tests/test_options.py @@ -0,0 +1,50 @@ +# Copyright (C) 2017 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Test option utilities.""" + +import click +import unittest + +from mailman.utilities.options import validate_runner_spec + + +class TestValidateRunnerSpec(unittest.TestCase): + def test_false_value(self): + self.assertIsNone(validate_runner_spec(None, None, None)) + + def test_runner_only(self): + specs = validate_runner_spec(None, None, 'incoming') + self.assertEqual(specs, ('incoming', 1, 1)) + + def test_full_runner_spec(self): + specs = validate_runner_spec(None, None, 'incoming:2:4') + self.assertEqual(specs, ('incoming', 2, 4)) + + def test_bad_runner_spec(self): + with self.assertRaises(click.BadParameter) as cm: + validate_runner_spec(None, None, 'incoming:not:int') + self.assertEqual( + cm.exception.message, + 'slice and range must be integers: incoming:not:int') + + def test_bad_runner_spec_parts(self): + with self.assertRaises(click.UsageError) as cm: + validate_runner_spec(None, None, 'incoming:2') + self.assertEqual( + cm.exception.message, + 'Bad runner spec: incoming:2') |
