summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--pylint.rc10
-rw-r--r--src/mailman/Utils.py12
-rw-r--r--src/mailman/app/bounces.py11
-rw-r--r--src/mailman/app/commands.py2
-rw-r--r--src/mailman/app/finder.py3
-rw-r--r--src/mailman/app/lifecycle.py5
-rw-r--r--src/mailman/app/membership.py30
-rw-r--r--src/mailman/constants.py6
-rw-r--r--src/mailman/domain.py2
-rw-r--r--src/mailman/i18n.py38
-rw-r--r--src/mailman/inject.py4
-rw-r--r--src/mailman/interact.py33
-rw-r--r--src/mailman/options.py9
-rw-r--r--src/mailman/passwords.py80
-rw-r--r--src/mailman/version.py2
15 files changed, 206 insertions, 41 deletions
diff --git a/pylint.rc b/pylint.rc
index 84e3b13ac..6e1ecfd9c 100644
--- a/pylint.rc
+++ b/pylint.rc
@@ -53,8 +53,14 @@ load-plugins=
#enable-msg=
# Disable the message(s) with the given id(s).
+# C0103: *Invalid name %s* in module globals
# I0011: *Locally disabling %s*
-disable-msg=I0011
+# R0903: *Too few public methods*
+# R0913: *Too many arguments*
+# R0914: *Too many local variables*
+# W0142: *Used * or ** magic*
+# W0704: *Except doesn't do anything*
+disable-msg=C0103,I0011,R0903,R0914,W0142,W0704
[REPORTS]
@@ -250,7 +256,7 @@ max-public-methods=50
[IMPORTS]
# Deprecated modules which should not be used, separated by a comma
-deprecated-modules=regsub,string,TERMIOS,Bastion,rexec
+deprecated-modules=regsub,TERMIOS,Bastion,rexec
# Create a graph of every (i.e. internal and external) dependencies in the
# given file (report R0402 must not be disabled)
diff --git a/src/mailman/Utils.py b/src/mailman/Utils.py
index 799703ba6..7f2fd2a8f 100644
--- a/src/mailman/Utils.py
+++ b/src/mailman/Utils.py
@@ -39,6 +39,7 @@ import random
import logging
import htmlentitydefs
+# pylint: disable-msg=E0611,W0403
from email.errors import HeaderParseError
from email.header import decode_header, make_header
from lazr.config import as_boolean
@@ -71,7 +72,8 @@ log = logging.getLogger('mailman.error')
-# a much more naive implementation than say, Emacs's fill-paragraph!
+# A much more naive implementation than say, Emacs's fill-paragraph!
+# pylint: disable-msg=R0912
def wrap(text, column=70, honor_leading_ws=True):
"""Wrap and fill the text to the specified column.
@@ -179,7 +181,7 @@ def Secure_MakeRandomPassword(length):
try:
fd = os.open('/dev/urandom', os.O_RDONLY)
except OSError, e:
- if e.errno <> errno.ENOENT:
+ if e.errno != errno.ENOENT:
raise
# We have no available source of cryptographically
# secure random characters. Log an error and fallback
@@ -246,7 +248,7 @@ def get_global_password(siteadmin=True):
challenge = fp.read()[:-1] # strip off trailing nl
fp.close()
except IOError, e:
- if e.errno <> errno.ENOENT:
+ if e.errno != errno.ENOENT:
raise
# It's okay not to have a site admin password
return None
@@ -378,7 +380,7 @@ def findtext(templatefile, raw_dict=None, raw=False, lang=None, mlist=None):
fp = open(filename)
raise OuterExit
except IOError, e:
- if e.errno <> errno.ENOENT:
+ if e.errno != errno.ENOENT:
raise
# Okay, it doesn't exist, keep looping
fp = None
@@ -391,7 +393,7 @@ def findtext(templatefile, raw_dict=None, raw=False, lang=None, mlist=None):
filename = os.path.join(TEMPLATE_DIR, 'en', templatefile)
fp = open(filename)
except IOError, e:
- if e.errno <> errno.ENOENT:
+ if e.errno != errno.ENOENT:
raise
# We never found the template. BAD!
raise IOError(errno.ENOENT, 'No template file found', templatefile)
diff --git a/src/mailman/app/bounces.py b/src/mailman/app/bounces.py
index cea91b2e2..3972e2e6d 100644
--- a/src/mailman/app/bounces.py
+++ b/src/mailman/app/bounces.py
@@ -30,7 +30,7 @@ from email.mime.message import MIMEMessage
from email.mime.text import MIMEText
from mailman.Utils import oneline
-from mailman.email.message import Message, UserNotification
+from mailman.email.message import UserNotification
from mailman.i18n import _
log = logging.getLogger('mailman.config')
@@ -38,6 +38,15 @@ log = logging.getLogger('mailman.config')
def bounce_message(mlist, msg, e=None):
+ """Bounce the message back to the original author.
+
+ :param mlist: The mailing list that the message was posted to.
+ :type mlist: `IMailingList`
+ :param msg: The original message.
+ :type msg: `email.message.Message`
+ :param e: Optional exception causing the bounce.
+ :type e: Exception
+ """
# Bounce a message back to the sender, with an error message if provided
# in the exception argument.
if msg.sender is None:
diff --git a/src/mailman/app/commands.py b/src/mailman/app/commands.py
index 7131defb4..a4867e7fc 100644
--- a/src/mailman/app/commands.py
+++ b/src/mailman/app/commands.py
@@ -40,5 +40,5 @@ def initialize():
verifyObject(IEmailCommand, command)
assert command_class.name not in config.commands, (
'Duplicate email command "{0}" found in {1}'.format(
- command_class.name, module))
+ command_class.name, command_class.__module__))
config.commands[command_class.name] = command_class()
diff --git a/src/mailman/app/finder.py b/src/mailman/app/finder.py
index 2632957b8..2190c761e 100644
--- a/src/mailman/app/finder.py
+++ b/src/mailman/app/finder.py
@@ -27,6 +27,7 @@ __all__ = [
import os
import sys
+
from pkg_resources import resource_listdir
@@ -45,7 +46,7 @@ def find_components(package, interface):
# Find all rules found in all modules inside our package.
for filename in resource_listdir(package, ''):
basename, extension = os.path.splitext(filename)
- if extension <> '.py':
+ if extension != '.py':
continue
module_name = '{0}.{1}'.format(package, basename)
__import__(module_name, fromlist='*')
diff --git a/src/mailman/app/lifecycle.py b/src/mailman/app/lifecycle.py
index ff087a874..7fdbc9d89 100644
--- a/src/mailman/app/lifecycle.py
+++ b/src/mailman/app/lifecycle.py
@@ -17,7 +17,7 @@
"""Application level list creation."""
-from __future__ import unicode_literals
+from __future__ import absolute_import, unicode_literals
__metaclass__ = type
__all__ = [
@@ -27,11 +27,9 @@ __all__ = [
import os
-import sys
import shutil
import logging
-from mailman import Utils
from mailman.config import config
from mailman.core import errors
from mailman.email.validate import validate
@@ -48,6 +46,7 @@ def create_list(fqdn_listname, owners=None):
if owners is None:
owners = []
validate(fqdn_listname)
+ # pylint: disable-msg=W0612
listname, domain = fqdn_listname.split('@', 1)
if domain not in config.domains:
raise errors.BadDomainSpecificationError(domain)
diff --git a/src/mailman/app/membership.py b/src/mailman/app/membership.py
index 6681cdda3..4022d5727 100644
--- a/src/mailman/app/membership.py
+++ b/src/mailman/app/membership.py
@@ -17,7 +17,7 @@
"""Application support for membership management."""
-from __future__ import unicode_literals
+from __future__ import absolute_import, unicode_literals
__metaclass__ = type
__all__ = [
@@ -33,7 +33,7 @@ from mailman import i18n
from mailman.app.notifications import send_goodbye_message
from mailman.config import config
from mailman.core import errors
-from mailman.email.message import Message, OwnerNotification
+from mailman.email.message import OwnerNotification
from mailman.email.validate import validate
from mailman.interfaces.member import AlreadySubscribedError, MemberRole
@@ -47,17 +47,17 @@ def add_member(mlist, address, realname, password, delivery_mode, language):
The member's subscription must be approved by whatever policy the list
enforces.
- :param mlist: the mailing list to add the member to
- :type mlist: IMailingList
- :param address: the address to subscribe
+ :param mlist: The mailing list to add the member to.
+ :type mlist: `IMailingList`
+ :param address: The address to subscribe.
:type address: string
- :param realname: the subscriber's full name
+ :param realname: The subscriber's full name.
:type realname: string
- :param password: the subscriber's password
+ :param password: The subscriber's password.
:type password: string
- :param delivery_mode: the delivery mode the subscriber has chosen
+ :param delivery_mode: The delivery mode the subscriber has chosen.
:type delivery_mode: DeliveryMode
- :param language: the language that the subscriber is going to use
+ :param language: The language that the subscriber is going to use.
:type language: string
"""
# Let's be extra cautious.
@@ -104,6 +104,7 @@ def add_member(mlist, address, realname, password, delivery_mode, language):
raise AssertionError(
'User should have had linked address: {0}'.format(address))
# Create the member and set the appropriate preferences.
+ # pylint: disable-msg=W0631
member = address_obj.subscribe(mlist, MemberRole.member)
member.preferences.preferred_language = language
member.preferences.delivery_mode = delivery_mode
@@ -113,6 +114,17 @@ def add_member(mlist, address, realname, password, delivery_mode, language):
def delete_member(mlist, address, admin_notif=None, userack=None):
+ """Delete a member right now.
+
+ :param mlist: The mailing list to add the member to.
+ :type mlist: `IMailingList`
+ :param address: The address to subscribe.
+ :type address: string
+ :param admin_notif: Whether the list administrator should be notified that
+ this member was deleted.
+ :type admin_notif: bool, or None to let the mailing list's
+ `admin_notify_mchange` attribute decide.
+ """
if userack is None:
userack = mlist.send_goodbye_msg
if admin_notif is None:
diff --git a/src/mailman/constants.py b/src/mailman/constants.py
index 2cd48270c..73158d8c7 100644
--- a/src/mailman/constants.py
+++ b/src/mailman/constants.py
@@ -33,7 +33,13 @@ from mailman.interfaces.preferences import IPreferences
+# pylint: disable-msg=W0232
+# no class __init__()
+# pylint: disable-msg=R0903
+# too few public methods
class SystemDefaultPreferences:
+ """The default system preferences."""
+
implements(IPreferences)
acknowledge_posts = False
diff --git a/src/mailman/domain.py b/src/mailman/domain.py
index 4a7bef755..39c27a029 100644
--- a/src/mailman/domain.py
+++ b/src/mailman/domain.py
@@ -59,6 +59,8 @@ class Domain:
self.contact_address = (contact_address
if contact_address is not None
else 'postmaster@' + email_host)
+ # pylint: disable-msg=E1101
+ # no netloc member; yes it does
self.url_host = urlparse(self.base_url).netloc
def confirm_address(self, token=''):
diff --git a/src/mailman/i18n.py b/src/mailman/i18n.py
index d06db4cb8..51330ef2e 100644
--- a/src/mailman/i18n.py
+++ b/src/mailman/i18n.py
@@ -46,10 +46,12 @@ MESSAGES_DIR = os.path.dirname(mailman.messages.__file__)
class Template(string.Template):
+ """Match any attribute path."""
idpattern = r'[_a-z][_a-z0-9.]*'
class attrdict(dict):
+ """Follow attribute paths."""
def __getitem__(self, key):
parts = key.split('.')
value = super(attrdict, self).__getitem__(parts.pop(0))
@@ -62,6 +64,12 @@ class attrdict(dict):
def set_language(language_code=None):
+ """Set the global translation context from a language code.
+
+ :param language_code: The two letter language code to set.
+ :type language_code: str
+ """
+ # pylint: disable-msg=W0603
global _translation
# gettext.translation() API requires None or a sequence.
codes = (None if language_code is None else [language_code])
@@ -74,10 +82,21 @@ def set_language(language_code=None):
def get_translation():
+ """Return the global translation context.
+
+ :return: The global translation context.
+ :rtype: `GNUTranslation`
+ """
return _translation
def set_translation(translation):
+ """Set the global translation context.
+
+ :param translation: The translation context.
+ :type translation: `GNUTranslation`.
+ """
+ # pylint: disable-msg=W0603
global _translation
_translation = translation
@@ -92,7 +111,9 @@ class using_language:
self._old_translation = _translation
set_language(self._language_code)
+ # pylint: disable-msg=W0613
def __exit__(self, *exc_info):
+ # pylint: disable-msg=W0603
global _translation
_translation = self._old_translation
# Do not suppress exceptions.
@@ -107,6 +128,13 @@ if _translation is None:
def _(s):
+ """Translate the string.
+
+ :param s: The string to transate
+ :type s: string
+ :return: The translated string
+ :rtype: string
+ """
if s == '':
return ''
assert s, 'Cannot translate: {0}'.format(s)
@@ -122,6 +150,7 @@ def _(s):
# original string is 1) locals dictionary, 2) globals dictionary.
#
# Get the frame of the caller.
+ # pylint: disable-msg=W0212
frame = sys._getframe(1)
# A `safe' dictionary is used so we won't get an exception if there's a
# missing key in the dictionary.
@@ -144,6 +173,13 @@ def _(s):
def ctime(date):
+ """Translate a ctime.
+
+ :param date: The date to translate.
+ :type date: str or time float
+ :return: The translated date.
+ :rtype: string
+ """
# Don't make these module globals since we have to do runtime translation
# of the strings anyway.
daysofweek = [
@@ -156,6 +192,7 @@ def ctime(date):
_('Jul'), _('Aug'), _('Sep'), _('Oct'), _('Nov'), _('Dec')
]
+ # pylint: disable-msg=W0612
tzname = _('Server Local Time')
if isinstance(date, str):
try:
@@ -189,6 +226,7 @@ def ctime(date):
mon = i
break
else:
+ # pylint: disable-msg=W0612
year, mon, day, hh, mm, ss, wday, yday, dst = time.localtime(date)
if dst in (0, 1):
tzname = time.tzname[dst]
diff --git a/src/mailman/inject.py b/src/mailman/inject.py
index ac2072fa1..eec096066 100644
--- a/src/mailman/inject.py
+++ b/src/mailman/inject.py
@@ -15,6 +15,8 @@
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
+"""Inject a message into a queue."""
+
from __future__ import absolute_import, unicode_literals
__metaclass__ = type
@@ -24,6 +26,8 @@ __all__ = [
]
+# pylint doesn't understand absolute_import
+# pylint: disable-msg=E0611,W0403
from email import message_from_string
from email.utils import formatdate, make_msgid
diff --git a/src/mailman/interact.py b/src/mailman/interact.py
index a30f22cee..d3994eb14 100644
--- a/src/mailman/interact.py
+++ b/src/mailman/interact.py
@@ -33,19 +33,31 @@ DEFAULT_BANNER = object()
def interact(upframe=True, banner=DEFAULT_BANNER, overrides=None):
- # The interactive prompt's namespace
- ns = dict()
- # If uplocals is true, also populate the console's locals with the locals
- # of the frame that called this function (i.e. one up from here).
+ """Start an interactive interpreter prompt.
+
+ :param upframe: Whether or not to populate the interpreter's globals with
+ the locals from the frame that called this function.
+ :type upfframe: bool
+ :param banner: The banner to print before the interpreter starts.
+ :type banner: string
+ :param overrides: Additional interpreter globals to add.
+ :type overrides: dict
+ """
+ # The interactive prompt's namespace.
+ namespace = dict()
+ # Populate the console's with the locals of the frame that called this
+ # function (i.e. one up from here).
if upframe:
+ # pylint: disable-msg=W0212
frame = sys._getframe(1)
- ns.update(frame.f_globals)
- ns.update(frame.f_locals)
+ namespace.update(frame.f_globals)
+ namespace.update(frame.f_locals)
if overrides is not None:
- ns.update(overrides)
- interp = code.InteractiveConsole(ns)
- # Try to import the readline module, but don't worry if it's unavailable
+ namespace.update(overrides)
+ interp = code.InteractiveConsole(namespace)
+ # Try to import the readline module, but don't worry if it's unavailable.
try:
+ # pylint: disable-msg=W0612
import readline
except ImportError:
pass
@@ -54,8 +66,9 @@ def interact(upframe=True, banner=DEFAULT_BANNER, overrides=None):
# than once, this could cause a problem.
startup = os.environ.get('PYTHONSTARTUP')
if startup:
+ # pylint: disable-msg=W0702
try:
- execfile(startup, ns)
+ execfile(startup, namespace)
except:
pass
# We don't want the funky console object in parentheses in the banner.
diff --git a/src/mailman/options.py b/src/mailman/options.py
index d18c78cea..41c3651e4 100644
--- a/src/mailman/options.py
+++ b/src/mailman/options.py
@@ -17,7 +17,7 @@
"""Common argument parsing."""
-from __future__ import unicode_literals
+from __future__ import absolute_import, unicode_literals
__metaclass__ = type
__all__ = [
@@ -40,7 +40,9 @@ from mailman.version import MAILMAN_VERSION
+# pylint: disable-msg=W0613
def check_unicode(option, opt, value):
+ """Check that the value is a unicode string."""
if isinstance(value, unicode):
return value
try:
@@ -50,7 +52,9 @@ def check_unicode(option, opt, value):
'option {0}: Cannot decode: {1}'.format(opt, value))
+# pylint: disable-msg=W0613
def check_yesno(option, opt, value):
+ """Check that the value is 'yes' or 'no'."""
value = value.lower()
if value not in ('yes', 'no', 'y', 'n'):
raise OptionValueError('option {0}: invalid: {1}'.format(opt, value))
@@ -73,6 +77,7 @@ class SafeOptionParser(OptionParser):
having to wrap the options in str() calls.
"""
def add_option(self, *args, **kwargs):
+ """See `OptionParser`."""
# Check to see if the first or first two options are unicodes and turn
# them into 8-bit strings before calling the superclass's method.
if len(args) == 0:
@@ -150,6 +155,7 @@ class SingleMailingListOptions(Options):
"""A helper for specifying the mailing list on the command line."""
def add_options(self):
+ """See `Options`."""
self.parser.add_option(
'-l', '--listname',
type='unicode', help=_('The mailing list name'))
@@ -160,6 +166,7 @@ class MultipleMailingListOptions(Options):
"""A helper for specifying multiple mailing lists on the command line."""
def add_options(self):
+ """See `Options`."""
self.parser.add_option(
'-l', '--listname',
default=[], action='append', dest='listnames', type='unicode',
diff --git a/src/mailman/passwords.py b/src/mailman/passwords.py
index a03ef8fdb..54482ca44 100644
--- a/src/mailman/passwords.py
+++ b/src/mailman/passwords.py
@@ -20,13 +20,13 @@
Represents passwords using RFC 2307 syntax (as best we can tell).
"""
-from __future__ import unicode_literals
+from __future__ import absolute_import, unicode_literals
__metaclass__ = type
__all__ = [
'Schemes',
- 'make_secret',
'check_response',
+ 'make_secret',
]
@@ -47,71 +47,102 @@ ITERATIONS = 2000
+# pylint: disable-msg=W0232
class PasswordScheme:
+ """Password scheme base class."""
TAG = b''
@staticmethod
def make_secret(password):
- """Return the hashed password"""
+ """Return the hashed password.
+
+ :param password: The clear text password.
+ :type password: string
+ :return: The encrypted password.
+ :rtype: string
+ """
raise NotImplementedError
@staticmethod
def check_response(challenge, response):
- """Return True if response matches challenge.
+ """Check a response against a challenge.
It is expected that the scheme specifier prefix is already stripped
from the response string.
+
+ :param challenge: The challenge.
+ :type challenge: string
+ :param response: The response.
+ :type response: string
+ :return: True if the response matches the challenge.
+ :rtype: bool
"""
raise NotImplementedError
class NoPasswordScheme(PasswordScheme):
+ """A password scheme without passwords."""
+
TAG = b'NONE'
@staticmethod
def make_secret(password):
+ """See `PasswordScheme`."""
return b''
+ # pylint: disable-msg=W0613
@staticmethod
def check_response(challenge, response):
+ """See `PasswordScheme`."""
return False
class ClearTextPasswordScheme(PasswordScheme):
+ """A password scheme that stores clear text passwords."""
+
TAG = b'CLEARTEXT'
@staticmethod
def make_secret(password):
+ """See `PasswordScheme`."""
return password
@staticmethod
def check_response(challenge, response):
+ """See `PasswordScheme`."""
return challenge == response
class SHAPasswordScheme(PasswordScheme):
+ """A password scheme that encodes the password using SHA1."""
+
TAG = b'SHA'
@staticmethod
def make_secret(password):
+ """See `PasswordScheme`."""
h = hashlib.sha1(password)
return encode(h.digest())
@staticmethod
def check_response(challenge, response):
+ """See `PasswordScheme`."""
h = hashlib.sha1(response)
return challenge == encode(h.digest())
class SSHAPasswordScheme(PasswordScheme):
+ """A password scheme that encodes the password using salted SHA1."""
+
TAG = b'SSHA'
@staticmethod
def make_secret(password):
+ """See `PasswordScheme`."""
salt = os.urandom(SALT_LENGTH)
h = hashlib.sha1(password)
h.update(salt)
@@ -119,6 +150,7 @@ class SSHAPasswordScheme(PasswordScheme):
@staticmethod
def check_response(challenge, response):
+ """See `PasswordScheme`."""
# Get the salt from the challenge
challenge_bytes = decode(challenge)
digest = challenge_bytes[:20]
@@ -131,6 +163,8 @@ class SSHAPasswordScheme(PasswordScheme):
# Basic algorithm given by Bob Fleck
class PBKDF2PasswordScheme(PasswordScheme):
+ """RFC 2989 password encoding scheme."""
+
# This is a bit nasty if we wanted a different prf or iterations. OTOH,
# we really have no clue what the standard LDAP-ish specification for
# those options is.
@@ -157,7 +191,9 @@ class PBKDF2PasswordScheme(PasswordScheme):
@staticmethod
def make_secret(password):
- """From RFC2898 sec. 5.2. Simplified to handle only 20 byte output
+ """See `PasswordScheme`.
+
+ From RFC2898 sec. 5.2. Simplified to handle only 20 byte output
case. Output of 20 bytes means always exactly one block to handle,
and a constant block counter appended to the salt in the initial hmac
update.
@@ -167,11 +203,13 @@ class PBKDF2PasswordScheme(PasswordScheme):
derived_key = encode(digest + salt)
return derived_key
+ # pylint: disable-msg=W0221
@staticmethod
def check_response(challenge, response, prf, iterations):
- # Decode the challenge to get the number of iterations and salt
- # XXX we don't support anything but sha prf
- if prf.lower() <> b'sha':
+ """See `PasswordScheme`."""
+ # Decode the challenge to get the number of iterations and salt. We
+ # don't support anything but SHA PRF.
+ if prf.lower() != b'sha':
return False
try:
iterations = int(iterations)
@@ -186,6 +224,7 @@ class PBKDF2PasswordScheme(PasswordScheme):
class Schemes(Enum):
+ """List of password schemes."""
# no_scheme is deliberately ugly because no one should be using it. Yes,
# this makes cleartext inconsistent, but that's a common enough
# terminology to justify the missing underscore.
@@ -215,6 +254,15 @@ _DEFAULT_SCHEME = NoPasswordScheme
def make_secret(password, scheme=None):
+ """Encrypt a password.
+
+ :param password: The clear text password.
+ :type password: string
+ :param scheme: The password scheme name.
+ :type scheme: string
+ :return: The encrypted password.
+ :rtype: string
+ """
# The hash algorithms operate on bytes not strings. The password argument
# as provided here by the client will be a string (in Python 2 either
# unicode or 8-bit, in Python 3 always unicode). We need to encode this
@@ -231,6 +279,15 @@ def make_secret(password, scheme=None):
def check_response(challenge, response):
+ """Check a response against a challenge.
+
+ :param challenge: The challenge.
+ :type challenge: string
+ :param response: The response.
+ :type response: string
+ :return: True if the response matches the challenge.
+ :rtype: bool
+ """
mo = re.match(r'{(?P<scheme>[^}]+?)}(?P<rest>.*)',
challenge, re.IGNORECASE)
if not mo:
@@ -250,4 +307,11 @@ def check_response(challenge, response):
def lookup_scheme(scheme_name):
+ """Look up a password scheme.
+
+ :param scheme_name: The password scheme name.
+ :type scheme_name: string
+ :return: Password scheme class.
+ :rtype: `PasswordScheme`
+ """
return _SCHEMES_BY_TAG.get(scheme_name.lower())
diff --git a/src/mailman/version.py b/src/mailman/version.py
index 68034239a..5c4a0d9c3 100644
--- a/src/mailman/version.py
+++ b/src/mailman/version.py
@@ -15,6 +15,8 @@
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
+"""Mailman version strings."""
+
# Mailman version
VERSION = "3.0.0a3"
CODENAME = 'Working Man'