Refactoring a 300-line if

Michael JasonSmith

GroupServer.org

In this talk I present a war-story about a 300-line if-statement. I talk about how the code came about, and how I wrangled it to something that could be managed more easily.

For those new to Python I present some techniques, idioms, and libraries for managing data. For more experienced Pythonistas I will demonstrate a way to implement some patterns, and other useful technique to decompose software into smaller parts.

The if-statement in question lurked deep within the code-base of GroupServer ― a mailing-list manager with a great web interface.

Mailing list

Discussion group: GroupServer development

  • With a mailing-list manager you normally define a group, like GroupServer development.
  • A normal group works like this:
    1. A member sends an email to the list,
    2. It is processed by GroupServer, and
    3. GroupServer sends an email to all the group members.

Mailing list

Discussion group: GroupServer development

  • Only members can post to (most) lists.
  • People who are not members are told to go away.

It is this if-statement that got away on us.

Mailing list

Simple, in theory

            if (person.is_member(group)):
    group.email(message)
else:
    person.notify_not_a_member()

          

The pseudo-code for the if-statement on the previous two slides is not too terrible. It has two branches:

  1. Send the email out if the message is from a member.
  2. Notify everyone else that they cannot post.

However, when you have code being used by people complexity has a habit of rising, a bit like damp. Or termites.

Complexity rises

Reality steps in

            if (group.discussion):
    if (person.is_member(group)):
        group.email(message)
    else:
        person.notify_not_a_member()
elif (group.announcement):
    if (person.is_posting_member(group)):
        group.email(message)
    elif (person.is_member(group)):
        person.notify_not_a_posting_member()
    else:
        person.notify_not_a_member()
elif (group.support):
    group.email(message)
elif (group.closed):
    person.notify_closed()

          






          

The pseudo-code for a more realistic scenario would be a bit more like this, with four different types of groups.

  • Our if-statement from before is still there, at the top. It applies to discussion groups only.
  • GroupServer introduced announcement groups, like GroupServer announcements, that restrict posting to the subset of Posting members.
    • Posting members can post,
    • Most group members are blocked from posting, and
    • Non-members are also blocked from posting.
  • Support groups
    • Support groups are needed because of the complexity of the other two groups!
    • Everyone can post to a support-group.
    • Only group members receive the messages.
  • Closed groups
    • No one can post to a closed group.
    • GroupServer has them to allow the archive to access the excellent archives, even after the group has finished.
    • Also used before the group starts, so there can be a good launch.

Experts may start feeling twitchy about now, but it does not end there…

Complexity rises

And…

            if (group.discussion):
    if (person.is_member(group)):
        group.email(message)
    else:
        person.notify_not_a_member()
elif (group.announcement):
    if (person.is_posting_member(group)):
        group.email(message)
    elif (person.is_member(group)):
        person.notify_not_a_posting_member()
    else:
        person.notify_not_a_member()
elif (group.support):
    group.email(message)
elif (group.closed):
    person.notify_closed()

          
  • Blocked
  • Required properties
  • Broken email address
  • Posting-limit

…which leads to…

Have not even covered all the other rules that have nothing to do with membership.

  • Some people horrid, so are blocked.
  • There is a difference in how GroupServer responds to people with and without a profile.
  • Broken email addresses need to be addressed specifically.
  • Those with a profile can lack required fields (that may be specific to a group).
  • The posting-limit is there to make people write thoughtful email messages.

And all this ends up leading to…

The 300-line if

            # coding=utf-8
import pytz
from datetime import datetime, timedelta

from zope.app.apidoc import interface
from zope.component import createObject, adapts
from zope.interface import implements

from Products.CustomUserFolder.interfaces import IGSUserInfo
from Products.XWFChat.interfaces import IGSGroupFolder
from Products.GSGroup.interfaces import IGSGroupInfo
from Products.GSGroupMember.groupmembership import user_member_of_group,\
  user_participation_coach_of_group, user_admin_of_group
from Products.XWFCore.XWFUtils import munge_date, timedelta_to_string, \
  comma_comma_and
from Products.GSSearch.queries import MessageQuery
from Products.GSProfile import interfaces as profileinterfaces
from gs.profile.email.base.emailuser import EmailUser
from interfaces import IGSPostingUser

#TODO Replace with an audit trail
import logging
log = logging.getLogger('GSGroupMember')

class GSGroupMemberPostingInfo(object):

    adapts(IGSGroupFolder, IGSUserInfo)
    implements(IGSPostingUser)

    def __init__(self, group, userInfo):
        assert IGSGroupFolder.providedBy(group),\
          u'%s is not a group folder' % group
        assert IGSUserInfo.providedBy(userInfo),\
          u'%s is not a user-info' % userInfo

        self.site_root = site_root = group.site_root()

        self.mailingListManager = site_root.ListManager
        self.mailingList = self.mailingListManager.get_list(group.getId())

        self.userInfo = userInfo
        self.groupInfo = IGSGroupInfo(group)

        da = site_root.zsqlalchemy
        assert da
        self.messageQuery = MessageQuery(group, da)

        self.__status = None
        self.__statusNum = 0
        self.__canPost = None
        self.__profileInterfaces = None
          
                @property
    def status(self):
        if self.__status == None:
            # call self.canPost so that __status gets set as a side-effect.
            _justCall = self.canPost
        retval = self.__status
        assert retval
        assert type(retval) == unicode
        return retval

    @property
    def statusNum(self):
        retval = self.__statusNum
        assert type(retval) == int
        return retval

    @property
    def canPost(self):
        if self.__canPost == None:
            self.__canPost = \
              self.group_is_unclosed() or\
               (not(self.user_anonymous()) and\
                self.user_is_member() and\
                self.user_has_preferred_email_addresses() and\
                self.user_is_posting_member() and\
                not(self.user_posting_limit_hit()) and\
                not(self.user_blocked_from_posting()) and\
                self.user_has_required_properties())
        retval = self.__canPost
        assert type(retval) == bool
        return retval

    def group_is_unclosed(self):
        '''A closed group is one where only members can post. It is
          defined by the Germanic-property "unclosed", which GroupServer
          inherited from MailBoxer. (We would love to change its name, but
          it would break too much code.)

          If the "unclosed" property is "True" then the group is open to
          any poster, and we do not have to bother with any member-specific
          checks. Support groups like this.

          If the "unclosed" property is "False" then we have to perform the
          member-specific checks to ensure that the posting user is allowed
          to post.
        '''
        retval = self.mailingList.getProperty('unclosed', False)
        if retval:
            self.__status = u'the group is open to anyone posting'
            self.__statusNum = self.__statusNum + 0
        else:
            self.__status = u'not a member'
            self.__statusNum = 1

        assert type(self.__status) == unicode
        assert type(retval) == bool
        return retval
          
                def user_anonymous(self):
        '''Is the user anonymous? Anonymous users are not allowed to post.
        '''
        retval = self.userInfo.anonymous
        if retval:
            self.__status = u'not logged in'
            self.__statusNum = 2
        else:
            self.__status = u'logged in'
            self.__statusNum = 0

        assert type(self.__status) == unicode
        assert type(retval) == bool
        return retval

    def user_has_preferred_email_addresses(self):
        '''Does the user have at least one preferred (alias default
        delivery) email address to post. This is mostly a safety
        catch to ensure that the user has verified the email addresses.
        '''
        emailUser = EmailUser(self.groupInfo.groupObj, self.userInfo)
        preferredEmailAddresses = emailUser.get_delivery_addresses()
        retval = len(preferredEmailAddresses) >= 1
        if retval:
            self.__status = u'preferred email addresses'
        else:
            self.__status = u'no preferred email addresses'
            self.__statusNum = 4
        assert type(self.__status) == unicode
        assert type(retval) == bool
        return retval

    def user_is_member(self):
        '''A user is a member of the group if the the user has the member
        role in the group context. While this may sound like I am stating
        the blindingly obvious, this was not always the case!
        '''
        retval = user_member_of_group(self.userInfo.user,
                                      self.groupInfo.groupObj)
        if retval:
            self.__status = u'a member'
        else:
            self.__status = u'not a member'
            self.__statusNum = 8
        assert type(self.__status) == unicode
        assert type(retval) == bool
        return retval
          
                def user_is_posting_member(self):
        '''Check the "posting_members" property of the mailing list,
        which is assumed to be a lines-property containing the user-IDs of
        the people who can post. If the property does not contain any
        values, it is assumed that everyone is a posting member.
        '''
        postingMembers = self.mailingList.getProperty('posting_members', [])
        if postingMembers:
            retval = self.userInfo.id in postingMembers
        else:
            retval = True
        if retval:
            self.__status = u'posting member'
        else:
            self.__status = u'not a posting member'
            self.__statusNum = 16
        assert type(self.__status) == unicode
        assert type(retval) == bool
        return retval

    def user_posting_limit_hit(self):
        '''The posting limits are based on the *rate* of posting to the
        group. The maximum allowed rate of posting is defined by the
        "senderlimit" and "senderinterval" properties of the mailing list
        for the group. If the user has exceeded his or her posting limits
        if  more than "senderlimit" posts have been sent in
        "senderinterval" seconds to the group.
        '''
        if user_participation_coach_of_group(self.userInfo, self.groupInfo):
            retval = False
            self.__status = u'participation coach'
            self.__statusNum = self.__statusNum + 0
        elif user_admin_of_group(self.userInfo, self.groupInfo):
            retval = False
            self.__status = u'administrator of'
            self.__statusNum = self.__statusNum + 0
        else:
            # The user is not the participation coach or the administrator
            # of the group
            sid = self.groupInfo.siteInfo.id
            gid = self.groupInfo.id
            uid = self.userInfo.id
            limit = self.mailingList.getValueFor('senderlimit')
            interval = self.mailingList.getValueFor('senderinterval')
            td = timedelta(seconds=interval)
            now = datetime.now(pytz.utc)
            earlyDate = now - td
            count = self.messageQuery.num_posts_after_date(sid, gid, uid,
                                                           earlyDate)
            if count >= limit:
                # The user has made over the allowed number of posts in
                # the interval
                retval = True
                d = self.old_message_post_date()

                canPostDate = d + td
                prettyDate = munge_date(self.groupInfo.groupObj,
                    canPostDate, user=self.userInfo.user)
                prettyDelta = timedelta_to_string(canPostDate - now)
          
                            self.__status = u'post again at %s\n-- in %s' %\
                  (prettyDate, prettyDelta)
                self.__statusNum = 32
            else:
                retval = False
                self.__status = u'under the posting limit'
                self.__statusNum = self.__statusNum + 0
        assert type(self.__status) == unicode
        assert type(retval) == bool
        return retval

    def old_message_post_date(self):
        sid = self.groupInfo.siteInfo.id
        gid = self.groupInfo.id
        uid = self.userInfo.id
        limit = self.mailingList.getValueFor('senderlimit')
        offset = limit - 1
        if offset < 0:
            offset = 0
            log.warning("senderlimit of %s was set to 0 or less" % gid)

        tokens = createObject('groupserver.SearchTextTokens', '')
        posts = self.messageQuery.post_search_keyword(tokens, sid, [gid],
          [uid], 1, offset)

        assert len(posts) == 1
        retval = posts[0]['date']
        assert isinstance(retval, datetime)

        return retval

    def user_blocked_from_posting(self):
        '''Blocking a user from posting is a powerful, but rarely used
        tool. Rather than removing a disruptive member from the group, or
        moderating the user, the user can be blocked from posting.
        '''
        blockedMemberIds = self.mailingList.getProperty('blocked_members',
                                                        [])
        if (self.userInfo.id in blockedMemberIds):
            retval = True
            self.__status = u'blocked from posting'
            self.__statusNum = self.__statusNum + 64
        else:
            retval = False
            self.__status = u'not blocked from posting'
            self.__statusNum = self.__statusNum + 0
        assert type(self.__status) == unicode
        assert type(retval) == bool
        return retval
          
                def user_has_required_properties(self):
        '''The user must have the required properties filled out before
        he or she can post to the group — otherwise they would not be
        required, would they! The required properties can come from one
        of two places: the properties that are required for the site, and
        the properties required for the group.
        '''
        requiredSiteProperties = self.get_required_site_properties()
        requiredGroupProperties = self.get_required_group_properties()
        requiredProperties = requiredSiteProperties + requiredGroupProperties

        unsetRequiredProps = [p for p in requiredProperties
                              if not(self.userInfo.get_property(p, None))]
        if unsetRequiredProps:
            retval = False
            self.__status = u'required properties set'
            fields = [a.title for n, a in self.get_site_properties()
                      if n in unsetRequiredProps]
            f = comma_comma_and(fields)
            attr = (len(fields) == 1 and u'attribute') or u'attributes'
            isare = (len(fields) == 1 and u'is') or u'are'
            self.__status = u'required %s %s %s not set' % (attr, f, isare)
            self.__statusNum = self.__statusNum + 128
        else:
            retval = True
            self.__status = u'required properties set'

        assert type(self.__status) == unicode
        assert type(retval) == bool
        return retval

    def get_site_properties(self):
        '''Whole-heartly nicked from the GSProfile code, the site-wide
        user properties rely on a bit of voodoo: the schemas themselves
        are defined in the file-system, but which schema to use is stored
        in the "GlobalConfiguration" instance.
        '''
        if self.__profileInterfaces == None:
            assert hasattr(self.site_root, 'GlobalConfiguration')
            config = self.site_root.GlobalConfiguration
            ifName = config.getProperty('profileInterface',
                        'IGSCoreProfile')
            # --=mpj17=-- Sometimes profileInterface is set to ''
            ifName = (ifName and ifName) or 'IGSCoreProfile'
            assert hasattr(profileinterfaces, ifName), \
                'Interface "%s" not found.' % ifName
            profileInterface = getattr(profileinterfaces, ifName)
            self.__profileInterfaces =\
              interface.getFieldsInOrder(profileInterface)
        retval = self.__profileInterfaces
        return retval
          
                def get_required_site_properties(self):
        '''Site-properties are properties that are required to be a member
        of the site. It is very hard *not* to have required site-properties
        filled out, but as subscription-by-email is possible, we have to
        allow for the possibility.
        '''
        retval = [n for n, a in self.get_site_properties() if a.required]
        assert type(retval) == list
        return retval

    def get_required_group_properties(self):
        '''Required group properties are stored on the mailing-list
        instance for the group. They are checked against the site-wide
        user properties, to ensure that it is *possible* to have the
        user-profile attribute filled.
        '''
        groupProps = self.mailingList.getProperty('required_properties', [])
        siteProps = [n for n, _a in self.get_site_properties()]
        retval = []
        for prop in groupProps:
            if prop in siteProps:
                retval.append(prop)
        assert type(retval) == list
        return retval
          

The 300-line if-statement.

  • Not supposed to be able to read this: it is embarrassing enough already.
  • This gives you an idea of what the file felt like.
  • It lacks the closed group-type. My boss asked me to add closed groups, and I declined, because I could not touch the code without breaking it. So he allowed me to rewrite the code into something more sane.

Current code

github.com/groupserver/gs.group.member.canpost

            # -*- coding: utf-8 -*-
##############################################################################
#
# Copyright © 2013 OnlineGroups.net and Contributors.
# All Rights Reserved.
#
# This software is subject to the provisions of the Zope Public License,
# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
# FOR A PARTICULAR PURPOSE.
#
##############################################################################
from operator import and_
from zope.cachedescriptors.property import Lazy
from zope.component import getGlobalSiteManager
from gs.group.member.canpost.interfaces import IGSCanPostRule
          
            class CanPostToGroup(object):
    def __init__(self, group, userInfo):
        self.group = group
        self.userInfo = userInfo

    @Lazy
    def adaptors(self):
        gsm = getGlobalSiteManager()
        retval = [a for a in
                  gsm.getAdapters((self.userInfo, self.group),
                                  IGSCanPostRule)]
        retval.sort(key=lambda r: r[1].weight)
        return retval

    @Lazy
    def rules(self):
        retval = [instance
                  for name, instance
                  in self.adaptors]
        return retval

    @Lazy
    def canPost(self):
        return reduce(
            and_, [rule.canPost for
                   rule in self.rules], True)

    @Lazy
    def statusNum(self):
        statusNums = [rule.statusNum
                      for rule in self.rules
                      if rule.statusNum != 0]
        retval = (statusNums and min(statusNums)) or 0
        assert retval >= 0
        return retval

    @Lazy
    def status(self):
        retval = [rule.status
                  for rule in self.rules
                  if rule.statusNum == self.statusNum][0]
        return retval
          

This is the current code at the same typeface size as the previous slide, to give you some idea of the change.

  • On the left there are about a dozen lines, with a Copyright and licence block, and a few imports
  • On the right there are another two-dozen lines of actual code. Where did the rules go?
    • Each rule is in a class,
    • Rules assembled by this class,
    • A single aggregate Boolean is delivered by this code.

The first detailed bit of code I want to look at is the one that returns the Boolean, canPost

The core logic

          @property
def canPost(self):
    return functools.reduce(
        operator.and_,
        [rule.canPost for rule in self.rules],
        True)
        
  • The core logic is a reduce that does an and across all the rules, so it works like an if.
  • Lot of useful things from core Python here. (This is both Python 2, Python 3, PyPy, and PyPy3 code.)
    • @property is a decorator. It makes a method behave like a property, so you can call it like obj.canPost ― without the brackets.
    • The functools standard library contains many things for dealing with lists-of-stuff, a style of programming known as functional programming, hence the name. In this case I am using the reduce function to reduce a list to a single value. It takes three arguments, but the two main ones are what to reduce, and how to reduce it.
      1. What to reduce, in this case a list of rules. We create a list of Boolean values by asking each rule if the person can post, using a list comprehension.
      2. How to reduce this list. In this case I am doing a logical and using operator.and_. The operator library is the off-sider of functools. It contains all the Python operators as functions, removing the need for a lambda function.
      3. Finally there is an initial value, which is used if the list is empty or has only one value.

The second part of the can-post code gets the rules that are used in the canPost method.

Get the rules

          @property
def rules(self):
    registry = getGlobalSiteManager()  # A singleton
    retval = [rule for name, rule in
              registry.getAdapters((self.user, self.group),
                                   IGSCanPostRule)]
    retval.sort(key=operator.attrgetter('weight'))
    return retval
        

This is the code that gets all the rules, which are fed to the reduce we saw previously. It contains some standard things: the @property decorator, the use of a list comprehension, the sort method of a list, and operator.attrgetter (the operator library once-again saving us a lambda).

Then there are some non-core bits, which I will explain soon, but I will point out the bits you have probably never seen:

  • There is this registry thing. It is a singleton, like a database we do not want more than one copy of a registry of things.
  • And then there is this getAdapters call. It is passed a 2-tuple of a user and a group. The user-and-group had to come up some time as we are trying to figure out if a user can post to a group. Then there is what looks like a class-name, beginning with I.

To explain this code I have to explain what an adapter is.

Adapters

Conceptually an adapter is what it sounds like:

  • Take something with one interface (110V, 10A, AC)
  • Munge it some how
  • Provide something with a different interface (5V, 0.85A, DC)

However, I will not discuss such simple adapters today.

Multi-adapters

↑ User
↓ Rule
↑ Group
registry.getAdapters((self.user, self.group), IGSCanPostRule)
        

What we are dealing with is a more complex multi-adapter.

  • Take two or more inputs: a DSL line connecting to the outside World, and 12V DC power.
  • Provide something with another interface: Ethernet.

In our case we are adapting a user and a group, in order to get a rule. So what we are adapting is passed in as the first argument, which is why it is in a tuple. What we want is the last (second) argument.

So what does one of these adapters look like?

Posting member

gs.group.type.announcement





class PostingMember(BaseRule):
    weight = 90

    def __init__(self, user, group):
        self.user = user
        self.group = group

    @property
    def canPost(self):
        postingMembers = self.group.getProperty('posting_members', [])
        retval = postingMembers and (self.user.id in postingMembers)
        return retval
        

Adapters look like normal classes. Here is the actual rule for an announcement group (almost).

  • There is the weight that the rules were sorted by.
  • It is initialised with a user and a group, which was passed in by the getAdapters call
  • The canPost attribute is following my normal style of using a method with the @property decorator. (The rule is looking up a list of user-identifiers.)

But what makes this an adapter rather than a normal class?

Posting member: adapter addition

gs.group.type.announcement

@adapter(IGSUser, IGSAnnouncementGroup)
# ….getAdapters((self.user, self.group),…)
@implementer(IGSCanPostRule)
# ….getAdapters(…, IGSCanPostRule)
class PostingMember(BaseRule):
    weight = 90

    def __init__(self, user, group):
        self.user = user
        self.group = group

    @property
    def canPost(self):
        postingMembers = self.group.getProperty('posting_members', [])
        retval = postingMembers and (self.user.id in postingMembers)
        return retval

All these extra things that I hid from you make this an adapter. They may look odd, but they are just signals about what this class does. If we look at this new code a bit closer…

Posting member: details

gs.group.type.announcement

@adapter(IGSUser, IGSAnnouncementGroup)
# ….getAdapters((self.user, self.group),…)
@implementer(IGSCanPostRule)
# ….getAdapters(…, IGSCanPostRule)
class PostingMember(BaseRule):
    weight = 90

    def __init__(self, user, group):
        self.user = user
        self.group = group
    …
        
  • The @adapter is a class decorator. It mirrors the user and group ― both in the __init__ and in the getAdapters call. However, we have more of these funny class names beginning with I that I promise to get to soon.
  • Another class-decorator @implementer now closely matches up to something in the getAdapters call.

However, declaring these is not enough, we also have to keep track of them. Somehow.

Posting member: keeping track

@adapter(IGSUser, IGSAnnouncementGroup)
@implementer(IGSCanPostRule)
class PostingMember(BaseRule):
    …

registry = getGlobalSiteManager()  # A singleton
registry.registerAdapter(PostingMember,
                         name='Posting member')

Keeping track of the adapters is where the registry, which you saw earlier, comes into play again. It keeps track of everything for us. The only oddity is calling the getGlobalSiteManager each time to get it: it returns a singleton, because we only want one single registry.

The adapters themselves are added using registerAdapter, which takes a class and a name (which you may recall we threw out earlier).

To explain why it is called a site manager, and where these decorators and functions come from, I must address the elephant in the room.

The elephant in the room

from zope.component import (getGlobalSiteManager, adapter)
from zope.interface import (implementer, Interface)

zope.component + zope.interface + zope.event = ZCA

The worst thing about the ZCA is the name Zope

  • The three non-standard things that I have shown to you (and there are only three) have all come from two libraries: zope.component and zope.interface.
  • Together, with another small library called zope.event, they are known as the Zope Component Architecture (ZCA).
  • Zope has a… patchy representation, which unfairly sullies the reputation of the ZCA: a friend of mine once quipped that the name Zope was the worst thing about the ZCA. Despite this, the ZCA provides the core of Pyramid, Plone, and Mailman 3.
  • The web-framework is still present, but it is totally optional. I am just going to talk about the ZCA.

And finally, I guess I should talk about these I things. You see one here: Interface.

Interfaces

class IGSCanPostRule(zope.interface.Interface):
    '''The interface for a can-post rule'''
    # That is all there is to most interfaces

…

@adapter(IGSUser, IGSAnnouncementGroup)
@implementer(IGSCanPostRule)
class PostingMember(BaseRule):
    …
       

As a concept interfaces are similar to interfaces in strongly typed languages such as Java, and they can be used to specify attributes and methods. However in practice I just use them as a sign (MasterCard accepted here).

  • The interfaces themselves are just classes that inherit from zope.interface.Interface and contain a doc-string. I use duck-typing, as normal in Python, for actual conformance to an API.
  • In UML they have the interface stereotype, and tradition has it that an interface class starts with I ― so they do not get mixed up with normal classes.
  • Classes that use interfaces are drawn with lolly-pop connectors in UML:
    • Take something with a user-interface and announcement group interface.
    • Provide something with a can-post rule interface.
    • The classes that use interfaces remain unconnected: they just look like they can connect. That is because this UML only shows the static structure, and the rule-instances were only connected up by getAdapters at run time.

However, interface classes themselves do get some UML connection action on.

Interface hierarchy

class IGSGroup(Interface):
    'The base interface for a group'

class IGSDiscussionGroup(IGSGroup):
    pass

class IGSAnnouncementGroup(IGSDiscussionGroup):
    pass

class IGSClosedGroup(IGSGroup):
    pass

class IGSSupportGroup(IGSGroup):
    pass
         
  • Interfaces can inherit, so they can be more specific: HSBC MasterCard accepted here.
  • All eventually inherit from the base Interface.
  • With our running announcement group example, its interface inherits from the discussion group interface.
  • The other group-interfaces just inherit from the base group interface.

Because interfaces inherit a really cool thing happens when we ask for all the rules for an announcement group.

Putting it all together

  • The can-post code gets a bunch of adapters for a user and a group. The can post code is unconcerned about the specifics of the what the user and group are, just that it gets rules rules.
    • There is one rule for an announcement group, that for the posting members, so the can-post code will get that.
    • It also gains six rules because the announcement group interface inherits from the discussion group.
    • The discussion group inherits from the base group, so we get a rule from that, too.
  • Finally all the rules are put together with an and_.

Now, you could argue that we have a complex system, and you'd be right.

Complexity tamed

  • Small
  • Understandable
  • Highly cohesive
  • Lightly coupled

$ python -m unittest discover\
  -s gs/group/type/announcement/tests/
........
-------------------------------------
Ran 8 tests in 0.012s

OK

The complexity of reality is intractable: discussion groups, announcement groups, support groups, and closed groups are all needed. The system has to reflect reality. However, using the ZCA we can create small components that are easy to understand.

  • Less likely to make an error because it is understandable in isolation.
  • The components are highly cohesive, in they work well together.
  • The components are lightly coupled, in you can change one without changing the others. If the Python product that supplies the Announcement group (gs.group.type.announcement) is not loaded then everything else is fine.
  • Being small and lightly coupled makes things easier to test, as each rule can be tested independently. Testing the old code would have sent the best programmer mad just with getting the mocking done.

And that'd be it, except for some messy confessions…

Messy confessions: ZCML

My name is Michael, and I like the ZCML

<adapter
  name="Posting Member"
  for="Products.CustomUserFolder.interfaces.IGSUser
       .interfaces.IGSAnnouncementGroup"
  provides="gs.group.member.canpost.interfaces.IGSCanPostRule"
  factory=".canpostrules.PostingMember"  />

First, if you look at the actual GroupServer code you will notice an absence of @adapter and @implementer class decorators. That is because I use the Zope Configuration Markup Language (ZCML).

  • An XML-based language that is used to provide the decoration and registration.
  • I think it simplifies the code.
  • I like how it places all the component-information in one file (configure.zcml)
  • Some disagree about the value of the ZCML, and I am willing to accept that I may be one of the few people alive who likes it.

Messy confessions: I'm @Lazy

from zope.cachedescriptors.property import Lazy
…
    @Lazy
    def canPost(self):
        …
        return retval

I actually use the @Lazy method decorator, rather than @property. In practice, it does much the same job, but it caches the result, so calling it the second time gives you an immediate answer. Switching methods and properties to @Lazy is a really cheep and easy way to speed up code.

There are other cache descriptors in the zope.cachedescriptors class, including ones where you can reset the cache. However, I have never needed anything other than @Lazy.

Messy confessions: many patterns

Patterns seen today:

…but not adapter.

I have shown you many design patterns today, but the adapter pattern is not one of them.

Strategy
The Strategy pattern defines an interchangeable set of algorithms. This is the main pattern that we have been walking through.
Singleton
I have pointed out the singleton registry when it has popped up.
Abstract factory
The abstract factory pattern allows us to separate a concrete implementation from the code that uses it, thanks to interfaces. We used getAdapters to get a bunch of adapters, but you can get just one:
storage = getMultiAdapter(
    (groupInfo, msg), IStorageForEmailMessage)
storage.store()
Decorator
In design patterns a decorator enhances a class, but does not change the interface. In Python support for decorators is built-in, which we saw with @adapter and @implementer
@implementer(IGSCanPostRule)
Proxy
Defines a surrogate for another object, which we just saw with the @Lazy decorator that hides a complex operator behind a light-weight property.

I have been using the term adapter in the ZCA sense. So what about the actual adapter pattern?

Messy confessions: an adapter

Raymond Hettinger, Beyond PEP 8 ― Best practices for beautiful intelligible code, PyCon 2015, Montréal, April 2015

https://youtu.be/wf-BqAjZb8M

With the adapter pattern you get a old-thing with a terrible API, and wrap it in a new class (the adapter) that has a nice API.

Raymond Hettinger gave a great talk at PyCon in 2015 where he created an adapter from scratch. The video is on YouTube, and it is worth watching simply for the performance-art aspect of the talk.

Contact

  • GroupServer can be found on the web at GroupServer.org.
  • You can also get the source-code from GitHub.
  • Those that like to follow the thoughts of a mailing-list manager with an excellent web interface can follow GroupServer on Twitter.
  • And finally, I am me. I like email.