summaryrefslogtreecommitdiff
path: root/src/mailman/commands
diff options
context:
space:
mode:
authorBarry Warsaw2017-07-22 03:02:05 +0000
committerBarry Warsaw2017-07-22 03:02:05 +0000
commitf00b94f18e1d82d1488cbcee6053f03423bc2f49 (patch)
tree1a8e56dff0eab71e58e5fc9ecc5f3c614d7edca7 /src/mailman/commands
parentf54c045519300f6f70947d1114f46c2b8ae0d368 (diff)
downloadmailman-f00b94f18e1d82d1488cbcee6053f03423bc2f49.tar.gz
mailman-f00b94f18e1d82d1488cbcee6053f03423bc2f49.tar.zst
mailman-f00b94f18e1d82d1488cbcee6053f03423bc2f49.zip
Diffstat (limited to 'src/mailman/commands')
-rw-r--r--src/mailman/commands/cli_aliases.py31
-rw-r--r--src/mailman/commands/cli_conf.py174
-rw-r--r--src/mailman/commands/cli_control.py276
-rw-r--r--src/mailman/commands/cli_digests.py141
-rw-r--r--src/mailman/commands/cli_help.py26
-rw-r--r--src/mailman/commands/cli_import.py111
-rw-r--r--src/mailman/commands/cli_info.py91
-rw-r--r--src/mailman/commands/cli_inject.py135
-rw-r--r--src/mailman/commands/cli_lists.py407
-rw-r--r--src/mailman/commands/cli_members.py349
-rw-r--r--src/mailman/commands/cli_qfile.py100
-rw-r--r--src/mailman/commands/cli_status.py55
-rw-r--r--src/mailman/commands/cli_unshunt.py59
-rw-r--r--src/mailman/commands/cli_version.py23
-rw-r--r--src/mailman/commands/cli_withlist.py404
-rw-r--r--src/mailman/commands/docs/aliases.rst38
-rw-r--r--src/mailman/commands/docs/conf.rst20
-rw-r--r--src/mailman/commands/docs/control.rst53
-rw-r--r--src/mailman/commands/docs/create.rst131
-rw-r--r--src/mailman/commands/docs/import.rst59
-rw-r--r--src/mailman/commands/docs/info.rst17
-rw-r--r--src/mailman/commands/docs/inject.rst126
-rw-r--r--src/mailman/commands/docs/lists.rst50
-rw-r--r--src/mailman/commands/docs/members.rst166
-rw-r--r--src/mailman/commands/docs/qfile.rst20
-rw-r--r--src/mailman/commands/docs/remove.rst33
-rw-r--r--src/mailman/commands/docs/shell.rst (renamed from src/mailman/commands/docs/withlist.rst)106
-rw-r--r--src/mailman/commands/docs/status.rst17
-rw-r--r--src/mailman/commands/docs/unshunt.rst15
-rw-r--r--src/mailman/commands/docs/version.rst9
-rw-r--r--src/mailman/commands/tests/data/__init__.py0
-rw-r--r--src/mailman/commands/tests/data/no-runners.cfg51
-rw-r--r--src/mailman/commands/tests/test_cli_conf.py74
-rw-r--r--src/mailman/commands/tests/test_cli_control.py295
-rw-r--r--src/mailman/commands/tests/test_cli_create.py116
-rw-r--r--src/mailman/commands/tests/test_cli_digests.py (renamed from src/mailman/commands/tests/test_digests.py)128
-rw-r--r--src/mailman/commands/tests/test_cli_import.py (renamed from src/mailman/commands/tests/test_import.py)46
-rw-r--r--src/mailman/commands/tests/test_cli_inject.py66
-rw-r--r--src/mailman/commands/tests/test_cli_lists.py (renamed from src/mailman/commands/tests/test_lists.py)32
-rw-r--r--src/mailman/commands/tests/test_cli_members.py (renamed from src/mailman/commands/tests/test_members.py)96
-rw-r--r--src/mailman/commands/tests/test_cli_qfile.py59
-rw-r--r--src/mailman/commands/tests/test_cli_shell.py173
-rw-r--r--src/mailman/commands/tests/test_cli_status.py64
-rw-r--r--src/mailman/commands/tests/test_cli_unshunt.py45
-rw-r--r--src/mailman/commands/tests/test_conf.py108
-rw-r--r--src/mailman/commands/tests/test_control.py244
-rw-r--r--src/mailman/commands/tests/test_create.py110
-rw-r--r--src/mailman/commands/tests/test_eml_confirm.py (renamed from src/mailman/commands/tests/test_confirm.py)0
-rw-r--r--src/mailman/commands/tests/test_eml_help.py (renamed from src/mailman/commands/tests/test_help.py)0
-rw-r--r--src/mailman/commands/tests/test_eml_membership.py (renamed from src/mailman/commands/tests/test_membership.py)0
-rw-r--r--src/mailman/commands/tests/test_shell.py81
51 files changed, 2486 insertions, 2544 deletions
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))