diff options
| author | Barry Warsaw | 2012-12-28 16:35:08 -0500 |
|---|---|---|
| committer | Barry Warsaw | 2012-12-28 16:35:08 -0500 |
| commit | 582d6e486f9693a2ce082071b747eec468df19b6 (patch) | |
| tree | c25dcc945f161c6d44154c20de72793702be313e | |
| parent | 2b663ac20e7898167cf242d3628f8cf0feeab12f (diff) | |
| download | mailman-582d6e486f9693a2ce082071b747eec468df19b6.tar.gz mailman-582d6e486f9693a2ce082071b747eec468df19b6.tar.zst mailman-582d6e486f9693a2ce082071b747eec468df19b6.zip | |
23 files changed, 294 insertions, 366 deletions
diff --git a/buildout.cfg b/buildout.cfg index ef5d619d2..594e6ea5a 100644 --- a/buildout.cfg +++ b/buildout.cfg @@ -4,7 +4,7 @@ parts = tags test unzip = true -develop = . +develop = . /home/barry/projects/mailman/zope.interface [interpreter] recipe = z3c.recipe.scripts diff --git a/src/mailman/app/commands.py b/src/mailman/app/commands.py index 70f0a8fa6..ad0b9d4a0 100644 --- a/src/mailman/app/commands.py +++ b/src/mailman/app/commands.py @@ -27,9 +27,9 @@ __all__ = [ from zope.interface.verify import verifyObject -from mailman.app.finder import find_components from mailman.config import config from mailman.interfaces.command import IEmailCommand +from mailman.utilities.modules import find_components diff --git a/src/mailman/app/docs/lifecycle.rst b/src/mailman/app/docs/lifecycle.rst index f6bb7ddae..ee7185373 100644 --- a/src/mailman/app/docs/lifecycle.rst +++ b/src/mailman/app/docs/lifecycle.rst @@ -12,72 +12,18 @@ which performs additional tasks such as: * validating the list's posting address (which also serves as the list's fully qualified name); * ensuring that the list's domain is registered; - * applying all matching styles to the new list; + * :ref:`applying a list style <list-creation-styles>` to the new list; * creating and assigning list owners; * notifying watchers of list creation; * creating ancillary artifacts (such as the list's on-disk directory) -Posting address validation -========================== - -If you try to use the higher-level interface to create a mailing list with a -bogus posting address, you get an exception. - - >>> create_list('not a valid address') - Traceback (most recent call last): - ... - InvalidEmailAddressError: not a valid address - -If the posting address is valid, but the domain has not been registered with -Mailman yet, you get an exception. - - >>> create_list('test@example.org') - Traceback (most recent call last): - ... - BadDomainSpecificationError: example.org - - -Creating a list applies its styles -================================== - -Start by registering a test style. -:: - - >>> from zope.interface import implementer - >>> from mailman.interfaces.styles import IStyle - >>> @implementer(IStyle) - ... class TestStyle(object): - ... name = 'test' - ... priority = 10 - ... def apply(self, mailing_list): - ... # Just does something very simple. - ... mailing_list.msg_footer = 'test footer' - ... def match(self, mailing_list, styles): - ... # Applies to any test list - ... if 'test' in mailing_list.fqdn_listname: - ... styles.append(self) - - >>> from zope.component import getUtility - >>> from mailman.interfaces.styles import IStyleManager - >>> getUtility(IStyleManager).register(TestStyle()) - -Using the higher level interface for creating a list, applies all matching -list styles. - - >>> mlist_1 = create_list('test_1@example.com') - >>> print mlist_1.fqdn_listname - test_1@example.com - >>> print mlist_1.msg_footer - test footer - - Creating a list with owners =========================== You can also specify a list of owner email addresses. If these addresses are not yet known, they will be registered, and new users will be linked to them. -However the addresses are not verified. +:: >>> owners = [ ... 'aperson@example.com', @@ -85,12 +31,9 @@ However the addresses are not verified. ... 'cperson@example.com', ... 'dperson@example.com', ... ] - >>> mlist_2 = create_list('test_2@example.com', owners) - >>> print mlist_2.fqdn_listname - test_2@example.com - >>> print mlist_2.msg_footer - test footer - >>> dump_list(address.email for address in mlist_2.owners.addresses) + + >>> ant = create_list('ant@example.com', owners) + >>> dump_list(address.email for address in ant.owners.addresses) aperson@example.com bperson@example.com cperson@example.com @@ -99,37 +42,33 @@ However the addresses are not verified. None of the owner addresses are verified. >>> any(address.verified_on is not None - ... for address in mlist_2.owners.addresses) + ... for address in ant.owners.addresses) False However, all addresses are linked to users. - >>> # The owners have no names yet - >>> len(list(mlist_2.owners.users)) - 4 - -If you create a mailing list with owner addresses that are already known to -the system, they won't be created again. -:: - >>> from mailman.interfaces.usermanager import IUserManager + >>> from zope.component import getUtility >>> user_manager = getUtility(IUserManager) + >>> for address in owners: + ... user = user_manager.get_user(address) + ... print int(user.user_id.int), list(user.addresses)[0] + 1 aperson@example.com + 2 bperson@example.com + 3 cperson@example.com + 4 dperson@example.com - >>> user_a = user_manager.get_user('aperson@example.com') - >>> user_b = user_manager.get_user('bperson@example.com') - >>> user_c = user_manager.get_user('cperson@example.com') - >>> user_d = user_manager.get_user('dperson@example.com') - >>> user_a.display_name = 'Anne Person' - >>> user_b.display_name = 'Bart Person' - >>> user_c.display_name = 'Caty Person' - >>> user_d.display_name = 'Dirk Person' +If you create a mailing list with owner addresses that are already known to +the system, they won't be created again. - >>> mlist_3 = create_list('test_3@example.com', owners) - >>> dump_list(user.display_name for user in mlist_3.owners.users) - Anne Person - Bart Person - Caty Person - Dirk Person + >>> bee = create_list('bee@example.com', owners) + >>> from operator import attrgetter + >>> for user in sorted(bee.owners.users, key=attrgetter('user_id')): + ... print int(user.user_id.int), list(user.addresses)[0] + 1 aperson@example.com + 2 bperson@example.com + 3 cperson@example.com + 4 dperson@example.com Deleting a list @@ -140,17 +79,16 @@ artifacts. :: >>> from mailman.app.lifecycle import remove_list - >>> remove_list(mlist_2) + >>> remove_list(bee) >>> from mailman.interfaces.listmanager import IListManager - >>> from zope.component import getUtility - >>> print getUtility(IListManager).get('test_2@example.com') + >>> print getUtility(IListManager).get('bee@example.com') None We should now be able to completely recreate the mailing list. - >>> mlist_2a = create_list('test_2@example.com', owners) - >>> dump_list(address.email for address in mlist_2a.owners.addresses) + >>> buzz = create_list('bee@example.com', owners) + >>> dump_list(address.email for address in bee.owners.addresses) aperson@example.com bperson@example.com cperson@example.com diff --git a/src/mailman/app/finder.py b/src/mailman/app/finder.py deleted file mode 100644 index 679da483a..000000000 --- a/src/mailman/app/finder.py +++ /dev/null @@ -1,78 +0,0 @@ -# Copyright (C) 2009-2012 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/>. - -"""Find various kinds of object in package space.""" - -from __future__ import absolute_import, unicode_literals - -__metaclass__ = type -__all__ = [ - 'find_components', - 'scan_module', - ] - - -import os -import sys - -from pkg_resources import resource_listdir - - - -def scan_module(module, interface): - """Return all the items in a module that conform to an interface. - - :param module: A module object. The module's `__all__` will be scanned. - :type module: module - :param interface: The interface that returned objects must conform to. - :type interface: `Interface` - :return: The sequence of matching components. - :rtype: objects implementing `interface` - """ - missing = object() - for name in module.__all__: - component = getattr(module, name, missing) - assert component is not missing, ( - '%s has bad __all__: %s' % (module, name)) - if interface.implementedBy(component): - yield component - - -def find_components(package, interface): - """Find components which conform to a given interface. - - Search all the modules in a given package, returning an iterator over all - objects found that conform to the given interface. - - :param package: The package path to search. - :type package: string - :param interface: The interface that returned objects must conform to. - :type interface: `Interface` - :return: The sequence of matching components. - :rtype: objects implementing `interface` - """ - for filename in resource_listdir(package, ''): - basename, extension = os.path.splitext(filename) - if extension != '.py': - continue - module_name = '{0}.{1}'.format(package, basename) - __import__(module_name, fromlist='*') - module = sys.modules[module_name] - if not hasattr(module, '__all__'): - continue - for component in scan_module(module, interface): - yield component diff --git a/src/mailman/app/lifecycle.py b/src/mailman/app/lifecycle.py index 326498478..9dfa5a9a2 100644 --- a/src/mailman/app/lifecycle.py +++ b/src/mailman/app/lifecycle.py @@ -47,7 +47,7 @@ log = logging.getLogger('mailman.error') -def create_list(fqdn_listname, owners=None): +def create_list(fqdn_listname, owners=None, style_name=None): """Create the named list and apply styles. The mailing may not exist yet, but the domain specified in `fqdn_listname` @@ -57,6 +57,9 @@ def create_list(fqdn_listname, owners=None): :type fqdn_listname: string :param owners: The mailing list owners. :type owners: list of string email addresses + :param style_name: The name of the style to apply to the newly created + list. If not given, the default is taken from the configuration file. + :type style_name: string :return: The new mailing list. :rtype: `IMailingList` :raises BadDomainSpecificationError: when the hostname part of @@ -66,13 +69,16 @@ def create_list(fqdn_listname, owners=None): """ if owners is None: owners = [] - # This raises I + # This raises InvalidEmailAddressError if the address is not a valid + # posting address. Let these percolate up. getUtility(IEmailValidator).validate(fqdn_listname) listname, domain = fqdn_listname.split('@', 1) if domain not in getUtility(IDomainManager): raise BadDomainSpecificationError(domain) mlist = getUtility(IListManager).create(fqdn_listname) - for style in getUtility(IStyleManager).lookup(mlist): + style = getUtility(IStyleManager).get( + config.styles.default if style_name is None else style_name) + if style is not None: style.apply(mlist) # Coordinate with the MTA, as defined in the configuration file. call_name(config.mta.incoming).create(mlist) diff --git a/src/mailman/app/tests/test_lifecycle.py b/src/mailman/app/tests/test_lifecycle.py new file mode 100644 index 000000000..f0bcdad3f --- /dev/null +++ b/src/mailman/app/tests/test_lifecycle.py @@ -0,0 +1,50 @@ +# Copyright (C) 2012 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 high level list lifecycle API.""" + +from __future__ import absolute_import, print_function, unicode_literals + +__metaclass__ = type +__all__ = [ + 'TestLifecycle', + ] + + +import unittest + +from mailman.interfaces.address import InvalidEmailAddressError +from mailman.interfaces.domain import BadDomainSpecificationError +from mailman.app.lifecycle import create_list +from mailman.testing.layers import ConfigLayer + + + +class TestLifecycle(unittest.TestCase): + """Test the high level list lifecycle API.""" + + layer = ConfigLayer + + def test_posting_address_validation(self): + # Creating a mailing list with a bogus address raises an exception. + self.assertRaises(InvalidEmailAddressError, + create_list, 'bogus address') + + def test_unregistered_domain(self): + # Creating a list with an unregistered domain raises an exception. + self.assertRaises(BadDomainSpecificationError, + create_list, 'test@nodomain.example.org') diff --git a/src/mailman/app/tests/test_subscriptions.py b/src/mailman/app/tests/test_subscriptions.py index c4c8f2795..8771390c1 100644 --- a/src/mailman/app/tests/test_subscriptions.py +++ b/src/mailman/app/tests/test_subscriptions.py @@ -34,7 +34,6 @@ from mailman.app.lifecycle import create_list from mailman.interfaces.address import InvalidEmailAddressError from mailman.interfaces.subscriptions import ( MissingUserError, ISubscriptionService) -from mailman.testing.helpers import reset_the_world from mailman.testing.layers import ConfigLayer @@ -46,9 +45,6 @@ class TestJoin(unittest.TestCase): self._mlist = create_list('test@example.com') self._service = getUtility(ISubscriptionService) - def tearDown(self): - reset_the_world() - def test_join_user_with_bogus_id(self): # When `subscriber` is a missing user id, an exception is raised. with self.assertRaises(MissingUserError) as cm: diff --git a/src/mailman/bin/mailman.py b/src/mailman/bin/mailman.py index 6b15c9838..b3150148c 100644 --- a/src/mailman/bin/mailman.py +++ b/src/mailman/bin/mailman.py @@ -30,10 +30,10 @@ import argparse from zope.interface.verify import verifyObject -from mailman.app.finder import find_components from mailman.core.i18n import _ from mailman.core.initialize import initialize from mailman.interfaces.command import ICLISubCommand +from mailman.utilities.modules import find_components from mailman.version import MAILMAN_VERSION_FULL diff --git a/src/mailman/config/schema.cfg b/src/mailman/config/schema.cfg index 37322418a..ae4ba735e 100644 --- a/src/mailman/config/schema.cfg +++ b/src/mailman/config/schema.cfg @@ -577,12 +577,16 @@ configuration: python:mailman.config.mail_archive class: mailman.archiving.prototype.Prototype -[style.master] -# The style's priority, with 0 being the lowest priority. -priority: 0 +[styles] +# Python import paths inside which components are searched for which implement +# the IStyle interface. Use one path per line. +paths: + mailman.styles -# The class implementing the IStyle interface, which applies the style. -class: mailman.styles.default.DefaultStyle +# The default style to apply if nothing else was requested. The value is the +# name of an existing style. If no such style exists, no style will be +# applied. +default: default [digests] diff --git a/src/mailman/core/chains.py b/src/mailman/core/chains.py index 61807a6c0..a02cfa972 100644 --- a/src/mailman/core/chains.py +++ b/src/mailman/core/chains.py @@ -28,10 +28,10 @@ __all__ = [ from zope.interface.verify import verifyObject -from mailman.app.finder import find_components from mailman.chains.base import Chain, TerminalChainBase from mailman.config import config from mailman.interfaces.chain import LinkAction, IChain +from mailman.utilities.modules import find_components diff --git a/src/mailman/core/pipelines.py b/src/mailman/core/pipelines.py index 972417c2c..fe007b9e2 100644 --- a/src/mailman/core/pipelines.py +++ b/src/mailman/core/pipelines.py @@ -36,12 +36,13 @@ from zope.interface import implementer from zope.interface.verify import verifyObject from mailman.app.bounces import bounce_message -from mailman.app.finder import find_components from mailman.config import config from mailman.core import errors from mailman.core.i18n import _ from mailman.interfaces.handler import IHandler from mailman.interfaces.pipeline import IPipeline +from mailman.utilities.modules import find_components + dlog = logging.getLogger('mailman.debug') vlog = logging.getLogger('mailman.vette') diff --git a/src/mailman/core/rules.py b/src/mailman/core/rules.py index 6aac0a64b..8a7ce2b4a 100644 --- a/src/mailman/core/rules.py +++ b/src/mailman/core/rules.py @@ -27,9 +27,9 @@ __all__ = [ from zope.interface.verify import verifyObject -from mailman.app.finder import find_components from mailman.config import config from mailman.interfaces.rules import IRule +from mailman.utilities.modules import find_components diff --git a/src/mailman/database/schema/mm_20121015000000.py b/src/mailman/database/schema/mm_20121015000000.py index 51e0602e7..bfbf5cb62 100644 --- a/src/mailman/database/schema/mm_20121015000000.py +++ b/src/mailman/database/schema/mm_20121015000000.py @@ -18,6 +18,9 @@ """3.0b2 -> 3.0b3 schema migrations. * bans.mailing_list -> bans.list_id + +Added: +* mailinglist.style """ from __future__ import absolute_import, print_function, unicode_literals @@ -84,5 +87,6 @@ def upgrade_postgres(database, store, version, module_path): WHERE id = {1}; """.format(_make_listid(mailing_list), id)) store.execute('ALTER TABLE ban DROP COLUMN mailing_list;') + store.execute('ALTER TABLE mailinglist ADD COLUMN style_name;') # Record the migration in the version table. database.load_schema(store, version, None, module_path) diff --git a/src/mailman/database/schema/sqlite_20121015000000_01.sql b/src/mailman/database/schema/sqlite_20121015000000_01.sql index c0df75111..e9e3661e0 100644 --- a/src/mailman/database/schema/sqlite_20121015000000_01.sql +++ b/src/mailman/database/schema/sqlite_20121015000000_01.sql @@ -20,3 +20,4 @@ INSERT INTO ban_backup SELECT FROM ban; ALTER TABLE ban_backup ADD COLUMN list_id TEXT; +ALTER TABLE mailinglist ADD COLUMN style_name TEXT; diff --git a/src/mailman/database/tests/test_migrations.py b/src/mailman/database/tests/test_migrations.py index 4401b030f..e813d4341 100644 --- a/src/mailman/database/tests/test_migrations.py +++ b/src/mailman/database/tests/test_migrations.py @@ -24,6 +24,8 @@ __all__ = [ 'TestMigration20120407MigratedData', 'TestMigration20120407Schema', 'TestMigration20120407UnchangedData', + 'TestMigration20121015MigratedData', + 'TestMigration20121015Schema', ] @@ -151,8 +153,10 @@ class TestMigration20120407UnchangedData(MigrationTestBase): 'mailman.database.tests.data', 'migration_{0}_1.sql'.format(self._database.TAG)) self._database.load_sql(self._database.store, sample_data) - # Update to the current migration we're testing. - self._database.load_migrations('20120407000000') + # XXX 2012-12-28: We have to load the last migration defined in the + # system, otherwise the ORM model will not match the SQL table + # definitions and we'll get OperationalErrors from SQLite. + self._database.load_migrations('20121015000000') def test_migration_domains(self): # Test that the domains table, which isn't touched, doesn't change. @@ -216,8 +220,10 @@ class TestMigration20120407MigratedData(MigrationTestBase): self._database.load_sql(self._database.store, sample_data) def _upgrade(self): - # Update to the current migration we're testing. - self._database.load_migrations('20120407000000') + # XXX 2012-12-28: We have to load the last migration defined in the + # system, otherwise the ORM model will not match the SQL table + # definitions and we'll get OperationalErrors from SQLite. + self._database.load_migrations('20121015000000') def test_migration_archive_policy_never_0(self): # Test that the new archive_policy value is updated correctly. In the @@ -369,6 +375,10 @@ class TestMigration20121015Schema(MigrationTestBase): ['20121014999999'], ('list_id',), ('mailing_list',)) + self._missing_present('mailinglist', + ['20121014999999'], + ('style_name',), + ()) def test_post_upgrade_column_migrations(self): self._missing_present('ban', @@ -376,6 +386,11 @@ class TestMigration20121015Schema(MigrationTestBase): '20121015000000'], ('mailing_list',), ('list_id',)) + self._missing_present('mailinglist', + ['20121014999999', + '20121015000000'], + (), + ('style_name',)) class TestMigration20121015MigratedData(MigrationTestBase): diff --git a/src/mailman/interfaces/mailinglist.py b/src/mailman/interfaces/mailinglist.py index 8a4436a21..798680782 100644 --- a/src/mailman/interfaces/mailinglist.py +++ b/src/mailman/interfaces/mailinglist.py @@ -132,6 +132,9 @@ class IMailingList(Interface): """Advertise this mailing list when people ask for an overview of the available mailing lists.""") + style_name = Attribute( + """The name of the last style applied, or None.""") + # Contact addresses posting_address = Attribute( diff --git a/src/mailman/interfaces/styles.py b/src/mailman/interfaces/styles.py index 8dfd52145..4a7b69dcb 100644 --- a/src/mailman/interfaces/styles.py +++ b/src/mailman/interfaces/styles.py @@ -43,9 +43,6 @@ class IStyle(Interface): name = Attribute( """The name of this style. Must be unique.""") - priority = Attribute( - """The priority of this style, as an integer.""") - def apply(mailing_list): """Apply the style to the mailing list. @@ -53,50 +50,28 @@ class IStyle(Interface): :param mailing_list: the mailing list to apply the style to. """ - def match(mailing_list, styles): - """Give this style a chance to match the mailing list. - - If the style's internal matching rules match the `mailing_list`, then - the style may append itself to the `styles` list. This list will be - ordered when returned from `IStyleManager.lookup()`. - - :type mailing_list: `IMailingList`. - :param mailing_list: the mailing list object. - :param styles: ordered list of `IStyles` matched so far. - """ - - class IStyleManager(Interface): - """A manager of styles and style chains.""" + """A manager of styles.""" def get(name): """Return the named style or None. - :type name: Unicode + :type name: string :param name: A style name. :return: the named `IStyle` or None if the style doesn't exist. """ - def lookup(mailing_list): - """Return a list of styles for the given mailing list. - - Use various registered rules to find an `IStyle` for the given mailing - list. The returned styles are ordered by their priority. + styles = Attribute( + 'An iterator over all the styles known by this manager.') - Style matches can be registered and reordered by plugins. + def populate(): + """Populate the styles from the configuration files. - :type mailing_list: `IMailingList`. - :param mailing_list: The mailing list object to find a style for. - :return: ordered list of `IStyles`. Zero is the lowest priority. + This clears the current set of styles and resets them from those + defined in the configuration files. """ - styles = Attribute( - """An iterator over all the styles known by this manager. - - Styles are ordered by their priority, which may be changed. - """) - def register(style): """Register a style with this manager. @@ -111,10 +86,3 @@ class IStyleManager(Interface): :param style: an IStyle. :raises KeyError: If the style's name is not currently registered. """ - - def populate(): - """Populate the styles from the configuration files. - - This clears the current set of styles and resets them from those - defined in the configuration files. - """ diff --git a/src/mailman/model/mailinglist.py b/src/mailman/model/mailinglist.py index 324e1a1c8..a78745936 100644 --- a/src/mailman/model/mailinglist.py +++ b/src/mailman/model/mailinglist.py @@ -87,6 +87,7 @@ class MailingList(Model): include_rfc2369_headers = Bool() advertised = Bool() anonymous_list = Bool() + style_name = Unicode() # Attributes not directly modifiable via the web u/i created_at = DateTime() admin_member_chunksize = Int() diff --git a/src/mailman/runners/tests/test_bounce.py b/src/mailman/runners/tests/test_bounce.py index 5c21000bf..54bbdba4f 100644 --- a/src/mailman/runners/tests/test_bounce.py +++ b/src/mailman/runners/tests/test_bounce.py @@ -243,15 +243,11 @@ class TestStyle: """See `IStyle`.""" name = 'test' - priority = 10 def apply(self, mailing_list): """See `IStyle`.""" mailing_list.preferred_language = 'en' - def match(self, mailing_list, styles): - styles.append(self) - class TestBounceRunnerBug876774(unittest.TestCase): @@ -271,7 +267,7 @@ class TestBounceRunnerBug876774(unittest.TestCase): self._style_manager = getUtility(IStyleManager) self._style_manager.register(self._style) # Now we can create the mailing list. - self._mlist = create_list('test@example.com') + self._mlist = create_list('test@example.com', style_name='test') self._bounceq = config.switchboards['bounces'] self._processor = getUtility(IBounceProcessor) self._runner = make_testable_runner(BounceRunner, 'bounces') diff --git a/src/mailman/styles/default.py b/src/mailman/styles/default.py index cb4da396d..138d49872 100644 --- a/src/mailman/styles/default.py +++ b/src/mailman/styles/default.py @@ -47,7 +47,6 @@ class DefaultStyle: """The default (i.e. legacy) style.""" name = 'default' - priority = 0 # the lowest priority style def apply(self, mailing_list): """See `IStyle`.""" @@ -55,6 +54,7 @@ class DefaultStyle: mlist = mailing_list # List identity. mlist.display_name = mlist.list_name.capitalize() + mlist.style_name = self.name mlist.include_rfc2369_headers = True mlist.allow_list_posts = True # Most of these were ripped from the old MailList.InitVars() method. @@ -217,9 +217,3 @@ from: .*@uplinkpro.com mlist.owner_chain = 'default-owner-chain' # The default pipeline to send -owner email through. mlist.owner_pipeline = 'default-owner-pipeline' - - def match(self, mailing_list, styles): - """See `IStyle`.""" - # If no other styles have matched, then the default style matches. - if len(styles) == 0: - styles.append(self) diff --git a/src/mailman/styles/docs/styles.rst b/src/mailman/styles/docs/styles.rst index 8f589f10b..ccacbdb88 100644 --- a/src/mailman/styles/docs/styles.rst +++ b/src/mailman/styles/docs/styles.rst @@ -3,58 +3,37 @@ List styles =========== List styles are a way to name and apply a template of attribute settings to -new mailing lists. Every style has a name, which must be unique within the -context of a specific style manager. There is usually only one global style -manager. +new mailing lists. Every style has a name, which must be unique. -Styles also have a priority, which allows you to specify the order in which -multiple styles will be applied. A style has a `match` function which is used -to determine whether the style should be applied to a particular mailing list -or not. And finally, application of a style to a mailing list can really -modify the mailing list any way it wants. +Styles are generally only applied when a mailing list is created, although +there is no reason why styles can't be applied to an existing mailing list. +However, when a style changes, the mailing lists using that style are not +automatically updated. Instead, think of styles as the initial set of +defaults for just about any mailing list attribute. In fact, application of a +style to a mailing list can really modify the mailing list in any way. -Let's start with a vanilla mailing list and a default style manager. -:: +To start with, there is only one style, the default style. - >>> from mailman.interfaces.listmanager import IListManager >>> from zope.component import getUtility - >>> mlist = getUtility(IListManager).create('_xtest@example.com') - - >>> from mailman.styles.manager import StyleManager - >>> style_manager = StyleManager() - >>> style_manager.populate() - >>> styles = sorted(style.name for style in style_manager.styles) - >>> len(styles) - 1 - >>> print styles[0] + >>> from mailman.interfaces.styles import IStyleManager + >>> manager = getUtility(IStyleManager) + >>> for style in manager.styles: + ... print style.name default +When you create a mailing list through the low-level `IListManager` API, no +style is applied. -The default style -================= - -There is a default style which implements a legacy style roughly corresponding -to discussion mailing lists. This style matches when no other styles match, -and it has the lowest priority. The low priority means that it is matched -last and if it matches, it is applied last. - - >>> default_style = style_manager.get('default') - >>> print default_style.name - default - >>> default_style.priority - 0 + >>> from mailman.interfaces.listmanager import IListManager + >>> mlist = getUtility(IListManager).create('ant@example.com') + >>> print mlist.style_name + None -Given a mailing list, you can ask the style manager to find all the styles -that match the list. The registered styles will be sorted by decreasing -priority and each style's ``match()`` method will be called in turn. The -sorted list of matching styles will be returned -- but not applied -- by the -style manager's ``lookup()`` method. +By applying a style, the style name gets assigned. - >>> matched_styles = [style.name for style in style_manager.lookup(mlist)] - >>> len(matched_styles) - 1 - >>> print matched_styles[0] - default + >>> manager.get('default').apply(mlist) + >>> print mlist.list_id, mlist.style_name + ant.example.com default Registering styles @@ -66,82 +45,88 @@ New styles must implement the ``IStyle`` interface. >>> from mailman.interfaces.styles import IStyle >>> @implementer(IStyle) ... class TestStyle: - ... name = 'test' - ... priority = 10 + ... name = 'a-test-style' ... def apply(self, mailing_list): ... # Just does something very simple. + ... mailing_list.style_name = self.name ... mailing_list.style_thing = 'thing 1' - ... def match(self, mailing_list, styles): - ... # Applies to any test list - ... if 'test' in mailing_list.fqdn_listname: - ... styles.append(self) You can register a new style with the style manager. - >>> style_manager.register(TestStyle()) + >>> manager.register(TestStyle()) -And now if you look up matching styles, you should find only the new test -style. This is because the default style only gets applied when no other -styles match the mailing list. +All registered styles are returned in alphabetical order by style name. - >>> matched_styles = sorted( - ... style.name for style in style_manager.lookup(mlist)) - >>> len(matched_styles) - 1 - >>> print matched_styles[0] - test - >>> for style in style_manager.lookup(mlist): - ... style.apply(mlist) - >>> print mlist.style_thing - thing 1 + >>> for style in manager.styles: + ... print style.name + a-test-style + default +You can also ask the style manager for the style, by name. -Style priority -============== + >>> test_style = manager.get('a-test-style') + >>> print test_style.name + a-test-style -When multiple styles match a particular mailing list, they are applied in -descending order of priority. In other words, a priority zero style would be -applied last. -:: - >>> class AnotherTestStyle(TestStyle): - ... name = 'another' - ... priority = 5 - ... # Use the base class's match() method. - ... def apply(self, mailing_list): - ... mailing_list.style_thing = 'thing 2' +Unregistering styles +==================== - >>> mlist.style_thing = 'thing 0' - >>> print mlist.style_thing - thing 0 - >>> style_manager.register(AnotherTestStyle()) - >>> for style in style_manager.lookup(mlist): - ... style.apply(mlist) - >>> print mlist.style_thing - thing 2 +You can unregister a style, making it unavailable in the future. + + >>> manager.unregister(test_style) + >>> for style in manager.styles: + ... print style.name + default + +Asking for a missing style returns None. + + >>> print manager.get('a-test-style') + None + + +.. _list-creation-styles: + +Apply styles at list creation +============================= + +You can specify a style to apply when creating a list through the high-level +API. Let's start by registering the test style. + + >>> manager.register(test_style) + +Now, when we use the high level API, we can ask for the style to be applied. + + >>> from mailman.app.lifecycle import create_list + >>> mlist = create_list('bee@example.com', style_name=test_style.name) + >>> print mlist.list_id, mlist.style_name + bee.example.com a-test-style -You can change the priority of a style, and if you reapply the styles, they -will take effect in the new priority order. +The style has been applied. - >>> style_1 = style_manager.get('test') - >>> style_1.priority = 5 - >>> style_2 = style_manager.get('another') - >>> style_2.priority = 10 - >>> for style in style_manager.lookup(mlist): - ... style.apply(mlist) >>> print mlist.style_thing thing 1 +If no style name is provided when creating the list, the system default style +(which may or may not be the style named 'default') is applied. -Unregistering styles -==================== + >>> @implementer(IStyle) + ... class AnotherStyle: + ... name = 'another-style' + ... def apply(self, mailing_list): + ... # Just does something very simple. + ... mailing_list.style_name = self.name + ... mailing_list.style_thing = 'thing 2' + >>> another_style = AnotherStyle() -You can unregister a style, making it unavailable in the future. +We'll set up the system default to apply this newly registered style if no +other style is explicitly given. - >>> style_manager.unregister(style_2) - >>> matched_styles = sorted( - ... style.name for style in style_manager.lookup(mlist)) - >>> len(matched_styles) - 1 - >>> print matched_styles[0] - test + >>> from mailman.testing.helpers import configuration + >>> with configuration('styles', default=another_style.name): + ... manager.register(another_style) + ... mlist = create_list('cat@example.com') + >>> print mlist.style_name + another-style + >>> print mlist.style_thing + thing 2 diff --git a/src/mailman/styles/manager.py b/src/mailman/styles/manager.py index c2729abfb..5962d308f 100644 --- a/src/mailman/styles/manager.py +++ b/src/mailman/styles/manager.py @@ -26,7 +26,6 @@ __all__ = [ ] -from operator import attrgetter from zope.component import getUtility from zope.interface import implementer from zope.interface.verify import verifyObject @@ -34,7 +33,7 @@ from zope.interface.verify import verifyObject from mailman.interfaces.configuration import ConfigurationUpdatedEvent from mailman.interfaces.styles import ( DuplicateStyleError, IStyle, IStyleManager) -from mailman.utilities.modules import call_name +from mailman.utilities.modules import find_components @@ -50,35 +49,27 @@ class StyleManager: self._styles.clear() # Avoid circular imports. from mailman.config import config - # Install all the styles described by the configuration files. - for section in config.style_configs: - class_path = section['class'] - style = call_name(class_path) - assert section.name.startswith('style'), ( - 'Bad style section name: %s' % section.name) - style.name = section.name[6:] - style.priority = int(section.priority) - self.register(style) + # Calculate the Python import paths to search. + paths = filter(None, (path.strip() + for path in config.styles.paths.splitlines())) + for path in paths: + for style_class in find_components(path, IStyle): + style = style_class() + verifyObject(IStyle, style) + assert style.name not in self._styles, ( + 'Duplicate style "{0}" found in {1}'.format( + style.name, style_class)) + self._styles[style.name] = style def get(self, name): """See `IStyleManager`.""" return self._styles.get(name) - def lookup(self, mailing_list): - """See `IStyleManager`.""" - matched_styles = [] - for style in self.styles: - style.match(mailing_list, matched_styles) - for style in matched_styles: - yield style - @property def styles(self): """See `IStyleManager`.""" - for style in sorted(self._styles.values(), - key=attrgetter('priority'), - reverse=True): - yield style + for style_name in sorted(self._styles): + yield self._styles[style_name] def register(self, style): """See `IStyleManager`.""" diff --git a/src/mailman/utilities/modules.py b/src/mailman/utilities/modules.py index d8b788054..4ba549f99 100644 --- a/src/mailman/utilities/modules.py +++ b/src/mailman/utilities/modules.py @@ -22,12 +22,17 @@ from __future__ import absolute_import, unicode_literals __metaclass__ = type __all__ = [ 'call_name', + 'find_components', 'find_name', + 'scan_module', ] +import os import sys +from pkg_resources import resource_listdir + def find_name(dotted_name): @@ -43,6 +48,7 @@ def find_name(dotted_name): return getattr(sys.modules[package_path], object_name) + def call_name(dotted_name, *args, **kws): """Imports and calls the named object in package space. @@ -57,3 +63,50 @@ def call_name(dotted_name, *args, **kws): """ named_callable = find_name(dotted_name) return named_callable(*args, **kws) + + + +def scan_module(module, interface): + """Return all the items in a module that conform to an interface. + + :param module: A module object. The module's `__all__` will be scanned. + :type module: module + :param interface: The interface that returned objects must conform to. + :type interface: `Interface` + :return: The sequence of matching components. + :rtype: objects implementing `interface` + """ + missing = object() + for name in module.__all__: + component = getattr(module, name, missing) + assert component is not missing, ( + '%s has bad __all__: %s' % (module, name)) + if interface.implementedBy(component): + yield component + + + +def find_components(package, interface): + """Find components which conform to a given interface. + + Search all the modules in a given package, returning an iterator over all + objects found that conform to the given interface. + + :param package: The package path to search. + :type package: string + :param interface: The interface that returned objects must conform to. + :type interface: `Interface` + :return: The sequence of matching components. + :rtype: objects implementing `interface` + """ + for filename in resource_listdir(package, ''): + basename, extension = os.path.splitext(filename) + if extension != '.py': + continue + module_name = '{0}.{1}'.format(package, basename) + __import__(module_name, fromlist='*') + module = sys.modules[module_name] + if not hasattr(module, '__all__'): + continue + for component in scan_module(module, interface): + yield component |
