diff options
Diffstat (limited to 'libbe')
-rw-r--r-- | libbe/beuuid.py | 62 | ||||
-rw-r--r-- | libbe/comment.py | 382 | ||||
-rw-r--r-- | libbe/tree.py | 158 |
3 files changed, 602 insertions, 0 deletions
diff --git a/libbe/beuuid.py b/libbe/beuuid.py new file mode 100644 index 0000000..e2435ea --- /dev/null +++ b/libbe/beuuid.py @@ -0,0 +1,62 @@ +# Copyright (C) 2005 Aaron Bentley and Panometrics, Inc. +# <abentley@panoramicfeedback.com> +# +# 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., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +""" +Backwards compatibility support for Python 2.4. Once people give up +on 2.4 ;), the uuid call should be merged into bugdir.py +""" + +import unittest + +try: + from uuid import uuid4 # Python >= 2.5 + def uuid_gen(): + id = uuid4() + idstr = id.urn + start = "urn:uuid:" + assert idstr.startswith(start) + return idstr[len(start):] +except ImportError: + import os + import sys + from subprocess import Popen, PIPE + + def uuid_gen(): + # Shell-out to system uuidgen + args = ['uuidgen', 'r'] + try: + if sys.platform != "win32": + q = Popen(args, stdin=PIPE, stdout=PIPE, stderr=PIPE) + else: + # win32 don't have os.execvp() so have to run command in a shell + q = Popen(args, stdin=PIPE, stdout=PIPE, stderr=PIPE, + shell=True, cwd=cwd) + except OSError, e : + strerror = "%s\nwhile executing %s" % (e.args[1], args) + raise OSError, strerror + output, error = q.communicate() + status = q.wait() + if status != 0: + strerror = "%s\nwhile executing %s" % (status, args) + raise Exception, strerror + return output.rstrip('\n') + +class UUIDtestCase(unittest.TestCase): + def testUUID_gen(self): + id = uuid_gen() + self.failUnless(len(id) == 36, "invalid UUID '%s'" % id) + +suite = unittest.TestLoader().loadTestsFromTestCase(UUIDtestCase) diff --git a/libbe/comment.py b/libbe/comment.py new file mode 100644 index 0000000..95dade6 --- /dev/null +++ b/libbe/comment.py @@ -0,0 +1,382 @@ +# Bugs Everywhere, a distributed bugtracker +# Copyright (C) 2005 Aaron Bentley and Panometrics, Inc. +# <abentley@panoramicfeedback.com> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, +# MA 02110-1301, USA +import os +import os.path +import time +import textwrap +import doctest + +from beuuid import uuid_gen +import mapfile +from tree import Tree +import utility + +INVALID_UUID = "!!~~\n INVALID-UUID \n~~!!" + +def _list_to_root(comments, bug): + """ + Convert a raw list of comments to single (dummy) root comment. We + use a dummy root comment, because there can be several comment + threads rooted on the same parent bug. To simplify comment + interaction, we condense these threads into a single thread with a + Comment dummy root. + + No Comment method should use the dummy comment. + """ + root_comments = [] + uuid_map = {} + for comment in comments: + assert comment.uuid != None + uuid_map[comment.uuid] = comment + for comm in comments: + if comm.in_reply_to == None: + root_comments.append(comm) + else: + parentUUID = comm.in_reply_to + parent = uuid_map[parentUUID] + parent.add_reply(comm) + dummy_root = Comment(bug, uuid=INVALID_UUID) + dummy_root.extend(root_comments) + return dummy_root + +def loadComments(bug): + path = bug.get_path("comments") + if not os.path.isdir(path): + return Comment(bug, uuid=INVALID_UUID) + comments = [] + for uuid in os.listdir(path): + if uuid.startswith('.'): + continue + comm = Comment(bug, uuid, loadNow=True) + comments.append(comm) + return _list_to_root(comments, bug) + +def saveComments(bug): + path = bug.get_path("comments") + bug.rcs.mkdir(path) + for comment in bug.comment_root.traverse(): + comment.save() + +class Comment(Tree): + def __init__(self, bug=None, uuid=None, loadNow=False, + in_reply_to=None, body=None): + """ + Set loadNow=True to load an old bug. + Set loadNow=False to create a new bug. + + The uuid option is required when loadNow==True. + + The in_reply_to and body options are only used if + loadNow==False (the default). When loadNow==True, they are + loaded from the bug database. + + in_reply_to should be the uuid string of the parent comment. + """ + Tree.__init__(self) + self.bug = bug + if bug != None: + self.rcs = bug.rcs + else: + self.rcs = None + if loadNow == True: + self.uuid = uuid + self.load() + else: + if uuid != None: + self.uuid = uuid + else: + self.uuid = uuid_gen() + self.time = time.time() + if self.rcs != None: + self.From = self.rcs.get_user_id() + else: + self.From = None + self.in_reply_to = in_reply_to + self.content_type = "text/plain" + self.body = body + + def traverse(self, *args, **kwargs): + """Avoid working with the possible dummy root comment""" + for comment in Tree.traverse(self, *args, **kwargs): + if comment.uuid == INVALID_UUID: + continue + yield comment + + def _clean_string(self, value): + """ + >>> comm = Comment() + >>> comm._clean_string(None) + '' + >>> comm._clean_string("abc") + 'abc' + """ + if value == None: + return "" + return value + + def string(self, indent=0, shortname=None): + """ + >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n") + >>> comm.time = utility.str_to_time("Thu, 20 Nov 2008 15:55:11 +0000") + >>> print comm.string(indent=2, shortname="com-1") + --------- Comment --------- + Name: com-1 + From: + Date: Thu, 20 Nov 2008 15:55:11 +0000 + <BLANKLINE> + Some insightful remarks + """ + if shortname == None: + shortname = self.uuid + lines = [] + lines.append("--------- Comment ---------") + lines.append("Name: %s" % shortname) + lines.append("From: %s" % self._clean_string(self.From)) + lines.append("Date: %s" % utility.time_to_str(self.time)) + lines.append("") + lines.append(textwrap.fill(self._clean_string(self.body), + width=(79-indent))) + + istring = ' '*indent + 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 = utility.str_to_time("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(self): + map = mapfile.map_load(self.get_path("values")) + self.time = utility.str_to_time(map["Date"]) + self.From = map["From"] + self.in_reply_to = map.get("In-reply-to") + self.content_type = map.get("Content-type", "text/plain") + self.body = self.rcs.get_file_contents(self.get_path("body")) + + def save(self): + assert self.rcs != None + map_file = {"Date": utility.time_to_str(self.time)} + self._add_headers(map_file, ("From", "in_reply_to", "content_type")) + self.rcs.mkdir(self.get_path()) + mapfile.map_save(self.rcs, self.get_path("values"), map_file) + self.rcs.set_file_contents(self.get_path("body"), self.body) + + def _add_headers(self, map, names): + map_names = {} + for name in names: + map_names[name] = self._pyname_to_header(name) + self._add_attrs(map, map_names) + + def _pyname_to_header(self, name): + return name.capitalize().replace('_', '-') + + def _add_attrs(self, map, map_names): + for name in map_names.keys(): + value = getattr(self, name) + if value is not None: + map[map_names[name]] = value + + def remove(self): + for comment in self.traverse(): + path = comment.get_path() + self.rcs.recursive_remove(path) + + def add_reply(self, reply): + if reply.time != None and self.time != None: + assert reply.time > self.time + 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") + """ + reply = Comment(self.bug, body=body) + self.add_reply(reply) + return reply + + def string_thread(self, name_map={}, indent=0, + auto_name_map=False, bug_shortname=None): + """ + Return a sting displaying a thread of comments. + bug_shortname is only used if auto_name_map == True. + + >>> a = Comment(bug=None, uuid="a", body="Insightful remarks") + >>> a.time = utility.str_to_time("Thu, 20 Nov 2008 01:00:00 +0000") + >>> b = a.new_reply("Critique original comment") + >>> b.uuid = "b" + >>> b.time = utility.str_to_time("Thu, 20 Nov 2008 02:00:00 +0000") + >>> c = b.new_reply("Begin flamewar :p") + >>> c.uuid = "c" + >>> c.time = utility.str_to_time("Thu, 20 Nov 2008 03:00:00 +0000") + >>> d = a.new_reply("Useful examples") + >>> d.uuid = "d" + >>> d.time = utility.str_to_time("Thu, 20 Nov 2008 04:00:00 +0000") + >>> a.sort(key=lambda comm : comm.time) + >>> print a.string_thread() + --------- Comment --------- + Name: a + From: + Date: Thu, 20 Nov 2008 01:00:00 +0000 + <BLANKLINE> + Insightful remarks + --------- Comment --------- + Name: b + From: + Date: Thu, 20 Nov 2008 02:00:00 +0000 + <BLANKLINE> + Critique original comment + --------- Comment --------- + Name: c + From: + Date: Thu, 20 Nov 2008 03:00:00 +0000 + <BLANKLINE> + Begin flamewar :p + --------- Comment --------- + Name: d + From: + Date: Thu, 20 Nov 2008 04:00:00 +0000 + <BLANKLINE> + Useful examples + >>> print a.string_thread(auto_name_map=True, bug_shortname="bug-1") + --------- Comment --------- + Name: bug-1:1 + From: + Date: Thu, 20 Nov 2008 01:00:00 +0000 + <BLANKLINE> + Insightful remarks + --------- Comment --------- + Name: bug-1:2 + From: + Date: Thu, 20 Nov 2008 02:00:00 +0000 + <BLANKLINE> + Critique original comment + --------- Comment --------- + Name: bug-1:3 + From: + Date: Thu, 20 Nov 2008 03:00:00 +0000 + <BLANKLINE> + Begin flamewar :p + --------- Comment --------- + Name: bug-1:4 + From: + Date: Thu, 20 Nov 2008 04:00:00 +0000 + <BLANKLINE> + Useful examples + """ + if auto_name_map == True: + name_map = {} + for shortname,comment in self.comment_shortnames(bug_shortname): + name_map[comment.uuid] = shortname + stringlist = [] + for depth,comment in self.thread(flatten=True): + ind = 2*depth+indent + if comment.uuid in name_map: + sname = name_map[comment.uuid] + else: + sname = None + stringlist.append(comment.string(indent=ind, shortname=sname)) + return '\n'.join(stringlist) + + def comment_shortnames(self, bug_shortname=""): + """ + Iterate through (id, comment) pairs, in time order. + (This is a user-friendly id, not the comment uuid). + + SIDE-EFFECT : will sort the comment tree by comment.time + + >>> a = Comment(bug=None, uuid="a") + >>> b = a.new_reply() + >>> b.uuid = "b" + >>> c = b.new_reply() + >>> c.uuid = "c" + >>> d = a.new_reply() + >>> d.uuid = "d" + >>> for id,name in a.comment_shortnames("bug-1"): + ... print id, name.uuid + bug-1:1 a + bug-1:2 b + bug-1:3 c + bug-1:4 d + """ + self.sort(key=lambda comm : comm.time) + for num,comment in enumerate(self.traverse()): + yield ("%s:%d" % (bug_shortname, num+1), comment) + + def comment_from_shortname(self, comment_shortname, *args, **kwargs): + """ + Use a comment shortname to look up a comment. + >>> a = Comment(bug=None, uuid="a") + >>> b = a.new_reply() + >>> b.uuid = "b" + >>> c = b.new_reply() + >>> c.uuid = "c" + >>> d = a.new_reply() + >>> d.uuid = "d" + >>> comm = a.comment_from_shortname("bug-1:3", bug_shortname="bug-1") + >>> id(comm) == id(c) + True + """ + for cur_name, comment in self.comment_shortnames(*args, **kwargs): + if comment_shortname == cur_name: + return comment + raise KeyError(comment_shortname) + + def comment_from_uuid(self, uuid): + """ + Use a comment shortname to look up a comment. + >>> a = Comment(bug=None, uuid="a") + >>> b = a.new_reply() + >>> b.uuid = "b" + >>> c = b.new_reply() + >>> c.uuid = "c" + >>> d = a.new_reply() + >>> d.uuid = "d" + >>> comm = a.comment_from_uuid("d") + >>> id(comm) == id(d) + True + """ + for comment in self.traverse(): + if comment.uuid == uuid: + return comment + raise KeyError(uuid) + +suite = doctest.DocTestSuite() diff --git a/libbe/tree.py b/libbe/tree.py new file mode 100644 index 0000000..e6f144e --- /dev/null +++ b/libbe/tree.py @@ -0,0 +1,158 @@ +# Bugs Everywhere, a distributed bugtracker +# Copyright (C) 2008 W. Trevor King +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA +# 02110-1301, USA + +import doctest + +class Tree(list): + """ + Construct + +-b---d-g + a-+ +-e + +-c-+-f-h-i + with + >>> i = Tree(); i.n = "i" + >>> h = Tree([i]); h.n = "h" + >>> f = Tree([h]); f.n = "f" + >>> e = Tree(); e.n = "e" + >>> c = Tree([f,e]); c.n = "c" + >>> g = Tree(); g.n = "g" + >>> d = Tree([g]); d.n = "d" + >>> b = Tree([d]); b.n = "b" + >>> a = Tree(); a.n = "a" + >>> a.append(c) + >>> a.append(b) + + >>> a.branch_len() + 5 + >>> a.sort(key=lambda node : node.branch_len()) + >>> "".join([node.n for node in a.traverse()]) + 'abdgcefhi' + >>> "".join([node.n for node in a.traverse(depthFirst=False)]) + 'abcdefghi' + >>> for depth,node in a.thread(): + ... print "%*s" % (2*depth+1, node.n) + a + b + d + g + c + e + f + h + i + >>> for depth,node in a.thread(flatten=True): + ... print "%*s" % (2*depth+1, node.n) + a + b + d + g + c + e + f + h + i + """ + def branch_len(self): + """ + Exhaustive search every time == SLOW. + + Use only on small trees, or reimplement by overriding + child-addition methods to allow accurate caching. + + For the tree + +-b---d-g + a-+ +-e + +-c-+-f-h-i + this method returns 5. + """ + if len(self) == 0: + return 1 + else: + return 1 + max([child.branch_len() for child in self]) + + def sort(self, *args, **kwargs): + """ + This method can be slow, e.g. on a branch_len() sort, since a + node at depth N from the root has it's branch_len() method + called N times. + """ + list.sort(self, *args, **kwargs) + for child in self: + child.sort() + + def traverse(self, depthFirst=True): + """ + Note: you might want to sort() your tree first. + """ + if depthFirst == True: + yield self + for child in self: + for descendant in child.traverse(): + yield descendant + else: # breadth first, Wikipedia algorithm + # http://en.wikipedia.org/wiki/Breadth-first_search + queue = [self] + while len(queue) > 0: + node = queue.pop(0) + yield node + queue.extend(node) + + def thread(self, flatten=False): + """ + When flatten==False, the depth of any node is one greater than + the depth of its parent. That way the inheritance is + explicit, but you can end up with highly indented threads. + + When flatten==True, the depth of any node is only greater than + the depth of its parent when there is a branch, and the node + is not the last child. This can lead to ancestry ambiguity, + but keeps the total indentation down. E.g. + +-b +-b-c + a-+-c and a-+ + +-d-e-f +-d-e-f + would both produce (after sorting by branch_len()) + (0, a) + (1, b) + (1, c) + (0, d) + (0, e) + (0, f) + """ + stack = [] # ancestry of the current node + if flatten == True: + depthDict = {} + + for node in self.traverse(depthFirst=True): + while len(stack) > 0 \ + and id(node) not in [id(c) for c in stack[-1]]: + stack.pop(-1) + if flatten == False: + depth = len(stack) + else: + if len(stack) == 0: + depth = 0 + else: + parent = stack[-1] + depth = depthDict[id(parent)] + if len(parent) > 1 and node != parent[-1]: + depth += 1 + depthDict[id(node)] = depth + yield (depth,node) + stack.append(node) + +suite = doctest.DocTestSuite() |