| Trees | Indices | Help |
|
|---|
|
|
1 # This file is part of Buildbot. Buildbot is free software: you can
2 # redistribute it and/or modify it under the terms of the GNU General Public
3 # License as published by the Free Software Foundation, version 2.
4 #
5 # This program is distributed in the hope that it will be useful, but WITHOUT
6 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
7 # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
8 # details.
9 #
10 # You should have received a copy of the GNU General Public License along with
11 # this program; if not, write to the Free Software Foundation, Inc., 51
12 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
13 #
14 # Copyright Buildbot Team Members
15
16
17 import re
18 import types
19 from email.Message import Message
20 from email.Utils import formatdate
21 from email.MIMEText import MIMEText
22 from email.MIMENonMultipart import MIMENonMultipart
23 from email.MIMEMultipart import MIMEMultipart
24 from StringIO import StringIO
25 import urllib
26
27 from zope.interface import implements
28 from twisted.internet import defer, reactor
29 from twisted.python import log as twlog
30
31 try:
32 from twisted.mail.smtp import ESMTPSenderFactory
33 ESMTPSenderFactory = ESMTPSenderFactory # for pyflakes
34 except ImportError:
35 ESMTPSenderFactory = None
36
37 have_ssl = True
38 try:
39 from twisted.internet import ssl
40 from OpenSSL.SSL import SSLv3_METHOD
41 except ImportError:
42 have_ssl = False
43
44 from buildbot import interfaces, util, config
45 from buildbot.process.users import users
46 from buildbot.status import base
47 from buildbot.status.results import FAILURE, SUCCESS, WARNINGS, Results
48
49 VALID_EMAIL = re.compile("[a-zA-Z0-9\.\_\%\-\+]+@[a-zA-Z0-9\.\_\%\-]+.[a-zA-Z]{2,6}")
50
51 ENCODING = 'utf8'
52 LOG_ENCODING = 'utf-8'
67
70 """Generate a buildbot mail message and return a tuple of message text
71 and type."""
72 ss = build.getSourceStamp()
73 prev = build.getPreviousBuild()
74
75 text = ""
76 if results == FAILURE:
77 if "change" in mode and prev and prev.getResults() != results or \
78 "problem" in mode and prev and prev.getResults() != FAILURE:
79 text += "The Buildbot has detected a new failure"
80 else:
81 text += "The Buildbot has detected a failed build"
82 elif results == WARNINGS:
83 text += "The Buildbot has detected a problem in the build"
84 elif results == SUCCESS:
85 if "change" in mode and prev and prev.getResults() != results:
86 text += "The Buildbot has detected a restored build"
87 else:
88 text += "The Buildbot has detected a passing build"
89
90 if ss and ss.project:
91 project = ss.project
92 else:
93 project = master_status.getTitle()
94 text += " on builder %s while building %s.\n" % (name, project)
95
96 if master_status.getURLForThing(build):
97 text += "Full details are available at:\n %s\n" % master_status.getURLForThing(build)
98 text += "\n"
99
100 if master_status.getBuildbotURL():
101 text += "Buildbot URL: %s\n\n" % urllib.quote(master_status.getBuildbotURL(), '/:')
102
103 text += "Buildslave for this Build: %s\n\n" % build.getSlavename()
104 text += "Build Reason: %s\n" % build.getReason()
105
106 source = ""
107 if ss and ss.branch:
108 source += "[branch %s] " % ss.branch
109 if ss and ss.revision:
110 source += str(ss.revision)
111 else:
112 source += "HEAD"
113 if ss and ss.patch:
114 source += " (plus patch)"
115
116 text += "Build Source Stamp: %s\n" % source
117
118 text += "Blamelist: %s\n" % ",".join(build.getResponsibleUsers())
119
120 text += "\n"
121
122 t = build.getText()
123 if t:
124 t = ": " + " ".join(t)
125 else:
126 t = ""
127
128 if results == SUCCESS:
129 text += "Build succeeded!\n"
130 elif results == WARNINGS:
131 text += "Build Had Warnings%s\n" % t
132 else:
133 text += "BUILD FAILED%s\n" % t
134
135 text += "\n"
136 text += "sincerely,\n"
137 text += " -The Buildbot\n"
138 text += "\n"
139 return { 'body' : text, 'type' : 'plain' }
140
142 """This is a status notifier which sends email to a list of recipients
143 upon the completion of each build. It can be configured to only send out
144 mail for certain builds, and only send messages when the build fails, or
145 when it transitions from success to failure. It can also be configured to
146 include various build logs in each message.
147
148 By default, the message will be sent to the Interested Users list, which
149 includes all developers who made changes in the build. You can add
150 additional recipients with the extraRecipients argument.
151
152 To get a simple one-message-per-build (say, for a mailing list), use
153 sendToInterestedUsers=False, extraRecipients=['listaddr@example.org']
154
155 Each MailNotifier sends mail to a single set of recipients. To send
156 different kinds of mail to different recipients, use multiple
157 MailNotifiers.
158 """
159
160 implements(interfaces.IEmailSender)
161
162 compare_attrs = ["extraRecipients", "lookup", "fromaddr", "mode",
163 "categories", "builders", "addLogs", "relayhost",
164 "subject", "sendToInterestedUsers", "customMesg",
165 "messageFormatter", "extraHeaders"]
166
167 possible_modes = ("change", "failing", "passing", "problem", "warnings")
168
169 - def __init__(self, fromaddr, mode=("failing", "passing", "warnings"),
170 categories=None, builders=None, addLogs=False,
171 relayhost="localhost", buildSetSummary=False,
172 subject="buildbot %(result)s in %(title)s on %(builder)s",
173 lookup=None, extraRecipients=[],
174 sendToInterestedUsers=True, customMesg=None,
175 messageFormatter=defaultMessage, extraHeaders=None,
176 addPatch=True, useTls=False,
177 smtpUser=None, smtpPassword=None, smtpPort=25):
178 """
179 @type fromaddr: string
180 @param fromaddr: the email address to be used in the 'From' header.
181 @type sendToInterestedUsers: boolean
182 @param sendToInterestedUsers: if True (the default), send mail to all
183 of the Interested Users. If False, only
184 send mail to the extraRecipients list.
185
186 @type extraRecipients: tuple of strings
187 @param extraRecipients: a list of email addresses to which messages
188 should be sent (in addition to the
189 InterestedUsers list, which includes any
190 developers who made Changes that went into this
191 build). It is a good idea to create a small
192 mailing list and deliver to that, then let
193 subscribers come and go as they please. The
194 addresses in this list are used literally (they
195 are not processed by lookup).
196
197 @type subject: string
198 @param subject: a string to be used as the subject line of the message.
199 %(builder)s will be replaced with the name of the
200 builder which provoked the message.
201
202 @type mode: list of strings
203 @param mode: a list of MailNotifer.possible_modes:
204 - "change": send mail about builds which change status
205 - "failing": send mail about builds which fail
206 - "passing": send mail about builds which succeed
207 - "problem": send mail about a build which failed
208 when the previous build passed
209 - "warnings": send mail if a build contain warnings
210 Defaults to ("failing", "passing", "warnings").
211
212 @type builders: list of strings
213 @param builders: a list of builder names for which mail should be
214 sent. Defaults to None (send mail for all builds).
215 Use either builders or categories, but not both.
216
217 @type categories: list of strings
218 @param categories: a list of category names to serve status
219 information for. Defaults to None (all
220 categories). Use either builders or categories,
221 but not both.
222
223 @type addLogs: boolean
224 @param addLogs: if True, include all build logs as attachments to the
225 messages. These can be quite large. This can also be
226 set to a list of log names, to send a subset of the
227 logs. Defaults to False.
228
229 @type addPatch: boolean
230 @param addPatch: if True, include the patch when the source stamp
231 includes one.
232
233 @type relayhost: string
234 @param relayhost: the host to which the outbound SMTP connection
235 should be made. Defaults to 'localhost'
236
237 @type buildSetSummary: boolean
238 @param buildSetSummary: if True, this notifier will only send a summary
239 email when a buildset containing any of its
240 watched builds completes
241
242 @type lookup: implementor of {IEmailLookup}
243 @param lookup: object which provides IEmailLookup, which is
244 responsible for mapping User names for Interested
245 Users (which come from the VC system) into valid
246 email addresses. If not provided, the notifier will
247 only be able to send mail to the addresses in the
248 extraRecipients list. Most of the time you can use a
249 simple Domain instance. As a shortcut, you can pass
250 as string: this will be treated as if you had provided
251 Domain(str). For example, lookup='twistedmatrix.com'
252 will allow mail to be sent to all developers whose SVN
253 usernames match their twistedmatrix.com account names.
254
255 @type customMesg: func
256 @param customMesg: (this function is deprecated)
257
258 @type messageFormatter: func
259 @param messageFormatter: function taking (mode, name, build, result,
260 master_status) and returning a dictionary
261 containing two required keys "body" and "type",
262 with a third optional key, "subject". The
263 "body" key gives a string that contains the
264 complete text of the message. The "type" key
265 is the message type ('plain' or 'html'). The
266 'html' type should be used when generating an
267 HTML message. The optional "subject" key
268 gives the subject for the email.
269
270 @type extraHeaders: dict
271 @param extraHeaders: A dict of extra headers to add to the mail. It's
272 best to avoid putting 'To', 'From', 'Date',
273 'Subject', or 'CC' in here. Both the names and
274 values may be WithProperties instances.
275
276 @type useTls: boolean
277 @param useTls: Send emails using TLS and authenticate with the
278 smtp host. Defaults to False.
279
280 @type smtpUser: string
281 @param smtpUser: The user that will attempt to authenticate with the
282 relayhost when useTls is True.
283
284 @type smtpPassword: string
285 @param smtpPassword: The password that smtpUser will use when
286 authenticating with relayhost.
287
288 @type smtpPort: int
289 @param smtpPort: The port that will be used when connecting to the
290 relayhost. Defaults to 25.
291 """
292 base.StatusReceiverMultiService.__init__(self)
293
294 if not isinstance(extraRecipients, (list, tuple)):
295 config.error("extraRecipients must be a list or tuple")
296 else:
297 for r in extraRecipients:
298 if not isinstance(r, str) or not VALID_EMAIL.search(r):
299 config.error(
300 "extra recipient %r is not a valid email" % (r,))
301 self.extraRecipients = extraRecipients
302 self.sendToInterestedUsers = sendToInterestedUsers
303 self.fromaddr = fromaddr
304 if isinstance(mode, basestring):
305 if mode == "all":
306 mode = ("failing", "passing", "warnings")
307 elif mode == "warnings":
308 mode = ("failing", "warnings")
309 else:
310 mode = (mode,)
311 for m in mode:
312 if m not in self.possible_modes:
313 config.error(
314 "mode %s is not a valid mode" % (m,))
315 self.mode = mode
316 self.categories = categories
317 self.builders = builders
318 self.addLogs = addLogs
319 self.relayhost = relayhost
320 if '\n' in subject:
321 config.error(
322 'Newlines are not allowed in email subjects')
323 self.subject = subject
324 if lookup is not None:
325 if type(lookup) is str:
326 lookup = Domain(lookup)
327 assert interfaces.IEmailLookup.providedBy(lookup)
328 self.lookup = lookup
329 self.customMesg = customMesg
330 self.messageFormatter = messageFormatter
331 if extraHeaders:
332 if not isinstance(extraHeaders, dict):
333 config.error("extraHeaders must be a dictionary")
334 self.extraHeaders = extraHeaders
335 self.addPatch = addPatch
336 self.useTls = useTls
337 self.smtpUser = smtpUser
338 self.smtpPassword = smtpPassword
339 self.smtpPort = smtpPort
340 self.buildSetSummary = buildSetSummary
341 self.buildSetSubscription = None
342 self.watched = []
343 self.master_status = None
344
345 # you should either limit on builders or categories, not both
346 if self.builders != None and self.categories != None:
347 config.error(
348 "Please specify only builders or categories to include - " +
349 "not both.")
350
351 if customMesg:
352 config.error(
353 "customMesg is deprecated; use messageFormatter instead")
354
356 """
357 @type parent: L{buildbot.master.BuildMaster}
358 """
359 base.StatusReceiverMultiService.setServiceParent(self, parent)
360 self.master_status = self.parent
361 self.master_status.subscribe(self)
362 self.master = self.master_status.master
363
365 if self.buildSetSummary:
366 self.buildSetSubscription = \
367 self.master.subscribeToBuildsetCompletions(self.buildsetFinished)
368
369 base.StatusReceiverMultiService.startService(self)
370
372 if self.buildSetSubscription is not None:
373 self.buildSetSubscription.unsubscribe()
374 self.buildSetSubscription = None
375
376 return base.StatusReceiverMultiService.stopService(self)
377
379 self.master_status.unsubscribe(self)
380 self.master_status = None
381 for w in self.watched:
382 w.unsubscribe(self)
383 return base.StatusReceiverMultiService.disownServiceParent(self)
384
386 # only subscribe to builders we are interested in
387 if self.categories != None and builder.category not in self.categories:
388 return None
389
390 self.watched.append(builder)
391 return self # subscribe to this builder
392
395
398
401
403 # here is where we actually do something.
404 builder = build.getBuilder()
405 if self.builders is not None and builder.name not in self.builders:
406 return False # ignore this build
407 if self.categories is not None and \
408 builder.category not in self.categories:
409 return False # ignore this build
410
411 prev = build.getPreviousBuild()
412 if "change" in self.mode:
413 if prev and prev.getResults() != results:
414 return True
415 if "failing" in self.mode and results == FAILURE:
416 return True
417 if "passing" in self.mode and results == SUCCESS:
418 return True
419 if "problem" in self.mode and results == FAILURE:
420 if prev and prev.getResults() != FAILURE:
421 return True
422 if "warnings" in self.mode and results == WARNINGS:
423 return True
424
425 return False
426
428 if ( not self.buildSetSummary and
429 self.isMailNeeded(build, results) ):
430 # for testing purposes, buildMessage returns a Deferred that fires
431 # when the mail has been sent. To help unit tests, we return that
432 # Deferred here even though the normal IStatusReceiver.buildFinished
433 # signature doesn't do anything with it. If that changes (if
434 # .buildFinished's return value becomes significant), we need to
435 # rearrange this.
436 return self.buildMessage(name, [build], results)
437 return None
438
440 builds = []
441 for (builddictlist, builder) in res:
442 for builddict in builddictlist:
443 build = builder.getBuild(builddict['number'])
444 if build is not None and self.isMailNeeded(build, build.results):
445 builds.append(build)
446
447 self.buildMessage("Buildset Complete: " + buildset['reason'], builds,
448 buildset['results'])
449
451 dl = []
452 for breq in breqs:
453 buildername = breq['buildername']
454 builder = self.master_status.getBuilder(buildername)
455 d = self.master.db.builds.getBuildsForRequest(breq['brid'])
456 d.addCallback(lambda builddictlist: (builddictlist, builder))
457 dl.append(d)
458 d = defer.gatherResults(dl)
459 d.addCallback(self._gotBuilds, buildset)
460
462 d = self.master.db.buildrequests.getBuildRequests(bsid=bsid)
463 d.addCallback(self._gotBuildRequests, buildset)
464
466 d = self.master.db.buildsets.getBuildset(bsid=bsid)
467 d.addCallback(self._gotBuildSet, bsid)
468
469 return d
470
472 #
473 # logs is a list of tuples that contain the log
474 # name, log url, and the log contents as a list of strings.
475 #
476 logs = list()
477 for logf in build.getLogs():
478 logStep = logf.getStep()
479 stepName = logStep.getName()
480 logStatus, dummy = logStep.getResults()
481 logName = logf.getName()
482 logs.append(('%s.%s' % (stepName, logName),
483 '%s/steps/%s/logs/%s' % (
484 master_status.getURLForThing(build),
485 stepName, logName),
486 logf.getText().splitlines(),
487 logStatus))
488
489 attrs = {'builderName': name,
490 'title': master_status.getTitle(),
491 'mode': mode,
492 'result': Results[results],
493 'buildURL': master_status.getURLForThing(build),
494 'buildbotURL': master_status.getBuildbotURL(),
495 'buildText': build.getText(),
496 'buildProperties': build.getProperties(),
497 'slavename': build.getSlavename(),
498 'reason': build.getReason(),
499 'responsibleUsers': build.getResponsibleUsers(),
500 'branch': "",
501 'revision': "",
502 'patch': "",
503 'patch_info': "",
504 'changes': [],
505 'logs': logs}
506
507 ss = build.getSourceStamp()
508 if ss:
509 attrs['branch'] = ss.branch
510 attrs['revision'] = ss.revision
511 attrs['patch'] = ss.patch
512 attrs['patch_info'] = ss.patch_info
513 attrs['changes'] = ss.changes[:]
514
515 return attrs
516
518 # patches don't come with an encoding. If the patch is valid utf-8,
519 # we'll attach it as MIMEText; otherwise, it gets attached as a binary
520 # file. This will suit the vast majority of cases, since utf8 is by
521 # far the most common encoding.
522 if type(patch[1]) != types.UnicodeType:
523 try:
524 unicode = patch[1].decode('utf8')
525 except UnicodeDecodeError:
526 unicode = None
527 else:
528 unicode = patch[1]
529
530 if unicode:
531 a = MIMEText(unicode.encode(ENCODING), _charset=ENCODING)
532 else:
533 # MIMEApplication is not present in Python-2.4 :(
534 a = MIMENonMultipart('application', 'octet-stream')
535 a.set_payload(patch[1])
536 a.add_header('Content-Disposition', "attachment",
537 filename="source patch " + str(index) )
538 return a
539
540 - def createEmail(self, msgdict, builderName, title, results, builds=None,
541 patches=None, logs=None):
542 text = msgdict['body'].encode(ENCODING)
543 type = msgdict['type']
544 if 'subject' in msgdict:
545 subject = msgdict['subject'].encode(ENCODING)
546 else:
547 subject = self.subject % { 'result': Results[results],
548 'projectName': title,
549 'title': title,
550 'builder': builderName,
551 }
552
553 assert '\n' not in subject, \
554 "Subject cannot contain newlines"
555
556 assert type in ('plain', 'html'), \
557 "'%s' message type must be 'plain' or 'html'." % type
558
559 if patches or logs:
560 m = MIMEMultipart()
561 m.attach(MIMEText(text, type, ENCODING))
562 else:
563 m = Message()
564 m.set_payload(text, ENCODING)
565 m.set_type("text/%s" % type)
566
567 m['Date'] = formatdate(localtime=True)
568 m['Subject'] = subject
569 m['From'] = self.fromaddr
570 # m['To'] is added later
571
572 if patches:
573 for (i, patch) in enumerate(patches):
574 a = self.patch_to_attachment(patch, i)
575 m.attach(a)
576 if logs:
577 for log in logs:
578 name = "%s.%s" % (log.getStep().getName(),
579 log.getName())
580 if ( self._shouldAttachLog(log.getName()) or
581 self._shouldAttachLog(name) ):
582 text = log.getText()
583 if not isinstance(text, unicode):
584 text = text.decode(LOG_ENCODING)
585 a = MIMEText(text.encode(ENCODING),
586 _charset=ENCODING)
587 a.add_header('Content-Disposition', "attachment",
588 filename=name)
589 m.attach(a)
590
591 #@todo: is there a better way to do this?
592 # Add any extra headers that were requested, doing WithProperties
593 # interpolation if only one build was given
594 if self.extraHeaders:
595 if len(builds) == 1:
596 extraHeaders = builds[0].render(self.extraHeaders)
597 else:
598 extraHeaders = self.extraHeaders
599 for k,v in extraHeaders.items():
600 if k in m:
601 twlog.msg("Warning: Got header " + k +
602 " in self.extraHeaders "
603 "but it already exists in the Message - "
604 "not adding it.")
605 m[k] = v
606
607 return m
608
610 if self.customMesg:
611 # the customMesg stuff can be *huge*, so we prefer not to load it
612 attrs = self.getCustomMesgData(self.mode, name, build, results,
613 self.master_status)
614 text, type = self.customMesg(attrs)
615 msgdict = { 'body' : text, 'type' : type }
616 else:
617 msgdict = self.messageFormatter(self.mode, name, build, results,
618 self.master_status)
619
620 return msgdict
621
622
624 patches = []
625 logs = []
626 msgdict = {"body":""}
627
628 for build in builds:
629 ss = build.getSourceStamp()
630 if ss and ss.patch and self.addPatch:
631 patches.append(ss.patch)
632 if self.addLogs:
633 logs.extend(build.getLogs())
634
635 tmp = self.buildMessageDict(name=build.getBuilder().name,
636 build=build, results=build.results)
637 msgdict['body'] += tmp['body']
638 msgdict['body'] += '\n\n'
639 msgdict['type'] = tmp['type']
640 if "subject" in tmp:
641 msgdict['subject'] = tmp['subject']
642
643 m = self.createEmail(msgdict, name, self.master_status.getTitle(),
644 results, builds, patches, logs)
645
646 # now, who is this message going to?
647 if self.sendToInterestedUsers:
648 dl = []
649 for build in builds:
650 if self.lookup:
651 d = self.useLookup(build)
652 else:
653 d = self.useUsers(build)
654 dl.append(d)
655 d = defer.gatherResults(dl)
656 else:
657 d = defer.succeed([])
658 d.addCallback(self._gotRecipients, m)
659 return d
660
662 dl = []
663 for u in build.getInterestedUsers():
664 d = defer.maybeDeferred(self.lookup.getAddress, u)
665 dl.append(d)
666 return defer.gatherResults(dl)
667
669 dl = []
670 ss = build.getSourceStamp()
671 for change in ss.changes:
672 d = self.master.db.changes.getChangeUids(change.number)
673 def getContacts(uids):
674 def uidContactPair(contact, uid):
675 return (contact, uid)
676 contacts = []
677 for uid in uids:
678 d = users.getUserContact(self.master,
679 contact_type='email',
680 uid=uid)
681 d.addCallback(lambda contact: uidContactPair(contact, uid))
682 contacts.append(d)
683 return defer.gatherResults(contacts)
684 d.addCallback(getContacts)
685 def logNoMatch(contacts):
686 for pair in contacts:
687 contact, uid = pair
688 if contact is None:
689 twlog.msg("Unable to find email for uid: %r" % uid)
690 return [pair[0] for pair in contacts]
691 d.addCallback(logNoMatch)
692 def addOwners(recipients):
693 owners = [e for e in build.getInterestedUsers()
694 if e not in build.getResponsibleUsers()]
695 recipients.extend(owners)
696 return recipients
697 d.addCallback(addOwners)
698 dl.append(d)
699 d = defer.gatherResults(dl)
700 @d.addCallback
701 def gatherRecipients(res):
702 recipients = []
703 map(recipients.extend, res)
704 return recipients
705 return d
706
711
713 to_recipients = set()
714 cc_recipients = set()
715
716 for r in reduce(list.__add__, rlist, []):
717 if r is None: # getAddress didn't like this address
718 continue
719
720 # Git can give emails like 'User' <user@foo.com>@foo.com so check
721 # for two @ and chop the last
722 if r.count('@') > 1:
723 r = r[:r.rindex('@')]
724
725 if VALID_EMAIL.search(r):
726 to_recipients.add(r)
727 else:
728 twlog.msg("INVALID EMAIL: %r" + r)
729
730 # If we're sending to interested users put the extras in the
731 # CC list so they can tell if they are also interested in the
732 # change:
733 if self.sendToInterestedUsers and to_recipients:
734 cc_recipients.update(self.extraRecipients)
735 else:
736 to_recipients.update(self.extraRecipients)
737
738 m['To'] = ", ".join(sorted(to_recipients))
739 if cc_recipients:
740 m['CC'] = ", ".join(sorted(cc_recipients))
741
742 return self.sendMessage(m, list(to_recipients | cc_recipients))
743
745 result = defer.Deferred()
746
747 if have_ssl and self.useTls:
748 client_factory = ssl.ClientContextFactory()
749 client_factory.method = SSLv3_METHOD
750 else:
751 client_factory = None
752
753 if self.smtpUser and self.smtpPassword:
754 useAuth = True
755 else:
756 useAuth = False
757
758 if not ESMTPSenderFactory:
759 raise RuntimeError("twisted-mail is not installed - cannot "
760 "send mail")
761 sender_factory = ESMTPSenderFactory(
762 self.smtpUser, self.smtpPassword,
763 self.fromaddr, recipients, StringIO(s),
764 result, contextFactory=client_factory,
765 requireTransportSecurity=self.useTls,
766 requireAuthentication=useAuth)
767
768 reactor.connectTCP(self.relayhost, self.smtpPort, sender_factory)
769
770 return result
771
776
| Trees | Indices | Help |
|
|---|
| Generated by Epydoc 3.0.1 on Sun Mar 25 19:40:38 2012 | http://epydoc.sourceforge.net |