summaryrefslogtreecommitdiff
path: root/src/mailman/bin
diff options
context:
space:
mode:
Diffstat (limited to 'src/mailman/bin')
-rw-r--r--src/mailman/bin/mailman.py142
-rw-r--r--src/mailman/bin/master.py222
-rw-r--r--src/mailman/bin/runner.py198
-rw-r--r--src/mailman/bin/tests/test_mailman.py62
-rw-r--r--src/mailman/bin/tests/test_master.py74
5 files changed, 400 insertions, 298 deletions
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()