#!/usr/bin/python # Make sure you are using the python that has the roundup runtime used by the # tracker. Requires Python 2.3 or later. # # Script to create a tracker summary by Richard Jones and Paul Dubois import sys, math # kludge sys.path.insert(1,'/home/roundup/roundup/lib/python2.4/site-packages') # import roundup import roundup.date, roundup.instance import optparse , datetime import cStringIO, MimeWriter, smtplib from roundup.mailer import SMTPConnection ### CONFIGURATION # List of statuses to treat as closed -- these don't have to all exist. resolvedStatusDefault = 'resolved,done,done-cbb,closed,faq' # Period of time for report. Uses roundup syntax for ranges. defaultDates = '-1w;' # Email address of recipient of report, if any. defaultMailTo = '' # Comma-delimited list of statuses not to include in report. ignoredStatusesDefault = '' # Number of most-discussed messages to display discussionMax = 10 # Smallest number of messages to be eligible for 'most discussed'. discussionThreshold = 3 # Date format per time.strftime dateFormat="%F" # ISO 8601: yyyy-mm-dd ##### END CONFIGURATION; ALSO SEE CUSTOMIZATION BELOW usage = """%prog trackerHome [--mail mailTo] [--dates 'date1;date2'] [--brief] [--text] # less likely [--resolved 'status1,status2'] [--status 'status1,status2'] [--output filename] [--errors filename] # for maintainers [--DEBUG] [--AUDIT recent|all] dates is a roundup date range such as: '-1w;' -- the last week (the default) '-3m;' -- the last 3 months 'from 2006-10-25 to 2006-12-25' -- Thanksgiving to Christmas, 2006 mailTo is one or more email addresses, comma delimited. resolved is a list of statuses to treat as 'closed'. statuses are names of statuses to ignore. Be sure to protect commas and semicolons from the shell with quotes! Execute %prog --help for detailed help """ #### Options parser = optparse.OptionParser(usage=usage) parser.add_option('-b','--brief', dest='brief', action='store_true', default=False, help='Show summary only, no tables.') parser.add_option('-a','--audit', dest='audit', default='', metavar = 'all|recent', help='Print journal for "all" or "recent" transactions, then halt.') parser.add_option('-D','--DEBUG', dest='debug', action='store_true', default=False, help='Just print the result; if mailTo set, print email but do not send it.') parser.add_option('-d','--dates', dest='dates', default=defaultDates, metavar="'from;to'", help="""Specification for range of dates, such as: \ '-1w;' -- previous week; '-1y;' -- previous year; 'from 2006-11-1 to 2006-12-1' -- exact period""") parser.add_option('-m','--mail', dest='mailTo', default=defaultMailTo, help='Mail the report to the address(es) mailTo; if not given, print report.') parser.add_option('-s','--status', dest='ignore', metavar="STATUSES_TO_IGNORE", default=ignoredStatusesDefault, help="Comma-delimited list of statuses to ignore in report.") parser.add_option('-r','--resolved', dest='resolved', default=resolvedStatusDefault, help="Comma-delimited list of statuses that corresponds to being 'closed'.") parser.add_option('-t', '--text', dest='text', action='store_true', default=False, help="Write text report only, no HTML.") parser.add_option('-o','--output', dest='output', default='', metavar='FILENAME', help='File name for output; default is stdout.') parser.add_option('-e','--errors', dest='errors', default='', metavar='FILENAME', help='File name for error output; default is stderr.') #### Get the command line args: (options, args) = parser.parse_args() if options.output: sys.stdout = open(options.output, 'w') if options.errors: sys.stderr = open(options.errors, 'w') if len(args) != 1: parser.error("""Incorrect number of arguments; you must supply a tracker home.""") instanceHome = args[0] # Open the instance instance = roundup.instance.open(instanceHome) db = instance.open('admin') # CUSTOMIZATION columnWidth = 72 #for text report dateWidth = len(roundup.date.Date('2006-09-30').pretty(format=dateFormat)) titleWidth = columnWidth - dateWidth #zero means no limit durationWidth = len('9999 days') tableStyle = 'border="1" cellspacing="0" cellpadding="3"' def formatDuration(duration): "Change a duration into text." return "%4.0f days" % (duration.as_seconds()/(3600.*24),) def closedOrBlank(statusID): "CLOSED or a blank" if statusID in resolvedStatusIDS: return "CLOSED" else: return "" def F(**args): "args is the dictionary to return, with additions if desired." return args # Set what fields show up in the different tables. # May need to modify getIssueAttribute to match your schema or add things # you want to compute. # Each field has a title and a field width. # In text mode: # a title of '
, indent' means a new line is started for each # record, and the integer indent indicates an indentation for the next line. # If used, titles are omitted. # In html mode,
is ignored. ### Text Tables # The 'Created or Repopened' table, text mode. createdTable = """ titles: 'Title', titleWidth 'Date', dateWidth '
', 0 'Status', len('CLOSED') 'Link', linkWidth 'Action', actionWidth 'By', userNameWidth '
', len('CLOSED') 'Keywords', columnWidth records: title (reopenedDate or creation).pretty(format=dateFormat) closedOrBlank(statusID) link(issueNumber) reopened or 'created' reopener or creator keywords """ # The table of closed issues, text mode. closedTable = """ titles: 'Title', titleWidth 'Duration', durationWidth '
', len('CLOSED') 'Link', linkWidth 'By', userNameWidth '
', len('CLOSED') 'Keywords', columnWidth records: title formatDuration(duration) link(issueNumber) actor keywords """ # The table of most discussed issues, text mode. discussedTable = """ titles: 'N', -3 'Title', columnWidth - durationWidth 'Duration', durationWidth '
', 0 'Status', statusWidth 'Link', linkWidth records: numberOfMessages title formatDuration(duration) status link(issueNumber) """ ### HTML Tables # The 'Created or Repopened' table, html mode. # Titles are links, use zero on width; limit is applied in hlink htmlCreatedTable = """ titles: 'Title', 0 'Status', len('CLOSED') 'Date', dateWidth 'Action', actionWidth 'By', userNameWidth 'Keywords', 15 records: hlink(issueNumber, title) closedOrBlank(statusID) (reopenedDate or creation).pretty(format=dateFormat) reopened or 'created' reopener or creator keywords """ # The table of closed issues, html mode. htmlClosedTable = """ titles: 'Title', 0 'By', userNameWidth 'Duration', durationWidth 'Keywords', 15 records: hlink(issueNumber, title) actor formatDuration(duration) keywords """ # The table of most discussed issues, text mode. htmlDiscussedTable = """ titles: 'N', -3 'Title', 0 'Status', statusWidth 'Duration', durationWidth records: numberOfMessages hlink(issueNumber, title) status formatDuration(duration) """ ### END CUSTOMIZATION # well, except you might want to add to this...locals in getIssueAttributes # are usable in the 'records' section of tables. # Keywords patchID = db.keyword.lookup('patch') keywordIDS = db.keyword.getnodeids(False) keywordsDict = {} for id in keywordIDS: keywordsDict[id] = db.keyword.get(id, 'name') def isPatch(issueID): keywordIDS = db.issue.get(issueID, 'keywords') return patchID in keywordIDS def getKeywordString (issueID): keywordIDS = db.issue.get(issueID, 'keywords') sep = '' keywords = '' for id in keywordIDS: try: k = keywordsDict[id] keywords += (sep + k) sep = ', ' except KeyError: #retired kw pass return keywords def getIssueAttributes (issueID): """Return dictionaries with the issue's title, etc. Assumes issue was created no later than to_value """ issueNumber = issueID reopened = '' reopener = '' reopenedDate = '' numberOfMessages = 0 status2 = db.issue.get(issueID, 'status') status1 = status2 actor2 = db.issue.get(issueID, 'actor') activity2 = db.issue.get(issueID, 'activity') statusID = None actorID = None activity = None keywords = getKeywordString(issueID) # programming note: # the journal records the OLD value of the status in the data field # So we know the end value (status2) and can walk backwards through it # Want to set statusID = status in effect at to_value # Also discover any activity that changed a resolved to non-resolved. journal = db.issue.history(issueID) journal.reverse() # this trick catches the first time we are in the interval of interest if activity2 < to_value: statusID = statusID or status2 actorID = actorID or actor2 activity = activity or activity2 for x,ts,userid,act,data in journal: inPeriod = ts >= from_value and ts < to_value if inPeriod: statusID = statusID or status2 actorID = actorID or actor2 activity = activity or activity2 if act == 'set': if data.has_key('messages'): if inPeriod: numberOfMessages += 1 if data.has_key('status'): status1 = data['status'] if ts < to_value: if (status1 in resolvedStatusIDS) and \ (status2 not in resolvedStatusIDS): if not reopened: # want the latest reopener only if inPeriod and issueID not in recentlyCreatedIssueIDS: reopenedIssueIDS.append(issueID) reopened = 'reopened' reopener = userMap[userid] reopenedDate = ts actor2 = userid activity2 = ts status2 = status1 messages.append((numberOfMessages, issueID)) # get these set if not done before statusID = statusID or status2 activity = activity or activity2 actorID = actorID or actor2 # calculate the fields for the reports status = statusMap[statusID] actor = userMap[actorID] title = db.issue.get(issueID, 'title') creation = db.issue.get(issueID, 'creation') creatorID = db.issue.get(issueID, 'creator') creator = userMap[creatorID] if statusID in resolvedStatusIDS: duration = activity - (reopenedDate or creation) else: duration = to_value - (reopenedDate or creation) if options.debug: print >>bugfile, issueID, status, creator, actor, "%4.0f" %(duration.as_seconds()/(3600.24)), \ activity.pretty(dateFormat), reopenedDate or 'created', creation.pretty(dateFormat) # out of a sense of neatness del status1, status2, actor2, activity2 del x, ts, userid, act, data, journal # return the dictionary of local values for use in evals of table values. return locals() ### Utility def link (issueID): "url of issue whose ID is issueID" return "%sissue%s" % (db.config.TRACKER_WEB, issueID) def hlink (issueID, title): "html link to url/title of issue whose ID is issueID" return '''%s''' %(link(issueID), title[:titleWidth]) def statusCompare (x, y): """Compare two statusIDs by order.""" xs = db.status.get(x, 'order') ys = db.status.get(y, 'order') c = float(xs) - float(ys) if c >= 0.0: return int(c) else: return -int(abs(c)) def issueIdCompare (x, y): """Compare two issue ids numerically.""" return int(x) - int(y) def issueStatus(issueID): """Get the status name from an issueID.""" sid = db.issue.get(issueID, 'status') return db.status.get(sid, 'name') def statusUsable (statusID): """Is this status is one we want to deal with?""" if db.status.is_retired(statusID): return False if db.status.get(statusID, 'name') in ignoredStatuses: return False return True #Table writers class Table (object): def __init__ (self, caption, issueIDS, tableSpec): self.caption = caption + ' (%d)' % len(issueIDS) self.issueIDS = issueIDS self.breaks = [] self.spec = self.parse(tableSpec) def parse(self, tableSpec): """Sample: titles: 'ID', -6 'Title', 25 'Status', 30 records: id title status """ titles = [] fields = [] fws = [] for line in tableSpec.split('\n'): line = line.strip() if not line: continue if line.startswith("titles"): inTitles = True elif line.startswith("records"): inTitles = False elif inTitles: title, fw = eval(line) if title == '
': self.breaks.append((len(titles), fw)) else: titles.append(title) if fw > 0: fw = max(fw, len(title)) elif fw < 0: fw = min(fw, -len(title)) fws.append(fw) else: fields.append(line) self.titles = titles self.fields = fields self.fws = fws if len(titles) != len(fields): raise ValueError, 'Number of titles and fields do not match.' if len(titles) == 0: raise ValueError, 'No titles or fields given' def write(self, device): if not self.issueIDS: return fields = self.fields fws = self.fws self.prologue(device) self.writeFields(device, fields, fws) self.epilogue(device) def writeTitles(self, device, titles, fws): self.startTitles(device, titles, fws) for i in range(len(titles)): t = titles[i] fw = fws[i] self.startTitle(device, t, fw) self.writeTitle(device, t, fw) self.endTitle(device, t, fw) self.endTitles(device, titles, fws) def writeFields(self, device, fields, fws): for issueID in self.issueIDS: d = issueAttributes[issueID] self.startFields(d, device, fields, fws) for i in range(len(fields)): f = fields[i] fw = fws[i] self.startField(d, device, f, fw) self.writeField(d, device, f, fw) self.endField(d, device, f, fw) for count, indent in self.breaks: if i+1 == count: print >>device if indent: print >>device, ' '.ljust(indent), self.endFields(d, device, fields, fws) def prologue(self, device): print >>device print >>device, self.caption print >>device, '_'*len(self.caption) print >>device if not self.breaks: self.writeTitles(device, self.titles, self.fws) def epilogue(self, device): print >>device def startTitles(self, device, titles, fws): pass def endTitles(self, device, titles, fws): print >>device def startTitle(self, device, title, fw): pass def writeTitle(self, device, title, fw): fwa = abs(fw) if fwa > 0: print >>device, title[0:fwa].ljust(fwa), elif fwa < 0: print >>device, title[0:fwa].rjust(fwa), else: print >>device, title, def endTitle(self, device, title, fw): pass def startFields(self, d, device, fields, fws): pass def endFields(self, d, device, fields, fws): print >>device if self.breaks: print >>device def startField(self, d, device, field, fw): pass def writeField(self, d, device, field, fw): fwa = abs(fw) value = str(eval(field, d, globals())) if fw > 0: print >>device, value[0:fwa].ljust(fwa), elif fw < 0: print >>device, value[0:fwa].rjust(fwa), else: print >>device, value, def endField(self, d, device, field, fw): pass class htmlTable (Table): def __init__ (self, caption, issueIDS, tableSpec): super(htmlTable, self).__init__(caption, issueIDS, tableSpec) def prologue(self, device): print >>device, """
""" % (tableStyle, self.caption) self.writeTitles(device, self.titles, self.fws) def epilogue(self, device): print >>device, '
%s
' def startTitles(self, device, titles, fws): print >>device, ' ' def endTitles(self, device, titles, fws): print >>device, ' ' def startTitle(self, device, title, fw): print >>device, ' ', def writeTitle(self, device, title, fw): if not title: title = ' ' fwa = abs(fw) if fw != 0: print >>device, title[0:fwa], else: print >>device, title, def endTitle(self, device, title, fw): print >>device, '' def startFields(self, d, device, fields, fws): print >>device, ' ' def endFields(self, d, device, fields, fws): print >>device, ' ' def startField(self, d, device, field, fw): print >>device, ' ', def writeField(self, d, device, field, fw): if field == 'issueRef': field = 'issueHRef' fw = 0 value = str(eval(field, d, globals())) fwa = abs(fw) if not value: value = ' ' fwa = 0 if fwa != 0: print >>device, value[0:fwa], else: print >>device, value, def endField(self, d, device, field, fw): print >>device, '' # Summary writers def htmlSummary(body, caption): "Print html summary" print >>body, '

ACTIVITY SUMMARY ('+ from_value.pretty(format=dateFormat) + \ ' - ' + to_value.pretty(format=dateFormat) + ')

' print >>body, '

%s at %s

' % (db.config.TRACKER_NAME, db.config.TRACKER_WEB) print >>body, '

' print >>body, '''To view or respond to any of the issues listed below, simply click on the issue ID. Do not respond to this message.''' print >>body, '

' print >>body, '

' fmt = "%5d open (%+3d) / %5d closed (%+3d) / %5d total (%+3d)" print >>body, '

' print >>body, '

' print >>body, fmt % \ (nOpen, nOpen-nOpenOld, nClosed, nClosed-nClosedOld, (nOpen+nClosed), (nOpen+nClosed)-(nOpenOld+nClosedOld)) print >>body, '

' print >>body, "

" print >>body, "Open issues with patches: %5d" % nPatch print >>body, '

' print >>body, '

' print >>body, "Average duration of open issues:", "%3.0f" % averageDuration, "days." print >>body, '

' print >>body, "

" print >>body, "Median duration of open issues:", "%3.0f" % medianDuration, "days." print >>body, '

' print >>body, '

' print >>body, ''' ''' % (tableStyle, caption) for sid in statusIDS: if sid in resolvedStatusIDS: continue (ntot, nold) = statusReport[sid] name = statusMap[sid] print >>body, ''''''% \ (name, ntot, ntot-nold) print >>body, '
%s
STATUS NumberChange
%s%6d%+6d
' print >>body, '

' def textSummary(body, caption): "Print text summary" print >>body print >>body, 'ACTIVITY SUMMARY ('+ from_value.pretty(format=dateFormat) + \ ' - ' + to_value.pretty(format=dateFormat) + ')' print >>body, '%s at %s' % (db.config.TRACKER_NAME, db.config.TRACKER_WEB) print >>body, ''' To view or respond to any of the issues listed below, click on the issue number. Do NOT respond to this message. ''' print >>body fmt = "%5d open (%+3d) / %5d closed (%+3d) / %5d total (%+3d)" print >>body, fmt % \ (nOpen, nOpen-nOpenOld, nClosed, nClosed-nClosedOld, (nOpen+nClosed), (nOpen+nClosed)-(nOpenOld+nClosedOld)) print >>body print >>body, "Open issues with patches: %5d" % nPatch print >>body print >>body, "Average duration of open issues:", "%3.0f" % averageDuration, "days." print >>body, "Median duration of open issues:", "%3.0f" % medianDuration, "days." print >>body print >>body, caption for sid in statusIDS: if sid in resolvedStatusIDS: continue (ntot, nold) = statusReport[sid] name = statusMap[sid] fmt = "%" + str(statusWidth) + "s %5d (%+3d)" print >>body, (fmt % (name, ntot, ntot-nold)) def writeTextReport(device): "Write the report in text mode to device." textSummary(device, "Open Issues Breakdown") if not options.brief: messages.sort() messages.reverse() t = Table('Issues Created Or Reopened', createdOrReopenedIDS, createdTable) t.write(device) t = Table('Issues Now Closed', recentlyClosedIssueIDS, closedTable) t.write(device) t = Table('Top Issues Most Discussed', discussedIDS, discussedTable) t.write(device) def writeHtmlReport(device): "Write the report in html mode to device." htmlSummary(device, 'Open Issues Breakdown') if not options.brief: t = htmlTable('Issues Created Or Reopened', createdOrReopenedIDS, htmlCreatedTable) t.write(device) t = htmlTable('Issues Now Closed', recentlyClosedIssueIDS, htmlClosedTable) t.write(device) t = htmlTable('Top Issues Most Discussed', discussedIDS, htmlDiscussedTable) t.write(device) def writeAudit(device, issueIDS): "Write audit of given issues to device." for issueID in issueIDS: journal = db.issue.history(issueID) for x,ts,userid,act,data in journal: user = db.user.get(userid, 'username') print >>device, x, ts, '%s (%s)'%(userid, user), act if act == 'set': for d, v in data.items(): print >>device, ' ', d, ': ', v # Options processing if options.debug: bugfile = open('bugfile', 'w') print >>bugfile, options # ...dates dates = roundup.date.Range(options.dates, roundup.date.Date) from_value = dates.from_value to_value = dates.to_value if to_value is None: to_value = roundup.date.Date('.') if from_value is None: from_value = roundup.date.Date('1980-1-1') early_period = ';%s'%from_value whole_period = ';%s'%to_value # ... statuses ignoredStatuses = [s.strip() for s in options.ignore.split(',')] if options.debug: print >>bugfile, "from,to", from_value, to_value print >>bugfile, 'ignoredStatuses', ignoredStatuses # ...audit if options.audit: # Name of audit file (not usually used by normal users) auditFilename = 'audit.txt' if options.audit not in ['all', 'recent']: raise ValueError, "AUDIT option must be 'recent' or 'all'." audit = open(auditFilename, 'w') if options.audit == 'recent': IDS = db.issue.filter(None, F(activity=options.dates)) elif options.audit == 'full': IDS = db.issue.filter(None, F(activity=whole_period)) IDS.sort(issueIdCompare) writeAudit(audit, IDS) audit.close() sys.exit(0) # MAIN # Make lists and maps of things # Users adminUserID = db.user.lookup('admin') userIDS_ALL = db.user.getnodeids(retired=None) userMap = {None: adminUserID} userLookup = {} for userid in userIDS_ALL: name = db.user.get(userid, 'username') userLookup[name] = userid userMap[userid] = name userNames = userLookup.keys() userNameWidth = max([len(uname) for uname in userNames]) # Make map of statuses / names, including retired ones. # Just doing lookup on names will miss retired ones. statusIDS_ALL = db.status.getnodeids(retired=None) statusLookup = {'None': None} statusMap = {None: 'None'} for sid in statusIDS_ALL: name = db.status.get(sid, 'name') statusLookup[name] = sid statusMap[sid] = name # Make a list of statuses considered closed (including retired ones) resolvedStatusOptions = options.resolved.split(',') resolvedStatusIDS = [] for r in resolvedStatusOptions: try: sid = statusLookup[r] resolvedStatusIDS.append(sid) except KeyError: pass if options.debug: print >>bugfile, 'statusLookup', statusLookup print >>bugfile, 'resolvedStatusIDS', resolvedStatusIDS statusIDS = [sid for sid in statusIDS_ALL if statusUsable(sid)] statusIDS.sort(statusCompare) statusNames = [statusMap[sid] for sid in statusIDS] statusWidth = max([len(s) for s in statusLookup.keys()]) actionWidth = len('reopened') # Important note: for 'all', get only issues created before to_value # Not just efficient, simplifies logic in getIssueAttribue # All issues allIssueIDS = db.issue.filter(None, F(creation=whole_period)) allIssueIDS.sort(issueIdCompare) earlyIssueIDS = db.issue.filter(None, F(creation=early_period)) # Closed issues oldClosedIssueIDS = db.issue.filter(None, F(creation=early_period, status= resolvedStatusIDS)) closedIssueIDS = db.issue.filter(None,F(creation=whole_period, status=resolvedStatusIDS)) recentlyClosedIssueIDS=db.issue.filter(None, F(activity=options.dates, status=resolvedStatusIDS)) recentlyClosedIssueIDS.sort(issueIdCompare) # Created and reopened issues recentlyCreatedIssueIDS=db.issue.filter(None, F(creation=options.dates)) reopenedIssueIDS = [] # gets accumulated in getIssueAttributes # Create more data needed for reports if allIssueIDS: maxIssueID = allIssueIDS[-1] else: maxIssueID = 1000 linkWidth = len(link(maxIssueID)) messages = [] statusReport = {} issueAttributes = {} for issueID in allIssueIDS: issueAttributes[issueID] = getIssueAttributes(issueID) createdOrReopenedIDS = reopenedIssueIDS + recentlyCreatedIssueIDS createdOrReopenedIDS.sort(issueIdCompare) durations = [issueAttributes[id]['duration'].as_seconds()/(3600*24.) for id in allIssueIDS if \ id not in closedIssueIDS] if durations: if options.debug: print >>bugfile, 'Min duration:', durations[-1], '; max', durations[0] averageDuration = sum(durations)/len(durations) durations.sort() medianDuration = durations[len(durations)/2] else: averageDuration = "N/A" medianDuration = "N/A" messages.sort() messages.reverse() discussedIDS = [id for (num, id) in messages[:discussionMax] \ if num >= discussionThreshold] nTotal = len(allIssueIDS) nTotalOld = len(earlyIssueIDS) nClosed = len(closedIssueIDS) nClosedOld = len(oldClosedIssueIDS) nOpen = nTotal - nClosed nOpenOld = nTotalOld - nClosedOld nPatch = 0 for id in allIssueIDS: if id in closedIssueIDS: continue if isPatch(id): nPatch += 1 for sid in statusIDS: IDS = db.issue.filter(None, F(creation=whole_period, status=sid)) IDSOld = db.issue.filter(None, F(creation=early_period, status=sid)) nNow, nThen = (len(IDS), len(IDSOld)) statusReport[sid] = (nNow, nThen) # Now make the reports textReport = cStringIO.StringIO() writeTextReport(textReport) htmlReport = cStringIO.StringIO() writeHtmlReport(htmlReport) def sendReport (recipient): "Send the email message." message = cStringIO.StringIO() writer = MimeWriter.MimeWriter(message) writer.addheader('Subject', 'Summary of %s Issues'%db.config.TRACKER_NAME) writer.addheader('To', recipient) writer.addheader('From', '%s <%s>'%(db.config.TRACKER_NAME, 'status@bugs.python.org')) writer.addheader('Reply-To', '%s <%s>'%(db.config.TRACKER_NAME, 'status@bugs.python.org')) writer.addheader('MIME-Version', '1.0') writer.addheader('X-Roundup-Name', db.config.TRACKER_NAME) # start the multipart part = writer.startmultipartbody('alternative') part = writer.nextpart() body = part.startbody('text/plain') # do the plain text bit print >>body, textReport.getvalue() # now the HTML one if not options.text: part = writer.nextpart() body = part.startbody('text/html') print >>body, htmlReport.getvalue() # finish off the multipart writer.lastpart() # all done, send! if options.debug: print message.getvalue() else: smtp = SMTPConnection(db.config) smtp.sendmail(db.config.ADMIN_EMAIL, options.mailTo, message.getvalue()) # Email? Or just print it. if not options.mailTo: print textReport.getvalue() else: for recipient in options.mailTo.split(','): sendReport(recipient)