Barry Warsaw pushed to branch master at mailman / Mailman
Commits: a11e089c by Barry Warsaw at 2015-12-22T23:14:44Z The ``mailman members`` command can now be used to display members based on subscription roles. Also, the positional "list" argument can now accept list names or list-ids. - - - - - 6 changed files: - − port_me/list_owners.py - src/mailman/commands/cli_members.py - src/mailman/commands/docs/members.rst - + src/mailman/commands/tests/test_members.py - src/mailman/docs/NEWS.rst - src/mailman/model/roster.py Changes: ===================================== port_me/list_owners.py deleted ===================================== --- a/port_me/list_owners.py +++ /dev/null @@ -1,90 +0,0 @@ -# Copyright (C) 2002-2015 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/>. - -import sys -import optparse - -from zope.component import getUtility - -from mailman.MailList import MailList -from mailman.core.i18n import _ -from mailman.initialize import initialize -from mailman.interfaces.listmanager import IListManager -from mailman.version import MAILMAN_VERSION - - - -def parseargs(): - parser = optparse.OptionParser(version=MAILMAN_VERSION, - usage=_("""\ -%prog [options] [listname ...] - -List the owners of a mailing list, or all mailing lists if no list names are -given.""")) - parser.add_option('-w', '--with-listnames', - default=False, action='store_true', - help=_("""\ -Group the owners by list names and include the list names in the output. -Otherwise, the owners will be sorted and uniquified based on the email -address.""")) - parser.add_option('-m', '--moderators', - default=False, action='store_true', - help=_('Include the list moderators in the output.')) - parser.add_option('-C', '--config', - help=_('Alternative configuration file to use')) - opts, args = parser.parse_args() - return parser, opts, args - - - -def main(): - parser, opts, args = parseargs() - initialize(opts.config) - - list_manager = getUtility(IListManager) - listnames = set(args or list_manager.names) - bylist = {} - - for listname in listnames: - mlist = list_manager.get(listname) - addrs = [addr.address for addr in mlist.owners.addresses] - if opts.moderators: - addrs.extend([addr.address for addr in mlist.moderators.addresses]) - bylist[listname] = addrs - - if opts.with_listnames: - for listname in listnames: - unique = set() - for addr in bylist[listname]: - unique.add(addr) - keys = list(unique) - keys.sort() - print listname - for k in keys: - print '\t', k - else: - unique = set() - for listname in listnames: - for addr in bylist[listname]: - unique.add(addr) - for k in sorted(unique): - print k - - - -if __name__ == '__main__': - main() ===================================== src/mailman/commands/cli_members.py ===================================== --- a/src/mailman/commands/cli_members.py +++ b/src/mailman/commands/cli_members.py @@ -23,8 +23,8 @@ __all__ = [ import sys -import codecs +from contextlib import ExitStack from email.utils import formataddr, parseaddr from mailman.app.membership import add_member from mailman.core.i18n import _ @@ -32,7 +32,7 @@ from mailman.database.transaction import transactional from mailman.interfaces.command import ICLISubCommand from mailman.interfaces.listmanager import IListManager from mailman.interfaces.member import ( - AlreadySubscribedError, DeliveryMode, DeliveryStatus) + AlreadySubscribedError, DeliveryMode, DeliveryStatus, MemberRole) from mailman.interfaces.subscriptions import RequestRecord from operator import attrgetter from zope.component import getUtility @@ -63,6 +63,15 @@ class Members: 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.')) @@ -89,20 +98,26 @@ class Members: was disabled for unknown (legacy) reasons.""")) # Required positional argument. command_parser.add_argument( - 'listname', metavar='LISTNAME', nargs=1, + 'list', metavar='LIST', 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.""")) + 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.listname) == 1, 'Missing mailing list name' - fqdn_listname = args.listname[0] - mlist = getUtility(IListManager).get(fqdn_listname) + 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: $fqdn_listname')) + self.parser.error(_('No such list: $list_spec')) if args.input_filename is None: self.display_members(mlist, args) else: @@ -116,10 +131,6 @@ class Members: :param args: The command line arguments. :type args: `argparse.Namespace` """ - if args.output_filename == '-' or args.output_filename is None: - fp = sys.stdout - else: - fp = codecs.open(args.output_filename, 'w', 'utf-8') if args.digest == 'any': digest_types = [DeliveryMode.plaintext_digests, DeliveryMode.mime_digests, @@ -129,6 +140,7 @@ class Members: else: # Don't filter on digest type. pass + if args.nomail is None: # Don't filter on delivery status. pass @@ -146,31 +158,49 @@ class Members: DeliveryStatus.by_moderator, DeliveryStatus.unknown] else: - raise AssertionError('Unknown delivery status: %s' % args.nomail) - try: - addresses = list(mlist.members.addresses) + status = args.nomail + self.parser.error(_('Unknown delivery status: $status')) + + 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: + role = args.role + self.parser.error(_('Unknown member role: $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.fqdn_listname, 'has no members', file=fp) + print(_('$mlist.list_id has no members'), file=fp) return for address in sorted(addresses, key=attrgetter('email')): if args.regular: - member = mlist.members.get_member(address.email) + member = roster.get_member(address.email) if member.delivery_mode != DeliveryMode.regular: continue if args.digest is not None: - member = mlist.members.get_member(address.email) + member = roster.get_member(address.email) if member.delivery_mode not in digest_types: continue if args.nomail is not None: - member = mlist.members.get_member(address.email) + 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) - finally: - if fp is not sys.stdout: - fp.close() @transactional def add_members(self, mlist, args): @@ -181,11 +211,12 @@ class Members: :param args: The command line arguments. :type args: `argparse.Namespace` """ - if args.input_filename == '-': - fp = sys.stdin - else: - fp = codecs.open(args.input_filename, 'r', 'utf-8') - try: + 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: @@ -200,8 +231,8 @@ class Members: except AlreadySubscribedError: # It's okay if the address is already subscribed, just # print a warning and continue. - print('Already subscribed (skipping):', - email, display_name) - finally: - if fp is not sys.stdin: - fp.close() + if not display_name: + print(_('Already subscribed (skipping): $email')) + else: + print(_('Already subscribed (skipping): ' + '$display_name <$email>')) ===================================== src/mailman/commands/docs/members.rst ===================================== --- a/src/mailman/commands/docs/members.rst +++ b/src/mailman/commands/docs/members.rst @@ -6,15 +6,16 @@ The ``mailman members`` command allows a site administrator to display, add, and remove members from a mailing list. :: - >>> mlist1 = create_list('te...@example.com') + >>> ant = create_list('a...@example.com') >>> class FakeArgs: ... input_filename = None ... output_filename = None - ... listname = [] + ... list = [] ... regular = False ... digest = None ... nomail = None + ... role = None >>> args = FakeArgs() >>> from mailman.commands.cli_members import Members @@ -27,19 +28,18 @@ 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.listname = [mlist1.fqdn_listname] + >>> args.list = ['ant.example.com'] >>> command.process(args) - te...@example.com has no members + ant.example.com has no members Once the mailing list add some members, they will be displayed. -:: >>> from mailman.testing.helpers import subscribe - >>> subscribe(mlist1, 'Anne', email='a...@example.com') - <Member: Anne Person <a...@example.com> on te...@example.com + >>> subscribe(ant, 'Anne', email='a...@example.com') + <Member: Anne Person <a...@example.com> on a...@example.com as MemberRole.member> - >>> subscribe(mlist1, 'Bart', email='b...@example.com') - <Member: Bart Person <b...@example.com> on te...@example.com + >>> subscribe(ant, 'Bart', email='b...@example.com') + <Member: Bart Person <b...@example.com> on a...@example.com as MemberRole.member> >>> command.process(args) Anne Person <a...@example.com> @@ -48,8 +48,8 @@ Once the mailing list add some members, they will be displayed. Members are displayed in alphabetical order based on their address. :: - >>> subscribe(mlist1, 'Anne', email='a...@aaaxample.com') - <Member: Anne Person <a...@aaaxample.com> on te...@example.com + >>> subscribe(ant, 'Anne', email='a...@aaaxample.com') + <Member: Anne Person <a...@aaaxample.com> on a...@example.com as MemberRole.member> >>> command.process(args) Anne Person <a...@aaaxample.com> @@ -58,17 +58,15 @@ Members are displayed in alphabetical order based on their address. You can also output this list to a file. - >>> from tempfile import mkstemp - >>> fd, args.output_filename = mkstemp() - >>> import os - >>> os.close(fd) - >>> command.process(args) - >>> with open(args.output_filename) as fp: - ... print(fp.read()) + >>> 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()) Anne Person <a...@aaaxample.com> Anne Person <a...@example.com> Bart Person <b...@example.com> - >>> os.remove(args.output_filename) >>> args.output_filename = None The output file can also be standard out. @@ -88,7 +86,7 @@ You can limit output to just the regular non-digest members... >>> from mailman.interfaces.member import DeliveryMode >>> args.regular = True - >>> member = mlist1.members.get_member('a...@example.com') + >>> member = ant.members.get_member('a...@example.com') >>> member.preferences.delivery_mode = DeliveryMode.plaintext_digests >>> command.process(args) Anne Person <a...@aaaxample.com> @@ -97,7 +95,7 @@ You can limit output to just the regular non-digest members... ...or just the digest members. Furthermore, you can either display all digest members... - >>> member = mlist1.members.get_member('a...@aaaxample.com') + >>> member = ant.members.get_member('a...@aaaxample.com') >>> member.preferences.delivery_mode = DeliveryMode.mime_digests >>> args.regular = False >>> args.digest = 'any' @@ -132,16 +130,16 @@ status is enabled... >>> from mailman.interfaces.member import DeliveryStatus - >>> member = mlist1.members.get_member('a...@aaaxample.com') + >>> member = ant.members.get_member('a...@aaaxample.com') >>> member.preferences.delivery_status = DeliveryStatus.by_moderator - >>> member = mlist1.members.get_member('b...@example.com') + >>> member = ant.members.get_member('b...@example.com') >>> member.preferences.delivery_status = DeliveryStatus.by_user - >>> member = subscribe(mlist1, 'Cris', email='c...@example.com') + >>> member = subscribe(ant, 'Cris', email='c...@example.com') >>> member.preferences.delivery_status = DeliveryStatus.unknown - >>> member = subscribe(mlist1, 'Dave', email='d...@example.com') + >>> member = subscribe(ant, 'Dave', email='d...@example.com') >>> member.preferences.delivery_status = DeliveryStatus.enabled - >>> member = subscribe(mlist1, 'Elle', email='e...@example.com') + >>> member = subscribe(ant, 'Elle', email='e...@example.com') >>> member.preferences.delivery_status = DeliveryStatus.by_bounces >>> args.nomail = 'enabled' @@ -195,23 +193,20 @@ need a file containing email addresses and full names that can be parsed by ``email.utils.parseaddr()``. :: - >>> mlist2 = create_list('te...@example.com') - - >>> import os - >>> path = os.path.join(config.VAR_DIR, 'addresses.txt') - >>> with open(path, 'w') as fp: + >>> bee = create_list('b...@example.com') + >>> with NamedTemporaryFile('w', buffering=1, encoding='utf-8') as fp: ... for address in ('aper...@example.com', ... 'Bart Person <bper...@example.com>', ... 'cper...@example.com (Cate Person)', ... ): ... print(address, file=fp) - - >>> args.input_filename = path - >>> args.listname = [mlist2.fqdn_listname] - >>> command.process(args) + ... fp.flush() + ... args.input_filename = fp.name + ... args.list = ['bee.example.com'] + ... command.process(args) >>> from operator import attrgetter - >>> dump_list(mlist2.members.addresses, key=attrgetter('email')) + >>> dump_list(bee.members.addresses, key=attrgetter('email')) aper...@example.com Bart Person <bper...@example.com> Cate Person <cper...@example.com> @@ -227,15 +222,17 @@ taken from standard input. ... 'fper...@example.com (Fred Person)', ... ): ... print(address, file=fp) + >>> args.input_filename = '-' >>> filepos = fp.seek(0) >>> import sys - >>> sys.stdin = fp - - >>> args.input_filename = '-' - >>> command.process(args) - >>> sys.stdin = sys.__stdin__ - - >>> dump_list(mlist2.members.addresses, key=attrgetter('email')) + >>> try: + ... stdin = sys.stdin + ... sys.stdin = fp + ... command.process(args) + ... finally: + ... sys.stdin = stdin + + >>> dump_list(bee.members.addresses, key=attrgetter('email')) aper...@example.com Bart Person <bper...@example.com> Cate Person <cper...@example.com> @@ -246,7 +243,7 @@ taken from standard input. Blank lines and lines that begin with '#' are ignored. :: - >>> with open(path, 'w') as fp: + >>> with NamedTemporaryFile('w', buffering=1, encoding='utf-8') as fp: ... for address in ('gper...@example.com', ... '# hper...@example.com', ... ' ', @@ -254,10 +251,10 @@ Blank lines and lines that begin with '#' are ignored. ... 'iper...@example.com', ... ): ... print(address, file=fp) + ... args.input_filename = fp.name + ... command.process(args) - >>> args.input_filename = path - >>> command.process(args) - >>> dump_list(mlist2.members.addresses, key=attrgetter('email')) + >>> dump_list(bee.members.addresses, key=attrgetter('email')) aper...@example.com Bart Person <bper...@example.com> Cate Person <cper...@example.com> @@ -271,18 +268,18 @@ Addresses which are already subscribed are ignored, although a warning is printed. :: - >>> with open(path, 'w') as fp: + >>> with NamedTemporaryFile('w', buffering=1, encoding='utf-8') as fp: ... for address in ('gper...@example.com', ... 'aper...@example.com', ... 'jper...@example.com', ... ): ... print(address, file=fp) - - >>> command.process(args) + ... args.input_filename = fp.name + ... command.process(args) Already subscribed (skipping): gper...@example.com Already subscribed (skipping): aper...@example.com - >>> dump_list(mlist2.members.addresses, key=attrgetter('email')) + >>> dump_list(bee.members.addresses, key=attrgetter('email')) aper...@example.com Bart Person <bper...@example.com> Cate Person <cper...@example.com> ===================================== src/mailman/commands/tests/test_members.py ===================================== --- /dev/null +++ b/src/mailman/commands/tests/test_members.py @@ -0,0 +1,153 @@ +# Copyright (C) 2015 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 members` command.""" + +__all__ = [ + 'TestCLIMembers', + ] + + +import sys +import unittest + +from functools import partial +from io import StringIO +from mailman.app.lifecycle import create_list +from mailman.commands.cli_members import Members +from mailman.interfaces.member import MemberRole +from mailman.testing.helpers import subscribe +from mailman.testing.layers import ConfigLayer +from tempfile import NamedTemporaryFile +from unittest.mock import patch + + + +class FakeArgs: + input_filename = None + output_filename = None + role = None + regular = None + digest = None + nomail = None + list = None + + +class FakeParser: + def __init__(self): + self.message = None + + def error(self, message): + self.message = message + sys.exit(1) + + + +class TestCLIMembers(unittest.TestCase): + layer = ConfigLayer + + def setUp(self): + self._mlist = create_list('a...@example.com') + self.command = Members() + self.command.parser = FakeParser() + self.args = FakeArgs() + + def test_no_such_list(self): + self.args.list = ['bee.example.com'] + with self.assertRaises(SystemExit): + self.command.process(self.args) + self.assertEqual(self.command.parser.message, + 'No such list: bee.example.com') + + def test_bad_delivery_status(self): + self.args.list = ['ant.example.com'] + self.args.nomail = 'bogus' + with self.assertRaises(SystemExit): + self.command.process(self.args) + self.assertEqual(self.command.parser.message, + 'Unknown delivery status: bogus') + + def test_role_administrator(self): + subscribe(self._mlist, 'Anne', role=MemberRole.owner) + subscribe(self._mlist, 'Bart', role=MemberRole.moderator) + subscribe(self._mlist, 'Cate', role=MemberRole.nonmember) + subscribe(self._mlist, 'Dave', role=MemberRole.member) + self.args.list = ['ant.example.com'] + self.args.role = 'administrator' + with NamedTemporaryFile('w', encoding='utf-8') as outfp: + self.args.output_filename = outfp.name + self.command.process(self.args) + with open(outfp.name, 'r', encoding='utf-8') as infp: + lines = infp.readlines() + self.assertEqual(len(lines), 2) + self.assertEqual(lines[0], 'Anne Person <aper...@example.com>\n') + self.assertEqual(lines[1], 'Bart Person <bper...@example.com>\n') + + def test_role_any(self): + subscribe(self._mlist, 'Anne', role=MemberRole.owner) + subscribe(self._mlist, 'Bart', role=MemberRole.moderator) + subscribe(self._mlist, 'Cate', role=MemberRole.nonmember) + subscribe(self._mlist, 'Dave', role=MemberRole.member) + self.args.list = ['ant.example.com'] + self.args.role = 'any' + with NamedTemporaryFile('w', encoding='utf-8') as outfp: + self.args.output_filename = outfp.name + self.command.process(self.args) + with open(outfp.name, 'r', encoding='utf-8') as infp: + lines = infp.readlines() + self.assertEqual(len(lines), 4) + self.assertEqual(lines[0], 'Anne Person <aper...@example.com>\n') + self.assertEqual(lines[1], 'Bart Person <bper...@example.com>\n') + self.assertEqual(lines[2], 'Cate Person <cper...@example.com>\n') + self.assertEqual(lines[3], 'Dave Person <dper...@example.com>\n') + + def test_role_moderator(self): + subscribe(self._mlist, 'Anne', role=MemberRole.owner) + subscribe(self._mlist, 'Bart', role=MemberRole.moderator) + subscribe(self._mlist, 'Cate', role=MemberRole.nonmember) + subscribe(self._mlist, 'Dave', role=MemberRole.member) + self.args.list = ['ant.example.com'] + self.args.role = 'moderator' + with NamedTemporaryFile('w', encoding='utf-8') as outfp: + self.args.output_filename = outfp.name + self.command.process(self.args) + with open(outfp.name, 'r', encoding='utf-8') as infp: + lines = infp.readlines() + self.assertEqual(len(lines), 1) + self.assertEqual(lines[0], 'Bart Person <bper...@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_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 <aper...@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) + self.assertEqual( + outfp.getvalue(), + 'Already subscribed (skipping): Anne Person <aper...@example.com>\n' + ) ===================================== src/mailman/docs/NEWS.rst ===================================== --- a/src/mailman/docs/NEWS.rst +++ b/src/mailman/docs/NEWS.rst @@ -144,6 +144,9 @@ Other * The mailing list "data directory" has been renamed. Instead of using the fqdn listname, the subdirectory inside ``[paths]list_data_dir`` now uses the List-ID. + * The ``mailman members`` command can now be used to display members based on + subscription roles. Also, the positional "list" argument can now accept + list names or list-ids. 3.0.0 -- "Show Don't Tell" ===================================== src/mailman/model/roster.py ===================================== --- a/src/mailman/model/roster.py +++ b/src/mailman/model/roster.py @@ -39,7 +39,7 @@ from mailman.interfaces.member import DeliveryMode, MemberRole from mailman.interfaces.roster import IRoster from mailman.model.address import Address from mailman.model.member import Member -from sqlalchemy import and_, or_ +from sqlalchemy import or_ from zope.interface import implementer View it on GitLab: https://gitlab.com/mailman/mailman/commit/a11e089cc1e0e5aff2502e584014295a414a43f9
_______________________________________________ Mailman-checkins mailing list Mailman-checkins@python.org Unsubscribe: https://mail.python.org/mailman/options/mailman-checkins/archive%40jab.org