if
if
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.
It is this if-statement that got away on us.
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:
However, when you have code being used by people complexity has a habit of rising, a bit like damp. Or termites.
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.
Experts may start feeling twitchy about now, but it does not end there…
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()
…which leads to…
Have not even covered all the other rules that have nothing to do with membership.
And all this ends up leading to…
# 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.
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.
The first detailed bit of code I want to look at is
the one that returns the Boolean,
canPost
@property
def canPost(self):
return functools.reduce(
operator.and_,
[rule.canPost for rule in self.rules],
True)
reduce
that does
an and
across all the rules, so it
works like an if.
@property
is a decorator. It makes
a method behave like a property, so you can
call it like obj.canPost
―
without the brackets.
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.
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.
The second part of the can-post code gets the rules
that are used in the canPost
method.
@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:
registry
thing. It is a
singleton, like a database we do not want
more than one copy of a registry of things.
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.
Conceptually an adapter is what it sounds like:
However, I will not discuss such simple adapters today.
registry.getAdapters((self.user, self.group), IGSCanPostRule)
What we are dealing with is a more complex multi-adapter.
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?
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).
weight
that the rules
were sorted by.
getAdapters
call
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?
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…
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
…
@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.
@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.
@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.
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
zope.component
and
zope.interface
.
zope.event
, they are known as the Zope
Component Architecture (ZCA).
Zopewas the worst thing about the ZCA. Despite this, the ZCA provides the core of Pyramid, Plone, and Mailman 3.
And finally, I guess I should talk about these I things. You see one here: Interface.
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
).
zope.interface.Interface
and contain a doc-string. I use duck-typing, as
normal in Python, for actual conformance to an API.
interface
stereotype, and tradition has it that an
interface class starts with I ― so they
do not get mixed up with normal classes.
getAdapters
at
run time.
However, interface classes themselves do get some UML connection action on.
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
HSBC MasterCard accepted here.
Interface
.
Because interfaces inherit a really cool thing happens when we ask for all the rules for an announcement group.
getAdapters((self.user, self.group), IGSCanPostRule)
@adapter(IGSUser, IGSAnnouncementGroup)
@implementer(IGSCanPostRule)
reduce(and_, [rule.canPost for rule in self.rules], True)
and_
.
Now, you could argue that we have a complex system, and you'd be right.
$ 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.
gs.group.type.announcement
) is not
loaded then everything else is fine.
And that'd be it, except for some messy confessions…
<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).
configure.zcml
)
@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
.
Patterns seen today:
getGlobalSiteManager
getAdapters(…
@implementer(IGSCanPostRule)
@Lazy
…but not adapter.
I have shown you many design patterns today, but the adapter pattern is not one of them.
getAdapters
to get a bunch of adapters,
but you can get just one:
storage = getMultiAdapter(
(groupInfo, msg), IStorageForEmailMessage)
storage.store()
@adapter
and @implementer
@implementer(IGSCanPostRule)
@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?
Raymond Hettinger, Beyond PEP 8 ― Best practices for beautiful intelligible code, PyCon 2015, Montréal, April 2015
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.