aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorW. Trevor King <wking@drexel.edu>2009-07-27 08:12:16 -0400
committerW. Trevor King <wking@drexel.edu>2009-07-27 08:12:16 -0400
commit675aa10516d1fe2a5f0502be5553e38169f444c0 (patch)
treecb19b2ee621cd1120ae9afdafe6f52ee70137a32
parent885b04b50ad3bbde81ec2eccfbfb2e516d0d3a80 (diff)
parent7f2ee356b76303edd01efad6bd049fbc7f01b5ff (diff)
downloadbugseverywhere-675aa10516d1fe2a5f0502be5553e38169f444c0.tar.gz
Merged "be subscribe" and be-handle-mail subscription support.
Also assorted other changes and fixes in the be.subscribe branch. Highlights: * Much more powerful libbe.diff with subclassable report generators. * "be diff" compares working copy with last commit by default. * comment reference text shown in "be comment" EDITOR footer * .revision_id() for all VCSs * meaningful comment comparison and stricter bug comparison * stricter .sync_with_disk interpretation. See BugDir.__doc__. * Comment.From and .time_string -> .author and .date, for better conformance with settings_object.setting_name_to_attr_name().
-rw-r--r--.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/287d3cc1-1cd0-449a-b280-87c529e33951/body10
-rw-r--r--.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/287d3cc1-1cd0-449a-b280-87c529e33951/values8
-rw-r--r--.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/f72f8640-2e50-471e-aebe-0ddb8cdd5a2a/body2
-rw-r--r--.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/f72f8640-2e50-471e-aebe-0ddb8cdd5a2a/values11
-rw-r--r--.be/bugs/52034fd0-ec50-424d-b25d-2beaf2d2c317/comments/4c50ca0b-a08f-4723-b00d-4bf342cf86b6/body20
-rw-r--r--.be/bugs/52034fd0-ec50-424d-b25d-2beaf2d2c317/comments/4c50ca0b-a08f-4723-b00d-4bf342cf86b6/values11
-rw-r--r--.be/bugs/52034fd0-ec50-424d-b25d-2beaf2d2c317/comments/b17a561a-6100-490e-84eb-d1ae4b617940/body9
-rw-r--r--.be/bugs/52034fd0-ec50-424d-b25d-2beaf2d2c317/comments/b17a561a-6100-490e-84eb-d1ae4b617940/values8
-rw-r--r--.be/bugs/52034fd0-ec50-424d-b25d-2beaf2d2c317/values17
-rw-r--r--.be/settings4
-rw-r--r--becommands/comment.py22
-rw-r--r--becommands/diff.py46
-rw-r--r--becommands/subscribe.py368
-rwxr-xr-xinterfaces/email/interactive/be-handle-mail140
-rw-r--r--interfaces/email/interactive/send_pgp_mime.py41
-rwxr-xr-xinterfaces/xml/be-mbox-to-xml7
-rwxr-xr-xinterfaces/xml/be-xml-to-mbox6
-rw-r--r--libbe/arch.py12
-rw-r--r--libbe/bug.py95
-rw-r--r--libbe/bugdir.py283
-rw-r--r--libbe/bzr.py8
-rw-r--r--libbe/comment.py278
-rw-r--r--libbe/darcs.py43
-rw-r--r--libbe/diff.py464
-rw-r--r--libbe/git.py13
-rw-r--r--libbe/hg.py16
-rw-r--r--libbe/properties.py10
-rw-r--r--libbe/rcs.py46
-rw-r--r--libbe/settings_object.py11
-rw-r--r--libbe/tree.py33
30 files changed, 1648 insertions, 394 deletions
diff --git a/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/287d3cc1-1cd0-449a-b280-87c529e33951/body b/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/287d3cc1-1cd0-449a-b280-87c529e33951/body
new file mode 100644
index 0000000..53456f6
--- /dev/null
+++ b/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/287d3cc1-1cd0-449a-b280-87c529e33951/body
@@ -0,0 +1,10 @@
+Hmm, perhaps my thinking has been too revision-centric. I'm not
+really sure what other level of granularity is appropriate though.
+Both notifications and commits should be generated on a "per-session"
+level, so maybe I'll just ignore Arch and Mercurial (for whom revising
+history is difficult, so per-session commits can be more work) for the
+time being ;).
+
+In that case, _every_ commit will be a
+ notify-since <revision-id>
+sort of change, so I'll just use libbe.diff :).
diff --git a/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/287d3cc1-1cd0-449a-b280-87c529e33951/values b/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/287d3cc1-1cd0-449a-b280-87c529e33951/values
new file mode 100644
index 0000000..3e89c06
--- /dev/null
+++ b/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/287d3cc1-1cd0-449a-b280-87c529e33951/values
@@ -0,0 +1,8 @@
+Content-type: text/plain
+
+
+Date: Wed, 22 Jul 2009 19:07:28 +0000
+
+
+From: W. Trevor King <wking@drexel.edu>
+
diff --git a/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/f72f8640-2e50-471e-aebe-0ddb8cdd5a2a/body b/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/f72f8640-2e50-471e-aebe-0ddb8cdd5a2a/body
new file mode 100644
index 0000000..3c95f19
--- /dev/null
+++ b/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/f72f8640-2e50-471e-aebe-0ddb8cdd5a2a/body
@@ -0,0 +1,2 @@
+The intereface changed a bit as I implemented it. See "be help
+subscribe" for details.
diff --git a/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/f72f8640-2e50-471e-aebe-0ddb8cdd5a2a/values b/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/f72f8640-2e50-471e-aebe-0ddb8cdd5a2a/values
new file mode 100644
index 0000000..1c908f7
--- /dev/null
+++ b/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/f72f8640-2e50-471e-aebe-0ddb8cdd5a2a/values
@@ -0,0 +1,11 @@
+Content-type: text/plain
+
+
+Date: Wed, 22 Jul 2009 18:54:06 +0000
+
+
+From: W. Trevor King <wking@drexel.edu>
+
+
+In-reply-to: 85a2d1ac-200a-4ae7-841f-9f4e87795dbf
+
diff --git a/.be/bugs/52034fd0-ec50-424d-b25d-2beaf2d2c317/comments/4c50ca0b-a08f-4723-b00d-4bf342cf86b6/body b/.be/bugs/52034fd0-ec50-424d-b25d-2beaf2d2c317/comments/4c50ca0b-a08f-4723-b00d-4bf342cf86b6/body
new file mode 100644
index 0000000..90b386a
--- /dev/null
+++ b/.be/bugs/52034fd0-ec50-424d-b25d-2beaf2d2c317/comments/4c50ca0b-a08f-4723-b00d-4bf342cf86b6/body
@@ -0,0 +1,20 @@
+I'm all for flexibility, so long as it doesn't require too much
+hackery to implement it. You'll have two problems:
+
+ * Determining what to commit.
+
+ You'd have to have RCS keep a log of all versioned files it
+ touched, and extend .commit() to accept the keyword list "files"
+ and commit only those files. This is doable, but maybe not worth
+ the trouble.
+
+ * Generating meaningful commit messages.
+
+ You'd have to add this functionality to each command (and future
+ commands).
+
+This would probably not be a good idea for the Arch and Mercurial
+backends, since they have a limited ability to rewrite history when
+you screw up your commit message (as far as I can tell). Mercurial
+does have "hg rollback", but it only works once, and lots of
+typo-correction commits would just make the logs awkward.
diff --git a/.be/bugs/52034fd0-ec50-424d-b25d-2beaf2d2c317/comments/4c50ca0b-a08f-4723-b00d-4bf342cf86b6/values b/.be/bugs/52034fd0-ec50-424d-b25d-2beaf2d2c317/comments/4c50ca0b-a08f-4723-b00d-4bf342cf86b6/values
new file mode 100644
index 0000000..5823128
--- /dev/null
+++ b/.be/bugs/52034fd0-ec50-424d-b25d-2beaf2d2c317/comments/4c50ca0b-a08f-4723-b00d-4bf342cf86b6/values
@@ -0,0 +1,11 @@
+Content-type: text/plain
+
+
+Date: Fri, 24 Jul 2009 12:33:58 +0000
+
+
+From: W. Trevor King <wking@drexel.edu>
+
+
+In-reply-to: b17a561a-6100-490e-84eb-d1ae4b617940
+
diff --git a/.be/bugs/52034fd0-ec50-424d-b25d-2beaf2d2c317/comments/b17a561a-6100-490e-84eb-d1ae4b617940/body b/.be/bugs/52034fd0-ec50-424d-b25d-2beaf2d2c317/comments/b17a561a-6100-490e-84eb-d1ae4b617940/body
new file mode 100644
index 0000000..c88a838
--- /dev/null
+++ b/.be/bugs/52034fd0-ec50-424d-b25d-2beaf2d2c317/comments/b17a561a-6100-490e-84eb-d1ae4b617940/body
@@ -0,0 +1,9 @@
+...
+Also, why doesn't be commit after it takes an action? I think it's
+kinda weird that I have to commit after creating a new bug.
+...
+
+as posted in
+ http://bugs.debian.org/cgi-bin/bugreport.cgi?bug=477125
+ on
+ Fri, 12 Jun 2009 17:03:02 +0200
diff --git a/.be/bugs/52034fd0-ec50-424d-b25d-2beaf2d2c317/comments/b17a561a-6100-490e-84eb-d1ae4b617940/values b/.be/bugs/52034fd0-ec50-424d-b25d-2beaf2d2c317/comments/b17a561a-6100-490e-84eb-d1ae4b617940/values
new file mode 100644
index 0000000..c069931
--- /dev/null
+++ b/.be/bugs/52034fd0-ec50-424d-b25d-2beaf2d2c317/comments/b17a561a-6100-490e-84eb-d1ae4b617940/values
@@ -0,0 +1,8 @@
+Content-type: text/plain
+
+
+Date: Fri, 24 Jul 2009 12:09:02 +0000
+
+
+From: Martin F Krafft <madduck@debian.org>
+
diff --git a/.be/bugs/52034fd0-ec50-424d-b25d-2beaf2d2c317/values b/.be/bugs/52034fd0-ec50-424d-b25d-2beaf2d2c317/values
new file mode 100644
index 0000000..d060e87
--- /dev/null
+++ b/.be/bugs/52034fd0-ec50-424d-b25d-2beaf2d2c317/values
@@ -0,0 +1,17 @@
+creator: W. Trevor King <wking@drexel.edu>
+
+
+reporter: Martin F Krafft <madduck@debian.org>
+
+
+severity: wishlist
+
+
+status: open
+
+
+summary: Allow autocommit option for command line interface?
+
+
+time: Fri, 24 Jul 2009 12:04:08 +0000
+
diff --git a/.be/settings b/.be/settings
index a9bd6dd..15c4553 100644
--- a/.be/settings
+++ b/.be/settings
@@ -1,3 +1,7 @@
+extra_strings:
+- "SUBSCRIBE:W. Trevor King <wking@drexel.edu>\tall\t*"
+
+
inactive_status:
- - closed
- The bug is no longer relevant.
diff --git a/becommands/comment.py b/becommands/comment.py
index 9408b09..7bbee2c 100644
--- a/becommands/comment.py
+++ b/becommands/comment.py
@@ -38,7 +38,7 @@ def execute(args, manipulate_encodings=True):
>>> print comment.body
This is a comment about a
<BLANKLINE>
- >>> comment.From == bd.user_id
+ >>> comment.author == bd.user_id
True
>>> comment.time <= int(time.time())
True
@@ -68,10 +68,10 @@ def execute(args, manipulate_encodings=True):
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)
+ raise cmdutil.UserError("Invalid id '%s'." % shortname)
elif shortname.count(':') == 1:
# Split shortname generated by Comment.comment_shortnames()
bugname = shortname.split(':')[0]
@@ -79,7 +79,7 @@ def execute(args, manipulate_encodings=True):
else:
bugname = shortname
is_reply = False
-
+
bd = bugdir.BugDir(from_disk=True,
manipulate_encodings=manipulate_encodings)
bug = bd.bug_from_shortname(bugname)
@@ -89,10 +89,16 @@ def execute(args, manipulate_encodings=True):
bug_shortname=bugname)
else:
parent = bug.comment_root
-
+
if len(args) == 1: # try to launch an editor for comment-body entry
try:
- body = editor.editor_string("Please enter your comment above")
+ 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:
@@ -111,11 +117,11 @@ def execute(args, manipulate_encodings=True):
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.From = options.author
+ new.author = options.author
if options.alt_id != None:
new.alt_id = options.alt_id
if options.content_type != None:
diff --git a/becommands/diff.py b/becommands/diff.py
index 50dea7c..1ab2135 100644
--- a/becommands/diff.py
+++ b/becommands/diff.py
@@ -33,11 +33,21 @@ def execute(args, manipulate_encodings=True):
>>> if bd.rcs.versioned == True:
... execute([original], manipulate_encodings=False)
... else:
- ... print "a:cm: Bug A\\nstatus: open -> closed\\n"
- Modified bug reports:
- a:cm: Bug A
- status: open -> closed
- <BLANKLINE>
+ ... 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.rcs.versioned == True:
+ ... execute(["--modified", original], manipulate_encodings=False)
+ ... else:
+ ... print "a"
+ a
+ >>> if bd.rcs.versioned == False:
+ ... execute([original], manipulate_encodings=False)
+ ... else:
+ ... print "This directory is not revision-controlled."
+ This directory is not revision-controlled.
"""
parser = get_parser()
options, args = parser.parse_args(args)
@@ -53,23 +63,27 @@ def execute(args, manipulate_encodings=True):
if bd.rcs.versioned == False:
print "This directory is not revision-controlled."
else:
+ if revision == None: # get the most recent revision
+ revision = bd.rcs.revision_id(-1)
old_bd = bd.duplicate_bugdir(revision)
- r,m,a = diff.diff(old_bd, bd)
-
- optbugs = []
+ 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:
- optbugs.extend(a)
+ uuids.extend([c.name for c in tree.child_by_path("/bugs/new")])
if options.modified == True:
- optbugs.extend([new for old,new in m])
+ uuids.extend([c.name for c in tree.child_by_path("/bugs/mod")])
if options.removed == True:
- optbugs.extend(r)
- if len(optbugs) > 0:
- for bug in optbugs:
- print bug.uuid
+ 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 :
- print diff.diff_report((r,m,a), bd).encode(bd.encoding)
+ rep = tree.report_string()
+ if rep != None:
+ print rep
bd.remove_duplicate_bugdir()
def get_parser():
@@ -85,7 +99,7 @@ def get_parser():
long = "--%s" % s[1]
help = s[2]
parser.add_option(short, long, action="store_true",
- dest=attr, help=help)
+ default=False, dest=attr, help=help)
return parser
longhelp="""
diff --git a/becommands/subscribe.py b/becommands/subscribe.py
new file mode 100644
index 0000000..64a2867
--- /dev/null
+++ b/becommands/subscribe.py
@@ -0,0 +1,368 @@
+# 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.simple_bug_dir()
+ >>> 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 *
+ """
+ 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
+
+ types = [type_from_name(name, type_root, default=INVALID_TYPE,
+ default_ok=options.unsubscribe)
+ for name in types]
+ estrs = entity.extra_strings
+ 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
+
+ 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("-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:
+ 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 == ["*"]:
+ 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).
+
+ >>> bd = bugdir.simple_bug_dir()
+ >>> 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>]}}
+ """
+ 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/be-handle-mail b/interfaces/email/interactive/be-handle-mail
index f457b6a..ed45bdd 100755
--- a/interfaces/email/interactive/be-handle-mail
+++ b/interfaces/email/interactive/be-handle-mail
@@ -52,10 +52,14 @@ import traceback
import doctest
import unittest
-import libbe.cmdutil, libbe.encoding, libbe.utility
+from becommands import subscribe
+import libbe.cmdutil, libbe.encoding, libbe.utility, libbe.bugdir
+import libbe.diff
import send_pgp_mime
-HANDLER_ADDRESS = u"BE Bugs <wking@thor.physics.drexel.edu>"
+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")
@@ -135,6 +139,12 @@ class InvalidOption (InvalidCommand):
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
@@ -261,6 +271,23 @@ class Command (object):
send_pgp_mime.PGPMimeMessageFactory(u"\n".join(response_body))
return response_generator.plain()
+class DiffTree (libbe.diff.DiffTree):
+ def report_string(self):
+ return send_pgp_mime.flatten(self.report(), to_unicode=True)
+ def make_root(self):
+ return MIMEMultipart()
+ def join(self, root, part):
+ if part != None:
+ root.attach(send_pgp_mime.encodedMIMEText(part))
+ def data_string(self, depth, indent=False):
+ return libbe.diff.DiffTree.data_string(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()
+
class Message (object):
def __init__(self, email_text):
self.text = email_text
@@ -472,15 +499,15 @@ class Message (object):
finally:
if AUTOCOMMIT == True:
tag,subject = self._split_subject()
- command = Command(self, "commit", [subject])
- command.run()
+ self.commit_command = Command(self, "commit", [subject])
+ self.commit_command.run()
if LOGFILE != None:
LOGFILE.write("Autocommit:\n%s\n\n" %
- send_pgp_mime.flatten(command.response_msg(),
- to_unicode=True))
+ 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" % HANDLER_ADDRESS,
+ 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)
@@ -501,6 +528,88 @@ class Message (object):
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):
+ if AUTOCOMMIT != True: # no way to tell what's changed
+ raise NotificationFailed("Autocommit dissabled")
+ assert len(self._response_messages) > 0
+ 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.rcs.versioned == False: # no way to tell what's changed
+ raise NotificationFailed("Not versioned")
+
+ subscribers = subscribe.get_bugdir_subscribers(bd, THIS_SERVER)
+ if len(subscribers) == 0:
+ return []
+
+ before_bd, after_bd = self._get_before_and_after_bugdirs(bd)
+ 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)
+
+ emails = []
+ for subscriber,subscriptions in subscribers.items():
+ header.replace_header("to", subscriber)
+ parts = []
+ for id,types in subscriptions.items():
+ if id == "DIR":
+ if subscribe.BUGDIR_TYPE_ALL in types:
+ parts.append(diff_tree.report())
+ break
+ if subscribe.BUGDIR_TYPE_NEW in types:
+ new = diff_tree.child_by_path("/bugs/new")
+ parts.append(new.report())
+ continue # move on to next id
+ assert types == [subscribe.BUG_TYPE_ALL], types
+ type,bug_root = bug_index[id]
+ parts.append(bug_root.report())
+ 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()
+ for part in parts:
+ root.attach(part)
+ emails.append(send_pgp_mime.attach_root(header, root))
+ if LOGFILE != None:
+ LOGFILE.write("Notfying %s of changes\n" % subscriber)
+ return emails
+ def _get_before_and_after_bugdirs(self, bd):
+ commit_msg = self.commit_command.stdout
+ assert commit_msg.startswith("Committed "), commit_msg
+ after_revision = commit_msg[len("Committed "):]
+ before_revision = bd.rcs.revision_id(-2)
+ 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):
+ root_dir = os.path.basename(bd.root)
+ subject = "Changes to %s on %s by %s" \
+ % (root_dir, THIS_SERVER, self.author_addr())
+ 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"):
"""
@@ -571,6 +680,9 @@ def main():
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('--test', dest='test', action='store_true',
help='Run internal unit-tests and exit.')
@@ -615,8 +727,22 @@ def main():
LOGFILE.write(u"\n%s\n\n" % send_pgp_mime.flatten(response,
to_unicode=True))
send_pgp_mime.mail(response, send_pgp_mime.sendmail)
+ if options.subscribers == True:
+ LOGFILE.write(u"Checking for subscribers\n")
+ try:
+ emails = m.subscriber_emails()
+ except NotificationFailed, e:
+ 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()
diff --git a/interfaces/email/interactive/send_pgp_mime.py b/interfaces/email/interactive/send_pgp_mime.py
index babd720..09ac0ed 100644
--- a/interfaces/email/interactive/send_pgp_mime.py
+++ b/interfaces/email/interactive/send_pgp_mime.py
@@ -153,6 +153,25 @@ def header_from_text(text, encoding="us-ascii"):
p = Parser()
return p.parsestr(text, headersonly=True)
+def encodedMIMEText(body, encoding=None):
+ if encoding == None:
+ if type(body) == types.StringType:
+ encoding = "us-ascii"
+ elif type(body) == types.UnicodeType:
+ for encoding in ["us-ascii", "iso-8859-1", "utf-8"]:
+ try:
+ body.encode(encoding)
+ except UnicodeError:
+ pass
+ else:
+ break
+ assert encoding != None
+ # Create the message ('plain' stands for Content-Type: text/plain)
+ if encoding == "us-ascii":
+ return MIMEText(body)
+ else:
+ return MIMEText(body.encode(encoding), 'plain', encoding)
+
def attach_root(header, root_part):
"""
Attach the email.Message root_part to the email.Message header
@@ -355,26 +374,8 @@ class PGPMimeMessageFactory (object):
"""
def __init__(self, body):
self.body = body
- def encodedMIMEText(self, body, encoding=None):
- if encoding == None:
- if type(body) == types.StringType:
- encoding = "us-ascii"
- elif type(body) == types.UnicodeType:
- for encoding in ["us-ascii", "iso-8859-1", "utf-8"]:
- try:
- body.encode(encoding)
- except UnicodeError:
- pass
- else:
- break
- assert encoding != None
- # Create the message ('plain' stands for Content-Type: text/plain)
- if encoding == "us-ascii":
- return MIMEText(body)
- else:
- return MIMEText(body.encode(encoding), 'plain', encoding)
def clearBodyPart(self):
- body = self.encodedMIMEText(self.body)
+ body = encodedMIMEText(self.body)
body.add_header('Content-Disposition', 'inline')
return body
def passphrase_arg(self, passphrase=None):
@@ -387,7 +388,7 @@ class PGPMimeMessageFactory (object):
"""
text/plain
"""
- return self.encodedMIMEText(self.body)
+ return encodedMIMEText(self.body)
def sign(self, header, passphrase=None):
"""
multipart/signed
diff --git a/interfaces/xml/be-mbox-to-xml b/interfaces/xml/be-mbox-to-xml
index 57de719..dc6a1c5 100755
--- a/interfaces/xml/be-mbox-to-xml
+++ b/interfaces/xml/be-mbox-to-xml
@@ -28,6 +28,7 @@ from libbe.encoding import get_encoding, set_IO_stream_encodings
from mailbox import mbox, Message # the mailbox people really want an on-disk copy
from time import asctime, gmtime
import types
+from xml.sax.saxutils import escape
DEFAULT_ENCODING = get_encoding()
set_IO_stream_encodings(DEFAULT_ENCODING)
@@ -40,7 +41,7 @@ def comment_message_to_xml(message, fields=None):
new_fields = {}
new_fields[u'alt-id'] = message[u'message-id']
new_fields[u'in-reply-to'] = message[u'in-reply-to']
- new_fields[u'from'] = message[u'from']
+ new_fields[u'author'] = message[u'from']
new_fields[u'date'] = message[u'date']
new_fields[u'content-type'] = message.get_content_type()
for k,v in new_fields.items():
@@ -77,12 +78,12 @@ def comment_message_to_xml(message, fields=None):
if message.is_multipart():
ret = []
alt_id = fields[u'alt-id']
- from_str = fields[u'from']
+ from_str = fields[u'author']
date = fields[u'date']
for m in message.walk():
if m == message:
continue
- fields[u'from'] = from_str
+ fields[u'author'] = from_str
fields[u'date'] = date
if len(ret) > 0: # we've added one part already
fields.pop(u'alt-id') # don't pass alt-id to other parts
diff --git a/interfaces/xml/be-xml-to-mbox b/interfaces/xml/be-xml-to-mbox
index ea77c34..c630447 100755
--- a/interfaces/xml/be-xml-to-mbox
+++ b/interfaces/xml/be-xml-to-mbox
@@ -129,7 +129,7 @@ class Comment (LimitedAttrDict):
u"alt-id",
u"short-name",
u"in-reply-to",
- u"from",
+ u"author",
u"date",
u"content-type",
u"body"]
@@ -137,7 +137,7 @@ class Comment (LimitedAttrDict):
if bug == None:
bug = Bug()
bug[u"uuid"] = u"no-uuid"
- name,addr = email.utils.parseaddr(self["from"])
+ name,addr = email.utils.parseaddr(self["author"])
print "From %s %s" % (addr, rfc2822_to_asctime(self["date"]))
if "uuid" in self: id = self["uuid"]
elif "alt-id" in self: id = self["alt-id"]
@@ -145,7 +145,7 @@ class Comment (LimitedAttrDict):
if id != None:
print "Message-ID: <%s@%s>" % (id, DEFAULT_DOMAIN)
print "Date: %s" % self["date"]
- print "From: %s" % self["from"]
+ print "From: %s" % self["author"]
subject = ""
if "short-name" in self:
subject += self["short-name"]+u": "
diff --git a/libbe/arch.py b/libbe/arch.py
index 2f45aa9..1bdc8ae 100644
--- a/libbe/arch.py
+++ b/libbe/arch.py
@@ -176,7 +176,6 @@ class Arch(RCS):
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')
@@ -188,7 +187,6 @@ class Arch(RCS):
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)
@@ -281,6 +279,16 @@ class Arch(RCS):
assert revpath.startswith(self._archive_project_name()+'--')
revision = revpath[len(self._archive_project_name()+'--'):]
return revpath
+ def _rcs_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):
diff --git a/libbe/bug.py b/libbe/bug.py
index f3448e2..7554318 100644
--- a/libbe/bug.py
+++ b/libbe/bug.py
@@ -33,6 +33,11 @@ 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/
@@ -245,10 +250,13 @@ class Bug(settings_object.SavedSettingsObject):
def __repr__(self):
return "Bug(uuid=%r)" % self.uuid
- def set_sync_with_disk(self, value):
- self.sync_with_disk = value
- for comment in self.comments():
- comment.set_sync_with_disk(value)
+ 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)
@@ -331,11 +339,7 @@ class Bug(settings_object.SavedSettingsObject):
output = bugout
return output
- def __str__(self):
- return self.string(shortlist=True)
-
- def __cmp__(self, other):
- return cmp_full(self, other)
+ # methods for saving/loading/acessing settings and properties.
def get_path(self, name=None):
my_dir = os.path.join(self.bugdir.get_path("bugs"), self.uuid)
@@ -344,30 +348,25 @@ class Bug(settings_object.SavedSettingsObject):
assert name in ["values", "comments"]
return os.path.join(my_dir, name)
+ 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.rcs, self.get_path("values"))
self._setup_saved_settings()
- def load_comments(self, load_full=True):
- 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 save_settings(self):
+ if self.sync_with_disk == False:
+ raise DiskAccessRequired("save settings")
assert self.summary != None, "Can't save blank bug"
-
self.rcs.mkdir(self.get_path())
path = self.get_path("values")
mapfile.map_save(self.rcs, path, self._get_saved_settings())
-
+
def save(self):
"""
Save any loaded contents to disk. Because of lazy loading of
@@ -378,15 +377,39 @@ class Bug(settings_object.SavedSettingsObject):
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.rcs.recursive_remove(path)
+ # methods for managing comments
+
def comments(self):
for comment in self.comment_root.traverse():
yield comment
@@ -489,13 +512,35 @@ def cmp_attr(bug_1, bug_2, attr, invert=False):
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_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):
diff --git a/libbe/bugdir.py b/libbe/bugdir.py
index 6e020ee..e9854c9 100644
--- a/libbe/bugdir.py
+++ b/libbe/bugdir.py
@@ -17,11 +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.
+import copy
+import errno
import os
import os.path
-import errno
+import sys
import time
-import copy
import unittest
import doctest
@@ -62,6 +63,11 @@ class MultipleBugMatches(ValueError):
self.shortname = shortname
self.matches = matches
+class DiskAccessRequired (Exception):
+ def __init__(self, goal):
+ msg = "Cannot %s without accessing the disk" % goal
+ Exception.__init__(self, msg)
+
TREE_VERSION_STRING = "Bugs Everywhere Tree 1 0\n"
@@ -99,7 +105,8 @@ class BugDir (list, settings_object.SavedSettingsObject):
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 memoryy, a call to save() is a safe move.
+ 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.
@@ -107,9 +114,8 @@ class BugDir (list, settings_object.SavedSettingsObject):
changes, this .save() call will be a waste of time.
The BugDir will only load information from the file system when it
- loads new bugs/comments that it doesn't already have in memory, or
- when it explicitly asked to do so (e.g. .load() or
- __init__(from_disk=True)).
+ loads new settings/bugs/comments that it doesn't already have in
+ memory and .sync_with_disk == True.
Allow RCS initialization
========================
@@ -280,8 +286,7 @@ settings easy. Don't set this attribute. Set .rcs instead, and
def __init__(self, root=None, sink_to_existing_root=True,
assert_new_BugDir=False, allow_rcs_init=False,
- manipulate_encodings=True,
- from_disk=False, rcs=None):
+ manipulate_encodings=True, from_disk=False, rcs=None):
list.__init__(self)
settings_object.SavedSettingsObject.__init__(self)
self._manipulate_encodings = manipulate_encodings
@@ -310,15 +315,13 @@ settings easy. Don't set this attribute. Set .rcs instead, and
self.rcs = rcs
self._setup_user_id(self.user_id)
- def set_sync_with_disk(self, value):
- self.sync_with_disk = value
- for bug in self:
- bug.set_sync_with_disk(value)
+ # 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.
+ 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)
@@ -334,7 +337,77 @@ settings easy. Don't set this attribute. Set .rcs instead, and
raise NoBugDir(path)
return beroot
+ def _guess_rcs(self, allow_rcs_init=False):
+ """
+ Only called by __init__.
+ """
+ deepdir = self.get_path()
+ if not os.path.exists(deepdir):
+ deepdir = os.path.dirname(deepdir)
+ new_rcs = rcs.detect_rcs(deepdir)
+ install = False
+ if new_rcs.name == "None":
+ if allow_rcs_init == True:
+ new_rcs = rcs.installed_rcs()
+ new_rcs.init(self.root)
+ return new_rcs
+
+ # methods for saving/loading/accessing settings and properties.
+
+ def get_path(self, *args):
+ """
+ Return a path relative to .root.
+ """
+ my_dir = os.path.join(self.root, ".be")
+ if len(args) == 0:
+ return my_dir
+ assert args[0] in ["version", "settings", "bugs"], str(args)
+ return os.path.join(my_dir, *args)
+
+ def _get_settings(self, settings_path, for_duplicate_bugdir=False):
+ allow_no_rcs = not self.rcs.path_in_root(settings_path)
+ if allow_no_rcs == 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.rcs, settings_path, allow_no_rcs)
+ except rcs.NoSuchFile:
+ settings = {"rcs_name": "None"}
+ return settings
+
+ def _save_settings(self, settings_path, settings,
+ for_duplicate_bugdir=False):
+ allow_no_rcs = not self.rcs.path_in_root(settings_path)
+ if allow_no_rcs == 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.rcs.mkdir(self.get_path(), allow_no_rcs)
+ mapfile.map_save(self.rcs, settings_path, settings, allow_no_rcs)
+
+ 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.rcs = rcs.rcs_by_name(self.rcs_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_rcs=False):
+ """
+ Requires disk access.
+ """
+ if self.sync_with_disk == False:
+ raise DiskAccessRequired("get version")
if use_none_rcs == True:
RCS = rcs.rcs_by_name("None")
RCS.root(self.root)
@@ -348,30 +421,31 @@ settings easy. Don't set this attribute. Set .rcs instead, and
return tree_version
def set_version(self):
+ """
+ Requires disk access.
+ """
+ if self.sync_with_disk == False:
+ raise DiskAccessRequired("set version")
self.rcs.mkdir(self.get_path())
self.rcs.set_file_contents(self.get_path("version"),
TREE_VERSION_STRING)
- def get_path(self, *args):
- my_dir = os.path.join(self.root, ".be")
- if len(args) == 0:
- return my_dir
- assert args[0] in ["version", "settings", "bugs"], str(args)
- return os.path.join(my_dir, *args)
+ # methods controlling disk access
- def _guess_rcs(self, allow_rcs_init=False):
- deepdir = self.get_path()
- if not os.path.exists(deepdir):
- deepdir = os.path.dirname(deepdir)
- new_rcs = rcs.detect_rcs(deepdir)
- install = False
- if new_rcs.name == "None":
- if allow_rcs_init == True:
- new_rcs = rcs.installed_rcs()
- new_rcs.init(self.root)
- return new_rcs
+ 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_rcs=True)
if version != TREE_VERSION_STRING:
raise NotImplementedError, \
@@ -381,59 +455,43 @@ settings easy. Don't set this attribute. Set .rcs instead, and
raise NoBugDir(self.get_path())
self.load_settings()
- self.rcs = rcs.rcs_by_name(self.rcs_name)
- self._setup_user_id(self.user_id)
-
def load_all_bugs(self):
- "Warning: this could take a while."
+ """
+ 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 self.sync_with_disk = True, then any changes are
+ 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)
- 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)
-
- def _get_settings(self, settings_path):
- allow_no_rcs = not self.rcs.path_in_root(settings_path)
- # allow_no_rcs=True should only be for the special case of
- # configuring duplicate bugdir settings
-
- try:
- settings = mapfile.map_load(self.rcs, settings_path, allow_no_rcs)
- except rcs.NoSuchFile:
- settings = {"rcs_name": "None"}
- return settings
-
- def save_settings(self):
- settings = self._get_saved_settings()
- self._save_settings(self.get_path("settings"), settings)
-
- def _save_settings(self, settings_path, settings):
- allow_no_rcs = not self.rcs.path_in_root(settings_path)
- # allow_no_rcs=True should only be for the special case of
- # configuring duplicate bugdir settings
- self.rcs.mkdir(self.get_path(), allow_no_rcs)
- mapfile.map_save(self.rcs, settings_path, settings, allow_no_rcs)
+ # methods for managing duplicate BugDirs
def duplicate_bugdir(self, revision):
duplicate_path = self.rcs.duplicate_repo(revision)
@@ -442,23 +500,27 @@ settings easy. Don't set this attribute. Set .rcs instead, and
# initialized for versioning
duplicate_settings_path = os.path.join(duplicate_path,
".be", "settings")
- duplicate_settings = self._get_settings(duplicate_settings_path)
+ duplicate_settings = self._get_settings(duplicate_settings_path,
+ for_duplicate_bugdir=True)
if "rcs_name" in duplicate_settings:
duplicate_settings["rcs_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)
+ 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.rcs.remove_duplicate_repo()
+ # methods for managing bugs
+
def list_uuids(self):
uuids = []
- if os.path.exists(self.get_path()):
+ if self.sync_with_disk == True and os.path.exists(self.get_path()):
# list the uuids on disk
for uuid in os.listdir(self.get_path("bugs")):
if not (uuid.startswith('.')):
@@ -476,6 +538,8 @@ settings easy. Don't set this attribute. Set .rcs instead, and
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()
@@ -492,7 +556,8 @@ settings easy. Don't set this attribute. Set .rcs instead, and
def remove_bug(self, bug):
self.remove(bug)
- bug.remove()
+ if bug.sync_with_disk == True:
+ bug.remove()
def bug_shortname(self, bug):
"""
@@ -514,7 +579,7 @@ settings easy. Don't set this attribute. Set .rcs instead, and
def bug_from_shortname(self, shortname):
"""
- >>> bd = simple_bug_dir()
+ >>> bd = simple_bug_dir(sync_with_disk=False)
>>> bug_a = bd.bug_from_shortname('a')
>>> print type(bug_a)
<class 'libbe.bug.Bug'>
@@ -548,20 +613,31 @@ settings easy. Don't set this attribute. Set .rcs instead, and
return True
-def simple_bug_dir():
+def simple_bug_dir(sync_with_disk=True):
"""
- For testing
+ For testing. Set sync_with_disk==False for a memory-only bugdir.
>>> bugdir = simple_bug_dir()
- >>> ls = list(bugdir.list_uuids())
- >>> ls.sort()
- >>> print ls
+ >>> uuids = list(bugdir.list_uuids())
+ >>> uuids.sort()
+ >>> print uuids
['a', 'b']
"""
- dir = utility.Dir()
- assert os.path.exists(dir.path)
- bugdir = BugDir(dir.path, sink_to_existing_root=False, allow_rcs_init=True,
+ if sync_with_disk == True:
+ dir = utility.Dir()
+ assert os.path.exists(dir.path)
+ root = dir.path
+ assert_new_BugDir = True
+ rcs_init = True
+ else:
+ root = "/"
+ assert_new_BugDir = False
+ rcs_init = False
+ bugdir = BugDir(root, sink_to_existing_root=False,
+ assert_new_BugDir=assert_new_BugDir,
+ allow_rcs_init=rcs_init,
manipulate_encodings=False)
- bugdir._dir_ref = dir # postpone cleanup since dir.__del__() removes dir.
+ if sync_with_disk == True: # postpone cleanup since dir.__del__() removes dir.
+ bugdir._dir_ref = dir
bug_a = bugdir.new_bug("a", summary="Bug A")
bug_a.creator = "John Doe <jdoe@example.com>"
bug_a.time = 0
@@ -569,13 +645,13 @@ def simple_bug_dir():
bug_b.creator = "Jane Doe <jdoe@example.com>"
bug_b.time = 0
bug_b.status = "closed"
- bugdir.save()
+ if sync_with_disk == True:
+ bugdir.save()
+ bugdir.set_sync_with_disk(True)
return bugdir
class BugDirTestCase(unittest.TestCase):
- def __init__(self, *args, **kwargs):
- unittest.TestCase.__init__(self, *args, **kwargs)
def setUp(self):
self.dir = utility.Dir()
self.bugdir = BugDir(self.dir.path, sink_to_existing_root=False,
@@ -645,9 +721,12 @@ class BugDirTestCase(unittest.TestCase):
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:
@@ -655,7 +734,6 @@ class BugDirTestCase(unittest.TestCase):
self.failUnless(repLoaded.uuid == rep.uuid, repLoaded.uuid)
self.failUnless(comment.sync_with_disk == True,
comment.sync_with_disk)
- #load_settings()
self.failUnless(comment.content_type == "text/plain",
comment.content_type)
self.failUnless(repLoaded.settings["Content-type"]=="text/plain",
@@ -672,5 +750,42 @@ class BugDirTestCase(unittest.TestCase):
def testSyncedComments(self):
self.testComments(sync_with_disk=True)
-unitsuite = unittest.TestLoader().loadTestsFromTestCase(BugDirTestCase)
-suite = unittest.TestSuite([unitsuite])#, doctest.DocTestSuite()])
+class SimpleBugDirTestCase (unittest.TestCase):
+ def setUp(self):
+ # create a pre-existing bugdir in a temporary directory
+ self.dir = utility.Dir()
+ os.chdir(self.dir.path)
+ self.bugdir = BugDir(self.dir.path, sink_to_existing_root=False,
+ allow_rcs_init=True)
+ self.bugdir.new_bug("preexisting", summary="Hopefully not imported")
+ self.bugdir.save()
+ def tearDown(self):
+ self.dir.cleanup()
+ def testOnDiskCleanLoad(self):
+ """simple_bug_dir(sync_with_disk==True) should not import preexisting bugs."""
+ bugdir = simple_bug_dir(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)
+ def testInMemoryCleanLoad(self):
+ """simple_bug_dir(sync_with_disk==False) should not import preexisting bugs."""
+ bugdir = simple_bug_dir(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)
+
+
+unitsuite = unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
+suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
diff --git a/libbe/bzr.py b/libbe/bzr.py
index b33292c..7457815 100644
--- a/libbe/bzr.py
+++ b/libbe/bzr.py
@@ -93,6 +93,14 @@ class Bzr(RCS):
assert len(match.groups()) == 1
revision = match.groups()[0]
return revision
+ def _rcs_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)
def postcommit(self):
try:
self._u_invoke_client('merge')
diff --git a/libbe/comment.py b/libbe/comment.py
index 3249e8b..c5bec43 100644
--- a/libbe/comment.py
+++ b/libbe/comment.py
@@ -61,6 +61,11 @@ class MissingReference(ValueError):
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,
@@ -115,6 +120,8 @@ 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.isdir(path):
return Comment(bug, uuid=INVALID_UUID)
@@ -131,6 +138,8 @@ def loadComments(bug, load_full=False):
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()
@@ -162,9 +171,9 @@ class Comment(Tree, settings_object.SavedSettingsObject):
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="From",
+ @_versioned_property(name="Author",
doc="The author of the comment")
- def From(): return {}
+ def author(): return {}
@_versioned_property(name="In-reply-to",
doc="UUID for parent comment or bug")
@@ -178,17 +187,17 @@ class Comment(Tree, settings_object.SavedSettingsObject):
@_versioned_property(name="Date",
doc="An RFC 2822 timestamp for comment creation")
- def time_string(): return {}
+ def date(): return {}
def _get_time(self):
- if self.time_string == None:
+ if self.date == None:
return None
- return utility.str_to_time(self.time_string)
+ return utility.str_to_time(self.date)
def _set_time(self, value):
- self.time_string = utility.time_to_str(value)
+ self.date = utility.time_to_str(value)
time = property(fget=_get_time,
fset=_set_time,
- doc="An integer version of .time_string")
+ doc="An integer version of .date")
def _get_comment_body(self):
if self.rcs != None and self.sync_with_disk == True:
@@ -258,12 +267,28 @@ class Comment(Tree, settings_object.SavedSettingsObject):
self.uuid = uuid_gen()
self.time = int(time.time()) # only save to second precision
if self.rcs != None:
- self.From = self.rcs.get_user_id()
+ self.author = self.rcs.get_user_id()
self.in_reply_to = in_reply_to
self.body = body
- def set_sync_with_disk(self, value):
- self.sync_with_disk = True
+ 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"""
@@ -272,6 +297,8 @@ class Comment(Tree, settings_object.SavedSettingsObject):
continue
yield comment
+ # serializing methods
+
def _setting_attr_string(self, setting):
value = getattr(self, setting)
if value == None:
@@ -282,12 +309,12 @@ class Comment(Tree, settings_object.SavedSettingsObject):
"""
>>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
>>> comm.uuid = "0123"
- >>> comm.time_string = "Thu, 01 Jan 1970 00:00:00 +0000"
+ >>> 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>
- <from></from>
+ <author></author>
<date>Thu, 01 Jan 1970 00:00:00 +0000</date>
<content-type>text/plain</content-type>
<body>Some
@@ -309,8 +336,8 @@ class Comment(Tree, settings_object.SavedSettingsObject):
("alt-id", self.alt_id),
("short-name", shortname),
("in-reply-to", self.in_reply_to),
- ("from", self._setting_attr_string("From")),
- ("date", self.time_string),
+ ("author", self._setting_attr_string("author")),
+ ("date", self.date),
("content-type", self.content_type),
("body", body)]
lines = ["<comment>"]
@@ -328,11 +355,11 @@ class Comment(Tree, settings_object.SavedSettingsObject):
<alt-id> fields.
>>> commA = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
>>> commA.uuid = "0123"
- >>> commA.time_string = "Thu, 01 Jan 1970 00:00:00 +0000"
+ >>> 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','From','time_string','content_type','body']
+ >>> 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'"
@@ -342,15 +369,15 @@ class Comment(Tree, settings_object.SavedSettingsObject):
Mismatch on alt_id: '0123' should be 'None'
>>> print commB.alt_id
0123
- >>> commA.From
- >>> commB.From
+ >>> 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','from','date','content-type','body']
+ tags=['uuid','alt-id','in-reply-to','author','date','content-type','body']
uuid = None
body = None
for child in comment.getchildren():
@@ -368,10 +395,6 @@ class Comment(Tree, settings_object.SavedSettingsObject):
if child.tag == "body":
body = text
continue # don't set the bug's body yet.
- elif child.tag == 'from':
- attr_name = "From"
- elif child.tag == 'date':
- attr_name = 'time_string'
else:
attr_name = child.tag.replace('-','_')
setattr(self, attr_name, text)
@@ -389,7 +412,7 @@ class Comment(Tree, settings_object.SavedSettingsObject):
def string(self, indent=0, shortname=None):
"""
>>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
- >>> comm.time_string = "Thu, 01 Jan 1970 00:00:00 +0000"
+ >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
>>> print comm.string(indent=2, shortname="com-1")
--------- Comment ---------
Name: com-1
@@ -405,8 +428,8 @@ class Comment(Tree, settings_object.SavedSettingsObject):
lines = []
lines.append("--------- Comment ---------")
lines.append("Name: %s" % shortname)
- lines.append("From: %s" % (self._setting_attr_string("From")))
- lines.append("Date: %s" % self.time_string)
+ 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())
@@ -417,78 +440,6 @@ class Comment(Tree, settings_object.SavedSettingsObject):
sep = '\n' + istring
return istring + sep.join(lines).rstrip('\n')
- def __str__(self):
- """
- >>> comm = Comment(bug=None, body="Some insightful remarks")
- >>> comm.uuid = "com-1"
- >>> comm.time_string = "Thu, 20 Nov 2008 15:55:11 +0000"
- >>> comm.From = "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 get_path(self, name=None):
- my_dir = os.path.join(self.bug.get_path("comments"), self.uuid)
- if name is None:
- return my_dir
- assert name in ["values", "body"]
- return os.path.join(my_dir, name)
-
- def load_settings(self):
- self.settings = mapfile.map_load(self.rcs, self.get_path("values"))
- self._setup_saved_settings()
-
- def save_settings(self):
- self.rcs.mkdir(self.get_path())
- path = self.get_path("values")
- mapfile.map_save(self.rcs, 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).
- """
- assert self.body != None, "Can't save blank comment"
- self.save_settings()
- self._set_comment_body(new=self.body, force=True)
-
- def remove(self):
- for comment in self.traverse():
- path = comment.get_path()
- self.rcs.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)
- #raise Exception, "adding reply \n%s\n%s" % (self, 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 string_thread(self, string_method_name="string", name_map={},
indent=0, flatten=True,
auto_name_map=False, bug_shortname=None):
@@ -506,7 +457,7 @@ class Comment(Tree, settings_object.SavedSettingsObject):
name_map = {}
for shortname,comment in comm.comment_shortnames(bug_shortname):
name_map[comment.uuid] = shortname
- comm.sort(key=lambda c : c.From) # your sort
+ 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")
@@ -593,6 +544,80 @@ class Comment(Tree, settings_object.SavedSettingsObject):
indent=indent, auto_name_map=auto_name_map,
bug_shortname=bug_shortname)
+ # methods for saving/loading/acessing settings and properties.
+
+ def get_path(self, name=None):
+ my_dir = os.path.join(self.bug.get_path("comments"), self.uuid)
+ if name is None:
+ return my_dir
+ assert name in ["values", "body"]
+ return os.path.join(my_dir, name)
+
+ 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.rcs, self.get_path("values"))
+ # hack to deal with old BE comments:
+ if "From" in self.settings:
+ self.settings["Author"] = self.settings.pop("From")
+ self._setup_saved_settings()
+
+ def save_settings(self):
+ if self.sync_with_disk == False:
+ raise DiskAccessRequired("save settings")
+ self.rcs.mkdir(self.get_path())
+ path = self.get_path("values")
+ mapfile.map_save(self.rcs, 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.rcs.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.
@@ -659,4 +684,59 @@ class Comment(Tree, settings_object.SavedSettingsObject):
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/libbe/darcs.py b/libbe/darcs.py
index e7132c0..0720ed9 100644
--- a/libbe/darcs.py
+++ b/libbe/darcs.py
@@ -18,8 +18,13 @@ import codecs
import os
import re
import sys
-import unittest
+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 rcs
from rcs import RCS
@@ -138,24 +143,36 @@ class Darcs(RCS):
args = ['record', '--all', '--author', id, '--logfile', commitfile]
status,output,error = self._u_invoke_client(*args)
empty_strings = ["No changes!"]
- revision = None
if self._u_any_in_string(empty_strings, output) == True:
if allow_empty == False:
raise rcs.EmptyCommit()
- else: # we need a extra call to get the current revision
- args = ["changes", "--last=1", "--xml"]
- status,output,error = self._u_invoke_client(*args)
- revline = re.compile("[ \t]*<name>(.*)</name>")
- # note that darcs does _not_ make an empty revision.
- # this returns the last non-empty revision id...
+ # note that darcs does _not_ make an empty revision.
+ # this returns the last non-empty revision id...
+ revision = self._rcs_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]
+ match = revline.search(output)
+ assert match != None, output+error
+ assert len(match.groups()) == 1
+ revision = match.groups()[0]
return revision
-
+ def _rcs_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
rcs.make_rcs_testcase_subclasses(Darcs, sys.modules[__name__])
diff --git a/libbe/diff.py b/libbe/diff.py
index 963d692..4164e94 100644
--- a/libbe/diff.py
+++ b/libbe/diff.py
@@ -15,102 +15,396 @@
# 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"""
-from libbe import cmdutil, bugdir, bug
+from libbe import bugdir, bug, settings_object, tree
from libbe.utility import time_to_str
+import difflib
import doctest
-def diff(old_bugdir, new_bugdir):
- added = []
- removed = []
- modified = []
- for uuid in old_bugdir.list_uuids():
- old_bug = old_bugdir.bug_from_uuid(uuid)
- try:
- new_bug = new_bugdir.bug_from_uuid(uuid)
- if old_bug != new_bug:
- modified.append((old_bug, new_bug))
- except KeyError:
- removed.append(old_bug)
- for uuid in new_bugdir.list_uuids():
- if not old_bugdir.has_bug(uuid):
- new_bug = new_bugdir.bug_from_uuid(uuid)
- added.append(new_bug)
- return (removed, modified, added)
+class DiffTree (tree.Tree):
+ """
+ A tree holding difference data for easy report generation.
+ >>> all = DiffTree("all")
+ >>> bugdir = DiffTree("bugdir", data="target: None -> 1.0")
+ >>> all.append(bugdir)
+ >>> bugs = DiffTree("bugs", "bug-count: 5 -> 6")
+ >>> all.append(bugs)
+ >>> new = DiffTree("new", "new bugs: ABC, DEF")
+ >>> bugs.append(new)
+ >>> rem = DiffTree("rem", "removed bugs: RST, UVW")
+ >>> bugs.append(rem)
+ >>> print all.report_string()
+ target: None -> 1.0
+ bug-count: 5 -> 6
+ new bugs: ABC, DEF
+ removed bugs: RST, UVW
+ >>> print "\\n".join(all.paths())
+ all
+ all/bugdir
+ all/bugs
+ all/bugs/new
+ all/bugs/rem
+ >>> all.child_by_path("/") == all
+ True
+ >>> all.child_by_path("/bugs") == bugs
+ True
+ >>> all.child_by_path("/bugs/rem") == rem
+ True
+ >>> all.child_by_path("all") == all
+ True
+ >>> all.child_by_path("all/") == all
+ True
+ >>> all.child_by_path("all/bugs") == bugs
+ True
+ >>> all.child_by_path("/bugs").masked = True
+ >>> print all.report_string()
+ target: None -> 1.0
+ """
+ def __init__(self, name, data=None, data_string_fn=str,
+ requires_children=False, masked=False):
+ tree.Tree.__init__(self)
+ self.name = name
+ self.data = data
+ self.data_string_fn = data_string_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, depth=0):
+ if root == None:
+ root = self.make_root()
+ if self.masked == True:
+ return None
+ data_string = self.data_string(depth)
+ if self.data == None:
+ pass
+ elif self.requires_children == True and len(self) == 0:
+ pass
+ else:
+ self.join(root, data_string)
+ depth += 1
+ for child in self:
+ child.report(root, depth)
+ return root
+ def make_root(self):
+ return []
+ def join(self, root, part):
+ if part != None:
+ root.append(part)
+ def data_string(self, depth, indent=True):
+ if hasattr(self, "_cached_data_string"):
+ return self._cached_data_string
+ data_string = self.data_string_fn(self.data)
+ if indent == True:
+ data_string_lines = data_string.splitlines()
+ indent = " "*(depth)
+ line_sep = "\n"+indent
+ data_string = indent+line_sep.join(data_string_lines)
+ self._cached_data_string = data_string
+ return data_string
-def diff_report(diff_data, bug_dir):
- (removed, modified, added) = diff_data
- def modified_cmp(left, right):
- return bug.cmp_severity(left[1], right[1])
+class Diff (object):
+ """
+ Difference tree generator for BugDirs.
+ >>> import copy
+ >>> bd = bugdir.simple_bug_dir(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
+ """
+ def __init__(self, old_bugdir, new_bugdir):
+ self.old_bugdir = old_bugdir
+ self.new_bugdir = new_bugdir
- added.sort(bug.cmp_severity)
- removed.sort(bug.cmp_severity)
- modified.sort(modified_cmp)
- lines = []
-
- if len(added) > 0:
- lines.append("New bug reports:")
- for bg in added:
- lines.extend(bg.string(shortlist=True).splitlines())
- lines.append("")
+ # data assembly methods
- if len(modified) > 0:
- printed = False
- for old_bug, new_bug in modified:
- change_str = bug_changes(old_bug, new_bug, bug_dir)
- if change_str is None:
- continue
- if not printed:
- printed = True
- lines.append("Modified bug reports:")
- lines.extend(change_str.splitlines())
- if printed == True:
- lines.append("")
-
- if len(removed) > 0:
- lines.append("Removed bug reports:")
- for bg in removed:
- lines.extend(bg.string(shortlist=True).splitlines())
- lines.append("")
-
- return '\n'.join(lines)
-
-def change_lines(old, new, attributes):
- change_list = []
- for attr in attributes:
- old_attr = getattr(old, attr)
- new_attr = getattr(new, attr)
- if old_attr != new_attr:
- change_list.append((attr, old_attr, new_attr))
- if len(change_list) >= 0:
- return change_list
- else:
+ 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,
+ ["rcs_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)
-def bug_changes(old, new, bugs):
- change_list = change_lines(old, new, ("time", "creator", "severity",
- "target", "summary", "status", "assigned"))
+ # report generation methods
- old_comment_ids = [c.uuid for c in old.comments()]
- new_comment_ids = [c.uuid for c in new.comments()]
- change_strings = ["%s: %s -> %s" % f for f in change_list]
- for comment_id in new_comment_ids:
- if comment_id not in old_comment_ids:
- summary = comment_summary(new.comment_from_uuid(comment_id), "new")
- change_strings.append(summary)
- for comment_id in old_comment_ids:
- if comment_id not in new_comment_ids:
- summary = comment_summary(new.comment_from_uuid(comment_id),
- "removed")
- change_strings.append(summary)
+ 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("rcs_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
- if len(change_strings) == 0:
- return None
- return "%s\n %s" % (new.string(shortlist=True),
- " \n".join(change_strings))
+ # 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):
+ return self._comment_summary_string(comment)
+ 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)
-def comment_summary(comment, status):
- return "%8s comment from %s on %s" % (status, comment.From,
- time_to_str(comment.time))
suite = doctest.DocTestSuite()
diff --git a/libbe/git.py b/libbe/git.py
index 2f9ffa9..2b45679 100644
--- a/libbe/git.py
+++ b/libbe/git.py
@@ -111,7 +111,18 @@ class Git(RCS):
assert match != None, output+error
assert len(match.groups()) == 3
revision = match.groups()[1]
- return revision
+ full_revision = self._rcs_revision_id(-1)
+ assert full_revision.startswith(revision), \
+ "Mismatched revisions:\n%s\n%s" % (revision, full_revision)
+ return full_revision
+ def _rcs_revision_id(self, index):
+ args = ["rev-list", "--first-parent", "--reverse", "HEAD"]
+ status,output,error = self._u_invoke_client(*args)
+ commits = output.splitlines()
+ try:
+ return commits[index]
+ except IndexError:
+ return None
rcs.make_rcs_testcase_subclasses(Git, sys.modules[__name__])
diff --git a/libbe/hg.py b/libbe/hg.py
index a20eeb5..fcda829 100644
--- a/libbe/hg.py
+++ b/libbe/hg.py
@@ -80,14 +80,14 @@ class Hg(RCS):
strings = ["nothing changed"]
if self._u_any_in_string(strings, output) == True:
raise rcs.EmptyCommit()
- status,output,error = self._u_invoke_client('identify')
- revision = None
- revline = re.compile("(.*) tip")
- match = revline.search(output)
- assert match != None, output+error
- assert len(match.groups()) == 1
- revision = match.groups()[0]
- return revision
+ return self._rcs_revision_id(-1)
+ def _rcs_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:
+ return output.strip()
+ return None
rcs.make_rcs_testcase_subclasses(Hg, sys.modules[__name__])
diff --git a/libbe/properties.py b/libbe/properties.py
index 144220b..09dd20e 100644
--- a/libbe/properties.py
+++ b/libbe/properties.py
@@ -160,10 +160,10 @@ def _get_cached_mutable_property(self, cacher_name, property_name, default=None)
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):
+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:
- return 1 # any value > non-existant old 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)
@@ -327,7 +327,7 @@ def primed_property(primer, initVal=None):
return funcs
return decorator
-def change_hook_property(hook, mutable=False):
+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
@@ -359,9 +359,9 @@ def change_hook_property(hook, mutable=False):
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) != 0:
+ 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)
+ 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
diff --git a/libbe/rcs.py b/libbe/rcs.py
index 1e1cfa7..d979df0 100644
--- a/libbe/rcs.py
+++ b/libbe/rcs.py
@@ -214,6 +214,16 @@ class RCS(object):
changes to commit.
"""
return None
+ def _rcs_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._rcs_help()
@@ -407,6 +417,18 @@ class RCS(object):
pass
def postcommit(self, directory):
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._rcs_revision_id(index)
def _u_any_in_string(self, list, string):
"""
Return True if any of the strings in list are in string.
@@ -814,6 +836,30 @@ class RCS_commit_TestCase(RCSTestCase):
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.rcs.versioned:
+ self.failUnlessEqual(self.rcs.revision_id(5), None)
+ return
+ committed_revisions = []
+ for path in self.test_files:
+ full_path = self.full_path(path)
+ self.rcs.set_file_contents(
+ full_path, self.test_contents['rev_1'])
+ revision = self.rcs.commit("Initial %s contents." % path)
+ committed_revisions.append(revision)
+ self.rcs.set_file_contents(
+ full_path, self.test_contents['uncommitted'])
+ revision = self.rcs.commit("Altered %s contents." % path)
+ committed_revisions.append(revision)
+ for i,revision in enumerate(committed_revisions):
+ self.failUnlessEqual(self.rcs.revision_id(i), revision)
+ i += -len(committed_revisions) # check negative indices
+ self.failUnlessEqual(self.rcs.revision_id(i), revision)
+ i = len(committed_revisions)
+ self.failUnlessEqual(self.rcs.revision_id(i), None)
+ self.failUnlessEqual(self.rcs.revision_id(-i-1), None)
+
class RCS_duplicate_repo_TestCase(RCSTestCase):
"""Test cases for RCS.duplicate_repo method."""
diff --git a/libbe/settings_object.py b/libbe/settings_object.py
index dde247f..ceea9d5 100644
--- a/libbe/settings_object.py
+++ b/libbe/settings_object.py
@@ -148,7 +148,8 @@ def versioned_property(name, doc,
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)
+ 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)
@@ -385,30 +386,24 @@ class SavedSettingsObjectTests(unittest.TestCase):
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 == [
- "'None' -> '<class 'libbe.settings_object.EMPTY'>'"
- ], SAVES)
+ 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 == [
- "'None' -> '<class 'libbe.settings_object.EMPTY'>'",
"'<class 'libbe.settings_object.EMPTY'>' -> '[]'"
], SAVES)
t.list_type.append(5)
self.failUnless(SAVES == [
- "'None' -> '<class 'libbe.settings_object.EMPTY'>'",
"'<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
- "'None' -> '<class 'libbe.settings_object.EMPTY'>'",
"'<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.
- "'None' -> '<class 'libbe.settings_object.EMPTY'>'",
"'<class 'libbe.settings_object.EMPTY'>' -> '[]'",
"'[]' -> '[5]'"
], SAVES)
diff --git a/libbe/tree.py b/libbe/tree.py
index fe791a5..45ae085 100644
--- a/libbe/tree.py
+++ b/libbe/tree.py
@@ -35,7 +35,7 @@ class Tree(list):
>>> a = Tree(); a.n = "a"
>>> a.append(c)
>>> a.append(b)
-
+
>>> a.branch_len()
5
>>> a.sort(key=lambda node : -node.branch_len())
@@ -44,7 +44,7 @@ class Tree(list):
>>> 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(depthFirst=False)])
+ >>> "".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)
@@ -68,7 +68,18 @@ class Tree(list):
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.
@@ -97,11 +108,11 @@ class Tree(list):
for child in self:
child.sort(*args, **kwargs)
- def traverse(self, depthFirst=True):
+ def traverse(self, depth_first=True):
"""
Note: you might want to sort() your tree first.
"""
- if depthFirst == True:
+ if depth_first == True:
yield self
for child in self:
for descendant in child.traverse():
@@ -119,7 +130,7 @@ class Tree(list):
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,
@@ -138,8 +149,8 @@ class Tree(list):
stack = [] # ancestry of the current node
if flatten == True:
depthDict = {}
-
- for node in self.traverse(depthFirst=True):
+
+ 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)
@@ -157,4 +168,12 @@ class Tree(list):
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()