aboutsummaryrefslogtreecommitdiffstats
path: root/interfaces/email
diff options
context:
space:
mode:
Diffstat (limited to 'interfaces/email')
-rw-r--r--interfaces/email/interactive/README145
-rw-r--r--interfaces/email/interactive/_procmailrc22
-rw-r--r--interfaces/email/interactive/be-handle-mail950
-rw-r--r--interfaces/email/interactive/becommands/assign.py87
-rw-r--r--interfaces/email/interactive/becommands/close.py60
-rw-r--r--interfaces/email/interactive/becommands/comment.py228
-rw-r--r--interfaces/email/interactive/becommands/commit.py78
-rw-r--r--interfaces/email/interactive/becommands/depend.py339
-rw-r--r--interfaces/email/interactive/becommands/diff.py120
-rw-r--r--interfaces/email/interactive/becommands/help.py68
-rw-r--r--interfaces/email/interactive/becommands/html.py588
-rw-r--r--interfaces/email/interactive/becommands/init.py100
-rw-r--r--interfaces/email/interactive/becommands/list.py248
-rw-r--r--interfaces/email/interactive/becommands/merge.py165
-rw-r--r--interfaces/email/interactive/becommands/new.py80
-rw-r--r--interfaces/email/interactive/becommands/open.py58
-rw-r--r--interfaces/email/interactive/becommands/remove.py62
-rw-r--r--interfaces/email/interactive/becommands/set.py130
-rw-r--r--interfaces/email/interactive/becommands/severity.py103
-rw-r--r--interfaces/email/interactive/becommands/show.py116
-rw-r--r--interfaces/email/interactive/becommands/status.py115
-rw-r--r--interfaces/email/interactive/becommands/subscribe.py390
-rw-r--r--interfaces/email/interactive/becommands/tag.py134
-rw-r--r--interfaces/email/interactive/becommands/target.py95
-rw-r--r--interfaces/email/interactive/examples/comment11
-rw-r--r--interfaces/email/interactive/examples/failing_multiples16
-rw-r--r--interfaces/email/interactive/examples/invalid_command11
-rw-r--r--interfaces/email/interactive/examples/invalid_subject9
-rw-r--r--interfaces/email/interactive/examples/list11
-rw-r--r--interfaces/email/interactive/examples/missing_command11
-rw-r--r--interfaces/email/interactive/examples/multiple_commands14
-rw-r--r--interfaces/email/interactive/examples/new19
-rw-r--r--interfaces/email/interactive/examples/new_with_comment13
-rw-r--r--interfaces/email/interactive/examples/show11
-rw-r--r--interfaces/email/interactive/examples/unicode11
-rw-r--r--interfaces/email/interactive/libbe/arch.py312
-rw-r--r--interfaces/email/interactive/libbe/beuuid.py63
-rw-r--r--interfaces/email/interactive/libbe/bug.py580
-rw-r--r--interfaces/email/interactive/libbe/bugdir.py832
-rw-r--r--interfaces/email/interactive/libbe/bzr.py113
-rw-r--r--interfaces/email/interactive/libbe/cmdutil.py233
-rw-r--r--interfaces/email/interactive/libbe/comment.py744
-rw-r--r--interfaces/email/interactive/libbe/config.py89
-rw-r--r--interfaces/email/interactive/libbe/darcs.py184
-rw-r--r--interfaces/email/interactive/libbe/diff.py419
-rw-r--r--interfaces/email/interactive/libbe/editor.py108
-rw-r--r--interfaces/email/interactive/libbe/encoding.py61
-rw-r--r--interfaces/email/interactive/libbe/git.py148
-rw-r--r--interfaces/email/interactive/libbe/hg.py103
-rw-r--r--interfaces/email/interactive/libbe/mapfile.py116
-rw-r--r--interfaces/email/interactive/libbe/plugin.py77
-rw-r--r--interfaces/email/interactive/libbe/properties.py638
-rw-r--r--interfaces/email/interactive/libbe/settings_object.py412
-rw-r--r--interfaces/email/interactive/libbe/tree.py183
-rw-r--r--interfaces/email/interactive/libbe/upgrade.py187
-rw-r--r--interfaces/email/interactive/libbe/utility.py134
-rw-r--r--interfaces/email/interactive/libbe/vcs.py938
-rw-r--r--interfaces/email/interactive/libbe/version.py50
-rw-r--r--interfaces/email/interactive/send_pgp_mime.py611
59 files changed, 11953 insertions, 0 deletions
diff --git a/interfaces/email/interactive/README b/interfaces/email/interactive/README
new file mode 100644
index 0000000..79ef9a9
--- /dev/null
+++ b/interfaces/email/interactive/README
@@ -0,0 +1,145 @@
+Overview
+========
+
+The interactive email interface to Bugs Everywhere (BE) attempts to
+provide a Debian-bug-tracking-system-style interface to a BE
+repository. Users can mail in bug reports, comments, or control
+requests, which will be committed to the served repository.
+Developers can then pull the changes they approve of from the served
+repository into their other repositories and push updates back onto
+the served repository.
+
+For details about the Debian bug tracking system that inspired this
+interface, see http://www.debian.org/Bugs .
+
+Architecture
+============
+
+In order to reduce setup costs, the entire interface can piggyback on
+an existing email address, although from a security standpoint it's
+probably best to create a dedicated user. Incoming email is filtered
+by procmail, with matching emails being piped into be-handle-mail for
+execution.
+
+Once be-handle-mail receives the email, the parsing method is selected
+according to the subject tag that procmail used grab the email in the
+first place. There are three parsing styles:
+ Style Subject
+ creating bugs [be-bug:submit] new bug summary
+ commenting on bugs [be-bug:<bug-id>] commit message
+ control [be-bug] commit message
+These are analogous to submit@bugs.debian.org, nnn@bugs.debian.org,
+and control@bugs.debian.org respectively.
+
+Creating bugs
+=============
+
+This interface creates a bug whose summary is given by the email's
+post-tag subject. The body of the email must begin with a
+pseudo-header containing at least the "Version" field. Anything after
+the pseudo-header and before a line starting with '--' is, if present,
+attached as the bug's first comment.
+
+ From jdoe@example.com Fri Apr 18 12:00:00 2008
+ From: John Doe <jdoe@example.com>
+ Date: Fri, 18 Apr 2008 12:00:00 +0000
+ Content-Type: text/plain; charset=UTF-8
+ Content-Transfer-Encoding: 8bit
+ Subject: [be-bug:submit] Need tests for the email interface.
+
+ Version: XYZ
+ Severity: minor
+
+ Someone should write up a series of test emails to send into
+ be-handle mail so we can test changes quickly without having to
+ use procmail.
+
+ --
+ Goofy tagline not included.
+
+Available pseudo-headers are Version, Reporter, Assign, Depend,
+Severity, Status, Tag, and Target.
+
+Commenting on bugs
+==================
+
+This interface appends a comment to the bug specified in the subject
+tag. The the first non-multipart body is attached with the
+appropriate content-type. In the case of "text/plain" contents,
+anything following a line starting with '--' is stripped.
+
+ From jdoe@example.com Fri Apr 18 12:00:00 2008
+ From: John Doe <jdoe@example.com>
+ Date: Fri, 18 Apr 2008 12:00:00 +0000
+ Content-Type: text/plain; charset=UTF-8
+ Content-Transfer-Encoding: 8bit
+ Subject: [be-bug:XYZ] Isolated problem in baz()
+
+ Finally tracked it down to the bar() call. Some sort of
+ string<->unicode conversion problem. Solution ideas?
+
+ --
+ Goofy tagline not included.
+
+Controlling bugs
+================
+
+This interface consists of a list of allowed be commands, with one
+command per line. Blank lines and lines beginning with '#' are
+ignored, as well anything following a line starting with '--'. All
+the listed commands are executed in order and their output returned.
+The commands are split into arguments with the POSIX-compliant
+shlex.split().
+
+ From jdoe@example.com Fri Apr 18 12:00:00 2008
+ From: John Doe <jdoe@example.com>
+ Date: Fri, 18 Apr 2008 12:00:00 +0000
+ Content-Type: text/plain; charset=UTF-8
+ Content-Transfer-Encoding: 8bit
+ Subject: [be-bug] I'll handle XYZ by release 1.2.3
+
+ assign XYZ "John Doe <jdoe@example.com>"
+ status XYZ assigned
+ severity XYZ critical
+ target XYZ 1.2.3
+
+ --
+ Goofy tagline ignored.
+
+Example emails
+==============
+
+Take a look at my interfaces/email/interactive/examples for some
+more examples.
+
+Procmail rules
+==============
+
+The file _procmailrc as it stands is fairly appropriate for as a
+dedicated user's ~/.procmailrc. It forwards matching mail to
+be-handle-mail, which should be installed somewhere in the user's
+path. All non-matching mail is dumped into /dev/null. Everything
+procmail does will be logged to ~/be-mail/procmail.log.
+
+If you're piggybacking the interface on top of an existing account,
+you probably only need to add the be-handle-mail stanza to your
+existing ~/.procmailrc, since you will still want to receive non-bug
+emails.
+
+Note that you will probably have to add a
+ --be-dir /path/to/served/repository
+option to the be-handle-mail invocation so it knows what repository to
+serve.
+
+Multiple repositories may be served by the same email address by adding
+multiple be-handle-mail stanzas, each matching a different tag, for
+example the "[be-bug" portion of the stanza could be "[projectX-bug",
+"[projectY-bug", etc. If you change the base tag, be sure to add a
+ --tag-base "projectX-bug"
+or equivalent to your be-handle-mail invocation.
+
+Testing
+=======
+
+Send test emails in to be-handle-mail with something like
+ cat examples/blank | ./be-handle-mail -o -l - -a
diff --git a/interfaces/email/interactive/_procmailrc b/interfaces/email/interactive/_procmailrc
new file mode 100644
index 0000000..d42c0cf
--- /dev/null
+++ b/interfaces/email/interactive/_procmailrc
@@ -0,0 +1,22 @@
+# .procmailrc
+#
+# see man procmail, procmailrc, and procmailex
+#
+# If you already have a ~/.procmailrc file, you probably only need to
+# insert the bug-email grabbing stanza in your ~/.procmailrc.
+#
+# This file is released to the Public Domain.
+
+MAILDIR=$HOME/be-mail
+LOGFILE=$MAILDIR/procmail.log
+
+# Grab all incoming bug emails (but not replies). This rule eats
+# matching emails (i.e. no further procmail processing).
+:0
+* ^Subject: \[be-bug
+* !^Subject:.*\[be-bug].*Re:
+| be-handle-mail
+
+# Drop everything else
+:0
+/dev/null
diff --git a/interfaces/email/interactive/be-handle-mail b/interfaces/email/interactive/be-handle-mail
new file mode 100644
index 0000000..fa80698
--- /dev/null
+++ b/interfaces/email/interactive/be-handle-mail
@@ -0,0 +1,950 @@
+#!/usr/bin/env python
+#
+# Copyright (C) 2009 W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+"""
+Provide and email interface to the distributed bugtracker Bugs
+Everywhere. Recieves incoming email via procmail. Provides an
+interface similar to the Debian Bug Tracker. There are currently
+three distinct email types: submits, comments, and controls. The
+email types are differentiated by tags in the email subject. See
+SUBJECT_TAG* for the current values.
+
+Submit emails create a bug (and optionally add some intitial
+comments). The post-tag subject is used as the bug summary, and the
+email body is parsed for a pseudo-header. Any text after the
+psuedo-header but before a possible line starting with BREAK is added
+as the initial bug comment.
+
+Comment emails add comments to a bug. The first non-multipart portion
+of the email is used as the comment body. If that portion has a
+"text/plain" type, any text after and including a possible line
+starting with BREAK is stripped to avoid lots of taglines cluttering
+up the repository.
+
+Control emails preform any allowed BE commands. The first
+non-multipart portion of the email is used as the comment body. If
+that portion has a "text/plain" type, any text after and including a
+possible line starting with BREAK is stripped. Each pre-BREAK line of
+the portion should be a valid BE command, with the initial "be"
+omitted, e.g. "be status XYZ fixed" --> "status XYZ fixed".
+
+Any changes made to the repository are commited after the email is
+executed, with the email's post-tag subject as the commit message.
+"""
+
+import codecs
+import StringIO as StringIO
+import email
+from email.mime.multipart import MIMEMultipart
+import email.utils
+import os
+import os.path
+import re
+import shlex
+import sys
+import time
+import traceback
+import doctest
+import unittest
+
+from becommands import subscribe
+import libbe.cmdutil, libbe.encoding, libbe.utility, libbe.diff, \
+ libbe.bugdir, libbe.bug, libbe.comment
+import send_pgp_mime
+
+THIS_SERVER = u"thor.physics.drexel.edu"
+THIS_ADDRESS = u"BE Bugs <wking@thor.physics.drexel.edu>"
+
+_THIS_DIR = os.path.abspath(os.path.dirname(__file__))
+BE_DIR = _THIS_DIR
+LOGPATH = os.path.join(_THIS_DIR, u"be-handle-mail.log")
+LOGFILE = None
+
+# Tag strings generated by generate_global_tags()
+SUBJECT_TAG_BASE = u"be-bug"
+SUBJECT_TAG_RESPONSE = None
+SUBJECT_TAG_START = None
+SUBJECT_TAG_NEW = None
+SUBJECT_TAG_COMMENT = None
+SUBJECT_TAG_CONTROL = None
+
+BREAK = u"--"
+NEW_REQUIRED_PSEUDOHEADERS = [u"Version"]
+NEW_OPTIONAL_PSEUDOHEADERS = [u"Reporter", u"Assign", u"Depend", u"Severity",
+ u"Status", u"Tag", u"Target",
+ u"Confirm", u"Subscribe"]
+CONTROL_COMMENT = u"#"
+ALLOWED_COMMANDS = [u"assign", u"comment", u"commit", u"depend", u"help",
+ u"list", u"merge", u"new", u"open", u"severity", u"show",
+ u"status", u"subscribe", u"tag", u"target"]
+
+AUTOCOMMIT = True
+
+libbe.encoding.ENCODING = u"utf-8" # force default encoding
+ENCODING = libbe.encoding.get_encoding()
+
+class InvalidEmail (ValueError):
+ def __init__(self, msg, message):
+ ValueError.__init__(self, message)
+ self.msg = msg
+ def response(self):
+ header = self.msg.response_header
+ body = [u"Error processing email:\n",
+ self.response_body(), u""]
+ response_generator = \
+ send_pgp_mime.PGPMimeMessageFactory(u"\n".join(body))
+ response = MIMEMultipart()
+ response.attach(response_generator.plain())
+ response.attach(self.msg.msg)
+ ret = send_pgp_mime.attach_root(header, response)
+ return ret
+ def response_body(self):
+ err_text = [unicode(self)]
+ return u"\n".join(err_text)
+
+class InvalidSubject (InvalidEmail):
+ def __init__(self, msg, message=None):
+ if message == None:
+ message = u"Invalid subject"
+ InvalidEmail.__init__(self, msg, message)
+ def response_body(self):
+ err_text = u"\n".join([unicode(self), u"",
+ u"full subject was:",
+ self.msg.subject()])
+ return err_text
+
+class InvalidPseudoHeader (InvalidEmail):
+ def response_body(self):
+ err_text = [u"Invalid pseudo-header:\n",
+ unicode(self)]
+ return u"\n".join(err_text)
+
+class InvalidCommand (InvalidEmail):
+ def __init__(self, msg, command, message=None):
+ bigmessage = u"Invalid execution command '%s'" % command
+ if message != None:
+ bigmessage += u"\n%s" % message
+ InvalidEmail.__init__(self, msg, bigmessage)
+ self.command = command
+
+class InvalidOption (InvalidCommand):
+ def __init__(self, msg, option, message=None):
+ bigmessage = u"Invalid option '%s'" % (option)
+ if message != None:
+ bigmessage += u"\n%s" % message
+ InvalidCommand.__init__(self, msg, info, command, bigmessage)
+ self.option = option
+
+class NotificationFailed (Exception):
+ def __init__(self, msg):
+ bigmessage = "Notification failed: %s" % msg
+ Exception.__init__(self, bigmessage)
+ self.short_msg = msg
+
+class ID (object):
+ """
+ Sometimes you want to reference the output of a command that
+ hasn't been executed yet. ID is there for situations like
+ > a = Command(msg, "new", ["create a bug"])
+ > b = Command(msg, "comment", [ID(a), "and comment on it"])
+ """
+ def __init__(self, command):
+ self.command = command
+ def extract_id(self):
+ if hasattr(self, "cached_id"):
+ return self._cached_id
+ assert self.command.ret == 0, self.command.ret
+ if self.command.command == u"new":
+ regexp = re.compile(u"Created bug with ID (.*)")
+ else:
+ raise NotImplementedError, self.command.command
+ match = regexp.match(self.command.stdout)
+ assert len(match.groups()) == 1, str(match.groups())
+ self._cached_id = match.group(1)
+ return self._cached_id
+ def __str__(self):
+ if self.command.ret != 0:
+ return "<id for %s>" % repr(self.command)
+ return "<id %s>" % self.extract_id()
+
+class Command (object):
+ """
+ A becommands command wrapper.
+ Doesn't validate input, so do that before initializing.
+
+ Initialize with
+ Command(msg, command, args=None, stdin=None)
+ where
+ msg: the Message instance prompting this command
+ command: name of becommand to execute, e.g. "new"
+ args: list of arguments to pass to the command
+ stdin: if non-null, a string to pipe into the command's stdin
+ """
+ def __init__(self, msg, command, args=None, stdin=None):
+ self.msg = msg
+ self.command = command
+ if args == None:
+ self.args = []
+ else:
+ self.args = args
+ self.stdin = stdin
+ self.ret = None
+ self.stdout = None
+ self.stderr = None
+ self.err = None
+ def __str__(self):
+ return "<command: %s %s>" % (self.command, " ".join([str(s) for s in self.args]))
+ def normalize_args(self):
+ """
+ Expand any ID placeholders in self.args.
+ """
+ for i,arg in enumerate(self.args):
+ if isinstance(arg, ID):
+ self.args[i] = arg.extract_id()
+ def run(self):
+ """
+ Attempt to execute the command whose info is given in the dictionary
+ info. Returns the exit code, stdout, and stderr produced by the
+ command.
+ """
+ if self.command in [None, u""]: # don't accept blank commands
+ raise InvalidCommand(self.msg, self, "Blank")
+ elif self.command not in ALLOWED_COMMANDS:
+ raise InvalidCommand(self.msg, self, "Not allowed")
+ assert self.ret == None, u"running %s twice!" % unicode(self)
+ self.normalize_args()
+ # set stdin and catch stdout and stderr
+ if self.stdin != None:
+ orig_stdin = sys.stdin
+ sys.stdin = StringIO.StringIO(self.stdin)
+ new_stdout = codecs.getwriter(ENCODING)(StringIO.StringIO())
+ new_stderr = codecs.getwriter(ENCODING)(StringIO.StringIO())
+ orig_stdout = sys.stdout
+ orig_stderr = sys.stderr
+ sys.stdout = new_stdout
+ sys.stderr = new_stderr
+ # run the command
+ os.chdir(BE_DIR)
+ try:
+ self.ret = libbe.cmdutil.execute(self.command, self.args,
+ manipulate_encodings=False)
+ except libbe.cmdutil.GetHelp:
+ print libbe.cmdutil.help(command)
+ except libbe.cmdutil.GetCompletions:
+ self.err = InvalidOption(self.msg, self.command, u"--complete")
+ except libbe.cmdutil.UsageError, e:
+ self.err = InvalidCommand(self.msg, self,
+ "%s\n%s" % (type(e), unicode(e)))
+ except libbe.cmdutil.UserError, e:
+ self.err = InvalidCommand(self.msg, self,
+ "%s\n%s" % (type(e), unicode(e)))
+ # restore stdin, stdout, and stderr
+ if self.stdin != None:
+ sys.stdin = orig_stdin
+ sys.stdout.flush()
+ sys.stderr.flush()
+ sys.stdout = orig_stdout
+ sys.stderr = orig_stderr
+ self.stdout = codecs.decode(new_stdout.getvalue(), ENCODING)
+ self.stderr = codecs.decode(new_stderr.getvalue(), ENCODING)
+ if self.err != None:
+ raise self.err
+ return (self.ret, self.stdout, self.stderr)
+ def response_msg(self):
+ if self.ret == None: self.ret = -1
+ response_body = [u"Results of running: (exit code %d)" % self.ret,
+ u" %s %s" % (self.command, u" ".join(self.args))]
+ if self.stdout != None and len(self.stdout) > 0:
+ response_body.extend([u"", u"stdout:", u"", self.stdout])
+ if self.stderr != None and len(self.stderr) > 0:
+ response_body.extend([u"", u"stderr:", u"", self.stderr])
+ response_body.append(u"") # trailing endline
+ response_generator = \
+ send_pgp_mime.PGPMimeMessageFactory(u"\n".join(response_body))
+ return response_generator.plain()
+
+class DiffTree (libbe.diff.DiffTree):
+ """
+ In order to avoid tons of tiny MIMEText attachments, bug-level
+ nodes set .add_child_text=True (in .join()), which is propogated
+ on to their descendents. Instead of creating their own
+ attachement, each of these descendents appends his data_part to
+ the end of the bug-level MIMEText attachment.
+
+ For the example tree in the libbe.diff.Diff unittests:
+ bugdir
+ bugdir/settings
+ bugdir/bugs
+ bugdir/bugs/new
+ bugdir/bugs/new/c <- sets .add_child_text
+ bugdir/bugs/rem
+ bugdir/bugs/rem/b <- sets .add_child_text
+ bugdir/bugs/mod
+ bugdir/bugs/mod/a <- sets .add_child_text
+ bugdir/bugs/mod/a/settings
+ bugdir/bugs/mod/a/comments
+ bugdir/bugs/mod/a/comments/new
+ bugdir/bugs/mod/a/comments/new/acom
+ bugdir/bugs/mod/a/comments/rem
+ bugdir/bugs/mod/a/comments/mod
+ """
+ def report_or_none(self):
+ report = self.report()
+ payload = report.get_payload()
+ if payload == None or len(payload) == 0:
+ return None
+ return report
+ def report_string(self):
+ report = self.report_or_none()
+ if report == None:
+ return "No changes"
+ else:
+ return send_pgp_mime.flatten(self.report(), to_unicode=True)
+ def make_root(self):
+ return MIMEMultipart()
+ def join(self, root, parent, data_part):
+ if hasattr(parent, "attach_child_text"):
+ self.attach_child_text = True
+ if data_part != None:
+ send_pgp_mime.append_text(parent.data_mime_part, u"\n\n%s" % (data_part))
+ self.data_mime_part = parent.data_mime_part
+ else:
+ self.data_mime_part = None
+ if data_part != None:
+ self.data_mime_part = send_pgp_mime.encodedMIMEText(data_part)
+ if parent != None and parent.name in [u"new", u"rem", u"mod"]:
+ self.attach_child_text = True
+ if data_part == None: # make blank data_mime_part for children's appends
+ self.data_mime_part = send_pgp_mime.encodedMIMEText(u"")
+ if self.data_mime_part != None:
+ self.data_mime_part[u"Content-Description"] = self.name
+ root.attach(self.data_mime_part)
+ def data_part(self, depth, indent=False):
+ return libbe.diff.DiffTree.data_part(self, depth, indent=indent)
+
+class Diff (libbe.diff.Diff):
+ def bug_add_string(self, bug):
+ return bug.string(show_comments=True)
+ def _comment_summary_string(self, comment):
+ return comment.string()
+ def comment_add_string(self, comment):
+ return self._comment_summary_string(comment)
+ def comment_rem_string(self, comment):
+ return self._comment_summary_string(comment)
+
+class Message (object):
+ def __init__(self, email_text=None, disable_parsing=False):
+ if disable_parsing == False:
+ self.text = email_text
+ p=email.Parser.Parser()
+ self.msg=p.parsestr(self.text)
+ if LOGFILE != None:
+ LOGFILE.write(u"handling %s\n" % self.author_addr())
+ LOGFILE.write(u"\n%s\n\n" % self.text)
+ self.confirm = True # enable/disable confirmation email
+ def _yes_no(self, boolean):
+ if boolean == True:
+ return "yes"
+ return "no"
+ def author_tuple(self):
+ """
+ Extract and normalize the sender's email address. Returns a
+ (name, email) tuple.
+ """
+ if not hasattr(self, "author_tuple_cache"):
+ self._author_tuple_cache = \
+ send_pgp_mime.source_email(self.msg, return_realname=True)
+ return self._author_tuple_cache
+ def author_addr(self):
+ return email.utils.formataddr(self.author_tuple())
+ def author_name(self):
+ return self.author_tuple()[0]
+ def author_email(self):
+ return self.author_tuple()[1]
+ def default_msg_attribute_access(self, attr_name, default=None):
+ if attr_name in self.msg:
+ return self.msg[attr_name]
+ return default
+ def message_id(self, default=None):
+ return self.default_msg_attribute_access("message-id", default=default)
+ def subject(self):
+ if "subject" not in self.msg:
+ raise InvalidSubject(self, u"Email must contain a subject")
+ return self.msg["subject"]
+ def _split_subject(self):
+ """
+ Returns (tag, subject), with missing values replaced by None.
+ """
+ if hasattr(self, "_split_subject_cache"):
+ return self._split_subject_cache
+ args = self.subject().split(u"]",1)
+ if len(args) < 1:
+ self._split_subject_cache = (None, None)
+ elif len(args) < 2:
+ self._split_subject_cache = (args[0]+u"]", None)
+ else:
+ self._split_subject_cache = (args[0]+u"]", args[1].strip())
+ return self._split_subject_cache
+ def _subject_tag_type(self):
+ """
+ Parse subject tag, return (type, value), where type is one of
+ None, "new", "comment", or "control"; and value is None except
+ in the case of "comment", in which case it's the bug
+ ID/shortname.
+ """
+ tag,subject = self._split_subject()
+ type = None
+ value = None
+ if tag == SUBJECT_TAG_NEW:
+ type = u"new"
+ elif tag == SUBJECT_TAG_CONTROL:
+ type = u"control"
+ else:
+ match = SUBJECT_TAG_COMMENT.match(tag)
+ if len(match.groups()) == 1:
+ type = u"comment"
+ value = match.group(1)
+ return (type, value)
+ def validate_subject(self):
+ """
+ Validate the subject line.
+ """
+ tag,subject = self._split_subject()
+ if not tag.startswith(SUBJECT_TAG_START):
+ raise InvalidSubject(
+ self, u"Subject must start with '%s'" % SUBJECT_TAG_START)
+ tag_type,value = self._subject_tag_type()
+ if tag_type == None:
+ raise InvalidSubject(self, u"Invalid tag '%s'" % tag)
+ elif tag_type == u"new" and len(subject) == 0:
+ raise InvalidSubject(self, u"Cannot create a bug with blank title")
+ elif tag_type == u"comment" and len(value) == 0:
+ raise InvalidSubject(self, u"Must specify a bug ID to comment")
+ def _get_bodies_and_mime_types(self):
+ """
+ Traverse the email message returning (body, mime_type) for
+ each non-mulitpart portion of the message.
+ """
+ msg_charset = self.msg.get_content_charset(ENCODING).lower()
+ for part in self.msg.walk():
+ if part.is_multipart():
+ continue
+ body,mime_type=(part.get_payload(decode=True),part.get_content_type())
+ charset = part.get_content_charset(msg_charset).lower()
+ if mime_type.startswith("text/"):
+ body = unicode(body, charset) # convert text types to unicode
+ yield (body, mime_type)
+ def _parse_body_pseudoheaders(self, body, required, optional,
+ dictionary=None):
+ """
+ Grab any pseudo-headers from the beginning of body. Raise
+ InvalidPseudoHeader on errors. Returns the body text after
+ the pseudo-header and a dictionary of set options. If you
+ like, you can initialize the dictionary with some defaults
+ and pass your initialized dict in as dictionary.
+ """
+ if dictionary == None:
+ dictionary = {}
+ body_lines = body.splitlines()
+ all = required+optional
+ for i,line in enumerate(body_lines):
+ line = line.strip()
+ if len(line) == 0:
+ break
+ if ":" not in line:
+ raise InvalidPseudoheader(self, line)
+ key,value = line.split(":", 1)
+ value = value.strip()
+ if key not in all:
+ raise InvalidPseudoHeader(self, key)
+ if len(value) == 0:
+ raise InvalidEmail(
+ self, u"Blank value for: %s" % key)
+ dictionary[key] = value
+ missing = []
+ for key in required:
+ if key not in dictionary:
+ missing.append(key)
+ if len(missing) > 0:
+ raise InvalidPseudoHeader(self,
+ u"Missing required pseudo-headers:\n%s"
+ % u", ".join(missing))
+ remaining_body = u"\n".join(body_lines[i:]).strip()
+ return (remaining_body, dictionary)
+ def _strip_footer(self, body):
+ body_lines = body.splitlines()
+ for i,line in enumerate(body_lines):
+ if line.startswith(BREAK):
+ break
+ i += 1 # increment past the current valid line.
+ return u"\n".join(body_lines[:i]).strip()
+ def parse(self):
+ """
+ Parse the commands given in the email. Raises assorted
+ subclasses of InvalidEmail in the case of invalid messages,
+ otherwise returns a list of suggested commands to run.
+ """
+ self.validate_subject()
+ tag_type,value = self._subject_tag_type()
+ if tag_type == u"new":
+ commands = self.parse_new()
+ elif tag_type == u"comment":
+ commands = self.parse_comment(value)
+ elif tag_type == u"control":
+ commands = self.parse_control()
+ else:
+ raise Exception, u"Unrecognized tag type '%s'" % tag_type
+ return commands
+ def parse_new(self):
+ command = u"new"
+ tag,subject = self._split_subject()
+ summary = subject
+ options = {u"Reporter": self.author_addr(),
+ u"Confirm": self._yes_no(self.confirm),
+ u"Subscribe": "no",
+ }
+ body,mime_type = list(self._get_bodies_and_mime_types())[0]
+ comment_body,options = \
+ self._parse_body_pseudoheaders(body,
+ NEW_REQUIRED_PSEUDOHEADERS,
+ NEW_OPTIONAL_PSEUDOHEADERS,
+ options)
+ if options[u"Confirm"].lower() == "no":
+ self.confirm = False
+ if options[u"Subscribe"].lower() == "yes" and self.confirm == True:
+ # respond with the subscription format rather than the
+ # normal command-output format, because the subscription
+ # format is more user-friendly.
+ self.confirm = False
+ args = [u"--reporter", options[u"Reporter"]]
+ args.append(summary)
+ commands = [Command(self, command, args)]
+ id = ID(commands[0])
+ comment_body = self._strip_footer(comment_body)
+ if len(comment_body) > 0:
+ command = u"comment"
+ comment = u"Version: %s\n\n"%options[u"Version"] + comment_body
+ args = [u"--author", self.author_addr(),
+ u"--alt-id", self.message_id(),
+ u"--content-type", mime_type]
+ args.append(id)
+ args.append(u"-")
+ commands.append(Command(self, u"comment", args, stdin=comment))
+ for key,value in options.items():
+ if key in [u"Version", u"Reporter", u"Confirm"]:
+ continue # we've already handled these options
+ command = key.lower()
+ args = [id, value]
+ if key == u"Subscribe":
+ if value.lower() != "yes":
+ continue
+ args = ["--subscriber", self.author_addr(), id]
+ commands.append(Command(self, command, args))
+ return commands
+ def parse_comment(self, bug_uuid):
+ command = u"comment"
+ bug_id = bug_uuid
+ author = self.author_addr()
+ alt_id = self.message_id()
+ body,mime_type = list(self._get_bodies_and_mime_types())[0]
+ if mime_type == "text/plain":
+ body = self._strip_footer(body)
+ content_type = mime_type
+ args = [u"--author", author, u"--alt-id", alt_id,
+ u"--content-type", content_type, bug_id, u"-"]
+ commands = [Command(self, command, args, stdin=body)]
+ return commands
+ def parse_control(self):
+ body,mime_type = list(self._get_bodies_and_mime_types())[0]
+ commands = []
+ for line in body.splitlines():
+ line = line.strip()
+ if line.startswith(CONTROL_COMMENT) or len(line) == 0:
+ continue
+ if line.startswith(BREAK):
+ break
+ fields = shlex.split(line)
+ command,args = (fields[0], fields[1:])
+ commands.append(Command(self, command, args))
+ if len(commands) == 0:
+ raise InvalidEmail(self, u"No commands in control email.")
+ return commands
+ def run(self):
+ self._begin_response()
+ commands = self.parse()
+ try:
+ for command in commands:
+ command.run()
+ self._add_response(command.response_msg())
+ finally:
+ if AUTOCOMMIT == True:
+ tag,subject = self._split_subject()
+ self.commit_command = Command(self, "commit", [subject])
+ self.commit_command.run()
+ if LOGFILE != None:
+ LOGFILE.write(u"Autocommit:\n%s\n\n" %
+ send_pgp_mime.flatten(self.commit_command.response_msg(),
+ to_unicode=True))
+ def _begin_response(self):
+ tag,subject = self._split_subject()
+ response_header = [u"From: %s" % THIS_ADDRESS,
+ u"To: %s" % self.author_addr(),
+ u"Date: %s" % libbe.utility.time_to_str(time.time()),
+ u"Subject: %s Re: %s"%(SUBJECT_TAG_RESPONSE,subject)
+ ]
+ if self.message_id() != None:
+ response_header.append(u"In-reply-to: %s" % self.message_id())
+ self.response_header = \
+ send_pgp_mime.header_from_text(text=u"\n".join(response_header))
+ self._response_messages = []
+ def _add_response(self, response_message):
+ self._response_messages.append(response_message)
+ def response_email(self):
+ assert len(self._response_messages) > 0
+ if len(self._response_messages) == 1:
+ response_body = self._response_messages[0]
+ else:
+ response_body = MIMEMultipart()
+ for message in self._response_messages:
+ response_body.attach(message)
+ return send_pgp_mime.attach_root(self.response_header, response_body)
+ def subscriber_emails(self, previous_revision=None):
+ if previous_revision == None:
+ if AUTOCOMMIT != True: # no way to tell what's changed
+ raise NotificationFailed("Autocommit dissabled")
+ if len(self._response_messages) == 0:
+ raise NotificationFailed("Initial email failed.")
+ if self.commit_command.ret != 0:
+ # commit failed. Error already logged.
+ raise NotificationFailed("Commit failed")
+
+ # read only bugdir.
+ bd = libbe.bugdir.BugDir(from_disk=True,
+ manipulate_encodings=False)
+ if bd.vcs.versioned == False: # no way to tell what's changed
+ raise NotificationFailed("Not versioned")
+
+ bd.load_all_bugs()
+ subscribers = subscribe.get_bugdir_subscribers(bd, THIS_SERVER)
+
+ if len(subscribers) == 0:
+ return []
+
+ before_bd, after_bd = self._get_before_and_after_bugdirs(bd, previous_revision)
+ diff = Diff(before_bd, after_bd)
+ diff_tree = diff.report_tree(diff_tree=DiffTree)
+ bug_index = {}
+ for child in diff_tree.child_by_path("/bugs/new"):
+ bug_index[child.name] = ("added", child)
+ for child in diff_tree.child_by_path("/bugs/mod"):
+ bug_index[child.name] = ("modified", child)
+ for child in diff_tree.child_by_path("/bugs/rem"):
+ bug_index[child.name] = ("removed", child)
+ header = self._subscriber_header(bd, previous_revision)
+
+ emails = []
+ for subscriber,subscriptions in subscribers.items():
+ header.replace_header("to", subscriber)
+ parts = []
+ if "DIR" in subscriptions: # make sure we check the DIR level first
+ ordered_subscriptions = [("DIR", subscriptions.pop("DIR"))]
+ else:
+ ordered_subscriptions = []
+ ordered_subscriptions.extend(subscriptions.items())
+ for id,types in ordered_subscriptions:
+ if id == "DIR":
+ if subscribe.BUGDIR_TYPE_ALL in types:
+ parts.append(diff_tree.report_or_none())
+ break # we've attached everything, so stop checking.
+ if subscribe.BUGDIR_TYPE_NEW in types:
+ new = diff_tree.child_by_path("/bugs/new")
+ parts.append(new.report_or_none())
+ continue # move on to next id
+ # if we get this far, id refers to a bug.
+ assert types == [subscribe.BUG_TYPE_ALL], types
+ if id not in bug_index:
+ continue # no changes here, move on to next id
+ type,bug_root = bug_index[id]
+ if type == "added" \
+ and "DIR" in subscriptions \
+ and subscriptions["DIR"] == subscribe.BUGDIR_TYPE_NEW:
+ # this info already attached at the DIR level
+ continue # move on to next id
+ parts.append(bug_root.report_or_none())
+ parts = [p for p in parts if p != None]
+ if len(parts) == 0:
+ continue # no email to this subscriber
+ elif len(parts) == 1:
+ root = parts[0]
+ else: # join subscription parts into a single body
+ root = MIMEMultipart()
+ root[u"Content-Description"] = u"Multiple subscription trees."
+ for part in parts:
+ root.attach(part)
+ emails.append(send_pgp_mime.attach_root(header, root))
+ if LOGFILE != None:
+ LOGFILE.write(u"Preparing to notify %s of changes\n" % subscriber)
+ return emails
+ def _get_before_and_after_bugdirs(self, bd, previous_revision=None):
+ if previous_revision == None:
+ commit_msg = self.commit_command.stdout
+ assert commit_msg.startswith("Committed "), commit_msg
+ after_revision = commit_msg[len("Committed "):]
+ before_revision = bd.vcs.revision_id(-2)
+ else:
+ before_revision = previous_revision
+ if before_revision == None:
+ # this commit was the initial commit
+ before_bd = libbe.bugdir.BugDir(from_disk=False,
+ manipulate_encodings=False)
+ else:
+ before_bd = bd.duplicate_bugdir(before_revision)
+ #after_bd = bd.duplicate_bugdir(after_revision)
+ after_bd = bd # assume no changes since commit a few cycles ago
+ return (before_bd, after_bd)
+ def _subscriber_header(self, bd, previous_revision=None):
+ root_dir = os.path.basename(bd.root)
+ if previous_revision == None:
+ subject = "Changes to %s on %s by %s" \
+ % (root_dir, THIS_SERVER, self.author_addr())
+ else:
+ subject = "Changes to %s on %s since revision %s" \
+ % (root_dir, THIS_SERVER, previous_revision)
+ header = [u"From: %s" % THIS_ADDRESS,
+ u"To: %s" % u"DUMMY-AUTHOR",
+ u"Date: %s" % libbe.utility.time_to_str(time.time()),
+ u"Subject: %s Re: %s" % (SUBJECT_TAG_RESPONSE, subject)
+ ]
+ return send_pgp_mime.header_from_text(text=u"\n".join(header))
+
+def generate_global_tags(tag_base=u"be-bug"):
+ """
+ Generate a series of tags from a base tag string.
+ """
+ global SUBJECT_TAG_BASE, SUBJECT_TAG_START, SUBJECT_TAG_RESPONSE, \
+ SUBJECT_TAG_NEW, SUBJECT_TAG_COMMENT, SUBJECT_TAG_CONTROL
+ SUBJECT_TAG_BASE = tag_base
+ SUBJECT_TAG_START = u"[%s" % tag_base
+ SUBJECT_TAG_RESPONSE = u"[%s]" % tag_base
+ SUBJECT_TAG_NEW = u"[%s:submit]" % tag_base
+ SUBJECT_TAG_COMMENT = re.compile(u"\[%s:([\-0-9a-z]*)]" % tag_base)
+ SUBJECT_TAG_CONTROL = SUBJECT_TAG_RESPONSE
+
+def open_logfile(logpath=None):
+ """
+ If logpath=None, default to global LOGPATH.
+ Special logpath strings:
+ "-" set LOGFILE to sys.stderr
+ "none" disable logging
+ Relative logpaths are expanded relative to _THIS_DIR
+ """
+ global LOGPATH, LOGFILE
+ if logpath != None:
+ if logpath == u"-":
+ LOGPATH = u"stderr"
+ LOGFILE = sys.stderr
+ elif logpath == u"none":
+ LOGPATH = u"none"
+ LOGFILE = None
+ elif os.path.isabs(logpath):
+ LOGPATH = logpath
+ else:
+ LOGPATH = os.path.join(_THIS_DIR, logpath)
+ if LOGFILE == None and LOGPATH != u"none":
+ LOGFILE = codecs.open(LOGPATH, u"a+", ENCODING)
+ LOGFILE.write(u"Default encoding: %s\n" % ENCODING)
+
+def close_logfile():
+ if LOGFILE != None and LOGPATH not in [u"stderr", u"none"]:
+ LOGFILE.close()
+
+def test():
+ unitsuite = unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
+ suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
+ result = unittest.TextTestRunner(verbosity=2).run(suite)
+ num_errors = len(result.errors)
+ num_failures = len(result.failures)
+ num_bad = num_errors + num_failures
+ return num_bad
+
+def main(args):
+ from optparse import OptionParser
+ global AUTOCOMMIT, BE_DIR
+
+ usage="be-handle-mail [options]\n\n%s" % (__doc__)
+ parser = OptionParser(usage=usage)
+ parser.add_option('-b', '--be-dir', dest='be_dir', default=BE_DIR,
+ metavar="DIR",
+ help='Select the BE directory to serve (%default).')
+ parser.add_option('-t', '--tag-base', dest='tag_base',
+ default=SUBJECT_TAG_BASE, metavar="TAG",
+ help='Set the subject tag base (%default).')
+ parser.add_option('-o', '--output', dest='output', action='store_true',
+ help="Don't mail the generated message, print it to stdout instead. Useful for testing be-handle-mail functionality without the whole mail transfer agent and procmail setup.")
+ parser.add_option('-l', '--logfile', dest='logfile', metavar='LOGFILE',
+ help='Set the logfile to LOGFILE. Relative paths are relative to the location of this be-handle-mail file (%s). The special value of "-" directs the log output to stderr, and "none" disables logging.' % _THIS_DIR)
+ parser.add_option('-a', '--disable-autocommit', dest='autocommit',
+ default=True, action='store_false',
+ help='Disable the autocommit after parsing the email.')
+ parser.add_option('-s', '--disable-subscribers', dest='subscribers',
+ default=True, action='store_false',
+ help='Disable subscriber notification emails.')
+ parser.add_option('--notify-since', dest='notify_since', metavar='REVISION',
+ help='Notify subscribers of all changes since REVISION. When this option is set, no input email parsing is done.')
+ parser.add_option('--test', dest='test', action='store_true',
+ help='Run internal unit-tests and exit.')
+
+ pargs = args
+ options,args = parser.parse_args(args[1:])
+
+ if options.test == True:
+ num_bad = test()
+ if num_bad > 126:
+ num_bad = 1
+ sys.exit(num_bad)
+
+ BE_DIR = options.be_dir
+ AUTOCOMMIT = options.autocommit
+
+ if options.notify_since == None:
+ msg_text = sys.stdin.read()
+
+ libbe.encoding.set_IO_stream_encodings(ENCODING) # _after_ reading message
+ open_logfile(options.logfile)
+ generate_global_tags(options.tag_base)
+
+ if options.notify_since != None:
+ if options.subscribers == True:
+ if LOGFILE != None:
+ LOGFILE.write(u"Checking for subscribers to notify since revision %s\n"
+ % options.notify_since)
+ try:
+ m = Message(disable_parsing=True)
+ emails = m.subscriber_emails(options.notify_since)
+ except NotificationFailed, e:
+ if LOGFILE != None:
+ LOGFILE.write(unicode(e) + u"\n")
+ else:
+ for msg in emails:
+ if options.output == True:
+ print send_pgp_mime.flatten(msg, to_unicode=True)
+ else:
+ send_pgp_mime.mail(msg, send_pgp_mime.sendmail)
+ close_logfile()
+ sys.exit(0)
+
+ if len(msg_text.strip()) == 0: # blank email!?
+ if LOGFILE != None:
+ LOGFILE.write(u"Blank email!\n")
+ close_logfile()
+ sys.exit(1)
+ try:
+ m = Message(msg_text)
+ m.run()
+ except InvalidEmail, e:
+ response = e.response()
+ except Exception, e:
+ if LOGFILE != None:
+ LOGFILE.write(u"Uncaught exception:\n%s\n" % (e,))
+ traceback.print_tb(sys.exc_traceback, file=LOGFILE)
+ close_logfile()
+ sys.exit(1)
+ else:
+ response = m.response_email()
+ if options.output == True:
+ print send_pgp_mime.flatten(response, to_unicode=True)
+ elif m.confirm == True:
+ if LOGFILE != None:
+ LOGFILE.write(u"Sending response to %s\n" % m.author_addr())
+ LOGFILE.write(u"\n%s\n\n" % send_pgp_mime.flatten(response,
+ to_unicode=True))
+ send_pgp_mime.mail(response, send_pgp_mime.sendmail)
+ else:
+ if LOGFILE != None:
+ LOGFILE.write(u"Response declined by %s\n" % m.author_addr())
+ if options.subscribers == True:
+ if LOGFILE != None:
+ LOGFILE.write(u"Checking for subscribers\n")
+ try:
+ emails = m.subscriber_emails()
+ except NotificationFailed, e:
+ if LOGFILE != None:
+ LOGFILE.write(unicode(e) + u"\n")
+ else:
+ for msg in emails:
+ if options.output == True:
+ print send_pgp_mime.flatten(msg, to_unicode=True)
+ else:
+ send_pgp_mime.mail(msg, send_pgp_mime.sendmail)
+
+ close_logfile()
+
+
+class GenerateGlobalTagsTestCase (unittest.TestCase):
+ def setUp(self):
+ super(GenerateGlobalTagsTestCase, self).setUp()
+ self.save_global_tags()
+ def tearDown(self):
+ self.restore_global_tags()
+ super(GenerateGlobalTagsTestCase, self).tearDown()
+ def save_global_tags(self):
+ self.saved_globals = [SUBJECT_TAG_BASE, SUBJECT_TAG_START,
+ SUBJECT_TAG_RESPONSE, SUBJECT_TAG_NEW,
+ SUBJECT_TAG_COMMENT, SUBJECT_TAG_CONTROL]
+ def restore_global_tags(self):
+ global SUBJECT_TAG_BASE, SUBJECT_TAG_START, SUBJECT_TAG_RESPONSE, \
+ SUBJECT_TAG_NEW, SUBJECT_TAG_COMMENT, SUBJECT_TAG_CONTROL
+ SUBJECT_TAG_BASE, SUBJECT_TAG_START, SUBJECT_TAG_RESPONSE, \
+ SUBJECT_TAG_NEW, SUBJECT_TAG_COMMENT, SUBJECT_TAG_CONTROL = \
+ self.saved_globals
+ def test_restore_global_tags(self):
+ "Test global tag restoration by teardown function."
+ global SUBJECT_TAG_BASE
+ self.failUnlessEqual(SUBJECT_TAG_BASE, u"be-bug")
+ SUBJECT_TAG_BASE = "projectX-bug"
+ self.failUnlessEqual(SUBJECT_TAG_BASE, u"projectX-bug")
+ self.restore_global_tags()
+ self.failUnlessEqual(SUBJECT_TAG_BASE, u"be-bug")
+ def test_subject_tag_base(self):
+ "Should set SUBJECT_TAG_BASE global correctly"
+ generate_global_tags(u"projectX-bug")
+ self.failUnlessEqual(SUBJECT_TAG_BASE, u"projectX-bug")
+ def test_subject_tag_start(self):
+ "Should set SUBJECT_TAG_START global correctly"
+ generate_global_tags(u"projectX-bug")
+ self.failUnlessEqual(SUBJECT_TAG_START, u"[projectX-bug")
+ def test_subject_tag_response(self):
+ "Should set SUBJECT_TAG_RESPONSE global correctly"
+ generate_global_tags(u"projectX-bug")
+ self.failUnlessEqual(SUBJECT_TAG_RESPONSE, u"[projectX-bug]")
+ def test_subject_tag_new(self):
+ "Should set SUBJECT_TAG_NEW global correctly"
+ generate_global_tags(u"projectX-bug")
+ self.failUnlessEqual(SUBJECT_TAG_NEW, u"[projectX-bug:submit]")
+ def test_subject_tag_control(self):
+ "Should set SUBJECT_TAG_CONTROL global correctly"
+ generate_global_tags(u"projectX-bug")
+ self.failUnlessEqual(SUBJECT_TAG_CONTROL, u"[projectX-bug]")
+ def test_subject_tag_comment(self):
+ "Should set SUBJECT_TAG_COMMENT global correctly"
+ generate_global_tags(u"projectX-bug")
+ m = SUBJECT_TAG_COMMENT.match("[projectX-bug:xyz-123]")
+ self.failUnlessEqual(len(m.groups()), 1)
+ self.failUnlessEqual(m.group(1), u"xyz-123")
+
+if __name__ == "__main__":
+ main(sys.argv)
diff --git a/interfaces/email/interactive/becommands/assign.py b/interfaces/email/interactive/becommands/assign.py
new file mode 100644
index 0000000..794f028
--- /dev/null
+++ b/interfaces/email/interactive/becommands/assign.py
@@ -0,0 +1,87 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+# Marien Zwart <marienz@gentoo.org>
+# Thomas Gerigk <tgerigk@gmx.de>
+# W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+"""Assign an individual or group to fix a bug"""
+from libbe import cmdutil, bugdir
+__desc__ = __doc__
+
+def execute(args, manipulate_encodings=True):
+ """
+ >>> import os
+ >>> bd = bugdir.SimpleBugDir()
+ >>> os.chdir(bd.root)
+ >>> bd.bug_from_shortname("a").assigned is None
+ True
+
+ >>> execute(["a"], manipulate_encodings=False)
+ >>> bd._clear_bugs()
+ >>> bd.bug_from_shortname("a").assigned == bd.user_id
+ True
+
+ >>> execute(["a", "someone"], manipulate_encodings=False)
+ >>> bd._clear_bugs()
+ >>> print bd.bug_from_shortname("a").assigned
+ someone
+
+ >>> execute(["a","none"], manipulate_encodings=False)
+ >>> bd._clear_bugs()
+ >>> bd.bug_from_shortname("a").assigned is None
+ True
+ >>> bd.cleanup()
+ """
+ parser = get_parser()
+ options, args = parser.parse_args(args)
+ cmdutil.default_complete(options, args, parser,
+ bugid_args={0: lambda bug : bug.active==True})
+ assert(len(args) in (0, 1, 2))
+ if len(args) == 0:
+ raise cmdutil.UsageError("Please specify a bug id.")
+ if len(args) > 2:
+ help()
+ raise cmdutil.UsageError("Too many arguments.")
+ bd = bugdir.BugDir(from_disk=True,
+ manipulate_encodings=manipulate_encodings)
+ bug = cmdutil.bug_from_shortname(bd, args[0])
+ bug = bd.bug_from_shortname(args[0])
+ if len(args) == 1:
+ bug.assigned = bd.user_id
+ elif len(args) == 2:
+ if args[1] == "none":
+ bug.assigned = None
+ else:
+ bug.assigned = args[1]
+ bd.save()
+
+def get_parser():
+ parser = cmdutil.CmdOptionParser("be assign BUG-ID [ASSIGNEE]")
+ return parser
+
+longhelp = """
+Assign a person to fix a bug.
+
+By default, the bug is self-assigned. If an assignee is specified, the bug
+will be assigned to that person.
+
+Assignees should be the person's Bugs Everywhere identity, the string that
+appears in Creator fields.
+
+To un-assign a bug, specify "none" for the assignee.
+"""
+
+def help():
+ return get_parser().help_str() + longhelp
diff --git a/interfaces/email/interactive/becommands/close.py b/interfaces/email/interactive/becommands/close.py
new file mode 100644
index 0000000..0532ed2
--- /dev/null
+++ b/interfaces/email/interactive/becommands/close.py
@@ -0,0 +1,60 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+# Marien Zwart <marienz@gentoo.org>
+# Thomas Gerigk <tgerigk@gmx.de>
+# W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+"""Close a bug"""
+from libbe import cmdutil, bugdir
+__desc__ = __doc__
+
+def execute(args, manipulate_encodings=True):
+ """
+ >>> from libbe import bugdir
+ >>> import os
+ >>> bd = bugdir.SimpleBugDir()
+ >>> os.chdir(bd.root)
+ >>> print bd.bug_from_shortname("a").status
+ open
+ >>> execute(["a"], manipulate_encodings=False)
+ >>> bd._clear_bugs()
+ >>> print bd.bug_from_shortname("a").status
+ closed
+ >>> bd.cleanup()
+ """
+ parser = get_parser()
+ options, args = parser.parse_args(args)
+ cmdutil.default_complete(options, args, parser,
+ bugid_args={0: lambda bug : bug.active==True})
+ if len(args) == 0:
+ raise cmdutil.UsageError("Please specify a bug id.")
+ if len(args) > 1:
+ raise cmdutil.UsageError("Too many arguments.")
+ bd = bugdir.BugDir(from_disk=True,
+ manipulate_encodings=manipulate_encodings)
+ bug = cmdutil.bug_from_shortname(bd, args[0])
+ bug.status = "closed"
+ bd.save()
+
+def get_parser():
+ parser = cmdutil.CmdOptionParser("be close BUG-ID")
+ return parser
+
+longhelp="""
+Close the bug identified by BUG-ID.
+"""
+
+def help():
+ return get_parser().help_str() + longhelp
diff --git a/interfaces/email/interactive/becommands/comment.py b/interfaces/email/interactive/becommands/comment.py
new file mode 100644
index 0000000..9a614b2
--- /dev/null
+++ b/interfaces/email/interactive/becommands/comment.py
@@ -0,0 +1,228 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+# Chris Ball <cjb@laptop.org>
+# W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+"""Add a comment to a bug"""
+from libbe import cmdutil, bugdir, comment, editor
+import os
+import sys
+try: # import core module, Python >= 2.5
+ from xml.etree import ElementTree
+except ImportError: # look for non-core module
+ from elementtree import ElementTree
+__desc__ = __doc__
+
+def execute(args, manipulate_encodings=True):
+ """
+ >>> import time
+ >>> bd = bugdir.SimpleBugDir()
+ >>> os.chdir(bd.root)
+ >>> execute(["a", "This is a comment about a"], manipulate_encodings=False)
+ >>> bd._clear_bugs()
+ >>> bug = cmdutil.bug_from_shortname(bd, "a")
+ >>> bug.load_comments(load_full=False)
+ >>> comment = bug.comment_root[0]
+ >>> print comment.body
+ This is a comment about a
+ <BLANKLINE>
+ >>> comment.author == bd.user_id
+ True
+ >>> comment.time <= int(time.time())
+ True
+ >>> comment.in_reply_to is None
+ True
+
+ >>> if 'EDITOR' in os.environ:
+ ... del os.environ["EDITOR"]
+ >>> execute(["b"], manipulate_encodings=False)
+ Traceback (most recent call last):
+ UserError: No comment supplied, and EDITOR not specified.
+
+ >>> os.environ["EDITOR"] = "echo 'I like cheese' > "
+ >>> execute(["b"], manipulate_encodings=False)
+ >>> bd._clear_bugs()
+ >>> bug = cmdutil.bug_from_shortname(bd, "b")
+ >>> bug.load_comments(load_full=False)
+ >>> comment = bug.comment_root[0]
+ >>> print comment.body
+ I like cheese
+ <BLANKLINE>
+ >>> bd.cleanup()
+ """
+ parser = get_parser()
+ options, args = parser.parse_args(args)
+ complete(options, args, parser)
+ if len(args) == 0:
+ raise cmdutil.UsageError("Please specify a bug or comment id.")
+ if len(args) > 2:
+ raise cmdutil.UsageError("Too many arguments.")
+
+ shortname = args[0]
+ if shortname.count(':') > 1:
+ raise cmdutil.UserError("Invalid id '%s'." % shortname)
+ elif shortname.count(':') == 1:
+ # Split shortname generated by Comment.comment_shortnames()
+ bugname = shortname.split(':')[0]
+ is_reply = True
+ else:
+ bugname = shortname
+ is_reply = False
+
+ bd = bugdir.BugDir(from_disk=True,
+ manipulate_encodings=manipulate_encodings)
+ bug = cmdutil.bug_from_shortname(bd, bugname)
+ bug.load_comments(load_full=False)
+ if is_reply:
+ parent = bug.comment_root.comment_from_shortname(shortname,
+ bug_shortname=bugname)
+ else:
+ parent = bug.comment_root
+
+ if len(args) == 1: # try to launch an editor for comment-body entry
+ try:
+ if parent == bug.comment_root:
+ parent_body = bug.summary+"\n"
+ else:
+ parent_body = parent.body
+ estr = "Please enter your comment above\n\n> %s\n" \
+ % ("\n> ".join(parent_body.splitlines()))
+ body = editor.editor_string(estr)
+ except editor.CantFindEditor, e:
+ raise cmdutil.UserError, "No comment supplied, and EDITOR not specified."
+ if body is None:
+ raise cmdutil.UserError("No comment entered.")
+ elif args[1] == '-': # read body from stdin
+ binary = not (options.content_type == None
+ or options.content_type.startswith("text/"))
+ if not binary:
+ body = sys.stdin.read()
+ if not body.endswith('\n'):
+ body+='\n'
+ else: # read-in without decoding
+ body = sys.__stdin__.read()
+ else: # body = arg[1]
+ body = args[1]
+ if not body.endswith('\n'):
+ body+='\n'
+
+ if options.XML == False:
+ new = parent.new_reply(body=body)
+ if options.author != None:
+ new.author = options.author
+ if options.alt_id != None:
+ new.alt_id = options.alt_id
+ if options.content_type != None:
+ new.content_type = options.content_type
+ else: # import XML comment [list]
+ # read in the comments
+ str_body = body.encode("unicode_escape").replace(r'\n', '\n')
+ comment_list = ElementTree.XML(str_body)
+ if comment_list.tag not in ["bug", "comment-list"]:
+ raise comment.InvalidXML(
+ comment_list, "root element must be <bug> or <comment-list>")
+ new_comments = []
+ ids = []
+ for c in bug.comment_root.traverse():
+ ids.append(c.uuid)
+ if c.alt_id != None:
+ ids.append(c.alt_id)
+ for child in comment_list.getchildren():
+ if child.tag == "comment":
+ new = comment.Comment(bug)
+ new.from_xml(unicode(ElementTree.tostring(child)).decode("unicode_escape"))
+ if new.alt_id in ids:
+ raise cmdutil.UserError(
+ "Clashing comment alt_id: %s" % new.alt_id)
+ ids.append(new.uuid)
+ if new.alt_id != None:
+ ids.append(new.alt_id)
+ if new.in_reply_to == None:
+ new.in_reply_to = parent.uuid
+ new_comments.append(new)
+ else:
+ print >> sys.stderr, "Ignoring unknown tag %s in %s" \
+ % (child.tag, comment_list.tag)
+ try:
+ comment.list_to_root(new_comments,bug,root=parent, # link new comments
+ ignore_missing_references=options.ignore_missing_references)
+ except comment.MissingReference, e:
+ raise cmdutil.UserError(e)
+ # Protect against programmer error causing data loss:
+ kids = [c.uuid for c in parent.traverse()]
+ for nc in new_comments:
+ assert nc.uuid in kids, "%s wasn't added to %s" % (nc.uuid, parent.uuid)
+ nc.save()
+
+def get_parser():
+ parser = cmdutil.CmdOptionParser("be comment ID [COMMENT]")
+ parser.add_option("-a", "--author", metavar="AUTHOR", dest="author",
+ help="Set the comment author", default=None)
+ parser.add_option("--alt-id", metavar="ID", dest="alt_id",
+ help="Set an alternate comment ID", default=None)
+ parser.add_option("-c", "--content-type", metavar="MIME", dest="content_type",
+ help="Set comment content-type (e.g. text/plain)", default=None)
+ parser.add_option("-x", "--xml", action="store_true", default=False,
+ dest='XML', help="Use COMMENT to specify an XML comment description rather than the comment body. The root XML element should be either <bug> or <comment-list> with one or more <comment> children. The syntax for the <comment> elements should match that generated by 'be show --xml COMMENT-ID'. Unrecognized tags are ignored. Missing tags are left at the default value. The comment UUIDs are always auto-generated, so if you set a <uuid> field, but no <alt-id> field, your <uuid> will be used as the comment's <alt-id>. An exception is raised if <alt-id> conflicts with an existing comment.")
+ parser.add_option("-i", "--ignore-missing-references", action="store_true",
+ dest="ignore_missing_references",
+ help="For XML import, if any comment's <in-reply-to> refers to a non-existent comment, ignore it (instead of raising an exception).")
+ return parser
+
+longhelp="""
+To add a comment to a bug, use the bug ID as the argument. To reply
+to another comment, specify the comment name (as shown in "be show"
+output). COMMENT, if specified, should be either the text of your
+comment or "-", in which case the text will be read from stdin. If
+you do not specify a COMMENT, $EDITOR is used to launch an editor. If
+COMMENT is unspecified and EDITOR is not set, no comment will be
+created.
+"""
+
+def help():
+ return get_parser().help_str() + longhelp
+
+def complete(options, args, parser):
+ for option,value in cmdutil.option_value_pairs(options, parser):
+ if value == "--complete":
+ # no argument-options at the moment, so this is future-proofing
+ raise cmdutil.GetCompletions()
+ for pos,value in enumerate(args):
+ if value == "--complete":
+ if pos == 0: # fist positional argument is a bug or comment id
+ if len(args) >= 2:
+ partial = args[1].split(':')[0] # take only bugid portion
+ else:
+ partial = ""
+ ids = []
+ try:
+ bd = bugdir.BugDir(from_disk=True,
+ manipulate_encodings=False)
+ bugs = []
+ for uuid in bd.list_uuids():
+ if uuid.startswith(partial):
+ bug = bd.bug_from_uuid(uuid)
+ if bug.active == True:
+ bugs.append(bug)
+ for bug in bugs:
+ shortname = bd.bug_shortname(bug)
+ ids.append(shortname)
+ bug.load_comments(load_full=False)
+ for id,comment in bug.comment_shortnames(shortname):
+ ids.append(id)
+ except bugdir.NoBugDir:
+ pass
+ raise cmdutil.GetCompletions(ids)
+ raise cmdutil.GetCompletions()
diff --git a/interfaces/email/interactive/becommands/commit.py b/interfaces/email/interactive/becommands/commit.py
new file mode 100644
index 0000000..dc70e7e
--- /dev/null
+++ b/interfaces/email/interactive/becommands/commit.py
@@ -0,0 +1,78 @@
+# Copyright (C) 2009 W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+"""Commit the currently pending changes to the repository"""
+from libbe import cmdutil, bugdir, editor, vcs
+import sys
+__desc__ = __doc__
+
+def execute(args, manipulate_encodings=True):
+ """
+ >>> import os, time
+ >>> from libbe import bug
+ >>> bd = bugdir.SimpleBugDir()
+ >>> os.chdir(bd.root)
+ >>> full_path = "testfile"
+ >>> test_contents = "A test file"
+ >>> bd.vcs.set_file_contents(full_path, test_contents)
+ >>> execute(["Added %s." % (full_path)], manipulate_encodings=False) # doctest: +ELLIPSIS
+ Committed ...
+ >>> bd.cleanup()
+ """
+ parser = get_parser()
+ options, args = parser.parse_args(args)
+ cmdutil.default_complete(options, args, parser)
+ if len(args) != 1:
+ raise cmdutil.UsageError("Please supply a commit message")
+ bd = bugdir.BugDir(from_disk=True,
+ manipulate_encodings=manipulate_encodings)
+ if args[0] == '-': # read summary from stdin
+ assert options.body != "EDITOR", \
+ "Cannot spawn and editor when the summary is using stdin."
+ summary = sys.stdin.readline()
+ else:
+ summary = args[0]
+ if options.body == None:
+ body = None
+ elif options.body == "EDITOR":
+ body = editor.editor_string("Please enter your commit message above")
+ else:
+ body = bd.vcs.get_file_contents(options.body, allow_no_vcs=True)
+ try:
+ revision = bd.vcs.commit(summary, body=body,
+ allow_empty=options.allow_empty)
+ except vcs.EmptyCommit, e:
+ print e
+ return 1
+ else:
+ print "Committed %s" % revision
+
+def get_parser():
+ parser = cmdutil.CmdOptionParser("be commit COMMENT")
+ parser.add_option("-b", "--body", metavar="FILE", dest="body",
+ help='Provide a detailed body for the commit message. In the special case that FILE == "EDITOR", spawn an editor to enter the body text (in which case you cannot use stdin for the summary)', default=None)
+ parser.add_option("-a", "--allow-empty", dest="allow_empty",
+ help="Allow empty commits",
+ default=False, action="store_true")
+ return parser
+
+longhelp="""
+Commit the current repository status. The summary specified on the
+commandline is a string (only one line) that describes the commit
+briefly or "-", in which case the string will be read from stdin.
+"""
+
+def help():
+ return get_parser().help_str() + longhelp
diff --git a/interfaces/email/interactive/becommands/depend.py b/interfaces/email/interactive/becommands/depend.py
new file mode 100644
index 0000000..f72b8ba
--- /dev/null
+++ b/interfaces/email/interactive/becommands/depend.py
@@ -0,0 +1,339 @@
+# Copyright (C) 2009 W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+"""Add/remove bug dependencies"""
+from libbe import cmdutil, bugdir, tree
+import os, copy
+__desc__ = __doc__
+
+BLOCKS_TAG="BLOCKS:"
+BLOCKED_BY_TAG="BLOCKED-BY:"
+
+class BrokenLink (Exception):
+ def __init__(self, blocked_bug, blocking_bug, blocks=True):
+ if blocks == True:
+ msg = "Missing link: %s blocks %s" \
+ % (blocking_bug.uuid, blocked_bug.uuid)
+ else:
+ msg = "Missing link: %s blocked by %s" \
+ % (blocked_bug.uuid, blocking_bug.uuid)
+ Exception.__init__(self, msg)
+ self.blocked_bug = blocked_bug
+ self.blocking_bug = blocking_bug
+
+
+def execute(args, manipulate_encodings=True):
+ """
+ >>> from libbe import utility
+ >>> bd = bugdir.SimpleBugDir()
+ >>> bd.save()
+ >>> os.chdir(bd.root)
+ >>> execute(["a", "b"], manipulate_encodings=False)
+ a blocked by:
+ b
+ >>> execute(["a"], manipulate_encodings=False)
+ a blocked by:
+ b
+ >>> execute(["--show-status", "a"], manipulate_encodings=False) # doctest: +NORMALIZE_WHITESPACE
+ a blocked by:
+ b closed
+ >>> execute(["b", "a"], manipulate_encodings=False)
+ b blocked by:
+ a
+ b blocks:
+ a
+ >>> execute(["--show-status", "a"], manipulate_encodings=False) # doctest: +NORMALIZE_WHITESPACE
+ a blocked by:
+ b closed
+ a blocks:
+ b closed
+ >>> execute(["-r", "b", "a"], manipulate_encodings=False)
+ b blocks:
+ a
+ >>> execute(["-r", "a", "b"], manipulate_encodings=False)
+ >>> bd.cleanup()
+ """
+ parser = get_parser()
+ options, args = parser.parse_args(args)
+ cmdutil.default_complete(options, args, parser,
+ bugid_args={0: lambda bug : bug.active==True,
+ 1: lambda bug : bug.active==True})
+
+ if options.repair == True:
+ if len(args) > 0:
+ raise cmdutil.UsageError("No arguments with --repair calls.")
+ elif len(args) < 1:
+ raise cmdutil.UsageError("Please a bug id.")
+ elif len(args) > 2:
+ help()
+ raise cmdutil.UsageError("Too many arguments.")
+ elif len(args) == 2 and options.tree_depth != None:
+ raise cmdutil.UsageError("Only one bug id used in tree mode.")
+
+
+ bd = bugdir.BugDir(from_disk=True,
+ manipulate_encodings=manipulate_encodings)
+ if options.repair == True:
+ good,fixed,broken = check_dependencies(bd, repair_broken_links=True)
+ assert len(broken) == 0, broken
+ if len(fixed) > 0:
+ print "Fixed the following links:"
+ print "\n".join(["%s |-- %s" % (blockee.uuid, blocker.uuid)
+ for blockee,blocker in fixed])
+ return 0
+
+ bugA = cmdutil.bug_from_shortname(bd, args[0])
+
+ if options.tree_depth != None:
+ dtree = DependencyTree(bd, bugA, options.tree_depth)
+ if len(dtree.blocked_by_tree()) > 0:
+ print "%s blocked by:" % bugA.uuid
+ for depth,node in dtree.blocked_by_tree().thread():
+ if depth == 0: continue
+ print "%s%s" % (" "*(depth), node.bug.string(shortlist=True))
+ if len(dtree.blocks_tree()) > 0:
+ print "%s blocks:" % bugA.uuid
+ for depth,node in dtree.blocks_tree().thread():
+ if depth == 0: continue
+ print "%s%s" % (" "*(depth), node.bug.string(shortlist=True))
+ return 0
+
+ if len(args) == 2:
+ bugB = cmdutil.bug_from_shortname(bd, args[1])
+ if options.remove == True:
+ remove_block(bugA, bugB)
+ else: # add the dependency
+ add_block(bugA, bugB)
+
+ blocked_by = get_blocked_by(bd, bugA)
+ if len(blocked_by) > 0:
+ print "%s blocked by:" % bugA.uuid
+ if options.show_status == True:
+ print '\n'.join(["%s\t%s" % (bug.uuid, bug.status)
+ for bug in blocked_by])
+ else:
+ print '\n'.join([bug.uuid for bug in blocked_by])
+ blocks = get_blocks(bd, bugA)
+ if len(blocks) > 0:
+ print "%s blocks:" % bugA.uuid
+ if options.show_status == True:
+ print '\n'.join(["%s\t%s" % (bug.uuid, bug.status)
+ for bug in blocks])
+ else:
+ print '\n'.join([bug.uuid for bug in blocks])
+
+def get_parser():
+ parser = cmdutil.CmdOptionParser("be depend BUG-ID [BUG-ID]\nor: be depend --repair")
+ parser.add_option("-r", "--remove", action="store_true",
+ dest="remove", default=False,
+ help="Remove dependency (instead of adding it)")
+ parser.add_option("-s", "--show-status", action="store_true",
+ dest="show_status", default=False,
+ help="Show status of blocking bugs")
+ parser.add_option("-t", "--tree-depth", metavar="DEPTH", default=None,
+ type="int", dest="tree_depth",
+ help="Print dependency tree rooted at BUG-ID with DEPTH levels of both blockers and blockees. Set DEPTH <= 0 to disable the depth limit.")
+ parser.add_option("--repair", action="store_true",
+ dest="repair", default=False,
+ help="Check for and repair one-way links")
+ return parser
+
+longhelp="""
+Set a dependency with the second bug (B) blocking the first bug (A).
+If bug B is not specified, just print a list of bugs blocking (A).
+
+To search for bugs blocked by a particular bug, try
+ $ be list --extra-strings BLOCKED-BY:<your-bug-uuid>
+
+In repair mode, add the missing direction to any one-way links.
+
+The "|--" symbol in the repair-mode output is inspired by the
+"negative feedback" arrow common in biochemistry. See, for example
+ http://www.nature.com/nature/journal/v456/n7223/images/nature07513-f5.0.jpg
+"""
+
+def help():
+ return get_parser().help_str() + longhelp
+
+# internal helper functions
+
+def _generate_blocks_string(blocked_bug):
+ return "%s%s" % (BLOCKS_TAG, blocked_bug.uuid)
+
+def _generate_blocked_by_string(blocking_bug):
+ return "%s%s" % (BLOCKED_BY_TAG, blocking_bug.uuid)
+
+def _parse_blocks_string(string):
+ assert string.startswith(BLOCKS_TAG)
+ return string[len(BLOCKS_TAG):]
+
+def _parse_blocked_by_string(string):
+ assert string.startswith(BLOCKED_BY_TAG)
+ return string[len(BLOCKED_BY_TAG):]
+
+def _add_remove_extra_string(bug, string, add):
+ estrs = bug.extra_strings
+ if add == True:
+ estrs.append(string)
+ else: # remove the string
+ estrs.remove(string)
+ bug.extra_strings = estrs # reassign to notice change
+
+def _get_blocks(bug):
+ uuids = []
+ for line in bug.extra_strings:
+ if line.startswith(BLOCKS_TAG):
+ uuids.append(_parse_blocks_string(line))
+ return uuids
+
+def _get_blocked_by(bug):
+ uuids = []
+ for line in bug.extra_strings:
+ if line.startswith(BLOCKED_BY_TAG):
+ uuids.append(_parse_blocked_by_string(line))
+ return uuids
+
+def _repair_one_way_link(blocked_bug, blocking_bug, blocks=None):
+ if blocks == True: # add blocks link
+ blocks_string = _generate_blocks_string(blocked_bug)
+ _add_remove_extra_string(blocking_bug, blocks_string, add=True)
+ else: # add blocked by link
+ blocked_by_string = _generate_blocked_by_string(blocking_bug)
+ _add_remove_extra_string(blocked_bug, blocked_by_string, add=True)
+
+# functions exposed to other modules
+
+def add_block(blocked_bug, blocking_bug):
+ blocked_by_string = _generate_blocked_by_string(blocking_bug)
+ _add_remove_extra_string(blocked_bug, blocked_by_string, add=True)
+ blocks_string = _generate_blocks_string(blocked_bug)
+ _add_remove_extra_string(blocking_bug, blocks_string, add=True)
+
+def remove_block(blocked_bug, blocking_bug):
+ blocked_by_string = _generate_blocked_by_string(blocking_bug)
+ _add_remove_extra_string(blocked_bug, blocked_by_string, add=False)
+ blocks_string = _generate_blocks_string(blocked_bug)
+ _add_remove_extra_string(blocking_bug, blocks_string, add=False)
+
+def get_blocks(bugdir, bug):
+ """
+ Return a list of bugs that the given bug blocks.
+ """
+ blocks = []
+ for uuid in _get_blocks(bug):
+ blocks.append(bugdir.bug_from_uuid(uuid))
+ return blocks
+
+def get_blocked_by(bugdir, bug):
+ """
+ Return a list of bugs blocking the given bug blocks.
+ """
+ blocked_by = []
+ for uuid in _get_blocked_by(bug):
+ blocked_by.append(bugdir.bug_from_uuid(uuid))
+ return blocked_by
+
+def check_dependencies(bugdir, repair_broken_links=False):
+ """
+ Check that links are bi-directional for all bugs in bugdir.
+
+ >>> bd = bugdir.SimpleBugDir(sync_with_disk=False)
+ >>> a = bd.bug_from_uuid("a")
+ >>> b = bd.bug_from_uuid("b")
+ >>> blocked_by_string = _generate_blocked_by_string(b)
+ >>> _add_remove_extra_string(a, blocked_by_string, add=True)
+ >>> good,repaired,broken = check_dependencies(bd, repair_broken_links=False)
+ >>> good
+ []
+ >>> repaired
+ []
+ >>> broken
+ [(Bug(uuid='a'), Bug(uuid='b'))]
+ >>> _get_blocks(b)
+ []
+ >>> good,repaired,broken = check_dependencies(bd, repair_broken_links=True)
+ >>> _get_blocks(b)
+ ['a']
+ >>> good
+ []
+ >>> repaired
+ [(Bug(uuid='a'), Bug(uuid='b'))]
+ >>> broken
+ []
+ """
+ if bugdir.sync_with_disk == True:
+ bugdir.load_all_bugs()
+ good_links = []
+ fixed_links = []
+ broken_links = []
+ for bug in bugdir:
+ for blocker in get_blocked_by(bugdir, bug):
+ blocks = get_blocks(bugdir, blocker)
+ if (bug, blocks) in good_links+fixed_links+broken_links:
+ continue # already checked that link
+ if bug not in blocks:
+ if repair_broken_links == True:
+ _repair_one_way_link(bug, blocker, blocks=True)
+ fixed_links.append((bug, blocker))
+ else:
+ broken_links.append((bug, blocker))
+ else:
+ good_links.append((bug, blocker))
+ for blockee in get_blocks(bugdir, bug):
+ blocked_by = get_blocked_by(bugdir, blockee)
+ if (blockee, bug) in good_links+fixed_links+broken_links:
+ continue # already checked that link
+ if bug not in blocked_by:
+ if repair_broken_links == True:
+ _repair_one_way_link(blockee, bug, blocks=False)
+ fixed_links.append((blockee, bug))
+ else:
+ broken_links.append((blockee, bug))
+ else:
+ good_links.append((blockee, bug))
+ return (good_links, fixed_links, broken_links)
+
+class DependencyTree (object):
+ """
+ Note: should probably be DependencyDiGraph.
+ """
+ def __init__(self, bugdir, root_bug, depth_limit=0):
+ self.bugdir = bugdir
+ self.root_bug = root_bug
+ self.depth_limit = depth_limit
+ def _build_tree(self, child_fn):
+ root = tree.Tree()
+ root.bug = self.root_bug
+ root.depth = 0
+ stack = [root]
+ while len(stack) > 0:
+ node = stack.pop()
+ if self.depth_limit > 0 and node.depth == self.depth_limit:
+ continue
+ for bug in child_fn(self.bugdir, node.bug):
+ child = tree.Tree()
+ child.bug = bug
+ child.depth = node.depth+1
+ node.append(child)
+ stack.append(child)
+ return root
+ def blocks_tree(self):
+ if not hasattr(self, "_blocks_tree"):
+ self._blocks_tree = self._build_tree(get_blocks)
+ return self._blocks_tree
+ def blocked_by_tree(self):
+ if not hasattr(self, "_blocked_by_tree"):
+ self._blocked_by_tree = self._build_tree(get_blocked_by)
+ return self._blocked_by_tree
diff --git a/interfaces/email/interactive/becommands/diff.py b/interfaces/email/interactive/becommands/diff.py
new file mode 100644
index 0000000..b6ac5b0
--- /dev/null
+++ b/interfaces/email/interactive/becommands/diff.py
@@ -0,0 +1,120 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+# W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""Compare bug reports with older tree"""
+from libbe import cmdutil, bugdir, diff
+import os
+__desc__ = __doc__
+
+def execute(args, manipulate_encodings=True):
+ """
+ >>> import os
+ >>> bd = bugdir.SimpleBugDir()
+ >>> bd.set_sync_with_disk(True)
+ >>> original = bd.vcs.commit("Original status")
+ >>> bug = bd.bug_from_uuid("a")
+ >>> bug.status = "closed"
+ >>> changed = bd.vcs.commit("Closed bug a")
+ >>> os.chdir(bd.root)
+ >>> if bd.vcs.versioned == True:
+ ... execute([original], manipulate_encodings=False)
+ ... else:
+ ... print "Modified bugs:\\n a:cm: Bug A\\n Changed bug settings:\\n status: open -> closed"
+ Modified bugs:
+ a:cm: Bug A
+ Changed bug settings:
+ status: open -> closed
+ >>> if bd.vcs.versioned == True:
+ ... execute(["--modified", original], manipulate_encodings=False)
+ ... else:
+ ... print "a"
+ a
+ >>> if bd.vcs.versioned == False:
+ ... execute([original], manipulate_encodings=False)
+ ... else:
+ ... print "This directory is not revision-controlled."
+ This directory is not revision-controlled.
+ >>> bd.cleanup()
+ """
+ parser = get_parser()
+ options, args = parser.parse_args(args)
+ cmdutil.default_complete(options, args, parser)
+ if len(args) == 0:
+ revision = None
+ if len(args) == 1:
+ revision = args[0]
+ if len(args) > 1:
+ raise cmdutil.UsageError("Too many arguments.")
+ bd = bugdir.BugDir(from_disk=True,
+ manipulate_encodings=manipulate_encodings)
+ if bd.vcs.versioned == False:
+ print "This directory is not revision-controlled."
+ else:
+ if revision == None: # get the most recent revision
+ revision = bd.vcs.revision_id(-1)
+ old_bd = bd.duplicate_bugdir(revision)
+ d = diff.Diff(old_bd, bd)
+ tree = d.report_tree()
+
+ uuids = []
+ if options.all == True:
+ options.new = options.modified = options.removed = True
+ if options.new == True:
+ uuids.extend([c.name for c in tree.child_by_path("/bugs/new")])
+ if options.modified == True:
+ uuids.extend([c.name for c in tree.child_by_path("/bugs/mod")])
+ if options.removed == True:
+ uuids.extend([c.name for c in tree.child_by_path("/bugs/rem")])
+ if (options.new or options.modified or options.removed) == True:
+ print "\n".join(uuids)
+ else :
+ rep = tree.report_string()
+ if rep != None:
+ print rep
+ bd.remove_duplicate_bugdir()
+
+def get_parser():
+ parser = cmdutil.CmdOptionParser("be diff [options] REVISION")
+ # boolean options
+ bools = (("n", "new", "Print UUIDS for new bugs"),
+ ("m", "modified", "Print UUIDS for modified bugs"),
+ ("r", "removed", "Print UUIDS for removed bugs"),
+ ("a", "all", "Print UUIDS for all changed bugs"))
+ for s in bools:
+ attr = s[1].replace('-','_')
+ short = "-%c" % s[0]
+ long = "--%s" % s[1]
+ help = s[2]
+ parser.add_option(short, long, action="store_true",
+ default=False, dest=attr, help=help)
+ return parser
+
+longhelp="""
+Uses the VCS to compare the current tree with a previous tree, and
+prints a pretty report. If REVISION is given, it is a specifier for
+the particular previous tree to use. Specifiers are specific to their
+VCS.
+
+For Arch your specifier must be a fully-qualified revision name.
+
+Besides the standard summary output, you can use the options to output
+UUIDS for the different categories. This output can be used as the
+input to 'be show' to get and understanding of the current status.
+"""
+
+def help():
+ return get_parser().help_str() + longhelp
diff --git a/interfaces/email/interactive/becommands/help.py b/interfaces/email/interactive/becommands/help.py
new file mode 100644
index 0000000..a8f346a
--- /dev/null
+++ b/interfaces/email/interactive/becommands/help.py
@@ -0,0 +1,68 @@
+# Copyright (C) 2006-2009 Aaron Bentley and Panometrics, Inc.
+# Thomas Gerigk <tgerigk@gmx.de>
+# W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+"""Print help for given subcommand"""
+from libbe import cmdutil, utility
+__desc__ = __doc__
+
+def execute(args, manipulate_encodings=False):
+ """
+ Print help of specified command (the manipulate_encodings argument
+ is ignored).
+
+ >>> execute(["help"])
+ Usage: be help [COMMAND]
+ <BLANKLINE>
+ Options:
+ -h, --help Print a help message
+ --complete Print a list of available completions
+ <BLANKLINE>
+ Print help for specified command or list of all commands.
+ <BLANKLINE>
+ """
+ parser = get_parser()
+ options, args = parser.parse_args(args)
+ complete(options, args, parser)
+ if len(args) > 1:
+ raise cmdutil.UsageError("Too many arguments.")
+ if len(args) == 0:
+ print cmdutil.help()
+ else:
+ try:
+ print cmdutil.help(args[0])
+ except AttributeError:
+ print "No help available"
+
+def get_parser():
+ parser = cmdutil.CmdOptionParser("be help [COMMAND]")
+ return parser
+
+longhelp="""
+Print help for specified command or list of all commands.
+"""
+
+def help():
+ return get_parser().help_str() + longhelp
+
+def complete(options, args, parser):
+ for option, value in cmdutil.option_value_pairs(options, parser):
+ if value == "--complete":
+ # no argument-options at the moment, so this is future-proofing
+ raise cmdutil.GetCompletions()
+ if "--complete" in args:
+ cmds = [command for command,module in cmdutil.iter_commands()]
+ raise cmdutil.GetCompletions(cmds)
diff --git a/interfaces/email/interactive/becommands/html.py b/interfaces/email/interactive/becommands/html.py
new file mode 100644
index 0000000..908c714
--- /dev/null
+++ b/interfaces/email/interactive/becommands/html.py
@@ -0,0 +1,588 @@
+# Copyright (C) 2009 Gianluca Montecchi <gian@grys.it>
+# W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+"""Generate a static HTML dump of the current repository status"""
+from libbe import cmdutil, bugdir, bug
+#from html_data import *
+import codecs, os, re, string, time
+import xml.sax.saxutils, htmlentitydefs
+
+__desc__ = __doc__
+
+def execute(args, manipulate_encodings=True):
+ """
+ >>> import os
+ >>> bd = bugdir.SimpleBugDir()
+ >>> os.chdir(bd.root)
+ >>> execute([], manipulate_encodings=False)
+ Creating the html output in html_export
+ >>> os.path.exists("./html_export")
+ True
+ >>> os.path.exists("./html_export/index.html")
+ True
+ >>> os.path.exists("./html_export/index_inactive.html")
+ True
+ >>> os.path.exists("./html_export/bugs")
+ True
+ >>> os.path.exists("./html_export/bugs/a.html")
+ True
+ >>> os.path.exists("./html_export/bugs/b.html")
+ True
+ >>> bd.cleanup()
+ """
+ parser = get_parser()
+ options, args = parser.parse_args(args)
+ complete(options, args, parser)
+ cmdutil.default_complete(options, args, parser,
+ bugid_args={0: lambda bug : bug.active==False})
+
+ if len(args) == 0:
+ out_dir = options.outdir
+ print "Creating the html output in %s"%out_dir
+ else:
+ out_dir = args[0]
+ if len(args) > 0:
+ raise cmdutil.UsageError, "Too many arguments."
+
+ bd = bugdir.BugDir(from_disk=True,
+ manipulate_encodings=manipulate_encodings)
+ bd.load_all_bugs()
+ status_list = bug.status_values
+ severity_list = bug.severity_values
+ st = {}
+ se = {}
+ stime = {}
+ bugs_active = []
+ bugs_inactive = []
+ for s in status_list:
+ st[s] = 0
+ for b in sorted(bd, reverse=True):
+ stime[b.uuid] = b.time
+ if b.active == True:
+ bugs_active.append(b)
+ else:
+ bugs_inactive.append(b)
+ st[b.status] += 1
+ ordered_bug_list = sorted([(value,key) for (key,value) in stime.items()])
+ ordered_bug_list_in = sorted([(value,key) for (key,value) in stime.items()])
+ #open_bug_list = sorted([(value,key) for (key,value) in bugs.items()])
+
+ html_gen = BEHTMLGen(bd)
+ html_gen.create_index_file(out_dir, st, bugs_active, ordered_bug_list, "active", bd.encoding)
+ html_gen.create_index_file(out_dir, st, bugs_inactive, ordered_bug_list, "inactive", bd.encoding)
+
+def get_parser():
+ parser = cmdutil.CmdOptionParser("be open OUTPUT_DIR")
+ parser.add_option("-o", "--output", metavar="export_dir", dest="outdir",
+ help="Set the output path, default is ./html_export", default="html_export")
+ return parser
+
+longhelp="""
+Generate a set of html pages representing the current state of the bug
+directory.
+"""
+
+def help():
+ return get_parser().help_str() + longhelp
+
+def complete(options, args, parser):
+ for option, value in cmdutil.option_value_pairs(options, parser):
+ if "--complete" in args:
+ raise cmdutil.GetCompletions() # no positional arguments for list
+
+
+def escape(string):
+ if string == None:
+ return ""
+ chars = []
+ for char in xml.sax.saxutils.escape(string):
+ codepoint = ord(char)
+ if codepoint in htmlentitydefs.codepoint2name:
+ char = "&%s;" % htmlentitydefs.codepoint2name[codepoint]
+ chars.append(char)
+ return "".join(chars)
+
+class BEHTMLGen():
+ def __init__(self, bd):
+ self.index_value = ""
+ self.bd = bd
+
+ self.css_file = """
+ body {
+ font-family: "lucida grande", "sans serif";
+ color: #333;
+ width: auto;
+ margin: auto;
+ }
+
+
+ div.main {
+ padding: 20px;
+ margin: auto;
+ padding-top: 0;
+ margin-top: 1em;
+ background-color: #fcfcfc;
+ }
+
+ .comment {
+ padding: 20px;
+ margin: auto;
+ padding-top: 20px;
+ margin-top: 0;
+ }
+
+ .commentF {
+ padding: 0px;
+ margin: auto;
+ padding-top: 0px;
+ paddin-bottom: 20px;
+ margin-top: 0;
+ }
+
+ tb {
+ border = 1;
+ }
+
+ .wishlist-row {
+ background-color: #B4FF9B;
+ width: auto;
+ }
+
+ .minor-row {
+ background-color: #FCFF98;
+ width: auto;
+ }
+
+
+ .serious-row {
+ background-color: #FFB648;
+ width: auto;
+ }
+
+ .critical-row {
+ background-color: #FF752A;
+ width: auto;
+ }
+
+ .fatal-row {
+ background-color: #FF3300;
+ width: auto;
+ }
+
+ .person {
+ font-family: courier;
+ }
+
+ a, a:visited {
+ background: inherit;
+ text-decoration: none;
+ }
+
+ a {
+ color: #003d41;
+ }
+
+ a:visited {
+ color: #553d41;
+ }
+
+ ul {
+ list-style-type: none;
+ padding: 0;
+ }
+
+ p {
+ width: auto;
+ }
+
+ .inline-status-image {
+ position: relative;
+ top: 0.2em;
+ }
+
+ .dimmed {
+ color: #bbb;
+ }
+
+ table {
+ border-style: 10px solid #313131;
+ border-spacing: 0;
+ width: auto;
+ }
+
+ table.log {
+ }
+
+ td {
+ border-width: 0;
+ border-style: none;
+ padding-right: 0.5em;
+ padding-left: 0.5em;
+ width: auto;
+ }
+
+ .td_sel {
+ background-color: #afafaf;
+ border: 1px solid #afafaf;
+ font-weight:bold;
+ padding-right: 1em;
+ padding-left: 1em;
+
+ }
+
+ .td_nsel {
+ border: 0px;
+ padding-right: 1em;
+ padding-left: 1em;
+ }
+
+ tr {
+ vertical-align: top;
+ width: auto;
+ }
+
+ h1 {
+ padding: 0.5em;
+ background-color: #305275;
+ margin-top: 0;
+ margin-bottom: 0;
+ color: #fff;
+ margin-left: -20px;
+ margin-right: -20px;
+ }
+
+ wid {
+ text-transform: uppercase;
+ font-size: smaller;
+ margin-top: 1em;
+ margin-left: -0.5em;
+ /*background: #fffbce;*/
+ /*background: #628a0d;*/
+ padding: 5px;
+ color: #305275;
+ }
+
+ .attrname {
+ text-align: right;
+ font-size: smaller;
+ }
+
+ .attrval {
+ color: #222;
+ }
+
+ .issue-closed-fixed {
+ background-image: "green-check.png";
+ }
+
+ .issue-closed-wontfix {
+ background-image: "red-check.png";
+ }
+
+ .issue-closed-reorg {
+ background-image: "blue-check.png";
+ }
+
+ .inline-issue-link {
+ text-decoration: underline;
+ }
+
+ img {
+ border: 0;
+ }
+
+
+ div.footer {
+ font-size: small;
+ padding-left: 20px;
+ padding-right: 20px;
+ padding-top: 5px;
+ padding-bottom: 5px;
+ margin: auto;
+ background: #305275;
+ color: #fffee7;
+ }
+
+ .footer a {
+ color: #508d91;
+ }
+
+
+ .header {
+ font-family: "lucida grande", "sans serif";
+ font-size: smaller;
+ background-color: #a9a9a9;
+ text-align: left;
+
+ padding-right: 0.5em;
+ padding-left: 0.5em;
+
+ }
+
+
+ .selected-cell {
+ background-color: #e9e9e2;
+ }
+
+ .plain-cell {
+ background-color: #f9f9f9;
+ }
+
+
+ .logcomment {
+ padding-left: 4em;
+ font-size: smaller;
+ }
+
+ .id {
+ font-family: courier;
+ }
+
+ .table_bug {
+ background-color: #afafaf;
+ border: 2px solid #afafaf;
+ }
+
+ .message {
+ }
+
+ .progress-meter-done {
+ background-color: #03af00;
+ }
+
+ .progress-meter-undone {
+ background-color: #ddd;
+ }
+
+ .progress-meter {
+ }
+
+ """
+
+ self.index_first = """
+ <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+ <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
+ <head>
+ <title>BugsEverywhere Issue Tracker</title>
+ <meta http-equiv="Content-Type" content="text/html; charset=%s" />
+ <link rel="stylesheet" href="style.css" type="text/css" />
+ </head>
+ <body>
+
+
+ <div class="main">
+ <h1>BugsEverywhere Bug List</h1>
+ <p></p>
+ <table>
+
+ <tr>
+ <td class="%%s"><a href="index.html">Active Bugs</a></td>
+ <td class="%%s"><a href="index_inactive.html">Inactive Bugs</a></td>
+ </tr>
+
+ </table>
+ <table class="table_bug">
+ <tbody>
+ """ % self.bd.encoding
+
+ self.bug_line ="""
+ <tr class="%s-row">
+ <td ><a href="bugs/%s.html">%s</a></td>
+ <td ><a href="bugs/%s.html">%s</a></td>
+ <td><a href="bugs/%s.html">%s</a></td>
+ <td><a href="bugs/%s.html">%s</a></td>
+ <td><a href="bugs/%s.html">%s</a></td>
+ </tr>
+ """
+
+ self.detail_first = """
+ <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+ <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
+ <head>
+ <title>BugsEverywhere Issue Tracker</title>
+ <meta http-equiv="Content-Type" content="text/html; charset=%s" />
+ <link rel="stylesheet" href="../style.css" type="text/css" />
+ </head>
+ <body>
+
+
+ <div class="main">
+ <h1>BugsEverywhere Bug List</h1>
+ <h5><a href="%%s">Back to Index</a></h5>
+ <h2>Bug: _bug_id_</h2>
+ <table >
+ <tbody>
+ """ % self.bd.encoding
+
+
+
+ self.detail_line ="""
+ <tr>
+ <td align="right">%s</td><td>%s</td>
+ </tr>
+ """
+
+ self.index_last = """
+ </tbody>
+ </table>
+
+ </div>
+
+ <div class="footer">Generated by <a href="http://www.bugseverywhere.org/">BugsEverywhere</a> on %s</div>
+
+ </body>
+ </html>
+ """
+
+ self.comment_section = """
+ """
+
+ self.begin_comment_section ="""
+ <tr>
+ <td align="right">Comments:
+ </td>
+ <td>
+ """
+
+
+ self.end_comment_section ="""
+ </td>
+ </tr>
+ """
+
+ self.detail_last = """
+ </tbody>
+ </table>
+ </div>
+ <h5><a href="%s">Back to Index</a></h5>
+ <div class="footer">Generated by <a href="http://www.bugseverywhere.org/">BugsEverywhere</a>.</div>
+ </body>
+ </html>
+ """
+
+
+ def create_index_file(self, out_dir_path, summary, bugs, ordered_bug, fileid, encoding):
+ try:
+ os.stat(out_dir_path)
+ except:
+ try:
+ os.mkdir(out_dir_path)
+ except:
+ raise cmdutil.UsageError, "Cannot create output directory."
+ try:
+ FO = codecs.open(out_dir_path+"/style.css", "w", encoding)
+ FO.write(self.css_file)
+ FO.close()
+ except:
+ raise cmdutil.UsageError, "Cannot create the style.css file."
+
+ try:
+ os.mkdir(out_dir_path+"/bugs")
+ except:
+ pass
+
+ try:
+ if fileid == "active":
+ FO = codecs.open(out_dir_path+"/index.html", "w", encoding)
+ FO.write(self.index_first%('td_sel','td_nsel'))
+ if fileid == "inactive":
+ FO = codecs.open(out_dir_path+"/index_inactive.html", "w", encoding)
+ FO.write(self.index_first%('td_nsel','td_sel'))
+ except:
+ raise cmdutil.UsageError, "Cannot create the index.html file."
+
+ c = 0
+ t = len(bugs) - 1
+ for l in range(t, -1, -1):
+ line = self.bug_line%(escape(bugs[l].severity),
+ escape(bugs[l].uuid), escape(bugs[l].uuid[0:3]),
+ escape(bugs[l].uuid), escape(bugs[l].status),
+ escape(bugs[l].uuid), escape(bugs[l].severity),
+ escape(bugs[l].uuid), escape(bugs[l].summary),
+ escape(bugs[l].uuid), escape(bugs[l].time_string)
+ )
+ FO.write(line)
+ c += 1
+ self.create_detail_file(bugs[l], out_dir_path, fileid, encoding)
+ when = time.ctime()
+ FO.write(self.index_last%when)
+
+
+ def create_detail_file(self, bug, out_dir_path, fileid, encoding):
+ f = "%s.html"%bug.uuid
+ p = out_dir_path+"/bugs/"+f
+ try:
+ FD = codecs.open(p, "w", encoding)
+ except:
+ raise cmdutil.UsageError, "Cannot create the detail html file."
+
+ detail_first_ = re.sub('_bug_id_', bug.uuid[0:3], self.detail_first)
+ if fileid == "active":
+ FD.write(detail_first_%"../index.html")
+ if fileid == "inactive":
+ FD.write(detail_first_%"../index_inactive.html")
+
+
+
+ bug_ = self.bd.bug_from_shortname(bug.uuid)
+ bug_.load_comments(load_full=True)
+
+ FD.write(self.detail_line%("ID : ", bug.uuid))
+ FD.write(self.detail_line%("Short name : ", escape(bug.uuid[0:3])))
+ FD.write(self.detail_line%("Severity : ", escape(bug.severity)))
+ FD.write(self.detail_line%("Status : ", escape(bug.status)))
+ FD.write(self.detail_line%("Assigned : ", escape(bug.assigned)))
+ FD.write(self.detail_line%("Target : ", escape(bug.target)))
+ FD.write(self.detail_line%("Reporter : ", escape(bug.reporter)))
+ FD.write(self.detail_line%("Creator : ", escape(bug.creator)))
+ FD.write(self.detail_line%("Created : ", escape(bug.time_string)))
+ FD.write(self.detail_line%("Summary : ", escape(bug.summary)))
+ FD.write("<tr><td colspan=\"2\"><hr /></td></tr>")
+ FD.write(self.begin_comment_section)
+ tr = []
+ b = ''
+ level = 0
+ stack = []
+ for depth,comment in bug_.comment_root.thread(flatten=False):
+ while len(stack) > depth:
+ stack.pop(-1) # pop non-parents off the stack
+ FD.write("</div>\n") # close non-parent <div class="comment...
+ assert len(stack) == depth
+ stack.append(comment)
+ lines = ["--------- Comment ---------",
+ "Name: %s" % comment.uuid,
+ "From: %s" % escape(comment.author),
+ "Date: %s" % escape(comment.date),
+ ""]
+ lines.extend(escape(comment.body).splitlines())
+ if depth == 0:
+ FD.write('<div class="commentF">')
+ else:
+ FD.write('<div class="comment">')
+ FD.write("<br />\n".join(lines)+"<br />\n")
+ while len(stack) > 0:
+ stack.pop(-1)
+ FD.write("</div>\n") # close every remaining <div class="comment...
+ FD.write(self.end_comment_section)
+ if fileid == "active":
+ FD.write(self.detail_last%"../index.html")
+ if fileid == "inactive":
+ FD.write(self.detail_last%"../index_inactive.html")
+ FD.close()
+
+
diff --git a/interfaces/email/interactive/becommands/init.py b/interfaces/email/interactive/becommands/init.py
new file mode 100644
index 0000000..1125d93
--- /dev/null
+++ b/interfaces/email/interactive/becommands/init.py
@@ -0,0 +1,100 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+# W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+"""Assign the root directory for bug tracking"""
+import os.path
+from libbe import cmdutil, bugdir
+__desc__ = __doc__
+
+def execute(args, manipulate_encodings=True):
+ """
+ >>> from libbe import utility, vcs
+ >>> import os
+ >>> dir = utility.Dir()
+ >>> try:
+ ... bugdir.BugDir(dir.path)
+ ... except bugdir.NoBugDir, e:
+ ... True
+ True
+ >>> execute(['--root', dir.path], manipulate_encodings=False)
+ No revision control detected.
+ Directory initialized.
+ >>> del(dir)
+
+ >>> dir = utility.Dir()
+ >>> os.chdir(dir.path)
+ >>> vcs = vcs.installed_vcs()
+ >>> vcs.init('.')
+ >>> print vcs.name
+ Arch
+ >>> execute([], manipulate_encodings=False)
+ Using Arch for revision control.
+ Directory initialized.
+ >>> vcs.cleanup()
+
+ >>> try:
+ ... execute(['--root', '.'], manipulate_encodings=False)
+ ... except cmdutil.UserError, e:
+ ... str(e).startswith("Directory already initialized: ")
+ True
+ >>> execute(['--root', '/highly-unlikely-to-exist'], manipulate_encodings=False)
+ Traceback (most recent call last):
+ UserError: No such directory: /highly-unlikely-to-exist
+ >>> os.chdir('/')
+ """
+ parser = get_parser()
+ options, args = parser.parse_args(args)
+ cmdutil.default_complete(options, args, parser)
+ if len(args) > 0:
+ raise cmdutil.UsageError
+ try:
+ bd = bugdir.BugDir(options.root_dir, from_disk=False,
+ sink_to_existing_root=False,
+ assert_new_BugDir=True,
+ manipulate_encodings=manipulate_encodings)
+ except bugdir.NoRootEntry:
+ raise cmdutil.UserError("No such directory: %s" % options.root_dir)
+ except bugdir.AlreadyInitialized:
+ raise cmdutil.UserError("Directory already initialized: %s" % options.root_dir)
+ bd.save()
+ if bd.vcs.name is not "None":
+ print "Using %s for revision control." % bd.vcs.name
+ else:
+ print "No revision control detected."
+ print "Directory initialized."
+
+def get_parser():
+ parser = cmdutil.CmdOptionParser("be init")
+ parser.add_option("-r", "--root", metavar="DIR", dest="root_dir",
+ help="Set root dir to something other than the current directory.",
+ default=".")
+ return parser
+
+longhelp="""
+This command initializes Bugs Everywhere support for the specified directory
+and all its subdirectories. It will auto-detect any supported revision control
+system. You can use "be set vcs_name" to change the vcs being used.
+
+The directory defaults to your current working directory.
+
+It is usually a good idea to put the Bugs Everywhere root at the source code
+root, but you can put it anywhere. If you root Bugs Everywhere in a
+subdirectory, then only bugs created in that subdirectory (and its children)
+will appear there.
+"""
+
+def help():
+ return get_parser().help_str() + longhelp
diff --git a/interfaces/email/interactive/becommands/list.py b/interfaces/email/interactive/becommands/list.py
new file mode 100644
index 0000000..12e1e29
--- /dev/null
+++ b/interfaces/email/interactive/becommands/list.py
@@ -0,0 +1,248 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+# Chris Ball <cjb@laptop.org>
+# Oleg Romanyshyn <oromanyshyn@panoramicfeedback.com>
+# W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+"""List bugs"""
+from libbe import cmdutil, bugdir, bug
+import os
+import re
+__desc__ = __doc__
+
+# get a list of * for cmp_*() comparing two bugs.
+AVAILABLE_CMPS = [fn[4:] for fn in dir(bug) if fn[:4] == 'cmp_']
+AVAILABLE_CMPS.remove("attr") # a cmp_* template.
+
+def execute(args, manipulate_encodings=True):
+ """
+ >>> import os
+ >>> bd = bugdir.SimpleBugDir()
+ >>> os.chdir(bd.root)
+ >>> execute([], manipulate_encodings=False)
+ a:om: Bug A
+ >>> execute(["--status", "all"], manipulate_encodings=False)
+ a:om: Bug A
+ b:cm: Bug B
+ >>> bd.cleanup()
+ """
+ parser = get_parser()
+ options, args = parser.parse_args(args)
+ complete(options, args, parser)
+ if len(args) > 0:
+ raise cmdutil.UsageError("Too many arguments.")
+ cmp_list = []
+ if options.sort_by != None:
+ for cmp in options.sort_by.split(','):
+ if cmp not in AVAILABLE_CMPS:
+ raise cmdutil.UserError(
+ "Invalid sort on '%s'.\nValid sorts:\n %s"
+ % (cmp, '\n '.join(AVAILABLE_CMPS)))
+ cmp_list.append(eval('bug.cmp_%s' % cmp))
+
+ bd = bugdir.BugDir(from_disk=True,
+ manipulate_encodings=manipulate_encodings)
+ bd.load_all_bugs()
+ # select status
+ if options.status != None:
+ if options.status == "all":
+ status = bug.status_values
+ else:
+ status = options.status.split(',')
+ else:
+ status = []
+ if options.active == True:
+ status.extend(list(bug.active_status_values))
+ if options.unconfirmed == True:
+ status.append("unconfirmed")
+ if options.open == True:
+ status.append("opened")
+ if options.test == True:
+ status.append("test")
+ if status == []: # set the default value
+ status = bug.active_status_values
+ # select severity
+ if options.severity != None:
+ if options.severity == "all":
+ severity = bug.severity_values
+ else:
+ severity = options.severity.split(',')
+ else:
+ severity = []
+ if options.wishlist == True:
+ severity.extend("wishlist")
+ if options.important == True:
+ serious = bug.severity_values.index("serious")
+ severity.append(list(bug.severity_values[serious:]))
+ if severity == []: # set the default value
+ severity = bug.severity_values
+ # select assigned
+ if options.assigned != None:
+ if options.assigned == "all":
+ assigned = "all"
+ else:
+ assigned = options.assigned.split(',')
+ else:
+ assigned = []
+ if options.mine == True:
+ assigned.extend('-')
+ if assigned == []: # set the default value
+ assigned = "all"
+ for i in range(len(assigned)):
+ if assigned[i] == '-':
+ assigned[i] = bd.user_id
+ # select target
+ if options.target != None:
+ if options.target == "all":
+ target = "all"
+ else:
+ target = options.target.split(',')
+ else:
+ target = []
+ if options.cur_target == True:
+ target.append(bd.target)
+ if target == []: # set the default value
+ target = "all"
+ if options.extra_strings != None:
+ extra_string_regexps = [re.compile(x) for x in options.extra_strings.split(',')]
+
+ def filter(bug):
+ if status != "all" and not bug.status in status:
+ return False
+ if severity != "all" and not bug.severity in severity:
+ return False
+ if assigned != "all" and not bug.assigned in assigned:
+ return False
+ if target != "all" and not bug.target in target:
+ return False
+ if options.extra_strings != None:
+ if len(bug.extra_strings) == 0 and len(extra_string_regexps) > 0:
+ return False
+ for string in bug.extra_strings:
+ for regexp in extra_string_regexps:
+ if not regexp.match(string):
+ return False
+ return True
+
+ bugs = [b for b in bd if filter(b) ]
+ if len(bugs) == 0 and options.xml == False:
+ print "No matching bugs found"
+
+ def list_bugs(cur_bugs, title=None, just_uuids=False, xml=False):
+ if xml == True:
+ print '<?xml version="1.0" encoding="%s" ?>' % bd.encoding
+ print "<bugs>"
+ if len(cur_bugs) > 0:
+ if title != None and xml == False:
+ print cmdutil.underlined(title)
+ for bg in cur_bugs:
+ if xml == True:
+ print bg.xml(show_comments=True)
+ elif just_uuids:
+ print bg.uuid
+ else:
+ print bg.string(shortlist=True)
+ if xml == True:
+ print "</bugs>"
+
+ # sort bugs
+ cmp_list.extend(bug.DEFAULT_CMP_FULL_CMP_LIST)
+ cmp_fn = bug.BugCompoundComparator(cmp_list=cmp_list)
+ bugs.sort(cmp_fn)
+
+ # print list of bugs
+ list_bugs(bugs, just_uuids=options.uuids, xml=options.xml)
+
+def get_parser():
+ parser = cmdutil.CmdOptionParser("be list [options]")
+ parser.add_option("-s", "--status", metavar="STATUS", dest="status",
+ help="List bugs matching STATUS", default=None)
+ parser.add_option("-v", "--severity", metavar="SEVERITY", dest="severity",
+ help="List bugs matching SEVERITY", default=None)
+ parser.add_option("-a", "--assigned", metavar="ASSIGNED", dest="assigned",
+ help="List bugs matching ASSIGNED", default=None)
+ parser.add_option("-t", "--target", metavar="TARGET", dest="target",
+ help="List bugs matching TARGET", default=None)
+ parser.add_option("-e", "--extra-strings", metavar="STRINGS", dest="extra_strings",
+ help="List bugs matching _all_ extra strings in comma-seperated list STRINGS. e.g. --extra-strings TAG:working,TAG:xml", default=None)
+ parser.add_option("-S", "--sort", metavar="SORT-BY", dest="sort_by",
+ help="Adjust bug-sort criteria with comma-separated list SORT-BY. e.g. \"--sort creator,time\". Available criteria: %s" % ','.join(AVAILABLE_CMPS), default=None)
+ # boolean options. All but uuids and xml are special cases of long forms
+ bools = (("u", "uuids", "Only print the bug UUIDS"),
+ ("w", "wishlist", "List bugs with 'wishlist' severity"),
+ ("i", "important", "List bugs with >= 'serious' severity"),
+ ("A", "active", "List all active bugs"),
+ ("U", "unconfirmed", "List unconfirmed bugs"),
+ ("o", "open", "List open bugs"),
+ ("T", "test", "List bugs in testing"),
+ ("m", "mine", "List bugs assigned to you"),
+ ("c", "cur-target", "List bugs for the current target"),
+ ("x", "xml", "Dump as XML"))
+ for s in bools:
+ attr = s[1].replace('-','_')
+ short = "-%c" % s[0]
+ long = "--%s" % s[1]
+ help = s[2]
+ parser.add_option(short, long, action="store_true",
+ dest=attr, help=help)
+ return parser
+
+
+def help():
+ longhelp="""
+This command lists bugs. Normally it prints a short string like
+ 576:om: Allow attachments
+Where
+ 576 the bug id
+ o the bug status is 'open' (first letter)
+ m the bug severity is 'minor' (first letter)
+ Allo... the bug summary string
+
+You can optionally (-u) print only the bug ids.
+
+There are several criteria that you can filter by:
+ * status
+ * severity
+ * assigned (who the bug is assigned to)
+ * target (bugfix deadline)
+Allowed values for each criterion may be given in a comma seperated
+list. The special string "all" may be used with any of these options
+to match all values of the criterion.
+
+status
+ %s
+severity
+ %s
+assigned
+ free form, with the string '-' being a shortcut for yourself.
+target
+ free form
+
+In addition, there are some shortcut options that set boolean flags.
+The boolean options are ignored if the matching string option is used.
+""" % (','.join(bug.status_values),
+ ','.join(bug.severity_values))
+ return get_parser().help_str() + longhelp
+
+def complete(options, args, parser):
+ for option, value in cmdutil.option_value_pairs(options, parser):
+ if value == "--complete":
+ if option == "status":
+ raise cmdutil.GetCompletions(bug.status_values)
+ elif option == "severity":
+ raise cmdutil.GetCompletions(bug.severity_values)
+ raise cmdutil.GetCompletions()
+ if "--complete" in args:
+ raise cmdutil.GetCompletions() # no positional arguments for list
diff --git a/interfaces/email/interactive/becommands/merge.py b/interfaces/email/interactive/becommands/merge.py
new file mode 100644
index 0000000..f212b01
--- /dev/null
+++ b/interfaces/email/interactive/becommands/merge.py
@@ -0,0 +1,165 @@
+# Copyright (C) 2008-2009 W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+"""Merge duplicate bugs"""
+from libbe import cmdutil, bugdir
+import os, copy
+__desc__ = __doc__
+
+def execute(args, manipulate_encodings=True):
+ """
+ >>> from libbe import utility
+ >>> bd = bugdir.SimpleBugDir()
+ >>> bd.set_sync_with_disk(True)
+ >>> a = bd.bug_from_shortname("a")
+ >>> a.comment_root.time = 0
+ >>> dummy = a.new_comment("Testing")
+ >>> dummy.time = 1
+ >>> dummy = dummy.new_reply("Testing...")
+ >>> dummy.time = 2
+ >>> b = bd.bug_from_shortname("b")
+ >>> b.status = "open"
+ >>> b.comment_root.time = 0
+ >>> dummy = b.new_comment("1 2")
+ >>> dummy.time = 1
+ >>> dummy = dummy.new_reply("1 2 3 4")
+ >>> dummy.time = 2
+ >>> os.chdir(bd.root)
+ >>> execute(["a", "b"], manipulate_encodings=False)
+ Merging bugs a and b
+ >>> bd._clear_bugs()
+ >>> a = bd.bug_from_shortname("a")
+ >>> a.load_comments()
+ >>> mergeA = a.comment_from_shortname(":3")
+ >>> mergeA.time = 3
+ >>> print a.string(show_comments=True) # doctest: +ELLIPSIS
+ ID : a
+ Short name : a
+ Severity : minor
+ Status : open
+ Assigned :
+ Target :
+ Reporter :
+ Creator : John Doe <jdoe@example.com>
+ Created : ...
+ Bug A
+ --------- Comment ---------
+ Name: a:1
+ From: ...
+ Date: ...
+ <BLANKLINE>
+ Testing
+ --------- Comment ---------
+ Name: a:2
+ From: ...
+ Date: ...
+ <BLANKLINE>
+ Testing...
+ --------- Comment ---------
+ Name: a:3
+ From: ...
+ Date: ...
+ <BLANKLINE>
+ Merged from bug b
+ --------- Comment ---------
+ Name: a:4
+ From: ...
+ Date: ...
+ <BLANKLINE>
+ 1 2
+ --------- Comment ---------
+ Name: a:5
+ From: ...
+ Date: ...
+ <BLANKLINE>
+ 1 2 3 4
+ >>> b = bd.bug_from_shortname("b")
+ >>> b.load_comments()
+ >>> mergeB = b.comment_from_shortname(":3")
+ >>> mergeB.time = 3
+ >>> print b.string(show_comments=True) # doctest: +ELLIPSIS
+ ID : b
+ Short name : b
+ Severity : minor
+ Status : closed
+ Assigned :
+ Target :
+ Reporter :
+ Creator : Jane Doe <jdoe@example.com>
+ Created : ...
+ Bug B
+ --------- Comment ---------
+ Name: b:1
+ From: ...
+ Date: ...
+ <BLANKLINE>
+ 1 2
+ --------- Comment ---------
+ Name: b:2
+ From: ...
+ Date: ...
+ <BLANKLINE>
+ 1 2 3 4
+ --------- Comment ---------
+ Name: b:3
+ From: ...
+ Date: ...
+ <BLANKLINE>
+ Merged into bug a
+ >>> print b.status
+ closed
+ >>> bd.cleanup()
+ """
+ parser = get_parser()
+ options, args = parser.parse_args(args)
+ cmdutil.default_complete(options, args, parser,
+ bugid_args={0: lambda bug : bug.active==True,
+ 1: lambda bug : bug.active==True})
+
+ if len(args) < 2:
+ raise cmdutil.UsageError("Please specify two bug ids.")
+ if len(args) > 2:
+ help()
+ raise cmdutil.UsageError("Too many arguments.")
+
+ bd = bugdir.BugDir(from_disk=True,
+ manipulate_encodings=manipulate_encodings)
+ bugA = cmdutil.bug_from_shortname(bd, args[0])
+ bugA.load_comments()
+ bugB = cmdutil.bug_from_shortname(bd, args[1])
+ bugB.load_comments()
+ mergeA = bugA.new_comment("Merged from bug %s" % bugB.uuid)
+ newCommTree = copy.deepcopy(bugB.comment_root)
+ for comment in newCommTree.traverse(): # all descendant comments
+ comment.bug = bugA
+ comment.save() # force onto disk under bugA
+ for comment in newCommTree: # just the child comments
+ mergeA.add_reply(comment, allow_time_inversion=True)
+ bugB.new_comment("Merged into bug %s" % bugA.uuid)
+ bugB.status = "closed"
+ print "Merging bugs %s and %s" % (bugA.uuid, bugB.uuid)
+
+def get_parser():
+ parser = cmdutil.CmdOptionParser("be merge BUG-ID BUG-ID")
+ return parser
+
+longhelp="""
+The second bug (B) is merged into the first (A). This adds merge
+comments to both bugs, closes B, and appends B's comment tree to A's
+merge comment.
+"""
+
+def help():
+ return get_parser().help_str() + longhelp
diff --git a/interfaces/email/interactive/becommands/new.py b/interfaces/email/interactive/becommands/new.py
new file mode 100644
index 0000000..a8ee2ec
--- /dev/null
+++ b/interfaces/email/interactive/becommands/new.py
@@ -0,0 +1,80 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+# W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+"""Create a new bug"""
+from libbe import cmdutil, bugdir
+import sys
+__desc__ = __doc__
+
+def execute(args, manipulate_encodings=True):
+ """
+ >>> import os, time
+ >>> from libbe import bug
+ >>> bd = bugdir.SimpleBugDir()
+ >>> os.chdir(bd.root)
+ >>> bug.uuid_gen = lambda: "X"
+ >>> execute (["this is a test",], manipulate_encodings=False)
+ Created bug with ID X
+ >>> bd._clear_bugs()
+ >>> bug = bd.bug_from_uuid("X")
+ >>> print bug.summary
+ this is a test
+ >>> bug.time <= int(time.time())
+ True
+ >>> print bug.severity
+ minor
+ >>> bug.target == None
+ True
+ >>> bd.cleanup()
+ """
+ parser = get_parser()
+ options, args = parser.parse_args(args)
+ cmdutil.default_complete(options, args, parser)
+ if len(args) != 1:
+ raise cmdutil.UsageError("Please supply a summary message")
+ bd = bugdir.BugDir(from_disk=True,
+ manipulate_encodings=manipulate_encodings)
+ if args[0] == '-': # read summary from stdin
+ summary = sys.stdin.readline()
+ else:
+ summary = args[0]
+ bug = bd.new_bug(summary=summary.strip())
+ if options.reporter != None:
+ bug.reporter = options.reporter
+ else:
+ bug.reporter = bug.creator
+ if options.assigned != None:
+ bug.assigned = options.assigned
+ elif bd.default_assignee != None:
+ bug.assigned = bd.default_assignee
+ print "Created bug with ID %s" % bd.bug_shortname(bug)
+
+def get_parser():
+ parser = cmdutil.CmdOptionParser("be new SUMMARY")
+ parser.add_option("-r", "--reporter", metavar="REPORTER", dest="reporter",
+ help="The user who reported the bug", default=None)
+ parser.add_option("-a", "--assigned", metavar="ASSIGNED", dest="assigned",
+ help="The developer in charge of the bug", default=None)
+ return parser
+
+longhelp="""
+Create a new bug, with a new ID. The summary specified on the
+commandline is a string (only one line) that describes the bug briefly
+or "-", in which case the string will be read from stdin.
+"""
+
+def help():
+ return get_parser().help_str() + longhelp
diff --git a/interfaces/email/interactive/becommands/open.py b/interfaces/email/interactive/becommands/open.py
new file mode 100644
index 0000000..0c6bf05
--- /dev/null
+++ b/interfaces/email/interactive/becommands/open.py
@@ -0,0 +1,58 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+# Marien Zwart <marienz@gentoo.org>
+# Thomas Gerigk <tgerigk@gmx.de>
+# W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+"""Re-open a bug"""
+from libbe import cmdutil, bugdir
+__desc__ = __doc__
+
+def execute(args, manipulate_encodings=True):
+ """
+ >>> import os
+ >>> bd = bugdir.SimpleBugDir()
+ >>> os.chdir(bd.root)
+ >>> print bd.bug_from_shortname("b").status
+ closed
+ >>> execute(["b"], manipulate_encodings=False)
+ >>> bd._clear_bugs()
+ >>> print bd.bug_from_shortname("b").status
+ open
+ >>> bd.cleanup()
+ """
+ parser = get_parser()
+ options, args = parser.parse_args(args)
+ cmdutil.default_complete(options, args, parser,
+ bugid_args={0: lambda bug : bug.active==False})
+ if len(args) == 0:
+ raise cmdutil.UsageError, "Please specify a bug id."
+ if len(args) > 1:
+ raise cmdutil.UsageError, "Too many arguments."
+ bd = bugdir.BugDir(from_disk=True,
+ manipulate_encodings=manipulate_encodings)
+ bug = cmdutil.bug_from_shortname(bd, args[0])
+ bug.status = "open"
+
+def get_parser():
+ parser = cmdutil.CmdOptionParser("be open BUG-ID")
+ return parser
+
+longhelp="""
+Mark a bug as 'open'.
+"""
+
+def help():
+ return get_parser().help_str() + longhelp
diff --git a/interfaces/email/interactive/becommands/remove.py b/interfaces/email/interactive/becommands/remove.py
new file mode 100644
index 0000000..8d85033
--- /dev/null
+++ b/interfaces/email/interactive/becommands/remove.py
@@ -0,0 +1,62 @@
+# Copyright (C) 2008-2009 W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+"""Remove (delete) a bug and its comments"""
+from libbe import cmdutil, bugdir
+__desc__ = __doc__
+
+def execute(args, manipulate_encodings=True):
+ """
+ >>> from libbe import mapfile
+ >>> import os
+ >>> bd = bugdir.SimpleBugDir()
+ >>> os.chdir(bd.root)
+ >>> print bd.bug_from_shortname("b").status
+ closed
+ >>> execute (["b"], manipulate_encodings=False)
+ Removed bug b
+ >>> bd._clear_bugs()
+ >>> try:
+ ... bd.bug_from_shortname("b")
+ ... except bugdir.NoBugMatches:
+ ... print "Bug not found"
+ Bug not found
+ >>> bd.cleanup()
+ """
+ parser = get_parser()
+ options, args = parser.parse_args(args)
+ cmdutil.default_complete(options, args, parser,
+ bugid_args={0: lambda bug : bug.active==True})
+ if len(args) != 1:
+ raise cmdutil.UsageError, "Please specify a bug id."
+ bd = bugdir.BugDir(from_disk=True,
+ manipulate_encodings=manipulate_encodings)
+ bug = cmdutil.bug_from_shortname(bd, args[0])
+ bd.remove_bug(bug)
+ print "Removed bug %s" % bug.uuid
+
+def get_parser():
+ parser = cmdutil.CmdOptionParser("be remove BUG-ID")
+ return parser
+
+longhelp="""
+Remove (delete) an existing bug. Use with caution: if you're not using a
+revision control system, there may be no way to recover the lost information.
+You should use this command, for example, to get rid of blank or otherwise
+mangled bugs.
+"""
+
+def help():
+ return get_parser().help_str() + longhelp
diff --git a/interfaces/email/interactive/becommands/set.py b/interfaces/email/interactive/becommands/set.py
new file mode 100644
index 0000000..f7e68d3
--- /dev/null
+++ b/interfaces/email/interactive/becommands/set.py
@@ -0,0 +1,130 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+# Chris Ball <cjb@laptop.org>
+# Marien Zwart <marienz@gentoo.org>
+# Thomas Gerigk <tgerigk@gmx.de>
+# W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+"""Change tree settings"""
+import textwrap
+from libbe import cmdutil, bugdir, vcs, settings_object
+__desc__ = __doc__
+
+def _value_string(bd, setting):
+ val = bd.settings.get(setting, settings_object.EMPTY)
+ if val == settings_object.EMPTY:
+ default = getattr(bd, bd._setting_name_to_attr_name(setting))
+ if default not in [None, settings_object.EMPTY]:
+ val = "None (%s)" % default
+ else:
+ val = None
+ return str(val)
+
+def execute(args, manipulate_encodings=True):
+ """
+ >>> import os
+ >>> bd = bugdir.SimpleBugDir()
+ >>> os.chdir(bd.root)
+ >>> execute(["target"], manipulate_encodings=False)
+ None
+ >>> execute(["target", "tomorrow"], manipulate_encodings=False)
+ >>> execute(["target"], manipulate_encodings=False)
+ tomorrow
+ >>> execute(["target", "none"], manipulate_encodings=False)
+ >>> execute(["target"], manipulate_encodings=False)
+ None
+ >>> bd.cleanup()
+ """
+ parser = get_parser()
+ options, args = parser.parse_args(args)
+ complete(options, args, parser)
+ if len(args) > 2:
+ raise cmdutil.UsageError, "Too many arguments"
+ bd = bugdir.BugDir(from_disk=True,
+ manipulate_encodings=manipulate_encodings)
+ if len(args) == 0:
+ keys = bd.settings_properties
+ keys.sort()
+ for key in keys:
+ print "%16s: %s" % (key, _value_string(bd, key))
+ elif len(args) == 1:
+ print _value_string(bd, args[0])
+ else:
+ if args[1] == "none":
+ setattr(bd, args[0], settings_object.EMPTY)
+ else:
+ if args[0] not in bd.settings_properties:
+ msg = "Invalid setting %s\n" % args[0]
+ msg += 'Allowed settings:\n '
+ msg += '\n '.join(bd.settings_properties)
+ raise cmdutil.UserError(msg)
+ old_setting = bd.settings.get(args[0])
+ setattr(bd, args[0], args[1])
+
+def get_parser():
+ parser = cmdutil.CmdOptionParser("be set [NAME] [VALUE]")
+ return parser
+
+def get_bugdir_settings():
+ settings = []
+ for s in bugdir.BugDir.settings_properties:
+ settings.append(s)
+ settings.sort()
+ documented_settings = []
+ for s in settings:
+ set = getattr(bugdir.BugDir, s)
+ dstr = set.__doc__.strip()
+ # per-setting comment adjustments
+ if s == "vcs_name":
+ lines = dstr.split('\n')
+ while lines[0].startswith("This property defaults to") == False:
+ lines.pop(0)
+ assert len(lines) != None, \
+ "Unexpected vcs_name docstring:\n '%s'" % dstr
+ lines.insert(
+ 0, "The name of the revision control system to use.\n")
+ dstr = '\n'.join(lines)
+ doc = textwrap.wrap(dstr, width=70, initial_indent=' ',
+ subsequent_indent=' ')
+ documented_settings.append("%s\n%s" % (s, '\n'.join(doc)))
+ return documented_settings
+
+longhelp="""
+Show or change per-tree settings.
+
+If name and value are supplied, the name is set to a new value.
+If no value is specified, the current value is printed.
+If no arguments are provided, all names and values are listed.
+
+To unset a setting, set it to "none".
+
+Allowed settings are:
+
+%s""" % ('\n'.join(get_bugdir_settings()),)
+
+def help():
+ return get_parser().help_str() + longhelp
+
+def complete(options, args, parser):
+ for option, value in cmdutil.option_value_pairs(options, parser):
+ if value == "--complete":
+ # no argument-options at the moment, so this is future-proofing
+ raise cmdutil.GetCompletions()
+ for pos,value in enumerate(args):
+ if value == "--complete":
+ if pos == 0: # first positional argument is a setting name
+ props = bugdir.BugDir.settings_properties
+ raise cmdutil.GetCompletions(props)
+ raise cmdutil.GetCompletions() # no positional arguments for list
diff --git a/interfaces/email/interactive/becommands/severity.py b/interfaces/email/interactive/becommands/severity.py
new file mode 100644
index 0000000..660586e
--- /dev/null
+++ b/interfaces/email/interactive/becommands/severity.py
@@ -0,0 +1,103 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+# Marien Zwart <marienz@gentoo.org>
+# Thomas Gerigk <tgerigk@gmx.de>
+# W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+"""Show or change a bug's severity level"""
+from libbe import cmdutil, bugdir, bug
+__desc__ = __doc__
+
+def execute(args, manipulate_encodings=True):
+ """
+ >>> import os
+ >>> bd = bugdir.SimpleBugDir()
+ >>> os.chdir(bd.root)
+ >>> execute(["a"], manipulate_encodings=False)
+ minor
+ >>> execute(["a", "wishlist"], manipulate_encodings=False)
+ >>> execute(["a"], manipulate_encodings=False)
+ wishlist
+ >>> execute(["a", "none"], manipulate_encodings=False)
+ Traceback (most recent call last):
+ UserError: Invalid severity level: none
+ >>> bd.cleanup()
+ """
+ parser = get_parser()
+ options, args = parser.parse_args(args)
+ complete(options, args, parser)
+ if len(args) not in (1,2):
+ raise cmdutil.UsageError
+ bd = bugdir.BugDir(from_disk=True,
+ manipulate_encodings=manipulate_encodings)
+ bug = cmdutil.bug_from_shortname(bd, args[0])
+ if len(args) == 1:
+ print bug.severity
+ elif len(args) == 2:
+ try:
+ bug.severity = args[1]
+ except ValueError, e:
+ if e.name != "severity":
+ raise e
+ raise cmdutil.UserError ("Invalid severity level: %s" % e.value)
+
+def get_parser():
+ parser = cmdutil.CmdOptionParser("be severity BUG-ID [SEVERITY]")
+ return parser
+
+def help():
+ longhelp=["""
+Show or change a bug's severity level.
+
+If no severity is specified, the current value is printed. If a severity level
+is specified, it will be assigned to the bug.
+
+Severity levels are:
+"""]
+ try: # See if there are any per-tree severity configurations
+ bd = bugdir.BugDir(from_disk=True, manipulate_encodings=False)
+ except bugdir.NoBugDir, e:
+ pass # No tree, just show the defaults
+ longest_severity_len = max([len(s) for s in bug.severity_values])
+ for severity in bug.severity_values :
+ description = bug.severity_description[severity]
+ s = "%*s : %s\n" % (longest_severity_len, severity, description)
+ longhelp.append(s)
+ longhelp = ''.join(longhelp)
+ return get_parser().help_str() + longhelp
+
+def complete(options, args, parser):
+ for option,value in cmdutil.option_value_pairs(options, parser):
+ if value == "--complete":
+ # no argument-options at the moment, so this is future-proofing
+ raise cmdutil.GetCompletions()
+ for pos,value in enumerate(args):
+ if value == "--complete":
+ try: # See if there are any per-tree severity configurations
+ bd = bugdir.BugDir(from_disk=True,
+ manipulate_encodings=False)
+ except bugdir.NoBugDir:
+ bd = None
+ if pos == 0: # fist positional argument is a bug id
+ ids = []
+ if bd != None:
+ bd.load_all_bugs()
+ filter = lambda bg : bg.active==True
+ bugs = [bg for bg in bd if filter(bg)==True]
+ ids = [bd.bug_shortname(bg) for bg in bugs]
+ raise cmdutil.GetCompletions(ids)
+ elif pos == 1: # second positional argument is a severity
+ raise cmdutil.GetCompletions(bug.severity_values)
+ raise cmdutil.GetCompletions()
diff --git a/interfaces/email/interactive/becommands/show.py b/interfaces/email/interactive/becommands/show.py
new file mode 100644
index 0000000..50bd6eb
--- /dev/null
+++ b/interfaces/email/interactive/becommands/show.py
@@ -0,0 +1,116 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+# Chris Ball <cjb@laptop.org>
+# Thomas Gerigk <tgerigk@gmx.de>
+# Thomas Habets <thomas@habets.pp.se>
+# W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+"""Show a particular bug"""
+import sys
+from libbe import cmdutil, bugdir
+__desc__ = __doc__
+
+def execute(args, manipulate_encodings=True):
+ """
+ >>> import os
+ >>> bd = bugdir.SimpleBugDir()
+ >>> os.chdir(bd.root)
+ >>> execute (["a",], manipulate_encodings=False) # doctest: +ELLIPSIS
+ ID : a
+ Short name : a
+ Severity : minor
+ Status : open
+ Assigned :
+ Target :
+ Reporter :
+ Creator : John Doe <jdoe@example.com>
+ Created : ...
+ Bug A
+ <BLANKLINE>
+ >>> execute (["--xml", "a"], manipulate_encodings=False) # doctest: +ELLIPSIS
+ <?xml version="1.0" encoding="..." ?>
+ <bug>
+ <uuid>a</uuid>
+ <short-name>a</short-name>
+ <severity>minor</severity>
+ <status>open</status>
+ <creator>John Doe &lt;jdoe@example.com&gt;</creator>
+ <created>...</created>
+ <summary>Bug A</summary>
+ </bug>
+ >>> bd.cleanup()
+ """
+ parser = get_parser()
+ options, args = parser.parse_args(args)
+ cmdutil.default_complete(options, args, parser,
+ bugid_args={-1: lambda bug : bug.active==True})
+ if len(args) == 0:
+ raise cmdutil.UsageError
+ bd = bugdir.BugDir(from_disk=True,
+ manipulate_encodings=manipulate_encodings)
+ if options.XML:
+ print '<?xml version="1.0" encoding="%s" ?>' % bd.encoding
+ for shortname in args:
+ if shortname.count(':') > 1:
+ raise cmdutil.UserError("Invalid id '%s'." % shortname)
+ elif shortname.count(':') == 1:
+ # Split shortname generated by Comment.comment_shortnames()
+ bugname = shortname.split(':')[0]
+ is_comment = True
+ else:
+ bugname = shortname
+ is_comment = False
+ if is_comment == True and options.comments == False:
+ continue
+ bug = cmdutil.bug_from_shortname(bd, bugname)
+ if is_comment == False:
+ if options.XML:
+ print bug.xml(show_comments=options.comments)
+ else:
+ print bug.string(show_comments=options.comments)
+ else:
+ comment = bug.comment_root.comment_from_shortname(
+ shortname, bug_shortname=bugname)
+ if options.XML:
+ print comment.xml(shortname=shortname)
+ else:
+ if len(args) == 1 and options.only_raw_body == True:
+ sys.__stdout__.write(comment.body)
+ else:
+ print comment.string(shortname=shortname)
+ if shortname != args[-1] and options.XML == False:
+ print "" # add a blank line between bugs/comments
+
+def get_parser():
+ parser = cmdutil.CmdOptionParser("be show [options] ID [ID ...]")
+ parser.add_option("-x", "--xml", action="store_true", default=False,
+ dest='XML', help="Dump as XML")
+ parser.add_option("--only-raw-body", action="store_true",
+ dest='only_raw_body',
+ help="When printing only a single comment, just print it's body. This allows extraction of non-text content types.")
+ parser.add_option("-c", "--no-comments", dest="comments",
+ action="store_false", default=True,
+ help="Disable comment output. This is useful if you just want more details on a bug's current status.")
+ return parser
+
+longhelp="""
+Show all information about the bugs or comments whose IDs are given.
+
+It's probably not a good idea to mix bug and comment IDs in a single
+call, but you're free to do so if you like.
+"""
+
+def help():
+ return get_parser().help_str() + longhelp
diff --git a/interfaces/email/interactive/becommands/status.py b/interfaces/email/interactive/becommands/status.py
new file mode 100644
index 0000000..f315003
--- /dev/null
+++ b/interfaces/email/interactive/becommands/status.py
@@ -0,0 +1,115 @@
+# Copyright (C) 2008-2009 W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+"""Show or change a bug's status"""
+from libbe import cmdutil, bugdir, bug
+__desc__ = __doc__
+
+def execute(args, manipulate_encodings=True):
+ """
+ >>> import os
+ >>> bd = bugdir.SimpleBugDir()
+ >>> os.chdir(bd.root)
+ >>> execute(["a"], manipulate_encodings=False)
+ open
+ >>> execute(["a", "closed"], manipulate_encodings=False)
+ >>> execute(["a"], manipulate_encodings=False)
+ closed
+ >>> execute(["a", "none"], manipulate_encodings=False)
+ Traceback (most recent call last):
+ UserError: Invalid status: none
+ >>> bd.cleanup()
+ """
+ parser = get_parser()
+ options, args = parser.parse_args(args)
+ complete(options, args, parser)
+ if len(args) not in (1,2):
+ raise cmdutil.UsageError
+ bd = bugdir.BugDir(from_disk=True,
+ manipulate_encodings=manipulate_encodings)
+ bug = cmdutil.bug_from_shortname(bd, args[0])
+ if len(args) == 1:
+ print bug.status
+ else:
+ try:
+ bug.status = args[1]
+ except ValueError, e:
+ if e.name != "status":
+ raise
+ raise cmdutil.UserError ("Invalid status: %s" % e.value)
+
+def get_parser():
+ parser = cmdutil.CmdOptionParser("be status BUG-ID [STATUS]")
+ return parser
+
+
+def help():
+ try: # See if there are any per-tree status configurations
+ bd = bugdir.BugDir(from_disk=True,
+ manipulate_encodings=False)
+ except bugdir.NoBugDir, e:
+ pass # No tree, just show the defaults
+ longest_status_len = max([len(s) for s in bug.status_values])
+ active_statuses = []
+ for status in bug.active_status_values :
+ description = bug.status_description[status]
+ s = "%*s : %s" % (longest_status_len, status, description)
+ active_statuses.append(s)
+ inactive_statuses = []
+ for status in bug.inactive_status_values :
+ description = bug.status_description[status]
+ s = "%*s : %s" % (longest_status_len, status, description)
+ inactive_statuses.append(s)
+ longhelp="""
+Show or change a bug's status.
+
+If no status is specified, the current value is printed. If a status
+is specified, it will be assigned to the bug.
+
+There are two classes of statuses, active and inactive, which are only
+important for commands like "be list" that show only active bugs by
+default.
+
+Active status levels are:
+ %s
+Inactive status levels are:
+ %s
+
+You can overide the list of allowed statuses on a per-repository basis.
+See "be set --help" for more details.
+""" % ('\n '.join(active_statuses), '\n '.join(inactive_statuses))
+ return get_parser().help_str() + longhelp
+
+def complete(options, args, parser):
+ for option,value in cmdutil.option_value_pairs(options, parser):
+ if value == "--complete":
+ # no argument-options at the moment, so this is future-proofing
+ raise cmdutil.GetCompletions()
+ for pos,value in enumerate(args):
+ if value == "--complete":
+ try: # See if there are any per-tree status configurations
+ bd = bugdir.BugDir(from_disk=True,
+ manipulate_encodings=False)
+ except bugdir.NoBugDir:
+ bd = None
+ if pos == 0: # fist positional argument is a bug id
+ ids = []
+ if bd != None:
+ bd.load_all_bugs()
+ ids = [bd.bug_shortname(bg) for bg in bd]
+ raise cmdutil.GetCompletions(ids)
+ elif pos == 1: # second positional argument is a status
+ raise cmdutil.GetCompletions(bug.status_values)
+ raise cmdutil.GetCompletions()
diff --git a/interfaces/email/interactive/becommands/subscribe.py b/interfaces/email/interactive/becommands/subscribe.py
new file mode 100644
index 0000000..0a23057
--- /dev/null
+++ b/interfaces/email/interactive/becommands/subscribe.py
@@ -0,0 +1,390 @@
+# Copyright (C) 2009 W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+"""(Un)subscribe to change notification"""
+from libbe import cmdutil, bugdir, tree
+import os, copy
+__desc__ = __doc__
+
+TAG="SUBSCRIBE:"
+
+class SubscriptionType (tree.Tree):
+ """
+ Trees of subscription types to allow users to select exactly what
+ notifications they want to subscribe to.
+ """
+ def __init__(self, type_name, *args, **kwargs):
+ tree.Tree.__init__(self, *args, **kwargs)
+ self.type = type_name
+ def __str__(self):
+ return self.type
+ def __repr__(self):
+ return "<SubscriptionType: %s>" % str(self)
+ def string_tree(self, indent=0):
+ lines = []
+ for depth,node in self.thread():
+ lines.append("%s%s" % (" "*(indent+2*depth), node))
+ return "\n".join(lines)
+
+BUGDIR_TYPE_NEW = SubscriptionType("new")
+BUGDIR_TYPE_ALL = SubscriptionType("all", [BUGDIR_TYPE_NEW])
+
+# same name as BUGDIR_TYPE_ALL for consistency
+BUG_TYPE_ALL = SubscriptionType(str(BUGDIR_TYPE_ALL))
+
+INVALID_TYPE = SubscriptionType("INVALID")
+
+class InvalidType (ValueError):
+ def __init__(self, type_name, type_root):
+ msg = "Invalid type %s for tree:\n%s" \
+ % (type_name, type_root.string_tree(4))
+ ValueError.__init__(self, msg)
+ self.type_name = type_name
+ self.type_root = type_root
+
+
+def execute(args, manipulate_encodings=True):
+ """
+ >>> bd = bugdir.SimpleBugDir()
+ >>> bd.set_sync_with_disk(True)
+ >>> os.chdir(bd.root)
+ >>> a = bd.bug_from_shortname("a")
+ >>> print a.extra_strings
+ []
+ >>> execute(["-s","John Doe <j@doe.com>", "a"], manipulate_encodings=False) # doctest: +NORMALIZE_WHITESPACE
+ Subscriptions for a:
+ John Doe <j@doe.com> all *
+ >>> bd._clear_bugs() # resync our copy of bug
+ >>> a = bd.bug_from_shortname("a")
+ >>> print a.extra_strings
+ ['SUBSCRIBE:John Doe <j@doe.com>\\tall\\t*']
+ >>> execute(["-s","Jane Doe <J@doe.com>", "-S", "a.com,b.net", "a"], manipulate_encodings=False) # doctest: +NORMALIZE_WHITESPACE
+ Subscriptions for a:
+ Jane Doe <J@doe.com> all a.com,b.net
+ John Doe <j@doe.com> all *
+ >>> execute(["-s","Jane Doe <J@doe.com>", "-S", "a.edu", "a"], manipulate_encodings=False) # doctest: +NORMALIZE_WHITESPACE
+ Subscriptions for a:
+ Jane Doe <J@doe.com> all a.com,a.edu,b.net
+ John Doe <j@doe.com> all *
+ >>> execute(["-u", "-s","Jane Doe <J@doe.com>", "-S", "a.com", "a"], manipulate_encodings=False) # doctest: +NORMALIZE_WHITESPACE
+ Subscriptions for a:
+ Jane Doe <J@doe.com> all a.edu,b.net
+ John Doe <j@doe.com> all *
+ >>> execute(["-s","Jane Doe <J@doe.com>", "-S", "*", "a"], manipulate_encodings=False) # doctest: +NORMALIZE_WHITESPACE
+ Subscriptions for a:
+ Jane Doe <J@doe.com> all *
+ John Doe <j@doe.com> all *
+ >>> execute(["-u", "-s","Jane Doe <J@doe.com>", "a"], manipulate_encodings=False) # doctest: +NORMALIZE_WHITESPACE
+ Subscriptions for a:
+ John Doe <j@doe.com> all *
+ >>> execute(["-u", "-s","John Doe <j@doe.com>", "a"], manipulate_encodings=False)
+ >>> execute(["-s","Jane Doe <J@doe.com>", "-t", "new", "DIR"], manipulate_encodings=False) # doctest: +NORMALIZE_WHITESPACE
+ Subscriptions for bug directory:
+ Jane Doe <J@doe.com> new *
+ >>> execute(["-s","Jane Doe <J@doe.com>", "DIR"], manipulate_encodings=False) # doctest: +NORMALIZE_WHITESPACE
+ Subscriptions for bug directory:
+ Jane Doe <J@doe.com> all *
+ >>> bd.cleanup()
+ """
+ parser = get_parser()
+ options, args = parser.parse_args(args)
+ cmdutil.default_complete(options, args, parser,
+ bugid_args={0: lambda bug : bug.active==True})
+
+ if len(args) > 1:
+ help()
+ raise cmdutil.UsageError("Too many arguments.")
+
+ bd = bugdir.BugDir(from_disk=True,
+ manipulate_encodings=manipulate_encodings)
+
+ subscriber = options.subscriber
+ if subscriber == None:
+ subscriber = bd.user_id
+ if options.unsubscribe == True:
+ if options.servers == None:
+ options.servers = "INVALID"
+ if options.types == None:
+ options.types = "INVALID"
+ else:
+ if options.servers == None:
+ options.servers = "*"
+ if options.types == None:
+ options.types = "all"
+ servers = options.servers.split(",")
+ types = options.types.split(",")
+
+ if len(args) == 0 or args[0] == "DIR": # directory-wide subscriptions
+ type_root = BUGDIR_TYPE_ALL
+ entity = bd
+ entity_name = "bug directory"
+ else: # bug-specific subscriptions
+ type_root = BUG_TYPE_ALL
+ bug = bd.bug_from_shortname(args[0])
+ entity = bug
+ entity_name = bug.uuid
+ if options.list_all == True:
+ entity_name = "anything in the bug directory"
+
+ types = [type_from_name(name, type_root, default=INVALID_TYPE,
+ default_ok=options.unsubscribe)
+ for name in types]
+ estrs = entity.extra_strings
+ if options.list == True or options.list_all == True:
+ pass
+ else: # alter subscriptions
+ if options.unsubscribe == True:
+ estrs = unsubscribe(estrs, subscriber, types, servers, type_root)
+ else: # add the tag
+ estrs = subscribe(estrs, subscriber, types, servers, type_root)
+ entity.extra_strings = estrs # reassign to notice change
+
+ if options.list_all == True:
+ bd.load_all_bugs()
+ subscriptions = get_bugdir_subscribers(bd, servers[0])
+ else:
+ subscriptions = []
+ for estr in entity.extra_strings:
+ if estr.startswith(TAG):
+ subscriptions.append(estr[len(TAG):])
+
+ if len(subscriptions) > 0:
+ print "Subscriptions for %s:" % entity_name
+ print '\n'.join(subscriptions)
+
+
+def get_parser():
+ parser = cmdutil.CmdOptionParser("be subscribe ID")
+ parser.add_option("-u", "--unsubscribe", action="store_true",
+ dest="unsubscribe", default=False,
+ help="Unsubscribe instead of subscribing.")
+ parser.add_option("-a", "--list-all", action="store_true",
+ dest="list_all", default=False,
+ help="List all subscribers (no ID argument, read only action).")
+ parser.add_option("-l", "--list", action="store_true",
+ dest="list", default=False,
+ help="List subscribers (read only action).")
+ parser.add_option("-s", "--subscriber", dest="subscriber",
+ metavar="SUBSCRIBER",
+ help="Email address of the subscriber (defaults to bugdir.user_id).")
+ parser.add_option("-S", "--servers", dest="servers", metavar="SERVERS",
+ help="Servers from which you want notification.")
+ parser.add_option("-t", "--type", dest="types", metavar="TYPES",
+ help="Types of changes you wish to be notified about.")
+ return parser
+
+longhelp="""
+ID can be either a bug id, or blank/"DIR", in which case it refers to the
+whole bug directory.
+
+SERVERS specifies the servers from which you would like to receive
+notification. Multiple severs may be specified in a comma-separated
+list, or you can use "*" to match all servers (the default). If you
+have not selected a server, it should politely refrain from notifying
+you of changes, although there is no way to guarantee this behavior.
+
+Available TYPES:
+ For bugs:
+%s
+ For DIR :
+%s
+
+For unsubscription, any listed SERVERS and TYPES are removed from your
+subscription. Either the catch-all server "*" or type "%s" will
+remove SUBSCRIBER entirely from the specified ID.
+
+This command is intended for use primarily by public interfaces, since
+if you're just hacking away on your private repository, you'll known
+what's changed ;). This command just (un)sets the appropriate
+subscriptions, and leaves it up to each interface to perform the
+notification.
+""" % (BUG_TYPE_ALL.string_tree(6), BUGDIR_TYPE_ALL.string_tree(6),
+ BUGDIR_TYPE_ALL)
+
+def help():
+ return get_parser().help_str() + longhelp
+
+# internal helper functions
+
+def _generate_string(subscriber, types, servers):
+ types = sorted([str(t) for t in types])
+ servers = sorted(servers)
+ return "%s%s\t%s\t%s" % (TAG,subscriber,",".join(types),",".join(servers))
+
+def _parse_string(string, type_root):
+ assert string.startswith(TAG), string
+ string = string[len(TAG):]
+ subscriber,types,servers = string.split("\t")
+ types = [type_from_name(name, type_root) for name in types.split(",")]
+ return (subscriber,types,servers.split(","))
+
+def _get_subscriber(extra_strings, subscriber, type_root):
+ for i,string in enumerate(extra_strings):
+ if string.startswith(TAG):
+ s,ts,srvs = _parse_string(string, type_root)
+ if s == subscriber:
+ return i,s,ts,srvs # match!
+ return None # no match
+
+# functions exposed to other modules
+
+def type_from_name(name, type_root, default=None, default_ok=False):
+ if name == str(type_root):
+ return type_root
+ for t in type_root.traverse():
+ if name == str(t):
+ return t
+ if default_ok:
+ return default
+ raise InvalidType(name, type_root)
+
+def subscribe(extra_strings, subscriber, types, servers, type_root):
+ args = _get_subscriber(extra_strings, subscriber, type_root)
+ if args == None: # no match
+ extra_strings.append(_generate_string(subscriber, types, servers))
+ return extra_strings
+ # Alter matched string
+ i,s,ts,srvs = args
+ for t in types:
+ if t not in ts:
+ ts.append(t)
+ # remove descendant types
+ all_ts = copy.copy(ts)
+ for t in all_ts:
+ for tt in all_ts:
+ if tt in ts and t.has_descendant(tt):
+ ts.remove(tt)
+ if "*" in servers+srvs:
+ srvs = ["*"]
+ else:
+ srvs = list(set(servers+srvs))
+ extra_strings[i] = _generate_string(subscriber, ts, srvs)
+ return extra_strings
+
+def unsubscribe(extra_strings, subscriber, types, servers, type_root):
+ args = _get_subscriber(extra_strings, subscriber, type_root)
+ if args == None: # no match
+ return extra_strings # pass
+ # Remove matched string
+ i,s,ts,srvs = args
+ all_ts = copy.copy(ts)
+ for t in types:
+ for tt in all_ts:
+ if tt in ts and t.has_descendant(tt):
+ ts.remove(tt)
+ if "*" in servers+srvs:
+ srvs = []
+ else:
+ for srv in servers:
+ if srv in srvs:
+ srvs.remove(srv)
+ if len(ts) == 0 or len(srvs) == 0:
+ extra_strings.pop(i)
+ else:
+ extra_strings[i] = _generate_string(subscriber, ts, srvs)
+ return extra_strings
+
+def get_subscribers(extra_strings, type, server, type_root,
+ match_ancestor_types=False,
+ match_descendant_types=False):
+ """
+ Set match_ancestor_types=True if you want to find eveyone who
+ cares about your particular type.
+
+ Set match_descendant_types=True if you want to find subscribers
+ who may only care about some subset of your type. This is useful
+ for generating lists of all the subscribers in a given set of
+ extra_strings.
+
+ >>> def sgs(*args, **kwargs):
+ ... return sorted(get_subscribers(*args, **kwargs))
+ >>> es = []
+ >>> es = subscribe(es, "John Doe <j@doe.com>", [BUGDIR_TYPE_ALL], ["a.com"], BUGDIR_TYPE_ALL)
+ >>> es = subscribe(es, "Jane Doe <J@doe.com>", [BUGDIR_TYPE_NEW], ["*"], BUGDIR_TYPE_ALL)
+ >>> sgs(es, BUGDIR_TYPE_ALL, "a.com", BUGDIR_TYPE_ALL)
+ ['John Doe <j@doe.com>']
+ >>> sgs(es, BUGDIR_TYPE_ALL, "a.com", BUGDIR_TYPE_ALL, match_descendant_types=True)
+ ['Jane Doe <J@doe.com>', 'John Doe <j@doe.com>']
+ >>> sgs(es, BUGDIR_TYPE_ALL, "b.net", BUGDIR_TYPE_ALL, match_descendant_types=True)
+ ['Jane Doe <J@doe.com>']
+ >>> sgs(es, BUGDIR_TYPE_NEW, "a.com", BUGDIR_TYPE_ALL)
+ ['Jane Doe <J@doe.com>']
+ >>> sgs(es, BUGDIR_TYPE_NEW, "a.com", BUGDIR_TYPE_ALL, match_ancestor_types=True)
+ ['Jane Doe <J@doe.com>', 'John Doe <j@doe.com>']
+ """
+ for string in extra_strings:
+ if not string.startswith(TAG):
+ continue
+ subscriber,types,servers = _parse_string(string, type_root)
+ type_match = False
+ if type in types:
+ type_match = True
+ if type_match == False and match_ancestor_types == True:
+ for t in types:
+ if t.has_descendant(type):
+ type_match = True
+ break
+ if type_match == False and match_descendant_types == True:
+ for t in types:
+ if type.has_descendant(t):
+ type_match = True
+ break
+ server_match = False
+ if server in servers or servers == ["*"] or server == "*":
+ server_match = True
+ if type_match == True and server_match == True:
+ yield subscriber
+
+def get_bugdir_subscribers(bugdir, server):
+ """
+ I have a bugdir. Who cares about it, and what do they care about?
+ Returns a dict of dicts:
+ subscribers[user][id] = types
+ where id is either a bug.uuid (in the case of a bug subscription)
+ or "DIR" (in the case of a bugdir subscription).
+
+ Only checks bugs that are currently in memory, so you might want
+ to call bugdir.load_all_bugs() first.
+
+ >>> bd = bugdir.SimpleBugDir(sync_with_disk=False)
+ >>> a = bd.bug_from_shortname("a")
+ >>> bd.extra_strings = subscribe(bd.extra_strings, "John Doe <j@doe.com>", [BUGDIR_TYPE_ALL], ["a.com"], BUGDIR_TYPE_ALL)
+ >>> bd.extra_strings = subscribe(bd.extra_strings, "Jane Doe <J@doe.com>", [BUGDIR_TYPE_NEW], ["*"], BUGDIR_TYPE_ALL)
+ >>> a.extra_strings = subscribe(a.extra_strings, "John Doe <j@doe.com>", [BUG_TYPE_ALL], ["a.com"], BUG_TYPE_ALL)
+ >>> subscribers = get_bugdir_subscribers(bd, "a.com")
+ >>> subscribers["Jane Doe <J@doe.com>"]["DIR"]
+ [<SubscriptionType: new>]
+ >>> subscribers["John Doe <j@doe.com>"]["DIR"]
+ [<SubscriptionType: all>]
+ >>> subscribers["John Doe <j@doe.com>"]["a"]
+ [<SubscriptionType: all>]
+ >>> get_bugdir_subscribers(bd, "b.net")
+ {'Jane Doe <J@doe.com>': {'DIR': [<SubscriptionType: new>]}}
+ >>> bd.cleanup()
+ """
+ subscribers = {}
+ for sub in get_subscribers(bugdir.extra_strings, BUGDIR_TYPE_ALL, server,
+ BUGDIR_TYPE_ALL, match_descendant_types=True):
+ i,s,ts,srvs = _get_subscriber(bugdir.extra_strings,sub,BUGDIR_TYPE_ALL)
+ subscribers[sub] = {"DIR":ts}
+ for bug in bugdir:
+ for sub in get_subscribers(bug.extra_strings, BUG_TYPE_ALL, server,
+ BUG_TYPE_ALL, match_descendant_types=True):
+ i,s,ts,srvs = _get_subscriber(bug.extra_strings,sub,BUG_TYPE_ALL)
+ if sub in subscribers:
+ subscribers[sub][bug.uuid] = ts
+ else:
+ subscribers[sub] = {bug.uuid:ts}
+ return subscribers
diff --git a/interfaces/email/interactive/becommands/tag.py b/interfaces/email/interactive/becommands/tag.py
new file mode 100644
index 0000000..ecd853f
--- /dev/null
+++ b/interfaces/email/interactive/becommands/tag.py
@@ -0,0 +1,134 @@
+# Copyright (C) 2009 W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+"""Tag a bug, or search bugs for tags"""
+from libbe import cmdutil, bugdir
+import os, copy
+__desc__ = __doc__
+
+def execute(args, manipulate_encodings=True):
+ """
+ >>> from libbe import utility
+ >>> bd = bugdir.SimpleBugDir()
+ >>> bd.set_sync_with_disk(True)
+ >>> os.chdir(bd.root)
+ >>> a = bd.bug_from_shortname("a")
+ >>> print a.extra_strings
+ []
+ >>> execute(["a", "GUI"], manipulate_encodings=False)
+ Tags for a:
+ GUI
+ >>> bd._clear_bugs() # resync our copy of bug
+ >>> a = bd.bug_from_shortname("a")
+ >>> print a.extra_strings
+ ['TAG:GUI']
+ >>> execute(["a", "later"], manipulate_encodings=False)
+ Tags for a:
+ GUI
+ later
+ >>> execute(["a"], manipulate_encodings=False)
+ Tags for a:
+ GUI
+ later
+ >>> execute(["--list"], manipulate_encodings=False)
+ GUI
+ later
+ >>> execute(["a", "Alphabetically first"], manipulate_encodings=False)
+ Tags for a:
+ Alphabetically first
+ GUI
+ later
+ >>> bd._clear_bugs() # resync our copy of bug
+ >>> a = bd.bug_from_shortname("a")
+ >>> print a.extra_strings
+ ['TAG:Alphabetically first', 'TAG:GUI', 'TAG:later']
+ >>> a.extra_strings = []
+ >>> print a.extra_strings
+ []
+ >>> execute(["a"], manipulate_encodings=False)
+ >>> bd._clear_bugs() # resync our copy of bug
+ >>> a = bd.bug_from_shortname("a")
+ >>> print a.extra_strings
+ []
+ >>> execute(["a", "Alphabetically first"], manipulate_encodings=False)
+ Tags for a:
+ Alphabetically first
+ >>> execute(["--remove", "a", "Alphabetically first"], manipulate_encodings=False)
+ >>> bd.cleanup()
+ """
+ parser = get_parser()
+ options, args = parser.parse_args(args)
+ cmdutil.default_complete(options, args, parser,
+ bugid_args={0: lambda bug : bug.active==True})
+
+ if len(args) == 0 and options.list == False:
+ raise cmdutil.UsageError("Please specify a bug id.")
+ elif len(args) > 2 or (len(args) > 0 and options.list == True):
+ help()
+ raise cmdutil.UsageError("Too many arguments.")
+
+ bd = bugdir.BugDir(from_disk=True,
+ manipulate_encodings=manipulate_encodings)
+ if options.list:
+ bd.load_all_bugs()
+ tags = []
+ for bug in bd:
+ for estr in bug.extra_strings:
+ if estr.startswith("TAG:"):
+ tag = estr[4:]
+ if tag not in tags:
+ tags.append(tag)
+ tags.sort()
+ if len(tags) > 0:
+ print '\n'.join(tags)
+ return
+ bug = cmdutil.bug_from_shortname(bd, args[0])
+ if len(args) == 2:
+ given_tag = args[1]
+ estrs = bug.extra_strings
+ tag_string = "TAG:%s" % given_tag
+ if options.remove == True:
+ estrs.remove(tag_string)
+ else: # add the tag
+ estrs.append(tag_string)
+ bug.extra_strings = estrs # reassign to notice change
+
+ tags = []
+ for estr in bug.extra_strings:
+ if estr.startswith("TAG:"):
+ tags.append(estr[4:])
+
+ if len(tags) > 0:
+ print "Tags for %s:" % bug.uuid
+ print '\n'.join(tags)
+
+def get_parser():
+ parser = cmdutil.CmdOptionParser("be tag BUG-ID [TAG]\nor: be tag --list")
+ parser.add_option("-r", "--remove", action="store_true", dest="remove",
+ help="Remove TAG (instead of adding it)")
+ parser.add_option("-l", "--list", action="store_true", dest="list",
+ help="List all available tags and exit")
+ return parser
+
+longhelp="""
+If TAG is given, add TAG to BUG-ID. If it is not specified, just
+print the tags for BUG-ID.
+
+To search for bugs with a particular tag, try
+ $ be list --extra-strings TAG:<your-tag>
+"""
+
+def help():
+ return get_parser().help_str() + longhelp
diff --git a/interfaces/email/interactive/becommands/target.py b/interfaces/email/interactive/becommands/target.py
new file mode 100644
index 0000000..7e41451
--- /dev/null
+++ b/interfaces/email/interactive/becommands/target.py
@@ -0,0 +1,95 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+# Chris Ball <cjb@laptop.org>
+# Gianluca Montecchi <gian@grys.it>
+# Marien Zwart <marienz@gentoo.org>
+# Thomas Gerigk <tgerigk@gmx.de>
+# W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+"""Show or change a bug's target for fixing"""
+from libbe import cmdutil, bugdir
+__desc__ = __doc__
+
+def execute(args, manipulate_encodings=True):
+ """
+ >>> import os
+ >>> bd = bugdir.SimpleBugDir()
+ >>> os.chdir(bd.root)
+ >>> execute(["a"], manipulate_encodings=False)
+ No target assigned.
+ >>> execute(["a", "tomorrow"], manipulate_encodings=False)
+ >>> execute(["a"], manipulate_encodings=False)
+ tomorrow
+ >>> execute(["--list"], manipulate_encodings=False)
+ tomorrow
+ >>> execute(["a", "none"], manipulate_encodings=False)
+ >>> execute(["a"], manipulate_encodings=False)
+ No target assigned.
+ >>> bd.cleanup()
+ """
+ parser = get_parser()
+ options, args = parser.parse_args(args)
+ cmdutil.default_complete(options, args, parser,
+ bugid_args={0: lambda bug : bug.active==True})
+
+ if len(args) not in (1, 2):
+ if not (options.list == True and len(args) == 0):
+ raise cmdutil.UsageError
+ bd = bugdir.BugDir(from_disk=True,
+ manipulate_encodings=manipulate_encodings)
+ if options.list:
+ ts = set([bd.bug_from_uuid(bug).target for bug in bd.list_uuids()])
+ for target in sorted(ts):
+ if target and isinstance(target,str):
+ print target
+ return
+ bug = cmdutil.bug_from_shortname(bd, args[0])
+ if len(args) == 1:
+ if bug.target is None:
+ print "No target assigned."
+ else:
+ print bug.target
+ else:
+ assert len(args) == 2
+ if args[1] == "none":
+ bug.target = None
+ else:
+ bug.target = args[1]
+
+def get_parser():
+ parser = cmdutil.CmdOptionParser("be target BUG-ID [TARGET]\nor: be target --list")
+ parser.add_option("-l", "--list", action="store_true", dest="list",
+ help="List all available targets and exit")
+ return parser
+
+longhelp="""
+Show or change a bug's target for fixing.
+
+If no target is specified, the current value is printed. If a target
+is specified, it will be assigned to the bug.
+
+Targets are freeform; any text may be specified. They will generally be
+milestone names or release numbers.
+
+The value "none" can be used to unset the target.
+
+In the alternative `be target --list` form print a list of all
+currently specified targets. Note that bug status
+(i.e. opened/closed) is ignored. If you want to list all bugs
+matching a current target, see `be list --target TARGET'.
+"""
+
+def help():
+ return get_parser().help_str() + longhelp
diff --git a/interfaces/email/interactive/examples/comment b/interfaces/email/interactive/examples/comment
new file mode 100644
index 0000000..f22e4b2
--- /dev/null
+++ b/interfaces/email/interactive/examples/comment
@@ -0,0 +1,11 @@
+From jdoe@example.com Fri Apr 18 11:18:58 2008
+Message-ID: <xyz@example.com>
+Date: Fri, 18 Apr 2008 12:00:00 +0000
+From: John Doe <jdoe@example.com>
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+Subject: [be-bug:a1d] Subject ignored
+
+We sure do.
+--
+Goofy tagline ignored
diff --git a/interfaces/email/interactive/examples/failing_multiples b/interfaces/email/interactive/examples/failing_multiples
new file mode 100644
index 0000000..cf50211
--- /dev/null
+++ b/interfaces/email/interactive/examples/failing_multiples
@@ -0,0 +1,16 @@
+From jdoe@example.com Fri Apr 18 12:00:00 2008
+Message-ID: <abcd@example.com>
+Date: Fri, 18 Apr 2008 12:00:00 +0000
+From: John Doe <jdoe@example.com>
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+Subject: [be-bug] Commit message...
+
+new "test bug"
+new "test bug 2"
+failing-command
+new "test bug 3"
+
+--
+This message fails partway through, but the partial changes should be
+recorded in a commit...
diff --git a/interfaces/email/interactive/examples/invalid_command b/interfaces/email/interactive/examples/invalid_command
new file mode 100644
index 0000000..f2963c7
--- /dev/null
+++ b/interfaces/email/interactive/examples/invalid_command
@@ -0,0 +1,11 @@
+From jdoe@example.com Fri Apr 18 11:18:58 2008
+Message-ID: <abcd@example.com>
+Date: Fri, 18 Apr 2008 12:00:00 +0000
+From: John Doe <jdoe@example.com>
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+Subject: [be-bug]
+
+close
+--
+Close is currently disabled for the email interface.
diff --git a/interfaces/email/interactive/examples/invalid_subject b/interfaces/email/interactive/examples/invalid_subject
new file mode 100644
index 0000000..1e2eb88
--- /dev/null
+++ b/interfaces/email/interactive/examples/invalid_subject
@@ -0,0 +1,9 @@
+From jdoe@example.com Fri Apr 18 11:18:58 2008
+Message-ID: <abcd@example.com>
+Date: Fri, 18 Apr 2008 12:00:00 +0000
+From: John Doe <jdoe@example.com>
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+Subject: Spam!
+
+This should elicit an "invalid subject" response email.
diff --git a/interfaces/email/interactive/examples/list b/interfaces/email/interactive/examples/list
new file mode 100644
index 0000000..acba424
--- /dev/null
+++ b/interfaces/email/interactive/examples/list
@@ -0,0 +1,11 @@
+From jdoe@example.com Fri Apr 18 11:18:58 2008
+Message-ID: <abcd@example.com>
+Date: Fri, 18 Apr 2008 12:00:00 +0000
+From: John Doe <jdoe@example.com>
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+Subject: [be-bug] Subject ignored
+
+list --status all
+--
+Dummy content
diff --git a/interfaces/email/interactive/examples/missing_command b/interfaces/email/interactive/examples/missing_command
new file mode 100644
index 0000000..bb390fc
--- /dev/null
+++ b/interfaces/email/interactive/examples/missing_command
@@ -0,0 +1,11 @@
+From jdoe@example.com Fri Apr 18 11:18:58 2008
+Message-ID: <abcd@example.com>
+Date: Fri, 18 Apr 2008 12:00:00 +0000
+From: John Doe <jdoe@example.com>
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+Subject: [be-bug] Subject ignored
+
+abcde
+--
+This should elicit a "invalid command 'abcde'" response email.
diff --git a/interfaces/email/interactive/examples/multiple_commands b/interfaces/email/interactive/examples/multiple_commands
new file mode 100644
index 0000000..41ef730
--- /dev/null
+++ b/interfaces/email/interactive/examples/multiple_commands
@@ -0,0 +1,14 @@
+From jdoe@example.com Fri Apr 18 11:18:58 2008
+Message-ID: <abcd@example.com>
+Date: Fri, 18 Apr 2008 12:00:00 +0000
+From: John Doe <jdoe@example.com>
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+Subject: [be-bug] Subject ignored
+
+help
+list --status=all
+list --status=fixed
+show --xml 361
+--
+Goofy tagline ignored.
diff --git a/interfaces/email/interactive/examples/new b/interfaces/email/interactive/examples/new
new file mode 100644
index 0000000..c64db93
--- /dev/null
+++ b/interfaces/email/interactive/examples/new
@@ -0,0 +1,19 @@
+From jdoe@example.com Fri Apr 18 12:00:00 2008
+Message-ID: <abcd@example.com>
+Date: Fri, 18 Apr 2008 12:00:00 +0000
+From: John Doe <jdoe@example.com>
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+Subject: [be-bug:submit] Need tests for the email interface.
+
+Version: XYZ
+Reporter: Jane Doe
+Assign: Dick Tracy
+Depend: 00f
+Severity: critical
+Status: assigned
+Tag: topsecret
+Target: Law&Order
+
+--
+Goofy tagline not included, and no comment added.
diff --git a/interfaces/email/interactive/examples/new_with_comment b/interfaces/email/interactive/examples/new_with_comment
new file mode 100644
index 0000000..1077f0f
--- /dev/null
+++ b/interfaces/email/interactive/examples/new_with_comment
@@ -0,0 +1,13 @@
+From jdoe@example.com Fri Apr 18 11:18:58 2008
+Message-ID: <abcd@example.com>
+Date: Fri, 18 Apr 2008 12:00:00 +0000
+From: John Doe <jdoe@example.com>
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+Subject: [be-bug:submit] Need tests for the email interface.
+
+Version: XYZ
+
+I think so anyway.
+--
+Goofy tagline not included.
diff --git a/interfaces/email/interactive/examples/show b/interfaces/email/interactive/examples/show
new file mode 100644
index 0000000..c5f8a4d
--- /dev/null
+++ b/interfaces/email/interactive/examples/show
@@ -0,0 +1,11 @@
+From jdoe@example.com Fri Apr 18 11:18:58 2008
+Message-ID: <abcd@example.com>
+Date: Fri, 18 Apr 2008 12:00:00 +0000
+From: John Doe <jdoe@example.com>
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+Subject: [be-bug] Subject ignored
+
+show --xml 361
+--
+Can we show a bug?
diff --git a/interfaces/email/interactive/examples/unicode b/interfaces/email/interactive/examples/unicode
new file mode 100644
index 0000000..f0e8001
--- /dev/null
+++ b/interfaces/email/interactive/examples/unicode
@@ -0,0 +1,11 @@
+From jdoe@example.com Fri Apr 18 11:18:58 2008
+Message-ID: <abcd@example.com>
+Date: Fri, 18 Apr 2008 12:00:00 +0000
+From: John Doe <jdoe@example.com>
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+Subject: [be-bug] Subject ignored
+
+show --xml f7ccd916-b5c7-4890-a2e3-8c8ace17ae3a
+--
+Can we handle unicode output?
diff --git a/interfaces/email/interactive/libbe/arch.py b/interfaces/email/interactive/libbe/arch.py
new file mode 100644
index 0000000..ab55172
--- /dev/null
+++ b/interfaces/email/interactive/libbe/arch.py
@@ -0,0 +1,312 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+# Ben Finney <ben+python@benfinney.id.au>
+# James Rowe <jnrowe@ukfsn.org>
+# W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""
+GNU Arch (tla) backend.
+"""
+
+import codecs
+import os
+import re
+import shutil
+import sys
+import time
+import unittest
+import doctest
+
+from beuuid import uuid_gen
+import config
+import vcs
+
+
+
+DEFAULT_CLIENT = "tla"
+
+client = config.get_val("arch_client", default=DEFAULT_CLIENT)
+
+def new():
+ return Arch()
+
+class Arch(vcs.VCS):
+ name = "Arch"
+ client = client
+ versioned = True
+ _archive_name = None
+ _archive_dir = None
+ _tmp_archive = False
+ _project_name = None
+ _tmp_project = False
+ _arch_paramdir = os.path.expanduser("~/.arch-params")
+ def _vcs_help(self):
+ status,output,error = self._u_invoke_client("--help")
+ return output
+ def _vcs_detect(self, path):
+ """Detect whether a directory is revision-controlled using Arch"""
+ if self._u_search_parent_directories(path, "{arch}") != None :
+ config.set_val("arch_client", client)
+ return True
+ return False
+ def _vcs_init(self, path):
+ self._create_archive(path)
+ self._create_project(path)
+ self._add_project_code(path)
+ def _create_archive(self, path):
+ """
+ Create a temporary Arch archive in the directory PATH. This
+ archive will be removed by
+ __del__->cleanup->_vcs_cleanup->_remove_archive
+ """
+ # http://regexps.srparish.net/tutorial-tla/new-archive.html#Creating_a_New_Archive
+ assert self._archive_name == None
+ id = self.get_user_id()
+ name, email = self._u_parse_id(id)
+ if email == None:
+ email = "%s@example.com" % name
+ trailer = "%s-%s" % ("bugs-everywhere-auto", uuid_gen()[0:8])
+ self._archive_name = "%s--%s" % (email, trailer)
+ self._archive_dir = "/tmp/%s" % trailer
+ self._tmp_archive = True
+ self._u_invoke_client("make-archive", self._archive_name,
+ self._archive_dir, directory=path)
+ def _invoke_client(self, *args, **kwargs):
+ """
+ Invoke the client on our archive.
+ """
+ assert self._archive_name != None
+ command = args[0]
+ if len(args) > 1:
+ tailargs = args[1:]
+ else:
+ tailargs = []
+ arglist = [command, "-A", self._archive_name]
+ arglist.extend(tailargs)
+ args = tuple(arglist)
+ return self._u_invoke_client(*args, **kwargs)
+ def _remove_archive(self):
+ assert self._tmp_archive == True
+ assert self._archive_dir != None
+ assert self._archive_name != None
+ os.remove(os.path.join(self._arch_paramdir,
+ "=locations", self._archive_name))
+ shutil.rmtree(self._archive_dir)
+ self._tmp_archive = False
+ self._archive_dir = False
+ self._archive_name = False
+ def _create_project(self, path):
+ """
+ Create a temporary Arch project in the directory PATH. This
+ project will be removed by
+ __del__->cleanup->_vcs_cleanup->_remove_project
+ """
+ # http://mwolson.org/projects/GettingStartedWithArch.html
+ # http://regexps.srparish.net/tutorial-tla/new-project.html#Starting_a_New_Project
+ category = "bugs-everywhere"
+ branch = "mainline"
+ version = "0.1"
+ self._project_name = "%s--%s--%s" % (category, branch, version)
+ self._invoke_client("archive-setup", self._project_name,
+ directory=path)
+ self._tmp_project = True
+ def _remove_project(self):
+ assert self._tmp_project == True
+ assert self._project_name != None
+ assert self._archive_dir != None
+ shutil.rmtree(os.path.join(self._archive_dir, self._project_name))
+ self._tmp_project = False
+ self._project_name = False
+ def _archive_project_name(self):
+ assert self._archive_name != None
+ assert self._project_name != None
+ return "%s/%s" % (self._archive_name, self._project_name)
+ def _adjust_naming_conventions(self, path):
+ """
+ By default, Arch restricts source code filenames to
+ ^[_=a-zA-Z0-9].*$
+ See
+ http://regexps.srparish.net/tutorial-tla/naming-conventions.html
+ Since our bug directory '.be' doesn't satisfy these conventions,
+ we need to adjust them.
+
+ The conventions are specified in
+ project-root/{arch}/=tagging-method
+ """
+ tagpath = os.path.join(path, "{arch}", "=tagging-method")
+ lines_out = []
+ f = codecs.open(tagpath, "r", self.encoding)
+ for line in f:
+ if line.startswith("source "):
+ lines_out.append("source ^[._=a-zA-X0-9].*$\n")
+ else:
+ lines_out.append(line)
+ f.close()
+ f = codecs.open(tagpath, "w", self.encoding)
+ f.write("".join(lines_out))
+ f.close()
+
+ def _add_project_code(self, path):
+ # http://mwolson.org/projects/GettingStartedWithArch.html
+ # http://regexps.srparish.net/tutorial-tla/new-source.html
+ # http://regexps.srparish.net/tutorial-tla/importing-first.html
+ self._invoke_client("init-tree", self._project_name,
+ directory=path)
+ self._adjust_naming_conventions(path)
+ self._invoke_client("import", "--summary", "Began versioning",
+ directory=path)
+ def _vcs_cleanup(self):
+ if self._tmp_project == True:
+ self._remove_project()
+ if self._tmp_archive == True:
+ self._remove_archive()
+
+ def _vcs_root(self, path):
+ if not os.path.isdir(path):
+ dirname = os.path.dirname(path)
+ else:
+ dirname = path
+ status,output,error = self._u_invoke_client("tree-root", dirname)
+ root = output.rstrip('\n')
+
+ self._get_archive_project_name(root)
+
+ return root
+ def _get_archive_name(self, root):
+ status,output,error = self._u_invoke_client("archives")
+ lines = output.split('\n')
+ # e.g. output:
+ # jdoe@example.com--bugs-everywhere-auto-2008.22.24.52
+ # /tmp/BEtestXXXXXX/rootdir
+ # (+ repeats)
+ for archive,location in zip(lines[::2], lines[1::2]):
+ if os.path.realpath(location) == os.path.realpath(root):
+ self._archive_name = archive
+ assert self._archive_name != None
+ def _get_archive_project_name(self, root):
+ # get project names
+ status,output,error = self._u_invoke_client("tree-version", directory=root)
+ # e.g output
+ # jdoe@example.com--bugs-everywhere-auto-2008.22.24.52/be--mainline--0.1
+ archive_name,project_name = output.rstrip('\n').split('/')
+ self._archive_name = archive_name
+ self._project_name = project_name
+ def _vcs_get_user_id(self):
+ try:
+ status,output,error = self._u_invoke_client('my-id')
+ return output.rstrip('\n')
+ except Exception, e:
+ if 'no arch user id set' in e.args[0]:
+ return None
+ else:
+ raise
+ def _vcs_set_user_id(self, value):
+ self._u_invoke_client('my-id', value)
+ def _vcs_add(self, path):
+ self._u_invoke_client("add-id", path)
+ realpath = os.path.realpath(self._u_abspath(path))
+ pathAdded = realpath in self._list_added(self.rootdir)
+ if self.paranoid and not pathAdded:
+ self._force_source(path)
+ def _list_added(self, root):
+ assert os.path.exists(root)
+ assert os.access(root, os.X_OK)
+ root = os.path.realpath(root)
+ status,output,error = self._u_invoke_client("inventory", "--source",
+ "--both", "--all", root)
+ inv_str = output.rstrip('\n')
+ return [os.path.join(root, p) for p in inv_str.split('\n')]
+ def _add_dir_rule(self, rule, dirname, root):
+ inv_path = os.path.join(dirname, '.arch-inventory')
+ f = codecs.open(inv_path, "a", self.encoding)
+ f.write(rule)
+ f.close()
+ if os.path.realpath(inv_path) not in self._list_added(root):
+ paranoid = self.paranoid
+ self.paranoid = False
+ self.add(inv_path)
+ self.paranoid = paranoid
+ def _force_source(self, path):
+ rule = "source %s\n" % self._u_rel_path(path)
+ self._add_dir_rule(rule, os.path.dirname(path), self.rootdir)
+ if os.path.realpath(path) not in self._list_added(self.rootdir):
+ raise CantAddFile(path)
+ def _vcs_remove(self, path):
+ if not '.arch-ids' in path:
+ self._u_invoke_client("delete-id", path)
+ def _vcs_update(self, path):
+ pass
+ def _vcs_get_file_contents(self, path, revision=None, binary=False):
+ if revision == None:
+ return vcs.VCS._vcs_get_file_contents(self, path, revision, binary=binary)
+ else:
+ status,output,error = \
+ self._invoke_client("file-find", path, revision)
+ relpath = output.rstrip('\n')
+ abspath = os.path.join(self.rootdir, relpath)
+ f = codecs.open(abspath, "r", self.encoding)
+ contents = f.read()
+ f.close()
+ return contents
+ def _vcs_duplicate_repo(self, directory, revision=None):
+ if revision == None:
+ vcs.VCS._vcs_duplicate_repo(self, directory, revision)
+ else:
+ status,output,error = \
+ self._u_invoke_client("get", revision,directory)
+ def _vcs_commit(self, commitfile, allow_empty=False):
+ if allow_empty == False:
+ # arch applies empty commits without complaining, so check first
+ status,output,error = self._u_invoke_client("changes",expect=(0,1))
+ if status == 0:
+ raise vcs.EmptyCommit()
+ summary,body = self._u_parse_commitfile(commitfile)
+ args = ["commit", "--summary", summary]
+ if body != None:
+ args.extend(["--log-message",body])
+ status,output,error = self._u_invoke_client(*args)
+ revision = None
+ revline = re.compile("[*] committed (.*)")
+ match = revline.search(output)
+ assert match != None, output+error
+ assert len(match.groups()) == 1
+ revpath = match.groups()[0]
+ assert not " " in revpath, revpath
+ assert revpath.startswith(self._archive_project_name()+'--')
+ revision = revpath[len(self._archive_project_name()+'--'):]
+ return revpath
+ def _vcs_revision_id(self, index):
+ status,output,error = self._u_invoke_client("logs")
+ logs = output.splitlines()
+ first_log = logs.pop(0)
+ assert first_log == "base-0", first_log
+ try:
+ log = logs[index]
+ except IndexError:
+ return None
+ return "%s--%s" % (self._archive_project_name(), log)
+
+class CantAddFile(Exception):
+ def __init__(self, file):
+ self.file = file
+ Exception.__init__(self, "Can't automatically add file %s" % file)
+
+
+
+vcs.make_vcs_testcase_subclasses(Arch, sys.modules[__name__])
+
+unitsuite = unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
+suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
diff --git a/interfaces/email/interactive/libbe/beuuid.py b/interfaces/email/interactive/libbe/beuuid.py
new file mode 100644
index 0000000..490ed62
--- /dev/null
+++ b/interfaces/email/interactive/libbe/beuuid.py
@@ -0,0 +1,63 @@
+# Copyright (C) 2008-2009 W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""
+Backwards compatibility support for Python 2.4. Once people give up
+on 2.4 ;), the uuid call should be merged into bugdir.py
+"""
+
+import unittest
+
+
+try:
+ from uuid import uuid4 # Python >= 2.5
+ def uuid_gen():
+ id = uuid4()
+ idstr = id.urn
+ start = "urn:uuid:"
+ assert idstr.startswith(start)
+ return idstr[len(start):]
+except ImportError:
+ import os
+ import sys
+ from subprocess import Popen, PIPE
+
+ def uuid_gen():
+ # Shell-out to system uuidgen
+ args = ['uuidgen', 'r']
+ try:
+ if sys.platform != "win32":
+ q = Popen(args, stdin=PIPE, stdout=PIPE, stderr=PIPE)
+ else:
+ # win32 don't have os.execvp() so have to run command in a shell
+ q = Popen(args, stdin=PIPE, stdout=PIPE, stderr=PIPE,
+ shell=True, cwd=cwd)
+ except OSError, e :
+ strerror = "%s\nwhile executing %s" % (e.args[1], args)
+ raise OSError, strerror
+ output, error = q.communicate()
+ status = q.wait()
+ if status != 0:
+ strerror = "%s\nwhile executing %s" % (status, args)
+ raise Exception, strerror
+ return output.rstrip('\n')
+
+class UUIDtestCase(unittest.TestCase):
+ def testUUID_gen(self):
+ id = uuid_gen()
+ self.failUnless(len(id) == 36, "invalid UUID '%s'" % id)
+
+suite = unittest.TestLoader().loadTestsFromTestCase(UUIDtestCase)
diff --git a/interfaces/email/interactive/libbe/bug.py b/interfaces/email/interactive/libbe/bug.py
new file mode 100644
index 0000000..fd30ff7
--- /dev/null
+++ b/interfaces/email/interactive/libbe/bug.py
@@ -0,0 +1,580 @@
+# Copyright (C) 2008-2009 Chris Ball <cjb@laptop.org>
+# Thomas Habets <thomas@habets.pp.se>
+# W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""
+Define the Bug class for representing bugs.
+"""
+
+import os
+import os.path
+import errno
+import time
+import types
+import xml.sax.saxutils
+import doctest
+
+from beuuid import uuid_gen
+from properties import Property, doc_property, local_property, \
+ defaulting_property, checked_property, cached_property, \
+ primed_property, change_hook_property, settings_property
+import settings_object
+import mapfile
+import comment
+import utility
+
+
+class DiskAccessRequired (Exception):
+ def __init__(self, goal):
+ msg = "Cannot %s without accessing the disk" % goal
+ Exception.__init__(self, msg)
+
+### Define and describe valid bug categories
+# Use a tuple of (category, description) tuples since we don't have
+# ordered dicts in Python yet http://www.python.org/dev/peps/pep-0372/
+
+# in order of increasing severity. (name, description) pairs
+severity_def = (
+ ("wishlist","A feature that could improve usefulness, but not a bug."),
+ ("minor","The standard bug level."),
+ ("serious","A bug that requires workarounds."),
+ ("critical","A bug that prevents some features from working at all."),
+ ("fatal","A bug that makes the package unusable."))
+
+# in order of increasing resolution
+# roughly following http://www.bugzilla.org/docs/3.2/en/html/lifecycle.html
+active_status_def = (
+ ("unconfirmed","A possible bug which lacks independent existance confirmation."),
+ ("open","A working bug that has not been assigned to a developer."),
+ ("assigned","A working bug that has been assigned to a developer."),
+ ("test","The code has been adjusted, but the fix is still being tested."))
+inactive_status_def = (
+ ("closed", "The bug is no longer relevant."),
+ ("fixed", "The bug should no longer occur."),
+ ("wontfix","It's not a bug, it's a feature."))
+
+
+### Convert the description tuples to more useful formats
+
+severity_values = ()
+severity_description = {}
+severity_index = {}
+def load_severities(severity_def):
+ global severity_values
+ global severity_description
+ global severity_index
+ if severity_def == None:
+ return
+ severity_values = tuple([val for val,description in severity_def])
+ severity_description = dict(severity_def)
+ severity_index = {}
+ for i,severity in enumerate(severity_values):
+ severity_index[severity] = i
+load_severities(severity_def)
+
+active_status_values = []
+inactive_status_values = []
+status_values = []
+status_description = {}
+status_index = {}
+def load_status(active_status_def, inactive_status_def):
+ global active_status_values
+ global inactive_status_values
+ global status_values
+ global status_description
+ global status_index
+ if active_status_def == None:
+ active_status_def = globals()["active_status_def"]
+ if inactive_status_def == None:
+ inactive_status_def = globals()["inactive_status_def"]
+ active_status_values = tuple([val for val,description in active_status_def])
+ inactive_status_values = tuple([val for val,description in inactive_status_def])
+ status_values = active_status_values + inactive_status_values
+ status_description = dict(tuple(active_status_def) + tuple(inactive_status_def))
+ status_index = {}
+ for i,status in enumerate(status_values):
+ status_index[status] = i
+load_status(active_status_def, inactive_status_def)
+
+
+class Bug(settings_object.SavedSettingsObject):
+ """
+ >>> b = Bug()
+ >>> print b.status
+ open
+ >>> print b.severity
+ minor
+
+ There are two formats for time, int and string. Setting either
+ one will adjust the other appropriately. The string form is the
+ one stored in the bug's settings file on disk.
+ >>> print type(b.time)
+ <type 'int'>
+ >>> print type(b.time_string)
+ <type 'str'>
+ >>> b.time = 0
+ >>> print b.time_string
+ Thu, 01 Jan 1970 00:00:00 +0000
+ >>> b.time_string="Thu, 01 Jan 1970 00:01:00 +0000"
+ >>> b.time
+ 60
+ >>> print b.settings["time"]
+ Thu, 01 Jan 1970 00:01:00 +0000
+ """
+ settings_properties = []
+ required_saved_properties = []
+ _prop_save_settings = settings_object.prop_save_settings
+ _prop_load_settings = settings_object.prop_load_settings
+ def _versioned_property(settings_properties=settings_properties,
+ required_saved_properties=required_saved_properties,
+ **kwargs):
+ if "settings_properties" not in kwargs:
+ kwargs["settings_properties"] = settings_properties
+ if "required_saved_properties" not in kwargs:
+ kwargs["required_saved_properties"]=required_saved_properties
+ return settings_object.versioned_property(**kwargs)
+
+ @_versioned_property(name="severity",
+ doc="A measure of the bug's importance",
+ default="minor",
+ check_fn=lambda s: s in severity_values,
+ require_save=True)
+ def severity(): return {}
+
+ @_versioned_property(name="status",
+ doc="The bug's current status",
+ default="open",
+ check_fn=lambda s: s in status_values,
+ require_save=True)
+ def status(): return {}
+
+ @property
+ def active(self):
+ return self.status in active_status_values
+
+ @_versioned_property(name="target",
+ doc="The deadline for fixing this bug")
+ def target(): return {}
+
+ @_versioned_property(name="creator",
+ doc="The user who entered the bug into the system")
+ def creator(): return {}
+
+ @_versioned_property(name="reporter",
+ doc="The user who reported the bug")
+ def reporter(): return {}
+
+ @_versioned_property(name="assigned",
+ doc="The developer in charge of the bug")
+ def assigned(): return {}
+
+ @_versioned_property(name="time",
+ doc="An RFC 2822 timestamp for bug creation")
+ def time_string(): return {}
+
+ def _get_time(self):
+ if self.time_string == None:
+ return None
+ return utility.str_to_time(self.time_string)
+ def _set_time(self, value):
+ self.time_string = utility.time_to_str(value)
+ time = property(fget=_get_time,
+ fset=_set_time,
+ doc="An integer version of .time_string")
+
+ def _extra_strings_check_fn(value):
+ return utility.iterable_full_of_strings(value, \
+ alternative=settings_object.EMPTY)
+ def _extra_strings_change_hook(self, old, new):
+ self.extra_strings.sort() # to make merging easier
+ self._prop_save_settings(old, new)
+ @_versioned_property(name="extra_strings",
+ doc="Space for an array of extra strings. Useful for storing state for functionality implemented purely in becommands/<some_function>.py.",
+ default=[],
+ check_fn=_extra_strings_check_fn,
+ change_hook=_extra_strings_change_hook,
+ mutable=True)
+ def extra_strings(): return {}
+
+ @_versioned_property(name="summary",
+ doc="A one-line bug description")
+ def summary(): return {}
+
+ def _get_comment_root(self, load_full=False):
+ if self.sync_with_disk:
+ return comment.loadComments(self, load_full=load_full)
+ else:
+ return comment.Comment(self, uuid=comment.INVALID_UUID)
+
+ @Property
+ @cached_property(generator=_get_comment_root)
+ @local_property("comment_root")
+ @doc_property(doc="The trunk of the comment tree")
+ def comment_root(): return {}
+
+ def _get_vcs(self):
+ if hasattr(self.bugdir, "vcs"):
+ return self.bugdir.vcs
+
+ @Property
+ @cached_property(generator=_get_vcs)
+ @local_property("vcs")
+ @doc_property(doc="A revision control system instance.")
+ def vcs(): return {}
+
+ def __init__(self, bugdir=None, uuid=None, from_disk=False,
+ load_comments=False, summary=None):
+ settings_object.SavedSettingsObject.__init__(self)
+ self.bugdir = bugdir
+ self.uuid = uuid
+ if from_disk == True:
+ self.sync_with_disk = True
+ else:
+ self.sync_with_disk = False
+ if uuid == None:
+ self.uuid = uuid_gen()
+ self.time = int(time.time()) # only save to second precision
+ if self.vcs != None:
+ self.creator = self.vcs.get_user_id()
+ self.summary = summary
+
+ def __repr__(self):
+ return "Bug(uuid=%r)" % self.uuid
+
+ def __str__(self):
+ return self.string(shortlist=True)
+
+ def __cmp__(self, other):
+ return cmp_full(self, other)
+
+ # serializing methods
+
+ def _setting_attr_string(self, setting):
+ value = getattr(self, setting)
+ if value == None:
+ return ""
+ return str(value)
+
+ def xml(self, show_comments=False):
+ if self.bugdir == None:
+ shortname = self.uuid
+ else:
+ shortname = self.bugdir.bug_shortname(self)
+
+ if self.time == None:
+ timestring = ""
+ else:
+ timestring = utility.time_to_str(self.time)
+
+ info = [("uuid", self.uuid),
+ ("short-name", shortname),
+ ("severity", self.severity),
+ ("status", self.status),
+ ("assigned", self.assigned),
+ ("target", self.target),
+ ("reporter", self.reporter),
+ ("creator", self.creator),
+ ("created", timestring),
+ ("summary", self.summary)]
+ ret = '<bug>\n'
+ for (k,v) in info:
+ if v is not None:
+ ret += ' <%s>%s</%s>\n' % (k,xml.sax.saxutils.escape(v),k)
+ for estr in self.extra_strings:
+ ret += ' <extra-string>%s</extra-string>\n' % estr
+ if show_comments == True:
+ comout = self.comment_root.xml_thread(auto_name_map=True,
+ bug_shortname=shortname)
+ if len(comout) > 0:
+ ret += comout+'\n'
+ ret += '</bug>'
+ return ret
+
+ def string(self, shortlist=False, show_comments=False):
+ if self.bugdir == None:
+ shortname = self.uuid
+ else:
+ shortname = self.bugdir.bug_shortname(self)
+ if shortlist == False:
+ if self.time == None:
+ timestring = ""
+ else:
+ htime = utility.handy_time(self.time)
+ timestring = "%s (%s)" % (htime, self.time_string)
+ info = [("ID", self.uuid),
+ ("Short name", shortname),
+ ("Severity", self.severity),
+ ("Status", self.status),
+ ("Assigned", self._setting_attr_string("assigned")),
+ ("Target", self._setting_attr_string("target")),
+ ("Reporter", self._setting_attr_string("reporter")),
+ ("Creator", self._setting_attr_string("creator")),
+ ("Created", timestring)]
+ longest_key_len = max([len(k) for k,v in info])
+ infolines = [" %*s : %s\n" %(longest_key_len,k,v) for k,v in info]
+ bugout = "".join(infolines) + "%s" % self.summary.rstrip('\n')
+ else:
+ statuschar = self.status[0]
+ severitychar = self.severity[0]
+ chars = "%c%c" % (statuschar, severitychar)
+ bugout = "%s:%s: %s" % (shortname,chars,self.summary.rstrip('\n'))
+
+ if show_comments == True:
+ # take advantage of the string_thread(auto_name_map=True)
+ # SIDE-EFFECT of sorting by comment time.
+ comout = self.comment_root.string_thread(flatten=False,
+ auto_name_map=True,
+ bug_shortname=shortname)
+ output = bugout + '\n' + comout.rstrip('\n')
+ else :
+ output = bugout
+ return output
+
+ # methods for saving/loading/acessing settings and properties.
+
+ def get_path(self, *args):
+ dir = os.path.join(self.bugdir.get_path("bugs"), self.uuid)
+ if len(args) == 0:
+ return dir
+ assert args[0] in ["values", "comments"], str(args)
+ return os.path.join(dir, *args)
+
+ def set_sync_with_disk(self, value):
+ self.sync_with_disk = value
+ for comment in self.comments():
+ comment.set_sync_with_disk(value)
+
+ def load_settings(self):
+ if self.sync_with_disk == False:
+ raise DiskAccessRequired("load settings")
+ self.settings = mapfile.map_load(self.vcs, self.get_path("values"))
+ self._setup_saved_settings()
+
+ def save_settings(self):
+ if self.sync_with_disk == False:
+ raise DiskAccessRequired("save settings")
+ assert self.summary != None, "Can't save blank bug"
+ self.vcs.mkdir(self.get_path())
+ path = self.get_path("values")
+ mapfile.map_save(self.vcs, path, self._get_saved_settings())
+
+ def save(self):
+ """
+ Save any loaded contents to disk. Because of lazy loading of
+ comments, this is actually not too inefficient.
+
+ However, if self.sync_with_disk = True, then any changes are
+ automatically written to disk as soon as they happen, so
+ calling this method will just waste time (unless something
+ else has been messing with your on-disk files).
+ """
+ sync_with_disk = self.sync_with_disk
+ if sync_with_disk == False:
+ self.set_sync_with_disk(True)
+ self.save_settings()
+ if len(self.comment_root) > 0:
+ comment.saveComments(self)
+ if sync_with_disk == False:
+ self.set_sync_with_disk(False)
+
+ def load_comments(self, load_full=True):
+ if self.sync_with_disk == False:
+ raise DiskAccessRequired("load comments")
+ if load_full == True:
+ # Force a complete load of the whole comment tree
+ self.comment_root = self._get_comment_root(load_full=True)
+ else:
+ # Setup for fresh lazy-loading. Clear _comment_root, so
+ # _get_comment_root returns a fresh version. Turn of
+ # syncing temporarily so we don't write our blank comment
+ # tree to disk.
+ self.sync_with_disk = False
+ self.comment_root = None
+ self.sync_with_disk = True
+
+ def remove(self):
+ if self.sync_with_disk == False:
+ raise DiskAccessRequired("remove")
+ self.comment_root.remove()
+ path = self.get_path()
+ self.vcs.recursive_remove(path)
+
+ # methods for managing comments
+
+ def comments(self):
+ for comment in self.comment_root.traverse():
+ yield comment
+
+ def new_comment(self, body=None):
+ comm = self.comment_root.new_reply(body=body)
+ return comm
+
+ def comment_from_shortname(self, shortname, *args, **kwargs):
+ return self.comment_root.comment_from_shortname(shortname,
+ *args, **kwargs)
+
+ def comment_from_uuid(self, uuid):
+ return self.comment_root.comment_from_uuid(uuid)
+
+ def comment_shortnames(self, shortname=None):
+ """
+ SIDE-EFFECT : Comment.comment_shortnames will sort the comment
+ tree by comment.time
+ """
+ for id, comment in self.comment_root.comment_shortnames(shortname):
+ yield (id, comment)
+
+
+# The general rule for bug sorting is that "more important" bugs are
+# less than "less important" bugs. This way sorting a list of bugs
+# will put the most important bugs first in the list. When relative
+# importance is unclear, the sorting follows some arbitrary convention
+# (i.e. dictionary order).
+
+def cmp_severity(bug_1, bug_2):
+ """
+ Compare the severity levels of two bugs, with more severe bugs
+ comparing as less.
+ >>> bugA = Bug()
+ >>> bugB = Bug()
+ >>> bugA.severity = bugB.severity = "wishlist"
+ >>> cmp_severity(bugA, bugB) == 0
+ True
+ >>> bugB.severity = "minor"
+ >>> cmp_severity(bugA, bugB) > 0
+ True
+ >>> bugA.severity = "critical"
+ >>> cmp_severity(bugA, bugB) < 0
+ True
+ """
+ if not hasattr(bug_2, "severity") :
+ return 1
+ return -cmp(severity_index[bug_1.severity], severity_index[bug_2.severity])
+
+def cmp_status(bug_1, bug_2):
+ """
+ Compare the status levels of two bugs, with more 'open' bugs
+ comparing as less.
+ >>> bugA = Bug()
+ >>> bugB = Bug()
+ >>> bugA.status = bugB.status = "open"
+ >>> cmp_status(bugA, bugB) == 0
+ True
+ >>> bugB.status = "closed"
+ >>> cmp_status(bugA, bugB) < 0
+ True
+ >>> bugA.status = "fixed"
+ >>> cmp_status(bugA, bugB) > 0
+ True
+ """
+ if not hasattr(bug_2, "status") :
+ return 1
+ val_2 = status_index[bug_2.status]
+ return cmp(status_index[bug_1.status], status_index[bug_2.status])
+
+def cmp_attr(bug_1, bug_2, attr, invert=False):
+ """
+ Compare a general attribute between two bugs using the conventional
+ comparison rule for that attribute type. If invert == True, sort
+ *against* that convention.
+ >>> attr="severity"
+ >>> bugA = Bug()
+ >>> bugB = Bug()
+ >>> bugA.severity = "critical"
+ >>> bugB.severity = "wishlist"
+ >>> cmp_attr(bugA, bugB, attr) < 0
+ True
+ >>> cmp_attr(bugA, bugB, attr, invert=True) > 0
+ True
+ >>> bugB.severity = "critical"
+ >>> cmp_attr(bugA, bugB, attr) == 0
+ True
+ """
+ if not hasattr(bug_2, attr) :
+ return 1
+ val_1 = getattr(bug_1, attr)
+ val_2 = getattr(bug_2, attr)
+ if val_1 == None: val_1 = None
+ if val_2 == None: val_2 = None
+
+ if invert == True :
+ return -cmp(val_1, val_2)
+ else :
+ return cmp(val_1, val_2)
+
+# alphabetical rankings (a < z)
+cmp_uuid = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "uuid")
+cmp_creator = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "creator")
+cmp_assigned = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "assigned")
+cmp_target = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "target")
+cmp_reporter = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "reporter")
+cmp_summary = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "summary")
+# chronological rankings (newer < older)
+cmp_time = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "time", invert=True)
+
+def cmp_comments(bug_1, bug_2):
+ """
+ Compare two bugs' comments lists. Doesn't load any new comments,
+ so you should call each bug's .load_comments() first if you want a
+ full comparison.
+ """
+ comms_1 = sorted(bug_1.comments(), key = lambda comm : comm.uuid)
+ comms_2 = sorted(bug_2.comments(), key = lambda comm : comm.uuid)
+ result = cmp(len(comms_1), len(comms_2))
+ if result != 0:
+ return result
+ for c_1,c_2 in zip(comms_1, comms_2):
+ result = cmp(c_1, c_2)
+ if result != 0:
+ return result
+ return 0
+
+DEFAULT_CMP_FULL_CMP_LIST = \
+ (cmp_status, cmp_severity, cmp_assigned, cmp_time, cmp_creator,
+ cmp_reporter, cmp_target, cmp_comments, cmp_summary, cmp_uuid)
+
+class BugCompoundComparator (object):
+ def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST):
+ self.cmp_list = cmp_list
+ def __call__(self, bug_1, bug_2):
+ for comparison in self.cmp_list :
+ val = comparison(bug_1, bug_2)
+ if val != 0 :
+ return val
+ return 0
+
+cmp_full = BugCompoundComparator()
+
+
+# define some bonus cmp_* functions
+def cmp_last_modified(bug_1, bug_2):
+ """
+ Like cmp_time(), but use most recent comment instead of bug
+ creation for the timestamp.
+ """
+ def last_modified(bug):
+ time = bug.time
+ for comment in bug.comment_root.traverse():
+ if comment.time > time:
+ time = comment.time
+ return time
+ val_1 = last_modified(bug_1)
+ val_2 = last_modified(bug_2)
+ return -cmp(val_1, val_2)
+
+
+suite = doctest.DocTestSuite()
diff --git a/interfaces/email/interactive/libbe/bugdir.py b/interfaces/email/interactive/libbe/bugdir.py
new file mode 100644
index 0000000..c4f0f91
--- /dev/null
+++ b/interfaces/email/interactive/libbe/bugdir.py
@@ -0,0 +1,832 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+# Alexander Belchenko <bialix@ukr.net>
+# Chris Ball <cjb@laptop.org>
+# Oleg Romanyshyn <oromanyshyn@panoramicfeedback.com>
+# W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""
+Define the BugDir class for representing bug comments.
+"""
+
+import copy
+import errno
+import os
+import os.path
+import sys
+import time
+import unittest
+import doctest
+
+import bug
+import encoding
+from properties import Property, doc_property, local_property, \
+ defaulting_property, checked_property, fn_checked_property, \
+ cached_property, primed_property, change_hook_property, \
+ settings_property
+import mapfile
+import vcs
+import settings_object
+import upgrade
+import utility
+
+
+class NoBugDir(Exception):
+ def __init__(self, path):
+ msg = "The directory \"%s\" has no bug directory." % path
+ Exception.__init__(self, msg)
+ self.path = path
+
+class NoRootEntry(Exception):
+ def __init__(self, path):
+ self.path = path
+ Exception.__init__(self, "Specified root does not exist: %s" % path)
+
+class AlreadyInitialized(Exception):
+ def __init__(self, path):
+ self.path = path
+ Exception.__init__(self,
+ "Specified root is already initialized: %s" % path)
+
+class MultipleBugMatches(ValueError):
+ def __init__(self, shortname, matches):
+ msg = ("More than one bug matches %s. "
+ "Please be more specific.\n%s" % (shortname, matches))
+ ValueError.__init__(self, msg)
+ self.shortname = shortname
+ self.matches = matches
+
+class NoBugMatches(KeyError):
+ def __init__(self, shortname):
+ msg = "No bug matches %s" % shortname
+ KeyError.__init__(self, msg)
+ self.shortname = shortname
+
+class DiskAccessRequired (Exception):
+ def __init__(self, goal):
+ msg = "Cannot %s without accessing the disk" % goal
+ Exception.__init__(self, msg)
+
+
+class BugDir (list, settings_object.SavedSettingsObject):
+ """
+ Sink to existing root
+ ======================
+
+ Consider the following usage case:
+ You have a bug directory rooted in
+ /path/to/source
+ by which I mean the '.be' directory is at
+ /path/to/source/.be
+ However, you're of in some subdirectory like
+ /path/to/source/GUI/testing
+ and you want to comment on a bug. Setting sink_to_root=True wen
+ you initialize your BugDir will cause it to search for the '.be'
+ file in the ancestors of the path you passed in as 'root'.
+ /path/to/source/GUI/testing/.be miss
+ /path/to/source/GUI/.be miss
+ /path/to/source/.be hit!
+ So it still roots itself appropriately without much work for you.
+
+ File-system access
+ ==================
+
+ BugDirs live completely in memory when .sync_with_disk is False.
+ This is the default configuration setup by BugDir(from_disk=False).
+ If .sync_with_disk == True (e.g. BugDir(from_disk=True)), then
+ any changes to the BugDir will be immediately written to disk.
+
+ If you want to change .sync_with_disk, we suggest you use
+ .set_sync_with_disk(), which propogates the new setting through to
+ all bugs/comments/etc. that have been loaded into memory. If
+ you've been living in memory and want to move to
+ .sync_with_disk==True, but you're not sure if anything has been
+ changed in memory, a call to save() immediately before the
+ .set_sync_with_disk(True) call is a safe move.
+
+ Regardless of .sync_with_disk, a call to .save() will write out
+ all the contents that the BugDir instance has loaded into memory.
+ If sync_with_disk has been True over the course of all interesting
+ changes, this .save() call will be a waste of time.
+
+ The BugDir will only load information from the file system when it
+ loads new settings/bugs/comments that it doesn't already have in
+ memory and .sync_with_disk == True.
+
+ Allow VCS initialization
+ ========================
+
+ This one is for testing purposes. Setting it to True allows the
+ BugDir to search for an installed VCS backend and initialize it in
+ the root directory. This is a convenience option for supporting
+ tests of versioning functionality (e.g. .duplicate_bugdir).
+
+ Disable encoding manipulation
+ =============================
+
+ This one is for testing purposed. You might have non-ASCII
+ Unicode in your bugs, comments, files, etc. BugDir instances try
+ and support your preferred encoding scheme (e.g. "utf-8") when
+ dealing with stream and file input/output. For stream output,
+ this involves replacing sys.stdout and sys.stderr
+ (libbe.encode.set_IO_stream_encodings). However this messes up
+ doctest's output catching. In order to support doctest tests
+ using BugDirs, set manipulate_encodings=False, and stick to ASCII
+ in your tests.
+ """
+
+ settings_properties = []
+ required_saved_properties = []
+ _prop_save_settings = settings_object.prop_save_settings
+ _prop_load_settings = settings_object.prop_load_settings
+ def _versioned_property(settings_properties=settings_properties,
+ required_saved_properties=required_saved_properties,
+ **kwargs):
+ if "settings_properties" not in kwargs:
+ kwargs["settings_properties"] = settings_properties
+ if "required_saved_properties" not in kwargs:
+ kwargs["required_saved_properties"]=required_saved_properties
+ return settings_object.versioned_property(**kwargs)
+
+ @_versioned_property(name="target",
+ doc="The current project development target.")
+ def target(): return {}
+
+ def _guess_encoding(self):
+ return encoding.get_encoding()
+ def _check_encoding(value):
+ if value != None:
+ return encoding.known_encoding(value)
+ def _setup_encoding(self, new_encoding):
+ # change hook called before generator.
+ if new_encoding not in [None, settings_object.EMPTY]:
+ if self._manipulate_encodings == True:
+ encoding.set_IO_stream_encodings(new_encoding)
+ def _set_encoding(self, old_encoding, new_encoding):
+ self._setup_encoding(new_encoding)
+ self._prop_save_settings(old_encoding, new_encoding)
+
+ @_versioned_property(name="encoding",
+ doc="""The default input/output encoding to use (e.g. "utf-8").""",
+ change_hook=_set_encoding,
+ generator=_guess_encoding,
+ check_fn=_check_encoding)
+ def encoding(): return {}
+
+ def _setup_user_id(self, user_id):
+ self.vcs.user_id = user_id
+ def _guess_user_id(self):
+ return self.vcs.get_user_id()
+ def _set_user_id(self, old_user_id, new_user_id):
+ self._setup_user_id(new_user_id)
+ self._prop_save_settings(old_user_id, new_user_id)
+
+ @_versioned_property(name="user_id",
+ doc=
+"""The user's prefered name, e.g. 'John Doe <jdoe@example.com>'. Note
+that the Arch VCS backend *enforces* ids with this format.""",
+ change_hook=_set_user_id,
+ generator=_guess_user_id)
+ def user_id(): return {}
+
+ @_versioned_property(name="default_assignee",
+ doc=
+"""The default assignee for new bugs e.g. 'John Doe <jdoe@example.com>'.""")
+ def default_assignee(): return {}
+
+ @_versioned_property(name="vcs_name",
+ doc="""The name of the current VCS. Kept seperate to make saving/loading
+settings easy. Don't set this attribute. Set .vcs instead, and
+.vcs_name will be automatically adjusted.""",
+ default="None",
+ allowed=["None", "Arch", "bzr", "darcs", "git", "hg"])
+ def vcs_name(): return {}
+
+ def _get_vcs(self, vcs_name=None):
+ """Get and root a new revision control system"""
+ if vcs_name == None:
+ vcs_name = self.vcs_name
+ new_vcs = vcs.vcs_by_name(vcs_name)
+ self._change_vcs(None, new_vcs)
+ return new_vcs
+ def _change_vcs(self, old_vcs, new_vcs):
+ new_vcs.encoding = self.encoding
+ new_vcs.root(self.root)
+ self.vcs_name = new_vcs.name
+
+ @Property
+ @change_hook_property(hook=_change_vcs)
+ @cached_property(generator=_get_vcs)
+ @local_property("vcs")
+ @doc_property(doc="A revision control system instance.")
+ def vcs(): return {}
+
+ def _bug_map_gen(self):
+ map = {}
+ for bug in self:
+ map[bug.uuid] = bug
+ for uuid in self.list_uuids():
+ if uuid not in map:
+ map[uuid] = None
+ self._bug_map_value = map # ._bug_map_value used by @local_property
+
+ def _extra_strings_check_fn(value):
+ return utility.iterable_full_of_strings(value, \
+ alternative=settings_object.EMPTY)
+ def _extra_strings_change_hook(self, old, new):
+ self.extra_strings.sort() # to make merging easier
+ self._prop_save_settings(old, new)
+ @_versioned_property(name="extra_strings",
+ doc="Space for an array of extra strings. Useful for storing state for functionality implemented purely in becommands/<some_function>.py.",
+ default=[],
+ check_fn=_extra_strings_check_fn,
+ change_hook=_extra_strings_change_hook,
+ mutable=True)
+ def extra_strings(): return {}
+
+ @Property
+ @primed_property(primer=_bug_map_gen)
+ @local_property("bug_map")
+ @doc_property(doc="A dict of (bug-uuid, bug-instance) pairs.")
+ def _bug_map(): return {}
+
+ def _setup_severities(self, severities):
+ if severities not in [None, settings_object.EMPTY]:
+ bug.load_severities(severities)
+ def _set_severities(self, old_severities, new_severities):
+ self._setup_severities(new_severities)
+ self._prop_save_settings(old_severities, new_severities)
+ @_versioned_property(name="severities",
+ doc="The allowed bug severities and their descriptions.",
+ change_hook=_set_severities)
+ def severities(): return {}
+
+ def _setup_status(self, active_status, inactive_status):
+ bug.load_status(active_status, inactive_status)
+ def _set_active_status(self, old_active_status, new_active_status):
+ self._setup_status(new_active_status, self.inactive_status)
+ self._prop_save_settings(old_active_status, new_active_status)
+ @_versioned_property(name="active_status",
+ doc="The allowed active bug states and their descriptions.",
+ change_hook=_set_active_status)
+ def active_status(): return {}
+
+ def _set_inactive_status(self, old_inactive_status, new_inactive_status):
+ self._setup_status(self.active_status, new_inactive_status)
+ self._prop_save_settings(old_inactive_status, new_inactive_status)
+ @_versioned_property(name="inactive_status",
+ doc="The allowed inactive bug states and their descriptions.",
+ change_hook=_set_inactive_status)
+ def inactive_status(): return {}
+
+
+ def __init__(self, root=None, sink_to_existing_root=True,
+ assert_new_BugDir=False, allow_vcs_init=False,
+ manipulate_encodings=True, from_disk=False, vcs=None):
+ list.__init__(self)
+ settings_object.SavedSettingsObject.__init__(self)
+ self._manipulate_encodings = manipulate_encodings
+ if root == None:
+ root = os.getcwd()
+ if sink_to_existing_root == True:
+ self.root = self._find_root(root)
+ else:
+ if not os.path.exists(root):
+ raise NoRootEntry(root)
+ self.root = root
+ # get a temporary vcs until we've loaded settings
+ self.sync_with_disk = False
+ self.vcs = self._guess_vcs()
+
+ if from_disk == True:
+ self.sync_with_disk = True
+ self.load()
+ else:
+ self.sync_with_disk = False
+ if assert_new_BugDir == True:
+ if os.path.exists(self.get_path()):
+ raise AlreadyInitialized, self.get_path()
+ if vcs == None:
+ vcs = self._guess_vcs(allow_vcs_init)
+ self.vcs = vcs
+ self._setup_user_id(self.user_id)
+
+ def __del__(self):
+ self.cleanup()
+
+ def cleanup(self):
+ self.vcs.cleanup()
+
+ # methods for getting the BugDir situated in the filesystem
+
+ def _find_root(self, path):
+ """
+ Search for an existing bug database dir and it's ancestors and
+ return a BugDir rooted there. Only called by __init__, and
+ then only if sink_to_existing_root == True.
+ """
+ if not os.path.exists(path):
+ raise NoRootEntry(path)
+ versionfile=utility.search_parent_directories(path,
+ os.path.join(".be", "version"))
+ if versionfile != None:
+ beroot = os.path.dirname(versionfile)
+ root = os.path.dirname(beroot)
+ return root
+ else:
+ beroot = utility.search_parent_directories(path, ".be")
+ if beroot == None:
+ raise NoBugDir(path)
+ return beroot
+
+ def _guess_vcs(self, allow_vcs_init=False):
+ """
+ Only called by __init__.
+ """
+ deepdir = self.get_path()
+ if not os.path.exists(deepdir):
+ deepdir = os.path.dirname(deepdir)
+ new_vcs = vcs.detect_vcs(deepdir)
+ install = False
+ if new_vcs.name == "None":
+ if allow_vcs_init == True:
+ new_vcs = vcs.installed_vcs()
+ new_vcs.init(self.root)
+ return new_vcs
+
+ # methods for saving/loading/accessing settings and properties.
+
+ def get_path(self, *args):
+ """
+ Return a path relative to .root.
+ """
+ dir = os.path.join(self.root, ".be")
+ if len(args) == 0:
+ return dir
+ assert args[0] in ["version", "settings", "bugs"], str(args)
+ return os.path.join(dir, *args)
+
+ def _get_settings(self, settings_path, for_duplicate_bugdir=False):
+ allow_no_vcs = not self.vcs.path_in_root(settings_path)
+ if allow_no_vcs == True:
+ assert for_duplicate_bugdir == True
+ if self.sync_with_disk == False and for_duplicate_bugdir == False:
+ # duplicates can ignore this bugdir's .sync_with_disk status
+ raise DiskAccessRequired("_get settings")
+ try:
+ settings = mapfile.map_load(self.vcs, settings_path, allow_no_vcs)
+ except vcs.NoSuchFile:
+ settings = {"vcs_name": "None"}
+ return settings
+
+ def _save_settings(self, settings_path, settings,
+ for_duplicate_bugdir=False):
+ allow_no_vcs = not self.vcs.path_in_root(settings_path)
+ if allow_no_vcs == True:
+ assert for_duplicate_bugdir == True
+ if self.sync_with_disk == False and for_duplicate_bugdir == False:
+ # duplicates can ignore this bugdir's .sync_with_disk status
+ raise DiskAccessRequired("_save settings")
+ self.vcs.mkdir(self.get_path(), allow_no_vcs)
+ mapfile.map_save(self.vcs, settings_path, settings, allow_no_vcs)
+
+ def load_settings(self):
+ self.settings = self._get_settings(self.get_path("settings"))
+ self._setup_saved_settings()
+ self._setup_user_id(self.user_id)
+ self._setup_encoding(self.encoding)
+ self._setup_severities(self.severities)
+ self._setup_status(self.active_status, self.inactive_status)
+ self.vcs = vcs.vcs_by_name(self.vcs_name)
+ self._setup_user_id(self.user_id)
+
+ def save_settings(self):
+ settings = self._get_saved_settings()
+ self._save_settings(self.get_path("settings"), settings)
+
+ def get_version(self, path=None, use_none_vcs=False,
+ for_duplicate_bugdir=False):
+ """
+ Requires disk access.
+ """
+ if self.sync_with_disk == False:
+ raise DiskAccessRequired("get version")
+ if use_none_vcs == True:
+ VCS = vcs.vcs_by_name("None")
+ VCS.root(self.root)
+ VCS.encoding = encoding.get_encoding()
+ else:
+ VCS = self.vcs
+
+ if path == None:
+ path = self.get_path("version")
+ allow_no_vcs = not VCS.path_in_root(path)
+ if allow_no_vcs == True:
+ assert for_duplicate_bugdir == True
+ version = VCS.get_file_contents(
+ path, allow_no_vcs=allow_no_vcs).rstrip("\n")
+ return version
+
+ def set_version(self):
+ """
+ Requires disk access.
+ """
+ if self.sync_with_disk == False:
+ raise DiskAccessRequired("set version")
+ self.vcs.mkdir(self.get_path())
+ self.vcs.set_file_contents(self.get_path("version"),
+ upgrade.BUGDIR_DISK_VERSION+"\n")
+
+ # methods controlling disk access
+
+ def set_sync_with_disk(self, value):
+ """
+ Adjust .sync_with_disk for the BugDir and all it's children.
+ See the BugDir docstring for a description of the role of
+ .sync_with_disk.
+ """
+ self.sync_with_disk = value
+ for bug in self:
+ bug.set_sync_with_disk(value)
+
+ def load(self):
+ """
+ Reqires disk access
+ """
+ version = self.get_version(use_none_vcs=True)
+ if version != upgrade.BUGDIR_DISK_VERSION:
+ upgrade.upgrade(self.root, version)
+ else:
+ if not os.path.exists(self.get_path()):
+ raise NoBugDir(self.get_path())
+ self.load_settings()
+
+ def load_all_bugs(self):
+ """
+ Requires disk access.
+ Warning: this could take a while.
+ """
+ if self.sync_with_disk == False:
+ raise DiskAccessRequired("load all bugs")
+ self._clear_bugs()
+ for uuid in self.list_uuids():
+ self._load_bug(uuid)
+
+ def save(self):
+ """
+ Note that this command writes to disk _regardless_ of the
+ status of .sync_with_disk.
+
+ Save any loaded contents to disk. Because of lazy loading of
+ bugs and comments, this is actually not too inefficient.
+
+ However, if .sync_with_disk = True, then any changes are
+ automatically written to disk as soon as they happen, so
+ calling this method will just waste time (unless something
+ else has been messing with your on-disk files).
+
+ Requires disk access.
+ """
+ sync_with_disk = self.sync_with_disk
+ if sync_with_disk == False:
+ self.set_sync_with_disk(True)
+ self.set_version()
+ self.save_settings()
+ for bug in self:
+ bug.save()
+ if sync_with_disk == False:
+ self.set_sync_with_disk(sync_with_disk)
+
+ # methods for managing duplicate BugDirs
+
+ def duplicate_bugdir(self, revision):
+ duplicate_path = self.vcs.duplicate_repo(revision)
+
+ duplicate_version_path = os.path.join(duplicate_path, ".be", "version")
+ try:
+ version = self.get_version(duplicate_version_path,
+ for_duplicate_bugdir=True)
+ except DiskAccessRequired:
+ self.sync_with_disk = True # temporarily allow access
+ version = self.get_version(duplicate_version_path,
+ for_duplicate_bugdir=True)
+ self.sync_with_disk = False
+ if version != upgrade.BUGDIR_DISK_VERSION:
+ upgrade.upgrade(duplicate_path, version)
+
+ # setup revision VCS as None, since the duplicate may not be
+ # initialized for versioning
+ duplicate_settings_path = os.path.join(duplicate_path,
+ ".be", "settings")
+ duplicate_settings = self._get_settings(duplicate_settings_path,
+ for_duplicate_bugdir=True)
+ if "vcs_name" in duplicate_settings:
+ duplicate_settings["vcs_name"] = "None"
+ duplicate_settings["user_id"] = self.user_id
+ if "disabled" in bug.status_values:
+ # Hack to support old versions of BE bugs
+ duplicate_settings["inactive_status"] = self.inactive_status
+ self._save_settings(duplicate_settings_path, duplicate_settings,
+ for_duplicate_bugdir=True)
+
+ return BugDir(duplicate_path, from_disk=True, manipulate_encodings=self._manipulate_encodings)
+
+ def remove_duplicate_bugdir(self):
+ self.vcs.remove_duplicate_repo()
+
+ # methods for managing bugs
+
+ def list_uuids(self):
+ uuids = []
+ if self.sync_with_disk == True and os.path.exists(self.get_path()):
+ # list the uuids on disk
+ if os.path.exists(self.get_path("bugs")):
+ for uuid in os.listdir(self.get_path("bugs")):
+ if not (uuid.startswith('.')):
+ uuids.append(uuid)
+ yield uuid
+ # and the ones that are still just in memory
+ for bug in self:
+ if bug.uuid not in uuids:
+ uuids.append(bug.uuid)
+ yield bug.uuid
+
+ def _clear_bugs(self):
+ while len(self) > 0:
+ self.pop()
+ self._bug_map_gen()
+
+ def _load_bug(self, uuid):
+ if self.sync_with_disk == False:
+ raise DiskAccessRequired("_load bug")
+ bg = bug.Bug(bugdir=self, uuid=uuid, from_disk=True)
+ self.append(bg)
+ self._bug_map_gen()
+ return bg
+
+ def new_bug(self, uuid=None, summary=None):
+ bg = bug.Bug(bugdir=self, uuid=uuid, summary=summary)
+ bg.set_sync_with_disk(self.sync_with_disk)
+ if bg.sync_with_disk == True:
+ bg.save()
+ self.append(bg)
+ self._bug_map_gen()
+ return bg
+
+ def remove_bug(self, bug):
+ self.remove(bug)
+ if bug.sync_with_disk == True:
+ bug.remove()
+
+ def bug_shortname(self, bug):
+ """
+ Generate short names from uuids. Picks the minimum number of
+ characters (>=3) from the beginning of the uuid such that the
+ short names are unique.
+
+ Obviously, as the number of bugs in the database grows, these
+ short names will cease to be unique. The complete uuid should be
+ used for long term reference.
+ """
+ chars = 3
+ for uuid in self._bug_map.keys():
+ if bug.uuid == uuid:
+ continue
+ while (bug.uuid[:chars] == uuid[:chars]):
+ chars+=1
+ return bug.uuid[:chars]
+
+ def bug_from_shortname(self, shortname):
+ """
+ >>> bd = SimpleBugDir(sync_with_disk=False)
+ >>> bug_a = bd.bug_from_shortname('a')
+ >>> print type(bug_a)
+ <class 'libbe.bug.Bug'>
+ >>> print bug_a
+ a:om: Bug A
+ >>> bd.cleanup()
+ """
+ matches = []
+ self._bug_map_gen()
+ for uuid in self._bug_map.keys():
+ if uuid.startswith(shortname):
+ matches.append(uuid)
+ if len(matches) > 1:
+ raise MultipleBugMatches(shortname, matches)
+ if len(matches) == 1:
+ return self.bug_from_uuid(matches[0])
+ raise NoBugMatches(shortname)
+
+ def bug_from_uuid(self, uuid):
+ if not self.has_bug(uuid):
+ raise KeyError("No bug matches %s\n bug map: %s\n root: %s" \
+ % (uuid, self._bug_map, self.root))
+ if self._bug_map[uuid] == None:
+ self._load_bug(uuid)
+ return self._bug_map[uuid]
+
+ def has_bug(self, bug_uuid):
+ if bug_uuid not in self._bug_map:
+ self._bug_map_gen()
+ if bug_uuid not in self._bug_map:
+ return False
+ return True
+
+
+class SimpleBugDir (BugDir):
+ """
+ For testing. Set sync_with_disk==False for a memory-only bugdir.
+ >>> bugdir = SimpleBugDir()
+ >>> uuids = list(bugdir.list_uuids())
+ >>> uuids.sort()
+ >>> print uuids
+ ['a', 'b']
+ >>> bugdir.cleanup()
+ """
+ def __init__(self, sync_with_disk=True):
+ if sync_with_disk == True:
+ dir = utility.Dir()
+ assert os.path.exists(dir.path)
+ root = dir.path
+ assert_new_BugDir = True
+ vcs_init = True
+ else:
+ root = "/"
+ assert_new_BugDir = False
+ vcs_init = False
+ BugDir.__init__(self, root, sink_to_existing_root=False,
+ assert_new_BugDir=assert_new_BugDir,
+ allow_vcs_init=vcs_init,
+ manipulate_encodings=False)
+ if sync_with_disk == True: # postpone cleanup since dir.__del__() removes dir.
+ self._dir_ref = dir
+ bug_a = self.new_bug("a", summary="Bug A")
+ bug_a.creator = "John Doe <jdoe@example.com>"
+ bug_a.time = 0
+ bug_b = self.new_bug("b", summary="Bug B")
+ bug_b.creator = "Jane Doe <jdoe@example.com>"
+ bug_b.time = 0
+ bug_b.status = "closed"
+ if sync_with_disk == True:
+ self.save()
+ self.set_sync_with_disk(True)
+ def cleanup(self):
+ if hasattr(self, "_dir_ref"):
+ self._dir_ref.cleanup()
+ BugDir.cleanup(self)
+
+class BugDirTestCase(unittest.TestCase):
+ def setUp(self):
+ self.dir = utility.Dir()
+ self.bugdir = BugDir(self.dir.path, sink_to_existing_root=False,
+ allow_vcs_init=True)
+ self.vcs = self.bugdir.vcs
+ def tearDown(self):
+ self.bugdir.cleanup()
+ self.dir.cleanup()
+ def fullPath(self, path):
+ return os.path.join(self.dir.path, path)
+ def assertPathExists(self, path):
+ fullpath = self.fullPath(path)
+ self.failUnless(os.path.exists(fullpath)==True,
+ "path %s does not exist" % fullpath)
+ self.assertRaises(AlreadyInitialized, BugDir,
+ self.dir.path, assertNewBugDir=True)
+ def versionTest(self):
+ if self.vcs.versioned == False:
+ return
+ original = self.bugdir.vcs.commit("Began versioning")
+ bugA = self.bugdir.bug_from_uuid("a")
+ bugA.status = "fixed"
+ self.bugdir.save()
+ new = self.vcs.commit("Fixed bug a")
+ dupdir = self.bugdir.duplicate_bugdir(original)
+ self.failUnless(dupdir.root != self.bugdir.root,
+ "%s, %s" % (dupdir.root, self.bugdir.root))
+ bugAorig = dupdir.bug_from_uuid("a")
+ self.failUnless(bugA != bugAorig,
+ "\n%s\n%s" % (bugA.string(), bugAorig.string()))
+ bugAorig.status = "fixed"
+ self.failUnless(bug.cmp_status(bugA, bugAorig)==0,
+ "%s, %s" % (bugA.status, bugAorig.status))
+ self.failUnless(bug.cmp_severity(bugA, bugAorig)==0,
+ "%s, %s" % (bugA.severity, bugAorig.severity))
+ self.failUnless(bug.cmp_assigned(bugA, bugAorig)==0,
+ "%s, %s" % (bugA.assigned, bugAorig.assigned))
+ self.failUnless(bug.cmp_time(bugA, bugAorig)==0,
+ "%s, %s" % (bugA.time, bugAorig.time))
+ self.failUnless(bug.cmp_creator(bugA, bugAorig)==0,
+ "%s, %s" % (bugA.creator, bugAorig.creator))
+ self.failUnless(bugA == bugAorig,
+ "\n%s\n%s" % (bugA.string(), bugAorig.string()))
+ self.bugdir.remove_duplicate_bugdir()
+ self.failUnless(os.path.exists(dupdir.root)==False, str(dupdir.root))
+ def testRun(self):
+ self.bugdir.new_bug(uuid="a", summary="Ant")
+ self.bugdir.new_bug(uuid="b", summary="Cockroach")
+ self.bugdir.new_bug(uuid="c", summary="Praying mantis")
+ length = len(self.bugdir)
+ self.failUnless(length == 3, "%d != 3 bugs" % length)
+ uuids = list(self.bugdir.list_uuids())
+ self.failUnless(len(uuids) == 3, "%d != 3 uuids" % len(uuids))
+ self.failUnless(uuids == ["a","b","c"], str(uuids))
+ bugA = self.bugdir.bug_from_uuid("a")
+ bugAprime = self.bugdir.bug_from_shortname("a")
+ self.failUnless(bugA == bugAprime, "%s != %s" % (bugA, bugAprime))
+ self.bugdir.save()
+ self.versionTest()
+ def testComments(self, sync_with_disk=False):
+ if sync_with_disk == True:
+ self.bugdir.set_sync_with_disk(True)
+ self.bugdir.new_bug(uuid="a", summary="Ant")
+ bug = self.bugdir.bug_from_uuid("a")
+ comm = bug.comment_root
+ rep = comm.new_reply("Ants are small.")
+ rep.new_reply("And they have six legs.")
+ if sync_with_disk == False:
+ self.bugdir.save()
+ self.bugdir.set_sync_with_disk(True)
+ self.bugdir._clear_bugs()
+ bug = self.bugdir.bug_from_uuid("a")
+ bug.load_comments()
+ if sync_with_disk == False:
+ self.bugdir.set_sync_with_disk(False)
+ self.failUnless(len(bug.comment_root)==1, len(bug.comment_root))
+ for index,comment in enumerate(bug.comments()):
+ if index == 0:
+ repLoaded = comment
+ self.failUnless(repLoaded.uuid == rep.uuid, repLoaded.uuid)
+ self.failUnless(comment.sync_with_disk == sync_with_disk,
+ comment.sync_with_disk)
+ self.failUnless(comment.content_type == "text/plain",
+ comment.content_type)
+ self.failUnless(repLoaded.settings["Content-type"]=="text/plain",
+ repLoaded.settings)
+ self.failUnless(repLoaded.body == "Ants are small.",
+ repLoaded.body)
+ elif index == 1:
+ self.failUnless(comment.in_reply_to == repLoaded.uuid,
+ repLoaded.uuid)
+ self.failUnless(comment.body == "And they have six legs.",
+ comment.body)
+ else:
+ self.failIf(True, "Invalid comment: %d\n%s" % (index, comment))
+ def testSyncedComments(self):
+ self.testComments(sync_with_disk=True)
+
+class SimpleBugDirTestCase (unittest.TestCase):
+ def setUp(self):
+ # create a pre-existing bugdir in a temporary directory
+ self.dir = utility.Dir()
+ self.original_working_dir = os.getcwd()
+ os.chdir(self.dir.path)
+ self.bugdir = BugDir(self.dir.path, sink_to_existing_root=False,
+ allow_vcs_init=True)
+ self.bugdir.new_bug("preexisting", summary="Hopefully not imported")
+ self.bugdir.save()
+ def tearDown(self):
+ os.chdir(self.original_working_dir)
+ self.bugdir.cleanup()
+ self.dir.cleanup()
+ def testOnDiskCleanLoad(self):
+ """SimpleBugDir(sync_with_disk==True) should not import preexisting bugs."""
+ bugdir = SimpleBugDir(sync_with_disk=True)
+ self.failUnless(bugdir.sync_with_disk==True, bugdir.sync_with_disk)
+ uuids = sorted([bug.uuid for bug in bugdir])
+ self.failUnless(uuids == ['a', 'b'], uuids)
+ bugdir._clear_bugs()
+ uuids = sorted([bug.uuid for bug in bugdir])
+ self.failUnless(uuids == [], uuids)
+ bugdir.load_all_bugs()
+ uuids = sorted([bug.uuid for bug in bugdir])
+ self.failUnless(uuids == ['a', 'b'], uuids)
+ bugdir.cleanup()
+ def testInMemoryCleanLoad(self):
+ """SimpleBugDir(sync_with_disk==False) should not import preexisting bugs."""
+ bugdir = SimpleBugDir(sync_with_disk=False)
+ self.failUnless(bugdir.sync_with_disk==False, bugdir.sync_with_disk)
+ uuids = sorted([bug.uuid for bug in bugdir])
+ self.failUnless(uuids == ['a', 'b'], uuids)
+ self.failUnlessRaises(DiskAccessRequired, bugdir.load_all_bugs)
+ uuids = sorted([bug.uuid for bug in bugdir])
+ self.failUnless(uuids == ['a', 'b'], uuids)
+ bugdir._clear_bugs()
+ uuids = sorted([bug.uuid for bug in bugdir])
+ self.failUnless(uuids == [], uuids)
+ bugdir.cleanup()
+
+unitsuite = unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
+suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
diff --git a/interfaces/email/interactive/libbe/bzr.py b/interfaces/email/interactive/libbe/bzr.py
new file mode 100644
index 0000000..e9e0649
--- /dev/null
+++ b/interfaces/email/interactive/libbe/bzr.py
@@ -0,0 +1,113 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+# Ben Finney <ben+python@benfinney.id.au>
+# Marien Zwart <marienz@gentoo.org>
+# W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""
+Bazaar (bzr) backend.
+"""
+
+import os
+import re
+import sys
+import unittest
+import doctest
+
+import vcs
+
+
+def new():
+ return Bzr()
+
+class Bzr(vcs.VCS):
+ name = "bzr"
+ client = "bzr"
+ versioned = True
+ def _vcs_help(self):
+ status,output,error = self._u_invoke_client("--help")
+ return output
+ def _vcs_detect(self, path):
+ if self._u_search_parent_directories(path, ".bzr") != None :
+ return True
+ return False
+ def _vcs_root(self, path):
+ """Find the root of the deepest repository containing path."""
+ status,output,error = self._u_invoke_client("root", path)
+ return output.rstrip('\n')
+ def _vcs_init(self, path):
+ self._u_invoke_client("init", directory=path)
+ def _vcs_get_user_id(self):
+ status,output,error = self._u_invoke_client("whoami")
+ return output.rstrip('\n')
+ def _vcs_set_user_id(self, value):
+ self._u_invoke_client("whoami", value)
+ def _vcs_add(self, path):
+ self._u_invoke_client("add", path)
+ def _vcs_remove(self, path):
+ # --force to also remove unversioned files.
+ self._u_invoke_client("remove", "--force", path)
+ def _vcs_update(self, path):
+ pass
+ def _vcs_get_file_contents(self, path, revision=None, binary=False):
+ if revision == None:
+ return vcs.VCS._vcs_get_file_contents(self, path, revision, binary=binary)
+ else:
+ status,output,error = \
+ self._u_invoke_client("cat","-r",revision,path)
+ return output
+ def _vcs_duplicate_repo(self, directory, revision=None):
+ if revision == None:
+ vcs.VCS._vcs_duplicate_repo(self, directory, revision)
+ else:
+ self._u_invoke_client("branch", "--revision", revision,
+ ".", directory)
+ def _vcs_commit(self, commitfile, allow_empty=False):
+ args = ["commit", "--file", commitfile]
+ if allow_empty == True:
+ args.append("--unchanged")
+ status,output,error = self._u_invoke_client(*args)
+ else:
+ kwargs = {"expect":(0,3)}
+ status,output,error = self._u_invoke_client(*args, **kwargs)
+ if status != 0:
+ strings = ["ERROR: no changes to commit.", # bzr 1.3.1
+ "ERROR: No changes to commit."] # bzr 1.15.1
+ if self._u_any_in_string(strings, error) == True:
+ raise vcs.EmptyCommit()
+ else:
+ raise vcs.CommandError(args, status, stdout="", stderr=error)
+ revision = None
+ revline = re.compile("Committed revision (.*)[.]")
+ match = revline.search(error)
+ assert match != None, output+error
+ assert len(match.groups()) == 1
+ revision = match.groups()[0]
+ return revision
+ def _vcs_revision_id(self, index):
+ status,output,error = self._u_invoke_client("revno")
+ current_revision = int(output)
+ if index >= current_revision or index < -current_revision:
+ return None
+ if index >= 0:
+ return str(index+1) # bzr commit 0 is the empty tree.
+ return str(current_revision+index+1)
+
+
+vcs.make_vcs_testcase_subclasses(Bzr, sys.modules[__name__])
+
+unitsuite = unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
+suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
diff --git a/interfaces/email/interactive/libbe/cmdutil.py b/interfaces/email/interactive/libbe/cmdutil.py
new file mode 100644
index 0000000..9b64142
--- /dev/null
+++ b/interfaces/email/interactive/libbe/cmdutil.py
@@ -0,0 +1,233 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+# Oleg Romanyshyn <oromanyshyn@panoramicfeedback.com>
+# W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""
+Define assorted utilities to make command-line handling easier.
+"""
+
+import glob
+import optparse
+import os
+from textwrap import TextWrapper
+from StringIO import StringIO
+import sys
+import doctest
+
+import bugdir
+import plugin
+import encoding
+
+
+class UserError(Exception):
+ def __init__(self, msg):
+ Exception.__init__(self, msg)
+
+class UnknownCommand(UserError):
+ def __init__(self, cmd):
+ Exception.__init__(self, "Unknown command '%s'" % cmd)
+ self.cmd = cmd
+
+class UsageError(Exception):
+ pass
+
+class GetHelp(Exception):
+ pass
+
+class GetCompletions(Exception):
+ def __init__(self, completions=[]):
+ msg = "Get allowed completions"
+ Exception.__init__(self, msg)
+ self.completions = completions
+
+def iter_commands():
+ for name, module in plugin.iter_plugins("becommands"):
+ yield name.replace("_", "-"), module
+
+def get_command(command_name):
+ """Retrieves the module for a user command
+
+ >>> try:
+ ... get_command("asdf")
+ ... except UnknownCommand, e:
+ ... print e
+ Unknown command 'asdf'
+ >>> repr(get_command("list")).startswith("<module 'becommands.list' from ")
+ True
+ """
+ cmd = plugin.get_plugin("becommands", command_name.replace("-", "_"))
+ if cmd is None:
+ raise UnknownCommand(command_name)
+ return cmd
+
+
+def execute(cmd, args, manipulate_encodings=True):
+ enc = encoding.get_encoding()
+ cmd = get_command(cmd)
+ ret = cmd.execute([a.decode(enc) for a in args],
+ manipulate_encodings=manipulate_encodings)
+ if ret == None:
+ ret = 0
+ return ret
+
+def help(cmd=None, parser=None):
+ if cmd != None:
+ return get_command(cmd).help()
+ else:
+ cmdlist = []
+ for name, module in iter_commands():
+ cmdlist.append((name, module.__desc__))
+ longest_cmd_len = max([len(name) for name,desc in cmdlist])
+ ret = ["Bugs Everywhere - Distributed bug tracking",
+ "", "Supported commands"]
+ for name, desc in cmdlist:
+ numExtraSpaces = longest_cmd_len-len(name)
+ ret.append("be %s%*s %s" % (name, numExtraSpaces, "", desc))
+ ret.extend(["", "Run", " be help [command]", "for more information."])
+ longhelp = "\n".join(ret)
+ if parser == None:
+ return longhelp
+ return parser.help_str() + "\n" + longhelp
+
+def completions(cmd):
+ parser = get_command(cmd).get_parser()
+ longopts = []
+ for opt in parser.option_list:
+ longopts.append(opt.get_opt_string())
+ return longopts
+
+def raise_get_help(option, opt, value, parser):
+ raise GetHelp
+
+def raise_get_completions(option, opt, value, parser):
+ print "got completion arg"
+ if hasattr(parser, "command") and parser.command == "be":
+ comps = []
+ for command, module in iter_commands():
+ comps.append(command)
+ for opt in parser.option_list:
+ comps.append(opt.get_opt_string())
+ raise GetCompletions(comps)
+ raise GetCompletions(completions(sys.argv[1]))
+
+class CmdOptionParser(optparse.OptionParser):
+ def __init__(self, usage):
+ optparse.OptionParser.__init__(self, usage)
+ self.disable_interspersed_args()
+ self.remove_option("-h")
+ self.add_option("-h", "--help", action="callback",
+ callback=raise_get_help, help="Print a help message")
+ self.add_option("--complete", action="callback",
+ callback=raise_get_completions,
+ help="Print a list of available completions")
+
+ def error(self, message):
+ raise UsageError(message)
+
+ def iter_options(self):
+ return iter_combine([self._short_opt.iterkeys(),
+ self._long_opt.iterkeys()])
+
+ def help_str(self):
+ f = StringIO()
+ self.print_help(f)
+ return f.getvalue()
+
+def option_value_pairs(options, parser):
+ """
+ Iterate through OptionParser (option, value) pairs.
+ """
+ for option in [o.dest for o in parser.option_list if o.dest != None]:
+ value = getattr(options, option)
+ yield (option, value)
+
+def default_complete(options, args, parser, bugid_args={}):
+ """
+ A dud complete implementation for becommands so that the
+ --complete argument doesn't cause any problems. Use this
+ until you've set up a command-specific complete function.
+
+ bugid_args is an optional dict where the keys are positional
+ arguments taking bug shortnames and the values are functions for
+ filtering, since that's a common enough operation.
+ e.g. for "be open [options] BUGID"
+ bugid_args = {0: lambda bug : bug.active == False}
+ A positional argument of -1 specifies all remaining arguments
+ (e.g in the case of "be show BUGID BUGID ...").
+ """
+ for option,value in option_value_pairs(options, parser):
+ if value == "--complete":
+ raise GetCompletions()
+ if len(bugid_args.keys()) > 0:
+ max_pos_arg = max(bugid_args.keys())
+ else:
+ max_pos_arg = -1
+ for pos,value in enumerate(args):
+ if value == "--complete":
+ filter = None
+ if pos in bugid_args:
+ filter = bugid_args[pos]
+ if pos > max_pos_arg and -1 in bugid_args:
+ filter = bugid_args[-1]
+ if filter != None:
+ bugshortnames = []
+ try:
+ bd = bugdir.BugDir(from_disk=True,
+ manipulate_encodings=False)
+ bd.load_all_bugs()
+ bugs = [bug for bug in bd if filter(bug) == True]
+ bugshortnames = [bd.bug_shortname(bug) for bug in bugs]
+ except bugdir.NoBugDir:
+ pass
+ raise GetCompletions(bugshortnames)
+ raise GetCompletions()
+
+def complete_path(path):
+ """List possible path completions for path."""
+ comps = glob.glob(path+"*") + glob.glob(path+"/*")
+ if len(comps) == 1 and os.path.isdir(comps[0]):
+ comps.extend(glob.glob(comps[0]+"/*"))
+ return comps
+
+def underlined(instring):
+ """Produces a version of a string that is underlined with '='
+
+ >>> underlined("Underlined String")
+ 'Underlined String\\n================='
+ """
+
+ return "%s\n%s" % (instring, "="*len(instring))
+
+def bug_from_shortname(bdir, shortname):
+ """
+ Exception translation for the command-line interface.
+ """
+ try:
+ bug = bdir.bug_from_shortname(shortname)
+ except (bugdir.MultipleBugMatches, bugdir.NoBugMatches), e:
+ raise UserError(e.message)
+ return bug
+
+def _test():
+ import doctest
+ import sys
+ doctest.testmod()
+
+if __name__ == "__main__":
+ _test()
+
+suite = doctest.DocTestSuite()
diff --git a/interfaces/email/interactive/libbe/comment.py b/interfaces/email/interactive/libbe/comment.py
new file mode 100644
index 0000000..41bc7e6
--- /dev/null
+++ b/interfaces/email/interactive/libbe/comment.py
@@ -0,0 +1,744 @@
+# Bugs Everywhere, a distributed bugtracker
+# Copyright (C) 2008-2009 Chris Ball <cjb@laptop.org>
+# Thomas Habets <thomas@habets.pp.se>
+# W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""
+Define the Comment class for representing bug comments.
+"""
+
+import base64
+import os
+import os.path
+import sys
+import time
+import types
+try: # import core module, Python >= 2.5
+ from xml.etree import ElementTree
+except ImportError: # look for non-core module
+ from elementtree import ElementTree
+import xml.sax.saxutils
+import doctest
+
+from beuuid import uuid_gen
+from properties import Property, doc_property, local_property, \
+ defaulting_property, checked_property, cached_property, \
+ primed_property, change_hook_property, settings_property
+import settings_object
+import mapfile
+from tree import Tree
+import utility
+
+
+class InvalidShortname(KeyError):
+ def __init__(self, shortname, shortnames):
+ msg = "Invalid shortname %s\n%s" % (shortname, shortnames)
+ KeyError.__init__(self, msg)
+ self.shortname = shortname
+ self.shortnames = shortnames
+
+class InvalidXML(ValueError):
+ def __init__(self, element, comment):
+ msg = "Invalid comment xml: %s\n %s\n" \
+ % (comment, ElementTree.tostring(element))
+ ValueError.__init__(self, msg)
+ self.element = element
+ self.comment = comment
+
+class MissingReference(ValueError):
+ def __init__(self, comment):
+ msg = "Missing reference to %s" % (comment.in_reply_to)
+ ValueError.__init__(self, msg)
+ self.reference = comment.in_reply_to
+ self.comment = comment
+
+class DiskAccessRequired (Exception):
+ def __init__(self, goal):
+ msg = "Cannot %s without accessing the disk" % goal
+ Exception.__init__(self, msg)
+
+INVALID_UUID = "!!~~\n INVALID-UUID \n~~!!"
+
+def list_to_root(comments, bug, root=None,
+ ignore_missing_references=False):
+ """
+ Convert a raw list of comments to single root comment. We use a
+ dummy root comment by default, because there can be several
+ comment threads rooted on the same parent bug. To simplify
+ comment interaction, we condense these threads into a single
+ thread with a Comment dummy root. Can also be used to append
+ a list of subcomments to a non-dummy root comment, so long as
+ all the new comments are descendants of the root comment.
+
+ No Comment method should use the dummy comment.
+ """
+ root_comments = []
+ uuid_map = {}
+ for comment in comments:
+ assert comment.uuid != None
+ uuid_map[comment.uuid] = comment
+ for comment in comments:
+ if comment.alt_id != None and comment.alt_id not in uuid_map:
+ uuid_map[comment.alt_id] = comment
+ if root == None:
+ root = Comment(bug, uuid=INVALID_UUID)
+ else:
+ uuid_map[root.uuid] = root
+ for comm in comments:
+ if comm.in_reply_to == INVALID_UUID:
+ comm.in_reply_to = None
+ rep = comm.in_reply_to
+ if rep == None or rep == bug.uuid:
+ root_comments.append(comm)
+ else:
+ parentUUID = comm.in_reply_to
+ try:
+ parent = uuid_map[parentUUID]
+ parent.add_reply(comm)
+ except KeyError, e:
+ if ignore_missing_references == True:
+ print >> sys.stderr, \
+ "Ignoring missing reference to %s" % parentUUID
+ comm.in_reply_to = None
+ root_comments.append(comm)
+ else:
+ raise MissingReference(comm)
+ root.extend(root_comments)
+ return root
+
+def loadComments(bug, load_full=False):
+ """
+ Set load_full=True when you want to load the comment completely
+ from disk *now*, rather than waiting and lazy loading as required.
+ """
+ if bug.sync_with_disk == False:
+ raise DiskAccessRequired("load comments")
+ path = bug.get_path("comments")
+ if not os.path.exists(path):
+ return Comment(bug, uuid=INVALID_UUID)
+ comments = []
+ for uuid in os.listdir(path):
+ if uuid.startswith('.'):
+ continue
+ comm = Comment(bug, uuid, from_disk=True)
+ comm.set_sync_with_disk(bug.sync_with_disk)
+ if load_full == True:
+ comm.load_settings()
+ dummy = comm.body # force the body to load
+ comments.append(comm)
+ return list_to_root(comments, bug)
+
+def saveComments(bug):
+ if bug.sync_with_disk == False:
+ raise DiskAccessRequired("save comments")
+ for comment in bug.comment_root.traverse():
+ comment.save()
+
+
+class Comment(Tree, settings_object.SavedSettingsObject):
+ """
+ >>> c = Comment()
+ >>> c.uuid != None
+ True
+ >>> c.uuid = "some-UUID"
+ >>> print c.content_type
+ text/plain
+ """
+
+ settings_properties = []
+ required_saved_properties = []
+ _prop_save_settings = settings_object.prop_save_settings
+ _prop_load_settings = settings_object.prop_load_settings
+ def _versioned_property(settings_properties=settings_properties,
+ required_saved_properties=required_saved_properties,
+ **kwargs):
+ if "settings_properties" not in kwargs:
+ kwargs["settings_properties"] = settings_properties
+ if "required_saved_properties" not in kwargs:
+ kwargs["required_saved_properties"]=required_saved_properties
+ return settings_object.versioned_property(**kwargs)
+
+ @_versioned_property(name="Alt-id",
+ doc="Alternate ID for linking imported comments. Internally comments are linked (via In-reply-to) to the parent's UUID. However, these UUIDs are generated internally, so Alt-id is provided as a user-controlled linking target.")
+ def alt_id(): return {}
+
+ @_versioned_property(name="Author",
+ doc="The author of the comment")
+ def author(): return {}
+
+ @_versioned_property(name="In-reply-to",
+ doc="UUID for parent comment or bug")
+ def in_reply_to(): return {}
+
+ @_versioned_property(name="Content-type",
+ doc="Mime type for comment body",
+ default="text/plain",
+ require_save=True)
+ def content_type(): return {}
+
+ @_versioned_property(name="Date",
+ doc="An RFC 2822 timestamp for comment creation")
+ def date(): return {}
+
+ def _get_time(self):
+ if self.date == None:
+ return None
+ return utility.str_to_time(self.date)
+ def _set_time(self, value):
+ self.date = utility.time_to_str(value)
+ time = property(fget=_get_time,
+ fset=_set_time,
+ doc="An integer version of .date")
+
+ def _get_comment_body(self):
+ if self.vcs != None and self.sync_with_disk == True:
+ import vcs
+ binary = not self.content_type.startswith("text/")
+ return self.vcs.get_file_contents(self.get_path("body"), binary=binary)
+ def _set_comment_body(self, old=None, new=None, force=False):
+ if (self.vcs != None and self.sync_with_disk == True) or force==True:
+ assert new != None, "Can't save empty comment"
+ binary = not self.content_type.startswith("text/")
+ self.vcs.set_file_contents(self.get_path("body"), new, binary=binary)
+
+ @Property
+ @change_hook_property(hook=_set_comment_body)
+ @cached_property(generator=_get_comment_body)
+ @local_property("body")
+ @doc_property(doc="The meat of the comment")
+ def body(): return {}
+
+ def _get_vcs(self):
+ if hasattr(self.bug, "vcs"):
+ return self.bug.vcs
+
+ @Property
+ @cached_property(generator=_get_vcs)
+ @local_property("vcs")
+ @doc_property(doc="A revision control system instance.")
+ def vcs(): return {}
+
+ def _extra_strings_check_fn(value):
+ return utility.iterable_full_of_strings(value, \
+ alternative=settings_object.EMPTY)
+ def _extra_strings_change_hook(self, old, new):
+ self.extra_strings.sort() # to make merging easier
+ self._prop_save_settings(old, new)
+ @_versioned_property(name="extra_strings",
+ doc="Space for an array of extra strings. Useful for storing state for functionality implemented purely in becommands/<some_function>.py.",
+ default=[],
+ check_fn=_extra_strings_check_fn,
+ change_hook=_extra_strings_change_hook,
+ mutable=True)
+ def extra_strings(): return {}
+
+ def __init__(self, bug=None, uuid=None, from_disk=False,
+ in_reply_to=None, body=None):
+ """
+ Set from_disk=True to load an old comment.
+ Set from_disk=False to create a new comment.
+
+ The uuid option is required when from_disk==True.
+
+ The in_reply_to and body options are only used if
+ from_disk==False (the default). When from_disk==True, they are
+ loaded from the bug database.
+
+ in_reply_to should be the uuid string of the parent comment.
+ """
+ Tree.__init__(self)
+ settings_object.SavedSettingsObject.__init__(self)
+ self.bug = bug
+ self.uuid = uuid
+ if from_disk == True:
+ self.sync_with_disk = True
+ else:
+ self.sync_with_disk = False
+ if uuid == None:
+ self.uuid = uuid_gen()
+ self.time = int(time.time()) # only save to second precision
+ if self.vcs != None:
+ self.author = self.vcs.get_user_id()
+ self.in_reply_to = in_reply_to
+ self.body = body
+
+ def __cmp__(self, other):
+ return cmp_full(self, other)
+
+ def __str__(self):
+ """
+ >>> comm = Comment(bug=None, body="Some insightful remarks")
+ >>> comm.uuid = "com-1"
+ >>> comm.date = "Thu, 20 Nov 2008 15:55:11 +0000"
+ >>> comm.author = "Jane Doe <jdoe@example.com>"
+ >>> print comm
+ --------- Comment ---------
+ Name: com-1
+ From: Jane Doe <jdoe@example.com>
+ Date: Thu, 20 Nov 2008 15:55:11 +0000
+ <BLANKLINE>
+ Some insightful remarks
+ """
+ return self.string()
+
+ def traverse(self, *args, **kwargs):
+ """Avoid working with the possible dummy root comment"""
+ for comment in Tree.traverse(self, *args, **kwargs):
+ if comment.uuid == INVALID_UUID:
+ continue
+ yield comment
+
+ # serializing methods
+
+ def _setting_attr_string(self, setting):
+ value = getattr(self, setting)
+ if value == None:
+ return ""
+ return str(value)
+
+ def xml(self, indent=0, shortname=None):
+ """
+ >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
+ >>> comm.uuid = "0123"
+ >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
+ >>> print comm.xml(indent=2, shortname="com-1")
+ <comment>
+ <uuid>0123</uuid>
+ <short-name>com-1</short-name>
+ <author></author>
+ <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
+ <content-type>text/plain</content-type>
+ <body>Some
+ insightful
+ remarks</body>
+ </comment>
+ """
+ if shortname == None:
+ shortname = self.uuid
+ if self.content_type.startswith("text/"):
+ body = (self.body or "").rstrip('\n')
+ else:
+ maintype,subtype = self.content_type.split('/',1)
+ msg = email.mime.base.MIMEBase(maintype, subtype)
+ msg.set_payload(self.body or "")
+ email.encoders.encode_base64(msg)
+ body = base64.encodestring(self.body or "")
+ info = [("uuid", self.uuid),
+ ("alt-id", self.alt_id),
+ ("short-name", shortname),
+ ("in-reply-to", self.in_reply_to),
+ ("author", self._setting_attr_string("author")),
+ ("date", self.date),
+ ("content-type", self.content_type),
+ ("body", body)]
+ lines = ["<comment>"]
+ for (k,v) in info:
+ if v != None:
+ lines.append(' <%s>%s</%s>' % (k,xml.sax.saxutils.escape(v),k))
+ lines.append("</comment>")
+ istring = ' '*indent
+ sep = '\n' + istring
+ return istring + sep.join(lines).rstrip('\n')
+
+ def from_xml(self, xml_string, verbose=True):
+ """
+ Note: If alt-id is not given, translates any <uuid> fields to
+ <alt-id> fields.
+ >>> commA = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
+ >>> commA.uuid = "0123"
+ >>> commA.date = "Thu, 01 Jan 1970 00:00:00 +0000"
+ >>> xml = commA.xml(shortname="com-1")
+ >>> commB = Comment()
+ >>> commB.from_xml(xml)
+ >>> attrs=['uuid','alt_id','in_reply_to','author','date','content_type','body']
+ >>> for attr in attrs: # doctest: +ELLIPSIS
+ ... if getattr(commB, attr) != getattr(commA, attr):
+ ... estr = "Mismatch on %s: '%s' should be '%s'"
+ ... args = (attr, getattr(commB, attr), getattr(commA, attr))
+ ... print estr % args
+ Mismatch on uuid: '...' should be '0123'
+ Mismatch on alt_id: '0123' should be 'None'
+ >>> print commB.alt_id
+ 0123
+ >>> commA.author
+ >>> commB.author
+ """
+ if type(xml_string) == types.UnicodeType:
+ xml_string = xml_string.strip().encode("unicode_escape")
+ comment = ElementTree.XML(xml_string)
+ if comment.tag != "comment":
+ raise InvalidXML(comment, "root element must be <comment>")
+ tags=['uuid','alt-id','in-reply-to','author','date','content-type','body']
+ uuid = None
+ body = None
+ for child in comment.getchildren():
+ if child.tag == "short-name":
+ pass
+ elif child.tag in tags:
+ if child.text == None or len(child.text) == 0:
+ text = settings_object.EMPTY
+ else:
+ text = xml.sax.saxutils.unescape(child.text)
+ text = unicode(text).decode("unicode_escape").strip()
+ if child.tag == "uuid":
+ uuid = text
+ continue # don't set the bug's uuid tag.
+ if child.tag == "body":
+ body = text
+ continue # don't set the bug's body yet.
+ else:
+ attr_name = child.tag.replace('-','_')
+ setattr(self, attr_name, text)
+ elif verbose == True:
+ print >> sys.stderr, "Ignoring unknown tag %s in %s" \
+ % (child.tag, comment.tag)
+ if self.alt_id == None and uuid not in [None, self.uuid]:
+ self.alt_id = uuid
+ if body != None:
+ if self.content_type.startswith("text/"):
+ self.body = body+"\n" # restore trailing newline
+ else:
+ self.body = base64.decodestring(body)
+
+ def string(self, indent=0, shortname=None):
+ """
+ >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
+ >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
+ >>> print comm.string(indent=2, shortname="com-1")
+ --------- Comment ---------
+ Name: com-1
+ From:
+ Date: Thu, 01 Jan 1970 00:00:00 +0000
+ <BLANKLINE>
+ Some
+ insightful
+ remarks
+ """
+ if shortname == None:
+ shortname = self.uuid
+ lines = []
+ lines.append("--------- Comment ---------")
+ lines.append("Name: %s" % shortname)
+ lines.append("From: %s" % (self._setting_attr_string("author")))
+ lines.append("Date: %s" % self.date)
+ lines.append("")
+ if self.content_type.startswith("text/"):
+ lines.extend((self.body or "").splitlines())
+ else:
+ lines.append("Content type %s not printable. Try XML output instead" % self.content_type)
+
+ istring = ' '*indent
+ sep = '\n' + istring
+ return istring + sep.join(lines).rstrip('\n')
+
+ def string_thread(self, string_method_name="string", name_map={},
+ indent=0, flatten=True,
+ auto_name_map=False, bug_shortname=None):
+ """
+ Return a string displaying a thread of comments.
+ bug_shortname is only used if auto_name_map == True.
+
+ string_method_name (defaults to "string") is the name of the
+ Comment method used to generate the output string for each
+ Comment in the thread. The method must take the arguments
+ indent and shortname.
+
+ SIDE-EFFECT: if auto_name_map==True, calls comment_shortnames()
+ which will sort the tree by comment.time. Avoid by calling
+ name_map = {}
+ for shortname,comment in comm.comment_shortnames(bug_shortname):
+ name_map[comment.uuid] = shortname
+ comm.sort(key=lambda c : c.author) # your sort
+ comm.string_thread(name_map=name_map)
+
+ >>> a = Comment(bug=None, uuid="a", body="Insightful remarks")
+ >>> a.time = utility.str_to_time("Thu, 20 Nov 2008 01:00:00 +0000")
+ >>> b = a.new_reply("Critique original comment")
+ >>> b.uuid = "b"
+ >>> b.time = utility.str_to_time("Thu, 20 Nov 2008 02:00:00 +0000")
+ >>> c = b.new_reply("Begin flamewar :p")
+ >>> c.uuid = "c"
+ >>> c.time = utility.str_to_time("Thu, 20 Nov 2008 03:00:00 +0000")
+ >>> d = a.new_reply("Useful examples")
+ >>> d.uuid = "d"
+ >>> d.time = utility.str_to_time("Thu, 20 Nov 2008 04:00:00 +0000")
+ >>> a.sort(key=lambda comm : comm.time)
+ >>> print a.string_thread(flatten=True)
+ --------- Comment ---------
+ Name: a
+ From:
+ Date: Thu, 20 Nov 2008 01:00:00 +0000
+ <BLANKLINE>
+ Insightful remarks
+ --------- Comment ---------
+ Name: b
+ From:
+ Date: Thu, 20 Nov 2008 02:00:00 +0000
+ <BLANKLINE>
+ Critique original comment
+ --------- Comment ---------
+ Name: c
+ From:
+ Date: Thu, 20 Nov 2008 03:00:00 +0000
+ <BLANKLINE>
+ Begin flamewar :p
+ --------- Comment ---------
+ Name: d
+ From:
+ Date: Thu, 20 Nov 2008 04:00:00 +0000
+ <BLANKLINE>
+ Useful examples
+ >>> print a.string_thread(auto_name_map=True, bug_shortname="bug-1")
+ --------- Comment ---------
+ Name: bug-1:1
+ From:
+ Date: Thu, 20 Nov 2008 01:00:00 +0000
+ <BLANKLINE>
+ Insightful remarks
+ --------- Comment ---------
+ Name: bug-1:2
+ From:
+ Date: Thu, 20 Nov 2008 02:00:00 +0000
+ <BLANKLINE>
+ Critique original comment
+ --------- Comment ---------
+ Name: bug-1:3
+ From:
+ Date: Thu, 20 Nov 2008 03:00:00 +0000
+ <BLANKLINE>
+ Begin flamewar :p
+ --------- Comment ---------
+ Name: bug-1:4
+ From:
+ Date: Thu, 20 Nov 2008 04:00:00 +0000
+ <BLANKLINE>
+ Useful examples
+ """
+ if auto_name_map == True:
+ name_map = {}
+ for shortname,comment in self.comment_shortnames(bug_shortname):
+ name_map[comment.uuid] = shortname
+ stringlist = []
+ for depth,comment in self.thread(flatten=flatten):
+ ind = 2*depth+indent
+ if comment.uuid in name_map:
+ sname = name_map[comment.uuid]
+ else:
+ sname = None
+ string_fn = getattr(comment, string_method_name)
+ stringlist.append(string_fn(indent=ind, shortname=sname))
+ return '\n'.join(stringlist)
+
+ def xml_thread(self, name_map={}, indent=0,
+ auto_name_map=False, bug_shortname=None):
+ return self.string_thread(string_method_name="xml", name_map=name_map,
+ indent=indent, auto_name_map=auto_name_map,
+ bug_shortname=bug_shortname)
+
+ # methods for saving/loading/acessing settings and properties.
+
+ def get_path(self, *args):
+ dir = os.path.join(self.bug.get_path("comments"), self.uuid)
+ if len(args) == 0:
+ return dir
+ assert args[0] in ["values", "body"], str(args)
+ return os.path.join(dir, *args)
+
+ def set_sync_with_disk(self, value):
+ self.sync_with_disk = value
+
+ def load_settings(self):
+ if self.sync_with_disk == False:
+ raise DiskAccessRequired("load settings")
+ self.settings = mapfile.map_load(self.vcs, self.get_path("values"))
+ self._setup_saved_settings()
+
+ def save_settings(self):
+ if self.sync_with_disk == False:
+ raise DiskAccessRequired("save settings")
+ self.vcs.mkdir(self.get_path())
+ path = self.get_path("values")
+ mapfile.map_save(self.vcs, path, self._get_saved_settings())
+
+ def save(self):
+ """
+ Save any loaded contents to disk.
+
+ However, if self.sync_with_disk = True, then any changes are
+ automatically written to disk as soon as they happen, so
+ calling this method will just waste time (unless something
+ else has been messing with your on-disk files).
+ """
+ sync_with_disk = self.sync_with_disk
+ if sync_with_disk == False:
+ self.set_sync_with_disk(True)
+ assert self.body != None, "Can't save blank comment"
+ self.save_settings()
+ self._set_comment_body(new=self.body, force=True)
+ if sync_with_disk == False:
+ self.set_sync_with_disk(False)
+
+ def remove(self):
+ if self.sync_with_disk == False and self.uuid != INVALID_UUID:
+ raise DiskAccessRequired("remove")
+ for comment in self.traverse():
+ path = comment.get_path()
+ self.vcs.recursive_remove(path)
+
+ def add_reply(self, reply, allow_time_inversion=False):
+ if self.uuid != INVALID_UUID:
+ reply.in_reply_to = self.uuid
+ self.append(reply)
+
+ def new_reply(self, body=None):
+ """
+ >>> comm = Comment(bug=None, body="Some insightful remarks")
+ >>> repA = comm.new_reply("Critique original comment")
+ >>> repB = repA.new_reply("Begin flamewar :p")
+ >>> repB.in_reply_to == repA.uuid
+ True
+ """
+ reply = Comment(self.bug, body=body)
+ if self.bug != None:
+ reply.set_sync_with_disk(self.bug.sync_with_disk)
+ if reply.sync_with_disk == True:
+ reply.save()
+ self.add_reply(reply)
+ return reply
+
+ def comment_shortnames(self, bug_shortname=None):
+ """
+ Iterate through (id, comment) pairs, in time order.
+ (This is a user-friendly id, not the comment uuid).
+
+ SIDE-EFFECT : will sort the comment tree by comment.time
+
+ >>> a = Comment(bug=None, uuid="a")
+ >>> b = a.new_reply()
+ >>> b.uuid = "b"
+ >>> c = b.new_reply()
+ >>> c.uuid = "c"
+ >>> d = a.new_reply()
+ >>> d.uuid = "d"
+ >>> for id,name in a.comment_shortnames("bug-1"):
+ ... print id, name.uuid
+ bug-1:1 a
+ bug-1:2 b
+ bug-1:3 c
+ bug-1:4 d
+ """
+ if bug_shortname == None:
+ bug_shortname = ""
+ self.sort(key=lambda comm : comm.time)
+ for num,comment in enumerate(self.traverse()):
+ yield ("%s:%d" % (bug_shortname, num+1), comment)
+
+ def comment_from_shortname(self, comment_shortname, *args, **kwargs):
+ """
+ Use a comment shortname to look up a comment.
+ >>> a = Comment(bug=None, uuid="a")
+ >>> b = a.new_reply()
+ >>> b.uuid = "b"
+ >>> c = b.new_reply()
+ >>> c.uuid = "c"
+ >>> d = a.new_reply()
+ >>> d.uuid = "d"
+ >>> comm = a.comment_from_shortname("bug-1:3", bug_shortname="bug-1")
+ >>> id(comm) == id(c)
+ True
+ """
+ for cur_name, comment in self.comment_shortnames(*args, **kwargs):
+ if comment_shortname == cur_name:
+ return comment
+ raise InvalidShortname(comment_shortname,
+ list(self.comment_shortnames(*args, **kwargs)))
+
+ def comment_from_uuid(self, uuid):
+ """
+ Use a comment shortname to look up a comment.
+ >>> a = Comment(bug=None, uuid="a")
+ >>> b = a.new_reply()
+ >>> b.uuid = "b"
+ >>> c = b.new_reply()
+ >>> c.uuid = "c"
+ >>> d = a.new_reply()
+ >>> d.uuid = "d"
+ >>> comm = a.comment_from_uuid("d")
+ >>> id(comm) == id(d)
+ True
+ """
+ for comment in self.traverse():
+ if comment.uuid == uuid:
+ return comment
+ raise KeyError(uuid)
+
+def cmp_attr(comment_1, comment_2, attr, invert=False):
+ """
+ Compare a general attribute between two comments using the conventional
+ comparison rule for that attribute type. If invert == True, sort
+ *against* that convention.
+ >>> attr="author"
+ >>> commentA = Comment()
+ >>> commentB = Comment()
+ >>> commentA.author = "John Doe"
+ >>> commentB.author = "Jane Doe"
+ >>> cmp_attr(commentA, commentB, attr) > 0
+ True
+ >>> cmp_attr(commentA, commentB, attr, invert=True) < 0
+ True
+ >>> commentB.author = "John Doe"
+ >>> cmp_attr(commentA, commentB, attr) == 0
+ True
+ """
+ if not hasattr(comment_2, attr) :
+ return 1
+ val_1 = getattr(comment_1, attr)
+ val_2 = getattr(comment_2, attr)
+ if val_1 == None: val_1 = None
+ if val_2 == None: val_2 = None
+
+ if invert == True :
+ return -cmp(val_1, val_2)
+ else :
+ return cmp(val_1, val_2)
+
+# alphabetical rankings (a < z)
+cmp_uuid = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "uuid")
+cmp_author = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "author")
+cmp_in_reply_to = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "in_reply_to")
+cmp_content_type = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "content_type")
+cmp_body = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "body")
+# chronological rankings (newer < older)
+cmp_time = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "time", invert=True)
+
+DEFAULT_CMP_FULL_CMP_LIST = \
+ (cmp_time, cmp_author, cmp_content_type, cmp_body, cmp_in_reply_to,
+ cmp_uuid)
+
+class CommentCompoundComparator (object):
+ def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST):
+ self.cmp_list = cmp_list
+ def __call__(self, comment_1, comment_2):
+ for comparison in self.cmp_list :
+ val = comparison(comment_1, comment_2)
+ if val != 0 :
+ return val
+ return 0
+
+cmp_full = CommentCompoundComparator()
+
+suite = doctest.DocTestSuite()
diff --git a/interfaces/email/interactive/libbe/config.py b/interfaces/email/interactive/libbe/config.py
new file mode 100644
index 0000000..fb5a028
--- /dev/null
+++ b/interfaces/email/interactive/libbe/config.py
@@ -0,0 +1,89 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+# W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""
+Create, save, and load the per-user config file at path().
+"""
+
+import ConfigParser
+import codecs
+import locale
+import os.path
+import sys
+import doctest
+
+
+default_encoding = sys.getfilesystemencoding() or locale.getpreferredencoding()
+
+def path():
+ """Return the path to the per-user config file"""
+ return os.path.expanduser("~/.bugs_everywhere")
+
+def set_val(name, value, section="DEFAULT", encoding=None):
+ """Set a value in the per-user config file
+
+ :param name: The name of the value to set
+ :param value: The new value to set (or None to delete the value)
+ :param section: The section to store the name/value in
+ """
+ if encoding == None:
+ encoding = default_encoding
+ config = ConfigParser.ConfigParser()
+ if os.path.exists(path()) == False: # touch file or config
+ open(path(), "w").close() # read chokes on missing file
+ f = codecs.open(path(), "r", encoding)
+ config.readfp(f, path())
+ f.close()
+ if value is not None:
+ config.set(section, name, value)
+ else:
+ config.remove_option(section, name)
+ f = codecs.open(path(), "w", encoding)
+ config.write(f)
+ f.close()
+
+def get_val(name, section="DEFAULT", default=None, encoding=None):
+ """
+ Get a value from the per-user config file
+
+ :param name: The name of the value to get
+ :section: The section that the name is in
+ :return: The value, or None
+ >>> get_val("junk") is None
+ True
+ >>> set_val("junk", "random")
+ >>> get_val("junk")
+ u'random'
+ >>> set_val("junk", None)
+ >>> get_val("junk") is None
+ True
+ """
+ if os.path.exists(path()):
+ if encoding == None:
+ encoding = default_encoding
+ config = ConfigParser.ConfigParser()
+ f = codecs.open(path(), "r", encoding)
+ config.readfp(f, path())
+ f.close()
+ try:
+ return config.get(section, name)
+ except ConfigParser.NoOptionError:
+ return default
+ else:
+ return default
+
+suite = doctest.DocTestSuite()
diff --git a/interfaces/email/interactive/libbe/darcs.py b/interfaces/email/interactive/libbe/darcs.py
new file mode 100644
index 0000000..16005f2
--- /dev/null
+++ b/interfaces/email/interactive/libbe/darcs.py
@@ -0,0 +1,184 @@
+# Copyright (C) 2009 W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""
+Darcs backend.
+"""
+
+import codecs
+import os
+import re
+import sys
+try: # import core module, Python >= 2.5
+ from xml.etree import ElementTree
+except ImportError: # look for non-core module
+ from elementtree import ElementTree
+from xml.sax.saxutils import unescape
+import doctest
+import unittest
+
+import vcs
+
+
+def new():
+ return Darcs()
+
+class Darcs(vcs.VCS):
+ name="darcs"
+ client="darcs"
+ versioned=True
+ def _vcs_help(self):
+ status,output,error = self._u_invoke_client("--help")
+ return output
+ def _vcs_detect(self, path):
+ if self._u_search_parent_directories(path, "_darcs") != None :
+ return True
+ return False
+ def _vcs_root(self, path):
+ """Find the root of the deepest repository containing path."""
+ # Assume that nothing funny is going on; in particular, that we aren't
+ # dealing with a bare repo.
+ if os.path.isdir(path) != True:
+ path = os.path.dirname(path)
+ darcs_dir = self._u_search_parent_directories(path, "_darcs")
+ if darcs_dir == None:
+ return None
+ return os.path.dirname(darcs_dir)
+ def _vcs_init(self, path):
+ self._u_invoke_client("init", directory=path)
+ def _vcs_get_user_id(self):
+ # following http://darcs.net/manual/node4.html#SECTION00410030000000000000
+ # as of June 29th, 2009
+ if self.rootdir == None:
+ return None
+ darcs_dir = os.path.join(self.rootdir, "_darcs")
+ if darcs_dir != None:
+ for pref_file in ["author", "email"]:
+ pref_path = os.path.join(darcs_dir, "prefs", pref_file)
+ if os.path.exists(pref_path):
+ return self.get_file_contents(pref_path)
+ for env_variable in ["DARCS_EMAIL", "EMAIL"]:
+ if env_variable in os.environ:
+ return os.environ[env_variable]
+ return None
+ def _vcs_set_user_id(self, value):
+ if self.rootdir == None:
+ self.root(".")
+ if self.rootdir == None:
+ raise vcs.SettingIDnotSupported
+ author_path = os.path.join(self.rootdir, "_darcs", "prefs", "author")
+ f = codecs.open(author_path, "w", self.encoding)
+ f.write(value)
+ f.close()
+ def _vcs_add(self, path):
+ if os.path.isdir(path):
+ return
+ self._u_invoke_client("add", path)
+ def _vcs_remove(self, path):
+ if not os.path.isdir(self._u_abspath(path)):
+ os.remove(os.path.join(self.rootdir, path)) # darcs notices removal
+ def _vcs_update(self, path):
+ pass # darcs notices changes
+ def _vcs_get_file_contents(self, path, revision=None, binary=False):
+ if revision == None:
+ return vcs.VCS._vcs_get_file_contents(self, path, revision,
+ binary=binary)
+ else:
+ try:
+ return self._u_invoke_client("show", "contents", "--patch", revision, path)
+ except vcs.CommandError:
+ # Darcs versions < 2.0.0pre2 lack the "show contents" command
+
+ status,output,error = self._u_invoke_client("diff", "--unified",
+ "--from-patch",
+ revision, path)
+ major_patch = output
+ status,output,error = self._u_invoke_client("diff", "--unified",
+ "--patch",
+ revision, path)
+ target_patch = output
+
+ # "--output -" to be supported in GNU patch > 2.5.9
+ # but that hasn't been released as of June 30th, 2009.
+
+ # Rewrite path to status before the patch we want
+ args=["patch", "--reverse", path]
+ status,output,error = self._u_invoke(args, stdin=major_patch)
+ # Now apply the patch we want
+ args=["patch", path]
+ status,output,error = self._u_invoke(args, stdin=target_patch)
+
+ if os.path.exists(os.path.join(self.rootdir, path)) == True:
+ contents = vcs.VCS._vcs_get_file_contents(self, path,
+ binary=binary)
+ else:
+ contents = ""
+
+ # Now restore path to it's current incarnation
+ args=["patch", "--reverse", path]
+ status,output,error = self._u_invoke(args, stdin=target_patch)
+ args=["patch", path]
+ status,output,error = self._u_invoke(args, stdin=major_patch)
+ current_contents = vcs.VCS._vcs_get_file_contents(self, path,
+ binary=binary)
+ return contents
+ def _vcs_duplicate_repo(self, directory, revision=None):
+ if revision==None:
+ vcs.VCS._vcs_duplicate_repo(self, directory, revision)
+ else:
+ self._u_invoke_client("put", "--to-patch", revision, directory)
+ def _vcs_commit(self, commitfile, allow_empty=False):
+ id = self.get_user_id()
+ if '@' not in id:
+ id = "%s <%s@invalid.com>" % (id, id)
+ args = ['record', '--all', '--author', id, '--logfile', commitfile]
+ status,output,error = self._u_invoke_client(*args)
+ empty_strings = ["No changes!"]
+ if self._u_any_in_string(empty_strings, output) == True:
+ if allow_empty == False:
+ raise vcs.EmptyCommit()
+ # note that darcs does _not_ make an empty revision.
+ # this returns the last non-empty revision id...
+ revision = self._vcs_revision_id(-1)
+ else:
+ revline = re.compile("Finished recording patch '(.*)'")
+ match = revline.search(output)
+ assert match != None, output+error
+ assert len(match.groups()) == 1
+ revision = match.groups()[0]
+ return revision
+ def _vcs_revision_id(self, index):
+ status,output,error = self._u_invoke_client("changes", "--xml")
+ revisions = []
+ xml_str = output.encode("unicode_escape").replace(r"\n", "\n")
+ element = ElementTree.XML(xml_str)
+ assert element.tag == "changelog", element.tag
+ for patch in element.getchildren():
+ assert patch.tag == "patch", patch.tag
+ for child in patch.getchildren():
+ if child.tag == "name":
+ text = unescape(unicode(child.text).decode("unicode_escape").strip())
+ revisions.append(text)
+ revisions.reverse()
+ try:
+ return revisions[index]
+ except IndexError:
+ return None
+
+vcs.make_vcs_testcase_subclasses(Darcs, sys.modules[__name__])
+
+unitsuite = unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
+suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
diff --git a/interfaces/email/interactive/libbe/diff.py b/interfaces/email/interactive/libbe/diff.py
new file mode 100644
index 0000000..9253a23
--- /dev/null
+++ b/interfaces/email/interactive/libbe/diff.py
@@ -0,0 +1,419 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+# W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""Compare two bug trees."""
+
+import difflib
+import doctest
+
+from libbe import bugdir, bug, settings_object, tree
+from libbe.utility import time_to_str
+
+
+class DiffTree (tree.Tree):
+ """
+ A tree holding difference data for easy report generation.
+ >>> bugdir = DiffTree("bugdir")
+ >>> bdsettings = DiffTree("settings", data="target: None -> 1.0")
+ >>> bugdir.append(bdsettings)
+ >>> bugs = DiffTree("bugs", "bug-count: 5 -> 6")
+ >>> bugdir.append(bugs)
+ >>> new = DiffTree("new", "new bugs: ABC, DEF")
+ >>> bugs.append(new)
+ >>> rem = DiffTree("rem", "removed bugs: RST, UVW")
+ >>> bugs.append(rem)
+ >>> print bugdir.report_string()
+ target: None -> 1.0
+ bug-count: 5 -> 6
+ new bugs: ABC, DEF
+ removed bugs: RST, UVW
+ >>> print "\\n".join(bugdir.paths())
+ bugdir
+ bugdir/settings
+ bugdir/bugs
+ bugdir/bugs/new
+ bugdir/bugs/rem
+ >>> bugdir.child_by_path("/") == bugdir
+ True
+ >>> bugdir.child_by_path("/bugs") == bugs
+ True
+ >>> bugdir.child_by_path("/bugs/rem") == rem
+ True
+ >>> bugdir.child_by_path("bugdir") == bugdir
+ True
+ >>> bugdir.child_by_path("bugdir/") == bugdir
+ True
+ >>> bugdir.child_by_path("bugdir/bugs") == bugs
+ True
+ >>> bugdir.child_by_path("/bugs").masked = True
+ >>> print bugdir.report_string()
+ target: None -> 1.0
+ """
+ def __init__(self, name, data=None, data_part_fn=str,
+ requires_children=False, masked=False):
+ tree.Tree.__init__(self)
+ self.name = name
+ self.data = data
+ self.data_part_fn = data_part_fn
+ self.requires_children = requires_children
+ self.masked = masked
+ def paths(self, parent_path=None):
+ paths = []
+ if parent_path == None:
+ path = self.name
+ else:
+ path = "%s/%s" % (parent_path, self.name)
+ paths.append(path)
+ for child in self:
+ paths.extend(child.paths(path))
+ return paths
+ def child_by_path(self, path):
+ if hasattr(path, "split"): # convert string path to a list of names
+ names = path.split("/")
+ if names[0] == "":
+ names[0] = self.name # replace root with self
+ if len(names) > 1 and names[-1] == "":
+ names = names[:-1] # strip empty tail
+ else: # it was already an array
+ names = path
+ assert len(names) > 0, path
+ if names[0] == self.name:
+ if len(names) == 1:
+ return self
+ for child in self:
+ if names[1] == child.name:
+ return child.child_by_path(names[1:])
+ if len(names) == 1:
+ raise KeyError, "%s doesn't match '%s'" % (names, self.name)
+ raise KeyError, "%s points to child not in %s" % (names, [c.name for c in self])
+ def report_string(self):
+ return "\n".join(self.report())
+ def report(self, root=None, parent=None, depth=0):
+ if root == None:
+ root = self.make_root()
+ if self.masked == True:
+ return None
+ data_part = self.data_part(depth)
+ if self.requires_children == True and len(self) == 0:
+ pass
+ else:
+ self.join(root, parent, data_part)
+ if data_part != None:
+ depth += 1
+ for child in self:
+ child.report(root, self, depth)
+ return root
+ def make_root(self):
+ return []
+ def join(self, root, parent, data_part):
+ if data_part != None:
+ root.append(data_part)
+ def data_part(self, depth, indent=True):
+ if self.data == None:
+ return None
+ if hasattr(self, "_cached_data_part"):
+ return self._cached_data_part
+ data_part = self.data_part_fn(self.data)
+ if indent == True:
+ data_part_lines = data_part.splitlines()
+ indent = " "*(depth)
+ line_sep = "\n"+indent
+ data_part = indent+line_sep.join(data_part_lines)
+ self._cached_data_part = data_part
+ return data_part
+
+class Diff (object):
+ """
+ Difference tree generator for BugDirs.
+ >>> import copy
+ >>> bd = bugdir.SimpleBugDir(sync_with_disk=False)
+ >>> bd.user_id = "John Doe <j@doe.com>"
+ >>> bd_new = copy.deepcopy(bd)
+ >>> bd_new.target = "1.0"
+ >>> a = bd_new.bug_from_uuid("a")
+ >>> rep = a.comment_root.new_reply("I'm closing this bug")
+ >>> rep.uuid = "acom"
+ >>> rep.date = "Thu, 01 Jan 1970 00:00:00 +0000"
+ >>> a.status = "closed"
+ >>> b = bd_new.bug_from_uuid("b")
+ >>> bd_new.remove_bug(b)
+ >>> c = bd_new.new_bug("c", "Bug C")
+ >>> d = Diff(bd, bd_new)
+ >>> r = d.report_tree()
+ >>> print "\\n".join(r.paths())
+ bugdir
+ bugdir/settings
+ bugdir/bugs
+ bugdir/bugs/new
+ bugdir/bugs/new/c
+ bugdir/bugs/rem
+ bugdir/bugs/rem/b
+ bugdir/bugs/mod
+ bugdir/bugs/mod/a
+ bugdir/bugs/mod/a/settings
+ bugdir/bugs/mod/a/comments
+ bugdir/bugs/mod/a/comments/new
+ bugdir/bugs/mod/a/comments/new/acom
+ bugdir/bugs/mod/a/comments/rem
+ bugdir/bugs/mod/a/comments/mod
+ >>> print r.report_string()
+ Changed bug directory settings:
+ target: None -> 1.0
+ New bugs:
+ c:om: Bug C
+ Removed bugs:
+ b:cm: Bug B
+ Modified bugs:
+ a:cm: Bug A
+ Changed bug settings:
+ status: open -> closed
+ New comments:
+ from John Doe <j@doe.com> on Thu, 01 Jan 1970 00:00:00 +0000
+ I'm closing this bug...
+ >>> bd.cleanup()
+ """
+ def __init__(self, old_bugdir, new_bugdir):
+ self.old_bugdir = old_bugdir
+ self.new_bugdir = new_bugdir
+
+ # data assembly methods
+
+ def _changed_bugs(self):
+ """
+ Search for differences in all bugs between .old_bugdir and
+ .new_bugdir. Returns
+ (added_bugs, modified_bugs, removed_bugs)
+ where added_bugs and removed_bugs are lists of added and
+ removed bugs respectively. modified_bugs is a list of
+ (old_bug,new_bug) pairs.
+ """
+ if hasattr(self, "__changed_bugs"):
+ return self.__changed_bugs
+ added = []
+ removed = []
+ modified = []
+ for uuid in self.new_bugdir.list_uuids():
+ new_bug = self.new_bugdir.bug_from_uuid(uuid)
+ try:
+ old_bug = self.old_bugdir.bug_from_uuid(uuid)
+ except KeyError:
+ added.append(new_bug)
+ else:
+ if old_bug.sync_with_disk == True:
+ old_bug.load_comments()
+ if new_bug.sync_with_disk == True:
+ new_bug.load_comments()
+ if old_bug != new_bug:
+ modified.append((old_bug, new_bug))
+ for uuid in self.old_bugdir.list_uuids():
+ if not self.new_bugdir.has_bug(uuid):
+ old_bug = self.old_bugdir.bug_from_uuid(uuid)
+ removed.append(old_bug)
+ added.sort()
+ removed.sort()
+ modified.sort(self._bug_modified_cmp)
+ self.__changed_bugs = (added, modified, removed)
+ return self.__changed_bugs
+ def _bug_modified_cmp(self, left, right):
+ return cmp(left[1], right[1])
+ def _changed_comments(self, old, new):
+ """
+ Search for differences in all loaded comments between the bugs
+ old and new. Returns
+ (added_comments, modified_comments, removed_comments)
+ analogous to ._changed_bugs.
+ """
+ if hasattr(self, "__changed_comments"):
+ if new.uuid in self.__changed_comments:
+ return self.__changed_comments[new.uuid]
+ else:
+ self.__changed_comments = {}
+ added = []
+ removed = []
+ modified = []
+ old.comment_root.sort(key=lambda comm : comm.time)
+ new.comment_root.sort(key=lambda comm : comm.time)
+ old_comment_ids = [c.uuid for c in old.comments()]
+ new_comment_ids = [c.uuid for c in new.comments()]
+ for uuid in new_comment_ids:
+ new_comment = new.comment_from_uuid(uuid)
+ try:
+ old_comment = old.comment_from_uuid(uuid)
+ except KeyError:
+ added.append(new_comment)
+ else:
+ if old_comment != new_comment:
+ modified.append((old_comment, new_comment))
+ for uuid in old_comment_ids:
+ if uuid not in new_comment_ids:
+ new_comment = new.comment_from_uuid(uuid)
+ removed.append(new_comment)
+ self.__changed_comments[new.uuid] = (added, modified, removed)
+ return self.__changed_comments[new.uuid]
+ def _attribute_changes(self, old, new, attributes):
+ """
+ Take two objects old and new, and compare the value of *.attr
+ for attr in the list attribute names. Returns a list of
+ (attr_name, old_value, new_value)
+ tuples.
+ """
+ change_list = []
+ for attr in attributes:
+ old_value = getattr(old, attr)
+ new_value = getattr(new, attr)
+ if old_value != new_value:
+ change_list.append((attr, old_value, new_value))
+ if len(change_list) >= 0:
+ return change_list
+ return None
+ def _settings_properties_attribute_changes(self, old, new,
+ hidden_properties=[]):
+ properties = sorted(new.settings_properties)
+ for p in hidden_properties:
+ properties.remove(p)
+ attributes = [settings_object.setting_name_to_attr_name(None, p)
+ for p in properties]
+ return self._attribute_changes(old, new, attributes)
+ def _bugdir_attribute_changes(self):
+ return self._settings_properties_attribute_changes( \
+ self.old_bugdir, self.new_bugdir,
+ ["vcs_name"]) # tweaked by bugdir.duplicate_bugdir
+ def _bug_attribute_changes(self, old, new):
+ return self._settings_properties_attribute_changes(old, new)
+ def _comment_attribute_changes(self, old, new):
+ return self._settings_properties_attribute_changes(old, new)
+
+ # report generation methods
+
+ def report_tree(self, diff_tree=DiffTree):
+ """
+ Pretty bare to make it easy to adjust to specific cases. You
+ can pass in a DiffTree subclass via diff_tree to override the
+ default report assembly process.
+ """
+ if hasattr(self, "__report_tree"):
+ return self.__report_tree
+ bugdir_settings = sorted(self.new_bugdir.settings_properties)
+ bugdir_settings.remove("vcs_name") # tweaked by bugdir.duplicate_bugdir
+ root = diff_tree("bugdir")
+ bugdir_attribute_changes = self._bugdir_attribute_changes()
+ if len(bugdir_attribute_changes) > 0:
+ bugdir = diff_tree("settings", bugdir_attribute_changes,
+ self.bugdir_attribute_change_string)
+ root.append(bugdir)
+ bug_root = diff_tree("bugs")
+ root.append(bug_root)
+ add,mod,rem = self._changed_bugs()
+ bnew = diff_tree("new", "New bugs:", requires_children=True)
+ bug_root.append(bnew)
+ for bug in add:
+ b = diff_tree(bug.uuid, bug, self.bug_add_string)
+ bnew.append(b)
+ brem = diff_tree("rem", "Removed bugs:", requires_children=True)
+ bug_root.append(brem)
+ for bug in rem:
+ b = diff_tree(bug.uuid, bug, self.bug_rem_string)
+ brem.append(b)
+ bmod = diff_tree("mod", "Modified bugs:", requires_children=True)
+ bug_root.append(bmod)
+ for old,new in mod:
+ b = diff_tree(new.uuid, (old,new), self.bug_mod_string)
+ bmod.append(b)
+ bug_attribute_changes = self._bug_attribute_changes(old, new)
+ if len(bug_attribute_changes) > 0:
+ bset = diff_tree("settings", bug_attribute_changes,
+ self.bug_attribute_change_string)
+ b.append(bset)
+ if old.summary != new.summary:
+ data = (old.summary, new.summary)
+ bsum = diff_tree("summary", data, self.bug_summary_change_string)
+ b.append(bsum)
+ cr = diff_tree("comments")
+ b.append(cr)
+ a,m,d = self._changed_comments(old, new)
+ cnew = diff_tree("new", "New comments:", requires_children=True)
+ for comment in a:
+ c = diff_tree(comment.uuid, comment, self.comment_add_string)
+ cnew.append(c)
+ crem = diff_tree("rem", "Removed comments:",requires_children=True)
+ for comment in d:
+ c = diff_tree(comment.uuid, comment, self.comment_rem_string)
+ crem.append(c)
+ cmod = diff_tree("mod","Modified comments:",requires_children=True)
+ for o,n in m:
+ c = diff_tree(n.uuid, (o,n), self.comment_mod_string)
+ cmod.append(c)
+ comm_attribute_changes = self._comment_attribute_changes(o, n)
+ if len(comm_attribute_changes) > 0:
+ cset = diff_tree("settings", comm_attribute_changes,
+ self.comment_attribute_change_string)
+ if o.body != n.body:
+ data = (o.body, n.body)
+ cbody = diff_tree("cbody", data,
+ self.comment_body_change_string)
+ c.append(cbody)
+ cr.extend([cnew, crem, cmod])
+ self.__report_tree = root
+ return self.__report_tree
+
+ # change data -> string methods.
+ # Feel free to play with these in subclasses.
+
+ def attribute_change_string(self, attribute_changes, indent=0):
+ indent_string = " "*indent
+ change_strings = [u"%s: %s -> %s" % f for f in attribute_changes]
+ for i,change_string in enumerate(change_strings):
+ change_strings[i] = indent_string+change_string
+ return u"\n".join(change_strings)
+ def bugdir_attribute_change_string(self, attribute_changes):
+ return "Changed bug directory settings:\n%s" % \
+ self.attribute_change_string(attribute_changes, indent=1)
+ def bug_attribute_change_string(self, attribute_changes):
+ return "Changed bug settings:\n%s" % \
+ self.attribute_change_string(attribute_changes, indent=1)
+ def comment_attribute_change_string(self, attribute_changes):
+ return "Changed comment settings:\n%s" % \
+ self.attribute_change_string(attribute_changes, indent=1)
+ def bug_add_string(self, bug):
+ return bug.string(shortlist=True)
+ def bug_rem_string(self, bug):
+ return bug.string(shortlist=True)
+ def bug_mod_string(self, bugs):
+ old_bug,new_bug = bugs
+ return new_bug.string(shortlist=True)
+ def bug_summary_change_string(self, summaries):
+ old_summary,new_summary = summaries
+ return "summary changed:\n %s\n %s" % (old_summary, new_summary)
+ def _comment_summary_string(self, comment):
+ return "from %s on %s" % (comment.author, time_to_str(comment.time))
+ def comment_add_string(self, comment):
+ summary = self._comment_summary_string(comment)
+ first_line = comment.body.splitlines()[0]
+ return "%s\n %s..." % (summary, first_line)
+ def comment_rem_string(self, comment):
+ summary = self._comment_summary_string(comment)
+ first_line = comment.body.splitlines()[0]
+ return "%s\n %s..." % (summary, first_line)
+ def comment_mod_string(self, comments):
+ old_comment,new_comment = comments
+ return self._comment_summary_string(new_comment)
+ def comment_body_change_string(self, bodies):
+ old_body,new_body = bodies
+ return difflib.unified_diff(old_body, new_body)
+
+
+suite = doctest.DocTestSuite()
diff --git a/interfaces/email/interactive/libbe/editor.py b/interfaces/email/interactive/libbe/editor.py
new file mode 100644
index 0000000..ec41006
--- /dev/null
+++ b/interfaces/email/interactive/libbe/editor.py
@@ -0,0 +1,108 @@
+# Bugs Everywhere, a distributed bugtracker
+# Copyright (C) 2008-2009 W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""
+Define editor_string(), a function that invokes an editor to accept
+user-produced text as a string.
+"""
+
+import codecs
+import locale
+import os
+import sys
+import tempfile
+import doctest
+
+
+default_encoding = sys.getfilesystemencoding() or locale.getpreferredencoding()
+
+comment_marker = u"== Anything below this line will be ignored\n"
+
+class CantFindEditor(Exception):
+ def __init__(self):
+ Exception.__init__(self, "Can't find editor to get string from")
+
+def editor_string(comment=None, encoding=None):
+ """Invokes the editor, and returns the user-produced text as a string
+
+ >>> if "EDITOR" in os.environ:
+ ... del os.environ["EDITOR"]
+ >>> if "VISUAL" in os.environ:
+ ... del os.environ["VISUAL"]
+ >>> editor_string()
+ Traceback (most recent call last):
+ CantFindEditor: Can't find editor to get string from
+ >>> os.environ["EDITOR"] = "echo bar > "
+ >>> editor_string()
+ u'bar\\n'
+ >>> os.environ["VISUAL"] = "echo baz > "
+ >>> editor_string()
+ u'baz\\n'
+ >>> del os.environ["EDITOR"]
+ >>> del os.environ["VISUAL"]
+ """
+ if encoding == None:
+ encoding = default_encoding
+ for name in ('VISUAL', 'EDITOR'):
+ try:
+ editor = os.environ[name]
+ break
+ except KeyError:
+ pass
+ else:
+ raise CantFindEditor()
+ fhandle, fname = tempfile.mkstemp()
+ try:
+ if comment is not None:
+ cstring = u'\n'+comment_string(comment)
+ os.write(fhandle, cstring.encode(encoding))
+ os.close(fhandle)
+ oldmtime = os.path.getmtime(fname)
+ os.system("%s %s" % (editor, fname))
+ f = codecs.open(fname, "r", encoding)
+ output = trimmed_string(f.read())
+ f.close()
+ if output.rstrip('\n') == "":
+ output = None
+ finally:
+ os.unlink(fname)
+ return output
+
+
+def comment_string(comment):
+ """
+ >>> comment_string('hello') == comment_marker+"hello"
+ True
+ """
+ return comment_marker + comment
+
+
+def trimmed_string(instring):
+ """
+ >>> trimmed_string("hello\\n"+comment_marker)
+ u'hello\\n'
+ >>> trimmed_string("hi!\\n" + comment_string('Booga'))
+ u'hi!\\n'
+ """
+ out = []
+ for line in instring.splitlines(True):
+ if line.startswith(comment_marker):
+ break
+ out.append(line)
+ return ''.join(out)
+
+suite = doctest.DocTestSuite()
diff --git a/interfaces/email/interactive/libbe/encoding.py b/interfaces/email/interactive/libbe/encoding.py
new file mode 100644
index 0000000..fd513b5
--- /dev/null
+++ b/interfaces/email/interactive/libbe/encoding.py
@@ -0,0 +1,61 @@
+# Bugs Everywhere, a distributed bugtracker
+# Copyright (C) 2008-2009 W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""
+Support input/output/filesystem encodings (e.g. UTF-8).
+"""
+
+import codecs
+import locale
+import sys
+import doctest
+
+
+ENCODING = None # override get_encoding() output by setting this
+
+def get_encoding():
+ """
+ Guess a useful input/output/filesystem encoding... Maybe we need
+ seperate encodings for input/output and filesystem? Hmm...
+ """
+ if ENCODING != None:
+ return ENCODING
+ encoding = locale.getpreferredencoding() or sys.getdefaultencoding()
+ if sys.platform != 'win32' or sys.version_info[:2] > (2, 3):
+ encoding = locale.getlocale(locale.LC_TIME)[1] or encoding
+ # Python 2.3 on windows doesn't know about 'XYZ' alias for 'cpXYZ'
+ return encoding
+
+def known_encoding(encoding):
+ """
+ >>> known_encoding("highly-unlikely-encoding")
+ False
+ >>> known_encoding(get_encoding())
+ True
+ """
+ try:
+ codecs.lookup(encoding)
+ return True
+ except LookupError:
+ return False
+
+def set_IO_stream_encodings(encoding):
+ sys.stdin = codecs.getreader(encoding)(sys.__stdin__)
+ sys.stdout = codecs.getwriter(encoding)(sys.__stdout__)
+ sys.stderr = codecs.getwriter(encoding)(sys.__stderr__)
+
+suite = doctest.DocTestSuite()
diff --git a/interfaces/email/interactive/libbe/git.py b/interfaces/email/interactive/libbe/git.py
new file mode 100644
index 0000000..3abe3b8
--- /dev/null
+++ b/interfaces/email/interactive/libbe/git.py
@@ -0,0 +1,148 @@
+# Copyright (C) 2008-2009 Ben Finney <ben+python@benfinney.id.au>
+# Chris Ball <cjb@laptop.org>
+# W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""
+Git backend.
+"""
+
+import os
+import re
+import sys
+import unittest
+import doctest
+
+import vcs
+
+
+def new():
+ return Git()
+
+class Git(vcs.VCS):
+ name="git"
+ client="git"
+ versioned=True
+ def _vcs_help(self):
+ status,output,error = self._u_invoke_client("--help")
+ return output
+ def _vcs_detect(self, path):
+ if self._u_search_parent_directories(path, ".git") != None :
+ return True
+ return False
+ def _vcs_root(self, path):
+ """Find the root of the deepest repository containing path."""
+ # Assume that nothing funny is going on; in particular, that we aren't
+ # dealing with a bare repo.
+ if os.path.isdir(path) != True:
+ path = os.path.dirname(path)
+ status,output,error = self._u_invoke_client("rev-parse", "--git-dir",
+ directory=path)
+ gitdir = os.path.join(path, output.rstrip('\n'))
+ dirname = os.path.abspath(os.path.dirname(gitdir))
+ return dirname
+ def _vcs_init(self, path):
+ self._u_invoke_client("init", directory=path)
+ def _vcs_get_user_id(self):
+ status,output,error = \
+ self._u_invoke_client("config", "user.name", expect=(0,1))
+ if status == 0:
+ name = output.rstrip('\n')
+ else:
+ name = ""
+ status,output,error = \
+ self._u_invoke_client("config", "user.email", expect=(0,1))
+ if status == 0:
+ email = output.rstrip('\n')
+ else:
+ email = ""
+ if name != "" or email != "": # got something!
+ # guess missing info, if necessary
+ if name == "":
+ name = self._u_get_fallback_username()
+ if email == "":
+ email = self._u_get_fallback_email()
+ return self._u_create_id(name, email)
+ return None # Git has no infomation
+ def _vcs_set_user_id(self, value):
+ name,email = self._u_parse_id(value)
+ if email != None:
+ self._u_invoke_client("config", "user.email", email)
+ self._u_invoke_client("config", "user.name", name)
+ def _vcs_add(self, path):
+ if os.path.isdir(path):
+ return
+ self._u_invoke_client("add", path)
+ def _vcs_remove(self, path):
+ if not os.path.isdir(self._u_abspath(path)):
+ self._u_invoke_client("rm", "-f", path)
+ def _vcs_update(self, path):
+ self._vcs_add(path)
+ def _vcs_get_file_contents(self, path, revision=None, binary=False):
+ if revision == None:
+ return vcs.VCS._vcs_get_file_contents(self, path, revision, binary=binary)
+ else:
+ arg = "%s:%s" % (revision,path)
+ status,output,error = self._u_invoke_client("show", arg)
+ return output
+ def _vcs_duplicate_repo(self, directory, revision=None):
+ if revision==None:
+ vcs.VCS._vcs_duplicate_repo(self, directory, revision)
+ else:
+ #self._u_invoke_client("archive", revision, directory) # makes tarball
+ self._u_invoke_client("clone", "--no-checkout",".",directory)
+ self._u_invoke_client("checkout", revision, directory=directory)
+ def _vcs_commit(self, commitfile, allow_empty=False):
+ args = ['commit', '--all', '--file', commitfile]
+ if allow_empty == True:
+ args.append("--allow-empty")
+ status,output,error = self._u_invoke_client(*args)
+ else:
+ kwargs = {"expect":(0,1)}
+ status,output,error = self._u_invoke_client(*args, **kwargs)
+ strings = ["nothing to commit",
+ "nothing added to commit"]
+ if self._u_any_in_string(strings, output) == True:
+ raise vcs.EmptyCommit()
+ revision = None
+ revline = re.compile("(.*) (.*)[:\]] (.*)")
+ match = revline.search(output)
+ assert match != None, output+error
+ assert len(match.groups()) == 3
+ revision = match.groups()[1]
+ full_revision = self._vcs_revision_id(-1)
+ assert full_revision.startswith(revision), \
+ "Mismatched revisions:\n%s\n%s" % (revision, full_revision)
+ return full_revision
+ def _vcs_revision_id(self, index):
+ args = ["rev-list", "--first-parent", "--reverse", "HEAD"]
+ kwargs = {"expect":(0,128)}
+ status,output,error = self._u_invoke_client(*args, **kwargs)
+ if status == 128:
+ if error.startswith("fatal: ambiguous argument 'HEAD': unknown "):
+ return None
+ raise vcs.CommandError(args, status, stdout="", stderr=error)
+ commits = output.splitlines()
+ try:
+ return commits[index]
+ except IndexError:
+ return None
+
+
+vcs.make_vcs_testcase_subclasses(Git, sys.modules[__name__])
+
+unitsuite = unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
+suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
diff --git a/interfaces/email/interactive/libbe/hg.py b/interfaces/email/interactive/libbe/hg.py
new file mode 100644
index 0000000..f8f8121
--- /dev/null
+++ b/interfaces/email/interactive/libbe/hg.py
@@ -0,0 +1,103 @@
+# Copyright (C) 2007-2009 Aaron Bentley and Panometrics, Inc.
+# Ben Finney <ben+python@benfinney.id.au>
+# W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""
+Mercurial (hg) backend.
+"""
+
+import os
+import re
+import sys
+import unittest
+import doctest
+
+import vcs
+
+
+def new():
+ return Hg()
+
+class Hg(vcs.VCS):
+ name="hg"
+ client="hg"
+ versioned=True
+ def _vcs_help(self):
+ status,output,error = self._u_invoke_client("--help")
+ return output
+ def _vcs_detect(self, path):
+ """Detect whether a directory is revision-controlled using Mercurial"""
+ if self._u_search_parent_directories(path, ".hg") != None:
+ return True
+ return False
+ def _vcs_root(self, path):
+ status,output,error = self._u_invoke_client("root", directory=path)
+ return output.rstrip('\n')
+ def _vcs_init(self, path):
+ self._u_invoke_client("init", directory=path)
+ def _vcs_get_user_id(self):
+ status,output,error = self._u_invoke_client("showconfig","ui.username")
+ return output.rstrip('\n')
+ def _vcs_set_user_id(self, value):
+ """
+ Supported by the Config Extension, but that is not part of
+ standard Mercurial.
+ http://www.selenic.com/mercurial/wiki/index.cgi/ConfigExtension
+ """
+ raise vcs.SettingIDnotSupported
+ def _vcs_add(self, path):
+ self._u_invoke_client("add", path)
+ def _vcs_remove(self, path):
+ self._u_invoke_client("rm", "--force", path)
+ def _vcs_update(self, path):
+ pass
+ def _vcs_get_file_contents(self, path, revision=None, binary=False):
+ if revision == None:
+ return vcs.VCS._vcs_get_file_contents(self, path, revision, binary=binary)
+ else:
+ status,output,error = \
+ self._u_invoke_client("cat","-r",revision,path)
+ return output
+ def _vcs_duplicate_repo(self, directory, revision=None):
+ if revision == None:
+ return vcs.VCS._vcs_duplicate_repo(self, directory, revision)
+ else:
+ self._u_invoke_client("archive", "--rev", revision, directory)
+ def _vcs_commit(self, commitfile, allow_empty=False):
+ args = ['commit', '--logfile', commitfile]
+ status,output,error = self._u_invoke_client(*args)
+ if allow_empty == False:
+ strings = ["nothing changed"]
+ if self._u_any_in_string(strings, output) == True:
+ raise vcs.EmptyCommit()
+ return self._vcs_revision_id(-1)
+ def _vcs_revision_id(self, index, style="id"):
+ args = ["identify", "--rev", str(int(index)), "--%s" % style]
+ kwargs = {"expect": (0,255)}
+ status,output,error = self._u_invoke_client(*args, **kwargs)
+ if status == 0:
+ id = output.strip()
+ if id == '000000000000':
+ return None # before initial commit.
+ return id
+ return None
+
+
+vcs.make_vcs_testcase_subclasses(Hg, sys.modules[__name__])
+
+unitsuite = unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
+suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
diff --git a/interfaces/email/interactive/libbe/mapfile.py b/interfaces/email/interactive/libbe/mapfile.py
new file mode 100644
index 0000000..4d69601
--- /dev/null
+++ b/interfaces/email/interactive/libbe/mapfile.py
@@ -0,0 +1,116 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+# W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""
+Provide a means of saving and loading dictionaries of parameters. The
+saved "mapfiles" should be clear, flat-text files, and allow easy merging of
+independent/conflicting changes.
+"""
+
+import errno
+import os.path
+import yaml
+import doctest
+
+
+class IllegalKey(Exception):
+ def __init__(self, key):
+ Exception.__init__(self, 'Illegal key "%s"' % key)
+ self.key = key
+
+class IllegalValue(Exception):
+ def __init__(self, value):
+ Exception.__init__(self, 'Illegal value "%s"' % value)
+ self.value = value
+
+def generate(map):
+ """Generate a YAML mapfile content string.
+ >>> generate({"q":"p"})
+ 'q: p\\n\\n'
+ >>> generate({"q":u"Fran\u00e7ais"})
+ 'q: Fran\\xc3\\xa7ais\\n\\n'
+ >>> generate({"q":u"hello"})
+ 'q: hello\\n\\n'
+ >>> generate({"q=":"p"})
+ Traceback (most recent call last):
+ IllegalKey: Illegal key "q="
+ >>> generate({"q:":"p"})
+ Traceback (most recent call last):
+ IllegalKey: Illegal key "q:"
+ >>> generate({"q\\n":"p"})
+ Traceback (most recent call last):
+ IllegalKey: Illegal key "q\\n"
+ >>> generate({"":"p"})
+ Traceback (most recent call last):
+ IllegalKey: Illegal key ""
+ >>> generate({">q":"p"})
+ Traceback (most recent call last):
+ IllegalKey: Illegal key ">q"
+ >>> generate({"q":"p\\n"})
+ Traceback (most recent call last):
+ IllegalValue: Illegal value "p\\n"
+ """
+ keys = map.keys()
+ keys.sort()
+ for key in keys:
+ try:
+ assert not key.startswith('>')
+ assert('\n' not in key)
+ assert('=' not in key)
+ assert(':' not in key)
+ assert(len(key) > 0)
+ except AssertionError:
+ raise IllegalKey(unicode(key).encode('unicode_escape'))
+ if "\n" in map[key]:
+ raise IllegalValue(unicode(map[key]).encode('unicode_escape'))
+
+ lines = []
+ for key in keys:
+ lines.append(yaml.safe_dump({key: map[key]},
+ default_flow_style=False,
+ allow_unicode=True))
+ lines.append("")
+ return '\n'.join(lines)
+
+def parse(contents):
+ """
+ Parse a YAML mapfile string.
+ >>> parse('q: p\\n\\n')['q']
+ 'p'
+ >>> parse('q: \\'p\\'\\n\\n')['q']
+ 'p'
+ >>> contents = generate({"a":"b", "c":"d", "e":"f"})
+ >>> dict = parse(contents)
+ >>> dict["a"]
+ 'b'
+ >>> dict["c"]
+ 'd'
+ >>> dict["e"]
+ 'f'
+ """
+ return yaml.load(contents) or {}
+
+def map_save(vcs, path, map, allow_no_vcs=False):
+ """Save the map as a mapfile to the specified path"""
+ contents = generate(map)
+ vcs.set_file_contents(path, contents, allow_no_vcs)
+
+def map_load(vcs, path, allow_no_vcs=False):
+ contents = vcs.get_file_contents(path, allow_no_vcs=allow_no_vcs)
+ return parse(contents)
+
+suite = doctest.DocTestSuite()
diff --git a/interfaces/email/interactive/libbe/plugin.py b/interfaces/email/interactive/libbe/plugin.py
new file mode 100644
index 0000000..d593d69
--- /dev/null
+++ b/interfaces/email/interactive/libbe/plugin.py
@@ -0,0 +1,77 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+# Marien Zwart <marienz@gentoo.org>
+# W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""
+Allow simple listing and loading of the various becommands and libbe
+submodules (i.e. "plugins").
+"""
+
+import os
+import os.path
+import sys
+import doctest
+
+def my_import(mod_name):
+ module = __import__(mod_name)
+ components = mod_name.split('.')
+ for comp in components[1:]:
+ module = getattr(module, comp)
+ return module
+
+def iter_plugins(prefix):
+ """
+ >>> "list" in [n for n,m in iter_plugins("becommands")]
+ True
+ >>> "plugin" in [n for n,m in iter_plugins("libbe")]
+ True
+ """
+ modfiles = os.listdir(os.path.join(plugin_path, prefix))
+ modfiles.sort()
+ for modfile in modfiles:
+ if modfile.startswith('.'):
+ continue # the occasional emacs temporary file
+ if modfile.endswith(".py") and modfile != "__init__.py":
+ yield modfile[:-3], my_import(prefix+"."+modfile[:-3])
+
+
+def get_plugin(prefix, name):
+ """
+ >>> get_plugin("becommands", "asdf") is None
+ True
+ >>> q = repr(get_plugin("becommands", "list"))
+ >>> q.startswith("<module 'becommands.list' from ")
+ True
+ """
+ dirprefix = os.path.join(*prefix.split('.'))
+ command_path = os.path.join(plugin_path, dirprefix, name+".py")
+ if os.path.isfile(command_path):
+ return my_import(prefix + "." + name)
+ return None
+
+plugin_path = os.path.realpath(os.path.dirname(os.path.dirname(__file__)))
+if plugin_path not in sys.path:
+ sys.path.append(plugin_path)
+
+suite = doctest.DocTestSuite()
+
+def _test():
+ import doctest
+ doctest.testmod()
+
+if __name__ == "__main__":
+ _test()
diff --git a/interfaces/email/interactive/libbe/properties.py b/interfaces/email/interactive/libbe/properties.py
new file mode 100644
index 0000000..09dd20e
--- /dev/null
+++ b/interfaces/email/interactive/libbe/properties.py
@@ -0,0 +1,638 @@
+# Bugs Everywhere - a distributed bugtracker
+# Copyright (C) 2008-2009 W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""
+This module provides a series of useful decorators for defining
+various types of properties. For example usage, consider the
+unittests at the end of the module.
+
+See
+ http://www.python.org/dev/peps/pep-0318/
+and
+ http://www.phyast.pitt.edu/~micheles/python/documentation.html
+for more information on decorators.
+"""
+
+import copy
+import types
+import unittest
+
+
+class ValueCheckError (ValueError):
+ def __init__(self, name, value, allowed):
+ action = "in" # some list of allowed values
+ if type(allowed) == types.FunctionType:
+ action = "allowed by" # some allowed-value check function
+ msg = "%s not %s %s for %s" % (value, action, allowed, name)
+ ValueError.__init__(self, msg)
+ self.name = name
+ self.value = value
+ self.allowed = allowed
+
+def Property(funcs):
+ """
+ End a chain of property decorators, returning a property.
+ """
+ args = {}
+ args["fget"] = funcs.get("fget", None)
+ args["fset"] = funcs.get("fset", None)
+ args["fdel"] = funcs.get("fdel", None)
+ args["doc"] = funcs.get("doc", None)
+
+ #print "Creating a property with"
+ #for key, val in args.items(): print key, value
+ return property(**args)
+
+def doc_property(doc=None):
+ """
+ Add a docstring to a chain of property decorators.
+ """
+ def decorator(funcs=None):
+ """
+ Takes either a dict of funcs {"fget":fnX, "fset":fnY, ...}
+ or a function fn() returning such a dict.
+ """
+ if hasattr(funcs, "__call__"):
+ funcs = funcs() # convert from function-arg to dict
+ funcs["doc"] = doc
+ return funcs
+ return decorator
+
+def local_property(name, null=None, mutable_null=False):
+ """
+ Define get/set access to per-parent-instance local storage. Uses
+ ._<name>_value to store the value for a particular owner instance.
+ If the ._<name>_value attribute does not exist, returns null.
+
+ If mutable_null == True, we only release deepcopies of the null to
+ the outside world.
+ """
+ def decorator(funcs):
+ if hasattr(funcs, "__call__"):
+ funcs = funcs()
+ fget = funcs.get("fget", None)
+ fset = funcs.get("fset", None)
+ def _fget(self):
+ if fget is not None:
+ fget(self)
+ if mutable_null == True:
+ ret_null = copy.deepcopy(null)
+ else:
+ ret_null = null
+ value = getattr(self, "_%s_value" % name, ret_null)
+ return value
+ def _fset(self, value):
+ setattr(self, "_%s_value" % name, value)
+ if fset is not None:
+ fset(self, value)
+ funcs["fget"] = _fget
+ funcs["fset"] = _fset
+ funcs["name"] = name
+ return funcs
+ return decorator
+
+def settings_property(name, null=None):
+ """
+ Similar to local_property, except where local_property stores the
+ value in instance._<name>_value, settings_property stores the
+ value in instance.settings[name].
+ """
+ def decorator(funcs):
+ if hasattr(funcs, "__call__"):
+ funcs = funcs()
+ fget = funcs.get("fget", None)
+ fset = funcs.get("fset", None)
+ def _fget(self):
+ if fget is not None:
+ fget(self)
+ value = self.settings.get(name, null)
+ return value
+ def _fset(self, value):
+ self.settings[name] = value
+ if fset is not None:
+ fset(self, value)
+ funcs["fget"] = _fget
+ funcs["fset"] = _fset
+ funcs["name"] = name
+ return funcs
+ return decorator
+
+
+# Allow comparison and caching with _original_ values for mutables,
+# since
+#
+# >>> a = []
+# >>> b = a
+# >>> b.append(1)
+# >>> a
+# [1]
+# >>> a==b
+# True
+def _hash_mutable_value(value):
+ return repr(value)
+def _init_mutable_property_cache(self):
+ if not hasattr(self, "_mutable_property_cache_hash"):
+ # first call to _fget for any mutable property
+ self._mutable_property_cache_hash = {}
+ self._mutable_property_cache_copy = {}
+def _set_cached_mutable_property(self, cacher_name, property_name, value):
+ _init_mutable_property_cache(self)
+ self._mutable_property_cache_hash[(cacher_name, property_name)] = \
+ _hash_mutable_value(value)
+ self._mutable_property_cache_copy[(cacher_name, property_name)] = \
+ copy.deepcopy(value)
+def _get_cached_mutable_property(self, cacher_name, property_name, default=None):
+ _init_mutable_property_cache(self)
+ if (cacher_name, property_name) not in self._mutable_property_cache_copy:
+ return default
+ return self._mutable_property_cache_copy[(cacher_name, property_name)]
+def _cmp_cached_mutable_property(self, cacher_name, property_name, value, default=None):
+ _init_mutable_property_cache(self)
+ if (cacher_name, property_name) not in self._mutable_property_cache_hash:
+ _set_cached_mutable_property(self, cacher_name, property_name, default)
+ old_hash = self._mutable_property_cache_hash[(cacher_name, property_name)]
+ return cmp(_hash_mutable_value(value), old_hash)
+
+
+def defaulting_property(default=None, null=None,
+ mutable_default=False):
+ """
+ Define a default value for get access to a property.
+ If the stored value is null, then default is returned.
+
+ If mutable_default == True, we only release deepcopies of the
+ default to the outside world.
+
+ null should never escape to the outside world, so don't worry
+ about it being a mutable.
+ """
+ def decorator(funcs):
+ if hasattr(funcs, "__call__"):
+ funcs = funcs()
+ fget = funcs.get("fget")
+ fset = funcs.get("fset")
+ name = funcs.get("name", "<unknown>")
+ def _fget(self):
+ value = fget(self)
+ if value == null:
+ if mutable_default == True:
+ return copy.deepcopy(default)
+ else:
+ return default
+ return value
+ def _fset(self, value):
+ if value == default:
+ value = null
+ fset(self, value)
+ funcs["fget"] = _fget
+ funcs["fset"] = _fset
+ return funcs
+ return decorator
+
+def fn_checked_property(value_allowed_fn):
+ """
+ Define allowed values for get/set access to a property.
+ """
+ def decorator(funcs):
+ if hasattr(funcs, "__call__"):
+ funcs = funcs()
+ fget = funcs.get("fget")
+ fset = funcs.get("fset")
+ name = funcs.get("name", "<unknown>")
+ def _fget(self):
+ value = fget(self)
+ if value_allowed_fn(value) != True:
+ raise ValueCheckError(name, value, value_allowed_fn)
+ return value
+ def _fset(self, value):
+ if value_allowed_fn(value) != True:
+ raise ValueCheckError(name, value, value_allowed_fn)
+ fset(self, value)
+ funcs["fget"] = _fget
+ funcs["fset"] = _fset
+ return funcs
+ return decorator
+
+def checked_property(allowed=[]):
+ """
+ Define allowed values for get/set access to a property.
+ """
+ def decorator(funcs):
+ if hasattr(funcs, "__call__"):
+ funcs = funcs()
+ fget = funcs.get("fget")
+ fset = funcs.get("fset")
+ name = funcs.get("name", "<unknown>")
+ def _fget(self):
+ value = fget(self)
+ if value not in allowed:
+ raise ValueCheckError(name, value, allowed)
+ return value
+ def _fset(self, value):
+ if value not in allowed:
+ raise ValueCheckError(name, value, allowed)
+ fset(self, value)
+ funcs["fget"] = _fget
+ funcs["fset"] = _fset
+ return funcs
+ return decorator
+
+def cached_property(generator, initVal=None, mutable=False):
+ """
+ Allow caching of values generated by generator(instance), where
+ instance is the instance to which this property belongs. Uses
+ ._<name>_cache to store a cache flag for a particular owner
+ instance.
+
+ When the cache flag is True or missing and the stored value is
+ initVal, the first fget call triggers the generator function,
+ whose output is stored in _<name>_cached_value. That and
+ subsequent calls to fget will return this cached value.
+
+ If the input value is no longer initVal (e.g. a value has been
+ loaded from disk or set with fset), that value overrides any
+ cached value, and this property has no effect.
+
+ When the cache flag is False and the stored value is initVal, the
+ generator is not cached, but is called on every fget.
+
+ The cache flag is missing on initialization. Particular instances
+ may override by setting their own flag.
+
+ In the case that mutable == True, all caching is disabled and the
+ generator is called whenever the cached value would otherwise be
+ used.
+ """
+ def decorator(funcs):
+ if hasattr(funcs, "__call__"):
+ funcs = funcs()
+ fget = funcs.get("fget")
+ name = funcs.get("name", "<unknown>")
+ def _fget(self):
+ cache = getattr(self, "_%s_cache" % name, True)
+ value = fget(self)
+ if value == initVal:
+ if cache == True and mutable == False:
+ if hasattr(self, "_%s_cached_value" % name):
+ value = getattr(self, "_%s_cached_value" % name)
+ else:
+ value = generator(self)
+ setattr(self, "_%s_cached_value" % name, value)
+ else:
+ value = generator(self)
+ return value
+ funcs["fget"] = _fget
+ return funcs
+ return decorator
+
+def primed_property(primer, initVal=None):
+ """
+ Just like a cached_property, except that instead of returning a
+ new value and running fset to cache it, the primer performs some
+ background manipulation (e.g. loads data into instance.settings)
+ such that a _second_ pass through fget succeeds.
+
+ The 'cache' flag becomes a 'prime' flag, with priming taking place
+ whenever ._<name>_prime is True, or is False or missing and
+ value == initVal.
+ """
+ def decorator(funcs):
+ if hasattr(funcs, "__call__"):
+ funcs = funcs()
+ fget = funcs.get("fget")
+ name = funcs.get("name", "<unknown>")
+ def _fget(self):
+ prime = getattr(self, "_%s_prime" % name, False)
+ if prime == False:
+ value = fget(self)
+ if prime == True or (prime == False and value == initVal):
+ primer(self)
+ value = fget(self)
+ return value
+ funcs["fget"] = _fget
+ return funcs
+ return decorator
+
+def change_hook_property(hook, mutable=False, default=None):
+ """
+ Call the function hook(instance, old_value, new_value) whenever a
+ value different from the current value is set (instance is a a
+ reference to the class instance to which this property belongs).
+ This is useful for saving changes to disk, etc. This function is
+ called _after_ the new value has been stored, allowing you to
+ change the stored value if you want.
+
+ In the case of mutables, things are slightly trickier. Because
+ the property-owning class has no way of knowing when the value
+ changes. We work around this by caching a private deepcopy of the
+ mutable value, and checking for changes whenever the property is
+ set (obviously) or retrieved (to check for external changes). So
+ long as you're conscientious about accessing the property after
+ making external modifications, mutability woln't be a problem.
+ t.x.append(5) # external modification
+ t.x # dummy access notices change and triggers hook
+ See testChangeHookMutableProperty for an example of the expected
+ behavior.
+ """
+ def decorator(funcs):
+ if hasattr(funcs, "__call__"):
+ funcs = funcs()
+ fget = funcs.get("fget")
+ fset = funcs.get("fset")
+ name = funcs.get("name", "<unknown>")
+ def _fget(self, new_value=None, from_fset=False): # only used if mutable == True
+ if from_fset == True:
+ value = new_value # compare new value with cached
+ else:
+ value = fget(self) # compare current value with cached
+ if _cmp_cached_mutable_property(self, "change hook property", name, value, default) != 0:
+ # there has been a change, cache new value
+ old_value = _get_cached_mutable_property(self, "change hook property", name, default)
+ _set_cached_mutable_property(self, "change hook property", name, value)
+ if from_fset == True: # return previously cached value
+ value = old_value
+ else: # the value changed while we weren't looking
+ hook(self, old_value, value)
+ return value
+ def _fset(self, value):
+ if mutable == True: # get cached previous value
+ old_value = _fget(self, new_value=value, from_fset=True)
+ else:
+ old_value = fget(self)
+ fset(self, value)
+ if value != old_value:
+ hook(self, old_value, value)
+ if mutable == True:
+ funcs["fget"] = _fget
+ funcs["fset"] = _fset
+ return funcs
+ return decorator
+
+
+class DecoratorTests(unittest.TestCase):
+ def testLocalDoc(self):
+ class Test(object):
+ @Property
+ @doc_property("A fancy property")
+ def x():
+ return {}
+ self.failUnless(Test.x.__doc__ == "A fancy property",
+ Test.x.__doc__)
+ def testLocalProperty(self):
+ class Test(object):
+ @Property
+ @local_property(name="LOCAL")
+ def x():
+ return {}
+ t = Test()
+ self.failUnless(t.x == None, str(t.x))
+ t.x = 'z' # the first set initializes ._LOCAL_value
+ self.failUnless(t.x == 'z', str(t.x))
+ self.failUnless("_LOCAL_value" in dir(t), dir(t))
+ self.failUnless(t._LOCAL_value == 'z', t._LOCAL_value)
+ def testSettingsProperty(self):
+ class Test(object):
+ @Property
+ @settings_property(name="attr")
+ def x():
+ return {}
+ def __init__(self):
+ self.settings = {}
+ t = Test()
+ self.failUnless(t.x == None, str(t.x))
+ t.x = 'z' # the first set initializes ._LOCAL_value
+ self.failUnless(t.x == 'z', str(t.x))
+ self.failUnless("attr" in t.settings, t.settings)
+ self.failUnless(t.settings["attr"] == 'z', t.settings["attr"])
+ def testDefaultingLocalProperty(self):
+ class Test(object):
+ @Property
+ @defaulting_property(default='y', null='x')
+ @local_property(name="DEFAULT", null=5)
+ def x(): return {}
+ t = Test()
+ self.failUnless(t.x == 5, str(t.x))
+ t.x = 'x'
+ self.failUnless(t.x == 'y', str(t.x))
+ t.x = 'y'
+ self.failUnless(t.x == 'y', str(t.x))
+ t.x = 'z'
+ self.failUnless(t.x == 'z', str(t.x))
+ t.x = 5
+ self.failUnless(t.x == 5, str(t.x))
+ def testCheckedLocalProperty(self):
+ class Test(object):
+ @Property
+ @checked_property(allowed=['x', 'y', 'z'])
+ @local_property(name="CHECKED")
+ def x(): return {}
+ def __init__(self):
+ self._CHECKED_value = 'x'
+ t = Test()
+ self.failUnless(t.x == 'x', str(t.x))
+ try:
+ t.x = None
+ e = None
+ except ValueCheckError, e:
+ pass
+ self.failUnless(type(e) == ValueCheckError, type(e))
+ def testTwoCheckedLocalProperties(self):
+ class Test(object):
+ @Property
+ @checked_property(allowed=['x', 'y', 'z'])
+ @local_property(name="X")
+ def x(): return {}
+
+ @Property
+ @checked_property(allowed=['a', 'b', 'c'])
+ @local_property(name="A")
+ def a(): return {}
+ def __init__(self):
+ self._A_value = 'a'
+ self._X_value = 'x'
+ t = Test()
+ try:
+ t.x = 'a'
+ e = None
+ except ValueCheckError, e:
+ pass
+ self.failUnless(type(e) == ValueCheckError, type(e))
+ t.x = 'x'
+ t.x = 'y'
+ t.x = 'z'
+ try:
+ t.a = 'x'
+ e = None
+ except ValueCheckError, e:
+ pass
+ self.failUnless(type(e) == ValueCheckError, type(e))
+ t.a = 'a'
+ t.a = 'b'
+ t.a = 'c'
+ def testFnCheckedLocalProperty(self):
+ class Test(object):
+ @Property
+ @fn_checked_property(lambda v : v in ['x', 'y', 'z'])
+ @local_property(name="CHECKED")
+ def x(): return {}
+ def __init__(self):
+ self._CHECKED_value = 'x'
+ t = Test()
+ self.failUnless(t.x == 'x', str(t.x))
+ try:
+ t.x = None
+ e = None
+ except ValueCheckError, e:
+ pass
+ self.failUnless(type(e) == ValueCheckError, type(e))
+ def testCachedLocalProperty(self):
+ class Gen(object):
+ def __init__(self):
+ self.i = 0
+ def __call__(self, owner):
+ self.i += 1
+ return self.i
+ class Test(object):
+ @Property
+ @cached_property(generator=Gen(), initVal=None)
+ @local_property(name="CACHED")
+ def x(): return {}
+ t = Test()
+ self.failIf("_CACHED_cache" in dir(t), getattr(t, "_CACHED_cache", None))
+ self.failUnless(t.x == 1, t.x)
+ self.failUnless(t.x == 1, t.x)
+ self.failUnless(t.x == 1, t.x)
+ t.x = 8
+ self.failUnless(t.x == 8, t.x)
+ self.failUnless(t.x == 8, t.x)
+ t._CACHED_cache = False # Caching is off, but the stored value
+ val = t.x # is 8, not the initVal (None), so we
+ self.failUnless(val == 8, val) # get 8.
+ t._CACHED_value = None # Now we've set the stored value to None
+ val = t.x # so future calls to fget (like this)
+ self.failUnless(val == 2, val) # will call the generator every time...
+ val = t.x
+ self.failUnless(val == 3, val)
+ val = t.x
+ self.failUnless(val == 4, val)
+ t._CACHED_cache = True # We turn caching back on, and get
+ self.failUnless(t.x == 1, str(t.x)) # the original cached value.
+ del t._CACHED_cached_value # Removing that value forces a
+ self.failUnless(t.x == 5, str(t.x)) # single cache-regenerating call
+ self.failUnless(t.x == 5, str(t.x)) # to the genenerator, after which
+ self.failUnless(t.x == 5, str(t.x)) # we get the new cached value.
+ def testPrimedLocalProperty(self):
+ class Test(object):
+ def prime(self):
+ self.settings["PRIMED"] = "initialized"
+ @Property
+ @primed_property(primer=prime, initVal=None)
+ @settings_property(name="PRIMED")
+ def x(): return {}
+ def __init__(self):
+ self.settings={}
+ t = Test()
+ self.failIf("_PRIMED_prime" in dir(t), getattr(t, "_PRIMED_prime", None))
+ self.failUnless(t.x == "initialized", t.x)
+ t.x = 1
+ self.failUnless(t.x == 1, t.x)
+ t.x = None
+ self.failUnless(t.x == "initialized", t.x)
+ t._PRIMED_prime = True
+ t.x = 3
+ self.failUnless(t.x == "initialized", t.x)
+ t._PRIMED_prime = False
+ t.x = 3
+ self.failUnless(t.x == 3, t.x)
+ def testChangeHookLocalProperty(self):
+ class Test(object):
+ def _hook(self, old, new):
+ self.old = old
+ self.new = new
+
+ @Property
+ @change_hook_property(_hook)
+ @local_property(name="HOOKED")
+ def x(): return {}
+ t = Test()
+ t.x = 1
+ self.failUnless(t.old == None, t.old)
+ self.failUnless(t.new == 1, t.new)
+ t.x = 1
+ self.failUnless(t.old == None, t.old)
+ self.failUnless(t.new == 1, t.new)
+ t.x = 2
+ self.failUnless(t.old == 1, t.old)
+ self.failUnless(t.new == 2, t.new)
+ def testChangeHookMutableProperty(self):
+ class Test(object):
+ def _hook(self, old, new):
+ self.old = old
+ self.new = new
+ self.hook_calls += 1
+
+ @Property
+ @change_hook_property(_hook, mutable=True)
+ @local_property(name="HOOKED")
+ def x(): return {}
+ t = Test()
+ t.hook_calls = 0
+ t.x = []
+ self.failUnless(t.old == None, t.old)
+ self.failUnless(t.new == [], t.new)
+ self.failUnless(t.hook_calls == 1, t.hook_calls)
+ a = t.x
+ a.append(5)
+ t.x = a
+ self.failUnless(t.old == [], t.old)
+ self.failUnless(t.new == [5], t.new)
+ self.failUnless(t.hook_calls == 2, t.hook_calls)
+ t.x = []
+ self.failUnless(t.old == [5], t.old)
+ self.failUnless(t.new == [], t.new)
+ self.failUnless(t.hook_calls == 3, t.hook_calls)
+ # now append without reassigning. this doesn't trigger the
+ # change, since we don't ever set t.x, only get it and mess
+ # with it. It does, however, update our t.new, since t.new =
+ # t.x and is not a static copy.
+ t.x.append(5)
+ self.failUnless(t.old == [5], t.old)
+ self.failUnless(t.new == [5], t.new)
+ self.failUnless(t.hook_calls == 3, t.hook_calls)
+ # however, the next t.x get _will_ notice the change...
+ a = t.x
+ self.failUnless(t.old == [], t.old)
+ self.failUnless(t.new == [5], t.new)
+ self.failUnless(t.hook_calls == 4, t.hook_calls)
+ t.x.append(6) # this append(6) is not noticed yet
+ self.failUnless(t.old == [], t.old)
+ self.failUnless(t.new == [5,6], t.new)
+ self.failUnless(t.hook_calls == 4, t.hook_calls)
+ # this append(7) is not noticed, but the t.x get causes the
+ # append(6) to be noticed
+ t.x.append(7)
+ self.failUnless(t.old == [5], t.old)
+ self.failUnless(t.new == [5,6,7], t.new)
+ self.failUnless(t.hook_calls == 5, t.hook_calls)
+ a = t.x # now the append(7) is noticed
+ self.failUnless(t.old == [5,6], t.old)
+ self.failUnless(t.new == [5,6,7], t.new)
+ self.failUnless(t.hook_calls == 6, t.hook_calls)
+
+
+suite = unittest.TestLoader().loadTestsFromTestCase(DecoratorTests)
+
diff --git a/interfaces/email/interactive/libbe/settings_object.py b/interfaces/email/interactive/libbe/settings_object.py
new file mode 100644
index 0000000..ceea9d5
--- /dev/null
+++ b/interfaces/email/interactive/libbe/settings_object.py
@@ -0,0 +1,412 @@
+# Bugs Everywhere - a distributed bugtracker
+# Copyright (C) 2008-2009 W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""
+This module provides a base class implementing settings-dict based
+property storage useful for BE objects with saved properties
+(e.g. BugDir, Bug, Comment). For example usage, consider the
+unittests at the end of the module.
+"""
+
+import doctest
+import unittest
+
+from properties import Property, doc_property, local_property, \
+ defaulting_property, checked_property, fn_checked_property, \
+ cached_property, primed_property, change_hook_property, \
+ settings_property
+
+
+class _Token (object):
+ """
+ `Control' value class for properties. We want values that only
+ mean something to the settings_object module.
+ """
+ pass
+
+class UNPRIMED (_Token):
+ "Property has not been primed."
+ pass
+
+class EMPTY (_Token):
+ """
+ Property has been primed but has no user-set value, so use
+ default/generator value.
+ """
+ pass
+
+
+def prop_save_settings(self, old, new):
+ """
+ The default action undertaken when a property changes.
+ """
+ if self.sync_with_disk==True:
+ self.save_settings()
+
+def prop_load_settings(self):
+ """
+ The default action undertaken when an UNPRIMED property is accessed.
+ """
+ if self.sync_with_disk==True and self._settings_loaded==False:
+ self.load_settings()
+ else:
+ self._setup_saved_settings(flag_as_loaded=False)
+
+# Some name-mangling routines for pretty printing setting names
+def setting_name_to_attr_name(self, name):
+ """
+ Convert keys to the .settings dict into their associated
+ SavedSettingsObject attribute names.
+ >>> print setting_name_to_attr_name(None,"User-id")
+ user_id
+ """
+ return name.lower().replace('-', '_')
+
+def attr_name_to_setting_name(self, name):
+ """
+ The inverse of setting_name_to_attr_name.
+ >>> print attr_name_to_setting_name(None, "user_id")
+ User-id
+ """
+ return name.capitalize().replace('_', '-')
+
+
+def versioned_property(name, doc,
+ default=None, generator=None,
+ change_hook=prop_save_settings,
+ mutable=False,
+ primer=prop_load_settings,
+ allowed=None, check_fn=None,
+ settings_properties=[],
+ required_saved_properties=[],
+ require_save=False):
+ """
+ Combine the common decorators in a single function.
+
+ Use zero or one (but not both) of default or generator, since a
+ working default will keep the generator from functioning. Use the
+ default if you know what you want the default value to be at
+ 'coding time'. Use the generator if you can write a function to
+ determine a valid default at run time. If both default and
+ generator are None, then the property will be a defaulting
+ property which defaults to None.
+
+ allowed and check_fn have a similar relationship, although you can
+ use both of these if you want. allowed compares the proposed
+ value against a list determined at 'coding time' and check_fn
+ allows more flexible comparisons to take place at run time.
+
+ Set require_save to True if you want to save the default/generated
+ value for a property, to protect against future changes. E.g., we
+ currently expect all comments to be 'text/plain' but in the future
+ we may want to default to 'text/html'. If we don't want the old
+ comments to be interpreted as 'text/html', we would require that
+ the content type be saved.
+
+ change_hook, primer, settings_properties, and
+ required_saved_properties are only options to get their defaults
+ into our local scope. Don't mess with them.
+
+ Set mutable=True if:
+ * default is a mutable
+ * your generator function may return mutables
+ * you set change_hook and might have mutable property values
+ See the docstrings in libbe.properties for details on how each of
+ these cases are handled.
+ """
+ settings_properties.append(name)
+ if require_save == True:
+ required_saved_properties.append(name)
+ def decorator(funcs):
+ fulldoc = doc
+ if default != None or generator == None:
+ defaulting = defaulting_property(default=default, null=EMPTY,
+ mutable_default=mutable)
+ fulldoc += "\n\nThis property defaults to %s." % default
+ if generator != None:
+ cached = cached_property(generator=generator, initVal=EMPTY,
+ mutable=mutable)
+ fulldoc += "\n\nThis property is generated with %s." % generator
+ if check_fn != None:
+ fn_checked = fn_checked_property(value_allowed_fn=check_fn)
+ fulldoc += "\n\nThis property is checked with %s." % check_fn
+ if allowed != None:
+ checked = checked_property(allowed=allowed)
+ fulldoc += "\n\nThe allowed values for this property are: %s." \
+ % (', '.join(allowed))
+ hooked = change_hook_property(hook=change_hook, mutable=mutable,
+ default=EMPTY)
+ primed = primed_property(primer=primer, initVal=UNPRIMED)
+ settings = settings_property(name=name, null=UNPRIMED)
+ docp = doc_property(doc=fulldoc)
+ deco = hooked(primed(settings(docp(funcs))))
+ if default != None or generator == None:
+ deco = defaulting(deco)
+ if generator != None:
+ deco = cached(deco)
+ if check_fn != None:
+ deco = fn_checked(deco)
+ if allowed != None:
+ deco = checked(deco)
+ return Property(deco)
+ return decorator
+
+class SavedSettingsObject(object):
+
+ # Keep a list of properties that may be stored in the .settings dict.
+ #settings_properties = []
+
+ # A list of properties that we save to disk, even if they were
+ # never set (in which case we save the default value). This
+ # protects against future changes in default values.
+ #required_saved_properties = []
+
+ _setting_name_to_attr_name = setting_name_to_attr_name
+ _attr_name_to_setting_name = attr_name_to_setting_name
+
+ def __init__(self):
+ self._settings_loaded = False
+ self.sync_with_disk = False
+ self.settings = {}
+
+ def load_settings(self):
+ """Load the settings from disk."""
+ # Override. Must call ._setup_saved_settings() after loading.
+ self.settings = {}
+ self._setup_saved_settings()
+
+ def _setup_saved_settings(self, flag_as_loaded=True):
+ """
+ To be run after setting self.settings up from disk. Marks all
+ settings as primed.
+ """
+ for property in self.settings_properties:
+ if property not in self.settings:
+ self.settings[property] = EMPTY
+ elif self.settings[property] == UNPRIMED:
+ self.settings[property] = EMPTY
+ if flag_as_loaded == True:
+ self._settings_loaded = True
+
+ def save_settings(self):
+ """Load the settings from disk."""
+ # Override. Should save the dict output of ._get_saved_settings()
+ settings = self._get_saved_settings()
+ pass # write settings to disk....
+
+ def _get_saved_settings(self):
+ settings = {}
+ for k,v in self.settings.items():
+ if v != None and v != EMPTY:
+ settings[k] = v
+ for k in self.required_saved_properties:
+ settings[k] = getattr(self, self._setting_name_to_attr_name(k))
+ return settings
+
+ def clear_cached_setting(self, setting=None):
+ "If setting=None, clear *all* cached settings"
+ if setting != None:
+ if hasattr(self, "_%s_cached_value" % setting):
+ delattr(self, "_%s_cached_value" % setting)
+ else:
+ for setting in settings_properties:
+ self.clear_cached_setting(setting)
+
+
+class SavedSettingsObjectTests(unittest.TestCase):
+ def testSimpleProperty(self):
+ """Testing a minimal versioned property"""
+ class Test(SavedSettingsObject):
+ settings_properties = []
+ required_saved_properties = []
+ @versioned_property(name="Content-type",
+ doc="A test property",
+ settings_properties=settings_properties,
+ required_saved_properties=required_saved_properties)
+ def content_type(): return {}
+ def __init__(self):
+ SavedSettingsObject.__init__(self)
+ t = Test()
+ # access missing setting
+ self.failUnless(t._settings_loaded == False, t._settings_loaded)
+ self.failUnless(len(t.settings) == 0, len(t.settings))
+ self.failUnless(t.content_type == None, t.content_type)
+ # accessing t.content_type triggers the priming, which runs
+ # t._setup_saved_settings, which fills out t.settings with
+ # EMPTY data. t._settings_loaded is still false though, since
+ # the default priming does not do any of the `official' loading
+ # that occurs in t.load_settings.
+ self.failUnless(len(t.settings) == 1, len(t.settings))
+ self.failUnless(t.settings["Content-type"] == EMPTY,
+ t.settings["Content-type"])
+ self.failUnless(t._settings_loaded == False, t._settings_loaded)
+ # load settings creates an EMPTY value in the settings array
+ t.load_settings()
+ self.failUnless(t._settings_loaded == True, t._settings_loaded)
+ self.failUnless(t.settings["Content-type"] == EMPTY,
+ t.settings["Content-type"])
+ self.failUnless(t.content_type == None, t.content_type)
+ self.failUnless(len(t.settings) == 1, len(t.settings))
+ self.failUnless(t.settings["Content-type"] == EMPTY,
+ t.settings["Content-type"])
+ # now we set a value
+ t.content_type = 5
+ self.failUnless(t.settings["Content-type"] == 5,
+ t.settings["Content-type"])
+ self.failUnless(t.content_type == 5, t.content_type)
+ self.failUnless(t.settings["Content-type"] == 5,
+ t.settings["Content-type"])
+ # now we set another value
+ t.content_type = "text/plain"
+ self.failUnless(t.content_type == "text/plain", t.content_type)
+ self.failUnless(t.settings["Content-type"] == "text/plain",
+ t.settings["Content-type"])
+ self.failUnless(t._get_saved_settings()=={"Content-type":"text/plain"},
+ t._get_saved_settings())
+ # now we clear to the post-primed value
+ t.content_type = EMPTY
+ self.failUnless(t._settings_loaded == True, t._settings_loaded)
+ self.failUnless(t.settings["Content-type"] == EMPTY,
+ t.settings["Content-type"])
+ self.failUnless(t.content_type == None, t.content_type)
+ self.failUnless(len(t.settings) == 1, len(t.settings))
+ self.failUnless(t.settings["Content-type"] == EMPTY,
+ t.settings["Content-type"])
+ def testDefaultingProperty(self):
+ """Testing a defaulting versioned property"""
+ class Test(SavedSettingsObject):
+ settings_properties = []
+ required_saved_properties = []
+ @versioned_property(name="Content-type",
+ doc="A test property",
+ default="text/plain",
+ settings_properties=settings_properties,
+ required_saved_properties=required_saved_properties)
+ def content_type(): return {}
+ def __init__(self):
+ SavedSettingsObject.__init__(self)
+ t = Test()
+ self.failUnless(t._settings_loaded == False, t._settings_loaded)
+ self.failUnless(t.content_type == "text/plain", t.content_type)
+ self.failUnless(t._settings_loaded == False, t._settings_loaded)
+ t.load_settings()
+ self.failUnless(t._settings_loaded == True, t._settings_loaded)
+ self.failUnless(t.content_type == "text/plain", t.content_type)
+ self.failUnless(t.settings["Content-type"] == EMPTY,
+ t.settings["Content-type"])
+ self.failUnless(t._get_saved_settings() == {}, t._get_saved_settings())
+ t.content_type = "text/html"
+ self.failUnless(t.content_type == "text/html",
+ t.content_type)
+ self.failUnless(t.settings["Content-type"] == "text/html",
+ t.settings["Content-type"])
+ self.failUnless(t._get_saved_settings()=={"Content-type":"text/html"},
+ t._get_saved_settings())
+ def testRequiredDefaultingProperty(self):
+ """Testing a required defaulting versioned property"""
+ class Test(SavedSettingsObject):
+ settings_properties = []
+ required_saved_properties = []
+ @versioned_property(name="Content-type",
+ doc="A test property",
+ default="text/plain",
+ settings_properties=settings_properties,
+ required_saved_properties=required_saved_properties,
+ require_save=True)
+ def content_type(): return {}
+ def __init__(self):
+ SavedSettingsObject.__init__(self)
+ t = Test()
+ self.failUnless(t._get_saved_settings()=={"Content-type":"text/plain"},
+ t._get_saved_settings())
+ t.content_type = "text/html"
+ self.failUnless(t._get_saved_settings()=={"Content-type":"text/html"},
+ t._get_saved_settings())
+ def testClassVersionedPropertyDefinition(self):
+ """Testing a class-specific _versioned property decorator"""
+ class Test(SavedSettingsObject):
+ settings_properties = []
+ required_saved_properties = []
+ def _versioned_property(settings_properties=settings_properties,
+ required_saved_properties=required_saved_properties,
+ **kwargs):
+ if "settings_properties" not in kwargs:
+ kwargs["settings_properties"] = settings_properties
+ if "required_saved_properties" not in kwargs:
+ kwargs["required_saved_properties"]=required_saved_properties
+ return versioned_property(**kwargs)
+ @_versioned_property(name="Content-type",
+ doc="A test property",
+ default="text/plain",
+ require_save=True)
+ def content_type(): return {}
+ def __init__(self):
+ SavedSettingsObject.__init__(self)
+ t = Test()
+ self.failUnless(t._get_saved_settings()=={"Content-type":"text/plain"},
+ t._get_saved_settings())
+ t.content_type = "text/html"
+ self.failUnless(t._get_saved_settings()=={"Content-type":"text/html"},
+ t._get_saved_settings())
+ def testMutableChangeHookedProperty(self):
+ """Testing a mutable change-hooked property"""
+ SAVES = []
+ def prop_log_save_settings(self, old, new, saves=SAVES):
+ saves.append("'%s' -> '%s'" % (str(old), str(new)))
+ prop_save_settings(self, old, new)
+ class Test(SavedSettingsObject):
+ settings_properties = []
+ required_saved_properties = []
+ @versioned_property(name="List-type",
+ doc="A test property",
+ mutable=True,
+ change_hook=prop_log_save_settings,
+ settings_properties=settings_properties,
+ required_saved_properties=required_saved_properties)
+ def list_type(): return {}
+ def __init__(self):
+ SavedSettingsObject.__init__(self)
+ t = Test()
+ self.failUnless(t._settings_loaded == False, t._settings_loaded)
+ t.load_settings()
+ self.failUnless(SAVES == [], SAVES)
+ self.failUnless(t._settings_loaded == True, t._settings_loaded)
+ self.failUnless(t.list_type == None, t.list_type)
+ self.failUnless(SAVES == [], SAVES)
+ self.failUnless(t.settings["List-type"]==EMPTY,t.settings["List-type"])
+ t.list_type = []
+ self.failUnless(t.settings["List-type"] == [], t.settings["List-type"])
+ self.failUnless(SAVES == [
+ "'<class 'libbe.settings_object.EMPTY'>' -> '[]'"
+ ], SAVES)
+ t.list_type.append(5)
+ self.failUnless(SAVES == [
+ "'<class 'libbe.settings_object.EMPTY'>' -> '[]'",
+ ], SAVES)
+ self.failUnless(t.settings["List-type"] == [5],t.settings["List-type"])
+ self.failUnless(SAVES == [ # the append(5) has not yet been saved
+ "'<class 'libbe.settings_object.EMPTY'>' -> '[]'",
+ ], SAVES)
+ self.failUnless(t.list_type == [5], t.list_type) # <-get triggers saved
+
+ self.failUnless(SAVES == [ # now the append(5) has been saved.
+ "'<class 'libbe.settings_object.EMPTY'>' -> '[]'",
+ "'[]' -> '[5]'"
+ ], SAVES)
+
+unitsuite=unittest.TestLoader().loadTestsFromTestCase(SavedSettingsObjectTests)
+suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
diff --git a/interfaces/email/interactive/libbe/tree.py b/interfaces/email/interactive/libbe/tree.py
new file mode 100644
index 0000000..06d09e5
--- /dev/null
+++ b/interfaces/email/interactive/libbe/tree.py
@@ -0,0 +1,183 @@
+# Bugs Everywhere, a distributed bugtracker
+# Copyright (C) 2008-2009 W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""
+Define a traversable tree structure.
+"""
+
+import doctest
+
+class Tree(list):
+ """
+ Construct
+ +-b---d-g
+ a-+ +-e
+ +-c-+-f-h-i
+ with
+ >>> i = Tree(); i.n = "i"
+ >>> h = Tree([i]); h.n = "h"
+ >>> f = Tree([h]); f.n = "f"
+ >>> e = Tree(); e.n = "e"
+ >>> c = Tree([f,e]); c.n = "c"
+ >>> g = Tree(); g.n = "g"
+ >>> d = Tree([g]); d.n = "d"
+ >>> b = Tree([d]); b.n = "b"
+ >>> a = Tree(); a.n = "a"
+ >>> a.append(c)
+ >>> a.append(b)
+
+ >>> a.branch_len()
+ 5
+ >>> a.sort(key=lambda node : -node.branch_len())
+ >>> "".join([node.n for node in a.traverse()])
+ 'acfhiebdg'
+ >>> a.sort(key=lambda node : node.branch_len())
+ >>> "".join([node.n for node in a.traverse()])
+ 'abdgcefhi'
+ >>> "".join([node.n for node in a.traverse(depth_first=False)])
+ 'abcdefghi'
+ >>> for depth,node in a.thread():
+ ... print "%*s" % (2*depth+1, node.n)
+ a
+ b
+ d
+ g
+ c
+ e
+ f
+ h
+ i
+ >>> for depth,node in a.thread(flatten=True):
+ ... print "%*s" % (2*depth+1, node.n)
+ a
+ b
+ d
+ g
+ c
+ e
+ f
+ h
+ i
+ >>> a.has_descendant(g)
+ True
+ >>> c.has_descendant(g)
+ False
+ >>> a.has_descendant(a)
+ False
+ >>> a.has_descendant(a, match_self=True)
+ True
+ """
+ def __eq__(self, other):
+ return id(self) == id(other)
+
+ def branch_len(self):
+ """
+ Exhaustive search every time == SLOW.
+
+ Use only on small trees, or reimplement by overriding
+ child-addition methods to allow accurate caching.
+
+ For the tree
+ +-b---d-g
+ a-+ +-e
+ +-c-+-f-h-i
+ this method returns 5.
+ """
+ if len(self) == 0:
+ return 1
+ else:
+ return 1 + max([child.branch_len() for child in self])
+
+ def sort(self, *args, **kwargs):
+ """
+ This method can be slow, e.g. on a branch_len() sort, since a
+ node at depth N from the root has it's branch_len() method
+ called N times.
+ """
+ list.sort(self, *args, **kwargs)
+ for child in self:
+ child.sort(*args, **kwargs)
+
+ def traverse(self, depth_first=True):
+ """
+ Note: you might want to sort() your tree first.
+ """
+ if depth_first == True:
+ yield self
+ for child in self:
+ for descendant in child.traverse():
+ yield descendant
+ else: # breadth first, Wikipedia algorithm
+ # http://en.wikipedia.org/wiki/Breadth-first_search
+ queue = [self]
+ while len(queue) > 0:
+ node = queue.pop(0)
+ yield node
+ queue.extend(node)
+
+ def thread(self, flatten=False):
+ """
+ When flatten==False, the depth of any node is one greater than
+ the depth of its parent. That way the inheritance is
+ explicit, but you can end up with highly indented threads.
+
+ When flatten==True, the depth of any node is only greater than
+ the depth of its parent when there is a branch, and the node
+ is not the last child. This can lead to ancestry ambiguity,
+ but keeps the total indentation down. E.g.
+ +-b +-b-c
+ a-+-c and a-+
+ +-d-e-f +-d-e-f
+ would both produce (after sorting by branch_len())
+ (0, a)
+ (1, b)
+ (1, c)
+ (0, d)
+ (0, e)
+ (0, f)
+ """
+ stack = [] # ancestry of the current node
+ if flatten == True:
+ depthDict = {}
+
+ for node in self.traverse(depth_first=True):
+ while len(stack) > 0 \
+ and id(node) not in [id(c) for c in stack[-1]]:
+ stack.pop(-1)
+ if flatten == False:
+ depth = len(stack)
+ else:
+ if len(stack) == 0:
+ depth = 0
+ else:
+ parent = stack[-1]
+ depth = depthDict[id(parent)]
+ if len(parent) > 1 and node != parent[-1]:
+ depth += 1
+ depthDict[id(node)] = depth
+ yield (depth,node)
+ stack.append(node)
+
+ def has_descendant(self, descendant, depth_first=True, match_self=False):
+ if descendant == self:
+ return match_self
+ for d in self.traverse(depth_first):
+ if descendant == d:
+ return True
+ return False
+
+suite = doctest.DocTestSuite()
diff --git a/interfaces/email/interactive/libbe/upgrade.py b/interfaces/email/interactive/libbe/upgrade.py
new file mode 100644
index 0000000..4123c72
--- /dev/null
+++ b/interfaces/email/interactive/libbe/upgrade.py
@@ -0,0 +1,187 @@
+# Copyright (C) 2009 W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""
+Handle conversion between the various on-disk images.
+"""
+
+import os, os.path
+import sys
+import doctest
+
+import encoding
+import mapfile
+import vcs
+
+# a list of all past versions
+BUGDIR_DISK_VERSIONS = ["Bugs Everywhere Tree 1 0",
+ "Bugs Everywhere Directory v1.1",
+ "Bugs Everywhere Directory v1.2"]
+
+# the current version
+BUGDIR_DISK_VERSION = BUGDIR_DISK_VERSIONS[-1]
+
+class Upgrader (object):
+ "Class for converting "
+ initial_version = None
+ final_version = None
+ def __init__(self, root):
+ self.root = root
+ # use the "None" VCS to ensure proper encoding/decoding and
+ # simplify path construction.
+ self.vcs = vcs.vcs_by_name("None")
+ self.vcs.root(self.root)
+ self.vcs.encoding = encoding.get_encoding()
+
+ def get_path(self, *args):
+ """
+ Return a path relative to .root.
+ """
+ dir = os.path.join(self.root, ".be")
+ if len(args) == 0:
+ return dir
+ assert args[0] in ["version", "settings", "bugs"], str(args)
+ return os.path.join(dir, *args)
+
+ def check_initial_version(self):
+ path = self.get_path("version")
+ version = self.vcs.get_file_contents(path).rstrip("\n")
+ assert version == self.initial_version, version
+
+ def set_version(self):
+ path = self.get_path("version")
+ self.vcs.set_file_contents(path, self.final_version+"\n")
+
+ def upgrade(self):
+ print >> sys.stderr, "upgrading bugdir from '%s' to '%s'" \
+ % (self.initial_version, self.final_version)
+ self.check_initial_version()
+ self.set_version()
+ self._upgrade()
+
+ def _upgrade(self):
+ raise NotImplementedError
+
+
+class Upgrade_1_0_to_1_1 (Upgrader):
+ initial_version = "Bugs Everywhere Tree 1 0"
+ final_version = "Bugs Everywhere Directory v1.1"
+ def _upgrade_mapfile(self, path):
+ contents = self.vcs.get_file_contents(path)
+ old_format = False
+ for line in contents.splitlines():
+ if len(line.split("=")) == 2:
+ old_format = True
+ break
+ if old_format == True:
+ # translate to YAML.
+ newlines = []
+ for line in contents.splitlines():
+ line = line.rstrip('\n')
+ if len(line) == 0:
+ continue
+ fields = line.split("=")
+ if len(fields) == 2:
+ key,value = fields
+ newlines.append('%s: "%s"' % (key, value.replace('"','\\"')))
+ else:
+ newlines.append(line)
+ contents = '\n'.join(newlines)
+ # load the YAML and save
+ map = mapfile.parse(contents)
+ mapfile.map_save(self.vcs, path, map)
+
+ def _upgrade(self):
+ """
+ Comment value field "From" -> "Author".
+ Homegrown mapfile -> YAML.
+ """
+ path = self.get_path("settings")
+ self._upgrade_mapfile(path)
+ for bug_uuid in os.listdir(self.get_path("bugs")):
+ path = self.get_path("bugs", bug_uuid, "values")
+ self._upgrade_mapfile(path)
+ c_path = ["bugs", bug_uuid, "comments"]
+ if not os.path.exists(self.get_path(*c_path)):
+ continue # no comments for this bug
+ for comment_uuid in os.listdir(self.get_path(*c_path)):
+ path_list = c_path + [comment_uuid, "values"]
+ path = self.get_path(*path_list)
+ self._upgrade_mapfile(path)
+ settings = mapfile.map_load(self.vcs, path)
+ if "From" in settings:
+ settings["Author"] = settings.pop("From")
+ mapfile.map_save(self.vcs, path, settings)
+
+
+class Upgrade_1_1_to_1_2 (Upgrader):
+ initial_version = "Bugs Everywhere Directory v1.1"
+ final_version = "Bugs Everywhere Directory v1.2"
+ def _upgrade(self):
+ """
+ BugDir settings field "rcs_name" -> "vcs_name".
+ """
+ path = self.get_path("settings")
+ settings = mapfile.map_load(self.vcs, path)
+ if "rcs_name" in settings:
+ settings["vcs_name"] = settings.pop("rcs_name")
+ mapfile.map_save(self.vcs, path, settings)
+
+
+upgraders = [Upgrade_1_0_to_1_1,
+ Upgrade_1_1_to_1_2]
+upgrade_classes = {}
+for upgrader in upgraders:
+ upgrade_classes[(upgrader.initial_version,upgrader.final_version)]=upgrader
+
+def upgrade(path, current_version,
+ target_version=BUGDIR_DISK_VERSION):
+ """
+ Call the appropriate upgrade function to convert current_version
+ to target_version. If a direct conversion function does not exist,
+ use consecutive conversion functions.
+ """
+ if current_version not in BUGDIR_DISK_VERSIONS:
+ raise NotImplementedError, \
+ "Cannot handle version '%s' yet." % version
+ if target_version not in BUGDIR_DISK_VERSIONS:
+ raise NotImplementedError, \
+ "Cannot handle version '%s' yet." % version
+
+ if (current_version, target_version) in upgrade_classes:
+ # direct conversion
+ upgrade_class = upgrade_classes[(current_version, target_version)]
+ u = upgrade_class(path)
+ u.upgrade()
+ else:
+ # consecutive single-step conversion
+ i = BUGDIR_DISK_VERSIONS.index(current_version)
+ while True:
+ version_a = BUGDIR_DISK_VERSIONS[i]
+ version_b = BUGDIR_DISK_VERSIONS[i+1]
+ try:
+ upgrade_class = upgrade_classes[(version_a, version_b)]
+ except KeyError:
+ raise NotImplementedError, \
+ "Cannot convert version '%s' to '%s' yet." \
+ % (version_a, version_b)
+ u = upgrade_class(path)
+ u.upgrade()
+ if version_b == target_version:
+ break
+ i += 1
+
+suite = doctest.DocTestSuite()
diff --git a/interfaces/email/interactive/libbe/utility.py b/interfaces/email/interactive/libbe/utility.py
new file mode 100644
index 0000000..aafbf8d
--- /dev/null
+++ b/interfaces/email/interactive/libbe/utility.py
@@ -0,0 +1,134 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+# W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""
+Assorted utility functions that don't fit in anywhere else.
+"""
+
+import calendar
+import codecs
+import os
+import shutil
+import tempfile
+import time
+import types
+import doctest
+
+def search_parent_directories(path, filename):
+ """
+ Find the file (or directory) named filename in path or in any
+ of path's parents.
+
+ e.g.
+ search_parent_directories("/a/b/c", ".be")
+ will return the path to the first existing file from
+ /a/b/c/.be
+ /a/b/.be
+ /a/.be
+ /.be
+ or None if none of those files exist.
+ """
+ path = os.path.realpath(path)
+ assert os.path.exists(path)
+ old_path = None
+ while True:
+ check_path = os.path.join(path, filename)
+ if os.path.exists(check_path):
+ return check_path
+ if path == old_path:
+ return None
+ old_path = path
+ path = os.path.dirname(path)
+
+class Dir (object):
+ "A temporary directory for testing use"
+ def __init__(self):
+ self.path = tempfile.mkdtemp(prefix="BEtest")
+ self.rmtree = shutil.rmtree # save local reference for __del__
+ self.removed = False
+ def __del__(self):
+ self.cleanup()
+ def cleanup(self):
+ if self.removed == False:
+ self.rmtree(self.path)
+ self.removed = True
+ def __call__(self):
+ return self.path
+
+RFC_2822_TIME_FMT = "%a, %d %b %Y %H:%M:%S +0000"
+
+
+def time_to_str(time_val):
+ """Convert a time value into an RFC 2822-formatted string. This format
+ lacks sub-second data.
+ >>> time_to_str(0)
+ 'Thu, 01 Jan 1970 00:00:00 +0000'
+ """
+ return time.strftime(RFC_2822_TIME_FMT, time.gmtime(time_val))
+
+def str_to_time(str_time):
+ """Convert an RFC 2822-fomatted string into a time value.
+ >>> str_to_time("Thu, 01 Jan 1970 00:00:00 +0000")
+ 0
+ >>> q = time.time()
+ >>> str_to_time(time_to_str(q)) == int(q)
+ True
+ >>> str_to_time("Thu, 01 Jan 1970 00:00:00 -1000")
+ 36000
+ """
+ timezone_str = str_time[-5:]
+ if timezone_str != "+0000":
+ str_time = str_time.replace(timezone_str, "+0000")
+ time_val = calendar.timegm(time.strptime(str_time, RFC_2822_TIME_FMT))
+ timesign = -int(timezone_str[0]+"1") # "+" -> time_val ahead of GMT
+ timezone_tuple = time.strptime(timezone_str[1:], "%H%M")
+ timezone = timezone_tuple.tm_hour*3600 + timezone_tuple.tm_min*60
+ return time_val + timesign*timezone
+
+def handy_time(time_val):
+ return time.strftime("%a, %d %b %Y %H:%M", time.localtime(time_val))
+
+def time_to_gmtime(str_time):
+ """Convert an RFC 2822-fomatted string to a GMT string.
+ >>> time_to_gmtime("Thu, 01 Jan 1970 00:00:00 -1000")
+ 'Thu, 01 Jan 1970 10:00:00 +0000'
+ """
+ time_val = str_to_time(str_time)
+ return time_to_str(time_val)
+
+def iterable_full_of_strings(value, alternative=None):
+ """
+ Require an iterable full of strings.
+ >>> iterable_full_of_strings([])
+ True
+ >>> iterable_full_of_strings(["abc", "def", u"hij"])
+ True
+ >>> iterable_full_of_strings(["abc", None, u"hij"])
+ False
+ >>> iterable_full_of_strings(None, alternative=None)
+ True
+ """
+ if value == alternative:
+ return True
+ elif not hasattr(value, "__iter__"):
+ return False
+ for x in value:
+ if type(x) not in types.StringTypes:
+ return False
+ return True
+
+suite = doctest.DocTestSuite()
diff --git a/interfaces/email/interactive/libbe/vcs.py b/interfaces/email/interactive/libbe/vcs.py
new file mode 100644
index 0000000..a1d3022
--- /dev/null
+++ b/interfaces/email/interactive/libbe/vcs.py
@@ -0,0 +1,938 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+# Alexander Belchenko <bialix@ukr.net>
+# Ben Finney <ben+python@benfinney.id.au>
+# Chris Ball <cjb@laptop.org>
+# W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""
+Define the base VCS (Version Control System) class, which should be
+subclassed by other Version Control System backends. The base class
+implements a "do not version" VCS.
+"""
+
+from subprocess import Popen, PIPE
+import codecs
+import os
+import os.path
+import re
+from socket import gethostname
+import shutil
+import sys
+import tempfile
+import unittest
+import doctest
+
+from utility import Dir, search_parent_directories
+
+
+def _get_matching_vcs(matchfn):
+ """Return the first module for which matchfn(VCS_instance) is true"""
+ import arch
+ import bzr
+ import darcs
+ import git
+ import hg
+ for module in [arch, bzr, darcs, git, hg]:
+ vcs = module.new()
+ if matchfn(vcs) == True:
+ return vcs
+ del(vcs)
+ return VCS()
+
+def vcs_by_name(vcs_name):
+ """Return the module for the VCS with the given name"""
+ return _get_matching_vcs(lambda vcs: vcs.name == vcs_name)
+
+def detect_vcs(dir):
+ """Return an VCS instance for the vcs being used in this directory"""
+ return _get_matching_vcs(lambda vcs: vcs.detect(dir))
+
+def installed_vcs():
+ """Return an instance of an installed VCS"""
+ return _get_matching_vcs(lambda vcs: vcs.installed())
+
+
+class CommandError(Exception):
+ def __init__(self, command, status, stdout, stderr):
+ strerror = ["Command failed (%d):\n %s\n" % (status, stderr),
+ "while executing\n %s" % command]
+ Exception.__init__(self, "\n".join(strerror))
+ self.command = command
+ self.status = status
+ self.stdout = stdout
+ self.stderr = stderr
+
+class SettingIDnotSupported(NotImplementedError):
+ pass
+
+class VCSnotRooted(Exception):
+ def __init__(self):
+ msg = "VCS not rooted"
+ Exception.__init__(self, msg)
+
+class PathNotInRoot(Exception):
+ def __init__(self, path, root):
+ msg = "Path '%s' not in root '%s'" % (path, root)
+ Exception.__init__(self, msg)
+ self.path = path
+ self.root = root
+
+class NoSuchFile(Exception):
+ def __init__(self, pathname, root="."):
+ path = os.path.abspath(os.path.join(root, pathname))
+ Exception.__init__(self, "No such file: %s" % path)
+
+class EmptyCommit(Exception):
+ def __init__(self):
+ Exception.__init__(self, "No changes to commit")
+
+
+def new():
+ return VCS()
+
+class VCS(object):
+ """
+ This class implements a 'no-vcs' interface.
+
+ Support for other VCSs can be added by subclassing this class, and
+ overriding methods _vcs_*() with code appropriate for your VCS.
+
+ The methods _u_*() are utility methods available to the _vcs_*()
+ methods.
+ """
+ name = "None"
+ client = "" # command-line tool for _u_invoke_client
+ versioned = False
+ def __init__(self, paranoid=False, encoding=sys.getdefaultencoding()):
+ self.paranoid = paranoid
+ self.verboseInvoke = False
+ self.rootdir = None
+ self._duplicateBasedir = None
+ self._duplicateDirname = None
+ self.encoding = encoding
+ def __del__(self):
+ self.cleanup()
+
+ def _vcs_help(self):
+ """
+ Return the command help string.
+ (Allows a simple test to see if the client is installed.)
+ """
+ pass
+ def _vcs_detect(self, path=None):
+ """
+ Detect whether a directory is revision controlled with this VCS.
+ """
+ return True
+ def _vcs_root(self, path):
+ """
+ Get the VCS root. This is the default working directory for
+ future invocations. You would normally set this to the root
+ directory for your VCS.
+ """
+ if os.path.isdir(path)==False:
+ path = os.path.dirname(path)
+ if path == "":
+ path = os.path.abspath(".")
+ return path
+ def _vcs_init(self, path):
+ """
+ Begin versioning the tree based at path.
+ """
+ pass
+ def _vcs_cleanup(self):
+ """
+ Remove any cruft that _vcs_init() created outside of the
+ versioned tree.
+ """
+ pass
+ def _vcs_get_user_id(self):
+ """
+ Get the VCS's suggested user id (e.g. "John Doe <jdoe@example.com>").
+ If the VCS has not been configured with a username, return None.
+ """
+ return None
+ def _vcs_set_user_id(self, value):
+ """
+ Set the VCS's suggested user id (e.g "John Doe <jdoe@example.com>").
+ This is run if the VCS has not been configured with a usename, so
+ that commits will have a reasonable FROM value.
+ """
+ raise SettingIDnotSupported
+ def _vcs_add(self, path):
+ """
+ Add the already created file at path to version control.
+ """
+ pass
+ def _vcs_remove(self, path):
+ """
+ Remove the file at path from version control. Optionally
+ remove the file from the filesystem as well.
+ """
+ pass
+ def _vcs_update(self, path):
+ """
+ Notify the versioning system of changes to the versioned file
+ at path.
+ """
+ pass
+ def _vcs_get_file_contents(self, path, revision=None, binary=False):
+ """
+ Get the file contents as they were in a given revision.
+ Revision==None specifies the current revision.
+ """
+ assert revision == None, \
+ "The %s VCS does not support revision specifiers" % self.name
+ if binary == False:
+ f = codecs.open(os.path.join(self.rootdir, path), "r", self.encoding)
+ else:
+ f = open(os.path.join(self.rootdir, path), "rb")
+ contents = f.read()
+ f.close()
+ return contents
+ def _vcs_duplicate_repo(self, directory, revision=None):
+ """
+ Get the repository as it was in a given revision.
+ revision==None specifies the current revision.
+ dir specifies a directory to create the duplicate in.
+ """
+ shutil.copytree(self.rootdir, directory, True)
+ def _vcs_commit(self, commitfile, allow_empty=False):
+ """
+ Commit the current working directory, using the contents of
+ commitfile as the comment. Return the name of the old
+ revision (or None if commits are not supported).
+
+ If allow_empty == False, raise EmptyCommit if there are no
+ changes to commit.
+ """
+ return None
+ def _vcs_revision_id(self, index):
+ """
+ Return the name of the <index>th revision. Index will be an
+ integer (possibly <= 0). The choice of which branch to follow
+ when crossing branches/merges is not defined.
+
+ Return None if revision IDs are not supported, or if the
+ specified revision does not exist.
+ """
+ return None
+ def installed(self):
+ try:
+ self._vcs_help()
+ return True
+ except OSError, e:
+ if e.errno == errno.ENOENT:
+ return False
+ except CommandError:
+ return False
+ def detect(self, path="."):
+ """
+ Detect whether a directory is revision controlled with this VCS.
+ """
+ return self._vcs_detect(path)
+ def root(self, path):
+ """
+ Set the root directory to the path's VCS root. This is the
+ default working directory for future invocations.
+ """
+ self.rootdir = self._vcs_root(path)
+ def init(self, path):
+ """
+ Begin versioning the tree based at path.
+ Also roots the vcs at path.
+ """
+ if os.path.isdir(path)==False:
+ path = os.path.dirname(path)
+ self._vcs_init(path)
+ self.root(path)
+ def cleanup(self):
+ self._vcs_cleanup()
+ def get_user_id(self):
+ """
+ Get the VCS's suggested user id (e.g. "John Doe <jdoe@example.com>").
+ If the VCS has not been configured with a username, return the user's
+ id. You can override the automatic lookup procedure by setting the
+ VCS.user_id attribute to a string of your choice.
+ """
+ if hasattr(self, "user_id"):
+ if self.user_id != None:
+ return self.user_id
+ id = self._vcs_get_user_id()
+ if id == None:
+ name = self._u_get_fallback_username()
+ email = self._u_get_fallback_email()
+ id = self._u_create_id(name, email)
+ print >> sys.stderr, "Guessing id '%s'" % id
+ try:
+ self.set_user_id(id)
+ except SettingIDnotSupported:
+ pass
+ return id
+ def set_user_id(self, value):
+ """
+ Set the VCS's suggested user id (e.g "John Doe <jdoe@example.com>").
+ This is run if the VCS has not been configured with a usename, so
+ that commits will have a reasonable FROM value.
+ """
+ self._vcs_set_user_id(value)
+ def add(self, path):
+ """
+ Add the already created file at path to version control.
+ """
+ self._vcs_add(self._u_rel_path(path))
+ def remove(self, path):
+ """
+ Remove a file from both version control and the filesystem.
+ """
+ self._vcs_remove(self._u_rel_path(path))
+ if os.path.exists(path):
+ os.remove(path)
+ def recursive_remove(self, dirname):
+ """
+ Remove a file/directory and all its decendents from both
+ version control and the filesystem.
+ """
+ if not os.path.exists(dirname):
+ raise NoSuchFile(dirname)
+ for dirpath,dirnames,filenames in os.walk(dirname, topdown=False):
+ filenames.extend(dirnames)
+ for path in filenames:
+ fullpath = os.path.join(dirpath, path)
+ if os.path.exists(fullpath) == False:
+ continue
+ self._vcs_remove(self._u_rel_path(fullpath))
+ if os.path.exists(dirname):
+ shutil.rmtree(dirname)
+ def update(self, path):
+ """
+ Notify the versioning system of changes to the versioned file
+ at path.
+ """
+ self._vcs_update(self._u_rel_path(path))
+ def get_file_contents(self, path, revision=None, allow_no_vcs=False, binary=False):
+ """
+ Get the file as it was in a given revision.
+ Revision==None specifies the current revision.
+ """
+ if not os.path.exists(path):
+ raise NoSuchFile(path)
+ if self._use_vcs(path, allow_no_vcs):
+ relpath = self._u_rel_path(path)
+ contents = self._vcs_get_file_contents(relpath,revision,binary=binary)
+ else:
+ f = codecs.open(path, "r", self.encoding)
+ contents = f.read()
+ f.close()
+ return contents
+ def set_file_contents(self, path, contents, allow_no_vcs=False, binary=False):
+ """
+ Set the file contents under version control.
+ """
+ add = not os.path.exists(path)
+ if binary == False:
+ f = codecs.open(path, "w", self.encoding)
+ else:
+ f = open(path, "wb")
+ f.write(contents)
+ f.close()
+
+ if self._use_vcs(path, allow_no_vcs):
+ if add:
+ self.add(path)
+ else:
+ self.update(path)
+ def mkdir(self, path, allow_no_vcs=False, check_parents=True):
+ """
+ Create (if neccessary) a directory at path under version
+ control.
+ """
+ if check_parents == True:
+ parent = os.path.dirname(path)
+ if not os.path.exists(parent): # recurse through parents
+ self.mkdir(parent, allow_no_vcs, check_parents)
+ if not os.path.exists(path):
+ os.mkdir(path)
+ if self._use_vcs(path, allow_no_vcs):
+ self.add(path)
+ else:
+ assert os.path.isdir(path)
+ if self._use_vcs(path, allow_no_vcs):
+ #self.update(path)# Don't update directories. Changing files
+ pass # underneath them should be sufficient.
+
+ def duplicate_repo(self, revision=None):
+ """
+ Get the repository as it was in a given revision.
+ revision==None specifies the current revision.
+ Return the path to the arbitrary directory at the base of the new repo.
+ """
+ # Dirname in Baseir to protect against simlink attacks.
+ if self._duplicateBasedir == None:
+ self._duplicateBasedir = tempfile.mkdtemp(prefix='BEvcs')
+ self._duplicateDirname = \
+ os.path.join(self._duplicateBasedir, "duplicate")
+ self._vcs_duplicate_repo(directory=self._duplicateDirname,
+ revision=revision)
+ return self._duplicateDirname
+ def remove_duplicate_repo(self):
+ """
+ Clean up a duplicate repo created with duplicate_repo().
+ """
+ if self._duplicateBasedir != None:
+ shutil.rmtree(self._duplicateBasedir)
+ self._duplicateBasedir = None
+ self._duplicateDirname = None
+ def commit(self, summary, body=None, allow_empty=False):
+ """
+ Commit the current working directory, with a commit message
+ string summary and body. Return the name of the old revision
+ (or None if versioning is not supported).
+
+ If allow_empty == False (the default), raise EmptyCommit if
+ there are no changes to commit.
+ """
+ summary = summary.strip()+'\n'
+ if body is not None:
+ summary += '\n' + body.strip() + '\n'
+ descriptor, filename = tempfile.mkstemp()
+ revision = None
+ try:
+ temp_file = os.fdopen(descriptor, 'wb')
+ temp_file.write(summary)
+ temp_file.flush()
+ self.precommit()
+ revision = self._vcs_commit(filename, allow_empty=allow_empty)
+ temp_file.close()
+ self.postcommit()
+ finally:
+ os.remove(filename)
+ return revision
+ def precommit(self):
+ """
+ Executed before all attempted commits.
+ """
+ pass
+ def postcommit(self):
+ """
+ Only executed after successful commits.
+ """
+ pass
+ def revision_id(self, index=None):
+ """
+ Return the name of the <index>th revision. The choice of
+ which branch to follow when crossing branches/merges is not
+ defined.
+
+ Return None if index==None, revision IDs are not supported, or
+ if the specified revision does not exist.
+ """
+ if index == None:
+ return None
+ return self._vcs_revision_id(index)
+ def _u_any_in_string(self, list, string):
+ """
+ Return True if any of the strings in list are in string.
+ Otherwise return False.
+ """
+ for list_string in list:
+ if list_string in string:
+ return True
+ return False
+ def _u_invoke(self, args, stdin=None, expect=(0,), cwd=None):
+ """
+ expect should be a tuple of allowed exit codes. cwd should be
+ the directory from which the command will be executed.
+ """
+ if cwd == None:
+ cwd = self.rootdir
+ if self.verboseInvoke == True:
+ print >> sys.stderr, "%s$ %s" % (cwd, " ".join(args))
+ try :
+ if sys.platform != "win32":
+ q = Popen(args, stdin=PIPE, stdout=PIPE, stderr=PIPE, cwd=cwd)
+ else:
+ # win32 don't have os.execvp() so have to run command in a shell
+ q = Popen(args, stdin=PIPE, stdout=PIPE, stderr=PIPE,
+ shell=True, cwd=cwd)
+ except OSError, e :
+ raise CommandError(args, status=e.args[0], stdout="", stderr=e)
+ output,error = q.communicate(input=stdin)
+ status = q.wait()
+ if self.verboseInvoke == True:
+ print >> sys.stderr, "%d\n%s%s" % (status, output, error)
+ if status not in expect:
+ raise CommandError(args, status, output, error)
+ return status, output, error
+ def _u_invoke_client(self, *args, **kwargs):
+ directory = kwargs.get('directory',None)
+ expect = kwargs.get('expect', (0,))
+ stdin = kwargs.get('stdin', None)
+ cl_args = [self.client]
+ cl_args.extend(args)
+ return self._u_invoke(cl_args, stdin=stdin,expect=expect,cwd=directory)
+ def _u_search_parent_directories(self, path, filename):
+ """
+ Find the file (or directory) named filename in path or in any
+ of path's parents.
+
+ e.g.
+ search_parent_directories("/a/b/c", ".be")
+ will return the path to the first existing file from
+ /a/b/c/.be
+ /a/b/.be
+ /a/.be
+ /.be
+ or None if none of those files exist.
+ """
+ return search_parent_directories(path, filename)
+ def _use_vcs(self, path, allow_no_vcs):
+ """
+ Try and decide if _vcs_add/update/mkdir/etc calls will
+ succeed. Returns True is we think the vcs_call would
+ succeeed, and False otherwise.
+ """
+ use_vcs = True
+ exception = None
+ if self.rootdir != None:
+ if self.path_in_root(path) == False:
+ use_vcs = False
+ exception = PathNotInRoot(path, self.rootdir)
+ else:
+ use_vcs = False
+ exception = VCSnotRooted
+ if use_vcs == False and allow_no_vcs==False:
+ raise exception
+ return use_vcs
+ def path_in_root(self, path, root=None):
+ """
+ Return the relative path to path from root.
+ >>> vcs = new()
+ >>> vcs.path_in_root("/a.b/c/.be", "/a.b/c")
+ True
+ >>> vcs.path_in_root("/a.b/.be", "/a.b/c")
+ False
+ """
+ if root == None:
+ if self.rootdir == None:
+ raise VCSnotRooted
+ root = self.rootdir
+ path = os.path.abspath(path)
+ absRoot = os.path.abspath(root)
+ absRootSlashedDir = os.path.join(absRoot,"")
+ if not path.startswith(absRootSlashedDir):
+ return False
+ return True
+ def _u_rel_path(self, path, root=None):
+ """
+ Return the relative path to path from root.
+ >>> vcs = new()
+ >>> vcs._u_rel_path("/a.b/c/.be", "/a.b/c")
+ '.be'
+ """
+ if root == None:
+ if self.rootdir == None:
+ raise VCSnotRooted
+ root = self.rootdir
+ path = os.path.abspath(path)
+ absRoot = os.path.abspath(root)
+ absRootSlashedDir = os.path.join(absRoot,"")
+ if not path.startswith(absRootSlashedDir):
+ raise PathNotInRoot(path, absRootSlashedDir)
+ assert path != absRootSlashedDir, \
+ "file %s == root directory %s" % (path, absRootSlashedDir)
+ relpath = path[len(absRootSlashedDir):]
+ return relpath
+ def _u_abspath(self, path, root=None):
+ """
+ Return the absolute path from a path realtive to root.
+ >>> vcs = new()
+ >>> vcs._u_abspath(".be", "/a.b/c")
+ '/a.b/c/.be'
+ """
+ if root == None:
+ assert self.rootdir != None, "VCS not rooted"
+ root = self.rootdir
+ return os.path.abspath(os.path.join(root, path))
+ def _u_create_id(self, name, email=None):
+ """
+ >>> vcs = new()
+ >>> vcs._u_create_id("John Doe", "jdoe@example.com")
+ 'John Doe <jdoe@example.com>'
+ >>> vcs._u_create_id("John Doe")
+ 'John Doe'
+ """
+ assert len(name) > 0
+ if email == None or len(email) == 0:
+ return name
+ else:
+ return "%s <%s>" % (name, email)
+ def _u_parse_id(self, value):
+ """
+ >>> vcs = new()
+ >>> vcs._u_parse_id("John Doe <jdoe@example.com>")
+ ('John Doe', 'jdoe@example.com')
+ >>> vcs._u_parse_id("John Doe")
+ ('John Doe', None)
+ >>> try:
+ ... vcs._u_parse_id("John Doe <jdoe@example.com><what?>")
+ ... except AssertionError:
+ ... print "Invalid match"
+ Invalid match
+ """
+ emailexp = re.compile("(.*) <([^>]*)>(.*)")
+ match = emailexp.search(value)
+ if match == None:
+ email = None
+ name = value
+ else:
+ assert len(match.groups()) == 3
+ assert match.groups()[2] == "", match.groups()
+ email = match.groups()[1]
+ name = match.groups()[0]
+ assert name != None
+ assert len(name) > 0
+ return (name, email)
+ def _u_get_fallback_username(self):
+ name = None
+ for envariable in ["LOGNAME", "USERNAME"]:
+ if os.environ.has_key(envariable):
+ name = os.environ[envariable]
+ break
+ assert name != None
+ return name
+ def _u_get_fallback_email(self):
+ hostname = gethostname()
+ name = self._u_get_fallback_username()
+ return "%s@%s" % (name, hostname)
+ def _u_parse_commitfile(self, commitfile):
+ """
+ Split the commitfile created in self.commit() back into
+ summary and header lines.
+ """
+ f = codecs.open(commitfile, "r", self.encoding)
+ summary = f.readline()
+ body = f.read()
+ body.lstrip('\n')
+ if len(body) == 0:
+ body = None
+ f.close()
+ return (summary, body)
+
+
+def setup_vcs_test_fixtures(testcase):
+ """Set up test fixtures for VCS test case."""
+ testcase.vcs = testcase.Class()
+ testcase.dir = Dir()
+ testcase.dirname = testcase.dir.path
+
+ vcs_not_supporting_uninitialized_user_id = []
+ vcs_not_supporting_set_user_id = ["None", "hg"]
+ testcase.vcs_supports_uninitialized_user_id = (
+ testcase.vcs.name not in vcs_not_supporting_uninitialized_user_id)
+ testcase.vcs_supports_set_user_id = (
+ testcase.vcs.name not in vcs_not_supporting_set_user_id)
+
+ if not testcase.vcs.installed():
+ testcase.fail(
+ "%(name)s VCS not found" % vars(testcase.Class))
+
+ if testcase.Class.name != "None":
+ testcase.failIf(
+ testcase.vcs.detect(testcase.dirname),
+ "Detected %(name)s VCS before initialising"
+ % vars(testcase.Class))
+
+ testcase.vcs.init(testcase.dirname)
+
+
+class VCSTestCase(unittest.TestCase):
+ """Test cases for base VCS class."""
+
+ Class = VCS
+
+ def __init__(self, *args, **kwargs):
+ super(VCSTestCase, self).__init__(*args, **kwargs)
+ self.dirname = None
+
+ def setUp(self):
+ super(VCSTestCase, self).setUp()
+ setup_vcs_test_fixtures(self)
+
+ def tearDown(self):
+ del(self.vcs)
+ super(VCSTestCase, self).tearDown()
+
+ def full_path(self, rel_path):
+ return os.path.join(self.dirname, rel_path)
+
+
+class VCS_init_TestCase(VCSTestCase):
+ """Test cases for VCS.init method."""
+
+ def test_detect_should_succeed_after_init(self):
+ """Should detect VCS in directory after initialization."""
+ self.failUnless(
+ self.vcs.detect(self.dirname),
+ "Did not detect %(name)s VCS after initialising"
+ % vars(self.Class))
+
+ def test_vcs_rootdir_in_specified_root_path(self):
+ """VCS root directory should be in specified root path."""
+ rp = os.path.realpath(self.vcs.rootdir)
+ dp = os.path.realpath(self.dirname)
+ vcs_name = self.Class.name
+ self.failUnless(
+ dp == rp or rp == None,
+ "%(vcs_name)s VCS root in wrong dir (%(dp)s %(rp)s)" % vars())
+
+
+class VCS_get_user_id_TestCase(VCSTestCase):
+ """Test cases for VCS.get_user_id method."""
+
+ def test_gets_existing_user_id(self):
+ """Should get the existing user ID."""
+ if not self.vcs_supports_uninitialized_user_id:
+ return
+
+ user_id = self.vcs.get_user_id()
+ self.failUnless(
+ user_id is not None,
+ "unable to get a user id")
+
+
+class VCS_set_user_id_TestCase(VCSTestCase):
+ """Test cases for VCS.set_user_id method."""
+
+ def setUp(self):
+ super(VCS_set_user_id_TestCase, self).setUp()
+
+ if self.vcs_supports_uninitialized_user_id:
+ self.prev_user_id = self.vcs.get_user_id()
+ else:
+ self.prev_user_id = "Uninitialized identity <bogus@example.org>"
+
+ if self.vcs_supports_set_user_id:
+ self.test_new_user_id = "John Doe <jdoe@example.com>"
+ self.vcs.set_user_id(self.test_new_user_id)
+
+ def tearDown(self):
+ if self.vcs_supports_set_user_id:
+ self.vcs.set_user_id(self.prev_user_id)
+ super(VCS_set_user_id_TestCase, self).tearDown()
+
+ def test_raises_error_in_unsupported_vcs(self):
+ """Should raise an error in a VCS that doesn't support it."""
+ if self.vcs_supports_set_user_id:
+ return
+ self.assertRaises(
+ SettingIDnotSupported,
+ self.vcs.set_user_id, "foo")
+
+ def test_updates_user_id_in_supporting_vcs(self):
+ """Should update the user ID in an VCS that supports it."""
+ if not self.vcs_supports_set_user_id:
+ return
+ user_id = self.vcs.get_user_id()
+ self.failUnlessEqual(
+ self.test_new_user_id, user_id,
+ "user id not set correctly (expected %s, got %s)"
+ % (self.test_new_user_id, user_id))
+
+
+def setup_vcs_revision_test_fixtures(testcase):
+ """Set up revision test fixtures for VCS test case."""
+ testcase.test_dirs = ['a', 'a/b', 'c']
+ for path in testcase.test_dirs:
+ testcase.vcs.mkdir(testcase.full_path(path))
+
+ testcase.test_files = ['a/text', 'a/b/text']
+
+ testcase.test_contents = {
+ 'rev_1': "Lorem ipsum",
+ 'uncommitted': "dolor sit amet",
+ }
+
+
+class VCS_mkdir_TestCase(VCSTestCase):
+ """Test cases for VCS.mkdir method."""
+
+ def setUp(self):
+ super(VCS_mkdir_TestCase, self).setUp()
+ setup_vcs_revision_test_fixtures(self)
+
+ def tearDown(self):
+ for path in reversed(sorted(self.test_dirs)):
+ self.vcs.recursive_remove(self.full_path(path))
+ super(VCS_mkdir_TestCase, self).tearDown()
+
+ def test_mkdir_creates_directory(self):
+ """Should create specified directory in filesystem."""
+ for path in self.test_dirs:
+ full_path = self.full_path(path)
+ self.failUnless(
+ os.path.exists(full_path),
+ "path %(full_path)s does not exist" % vars())
+
+
+class VCS_commit_TestCase(VCSTestCase):
+ """Test cases for VCS.commit method."""
+
+ def setUp(self):
+ super(VCS_commit_TestCase, self).setUp()
+ setup_vcs_revision_test_fixtures(self)
+
+ def tearDown(self):
+ for path in reversed(sorted(self.test_dirs)):
+ self.vcs.recursive_remove(self.full_path(path))
+ super(VCS_commit_TestCase, self).tearDown()
+
+ def test_file_contents_as_specified(self):
+ """Should set file contents as specified."""
+ test_contents = self.test_contents['rev_1']
+ for path in self.test_files:
+ full_path = self.full_path(path)
+ self.vcs.set_file_contents(full_path, test_contents)
+ current_contents = self.vcs.get_file_contents(full_path)
+ self.failUnlessEqual(test_contents, current_contents)
+
+ def test_file_contents_as_committed(self):
+ """Should have file contents as specified after commit."""
+ test_contents = self.test_contents['rev_1']
+ for path in self.test_files:
+ full_path = self.full_path(path)
+ self.vcs.set_file_contents(full_path, test_contents)
+ revision = self.vcs.commit("Initial file contents.")
+ current_contents = self.vcs.get_file_contents(full_path)
+ self.failUnlessEqual(test_contents, current_contents)
+
+ def test_file_contents_as_set_when_uncommitted(self):
+ """Should set file contents as specified after commit."""
+ if not self.vcs.versioned:
+ return
+ for path in self.test_files:
+ full_path = self.full_path(path)
+ self.vcs.set_file_contents(
+ full_path, self.test_contents['rev_1'])
+ revision = self.vcs.commit("Initial file contents.")
+ self.vcs.set_file_contents(
+ full_path, self.test_contents['uncommitted'])
+ current_contents = self.vcs.get_file_contents(full_path)
+ self.failUnlessEqual(
+ self.test_contents['uncommitted'], current_contents)
+
+ def test_revision_file_contents_as_committed(self):
+ """Should get file contents as committed to specified revision."""
+ if not self.vcs.versioned:
+ return
+ for path in self.test_files:
+ full_path = self.full_path(path)
+ self.vcs.set_file_contents(
+ full_path, self.test_contents['rev_1'])
+ revision = self.vcs.commit("Initial file contents.")
+ self.vcs.set_file_contents(
+ full_path, self.test_contents['uncommitted'])
+ committed_contents = self.vcs.get_file_contents(
+ full_path, revision)
+ self.failUnlessEqual(
+ self.test_contents['rev_1'], committed_contents)
+
+ def test_revision_id_as_committed(self):
+ """Check for compatibility between .commit() and .revision_id()"""
+ if not self.vcs.versioned:
+ self.failUnlessEqual(self.vcs.revision_id(5), None)
+ return
+ committed_revisions = []
+ for path in self.test_files:
+ full_path = self.full_path(path)
+ self.vcs.set_file_contents(
+ full_path, self.test_contents['rev_1'])
+ revision = self.vcs.commit("Initial %s contents." % path)
+ committed_revisions.append(revision)
+ self.vcs.set_file_contents(
+ full_path, self.test_contents['uncommitted'])
+ revision = self.vcs.commit("Altered %s contents." % path)
+ committed_revisions.append(revision)
+ for i,revision in enumerate(committed_revisions):
+ self.failUnlessEqual(self.vcs.revision_id(i), revision)
+ i += -len(committed_revisions) # check negative indices
+ self.failUnlessEqual(self.vcs.revision_id(i), revision)
+ i = len(committed_revisions)
+ self.failUnlessEqual(self.vcs.revision_id(i), None)
+ self.failUnlessEqual(self.vcs.revision_id(-i-1), None)
+
+ def test_revision_id_as_committed(self):
+ """Check revision id before first commit"""
+ if not self.vcs.versioned:
+ self.failUnlessEqual(self.vcs.revision_id(5), None)
+ return
+ committed_revisions = []
+ for path in self.test_files:
+ self.failUnlessEqual(self.vcs.revision_id(0), None)
+
+
+class VCS_duplicate_repo_TestCase(VCSTestCase):
+ """Test cases for VCS.duplicate_repo method."""
+
+ def setUp(self):
+ super(VCS_duplicate_repo_TestCase, self).setUp()
+ setup_vcs_revision_test_fixtures(self)
+
+ def tearDown(self):
+ self.vcs.remove_duplicate_repo()
+ for path in reversed(sorted(self.test_dirs)):
+ self.vcs.recursive_remove(self.full_path(path))
+ super(VCS_duplicate_repo_TestCase, self).tearDown()
+
+ def test_revision_file_contents_as_committed(self):
+ """Should match file contents as committed to specified revision."""
+ if not self.vcs.versioned:
+ return
+ for path in self.test_files:
+ full_path = self.full_path(path)
+ self.vcs.set_file_contents(
+ full_path, self.test_contents['rev_1'])
+ revision = self.vcs.commit("Commit current status")
+ self.vcs.set_file_contents(
+ full_path, self.test_contents['uncommitted'])
+ dup_repo_path = self.vcs.duplicate_repo(revision)
+ dup_file_path = os.path.join(dup_repo_path, path)
+ dup_file_contents = file(dup_file_path, 'rb').read()
+ self.failUnlessEqual(
+ self.test_contents['rev_1'], dup_file_contents)
+ self.vcs.remove_duplicate_repo()
+
+
+def make_vcs_testcase_subclasses(vcs_class, namespace):
+ """Make VCSTestCase subclasses for vcs_class in the namespace."""
+ vcs_testcase_classes = [
+ c for c in (
+ ob for ob in globals().values() if isinstance(ob, type))
+ if issubclass(c, VCSTestCase)]
+
+ for base_class in vcs_testcase_classes:
+ testcase_class_name = vcs_class.__name__ + base_class.__name__
+ testcase_class_bases = (base_class,)
+ testcase_class_dict = dict(base_class.__dict__)
+ testcase_class_dict['Class'] = vcs_class
+ testcase_class = type(
+ testcase_class_name, testcase_class_bases, testcase_class_dict)
+ setattr(namespace, testcase_class_name, testcase_class)
+
+
+unitsuite = unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
+suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
diff --git a/interfaces/email/interactive/libbe/version.py b/interfaces/email/interactive/libbe/version.py
new file mode 100644
index 0000000..f8eebbd
--- /dev/null
+++ b/interfaces/email/interactive/libbe/version.py
@@ -0,0 +1,50 @@
+#!/usr/bin/env python
+# Copyright (C) 2009 W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""
+Store version info for this BE installation. By default, use the
+bzr-generated information in _version.py, but allow manual overriding
+by setting _VERSION. This allows support of both the "I don't want to
+be bothered setting version strings" and the "I want complete control
+over the version strings" workflows.
+"""
+
+import libbe._version as _version
+
+# Manually set a version string (optional, defaults to bzr revision id)
+#_VERSION = "1.2.3"
+
+def version(verbose=False):
+ """
+ Returns the version string for this BE installation. If
+ verbose==True, the string will include extra lines with more
+ detail (e.g. bzr branch nickname, etc.).
+ """
+ if "_VERSION" in globals():
+ string = _VERSION
+ else:
+ string = _version.version_info["revision_id"]
+ if verbose == True:
+ string += ("\n"
+ "revision: %(revno)d\n"
+ "nick: %(branch_nick)s\n"
+ "revision id: %(revision_id)s"
+ % _version.version_info)
+ return string
+
+if __name__ == "__main__":
+ print version(verbose=True)
diff --git a/interfaces/email/interactive/send_pgp_mime.py b/interfaces/email/interactive/send_pgp_mime.py
new file mode 100644
index 0000000..c19483e
--- /dev/null
+++ b/interfaces/email/interactive/send_pgp_mime.py
@@ -0,0 +1,611 @@
+#!/usr/bin/python
+#
+# Copyright (C) 2009 W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+"""
+Python module and command line tool for sending pgp/mime email.
+
+Mostly uses subprocess to call gpg and a sendmail-compatible mailer.
+If you lack gpg, either don't use the encryption functions or adjust
+the pgp_* commands. You may need to adjust the sendmail command to
+point to whichever sendmail-compatible mailer you have on your system.
+"""
+
+from cStringIO import StringIO
+import os
+import re
+#import GnuPGInterface # Maybe should use this instead of subprocess
+import smtplib
+import subprocess
+import sys
+import tempfile
+import types
+
+try:
+ from email import Message
+ from email.mime.text import MIMEText
+ from email.mime.multipart import MIMEMultipart
+ from email.mime.application import MIMEApplication
+ from email.encoders import encode_7or8bit
+ from email.generator import Generator
+ from email.parser import Parser
+ from email.utils import getaddress
+except ImportError:
+ # adjust to old python 2.4
+ from email import Message
+ from email.MIMEText import MIMEText
+ from email.MIMEMultipart import MIMEMultipart
+ from email.MIMENonMultipart import MIMENonMultipart
+ from email.Encoders import encode_7or8bit
+ from email.Generator import Generator
+ from email.Parser import Parser
+ from email.Utils import getaddresses
+
+ getaddress = getaddresses
+ class MIMEApplication (MIMENonMultipart):
+ def __init__(self, _data, _subtype, _encoder, **params):
+ MIMENonMultipart.__init__(self, 'application', _subtype, **params)
+ self.set_payload(_data)
+ _encoder(self)
+
+usage="""usage: %prog [options]
+
+Scriptable PGP MIME email using gpg.
+
+You can use gpg-agent for passphrase caching if your key requires a
+passphrase (it better!). Example usage would be to install gpg-agent,
+and then run
+ export GPG_TTY=`tty`
+ eval $(gpg-agent --daemon)
+in your shell before invoking this script. See gpg-agent(1) for more
+details. Alternatively, you can send your passphrase in on stdin
+ echo 'passphrase' | %prog [options]
+or use the --passphrase-file option
+ %prog [options] --passphrase-file FILE [more options]
+Both of these alternatives are much less secure than gpg-agent. You
+have been warned.
+"""
+
+verboseInvoke = False
+PGP_SIGN_AS = None
+PASSPHRASE = None
+
+# The following commands are adapted from my .mutt/pgp configuration
+#
+# Printf-like sequences:
+# %a The value of PGP_SIGN_AS.
+# %f Expands to the name of a file with text to be signed/encrypted.
+# %p Expands to the passphrase argument.
+# %R A string with some number (0 on up) of pgp_reciepient_arg
+# strings.
+# %r One key ID (e.g. recipient email address) to build a
+# pgp_reciepient_arg string.
+#
+# The above sequences can be used to optionally print a string if
+# their length is nonzero. For example, you may only want to pass the
+# -u/--local-user argument to gpg if PGP_SIGN_AS is defined. To
+# optionally print a string based upon one of the above sequences, the
+# following construct is used
+# %?<sequence_char>?<optional_string>?
+# where sequence_char is a character from the table above, and
+# optional_string is the string you would like printed if status_char
+# is nonzero. optional_string may contain other sequence as well as
+# normal text, but it may not contain any question marks.
+#
+# see http://codesorcery.net/old/mutt/mutt-gnupg-howto
+# http://www.mutt.org/doc/manual/manual-6.html#pgp_autosign
+# http://tldp.org/HOWTO/Mutt-GnuPG-PGP-HOWTO-8.html
+# for more details
+
+pgp_recipient_arg='-r "%r"'
+pgp_stdin_passphrase_arg='--passphrase-fd 0'
+pgp_sign_command='/usr/bin/gpg --no-verbose --quiet --batch %p --output - --detach-sign --armor --textmode %?a?-u "%a"? %f'
+pgp_encrypt_only_command='/usr/bin/gpg --no-verbose --quiet --batch --output - --encrypt --armor --textmode --always-trust --encrypt-to "%a" %R -- %f'
+pgp_encrypt_sign_command='/usr/bin/gpg --no-verbose --quiet --batch %p --output - --encrypt --sign %?a?-u "%a"? --armor --textmode --always-trust --encrypt-to "%a" %R -- %f'
+sendmail='/usr/sbin/sendmail -t'
+
+def mail(msg, sendmail=None):
+ """
+ Send an email Message instance on its merry way.
+
+ We can shell out to the user specified sendmail in case
+ the local host doesn't have an SMTP server set up
+ for easy smtplib usage.
+ """
+ if sendmail != None:
+ execute(sendmail, stdin=flatten(msg))
+ return None
+ s = smtplib.SMTP()
+ s.connect()
+ s.sendmail(from_addr=source_email(msg),
+ to_addrs=target_emails(msg),
+ msg=flatten(msg))
+ s.close()
+
+def header_from_text(text, encoding="us-ascii"):
+ """
+ Simple wrapper for instantiating an email.Message from text.
+ >>> header = header_from_text('\\n'.join(['From: me@big.edu','To: you@big.edu','Subject: testing']))
+ >>> print flatten(header)
+ From: me@big.edu
+ To: you@big.edu
+ Subject: testing
+ <BLANKLINE>
+ <BLANKLINE>
+ """
+ text = text.strip()
+ if type(text) == types.UnicodeType:
+ text = text.encode(encoding)
+ # assume StringType arguments are already encoded
+ p = Parser()
+ return p.parsestr(text, headersonly=True)
+
+def guess_encoding(text):
+ if type(text) == types.StringType:
+ encoding = "us-ascii"
+ elif type(text) == types.UnicodeType:
+ for encoding in ["us-ascii", "iso-8859-1", "utf-8"]:
+ try:
+ text.encode(encoding)
+ except UnicodeError:
+ pass
+ else:
+ break
+ assert encoding != None
+ return encoding
+
+def encodedMIMEText(body, encoding=None):
+ if encoding == None:
+ encoding = guess_encoding(body)
+ if encoding == "us-ascii":
+ return MIMEText(body)
+ else:
+ # Create the message ('plain' stands for Content-Type: text/plain)
+ return MIMEText(body.encode(encoding), 'plain', encoding)
+
+def append_text(text_part, new_text):
+ original_payload = text_part.get_payload(decode=True)
+ new_payload = u"%s%s" % (original_payload, new_text)
+ new_encoding = guess_encoding(new_payload)
+ text_part.set_payload(new_payload.encode(new_encoding), new_encoding)
+
+def attach_root(header, root_part):
+ """
+ Attach the email.Message root_part to the email.Message header
+ without generating a multi-part message.
+ """
+ for k,v in header.items():
+ root_part[k] = v
+ return root_part
+
+def execute(args, stdin=None, expect=(0,)):
+ """
+ Execute a command (allows us to drive gpg).
+ """
+ if verboseInvoke == True:
+ print >> sys.stderr, '$ '+args
+ try:
+ p = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, close_fds=True)
+ except OSError, e:
+ strerror = '%s\nwhile executing %s' % (e.args[1], args)
+ raise Exception, strerror
+ output, error = p.communicate(input=stdin)
+ status = p.wait()
+ if verboseInvoke == True:
+ print >> sys.stderr, '(status: %d)\n%s%s' % (status, output, error)
+ if status not in expect:
+ strerror = '%s\nwhile executing %s\n%s\n%d' % (args[1], args, error, status)
+ raise Exception, strerror
+ return status, output, error
+
+def replace(template, format_char, replacement_text):
+ """
+ >>> replace('--textmode %?a?-u %a? %f', 'f', 'file.in')
+ '--textmode %?a?-u %a? file.in'
+ >>> replace('--textmode %?a?-u %a? %f', 'a', '0xHEXKEY')
+ '--textmode -u 0xHEXKEY %f'
+ >>> replace('--textmode %?a?-u %a? %f', 'a', '')
+ '--textmode %f'
+ """
+ if replacement_text == None:
+ replacement_text = ""
+ regexp = re.compile('%[?]'+format_char+'[?]([^?]*)[?]')
+ if len(replacement_text) > 0:
+ str = regexp.sub('\g<1>', template)
+ else:
+ str = regexp.sub('', template)
+ regexp = re.compile('%'+format_char)
+ str = regexp.sub(replacement_text, str)
+ return str
+
+def flatten(msg, to_unicode=False):
+ """
+ Produce flat text output from an email Message instance.
+ """
+ assert msg != None
+ fp = StringIO()
+ g = Generator(fp, mangle_from_=False)
+ g.flatten(msg)
+ text = fp.getvalue()
+ if to_unicode == True:
+ encoding = msg.get_content_charset() or "utf-8"
+ text = unicode(text, encoding=encoding)
+ return text
+
+def source_email(msg, return_realname=False):
+ """
+ Search the header of an email Message instance to find the
+ sender's email address.
+ """
+ froms = msg.get_all('from', [])
+ from_tuples = getaddresses(froms) # [(realname, email_address), ...]
+ assert len(from_tuples) == 1
+ if return_realname == True:
+ return from_tuples[0] # (realname, email_address)
+ return from_tuples[0][1] # email_address
+
+def target_emails(msg):
+ """
+ Search the header of an email Message instance to find a
+ list of recipient's email addresses.
+ """
+ tos = msg.get_all('to', [])
+ ccs = msg.get_all('cc', [])
+ bccs = msg.get_all('bcc', [])
+ resent_tos = msg.get_all('resent-to', [])
+ resent_ccs = msg.get_all('resent-cc', [])
+ resent_bccs = msg.get_all('resent-bcc', [])
+ all_recipients = getaddresses(tos + ccs + bccs + resent_tos
+ + resent_ccs + resent_bccs)
+ return [addr[1] for addr in all_recipients]
+
+class PGPMimeMessageFactory (object):
+ """
+ See http://www.ietf.org/rfc/rfc3156.txt for specification details.
+ >>> from_addr = "me@big.edu"
+ >>> to_addr = "you@you.edu"
+ >>> header = header_from_text('\\n'.join(['From: %s'%from_addr,'To: %s'%to_addr,'Subject: testing']))
+ >>> source_email(header) == from_addr
+ True
+ >>> target_emails(header) == [to_addr]
+ True
+ >>> m = PGPMimeMessageFactory('check 1 2\\ncheck 1 2\\n')
+ >>> print flatten(m.clearBodyPart())
+ Content-Type: text/plain; charset="us-ascii"
+ MIME-Version: 1.0
+ Content-Transfer-Encoding: 7bit
+ Content-Disposition: inline
+ <BLANKLINE>
+ check 1 2
+ check 1 2
+ <BLANKLINE>
+ >>> print flatten(m.plain())
+ Content-Type: text/plain; charset="us-ascii"
+ MIME-Version: 1.0
+ Content-Transfer-Encoding: 7bit
+ <BLANKLINE>
+ check 1 2
+ check 1 2
+ <BLANKLINE>
+ >>> signed = m.sign(header)
+ >>> signed.set_boundary('boundsep')
+ >>> print flatten(signed).replace('\\t', ' '*4) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
+ Content-Type: multipart/signed; protocol="application/pgp-signature";
+ micalg="pgp-sha1"; boundary="boundsep"
+ MIME-Version: 1.0
+ Content-Disposition: inline
+ <BLANKLINE>
+ --boundsep
+ Content-Type: text/plain; charset="us-ascii"
+ MIME-Version: 1.0
+ Content-Transfer-Encoding: 7bit
+ Content-Disposition: inline
+ <BLANKLINE>
+ check 1 2
+ check 1 2
+ <BLANKLINE>
+ --boundsep
+ MIME-Version: 1.0
+ Content-Transfer-Encoding: 7bit
+ Content-Description: signature
+ Content-Type: application/pgp-signature; name="signature.asc";
+ charset="us-ascii"
+ <BLANKLINE>
+ -----BEGIN PGP SIGNATURE-----
+ ...
+ -----END PGP SIGNATURE-----
+ <BLANKLINE>
+ --boundsep--
+ >>> encrypted = m.encrypt(header)
+ >>> encrypted.set_boundary('boundsep')
+ >>> print flatten(encrypted).replace('\\t', ' '*4) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
+ Content-Type: multipart/encrypted;
+ protocol="application/pgp-encrypted";
+ micalg="pgp-sha1"; boundary="boundsep"
+ MIME-Version: 1.0
+ Content-Disposition: inline
+ <BLANKLINE>
+ --boundsep
+ Content-Type: application/pgp-encrypted
+ MIME-Version: 1.0
+ Content-Transfer-Encoding: 7bit
+ <BLANKLINE>
+ Version: 1
+ <BLANKLINE>
+ --boundsep
+ MIME-Version: 1.0
+ Content-Transfer-Encoding: 7bit
+ Content-Type: application/octet-stream; charset="us-ascii"
+ <BLANKLINE>
+ -----BEGIN PGP MESSAGE-----
+ ...
+ -----END PGP MESSAGE-----
+ <BLANKLINE>
+ --boundsep--
+ >>> signedAndEncrypted = m.signAndEncrypt(header)
+ >>> signedAndEncrypted.set_boundary('boundsep')
+ >>> print flatten(signedAndEncrypted).replace('\\t', ' '*4) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
+ Content-Type: multipart/encrypted;
+ protocol="application/pgp-encrypted";
+ micalg="pgp-sha1"; boundary="boundsep"
+ MIME-Version: 1.0
+ Content-Disposition: inline
+ <BLANKLINE>
+ --boundsep
+ Content-Type: application/pgp-encrypted
+ MIME-Version: 1.0
+ Content-Transfer-Encoding: 7bit
+ <BLANKLINE>
+ Version: 1
+ <BLANKLINE>
+ --boundsep
+ MIME-Version: 1.0
+ Content-Transfer-Encoding: 7bit
+ Content-Type: application/octet-stream; charset="us-ascii"
+ <BLANKLINE>
+ -----BEGIN PGP MESSAGE-----
+ ...
+ -----END PGP MESSAGE-----
+ <BLANKLINE>
+ --boundsep--
+ """
+ def __init__(self, body):
+ self.body = body
+ def clearBodyPart(self):
+ body = encodedMIMEText(self.body)
+ body.add_header('Content-Disposition', 'inline')
+ return body
+ def passphrase_arg(self, passphrase=None):
+ if passphrase == None and PASSPHRASE != None:
+ passphrase = PASSPHRASE
+ if passphrase == None:
+ return (None,'')
+ return (passphrase, pgp_stdin_passphrase_arg)
+ def plain(self):
+ """
+ text/plain
+ """
+ return encodedMIMEText(self.body)
+ def sign(self, header, passphrase=None):
+ """
+ multipart/signed
+ +-> text/plain (body)
+ +-> application/pgp-signature (signature)
+ """
+ passphrase,pass_arg = self.passphrase_arg(passphrase)
+ body = self.clearBodyPart()
+ bfile = tempfile.NamedTemporaryFile()
+ bfile.write(flatten(body))
+ bfile.flush()
+
+ args = replace(pgp_sign_command, 'f', bfile.name)
+ if PGP_SIGN_AS == None:
+ pgp_sign_as = '<%s>' % source_email(header)
+ else:
+ pgp_sign_as = PGP_SIGN_AS
+ args = replace(args, 'a', pgp_sign_as)
+ args = replace(args, 'p', pass_arg)
+ status,output,error = execute(args, stdin=passphrase)
+ signature = output
+
+ sig = MIMEApplication(_data=signature,
+ _subtype='pgp-signature; name="signature.asc"',
+ _encoder=encode_7or8bit)
+ sig['Content-Description'] = 'signature'
+ sig.set_charset('us-ascii')
+
+ msg = MIMEMultipart('signed', micalg='pgp-sha1',
+ protocol='application/pgp-signature')
+ msg.attach(body)
+ msg.attach(sig)
+
+ msg['Content-Disposition'] = 'inline'
+ return msg
+ def encrypt(self, header, passphrase=None):
+ """
+ multipart/encrypted
+ +-> application/pgp-encrypted (control information)
+ +-> application/octet-stream (body)
+ """
+ body = self.clearBodyPart()
+ bfile = tempfile.NamedTemporaryFile()
+ bfile.write(flatten(body))
+ bfile.flush()
+
+ recipients = [replace(pgp_recipient_arg, 'r', recipient)
+ for recipient in target_emails(header)]
+ recipient_string = ' '.join(recipients)
+ args = replace(pgp_encrypt_only_command, 'R', recipient_string)
+ args = replace(args, 'f', bfile.name)
+ if PGP_SIGN_AS == None:
+ pgp_sign_as = '<%s>' % source_email(header)
+ else:
+ pgp_sign_as = PGP_SIGN_AS
+ args = replace(args, 'a', pgp_sign_as)
+ status,output,error = execute(args)
+ encrypted = output
+
+ enc = MIMEApplication(_data=encrypted, _subtype='octet-stream',
+ _encoder=encode_7or8bit)
+ enc.set_charset('us-ascii')
+
+ control = MIMEApplication(_data='Version: 1\n', _subtype='pgp-encrypted',
+ _encoder=encode_7or8bit)
+
+ msg = MIMEMultipart('encrypted', micalg='pgp-sha1',
+ protocol='application/pgp-encrypted')
+ msg.attach(control)
+ msg.attach(enc)
+
+ msg['Content-Disposition'] = 'inline'
+ return msg
+ def signAndEncrypt(self, header, passphrase=None):
+ """
+ multipart/encrypted
+ +-> application/pgp-encrypted (control information)
+ +-> application/octet-stream (body)
+ """
+ passphrase,pass_arg = self.passphrase_arg(passphrase)
+ body = self.sign(header, passphrase)
+ body.__delitem__('Bcc')
+ bfile = tempfile.NamedTemporaryFile()
+ bfile.write(flatten(body))
+ bfile.flush()
+
+ recipients = [replace(pgp_recipient_arg, 'r', recipient)
+ for recipient in target_emails(header)]
+ recipient_string = ' '.join(recipients)
+ args = replace(pgp_encrypt_only_command, 'R', recipient_string)
+ args = replace(args, 'f', bfile.name)
+ if PGP_SIGN_AS == None:
+ pgp_sign_as = '<%s>' % source_email(header)
+ else:
+ pgp_sign_as = PGP_SIGN_AS
+ args = replace(args, 'a', pgp_sign_as)
+ args = replace(args, 'p', pass_arg)
+ status,output,error = execute(args, stdin=passphrase)
+ encrypted = output
+
+ enc = MIMEApplication(_data=encrypted, _subtype='octet-stream',
+ _encoder=encode_7or8bit)
+ enc.set_charset('us-ascii')
+
+ control = MIMEApplication(_data='Version: 1\n',
+ _subtype='pgp-encrypted',
+ _encoder=encode_7or8bit)
+
+ msg = MIMEMultipart('encrypted', micalg='pgp-sha1',
+ protocol='application/pgp-encrypted')
+ msg.attach(control)
+ msg.attach(enc)
+
+ msg['Content-Disposition'] = 'inline'
+ return msg
+
+def test():
+ import doctest
+ doctest.testmod()
+
+
+if __name__ == '__main__':
+ from optparse import OptionParser
+
+ parser = OptionParser(usage=usage)
+ parser.add_option('-t', '--test', dest='test', action='store_true',
+ help='Run doctests and exit')
+
+ parser.add_option('-H', '--header-file', dest='header_filename',
+ help='file containing email header', metavar='FILE')
+ parser.add_option('-B', '--body-file', dest='body_filename',
+ help='file containing email body', metavar='FILE')
+
+ parser.add_option('-P', '--passphrase-file', dest='passphrase_file',
+ help='file containing gpg passphrase', metavar='FILE')
+ parser.add_option('-p', '--passphrase-fd', dest='passphrase_fd',
+ help='file descriptor from which to read gpg passphrase (0 for stdin)',
+ type="int", metavar='DESCRIPTOR')
+
+ parser.add_option('--mode', dest='mode', default='sign',
+ help="One of 'sign', 'encrypt', 'sign-encrypt', or 'plain'. Defaults to %default.",
+ metavar='MODE')
+
+ parser.add_option('-a', '--sign-as', dest='sign_as',
+ help="The gpg key to sign with (gpg's -u/--local-user)",
+ metavar='KEY')
+
+ parser.add_option('--output', dest='output', action='store_true',
+ help="Don't mail the generated message, print it to stdout instead.")
+
+ (options, args) = parser.parse_args()
+
+ stdin_used = False
+
+ if options.passphrase_file != None:
+ PASSPHRASE = file(options.passphrase_file, 'r').read()
+ elif options.passphrase_fd != None:
+ if options.passphrase_fd == 0:
+ stdin_used = True
+ PASSPHRASE = sys.stdin.read()
+ else:
+ PASSPHRASE = os.read(options.passphrase_fd)
+
+ if options.sign_as:
+ PGP_SIGN_AS = options.sign_as
+
+ if options.test == True:
+ test()
+ sys.exit(0)
+
+ header = None
+ if options.header_filename != None:
+ if options.header_filename == '-':
+ assert stdin_used == False
+ stdin_used = True
+ header = sys.stdin.read()
+ else:
+ header = file(options.header_filename, 'r').read()
+ if header == None:
+ raise Exception, "missing header"
+ headermsg = header_from_text(header)
+ body = None
+ if options.body_filename != None:
+ if options.body_filename == '-':
+ assert stdin_used == False
+ stdin_used = True
+ body = sys.stdin.read()
+ else:
+ body = file(options.body_filename, 'r').read()
+ if body == None:
+ raise Exception, "missing body"
+
+ m = PGPMimeMessageFactory(body)
+ if options.mode == "sign":
+ bodymsg = m.sign(header)
+ elif options.mode == "encrypt":
+ bodymsg = m.encrypt(header)
+ elif options.mode == "sign-encrypt":
+ bodymsg = m.signAndEncrypt(header)
+ elif options.mode == "plain":
+ bodymsg = m.plain()
+ else:
+ print "Unrecognized mode '%s'" % options.mode
+
+ message = attach_root(headermsg, bodymsg)
+ if options.output == True:
+ message = flatten(message)
+ print message
+ else:
+ mail(message, sendmail)