diff options
author | W. Trevor King <wking@drexel.edu> | 2009-11-21 15:06:10 -0500 |
---|---|---|
committer | W. Trevor King <wking@drexel.edu> | 2009-11-21 15:06:10 -0500 |
commit | f3de7e1a6d07b5488fd3c9e01caba53216e612d2 (patch) | |
tree | 9ad63d3c5e960271eac24b13e94df741dca74f43 | |
parent | 64cb5e5ec672cd357bc66a8480465e531db25f52 (diff) | |
parent | 614d4e40e148520ac511cbe0606bcbdcf24c8a08 (diff) | |
download | bugseverywhere-f3de7e1a6d07b5488fd3c9e01caba53216e612d2.tar.gz |
Merged mostly completed `be email-bugs'.
Highlights:
* new be commands 'email-bugs' and 'import-xml'
* standardized <be-xml> format for XML files.
* new be-handle-mail interface '[be-bug:xml]'
* restrict_file_access security patch
* new subprocess handling submodule libbe.subproc
* test.py adjusted to use an installed VCS for most tests.
* assorted bugfixes
Altered interfaces to the following be commands:
* comment --xml tag gone, use import-xml.
* show --xml xml format updated to <be-xml> format.
Also adjusted be-mbox-to-xml and be-xml-to-mbox to handle new <be-xml>
format and provide better handling of *.extra_strings.
45 files changed, 1340 insertions, 478 deletions
diff --git a/.be/bugs/56506b73-36cc-4e32-a578-258a219edba8/comments/0a995544-20dc-42a6-8d3f-348ebbc8921e/body b/.be/bugs/56506b73-36cc-4e32-a578-258a219edba8/comments/0a995544-20dc-42a6-8d3f-348ebbc8921e/body new file mode 100644 index 0000000..8596c92 --- /dev/null +++ b/.be/bugs/56506b73-36cc-4e32-a578-258a219edba8/comments/0a995544-20dc-42a6-8d3f-348ebbc8921e/body @@ -0,0 +1,18 @@ +Since we'll be distributing a non-bzr-repo version, it would be nice +to adapt our 'submit bug' procedure + $ be new "The demuxulizer is broken" + Created bug with ID 48f + $ be comment 48f + <Describe bug> + $ bzr commit --message "Reported bug in demuxulizer" + $ bzr send --mail-to "be-devel@bugseverywhere.org" +to one that works with this setup. Without guaranteed versioning, +that would probably be something along the lines of + $ be new "The demuxulizer is broken" + Created bug with ID 48f + $ be comment 48f + <Describe bug> + $ be email-bugs [--to be-devel@bugseverywhere.org] 48f +With interfaces/email/interactive listening on the recieving end to +grab new-bug emails and import them into an incoming bug repository. + diff --git a/.be/bugs/56506b73-36cc-4e32-a578-258a219edba8/comments/0a995544-20dc-42a6-8d3f-348ebbc8921e/values b/.be/bugs/56506b73-36cc-4e32-a578-258a219edba8/comments/0a995544-20dc-42a6-8d3f-348ebbc8921e/values new file mode 100644 index 0000000..4bd8f81 --- /dev/null +++ b/.be/bugs/56506b73-36cc-4e32-a578-258a219edba8/comments/0a995544-20dc-42a6-8d3f-348ebbc8921e/values @@ -0,0 +1,8 @@ +Author: W. Trevor King <wking@drexel.edu> + + +Content-type: text/plain + + +Date: Fri, 20 Nov 2009 13:31:25 +0000 + diff --git a/.be/bugs/56506b73-36cc-4e32-a578-258a219edba8/values b/.be/bugs/56506b73-36cc-4e32-a578-258a219edba8/values new file mode 100644 index 0000000..2e15ca9 --- /dev/null +++ b/.be/bugs/56506b73-36cc-4e32-a578-258a219edba8/values @@ -0,0 +1,17 @@ +creator: W. Trevor King <wking@drexel.edu> + + +reporter: W. Trevor King <wking@drexel.edu> + + +severity: minor + + +status: open + + +summary: be email-bugs for bug submission from bzr-less users + + +time: Fri, 20 Nov 2009 13:26:59 +0000 + @@ -10,11 +10,19 @@ To fit into the current framework, your extension module should provide the following elements: __desc__ A short string describing the purpose of your plugin - execute(args) + execute(args, manipulate_encodings=True, restrict_file_access=False) The entry function for your plugin. args is everything from sys.argv after the name of your plugin (e.g. for the command `be open abc', args=['abc']). + manipulate_encodings should be passed through to any calls to + bugdir.BugDir(). See the BugDir documentation for details. + + If restrict_file_access==True, you should call + cmdutil.restrict_file_access(bugdir, path) + before attempting to read or write a file. See the + restrict_file_access documentation for details. + Note: be supports command-completion. To avoid raising errors you need to deal with possible '--complete' options and arguments. See the 'Command completion' section below for more information. diff --git a/becommands/assign.py b/becommands/assign.py index fbef281..2c78f69 100644 --- a/becommands/assign.py +++ b/becommands/assign.py @@ -21,7 +21,7 @@ from libbe import cmdutil, bugdir __desc__ = __doc__ -def execute(args, manipulate_encodings=True): +def execute(args, manipulate_encodings=True, restrict_file_access=False): """ >>> import os >>> bd = bugdir.SimpleBugDir() @@ -57,7 +57,7 @@ def execute(args, manipulate_encodings=True): 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 = cmdutil.bug_from_id(bd, args[0]) bug = bd.bug_from_shortname(args[0]) if len(args) == 1: bug.assigned = bd.user_id diff --git a/becommands/close.py b/becommands/close.py index 2cdcb59..a14cea8 100644 --- a/becommands/close.py +++ b/becommands/close.py @@ -21,7 +21,7 @@ from libbe import cmdutil, bugdir __desc__ = __doc__ -def execute(args, manipulate_encodings=True): +def execute(args, manipulate_encodings=True, restrict_file_access=False): """ >>> from libbe import bugdir >>> import os @@ -45,7 +45,7 @@ def execute(args, manipulate_encodings=True): 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 = cmdutil.bug_from_id(bd, args[0]) bug.status = "closed" bd.save() diff --git a/becommands/comment.py b/becommands/comment.py index dad32dd..8e899ce 100644 --- a/becommands/comment.py +++ b/becommands/comment.py @@ -19,20 +19,16 @@ 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): +def execute(args, manipulate_encodings=True, restrict_file_access=False): """ >>> 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 = cmdutil.bug_from_id(bd, "a") >>> bug.load_comments(load_full=False) >>> comment = bug.comment_root[0] >>> print comment.body @@ -54,7 +50,7 @@ def execute(args, manipulate_encodings=True): >>> os.environ["EDITOR"] = "echo 'I like cheese' > " >>> execute(["b"], manipulate_encodings=False) >>> bd._clear_bugs() - >>> bug = cmdutil.bug_from_shortname(bd, "b") + >>> bug = cmdutil.bug_from_id(bd, "b") >>> bug.load_comments(load_full=False) >>> comment = bug.comment_root[0] >>> print comment.body @@ -71,25 +67,10 @@ def execute(args, manipulate_encodings=True): 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 + bug, parent = cmdutil.bug_comment_from_id(bd, shortname) if len(args) == 1: # try to launch an editor for comment-body entry try: @@ -118,51 +99,11 @@ def execute(args, manipulate_encodings=True): if not body.endswith('\n'): body+='\n' - if options.XML == False: - new = parent.new_reply(body=body, content_type=options.content_type) - if options.author != None: - new.author = options.author - if options.alt_id != None: - new.alt_id = options.alt_id - 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() + new = parent.new_reply(body=body, content_type=options.content_type) + if options.author != None: + new.author = options.author + if options.alt_id != None: + new.alt_id = options.alt_id def get_parser(): parser = cmdutil.CmdOptionParser("be comment ID [COMMENT]") @@ -172,11 +113,6 @@ def get_parser(): 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=""" diff --git a/becommands/commit.py b/becommands/commit.py index dc70e7e..39d1e2e 100644 --- a/becommands/commit.py +++ b/becommands/commit.py @@ -18,9 +18,9 @@ from libbe import cmdutil, bugdir, editor, vcs import sys __desc__ = __doc__ -def execute(args, manipulate_encodings=True): +def execute(args, manipulate_encodings=True, restrict_file_access=False): """ - >>> import os, time + >>> import os >>> from libbe import bug >>> bd = bugdir.SimpleBugDir() >>> os.chdir(bd.root) @@ -49,6 +49,8 @@ def execute(args, manipulate_encodings=True): elif options.body == "EDITOR": body = editor.editor_string("Please enter your commit message above") else: + if restrict_file_access == True: + cmdutil.restrict_file_access(bd, options.body) body = bd.vcs.get_file_contents(options.body, allow_no_vcs=True) try: revision = bd.vcs.commit(summary, body=body, diff --git a/becommands/depend.py b/becommands/depend.py index f52527e..f50d693 100644 --- a/becommands/depend.py +++ b/becommands/depend.py @@ -35,7 +35,7 @@ class BrokenLink (Exception): self.blocking_bug = blocking_bug -def execute(args, manipulate_encodings=True): +def execute(args, manipulate_encodings=True, restrict_file_access=False): """ >>> from libbe import utility >>> bd = bugdir.SimpleBugDir() @@ -95,7 +95,7 @@ def execute(args, manipulate_encodings=True): for blockee,blocker in fixed]) return 0 - bugA = cmdutil.bug_from_shortname(bd, args[0]) + bugA = cmdutil.bug_from_id(bd, args[0]) if options.tree_depth != None: dtree = DependencyTree(bd, bugA, options.tree_depth) @@ -112,7 +112,7 @@ def execute(args, manipulate_encodings=True): return 0 if len(args) == 2: - bugB = cmdutil.bug_from_shortname(bd, args[1]) + bugB = cmdutil.bug_from_id(bd, args[1]) if options.remove == True: remove_block(bugA, bugB) else: # add the dependency diff --git a/becommands/diff.py b/becommands/diff.py index e71da9b..6477934 100644 --- a/becommands/diff.py +++ b/becommands/diff.py @@ -21,7 +21,7 @@ from libbe import cmdutil, bugdir, diff import os __desc__ = __doc__ -def execute(args, manipulate_encodings=True): +def execute(args, manipulate_encodings=True, restrict_file_access=False): """ >>> import os >>> bd = bugdir.SimpleBugDir() diff --git a/becommands/email_bugs.py b/becommands/email_bugs.py new file mode 100644 index 0000000..d0366df --- /dev/null +++ b/becommands/email_bugs.py @@ -0,0 +1,237 @@ +# 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. +"""Email specified bugs in a be-handle-mail compatible format.""" + +import copy +from cStringIO import StringIO +from email import Message +from email.mime.text import MIMEText +from email.generator import Generator +import sys +import time + +from libbe import cmdutil, bugdir +from libbe.subproc import invoke +from libbe.utility import time_to_str +from libbe.vcs import detect_vcs, installed_vcs +import show + +__desc__ = __doc__ + +sendmail='/usr/sbin/sendmail -t' + +def execute(args, manipulate_encodings=True, restrict_file_access=False): + """ + >>> import os + >>> from libbe import bug + >>> bd = bugdir.SimpleBugDir() + >>> bd.encoding = 'utf-8' + >>> os.chdir(bd.root) + >>> import email.charset as c + >>> c.add_charset('utf-8', c.SHORTEST, c.QP, 'utf-8') + >>> execute(["-o", "--to", "a@b.com", "--from", "b@c.edu", "a", "b"], + ... manipulate_encodings=False) # doctest: +ELLIPSIS + Content-Type: text/xml; charset="utf-8" + MIME-Version: 1.0 + Content-Transfer-Encoding: quoted-printable + From: b@c.edu + To: a@b.com + Date: ... + Subject: [be-bug:xml] Updates to a, b + <BLANKLINE> + <?xml version=3D"1.0" encoding=3D"utf-8" ?> + <be-xml> + <version> + <tag>...</tag> + <branch-nick>...</branch-nick> + <revno>...</revno> + <revision-id>... + </version> + <bug> + <uuid>a</uuid> + <short-name>a</short-name> + <severity>minor</severity> + <status>open</status> + <creator>John Doe <jdoe@example.com></creator> + <created>Thu, 01 Jan 1970 00:00:00 +0000</created> + <summary>Bug A</summary> + </bug> + <bug> + <uuid>b</uuid> + <short-name>b</short-name> + <severity>minor</severity> + <status>closed</status> + <creator>Jane Doe <jdoe@example.com></creator> + <created>Thu, 01 Jan 1970 00:00:00 +0000</created> + <summary>Bug B</summary> + </bug> + </be-xml> + >>> bd.cleanup() + + Note that the '=3D' bits in + <?xml version=3D"1.0" encoding=3D"utf-8" ?> + are the way quoted-printable escapes '='. + + The unclosed <revision-id>... is because revision ids can be long + enough to cause line wraps, and we want to ensure we match even if + the closing </revision-id> is split by the wrapping. + """ + 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) + xml = show.output(args, bd, as_xml=True, with_comments=True) + subject = options.subject + if subject == None: + subject = '[be-bug:xml] Updates to %s' % ', '.join(args) + submit_email = TextEmail(to_address=options.to_address, + from_address=options.from_address, + subject=subject, + body=xml, + encoding=bd.encoding, + subtype='xml') + if options.output == True: + print submit_email + else: + submit_email.send() + +def get_parser(): + parser = cmdutil.CmdOptionParser("be email-bugs [options] ID [ID ...]") + parser.add_option("-t", "--to", metavar="EMAIL", dest="to_address", + help="Submission email address (%default)", + default="be-devel@bugseverywhere.org") + parser.add_option("-f", "--from", metavar="EMAIL", dest="from_address", + help="Senders email address, overriding auto-generated default", + default=None) + parser.add_option("-s", "--subject", metavar="STRING", dest="subject", + help="Subject line, overriding auto-generated default. If you use this option, remember that be-handle-mail probably want something like '[be-bug:xml] ...'", + default=None) + 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 functionality.") + return parser + +longhelp=""" +Email specified bugs in a be-handle-mail compatible format. This is +the prefered method for reporting bugs if you did not install bzr by +branching a bzr repository. + +If you _did_ install bzr by branching a bzr repository, we suggest you +commit any new bug information with + bzr commit --message "Reported bug in demuxulizer" +and then email a bzr merge directive with + bzr send --mail-to "be-devel@bugseverywhere.org" +rather than using this command. +""" + +def help(): + return get_parser().help_str() + longhelp + +class TextEmail (object): + """ + Make it very easy to compose and send single-part text emails. + >>> msg = TextEmail(to_address='Monty <monty@a.com>', + ... from_address='Python <python@b.edu>', + ... subject='Parrots', + ... header={'x-special-header':'your info here'}, + ... body="Remarkable bird, id'nit, squire?\\nLovely plumage!") + >>> print msg # doctest: +ELLIPSIS + Content-Type: text/plain; charset="utf-8" + MIME-Version: 1.0 + Content-Transfer-Encoding: base64 + From: Python <python@b.edu> + To: Monty <monty@a.com> + Date: ... + Subject: Parrots + x-special-header: your info here + <BLANKLINE> + UmVtYXJrYWJsZSBiaXJkLCBpZCduaXQsIHNxdWlyZT8KTG92ZWx5IHBsdW1hZ2Uh + <BLANKLINE> + >>> import email.charset as c + >>> c.add_charset('utf-8', c.SHORTEST, c.QP, 'utf-8') + >>> print msg # doctest: +ELLIPSIS + Content-Type: text/plain; charset="utf-8" + MIME-Version: 1.0 + Content-Transfer-Encoding: quoted-printable + From: Python <python@b.edu> + To: Monty <monty@a.com> + Date: ... + Subject: Parrots + x-special-header: your info here + <BLANKLINE> + Remarkable bird, id'nit, squire? + Lovely plumage! + """ + def __init__(self, to_address, from_address=None, subject=None, + header=None, body=None, encoding='utf-8', subtype='plain'): + self.to_address = to_address + self.from_address = from_address + if self.from_address == None: + self.from_address = self._guess_from_address() + self.subject = subject + self.header = header + if self.header == None: + self.header = {} + self.body = body + self.encoding = encoding + self.subtype = subtype + def _guess_from_address(self): + vcs = detect_vcs('.') + if vcs.name == "None": + vcs = installed_vcs() + return vcs.get_user_id() + def encoded_MIME_body(self): + return MIMEText(self.body.encode(self.encoding), + self.subtype, + self.encoding) + def message(self): + response = self.encoded_MIME_body() + response['From'] = self.from_address + response['To'] = self.to_address + response['Date'] = time_to_str(time.time()) + response['Subject'] = self.subject + for k,v in self.header.items(): + response[k] = v + return response + def flatten(self, to_unicode=False): + """ + This is a simplified version of send_pgp_mime.flatten(). + """ + fp = StringIO() + g = Generator(fp, mangle_from_=False) + g.flatten(self.message()) + text = fp.getvalue() + if to_unicode == True: + encoding = msg.get_content_charset() or "utf-8" + text = unicode(text, encoding=encoding) + return text + def __str__(self): + return self.flatten() + def __unicode__(self): + return self.flatten(to_unicode=True) + def send(self, sendmail=None): + """ + This is a simplified version of send_pgp_mime.mail(). + + Send an email Message instance on its merry way by shelling + out to the user specified sendmail. + """ + if sendmail == None: + sendmail = SENDMAIL + invoke(sendmail, stdin=self.flatten()) diff --git a/becommands/help.py b/becommands/help.py index c12c56a..99ab8c4 100644 --- a/becommands/help.py +++ b/becommands/help.py @@ -20,7 +20,7 @@ from libbe import cmdutil, utility __desc__ = __doc__ -def execute(args, manipulate_encodings=False): +def execute(args, manipulate_encodings=True, restrict_file_access=False): """ Print help of specified command (the manipulate_encodings argument is ignored). diff --git a/becommands/html.py b/becommands/html.py index b0640da..622a531 100644 --- a/becommands/html.py +++ b/becommands/html.py @@ -21,7 +21,7 @@ import xml.sax.saxutils, htmlentitydefs __desc__ = __doc__ -def execute(args, manipulate_encodings=True): +def execute(args, manipulate_encodings=True, restrict_file_access=False): """ >>> import os >>> bd = bugdir.SimpleBugDir() diff --git a/becommands/import_xml.py b/becommands/import_xml.py new file mode 100644 index 0000000..a74d329 --- /dev/null +++ b/becommands/import_xml.py @@ -0,0 +1,267 @@ +# 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. +"""Import comments and bugs from XML""" +from libbe import cmdutil, bugdir, bug, comment, utility +from becommands.comment import complete +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, restrict_file_access=False): + """ + >>> import time + >>> import StringIO + >>> bd = bugdir.SimpleBugDir() + >>> os.chdir(bd.root) + >>> orig_stdin = sys.stdin + >>> sys.stdin = StringIO.StringIO("<be-xml><comment><uuid>c</uuid><body>This is a comment about a</body></comment></be-xml>") + >>> execute(["-c", "a", "-"], manipulate_encodings=False) + >>> sys.stdin = orig_stdin + >>> bd._clear_bugs() + >>> bug = cmdutil.bug_from_id(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 + >>> bd.cleanup() + """ + parser = get_parser() + options, args = parser.parse_args(args) + complete(options, args, parser) + if len(args) < 1: + raise cmdutil.UsageError("Please specify an XML file.") + if len(args) > 1: + raise cmdutil.UsageError("Too many arguments.") + filename = args[0] + + bd = bugdir.BugDir(from_disk=True, + manipulate_encodings=manipulate_encodings) + if options.comment_root != None: + croot_bug,croot_comment = \ + cmdutil.bug_comment_from_id(bd, options.comment_root) + else: + croot_bug,croot_comment = (None, None) + + if filename == '-': + xml = sys.stdin.read() + else: + if restrict_file_access == True: + cmdutil.restrict_file_access(bd, options.body) + xml = bd.vcs.get_file_contents(filename, allow_no_vcs=True) + str_xml = xml.encode('unicode_escape').replace(r'\n', '\n') + # unicode read + encode to string so we know the encoding, + # which might not be given (?) in a binary string read? + + # parse the xml + root_bugs = [] + root_comments = [] + version = {} + be_xml = ElementTree.XML(str_xml) + if be_xml.tag != 'be-xml': + raise utility.InvalidXML( + 'import-xml', be_xml, 'root element must be <be-xml>') + for child in be_xml.getchildren(): + if child.tag == 'bug': + new = bug.Bug(bugdir=bd) + new.from_xml(unicode(ElementTree.tostring(child)).decode('unicode_escape')) + root_bugs.append(new) + elif child.tag == 'comment': + new = comment.Comment(croot_bug) + new.from_xml(unicode(ElementTree.tostring(child)).decode("unicode_escape")) + root_comments.append(new) + elif child.tag == 'version': + for gchild in child.getchildren(): + if child.tag in ['tag', 'nick', 'revision', 'revision-id']: + text = xml.sax.saxutils.unescape(child.text) + text = text.decode('unicode_escape').strip() + version[child.tag] = text + else: + print >> sys.stderr, 'ignoring unknown tag %s in %s' \ + % (gchild.tag, child.tag) + else: + print >> sys.stderr, 'ignoring unknown tag %s in %s' \ + % (child.tag, comment_list.tag) + + # merge the new root_comments + if len(root_comments) > 0: + if croot_bug == None: + raise UserError( + '--comment-root option is required for your root comments:\n%s' + % '\n\n'.join([c.string() for c in root_comments])) + croot_cids = [] + for c in croot_bug.comment_root.traverse(): + croot_cids.append(c.uuid) + if c.alt_id != None: + croot_cids.append(c.alt_id) + for new in root_comments: + if new.alt_id in croot_cids: + raise cmdutil.UserError( + 'clashing comment alt_id: %s' % new.alt_id) + croot_cids.append(new.uuid) + if new.alt_id != None: + croot_cids.append(new.alt_id) + if new.in_reply_to == None: + new.in_reply_to = croot_comment.uuid + try: + # link new comments + comment.list_to_root(root_comments,croot_bug,root=croot_comment, + ignore_missing_references= \ + options.ignore_missing_references) + except comment.MissingReference, e: + raise cmdutil.UserError(e) + # merge the new croot_bugs + for new in root_bugs: + bd.append(new) + + # protect against programmer error causing data loss: + if croot_bug != None: + comms = [c.uuid for c in croot_comment.traverse()] + for new in root_comments: + assert new.uuid in comms, \ + "comment %s wasn't added to %s" % (new.uuid, croot_comment.uuid) + for new in root_bugs: + assert bd.has_bug(new.uuid), \ + "bug %s wasn't added" % (new.uuid) + + # save new information + for new in root_comments: + new.save() + for new in root_bugs: + new.save() + +def get_parser(): + parser = cmdutil.CmdOptionParser("be import-xml XMLFILE") + parser.add_option("-i", "--ignore-missing-references", action="store_true", + dest="ignore_missing_references", default=False, + help="If any comment's <in-reply-to> refers to a non-existent comment, ignore it (instead of raising an exception).") + parser.add_option("-a", "--add-only", action='store_true', + dest="add_only", default=False, + help="If any bug or comment listed in the XML file already exists in the bug repository, do not alter the repository version.") + parser.add_option("-c", "--comment-root", dest="comment_root", + help="Supply a bug or comment ID as the root of any <comment> elements that are direct children of the <be-xml> element. If any such <comment> elements exist, you are required to set this option.") + return parser + +longhelp=""" +Import comments and bugs from XMLFILE. If XMLFILE is '-', the file is +read from stdin. + +This command provides a fallback mechanism for passing bugs between +repositories, in case the repositories VCSs are incompatible. If the +VCSs are compatible, it's better to use their builtin merge/push/pull +to share this information, as that will preserve a more detailed +history. + +The XML file should be formatted similarly to + <be-xml> + <version> + <tag>1.0.0</tag> + <branch-nick>be</branch-nick> + <revno>446</revno> + <revision-id>a@b.com-20091119214553-iqyw2cpqluww3zna</revision-id> + <version> + <bug> + ... + <comment>...</comment> + <comment>...</comment> + </bug> + <bug>...</bug> + <comment>...</comment> + <comment>...</comment> + </be-xml> +where the ellipses mark output commpatible with Bug.xml() and +Comment.xml(). Take a look at the output of `be show --xml` for some +explicit examples. Unrecognized tags are ignored. Missing tags are +left at the default value. The version tag is not required, but is +strongly recommended. + +The bug and 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. Bugs do not have a permantent alt-id, so they +the <uuid>s you specify are not saved. The <uuid>s _are_ used to +match agains prexisting bug and comment uuids, and comment alt-ids, +and fields explicitly given in the XML file will replace old versions +unless the --add-only flag. + +*.extra_strings recieves special treatment, and if --add-only is not +set, the resulting list concatenates both source lists and removes +repeats. + +Here's an example of import activity: + Repository + bug (uuid=B, author=John, status=open) + estr (don't forget your towel) + estr (helps with space travel) + com (uuid=C1, author=Jane, body=Hello) + com (uuid=C2, author=Jess, body=World) + XML + bug (uuid=B, status=fixed) + estr (don't forget your towel) + estr (watch out for flying dolphins) + com (uuid=C1, body=So long) + com (uuid=C3, author=Jed, body=And thanks) + Result + bug (uuid=B, author=John, status=fixed) + estr (don't forget your towel) + estr (helps with space travel) + estr (watch out for flying dolphins) + com (uuid=C1, author=Jane, body=So long) + com (uuid=C2, author=Jess, body=World) + com (uuid=C4, alt-id=C3, author=Jed, body=And thanks) + Result, with --add-only + bug (uuid=B, author=John, status=open) + estr (don't forget your towel) + estr (helps with space travel) + com (uuid=C1, author=Jane, body=Hello) + com (uuid=C2, author=Jess, body=World) + com (uuid=C4, alt-id=C3, author=Jed, body=And thanks) + +Examples: + +Import comments (e.g. emails from an mbox) and append to bug XYZ + $ be-mbox-to-xml mail.mbox | be import-xml --c XYZ - +Or you can append those emails underneath the prexisting comment XYZ-3 + $ be-mbox-to-xml mail.mbox | be import-xml --c XYZ-3 - + +User creates a new bug + user$ be new "The demuxulizer is broken" + Created bug with ID 48f + user$ be comment 48f + <Describe bug> + ... +User exports bug as xml and emails it to the developers + user$ be show --xml 48f > 48f.xml + user$ cat 48f.xml | mail -s "Demuxulizer bug xml" devs@b.com +or equivalently (with a slightly fancier be-handle-mail compatible +email): + user$ be email-bugs 48f +Devs recieve email, and save it's contents as demux-bug.xml + dev$ cat demux-bug.xml | be import-xml - +""" + +def help(): + return get_parser().help_str() + longhelp diff --git a/becommands/init.py b/becommands/init.py index 275dd77..7d6d475 100644 --- a/becommands/init.py +++ b/becommands/init.py @@ -20,7 +20,7 @@ import os.path from libbe import cmdutil, bugdir __desc__ = __doc__ -def execute(args, manipulate_encodings=True): +def execute(args, manipulate_encodings=True, restrict_file_access=False): """ >>> from libbe import utility, vcs >>> import os @@ -37,14 +37,14 @@ def execute(args, manipulate_encodings=True): >>> 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. + >>> _vcs = vcs.installed_vcs() + >>> _vcs.init('.') + >>> _vcs.name in vcs.VCS_ORDER + True + >>> execute([], manipulate_encodings=False) # doctest: +ELLIPSIS + Using ... for revision control. Directory initialized. - >>> vcs.cleanup() + >>> _vcs.cleanup() >>> try: ... execute(['--root', '.'], manipulate_encodings=False) diff --git a/becommands/list.py b/becommands/list.py index 14e387b..4711789 100644 --- a/becommands/list.py +++ b/becommands/list.py @@ -26,7 +26,7 @@ __desc__ = __doc__ 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): +def execute(args, manipulate_encodings=True, restrict_file_access=False): """ >>> import os >>> bd = bugdir.SimpleBugDir() diff --git a/becommands/merge.py b/becommands/merge.py index bc18479..31c781f 100644 --- a/becommands/merge.py +++ b/becommands/merge.py @@ -19,7 +19,7 @@ from libbe import cmdutil, bugdir import os, copy __desc__ = __doc__ -def execute(args, manipulate_encodings=True): +def execute(args, manipulate_encodings=True, restrict_file_access=False): """ >>> from libbe import utility >>> bd = bugdir.SimpleBugDir() @@ -137,9 +137,9 @@ def execute(args, manipulate_encodings=True): bd = bugdir.BugDir(from_disk=True, manipulate_encodings=manipulate_encodings) - bugA = cmdutil.bug_from_shortname(bd, args[0]) + bugA = cmdutil.bug_from_id(bd, args[0]) bugA.load_comments() - bugB = cmdutil.bug_from_shortname(bd, args[1]) + bugB = cmdutil.bug_from_id(bd, args[1]) bugB.load_comments() mergeA = bugA.new_comment("Merged from bug %s" % bugB.uuid) newCommTree = copy.deepcopy(bugB.comment_root) diff --git a/becommands/new.py b/becommands/new.py index 30a7d28..92d61e4 100644 --- a/becommands/new.py +++ b/becommands/new.py @@ -20,7 +20,7 @@ from libbe import cmdutil, bugdir import sys __desc__ = __doc__ -def execute(args, manipulate_encodings=True): +def execute(args, manipulate_encodings=True, restrict_file_access=False): """ >>> import os, time >>> from libbe import bug diff --git a/becommands/open.py b/becommands/open.py index c9e55a2..c2c15e2 100644 --- a/becommands/open.py +++ b/becommands/open.py @@ -21,7 +21,7 @@ from libbe import cmdutil, bugdir __desc__ = __doc__ -def execute(args, manipulate_encodings=True): +def execute(args, manipulate_encodings=True, restrict_file_access=False): """ >>> import os >>> bd = bugdir.SimpleBugDir() @@ -44,7 +44,7 @@ def execute(args, manipulate_encodings=True): 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 = cmdutil.bug_from_id(bd, args[0]) bug.status = "open" def get_parser(): diff --git a/becommands/remove.py b/becommands/remove.py index 0e61362..e4f0065 100644 --- a/becommands/remove.py +++ b/becommands/remove.py @@ -18,7 +18,7 @@ from libbe import cmdutil, bugdir __desc__ = __doc__ -def execute(args, manipulate_encodings=True): +def execute(args, manipulate_encodings=True, restrict_file_access=False): """ >>> from libbe import mapfile >>> import os @@ -44,7 +44,7 @@ def execute(args, manipulate_encodings=True): 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]) + bug = cmdutil.bug_from_id(bd, args[0]) bd.remove_bug(bug) print "Removed bug %s" % bug.uuid diff --git a/becommands/set.py b/becommands/set.py index e5cab8d..c8c5a2c 100644 --- a/becommands/set.py +++ b/becommands/set.py @@ -32,7 +32,7 @@ def _value_string(bd, setting): val = None return str(val) -def execute(args, manipulate_encodings=True): +def execute(args, manipulate_encodings=True, restrict_file_access=False): """ >>> import os >>> bd = bugdir.SimpleBugDir() diff --git a/becommands/severity.py b/becommands/severity.py index e987760..524976b 100644 --- a/becommands/severity.py +++ b/becommands/severity.py @@ -21,7 +21,7 @@ from libbe import cmdutil, bugdir, bug __desc__ = __doc__ -def execute(args, manipulate_encodings=True): +def execute(args, manipulate_encodings=True, restrict_file_access=False): """ >>> import os >>> bd = bugdir.SimpleBugDir() @@ -43,7 +43,7 @@ def execute(args, manipulate_encodings=True): raise cmdutil.UsageError bd = bugdir.BugDir(from_disk=True, manipulate_encodings=manipulate_encodings) - bug = cmdutil.bug_from_shortname(bd, args[0]) + bug = cmdutil.bug_from_id(bd, args[0]) if len(args) == 1: print bug.severity elif len(args) == 2: diff --git a/becommands/show.py b/becommands/show.py index 94e16bf..557c63a 100644 --- a/becommands/show.py +++ b/becommands/show.py @@ -17,12 +17,12 @@ # 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""" +"""Show a particular bug, comment, or combination of both.""" import sys -from libbe import cmdutil, bugdir +from libbe import cmdutil, bugdir, comment, version, _version __desc__ = __doc__ -def execute(args, manipulate_encodings=True): +def execute(args, manipulate_encodings=True, restrict_file_access=False): """ >>> import os >>> bd = bugdir.SimpleBugDir() @@ -41,15 +41,23 @@ def execute(args, manipulate_encodings=True): <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 <jdoe@example.com></creator> - <created>...</created> - <summary>Bug A</summary> - </bug> + <be-xml> + <version> + <tag>...</tag> + <branch-nick>...</branch-nick> + <revno>...</revno> + <revision-id>...</revision-id> + </version> + <bug> + <uuid>a</uuid> + <short-name>a</short-name> + <severity>minor</severity> + <status>open</status> + <creator>John Doe <jdoe@example.com></creator> + <created>Thu, 01 Jan 1970 00:00:00 +0000</created> + <summary>Bug A</summary> + </bug> + </be-xml> >>> bd.cleanup() """ parser = get_parser() @@ -60,38 +68,18 @@ def execute(args, manipulate_encodings=True): 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 + + if options.only_raw_body == True: + if len(args) != 1: + raise cmdutil.UsageError( + 'only one ID accepted with --only-raw-body') + bug,comment = cmdutil.bug_comment_from_id(bd, args[0]) + if comment == bug.comment_root: + raise cmdutil.UsageError( + "--only-raw-body requires a comment ID, not '%s'" % args[0]) + sys.__stdout__.write(comment.body) + sys.exit(0) + print output(args, bd, as_xml=options.XML, with_comments=options.comments) def get_parser(): parser = cmdutil.CmdOptionParser("be show [options] ID [ID ...]") @@ -108,9 +96,87 @@ def get_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. +Without the --xml flag set, 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. With the --xml flag set, there will never be any root comments, +so mix and match away (the bug listings for directly requested +comments will be restricted to the bug uuid and the requested +comment(s)). + +Directly requested comments will be grouped by their parent bug and +placed at the end of the output, so the ordering may not match the +order of the listed IDs. """ def help(): return get_parser().help_str() + longhelp + +def _sort_ids(ids, with_comments=True): + bugs = [] + root_comments = {} + for id in ids: + bugname,commname = cmdutil.parse_id(id) + if commname == None: + bugs.append(bugname) + elif with_comments == True: + if bugname not in root_comments: + root_comments[bugname] = [commname] + else: + root_comments[bugname].append(commname) + for bugname in root_comments.keys(): + assert bugname not in bugs, \ + "specifically requested both '%s%s' and '%s'" \ + % (bugname, root_comments[bugname][0], bugname) + return (bugs, root_comments) + +def _xml_header(encoding): + lines = ['<?xml version="1.0" encoding="%s" ?>' % encoding, + '<be-xml>', + ' <version>', + ' <tag>%s</tag>' % version.version()] + for tag in ['branch-nick', 'revno', 'revision-id']: + value = _version.version_info[tag.replace('-', '_')] + lines.append(' <%s>%s</%s>' % (tag, value, tag)) + lines.append(' </version>') + return lines + +def _xml_footer(): + return ['</be-xml>'] + +def output(ids, bd, as_xml=True, with_comments=True): + bugs,root_comments = _sort_ids(ids, with_comments) + lines = [] + if as_xml: + lines.extend(_xml_header(bd.encoding)) + else: + spaces_left = len(ids) - 1 + for bugname in bugs: + bug = cmdutil.bug_from_id(bd, bugname) + if as_xml: + lines.append(bug.xml(indent=2, show_comments=with_comments)) + else: + lines.append(bug.string(show_comments=with_comments)) + if spaces_left > 0: + spaces_left -= 1 + lines.append('') # add a blank line between bugs/comments + for bugname,comments in root_comments.items(): + bug = cmdutil.bug_from_id(bd, bugname) + if as_xml: + lines.extend([' <bug>', ' <uuid>%s</uuid>' % bug.uuid]) + for commname in comments: + try: + comment = bug.comment_root.comment_from_shortname(commname) + except comment.InvalidShortname, e: + raise UserError(e.message) + if as_xml: + lines.append(comment.xml(indent=4, shortname=bugname)) + else: + lines.append(comment.string(shortname=shortname)) + if spaces_left > 0: + spaces_left -= 1 + lines.append('') # add a blank line between bugs/comments + if as_xml: + lines.append('</bug>') + if as_xml: + lines.extend(_xml_footer()) + return '\n'.join(lines) diff --git a/becommands/status.py b/becommands/status.py index 827c7ce..fd31c97 100644 --- a/becommands/status.py +++ b/becommands/status.py @@ -18,7 +18,7 @@ from libbe import cmdutil, bugdir, bug __desc__ = __doc__ -def execute(args, manipulate_encodings=True): +def execute(args, manipulate_encodings=True, restrict_file_access=False): """ >>> import os >>> bd = bugdir.SimpleBugDir() @@ -40,7 +40,7 @@ def execute(args, manipulate_encodings=True): raise cmdutil.UsageError bd = bugdir.BugDir(from_disk=True, manipulate_encodings=manipulate_encodings) - bug = cmdutil.bug_from_shortname(bd, args[0]) + bug = cmdutil.bug_from_id(bd, args[0]) if len(args) == 1: print bug.status else: diff --git a/becommands/subscribe.py b/becommands/subscribe.py index 0a23057..051341b 100644 --- a/becommands/subscribe.py +++ b/becommands/subscribe.py @@ -55,7 +55,7 @@ class InvalidType (ValueError): self.type_root = type_root -def execute(args, manipulate_encodings=True): +def execute(args, manipulate_encodings=True, restrict_file_access=False): """ >>> bd = bugdir.SimpleBugDir() >>> bd.set_sync_with_disk(True) diff --git a/becommands/tag.py b/becommands/tag.py index 31b43ba..e22cb70 100644 --- a/becommands/tag.py +++ b/becommands/tag.py @@ -19,7 +19,7 @@ from libbe import cmdutil, bugdir import os, copy __desc__ = __doc__ -def execute(args, manipulate_encodings=True): +def execute(args, manipulate_encodings=True, restrict_file_access=False): """ >>> from libbe import utility >>> bd = bugdir.SimpleBugDir() @@ -95,7 +95,7 @@ def execute(args, manipulate_encodings=True): if len(tags) > 0: print '\n'.join(tags) return - bug = cmdutil.bug_from_shortname(bd, args[0]) + bug = cmdutil.bug_from_id(bd, args[0]) if len(args) == 2: given_tag = args[1] estrs = bug.extra_strings diff --git a/becommands/target.py b/becommands/target.py index 7e41451..efb2479 100644 --- a/becommands/target.py +++ b/becommands/target.py @@ -22,7 +22,7 @@ from libbe import cmdutil, bugdir __desc__ = __doc__ -def execute(args, manipulate_encodings=True): +def execute(args, manipulate_encodings=True, restrict_file_access=False): """ >>> import os >>> bd = bugdir.SimpleBugDir() @@ -55,7 +55,7 @@ def execute(args, manipulate_encodings=True): if target and isinstance(target,str): print target return - bug = cmdutil.bug_from_shortname(bd, args[0]) + bug = cmdutil.bug_from_id(bd, args[0]) if len(args) == 1: if bug.target is None: print "No target assigned." diff --git a/interfaces/email/interactive/README b/interfaces/email/interactive/README index 79ef9a9..2070973 100644 --- a/interfaces/email/interactive/README +++ b/interfaces/email/interactive/README @@ -23,13 +23,15 @@ 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: +first place. There are four 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 + xml [be-bug:xml] commit message These are analogous to submit@bugs.debian.org, nnn@bugs.debian.org, -and control@bugs.debian.org respectively. +and control@bugs.debian.org respectively. The xml style has no Debian +analog. Creating bugs ============= @@ -106,6 +108,15 @@ shlex.split(). -- Goofy tagline ignored. +XML +=== + +This interface allows users without access to the versioned source of +the program to conveniently submit bugs and comments using be. You +should not attempt to compose emails for this interface by hand. See +the documentation for the `email-bugs' and `import-xml' be commands +for details. + Example emails ============== diff --git a/interfaces/email/interactive/be-handle-mail b/interfaces/email/interactive/be-handle-mail index fa80698..e0e3490 100755 --- a/interfaces/email/interactive/be-handle-mail +++ b/interfaces/email/interactive/be-handle-mail @@ -81,6 +81,7 @@ SUBJECT_TAG_START = None SUBJECT_TAG_NEW = None SUBJECT_TAG_COMMENT = None SUBJECT_TAG_CONTROL = None +SUBJECT_TAG_XML = None BREAK = u"--" NEW_REQUIRED_PSEUDOHEADERS = [u"Version"] @@ -90,7 +91,7 @@ NEW_OPTIONAL_PSEUDOHEADERS = [u"Reporter", u"Assign", u"Depend", u"Severity", 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"] + u"status", u"subscribe", u"tag", u"target", u"import-xml"] AUTOCOMMIT = True @@ -241,7 +242,8 @@ class Command (object): os.chdir(BE_DIR) try: self.ret = libbe.cmdutil.execute(self.command, self.args, - manipulate_encodings=False) + manipulate_encodings=False, + restrict_file_access=True) except libbe.cmdutil.GetHelp: print libbe.cmdutil.help(command) except libbe.cmdutil.GetCompletions: @@ -402,8 +404,8 @@ class Message (object): 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 + None, "new", "comment", "control", or "xml"; and value is None + except in the case of "comment", in which case it's the bug ID/shortname. """ tag,subject = self._split_subject() @@ -413,6 +415,8 @@ class Message (object): type = u"new" elif tag == SUBJECT_TAG_CONTROL: type = u"control" + elif tag == SUBJECT_TAG_XML: + type = u"xml" else: match = SUBJECT_TAG_COMMENT.match(tag) if len(match.groups()) == 1: @@ -506,6 +510,8 @@ class Message (object): commands = self.parse_comment(value) elif tag_type == u"control": commands = self.parse_control() + elif tag_type == u"xml": + commands = self.parse_xml() else: raise Exception, u"Unrecognized tag type '%s'" % tag_type return commands @@ -564,8 +570,10 @@ class Message (object): 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"-"] + args = [u"--author", author] + if alt_id != None: + args.extend([u"--alt-id", alt_id]) + args.extend([u"--content-type", content_type, bug_id, u"-"]) commands = [Command(self, command, args, stdin=body)] return commands def parse_control(self): @@ -583,6 +591,16 @@ class Message (object): if len(commands) == 0: raise InvalidEmail(self, u"No commands in control email.") return commands + def parse_xml(self): + command = u"import-xml" + body,mime_type = list(self._get_bodies_and_mime_types())[0] + if mime_type != "text/xml": + raise InvalidEmail(self, + u"Emails to %s must have MIME type 'text/xml', not '%s'." + % (SUBJECT_TAG_XML, mime_type)) + args = [u"-"] + commands = [Command(self, command, args, stdin=body)] + return commands def run(self): self._begin_response() commands = self.parse() @@ -736,13 +754,15 @@ 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_NEW, SUBJECT_TAG_COMMENT, SUBJECT_TAG_CONTROL, \ + SUBJECT_TAG_XML 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 + SUBJECT_TAG_XML = u"[%s:xml]" % tag_base def open_logfile(logpath=None): """ @@ -945,6 +965,10 @@ class GenerateGlobalTagsTestCase (unittest.TestCase): m = SUBJECT_TAG_COMMENT.match("[projectX-bug:xyz-123]") self.failUnlessEqual(len(m.groups()), 1) self.failUnlessEqual(m.group(1), u"xyz-123") + def test_subject_tag_xml(self): + "Should set SUBJECT_TAG_XML global correctly" + generate_global_tags(u"projectX-bug") + self.failUnlessEqual(SUBJECT_TAG_XML, u"[projectX-bug:xml]") if __name__ == "__main__": main(sys.argv) diff --git a/interfaces/email/interactive/examples/email_bugs b/interfaces/email/interactive/examples/email_bugs new file mode 100644 index 0000000..949e1c1 --- /dev/null +++ b/interfaces/email/interactive/examples/email_bugs @@ -0,0 +1,37 @@ +From jdoe@example.com Fri Apr 18 12:00:00 2008 +Content-Type: text/xml; charset="utf-8" +MIME-Version: 1.0 +Content-Transfer-Encoding: quoted-printable +From: jdoe@example.com +To: a@b.com +Date: Fri, 18 Apr 2008 12:00:00 +0000 +Subject: [be-bug:xml] Updates to a, b + +<?xml version="1.0" encoding="utf-8" ?> +<be-xml> + <version> + <tag>1.0.0</tag> + <branch-nick>be</branch-nick> + <revno>446</revno> + <revision-id>wking@drexel.edu-20091119214553-iqyw2cpqluww3zna</revision-id> + </version> + <bug> + <uuid>a</uuid> + <short-name>a</short-name> + <severity>minor</severity> + <status>open</status> + <creator>John Doe <jdoe@example.com></creator> + <created>Thu, 01 Jan 1970 00:00:00 +0000</created> + <summary>Bug A</summary> + </bug> + <bug> + <uuid>b</uuid> + <short-name>b</short-name> + <severity>minor</severity> + <status>closed</status> + <creator>Jane Doe <jdoe@example.com></creator> + <created>Thu, 01 Jan 1970 00:00:00 +0000</created> + <summary>Bug B</summary> + </bug> +</be-xml> + diff --git a/interfaces/xml/be-mbox-to-xml b/interfaces/xml/be-mbox-to-xml index a740117..3af2978 100755 --- a/interfaces/xml/be-mbox-to-xml +++ b/interfaces/xml/be-mbox-to-xml @@ -15,8 +15,8 @@ # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. """ -Convert an mbox into xml suitable for imput into be. - $ cat mbox | be-mbox-to-xml | be comment --xml <ID> - +Convert an mbox into xml suitable for input into be. + $ be-mbox-to-xml file.mbox | be import-xml -c <ID> - mbox is a flat-file format, consisting of a series of messages. Messages begin with a a From_ line, followed by RFC 822 email, followed by a blank line. @@ -140,10 +140,10 @@ def comment_message_to_xml(message, fields=None): def main(mbox_filename): mb = mbox(mbox_filename) print u'<?xml version="1.0" encoding="%s" ?>' % DEFAULT_ENCODING - print u"<comment-list>" + print u"<be-xml>" for message in mb: print comment_message_to_xml(message) - print u"</comment-list>" + print u"</be-xml>" if __name__ == "__main__": diff --git a/interfaces/xml/be-xml-to-mbox b/interfaces/xml/be-xml-to-mbox index 7960d56..dc4524e 100755 --- a/interfaces/xml/be-xml-to-mbox +++ b/interfaces/xml/be-xml-to-mbox @@ -86,21 +86,23 @@ class Bug (LimitedAttrDict): u"comments", u"extra-strings"] def print_to_mbox(self): - name,addr = email.utils.parseaddr(self["creator"]) - print "From %s %s" % (addr, rfc2822_to_asctime(self["created"])) - print "Message-ID: <%s@%s>" % (self["uuid"], DEFAULT_DOMAIN) - print "Date: %s" % self["created"] - print "From: %s" % self["creator"] - print "Content-Type: %s; charset=%s" % ("text/plain", DEFAULT_ENCODING) - print "Content-Transfer-Encoding: 8bit" - print "Subject: %s: %s" % (self["short-name"], self["summary"]) - print "" - print self["summary"] - print "" - if "extra-strings" in self: - print "extra strings:\n ", - print '\n '.join(self["extra_strings"]) - print "" + if "creator" in self: + # otherwise, probably a `be show` uuid-only bug to avoid + # root comments. + name,addr = email.utils.parseaddr(self["creator"]) + print "From %s %s" % (addr, rfc2822_to_asctime(self["created"])) + print "Message-id: <%s@%s>" % (self["uuid"], DEFAULT_DOMAIN) + print "Date: %s" % self["created"] + print "From: %s" % self["creator"] + print "Content-Type: %s; charset=%s" % ("text/plain", DEFAULT_ENCODING) + print "Content-Transfer-Encoding: 8bit" + print "Subject: %s: %s" % (self["short-name"], self["summary"]) + if "extra-strings" in self: + for estr in self["extra_strings"]: + print "X-Extra-String: %s" % estr + print "" + print self["summary"] + print "" if "comments" in self: for comment in self["comments"]: comment.print_to_mbox(self) @@ -131,7 +133,8 @@ class Comment (LimitedAttrDict): u"author", u"date", u"content-type", - u"body"] + u"body", + u"extra-strings"] def print_to_mbox(self, bug=None): if bug == None: bug = Bug() @@ -142,7 +145,7 @@ class Comment (LimitedAttrDict): elif "alt-id" in self: id = self["alt-id"] else: id = None if id != None: - print "Message-ID: <%s@%s>" % (id, DEFAULT_DOMAIN) + print "Message-id: <%s@%s>" % (id, DEFAULT_DOMAIN) print "Date: %s" % self["date"] print "From: %s" % self["author"] subject = "" @@ -156,6 +159,9 @@ class Comment (LimitedAttrDict): if "in-reply-to" not in self.keys(): self["in-reply-to"] = bug["uuid"] print "In-Reply-To: <%s@%s>" % (self["in-reply-to"], DEFAULT_DOMAIN) + if "extra-strings" in self: + for estr in self["extra_strings"]: + print "X-Extra-String: %s" % estr if self["content-type"].startswith("text/"): print "Content-Transfer-Encoding: 8bit" print "Content-Type: %s; charset=%s" % (self["content-type"], DEFAULT_ENCODING) @@ -168,9 +174,15 @@ class Comment (LimitedAttrDict): assert element.tag == "comment", element.tag for field in element.getchildren(): text = unescape(unicode(field.text).decode("unicode_escape").strip()) - if field.tag == "body": - text+="\n" - self[field.tag] = text + if field.tag == "extra-string": + if "extra-strings" in self: + self["extra-strings"].append(text) + else: + self["extra-strings"] = [text] + else: + if field.tag == "body": + text+="\n" + self[field.tag] = text def print_to_mbox(element): if element.tag == "bug": @@ -181,16 +193,9 @@ def print_to_mbox(element): c = Comment() c.init_from_etree(element) c.print_to_mbox() - elif element.tag in ["bugs", "bug-list"]: - for b_elt in element.getchildren(): - b = Bug() - b.init_from_etree(b_elt) - b.print_to_mbox() - elif element.tag in ["comments", "comment-list"]: - for c_elt in element.getchildren(): - c = Comment() - c.init_from_etree(c_elt) - c.print_to_mbox() + elif element.tag in ["be-xml"]: + for elt in element.getchildren(): + print_to_mbox(elt) if __name__ == "__main__": import sys diff --git a/libbe/arch.py b/libbe/arch.py index 4687555..48129b5 100644 --- a/libbe/arch.py +++ b/libbe/arch.py @@ -45,7 +45,7 @@ def new(): return Arch() class Arch(vcs.VCS): - name = "Arch" + name = "arch" client = client versioned = True _archive_name = None diff --git a/libbe/bug.py b/libbe/bug.py index 48f8358..fecb9b7 100644 --- a/libbe/bug.py +++ b/libbe/bug.py @@ -25,6 +25,10 @@ import os.path import errno 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 @@ -271,40 +275,100 @@ class Bug(settings_object.SavedSettingsObject): return str(value) return value - def xml(self, show_comments=False): - if self.bugdir == None: - shortname = self.uuid - else: - shortname = self.bugdir.bug_shortname(self) + def xml(self, indent=0, shortname=None, show_comments=False): + if shortname == None: + 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' + 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)] + lines = ['<bug>'] for (k,v) in info: if v is not None: - ret += ' <%s>%s</%s>\n' % (k,xml.sax.saxutils.escape(v),k) + lines.append(' <%s>%s</%s>' % (k,xml.sax.saxutils.escape(v),k)) for estr in self.extra_strings: - ret += ' <extra-string>%s</extra-string>\n' % estr + lines.append(' <extra-string>%s</extra-string>\n' % estr) if show_comments == True: - comout = self.comment_root.xml_thread(auto_name_map=True, + comout = self.comment_root.xml_thread(indent=indent+2, + auto_name_map=True, bug_shortname=shortname) if len(comout) > 0: - ret += comout+'\n' - ret += '</bug>' - return ret + lines.append(comout) + lines.append('</bug>') + istring = ' '*indent + sep = '\n' + istring + return istring + sep.join(lines).rstrip('\n') + + def from_xml(self, xml_string, verbose=True): + """ + Note: If a bug uuid is given, set .alt_id to it's value. + >>> bugA = Bug(uuid="0123", summary="Need to test Bug.from_xml()") + >>> bugA.date = "Thu, 01 Jan 1970 00:00:00 +0000" + >>> bugA.creator = u'Fran\xe7ois' + >>> bugA.extra_strings += ['TAG: very helpful'] + >>> commA = bugA.comment_root.new_reply(body='comment A') + >>> commB = bugA.comment_root.new_reply(body='comment B') + >>> commC = commA.new_reply(body='comment C') + >>> xml = bugA.xml(shortname="bug-1") + >>> bugB = Bug() + >>> bugB.from_xml(xml, verbose=True) + >>> bugB.xml(shortname="bug-1") == xml + False + >>> bugB.uuid = bugB.alt_id + >>> bugB.xml(shortname="bug-1") == xml + True + """ + if type(xml_string) == types.UnicodeType: + xml_string = xml_string.strip().encode('unicode_escape') + bug = ElementTree.XML(xml_string) + if bug.tag != 'bug': + raise utility.InvalidXML( \ + 'bug', bug, 'root element must be <comment>') + tags=['uuid','short-name','severity','status','assigned','target', + 'reporter', 'creator', 'created', 'summary', 'extra-string', + 'comment'] + uuid = None + estrs = [] + for child in bug.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 = text.decode('unicode_escape').strip() + if child.tag == "uuid": + uuid = text + continue # don't set the bug's uuid tag. + if child.tag == 'extra-string': + estrs.append(text) + continue # don't set the bug's extra_string 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 uuid not in [None, self.uuid]: + if not hasattr(self, 'alt_id') or self.alt_id == None: + self.alt_id = uuid + self.extra_strings = estrs def string(self, shortlist=False, show_comments=False): if self.bugdir == None: @@ -525,6 +589,7 @@ 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") +cmp_extra_strings = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "extra_strings") # chronological rankings (newer < older) cmp_time = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "time", invert=True) @@ -547,7 +612,8 @@ def cmp_comments(bug_1, bug_2): 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) + cmp_reporter, cmp_target, cmp_comments, cmp_summary, cmp_uuid, + cmp_extra_strings) class BugCompoundComparator (object): def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST): diff --git a/libbe/bugdir.py b/libbe/bugdir.py index 5b942d3..675b744 100644 --- a/libbe/bugdir.py +++ b/libbe/bugdir.py @@ -213,7 +213,7 @@ that the Arch VCS backend *enforces* ids with this format.""", 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"]) + allowed=["None"]+vcs.VCS_ORDER) def vcs_name(): return {} def _get_vcs(self, vcs_name=None): diff --git a/libbe/bzr.py b/libbe/bzr.py index 2cf1cba..281493d 100644 --- a/libbe/bzr.py +++ b/libbe/bzr.py @@ -90,7 +90,7 @@ class Bzr(vcs.VCS): if self._u_any_in_string(strings, error) == True: raise vcs.EmptyCommit() else: - raise vcs.CommandError(args, status, stdout="", stderr=error) + raise vcs.CommandError(args, status, stderr=error) revision = None revline = re.compile("Committed revision (.*)[.]") match = revline.search(error) diff --git a/libbe/cmdutil.py b/libbe/cmdutil.py index f1c8acd..e37750d 100644 --- a/libbe/cmdutil.py +++ b/libbe/cmdutil.py @@ -30,10 +30,10 @@ import sys import doctest import bugdir +import comment import plugin import encoding - class UserError(Exception): def __init__(self, msg): Exception.__init__(self, msg) @@ -76,11 +76,12 @@ def get_command(command_name): return cmd -def execute(cmd, args, manipulate_encodings=True): +def execute(cmd, args, manipulate_encodings=True, restrict_file_access=False): enc = encoding.get_encoding() cmd = get_command(cmd) ret = cmd.execute([a.decode(enc) for a in args], - manipulate_encodings=manipulate_encodings) + manipulate_encodings=manipulate_encodings, + restrict_file_access=restrict_file_access) if ret == None: ret = 0 return ret @@ -213,16 +214,85 @@ def underlined(instring): return "%s\n%s" % (instring, "="*len(instring)) -def bug_from_shortname(bdir, shortname): +def restrict_file_access(bugdir, path): + """ + Check that the file at path is inside bugdir.root. This is + important if you allow other users to execute becommands with your + username (e.g. if you're running be-handle-mail through your + ~/.procmailrc). If this check wasn't made, a user could e.g. + run + be commit -b ~/.ssh/id_rsa "Hack to expose ssh key" + which would expose your ssh key to anyone who could read the VCS + log. + """ + in_root = bugdir.vcs.path_in_root(path, bugdir.root) + if in_root == False: + raise UserError('file access restricted!\n %s not in %s' + % (path, bugdir.root)) + +def parse_id(id): + """ + Return (bug_id, comment_id) tuple. + Basically inverts Comment.comment_shortnames() + >>> parse_id('XYZ') + ('XYZ', None) + >>> parse_id('XYZ:123') + ('XYZ', ':123') + >>> parse_id('') + Traceback (most recent call last): + ... + UserError: invalid id ''. + >>> parse_id('::') + Traceback (most recent call last): + ... + UserError: invalid id '::'. + """ + if len(id) == 0: + raise UserError("invalid id '%s'." % id) + if id.count(':') > 1: + raise UserError("invalid id '%s'." % id) + elif id.count(':') == 1: + # Split shortname generated by Comment.comment_shortnames() + bug_id,comment_id = id.split(':') + comment_id = ':'+comment_id + else: + bug_id = id + comment_id = None + return (bug_id, comment_id) + +def bug_from_id(bdir, id): """ Exception translation for the command-line interface. + id can be either the bug shortname or the full uuid. """ try: - bug = bdir.bug_from_shortname(shortname) + bug = bdir.bug_from_shortname(id) except (bugdir.MultipleBugMatches, bugdir.NoBugMatches), e: raise UserError(e.message) return bug +def bug_comment_from_id(bdir, id): + """ + Return (bug,comment) tuple matching shortname. id can be either + the bug/comment shortname or the full uuid. If there is no + comment part to the id, the returned comment is the bug's + .comment_root. + """ + bug_id,comment_id = parse_id(id) + try: + bug = bdir.bug_from_shortname(bug_id) + except (bugdir.MultipleBugMatches, bugdir.NoBugMatches), e: + raise UserError(e.message) + if comment_id == None: + comm = bug.comment_root + else: + #bug.load_comments(load_full=False) + try: + comm = bug.comment_root.comment_from_shortname(comment_id) + except comment.InvalidShortname, e: + raise UserError(e.message) + return (bug, comm) + def _test(): import doctest import sys diff --git a/libbe/comment.py b/libbe/comment.py index 5f67878..5cc43c4 100644 --- a/libbe/comment.py +++ b/libbe/comment.py @@ -51,14 +51,6 @@ class InvalidShortname(KeyError): 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) @@ -331,27 +323,29 @@ class Comment(Tree, settings_object.SavedSettingsObject): """ if shortname == None: shortname = self.uuid - if self.content_type.startswith("text/"): - body = (self.body or "").rstrip('\n') + 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 "") + 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>"] + 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>") + for estr in self.extra_strings: + lines.append(' <extra-string>%s</extra-string>\n' % estr) + lines.append('</comment>') istring = ' '*indent sep = '\n' + istring return istring + sep.join(lines).rstrip('\n') @@ -363,58 +357,61 @@ class Comment(Tree, settings_object.SavedSettingsObject): >>> commA = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n") >>> commA.uuid = "0123" >>> commA.date = "Thu, 01 Jan 1970 00:00:00 +0000" + >>> commA.author = u'Fran\xe7ois' + >>> commA.extra_strings += ['TAG: very helpful'] >>> 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 + >>> commB.from_xml(xml, verbose=True) + >>> commB.xml(shortname="com-1") == xml + False + >>> commB.uuid = commB.alt_id + >>> commB.alt_id = None + >>> commB.xml(shortname="com-1") == xml + True """ if type(xml_string) == types.UnicodeType: - xml_string = xml_string.strip().encode("unicode_escape") + 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'] + if comment.tag != 'comment': + raise utility.InvalidXML( \ + 'comment', comment, 'root element must be <comment>') + tags=['uuid','alt-id','in-reply-to','author','date','content-type', + 'body','extra-string'] uuid = None body = None + estrs = [] for child in comment.getchildren(): - if child.tag == "short-name": + 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": + text = text.decode('unicode_escape').strip() + if child.tag == 'uuid': uuid = text - continue # don't set the bug's uuid tag. - if child.tag == "body": + continue # don't set the comment's uuid tag. + if child.tag == 'body': body = text - continue # don't set the bug's body yet. + continue # don't set the comment's body yet. + if child.tag == 'extra-string': + estrs.append(text) + continue # don't set the comment's extra_string yet. else: attr_name = child.tag.replace('-','_') setattr(self, attr_name, text) elif verbose == True: - print >> sys.stderr, "Ignoring unknown tag %s in %s" \ + 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 + if self.content_type.startswith('text/'): + self.body = body+'\n' # restore trailing newline else: self.body = base64.decodestring(body) + self.extra_strings = estrs def string(self, indent=0, shortname=None): """ @@ -644,6 +641,12 @@ class Comment(Tree, settings_object.SavedSettingsObject): bug-1:2 b bug-1:3 c bug-1:4 d + >>> for id,name in a.comment_shortnames(): + ... print id, name.uuid + :1 a + :2 b + :3 c + :4 d """ if bug_shortname == None: bug_shortname = "" @@ -726,12 +729,14 @@ cmp_author = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "autho 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") +cmp_extra_strings = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "extra_strings") # 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) + cmp_uuid, cmp_extra_strings) class CommentCompoundComparator (object): def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST): diff --git a/libbe/git.py b/libbe/git.py index cb4436a..55556de 100644 --- a/libbe/git.py +++ b/libbe/git.py @@ -134,7 +134,7 @@ class Git(vcs.VCS): if status == 128: if error.startswith("fatal: ambiguous argument 'HEAD': unknown "): return None - raise vcs.CommandError(args, status, stdout="", stderr=error) + raise vcs.CommandError(args, status, stderr=error) commits = output.splitlines() try: return commits[index] diff --git a/libbe/subproc.py b/libbe/subproc.py new file mode 100644 index 0000000..3e58271 --- /dev/null +++ b/libbe/subproc.py @@ -0,0 +1,220 @@ +# 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. + +""" +Functions for running external commands in subprocesses. +""" + +from subprocess import Popen, PIPE +import sys +import doctest + +from encoding import get_encoding + +_MSWINDOWS = sys.platform == 'win32' +_POSIX = not _MSWINDOWS + +if _POSIX == True: + import os + import select + +class CommandError(Exception): + def __init__(self, command, status, stdout=None, stderr=None): + 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 + +def invoke(args, stdin=None, stdout=PIPE, stderr=PIPE, expect=(0,), + cwd=None, unicode_output=True, verbose=False, encoding=None): + """ + expect should be a tuple of allowed exit codes. cwd should be + the directory from which the command will be executed. When + unicode_output == True, convert stdout and stdin strings to + unicode before returing them. + """ + if cwd == None: + cwd = '.' + if verbose == True: + print >> sys.stderr, '%s$ %s' % (cwd, ' '.join(args)) + try : + if _POSIX: + q = Popen(args, stdin=PIPE, stdout=stdout, stderr=stderr, cwd=cwd) + else: + assert _MSWINDOWS==True, 'invalid platform' + # win32 don't have os.execvp() so have to run command in a shell + q = Popen(args, stdin=PIPE, stdout=stdout, stderr=stderr, + shell=True, cwd=cwd) + except OSError, e: + raise CommandError(args, status=e.args[0], stderr=e) + stdout,stderr = q.communicate(input=stdin) + status = q.wait() + if unicode_output == True: + if encoding == None: + encoding = get_encoding() + if stdout != None: + stdout = unicode(stdout, encoding) + if stderr != None: + stderr = unicode(stderr, encoding) + if verbose == True: + print >> sys.stderr, '%d\n%s%s' % (status, stdout, stderr) + if status not in expect: + raise CommandError(args, status, stdout, stderr) + return status, stdout, stderr + +class Pipe (object): + """ + Simple interface for executing POSIX-style pipes based on the + subprocess module. The only complication is the adaptation of + subprocess.Popen._comminucate to listen to the stderrs of all + processes involved in the pipe, as well as the terminal process' + stdout. There are two implementations of Pipe._communicate, one + for MS Windows, and one for POSIX systems. The MS Windows + implementation is currently untested. + + >>> p = Pipe([['find', '/etc/'], ['grep', '^/etc/ssh$']]) + >>> p.stdout + '/etc/ssh\\n' + >>> p.status + 1 + >>> p.statuses + [1, 0] + >>> p.stderrs # doctest: +ELLIPSIS + ["find: `...': Permission denied\\n...", ''] + """ + def __init__(self, cmds, stdin=None): + # spawn processes + self._procs = [] + for cmd in cmds: + if len(self._procs) != 0: + stdin = self._procs[-1].stdout + self._procs.append(Popen(cmd, stdin=stdin, stdout=PIPE, stderr=PIPE)) + + self.stdout,self.stderrs = self._communicate(input=None) + + # collect process statuses + self.statuses = [] + self.status = 0 + for proc in self._procs: + self.statuses.append(proc.wait()) + if self.statuses[-1] != 0: + self.status = self.statuses[-1] + + # Code excerpted from subprocess.Popen._communicate() + if _MSWINDOWS == True: + def _communicate(self, input=None): + assert input == None, 'stdin != None not yet supported' + # listen to each process' stderr + threads = [] + std_X_arrays = [] + for proc in self._procs: + stderr_array = [] + thread = Thread(target=proc._readerthread, + args=(proc.stderr, stderr_array)) + thread.setDaemon(True) + thread.start() + threads.append(thread) + std_X_arrays.append(stderr_array) + + # also listen to the last processes stdout + stdout_array = [] + thread = Thread(target=proc._readerthread, + args=(proc.stdout, stdout_array)) + thread.setDaemon(True) + thread.start() + threads.append(thread) + std_X_arrays.append(stdout_array) + + # join threads as they die + for thread in threads: + thread.join() + + # read output from reader threads + std_X_strings = [] + for std_X_array in std_X_arrays: + std_X_strings.append(std_X_array[0]) + + stdout = std_X_strings.pop(-1) + stderrs = std_X_strings + return (stdout, stderrs) + else: + assert _POSIX==True, 'invalid platform' + def _communicate(self, input=None): + read_set = [] + write_set = [] + read_arrays = [] + stdout = None # Return + stderr = None # Return + + if self._procs[0].stdin: + # Flush stdio buffer. This might block, if the user has + # been writing to .stdin in an uncontrolled fashion. + self._procs[0].stdin.flush() + if input: + write_set.append(self._procs[0].stdin) + else: + self._procs[0].stdin.close() + for proc in self._procs: + read_set.append(proc.stderr) + read_arrays.append([]) + read_set.append(self._procs[-1].stdout) + read_arrays.append([]) + + input_offset = 0 + while read_set or write_set: + try: + rlist, wlist, xlist = select.select(read_set, write_set, []) + except select.error, e: + if e.args[0] == errno.EINTR: + continue + raise + if self._procs[0].stdin in wlist: + # When select has indicated that the file is writable, + # we can write up to PIPE_BUF bytes without risk + # blocking. POSIX defines PIPE_BUF >= 512 + chunk = input[input_offset : input_offset + 512] + bytes_written = os.write(self.stdin.fileno(), chunk) + input_offset += bytes_written + if input_offset >= len(input): + self._procs[0].stdin.close() + write_set.remove(self._procs[0].stdin) + if self._procs[-1].stdout in rlist: + data = os.read(self._procs[-1].stdout.fileno(), 1024) + if data == '': + self._procs[-1].stdout.close() + read_set.remove(self._procs[-1].stdout) + read_arrays[-1].append(data) + for i,proc in enumerate(self._procs): + if proc.stderr in rlist: + data = os.read(proc.stderr.fileno(), 1024) + if data == '': + proc.stderr.close() + read_set.remove(proc.stderr) + read_arrays[i].append(data) + + # All data exchanged. Translate lists into strings. + read_strings = [] + for read_array in read_arrays: + read_strings.append(''.join(read_array)) + + stdout = read_strings.pop(-1) + stderrs = read_strings + return (stdout, stderrs) + +suite = doctest.DocTestSuite() diff --git a/libbe/utility.py b/libbe/utility.py index 4126913..7510b16 100644 --- a/libbe/utility.py +++ b/libbe/utility.py @@ -29,6 +29,21 @@ import time import types import doctest +class InvalidXML(ValueError): + """ + Invalid XML while parsing for a *.from_xml() method. + type - string identifying *, e.g. "bug", "comment", ... + element - ElementTree.Element instance which caused the error + error - string describing the error + """ + def __init__(self, type, element, error): + msg = 'Invalid %s xml: %s\n %s\n' \ + % (type, error, ElementTree.tostring(element)) + ValueError.__init__(self, msg) + self.type = type + self.element = element + self.error = error + def search_parent_directories(path, filename): """ Find the file (or directory) named filename in path or in any diff --git a/libbe/vcs.py b/libbe/vcs.py index 1ac5dd9..57a0245 100644 --- a/libbe/vcs.py +++ b/libbe/vcs.py @@ -25,7 +25,6 @@ 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 @@ -38,16 +37,24 @@ import unittest import doctest from utility import Dir, search_parent_directories +from subproc import CommandError, invoke +from plugin import get_plugin +# List VCS modules in order of preference. +# Don't list this module, it is implicitly last. +VCS_ORDER = ['arch', 'bzr', 'darcs', 'git', 'hg'] + +def set_preferred_vcs(name): + global VCS_ORDER + assert name in VCS_ORDER, \ + 'unrecognized VCS %s not in\n %s' % (name, VCS_ORDER) + VCS_ORDER.remove(name) + VCS_ORDER.insert(0, name) 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]: + for submodname in VCS_ORDER: + module = get_plugin('libbe', submodname) vcs = module.new() if matchfn(vcs) == True: return vcs @@ -67,15 +74,6 @@ def 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 @@ -126,6 +124,10 @@ class VCS(object): self._duplicateDirname = None self.encoding = encoding self.version = self._get_version() + def __str__(self): + return "<%s %s>" % (self.__class__.__name__, id(self)) + def __repr__(self): + return str(self) def _vcs_version(self): """ Return the VCS version string. @@ -332,6 +334,10 @@ class VCS(object): """ Get the file as it was in a given revision. Revision==None specifies the current revision. + + allow_no_vcs==True allows direct access to files through + codecs.open() or open() if the vcs decides it can't handle the + given path. """ if not os.path.exists(path): raise NoSuchFile(path) @@ -339,7 +345,10 @@ class VCS(object): relpath = self._u_rel_path(path) contents = self._vcs_get_file_contents(relpath,revision,binary=binary) else: - f = codecs.open(path, "r", self.encoding) + if binary == True: + f = codecs.open(path, "r", self.encoding) + else: + f = open(path, "rb") contents = f.read() f.close() return contents @@ -457,37 +466,14 @@ class VCS(object): if list_string in string: return True return False - def _u_invoke(self, args, stdin=None, expect=(0,), cwd=None, - unicode_output=True): - """ - expect should be a tuple of allowed exit codes. cwd should be - the directory from which the command will be executed. When - unicode_output == True, convert stdout and stdin strings to - unicode before returing them. - """ - 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) - stdout,stderr = q.communicate(input=stdin) - status = q.wait() - if unicode_output == True: - stdout = unicode(stdout, self.encoding) - stderr = unicode(stderr, self.encoding) - if self.verboseInvoke == True: - print >> sys.stderr, "%d\n%s%s" % (status, stdout, stderr) - if status not in expect: - raise CommandError(args, status, stdout, stderr) - return status, stdout, stderr + def _u_invoke(self, *args, **kwargs): + if 'cwd' not in kwargs: + kwargs['cwd'] = self.rootdir + if 'verbose' not in kwargs: + kwargs['verbose'] = self.verboseInvoke + if 'encoding' not in kwargs: + kwargs['encoding'] = self.encoding + return invoke(*args, **kwargs) def _u_invoke_client(self, *args, **kwargs): cl_args = [self.client] cl_args.extend(args) @@ -20,10 +20,10 @@ import os import os.path import shutil import string -from subprocess import Popen import sys -from update_copyright import Pipe, update_authors, update_files +from libbe.subproc import Pipe, invoke +from update_copyright import update_authors, update_files def validate_tag(tag): """ @@ -93,8 +93,8 @@ def make_version(): def make_changelog(filename, tag): print 'generate ChangeLog file', filename, 'up to tag', tag - p = Popen(['bzr', 'log', '--gnu-changelog', '-n1', '-r', - '..tag:%s' % tag], stdout=file(filename, 'w')) + p = invoke(['bzr', 'log', '--gnu-changelog', '-n1', '-r', + '..tag:%s' % tag], stdout=file(filename, 'w')) status = p.wait() assert status == 0, status @@ -8,7 +8,7 @@ When called with module name arguments, only run the doctests from those modules. """ -from libbe import plugin +from libbe import plugin, vcs import unittest import doctest import sys @@ -41,9 +41,10 @@ else: for modname,module in plugin.iter_plugins("becommands"): suite.addTest(doctest.DocTestSuite(module)) -#for s in suite._tests: -# print s -#exit(0) +_vcs = vcs.installed_vcs() +vcs.set_preferred_vcs(_vcs.name) +print 'Using %s as the testing VCS' % _vcs.name + result = unittest.TextTestRunner(verbosity=2).run(suite) numErrors = len(result.errors) diff --git a/update_copyright.py b/update_copyright.py index 6cdaa2f..5ca5455 100755 --- a/update_copyright.py +++ b/update_copyright.py @@ -24,9 +24,10 @@ import time import os import sys import select -from subprocess import Popen, PIPE, mswindows from threading import Thread +from libbe.subproc import Pipe + COPYRIGHT_TEXT="""# # 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 @@ -71,144 +72,6 @@ EXCLUDES = [ IGNORED_PATHS = ['./.be/', './.bzr/', './build/'] IGNORED_FILES = ['COPYING', 'update_copyright.py', 'catmutt'] -class Pipe (object): - """ - Simple interface for executing POSIX-style pipes based on the - subprocess module. The only complication is the adaptation of - subprocess.Popen._comminucate to listen to the stderrs of all - processes involved in the pipe, as well as the terminal process' - stdout. There are two implementations of Pipe._communicate, one - for MS Windows, and one for POSIX systems. The MS Windows - implementation is currently untested. - - >>> p = Pipe([['find', '/etc/'], ['grep', '^/etc/ssh$']]) - >>> p.stdout - '/etc/ssh\\n' - >>> p.status - 1 - >>> p.statuses - [1, 0] - >>> p.stderrs # doctest: +ELLIPSIS - ["find: `...': Permission denied\\n...", ''] - """ - def __init__(self, cmds, stdin=None): - # spawn processes - self._procs = [] - for cmd in cmds: - if len(self._procs) != 0: - stdin = self._procs[-1].stdout - self._procs.append(Popen(cmd, stdin=stdin, stdout=PIPE, stderr=PIPE)) - - self.stdout,self.stderrs = self._communicate(input=None) - - # collect process statuses - self.statuses = [] - self.status = 0 - for proc in self._procs: - self.statuses.append(proc.wait()) - if self.statuses[-1] != 0: - self.status = self.statuses[-1] - - # Code excerpted from subprocess.Popen._communicate() - if mswindows == True: - def _communicate(self, input=None): - assert input == None, "stdin != None not yet supported" - # listen to each process' stderr - threads = [] - std_X_arrays = [] - for proc in self._procs: - stderr_array = [] - thread = Thread(target=proc._readerthread, - args=(proc.stderr, stderr_array)) - thread.setDaemon(True) - thread.start() - threads.append(thread) - std_X_arrays.append(stderr_array) - - # also listen to the last processes stdout - stdout_array = [] - thread = Thread(target=proc._readerthread, - args=(proc.stdout, stdout_array)) - thread.setDaemon(True) - thread.start() - threads.append(thread) - std_X_arrays.append(stdout_array) - - # join threads as they die - for thread in threads: - thread.join() - - # read output from reader threads - std_X_strings = [] - for std_X_array in std_X_arrays: - std_X_strings.append(std_X_array[0]) - - stdout = std_X_strings.pop(-1) - stderrs = std_X_strings - return (stdout, stderrs) - else: # POSIX - def _communicate(self, input=None): - read_set = [] - write_set = [] - read_arrays = [] - stdout = None # Return - stderr = None # Return - - if self._procs[0].stdin: - # Flush stdio buffer. This might block, if the user has - # been writing to .stdin in an uncontrolled fashion. - self._procs[0].stdin.flush() - if input: - write_set.append(self._procs[0].stdin) - else: - self._procs[0].stdin.close() - for proc in self._procs: - read_set.append(proc.stderr) - read_arrays.append([]) - read_set.append(self._procs[-1].stdout) - read_arrays.append([]) - - input_offset = 0 - while read_set or write_set: - try: - rlist, wlist, xlist = select.select(read_set, write_set, []) - except select.error, e: - if e.args[0] == errno.EINTR: - continue - raise - if self._procs[0].stdin in wlist: - # When select has indicated that the file is writable, - # we can write up to PIPE_BUF bytes without risk - # blocking. POSIX defines PIPE_BUF >= 512 - chunk = input[input_offset : input_offset + 512] - bytes_written = os.write(self.stdin.fileno(), chunk) - input_offset += bytes_written - if input_offset >= len(input): - self._procs[0].stdin.close() - write_set.remove(self._procs[0].stdin) - if self._procs[-1].stdout in rlist: - data = os.read(self._procs[-1].stdout.fileno(), 1024) - if data == "": - self._procs[-1].stdout.close() - read_set.remove(self._procs[-1].stdout) - read_arrays[-1].append(data) - for i,proc in enumerate(self._procs): - if proc.stderr in rlist: - data = os.read(proc.stderr.fileno(), 1024) - if data == "": - proc.stderr.close() - read_set.remove(proc.stderr) - read_arrays[i].append(data) - - # All data exchanged. Translate lists into strings. - read_strings = [] - for read_array in read_arrays: - read_strings.append(''.join(read_array)) - - stdout = read_strings.pop(-1) - stderrs = read_strings - return (stdout, stderrs) - def _strip_email(*args): """ >>> _strip_email('J Doe <jdoe@a.com>') |