diff options
author | W. Trevor King <wking@drexel.edu> | 2009-12-31 15:54:12 -0500 |
---|---|---|
committer | W. Trevor King <wking@drexel.edu> | 2009-12-31 15:54:12 -0500 |
commit | b0b5341c4045dd27cfbb3e2585cb2614ed9ad903 (patch) | |
tree | 37c7c2d011617ccd7a6f28a24ea77bb1b3cddfe7 /libbe | |
parent | a06030436d3940dddfba37b344f90651366d67e1 (diff) | |
parent | 2d1562d951e763fed71fe60e77cc9921be9abdc9 (diff) | |
download | bugseverywhere-b0b5341c4045dd27cfbb3e2585cb2614ed9ad903.tar.gz |
Merged be.restructure, major internal reorganization.
Added a bunch of classes to make the commands, user interfaces, and
storage backends more abstract and distinct. This should make it much
easier to extend and maintain BE.
Features:
* Directory restructured:
becommands/ -> libbe/commands
submods sorted by functionality.
* Lots of new classes:
Option, Argument, Command
InputOutput, StorageCallbacks, UserInterface
Storage
* Consolidated ID handling in libbe.util.id
* Transitioned VCS backends for Python-based VCSs from subprocess
calss to internal python calls.
Plus the user-visible changes:
* New bugdir/bug/comment ID format replaces old bug:comment format.
* Deprecated support for `be diff` on Arch and Darcs <= 2.3.1. A new
backend abstraction (Storage) makes the former implementation
ungainly.
* Improved command completion.
* Removed commands close, open, email_bugs,
* Flipped some arguments
`be assign BUG-ID [ASSIGNEE]` -> `be status ASSIGNED BUG-ID ...`
`be severity BUG-ID SEVERITY` -> `be severity SEVERITY BUG-ID ...`
`be status BUG-ID STATUS` -> `be status STATUS BUG-ID ...`
In the merge:
* Added 'commit' to list of pagerless commands.
* Updated doc/README.dev
See
#bea86499-824e-4e77-b085-2d581fa9ccab/1100c966-9671-4bc6-8b68-6d408a910da1#
for a discussion of why the changes were made and some of the
difficulties en-route.
Diffstat (limited to 'libbe')
68 files changed, 10356 insertions, 3357 deletions
diff --git a/libbe/beuuid.py b/libbe/beuuid.py deleted file mode 100644 index a3a3b6c..0000000 --- a/libbe/beuuid.py +++ /dev/null @@ -1,67 +0,0 @@ -# Copyright (C) 2008-2009 Gianluca Montecchi <gian@grys.it> -# W. Trevor King <wking@drexel.edu> -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, write to the Free Software Foundation, Inc., -# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - -""" -Backwards compatibility support for Python 2.4. Once people give up -on 2.4 ;), the uuid call should be merged into bugdir.py -""" - -import libbe -if libbe.TESTING == True: - 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') - -if libbe.TESTING == True: - 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/bug.py b/libbe/bug.py index 06c2cc5..66ba579 100644 --- a/libbe/bug.py +++ b/libbe/bug.py @@ -24,6 +24,7 @@ import copy import os import os.path import errno +import sys import time import types try: # import core module, Python >= 2.5 @@ -33,14 +34,15 @@ except ImportError: # look for non-core module import xml.sax.saxutils import libbe -from beuuid import uuid_gen -from properties import Property, doc_property, local_property, \ - defaulting_property, checked_property, cached_property, \ +import libbe.util.id +from libbe.storage.util.properties import Property, doc_property, \ + local_property, defaulting_property, checked_property, cached_property, \ primed_property, change_hook_property, settings_property -import settings_object -import mapfile -import comment -import utility +import libbe.storage.util.settings_object as settings_object +import libbe.storage.util.mapfile as mapfile +import libbe.comment as comment +import libbe.util.utility as utility + if libbe.TESTING == True: import doctest @@ -169,7 +171,7 @@ class Bug(settings_object.SavedSettingsObject): check_fn=lambda s: s in status_values, require_save=True) def status(): return {} - + @property def active(self): return self.status in active_status_values @@ -219,8 +221,8 @@ class Bug(settings_object.SavedSettingsObject): def summary(): return {} def _get_comment_root(self, load_full=False): - if self.sync_with_disk: - return comment.loadComments(self, load_full=load_full) + if self.storage != None and self.storage.is_readable(): + return comment.load_comments(self, load_full=load_full) else: return comment.Comment(self, uuid=comment.INVALID_UUID) @@ -230,31 +232,26 @@ class Bug(settings_object.SavedSettingsObject): @doc_property(doc="The trunk of the comment tree. We use a dummy root comment by default, because there can be several comment threads rooted on the same parent bug. To simplify comment interaction, we condense these threads into a single thread with a Comment dummy root.") def comment_root(): return {} - def _get_vcs(self): - if hasattr(self.bugdir, "vcs"): - return self.bugdir.vcs - - @Property - @cached_property(generator=_get_vcs) - @local_property("vcs") - @doc_property(doc="A revision control system instance.") - def vcs(): return {} - - def __init__(self, bugdir=None, uuid=None, from_disk=False, + def __init__(self, bugdir=None, uuid=None, from_storage=False, load_comments=False, summary=None): settings_object.SavedSettingsObject.__init__(self) self.bugdir = bugdir + self.storage = None self.uuid = uuid - if from_disk == True: - self.sync_with_disk = True - else: - self.sync_with_disk = False + self.id = libbe.util.id.ID(self, 'bug') + if from_storage == False: if uuid == None: - self.uuid = uuid_gen() + self.uuid = libbe.util.id.uuid_gen() + self.settings = {} + self._setup_saved_settings() self.time = int(time.time()) # only save to second precision - if self.vcs != None: - self.creator = self.vcs.get_user_id() self.summary = summary + dummy = self.comment_root + if self.bugdir != None: + self.storage = self.bugdir.storage + if from_storage == False: + if self.storage != None and self.storage.is_writeable(): + self.save() def __repr__(self): return "Bug(uuid=%r)" % self.uuid @@ -275,20 +272,46 @@ class Bug(settings_object.SavedSettingsObject): return str(value) return value - def xml(self, indent=0, shortname=None, show_comments=False): - if shortname == None: - if self.bugdir == None: - shortname = self.uuid + def string(self, shortlist=False, show_comments=False): + if shortlist == False: + if self.time == None: + timestring = "" else: - shortname = self.bugdir.bug_shortname(self) + htime = utility.handy_time(self.time) + timestring = "%s (%s)" % (htime, self.time_string) + info = [("ID", self.uuid), + ("Short name", self.id.user()), + ("Severity", self.severity), + ("Status", self.status), + ("Assigned", self._setting_attr_string("assigned")), + ("Reporter", self._setting_attr_string("reporter")), + ("Creator", self._setting_attr_string("creator")), + ("Created", timestring)] + longest_key_len = max([len(k) for k,v in info]) + infolines = [" %*s : %s\n" %(longest_key_len,k,v) for k,v in info] + bugout = "".join(infolines) + "%s" % self.summary.rstrip('\n') + else: + statuschar = self.status[0] + severitychar = self.severity[0] + chars = "%c%c" % (statuschar, severitychar) + bugout = "%s:%s: %s" % (self.id.user(),chars,self.summary.rstrip('\n')) + if show_comments == True: + self.comment_root.sort(cmp=libbe.comment.cmp_time, reverse=True) + comout = self.comment_root.string_thread(flatten=False) + output = bugout + '\n' + comout.rstrip('\n') + else : + output = bugout + return output + + def xml(self, indent=0, show_comments=False): if self.time == None: timestring = "" else: timestring = utility.time_to_str(self.time) info = [('uuid', self.uuid), - ('short-name', shortname), + ('short-name', self.id.user()), ('severity', self.severity), ('status', self.status), ('assigned', self.assigned), @@ -303,9 +326,7 @@ class Bug(settings_object.SavedSettingsObject): for estr in self.extra_strings: lines.append(' <extra-string>%s</extra-string>' % estr) if show_comments == True: - comout = self.comment_root.xml_thread(indent=indent+2, - auto_name_map=True, - bug_shortname=shortname) + comout = self.comment_root.xml_thread(indent=indent+2) if len(comout) > 0: lines.append(comout) lines.append('</bug>') @@ -323,16 +344,16 @@ class Bug(settings_object.SavedSettingsObject): >>> commA = bugA.comment_root.new_reply(body='comment A') >>> commB = bugA.comment_root.new_reply(body='comment B') >>> commC = commA.new_reply(body='comment C') - >>> xml = bugA.xml(shortname="bug-1", show_comments=True) + >>> xml = bugA.xml(show_comments=True) >>> bugB = Bug() >>> bugB.from_xml(xml, verbose=True) - >>> bugB.xml(shortname="bug-1", show_comments=True) == xml + >>> bugB.xml(show_comments=True) == xml False >>> bugB.uuid = bugB.alt_id >>> for comm in bugB.comments(): ... comm.uuid = comm.alt_id ... comm.alt_id = None - >>> bugB.xml(shortname="bug-1", show_comments=True) == xml + >>> bugB.xml(show_comments=True) == xml True >>> bugB.explicit_attrs # doctest: +NORMALIZE_WHITESPACE ['severity', 'status', 'creator', 'created', 'summary'] @@ -378,13 +399,13 @@ class Bug(settings_object.SavedSettingsObject): self.explicit_attrs.append(attr_name) setattr(self, attr_name, text) elif verbose == True: - print >> sys.stderr, "Ignoring unknown tag %s in %s" \ + print >> sys.stderr, 'Ignoring unknown tag %s in %s' \ % (child.tag, comment.tag) if uuid != self.uuid: if not hasattr(self, 'alt_id') or self.alt_id == None: self.alt_id = uuid self.extra_strings = estrs - self.add_comments(comments) + self.add_comments(comments, ignore_missing_references=True) def add_comment(self, comment, *args, **kwargs): """ @@ -402,10 +423,10 @@ class Bug(settings_object.SavedSettingsObject): >>> commC.uuid = 'commC' >>> commC.in_reply_to = commA.uuid >>> bugA.add_comment(commC) - >>> print bugA.xml(shortname="bug-1", show_comments=True) # doctest: +ELLIPSIS + >>> print bugA.xml(show_comments=True) # doctest: +ELLIPSIS <bug> <uuid>0123</uuid> - <short-name>bug-1</short-name> + <short-name>/012</short-name> <severity>minor</severity> <status>open</status> <creator>Jack</creator> @@ -413,7 +434,7 @@ class Bug(settings_object.SavedSettingsObject): <summary>Need to test Bug.add_comment()</summary> <comment> <uuid>commA</uuid> - <short-name>bug-1:1</short-name> + <short-name>/012/commA</short-name> <author></author> <date>...</date> <content-type>text/plain</content-type> @@ -421,7 +442,7 @@ class Bug(settings_object.SavedSettingsObject): </comment> <comment> <uuid>commC</uuid> - <short-name>bug-1:2</short-name> + <short-name>/012/commC</short-name> <in-reply-to>commA</in-reply-to> <author></author> <date>...</date> @@ -430,7 +451,7 @@ class Bug(settings_object.SavedSettingsObject): </comment> <comment> <uuid>commB</uuid> - <short-name>bug-1:3</short-name> + <short-name>/012/commB</short-name> <author></author> <date>...</date> <content-type>text/plain</content-type> @@ -458,8 +479,9 @@ class Bug(settings_object.SavedSettingsObject): if c.alt_id != None: uuid_map[c.alt_id] = c uuid_map[None] = self.comment_root + uuid_map[comment.INVALID_UUID] = self.comment_root if default_parent != self.comment_root: - assert default_parent.uuid in uuid_map, default_parent + assert default_parent.uuid in uuid_map, default_parent.uuid for c in comments: if c.in_reply_to == None \ and default_parent.uuid != comment.INVALID_UUID: @@ -471,7 +493,7 @@ class Bug(settings_object.SavedSettingsObject): except KeyError: if ignore_missing_references == True: print >> sys.stderr, \ - "Ignoring missing reference to %s" % c.in_reply_to + 'Ignoring missing reference to %s' % c.in_reply_to parent = default_parent if parent.uuid != comment.INVALID_UUID: c.in_reply_to = parent.uuid @@ -533,7 +555,7 @@ class Bug(settings_object.SavedSettingsObject): >>> print bugA.xml(show_comments=True) # doctest: +ELLIPSIS <bug> <uuid>0123</uuid> - <short-name>0123</short-name> + <short-name>/012</short-name> <severity>minor</severity> <status>open</status> <creator>John</creator> @@ -544,7 +566,7 @@ class Bug(settings_object.SavedSettingsObject): <extra-string>TAG: very helpful</extra-string> <comment> <uuid>uuid-commA</uuid> - <short-name>0123:1</short-name> + <short-name>/012/uuid-commA</short-name> <author></author> <date>...</date> <content-type>text/plain</content-type> @@ -552,7 +574,7 @@ class Bug(settings_object.SavedSettingsObject): </comment> <comment> <uuid>uuid-commB</uuid> - <short-name>0123:2</short-name> + <short-name>/012/uuid-commB</short-name> <author></author> <date>...</date> <content-type>text/plain</content-type> @@ -590,6 +612,7 @@ class Bug(settings_object.SavedSettingsObject): if accept_comments == True: o_comm_copy = copy.copy(o_comm) o_comm_copy.bug = self + o_comm_copy.id = libbe.util.id.ID(o_comm_copy, 'comment') self.comment_root.add_reply(o_comm_copy) elif change_exception == True: raise ValueError, \ @@ -600,116 +623,68 @@ class Bug(settings_object.SavedSettingsObject): accept_extra_strings=accept_extra_strings, change_exception=change_exception) - def string(self, shortlist=False, show_comments=False): - if self.bugdir == None: - shortname = self.uuid - else: - shortname = self.bugdir.bug_shortname(self) - if shortlist == False: - if self.time == None: - timestring = "" - else: - htime = utility.handy_time(self.time) - timestring = "%s (%s)" % (htime, self.time_string) - info = [("ID", self.uuid), - ("Short name", shortname), - ("Severity", self.severity), - ("Status", self.status), - ("Assigned", self._setting_attr_string("assigned")), - ("Reporter", self._setting_attr_string("reporter")), - ("Creator", self._setting_attr_string("creator")), - ("Created", timestring)] - longest_key_len = max([len(k) for k,v in info]) - infolines = [" %*s : %s\n" %(longest_key_len,k,v) for k,v in info] - bugout = "".join(infolines) + "%s" % self.summary.rstrip('\n') - else: - statuschar = self.status[0] - severitychar = self.severity[0] - chars = "%c%c" % (statuschar, severitychar) - bugout = "%s:%s: %s" % (shortname,chars,self.summary.rstrip('\n')) - - if show_comments == True: - # take advantage of the string_thread(auto_name_map=True) - # SIDE-EFFECT of sorting by comment time. - comout = self.comment_root.string_thread(flatten=False, - auto_name_map=True, - bug_shortname=shortname) - output = bugout + '\n' + comout.rstrip('\n') - else : - output = bugout - return output - # methods for saving/loading/acessing settings and properties. - def get_path(self, *args): - dir = os.path.join(self.bugdir.get_path("bugs"), self.uuid) - if len(args) == 0: - return dir - assert args[0] in ["values", "comments"], str(args) - return os.path.join(dir, *args) - - def set_sync_with_disk(self, value): - self.sync_with_disk = value - for comment in self.comments(): - comment.set_sync_with_disk(value) - - def load_settings(self): - if self.sync_with_disk == False: - raise DiskAccessRequired("load settings") - self.settings = mapfile.map_load(self.vcs, self.get_path("values")) + def load_settings(self, settings_mapfile=None): + if settings_mapfile == None: + settings_mapfile = \ + self.storage.get(self.id.storage('values'), default='\n') + try: + self.settings = mapfile.parse(settings_mapfile) + except mapfile.InvalidMapfileContents, e: + raise Exception('Invalid settings file for bug %s\n' + '(BE version missmatch?)' % self.id.user()) self._setup_saved_settings() def save_settings(self): - if self.sync_with_disk == False: - raise DiskAccessRequired("save settings") - assert self.summary != None, "Can't save blank bug" - self.vcs.mkdir(self.get_path()) - path = self.get_path("values") - mapfile.map_save(self.vcs, path, self._get_saved_settings()) + mf = mapfile.generate(self._get_saved_settings()) + self.storage.set(self.id.storage('values'), mf) def save(self): """ - Save any loaded contents to disk. Because of lazy loading of - comments, this is actually not too inefficient. - - However, if self.sync_with_disk = True, then any changes are - automatically written to disk as soon as they happen, so - calling this method will just waste time (unless something - else has been messing with your on-disk files). + Save any loaded contents to storage. Because of lazy loading + of comments, this is actually not too inefficient. + + However, if self.storage.is_writeable() == True, then any + changes are automatically written to storage as soon as they + happen, so calling this method will just waste time (unless + something else has been messing with your stored files). """ - sync_with_disk = self.sync_with_disk - if sync_with_disk == False: - self.set_sync_with_disk(True) + assert self.storage != None, "Can't save without storage" + if self.bugdir != None: + parent = self.bugdir.id.storage() + else: + parent = None + self.storage.add(self.id.storage(), parent=parent, directory=True) + self.storage.add(self.id.storage('values'), parent=self.id.storage(), + directory=False) self.save_settings() if len(self.comment_root) > 0: - comment.saveComments(self) - if sync_with_disk == False: - self.set_sync_with_disk(False) + comment.save_comments(self) 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 + # next _get_comment_root returns a fresh version. Turn of + # writing temporarily so we don't write our blank comment # tree to disk. - self.sync_with_disk = False + w = self.storage.writeable + self.storage.writeable = False self.comment_root = None - self.sync_with_disk = True + self.storage.writeable = w def remove(self): - if self.sync_with_disk == False: - raise DiskAccessRequired("remove") - self.comment_root.remove() - path = self.get_path() - self.vcs.recursive_remove(path) - + self.storage.recursive_remove(self.id.storage()) + # methods for managing comments + def uuids(self): + for comment in self.comments(): + yield comment.uuid + def comments(self): for comment in self.comment_root.traverse(): yield comment @@ -718,20 +693,15 @@ class Bug(settings_object.SavedSettingsObject): comm = self.comment_root.new_reply(body=body) return comm - def comment_from_shortname(self, shortname, *args, **kwargs): - return self.comment_root.comment_from_shortname(shortname, - *args, **kwargs) - def comment_from_uuid(self, uuid, *args, **kwargs): return self.comment_root.comment_from_uuid(uuid, *args, **kwargs) - def comment_shortnames(self, shortname=None): - """ - SIDE-EFFECT : Comment.comment_shortnames will sort the comment - tree by comment.time - """ - for id, comment in self.comment_root.comment_shortnames(shortname): - yield (id, comment) + # methods for id generation + + def sibling_uuids(self): + if self.bugdir != None: + return self.bugdir.uuids() + return [] # The general rule for bug sorting is that "more important" bugs are @@ -805,7 +775,7 @@ def cmp_attr(bug_1, bug_2, attr, invert=False): val_2 = getattr(bug_2, attr) if val_1 == None: val_1 = None if val_2 == None: val_2 = None - + if invert == True : return -cmp(val_1, val_2) else : @@ -851,7 +821,7 @@ class BugCompoundComparator (object): if val != 0 : return val return 0 - + cmp_full = BugCompoundComparator() diff --git a/libbe/bugdir.py b/libbe/bugdir.py index 7005181..737dacf 100644 --- a/libbe/bugdir.py +++ b/libbe/bugdir.py @@ -27,24 +27,26 @@ import copy import errno import os import os.path -import sys import time import libbe -import bug -import encoding -from properties import Property, doc_property, local_property, \ - defaulting_property, checked_property, fn_checked_property, \ - cached_property, primed_property, change_hook_property, \ - settings_property -import mapfile -import vcs -import settings_object -import upgrade -import utility +import libbe.storage as storage +from libbe.storage.util.properties import Property, doc_property, \ + local_property, defaulting_property, checked_property, \ + fn_checked_property, cached_property, primed_property, \ + change_hook_property, settings_property +import libbe.storage.util.settings_object as settings_object +import libbe.storage.util.mapfile as mapfile +import libbe.bug as bug +import libbe.util.utility as utility +import libbe.util.id + if libbe.TESTING == True: - import unittest import doctest + import sys + import unittest + + import libbe.storage.base class NoBugDir(Exception): @@ -72,11 +74,13 @@ class MultipleBugMatches(ValueError): self.shortname = shortname self.matches = matches -class NoBugMatches(KeyError): - def __init__(self, shortname): - msg = "No bug matches %s" % shortname - KeyError.__init__(self, msg) - self.shortname = shortname +class NoBugMatches(libbe.util.id.NoIDMatches): + def __init__(self, *args, **kwargs): + libbe.util.id.NoIDMatches.__init__(self, *args, **kwargs) + def __str__(self): + if self.msg == None: + return 'No bug matches %s' % self.id + return self.msg class DiskAccessRequired (Exception): def __init__(self, goal): @@ -86,69 +90,7 @@ class DiskAccessRequired (Exception): class BugDir (list, settings_object.SavedSettingsObject): """ - Sink to existing root - ====================== - - Consider the following usage case: - You have a bug directory rooted in - /path/to/source - by which I mean the '.be' directory is at - /path/to/source/.be - However, you're of in some subdirectory like - /path/to/source/GUI/testing - and you want to comment on a bug. Setting sink_to_root=True wen - you initialize your BugDir will cause it to search for the '.be' - file in the ancestors of the path you passed in as 'root'. - /path/to/source/GUI/testing/.be miss - /path/to/source/GUI/.be miss - /path/to/source/.be hit! - So it still roots itself appropriately without much work for you. - - File-system access - ================== - - BugDirs live completely in memory when .sync_with_disk is False. - This is the default configuration setup by BugDir(from_disk=False). - If .sync_with_disk == True (e.g. BugDir(from_disk=True)), then - any changes to the BugDir will be immediately written to disk. - - If you want to change .sync_with_disk, we suggest you use - .set_sync_with_disk(), which propogates the new setting through to - all bugs/comments/etc. that have been loaded into memory. If - you've been living in memory and want to move to - .sync_with_disk==True, but you're not sure if anything has been - changed in memory, a call to .save() immediately before the - .set_sync_with_disk(True) call is a safe move. - - Regardless of .sync_with_disk, a call to .save() will write out - all the contents that the BugDir instance has loaded into memory. - If sync_with_disk has been True over the course of all interesting - changes, this .save() call will be a waste of time. - - The BugDir will only load information from the file system when it - loads new settings/bugs/comments that it doesn't already have in - memory and .sync_with_disk == True. - - Allow VCS initialization - ======================== - - This one is for testing purposes. Setting it to True allows the - BugDir to search for an installed VCS backend and initialize it in - the root directory. This is a convenience option for supporting - tests of versioning functionality (e.g. .duplicate_bugdir). - - Disable encoding manipulation - ============================= - - This one is for testing purposed. You might have non-ASCII - Unicode in your bugs, comments, files, etc. BugDir instances try - and support your preferred encoding scheme (e.g. "utf-8") when - dealing with stream and file input/output. For stream output, - this involves replacing sys.stdout and sys.stderr - (libbe.encode.set_IO_stream_encodings). However this messes up - doctest's output catching. In order to support doctest tests - using BugDirs, set manipulate_encodings=False, and stick to ASCII - in your tests. + TODO: simple bugdir manipulation examples... """ settings_properties = [] @@ -168,104 +110,6 @@ class BugDir (list, settings_object.SavedSettingsObject): doc="The current project development target.") def target(): return {} - def _guess_encoding(self): - return encoding.get_encoding() - def _check_encoding(value): - if value != None: - return encoding.known_encoding(value) - def _setup_encoding(self, new_encoding): - # change hook called before generator. - if new_encoding not in [None, settings_object.EMPTY]: - if self._manipulate_encodings == True: - encoding.set_IO_stream_encodings(new_encoding) - def _set_encoding(self, old_encoding, new_encoding): - self._setup_encoding(new_encoding) - self._prop_save_settings(old_encoding, new_encoding) - - @_versioned_property(name="encoding", - doc="""The default input/output encoding to use (e.g. "utf-8").""", - change_hook=_set_encoding, - generator=_guess_encoding, - check_fn=_check_encoding) - def encoding(): return {} - - def _setup_user_id(self, user_id): - self.vcs.user_id = user_id - def _guess_user_id(self): - return self.vcs.get_user_id() - def _set_user_id(self, old_user_id, new_user_id): - self._setup_user_id(new_user_id) - self._prop_save_settings(old_user_id, new_user_id) - - @_versioned_property(name="user_id", - doc= -"""The user's prefered name, e.g. 'John Doe <jdoe@example.com>'. Note -that the Arch VCS backend *enforces* ids with this format.""", - change_hook=_set_user_id, - generator=_guess_user_id) - def user_id(): return {} - - @_versioned_property(name="default_assignee", - doc= -"""The default assignee for new bugs e.g. 'John Doe <jdoe@example.com>'.""") - def default_assignee(): return {} - - @_versioned_property(name="vcs_name", - doc="""The name of the current VCS. Kept seperate to make saving/loading -settings easy. Don't set this attribute. Set .vcs instead, and -.vcs_name will be automatically adjusted.""", - default="None", - allowed=["None"]+vcs.VCS_ORDER) - def vcs_name(): return {} - - def _get_vcs(self, vcs_name=None): - """Get and root a new revision control system""" - if vcs_name == None: - vcs_name = self.vcs_name - new_vcs = vcs.vcs_by_name(vcs_name) - self._change_vcs(None, new_vcs) - return new_vcs - def _change_vcs(self, old_vcs, new_vcs): - new_vcs.encoding = self.encoding - new_vcs.root(self.root) - self.vcs_name = new_vcs.name - - @Property - @change_hook_property(hook=_change_vcs) - @cached_property(generator=_get_vcs) - @local_property("vcs") - @doc_property(doc="A revision control system instance.") - def vcs(): return {} - - def _bug_map_gen(self): - map = {} - for bug in self: - map[bug.uuid] = bug - for uuid in self.uuids(): - if uuid not in map: - map[uuid] = None - self._bug_map_value = map # ._bug_map_value used by @local_property - - def _extra_strings_check_fn(value): - return utility.iterable_full_of_strings(value, \ - alternative=settings_object.EMPTY) - def _extra_strings_change_hook(self, old, new): - self.extra_strings.sort() # to make merging easier - self._prop_save_settings(old, new) - @_versioned_property(name="extra_strings", - doc="Space for an array of extra strings. Useful for storing state for functionality implemented purely in becommands/<some_function>.py.", - default=[], - check_fn=_extra_strings_check_fn, - change_hook=_extra_strings_change_hook, - mutable=True) - def extra_strings(): return {} - - @Property - @primed_property(primer=_bug_map_gen) - @local_property("bug_map") - @doc_property(doc="A dict of (bug-uuid, bug-instance) pairs.") - def _bug_map(): return {} - def _setup_severities(self, severities): if severities not in [None, settings_object.EMPTY]: bug.load_severities(severities) @@ -295,278 +139,114 @@ settings easy. Don't set this attribute. Set .vcs instead, and change_hook=_set_inactive_status) def inactive_status(): return {} + def _extra_strings_check_fn(value): + return utility.iterable_full_of_strings(value, \ + alternative=settings_object.EMPTY) + def _extra_strings_change_hook(self, old, new): + self.extra_strings.sort() # to make merging easier + self._prop_save_settings(old, new) + @_versioned_property(name="extra_strings", + doc="Space for an array of extra strings. Useful for storing state for functionality implemented purely in becommands/<some_function>.py.", + default=[], + check_fn=_extra_strings_check_fn, + change_hook=_extra_strings_change_hook, + mutable=True) + def extra_strings(): return {} - def __init__(self, root=None, sink_to_existing_root=True, - assert_new_BugDir=False, allow_vcs_init=False, - manipulate_encodings=True, from_disk=False, vcs=None): - list.__init__(self) - settings_object.SavedSettingsObject.__init__(self) - self._manipulate_encodings = manipulate_encodings - if root == None: - root = os.getcwd() - if sink_to_existing_root == True: - self.root = self._find_root(root) - else: - if not os.path.exists(root): - self.root = None - raise NoRootEntry(root) - self.root = root - # get a temporary vcs until we've loaded settings - self.sync_with_disk = False - self.vcs = self._guess_vcs() - - if from_disk == True: - self.sync_with_disk = True - self.load() - else: - self.sync_with_disk = False - if assert_new_BugDir == True: - if os.path.exists(self.get_path()): - raise AlreadyInitialized, self.get_path() - if vcs == None: - vcs = self._guess_vcs(allow_vcs_init) - self.vcs = vcs - self._setup_user_id(self.user_id) - - def cleanup(self): - self.vcs.cleanup() + def _bug_map_gen(self): + map = {} + for bug in self: + map[bug.uuid] = bug + for uuid in self.uuids(): + if uuid not in map: + map[uuid] = None + self._bug_map_value = map # ._bug_map_value used by @local_property - # methods for getting the BugDir situated in the filesystem + @Property + @primed_property(primer=_bug_map_gen) + @local_property("bug_map") + @doc_property(doc="A dict of (bug-uuid, bug-instance) pairs.") + def _bug_map(): return {} - def _find_root(self, path): - """ - Search for an existing bug database dir and it's ancestors and - return a BugDir rooted there. Only called by __init__, and - then only if sink_to_existing_root == True. - """ - if not os.path.exists(path): - self.root = None - raise NoRootEntry(path) - versionfile=utility.search_parent_directories(path, - os.path.join(".be", "version")) - if versionfile != None: - beroot = os.path.dirname(versionfile) - root = os.path.dirname(beroot) - return root + def __init__(self, storage, uuid=None, from_storage=False): + list.__init__(self) + settings_object.SavedSettingsObject.__init__(self) + self.storage = storage + self.id = libbe.util.id.ID(self, 'bugdir') + self.uuid = uuid + if from_storage == True: + if self.uuid == None: + self.uuid = [c for c in self.storage.children() + if c != 'version'][0] + self.load_settings() else: - beroot = utility.search_parent_directories(path, ".be") - if beroot == None: - self.root = None - raise NoBugDir(path) - return beroot - - def _guess_vcs(self, allow_vcs_init=False): - """ - Only called by __init__. - """ - deepdir = self.get_path() - if not os.path.exists(deepdir): - deepdir = os.path.dirname(deepdir) - new_vcs = vcs.detect_vcs(deepdir) - install = False - if new_vcs.name == "None": - if allow_vcs_init == True: - new_vcs = vcs.installed_vcs() - new_vcs.init(self.root) - return new_vcs + if self.uuid == None: + self.uuid = libbe.util.id.uuid_gen() + self.settings = {} + self._setup_saved_settings() + if self.storage != None and self.storage.is_writeable(): + self.save() # methods for saving/loading/accessing settings and properties. - def get_path(self, *args): - """ - Return a path relative to .root. - """ - dir = os.path.join(self.root, ".be") - if len(args) == 0: - return dir - assert args[0] in ["version", "settings", "bugs"], str(args) - return os.path.join(dir, *args) - - def _get_settings(self, settings_path, for_duplicate_bugdir=False): - allow_no_vcs = not self.vcs.path_in_root(settings_path) - if allow_no_vcs == True: - assert for_duplicate_bugdir == True - if self.sync_with_disk == False and for_duplicate_bugdir == False: - # duplicates can ignore this bugdir's .sync_with_disk status - raise DiskAccessRequired("_get settings") + def load_settings(self, settings_mapfile=None): + if settings_mapfile == None: + settings_mapfile = \ + self.storage.get(self.id.storage('settings'), default='\n') try: - settings = mapfile.map_load(self.vcs, settings_path, allow_no_vcs) - except vcs.NoSuchFile: - settings = {"vcs_name": "None"} - return settings - - def _save_settings(self, settings_path, settings, - for_duplicate_bugdir=False): - allow_no_vcs = not self.vcs.path_in_root(settings_path) - if allow_no_vcs == True: - assert for_duplicate_bugdir == True - if self.sync_with_disk == False and for_duplicate_bugdir == False: - # duplicates can ignore this bugdir's .sync_with_disk status - raise DiskAccessRequired("_save settings") - self.vcs.mkdir(self.get_path(), allow_no_vcs) - mapfile.map_save(self.vcs, settings_path, settings, allow_no_vcs) - - def load_settings(self): - self.settings = self._get_settings(self.get_path("settings")) + self.settings = mapfile.parse(settings_mapfile) + except mapfile.InvalidMapfileContents, e: + raise Exception('Invalid settings file for bugdir %s\n' + '(BE version missmatch?)' % self.id.user()) self._setup_saved_settings() - self._setup_user_id(self.user_id) - self._setup_encoding(self.encoding) + #self._setup_user_id(self.user_id) self._setup_severities(self.severities) self._setup_status(self.active_status, self.inactive_status) - if self.vcs_name != self.vcs.name: - self.vcs = vcs.vcs_by_name(self.vcs_name) - self._setup_user_id(self.user_id) def save_settings(self): - settings = self._get_saved_settings() - self._save_settings(self.get_path("settings"), settings) - - def get_version(self, path=None, use_none_vcs=False, - for_duplicate_bugdir=False): - """ - Requires disk access. - """ - if self.sync_with_disk == False: - raise DiskAccessRequired("get version") - if use_none_vcs == True: - VCS = vcs.vcs_by_name("None") - VCS.root(self.root) - VCS.encoding = encoding.get_encoding() - else: - VCS = self.vcs - - if path == None: - path = self.get_path("version") - allow_no_vcs = not VCS.path_in_root(path) - if allow_no_vcs == True: - assert for_duplicate_bugdir == True - version = VCS.get_file_contents( - path, allow_no_vcs=allow_no_vcs).rstrip("\n") - return version - - def set_version(self): - """ - Requires disk access. - """ - if self.sync_with_disk == False: - raise DiskAccessRequired("set version") - self.vcs.mkdir(self.get_path()) - self.vcs.set_file_contents(self.get_path("version"), - upgrade.BUGDIR_DISK_VERSION+"\n") - - # methods controlling disk access - - def set_sync_with_disk(self, value): - """ - Adjust .sync_with_disk for the BugDir and all it's children. - See the BugDir docstring for a description of the role of - .sync_with_disk. - """ - self.sync_with_disk = value - for bug in self: - bug.set_sync_with_disk(value) - - def load(self): - """ - Reqires disk access - """ - version = self.get_version(use_none_vcs=True) - if version != upgrade.BUGDIR_DISK_VERSION: - upgrade.upgrade(self.root, version) - else: - if not os.path.exists(self.get_path()): - raise NoBugDir(self.get_path()) - self.load_settings() + mf = mapfile.generate(self._get_saved_settings()) + self.storage.set(self.id.storage('settings'), mf) def load_all_bugs(self): """ - Requires disk access. Warning: this could take a while. """ - if self.sync_with_disk == False: - raise DiskAccessRequired("load all bugs") self._clear_bugs() for uuid in self.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 storage. Because of lazy loading + of bugs and comments, this is actually not too inefficient. - Save any loaded contents to disk. Because of lazy loading of - bugs and comments, this is actually not too inefficient. - - However, if .sync_with_disk = True, then any changes are - automatically written to disk as soon as they happen, so - calling this method will just waste time (unless something - else has been messing with your on-disk files). - - Requires disk access. + However, if self.storage.is_writeable() == True, then any + changes are automatically written to storage as soon as they + happen, so calling this method will just waste time (unless + something else has been messing with your stored files). """ - sync_with_disk = self.sync_with_disk - if sync_with_disk == False: - self.set_sync_with_disk(True) - self.set_version() + self.storage.add(self.id.storage(), directory=True) + self.storage.add(self.id.storage('settings'), parent=self.id.storage(), + directory=False) self.save_settings() for bug in self: bug.save() - if sync_with_disk == False: - self.set_sync_with_disk(sync_with_disk) - - # methods for managing duplicate BugDirs - - def duplicate_bugdir(self, revision): - duplicate_path = self.vcs.duplicate_repo(revision) - - duplicate_version_path = os.path.join(duplicate_path, ".be", "version") - try: - version = self.get_version(duplicate_version_path, - for_duplicate_bugdir=True) - except DiskAccessRequired: - self.sync_with_disk = True # temporarily allow access - version = self.get_version(duplicate_version_path, - for_duplicate_bugdir=True) - self.sync_with_disk = False - if version != upgrade.BUGDIR_DISK_VERSION: - upgrade.upgrade(duplicate_path, version) - - # setup revision VCS as None, since the duplicate may not be - # initialized for versioning - duplicate_settings_path = os.path.join(duplicate_path, - ".be", "settings") - duplicate_settings = self._get_settings(duplicate_settings_path, - for_duplicate_bugdir=True) - if "vcs_name" in duplicate_settings: - duplicate_settings["vcs_name"] = "None" - duplicate_settings["user_id"] = self.user_id - if "disabled" in bug.status_values: - # Hack to support old versions of BE bugs - duplicate_settings["inactive_status"] = self.inactive_status - self._save_settings(duplicate_settings_path, duplicate_settings, - for_duplicate_bugdir=True) - - return BugDir(duplicate_path, from_disk=True, manipulate_encodings=self._manipulate_encodings) - - def remove_duplicate_bugdir(self): - self.vcs.remove_duplicate_repo() # methods for managing bugs def uuids(self): uuids = [] - if self.sync_with_disk == True and os.path.exists(self.get_path()): - # list the uuids on disk - if os.path.exists(self.get_path("bugs")): - for uuid in os.listdir(self.get_path("bugs")): - if not (uuid.startswith('.')): - uuids.append(uuid) - yield uuid - # and the ones that are still just in memory + # list the uuids in memory for bug in self: - if bug.uuid not in uuids: - uuids.append(bug.uuid) - yield bug.uuid + uuids.append(bug.uuid) + yield bug.uuid + if self.storage != None and self.storage.is_readable(): + # and the ones that are still just in storage + child_uuids = libbe.util.id.child_uuids( + self.storage.children(self.id.storage())) + for id in child_uuids: + if id not in uuids: + yield id def _clear_bugs(self): while len(self) > 0: @@ -574,70 +254,28 @@ settings easy. Don't set this attribute. Set .vcs 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) + bg = bug.Bug(bugdir=self, uuid=uuid, from_storage=True) self.append(bg) self._bug_map_gen() return bg - def new_bug(self, uuid=None, summary=None): - bg = bug.Bug(bugdir=self, uuid=uuid, summary=summary) - bg.set_sync_with_disk(self.sync_with_disk) - if bg.sync_with_disk == True: - bg.save() + def new_bug(self, summary=None, _uuid=None): + bg = bug.Bug(bugdir=self, uuid=_uuid, summary=summary, + from_storage=False) self.append(bg) self._bug_map_gen() return bg def remove_bug(self, bug): self.remove(bug) - if bug.sync_with_disk == True: + if self.storage != None and self.storage.is_writeable(): bug.remove() - def bug_shortname(self, bug): - """ - Generate short names from uuids. Picks the minimum number of - characters (>=3) from the beginning of the uuid such that the - short names are unique. - - Obviously, as the number of bugs in the database grows, these - short names will cease to be unique. The complete uuid should be - used for long term reference. - """ - chars = 3 - for uuid in self._bug_map.keys(): - if bug.uuid == uuid: - continue - while (bug.uuid[:chars] == uuid[:chars]): - chars+=1 - return bug.uuid[:chars] - - def bug_from_shortname(self, shortname): - """ - >>> bd = SimpleBugDir(sync_with_disk=False) - >>> bug_a = bd.bug_from_shortname('a') - >>> print type(bug_a) - <class 'libbe.bug.Bug'> - >>> print bug_a - a:om: Bug A - >>> bd.cleanup() - """ - matches = [] - self._bug_map_gen() - for uuid in self._bug_map.keys(): - if uuid.startswith(shortname): - matches.append(uuid) - if len(matches) > 1: - raise MultipleBugMatches(shortname, matches) - if len(matches) == 1: - return self.bug_from_uuid(matches[0]) - raise NoBugMatches(shortname) - def bug_from_uuid(self, uuid): if not self.has_bug(uuid): - raise KeyError("No bug matches %s\n bug map: %s\n root: %s" \ - % (uuid, self._bug_map, self.root)) + raise NoBugMatches( + uuid, self.uuids(), + 'No bug matches %s in %s' % (uuid, self.storage)) if self._bug_map[uuid] == None: self._load_bug(uuid) return self._bug_map[uuid] @@ -649,176 +287,272 @@ settings easy. Don't set this attribute. Set .vcs instead, and return False return True + # methods for id generation -class SimpleBugDir (BugDir): - """ - For testing. Set sync_with_disk==False for a memory-only bugdir. - >>> bugdir = SimpleBugDir() - >>> uuids = list(bugdir.uuids()) - >>> uuids.sort() - >>> print uuids - ['a', 'b'] - >>> bugdir.cleanup() - """ - def __init__(self, sync_with_disk=True): - if sync_with_disk == True: - dir = utility.Dir() - assert os.path.exists(dir.path) - root = dir.path - assert_new_BugDir = True - vcs_init = True - else: - root = "/" - assert_new_BugDir = False - vcs_init = False - BugDir.__init__(self, root, sink_to_existing_root=False, - assert_new_BugDir=assert_new_BugDir, - allow_vcs_init=vcs_init, - manipulate_encodings=False) - if sync_with_disk == True: # postpone cleanup since dir.cleanup() removes dir. - self._dir_ref = dir - bug_a = self.new_bug("a", summary="Bug A") - bug_a.creator = "John Doe <jdoe@example.com>" - bug_a.time = 0 - bug_b = self.new_bug("b", summary="Bug B") - bug_b.creator = "Jane Doe <jdoe@example.com>" - bug_b.time = 0 - bug_b.status = "closed" - if sync_with_disk == True: - self.save() - self.set_sync_with_disk(True) - def cleanup(self): - if hasattr(self, "_dir_ref"): - self._dir_ref.cleanup() - BugDir.cleanup(self) + def sibling_uuids(self): + return [] + + # methods for managing duplicate BugDirs + + def duplicate_bugdir(self, revision): + """ + Duplicate bugdirs are read-only copies used for generating + diffs between revisions. + """ + storage_version = self.storage.storage_version(revision) + if storage_version != libbe.storage.STORAGE_VERSION: + raise libbe.storage.InvalidStorageVersion(storage_version) + s = copy.deepcopy(self.storage) + s.writeable = False + class RevisionedStorage (object): + def __init__(self, storage, default_revision): + self.s = storage + self.sget = self.s.get + self.schildren = self.s.children + self.r = default_revision + def get(self, *args, **kwargs): + if not 'revision' in kwargs or kwargs['revision'] == None: + kwargs['revision'] = self.r + return self.sget(*args, **kwargs) + def children(self, *args, **kwargs): + if not 'revision' in kwargs or kwargs['revision'] == None: + kwargs['revision'] = self.r + return self.schildren(*args, **kwargs) + rs = RevisionedStorage(s, revision) + s.get = rs.get + s.children = rs.children + dbd = BugDir(s, from_storage=True) +# dbd = copy.copy(self) +# dbd.storage = copy.copy(self.storage) +# dbd._bug_map = copy.copy(self._bug_map) +# dbd.storage.writeable = False +# added,changed,removed = self.storage.changed_since(revision) +# for id in added: +# pass +# for id in removed: +# pass +# for id in changed: +# parsed = libbe.util.id.parse_id(id) +# if parsed['type'] == 'bugdir': +# assert parsed['remaining'] == ['settings'], parsed['remaining'] +# dbd._settings = copy.copy(self._settings) +# mf = self.storage.get(self.id.storage('settings'), default='\n', +# revision=revision) +# dbd.load_settings(mf) +# else: +# if parsed['bug'] not in self: +# self._load_bug(parsed['bug']) +# dbd._load_bug(parsed['bug']) +# else: +# bug = copy.copy(self._bug_map[parsed['bug']]) +# bug.settings = copy.copy(bug.settings) +# dbd._bug_map[parsed['bug']] = bug +# if parsed['type'] == 'bug': +# assert parsed['remaining'] == ['values'], parsed['remaining'] +# mf = self.storage.get(self.id.storage('values'), default='\n', +# revision=revision) +# bug.load_settings(mf) +# elif parsed['type'] == 'comment': +# assert parsed['remaining'] in [['values'], ['body']], \ +# parsed['remaining'] +# bug.comment_root = copy.deepcopy(bug.comment_root) +# comment = bug.comment_from_uuid(parsed['comment']) +# if parsed['remaining'] == ['values']: +# mf = self.storage.get(self.id.storage('values'), default='\n', +# revision=revision) +# comment.load_settings(mf) +# else: +# body = self.storage.get(self.id.storage('body'), default='\n', +# revision=revision) +# comment.body = body +# else: +# assert 1==0, 'Unkown type "%s" for id "%s"' % (type, id) +# dbd.storage.readable = False # so we won't read in added bugs, etc. + return dbd if libbe.TESTING == True: - class BugDirTestCase(unittest.TestCase): - def setUp(self): - self.dir = utility.Dir() - self.bugdir = BugDir(self.dir.path, sink_to_existing_root=False, - allow_vcs_init=True) - self.vcs = self.bugdir.vcs - def tearDown(self): - self.bugdir.cleanup() - self.dir.cleanup() - def fullPath(self, path): - return os.path.join(self.dir.path, path) - def assertPathExists(self, path): - fullpath = self.fullPath(path) - self.failUnless(os.path.exists(fullpath)==True, - "path %s does not exist" % fullpath) - self.assertRaises(AlreadyInitialized, BugDir, - self.dir.path, assertNewBugDir=True) - def versionTest(self): - if self.vcs.versioned == False: - return - original = self.bugdir.vcs.commit("Began versioning") - bugA = self.bugdir.bug_from_uuid("a") - bugA.status = "fixed" - self.bugdir.save() - new = self.vcs.commit("Fixed bug a") - dupdir = self.bugdir.duplicate_bugdir(original) - self.failUnless(dupdir.root != self.bugdir.root, - "%s, %s" % (dupdir.root, self.bugdir.root)) - bugAorig = dupdir.bug_from_uuid("a") - self.failUnless(bugA != bugAorig, - "\n%s\n%s" % (bugA.string(), bugAorig.string())) - bugAorig.status = "fixed" - self.failUnless(bug.cmp_status(bugA, bugAorig)==0, - "%s, %s" % (bugA.status, bugAorig.status)) - self.failUnless(bug.cmp_severity(bugA, bugAorig)==0, - "%s, %s" % (bugA.severity, bugAorig.severity)) - self.failUnless(bug.cmp_assigned(bugA, bugAorig)==0, - "%s, %s" % (bugA.assigned, bugAorig.assigned)) - self.failUnless(bug.cmp_time(bugA, bugAorig)==0, - "%s, %s" % (bugA.time, bugAorig.time)) - self.failUnless(bug.cmp_creator(bugA, bugAorig)==0, - "%s, %s" % (bugA.creator, bugAorig.creator)) - self.failUnless(bugA == bugAorig, - "\n%s\n%s" % (bugA.string(), bugAorig.string())) - self.bugdir.remove_duplicate_bugdir() - self.failUnless(os.path.exists(dupdir.root)==False, - str(dupdir.root)) - def testRun(self): - self.bugdir.new_bug(uuid="a", summary="Ant") - self.bugdir.new_bug(uuid="b", summary="Cockroach") - self.bugdir.new_bug(uuid="c", summary="Praying mantis") - length = len(self.bugdir) - self.failUnless(length == 3, "%d != 3 bugs" % length) - uuids = list(self.bugdir.uuids()) - self.failUnless(len(uuids) == 3, "%d != 3 uuids" % len(uuids)) - self.failUnless(uuids == ["a","b","c"], str(uuids)) - bugA = self.bugdir.bug_from_uuid("a") - bugAprime = self.bugdir.bug_from_shortname("a") - self.failUnless(bugA == bugAprime, "%s != %s" % (bugA, bugAprime)) - self.bugdir.save() - self.versionTest() - def testComments(self, sync_with_disk=False): - if sync_with_disk == True: - self.bugdir.set_sync_with_disk(True) - self.bugdir.new_bug(uuid="a", summary="Ant") - bug = self.bugdir.bug_from_uuid("a") - comm = bug.comment_root - rep = comm.new_reply("Ants are small.") - rep.new_reply("And they have six legs.") - if sync_with_disk == False: - self.bugdir.save() - self.bugdir.set_sync_with_disk(True) - self.bugdir._clear_bugs() - bug = self.bugdir.bug_from_uuid("a") - bug.load_comments() - if sync_with_disk == False: - self.bugdir.set_sync_with_disk(False) - self.failUnless(len(bug.comment_root)==1, len(bug.comment_root)) - for index,comment in enumerate(bug.comments()): - if index == 0: - repLoaded = comment - self.failUnless(repLoaded.uuid == rep.uuid, repLoaded.uuid) - self.failUnless(comment.sync_with_disk == sync_with_disk, - comment.sync_with_disk) - self.failUnless(comment.content_type == "text/plain", - comment.content_type) - self.failUnless(repLoaded.settings["Content-type"] == \ - "text/plain", - repLoaded.settings) - self.failUnless(repLoaded.body == "Ants are small.", - repLoaded.body) - elif index == 1: - self.failUnless(comment.in_reply_to == repLoaded.uuid, - repLoaded.uuid) - self.failUnless(comment.body == "And they have six legs.", - comment.body) + class SimpleBugDir (BugDir): + """ + For testing. Set memory=True for a memory-only bugdir. + >>> bugdir = SimpleBugDir() + >>> uuids = list(bugdir.uuids()) + >>> uuids.sort() + >>> print uuids + ['a', 'b'] + >>> bugdir.cleanup() + """ + def __init__(self, memory=True, versioned=False): + if memory == True: + storage = None + else: + dir = utility.Dir() + self._dir_ref = dir # postpone cleanup since dir.cleanup() removes dir. + if versioned == False: + storage = libbe.storage.base.Storage(dir.path) else: - self.failIf(True, - "Invalid comment: %d\n%s" % (index, comment)) - def testSyncedComments(self): - self.testComments(sync_with_disk=True) - + storage = libbe.storage.base.VersionedStorage(dir.path) + storage.init() + storage.connect() + BugDir.__init__(self, storage=storage, uuid='abc123') + bug_a = self.new_bug(summary='Bug A', _uuid='a') + bug_a.creator = 'John Doe <jdoe@example.com>' + bug_a.time = 0 + bug_b = self.new_bug(summary='Bug B', _uuid='b') + bug_b.creator = 'Jane Doe <jdoe@example.com>' + bug_b.time = 0 + bug_b.status = 'closed' + if self.storage != None: + self.storage.disconnect() # flush to storage + self.storage.connect() + + def cleanup(self): + if self.storage != None: + self.storage.writeable = True + self.storage.disconnect() + self.storage.destroy() + if hasattr(self, '_dir_ref'): + self._dir_ref.cleanup() + + def flush_reload(self): + if self.storage != None: + self.storage.disconnect() + self.storage.connect() + self._clear_bugs() + +# class BugDirTestCase(unittest.TestCase): +# def setUp(self): +# self.dir = utility.Dir() +# self.bugdir = BugDir(self.dir.path, sink_to_existing_root=False, +# allow_storage_init=True) +# self.storage = self.bugdir.storage +# def tearDown(self): +# self.bugdir.cleanup() +# self.dir.cleanup() +# def fullPath(self, path): +# return os.path.join(self.dir.path, path) +# def assertPathExists(self, path): +# fullpath = self.fullPath(path) +# self.failUnless(os.path.exists(fullpath)==True, +# "path %s does not exist" % fullpath) +# self.assertRaises(AlreadyInitialized, BugDir, +# self.dir.path, assertNewBugDir=True) +# def versionTest(self): +# if self.storage != None and self.storage.versioned == False: +# return +# original = self.bugdir.storage.commit("Began versioning") +# bugA = self.bugdir.bug_from_uuid("a") +# bugA.status = "fixed" +# self.bugdir.save() +# new = self.storage.commit("Fixed bug a") +# dupdir = self.bugdir.duplicate_bugdir(original) +# self.failUnless(dupdir.root != self.bugdir.root, +# "%s, %s" % (dupdir.root, self.bugdir.root)) +# bugAorig = dupdir.bug_from_uuid("a") +# self.failUnless(bugA != bugAorig, +# "\n%s\n%s" % (bugA.string(), bugAorig.string())) +# bugAorig.status = "fixed" +# self.failUnless(bug.cmp_status(bugA, bugAorig)==0, +# "%s, %s" % (bugA.status, bugAorig.status)) +# self.failUnless(bug.cmp_severity(bugA, bugAorig)==0, +# "%s, %s" % (bugA.severity, bugAorig.severity)) +# self.failUnless(bug.cmp_assigned(bugA, bugAorig)==0, +# "%s, %s" % (bugA.assigned, bugAorig.assigned)) +# self.failUnless(bug.cmp_time(bugA, bugAorig)==0, +# "%s, %s" % (bugA.time, bugAorig.time)) +# self.failUnless(bug.cmp_creator(bugA, bugAorig)==0, +# "%s, %s" % (bugA.creator, bugAorig.creator)) +# self.failUnless(bugA == bugAorig, +# "\n%s\n%s" % (bugA.string(), bugAorig.string())) +# self.bugdir.remove_duplicate_bugdir() +# self.failUnless(os.path.exists(dupdir.root)==False, +# str(dupdir.root)) +# def testRun(self): +# self.bugdir.new_bug(uuid="a", summary="Ant") +# self.bugdir.new_bug(uuid="b", summary="Cockroach") +# self.bugdir.new_bug(uuid="c", summary="Praying mantis") +# length = len(self.bugdir) +# self.failUnless(length == 3, "%d != 3 bugs" % length) +# uuids = list(self.bugdir.uuids()) +# self.failUnless(len(uuids) == 3, "%d != 3 uuids" % len(uuids)) +# self.failUnless(uuids == ["a","b","c"], str(uuids)) +# bugA = self.bugdir.bug_from_uuid("a") +# bugAprime = self.bugdir.bug_from_shortname("a") +# self.failUnless(bugA == bugAprime, "%s != %s" % (bugA, bugAprime)) +# self.bugdir.save() +# self.versionTest() +# def testComments(self, sync_with_disk=False): +# if sync_with_disk == True: +# self.bugdir.set_sync_with_disk(True) +# self.bugdir.new_bug(uuid="a", summary="Ant") +# bug = self.bugdir.bug_from_uuid("a") +# comm = bug.comment_root +# rep = comm.new_reply("Ants are small.") +# rep.new_reply("And they have six legs.") +# if sync_with_disk == False: +# self.bugdir.save() +# self.bugdir.set_sync_with_disk(True) +# self.bugdir._clear_bugs() +# bug = self.bugdir.bug_from_uuid("a") +# bug.load_comments() +# if sync_with_disk == False: +# self.bugdir.set_sync_with_disk(False) +# self.failUnless(len(bug.comment_root)==1, len(bug.comment_root)) +# for index,comment in enumerate(bug.comments()): +# if index == 0: +# repLoaded = comment +# self.failUnless(repLoaded.uuid == rep.uuid, repLoaded.uuid) +# self.failUnless(comment.sync_with_disk == sync_with_disk, +# comment.sync_with_disk) +# self.failUnless(comment.content_type == "text/plain", +# comment.content_type) +# self.failUnless(repLoaded.settings["Content-type"] == \ +# "text/plain", +# repLoaded.settings) +# self.failUnless(repLoaded.body == "Ants are small.", +# repLoaded.body) +# elif index == 1: +# self.failUnless(comment.in_reply_to == repLoaded.uuid, +# repLoaded.uuid) +# self.failUnless(comment.body == "And they have six legs.", +# comment.body) +# else: +# self.failIf(True, +# "Invalid comment: %d\n%s" % (index, comment)) +# def testSyncedComments(self): +# self.testComments(sync_with_disk=True) + class SimpleBugDirTestCase (unittest.TestCase): def setUp(self): # create a pre-existing bugdir in a temporary directory self.dir = utility.Dir() - self.original_working_dir = os.getcwd() - os.chdir(self.dir.path) - self.bugdir = BugDir(self.dir.path, sink_to_existing_root=False, - allow_vcs_init=True) - self.bugdir.new_bug("preexisting",summary="Hopefully not imported") - self.bugdir.save() + self.storage = libbe.storage.base.Storage(self.dir.path) + self.storage.init() + self.storage.connect() + self.bugdir = BugDir(self.storage) + self.bugdir.new_bug(summary="Hopefully not imported", + _uuid="preexisting") + self.storage.disconnect() + self.storage.connect() def tearDown(self): - os.chdir(self.original_working_dir) - self.bugdir.cleanup() + if self.storage != None: + self.storage.disconnect() + self.storage.destroy() self.dir.cleanup() def testOnDiskCleanLoad(self): """ - SimpleBugDir(sync_with_disk==True) should not import + SimpleBugDir(memory==False) should not import preexisting bugs. """ - bugdir = SimpleBugDir(sync_with_disk=True) - self.failUnless(bugdir.sync_with_disk==True, bugdir.sync_with_disk) + bugdir = SimpleBugDir(memory=False) + self.failUnless(bugdir.storage.is_readable() == True, + bugdir.storage.is_readable()) + self.failUnless(bugdir.storage.is_writeable() == True, + bugdir.storage.is_writeable()) uuids = sorted([bug.uuid for bug in bugdir]) self.failUnless(uuids == ['a', 'b'], uuids) - bugdir._clear_bugs() + bugdir.flush_reload() + uuids = sorted(bugdir.uuids()) + self.failUnless(uuids == ['a', 'b'], uuids) uuids = sorted([bug.uuid for bug in bugdir]) self.failUnless(uuids == [], uuids) bugdir.load_all_bugs() @@ -827,21 +561,45 @@ if libbe.TESTING == True: bugdir.cleanup() def testInMemoryCleanLoad(self): """ - SimpleBugDir(sync_with_disk==False) should not import + SimpleBugDir(memory==True) should not import preexisting bugs. """ - bugdir = SimpleBugDir(sync_with_disk=False) - self.failUnless(bugdir.sync_with_disk==False, - bugdir.sync_with_disk) + bugdir = SimpleBugDir(memory=True) + self.failUnless(bugdir.storage == None, bugdir.storage) 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(bugdir.uuids()) + self.failUnless(uuids == [], uuids) uuids = sorted([bug.uuid for bug in bugdir]) self.failUnless(uuids == [], uuids) bugdir.cleanup() - + unitsuite =unittest.TestLoader().loadTestsFromModule(sys.modules[__name__]) suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()]) + +# def _get_settings(self, settings_path, for_duplicate_bugdir=False): +# allow_no_storage = not self.storage.path_in_root(settings_path) +# if allow_no_storage == 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.storage, settings_path, allow_no_storage) +# except storage.NoSuchFile: +# settings = {"storage_name": "None"} +# return settings + +# def _save_settings(self, settings_path, settings, +# for_duplicate_bugdir=False): +# allow_no_storage = not self.storage.path_in_root(settings_path) +# if allow_no_storage == 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.storage.mkdir(self.get_path(), allow_no_storage) +# mapfile.map_save(self.storage, settings_path, settings, allow_no_storage) diff --git a/libbe/bzr.py b/libbe/bzr.py deleted file mode 100644 index 62a9b11..0000000 --- a/libbe/bzr.py +++ /dev/null @@ -1,117 +0,0 @@ -# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc. -# Ben Finney <benf@cybersource.com.au> -# Gianluca Montecchi <gian@grys.it> -# Marien Zwart <marienz@gentoo.org> -# W. Trevor King <wking@drexel.edu> -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, write to the Free Software Foundation, Inc., -# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - -""" -Bazaar (bzr) backend. -""" - -import os -import re -import sys -import unittest - -import libbe -import vcs -if libbe.TESTING == True: - import doctest - - -def new(): - return Bzr() - -class Bzr(vcs.VCS): - name = "bzr" - client = "bzr" - versioned = True - def _vcs_version(self): - status,output,error = self._u_invoke_client("--version") - return output - def _vcs_detect(self, path): - if self._u_search_parent_directories(path, ".bzr") != None : - return True - return False - def _vcs_root(self, path): - """Find the root of the deepest repository containing path.""" - status,output,error = self._u_invoke_client("root", path) - return output.rstrip('\n') - def _vcs_init(self, path): - self._u_invoke_client("init", cwd=path) - def _vcs_get_user_id(self): - status,output,error = self._u_invoke_client("whoami") - return output.rstrip('\n') - def _vcs_set_user_id(self, value): - self._u_invoke_client("whoami", value) - def _vcs_add(self, path): - self._u_invoke_client("add", path) - def _vcs_remove(self, path): - # --force to also remove unversioned files. - self._u_invoke_client("remove", "--force", path) - def _vcs_update(self, path): - pass - def _vcs_get_file_contents(self, path, revision=None, binary=False): - if revision == None: - return vcs.VCS._vcs_get_file_contents(self, path, revision, binary=binary) - else: - status,output,error = \ - self._u_invoke_client("cat","-r",revision,path) - return output - def _vcs_duplicate_repo(self, directory, revision=None): - if revision == None: - vcs.VCS._vcs_duplicate_repo(self, directory, revision) - else: - self._u_invoke_client("branch", "--revision", revision, - ".", directory) - def _vcs_commit(self, commitfile, allow_empty=False): - args = ["commit", "--file", commitfile] - if allow_empty == True: - args.append("--unchanged") - status,output,error = self._u_invoke_client(*args) - else: - kwargs = {"expect":(0,3)} - status,output,error = self._u_invoke_client(*args, **kwargs) - if status != 0: - strings = ["ERROR: no changes to commit.", # bzr 1.3.1 - "ERROR: No changes to commit."] # bzr 1.15.1 - if self._u_any_in_string(strings, error) == True: - raise vcs.EmptyCommit() - else: - raise vcs.CommandError(args, status, stderr=error) - revision = None - revline = re.compile("Committed revision (.*)[.]") - match = revline.search(error) - assert match != None, output+error - assert len(match.groups()) == 1 - revision = match.groups()[0] - return revision - def _vcs_revision_id(self, index): - status,output,error = self._u_invoke_client("revno") - current_revision = int(output) - if index >= current_revision or index < -current_revision: - return None - if index >= 0: - return str(index+1) # bzr commit 0 is the empty tree. - return str(current_revision+index+1) - - -if libbe.TESTING == True: - vcs.make_vcs_testcase_subclasses(Bzr, sys.modules[__name__]) - - unitsuite =unittest.TestLoader().loadTestsFromModule(sys.modules[__name__]) - suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()]) diff --git a/libbe/cmdutil.py b/libbe/cmdutil.py deleted file mode 100644 index c567984..0000000 --- a/libbe/cmdutil.py +++ /dev/null @@ -1,356 +0,0 @@ -# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc. -# Gianluca Montecchi <gian@grys.it> -# Oleg Romanyshyn <oromanyshyn@panoramicfeedback.com> -# W. Trevor King <wking@drexel.edu> -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, write to the Free Software Foundation, Inc., -# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - -""" -Define assorted utilities to make command-line handling easier. -""" - -import glob -import optparse -import os -from textwrap import TextWrapper -from StringIO import StringIO -import sys - -import libbe -import bugdir -import comment -import plugin -import encoding -if libbe.TESTING == True: - import doctest - - -class UserError(Exception): - def __init__(self, msg): - Exception.__init__(self, msg) - -class UnknownCommand(UserError): - def __init__(self, cmd): - Exception.__init__(self, "Unknown command '%s'" % cmd) - self.cmd = cmd - -class UsageError(Exception): - pass - -class GetHelp(Exception): - pass - -class GetCompletions(Exception): - def __init__(self, completions=[]): - msg = "Get allowed completions" - Exception.__init__(self, msg) - self.completions = completions - -def iter_commands(): - for name, module in plugin.iter_plugins("becommands"): - yield name.replace("_", "-"), module - -def get_command(command_name): - """Retrieves the module for a user command - - >>> try: - ... get_command("asdf") - ... except UnknownCommand, e: - ... print e - Unknown command 'asdf' - >>> repr(get_command("list")).startswith("<module 'becommands.list' from ") - True - """ - cmd = plugin.get_plugin("becommands", command_name.replace("-", "_")) - if cmd is None: - raise UnknownCommand(command_name) - return cmd - - -def execute(cmd, args, - manipulate_encodings=True, restrict_file_access=False, - dir="."): - enc = encoding.get_encoding() - cmd = get_command(cmd) - ret = cmd.execute([a.decode(enc) for a in args], - manipulate_encodings=manipulate_encodings, - restrict_file_access=restrict_file_access, - dir=dir) - if ret == None: - ret = 0 - return ret - -def help(cmd=None, parser=None): - if cmd != None: - return get_command(cmd).help() - else: - cmdlist = [] - for name, module in iter_commands(): - cmdlist.append((name, module.__desc__)) - longest_cmd_len = max([len(name) for name,desc in cmdlist]) - ret = ["Bugs Everywhere - Distributed bug tracking", - "", "Supported commands"] - for name, desc in cmdlist: - numExtraSpaces = longest_cmd_len-len(name) - ret.append("be %s%*s %s" % (name, numExtraSpaces, "", desc)) - ret.extend(["", "Run", " be help [command]", "for more information."]) - longhelp = "\n".join(ret) - if parser == None: - return longhelp - return parser.help_str() + "\n" + longhelp - -def completions(cmd): - parser = get_command(cmd).get_parser() - longopts = [] - for opt in parser.option_list: - longopts.append(opt.get_opt_string()) - return longopts - -def raise_get_help(option, opt, value, parser): - raise GetHelp - -def raise_get_completions(option, opt, value, parser): - print "got completion arg" - if hasattr(parser, "command") and parser.command == "be": - comps = [] - for command, module in iter_commands(): - comps.append(command) - for opt in parser.option_list: - comps.append(opt.get_opt_string()) - raise GetCompletions(comps) - raise GetCompletions(completions(sys.argv[1])) - -class CmdOptionParser(optparse.OptionParser): - def __init__(self, usage): - optparse.OptionParser.__init__(self, usage) - self.disable_interspersed_args() - self.remove_option("-h") - self.add_option("-h", "--help", action="callback", - callback=raise_get_help, help="Print a help message") - self.add_option("--complete", action="callback", - callback=raise_get_completions, - help="Print a list of available completions") - - def error(self, message): - raise UsageError(message) - - def iter_options(self): - return iter_combine([self._short_opt.iterkeys(), - self._long_opt.iterkeys()]) - - def help_str(self): - f = StringIO() - self.print_help(f) - return f.getvalue() - -def option_value_pairs(options, parser): - """ - Iterate through OptionParser (option, value) pairs. - """ - for option in [o.dest for o in parser.option_list if o.dest != None]: - value = getattr(options, option) - yield (option, value) - -def default_complete(options, args, parser, bugid_args={}): - """ - A dud complete implementation for becommands so that the - --complete argument doesn't cause any problems. Use this - until you've set up a command-specific complete function. - - bugid_args is an optional dict where the keys are positional - arguments taking bug shortnames and the values are functions for - filtering, since that's a common enough operation. - e.g. for "be open [options] BUGID" - bugid_args = {0: lambda bug : bug.active == False} - A positional argument of -1 specifies all remaining arguments - (e.g in the case of "be show BUGID BUGID ..."). - """ - for option,value in option_value_pairs(options, parser): - if value == "--complete": - raise GetCompletions() - if len(bugid_args.keys()) > 0: - max_pos_arg = max(bugid_args.keys()) - else: - max_pos_arg = -1 - for pos,value in enumerate(args): - if value == "--complete": - filter = None - if pos in bugid_args: - filter = bugid_args[pos] - if pos > max_pos_arg and -1 in bugid_args: - filter = bugid_args[-1] - if filter != None: - bugshortnames = [] - try: - bd = bugdir.BugDir(from_disk=True, - manipulate_encodings=False) - bd.load_all_bugs() - bugs = [bug for bug in bd if filter(bug) == True] - bugshortnames = [bd.bug_shortname(bug) for bug in bugs] - except bugdir.NoBugDir: - pass - raise GetCompletions(bugshortnames) - raise GetCompletions() - -def complete_path(path): - """List possible path completions for path.""" - comps = glob.glob(path+"*") + glob.glob(path+"/*") - if len(comps) == 1 and os.path.isdir(comps[0]): - comps.extend(glob.glob(comps[0]+"/*")) - return comps - -def underlined(instring): - """Produces a version of a string that is underlined with '=' - - >>> underlined("Underlined String") - 'Underlined String\\n=================' - """ - - return "%s\n%s" % (instring, "="*len(instring)) - -def select_values(string, possible_values, name="unkown"): - """ - This function allows the user to select values from a list of - possible values. The default is to select all the values: - - >>> select_values(None, ['abc', 'def', 'hij']) - ['abc', 'def', 'hij'] - - The user selects values with a comma-separated limit_string. - Prepending a minus sign to such a list denotes blacklist mode: - - >>> select_values('-abc,hij', ['abc', 'def', 'hij']) - ['def'] - - Without the leading -, the selection is in whitelist mode: - - >>> select_values('abc,hij', ['abc', 'def', 'hij']) - ['abc', 'hij'] - - In either case, appropriate errors are raised if on of the - user-values is not in the list of possible values. The name - parameter lets you make the error message more clear: - - >>> select_values('-xyz,hij', ['abc', 'def', 'hij'], name="foobar") - Traceback (most recent call last): - ... - UserError: Invalid foobar xyz - ['abc', 'def', 'hij'] - >>> select_values('xyz,hij', ['abc', 'def', 'hij'], name="foobar") - Traceback (most recent call last): - ... - UserError: Invalid foobar xyz - ['abc', 'def', 'hij'] - """ - possible_values = list(possible_values) # don't alter the original - if string == None: - pass - elif string.startswith('-'): - blacklisted_values = set(string[1:].split(',')) - for value in blacklisted_values: - if value not in possible_values: - raise UserError('Invalid %s %s\n %s' - % (name, value, possible_values)) - possible_values.remove(value) - else: - whitelisted_values = string.split(',') - for value in whitelisted_values: - if value not in possible_values: - raise UserError('Invalid %s %s\n %s' - % (name, value, possible_values)) - possible_values = whitelisted_values - return possible_values - -def restrict_file_access(bugdir, path): - """ - Check that the file at path is inside bugdir.root. This is - important if you allow other users to execute becommands with your - username (e.g. if you're running be-handle-mail through your - ~/.procmailrc). If this check wasn't made, a user could e.g. - run - be commit -b ~/.ssh/id_rsa "Hack to expose ssh key" - which would expose your ssh key to anyone who could read the VCS - log. - """ - in_root = bugdir.vcs.path_in_root(path, bugdir.root) - if in_root == False: - raise UserError('file access restricted!\n %s not in %s' - % (path, bugdir.root)) - -def parse_id(id): - """ - Return (bug_id, comment_id) tuple. - Basically inverts Comment.comment_shortnames() - >>> parse_id('XYZ') - ('XYZ', None) - >>> parse_id('XYZ:123') - ('XYZ', ':123') - >>> parse_id('') - Traceback (most recent call last): - ... - UserError: invalid id ''. - >>> parse_id('::') - Traceback (most recent call last): - ... - UserError: invalid id '::'. - """ - if len(id) == 0: - raise UserError("invalid id '%s'." % id) - if id.count(':') > 1: - raise UserError("invalid id '%s'." % id) - elif id.count(':') == 1: - # Split shortname generated by Comment.comment_shortnames() - bug_id,comment_id = id.split(':') - comment_id = ':'+comment_id - else: - bug_id = id - comment_id = None - return (bug_id, comment_id) - -def bug_from_id(bdir, id): - """ - Exception translation for the command-line interface. - id can be either the bug shortname or the full uuid. - """ - try: - bug = bdir.bug_from_shortname(id) - except (bugdir.MultipleBugMatches, bugdir.NoBugMatches), e: - raise UserError(e.message) - return bug - -def bug_comment_from_id(bdir, id): - """ - Return (bug,comment) tuple matching shortname. id can be either - the bug/comment shortname or the full uuid. If there is no - comment part to the id, the returned comment is the bug's - .comment_root. - """ - bug_id,comment_id = parse_id(id) - try: - bug = bdir.bug_from_shortname(bug_id) - except (bugdir.MultipleBugMatches, bugdir.NoBugMatches), e: - raise UserError(e.message) - if comment_id == None: - comm = bug.comment_root - else: - #bug.load_comments(load_full=False) - try: - comm = bug.comment_root.comment_from_shortname(comment_id) - except comment.InvalidShortname, e: - raise UserError(e.message) - return (bug, comm) - -if libbe.TESTING == True: - suite = doctest.DocTestSuite() diff --git a/libbe/command/__init__.py b/libbe/command/__init__.py new file mode 100644 index 0000000..8c92e6f --- /dev/null +++ b/libbe/command/__init__.py @@ -0,0 +1,40 @@ +# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc. +# W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import base + +UserError = base.UserError +UnknownCommand = base.UnknownCommand +get_command = base.get_command +get_command_class = base.get_command_class +commands = base.commands +Option = base.Option +Argument = base.Argument +Command = base.Command +InputOutput = base.InputOutput +StdInputOutput = base.StdInputOutput +StringInputOutput = base.StringInputOutput +UnconnectedStorageGetter = base.UnconnectedStorageGetter +StorageCallbacks = base.StorageCallbacks +UserInterface = base.UserInterface + +__all__ = [UserError, UnknownCommand, + get_command, get_command_class, commands, + Option, Argument, Command, + InputOutput, StdInputOutput, StringInputOutput, + StorageCallbacks, UnconnectedStorageGetter, + UserInterface] diff --git a/libbe/command/assign.py b/libbe/command/assign.py new file mode 100644 index 0000000..b37b9fd --- /dev/null +++ b/libbe/command/assign.py @@ -0,0 +1,98 @@ +# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc. +# Gianluca Montecchi <gian@grys.it> +# Marien Zwart <marienz@gentoo.org> +# Thomas Gerigk <tgerigk@gmx.de> +# W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import libbe +import libbe.command +import libbe.command.util + + +class Assign (libbe.command.Command): + """Assign an individual or group to fix a bug + + >>> import sys + >>> import libbe.bugdir + >>> bd = libbe.bugdir.SimpleBugDir(memory=False) + >>> io = libbe.command.StringInputOutput() + >>> io.stdout = sys.stdout + >>> ui = libbe.command.UserInterface(io=io) + >>> ui.storage_callbacks.set_storage(bd.storage) + >>> cmd = Assign(ui=ui) + + >>> bd.bug_from_uuid('a').assigned is None + True + >>> ui._user_id = u'Fran\xe7ois' + >>> ret = ui.run(cmd, args=['-', '/a']) + >>> bd.flush_reload() + >>> bd.bug_from_uuid('a').assigned + u'Fran\\xe7ois' + + >>> ret = ui.run(cmd, args=['someone', '/a', '/b']) + >>> bd.flush_reload() + >>> bd.bug_from_uuid('a').assigned + 'someone' + >>> bd.bug_from_uuid('b').assigned + 'someone' + + >>> ret = ui.run(cmd, args=['none', '/a']) + >>> bd.flush_reload() + >>> bd.bug_from_uuid('a').assigned is None + True + >>> ui.cleanup() + >>> bd.cleanup() + """ + name = 'assign' + + def __init__(self, *args, **kwargs): + libbe.command.Command.__init__(self, *args, **kwargs) + self.args.extend([ + libbe.command.Argument( + name='assigned', metavar='ASSIGNED', default=None, + completion_callback=libbe.command.util.complete_assigned), + libbe.command.Argument( + name='bug-id', metavar='BUG-ID', default=None, + repeatable=True, + completion_callback=libbe.command.util.complete_bug_id), + ]) + + def _run(self, **params): + assigned = params['assigned'] + if assigned == 'none': + assigned = None + elif assigned == '-': + assigned = self._get_user_id() + bugdir = self._get_bugdir() + for bug_id in params['bug-id']: + bug,dummy_comment = \ + libbe.command.util.bug_comment_from_user_id(bugdir, bug_id) + if bug.assigned != assigned: + bug.assigned = assigned + return 0 + + def _long_help(self): + return """ +Assign a person to fix a bug. + +Assigneds should be the person's Bugs Everywhere identity, the same +string that appears in Creator fields. + +Special assigned strings: + "-" assign the bug to yourself + "none" un-assigns the bug +""" diff --git a/libbe/command/base.py b/libbe/command/base.py new file mode 100644 index 0000000..2f0ccc6 --- /dev/null +++ b/libbe/command/base.py @@ -0,0 +1,511 @@ +# Copyright + +import codecs +import optparse +import os.path +import StringIO +import sys + +import libbe +import libbe.storage +import libbe.ui.util.user +import libbe.util.encoding +import libbe.util.plugin + +class UserError(Exception): + pass + +class UnknownCommand(UserError): + def __init__(self, cmd): + Exception.__init__(self, "Unknown command '%s'" % cmd) + self.cmd = cmd + +def get_command(command_name): + """Retrieves the module for a user command + + >>> try: + ... get_command('asdf') + ... except UnknownCommand, e: + ... print e + Unknown command 'asdf' + >>> repr(get_command('list')).startswith("<module 'libbe.command.list' from ") + True + """ + try: + cmd = libbe.util.plugin.import_by_name( + 'libbe.command.%s' % command_name.replace("-", "_")) + except ImportError, e: + raise UnknownCommand(command_name) + return cmd + +def get_command_class(module=None, command_name=None): + """Retrieves a command class from a module. + + >>> import_xml_mod = get_command('import-xml') + >>> import_xml = get_command_class(import_xml_mod, 'import-xml') + >>> repr(import_xml) + "<class 'libbe.command.import_xml.Import_XML'>" + >>> import_xml = get_command_class(command_name='import-xml') + >>> repr(import_xml) + "<class 'libbe.command.import_xml.Import_XML'>" + """ + if module == None: + module = get_command(command_name) + try: + cname = command_name.capitalize().replace('-', '_') + cmd = getattr(module, cname) + except ImportError, e: + raise UnknownCommand(command_name) + return cmd + +def commands(): + for modname in libbe.util.plugin.modnames('libbe.command'): + if modname not in ['base', 'util']: + yield modname + +class CommandInput (object): + def __init__(self, name, help=''): + self.name = name + self.help = help + + def __str__(self): + return '<%s %s>' % (self.__class__.__name__, self.name) + + def __repr__(self): + return self.__str__() + +class Argument (CommandInput): + def __init__(self, metavar=None, default=None, type='string', + optional=False, repeatable=False, + completion_callback=None, *args, **kwargs): + CommandInput.__init__(self, *args, **kwargs) + self.metavar = metavar + self.default = default + self.type = type + self.optional = optional + self.repeatable = repeatable + self.completion_callback = completion_callback + if self.metavar == None: + self.metavar = self.name.upper() + +class Option (CommandInput): + def __init__(self, callback=None, short_name=None, arg=None, + *args, **kwargs): + CommandInput.__init__(self, *args, **kwargs) + self.callback = callback + self.short_name = short_name + self.arg = arg + if self.arg == None and self.callback == None: + # use an implicit boolean argument + self.arg = Argument(name=self.name, help=self.help, + default=False, type='bool') + self.validate() + + def validate(self): + if self.arg == None: + assert self.callback != None, self.name + return + assert self.callback == None, '%s: %s' (self.name, self.callback) + assert self.arg.name == self.name, \ + 'Name missmatch: %s != %s' % (self.arg.name, self.name) + assert self.arg.optional == False, self.name + assert self.arg.repeatable == False, self.name + + def __str__(self): + return '--%s' % self.name + + def __repr__(self): + return '<Option %s>' % self.__str__() + +class _DummyParser (optparse.OptionParser): + def __init__(self, command): + optparse.OptionParser.__init__(self) + self.remove_option('-h') + self.command = command + self._command_opts = [] + for option in self.command.options: + self._add_option(option) + + def _add_option(self, option): + # from libbe.ui.command_line.CmdOptionParser._add_option + option.validate() + long_opt = '--%s' % option.name + if option.short_name != None: + short_opt = '-%s' % option.short_name + assert '_' not in option.name, \ + 'Non-reconstructable option name %s' % option.name + kwargs = {'dest':option.name.replace('-', '_'), + 'help':option.help} + if option.arg == None or option.arg.type == 'bool': + kwargs['action'] = 'store_true' + kwargs['metavar'] = None + kwargs['default'] = False + else: + kwargs['type'] = option.arg.type + kwargs['action'] = 'store' + kwargs['metavar'] = option.arg.metavar + kwargs['default'] = option.arg.default + if option.short_name != None: + opt = optparse.Option(short_opt, long_opt, **kwargs) + else: + opt = optparse.Option(long_opt, **kwargs) + #option.takes_value = lambda : option.arg != None + opt._option = option + self._command_opts.append(opt) + self.add_option(opt) + +class OptionFormatter (optparse.IndentedHelpFormatter): + def __init__(self, command): + optparse.IndentedHelpFormatter.__init__(self) + self.command = command + def option_help(self): + # based on optparse.OptionParser.format_option_help() + parser = _DummyParser(self.command) + self.store_option_strings(parser) + ret = [] + ret.append(self.format_heading('Options')) + self.indent() + for option in parser._command_opts: + ret.append(self.format_option(option)) + ret.append('\n') + self.dedent() + # Drop the last '\n', or the header if no options or option groups: + return ''.join(ret[:-1]) + +class Command (object): + """One-line command description here. + + >>> c = Command() + >>> print c.help() + usage: be command [options] + <BLANKLINE> + Options: + -h, --help Print a help message. + <BLANKLINE> + --complete Print a list of possible completions. + <BLANKLINE> + A detailed help message. + """ + + name = 'command' + + def __init__(self, ui=None): + self.ui = ui # calling user-interface + self.status = None + self.result = None + self.restrict_file_access = True + self.options = [ + Option(name='help', short_name='h', + help='Print a help message.', + callback=self.help), + Option(name='complete', + help='Print a list of possible completions.', + callback=self.complete), + ] + self.args = [] + + def run(self, options=None, args=None): + self.status = 1 # in case we raise an exception + params = self._parse_options_args(options, args) + if params['help'] == True: + pass + else: + params.pop('help') + if params['complete'] != None: + pass + else: + params.pop('complete') + + self.status = self._run(**params) + return self.status + + def _parse_options_args(self, options=None, args=None): + if options == None: + options = {} + if args == None: + args = [] + params = {} + for option in self.options: + assert option.name not in params, params[option.name] + if option.name in options: + params[option.name] = options.pop(option.name) + elif option.arg != None: + params[option.name] = option.arg.default + else: # non-arg options are flags, set to default flag value + params[option.name] = False + assert 'user-id' not in params, params['user-id'] + if 'user-id' in options: + self._user_id = options.pop('user-id') + if len(options) > 0: + raise UserError, 'Invalid option passed to command %s:\n %s' \ + % (self.name, '\n '.join(['%s: %s' % (k,v) + for k,v in options.items()])) + in_optional_args = False + for i,arg in enumerate(self.args): + if arg.repeatable == True: + assert i == len(self.args)-1, arg.name + if in_optional_args == True: + assert arg.optional == True, arg.name + else: + in_optional_args = arg.optional + if i < len(args): + if arg.repeatable == True: + params[arg.name] = [args[i]] + else: + params[arg.name] = args[i] + else: # no value given + assert in_optional_args == True, arg.name + params[arg.name] = arg.default + if len(args) > len(self.args): # add some additional repeats + assert self.args[-1].repeatable == True, self.args[-1].name + params[self.args[-1].name].extend(args[len(self.args):]) + return params + + def _run(self, **kwargs): + raise NotImplementedError + + def help(self, *args): + return '\n\n'.join([self.usage(), + self._option_help(), + self._long_help().rstrip('\n')]) + + def usage(self): + usage = 'usage: be %s [options]' % self.name + num_optional = 0 + for arg in self.args: + usage += ' ' + if arg.optional == True: + usage += '[' + num_optional += 1 + usage += arg.metavar + if arg.repeatable == True: + usage += ' ...' + usage += ']'*num_optional + return usage + + def _option_help(self): + o = OptionFormatter(self) + return o.option_help().strip('\n') + + def _long_help(self): + return "A detailed help message." + + def complete(self, argument=None, fragment=None): + if argument == None: + ret = ['--%s' % o.name for o in self.options] + if len(self.args) > 0 and self.args[0].completion_callback != None: + ret.extend(self.args[0].completion_callback(self, argument, fragment)) + return ret + elif argument.completion_callback != None: + # finish a particular argument + return argument.completion_callback(self, argument, fragment) + return [] # the particular argument doesn't supply completion info + + def _check_restricted_access(self, storage, path): + """ + Check that the file at path is inside bugdir.root. This is + important if you allow other users to execute becommands with + your username (e.g. if you're running be-handle-mail through + your ~/.procmailrc). If this check wasn't made, a user could + e.g. run + be commit -b ~/.ssh/id_rsa "Hack to expose ssh key" + which would expose your ssh key to anyone who could read the + VCS log. + + >>> class DummyStorage (object): pass + >>> s = DummyStorage() + >>> s.repo = os.path.expanduser('~/x/') + >>> c = Command() + >>> try: + ... c._check_restricted_access(s, os.path.expanduser('~/.ssh/id_rsa')) + ... except UserError, e: + ... assert str(e).startswith('file access restricted!'), str(e) + ... print 'we got the expected error' + we got the expected error + >>> c._check_restricted_access(s, os.path.expanduser('~/x')) + >>> c._check_restricted_access(s, os.path.expanduser('~/x/y')) + >>> c.restrict_file_access = False + >>> c._check_restricted_access(s, os.path.expanduser('~/.ssh/id_rsa')) + """ + if self.restrict_file_access == True: + path = os.path.abspath(path) + repo = os.path.abspath(storage.repo).rstrip(os.path.sep) + if path == repo or path.startswith(repo+os.path.sep): + return + raise UserError('file access restricted!\n %s not in %s' + % (path, repo)) + + def cleanup(self): + pass + +class InputOutput (object): + def __init__(self, stdin=None, stdout=None): + self.stdin = stdin + self.stdout = stdout + + def setup_command(self, command): + if not hasattr(self.stdin, 'encoding'): + self.stdin.encoding = libbe.util.encoding.get_input_encoding() + if not hasattr(self.stdout, 'encoding'): + self.stdout.encoding = libbe.util.encoding.get_output_encoding() + command.stdin = self.stdin + command.stdin.encoding = self.stdin.encoding + command.stdout = self.stdout + command.stdout.encoding = self.stdout.encoding + + def cleanup(self): + pass + +class StdInputOutput (InputOutput): + def __init__(self, input_encoding=None, output_encoding=None): + stdin,stdout = self._get_io(input_encoding, output_encoding) + InputOutput.__init__(self, stdin, stdout) + + def _get_io(self, input_encoding=None, output_encoding=None): + if input_encoding == None: + input_encoding = libbe.util.encoding.get_input_encoding() + if output_encoding == None: + output_encoding = libbe.util.encoding.get_output_encoding() + stdin = codecs.getwriter(input_encoding)(sys.stdin) + stdin.encoding = input_encoding + stdout = codecs.getwriter(output_encoding)(sys.stdout) + stdout.encoding = output_encoding + return (stdin, stdout) + +class StringInputOutput (InputOutput): + """ + >>> s = StringInputOutput() + >>> s.set_stdin('hello') + >>> s.stdin.read() + 'hello' + >>> s.stdin.read() + >>> print >> s.stdout, 'goodbye' + >>> s.get_stdout() + 'goodbye\n' + >>> s.get_stdout() + '' + + Also works with unicode strings + + >>> s.set_stdin(u'hello') + >>> s.stdin.read() + u'hello' + >>> print >> s.stdout, u'goodbye' + >>> s.get_stdout() + u'goodbye\n' + """ + def __init__(self): + stdin = StringIO.StringIO() + stdin.encoding = 'utf-8' + stdout = StringIO.StringIO() + stdout.encoding = 'utf-8' + InputOutput.__init__(self, stdin, stdout) + + def set_stdin(self, stdin_string): + self.stdin = StringIO.StringIO(stdin_string) + + def get_stdout(self): + ret = self.stdout.getvalue() + self.stdout = StringIO.StringIO() # clear stdout for next read + self.stdin.encoding = 'utf-8' + return ret + +class UnconnectedStorageGetter (object): + def __init__(self, location): + self.location = location + + def __call__(self): + return libbe.storage.get_storage(self.location) + +class StorageCallbacks (object): + def __init__(self, location=None): + if location == None: + location = '.' + self.location = location + self._get_unconnected_storage = UnconnectedStorageGetter(location) + + def setup_command(self, command): + command._get_unconnected_storage = self.get_unconnected_storage + command._get_storage = self.get_storage + command._get_bugdir = self.get_bugdir + + def get_unconnected_storage(self): + """ + Callback for use by commands that need it. + + The returned Storage instance is may actually be connected, + but commands that make use of the returned value should only + make use of non-connected Storage methods. This is mainly + intended for the init command, which calls Storage.init(). + """ + if not hasattr(self, '_unconnected_storage'): + if self._get_unconnected_storage == None: + raise NotImplementedError + self._unconnected_storage = self._get_unconnected_storage() + return self._unconnected_storage + + def set_unconnected_storage(self, unconnected_storage): + self._unconnected_storage = unconnected_storage + + def get_storage(self): + """Callback for use by commands that need it.""" + if not hasattr(self, '_storage'): + self._storage = self.get_unconnected_storage() + self._storage.connect() + version = self._storage.storage_version() + if version != libbe.storage.STORAGE_VERSION: + raise libbe.storage.InvalidStorageVersion(version) + return self._storage + + def set_storage(self, storage): + self._storage = storage + + def get_bugdir(self): + """Callback for use by commands that need it.""" + if not hasattr(self, '_bugdir'): + self._bugdir = libbe.bugdir.BugDir(self.get_storage(), + from_storage=True) + return self._bugdir + + def set_bugdir(self, bugdir): + self._bugdir = bugdir + + def cleanup(self): + if hasattr(self, '_storage'): + self._storage.disconnect() + +class UserInterface (object): + def __init__(self, io=None, location=None): + if io == None: + io = StringInputOutput() + self.io = io + self.storage_callbacks = StorageCallbacks(location) + self.restrict_file_access = True + + def help(self): + raise NotImplementedError + + def run(self, command, options=None, args=None): + self.setup_command(command) + return command.run(options, args) + + def setup_command(self, command): + if command.ui == None: + command.ui = self + if self.io != None: + self.io.setup_command(command) + if self.storage_callbacks != None: + self.storage_callbacks.setup_command(command) + command.restrict_file_access = self.restrict_file_access + command._get_user_id = self._get_user_id + + def _get_user_id(self): + """Callback for use by commands that need it.""" + if not hasattr(self, '_user_id'): + self._user_id = libbe.ui.util.user.get_user_id( + self.storage_callbacks.get_storage()) + return self._user_id + + def cleanup(self): + self.storage_callbacks.cleanup() + self.io.cleanup() diff --git a/libbe/command/close.py b/libbe/command/close.py new file mode 100644 index 0000000..026c605 --- /dev/null +++ b/libbe/command/close.py @@ -0,0 +1,63 @@ +# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc. +# Gianluca Montecchi <gian@grys.it> +# Marien Zwart <marienz@gentoo.org> +# Thomas Gerigk <tgerigk@gmx.de> +# W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Close a bug""" +from libbe import cmdutil, bugdir +__desc__ = __doc__ + +def execute(args, manipulate_encodings=True, restrict_file_access=False, + dir="."): + """ + >>> from libbe import bugdir + >>> import os + >>> bd = bugdir.SimpleBugDir() + >>> os.chdir(bd.root) + >>> print bd.bug_from_shortname("a").status + open + >>> execute(["a"], manipulate_encodings=False) + >>> bd._clear_bugs() + >>> print bd.bug_from_shortname("a").status + closed + >>> bd.cleanup() + """ + parser = get_parser() + options, args = parser.parse_args(args) + cmdutil.default_complete(options, args, parser, + bugid_args={0: lambda bug : bug.active==True}) + if len(args) == 0: + raise cmdutil.UsageError("Please specify a bug id.") + if len(args) > 1: + raise cmdutil.UsageError("Too many arguments.") + bd = bugdir.BugDir(from_disk=True, + manipulate_encodings=manipulate_encodings, + root=dir) + bug = cmdutil.bug_from_id(bd, args[0]) + bug.status = "closed" + bd.save() + +def get_parser(): + parser = cmdutil.CmdOptionParser("be close BUG-ID") + return parser + +longhelp=""" +Close the bug identified by BUG-ID. +""" + +def help(): + return get_parser().help_str() + longhelp diff --git a/libbe/command/comment.py b/libbe/command/comment.py new file mode 100644 index 0000000..7b859a8 --- /dev/null +++ b/libbe/command/comment.py @@ -0,0 +1,157 @@ +# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc. +# Gianluca Montecchi <gian@grys.it> +# W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import os +import sys + +import libbe +import libbe.command +import libbe.command.util +import libbe.comment +import libbe.ui.util.editor +import libbe.util.id + + +class Comment (libbe.command.Command): + """Add a comment to a bug + + >>> import time + >>> import libbe.bugdir + >>> bd = libbe.bugdir.SimpleBugDir(memory=False) + >>> io = libbe.command.StringInputOutput() + >>> io.stdout = sys.stdout + >>> ui = libbe.command.UserInterface(io=io) + >>> ui.storage_callbacks.set_storage(bd.storage) + >>> cmd = Comment(ui=ui) + + >>> ui._user_id = u'Fran\\xe7ois' + >>> ret = ui.run(cmd, args=['/a', 'This is a comment about a']) + >>> bd.flush_reload() + >>> bug = bd.bug_from_uuid('a') + >>> bug.load_comments(load_full=False) + >>> comment = bug.comment_root[0] + >>> comment.id.storage() == comment.uuid + True + >>> print comment.body + This is a comment about a + <BLANKLINE> + >>> comment.author + u'Fran\\xe7ois' + >>> comment.time <= int(time.time()) + True + >>> comment.in_reply_to is None + True + + >>> if 'EDITOR' in os.environ: + ... del os.environ['EDITOR'] + >>> ui._user_id = u'Frank' + >>> ret = ui.run(cmd, args=['/b']) + Traceback (most recent call last): + UserError: No comment supplied, and EDITOR not specified. + + >>> os.environ['EDITOR'] = "echo 'I like cheese' > " + >>> ret = ui.run(cmd, args=['/b']) + >>> bd.flush_reload() + >>> bug = bd.bug_from_uuid('b') + >>> bug.load_comments(load_full=False) + >>> comment = bug.comment_root[0] + >>> print comment.body + I like cheese + <BLANKLINE> + >>> ui.cleanup() + >>> bd.cleanup() + """ + name = 'comment' + + def __init__(self, *args, **kwargs): + libbe.command.Command.__init__(self, *args, **kwargs) + self.options.extend([ + libbe.command.Option(name='author', short_name='a', + help='Set the comment author', + arg=libbe.command.Argument( + name='author', metavar='AUTHOR')), + libbe.command.Option(name='alt-id', + help='Set an alternate comment ID', + arg=libbe.command.Argument( + name='alt-id', metavar='ID')), + libbe.command.Option(name='content-type', short_name='c', + help='Set comment content-type (e.g. text/plain)', + arg=libbe.command.Argument(name='content-type', + metavar='MIME')), + ]) + self.args.extend([ + libbe.command.Argument( + name='id', metavar='ID', default=None, + completion_callback=libbe.command.util.complete_bug_comment_id), + libbe.command.Argument( + name='comment', metavar='COMMENT', default=None, + optional=True, + completion_callback=libbe.command.util.complete_assigned), + ]) + + def _run(self, **params): + bugdir = self._get_bugdir() + bug,parent = \ + libbe.command.util.bug_comment_from_user_id(bugdir, params['id']) + if params['comment'] == None: + # try to launch an editor for comment-body entry + try: + if parent == bug.comment_root: + parent_body = bug.summary+'\n' + else: + parent_body = parent.body + estr = 'Please enter your comment above\n\n> %s\n' \ + % ('\n> '.join(parent_body.splitlines())) + body = libbe.ui.util.editor.editor_string(estr) + except libbe.ui.util.editor.CantFindEditor, e: + raise libbe.command.UserError( + 'No comment supplied, and EDITOR not specified.') + if body is None: + raise libbe.command.UserError('No comment entered.') + elif params['comment'] == '-': # read body from stdin + binary = not (params['content-type'] == None + or params['content-type'].startswith("text/")) + if not binary: + body = self.stdin.read() + if not body.endswith('\n'): + body += '\n' + else: # read-in without decoding + body = sys.stdin.read() + else: # body given on command line + body = params['comment'] + if not body.endswith('\n'): + body+='\n' + if params['author'] == None: + params['author'] = self._get_user_id() + + new = parent.new_reply(body=body) + for key in ['alt-id', 'author', 'content-type']: + if params[key] != None: + setattr(new, key, params[key]) + return 0 + + def _long_help(self): + return """ +To add a comment to a bug, use the bug ID as the argument. To reply +to another comment, specify the comment name (as shown in "be show" +output). COMMENT, if specified, should be either the text of your +comment or "-", in which case the text will be read from stdin. If +you do not specify a COMMENT, $EDITOR is used to launch an editor. If +COMMENT is unspecified and EDITOR is not set, no comment will be +created. +""" diff --git a/libbe/command/commit.py b/libbe/command/commit.py new file mode 100644 index 0000000..4938c44 --- /dev/null +++ b/libbe/command/commit.py @@ -0,0 +1,93 @@ +# Copyright (C) 2009 W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import sys + +import libbe +import libbe.bugdir +import libbe.command +import libbe.command.util +import libbe.storage +import libbe.ui.util.editor + + +class Commit (libbe.command.Command): + """Commit the currently pending changes to the repository + + >>> import sys + >>> import libbe.bugdir + >>> bd = libbe.bugdir.SimpleBugDir(memory=False, versioned=True) + >>> io = libbe.command.StringInputOutput() + >>> io.stdout = sys.stdout + >>> ui = libbe.command.UserInterface(io=io) + >>> ui.storage_callbacks.set_storage(bd.storage) + >>> cmd = Commit(ui=ui) + + >>> bd.extra_strings = ['hi there'] + >>> bd.flush_reload() + >>> ui.run(cmd, {'user-id':'Joe'}, ['Making a commit']) # doctest: +ELLIPSIS + Committed ... + >>> ui.cleanup() + >>> bd.cleanup() + """ + name = 'commit' + + def __init__(self, *args, **kwargs): + libbe.command.Command.__init__(self, *args, **kwargs) + self.options.extend([ + libbe.command.Option(name='body', short_name='b', + help='Provide the detailed body for the commit message. In the special case that FILE == "EDITOR", spawn an editor to enter the body text (in which case you cannot use stdin for the summary)', + arg=libbe.command.Argument(name='body', metavar='FILE', + completion_callback=libbe.command.util.complete_path)), + libbe.command.Option(name='allow-empty', short_name='a', + help='Allow empty commits'), + ]) + self.args.extend([ + libbe.command.Argument( + name='comment', metavar='COMMENT', default=None), + ]) + + def _run(self, **params): + if params['comment'] == '-': # read summary from stdin + assert params['body'] != 'EDITOR', \ + 'Cannot spawn and editor when the summary is using stdin.' + summary = sys.stdin.readline() + else: + summary = params['comment'] + storage = self._get_storage() + if params['body'] == None: + body = None + elif params['body'] == 'EDITOR': + body = libbe.ui.util.editor.editor_string( + 'Please enter your commit message above') + else: + self._check_restricted_access(storage, params['body']) + body = libbe.util.encoding.get_file_contents( + params['body'], decode=True) + try: + revision = storage.commit(summary, body=body, + allow_empty=params['allow-empty']) + print >> self.stdout, 'Committed %s' % revision + except libbe.storage.EmptyCommit, e: + print >> self.stdout, e + return 1 + + def _long_help(self): + return """ +Commit the current repository status. The summary specified on the +commandline is a string (only one line) that describes the commit +briefly or "-", in which case the string will be read from stdin. +""" diff --git a/libbe/command/depend.py b/libbe/command/depend.py new file mode 100644 index 0000000..9068f3a --- /dev/null +++ b/libbe/command/depend.py @@ -0,0 +1,408 @@ +# Copyright (C) 2009 Gianluca Montecchi <gian@grys.it> +# W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import copy +import os + +import libbe +import libbe.bug +import libbe.command +import libbe.command.util +import libbe.util.tree + +BLOCKS_TAG="BLOCKS:" +BLOCKED_BY_TAG="BLOCKED-BY:" + +class BrokenLink (Exception): + def __init__(self, blocked_bug, blocking_bug, blocks=True): + if blocks == True: + msg = "Missing link: %s blocks %s" \ + % (blocking_bug.uuid, blocked_bug.uuid) + else: + msg = "Missing link: %s blocked by %s" \ + % (blocked_bug.uuid, blocking_bug.uuid) + Exception.__init__(self, msg) + self.blocked_bug = blocked_bug + self.blocking_bug = blocking_bug + +class Depend (libbe.command.Command): + """Add/remove bug dependencies + + >>> import sys + >>> import libbe.bugdir + >>> bd = libbe.bugdir.SimpleBugDir(memory=False) + >>> io = libbe.command.StringInputOutput() + >>> io.stdout = sys.stdout + >>> ui = libbe.command.UserInterface(io=io) + >>> ui.storage_callbacks.set_storage(bd.storage) + >>> cmd = Depend(ui=ui) + + >>> ret = ui.run(cmd, args=['/a', '/b']) + a blocked by: + b + >>> ret = ui.run(cmd, args=['/a']) + a blocked by: + b + >>> ret = ui.run(cmd, {'show-status':True}, ['/a']) # doctest: +NORMALIZE_WHITESPACE + a blocked by: + b closed + >>> ret = ui.run(cmd, args=['/b', '/a']) + b blocked by: + a + b blocks: + a + >>> ret = ui.run(cmd, {'show-status':True}, ['/a']) # doctest: +NORMALIZE_WHITESPACE + a blocked by: + b closed + a blocks: + b closed + >>> ret = ui.run(cmd, {'repair':True}) + >>> ret = ui.run(cmd, {'remove':True}, ['/b', '/a']) + b blocks: + a + >>> ret = ui.run(cmd, {'remove':True}, ['/a', '/b']) + >>> ui.cleanup() + >>> bd.cleanup() + """ + name = 'depend' + + def __init__(self, *args, **kwargs): + libbe.command.Command.__init__(self, *args, **kwargs) + self.options.extend([ + libbe.command.Option(name='remove', short_name='r', + help='Remove dependency (instead of adding it)'), + libbe.command.Option(name='show-status', short_name='s', + help='Show status of blocking bugs'), + libbe.command.Option(name='status', + help='Only show bugs matching the STATUS specifier', + arg=libbe.command.Argument( + name='status', metavar='STATUS', default=None, + completion_callback=libbe.command.util.complete_status)), + libbe.command.Option(name='severity', + help='Only show bugs matching the SEVERITY specifier', + arg=libbe.command.Argument( + name='severity', metavar='SEVERITY', default=None, + completion_callback=libbe.command.util.complete_severity)), + libbe.command.Option(name='tree-depth', short_name='t', + help='Print dependency tree rooted at BUG-ID with DEPTH levels of both blockers and blockees. Set DEPTH <= 0 to disable the depth limit.', + arg=libbe.command.Argument( + name='tree-depth', metavar='INT', type='int', + completion_callback=libbe.command.util.complete_severity)), + libbe.command.Option(name='repair', + help='Check for and repair one-way links'), + ]) + self.args.extend([ + libbe.command.Argument( + name='bug-id', metavar='BUG-ID', default=None, + optional=True, + completion_callback=libbe.command.util.complete_bug_id), + libbe.command.Argument( + name='blocking-bug-id', metavar='BUG-ID', default=None, + optional=True, + completion_callback=libbe.command.util.complete_bug_id), + ]) + + def _run(self, **params): + if params['repair'] == True and params['bug-id'] != None: + raise libbe.command.UsageError( + 'No arguments with --repair calls.') + if params['repair'] == False and params['bug-id'] == None: + raise libbe.command.UsageError( + 'Must specify either --repair or a BUG-ID') + if params['tree-depth'] != None \ + and params['blocking-bug-id'] != None: + raise libbe.command.UsageError( + 'Only one bug id used in tree mode.') + bugdir = self._get_bugdir() + if params['repair'] == True: + good,fixed,broken = check_dependencies(bugdir, repair_broken_links=True) + assert len(broken) == 0, broken + if len(fixed) > 0: + print >> self.stdout, 'Fixed the following links:' + print >> self.stdout, \ + '\n'.join(['%s |-- %s' % (blockee.uuid, blocker.uuid) + for blockee,blocker in fixed]) + return 0 + allowed_status_values = \ + libbe.command.util.select_values( + params['status'], libbe.bug.status_values) + allowed_severity_values = \ + libbe.command.util.select_values( + params['severity'], libbe.bug.severity_values) + + bugA, dummy_comment = libbe.command.util.bug_comment_from_user_id( + bugdir, params['bug-id']) + + if params['tree-depth'] != None: + dtree = DependencyTree(bugdir, bugA, params['tree-depth'], + allowed_status_values, + allowed_severity_values) + if len(dtree.blocked_by_tree()) > 0: + print >> self.stdout, '%s blocked by:' % bugA.uuid + for depth,node in dtree.blocked_by_tree().thread(): + if depth == 0: continue + print >> self.stdout, \ + '%s%s' % (' '*(depth), + node.bug.string(shortlist=True)) + if len(dtree.blocks_tree()) > 0: + print >> self.stdout, '%s blocks:' % bugA.uuid + for depth,node in dtree.blocks_tree().thread(): + if depth == 0: continue + print >> self.stdout, \ + '%s%s' % (' '*(depth), + node.bug.string(shortlist=True)) + return 0 + + if params['blocking-bug-id'] != None: + bugB,dummy_comment = libbe.command.util.bug_comment_from_user_id( + bugdir, params['blocking-bug-id']) + if params['remove'] == True: + remove_block(bugA, bugB) + else: # add the dependency + add_block(bugA, bugB) + + blocked_by = get_blocked_by(bugdir, bugA) + if len(blocked_by) > 0: + print >> self.stdout, '%s blocked by:' % bugA.uuid + if params['show-status'] == True: + print >> self.stdout, \ + '\n'.join(['%s\t%s' % (_bug.uuid, _bug.status) + for _bug in blocked_by]) + else: + print >> self.stdout, \ + '\n'.join([_bug.uuid for _bug in blocked_by]) + blocks = get_blocks(bugdir, bugA) + if len(blocks) > 0: + print >> self.stdout, '%s blocks:' % bugA.uuid + if params['show-status'] == True: + print >> self.stdout, \ + '\n'.join(['%s\t%s' % (_bug.uuid, _bug.status) + for _bug in blocks]) + else: + print >> self.stdout, \ + '\n'.join([_bug.uuid for _bug in blocks]) + return 0 + + def _long_help(self): + return """ +Set a dependency with the second bug (B) blocking the first bug (A). +If bug B is not specified, just print a list of bugs blocking (A). + +To search for bugs blocked by a particular bug, try + $ be list --extra-strings BLOCKED-BY:<your-bug-uuid> + +The --status and --severity options allow you to either blacklist or +whitelist values, for example + $ be list --status open,assigned +will only follow and print dependencies with open or assigned status. +You select blacklist mode by starting the list with a minus sign, for +example + $ be list --severity -target +which will only follow and print dependencies with non-target severity. + +If neither bug A nor B is specified, check for and repair the missing +side of any one-way links. + +The "|--" symbol in the repair-mode output is inspired by the +"negative feedback" arrow common in biochemistry. See, for example + http://www.nature.com/nature/journal/v456/n7223/images/nature07513-f5.0.jpg +""" + +# internal helper functions + +def _generate_blocks_string(blocked_bug): + return '%s%s' % (BLOCKS_TAG, blocked_bug.uuid) + +def _generate_blocked_by_string(blocking_bug): + return '%s%s' % (BLOCKED_BY_TAG, blocking_bug.uuid) + +def _parse_blocks_string(string): + assert string.startswith(BLOCKS_TAG) + return string[len(BLOCKS_TAG):] + +def _parse_blocked_by_string(string): + assert string.startswith(BLOCKED_BY_TAG) + return string[len(BLOCKED_BY_TAG):] + +def _add_remove_extra_string(bug, string, add): + estrs = bug.extra_strings + if add == True: + estrs.append(string) + else: # remove the string + estrs.remove(string) + bug.extra_strings = estrs # reassign to notice change + +def _get_blocks(bug): + uuids = [] + for line in bug.extra_strings: + if line.startswith(BLOCKS_TAG): + uuids.append(_parse_blocks_string(line)) + return uuids + +def _get_blocked_by(bug): + uuids = [] + for line in bug.extra_strings: + if line.startswith(BLOCKED_BY_TAG): + uuids.append(_parse_blocked_by_string(line)) + return uuids + +def _repair_one_way_link(blocked_bug, blocking_bug, blocks=None): + if blocks == True: # add blocks link + blocks_string = _generate_blocks_string(blocked_bug) + _add_remove_extra_string(blocking_bug, blocks_string, add=True) + else: # add blocked by link + blocked_by_string = _generate_blocked_by_string(blocking_bug) + _add_remove_extra_string(blocked_bug, blocked_by_string, add=True) + +# functions exposed to other modules + +def add_block(blocked_bug, blocking_bug): + blocked_by_string = _generate_blocked_by_string(blocking_bug) + _add_remove_extra_string(blocked_bug, blocked_by_string, add=True) + blocks_string = _generate_blocks_string(blocked_bug) + _add_remove_extra_string(blocking_bug, blocks_string, add=True) + +def remove_block(blocked_bug, blocking_bug): + blocked_by_string = _generate_blocked_by_string(blocking_bug) + _add_remove_extra_string(blocked_bug, blocked_by_string, add=False) + blocks_string = _generate_blocks_string(blocked_bug) + _add_remove_extra_string(blocking_bug, blocks_string, add=False) + +def get_blocks(bugdir, bug): + """ + Return a list of bugs that the given bug blocks. + """ + blocks = [] + for uuid in _get_blocks(bug): + blocks.append(bugdir.bug_from_uuid(uuid)) + return blocks + +def get_blocked_by(bugdir, bug): + """ + Return a list of bugs blocking the given bug. + """ + blocked_by = [] + for uuid in _get_blocked_by(bug): + blocked_by.append(bugdir.bug_from_uuid(uuid)) + return blocked_by + +def check_dependencies(bugdir, repair_broken_links=False): + """ + Check that links are bi-directional for all bugs in bugdir. + + >>> import libbe.bugdir + >>> bd = libbe.bugdir.SimpleBugDir() + >>> a = bd.bug_from_uuid("a") + >>> b = bd.bug_from_uuid("b") + >>> blocked_by_string = _generate_blocked_by_string(b) + >>> _add_remove_extra_string(a, blocked_by_string, add=True) + >>> good,repaired,broken = check_dependencies(bd, repair_broken_links=False) + >>> good + [] + >>> repaired + [] + >>> broken + [(Bug(uuid='a'), Bug(uuid='b'))] + >>> _get_blocks(b) + [] + >>> good,repaired,broken = check_dependencies(bd, repair_broken_links=True) + >>> _get_blocks(b) + ['a'] + >>> good + [] + >>> repaired + [(Bug(uuid='a'), Bug(uuid='b'))] + >>> broken + [] + """ + if bugdir.storage != None: + bugdir.load_all_bugs() + good_links = [] + fixed_links = [] + broken_links = [] + for bug in bugdir: + for blocker in get_blocked_by(bugdir, bug): + blocks = get_blocks(bugdir, blocker) + if (bug, blocks) in good_links+fixed_links+broken_links: + continue # already checked that link + if bug not in blocks: + if repair_broken_links == True: + _repair_one_way_link(bug, blocker, blocks=True) + fixed_links.append((bug, blocker)) + else: + broken_links.append((bug, blocker)) + else: + good_links.append((bug, blocker)) + for blockee in get_blocks(bugdir, bug): + blocked_by = get_blocked_by(bugdir, blockee) + if (blockee, bug) in good_links+fixed_links+broken_links: + continue # already checked that link + if bug not in blocked_by: + if repair_broken_links == True: + _repair_one_way_link(blockee, bug, blocks=False) + fixed_links.append((blockee, bug)) + else: + broken_links.append((blockee, bug)) + else: + good_links.append((blockee, bug)) + return (good_links, fixed_links, broken_links) + +class DependencyTree (object): + """ + Note: should probably be DependencyDiGraph. + """ + def __init__(self, bugdir, root_bug, depth_limit=0, + allowed_status_values=None, + allowed_severity_values=None): + self.bugdir = bugdir + self.root_bug = root_bug + self.depth_limit = depth_limit + self.allowed_status_values = allowed_status_values + self.allowed_severity_values = allowed_severity_values + + def _build_tree(self, child_fn): + root = tree.Tree() + root.bug = self.root_bug + root.depth = 0 + stack = [root] + while len(stack) > 0: + node = stack.pop() + if self.depth_limit > 0 and node.depth == self.depth_limit: + continue + for bug in child_fn(self.bugdir, node.bug): + if self.allowed_status_values != None \ + and not bug.status in self.allowed_status_values: + continue + if self.allowed_severity_values != None \ + and not bug.severity in self.allowed_severity_values: + continue + child = tree.Tree() + child.bug = bug + child.depth = node.depth+1 + node.append(child) + stack.append(child) + return root + + def blocks_tree(self): + if not hasattr(self, "_blocks_tree"): + self._blocks_tree = self._build_tree(get_blocks) + return self._blocks_tree + + def blocked_by_tree(self): + if not hasattr(self, "_blocked_by_tree"): + self._blocked_by_tree = self._build_tree(get_blocked_by) + return self._blocked_by_tree diff --git a/libbe/command/diff.py b/libbe/command/diff.py new file mode 100644 index 0000000..ebfe8fe --- /dev/null +++ b/libbe/command/diff.py @@ -0,0 +1,138 @@ +# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc. +# Gianluca Montecchi <gian@grys.it> +# W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import libbe +import libbe.bug +import libbe.command +import libbe.command.util +import libbe.storage + +import libbe.diff + +class Diff (libbe.command.Command): + __doc__ = """Compare bug reports with older tree + + >>> import sys + >>> import libbe.bugdir + >>> bd = libbe.bugdir.SimpleBugDir(memory=False, versioned=True) + >>> io = libbe.command.StringInputOutput() + >>> io.stdout = sys.stdout + >>> ui = libbe.command.UserInterface(io=io) + >>> ui.storage_callbacks.set_storage(bd.storage) + >>> cmd = Diff() + + >>> original = bd.storage.commit('Original status') + >>> bug = bd.bug_from_uuid('a') + >>> bug.status = 'closed' + >>> changed = bd.storage.commit('Closed bug a') + >>> ret = ui.run(cmd, args=[original]) + Modified bugs: + abc/a:cm: Bug A + Changed bug settings: + status: open -> closed + >>> ret = ui.run(cmd, {'subscribe':'%(bugdir_id)s:mod', 'uuids':True}, [original]) + a + >>> bd.storage.versioned = False + >>> ret = ui.run(cmd, args=[original]) + Traceback (most recent call last): + ... + UserError: This repository is not revision-controlled. + >>> ui.cleanup() + >>> bd.cleanup() + """ % {'bugdir_id':libbe.diff.BUGDIR_ID} + name = 'diff' + + def __init__(self, *args, **kwargs): + libbe.command.Command.__init__(self, *args, **kwargs) + self.options.extend([ + libbe.command.Option(name='repo', short_name='r', + help='Compare with repository in REPO instead' + ' of the current repository.', + arg=libbe.command.Argument( + name='repo', metavar='REPO', + completion_callback=libbe.command.util.complete_path)), + libbe.command.Option(name='subscribe', short_name='s', + help='Only print changes matching SUBSCRIPTION, ' + 'subscription is a comma-separated list of ID:TYPE ' + 'tuples. See `be subscribe --help` for descriptions ' + 'of ID and TYPE.', + arg=libbe.command.Argument( + name='subscribe', metavar='SUBSCRIPTION')), + libbe.command.Option(name='uuids', short_name='u', + help='Only print the changed bug UUIDS.'), + ]) + self.args.extend([ + libbe.command.Argument( + name='revision', metavar='REVISION', default=None, + optional=True) + ]) + + def _run(self, **params): + try: + subscriptions = libbe.diff.subscriptions_from_string( + params['subscribe']) + except ValueError, e: + raise libbe.command.UserError(e.msg) + bugdir = self._get_bugdir() + if bugdir.storage.versioned == False: + raise libbe.command.UserError( + 'This repository is not revision-controlled.') + if params['repo'] == None: + if params['revision'] == None: # get the most recent revision + params['revision'] = bugdir.storage.revision_id(-1) + old_bd = bugdir.duplicate_bugdir(params['revision']) + else: + old_storage = libbe.storage.get_storage(params['repo']) + old_storage.connect() + old_bd_current = bugdir.BugDir(old_storage, from_disk=True) + if params['revision'] == None: # use the current working state + old_bd = old_bd_current + else: + if old_bd_current.storage.versioned == False: + raise libbe.command.UserError( + '%s is not revision-controlled.' + % storage.repo) + old_bd = old_bd_current.duplicate_bugdir(revision) + d = libbe.diff.Diff(old_bd, bugdir) + tree = d.report_tree(subscriptions) + + if params['uuids'] == True: + uuids = [] + bugs = tree.child_by_path('/bugs') + for bug_type in bugs: + uuids.extend([bug.name for bug in bug_type]) + print >> self.stdout, '\n'.join(uuids) + else : + rep = tree.report_string() + if rep != None: + print >> self.stdout, rep + return 0 + + def _long_help(self): + return """ +Uses the storage backend to compare the current tree with a previous +tree, and prints a pretty report. If REVISION is given, it is a +specifier for the particular previous tree to use. Specifiers are +specific to their storage backend. + +For Arch your specifier must be a fully-qualified revision name. + +Besides the standard summary output, you can use the options to output +UUIDS for the different categories. This output can be used as the +input to 'be show' to get an understanding of the current status. +""" diff --git a/libbe/command/due.py b/libbe/command/due.py new file mode 100644 index 0000000..2eb3194 --- /dev/null +++ b/libbe/command/due.py @@ -0,0 +1,117 @@ +# Copyright (C) 2009 W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import libbe +import libbe.command +import libbe.command.util +import libbe.util.utility + + +DUE_TAG = 'DUE:' + + +class Due (libbe.command.Command): + """Set bug due dates + + >>> import sys + >>> import libbe.bugdir + >>> bd = libbe.bugdir.SimpleBugDir(memory=False) + >>> io = libbe.command.StringInputOutput() + >>> io.stdout = sys.stdout + >>> ui = libbe.command.UserInterface(io=io) + >>> ui.storage_callbacks.set_storage(bd.storage) + >>> cmd = Due(ui=ui) + + >>> ret = ui.run(cmd, args=['/a']) + No due date assigned. + >>> ret = ui.run(cmd, args=['/a', 'Thu, 01 Jan 1970 00:00:00 +0000']) + >>> ret = ui.run(cmd, args=['/a']) + Thu, 01 Jan 1970 00:00:00 +0000 + >>> ret = ui.run(cmd, args=['/a', 'none']) + >>> ret = ui.run(cmd, args=['/a']) + No due date assigned. + >>> ui.cleanup() + >>> bd.cleanup() + """ + name = 'due' + + def __init__(self, *args, **kwargs): + libbe.command.Command.__init__(self, *args, **kwargs) + self.args.extend([ + libbe.command.Argument( + name='bug-id', metavar='BUG-ID', + completion_callback=libbe.command.util.complete_bug_id), + libbe.command.Argument( + name='due', metavar='DUE', optional=True), + ]) + + def _run(self, **params): + bugdir = self._get_bugdir() + bug,dummy_comment = libbe.command.util.bug_comment_from_user_id( + bugdir, params['bug-id']) + if params['due'] == None: + due_time = get_due(bug) + if due_time is None: + print >> self.stdout, 'No due date assigned.' + else: + print >> self.stdout, libbe.util.utility.time_to_str(due_time) + else: + if params['due'] == 'none': + remove_due(bug) + else: + due_time = libbe.util.utility.str_to_time(params['due']) + set_due(bug, due_time) + + def _long_help(self): + return """ +If no DATE is specified, the bug's current due date is printed. If +DATE is specified, it will be assigned to the bug. +""" + +# internal helper functions + +def _generate_due_string(time): + return "%s%s" % (DUE_TAG, libbe.util.utility.time_to_str(time)) + +def _parse_due_string(string): + assert string.startswith(DUE_TAG) + return libbe.util.utility.str_to_time(string[len(DUE_TAG):]) + +# functions exposed to other modules + +def get_due(bug): + matched = [] + for line in bug.extra_strings: + if line.startswith(DUE_TAG): + matched.append(_parse_due_string(line)) + if len(matched) == 0: + return None + if len(matched) > 1: + raise Exception('Several due dates for %s?:\n %s' + % (bug.uuid, '\n '.join(matched))) + return matched[0] + +def remove_due(bug): + estrs = bug.extra_strings + for due_str in [s for s in estrs if s.startswith(DUE_TAG)]: + estrs.remove(due_str) + bug.extra_strings = estrs # reassign to notice change + +def set_due(bug, time): + remove_due(bug) + estrs = bug.extra_strings + estrs.append(_generate_due_string(time)) + bug.extra_strings = estrs # reassign to notice change diff --git a/libbe/command/email_bugs.py b/libbe/command/email_bugs.py new file mode 100644 index 0000000..f6641e3 --- /dev/null +++ b/libbe/command/email_bugs.py @@ -0,0 +1,239 @@ +# Copyright (C) 2009 W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Email specified bugs in a be-handle-mail compatible format.""" + +import copy +from cStringIO import StringIO +from email import Message +from email.mime.text import MIMEText +from email.generator import Generator +import sys +import time + +from libbe import cmdutil, bugdir +from libbe.subproc import invoke +from libbe.utility import time_to_str +from libbe.vcs import detect_vcs, installed_vcs +import show + +__desc__ = __doc__ + +sendmail='/usr/sbin/sendmail -t' + +def execute(args, manipulate_encodings=True, restrict_file_access=False, + dir="."): + """ + >>> import os + >>> from libbe import bug + >>> bd = bugdir.SimpleBugDir() + >>> bd.encoding = 'utf-8' + >>> os.chdir(bd.root) + >>> import email.charset as c + >>> c.add_charset('utf-8', c.SHORTEST, c.QP, 'utf-8') + >>> execute(["-o", "--to", "a@b.com", "--from", "b@c.edu", "a", "b"], + ... manipulate_encodings=False) # doctest: +ELLIPSIS + Content-Type: text/xml; charset="utf-8" + MIME-Version: 1.0 + Content-Transfer-Encoding: quoted-printable + From: b@c.edu + To: a@b.com + Date: ... + Subject: [be-bug:xml] Updates to a, b + <BLANKLINE> + <?xml version=3D"1.0" encoding=3D"utf-8" ?> + <be-xml> + <version> + <tag>...</tag> + <branch-nick>...</branch-nick> + <revno>...</revno> + <revision-id>... + </version> + <bug> + <uuid>a</uuid> + <short-name>a</short-name> + <severity>minor</severity> + <status>open</status> + <creator>John Doe <jdoe@example.com></creator> + <created>Thu, 01 Jan 1970 00:00:00 +0000</created> + <summary>Bug A</summary> + </bug> + <bug> + <uuid>b</uuid> + <short-name>b</short-name> + <severity>minor</severity> + <status>closed</status> + <creator>Jane Doe <jdoe@example.com></creator> + <created>Thu, 01 Jan 1970 00:00:00 +0000</created> + <summary>Bug B</summary> + </bug> + </be-xml> + >>> bd.cleanup() + + Note that the '=3D' bits in + <?xml version=3D"1.0" encoding=3D"utf-8" ?> + are the way quoted-printable escapes '='. + + The unclosed <revision-id>... is because revision ids can be long + enough to cause line wraps, and we want to ensure we match even if + the closing </revision-id> is split by the wrapping. + """ + parser = get_parser() + options, args = parser.parse_args(args) + cmdutil.default_complete(options, args, parser, + bugid_args={-1: lambda bug : bug.active==True}) + if len(args) == 0: + raise cmdutil.UsageError + bd = bugdir.BugDir(from_disk=True, + manipulate_encodings=manipulate_encodings, + root=dir) + xml = show.output(args, bd, as_xml=True, with_comments=True) + subject = options.subject + if subject == None: + subject = '[be-bug:xml] Updates to %s' % ', '.join(args) + submit_email = TextEmail(to_address=options.to_address, + from_address=options.from_address, + subject=subject, + body=xml, + encoding=bd.encoding, + subtype='xml') + if options.output == True: + print submit_email + else: + submit_email.send() + +def get_parser(): + parser = cmdutil.CmdOptionParser("be email-bugs [options] ID [ID ...]") + parser.add_option("-t", "--to", metavar="EMAIL", dest="to_address", + help="Submission email address (%default)", + default="be-devel@bugseverywhere.org") + parser.add_option("-f", "--from", metavar="EMAIL", dest="from_address", + help="Senders email address, overriding auto-generated default", + default=None) + parser.add_option("-s", "--subject", metavar="STRING", dest="subject", + help="Subject line, overriding auto-generated default. If you use this option, remember that be-handle-mail probably want something like '[be-bug:xml] ...'", + default=None) + parser.add_option('-o', '--output', dest='output', action='store_true', + help="Don't mail the generated message, print it to stdout instead. Useful for testing functionality.") + return parser + +longhelp=""" +Email specified bugs in a be-handle-mail compatible format. This is +the prefered method for reporting bugs if you did not install bzr by +branching a bzr repository. + +If you _did_ install bzr by branching a bzr repository, we suggest you +commit any new bug information with + bzr commit --message "Reported bug in demuxulizer" +and then email a bzr merge directive with + bzr send --mail-to "be-devel@bugseverywhere.org" +rather than using this command. +""" + +def help(): + return get_parser().help_str() + longhelp + +class TextEmail (object): + """ + Make it very easy to compose and send single-part text emails. + >>> msg = TextEmail(to_address='Monty <monty@a.com>', + ... from_address='Python <python@b.edu>', + ... subject='Parrots', + ... header={'x-special-header':'your info here'}, + ... body="Remarkable bird, id'nit, squire?\\nLovely plumage!") + >>> print msg # doctest: +ELLIPSIS + Content-Type: text/plain; charset="utf-8" + MIME-Version: 1.0 + Content-Transfer-Encoding: base64 + From: Python <python@b.edu> + To: Monty <monty@a.com> + Date: ... + Subject: Parrots + x-special-header: your info here + <BLANKLINE> + UmVtYXJrYWJsZSBiaXJkLCBpZCduaXQsIHNxdWlyZT8KTG92ZWx5IHBsdW1hZ2Uh + <BLANKLINE> + >>> import email.charset as c + >>> c.add_charset('utf-8', c.SHORTEST, c.QP, 'utf-8') + >>> print msg # doctest: +ELLIPSIS + Content-Type: text/plain; charset="utf-8" + MIME-Version: 1.0 + Content-Transfer-Encoding: quoted-printable + From: Python <python@b.edu> + To: Monty <monty@a.com> + Date: ... + Subject: Parrots + x-special-header: your info here + <BLANKLINE> + Remarkable bird, id'nit, squire? + Lovely plumage! + """ + def __init__(self, to_address, from_address=None, subject=None, + header=None, body=None, encoding='utf-8', subtype='plain'): + self.to_address = to_address + self.from_address = from_address + if self.from_address == None: + self.from_address = self._guess_from_address() + self.subject = subject + self.header = header + if self.header == None: + self.header = {} + self.body = body + self.encoding = encoding + self.subtype = subtype + def _guess_from_address(self): + vcs = detect_vcs('.') + if vcs.name == "None": + vcs = installed_vcs() + return vcs.get_user_id() + def encoded_MIME_body(self): + return MIMEText(self.body.encode(self.encoding), + self.subtype, + self.encoding) + def message(self): + response = self.encoded_MIME_body() + response['From'] = self.from_address + response['To'] = self.to_address + response['Date'] = time_to_str(time.time()) + response['Subject'] = self.subject + for k,v in self.header.items(): + response[k] = v + return response + def flatten(self, to_unicode=False): + """ + This is a simplified version of send_pgp_mime.flatten(). + """ + fp = StringIO() + g = Generator(fp, mangle_from_=False) + g.flatten(self.message()) + text = fp.getvalue() + if to_unicode == True: + encoding = msg.get_content_charset() or "utf-8" + text = unicode(text, encoding=encoding) + return text + def __str__(self): + return self.flatten() + def __unicode__(self): + return self.flatten(to_unicode=True) + def send(self, sendmail=None): + """ + This is a simplified version of send_pgp_mime.mail(). + + Send an email Message instance on its merry way by shelling + out to the user specified sendmail. + """ + if sendmail == None: + sendmail = SENDMAIL + invoke(sendmail, stdin=self.flatten()) diff --git a/libbe/command/help.py b/libbe/command/help.py new file mode 100644 index 0000000..d509d31 --- /dev/null +++ b/libbe/command/help.py @@ -0,0 +1,82 @@ +# Copyright (C) 2006-2009 Aaron Bentley and Panometrics, Inc. +# Gianluca Montecchi <gian@grys.it> +# Thomas Gerigk <tgerigk@gmx.de> +# W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import libbe +import libbe.command +import libbe.command.util + +TOPICS = {} + +class Help (libbe.command.Command): + """Print help for given command or topic + + >>> import sys + >>> import libbe.bugdir + >>> io = libbe.command.StringInputOutput() + >>> io.stdout = sys.stdout + >>> ui = libbe.command.UserInterface(io=io) + >>> cmd = Help() + + >>> ret = ui.run(cmd, args=['help']) + usage: be help [options] [TOPIC] + <BLANKLINE> + Options: + -h, --help Print a help message. + <BLANKLINE> + --complete Print a list of possible completions. + <BLANKLINE> + <BLANKLINE> + Print help for specified command/topic or list of all commands. + """ + name = 'help' + + def __init__(self, *args, **kwargs): + libbe.command.Command.__init__(self, *args, **kwargs) + self.args.extend([ + libbe.command.Argument( + name='topic', metavar='TOPIC', default=None, + optional=True, + completion_callback=self.complete_topic) + ]) + + def _run(self, **params): + if params['topic'] == None: + if hasattr(self.ui, 'help'): + self.ui.help() + elif params['topic'] in libbe.command.commands(): + module = libbe.command.get_command(params['topic']) + Class = libbe.command.get_command_class(module,params['topic']) + c = Class(ui=self.ui) + print >> self.stdout, c.help().rstrip('\n') + elif params['topic'] in TOPICS: + print >> self.stdout, TOPICS[params['topic']].rstrip('\n') + else: + raise libbe.command.UserError( + '"%s" is neither a command nor topic' % params['topic']) + return 0 + + def _long_help(self): + return """ +Print help for specified command/topic or list of all commands. +""" + + def complete_topic(self, command, argument, fragment=None): + commands = libbe.command.util.complete_command() + topics = sorted(TOPICS.keys()) + return commands + topics diff --git a/libbe/command/html.py b/libbe/command/html.py new file mode 100644 index 0000000..7fd753a --- /dev/null +++ b/libbe/command/html.py @@ -0,0 +1,629 @@ +# Copyright (C) 2009 Gianluca Montecchi <gian@grys.it> +# W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import codecs +import htmlentitydefs +import os +import os.path +import re +import string +import time +import xml.sax.saxutils + +import libbe +import libbe.command +import libbe.command.util +import libbe.util.encoding + + +class HTML (libbe.command.Command): + """Generate a static HTML dump of the current repository status + + >>> import sys + >>> import libbe.bugdir + >>> bd = libbe.bugdir.SimpleBugDir(memory=False) + >>> io = libbe.command.StringInputOutput() + >>> io.stdout = sys.stdout + >>> ui = libbe.command.UserInterface(io=io) + >>> ui.storage_callbacks.set_storage(bd.storage) + >>> cmd = HTML(ui=ui) + + >>> ret = ui.run(cmd, {'output':os.path.join(bd.storage.repo, 'html_export')}) + >>> os.path.exists(os.path.join(bd.storage.repo, 'html_export')) + True + >>> os.path.exists(os.path.join(bd.storage.repo, 'html_export', 'index.html')) + True + >>> os.path.exists(os.path.join(bd.storage.repo, 'html_export', 'index_inactive.html')) + True + >>> os.path.exists(os.path.join(bd.storage.repo, 'html_export', 'bugs')) + True + >>> os.path.exists(os.path.join(bd.storage.repo, 'html_export', 'bugs', 'a.html')) + True + >>> os.path.exists(os.path.join(bd.storage.repo, 'html_export', 'bugs', 'b.html')) + True + >>> ui.cleanup() + >>> bd.cleanup() + """ + name = 'html' + + def __init__(self, *args, **kwargs): + libbe.command.Command.__init__(self, *args, **kwargs) + self.options.extend([ + libbe.command.Option(name='output', short_name='o', + help='Set the output path (%default)', + arg=libbe.command.Argument( + name='output', metavar='DIR', default='./html_export', + completion_callback=libbe.command.util.complete_path)), + libbe.command.Option(name='template-dir', short_name='t', + help='Use a different template. Defaults to internal templates', + arg=libbe.command.Argument( + name='template-dir', metavar='DIR', + completion_callback=libbe.command.util.complete_path)), + libbe.command.Option(name='title', + help='Set the bug repository title (%default)', + arg=libbe.command.Argument( + name='title', metavar='STRING', + default='BugsEverywhere Issue Tracker')), + libbe.command.Option(name='index-header', + help='Set the index page headers (%default)', + arg=libbe.command.Argument( + name='index-header', metavar='STRING', + default='BugsEverywhere Bug List')), + libbe.command.Option(name='export-template', short_name='e', + help='Export the default template and exit.'), + libbe.command.Option(name='export-template-dir', short_name='d', + help='Set the directory for the template export (%default)', + arg=libbe.command.Argument( + name='export-template-dir', metavar='DIR', + default='./default-templates/', + completion_callback=libbe.command.util.complete_path)), + libbe.command.Option(name='verbose', short_name='v', + help='Verbose output, default is %default'), + ]) + + def _run(self, **params): + if params['export-template'] == True: + html_gen.write_default_template(params['export-template-dir']) + return 0 + bugdir = self._get_bugdir() + bugdir.load_all_bugs() + html_gen = HTMLGen(bugdir, + template=params['template-dir'], + title=params['title'], + index_header=params['index-header'], + verbose=params['verbose'], + stdout=self.stdout) + html_gen.run(params['output']) + return 0 + + def _long_help(self): + return """ +Generate a set of html pages representing the current state of the bug +directory. +""" + +Html = HTML # alias for libbe.command.base.get_command_class() + +class HTMLGen (object): + def __init__(self, bd, template=None, + title="Site Title", index_header="Index Header", + verbose=False, encoding=None, stdout=None, + ): + self.generation_time = time.ctime() + self.bd = bd + if template == None: + self.template = "default" + else: + self.template = os.path.abspath(os.path.expanduser(template)) + self.title = title + self.index_header = index_header + self.verbose = verbose + self.stdout = stdout + if encoding != None: + self.encoding = encoding + else: + self.encoding = libbe.util.encoding.get_filesystem_encoding() + self._load_default_templates() + if template != None: + self._load_user_templates() + + def run(self, out_dir): + if self.verbose == True: + print >> self.stdout, \ + 'Creating the html output in %s using templates in %s' \ + % (out_dir, self.template) + + bugs_active = [] + bugs_inactive = [] + bugs = [b for b in self.bd] + bugs.sort() + bugs_active = [b for b in bugs if b.active == True] + bugs_inactive = [b for b in bugs if b.active != True] + + self._create_output_directories(out_dir) + self._write_css_file() + for b in bugs: + if b.active: + up_link = '../index.html' + else: + up_link = '../index_inactive.html' + self._write_bug_file(b, up_link) + self._write_index_file( + bugs_active, title=self.title, + index_header=self.index_header, bug_type='active') + self._write_index_file( + bugs_inactive, title=self.title, + index_header=self.index_header, bug_type='inactive') + + def _create_output_directories(self, out_dir): + if self.verbose: + print >> self.stdout, 'Creating output directories' + self.out_dir = self._make_dir(out_dir) + self.out_dir_bugs = self._make_dir( + os.path.join(self.out_dir, 'bugs')) + + def _write_css_file(self): + if self.verbose: + print >> self.stdout, 'Writing css file' + assert hasattr(self, 'out_dir'), \ + 'Must run after ._create_output_directories()' + self._write_file(self.css_file, + [self.out_dir,'style.css']) + + def _write_bug_file(self, bug, up_link): + if self.verbose: + print >> self.stdout, '\tCreating bug file for %s' % bug.id.user() + assert hasattr(self, 'out_dir_bugs'), \ + 'Must run after ._create_output_directories()' + + bug.load_comments(load_full=True) + comment_entries = self._generate_bug_comment_entries(bug) + filename = '%s.html' % bug.uuid + fullpath = os.path.join(self.out_dir_bugs, filename) + template_info = {'title':self.title, + 'charset':self.encoding, + 'up_link':up_link, + 'shortname':bug.id.user(), + 'comment_entries':comment_entries, + 'generation_time':self.generation_time} + for attr in ['uuid', 'severity', 'status', 'assigned', + 'reporter', 'creator', 'time_string', 'summary']: + template_info[attr] = self._escape(getattr(bug, attr)) + self._write_file(self.bug_file % template_info, [fullpath]) + + def _generate_bug_comment_entries(self, bug): + assert hasattr(self, 'out_dir_bugs'), \ + 'Must run after ._create_output_directories()' + + stack = [] + comment_entries = [] + for depth,comment in bug.comment_root.thread(flatten=False): + while len(stack) > depth: + # pop non-parents off the stack + stack.pop(-1) + # close non-parent <div class="comment... + comment_entries.append('</div>\n') + assert len(stack) == depth + stack.append(comment) + if depth == 0: + comment_entries.append('<div class="comment root">') + else: + comment_entries.append('<div class="comment">') + template_info = {} + for attr in ['uuid', 'author', 'date', 'body']: + value = getattr(comment, attr) + if attr == 'body': + save_body = False + if comment.content_type == 'text/html': + pass # no need to escape html... + elif comment.content_type.startswith('text/'): + value = '<pre>\n'+self._escape(value)+'\n</pre>' + elif comment.content_type.startswith('image/'): + save_body = True + value = '<img src="./%s/%s" />' \ + % (bug.uuid, comment.uuid) + else: + save_body = True + value = '<a href="./%s/%s">Link to %s file</a>.' \ + % (bug.uuid, comment.uuid, comment.content_type) + if save_body == True: + per_bug_dir = os.path.join(self.out_dir_bugs, bug.uuid) + if not os.path.exists(per_bug_dir): + os.mkdir(per_bug_dir) + comment_path = os.path.join(per_bug_dir, comment.uuid) + self._write_file( + '<Files %s>\n ForceType %s\n</Files>' \ + % (comment.uuid, comment.content_type), + [per_bug_dir, '.htaccess'], mode='a') + self._write_file( # TODO: long_to_linked_user() + libbe.util.id.long_to_short_text( + [self.bd], comment.body), + [per_bug_dir, comment.uuid], mode='wb') + else: + value = self._escape(value) + template_info[attr] = value + comment_entries.append(self.bug_comment_entry % template_info) + while len(stack) > 0: + stack.pop(-1) + comment_entries.append('</div>\n') # close every remaining <div class='comment... + return '\n'.join(comment_entries) + + def _write_index_file(self, bugs, title, index_header, bug_type='active'): + if self.verbose: + print >> self.stdout, 'Writing %s index file for %d bugs' % (bug_type, len(bugs)) + assert hasattr(self, 'out_dir'), 'Must run after ._create_output_directories()' + esc = self._escape + + bug_entries = self._generate_index_bug_entries(bugs) + + if bug_type == 'active': + filename = 'index.html' + elif bug_type == 'inactive': + filename = 'index_inactive.html' + else: + raise Exception, 'Unrecognized bug_type: "%s"' % bug_type + template_info = {'title':title, + 'index_header':index_header, + 'charset':self.encoding, + 'active_class':'tab sel', + 'inactive_class':'tab nsel', + 'bug_entries':bug_entries, + 'generation_time':self.generation_time} + if bug_type == 'inactive': + template_info['active_class'] = 'tab nsel' + template_info['inactive_class'] = 'tab sel' + + self._write_file(self.index_file % template_info, + [self.out_dir, filename]) + + def _generate_index_bug_entries(self, bugs): + bug_entries = [] + for bug in bugs: + if self.verbose: + print >> self.stdout, '\tCreating bug entry for %s' % bug.id.user() + template_info = {'shortname':bug.id.user()} + for attr in ['uuid', 'severity', 'status', 'assigned', + 'reporter', 'creator', 'time_string', 'summary']: + template_info[attr] = self._escape(getattr(bug, attr)) + bug_entries.append(self.index_bug_entry % template_info) + return '\n'.join(bug_entries) + + def _escape(self, string): + if string == None: + return '' + chars = [] + for char in string: + codepoint = ord(char) + if codepoint in htmlentitydefs.codepoint2name: + char = '&%s;' % htmlentitydefs.codepoint2name[codepoint] + #else: xml.sax.saxutils.escape(char) + chars.append(char) + return ''.join(chars) + + def _load_user_templates(self): + for filename,attr in [('style.css','css_file'), + ('index_file.tpl','index_file'), + ('index_bug_entry.tpl','index_bug_entry'), + ('bug_file.tpl','bug_file'), + ('bug_comment_entry.tpl','bug_comment_entry')]: + fullpath = os.path.join(self.template, filename) + if os.path.exists(fullpath): + setattr(self, attr, self._read_file([fullpath])) + + def _make_dir(self, dir_path): + dir_path = os.path.abspath(os.path.expanduser(dir_path)) + if not os.path.exists(dir_path): + try: + os.makedirs(dir_path) + except: + raise libbe.command.UserError( + 'Cannot create output directory "%s".' % dir_path) + return dir_path + + def _write_file(self, content, path_array, mode='w'): + return libbe.util.encoding.set_file_contents( + os.path.join(*path_array), content, mode, self.encoding) + + def _read_file(self, path_array, mode='r'): + return libbe.util.encoding.get_file_contents( + os.path.join(*path_array), mode, self.encoding, decode=True) + + def write_default_template(self, out_dir): + if self.verbose: + print >> self.stdout, 'Creating output directories' + self.out_dir = self._make_dir(out_dir) + if self.verbose: + print >> self.stdout, 'Creating css file' + self._write_css_file() + if self.verbose: + print >> self.stdout, 'Creating index_file.tpl file' + self._write_file(self.index_file, + [self.out_dir, 'index_file.tpl']) + if self.verbose: + print >> self.stdout, 'Creating index_bug_entry.tpl file' + self._write_file(self.index_bug_entry, + [self.out_dir, 'index_bug_entry.tpl']) + if self.verbose: + print >> self.stdout, 'Creating bug_file.tpl file' + self._write_file(self.bug_file, + [self.out_dir, 'bug_file.tpl']) + if self.verbose: + print >> self.stdout, 'Creating bug_comment_entry.tpl file' + self._write_file(self.bug_comment_entry, + [self.out_dir, 'bug_comment_entry.tpl']) + + def _load_default_templates(self): + self.css_file = """ + body { + font-family: "lucida grande", "sans serif"; + color: #333; + width: auto; + margin: auto; + } + + div.main { + padding: 20px; + margin: auto; + padding-top: 0; + margin-top: 1em; + background-color: #fcfcfc; + } + + div.footer { + font-size: small; + padding-left: 20px; + padding-right: 20px; + padding-top: 5px; + padding-bottom: 5px; + margin: auto; + background: #305275; + color: #fffee7; + } + + table { + border-style: solid; + border: 10px #313131; + border-spacing: 0; + width: auto; + } + + tb { border: 1px; } + + tr { + vertical-align: top; + width: auto; + } + + td { + border-width: 0; + border-style: none; + padding-right: 0.5em; + padding-left: 0.5em; + width: auto; + } + + img { border-style: none; } + + h1 { + padding: 0.5em; + background-color: #305275; + margin-top: 0; + margin-bottom: 0; + color: #fff; + margin-left: -20px; + margin-right: -20px; + } + + ul { + list-style-type: none; + padding: 0; + } + + p { width: auto; } + + a, a:visited { + background: inherit; + text-decoration: none; + } + + a { color: #003d41; } + a:visited { color: #553d41; } + .footer a { color: #508d91; } + + /* bug index pages */ + + td.tab { + padding-right: 1em; + padding-left: 1em; + } + + td.sel.tab { + background-color: #afafaf; + border: 1px solid #afafaf; + font-weight:bold; + } + + td.nsel.tab { border: 0px; } + + table.bug_list { + background-color: #afafaf; + border: 2px solid #afafaf; + } + + .bug_list tr { width: auto; } + tr.wishlist { background-color: #B4FF9B; } + tr.minor { background-color: #FCFF98; } + tr.serious { background-color: #FFB648; } + tr.critical { background-color: #FF752A; } + tr.fatal { background-color: #FF3300; } + + /* bug detail pages */ + + td.bug_detail_label { text-align: right; } + td.bug_detail { } + td.bug_comment_label { text-align: right; vertical-align: top; } + td.bug_comment { } + + div.comment { + padding: 20px; + padding-top: 20px; + margin: auto; + margin-top: 0; + } + + div.root.comment { + padding: 0px; + /* padding-top: 0px; */ + padding-bottom: 20px; + } + """ + + self.index_file = """ + <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" + "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> + <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> + <head> + <title>%(title)s</title> + <meta http-equiv="Content-Type" content="text/html; charset=%(charset)s" /> + <link rel="stylesheet" href="style.css" type="text/css" /> + </head> + <body> + + <div class="main"> + <h1>%(index_header)s</h1> + <p></p> + <table> + + <tr> + <td class="%(active_class)s"><a href="index.html">Active Bugs</a></td> + <td class="%(inactive_class)s"><a href="index_inactive.html">Inactive Bugs</a></td> + </tr> + + </table> + <table class="bug_list"> + <tbody> + + %(bug_entries)s + + </tbody> + </table> + </div> + + <div class="footer"> + <p>Generated by <a href="http://www.bugseverywhere.org/"> + BugsEverywhere</a> on %(generation_time)s</p> + <p> + <a href="http://validator.w3.org/check?uri=referer">Validate XHTML</a> | + <a href="http://jigsaw.w3.org/css-validator/check?uri=referer">Validate CSS</a> + </p> + </div> + + </body> + </html> + """ + + self.index_bug_entry =""" + <tr class="%(severity)s"> + <td><a href="bugs/%(uuid)s.html">%(shortname)s</a></td> + <td><a href="bugs/%(uuid)s.html">%(status)s</a></td> + <td><a href="bugs/%(uuid)s.html">%(severity)s</a></td> + <td><a href="bugs/%(uuid)s.html">%(summary)s</a></td> + <td><a href="bugs/%(uuid)s.html">%(time_string)s</a></td> + </tr> + """ + + self.bug_file = """ + <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" + "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> + <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> + <head> + <title>%(title)s</title> + <meta http-equiv="Content-Type" content="text/html; charset=%(charset)s" /> + <link rel="stylesheet" href="../style.css" type="text/css" /> + </head> + <body> + + <div class="main"> + <h1>BugsEverywhere Bug List</h1> + <h5><a href="%(up_link)s">Back to Index</a></h5> + <h2>Bug: %(shortname)s</h2> + <table> + <tbody> + + <tr><td class="bug_detail_label">ID :</td> + <td class="bug_detail">%(uuid)s</td></tr> + <tr><td class="bug_detail_label">Short name :</td> + <td class="bug_detail">%(shortname)s</td></tr> + <tr><td class="bug_detail_label">Status :</td> + <td class="bug_detail">%(status)s</td></tr> + <tr><td class="bug_detail_label">Severity :</td> + <td class="bug_detail">%(severity)s</td></tr> + <tr><td class="bug_detail_label">Assigned :</td> + <td class="bug_detail">%(assigned)s</td></tr> + <tr><td class="bug_detail_label">Reporter :</td> + <td class="bug_detail">%(reporter)s</td></tr> + <tr><td class="bug_detail_label">Creator :</td> + <td class="bug_detail">%(creator)s</td></tr> + <tr><td class="bug_detail_label">Created :</td> + <td class="bug_detail">%(time_string)s</td></tr> + <tr><td class="bug_detail_label">Summary :</td> + <td class="bug_detail">%(summary)s</td></tr> + </tbody> + </table> + + <hr/> + + %(comment_entries)s + + </div> + <h5><a href="%(up_link)s">Back to Index</a></h5> + + <div class="footer"> + <p>Generated by <a href="http://www.bugseverywhere.org/"> + BugsEverywhere</a> on %(generation_time)s</p> + <p> + <a href="http://validator.w3.org/check?uri=referer">Validate XHTML</a> | + <a href="http://jigsaw.w3.org/css-validator/check?uri=referer">Validate CSS</a> + </p> + </div> + + </body> + </html> + """ + + self.bug_comment_entry =""" + <table> + <tr> + <td class="bug_comment_label">Comment:</td> + <td class="bug_comment"> + --------- Comment ---------<br/> + Name: %(uuid)s<br/> + From: %(author)s<br/> + Date: %(date)s<br/> + <br/> + %(body)s + </td> + </tr> + </table> + """ + + # strip leading whitespace + for attr in ['css_file', 'index_file', 'index_bug_entry', 'bug_file', + 'bug_comment_entry']: + value = getattr(self, attr) + value = value.replace('\n'+' '*12, '\n') + setattr(self, attr, value.strip()+'\n') diff --git a/libbe/command/import_xml.py b/libbe/command/import_xml.py new file mode 100644 index 0000000..be22c82 --- /dev/null +++ b/libbe/command/import_xml.py @@ -0,0 +1,443 @@ +# Copyright (C) 2009 W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import copy +import os +import sys +try: # import core module, Python >= 2.5 + from xml.etree import ElementTree +except ImportError: # look for non-core module + from elementtree import ElementTree + +import libbe +import libbe.bug +import libbe.command +import libbe.command.util +import libbe.comment +import libbe.util.encoding +import libbe.util.utility + +if libbe.TESTING == True: + import doctest + import StringIO + import unittest + + import libbe.bugdir + +class Import_XML (libbe.command.Command): + """Import comments and bugs from XML + + >>> import time + >>> import StringIO + >>> import libbe.bugdir + >>> bd = libbe.bugdir.SimpleBugDir(memory=False) + >>> io = libbe.command.StringInputOutput() + >>> io.stdout = sys.stdout + >>> ui = libbe.command.UserInterface(io=io) + >>> ui.storage_callbacks.set_storage(bd.storage) + >>> cmd = Import_XML(ui=ui) + + >>> ui.io.set_stdin('<be-xml><comment><uuid>c</uuid><body>This is a comment about a</body></comment></be-xml>') + >>> ret = ui.run(cmd, {'comment-root':'/a'}, ['-']) + >>> bd.flush_reload() + >>> bug = bd.bug_from_uuid('a') + >>> bug.load_comments(load_full=False) + >>> comment = bug.comment_root[0] + >>> print comment.body + This is a comment about a + <BLANKLINE> + >>> comment.time <= int(time.time()) + True + >>> comment.in_reply_to is None + True + >>> ui.cleanup() + >>> bd.cleanup() + """ + name = 'import-xml' + + def __init__(self, *args, **kwargs): + libbe.command.Command.__init__(self, *args, **kwargs) + self.options.extend([ + libbe.command.Option(name='ignore-missing-references', short_name='i', + help="If any comment's <in-reply-to> refers to a non-existent comment, ignore it (instead of raising an exception)."), + libbe.command.Option(name='add-only', short_name='a', + help='If any bug or comment listed in the XML file already exists in the bug repository, do not alter the repository version.'), + libbe.command.Option(name='comment-root', short_name='c', + help='Supply a bug or comment ID as the root of any <comment> elements that are direct children of the <be-xml> element. If any such <comment> elements exist, you are required to set this option.', + arg=libbe.command.Argument( + name='comment-root', metavar='ID', + completion_callback=libbe.command.util.complete_bug_comment_id)), + ]) + self.args.extend([ + libbe.command.Argument( + name='xml-file', metavar='XML-FILE'), + ]) + + def _run(self, **params): + bugdir = self._get_bugdir() + writeable = bugdir.storage.writeable + bugdir.storage.writeable = False + if params['comment-root'] != None: + croot_bug,croot_comment = \ + libbe.command.util.bug_comment_from_user_id( + bugdir, params['comment-root']) + croot_bug.load_comments(load_full=True) + if croot_comment.uuid == libbe.comment.INVALID_UUID: + croot_comment = croot_bug.comment_root + else: + croot_comment = croot_bug.comment_from_uuid(croot_comment.uuid) + new_croot_bug = libbe.bug.Bug(bugdir=bugdir, uuid=croot_bug.uuid) + new_croot_bug.explicit_attrs = [] + new_croot_bug.comment_root = copy.deepcopy(croot_bug.comment_root) + if croot_comment.uuid == libbe.comment.INVALID_UUID: + new_croot_comment = new_croot_bug.comment_root + else: + new_croot_comment = \ + new_croot_bug.comment_from_uuid(croot_comment.uuid) + for new in new_croot_bug.comments(): + new.explicit_attrs = [] + else: + croot_bug,croot_comment = (None, None) + + if params['xml-file'] == '-': + xml = self.stdin.read().encode(self.stdin.encoding) + else: + self._check_restricted_access(storage, params['xml-file']) + xml = libbe.util.encoding.get_file_contents( + params['xml-file']) + + # parse the xml + root_bugs = [] + root_comments = [] + version = {} + be_xml = ElementTree.XML(xml) + if be_xml.tag != 'be-xml': + raise libbe.util.utility.InvalidXML( + 'import-xml', be_xml, 'root element must be <be-xml>') + for child in be_xml.getchildren(): + if child.tag == 'bug': + new = libbe.bug.Bug(bugdir=bugdir) + new.from_xml(child) + root_bugs.append(new) + elif child.tag == 'comment': + new = libbe.comment.Comment(croot_bug) + new.from_xml(child) + root_comments.append(new) + elif child.tag == 'version': + for gchild in child.getchildren(): + if child.tag in ['tag', 'nick', 'revision', 'revision-id']: + text = xml.sax.saxutils.unescape(child.text) + text = text.decode('unicode_escape').strip() + version[child.tag] = text + else: + print >> sys.stderr, 'ignoring unknown tag %s in %s' \ + % (gchild.tag, child.tag) + else: + print >> sys.stderr, 'ignoring unknown tag %s in %s' \ + % (child.tag, comment_list.tag) + + # merge the new root_comments + if params['add-only'] == True: + accept_changes = False + accept_extra_strings = False + else: + accept_changes = True + accept_extra_strings = True + accept_comments = True + if len(root_comments) > 0: + if croot_bug == None: + raise UserError( + '--comment-root option is required for your root comments:\n%s' + % '\n\n'.join([c.string() for c in root_comments])) + try: + # link new comments + new_croot_bug.add_comments(root_comments, + default_parent=new_croot_comment, + ignore_missing_references= \ + params['ignore-missing-references']) + except libbe.comment.MissingReference, e: + raise libbe.command.UserError(e) + croot_bug.merge(new_croot_bug, accept_changes=accept_changes, + accept_extra_strings=accept_extra_strings, + accept_comments=accept_comments) + + # merge the new croot_bugs + merged_bugs = [] + old_bugs = [] + for new in root_bugs: + try: + old = bugdir.bug_from_uuid(new.alt_id) + except KeyError: + old = None + if old == None: + bd.append(new) + else: + old.load_comments(load_full=True) + old.merge(new, accept_changes=accept_changes, + accept_extra_strings=accept_extra_strings, + accept_comments=accept_comments) + merged_bugs.append(new) + old_bugs.append(old) + + # protect against programmer error causing data loss: + if croot_bug != None: + comms = [c.uuid for c in croot_comment.traverse()] + for new in root_comments: + assert new.uuid in comms, \ + "comment %s wasn't added to %s" % (new.uuid, croot_comment.uuid) + for new in root_bugs: + if not new in merged_bugs: + assert bugdir.has_bug(new.uuid), \ + "bug %s wasn't added" % (new.uuid) + + # save new information + bugdir.storage.writeable = writeable + if croot_bug != None: + croot_bug.save() + for new in root_bugs: + if not new in merged_bugs: + new.save() + for old in old_bugs: + old.save() + + def _long_help(self): + return """ +Import comments and bugs from XMLFILE. If XMLFILE is '-', the file is +read from stdin. + +This command provides a fallback mechanism for passing bugs between +repositories, in case the repositories VCSs are incompatible. If the +VCSs are compatible, it's better to use their builtin merge/push/pull +to share this information, as that will preserve a more detailed +history. + +The XML file should be formatted similarly to + <be-xml> + <version> + <tag>1.0.0</tag> + <branch-nick>be</branch-nick> + <revno>446</revno> + <revision-id>a@b.com-20091119214553-iqyw2cpqluww3zna</revision-id> + <version> + <bug> + ... + <comment>...</comment> + <comment>...</comment> + </bug> + <bug>...</bug> + <comment>...</comment> + <comment>...</comment> + </be-xml> +where the ellipses mark output commpatible with Bug.xml() and +Comment.xml(). Take a look at the output of `be show --xml` for some +explicit examples. Unrecognized tags are ignored. Missing tags are +left at the default value. The version tag is not required, but is +strongly recommended. + +The bug and comment UUIDs are always auto-generated, so if you set a +<uuid> field, but no <alt-id> field, your <uuid> will be used as the +comment's <alt-id>. An exception is raised if <alt-id> conflicts with +an existing comment. Bugs do not have a permantent alt-id, so they +the <uuid>s you specify are not saved. The <uuid>s _are_ used to +match agains prexisting bug and comment uuids, and comment alt-ids, +and fields explicitly given in the XML file will replace old versions +unless the --add-only flag. + +*.extra_strings recieves special treatment, and if --add-only is not +set, the resulting list concatenates both source lists and removes +repeats. + +Here's an example of import activity: + Repository + bug (uuid=B, creator=John, status=open) + estr (don't forget your towel) + estr (helps with space travel) + com (uuid=C1, author=Jane, body=Hello) + com (uuid=C2, author=Jess, body=World) + XML + bug (uuid=B, status=fixed) + estr (don't forget your towel) + estr (watch out for flying dolphins) + com (uuid=C1, body=So long) + com (uuid=C3, author=Jed, body=And thanks) + Result + bug (uuid=B, creator=John, status=fixed) + estr (don't forget your towel) + estr (helps with space travel) + estr (watch out for flying dolphins) + com (uuid=C1, author=Jane, body=So long) + com (uuid=C2, author=Jess, body=World) + com (uuid=C4, alt-id=C3, author=Jed, body=And thanks) + Result, with --add-only + bug (uuid=B, creator=John, status=open) + estr (don't forget your towel) + estr (helps with space travel) + com (uuid=C1, author=Jane, body=Hello) + com (uuid=C2, author=Jess, body=World) + com (uuid=C4, alt-id=C3, author=Jed, body=And thanks) + +Examples: + +Import comments (e.g. emails from an mbox) and append to bug XYZ + $ be-mbox-to-xml mail.mbox | be import-xml --c XYZ - +Or you can append those emails underneath the prexisting comment XYZ-3 + $ be-mbox-to-xml mail.mbox | be import-xml --c XYZ-3 - + +User creates a new bug + user$ be new "The demuxulizer is broken" + Created bug with ID 48f + user$ be comment 48f + <Describe bug> + ... +User exports bug as xml and emails it to the developers + user$ be show --xml 48f > 48f.xml + user$ cat 48f.xml | mail -s "Demuxulizer bug xml" devs@b.com +or equivalently (with a slightly fancier be-handle-mail compatible +email): + user$ be email-bugs 48f +Devs recieve email, and save it's contents as demux-bug.xml + dev$ cat demux-bug.xml | be import-xml - +""" + + +Import_xml = Import_XML # alias for libbe.command.base.get_command_class() + +if libbe.TESTING == True: + class LonghelpTestCase (unittest.TestCase): + """ + Test import scenarios given in longhelp. + """ + def setUp(self): + self.bugdir = libbe.bugdir.SimpleBugDir(memory=False) + io = libbe.command.StringInputOutput() + self.ui = libbe.command.UserInterface(io=io) + self.ui.storage_callbacks.set_storage(self.bugdir.storage) + self.cmd = Import_XML(ui=self.ui) + self.cmd._storage = self.bugdir.storage + self.cmd._setup_io = lambda i_enc,o_enc : None + bugA = self.bugdir.bug_from_uuid('a') + self.bugdir.remove_bug(bugA) + self.bugdir.storage.writeable = False + bugB = self.bugdir.bug_from_uuid('b') + bugB.creator = 'John' + bugB.status = 'open' + bugB.extra_strings += ["don't forget your towel"] + bugB.extra_strings += ['helps with space travel'] + comm1 = bugB.comment_root.new_reply(body='Hello\n') + comm1.uuid = 'c1' + comm1.author = 'Jane' + comm2 = bugB.comment_root.new_reply(body='World\n') + comm2.uuid = 'c2' + comm2.author = 'Jess' + self.bugdir.storage.writeable = True + bugB.save() + self.xml = """ + <be-xml> + <bug> + <uuid>b</uuid> + <status>fixed</status> + <summary>a test bug</summary> + <extra-string>don't forget your towel</extra-string> + <extra-string>watch out for flying dolphins</extra-string> + <comment> + <uuid>c1</uuid> + <body>So long</body> + </comment> + <comment> + <uuid>c3</uuid> + <author>Jed</author> + <body>And thanks</body> + </comment> + </bug> + </be-xml> + """ + def tearDown(self): + self.bugdir.cleanup() + self.ui.cleanup() + def _execute(self, params={}, args=[]): + self.ui.io.set_stdin(self.xml) + self.ui.run(self.cmd, params, args) + self.bugdir.flush_reload() + def testCleanBugdir(self): + uuids = list(self.bugdir.uuids()) + self.failUnless(uuids == ['b'], uuids) + def testNotAddOnly(self): + self._execute({}, ['-']) + uuids = list(self.bugdir.uuids()) + self.failUnless(uuids == ['b'], uuids) + bugB = self.bugdir.bug_from_uuid('b') + self.failUnless(bugB.uuid == 'b', bugB.uuid) + self.failUnless(bugB.creator == 'John', bugB.creator) + self.failUnless(bugB.status == 'fixed', bugB.status) + estrs = ["don't forget your towel", + 'helps with space travel', + 'watch out for flying dolphins'] + self.failUnless(bugB.extra_strings == estrs, bugB.extra_strings) + comments = list(bugB.comments()) + self.failUnless(len(comments) == 3, + ['%s (%s, %s)' % (c.uuid, c.alt_id, c.body) + for c in comments]) + c1 = bugB.comment_from_uuid('c1') + comments.remove(c1) + self.failUnless(c1.uuid == 'c1', c1.uuid) + self.failUnless(c1.alt_id == None, c1.alt_id) + self.failUnless(c1.author == 'Jane', c1.author) + self.failUnless(c1.body == 'So long\n', c1.body) + c2 = bugB.comment_from_uuid('c2') + comments.remove(c2) + self.failUnless(c2.uuid == 'c2', c2.uuid) + self.failUnless(c2.alt_id == None, c2.alt_id) + self.failUnless(c2.author == 'Jess', c2.author) + self.failUnless(c2.body == 'World\n', c2.body) + c4 = comments[0] + self.failUnless(len(c4.uuid) == 36, c4.uuid) + self.failUnless(c4.alt_id == 'c3', c4.alt_id) + self.failUnless(c4.author == 'Jed', c4.author) + self.failUnless(c4.body == 'And thanks\n', c4.body) + def testAddOnly(self): + self._execute({'add-only':True}, ['-']) + uuids = list(self.bugdir.uuids()) + self.failUnless(uuids == ['b'], uuids) + bugB = self.bugdir.bug_from_uuid('b') + self.failUnless(bugB.uuid == 'b', bugB.uuid) + self.failUnless(bugB.creator == 'John', bugB.creator) + self.failUnless(bugB.status == 'open', bugB.status) + estrs = ["don't forget your towel", + 'helps with space travel'] + self.failUnless(bugB.extra_strings == estrs, bugB.extra_strings) + comments = list(bugB.comments()) + self.failUnless(len(comments) == 3, + ['%s (%s)' % (c.uuid, c.alt_id) for c in comments]) + c1 = bugB.comment_from_uuid('c1') + comments.remove(c1) + self.failUnless(c1.uuid == 'c1', c1.uuid) + self.failUnless(c1.alt_id == None, c1.alt_id) + self.failUnless(c1.author == 'Jane', c1.author) + self.failUnless(c1.body == 'Hello\n', c1.body) + c2 = bugB.comment_from_uuid('c2') + comments.remove(c2) + self.failUnless(c2.uuid == 'c2', c2.uuid) + self.failUnless(c2.alt_id == None, c2.alt_id) + self.failUnless(c2.author == 'Jess', c2.author) + self.failUnless(c2.body == 'World\n', c2.body) + c4 = comments[0] + self.failUnless(len(c4.uuid) == 36, c4.uuid) + self.failUnless(c4.alt_id == 'c3', c4.alt_id) + self.failUnless(c4.author == 'Jed', c4.author) + self.failUnless(c4.body == 'And thanks\n', c4.body) + + unitsuite =unittest.TestLoader().loadTestsFromModule(sys.modules[__name__]) + suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()]) diff --git a/libbe/command/init.py b/libbe/command/init.py new file mode 100644 index 0000000..2a78147 --- /dev/null +++ b/libbe/command/init.py @@ -0,0 +1,125 @@ +# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc. +# Gianluca Montecchi <gian@grys.it> +# W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import os.path + +import libbe +import libbe.bugdir +import libbe.command +import libbe.storage + +class Init (libbe.command.Command): + """Create an on-disk bug repository + + >>> import os, sys + >>> import libbe.storage.vcs + >>> import libbe.storage.vcs.base + >>> import libbe.util.utility + >>> io = libbe.command.StringInputOutput() + >>> io.stdout = sys.stdout + >>> ui = libbe.command.UserInterface(io=io) + >>> cmd = Init() + + >>> dir = libbe.util.utility.Dir() + >>> vcs = libbe.storage.vcs.vcs_by_name('None') + >>> vcs.repo = dir.path + >>> try: + ... vcs.connect() + ... except libbe.storage.ConnectionError: + ... 'got error' + 'got error' + >>> ui.storage_callbacks.set_unconnected_storage(vcs) + >>> ui.run(cmd) + No revision control detected. + BE repository initialized. + >>> bd = libbe.bugdir.BugDir(vcs) + >>> vcs.disconnect() + >>> vcs.destroy() + >>> dir.cleanup() + + >>> dir = libbe.util.utility.Dir() + >>> vcs = libbe.storage.vcs.installed_vcs() + >>> vcs.repo = dir.path + >>> vcs._vcs_init(vcs.repo) + >>> ui.storage_callbacks.set_unconnected_storage(vcs) + >>> if vcs.name in libbe.storage.vcs.base.VCS_ORDER: + ... ui.run(cmd) # doctest: +ELLIPSIS + ... else: + ... vcs.init() + ... vcs.connect() + ... print 'Using ... for revision control.\\nDirectory initialized.' + Using ... for revision control. + BE repository initialized. + >>> vcs.disconnect() + >>> vcs.destroy() + >>> dir.cleanup() + """ + name = 'init' + + def __init__(self, *args, **kwargs): + libbe.command.Command.__init__(self, *args, **kwargs) + + def _run(self, **params): + storage = self._get_unconnected_storage() + if not os.path.isdir(storage.repo): + raise libbe.command.UserError( + 'No such directory: %s' % storage.repo) + try: + storage.connect() + raise libbe.command.UserError( + 'Directory already initialized: %s' % storage.repo) + except libbe.storage.ConnectionError: + pass + storage.init() + storage.connect() + bd = libbe.bugdir.BugDir(storage, from_storage=False) + bd.save() + if bd.storage.name is not 'None': + print >> self.stdout, \ + 'Using %s for revision control.' % storage.name + else: + print >> self.stdout, 'No revision control detected.' + print >> self.stdout, 'BE repository initialized.' + + def _long_help(self): + return """ +This command initializes Bugs Everywhere support for the specified directory +and all its subdirectories. It will auto-detect any supported revision control +system. You can use "be set vcs_name" to change the vcs being used. + +The directory defaults to your current working directory, but you can +change that by passing the --repo option to be + $ be --repo path/to/new/bug/root init + +When initialized in a version-controlled directory, BE sinks to the +version-control root. In that case, the BE repository will be created +under that directory, rather than the current directory or the one +passed in --repo. Consider the following tree, versioned in Git. + ~ + `--projectX + |-- .git + `-- src +Calling + ~$ be --repo ./projectX/src init +will create the BE repository rooted in projectX: + ~ + `--projectX + |-- .be + |-- .git + `-- src +""" diff --git a/libbe/command/list.py b/libbe/command/list.py new file mode 100644 index 0000000..3442f42 --- /dev/null +++ b/libbe/command/list.py @@ -0,0 +1,264 @@ +# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc. +# Gianluca Montecchi <gian@grys.it> +# Oleg Romanyshyn <oromanyshyn@panoramicfeedback.com> +# W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import os +import re + +import libbe +import libbe.bug +import libbe.command +import libbe.command.util + +# get a list of * for cmp_*() comparing two bugs. +AVAILABLE_CMPS = [fn[4:] for fn in dir(libbe.bug) if fn[:4] == 'cmp_'] +AVAILABLE_CMPS.remove('attr') # a cmp_* template. + +class Filter (object): + def __init__(self, status, severity, assigned, extra_strings_regexps): + self.status = status + self.severity = severity + self.assigned = assigned + self.extra_strings_regexps = extra_strings_regexps + + def __call__(self, bug): + if self.status != "all" and not bug.status in self.status: + return False + if self.severity != "all" and not bug.severity in self.severity: + return False + if self.assigned != "all" and not bug.assigned in self.assigned: + return False + if len(bug.extra_strings) == 0: + if len(self.extra_strings_regexps) > 0: + return False + else: + for string in bug.extra_strings: + for regexp in self.extra_strings_regexps: + if not regexp.match(string): + return False + return True + +class List (libbe.command.Command): + """List bugs + + >>> import sys + >>> import libbe.bugdir + >>> bd = libbe.bugdir.SimpleBugDir(memory=False) + >>> io = libbe.command.StringInputOutput() + >>> io.stdout = sys.stdout + >>> ui = libbe.command.UserInterface(io=io) + >>> ui.storage_callbacks.set_storage(bd.storage) + >>> cmd = List(ui=ui) + + >>> ret = ui.run(cmd) + abc/a:om: Bug A + >>> ret = ui.run(cmd, {'status':'closed'}) + abc/b:cm: Bug B + >>> bd.storage.writeable + True + >>> ui.cleanup() + >>> bd.cleanup() + """ + + name = 'list' + + def __init__(self, *args, **kwargs): + libbe.command.Command.__init__(self, *args, **kwargs) + self.options.extend([ + libbe.command.Option(name='status', + help='Only show bugs matching the STATUS specifier', + arg=libbe.command.Argument( + name='status', metavar='STATUS', default='active', + completion_callback=libbe.command.util.complete_status)), + libbe.command.Option(name='severity', + help='Only show bugs matching the SEVERITY specifier', + arg=libbe.command.Argument( + name='severity', metavar='SEVERITY', default='all', + completion_callback=libbe.command.util.complete_severity)), + libbe.command.Option(name='assigned', short_name='a', + help='Only show bugs matching ASSIGNED', + arg=libbe.command.Argument( + name='assigned', metavar='ASSIGNED', default='all', + completion_callback=libbe.command.util.complete_assigned)), + libbe.command.Option(name='extra-strings', short_name='e', + help='Only show bugs matching STRINGS, e.g. --extra-strings' + ' TAG:working,TAG:xml', + arg=libbe.command.Argument( + name='extra-strings', metavar='STRINGS', default=None, + completion_callback=libbe.command.util.complete_extra_strings)), + libbe.command.Option(name='sort', short_name='S', + help='Adjust bug-sort criteria with comma-separated list ' + 'SORT. e.g. "--sort creator,time". ' + 'Available criteria: %s' % ','.join(AVAILABLE_CMPS), + arg=libbe.command.Argument( + name='sort', metavar='SORT', default=None, + completion_callback=libbe.command.util.Completer(AVAILABLE_CMPS))), + libbe.command.Option(name='uuids', short_name='u', + help='Only print the bug UUIDS'), + libbe.command.Option(name='xml', short_name='x', + help='Dump output in XML format'), + ]) +# parser.add_option("-S", "--sort", metavar="SORT-BY", dest="sort_by", +# help="Adjust bug-sort criteria with comma-separated list SORT-BY. e.g. \"--sort creator,time\". Available criteria: %s" % ','.join(AVAILABLE_CMPS), default=None) +# # boolean options. All but uuids and xml are special cases of long forms +# ("w", "wishlist", "List bugs with 'wishlist' severity"), +# ("i", "important", "List bugs with >= 'serious' severity"), +# ("A", "active", "List all active bugs"), +# ("U", "unconfirmed", "List unconfirmed bugs"), +# ("o", "open", "List open bugs"), +# ("T", "test", "List bugs in testing"), +# ("m", "mine", "List bugs assigned to you")) +# for s in bools: +# attr = s[1].replace('-','_') +# short = "-%c" % s[0] +# long = "--%s" % s[1] +# help = s[2] +# parser.add_option(short, long, action="store_true", +# dest=attr, help=help, default=False) +# return parser +# +# ]) + + def _run(self, **params): + bugdir = self._get_bugdir() + writeable = bugdir.storage.writeable + bugdir.storage.writeable = False + cmp_list, status, severity, assigned, extra_strings_regexps = \ + self._parse_params(params) + filter = Filter(status, severity, assigned, extra_strings_regexps) + bugs = [bugdir.bug_from_uuid(uuid) for uuid in bugdir.uuids()] + bugs = [b for b in bugs if filter(b) == True] + self.result = bugs + if len(bugs) == 0 and params['xml'] == False: + print >> self.stdout, "No matching bugs found" + + # sort bugs + bugs = self._sort_bugs(bugs, cmp_list) + + # print list of bugs + if params['uuids'] == True: + for bug in bugs: + print >> self.stdout, bug.uuid + else: + self._list_bugs(bugs, xml=params['xml']) + bugdir.storage.writeable = writeable + return 0 + + def _parse_params(self, params): + cmp_list = [] + if params['sort'] != None: + for cmp in params['sort'].sort_by.split(','): + if cmp not in AVAILABLE_CMPS: + raise libbe.command.UserError( + "Invalid sort on '%s'.\nValid sorts:\n %s" + % (cmp, '\n '.join(AVAILABLE_CMPS))) + cmp_list.append(eval('libbe.bug.cmp_%s' % cmp)) + # select status + if params['status'] == 'all': + status = libbe.bug.status_values + elif params['status'] == 'active': + status = list(libbe.bug.active_status_values) + elif params['status'] == 'inactive': + status = list(libbe.bug.inactive_status_values) + else: + status = libbe.command.util.select_values( + params['status'], libbe.bug.status_values) + # select severity + if params['severity'] == 'all': + severity = libbe.bug.severity_values + elif params['important'] == True: + serious = libbe.bug.severity_values.index('serious') + severity.append(list(libbe.bug.severity_values[serious:])) + else: + severity = libbe.command.util.select_values( + params['severity'], bug.severity_values) + # select assigned + if params['assigned'] == "all": + assigned = "all" + else: + possible_assignees = [] + for bug in self.bugdir: + if bug.assigned != None \ + and not bug.assigned in possible_assignees: + possible_assignees.append(bug.assigned) + assigned = libbe.command.util.select_values( + params['assigned'], possible_assignees) + for i in range(len(assigned)): + if assigned[i] == '-': + assigned[i] = params['user-id'] + if params['extra-strings'] == None: + extra_strings_regexps = [] + else: + extra_strings_regexps = [re.compile(x) + for x in params['extra-strings'].split(',')] + return (cmp_list, status, severity, assigned, extra_strings_regexps) + + def _sort_bugs(self, bugs, cmp_list=[]): + cmp_list.extend(libbe.bug.DEFAULT_CMP_FULL_CMP_LIST) + cmp_fn = libbe.bug.BugCompoundComparator(cmp_list=cmp_list) + bugs.sort(cmp_fn) + return bugs + + def _list_bugs(self, bugs, xml=False): + if xml == True: + print >> self.stdout, \ + '<?xml version="1.0" encoding="%s" ?>' % self.stdout.encoding + print >> self.stdout, '<bugs>' + if len(bugs) > 0: + for bug in bugs: + if xml == True: + print >> self.stdout, bug.xml(show_comments=True) + else: + print >> self.stdout, bug.string(shortlist=True) + if xml == True: + print >> self.stdout, '</bugs>' + + def _long_help(self): + return """ +This command lists bugs. Normally it prints a short string like + 576:om: Allow attachments +Where + 576 the bug id + o the bug status is 'open' (first letter) + m the bug severity is 'minor' (first letter) + Allo... the bug summary string + +You can optionally (-u) print only the bug ids. + +There are several criteria that you can filter by: + * status + * severity + * assigned (who the bug is assigned to) +Allowed values for each criterion may be given in a comma seperated +list. The special string "all" may be used with any of these options +to match all values of the criterion. As with the --status and +--severity options for `be depend`, starting the list with a minus +sign makes your selections a blacklist instead of the default +whitelist. + +status + %s +severity + %s +assigned + free form, with the string '-' being a shortcut for yourself. + +In addition, there are some shortcut options that set boolean flags. +The boolean options are ignored if the matching string option is used. +""" % (','.join(libbe.bug.status_values), + ','.join(libbe.bug.severity_values)) diff --git a/libbe/command/merge.py b/libbe/command/merge.py new file mode 100644 index 0000000..0c34f69 --- /dev/null +++ b/libbe/command/merge.py @@ -0,0 +1,189 @@ +# Copyright (C) 2008-2009 Gianluca Montecchi <gian@grys.it> +# W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import copy +import os + +import libbe +import libbe.command +import libbe.command.util + + +class Merge (libbe.command.Command): + """Merge duplicate bugs + + >>> import sys + >>> import libbe.bugdir + >>> import libbe.comment + >>> bd = libbe.bugdir.SimpleBugDir(memory=False) + >>> io = libbe.command.StringInputOutput() + >>> io.stdout = sys.stdout + >>> ui = libbe.command.UserInterface(io=io) + >>> ui.storage_callbacks.set_bugdir(bd) + >>> cmd = Merge(ui=ui) + + >>> a = bd.bug_from_uuid('a') + >>> a.comment_root.time = 0 + >>> dummy = a.new_comment('Testing') + >>> dummy.time = 1 + >>> dummy = dummy.new_reply('Testing...') + >>> dummy.time = 2 + >>> b = bd.bug_from_uuid('b') + >>> b.status = 'open' + >>> b.comment_root.time = 0 + >>> dummy = b.new_comment('1 2') + >>> dummy.time = 1 + >>> dummy = dummy.new_reply('1 2 3 4') + >>> dummy.time = 2 + + >>> ret = ui.run(cmd, args=['/a', '/b']) + Merged bugs #abc/a# and #abc/b# + >>> bd.flush_reload() + >>> a = bd.bug_from_uuid('a') + >>> a.load_comments() + >>> a_comments = sorted([c for c in a.comments()], + ... cmp=libbe.comment.cmp_time) + >>> mergeA = a_comments[0] + >>> mergeA.time = 3 + >>> print a.string(show_comments=True) # doctest: +ELLIPSIS + ID : a + Short name : abc/a + Severity : minor + Status : open + Assigned : + Reporter : + Creator : John Doe <jdoe@example.com> + Created : ... + Bug A + --------- Comment --------- + Name: abc/a/... + From: ... + Date: ... + <BLANKLINE> + Testing + --------- Comment --------- + Name: abc/a/... + From: ... + Date: ... + <BLANKLINE> + Testing... + --------- Comment --------- + Name: abc/a/... + From: ... + Date: ... + <BLANKLINE> + Merged from bug #abc/b# + --------- Comment --------- + Name: abc/a/... + From: ... + Date: ... + <BLANKLINE> + 1 2 + --------- Comment --------- + Name: abc/a/... + From: ... + Date: ... + <BLANKLINE> + 1 2 3 4 + >>> b = bd.bug_from_uuid('b') + >>> b.load_comments() + >>> b_comments = sorted([c for c in b.comments()], + ... libbe.comment.cmp_time) + >>> mergeB = b_comments[0] + >>> mergeB.time = 3 + >>> print b.string(show_comments=True) # doctest: +ELLIPSIS + ID : b + Short name : abc/b + Severity : minor + Status : closed + Assigned : + Reporter : + Creator : Jane Doe <jdoe@example.com> + Created : ... + Bug B + --------- Comment --------- + Name: abc/b/... + From: ... + Date: ... + <BLANKLINE> + 1 2 + --------- Comment --------- + Name: abc/b/... + From: ... + Date: ... + <BLANKLINE> + 1 2 3 4 + --------- Comment --------- + Name: abc/b/... + From: ... + Date: ... + <BLANKLINE> + Merged into bug #abc/a# + >>> print b.status + closed + >>> ui.cleanup() + >>> bd.cleanup() + """ + name = 'merge' + + def __init__(self, *args, **kwargs): + libbe.command.Command.__init__(self, *args, **kwargs) + self.args.extend([ + libbe.command.Argument( + name='bug-id', metavar='BUG-ID', default=None, + completion_callback=libbe.command.util.complete_bug_id), + libbe.command.Argument( + name='bug-id-to-merge', metavar='BUG-ID', default=None, + completion_callback=libbe.command.util.complete_bug_id), + ]) + + def _run(self, **params): + bugdir = self._get_bugdir() + bugA,dummy_comment = \ + libbe.command.util.bug_comment_from_user_id( + bugdir, params['bug-id']) + bugA.load_comments() + bugB,dummy_comment = \ + libbe.command.util.bug_comment_from_user_id( + bugdir, params['bug-id-to-merge']) + bugB.load_comments() + mergeA = bugA.new_comment('Merged from bug #%s#' % bugB.id.long_user()) + newCommTree = copy.deepcopy(bugB.comment_root) + for comment in newCommTree.traverse(): # all descendant comments + comment.bug = bugA + # uuids must be unique in storage + if comment.alt_id == None: + comment.storage = None + comment.alt_id = comment.uuid + comment.storage = bugdir.storage + comment.uuid = libbe.util.id.uuid_gen() + comment.save() # force onto disk under bugA + + for comment in newCommTree: # just the child comments + mergeA.add_reply(comment, allow_time_inversion=True) + bugB.new_comment('Merged into bug #%s#' % bugA.id.long_user()) + bugB.status = 'closed' + print >> self.stdout, 'Merged bugs #%s# and #%s#' \ + % (bugA.id.user(), bugB.id.user()) + return 0 + + def _long_help(self): + return """ +The second bug (B) is merged into the first (A). This adds merge +comments to both bugs, closes B, and appends B's comment tree to A's +merge comment. +""" diff --git a/libbe/command/new.py b/libbe/command/new.py new file mode 100644 index 0000000..a470052 --- /dev/null +++ b/libbe/command/new.py @@ -0,0 +1,97 @@ +# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc. +# Gianluca Montecchi <gian@grys.it> +# W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import libbe +import libbe.command +import libbe.command.util + + +class New (libbe.command.Command): + """Create a new bug + + >>> import os + >>> import sys + >>> import time + >>> import libbe.bugdir + >>> import libbe.util.id + >>> bd = libbe.bugdir.SimpleBugDir(memory=False) + >>> io = libbe.command.StringInputOutput() + >>> io.stdout = sys.stdout + >>> ui = libbe.command.UserInterface(io=io) + >>> ui.storage_callbacks.set_storage(bd.storage) + >>> cmd = New() + + >>> uuid_gen = libbe.util.id.uuid_gen + >>> libbe.util.id.uuid_gen = lambda: 'X' + >>> ret = ui.run(cmd, args=['this is a test',]) + Created bug with ID abc/X + >>> libbe.util.id.uuid_gen = uuid_gen + >>> bd.flush_reload() + >>> bug = bd.bug_from_uuid('X') + >>> print bug.summary + this is a test + >>> bug.time <= int(time.time()) + True + >>> print bug.severity + minor + >>> print bug.status + open + >>> ui.cleanup() + >>> bd.cleanup() + """ + name = 'new' + + def __init__(self, *args, **kwargs): + libbe.command.Command.__init__(self, *args, **kwargs) + self.options.extend([ + libbe.command.Option(name='reporter', short_name='r', + help='The user who reported the bug', + arg=libbe.command.Argument( + name='reporter', metavar='NAME')), + libbe.command.Option(name='assigned', short_name='a', + help='The developer in charge of the bug', + arg=libbe.command.Argument( + name='assigned', metavar='NAME', + completion_callback=libbe.command.util.complete_assigned)), + ]) + self.args.extend([ + libbe.command.Argument(name='summary', metavar='SUMMARY') + ]) + + def _run(self, **params): + if params['summary'] == '-': # read summary from stdin + summary = self.stdin.readline() + else: + summary = params['summary'] + bugdir = self._get_bugdir() + bug = bugdir.new_bug(summary=summary.strip()) + if params['reporter'] != None: + bug.reporter = params['reporter'] + else: + bug.reporter = bug.creator + if params['assigned'] != None: + bug.assigned = params['assigned'] + print >> self.stdout, 'Created bug with ID %s' % bug.id.user() + return 0 + + def _long_help(self): + return """ +Create a new bug, with a new ID. The summary specified on the +commandline is a string (only one line) that describes the bug briefly +or "-", in which case the string will be read from stdin. +""" diff --git a/libbe/command/open.py b/libbe/command/open.py new file mode 100644 index 0000000..a6fe48d --- /dev/null +++ b/libbe/command/open.py @@ -0,0 +1,61 @@ +# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc. +# Gianluca Montecchi <gian@grys.it> +# Marien Zwart <marienz@gentoo.org> +# Thomas Gerigk <tgerigk@gmx.de> +# W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Re-open a bug""" +from libbe import cmdutil, bugdir +__desc__ = __doc__ + +def execute(args, manipulate_encodings=True, restrict_file_access=False, + dir="."): + """ + >>> import os + >>> bd = bugdir.SimpleBugDir() + >>> os.chdir(bd.root) + >>> print bd.bug_from_shortname("b").status + closed + >>> execute(["b"], manipulate_encodings=False) + >>> bd._clear_bugs() + >>> print bd.bug_from_shortname("b").status + open + >>> bd.cleanup() + """ + parser = get_parser() + options, args = parser.parse_args(args) + cmdutil.default_complete(options, args, parser, + bugid_args={0: lambda bug : bug.active==False}) + if len(args) == 0: + raise cmdutil.UsageError, "Please specify a bug id." + if len(args) > 1: + raise cmdutil.UsageError, "Too many arguments." + bd = bugdir.BugDir(from_disk=True, + manipulate_encodings=manipulate_encodings, + root=dir) + bug = cmdutil.bug_from_id(bd, args[0]) + bug.status = "open" + +def get_parser(): + parser = cmdutil.CmdOptionParser("be open BUG-ID") + return parser + +longhelp=""" +Mark a bug as 'open'. +""" + +def help(): + return get_parser().help_str() + longhelp diff --git a/libbe/command/remove.py b/libbe/command/remove.py new file mode 100644 index 0000000..c6d481f --- /dev/null +++ b/libbe/command/remove.py @@ -0,0 +1,79 @@ +# Copyright (C) 2008-2009 Gianluca Montecchi <gian@grys.it> +# W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import libbe +import libbe.command +import libbe.command.util + + +class Remove (libbe.command.Command): + """Remove (delete) a bug and its comments + + >>> import sys + >>> import libbe.bugdir + >>> bd = libbe.bugdir.SimpleBugDir(memory=False) + >>> io = libbe.command.StringInputOutput() + >>> io.stdout = sys.stdout + >>> ui = libbe.command.UserInterface(io=io) + >>> ui.storage_callbacks.set_storage(bd.storage) + >>> cmd = Remove(ui=ui) + + >>> print bd.bug_from_uuid('b').status + closed + >>> ret = ui.run(cmd, args=['/b']) + Removed bug abc/b + >>> bd.flush_reload() + >>> try: + ... bd.bug_from_uuid('b') + ... except libbe.bugdir.NoBugMatches: + ... print 'Bug not found' + Bug not found + >>> ui.cleanup() + >>> bd.cleanup() + """ + name = 'remove' + + def __init__(self, *args, **kwargs): + libbe.command.Command.__init__(self, *args, **kwargs) + self.args.extend([ + libbe.command.Argument( + name='bug-id', metavar='BUG-ID', default=None, + repeatable=True, + completion_callback=libbe.command.util.complete_bug_id), + ]) + + def _run(self, **params): + bugdir = self._get_bugdir() + user_ids = [] + for bug_id in params['bug-id']: + bug,dummy_comment = libbe.command.util.bug_comment_from_user_id( + bugdir, bug_id) + user_ids.append(bug.id.user()) + bugdir.remove_bug(bug) + if len(user_ids) == 1: + print >> self.stdout, 'Removed bug %s' % user_ids[0] + else: + print >> self.stdout, 'Removed bugs %s' % ', '.join(user_ids) + return 0 + + def _long_help(self): + return """ +Remove (delete) existing bugs. Use with caution: if you're not using +a revision control system, there may be no way to recover the lost +information. You should use this command, for example, to get rid of +blank or otherwise mangled bugs. +""" diff --git a/libbe/command/set.py b/libbe/command/set.py new file mode 100644 index 0000000..46a63b4 --- /dev/null +++ b/libbe/command/set.py @@ -0,0 +1,144 @@ +# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc. +# Gianluca Montecchi <gian@grys.it> +# Marien Zwart <marienz@gentoo.org> +# Thomas Gerigk <tgerigk@gmx.de> +# W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + + +import textwrap + +import libbe +import libbe.bugdir +import libbe.command +import libbe.command.util +from libbe.storage.util.settings_object import EMPTY + + +class Set (libbe.command.Command): + """Change bug directory settings + + >>> import sys + >>> import libbe.bugdir + >>> bd = libbe.bugdir.SimpleBugDir(memory=False) + >>> io = libbe.command.StringInputOutput() + >>> io.stdout = sys.stdout + >>> ui = libbe.command.UserInterface(io=io) + >>> ui.storage_callbacks.set_storage(bd.storage) + >>> cmd = Set(ui=ui) + + >>> ret = ui.run(cmd, args=['target']) + None + >>> ret = ui.run(cmd, args=['target', 'abcdefg']) + >>> ret = ui.run(cmd, args=['target']) + abcdefg + >>> ret = ui.run(cmd, args=['target', 'none']) + >>> ret = ui.run(cmd, args=['target']) + None + >>> ui.cleanup() + >>> bd.cleanup() + """ + name = 'set' + + def __init__(self, *args, **kwargs): + libbe.command.Command.__init__(self, *args, **kwargs) + self.args.extend([ + libbe.command.Argument( + name='setting', metavar='SETTING', optional=True, + completion_callback=complete_bugdir_settings), + libbe.command.Argument( + name='value', metavar='VALUE', optional=True) + ]) + + def _run(self, **params): + bugdir = self._get_bugdir() + if params['setting'] == None: + keys = bugdir.settings_properties + keys.sort() + for key in keys: + print >> self.stdout, \ + '%16s: %s' % (key, _value_string(bugdir, key)) + return 0 + if params['setting'] not in bugdir.settings_properties: + msg = 'Invalid setting %s\n' % params['setting'] + msg += 'Allowed settings:\n ' + msg += '\n '.join(bugdir.settings_properties) + raise libbe.command.UserError(msg) + if params['value'] == None: + print _value_string(bugdir, params['setting']) + else: + if params['value'] == 'none': + params['value'] = EMPTY + old_setting = bugdir.settings.get(params['setting']) + attr = bugdir._setting_name_to_attr_name(params['setting']) + setattr(bugdir, attr, params['value']) + return 0 + + def _long_help(self): + return """ +Show or change per-tree settings. + +If name and value are supplied, the name is set to a new value. +If no value is specified, the current value is printed. +If no arguments are provided, all names and values are listed. + +To unset a setting, set it to "none". + +Allowed settings are: + +%s""" % ('\n'.join(get_bugdir_settings()),) + +def get_bugdir_settings(): + settings = [] + for s in libbe.bugdir.BugDir.settings_properties: + settings.append(s) + settings.sort() + documented_settings = [] + for s in settings: + set = getattr(libbe.bugdir.BugDir, s) + dstr = set.__doc__.strip() + # per-setting comment adjustments + if s == 'vcs_name': + lines = dstr.split('\n') + while lines[0].startswith('This property defaults to') == False: + lines.pop(0) + assert len(lines) != None, \ + 'Unexpected vcs_name docstring:\n "%s"' % dstr + lines.insert( + 0, 'The name of the revision control system to use.\n') + dstr = '\n'.join(lines) + doc = textwrap.wrap(dstr, width=70, initial_indent=' ', + subsequent_indent=' ') + documented_settings.append('%s\n%s' % (s, '\n'.join(doc))) + return documented_settings + +def _value_string(bugdir, setting): + val = bugdir.settings.get(setting, EMPTY) + if val == EMPTY: + default = getattr(bugdir, bugdir._setting_name_to_attr_name(setting)) + if default not in [None, EMPTY]: + val = 'None (%s)' % default + else: + val = None + return str(val) + +def complete_bugdir_settings(command, argument, fragment=None): + """ + List possible command completions for fragment. + + Neither the command nor argument arguments are used. + """ + return libbe.bugdir.BugDir.settings_properties diff --git a/libbe/command/severity.py b/libbe/command/severity.py new file mode 100644 index 0000000..3587325 --- /dev/null +++ b/libbe/command/severity.py @@ -0,0 +1,98 @@ +# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc. +# Gianluca Montecchi <gian@grys.it> +# Marien Zwart <marienz@gentoo.org> +# Thomas Gerigk <tgerigk@gmx.de> +# W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import libbe +import libbe.bug +import libbe.command +import libbe.command.util + + +class Severity (libbe.command.Command): + """Change a bug's severity level + + >>> import sys + >>> import libbe.bugdir + >>> bd = libbe.bugdir.SimpleBugDir(memory=False) + >>> io = libbe.command.StringInputOutput() + >>> io.stdout = sys.stdout + >>> ui = libbe.command.UserInterface(io=io) + >>> ui.storage_callbacks.set_bugdir(bd) + >>> cmd = Severity(ui=ui) + + >>> bd.bug_from_uuid('a').severity + 'minor' + >>> ret = ui.run(cmd, args=['wishlist', '/a']) + >>> bd.flush_reload() + >>> bd.bug_from_uuid('a').severity + 'wishlist' + >>> ret = ui.run(cmd, args=['none', '/a']) + Traceback (most recent call last): + UserError: Invalid severity level: none + >>> ui.cleanup() + >>> bd.cleanup() + """ + name = 'severity' + + def __init__(self, *args, **kwargs): + libbe.command.Command.__init__(self, *args, **kwargs) + self.args.extend([ + libbe.command.Argument( + name='severity', metavar='SEVERITY', default=None, + completion_callback=libbe.command.util.complete_severity), + libbe.command.Argument( + name='bug-id', metavar='BUG-ID', default=None, + repeatable=True, + completion_callback=libbe.command.util.complete_bug_id), + ]) + + def _run(self, **params): + bugdir = self._get_bugdir() + for bug_id in params['bug-id']: + bug,dummy_comment = \ + libbe.command.util.bug_comment_from_user_id(bugdir, bug_id) + if bug.severity != params['severity']: + try: + bug.severity = params['severity'] + except ValueError, e: + if e.name != 'severity': + raise e + raise libbe.command.UserError( + 'Invalid severity level: %s' % e.value) + return 0 + + def _long_help(self): + ret = [""" +Show or change a bug's severity level. + +If no severity is specified, the current value is printed. If a severity level +is specified, it will be assigned to the bug. + +Severity levels are: +"""] + try: # See if there are any per-tree severity configurations + bd = self._get_bugdir() + except NotImplementedError: + pass # No tree, just show the defaults + longest_severity_len = max([len(s) for s in libbe.bug.severity_values]) + for severity in libbe.bug.severity_values : + description = libbe.bug.severity_description[severity] + ret.append('%*s : %s\n' \ + % (longest_severity_len, severity, description)) + return ''.join(ret) diff --git a/libbe/command/show.py b/libbe/command/show.py new file mode 100644 index 0000000..5ab6dc7 --- /dev/null +++ b/libbe/command/show.py @@ -0,0 +1,207 @@ +# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc. +# Gianluca Montecchi <gian@grys.it> +# Thomas Gerigk <tgerigk@gmx.de> +# Thomas Habets <thomas@habets.pp.se> +# W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import sys + +import libbe +import libbe.command +import libbe.command.util +import libbe.util.id +import libbe.version +import libbe._version + + +class Show (libbe.command.Command): + """Show a particular bug, comment, or combination of both. + + >>> import sys + >>> import libbe.bugdir + >>> bd = libbe.bugdir.SimpleBugDir(memory=False) + >>> io = libbe.command.StringInputOutput() + >>> io.stdout = sys.stdout + >>> io.stdout.encoding = 'ascii' + >>> ui = libbe.command.UserInterface(io=io) + >>> ui.storage_callbacks.set_bugdir(bd) + >>> cmd = Show(ui=ui) + + >>> ret = ui.run(cmd, args=['/a',]) # doctest: +ELLIPSIS + ID : a + Short name : abc/a + Severity : minor + Status : open + Assigned : + Reporter : + Creator : John Doe <jdoe@example.com> + Created : ... + Bug A + <BLANKLINE> + + >>> ret = ui.run(cmd, {'xml':True}, ['/a']) # doctest: +ELLIPSIS + <?xml version="1.0" encoding="..." ?> + <be-xml> + <version> + <tag>...</tag> + <branch-nick>...</branch-nick> + <revno>...</revno> + <revision-id>...</revision-id> + </version> + <bug> + <uuid>a</uuid> + <short-name>abc/a</short-name> + <severity>minor</severity> + <status>open</status> + <creator>John Doe <jdoe@example.com></creator> + <created>Thu, 01 Jan 1970 00:00:00 +0000</created> + <summary>Bug A</summary> + </bug> + </be-xml> + >>> ui.cleanup() + >>> bd.cleanup() + """ + name = 'show' + + def __init__(self, *args, **kwargs): + libbe.command.Command.__init__(self, *args, **kwargs) + self.options.extend([ + libbe.command.Option(name='xml', short_name='x', + help='Dump as XML'), + libbe.command.Option(name='only-raw-body', + help="When printing only a single comment, just print it's" + " body. This allows extraction of non-text content types."), + libbe.command.Option(name='no-comments', short_name='c', + help="Disable comment output. This is useful if you just " + "want more details on a bug's current status."), + ]) + self.args.extend([ + libbe.command.Argument( + name='id', metavar='ID', default=None, + optional=True, repeatable=True, + completion_callback=libbe.command.util.complete_bug_comment_id), + ]) + + def _run(self, **params): + bugdir = self._get_bugdir() + if params['only-raw-body'] == True: + if len(params['id']) != 1: + raise libbe.command.UsageError( + 'only one ID accepted with --only-raw-body') + bug,comment = libbe.command.util.bug_comment_from_user_id( + bugdir, params['id'][0]) + if comment == bug.comment_root: + raise libbe.command.UsageError( + "--only-raw-body requires a comment ID, not '%s'" + % params['id'][0]) + sys.__stdout__.write(comment.body) + return 0 + print >> self.stdout, \ + output(bugdir, params['id'], encoding=self.stdout.encoding, + as_xml=params['xml'], + with_comments=not params['no-comments']) + return 0 + + def _long_help(self): + return """ +Show all information about the bugs or comments whose IDs are given. +If no IDs are given, show the entire repository. + +Without the --xml flag set, it's probably not a good idea to mix bug +and comment IDs in a single call, but you're free to do so if you +like. With the --xml flag set, there will never be any root comments, +so mix and match away (the bug listings for directly requested +comments will be restricted to the bug uuid and the requested +comment(s)). + +Directly requested comments will be grouped by their parent bug and +placed at the end of the output, so the ordering may not match the +order of the listed IDs. +""" + +def _sort_ids(bugdir, ids, with_comments=True): + bugs = [] + root_comments = {} + for id in ids: + p = libbe.util.id.parse_user(bugdir, id) + if p['type'] == 'bug': + bugs.append(p['bug']) + elif with_comments == True: + if p['bug'] not in root_comments: + root_comments[p['bug']] = [p['comment']] + else: + root_comments[p['bug']].append(p['comment']) + for bugname in root_comments.keys(): + assert bugname not in bugs, \ + 'specifically requested both #/%s/%s# and #/%s#' \ + % (bugname, root_comments[bugname][0], bugname) + return (bugs, root_comments) + +def _xml_header(encoding): + lines = ['<?xml version="1.0" encoding="%s" ?>' % encoding, + '<be-xml>', + ' <version>', + ' <tag>%s</tag>' % libbe.version.version()] + for tag in ['branch-nick', 'revno', 'revision-id']: + value = libbe._version.version_info[tag.replace('-', '_')] + lines.append(' <%s>%s</%s>' % (tag, value, tag)) + lines.append(' </version>') + return lines + +def _xml_footer(): + return ['</be-xml>'] + +def output(bd, ids, encoding, as_xml=True, with_comments=True): + if len(ids) == 0: + bd.load_all_bugs() + ids = [bug.id.user() for bug in bd] + bugs,root_comments = _sort_ids(bd, ids, with_comments) + lines = [] + if as_xml: + lines.extend(_xml_header(encoding)) + else: + spaces_left = len(ids) - 1 + for bugname in bugs: + bug = bd.bug_from_uuid(bugname) + if as_xml: + lines.append(bug.xml(indent=2, show_comments=with_comments)) + else: + lines.append(bug.string(show_comments=with_comments)) + if spaces_left > 0: + spaces_left -= 1 + lines.append('') # add a blank line between bugs/comments + for bugname,comments in root_comments.items(): + bug = bd.bug_from_uuid(bugname) + if as_xml: + lines.extend([' <bug>', ' <uuid>%s</uuid>' % bug.uuid]) + for commname in comments: + try: + comment = bug.comment_root.comment_from_uuid(commname) + except KeyError, e: + raise libbe.command.UserError(e.message) + if as_xml: + lines.append(comment.xml(indent=4)) + else: + lines.append(comment.string()) + if spaces_left > 0: + spaces_left -= 1 + lines.append('') # add a blank line between bugs/comments + if as_xml: + lines.append('</bug>') + if as_xml: + lines.extend(_xml_footer()) + return '\n'.join(lines) diff --git a/libbe/command/status.py b/libbe/command/status.py new file mode 100644 index 0000000..57a44aa --- /dev/null +++ b/libbe/command/status.py @@ -0,0 +1,108 @@ +# Copyright (C) 2008-2009 Gianluca Montecchi <gian@grys.it> +# W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import libbe +import libbe.bug +import libbe.command +import libbe.command.util + + +class Status (libbe.command.Command): + """Change a bug's status level + + >>> import sys + >>> import libbe.bugdir + >>> bd = libbe.bugdir.SimpleBugDir(memory=False) + >>> io = libbe.command.StringInputOutput() + >>> io.stdout = sys.stdout + >>> ui = libbe.command.UserInterface(io=io) + >>> ui.storage_callbacks.set_bugdir(bd) + >>> cmd = Status(ui=ui) + >>> cmd._storage = bd.storage + + >>> bd.bug_from_uuid('a').status + 'open' + >>> ret = ui.run(cmd, args=['closed', '/a']) + >>> bd.flush_reload() + >>> bd.bug_from_uuid('a').status + 'closed' + >>> ret = ui.run(cmd, args=['none', '/a']) + Traceback (most recent call last): + UserError: Invalid status level: none + >>> ui.cleanup() + >>> bd.cleanup() + """ + name = 'status' + + def __init__(self, *args, **kwargs): + libbe.command.Command.__init__(self, *args, **kwargs) + self.args.extend([ + libbe.command.Argument( + name='status', metavar='STATUS', default=None, + completion_callback=libbe.command.util.complete_status), + libbe.command.Argument( + name='bug-id', metavar='BUG-ID', default=None, + repeatable=True, + completion_callback=libbe.command.util.complete_bug_id), + ]) + + def _run(self, **params): + bugdir = self._get_bugdir() + for bug_id in params['bug-id']: + bug,dummy_comment = \ + libbe.command.util.bug_comment_from_user_id(bugdir, bug_id) + if bug.status != params['status']: + try: + bug.status = params['status'] + except ValueError, e: + if e.name != 'status': + raise e + raise libbe.command.UserError( + 'Invalid status level: %s' % e.value) + return 0 + + def _long_help(self): + longest_status_len = max([len(s) for s in libbe.bug.status_values]) + active_statuses = [] + for status in libbe.bug.active_status_values : + description = libbe.bug.status_description[status] + s = '%*s : %s' % (longest_status_len, status, description) + active_statuses.append(s) + inactive_statuses = [] + for status in libbe.bug.inactive_status_values : + description = libbe.bug.status_description[status] + s = '%*s : %s' % (longest_status_len, status, description) + inactive_statuses.append(s) + ret = """ +Show or change a bug's status. + +If no status is specified, the current value is printed. If a status +is specified, it will be assigned to the bug. + +There are two classes of statuses, active and inactive, which are only +important for commands like "be list" that show only active bugs by +default. + +Active status levels are: + %s +Inactive status levels are: + %s + +You can overide the list of allowed statuses on a per-repository basis. +See "be set --help" for more details. +""" % ('\n '.join(active_statuses), '\n '.join(inactive_statuses)) + return ret diff --git a/libbe/command/subscribe.py b/libbe/command/subscribe.py new file mode 100644 index 0000000..78d6fe0 --- /dev/null +++ b/libbe/command/subscribe.py @@ -0,0 +1,385 @@ +# Copyright (C) 2009 W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import copy +import os + +import libbe +import libbe.bug +import libbe.command +import libbe.diff +import libbe.command.util +import libbe.util.id +import libbe.util.tree + + +TAG="SUBSCRIBE:" + + +class Subscribe (libbe.command.Command): + """(Un)subscribe to change notification + + >>> import sys + >>> import libbe.bugdir + >>> bd = libbe.bugdir.SimpleBugDir(memory=False) + >>> io = libbe.command.StringInputOutput() + >>> io.stdout = sys.stdout + >>> ui = libbe.command.UserInterface(io=io) + >>> ui.storage_callbacks.set_bugdir(bd) + >>> cmd = Subscribe(ui=ui) + + >>> a = bd.bug_from_uuid('a') + >>> print a.extra_strings + [] + >>> ret = ui.run(cmd, {'subscriber':'John Doe <j@doe.com>'}, ['/a']) # doctest: +NORMALIZE_WHITESPACE + Subscriptions for abc/a: + John Doe <j@doe.com> all * + >>> bd.flush_reload() + >>> a = bd.bug_from_uuid('a') + >>> print a.extra_strings + ['SUBSCRIBE:John Doe <j@doe.com>\\tall\\t*'] + >>> ret = ui.run(cmd, {'subscriber':'Jane Doe <J@doe.com>', 'servers':'a.com,b.net'}, ['/a']) # doctest: +NORMALIZE_WHITESPACE + Subscriptions for abc/a: + Jane Doe <J@doe.com> all a.com,b.net + John Doe <j@doe.com> all * + >>> ret = ui.run(cmd, {'subscriber':'Jane Doe <J@doe.com>', 'servers':'a.edu'}, ['/a']) # doctest: +NORMALIZE_WHITESPACE + Subscriptions for abc/a: + Jane Doe <J@doe.com> all a.com,a.edu,b.net + John Doe <j@doe.com> all * + >>> ret = ui.run(cmd, {'unsubscribe':True, 'subscriber':'Jane Doe <J@doe.com>', 'servers':'a.com'}, ['/a']) # doctest: +NORMALIZE_WHITESPACE + Subscriptions for abc/a: + Jane Doe <J@doe.com> all a.edu,b.net + John Doe <j@doe.com> all * + >>> ret = ui.run(cmd, {'subscriber':'Jane Doe <J@doe.com>', 'servers':'*'}, ['/a']) # doctest: +NORMALIZE_WHITESPACE + Subscriptions for abc/a: + Jane Doe <J@doe.com> all * + John Doe <j@doe.com> all * + >>> ret = ui.run(cmd, {'unsubscribe':True, 'subscriber':'Jane Doe <J@doe.com>'}, ['/a']) # doctest: +NORMALIZE_WHITESPACE + Subscriptions for abc/a: + John Doe <j@doe.com> all * + >>> ret = ui.run(cmd, {'unsubscribe':True, 'subscriber':'John Doe <j@doe.com>'}, ['/a']) + >>> ret = ui.run(cmd, {'subscriber':'Jane Doe <J@doe.com>', 'types':'new'}, ['DIR']) # doctest: +NORMALIZE_WHITESPACE + Subscriptions for bug directory: + Jane Doe <J@doe.com> new * + >>> ret = ui.run(cmd, {'subscriber':'Jane Doe <J@doe.com>'}, ['DIR']) # doctest: +NORMALIZE_WHITESPACE + Subscriptions for bug directory: + Jane Doe <J@doe.com> all * + >>> ui.cleanup() + >>> bd.cleanup() + """ + name = 'subscribe' + + def __init__(self, *args, **kwargs): + libbe.command.Command.__init__(self, *args, **kwargs) + self.options.extend([ + libbe.command.Option(name='unsubscribe', short_name='u', + help='Unsubscribe instead of subscribing'), + libbe.command.Option(name='list-all', short_name='a', + help='List all subscribers (no ID argument, read only action)'), + libbe.command.Option(name='list', short_name='l', + help='List subscribers (read only action).'), + libbe.command.Option(name='subscriber', short_name='s', + help='Email address of the subscriber (defaults to bugdir.user_id).', + arg=libbe.command.Argument( + name='subscriber', metavar='EMAIL')), + libbe.command.Option(name='servers', short_name='S', + help='Servers from which you want notification.', + arg=libbe.command.Argument( + name='servers', metavar='STRING')), + libbe.command.Option(name='types', short_name='t', + help='Types of changes you wish to be notified about.', + arg=libbe.command.Argument( + name='types', metavar='STRING')), + ]) + self.args.extend([ + libbe.command.Argument( + name='id', metavar='ID', default=tuple(), + optional=True, repeatable=True, + completion_callback=libbe.command.util.complete_bug_comment_id), + ]) + + def _run(self, **params): + bugdir = self._get_bugdir() + if params['list-all'] == True or params['list'] == True: + writeable = bugdir.storage.writeable + bugdir.storage.writeable = False + if params['list-all'] == True: + assert len(params['id']) == 0, params['id'] + subscriber = params['subscriber'] + if subscriber == None: + subscriber = self._get_user_id() + if params['unsubscribe'] == True: + if params['servers'] == None: + params['servers'] = 'INVALID' + if params['types'] == None: + params['types'] = 'INVALID' + else: + if params['servers'] == None: + params['servers'] = '*' + if params['types'] == None: + params['types'] = 'all' + servers = params['servers'].split(',') + types = params['types'].split(',') + + if len(params['id']) == 0: + params['id'] = [libbe.diff.BUGDIR_ID] + for _id in params['id']: + if _id == libbe.diff.BUGDIR_ID: # directory-wide subscriptions + type_root = libbe.diff.BUGDIR_TYPE_ALL + entity = bugdir + entity_name = 'bug directory' + else: # bug-specific subscriptions + type_root = libbe.diff.BUG_TYPE_ALL + bug,dummy_comment = libbe.command.util.bug_comment_from_user_id( + bugdir, _id) + entity = bug + entity_name = bug.id.user() + if params['list-all'] == True: + entity_name = 'anything in the bug directory' + types = [libbe.diff.type_from_name(name, type_root, default=libbe.diff.INVALID_TYPE, + default_ok=params['unsubscribe']) + for name in types] + estrs = entity.extra_strings + if params['list'] == True or params['list-all'] == True: + pass + else: # alter subscriptions + if params['unsubscribe'] == True: + estrs = unsubscribe(estrs, subscriber, types, servers, type_root) + else: # add the tag + estrs = subscribe(estrs, subscriber, types, servers, type_root) + entity.extra_strings = estrs # reassign to notice change + + if params['list-all'] == True: + bugdir.load_all_bugs() + subscriptions = get_bugdir_subscribers(bugdir, servers[0]) + else: + subscriptions = [] + for estr in entity.extra_strings: + if estr.startswith(TAG): + subscriptions.append(estr[len(TAG):]) + + if len(subscriptions) > 0: + print >> self.stdout, 'Subscriptions for %s:' % entity_name + print >> self.stdout, '\n'.join(subscriptions) + if params['list-all'] == True or params['list'] == True: + bugdir.storage.writeable = writeable + return 0 + + def _long_help(self): + return """ +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 %s: +%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. +""" % (libbe.diff.BUG_TYPE_ALL.string_tree(6), libbe.diff.BUGDIR_ID, + libbe.diff.BUGDIR_TYPE_ALL.string_tree(6), + libbe.diff.BUGDIR_TYPE_ALL) + + +# 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 = [libbe.diff.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 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>", [libbe.diff.BUGDIR_TYPE_ALL], + ... ["a.com"], libbe.diff.BUGDIR_TYPE_ALL) + >>> es = subscribe(es, "Jane Doe <J@doe.com>", [libbe.diff.BUGDIR_TYPE_NEW], + ... ["*"], libbe.diff.BUGDIR_TYPE_ALL) + >>> sgs(es, libbe.diff.BUGDIR_TYPE_ALL, "a.com", libbe.diff.BUGDIR_TYPE_ALL) + ['John Doe <j@doe.com>'] + >>> sgs(es, libbe.diff.BUGDIR_TYPE_ALL, "a.com", libbe.diff.BUGDIR_TYPE_ALL, + ... match_descendant_types=True) + ['Jane Doe <J@doe.com>', 'John Doe <j@doe.com>'] + >>> sgs(es, libbe.diff.BUGDIR_TYPE_ALL, "b.net", libbe.diff.BUGDIR_TYPE_ALL, + ... match_descendant_types=True) + ['Jane Doe <J@doe.com>'] + >>> sgs(es, libbe.diff.BUGDIR_TYPE_NEW, "a.com", libbe.diff.BUGDIR_TYPE_ALL) + ['Jane Doe <J@doe.com>'] + >>> sgs(es, libbe.diff.BUGDIR_TYPE_NEW, "a.com", libbe.diff.BUGDIR_TYPE_ALL, + ... match_ancestor_types=True) + ['Jane Doe <J@doe.com>', 'John Doe <j@doe.com>'] + """ + for string in extra_strings: + if not string.startswith(TAG): + continue + subscriber,types,servers = _parse_string(string, type_root) + type_match = False + if type in types: + type_match = True + if type_match == False and match_ancestor_types == True: + for t in types: + if t.has_descendant(type): + type_match = True + break + if type_match == False and match_descendant_types == True: + for t in types: + if type.has_descendant(t): + type_match = True + break + server_match = False + if server in servers or servers == ["*"] or server == "*": + server_match = True + if type_match == True and server_match == True: + yield subscriber + +def get_bugdir_subscribers(bugdir, server): + """ + I have a bugdir. Who cares about it, and what do they care about? + Returns a dict of dicts: + subscribers[user][id] = types + where id is either a bug.uuid (in the case of a bug subscription) + or "%(bugdir_id)s" (in the case of a bugdir subscription). + + Only checks bugs that are currently in memory, so you might want + to call bugdir.load_all_bugs() first. + + >>> bd = bugdir.SimpleBugDir(sync_with_disk=False) + >>> a = bd.bug_from_shortname("a") + >>> bd.extra_strings = subscribe(bd.extra_strings, "John Doe <j@doe.com>", + ... [libbe.diff.BUGDIR_TYPE_ALL], ["a.com"], libbe.diff.BUGDIR_TYPE_ALL) + >>> bd.extra_strings = subscribe(bd.extra_strings, "Jane Doe <J@doe.com>", + ... [libbe.diff.BUGDIR_TYPE_NEW], ["*"], libbe.diff.BUGDIR_TYPE_ALL) + >>> a.extra_strings = subscribe(a.extra_strings, "John Doe <j@doe.com>", + ... [libbe.diff.BUG_TYPE_ALL], ["a.com"], libbe.diff.BUG_TYPE_ALL) + >>> subscribers = get_bugdir_subscribers(bd, "a.com") + >>> subscribers["Jane Doe <J@doe.com>"]["%(bugdir_id)s"] + [<SubscriptionType: new>] + >>> subscribers["John Doe <j@doe.com>"]["%(bugdir_id)s"] + [<SubscriptionType: all>] + >>> subscribers["John Doe <j@doe.com>"]["a"] + [<SubscriptionType: all>] + >>> get_bugdir_subscribers(bd, "b.net") + {'Jane Doe <J@doe.com>': {'%(bugdir_id)s': [<SubscriptionType: new>]}} + >>> bd.cleanup() + """ % {'bugdir_id':libbe.diff.BUGDIR_ID} + subscribers = {} + for sub in get_subscribers(bugdir.extra_strings, libbe.diff.BUGDIR_TYPE_ALL, + server, libbe.diff.BUGDIR_TYPE_ALL, + match_descendant_types=True): + i,s,ts,srvs = _get_subscriber(bugdir.extra_strings, sub, + libbe.diff.BUGDIR_TYPE_ALL) + subscribers[sub] = {"DIR":ts} + for bug in bugdir: + for sub in get_subscribers(bug.extra_strings, libbe.diff.BUG_TYPE_ALL, + server, libbe.diff.BUG_TYPE_ALL, + match_descendant_types=True): + i,s,ts,srvs = _get_subscriber(bug.extra_strings, sub, + libbe.diff.BUG_TYPE_ALL) + if sub in subscribers: + subscribers[sub][bug.uuid] = ts + else: + subscribers[sub] = {bug.uuid:ts} + return subscribers diff --git a/libbe/command/tag.py b/libbe/command/tag.py new file mode 100644 index 0000000..87589c0 --- /dev/null +++ b/libbe/command/tag.py @@ -0,0 +1,152 @@ +# Copyright (C) 2009 Gianluca Montecchi <gian@grys.it> +# W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import libbe +import libbe.command +import libbe.command.util + + +TAG_TAG = 'TAG:' + + +class Tag (libbe.command.Command): + __doc__ = """Tag a bug, or search bugs for tags + + >>> import sys + >>> import libbe.bugdir + >>> bd = libbe.bugdir.SimpleBugDir(memory=False) + >>> io = libbe.command.StringInputOutput() + >>> io.stdout = sys.stdout + >>> ui = libbe.command.UserInterface(io=io) + >>> ui.storage_callbacks.set_bugdir(bd) + >>> cmd = Tag(ui=ui) + + >>> a = bd.bug_from_uuid('a') + >>> print a.extra_strings + [] + >>> ret = ui.run(cmd, args=['/a', 'GUI']) + Tags for abc/a: + GUI + >>> bd.flush_reload() + >>> a = bd.bug_from_uuid('a') + >>> print a.extra_strings + ['%(tag_tag)sGUI'] + >>> ret = ui.run(cmd, args=['/a', 'later']) + Tags for abc/a: + GUI + later + >>> ret = ui.run(cmd, args=['/a']) + Tags for abc/a: + GUI + later + >>> ret = ui.run(cmd, {'list':True}) + GUI + later + >>> ret = ui.run(cmd, args=['/a', 'Alphabetically first']) + Tags for abc/a: + Alphabetically first + GUI + later + >>> bd.flush_reload() + >>> a = bd.bug_from_uuid('a') + >>> print a.extra_strings + ['%(tag_tag)sAlphabetically first', '%(tag_tag)sGUI', '%(tag_tag)slater'] + >>> a.extra_strings = [] + >>> print a.extra_strings + [] + >>> ret = ui.run(cmd, args=['/a']) + >>> bd.flush_reload() + >>> a = bd.bug_from_uuid('a') + >>> print a.extra_strings + [] + >>> ret = ui.run(cmd, args=['/a', 'Alphabetically first']) + Tags for abc/a: + Alphabetically first + >>> ret = ui.run(cmd, {'remove':True}, ['/a', 'Alphabetically first']) + >>> ui.cleanup() + >>> bd.cleanup() + """ % {'tag_tag':TAG_TAG} + name = 'tag' + + def __init__(self, *args, **kwargs): + libbe.command.Command.__init__(self, *args, **kwargs) + self.options.extend([ + libbe.command.Option(name='remove', short_name='r', + help='Remove TAG (instead of adding it)'), + libbe.command.Option(name='list', short_name='l', + help='List all available tags and exit'), + ]) + self.args.extend([ + libbe.command.Argument( + name='id', metavar='BUG-ID', optional=True, + completion_callback=libbe.command.util.complete_bug_id), + libbe.command.Argument( + name='tag', metavar='TAG', default=tuple(), + optional=True, repeatable=True), + ]) + + def _run(self, **params): + if params['id'] == None and params['list'] == False: + raise libbe.command.UserError('Please specify a bug id.') + if params['id'] != None and params['list'] == True: + raise libbe.command.UserError( + 'Do not specify a bug id with the --list option.') + bugdir = self._get_bugdir() + if params['list'] == True: + bugdir.load_all_bugs() + tags = [] + for bug in bugdir: + for estr in bug.extra_strings: + if estr.startswith(TAG_TAG): + tag = estr[len(TAG_TAG):] + if tag not in tags: + tags.append(tag) + tags.sort() + if len(tags) > 0: + print >> self.stdout, '\n'.join(tags) + return 0 + + bug,dummy_comment = libbe.command.util.bug_comment_from_user_id( + bugdir, params['id']) + if len(params['tag']) > 0: + estrs = bug.extra_strings + for tag in params['tag']: + tag_string = '%s%s' % (TAG_TAG, tag) + if params['remove'] == True: + estrs.remove(tag_string) + else: # add the tag + estrs.append(tag_string) + bug.extra_strings = estrs # reassign to notice change + + tags = [] + for estr in bug.extra_strings: + if estr.startswith(TAG_TAG): + tags.append(estr[len(TAG_TAG):]) + + if len(tags) > 0: + print "Tags for %s:" % bug.id.user() + print '\n'.join(tags) + return 0 + + def _long_help(self): + return """ +If TAG is given, add TAG to BUG-ID. If it is not specified, just +print the tags for BUG-ID. + +To search for bugs with a particular tag, try + $ be list --extra-strings %s<your-tag> +""" % TAG_TAG diff --git a/libbe/command/target.py b/libbe/command/target.py new file mode 100644 index 0000000..9f8feae --- /dev/null +++ b/libbe/command/target.py @@ -0,0 +1,199 @@ +# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc. +# Chris Ball <cjb@laptop.org> +# Gianluca Montecchi <gian@grys.it> +# Marien Zwart <marienz@gentoo.org> +# Thomas Gerigk <tgerigk@gmx.de> +# W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import libbe +import libbe.command +import libbe.command.util +import libbe.command.depend + + +class Target (libbe.command.Command): + """Assorted bug target manipulations and queries + + >>> import os, StringIO, sys + >>> import libbe.bugdir + >>> bd = libbe.bugdir.SimpleBugDir(memory=False) + >>> io = libbe.command.StringInputOutput() + >>> io.stdout = sys.stdout + >>> ui = libbe.command.UserInterface(io=io) + >>> ui.storage_callbacks.set_storage(bd.storage) + >>> cmd = Target(ui=ui) + + >>> ret = ui.run(cmd, args=['/a']) + No target assigned. + >>> ret = ui.run(cmd, args=['/a', 'tomorrow']) + >>> ret = ui.run(cmd, args=['/a']) + tomorrow + + >>> ui.io.stdout = StringIO.StringIO() + >>> ret = ui.run(cmd, {'resolve':True}, ['tomorrow']) + >>> output = ui.io.get_stdout().strip() + >>> target = bd.bug_from_uuid(output) + >>> print target.summary + tomorrow + >>> print target.severity + target + + >>> ui.io.stdout = sys.stdout + >>> ret = ui.run(cmd, args=['/a', 'none']) + >>> ret = ui.run(cmd, args=['/a']) + No target assigned. + >>> ui.cleanup() + >>> bd.cleanup() + """ + name = 'target' + + def __init__(self, *args, **kwargs): + libbe.command.Command.__init__(self, *args, **kwargs) + self.options.extend([ + libbe.command.Option(name='resolve', short_name='r', + help="Print the UUID for the target bug whose summary " + "matches TARGET. If TARGET is not given, print the UUID " + "of the current bugdir target."), + ]) + self.args.extend([ + libbe.command.Argument( + name='id', metavar='BUG-ID', optional=True, + completion_callback=libbe.command.util.complete_bug_id), + libbe.command.Argument( + name='target', metavar='TARGET', optional=True, + completion_callback=complete_target), + ]) + + def _run(self, **params): + if params['resolve'] == False: + if params['id'] == None: + raise libbe.command.UserError('Please specify a bug id.') + else: + if params['target'] != None: + raise libbe.command.UserError('Too many arguments') + params['target'] = params.pop('id') + bugdir = self._get_bugdir() + if params['resolve'] == True: + bug = bug_from_target_summary(bugdir, params['target']) + if bug == None: + print >> self.stdout, 'No target assigned.' + else: + print >> self.stdout, bug.uuid + return 0 + bug,dummy_comment = libbe.command.util.bug_comment_from_user_id( + bugdir, params['id']) + if params['target'] == None: + target = bug_target(bugdir, bug) + if target == None: + print >> self.stdout, 'No target assigned.' + else: + print >> self.stdout, target.summary + else: + if params['target'] == 'none': + target = remove_target(bugdir, bug) + else: + target = add_target(bugdir, bug, params['target']) + return 0 + + def usage(self): + return 'usage: be %(name)s BUG-ID [TARGET]\nor: be %(name)s --resolve [TARGET]' \ + % vars(self) + + def _long_help(self): + return """ +Assorted bug target manipulations and queries. + +If no target is specified, the bug's current target is printed. If +TARGET is specified, it will be assigned to the bug, creating a new +target bug if necessary. + +Targets are free-form; any text may be specified. They will generally +be milestone names or release numbers. The value "none" can be used +to unset the target. + +In the alternative `be target --resolve TARGET` form, print the UUID +of the target-bug with summary TARGET. If target is not given, return +use the bugdir's current target (see `be set`). + +If you want to list all bugs blocking the current target, try + $ be depend --status -closed,fixed,wontfix --severity -target \ + $(be target --resolve) + +If you want to set the current bugdir target by summary (rather than +by UUID), try + $ be set target $(be target --resolve SUMMARY) +""" + +def bug_from_target_summary(bugdir, summary=None): + if summary == None: + if bugdir.target == None: + return None + else: + return bugdir.bug_from_uuid(bugdir.target) + matched = [] + for uuid in bugdir.uuids(): + bug = bugdir.bug_from_uuid(uuid) + if bug.severity == 'target' and bug.summary == summary: + matched.append(bug) + if len(matched) == 0: + return None + if len(matched) > 1: + raise Exception('Several targets with same summary: %s' + % '\n '.join([bug.uuid for bug in matched])) + return matched[0] + +def bug_target(bugdir, bug): + if bug.severity == 'target': + return bug + matched = [] + for blocked in libbe.command.depend.get_blocks(bugdir, bug): + if blocked.severity == 'target': + matched.append(blocked) + if len(matched) == 0: + return None + if len(matched) > 1: + raise Exception('This bug (%s) blocks several targets: %s' + % (bug.uuid, + '\n '.join([b.uuid for b in matched]))) + return matched[0] + +def remove_target(bugdir, bug): + target = bug_target(bugdir, bug) + libbe.command.depend.remove_block(target, bug) + return target + +def add_target(bugdir, bug, summary): + target = bug_from_target_summary(bugdir, summary) + if target == None: + target = bugdir.new_bug(summary=summary) + target.severity = 'target' + libbe.command.depend.add_block(target, bug) + return target + +def targets(bugdir): + bugdir.load_all_bugs() + for bug in bugdir: + if bug.severity == 'target': + yield bug.summary + +def complete_target(command, argument, fragment=None): + """ + List possible command completions for fragment. + + argument argument is not used. + """ + return targets(command._get_bugdir()) diff --git a/libbe/command/util.py b/libbe/command/util.py new file mode 100644 index 0000000..a5398cf --- /dev/null +++ b/libbe/command/util.py @@ -0,0 +1,186 @@ +# Copyright + +import glob +import os.path + +import libbe +import libbe.command + +class Completer (object): + def __init__(self, options): + self.options = options + def __call__(self, bugdir, fragment=None): + return [fragment] + +def complete_command(command, argument, fragment=None): + """ + List possible command completions for fragment. + + command argument is not used. + """ + return list(libbe.command.commands()) + +def complete_path(command, argument, fragment=None): + """ + List possible path completions for fragment. + + command argument is not used. + """ + if fragment == None: + fragment = '.' + comps = glob.glob(fragment+'*') + glob.glob(fragment+'/*') + if len(comps) == 1 and os.path.isdir(comps[0]): + comps.extend(glob.glob(comps[0]+'/*')) + return comps + +def complete_status(command, argument, fragment=None): + bd = command._get_bugdir() + import libbe.bug + return libbe.bug.status_values + +def complete_severity(command, argument, fragment=None): + bd = command._get_bugdir() + import libbe.bug + return libbe.bug.severity_values + +def complete_assigned(command, argument, fragment=None): + if fragment == None: + return [] + return [fragment] + +def complete_extra_strings(command, argument, fragment=None): + if fragment == None: + return [] + return [fragment] + +def complete_bug_id(command, argument, fragment=None): + return complete_bug_comment_id(command, argument, fragment, + comments=False) + +def complete_bug_comment_id(command, argument, fragment=None, + active_only=True, comments=True): + import libbe.bugdir + import libbe.util.id + bd = command._get_bugdir() + if fragment == None or len(fragment) == 0: + fragment = '/' + try: + p = libbe.util.id.parse_user(bd, fragment) + matches = None + root,residual = (fragment, None) + if not root.endswith('/'): + root += '/' + except libbe.util.id.InvalidIDStructure, e: + return [] + except libbe.util.id.NoIDMatches: + return [] + except libbe.util.id.MultipleIDMatches, e: + if e.common == None: + # choose among bugdirs + return e.matches + common = e.common + matches = e.matches + root,residual = libbe.util.id.residual(common, fragment) + p = libbe.util.id.parse_user(bd, e.common) + bug = None + if matches == None: # fragment was complete, get a list of children uuids + if p['type'] == 'bugdir': + matches = bd.uuids() + common = bd.id.user() + elif p['type'] == 'bug': + if comments == False: + return [fragment] + bug = bd.bug_from_uuid(p['bug']) + matches = bug.uuids() + common = bug.id.user() + else: + assert p['type'] == 'comment', p + return [fragment] + if p['type'] == 'bugdir': + child_fn = bd.bug_from_uuid + elif p['type'] == 'bug': + if comments == False: + return[fragment] + if bug == None: + bug = bd.bug_from_uuid(p['bug']) + child_fn = bug.comment_from_uuid + elif p['type'] == 'comment': + assert matches == None, matches + return [fragment] + possible = [] + common += '/' + for m in matches: + child = child_fn(m) + id = child.id.user() + possible.append(id.replace(common, root)) + return possible + +def select_values(string, possible_values, name="unkown"): + """ + This function allows the user to select values from a list of + possible values. The default is to select all the values: + + >>> select_values(None, ['abc', 'def', 'hij']) + ['abc', 'def', 'hij'] + + The user selects values with a comma-separated limit_string. + Prepending a minus sign to such a list denotes blacklist mode: + + >>> select_values('-abc,hij', ['abc', 'def', 'hij']) + ['def'] + + Without the leading -, the selection is in whitelist mode: + + >>> select_values('abc,hij', ['abc', 'def', 'hij']) + ['abc', 'hij'] + + In either case, appropriate errors are raised if on of the + user-values is not in the list of possible values. The name + parameter lets you make the error message more clear: + + >>> select_values('-xyz,hij', ['abc', 'def', 'hij'], name="foobar") + Traceback (most recent call last): + ... + UserError: Invalid foobar xyz + ['abc', 'def', 'hij'] + >>> select_values('xyz,hij', ['abc', 'def', 'hij'], name="foobar") + Traceback (most recent call last): + ... + UserError: Invalid foobar xyz + ['abc', 'def', 'hij'] + """ + possible_values = list(possible_values) # don't alter the original + if string == None: + pass + elif string.startswith('-'): + blacklisted_values = set(string[1:].split(',')) + for value in blacklisted_values: + if value not in possible_values: + raise libbe.command.UserError('Invalid %s %s\n %s' + % (name, value, possible_values)) + possible_values.remove(value) + else: + whitelisted_values = string.split(',') + for value in whitelisted_values: + if value not in possible_values: + raise libbe.command.UserError( + 'Invalid %s %s\n %s' + % (name, value, possible_values)) + possible_values = whitelisted_values + return possible_values + +def bug_comment_from_user_id(bugdir, id): + p = libbe.util.id.parse_user(bugdir, id) + if not p['type'] in ['bug', 'comment']: + raise libbe.command.UserError( + '%s is a %s id, not a bug or comment id' % (id, p['type'])) + if p['bugdir'] != bugdir.uuid: + raise libbe.command.UserError( + "%s doesn't belong to this bugdir (%s)" + % (id, bugdir.uuid)) + bug = bugdir.bug_from_uuid(p['bug']) + if 'comment' in p: + comment = bug.comment_from_uuid(p['comment']) + else: + comment = bug.comment_root + return (bug, comment) diff --git a/libbe/comment.py b/libbe/comment.py index 32536d4..fab1f54 100644 --- a/libbe/comment.py +++ b/libbe/comment.py @@ -1,4 +1,3 @@ -# Bugs Everywhere, a distributed bugtracker # Copyright (C) 2008-2009 Gianluca Montecchi <gian@grys.it> # Thomas Habets <thomas@habets.pp.se> # W. Trevor King <wking@drexel.edu> @@ -34,14 +33,15 @@ except ImportError: # look for non-core module import xml.sax.saxutils import libbe -from beuuid import uuid_gen -from properties import Property, doc_property, local_property, \ - defaulting_property, checked_property, cached_property, \ +import libbe.util.id +from libbe.storage.util.properties import Property, doc_property, \ + local_property, defaulting_property, checked_property, cached_property, \ primed_property, change_hook_property, settings_property -import settings_object -import mapfile -from tree import Tree -import utility +import libbe.storage.util.settings_object as settings_object +import libbe.storage.util.mapfile as mapfile +from libbe.util.tree import Tree +import libbe.util.utility as utility + if libbe.TESTING == True: import doctest @@ -67,33 +67,28 @@ class DiskAccessRequired (Exception): INVALID_UUID = "!!~~\n INVALID-UUID \n~~!!" -def loadComments(bug, load_full=False): +def load_comments(bug, load_full=False): """ Set load_full=True when you want to load the comment completely from disk *now*, rather than waiting and lazy loading as required. """ - if bug.sync_with_disk == False: - raise DiskAccessRequired("load comments") - path = bug.get_path("comments") - if not os.path.exists(path): - return Comment(bug, uuid=INVALID_UUID) + uuids = [] + for id in libbe.util.id.child_uuids( + bug.storage.children( + bug.id.storage())): + uuids.append(id) comments = [] - for uuid in os.listdir(path): - if uuid.startswith('.'): - continue - comm = Comment(bug, uuid, from_disk=True) - comm.set_sync_with_disk(bug.sync_with_disk) + for uuid in uuids: + comm = Comment(bug, uuid, from_storage=True) if load_full == True: comm.load_settings() dummy = comm.body # force the body to load comments.append(comm) bug.comment_root = Comment(bug, uuid=INVALID_UUID) - bug.add_comments(comments) + bug.add_comments(comments, ignore_missing_references=True) return bug.comment_root -def saveComments(bug): - if bug.sync_with_disk == False: - raise DiskAccessRequired("save comments") +def save_comments(bug): for comment in bug.comment_root.traverse(): comment.save() @@ -154,15 +149,18 @@ class Comment(Tree, settings_object.SavedSettingsObject): doc="An integer version of .date") def _get_comment_body(self): - if self.vcs != None and self.sync_with_disk == True: - import vcs - binary = not self.content_type.startswith("text/") - return self.vcs.get_file_contents(self.get_path("body"), binary=binary) + if self.storage != None and self.storage.is_readable() \ + and self.uuid != INVALID_UUID: + return self.storage.get(self.id.storage("body"), + decode=self.content_type.startswith("text/")) def _set_comment_body(self, old=None, new=None, force=False): - if (self.vcs != None and self.sync_with_disk == True) or force==True: + assert self.uuid != INVALID_UUID, self + if self.bug != None and self.bug.bugdir != None: + new = libbe.util.id.short_to_long_text([self.bug.bugdir], new) + if (self.storage != None and self.storage.writeable == True) \ + or force==True: assert new != None, "Can't save empty comment" - binary = not self.content_type.startswith("text/") - self.vcs.set_file_contents(self.get_path("body"), new, binary=binary) + self.storage.set(self.id.storage("body"), new) @Property @change_hook_property(hook=_set_comment_body) @@ -171,16 +169,6 @@ class Comment(Tree, settings_object.SavedSettingsObject): @doc_property(doc="The meat of the comment") def body(): return {} - def _get_vcs(self): - if hasattr(self.bug, "vcs"): - return self.bug.vcs - - @Property - @cached_property(generator=_get_vcs) - @local_property("vcs") - @doc_property(doc="A revision control system instance.") - def vcs(): return {} - def _extra_strings_check_fn(value): return utility.iterable_full_of_strings(value, \ alternative=settings_object.EMPTY) @@ -195,35 +183,39 @@ class Comment(Tree, settings_object.SavedSettingsObject): mutable=True) def extra_strings(): return {} - def __init__(self, bug=None, uuid=None, from_disk=False, + def __init__(self, bug=None, uuid=None, from_storage=False, in_reply_to=None, body=None): """ - Set from_disk=True to load an old comment. - Set from_disk=False to create a new comment. + Set from_storage=True to load an old comment. + Set from_storage=False to create a new comment. + + The uuid option is required when from_storage==True. - The uuid option is required when from_disk==True. - The in_reply_to and body options are only used if - from_disk==False (the default). When from_disk==True, they are - loaded from the bug database. - + from_storage==False (the default). When from_storage==True, + they are loaded from the bug database. + in_reply_to should be the uuid string of the parent comment. """ Tree.__init__(self) settings_object.SavedSettingsObject.__init__(self) self.bug = bug - self.uuid = uuid - if from_disk == True: - self.sync_with_disk = True - else: - self.sync_with_disk = False + self.storage = None + self.uuid = uuid + self.id = libbe.util.id.ID(self, 'comment') + if from_storage == False: if uuid == None: - self.uuid = uuid_gen() + self.uuid = libbe.util.id.uuid_gen() + self.settings = {} + self._setup_saved_settings() self.time = int(time.time()) # only save to second precision - if self.vcs != None: - self.author = self.vcs.get_user_id() self.in_reply_to = in_reply_to self.body = body + if self.bug != None: + self.storage = self.bug.storage + if from_storage == False: + if self.storage != None and self.storage.is_writeable(): + self.save() def __cmp__(self, other): return cmp_full(self, other) @@ -236,7 +228,7 @@ class Comment(Tree, settings_object.SavedSettingsObject): >>> comm.author = "Jane Doe <jdoe@example.com>" >>> print comm --------- Comment --------- - Name: com-1 + Name: //com From: Jane Doe <jdoe@example.com> Date: Thu, 20 Nov 2008 15:55:11 +0000 <BLANKLINE> @@ -261,15 +253,15 @@ class Comment(Tree, settings_object.SavedSettingsObject): return str(value) return value - def xml(self, indent=0, shortname=None): + def xml(self, indent=0): """ >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n") >>> comm.uuid = "0123" >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000" - >>> print comm.xml(indent=2, shortname="com-1") + >>> print comm.xml(indent=2) <comment> <uuid>0123</uuid> - <short-name>com-1</short-name> + <short-name>//012</short-name> <author></author> <date>Thu, 01 Jan 1970 00:00:00 +0000</date> <content-type>text/plain</content-type> @@ -278,8 +270,6 @@ class Comment(Tree, settings_object.SavedSettingsObject): remarks</body> </comment> """ - if shortname == None: - shortname = self.uuid if self.content_type.startswith('text/'): body = (self.body or '').rstrip('\n') else: @@ -290,7 +280,7 @@ class Comment(Tree, settings_object.SavedSettingsObject): body = base64.encodestring(self.body or '') info = [('uuid', self.uuid), ('alt-id', self.alt_id), - ('short-name', shortname), + ('short-name', self.id.user()), ('in-reply-to', self.in_reply_to), ('author', self._setting_attr_string('author')), ('date', self.date), @@ -316,16 +306,16 @@ class Comment(Tree, settings_object.SavedSettingsObject): >>> commA.date = "Thu, 01 Jan 1970 00:00:00 +0000" >>> commA.author = u'Fran\xe7ois' >>> commA.extra_strings += ['TAG: very helpful'] - >>> xml = commA.xml(shortname="com-1") + >>> xml = commA.xml() >>> commB = Comment() >>> commB.from_xml(xml, verbose=True) >>> commB.explicit_attrs ['author', 'date', 'content_type', 'body', 'alt_id'] - >>> commB.xml(shortname="com-1") == xml + >>> commB.xml() == xml False >>> commB.uuid = commB.alt_id >>> commB.alt_id = None - >>> commB.xml(shortname="com-1") == xml + >>> commB.xml() == xml True """ if type(xml_string) == types.UnicodeType: @@ -378,7 +368,7 @@ class Comment(Tree, settings_object.SavedSettingsObject): self.body = base64.decodestring(body) self.extra_strings = estrs - def merge(self, other, accept_changes=True, + def merge(self, other, accept_changes=True, accept_extra_strings=True, change_exception=False): """ Merge info from other into this comment. Overrides any @@ -419,7 +409,7 @@ class Comment(Tree, settings_object.SavedSettingsObject): >>> print commA.xml() <comment> <uuid>0123</uuid> - <short-name>0123</short-name> + <short-name>//012</short-name> <author>John</author> <date>Thu, 01 Jan 1970 00:00:00 +0000</date> <content-type>text/plain</content-type> @@ -450,13 +440,14 @@ class Comment(Tree, settings_object.SavedSettingsObject): 'Merge would add extra string "%s" to comment %s' \ % (estr, self.uuid) - def string(self, indent=0, shortname=None): + def string(self, indent=0): """ >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n") + >>> comm.uuid = 'abcdef' >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000" - >>> print comm.string(indent=2, shortname="com-1") + >>> print comm.string(indent=2) --------- Comment --------- - Name: com-1 + Name: //abc From: Date: Thu, 01 Jan 1970 00:00:00 +0000 <BLANKLINE> @@ -464,35 +455,35 @@ class Comment(Tree, settings_object.SavedSettingsObject): insightful remarks """ - if shortname == None: - shortname = self.uuid lines = [] lines.append("--------- Comment ---------") - lines.append("Name: %s" % shortname) + lines.append("Name: %s" % self.id.user()) 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()) + body = (self.body or "") + if self.bug != None and self.bug.bugdir != None: + body = libbe.util.id.long_to_short_text([self.bug.bugdir], body) + lines.extend(body.splitlines()) else: lines.append("Content type %s not printable. Try XML output instead" % self.content_type) - + istring = ' '*indent sep = '\n' + istring return istring + sep.join(lines).rstrip('\n') - def string_thread(self, string_method_name="string", name_map={}, - indent=0, flatten=True, - auto_name_map=False, bug_shortname=None): + def string_thread(self, string_method_name="string", + indent=0, flatten=True): """ Return a string displaying a thread of comments. bug_shortname is only used if auto_name_map == True. - + string_method_name (defaults to "string") is the name of the Comment method used to generate the output string for each Comment in the thread. The method must take the arguments indent and shortname. - + SIDE-EFFECT: if auto_name_map==True, calls comment_shortnames() which will sort the tree by comment.time. Avoid by calling name_map = {} @@ -515,132 +506,119 @@ class Comment(Tree, settings_object.SavedSettingsObject): >>> a.sort(key=lambda comm : comm.time) >>> print a.string_thread(flatten=True) --------- Comment --------- - Name: a + Name: //a From: Date: Thu, 20 Nov 2008 01:00:00 +0000 <BLANKLINE> Insightful remarks --------- Comment --------- - Name: b + Name: //b From: Date: Thu, 20 Nov 2008 02:00:00 +0000 <BLANKLINE> Critique original comment --------- Comment --------- - Name: c + Name: //c From: Date: Thu, 20 Nov 2008 03:00:00 +0000 <BLANKLINE> Begin flamewar :p --------- Comment --------- - Name: d + 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") + >>> print a.string_thread() --------- Comment --------- - Name: bug-1:1 + Name: //a From: Date: Thu, 20 Nov 2008 01:00:00 +0000 <BLANKLINE> Insightful remarks --------- Comment --------- - Name: bug-1:2 + Name: //b From: Date: Thu, 20 Nov 2008 02:00:00 +0000 <BLANKLINE> Critique original comment --------- Comment --------- - Name: bug-1:3 + Name: //c From: Date: Thu, 20 Nov 2008 03:00:00 +0000 <BLANKLINE> Begin flamewar :p --------- Comment --------- - Name: bug-1:4 + Name: //d From: Date: Thu, 20 Nov 2008 04:00:00 +0000 <BLANKLINE> Useful examples """ - if auto_name_map == True: - name_map = {} - for shortname,comment in self.comment_shortnames(bug_shortname): - name_map[comment.uuid] = shortname stringlist = [] for depth,comment in self.thread(flatten=flatten): ind = 2*depth+indent - if comment.uuid in name_map: - sname = name_map[comment.uuid] - else: - sname = None string_fn = getattr(comment, string_method_name) - stringlist.append(string_fn(indent=ind, shortname=sname)) + stringlist.append(string_fn(indent=ind)) return '\n'.join(stringlist) - def xml_thread(self, name_map={}, indent=0, - auto_name_map=False, bug_shortname=None): - return self.string_thread(string_method_name="xml", name_map=name_map, - indent=indent, auto_name_map=auto_name_map, - bug_shortname=bug_shortname) + def xml_thread(self, indent=0): + return self.string_thread(string_method_name="xml", indent=indent) # methods for saving/loading/acessing settings and properties. - def get_path(self, *args): - dir = os.path.join(self.bug.get_path("comments"), self.uuid) - if len(args) == 0: - return dir - assert args[0] in ["values", "body"], str(args) - return os.path.join(dir, *args) - - def set_sync_with_disk(self, value): - self.sync_with_disk = value - - def load_settings(self): - if self.sync_with_disk == False: - raise DiskAccessRequired("load settings") - self.settings = mapfile.map_load(self.vcs, self.get_path("values")) + def load_settings(self, settings_mapfile=None): + if settings_mapfile == None: + settings_mapfile = \ + self.storage.get(self.id.storage("values"), default="\n") + try: + self.settings = mapfile.parse(settings_mapfile) + except mapfile.InvalidMapfileContents, e: + raise Exception('Invalid settings file for comment %s\n' + '(BE version missmatch?)' % self.id.user()) self._setup_saved_settings() def save_settings(self): - if self.sync_with_disk == False: - raise DiskAccessRequired("save settings") - self.vcs.mkdir(self.get_path()) - path = self.get_path("values") - mapfile.map_save(self.vcs, path, self._get_saved_settings()) + mf = mapfile.generate(self._get_saved_settings()) + self.storage.set(self.id.storage("values"), mf) 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). + Save any loaded contents to storage. + + However, if self.storage.is_writeable() == True, then any + changes are automatically written to storage as soon as they + happen, so calling this method will just waste time (unless + something else has been messing with your stored files). """ - sync_with_disk = self.sync_with_disk - if sync_with_disk == False: - self.set_sync_with_disk(True) + if self.uuid == INVALID_UUID: + return + assert self.storage != None, "Can't save without storage" assert self.body != None, "Can't save blank comment" + if self.bug != None: + parent = self.bug.id.storage() + else: + parent = None + self.storage.add(self.id.storage(), parent=parent, directory=True) + self.storage.add(self.id.storage('values'), parent=self.id.storage(), + directory=False) + self.storage.add(self.id.storage('body'), parent=self.id.storage(), + directory=False) self.save_settings() self._set_comment_body(new=self.body, force=True) - if sync_with_disk == False: - self.set_sync_with_disk(False) def remove(self): - if self.sync_with_disk == False and self.uuid != INVALID_UUID: - raise DiskAccessRequired("remove") - for comment in self.traverse(): - path = comment.get_path() - self.vcs.recursive_remove(path) + for comment in self: + comment.remove() + if self.uuid != INVALID_UUID: + self.storage.recursive_remove(self.id.storage()) 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, content_type=None): + def new_reply(self, body=None): """ >>> comm = Comment(bug=None, body="Some insightful remarks") >>> repA = comm.new_reply("Critique original comment") @@ -649,71 +627,12 @@ class Comment(Tree, settings_object.SavedSettingsObject): True """ reply = Comment(self.bug, body=body) - if content_type != None: # set before saving body to decide binary format - reply.content_type = content_type - if self.bug != None: - reply.set_sync_with_disk(self.bug.sync_with_disk) - if reply.sync_with_disk == True: - reply.save() self.add_reply(reply) return reply - def comment_shortnames(self, bug_shortname=None): - """ - Iterate through (id, comment) pairs, in time order. - (This is a user-friendly id, not the comment uuid). - - SIDE-EFFECT : will sort the comment tree by comment.time - - >>> a = Comment(bug=None, uuid="a") - >>> b = a.new_reply() - >>> b.uuid = "b" - >>> c = b.new_reply() - >>> c.uuid = "c" - >>> d = a.new_reply() - >>> d.uuid = "d" - >>> for id,name in a.comment_shortnames("bug-1"): - ... print id, name.uuid - bug-1:1 a - bug-1:2 b - bug-1:3 c - bug-1:4 d - >>> for id,name in a.comment_shortnames(): - ... print id, name.uuid - :1 a - :2 b - :3 c - :4 d - """ - if bug_shortname == None: - bug_shortname = "" - self.sort(key=lambda comm : comm.time) - for num,comment in enumerate(self.traverse()): - yield ("%s:%d" % (bug_shortname, num+1), comment) - - def comment_from_shortname(self, comment_shortname, *args, **kwargs): - """ - Use a comment shortname to look up a comment. - >>> a = Comment(bug=None, uuid="a") - >>> b = a.new_reply() - >>> b.uuid = "b" - >>> c = b.new_reply() - >>> c.uuid = "c" - >>> d = a.new_reply() - >>> d.uuid = "d" - >>> comm = a.comment_from_shortname("bug-1:3", bug_shortname="bug-1") - >>> id(comm) == id(c) - True - """ - for cur_name, comment in self.comment_shortnames(*args, **kwargs): - if comment_shortname == cur_name: - return comment - raise InvalidShortname(comment_shortname, - list(self.comment_shortnames(*args, **kwargs))) - def comment_from_uuid(self, uuid, match_alt_id=True): """ - Use a comment shortname to look up a comment. + Use a uuid to look up a comment. >>> a = Comment(bug=None, uuid="a") >>> b = a.new_reply() >>> b.uuid = "b" @@ -741,6 +660,14 @@ class Comment(Tree, settings_object.SavedSettingsObject): return comment raise KeyError(uuid) + # methods for id generation + + def sibling_uuids(self): + if self.bug != None: + return self.bug.uuids() + return [] + + def cmp_attr(comment_1, comment_2, attr, invert=False): """ Compare a general attribute between two comments using the conventional @@ -765,7 +692,7 @@ def cmp_attr(comment_1, comment_2, attr, invert=False): 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 : @@ -795,7 +722,7 @@ class CommentCompoundComparator (object): if val != 0 : return val return 0 - + cmp_full = CommentCompoundComparator() if libbe.TESTING == True: diff --git a/libbe/darcs.py b/libbe/darcs.py deleted file mode 100644 index d94eaef..0000000 --- a/libbe/darcs.py +++ /dev/null @@ -1,192 +0,0 @@ -# Copyright (C) 2009 Gianluca Montecchi <gian@grys.it> -# W. Trevor King <wking@drexel.edu> -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, write to the Free Software Foundation, Inc., -# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - -""" -Darcs backend. -""" - -import codecs -import os -import re -import sys -try: # import core module, Python >= 2.5 - from xml.etree import ElementTree -except ImportError: # look for non-core module - from elementtree import ElementTree -from xml.sax.saxutils import unescape - -import libbe -import vcs -if libbe.TESTING == True: - import doctest - import unittest - - -def new(): - return Darcs() - -class Darcs(vcs.VCS): - name="darcs" - client="darcs" - versioned=True - def _vcs_version(self): - status,output,error = self._u_invoke_client("--version") - num_part = output.split(" ")[0] - self.parsed_version = [int(i) for i in num_part.split(".")] - return output - def _vcs_detect(self, path): - if self._u_search_parent_directories(path, "_darcs") != None : - return True - return False - def _vcs_root(self, path): - """Find the root of the deepest repository containing path.""" - # Assume that nothing funny is going on; in particular, that we aren't - # dealing with a bare repo. - if os.path.isdir(path) != True: - path = os.path.dirname(path) - darcs_dir = self._u_search_parent_directories(path, "_darcs") - if darcs_dir == None: - return None - return os.path.dirname(darcs_dir) - def _vcs_init(self, path): - self._u_invoke_client("init", cwd=path) - def _vcs_get_user_id(self): - # following http://darcs.net/manual/node4.html#SECTION00410030000000000000 - # as of June 29th, 2009 - if self.rootdir == None: - return None - darcs_dir = os.path.join(self.rootdir, "_darcs") - if darcs_dir != None: - for pref_file in ["author", "email"]: - pref_path = os.path.join(darcs_dir, "prefs", pref_file) - if os.path.exists(pref_path): - return self.get_file_contents(pref_path) - for env_variable in ["DARCS_EMAIL", "EMAIL"]: - if env_variable in os.environ: - return os.environ[env_variable] - return None - def _vcs_set_user_id(self, value): - if self.rootdir == None: - self.root(".") - if self.rootdir == None: - raise vcs.SettingIDnotSupported - author_path = os.path.join(self.rootdir, "_darcs", "prefs", "author") - f = codecs.open(author_path, "w", self.encoding) - f.write(value) - f.close() - def _vcs_add(self, path): - if os.path.isdir(path): - return - self._u_invoke_client("add", path) - def _vcs_remove(self, path): - if not os.path.isdir(self._u_abspath(path)): - os.remove(os.path.join(self.rootdir, path)) # darcs notices removal - def _vcs_update(self, path): - pass # darcs notices changes - def _vcs_get_file_contents(self, path, revision=None, binary=False): - if revision == None: - return vcs.VCS._vcs_get_file_contents(self, path, revision, - binary=binary) - else: - if self.parsed_version[0] >= 2: - status,output,error = self._u_invoke_client( \ - "show", "contents", "--patch", revision, path) - return output - else: - # Darcs versions < 2.0.0pre2 lack the "show contents" command - - status,output,error = self._u_invoke_client( \ - "diff", "--unified", "--from-patch", revision, path, - unicode_output=False) - major_patch = output - status,output,error = self._u_invoke_client( \ - "diff", "--unified", "--patch", revision, path, - unicode_output=False) - target_patch = output - - # "--output -" to be supported in GNU patch > 2.5.9 - # but that hasn't been released as of June 30th, 2009. - - # Rewrite path to status before the patch we want - args=["patch", "--reverse", path] - status,output,error = self._u_invoke(args, stdin=major_patch) - # Now apply the patch we want - args=["patch", path] - status,output,error = self._u_invoke(args, stdin=target_patch) - - if os.path.exists(os.path.join(self.rootdir, path)) == True: - contents = vcs.VCS._vcs_get_file_contents(self, path, - binary=binary) - else: - contents = "" - - # Now restore path to it's current incarnation - args=["patch", "--reverse", path] - status,output,error = self._u_invoke(args, stdin=target_patch) - args=["patch", path] - status,output,error = self._u_invoke(args, stdin=major_patch) - current_contents = vcs.VCS._vcs_get_file_contents(self, path, - binary=binary) - return contents - def _vcs_duplicate_repo(self, directory, revision=None): - if revision==None: - vcs.VCS._vcs_duplicate_repo(self, directory, revision) - else: - self._u_invoke_client("put", "--to-patch", revision, directory) - def _vcs_commit(self, commitfile, allow_empty=False): - id = self.get_user_id() - if '@' not in id: - id = "%s <%s@invalid.com>" % (id, id) - args = ['record', '--all', '--author', id, '--logfile', commitfile] - status,output,error = self._u_invoke_client(*args) - empty_strings = ["No changes!"] - if self._u_any_in_string(empty_strings, output) == True: - if allow_empty == False: - raise vcs.EmptyCommit() - # note that darcs does _not_ make an empty revision. - # this returns the last non-empty revision id... - revision = self._vcs_revision_id(-1) - else: - revline = re.compile("Finished recording patch '(.*)'") - match = revline.search(output) - assert match != None, output+error - assert len(match.groups()) == 1 - revision = match.groups()[0] - return revision - def _vcs_revision_id(self, index): - status,output,error = self._u_invoke_client("changes", "--xml") - revisions = [] - xml_str = output.encode("unicode_escape").replace(r"\n", "\n") - element = ElementTree.XML(xml_str) - assert element.tag == "changelog", element.tag - for patch in element.getchildren(): - assert patch.tag == "patch", patch.tag - for child in patch.getchildren(): - if child.tag == "name": - text = unescape(unicode(child.text).decode("unicode_escape").strip()) - revisions.append(text) - revisions.reverse() - try: - return revisions[index] - except IndexError: - return None - -if libbe.TESTING == True: - vcs.make_vcs_testcase_subclasses(Darcs, sys.modules[__name__]) - - unitsuite =unittest.TestLoader().loadTestsFromModule(sys.modules[__name__]) - suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()]) diff --git a/libbe/diff.py b/libbe/diff.py index c0132ff..f82dbfa 100644 --- a/libbe/diff.py +++ b/libbe/diff.py @@ -22,47 +22,48 @@ import difflib import types import libbe -from libbe import bugdir, bug, settings_object, tree -from libbe.utility import time_to_str -if libbe.TESTING == True: - import doctest +import libbe.bugdir +import libbe.bug +import libbe.util.tree +from libbe.storage.util.settings_object import setting_name_to_attr_name +from libbe.util.utility import time_to_str -class SubscriptionType (tree.Tree): +class SubscriptionType (libbe.util.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) + libbe.util.tree.Tree.__init__(self, *args, **kwargs) self.type = type_name def __str__(self): return self.type def __cmp__(self, other): return cmp(self.type, other.type) def __repr__(self): - return "<SubscriptionType: %s>" % str(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) + lines.append('%s%s' % (' '*(indent+2*depth), node)) + return '\n'.join(lines) -BUGDIR_ID = "DIR" -BUGDIR_TYPE_NEW = SubscriptionType("new") -BUGDIR_TYPE_MOD = SubscriptionType("mod") -BUGDIR_TYPE_REM = SubscriptionType("rem") -BUGDIR_TYPE_ALL = SubscriptionType("all", +BUGDIR_ID = 'DIR' +BUGDIR_TYPE_NEW = SubscriptionType('new') +BUGDIR_TYPE_MOD = SubscriptionType('mod') +BUGDIR_TYPE_REM = SubscriptionType('rem') +BUGDIR_TYPE_ALL = SubscriptionType('all', [BUGDIR_TYPE_NEW, BUGDIR_TYPE_MOD, BUGDIR_TYPE_REM]) # same name as BUGDIR_TYPE_ALL for consistency BUG_TYPE_ALL = SubscriptionType(str(BUGDIR_TYPE_ALL)) -INVALID_TYPE = SubscriptionType("INVALID") +INVALID_TYPE = SubscriptionType('INVALID') class InvalidType (ValueError): def __init__(self, type_name, type_root): - msg = "Invalid type %s for tree:\n%s" \ + msg = 'Invalid type %s for tree:\n%s' \ % (type_name, type_root.string_tree(4)) ValueError.__init__(self, msg) self.type_name = type_name @@ -89,9 +90,9 @@ class Subscription (object): def __init__(self, id, subscription_type, **kwargs): if 'type_root' not in kwargs: if id == BUGDIR_ID: - kwargs['type_root'] = BUGDIR_TYPE_ALL + kwargs['type_root'] = BUGDIR_TYPE_ALL else: - kwargs['type_root'] = BUG_TYPE_ALL + kwargs['type_root'] = BUG_TYPE_ALL if type(subscription_type) in types.StringTypes: subscription_type = type_from_name(subscription_type, **kwargs) self.id = id @@ -108,7 +109,7 @@ class Subscription (object): def __str__(self): return str(self.type) def __repr__(self): - return "<Subscription: %s (%s)>" % (self.id, self.type) + return '<Subscription: %s (%s)>' % (self.id, self.type) def subscriptions_from_string(string=None, subscription_sep=',', id_sep=':'): """ @@ -133,48 +134,48 @@ def subscriptions_from_string(string=None, subscription_sep=',', id_sep=':'): subscriptions.append(Subscription(id, type)) return subscriptions -class DiffTree (tree.Tree): +class DiffTree (libbe.util.tree.Tree): """ A tree holding difference data for easy report generation. - >>> bugdir = DiffTree("bugdir") - >>> bdsettings = DiffTree("settings", data="target: None -> 1.0") + >>> bugdir = DiffTree('bugdir') + >>> bdsettings = DiffTree('settings', data='target: None -> 1.0') >>> bugdir.append(bdsettings) - >>> bugs = DiffTree("bugs", "bug-count: 5 -> 6") + >>> bugs = DiffTree('bugs', 'bug-count: 5 -> 6') >>> bugdir.append(bugs) - >>> new = DiffTree("new", "new bugs: ABC, DEF") + >>> new = DiffTree('new', 'new bugs: ABC, DEF') >>> bugs.append(new) - >>> rem = DiffTree("rem", "removed bugs: RST, UVW") + >>> rem = DiffTree('rem', 'removed bugs: RST, UVW') >>> bugs.append(rem) >>> print bugdir.report_string() target: None -> 1.0 bug-count: 5 -> 6 new bugs: ABC, DEF removed bugs: RST, UVW - >>> print "\\n".join(bugdir.paths()) + >>> print '\\n'.join(bugdir.paths()) bugdir bugdir/settings bugdir/bugs bugdir/bugs/new bugdir/bugs/rem - >>> bugdir.child_by_path("/") == bugdir + >>> bugdir.child_by_path('/') == bugdir True - >>> bugdir.child_by_path("/bugs") == bugs + >>> bugdir.child_by_path('/bugs') == bugs True - >>> bugdir.child_by_path("/bugs/rem") == rem + >>> bugdir.child_by_path('/bugs/rem') == rem True - >>> bugdir.child_by_path("bugdir") == bugdir + >>> bugdir.child_by_path('bugdir') == bugdir True - >>> bugdir.child_by_path("bugdir/") == bugdir + >>> bugdir.child_by_path('bugdir/') == bugdir True - >>> bugdir.child_by_path("bugdir/bugs") == bugs + >>> bugdir.child_by_path('bugdir/bugs') == bugs True - >>> bugdir.child_by_path("/bugs").masked = True + >>> bugdir.child_by_path('/bugs').masked = True >>> print bugdir.report_string() target: None -> 1.0 """ def __init__(self, name, data=None, data_part_fn=str, requires_children=False, masked=False): - tree.Tree.__init__(self) + libbe.util.tree.Tree.__init__(self) self.name = name self.data = data self.data_part_fn = data_part_fn @@ -185,17 +186,17 @@ class DiffTree (tree.Tree): if parent_path == None: path = self.name else: - path = "%s/%s" % (parent_path, self.name) + 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] == "": + 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] == "": + if len(names) > 1 and names[-1] == '': names = names[:-1] # strip empty tail else: # it was already an array names = path @@ -208,7 +209,7 @@ class DiffTree (tree.Tree): 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]) + raise KeyError, '%s points to child not in %s' % (names, [c.name for c in self]) def report_string(self): report = self.report() if report == None: @@ -238,13 +239,13 @@ class DiffTree (tree.Tree): def data_part(self, depth, indent=True): if self.data == None: return None - if hasattr(self, "_cached_data_part"): + if hasattr(self, '_cached_data_part'): return self._cached_data_part data_part = self.data_part_fn(self.data) if indent == True: data_part_lines = data_part.splitlines() - indent = " "*(depth) - line_sep = "\n"+indent + indent = ' '*(depth) + line_sep = '\n'+indent data_part = indent+line_sep.join(data_part_lines) self._cached_data_part = data_part return data_part @@ -253,21 +254,21 @@ class Diff (object): """ Difference tree generator for BugDirs. >>> import copy - >>> bd = bugdir.SimpleBugDir(sync_with_disk=False) - >>> bd.user_id = "John Doe <j@doe.com>" + >>> bd = libbe.bugdir.SimpleBugDir(memory=True) >>> bd_new = copy.deepcopy(bd) - >>> bd_new.target = "1.0" - >>> a = bd_new.bug_from_uuid("a") + >>> 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") + >>> rep.uuid = 'acom' + >>> rep.author = 'John Doe <j@doe.com>' + >>> 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") + >>> c = bd_new.new_bug('Bug C', _uuid='c') >>> d = Diff(bd, bd_new) >>> r = d.report_tree() - >>> print "\\n".join(r.paths()) + >>> print '\\n'.join(r.paths()) bugdir bugdir/settings bugdir/bugs @@ -287,11 +288,11 @@ class Diff (object): Changed bug directory settings: target: None -> 1.0 New bugs: - c:om: Bug C + abc/c:om: Bug C Removed bugs: - b:cm: Bug B + abc/b:cm: Bug B Modified bugs: - a:cm: Bug A + abc/a:cm: Bug A Changed bug settings: status: open -> closed New comments: @@ -306,9 +307,9 @@ class Diff (object): >>> r = d.report_tree(subscriptions) >>> print r.report_string() New bugs: - c:om: Bug C + abc/c:om: Bug C Removed bugs: - b:cm: Bug B + abc/b:cm: Bug B While sending subscriptions to report_tree() makes the report generation more efficient (because you may not need to compare @@ -319,10 +320,10 @@ class Diff (object): >>> d.full_report() >>> print d.report_tree([subscriptions[0]]).report_string() New bugs: - c:om: Bug C + abc/c:om: Bug C >>> print d.report_tree([subscriptions[1]]).report_string() Removed bugs: - b:cm: Bug B + abc/b:cm: Bug B >>> bd.cleanup() """ @@ -356,9 +357,9 @@ class Diff (object): for s in subscriptions: if s.id != BUGDIR_ID: try: - bug = self.new_bugdir.bug_from_shortname(s.id) - except bugdir.NoBugMatches: - bug = self.old_bugdir.bug_from_shortname(s.id) + bug = self.new_bugdir.bug_from_uuid(s.id) + except libbe.bugdir.NoBugMatches: + bug = self.old_bugdir.bug_from_uuid(s.id) subscribed_bugs.append(bug.uuid) new_uuids.extend([s for s in subscribed_bugs if self.new_bugdir.has_bug(s)]) @@ -382,9 +383,9 @@ class Diff (object): if BUGDIR_TYPE_ALL in bugdir_types \ or BUGDIR_TYPE_MOD in bugdir_types \ or uuid in subscribed_bugs: - if old_bug.sync_with_disk == True: + if old_bug.storage != None and old_bug.storage.is_readable(): old_bug.load_comments() - if new_bug.sync_with_disk == True: + if new_bug.storage != None and new_bug.storage.is_readable(): new_bug.load_comments() if old_bug != new_bug: modified.append((old_bug, new_bug)) @@ -405,7 +406,7 @@ class Diff (object): (added_comments, modified_comments, removed_comments) analogous to ._changed_bugs. """ - if hasattr(self, "__changed_comments"): + if hasattr(self, '__changed_comments'): if new.uuid in self.__changed_comments: return self.__changed_comments[new.uuid] else: @@ -453,13 +454,12 @@ class Diff (object): properties = sorted(new.settings_properties) for p in hidden_properties: properties.remove(p) - attributes = [settings_object.setting_name_to_attr_name(None, p) + attributes = [setting_name_to_attr_name(None, p) for p in properties] return self._attribute_changes(old, new, attributes) def _bugdir_attribute_changes(self): return self._settings_properties_attribute_changes( \ - self.old_bugdir, self.new_bugdir, - ["vcs_name"]) # tweaked by bugdir.duplicate_bugdir + self.old_bugdir, self.new_bugdir) def _bug_attribute_changes(self, old, new): return self._settings_properties_attribute_changes(old, new) def _comment_attribute_changes(self, old, new): @@ -529,65 +529,64 @@ class Diff (object): if subscriptions == None: subscriptions = [Subscription(BUGDIR_ID, BUGDIR_TYPE_ALL)] bugdir_settings = sorted(self.new_bugdir.settings_properties) - bugdir_settings.remove("vcs_name") # tweaked by bugdir.duplicate_bugdir - root = diff_tree("bugdir") + root = diff_tree('bugdir') bugdir_subscriptions = [s.type for s in subscriptions if s.id == BUGDIR_ID] if BUGDIR_TYPE_ALL in bugdir_subscriptions: bugdir_attribute_changes = self._bugdir_attribute_changes() if len(bugdir_attribute_changes) > 0: - bugdir = diff_tree("settings", bugdir_attribute_changes, + bugdir = diff_tree('settings', bugdir_attribute_changes, self.bugdir_attribute_change_string) root.append(bugdir) - bug_root = diff_tree("bugs") + bug_root = diff_tree('bugs') root.append(bug_root) add,mod,rem = self._changed_bugs(subscriptions) - bnew = diff_tree("new", "New bugs:", requires_children=True) + 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) + 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) + 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, + 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) + bsum = diff_tree('summary', data, self.bug_summary_change_string) b.append(bsum) - cr = diff_tree("comments") + cr = diff_tree('comments') b.append(cr) a,m,d = self._changed_comments(old, new) - cnew = diff_tree("new", "New comments:", requires_children=True) + 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) + 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) + 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, + 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, + cbody = diff_tree('cbody', data, self.comment_body_change_string) c.append(cbody) cr.extend([cnew, crem, cmod]) @@ -597,19 +596,19 @@ class Diff (object): # 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] + 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) + return u'\n'.join(change_strings) def bugdir_attribute_change_string(self, attribute_changes): - return "Changed bug directory settings:\n%s" % \ + 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" % \ + 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" % \ + 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) @@ -620,24 +619,20 @@ class Diff (object): 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) + 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)) + 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) + return '%s\n %s...' % (summary, first_line) def comment_rem_string(self, comment): summary = self._comment_summary_string(comment) first_line = comment.body.splitlines()[0] - return "%s\n %s..." % (summary, first_line) + return '%s\n %s...' % (summary, first_line) def comment_mod_string(self, comments): old_comment,new_comment = comments return self._comment_summary_string(new_comment) def comment_body_change_string(self, bodies): old_body,new_body = bodies return difflib.unified_diff(old_body, new_body) - - -if libbe.TESTING == True: - suite = doctest.DocTestSuite() diff --git a/libbe/error.py b/libbe/error.py new file mode 100644 index 0000000..fa5678d --- /dev/null +++ b/libbe/error.py @@ -0,0 +1,12 @@ +# Copyright + +""" +General error classes for Bugs-Everywhere. +""" + +class NotSupported (NotImplementedError): + def __init__(self, action, message): + msg = '%s not supported: %s' % (action, message) + NotImplementedError.__init__(self, msg) + self.action = action + self.message = message diff --git a/libbe/hg.py b/libbe/hg.py deleted file mode 100644 index ed27717..0000000 --- a/libbe/hg.py +++ /dev/null @@ -1,108 +0,0 @@ -# Copyright (C) 2007-2009 Aaron Bentley and Panometrics, Inc. -# Ben Finney <benf@cybersource.com.au> -# Gianluca Montecchi <gian@grys.it> -# W. Trevor King <wking@drexel.edu> -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, write to the Free Software Foundation, Inc., -# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - -""" -Mercurial (hg) backend. -""" - -import os -import re -import sys - -import libbe -import vcs - -if libbe.TESTING == True: - import unittest - import doctest - - -def new(): - return Hg() - -class Hg(vcs.VCS): - name="hg" - client="hg" - versioned=True - def _vcs_version(self): - status,output,error = self._u_invoke_client("--version") - return output - def _vcs_detect(self, path): - """Detect whether a directory is revision-controlled using Mercurial""" - if self._u_search_parent_directories(path, ".hg") != None: - return True - return False - def _vcs_root(self, path): - status,output,error = self._u_invoke_client("root", cwd=path) - return output.rstrip('\n') - def _vcs_init(self, path): - self._u_invoke_client("init", cwd=path) - def _vcs_get_user_id(self): - status,output,error = self._u_invoke_client("showconfig","ui.username") - return output.rstrip('\n') - def _vcs_set_user_id(self, value): - """ - Supported by the Config Extension, but that is not part of - standard Mercurial. - http://www.selenic.com/mercurial/wiki/index.cgi/ConfigExtension - """ - raise vcs.SettingIDnotSupported - def _vcs_add(self, path): - self._u_invoke_client("add", path) - def _vcs_remove(self, path): - self._u_invoke_client("rm", "--force", path) - def _vcs_update(self, path): - pass - def _vcs_get_file_contents(self, path, revision=None, binary=False): - if revision == None: - return vcs.VCS._vcs_get_file_contents(self, path, revision, binary=binary) - else: - status,output,error = \ - self._u_invoke_client("cat","-r",revision,path) - return output - def _vcs_duplicate_repo(self, directory, revision=None): - if revision == None: - return vcs.VCS._vcs_duplicate_repo(self, directory, revision) - else: - self._u_invoke_client("archive", "--rev", revision, directory) - def _vcs_commit(self, commitfile, allow_empty=False): - args = ['commit', '--logfile', commitfile] - status,output,error = self._u_invoke_client(*args) - if allow_empty == False: - strings = ["nothing changed"] - if self._u_any_in_string(strings, output) == True: - raise vcs.EmptyCommit() - return self._vcs_revision_id(-1) - def _vcs_revision_id(self, index, style="id"): - args = ["identify", "--rev", str(int(index)), "--%s" % style] - kwargs = {"expect": (0,255)} - status,output,error = self._u_invoke_client(*args, **kwargs) - if status == 0: - id = output.strip() - if id == '000000000000': - return None # before initial commit. - return id - return None - - -if libbe.TESTING == True: - vcs.make_vcs_testcase_subclasses(Hg, sys.modules[__name__]) - - unitsuite =unittest.TestLoader().loadTestsFromModule(sys.modules[__name__]) - suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()]) diff --git a/libbe/storage/__init__.py b/libbe/storage/__init__.py new file mode 100644 index 0000000..e99f799 --- /dev/null +++ b/libbe/storage/__init__.py @@ -0,0 +1,37 @@ +# Copyright + +import base + +ConnectionError = base.ConnectionError +InvalidStorageVersion = base.InvalidStorageVersion +InvalidID = base.InvalidID +InvalidRevision = base.InvalidRevision +InvalidDirectory = base.InvalidDirectory +NotWriteable = base.NotWriteable +NotReadable = base.NotReadable +EmptyCommit = base.EmptyCommit + +# a list of all past versions +STORAGE_VERSIONS = ['Bugs Everywhere Tree 1 0', + 'Bugs Everywhere Directory v1.1', + 'Bugs Everywhere Directory v1.2', + 'Bugs Everywhere Directory v1.3', + 'Bugs Everywhere Directory v1.4', + ] + +# the current version +STORAGE_VERSION = STORAGE_VERSIONS[-1] + +def get_storage(location): + """ + Return a Storage instance from a repo location string. + """ + import vcs + s = vcs.detect_vcs(location) + s.repo = location + return s + +__all__ = [ConnectionError, InvalidStorageVersion, InvalidID, + InvalidRevision, InvalidDirectory, NotWriteable, NotReadable, + EmptyCommit, STORAGE_VERSIONS, STORAGE_VERSION, + get_storage] diff --git a/libbe/storage/base.py b/libbe/storage/base.py new file mode 100644 index 0000000..1c711fa --- /dev/null +++ b/libbe/storage/base.py @@ -0,0 +1,909 @@ +# Copyright + +""" +Abstract bug repository data storage to easily support multiple backends. +""" + +import copy +import os +import pickle +import types + +from libbe.error import NotSupported +import libbe.storage +from libbe.util.tree import Tree +from libbe.util import InvalidObject +from libbe import TESTING + +if TESTING == True: + import doctest + import os.path + import sys + import unittest + + from libbe.util.utility import Dir + +class ConnectionError (Exception): + pass + +class InvalidStorageVersion(ConnectionError): + def __init__(self, active_version, expected_version=None): + if expected_version == None: + expected_version = libbe.storage.STORAGE_VERSION + msg = 'Storage in "%s" not the expected "%s"' \ + % (active_version, expected_version) + Exception.__init__(self, msg) + self.active_version = active_version + self.expected_version = expected_version + +class InvalidID (KeyError): + def __init__(self, id=None, revision=None, msg=None): + if msg == None and id != None: + msg = id + KeyError.__init__(self, msg) + self.id = id + self.revision = revision + +class InvalidRevision (KeyError): + pass + +class InvalidDirectory (Exception): + pass + +class DirectoryNotEmpty (InvalidDirectory): + pass + +class NotWriteable (NotSupported): + def __init__(self, msg): + NotSupported.__init__(self, 'write', msg) + +class NotReadable (NotSupported): + def __init__(self, msg): + NotSupported.__init__(self, 'read', msg) + +class EmptyCommit(Exception): + def __init__(self): + Exception.__init__(self, 'No changes to commit') + + +class Entry (Tree): + def __init__(self, id, value=None, parent=None, directory=False, + children=None): + if children == None: + Tree.__init__(self) + else: + Tree.__init__(self, children) + self.id = id + self.value = value + self.parent = parent + if self.parent != None: + if self.parent.directory == False: + raise InvalidDirectory( + 'Non-directory %s cannot have children' % self.parent) + parent.append(self) + self.directory = directory + + def __str__(self): + return '<Entry %s: %s>' % (self.id, self.value) + + def __repr__(self): + return str(self) + + def __cmp__(self, other, local=False): + if other == None: + return cmp(1, None) + if cmp(self.id, other.id) != 0: + return cmp(self.id, other.id) + if cmp(self.value, other.value) != 0: + return cmp(self.value, other.value) + if local == False: + if self.parent == None: + if cmp(self.parent, other.parent) != 0: + return cmp(self.parent, other.parent) + elif self.parent.__cmp__(other.parent, local=True) != 0: + return self.parent.__cmp__(other.parent, local=True) + for sc,oc in zip(self, other): + if sc.__cmp__(oc, local=True) != 0: + return sc.__cmp__(oc, local=True) + return 0 + + def _objects_to_ids(self): + if self.parent != None: + self.parent = self.parent.id + for i,c in enumerate(self): + self[i] = c.id + return self + + def _ids_to_objects(self, dict): + if self.parent != None: + self.parent = dict[self.parent] + for i,c in enumerate(self): + self[i] = dict[c] + return self + +class Storage (object): + """ + This class declares all the methods required by a Storage + interface. This implementation just keeps the data in a + dictionary and uses pickle for persistent storage. + """ + name = 'Storage' + + def __init__(self, repo='/', encoding='utf-8', options=None): + self.repo = repo + self.encoding = encoding + self.options = options + self.readable = True # soft limit (user choice) + self._readable = True # hard limit (backend choice) + self.writeable = True # soft limit (user choice) + self._writeable = True # hard limit (backend choice) + self.versioned = False + self.can_init = True + self.connected = False + + def __str__(self): + return '<%s %s %s>' % (self.__class__.__name__, id(self), self.repo) + + def __repr__(self): + return str(self) + + def version(self): + """Return a version string for this backend.""" + return '0' + + def storage_version(self, revision=None): + """Return the storage format for this backend.""" + return libbe.storage.STORAGE_VERSION + + def is_readable(self): + return self.readable and self._readable + + def is_writeable(self): + return self.writeable and self._writeable + + def init(self): + """Create a new storage repository.""" + if self.can_init == False: + raise NotSupported('init', + 'Cannot initialize this repository format.') + if self.is_writeable() == False: + raise NotWriteable('Cannot initialize unwriteable storage.') + return self._init() + + def _init(self): + f = open(os.path.join(self.repo, 'repo.pkl'), 'wb') + root = Entry(id='__ROOT__', directory=True) + d = {root.id:root} + pickle.dump(dict((k,v._objects_to_ids()) for k,v in d.items()), f, -1) + f.close() + + def destroy(self): + """Remove the storage repository.""" + if self.is_writeable() == False: + raise NotWriteable('Cannot destroy unwriteable storage.') + return self._destroy() + + def _destroy(self): + os.remove(os.path.join(self.repo, 'repo.pkl')) + + def connect(self): + """Open a connection to the repository.""" + if self.is_readable() == False: + raise NotReadable('Cannot connect to unreadable storage.') + self._connect() + self.connected = True + + def _connect(self): + try: + f = open(os.path.join(self.repo, 'repo.pkl'), 'rb') + except IOError: + raise ConnectionError(self) + d = pickle.load(f) + self._data = dict((k,v._ids_to_objects(d)) for k,v in d.items()) + f.close() + + def disconnect(self): + """Close the connection to the repository.""" + if self.is_writeable() == False: + return + if self.connected == False: + return + self._disconnect() + self.connected = False + + def _disconnect(self): + f = open(os.path.join(self.repo, 'repo.pkl'), 'wb') + pickle.dump(dict((k,v._objects_to_ids()) + for k,v in self._data.items()), f, -1) + f.close() + self._data = None + + def add(self, id, *args, **kwargs): + """Add an entry""" + if self.is_writeable() == False: + raise NotWriteable('Cannot add entry to unwriteable storage.') + try: # Maybe we've already added that id? + self.get(id) + pass # yup, no need to add another + except InvalidID: + self._add(id, *args, **kwargs) + + def _add(self, id, parent=None, directory=False): + if parent == None: + parent = '__ROOT__' + p = self._data[parent] + self._data[id] = Entry(id, parent=p, directory=directory) + + def remove(self, *args, **kwargs): + """Remove an entry.""" + if self.is_writeable() == False: + raise NotSupported('write', + 'Cannot remove entry from unwriteable storage.') + self._remove(*args, **kwargs) + + def _remove(self, id): + if self._data[id].directory == True \ + and len(self.children(id)) > 0: + raise DirectoryNotEmpty(id) + e = self._data.pop(id) + e.parent.remove(e) + + def recursive_remove(self, *args, **kwargs): + """Remove an entry and all its decendents.""" + if self.is_writeable() == False: + raise NotSupported('write', + 'Cannot remove entries from unwriteable storage.') + self._recursive_remove(*args, **kwargs) + + def _recursive_remove(self, id): + for entry in reversed(list(self._data[id].traverse())): + self._remove(entry.id) + + def children(self, *args, **kwargs): + """Return a list of specified entry's children's ids.""" + if self.is_readable() == False: + raise NotReadable('Cannot list children with unreadable storage.') + return self._children(*args, **kwargs) + + def _children(self, id=None, revision=None): + if id == None: + id = '__ROOT__' + return [c.id for c in self._data[id] if not c.id.startswith('__')] + + def get(self, *args, **kwargs): + """ + Get contents of and entry as they were in a given revision. + revision==None specifies the current revision. + + If there is no id, return default, unless default is not + given, in which case raise InvalidID. + """ + if self.is_readable() == False: + raise NotReadable('Cannot get entry with unreadable storage.') + if 'decode' in kwargs: + decode = kwargs.pop('decode') + else: + decode = False + value = self._get(*args, **kwargs) + if value != None: + if decode == True and type(value) != types.UnicodeType: + return unicode(value, self.encoding) + elif decode == False and type(value) != types.StringType: + return value.encode(self.encoding) + return value + + def _get(self, id, default=InvalidObject, revision=None): + if id in self._data: + return self._data[id].value + elif default == InvalidObject: + raise InvalidID(id) + return default + + def set(self, id, value, *args, **kwargs): + """ + Set the entry contents. + """ + if self.is_writeable() == False: + raise NotWriteable('Cannot set entry in unwriteable storage.') + if type(value) == types.UnicodeType: + value = value.encode(self.encoding) + self._set(id, value, *args, **kwargs) + + def _set(self, id, value): + if id not in self._data: + raise InvalidID(id) + if self._data[id].directory == True: + raise InvalidDirectory( + 'Directory %s cannot have data' % self.parent) + self._data[id].value = value + +class VersionedStorage (Storage): + """ + This class declares all the methods required by a Storage + interface that supports versioning. This implementation just + keeps the data in a list and uses pickle for persistent + storage. + """ + name = 'VersionedStorage' + + def __init__(self, *args, **kwargs): + Storage.__init__(self, *args, **kwargs) + self.versioned = True + + def _init(self): + f = open(os.path.join(self.repo, 'repo.pkl'), 'wb') + root = Entry(id='__ROOT__', directory=True) + summary = Entry(id='__COMMIT__SUMMARY__', value='Initial commit') + body = Entry(id='__COMMIT__BODY__') + initial_commit = {root.id:root, summary.id:summary, body.id:body} + d = dict((k,v._objects_to_ids()) for k,v in initial_commit.items()) + pickle.dump([d, copy.deepcopy(d)], f, -1) # [inital tree, working tree] + f.close() + + def _connect(self): + try: + f = open(os.path.join(self.repo, 'repo.pkl'), 'rb') + except IOError: + raise ConnectionError(self) + d = pickle.load(f) + self._data = [dict((k,v._ids_to_objects(t)) for k,v in t.items()) + for t in d] + f.close() + + def _disconnect(self): + f = open(os.path.join(self.repo, 'repo.pkl'), 'wb') + pickle.dump([dict((k,v._objects_to_ids()) + for k,v in t.items()) for t in self._data], f, -1) + f.close() + self._data = None + + def _add(self, id, parent=None, directory=False): + if parent == None: + parent = '__ROOT__' + p = self._data[-1][parent] + self._data[-1][id] = Entry(id, parent=p, directory=directory) + + def _remove(self, id): + if self._data[-1][id].directory == True \ + and len(self.children(id)) > 0: + raise DirectoryNotEmpty(id) + e = self._data[-1].pop(id) + e.parent.remove(e) + + def _recursive_remove(self, id): + for entry in reversed(list(self._data[-1][id].traverse())): + self._remove(entry.id) + + def _children(self, id=None, revision=None): + if id == None: + id = '__ROOT__' + if revision == None: + revision = -1 + return [c.id for c in self._data[revision][id] + if not c.id.startswith('__')] + + def _get(self, id, default=InvalidObject, revision=None): + if revision == None: + revision = -1 + if id in self._data[revision]: + return self._data[revision][id].value + elif default == InvalidObject: + raise InvalidID(id) + return default + + def _set(self, id, value): + if id not in self._data[-1]: + raise InvalidID(id) + self._data[-1][id].value = value + + def commit(self, *args, **kwargs): + """ + Commit the current repository, with a commit message string + summary and body. Return the name of the new revision. + + If allow_empty == False (the default), raise EmptyCommit if + there are no changes to commit. + """ + if self.is_writeable() == False: + raise NotWriteable('Cannot commit to unwriteable storage.') + return self._commit(*args, **kwargs) + + def _commit(self, summary, body=None, allow_empty=False): + if self._data[-1] == self._data[-2] and allow_empty == False: + raise EmptyCommit + self._data[-1]["__COMMIT__SUMMARY__"].value = summary + self._data[-1]["__COMMIT__BODY__"].value = body + rev = len(self._data)-1 + self._data.append(copy.deepcopy(self._data[-1])) + return rev + + 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. Revision indices start at 1; ID 0 is the blank + repository. + + Return None if index==None. + + If the specified revision does not exist, raise InvalidRevision. + """ + if index == None: + return None + try: + if int(index) != index: + raise InvalidRevision(index) + except ValueError: + raise InvalidRevision(index) + L = len(self._data) - 1 # -1 b/c of initial commit + if index >= -L and index <= L: + return index % L + raise InvalidRevision(i) + +if TESTING == True: + class StorageTestCase (unittest.TestCase): + """Test cases for base Storage class.""" + + Class = Storage + + def __init__(self, *args, **kwargs): + super(StorageTestCase, self).__init__(*args, **kwargs) + self.dirname = None + + def setUp(self): + """Set up test fixtures for Storage test case.""" + super(StorageTestCase, self).setUp() + self.dir = Dir() + self.dirname = self.dir.path + self.s = self.Class(repo=self.dirname) + self.assert_failed_connect() + self.s.init() + self.s.connect() + + def tearDown(self): + super(StorageTestCase, self).tearDown() + self.s.disconnect() + self.s.destroy() + self.assert_failed_connect() + self.dir.cleanup() + + def assert_failed_connect(self): + try: + self.s.connect() + self.fail( + "Connected to %(name)s repository before initialising" + % vars(self.Class)) + except ConnectionError: + pass + + class Storage_init_TestCase (StorageTestCase): + """Test cases for Storage.init method.""" + + def test_connect_should_succeed_after_init(self): + """Should connect after initialization.""" + self.s.connect() + + class Storage_connect_disconnect_TestCase (StorageTestCase): + """Test cases for Storage.connect and .disconnect methods.""" + + def test_multiple_disconnects(self): + """Should be able to call .disconnect multiple times.""" + self.s.disconnect() + self.s.disconnect() + + class Storage_add_remove_TestCase (StorageTestCase): + """Test cases for Storage.add, .remove, and .recursive_remove methods.""" + + def test_initially_empty(self): + """New repository should be empty.""" + self.failUnless(len(self.s.children()) == 0, self.s.children()) + + def test_add_identical_rooted(self): + """ + Adding entries with the same ID should not increase the number of children. + """ + for i in range(10): + self.s.add('some id', directory=False) + s = sorted(self.s.children()) + self.failUnless(s == ['some id'], s) + + def test_add_rooted(self): + """ + Adding entries should increase the number of children (rooted). + """ + ids = [] + for i in range(10): + ids.append(str(i)) + self.s.add(ids[-1], directory=(i % 2 == 0)) + s = sorted(self.s.children()) + self.failUnless(s == ids, '\n %s\n !=\n %s' % (s, ids)) + + def test_add_nonrooted(self): + """ + Adding entries should increase the number of children (nonrooted). + """ + self.s.add('parent', directory=True) + ids = [] + for i in range(10): + ids.append(str(i)) + self.s.add(ids[-1], 'parent', directory=(i % 2 == 0)) + s = sorted(self.s.children('parent')) + self.failUnless(s == ids, '\n %s\n !=\n %s' % (s, ids)) + s = self.s.children() + self.failUnless(s == ['parent'], s) + + def test_children(self): + """ + Non-UUID ids should be returned as such. + """ + self.s.add('parent', directory=True) + ids = [] + for i in range(10): + ids.append('parent/%s' % str(i)) + self.s.add(ids[-1], 'parent', directory=(i % 2 == 0)) + s = sorted(self.s.children('parent')) + self.failUnless(s == ids, '\n %s\n !=\n %s' % (s, ids)) + + def test_add_invalid_directory(self): + """ + Should not be able to add children to non-directories. + """ + self.s.add('parent', directory=False) + try: + self.s.add('child', 'parent', directory=False) + self.fail( + '%s.add() succeeded instead of raising InvalidDirectory' + % (vars(self.Class)['name'])) + except InvalidDirectory: + pass + try: + self.s.add('child', 'parent', directory=True) + self.fail( + '%s.add() succeeded instead of raising InvalidDirectory' + % (vars(self.Class)['name'])) + except InvalidDirectory: + pass + self.failUnless(len(self.s.children('parent')) == 0, + self.s.children('parent')) + + def test_remove_rooted(self): + """ + Removing entries should decrease the number of children (rooted). + """ + ids = [] + for i in range(10): + ids.append(str(i)) + self.s.add(ids[-1], directory=(i % 2 == 0)) + for i in range(10): + self.s.remove(ids.pop()) + s = sorted(self.s.children()) + self.failUnless(s == ids, '\n %s\n !=\n %s' % (s, ids)) + + def test_remove_nonrooted(self): + """ + Removing entries should decrease the number of children (nonrooted). + """ + self.s.add('parent', directory=True) + ids = [] + for i in range(10): + ids.append(str(i)) + self.s.add(ids[-1], 'parent', directory=False)#(i % 2 == 0)) + for i in range(10): + self.s.remove(ids.pop()) + s = sorted(self.s.children('parent')) + self.failUnless(s == ids, '\n %s\n !=\n %s' % (s, ids)) + if len(s) > 0: + s = self.s.children() + self.failUnless(s == ['parent'], s) + + def test_remove_directory_not_empty(self): + """ + Removing a non-empty directory entry should raise exception. + """ + self.s.add('parent', directory=True) + ids = [] + for i in range(10): + ids.append(str(i)) + self.s.add(ids[-1], 'parent', directory=(i % 2 == 0)) + self.s.remove(ids.pop()) # empty directory removal succeeds + try: + self.s.remove('parent') # empty directory removal succeeds + self.fail( + "%s.remove() didn't raise DirectoryNotEmpty" + % (vars(self.Class)['name'])) + except DirectoryNotEmpty: + pass + + def test_recursive_remove(self): + """ + Recursive remove should empty the tree. + """ + self.s.add('parent', directory=True) + ids = [] + for i in range(10): + ids.append(str(i)) + self.s.add(ids[-1], 'parent', directory=True) + for j in range(10): # add some grandkids + self.s.add(str(20*(i+1)+j), ids[-1], directory=(i%2 == 0)) + self.s.recursive_remove('parent') + s = sorted(self.s.children()) + self.failUnless(s == [], s) + + class Storage_get_set_TestCase (StorageTestCase): + """Test cases for Storage.get and .set methods.""" + + id = 'unlikely id' + val = 'unlikely value' + + def test_get_default(self): + """ + Get should return specified default if id not in Storage. + """ + ret = self.s.get(self.id, default=self.val) + self.failUnless(ret == self.val, + "%s.get() returned %s not %s" + % (vars(self.Class)['name'], ret, self.val)) + + def test_get_default_exception(self): + """ + Get should raise exception if id not in Storage and no default. + """ + try: + ret = self.s.get(self.id) + self.fail( + "%s.get() returned %s instead of raising InvalidID" + % (vars(self.Class)['name'], ret)) + except InvalidID: + pass + + def test_get_initial_value(self): + """ + Data value should be None before any value has been set. + """ + self.s.add(self.id, directory=False) + ret = self.s.get(self.id) + self.failUnless(ret == None, + "%s.get() returned %s not None" + % (vars(self.Class)['name'], ret)) + + def test_set_exception(self): + """ + Set should raise exception if id not in Storage. + """ + try: + self.s.set(self.id, self.val) + self.fail( + "%(name)s.set() did not raise InvalidID" + % vars(self.Class)) + except InvalidID: + pass + + def test_set(self): + """ + Set should define the value returned by get. + """ + self.s.add(self.id, directory=False) + self.s.set(self.id, self.val) + ret = self.s.get(self.id) + self.failUnless(ret == self.val, + "%s.get() returned %s not %s" + % (vars(self.Class)['name'], ret, self.val)) + + def test_unicode_set(self): + """ + Set should define the value returned by get. + """ + val = u'Fran\xe7ois' + self.s.add(self.id, directory=False) + self.s.set(self.id, val) + ret = self.s.get(self.id, decode=True) + self.failUnless(type(ret) == types.UnicodeType, + "%s.get() returned %s not UnicodeType" + % (vars(self.Class)['name'], type(ret))) + self.failUnless(ret == val, + "%s.get() returned %s not %s" + % (vars(self.Class)['name'], ret, self.val)) + ret = self.s.get(self.id) + self.failUnless(type(ret) == types.StringType, + "%s.get() returned %s not StringType" + % (vars(self.Class)['name'], type(ret))) + s = unicode(ret, self.s.encoding) + self.failUnless(s == val, + "%s.get() returned %s not %s" + % (vars(self.Class)['name'], s, self.val)) + + + class Storage_persistence_TestCase (StorageTestCase): + """Test cases for Storage.disconnect and .connect methods.""" + + id = 'unlikely id' + val = 'unlikely value' + + def test_get_set_persistence(self): + """ + Set should define the value returned by get after reconnect. + """ + self.s.add(self.id, directory=False) + self.s.set(self.id, self.val) + self.s.disconnect() + self.s.connect() + ret = self.s.get(self.id) + self.failUnless(ret == self.val, + "%s.get() returned %s not %s" + % (vars(self.Class)['name'], ret, self.val)) + + def test_add_nonrooted_persistence(self): + """ + Adding entries should increase the number of children after reconnect. + """ + self.s.add('parent', directory=True) + ids = [] + for i in range(10): + ids.append(str(i)) + self.s.add(ids[-1], 'parent', directory=(i % 2 == 0)) + self.s.disconnect() + self.s.connect() + s = sorted(self.s.children('parent')) + self.failUnless(s == ids, '\n %s\n !=\n %s' % (s, ids)) + s = self.s.children() + self.failUnless(s == ['parent'], s) + + class VersionedStorageTestCase (StorageTestCase): + """Test cases for base VersionedStorage class.""" + + Class = VersionedStorage + + class VersionedStorage_commit_TestCase (VersionedStorageTestCase): + """Test cases for VersionedStorage methods.""" + + id = 'unlikely id' + val = 'Some value' + commit_msg = 'Committing something interesting' + commit_body = 'Some\nlonger\ndescription\n' + + def _setup_for_empty_commit(self): + """ + Initialization might add some files to version control, so + commit those first, before testing the empty commit + functionality. + """ + try: + self.s.commit('Added initialization files') + except EmptyCommit: + pass + + def test_revision_id_exception(self): + """ + Invalid revision id should raise InvalidRevision. + """ + try: + rev = self.s.revision_id('highly unlikely revision id') + self.fail( + "%s.revision_id() didn't raise InvalidRevision, returned %s." + % (vars(self.Class)['name'], rev)) + except InvalidRevision: + pass + + def test_empty_commit_raises_exception(self): + """ + Empty commit should raise exception. + """ + self._setup_for_empty_commit() + try: + self.s.commit(self.commit_msg, self.commit_body) + self.fail( + "Empty %(name)s.commit() didn't raise EmptyCommit." + % vars(self.Class)) + except EmptyCommit: + pass + + def test_empty_commit_allowed(self): + """ + Empty commit should _not_ raise exception if allow_empty=True. + """ + self._setup_for_empty_commit() + self.s.commit(self.commit_msg, self.commit_body, + allow_empty=True) + + def test_commit_revision_ids(self): + """ + Commit / revision_id should agree on revision ids. + """ + def val(i): + return '%s:%d' % (self.val, i+1) + self.s.add(self.id, directory=False) + revs = [] + for i in range(10): + self.s.set(self.id, val(i)) + revs.append(self.s.commit('%s: %d' % (self.commit_msg, i), + self.commit_body)) + for i in range(10): + rev = self.s.revision_id(i+1) + self.failUnless(rev == revs[i], + "%s.revision_id(%d) returned %s not %s" + % (vars(self.Class)['name'], i+1, rev, revs[i])) + for i in range(-1, -9, -1): + rev = self.s.revision_id(i) + self.failUnless(rev == revs[i], + "%s.revision_id(%d) returned %s not %s" + % (vars(self.Class)['name'], i, rev, revs[i])) + + def test_get_previous_version(self): + """ + Get should be able to return the previous version. + """ + def val(i): + return '%s:%d' % (self.val, i+1) + self.s.add(self.id, directory=False) + revs = [] + for i in range(10): + self.s.set(self.id, val(i)) + revs.append(self.s.commit('%s: %d' % (self.commit_msg, i), + self.commit_body)) + for i in range(10): + ret = self.s.get(self.id, revision=revs[i]) + self.failUnless(ret == val(i), + "%s.get() returned %s not %s for revision %s" + % (vars(self.Class)['name'], ret, val(i), revs[i])) + + def test_get_previous_children(self): + """ + Children list should be revision dependent. + """ + self.s.add('parent', directory=True) + revs = [] + cur_children = [] + children = [] + for i in range(10): + new_child = str(i) + self.s.add(new_child, 'parent', directory=(i % 2 == 0)) + self.s.set(new_child, self.val) + revs.append(self.s.commit('%s: %d' % (self.commit_msg, i), + self.commit_body)) + cur_children.append(new_child) + children.append(list(cur_children)) + for i in range(10): + ret = self.s.children('parent', revision=revs[i]) + self.failUnless(ret == children[i], + "%s.get() returned %s not %s for revision %s" + % (vars(self.Class)['name'], ret, + children[i], revs[i])) + + def make_storage_testcase_subclasses(storage_class, namespace): + """Make StorageTestCase subclasses for storage_class in namespace.""" + storage_testcase_classes = [ + c for c in ( + ob for ob in globals().values() if isinstance(ob, type)) + if issubclass(c, StorageTestCase) \ + and c.Class == Storage] + + for base_class in storage_testcase_classes: + testcase_class_name = storage_class.__name__ + base_class.__name__ + testcase_class_bases = (base_class,) + testcase_class_dict = dict(base_class.__dict__) + testcase_class_dict['Class'] = storage_class + testcase_class = type( + testcase_class_name, testcase_class_bases, testcase_class_dict) + setattr(namespace, testcase_class_name, testcase_class) + + def make_versioned_storage_testcase_subclasses(storage_class, namespace): + """Make VersionedStorageTestCase subclasses for storage_class in namespace.""" + storage_testcase_classes = [ + c for c in ( + ob for ob in globals().values() if isinstance(ob, type)) + if issubclass(c, StorageTestCase) \ + and c.Class == Storage] + + for base_class in storage_testcase_classes: + testcase_class_name = storage_class.__name__ + base_class.__name__ + testcase_class_bases = (base_class,) + testcase_class_dict = dict(base_class.__dict__) + testcase_class_dict['Class'] = storage_class + testcase_class = type( + testcase_class_name, testcase_class_bases, testcase_class_dict) + setattr(namespace, testcase_class_name, testcase_class) + + make_storage_testcase_subclasses(VersionedStorage, sys.modules[__name__]) + + unitsuite =unittest.TestLoader().loadTestsFromModule(sys.modules[__name__]) + suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()]) diff --git a/libbe/storage/util/__init__.py b/libbe/storage/util/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/libbe/storage/util/__init__.py diff --git a/libbe/config.py b/libbe/storage/util/config.py index ccd236b..9f95d14 100644 --- a/libbe/config.py +++ b/libbe/storage/util/config.py @@ -22,16 +22,15 @@ Create, save, and load the per-user config file at path(). import ConfigParser import codecs -import locale import os.path -import sys import libbe +import libbe.util.encoding if libbe.TESTING == True: import doctest -default_encoding = sys.getfilesystemencoding() or locale.getpreferredencoding() +default_encoding = libbe.util.encoding.get_filesystem_encoding() def path(): """Return the path to the per-user config file""" @@ -47,16 +46,16 @@ def set_val(name, value, section="DEFAULT", encoding=None): if encoding == None: encoding = default_encoding config = ConfigParser.ConfigParser() - if os.path.exists(path()) == False: # touch file or config - open(path(), "w").close() # read chokes on missing file - f = codecs.open(path(), "r", encoding) + if os.path.exists(path()) == False: # touch file or config + open(path(), 'w').close() # read chokes on missing file + f = codecs.open(path(), 'r', encoding) config.readfp(f, path()) f.close() if value is not None: config.set(section, name, value) else: config.remove_option(section, name) - f = codecs.open(path(), "w", encoding) + f = codecs.open(path(), 'w', encoding) config.write(f) f.close() @@ -80,7 +79,7 @@ def get_val(name, section="DEFAULT", default=None, encoding=None): if encoding == None: encoding = default_encoding config = ConfigParser.ConfigParser() - f = codecs.open(path(), "r", encoding) + f = codecs.open(path(), 'r', encoding) config.readfp(f, path()) f.close() try: diff --git a/libbe/mapfile.py b/libbe/storage/util/mapfile.py index 8e1e279..35ae1a0 100644 --- a/libbe/mapfile.py +++ b/libbe/storage/util/mapfile.py @@ -24,6 +24,7 @@ independent/conflicting changes. import errno import os.path +import types import yaml import libbe @@ -39,32 +40,37 @@ class IllegalKey(Exception): class IllegalValue(Exception): def __init__(self, value): Exception.__init__(self, 'Illegal value "%s"' % value) - self.value = value + self.value = value + +class InvalidMapfileContents(Exception): + def __init__(self, contents): + Exception.__init__(self, 'Invalid YAML contents') + self.contents = contents def generate(map): """Generate a YAML mapfile content string. - >>> generate({"q":"p"}) + >>> generate({'q':'p'}) 'q: p\\n\\n' - >>> generate({"q":u"Fran\u00e7ais"}) + >>> generate({'q':u'Fran\u00e7ais'}) 'q: Fran\\xc3\\xa7ais\\n\\n' - >>> generate({"q":u"hello"}) + >>> generate({'q':u'hello'}) 'q: hello\\n\\n' - >>> generate({"q=":"p"}) + >>> generate({'q=':'p'}) Traceback (most recent call last): IllegalKey: Illegal key "q=" - >>> generate({"q:":"p"}) + >>> generate({'q:':'p'}) Traceback (most recent call last): IllegalKey: Illegal key "q:" - >>> generate({"q\\n":"p"}) + >>> generate({'q\\n':'p'}) Traceback (most recent call last): IllegalKey: Illegal key "q\\n" - >>> generate({"":"p"}) + >>> generate({'':'p'}) Traceback (most recent call last): IllegalKey: Illegal key "" - >>> generate({">q":"p"}) + >>> generate({'>q':'p'}) Traceback (most recent call last): IllegalKey: Illegal key ">q" - >>> generate({"q":"p\\n"}) + >>> generate({'q':'p\\n'}) Traceback (most recent call last): IllegalValue: Illegal value "p\\n" """ @@ -97,30 +103,28 @@ def parse(contents): 'p' >>> parse('q: \\'p\\'\\n\\n')['q'] 'p' - >>> contents = generate({"a":"b", "c":"d", "e":"f"}) + >>> contents = generate({'a':'b', 'c':'d', 'e':'f'}) >>> dict = parse(contents) - >>> dict["a"] + >>> dict['a'] 'b' - >>> dict["c"] + >>> dict['c'] 'd' - >>> dict["e"] + >>> dict['e'] 'f' - >>> contents = generate({"q":u"Fran\u00e7ais"}) + >>> contents = generate({'q':u'Fran\u00e7ais'}) >>> dict = parse(contents) - >>> dict["q"] + >>> dict['q'] u'Fran\\xe7ais' + >>> dict = parse('a!') + Traceback (most recent call last): + ... + InvalidMapfileContents: Invalid YAML contents """ - return yaml.load(contents) or {} - -def map_save(vcs, path, map, allow_no_vcs=False): - """Save the map as a mapfile to the specified path""" - contents = generate(map) - vcs.set_file_contents(path, contents, allow_no_vcs, binary=True) - -def map_load(vcs, path, allow_no_vcs=False): - contents = vcs.get_file_contents(path, allow_no_vcs=allow_no_vcs, - binary=True) - return parse(contents) + c = yaml.load(contents) + if type(c) == types.StringType: + raise InvalidMapfileContents( + 'Unable to parse YAML (BE format missmatch?):\n\n%s' % contents) + return c or {} if libbe.TESTING == True: suite = doctest.DocTestSuite() diff --git a/libbe/properties.py b/libbe/storage/util/properties.py index f756ff0..ddd7b25 100644 --- a/libbe/properties.py +++ b/libbe/storage/util/properties.py @@ -346,7 +346,7 @@ def change_hook_property(hook, mutable=False, default=None): mutable value, and checking for changes whenever the property is set (obviously) or retrieved (to check for external changes). So long as you're conscientious about accessing the property after - making external modifications, mutability woln't be a problem. + making external modifications, mutability won't be a problem. t.x.append(5) # external modification t.x # dummy access notices change and triggers hook See testChangeHookMutableProperty for an example of the expected diff --git a/libbe/settings_object.py b/libbe/storage/util/settings_object.py index 6a00ba9..8b86829 100644 --- a/libbe/settings_object.py +++ b/libbe/storage/util/settings_object.py @@ -32,7 +32,6 @@ if libbe.TESTING == True: import doctest import unittest - class _Token (object): """ `Control' value class for properties. We want values that only @@ -56,14 +55,15 @@ def prop_save_settings(self, old, new): """ The default action undertaken when a property changes. """ - if self.sync_with_disk==True: + if self.storage != None and self.storage.is_writeable(): self.save_settings() def prop_load_settings(self): """ The default action undertaken when an UNPRIMED property is accessed. """ - if self.sync_with_disk==True and self._settings_loaded==False: + if self.storage != None and self.storage.is_readable() \ + and self._settings_loaded==False: self.load_settings() else: self._setup_saved_settings(flag_as_loaded=False) @@ -182,7 +182,7 @@ class SavedSettingsObject(object): def __init__(self): self._settings_loaded = False - self.sync_with_disk = False + self.storage = None self.settings = {} def load_settings(self): @@ -197,9 +197,8 @@ class SavedSettingsObject(object): settings as primed. """ for property in self.settings_properties: - if property not in self.settings: - self.settings[property] = EMPTY - elif self.settings[property] == UNPRIMED: + if property not in self.settings \ + or self.settings[property] == UNPRIMED: self.settings[property] = EMPTY if flag_as_loaded == True: self._settings_loaded = True @@ -410,21 +409,21 @@ if libbe.TESTING == True: self.failUnless(t.settings["List-type"] == [], t.settings["List-type"]) self.failUnless(SAVES == [ - "'<class 'libbe.settings_object.EMPTY'>' -> '[]'" + "'<class 'libbe.storage.util.settings_object.EMPTY'>' -> '[]'" ], SAVES) t.list_type.append(5) self.failUnless(SAVES == [ - "'<class 'libbe.settings_object.EMPTY'>' -> '[]'", + "'<class 'libbe.storage.util.settings_object.EMPTY'>' -> '[]'", ], SAVES) self.failUnless(t.settings["List-type"] == [5], t.settings["List-type"]) self.failUnless(SAVES == [ # the append(5) has not yet been saved - "'<class 'libbe.settings_object.EMPTY'>' -> '[]'", + "'<class 'libbe.storage.util.settings_object.EMPTY'>' -> '[]'", ], SAVES) self.failUnless(t.list_type == [5], t.list_type)#get triggers saved self.failUnless(SAVES == [ # now the append(5) has been saved. - "'<class 'libbe.settings_object.EMPTY'>' -> '[]'", + "'<class 'libbe.storage.util.settings_object.EMPTY'>' -> '[]'", "'[]' -> '[5]'" ], SAVES) diff --git a/libbe/storage/util/upgrade.py b/libbe/storage/util/upgrade.py new file mode 100644 index 0000000..20ef1e4 --- /dev/null +++ b/libbe/storage/util/upgrade.py @@ -0,0 +1,331 @@ +# Copyright (C) 2009 W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +""" +Handle conversion between the various BE storage formats. +""" + +import codecs +import os, os.path +import sys + +import libbe +import libbe.bug +import libbe.storage.util.mapfile as mapfile +from libbe.storage import STORAGE_VERSIONS, STORAGE_VERSION +#import libbe.storage.vcs # delay import to avoid cyclic dependency +import libbe.ui.util.editor +import libbe.util +import libbe.util.encoding as encoding +import libbe.util.id + + +class Upgrader (object): + "Class for converting between different on-disk BE storage formats." + initial_version = None + final_version = None + def __init__(self, repo): + import libbe.storage.vcs + + self.repo = repo + vcs_name = self._get_vcs_name() + if vcs_name == None: + vcs_name = 'None' + self.vcs = libbe.storage.vcs.vcs_by_name(vcs_name) + self.vcs.repo = self.repo + self.vcs.root() + + def get_path(self, *args): + """ + Return the absolute path using args relative to .be. + """ + dir = os.path.join(self.repo, '.be') + if len(args) == 0: + return dir + return os.path.join(dir, *args) + + def _get_vcs_name(self): + return None + + def check_initial_version(self): + path = self.get_path('version') + version = encoding.get_file_contents(path, decode=True).rstrip('\n') + assert version == self.initial_version, '%s: %s' % (path, version) + + def set_version(self): + path = self.get_path('version') + encoding.set_file_contents(path, self.final_version+'\n') + self.vcs._vcs_update(path) + + def upgrade(self): + print >> sys.stderr, 'upgrading bugdir from "%s" to "%s"' \ + % (self.initial_version, self.final_version) + self.check_initial_version() + self.set_version() + self._upgrade() + + def _upgrade(self): + raise NotImplementedError + + +class Upgrade_1_0_to_1_1 (Upgrader): + initial_version = "Bugs Everywhere Tree 1 0" + final_version = "Bugs Everywhere Directory v1.1" + def _get_vcs_name(self): + path = self.get_path('settings') + settings = encoding.get_file_contents(path) + for line in settings.splitlines(False): + fields = line.split('=') + if len(fields) == 2 and fields[0] == 'rcs_name': + return fields[1] + return None + + def _upgrade_mapfile(self, path): + contents = encoding.get_file_contents(path, decode=True) + old_format = False + for line in contents.splitlines(): + if len(line.split('=')) == 2: + old_format = True + break + if old_format == True: + # translate to YAML. + newlines = [] + for line in contents.splitlines(): + line = line.rstrip('\n') + if len(line) == 0: + continue + fields = line.split("=") + if len(fields) == 2: + key,value = fields + newlines.append('%s: "%s"' % (key, value.replace('"','\\"'))) + else: + newlines.append(line) + contents = '\n'.join(newlines) + # load the YAML and save + map = mapfile.parse(contents) + contents = mapfile.generate(map) + encoding.set_file_contents(path, contents) + self.vcs._vcs_update(path) + + def _upgrade(self): + """ + Comment value field "From" -> "Author". + Homegrown mapfile -> YAML. + """ + path = self.get_path('settings') + self._upgrade_mapfile(path) + for bug_uuid in os.listdir(self.get_path('bugs')): + path = self.get_path('bugs', bug_uuid, 'values') + self._upgrade_mapfile(path) + c_path = ['bugs', bug_uuid, 'comments'] + if not os.path.exists(self.get_path(*c_path)): + continue # no comments for this bug + for comment_uuid in os.listdir(self.get_path(*c_path)): + path_list = c_path + [comment_uuid, 'values'] + path = self.get_path(*path_list) + self._upgrade_mapfile(path) + settings = mapfile.parse( + encoding.get_file_contents(path)) + if 'From' in settings: + settings['Author'] = settings.pop('From') + encoding.set_file_contents( + path, mapfile.generate(settings)) + self.vcs._vcs_update(path) + + +class Upgrade_1_1_to_1_2 (Upgrader): + initial_version = "Bugs Everywhere Directory v1.1" + final_version = "Bugs Everywhere Directory v1.2" + def _get_vcs_name(self): + path = self.get_path('settings') + settings = mapfile.parse(encoding.get_file_contents(path)) + if 'rcs_name' in settings: + return settings['rcs_name'] + return None + + def _upgrade(self): + """ + BugDir settings field "rcs_name" -> "vcs_name". + """ + path = self.get_path('settings') + settings = mapfile.parse(encoding.get_file_contents(path)) + if 'rcs_name' in settings: + settings['vcs_name'] = settings.pop('rcs_name') + encoding.set_file_contents(path, mapfile.generate(settings)) + self.vcs._vcs_update(path) + +class Upgrade_1_2_to_1_3 (Upgrader): + initial_version = "Bugs Everywhere Directory v1.2" + final_version = "Bugs Everywhere Directory v1.3" + def __init__(self, *args, **kwargs): + Upgrader.__init__(self, *args, **kwargs) + self._targets = {} # key: target text,value: new target bug + + def _get_vcs_name(self): + path = self.get_path('settings') + settings = mapfile.parse(encoding.get_file_contents(path)) + if 'vcs_name' in settings: + return settings['vcs_name'] + return None + + def _save_bug_settings(self, bug): + # The target bugs don't have comments + path = self.get_path('bugs', bug.uuid, 'values') + if not os.path.exists(path): + self.vcs._add_path(path, directory=False) + path = self.get_path('bugs', bug.uuid, 'values') + mf = mapfile.generate(bug._get_saved_settings()) + encoding.set_file_contents(path, mf) + self.vcs._vcs_update(path) + + def _target_bug(self, target_text): + if target_text not in self._targets: + bug = libbe.bug.Bug(summary=target_text) + bug.severity = 'target' + self._targets[target_text] = bug + return self._targets[target_text] + + def _upgrade_bugdir_mapfile(self): + path = self.get_path('settings') + mf = encoding.get_file_contents(path) + if mf == libbe.util.InvalidObject: + return # settings file does not exist + settings = mapfile.parse(mf) + if 'target' in settings: + settings['target'] = self._target_bug(settings['target']).uuid + mf = mapfile.generate(settings) + encoding.set_file_contents(path, mf) + self.vcs._vcs_update(path) + + def _upgrade_bug_mapfile(self, bug_uuid): + import libbe.command.depend as dep + path = self.get_path('bugs', bug_uuid, 'values') + mf = encoding.get_file_contents(path) + if mf == libbe.util.InvalidObject: + return # settings file does not exist + settings = mapfile.parse(mf) + if 'target' in settings: + target_bug = self._target_bug(settings['target']) + + blocked_by_string = '%s%s' % (dep.BLOCKED_BY_TAG, bug_uuid) + dep._add_remove_extra_string(target_bug, blocked_by_string, add=True) + blocks_string = dep._generate_blocks_string(target_bug) + estrs = settings.get('extra_strings', []) + estrs.append(blocks_string) + settings['extra_strings'] = sorted(estrs) + + settings.pop('target') + mf = mapfile.generate(settings) + encoding.set_file_contents(path, mf) + self.vcs._vcs_update(path) + + def _upgrade(self): + """ + Bug value field "target" -> target bugs. + Bugdir value field "target" -> pointer to current target bug. + """ + for bug_uuid in os.listdir(self.get_path('bugs')): + self._upgrade_bug_mapfile(bug_uuid) + self._upgrade_bugdir_mapfile() + for bug in self._targets.values(): + self._save_bug_settings(bug) + +class Upgrade_1_3_to_1_4 (Upgrader): + initial_version = "Bugs Everywhere Directory v1.3" + final_version = "Bugs Everywhere Directory v1.4" + def _get_vcs_name(self): + path = self.get_path('settings') + settings = mapfile.parse(encoding.get_file_contents(path)) + if 'vcs_name' in settings: + return settings['vcs_name'] + return None + + def _upgrade(self): + """ + add new directory "./be/BUGDIR-UUID" + "./be/bugs" -> "./be/BUGDIR-UUID/bugs" + "./be/settings" -> "./be/BUGDIR-UUID/settings" + """ + self.repo = os.path.abspath(self.repo) + basenames = [p for p in os.listdir(self.get_path())] + if not 'bugs' in basenames and not 'settings' in basenames \ + and len([p for p in basenames if len(p)==36]) == 1: + return # the user has upgraded the directory. + basenames = [p for p in basenames if p in ['bugs','settings']] + uuid = libbe.util.id.uuid_gen() + add = [self.get_path(uuid)] + move = [(self.get_path(p), self.get_path(uuid, p)) for p in basenames] + msg = ['Upgrading BE directory version v1.3 to v1.4', + '', + "Because BE's VCS drivers don't support 'move',", + 'please make the following changes with your VCS', + 'and re-run BE. Note that you can choose a different', + 'bugdir UUID to preserve uniformity across branches', + 'of a distributed repository.' + '', + 'add', + ' ' + '\n '.join(add), + 'move', + ' ' + '\n '.join(['%s %s' % (a,b) for a,b in move]), + ] + self.vcs._cached_path_id.destroy() + raise Exception('Need user assistance\n%s' % '\n'.join(msg)) + + +upgraders = [Upgrade_1_0_to_1_1, + Upgrade_1_1_to_1_2, + Upgrade_1_2_to_1_3, + Upgrade_1_3_to_1_4] +upgrade_classes = {} +for upgrader in upgraders: + upgrade_classes[(upgrader.initial_version,upgrader.final_version)]=upgrader + +def upgrade(path, current_version, + target_version=STORAGE_VERSION): + """ + Call the appropriate upgrade function to convert current_version + to target_version. If a direct conversion function does not exist, + use consecutive conversion functions. + """ + if current_version not in STORAGE_VERSIONS: + raise NotImplementedError, \ + "Cannot handle version '%s' yet." % current_version + if target_version not in STORAGE_VERSIONS: + raise NotImplementedError, \ + "Cannot handle version '%s' yet." % current_version + + if (current_version, target_version) in upgrade_classes: + # direct conversion + upgrade_class = upgrade_classes[(current_version, target_version)] + u = upgrade_class(path) + u.upgrade() + else: + # consecutive single-step conversion + i = STORAGE_VERSIONS.index(current_version) + while True: + version_a = STORAGE_VERSIONS[i] + version_b = STORAGE_VERSIONS[i+1] + try: + upgrade_class = upgrade_classes[(version_a, version_b)] + except KeyError: + raise NotImplementedError, \ + "Cannot convert version '%s' to '%s' yet." \ + % (version_a, version_b) + u = upgrade_class(path) + u.upgrade() + if version_b == target_version: + break + i += 1 diff --git a/libbe/storage/vcs/__init__.py b/libbe/storage/vcs/__init__.py new file mode 100644 index 0000000..ddfb00a --- /dev/null +++ b/libbe/storage/vcs/__init__.py @@ -0,0 +1,10 @@ +# Copyright + +import base + +set_preferred_vcs = base.set_preferred_vcs +vcs_by_name = base.vcs_by_name +detect_vcs = base.detect_vcs +installed_vcs = base.installed_vcs + +__all__ = [set_preferred_vcs, vcs_by_name, detect_vcs, installed_vcs] diff --git a/libbe/arch.py b/libbe/storage/vcs/arch.py index 45a3284..f1b5b7b 100644 --- a/libbe/arch.py +++ b/libbe/storage/vcs/arch.py @@ -30,62 +30,80 @@ import sys import time import libbe -from beuuid import uuid_gen -import config -import vcs +import libbe.ui.util.user +import libbe.storage.util.config +from libbe.util.id import uuid_gen +import base + if libbe.TESTING == True: import unittest import doctest -DEFAULT_CLIENT = "tla" +class CantAddFile(Exception): + def __init__(self, file): + self.file = file + Exception.__init__(self, "Can't automatically add file %s" % file) + +DEFAULT_CLIENT = 'tla' -client = config.get_val("arch_client", default=DEFAULT_CLIENT) +client = libbe.storage.util.config.get_val( + 'arch_client', default=DEFAULT_CLIENT) def new(): return Arch() -class Arch(vcs.VCS): - name = "arch" +class Arch(base.VCS): + name = 'arch' client = client - versioned = True _archive_name = None _archive_dir = None _tmp_archive = False _project_name = None _tmp_project = False - _arch_paramdir = os.path.expanduser("~/.arch-params") + _arch_paramdir = os.path.expanduser('~/.arch-params') + + def __init__(self, *args, **kwargs): + base.VCS.__init__(self, *args, **kwargs) + self.versioned = True + self.interspersed_vcs_files = True + self.paranoid = False + def _vcs_version(self): - status,output,error = self._u_invoke_client("--version") + status,output,error = self._u_invoke_client('--version') return output + def _vcs_detect(self, path): """Detect whether a directory is revision-controlled using Arch""" - if self._u_search_parent_directories(path, "{arch}") != None : - config.set_val("arch_client", client) + if self._u_search_parent_directories(path, '{arch}') != None : + libbe.storage.util.config.set_val('arch_client', client) return True return False + def _vcs_init(self, path): self._create_archive(path) self._create_project(path) self._add_project_code(path) + def _create_archive(self, path): """ Create a temporary Arch archive in the directory PATH. This archive will be removed by - cleanup->_vcs_cleanup->_remove_archive + destroy->_vcs_destroy->_remove_archive """ # http://regexps.srparish.net/tutorial-tla/new-archive.html#Creating_a_New_Archive assert self._archive_name == None id = self.get_user_id() - name, email = self._u_parse_id(id) + name, email = libbe.ui.util.user.parse_user_id(id) if email == None: - email = "%s@example.com" % name - trailer = "%s-%s" % ("bugs-everywhere-auto", uuid_gen()[0:8]) - self._archive_name = "%s--%s" % (email, trailer) - self._archive_dir = "/tmp/%s" % trailer + email = '%s@example.com' % name + trailer = '%s-%s' % ('bugs-everywhere-auto', uuid_gen()[0:8]) + self._archive_name = '%s--%s' % (email, trailer) + self._archive_dir = '/tmp/%s' % trailer self._tmp_archive = True - self._u_invoke_client("make-archive", self._archive_name, + self._u_invoke_client('make-archive', self._archive_name, self._archive_dir, cwd=path) + def _invoke_client(self, *args, **kwargs): """ Invoke the client on our archive. @@ -96,35 +114,38 @@ class Arch(vcs.VCS): tailargs = args[1:] else: tailargs = [] - arglist = [command, "-A", self._archive_name] + arglist = [command, '-A', self._archive_name] arglist.extend(tailargs) args = tuple(arglist) return self._u_invoke_client(*args, **kwargs) + def _remove_archive(self): assert self._tmp_archive == True assert self._archive_dir != None assert self._archive_name != None os.remove(os.path.join(self._arch_paramdir, - "=locations", self._archive_name)) + '=locations', self._archive_name)) shutil.rmtree(self._archive_dir) self._tmp_archive = False self._archive_dir = False self._archive_name = False + def _create_project(self, path): """ Create a temporary Arch project in the directory PATH. This project will be removed by - cleanup->_vcs_cleanup->_remove_project + destroy->_vcs_destroy->_remove_project """ # http://mwolson.org/projects/GettingStartedWithArch.html # http://regexps.srparish.net/tutorial-tla/new-project.html#Starting_a_New_Project - category = "bugs-everywhere" - branch = "mainline" - version = "0.1" - self._project_name = "%s--%s--%s" % (category, branch, version) - self._invoke_client("archive-setup", self._project_name, + category = 'bugs-everywhere' + branch = 'mainline' + version = '0.1' + self._project_name = '%s--%s--%s' % (category, branch, version) + self._invoke_client('archive-setup', self._project_name, cwd=path) self._tmp_project = True + def _remove_project(self): assert self._tmp_project == True assert self._project_name != None @@ -132,10 +153,12 @@ class Arch(vcs.VCS): shutil.rmtree(os.path.join(self._archive_dir, self._project_name)) self._tmp_project = False self._project_name = False + def _archive_project_name(self): assert self._archive_name != None assert self._project_name != None - return "%s/%s" % (self._archive_name, self._project_name) + return '%s/%s' % (self._archive_name, self._project_name) + def _adjust_naming_conventions(self, path): """ By default, Arch restricts source code filenames to @@ -144,51 +167,57 @@ class Arch(vcs.VCS): http://regexps.srparish.net/tutorial-tla/naming-conventions.html Since our bug directory '.be' doesn't satisfy these conventions, we need to adjust them. - + The conventions are specified in project-root/{arch}/=tagging-method """ - tagpath = os.path.join(path, "{arch}", "=tagging-method") + tagpath = os.path.join(path, '{arch}', '=tagging-method') lines_out = [] - f = codecs.open(tagpath, "r", self.encoding) + f = codecs.open(tagpath, 'r', self.encoding) for line in f: - if line.startswith("source "): - lines_out.append("source ^[._=a-zA-X0-9].*$\n") + if line.startswith('source '): + lines_out.append('source ^[._=a-zA-X0-9].*$\n') else: lines_out.append(line) f.close() - f = codecs.open(tagpath, "w", self.encoding) - f.write("".join(lines_out)) + f = codecs.open(tagpath, 'w', self.encoding) + f.write(''.join(lines_out)) f.close() def _add_project_code(self, path): # http://mwolson.org/projects/GettingStartedWithArch.html # http://regexps.srparish.net/tutorial-tla/new-source.html # http://regexps.srparish.net/tutorial-tla/importing-first.html - self._invoke_client("init-tree", self._project_name, + self._invoke_client('init-tree', self._project_name, cwd=path) self._adjust_naming_conventions(path) - self._invoke_client("import", "--summary", "Began versioning", + self._invoke_client('import', '--summary', 'Began versioning', cwd=path) - def _vcs_cleanup(self): + + def _vcs_destroy(self): if self._tmp_project == True: self._remove_project() if self._tmp_archive == True: self._remove_archive() + vcs_dir = os.path.join(self.repo, '{arch}') + if os.path.exists(vcs_dir): + shutil.rmtree(vcs_dir) + self._archive_name = None def _vcs_root(self, path): if not os.path.isdir(path): dirname = os.path.dirname(path) else: dirname = path - status,output,error = self._u_invoke_client("tree-root", dirname) + status,output,error = self._u_invoke_client('tree-root', dirname) root = output.rstrip('\n') - + self._get_archive_project_name(root) return root + def _get_archive_name(self, root): - status,output,error = self._u_invoke_client("archives") + status,output,error = self._u_invoke_client('archives') lines = output.split('\n') # e.g. output: # jdoe@example.com--bugs-everywhere-auto-2008.22.24.52 @@ -198,14 +227,16 @@ class Arch(vcs.VCS): 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", cwd=root) + status,output,error = self._u_invoke_client('tree-version', cwd=root) # e.g output # jdoe@example.com--bugs-everywhere-auto-2008.22.24.52/be--mainline--0.1 archive_name,project_name = output.rstrip('\n').split('/') self._archive_name = archive_name self._project_name = project_name + def _vcs_get_user_id(self): try: status,output,error = self._u_invoke_client('my-id') @@ -215,25 +246,26 @@ class Arch(vcs.VCS): return None else: raise - def _vcs_set_user_id(self, value): - self._u_invoke_client('my-id', value) + def _vcs_add(self, path): - self._u_invoke_client("add-id", path) + self._u_invoke_client('add-id', path) realpath = os.path.realpath(self._u_abspath(path)) - pathAdded = realpath in self._list_added(self.rootdir) + pathAdded = realpath in self._list_added(self.repo) if self.paranoid and not pathAdded: self._force_source(path) + def _list_added(self, root): assert os.path.exists(root) assert os.access(root, os.X_OK) root = os.path.realpath(root) - status,output,error = self._u_invoke_client("inventory", "--source", - "--both", "--all", root) + status,output,error = self._u_invoke_client('inventory', '--source', + '--both', '--all', root) inv_str = output.rstrip('\n') return [os.path.join(root, p) for p in inv_str.split('\n')] + def _add_dir_rule(self, rule, dirname, root): inv_path = os.path.join(dirname, '.arch-inventory') - f = codecs.open(inv_path, "a", self.encoding) + f = codecs.open(inv_path, 'a', self.encoding) f.write(rule) f.close() if os.path.realpath(inv_path) not in self._list_added(root): @@ -241,47 +273,50 @@ class Arch(vcs.VCS): self.paranoid = False self.add(inv_path) self.paranoid = paranoid + def _force_source(self, path): - rule = "source %s\n" % self._u_rel_path(path) - self._add_dir_rule(rule, os.path.dirname(path), self.rootdir) - if os.path.realpath(path) not in self._list_added(self.rootdir): + rule = 'source %s\n' % self._u_rel_path(path) + self._add_dir_rule(rule, os.path.dirname(path), self.repo) + if os.path.realpath(path) not in self._list_added(self.repo): raise CantAddFile(path) + def _vcs_remove(self, path): - if not '.arch-ids' in path: - self._u_invoke_client("delete-id", path) + if self._vcs_is_versioned(path): + self._u_invoke_client('delete-id', path) + arch_ids = os.path.join(self.repo, path, '.arch-ids') + if os.path.exists(arch_ids): + shutil.rmtree(arch_ids) + def _vcs_update(self, path): pass - def _vcs_get_file_contents(self, path, revision=None, binary=False): + + def _vcs_is_versioned(self, path): + if '.arch-ids' in path: + return False + return True + + def _vcs_get_file_contents(self, path, revision=None): if revision == None: - return vcs.VCS._vcs_get_file_contents(self, path, revision, binary=binary) + return base.VCS._vcs_get_file_contents(self, path, revision) else: status,output,error = \ - self._invoke_client("file-find", path, revision) + self._invoke_client('file-find', path, revision) relpath = output.rstrip('\n') - abspath = os.path.join(self.rootdir, relpath) - f = codecs.open(abspath, "r", self.encoding) - contents = f.read() - f.close() - return contents - def _vcs_duplicate_repo(self, directory, revision=None): - if revision == None: - vcs.VCS._vcs_duplicate_repo(self, directory, revision) - else: - status,output,error = \ - self._u_invoke_client("get", revision, directory) + return base.VCS._vcs_get_file_contents(self, relpath) + def _vcs_commit(self, commitfile, allow_empty=False): if allow_empty == False: # arch applies empty commits without complaining, so check first - status,output,error = self._u_invoke_client("changes",expect=(0,1)) + status,output,error = self._u_invoke_client('changes',expect=(0,1)) if status == 0: - raise vcs.EmptyCommit() + raise base.EmptyCommit() summary,body = self._u_parse_commitfile(commitfile) - args = ["commit", "--summary", summary] + args = ['commit', '--summary', summary] if body != None: - args.extend(["--log-message",body]) + args.extend(['--log-message',body]) status,output,error = self._u_invoke_client(*args) revision = None - revline = re.compile("[*] committed (.*)") + revline = re.compile('[*] committed (.*)') match = revline.search(output) assert match != None, output+error assert len(match.groups()) == 1 @@ -290,26 +325,26 @@ class Arch(vcs.VCS): assert revpath.startswith(self._archive_project_name()+'--') revision = revpath[len(self._archive_project_name()+'--'):] return revpath + def _vcs_revision_id(self, index): - status,output,error = self._u_invoke_client("logs") + status,output,error = self._u_invoke_client('logs') logs = output.splitlines() first_log = logs.pop(0) - assert first_log == "base-0", first_log + assert first_log == 'base-0', first_log try: - log = logs[index] + if index > 0: + log = logs[index-1] + elif index < 0: + log = logs[index] + else: + return None except IndexError: return None - return "%s--%s" % (self._archive_project_name(), log) - -class CantAddFile(Exception): - def __init__(self, file): - self.file = file - Exception.__init__(self, "Can't automatically add file %s" % file) - + return '%s--%s' % (self._archive_project_name(), log) if libbe.TESTING == True: - vcs.make_vcs_testcase_subclasses(Arch, sys.modules[__name__]) + base.make_vcs_testcase_subclasses(Arch, sys.modules[__name__]) unitsuite =unittest.TestLoader().loadTestsFromModule(sys.modules[__name__]) suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()]) diff --git a/libbe/storage/vcs/base.py b/libbe/storage/vcs/base.py new file mode 100644 index 0000000..99f43f3 --- /dev/null +++ b/libbe/storage/vcs/base.py @@ -0,0 +1,1106 @@ +# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc. +# Alexander Belchenko <bialix@ukr.net> +# Ben Finney <benf@cybersource.com.au> +# Chris Ball <cjb@laptop.org> +# Gianluca Montecchi <gian@grys.it> +# W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +""" +Define the base VCS (Version Control System) class, which should be +subclassed by other Version Control System backends. The base class +implements a "do not version" VCS. +""" + +import codecs +import os +import os.path +import re +import shutil +import sys +import tempfile +import types + +import libbe +import libbe.storage +import libbe.storage.base +import libbe.util.encoding +from libbe.storage.base import EmptyCommit, InvalidRevision, InvalidID +from libbe.util.utility import Dir, search_parent_directories +from libbe.util.subproc import CommandError, invoke +from libbe.util.plugin import import_by_name +import libbe.storage.util.upgrade as upgrade + +if libbe.TESTING == True: + import unittest + import doctest + + import libbe.ui.util.user + +# List VCS modules in order of preference. +# Don't list this module, it is implicitly last. +VCS_ORDER = ['arch', 'bzr', 'darcs', 'git', 'hg'] + +def set_preferred_vcs(name): + global VCS_ORDER + assert name in VCS_ORDER, \ + 'unrecognized VCS %s not in\n %s' % (name, VCS_ORDER) + VCS_ORDER.remove(name) + VCS_ORDER.insert(0, name) + +def _get_matching_vcs(matchfn): + """Return the first module for which matchfn(VCS_instance) is true""" + for submodname in VCS_ORDER: + module = import_by_name('libbe.storage.vcs.%s' % submodname) + vcs = module.new() + if matchfn(vcs) == True: + return vcs + return VCS() + +def vcs_by_name(vcs_name): + """Return the module for the VCS with the given name""" + if vcs_name == VCS.name: + return new() + return _get_matching_vcs(lambda vcs: vcs.name == vcs_name) + +def detect_vcs(dir): + """Return an VCS instance for the vcs being used in this directory""" + return _get_matching_vcs(lambda vcs: vcs._detect(dir)) + +def installed_vcs(): + """Return an instance of an installed VCS""" + return _get_matching_vcs(lambda vcs: vcs.installed()) + + +class VCSNotRooted (libbe.storage.base.ConnectionError): + def __init__(self, vcs): + msg = 'VCS not rooted' + libbe.storage.base.ConnectionError.__init__(self, msg) + self.vcs = vcs + +class VCSUnableToRoot (libbe.storage.base.ConnectionError): + def __init__(self, vcs): + msg = 'VCS unable to root' + libbe.storage.base.ConnectionError.__init__(self, msg) + self.vcs = vcs + +class InvalidPath (InvalidID): + def __init__(self, path, root, msg=None, **kwargs): + if msg == None: + msg = 'Path "%s" not in root "%s"' % (path, root) + InvalidID.__init__(self, msg=msg, **kwargs) + self.path = path + self.root = root + +class SpacerCollision (InvalidPath): + def __init__(self, path, spacer): + msg = 'Path "%s" collides with spacer directory "%s"' % (path, spacer) + InvalidPath.__init__(self, path, root=None, msg=msg) + self.spacer = spacer + +class NoSuchFile (InvalidID): + def __init__(self, pathname, root='.'): + path = os.path.abspath(os.path.join(root, pathname)) + InvalidID.__init__(self, 'No such file: %s' % path) + + +class CachedPathID (object): + """ + Storage ID <-> path policy. + .../.be/BUGDIR/bugs/BUG/comments/COMMENT + ^-- root path + + >>> dir = Dir() + >>> os.mkdir(os.path.join(dir.path, '.be')) + >>> os.mkdir(os.path.join(dir.path, '.be', 'abc')) + >>> os.mkdir(os.path.join(dir.path, '.be', 'abc', 'bugs')) + >>> os.mkdir(os.path.join(dir.path, '.be', 'abc', 'bugs', '123')) + >>> os.mkdir(os.path.join(dir.path, '.be', 'abc', 'bugs', '123', 'comments')) + >>> os.mkdir(os.path.join(dir.path, '.be', 'abc', 'bugs', '123', 'comments', 'def')) + >>> os.mkdir(os.path.join(dir.path, '.be', 'abc', 'bugs', '456')) + >>> file(os.path.join(dir.path, '.be', 'abc', 'values'), + ... 'w').close() + >>> file(os.path.join(dir.path, '.be', 'abc', 'bugs', '123', 'values'), + ... 'w').close() + >>> file(os.path.join(dir.path, '.be', 'abc', 'bugs', '123', 'comments', 'def', 'values'), + ... 'w').close() + >>> c = CachedPathID() + >>> c.root(dir.path) + >>> c.id(os.path.join(dir.path, '.be', 'abc', 'bugs', '123', 'comments', 'def', 'values')) + 'def/values' + >>> c.init() + >>> sorted(os.listdir(os.path.join(c._root, '.be'))) + ['abc', 'id-cache'] + >>> c.connect() + >>> c.path('123/values') # doctest: +ELLIPSIS + u'.../.be/abc/bugs/123/values' + >>> c.disconnect() + >>> c.destroy() + >>> sorted(os.listdir(os.path.join(c._root, '.be'))) + ['abc'] + >>> c.connect() # demonstrate auto init + >>> sorted(os.listdir(os.path.join(c._root, '.be'))) + ['abc', 'id-cache'] + >>> c.add_id(u'xyz', parent=None) # doctest: +ELLIPSIS + u'.../.be/xyz' + >>> c.add_id('xyz/def', parent='xyz') # doctest: +ELLIPSIS + u'.../.be/xyz/def' + >>> c.add_id('qrs', parent='123') # doctest: +ELLIPSIS + u'.../.be/abc/bugs/123/comments/qrs' + >>> c.disconnect() + >>> c.connect() + >>> c.path('qrs') # doctest: +ELLIPSIS + u'.../.be/abc/bugs/123/comments/qrs' + >>> c.remove_id('qrs') + >>> c.path('qrs') + Traceback (most recent call last): + ... + InvalidID: 'qrs' + >>> c.disconnect() + >>> c.destroy() + >>> dir.cleanup() + """ + def __init__(self, encoding=None): + self.encoding = libbe.util.encoding.get_filesystem_encoding() + self._spacer_dirs = ['.be', 'bugs', 'comments'] + + def root(self, path): + self._root = os.path.abspath(path).rstrip(os.path.sep) + self._cache_path = os.path.join( + self._root, self._spacer_dirs[0], 'id-cache') + + def init(self): + """ + Create cache file for an existing .be directory. + File if multiple lines of the form: + UUID\tPATH + """ + self._cache = {} + spaced_root = os.path.join(self._root, self._spacer_dirs[0]) + for dirpath, dirnames, filenames in os.walk(spaced_root): + if dirpath == spaced_root: + continue + try: + id = self.id(dirpath) + relpath = dirpath[len(self._root)+1:] + if id.count('/') == 0: + if id in self._cache: + print >> sys.stderr, 'Multiple paths for %s: \n %s\n %s' % (id, self._cache[id], relpath) + self._cache[id] = relpath + except InvalidPath: + pass + self._changed = True + self.disconnect() + + def destroy(self): + if os.path.exists(self._cache_path): + os.remove(self._cache_path) + + def connect(self): + if not os.path.exists(self._cache_path): + try: + self.init() + except IOError: + raise libbe.storage.base.ConnectionError + self._cache = {} # key: uuid, value: path + self._changed = False + f = codecs.open(self._cache_path, 'r', self.encoding) + for line in f: + fields = line.rstrip('\n').split('\t') + self._cache[fields[0]] = fields[1] + f.close() + + def disconnect(self): + if self._changed == True: + f = codecs.open(self._cache_path, 'w', self.encoding) + for uuid,path in self._cache.items(): + f.write('%s\t%s\n' % (uuid, path)) + f.close() + self._cache = {} + + def path(self, id, relpath=False): + fields = id.split('/', 1) + uuid = fields[0] + if len(fields) == 1: + extra = [] + else: + extra = fields[1:] + if uuid not in self._cache: + raise InvalidID(uuid) + if relpath == True: + return os.path.join(self._cache[uuid], *extra) + return os.path.join(self._root, self._cache[uuid], *extra) + + def add_id(self, id, parent=None): + if id.count('/') > 0: + # not a UUID-level path + assert id.startswith(parent), \ + 'Strange ID: "%s" should start with "%s"' % (id, parent) + path = self.path(id) + elif id in self._cache: + # already added + path = self.path(id) + else: + if parent == None: + parent_path = '' + spacer = self._spacer_dirs[0] + else: + assert parent.count('/') == 0, \ + 'Strange parent ID: "%s" should be UUID' % parent + parent_path = self.path(parent, relpath=True) + parent_spacer = parent_path.split(os.path.sep)[-2] + i = self._spacer_dirs.index(parent_spacer) + spacer = self._spacer_dirs[i+1] + path = os.path.join(parent_path, spacer, id) + self._cache[id] = path + self._changed = True + path = os.path.join(self._root, path) + return path + + def remove_id(self, id): + if id.count('/') > 0: + return # not a UUID-level path + self._cache.pop(id) + self._changed = True + + def id(self, path): + path = os.path.join(self._root, path) + if not path.startswith(self._root + os.path.sep): + raise InvalidPath(path, self._root) + path = path[len(self._root)+1:] + orig_path = path + if not path.startswith(self._spacer_dirs[0] + os.path.sep): + raise InvalidPath(path, self._spacer_dirs[0]) + for spacer in self._spacer_dirs: + if not path.startswith(spacer + os.path.sep): + break + id = path[len(spacer)+1:] + fields = path[len(spacer)+1:].split(os.path.sep,1) + if len(fields) == 1: + break + path = fields[1] + for spacer in self._spacer_dirs: + if id.endswith(os.path.sep + spacer): + raise SpacerCollision(orig_path, spacer) + if os.path.sep != '/': + id = id.replace(os.path.sep, '/') + return id + + +def new(): + return VCS() + +class VCS (libbe.storage.base.VersionedStorage): + """ + This class implements a 'no-vcs' interface. + + Support for other VCSs can be added by subclassing this class, and + overriding methods _vcs_*() with code appropriate for your VCS. + + The methods _u_*() are utility methods available to the _vcs_*() + methods. + + Sink to existing root + ====================== + + Consider the following usage case: + You have a bug directory rooted in + /path/to/source + by which I mean the '.be' directory is at + /path/to/source/.be + However, you're of in some subdirectory like + /path/to/source/GUI/testing + and you want to comment on a bug. Setting sink_to_root=True when + you initialize your BugDir will cause it to search for the '.be' + file in the ancestors of the path you passed in as 'root'. + /path/to/source/GUI/testing/.be miss + /path/to/source/GUI/.be miss + /path/to/source/.be hit! + So it still roots itself appropriately without much work for you. + + File-system access + ================== + + BugDirs live completely in memory when .sync_with_disk is False. + This is the default configuration setup by BugDir(from_disk=False). + If .sync_with_disk == True (e.g. BugDir(from_disk=True)), then + any changes to the BugDir will be immediately written to disk. + + If you want to change .sync_with_disk, we suggest you use + .set_sync_with_disk(), which propogates the new setting through to + all bugs/comments/etc. that have been loaded into memory. If + you've been living in memory and want to move to + .sync_with_disk==True, but you're not sure if anything has been + changed in memory, a call to .save() immediately before the + .set_sync_with_disk(True) call is a safe move. + + Regardless of .sync_with_disk, a call to .save() will write out + all the contents that the BugDir instance has loaded into memory. + If sync_with_disk has been True over the course of all interesting + changes, this .save() call will be a waste of time. + + The BugDir will only load information from the file system when it + loads new settings/bugs/comments that it doesn't already have in + memory and .sync_with_disk == True. + + Allow storage initialization + ======================== + + This one is for testing purposes. Setting it to True allows the + BugDir to search for an installed Storage backend and initialize + it in the root directory. This is a convenience option for + supporting tests of versioning functionality + (e.g. .duplicate_bugdir). + + Disable encoding manipulation + ============================= + + This one is for testing purposed. You might have non-ASCII + Unicode in your bugs, comments, files, etc. BugDir instances try + and support your preferred encoding scheme (e.g. "utf-8") when + dealing with stream and file input/output. For stream output, + this involves replacing sys.stdout and sys.stderr + (libbe.encode.set_IO_stream_encodings). However this messes up + doctest's output catching. In order to support doctest tests + using BugDirs, set manipulate_encodings=False, and stick to ASCII + in your tests. + + if root == None: + root = os.getcwd() + if sink_to_existing_root == True: + self.root = self._find_root(root) + else: + if not os.path.exists(root): + self.root = None + raise NoRootEntry(root) + self.root = root + # get a temporary storage until we've loaded settings + self.sync_with_disk = False + self.storage = self._guess_storage() + + if assert_new_BugDir == True: + if os.path.exists(self.get_path()): + raise AlreadyInitialized, self.get_path() + if storage == None: + storage = self._guess_storage(allow_storage_init) + self.storage = storage + self._setup_user_id(self.user_id) + + + # methods for getting the BugDir situated in the filesystem + + def _find_root(self, path): + ''' + Search for an existing bug database dir and it's ancestors and + return a BugDir rooted there. Only called by __init__, and + then only if sink_to_existing_root == True. + ''' + if not os.path.exists(path): + self.root = None + raise NoRootEntry(path) + versionfile=utility.search_parent_directories(path, + os.path.join(".be", "version")) + if versionfile != None: + beroot = os.path.dirname(versionfile) + root = os.path.dirname(beroot) + return root + else: + beroot = utility.search_parent_directories(path, ".be") + if beroot == None: + self.root = None + raise NoBugDir(path) + return beroot + + def _guess_storage(self, allow_storage_init=False): + ''' + Only called by __init__. + ''' + deepdir = self.get_path() + if not os.path.exists(deepdir): + deepdir = os.path.dirname(deepdir) + new_storage = storage.detect_storage(deepdir) + install = False + if new_storage.name == "None": + if allow_storage_init == True: + new_storage = storage.installed_storage() + new_storage.init(self.root) + return new_storage + +os.listdir(self.get_path("bugs")): + """ + name = 'None' + client = 'false' # command-line tool for _u_invoke_client + + def __init__(self, *args, **kwargs): + if 'encoding' not in kwargs: + kwargs['encoding'] = libbe.util.encoding.get_filesystem_encoding() + libbe.storage.base.VersionedStorage.__init__(self, *args, **kwargs) + self.versioned = False + self.interspersed_vcs_files = False + self.verbose_invoke = False + self._cached_path_id = CachedPathID() + self._rooted = False + + def _vcs_version(self): + """ + Return the VCS version string. + """ + return '0' + + def _vcs_get_user_id(self): + """ + Get the VCS's suggested user id (e.g. "John Doe <jdoe@example.com>"). + If the VCS has not been configured with a username, return None. + """ + return None + + def _vcs_detect(self, path=None): + """ + Detect whether a directory is revision controlled with this VCS. + """ + return True + + def _vcs_root(self, path): + """ + Get the VCS root. This is the default working directory for + future invocations. You would normally set this to the root + directory for your VCS. + """ + if os.path.isdir(path) == False: + path = os.path.dirname(path) + if path == '': + path = os.path.abspath('.') + return path + + def _vcs_init(self, path): + """ + Begin versioning the tree based at path. + """ + pass + + def _vcs_destroy(self): + """ + Remove any files used in versioning (e.g. whatever _vcs_init() + created). + """ + pass + + def _vcs_add(self, path): + """ + Add the already created file at path to version control. + """ + pass + + def _vcs_remove(self, path): + """ + Remove the file at path from version control. Optionally + remove the file from the filesystem as well. + """ + pass + + def _vcs_update(self, path): + """ + Notify the versioning system of changes to the versioned file + at path. + """ + pass + + def _vcs_is_versioned(self, path): + """ + Return true if a path is under version control, False + otherwise. You only need to set this if the VCS goes about + dumping VCS-specific files into the .be directory. + + If you do need to implement this method (e.g. Arch), set + self.interspersed_vcs_files = True + """ + assert self.interspersed_vcs_files == False + raise NotImplementedError + + def _vcs_get_file_contents(self, path, revision=None): + """ + Get the file contents as they were in a given revision. + Revision==None specifies the current revision. + """ + if revision != None: + raise libbe.storage.base.InvalidRevision( + 'The %s VCS does not support revision specifiers' % self.name) + path = os.path.join(self.repo, path) + if not os.path.exists(path): + return libbe.util.InvalidObject + if os.path.isdir(path): + return libbe.storage.base.InvalidDirectory + f = open(path, 'rb') + contents = f.read() + f.close() + return contents + + def _vcs_path(self, id, revision): + """ + Return the path to object id as of revision. + + Revision will not be None. + """ + raise NotImplementedError + + def _vcs_isdir(self, path, revision): + """ + Return True if path (as returned by _vcs_path) was a directory + as of revision, False otherwise. + + Revision will not be None. + """ + raise NotImplementedError + + def _vcs_listdir(self, path, revision): + """ + Return a list of the contents of the directory path (as + returned by _vcs_path) as of revision. + + Revision will not be None, and ._vcs_isdir(path, revision) + will be True. + """ + raise NotImplementedError + + def _vcs_commit(self, commitfile, allow_empty=False): + """ + Commit the current working directory, using the contents of + commitfile as the comment. Return the name of the old + revision (or None if commits are not supported). + + If allow_empty == False, raise EmptyCommit if there are no + changes to commit. + """ + return None + + def _vcs_revision_id(self, index): + """ + Return the name of the <index>th revision. Index will be an + integer (possibly <= 0). The choice of which branch to follow + when crossing branches/merges is not defined. + + Return None if revision IDs are not supported, or if the + specified revision does not exist. + """ + return None + + def version(self): + # Cache version string for efficiency. + if not hasattr(self, '_version'): + self._version = self._get_version() + return self._version + + def _get_version(self): + try: + ret = self._vcs_version() + return ret + except OSError, e: + if e.errno == errno.ENOENT: + return None + else: + raise OSError, e + except CommandError: + return None + + def installed(self): + if self.version() != None: + return True + return False + + def get_user_id(self): + """ + Get the VCS's suggested user id (e.g. "John Doe <jdoe@example.com>"). + If the VCS has not been configured with a username, return None. + You can override the automatic lookup procedure by setting the + VCS.user_id attribute to a string of your choice. + """ + if not hasattr(self, 'user_id'): + self.user_id = self._vcs_get_user_id() + return self.user_id + + def _detect(self, path='.'): + """ + Detect whether a directory is revision controlled with this VCS. + """ + return self._vcs_detect(path) + + def root(self): + """ + Set the root directory to the path's VCS root. This is the + default working directory for future invocations. + """ + if self._detect(self.repo) == False: + raise VCSUnableToRoot(self) + root = self._vcs_root(self.repo) + self.repo = os.path.abspath(root) + if os.path.isdir(self.repo) == False: + self.repo = os.path.dirname(self.repo) + self.be_dir = os.path.join( + self.repo, self._cached_path_id._spacer_dirs[0]) + self._cached_path_id.root(self.repo) + self._rooted = True + + def _init(self): + """ + Begin versioning the tree based at self.repo. + Also roots the vcs at path. + """ + if not os.path.exists(self.repo) or not os.path.isdir(self.repo): + raise VCSUnableToRoot(self) + if self._vcs_detect(self.repo) == False: + self._vcs_init(self.repo) + if self._rooted == False: + self.root() + os.mkdir(self.be_dir) + self._vcs_add(self._u_rel_path(self.be_dir)) + self._setup_storage_version() + self._cached_path_id.init() + + def _destroy(self): + self._vcs_destroy() + self._cached_path_id.destroy() + if os.path.exists(self.be_dir): + shutil.rmtree(self.be_dir) + + def _connect(self): + if self._rooted == False: + self.root() + if not os.path.isdir(self.be_dir): + raise libbe.storage.base.ConnectionError(self) + self._cached_path_id.connect() + self.check_storage_version() + + def _disconnect(self): + self._cached_path_id.disconnect() + + def _add_path(self, path, directory=False): + relpath = self._u_rel_path(path) + reldirs = relpath.split(os.path.sep) + if directory == False: + reldirs = reldirs[:-1] + dir = self.repo + for reldir in reldirs: + dir = os.path.join(dir, reldir) + if not os.path.exists(dir): + os.mkdir(dir) + self._vcs_add(self._u_rel_path(dir)) + elif not os.path.isdir(dir): + raise libbe.storage.base.InvalidDirectory + if directory == False: + if not os.path.exists(path): + open(path, 'w').close() + self._vcs_add(self._u_rel_path(path)) + + def _add(self, id, parent=None, **kwargs): + path = self._cached_path_id.add_id(id, parent) + self._add_path(path, **kwargs) + + def _remove(self, id): + path = self._cached_path_id.path(id) + if os.path.exists(path): + if os.path.isdir(path) and len(self.children(id)) > 0: + raise libbe.storage.base.DirectoryNotEmpty(id) + self._vcs_remove(self._u_rel_path(path)) + if os.path.exists(path): + if os.path.isdir(path): + os.rmdir(path) + else: + os.remove(path) + self._cached_path_id.remove_id(id) + + def _recursive_remove(self, id): + path = self._cached_path_id.path(id) + for dirpath,dirnames,filenames in os.walk(path, topdown=False): + filenames.extend(dirnames) + for f in filenames: + fullpath = os.path.join(dirpath, f) + if os.path.exists(fullpath) == False: + continue + self._vcs_remove(self._u_rel_path(fullpath)) + if os.path.exists(path): + shutil.rmtree(path) + path = self._cached_path_id.path(id, relpath=True) + for id,p in self._cached_path_id._cache.items(): + if p.startswith(path): + self._cached_path_id.remove_id(id) + + def _children(self, id=None, revision=None): + if revision == None: + id_to_path = self._cached_path_id.path + isdir = os.path.isdir + listdir = os.listdir + else: + id_to_path = lambda id : self._vcs_path(id, revision) + isdir = lambda path : self._vcs_isdir(path, revision) + listdir = lambda path : self._vcs_listdir(path, revision) + if id==None: + path = self.be_dir + else: + path = id_to_path(id) + if isdir(path) == False: + return [] + children = listdir(path) + for i,c in enumerate(children): + if c in self._cached_path_id._spacer_dirs: + children[i] = None + children.extend([os.path.join(c, c2) for c2 in + listdir(os.path.join(path, c))]) + elif c in ['id-cache', 'version']: + children[i] = None + elif self.interspersed_vcs_files \ + and self._vcs_is_versioned(c) == False: + children[i] = None + for i,c in enumerate(children): + if c == None: continue + cpath = os.path.join(path, c) + if self.interspersed_vcs_files == True \ + and revision != None \ + and self._vcs_is_versioned(cpath) == False: + children[i] = None + else: + children[i] = self._u_path_to_id(cpath) + children[i] + return [c for c in children if c != None] + + def _get(self, id, default=libbe.util.InvalidObject, revision=None): + try: + path = self._cached_path_id.path(id) + except InvalidID, e: + if default == libbe.util.InvalidObject: + raise e + return default + relpath = self._u_rel_path(path) + try: + contents = self._vcs_get_file_contents(relpath, revision) + except InvalidID, e: + if InvalidID == None: + e.id = InvalidID + raise + if contents in [libbe.storage.base.InvalidDirectory, + libbe.util.InvalidObject]: + raise InvalidID(id) + elif len(contents) == 0: + return None + return contents + + def _set(self, id, value): + try: + path = self._cached_path_id.path(id) + except InvalidID, e: + raise e + if not os.path.exists(path): + raise InvalidID(id) + if os.path.isdir(path): + raise libbe.storage.base.InvalidDirectory(id) + f = open(path, "wb") + f.write(value) + f.close() + self._vcs_update(self._u_rel_path(path)) + + def _commit(self, summary, body=None, allow_empty=False): + summary = summary.strip()+'\n' + if body is not None: + summary += '\n' + body.strip() + '\n' + descriptor, filename = tempfile.mkstemp() + revision = None + try: + temp_file = os.fdopen(descriptor, 'wb') + temp_file.write(summary) + temp_file.flush() + revision = self._vcs_commit(filename, allow_empty=allow_empty) + temp_file.close() + finally: + os.remove(filename) + return revision + + def revision_id(self, index=None): + if index == None: + return None + try: + if int(index) != index: + raise InvalidRevision(index) + except ValueError: + raise InvalidRevision(index) + revid = self._vcs_revision_id(index) + if revid == None: + raise libbe.storage.base.InvalidRevision(index) + return revid + + def _u_any_in_string(self, list, string): + """ + Return True if any of the strings in list are in string. + Otherwise return False. + """ + for list_string in list: + if list_string in string: + return True + return False + + def _u_invoke(self, *args, **kwargs): + if 'cwd' not in kwargs: + kwargs['cwd'] = self.repo + if 'verbose' not in kwargs: + kwargs['verbose'] = self.verbose_invoke + if 'encoding' not in kwargs: + kwargs['encoding'] = self.encoding + return invoke(*args, **kwargs) + + def _u_invoke_client(self, *args, **kwargs): + cl_args = [self.client] + cl_args.extend(args) + return self._u_invoke(cl_args, **kwargs) + + def _u_search_parent_directories(self, path, filename): + """ + Find the file (or directory) named filename in path or in any + of path's parents. + + e.g. + search_parent_directories("/a/b/c", ".be") + will return the path to the first existing file from + /a/b/c/.be + /a/b/.be + /a/.be + /.be + or None if none of those files exist. + """ + return search_parent_directories(path, filename) + + def _u_find_id(self, id, revision): + """ + Search for the relative path to id as of revision. + Returns None if the id is not found. + """ + assert self._rooted == True + be_dir = self._cached_path_id._spacer_dirs[0] + stack = [(be_dir, be_dir)] + while len(stack) > 0: + path,long_id = stack.pop() + if long_id.endswith('/'+id): + return path + if self._vcs_isdir(path, revision) == False: + continue + for child in self._vcs_listdir(path, revision): + stack.append((os.path.join(path, child), + '/'.join([long_id, child]))) + raise InvalidID(id, revision=revision) + + def _u_path_to_id(self, path): + return self._cached_path_id.id(path) + + def _u_rel_path(self, path, root=None): + """ + Return the relative path to path from root. + >>> vcs = new() + >>> vcs._u_rel_path("/a.b/c/.be", "/a.b/c") + '.be' + >>> vcs._u_rel_path("/a.b/c/", "/a.b/c") + '.' + >>> vcs._u_rel_path("/a.b/c/", "/a.b/c/") + '.' + >>> vcs._u_rel_path("./a", ".") + 'a' + """ + if root == None: + if self.repo == None: + raise VCSNotRooted(self) + root = self.repo + path = os.path.abspath(path) + absRoot = os.path.abspath(root) + absRootSlashedDir = os.path.join(absRoot,"") + if path in [absRoot, absRootSlashedDir]: + return '.' + if not path.startswith(absRootSlashedDir): + raise InvalidPath(path, absRootSlashedDir) + relpath = path[len(absRootSlashedDir):] + return relpath + + def _u_abspath(self, path, root=None): + """ + Return the absolute path from a path realtive to root. + >>> vcs = new() + >>> vcs._u_abspath(".be", "/a.b/c") + '/a.b/c/.be' + """ + if root == None: + assert self.repo != None, "VCS not rooted" + root = self.repo + return os.path.abspath(os.path.join(root, path)) + + def _u_parse_commitfile(self, commitfile): + """ + Split the commitfile created in self.commit() back into + summary and header lines. + """ + f = codecs.open(commitfile, 'r', self.encoding) + summary = f.readline() + body = f.read() + body.lstrip('\n') + if len(body) == 0: + body = None + f.close() + return (summary, body) + + def check_storage_version(self): + version = self.storage_version() + if version != libbe.storage.STORAGE_VERSION: + upgrade.upgrade(self.repo, version) + + def storage_version(self, revision=None, path=None): + """ + Requires disk access. + """ + if path == None: + path = os.path.join(self.repo, '.be', 'version') + if not os.path.exists(path): + raise libbe.storage.InvalidStorageVersion(None) + if revision == None: # don't require connection + return libbe.util.encoding.get_file_contents( + path, decode=True).rstrip('\n') + contents = self._vcs_get_file_contents(path, revision=revision) + if type(contents) != types.UnicodeType: + contents = unicode(contents, self.encoding) + return contents.strip() + + def _setup_storage_version(self): + """ + Requires disk access. + """ + assert self._rooted == True + path = os.path.join(self.be_dir, 'version') + if not os.path.exists(path): + libbe.util.encoding.set_file_contents(path, + libbe.storage.STORAGE_VERSION+'\n') + self._vcs_add(self._u_rel_path(path)) + + +if libbe.TESTING == True: + class VCSTestCase (unittest.TestCase): + """ + Test cases for base VCS class (in addition to the Storage test + cases). + """ + + Class = VCS + + def __init__(self, *args, **kwargs): + super(VCSTestCase, self).__init__(*args, **kwargs) + self.dirname = None + + def setUp(self): + """Set up test fixtures for Storage test case.""" + super(VCSTestCase, self).setUp() + self.dir = Dir() + self.dirname = self.dir.path + self.s = self.Class(repo=self.dirname) + if self.s.installed() == True: + self.s.init() + self.s.connect() + + def tearDown(self): + super(VCSTestCase, self).tearDown() + if self.s.installed() == True: + self.s.disconnect() + self.s.destroy() + self.dir.cleanup() + + class VCS_installed_TestCase (VCSTestCase): + def test_installed(self): + """ + See if the VCS is installed. + """ + self.failUnless(self.s.installed() == True, + '%(name)s VCS not found' % vars(self.Class)) + + + class VCS_detection_TestCase (VCSTestCase): + def test_detection(self): + """ + See if the VCS detects its installed repository + """ + if self.s.installed(): + self.s.disconnect() + self.failUnless(self.s._detect(self.dirname) == True, + 'Did not detected %(name)s VCS after initialising' + % vars(self.Class)) + self.s.connect() + + def test_no_detection(self): + """ + See if the VCS detects its installed repository + """ + if self.s.installed() and self.Class.name != 'None': + self.s.disconnect() + self.s.destroy() + self.failUnless(self.s._detect(self.dirname) == False, + 'Detected %(name)s VCS before initialising' + % vars(self.Class)) + self.s.init() + self.s.connect() + + def test_vcs_repo_in_specified_root_path(self): + """VCS root directory should be in specified root path.""" + rp = os.path.realpath(self.s.repo) + dp = os.path.realpath(self.dirname) + vcs_name = self.Class.name + self.failUnless( + dp == rp or rp == None, + "%(vcs_name)s VCS root in wrong dir (%(dp)s %(rp)s)" % vars()) + + class VCS_get_user_id_TestCase(VCSTestCase): + """Test cases for VCS.get_user_id method.""" + + def test_gets_existing_user_id(self): + """Should get the existing user ID.""" + if self.s.installed(): + user_id = self.s.get_user_id() + if user_id == None: + return + name,email = libbe.ui.util.user.parse_user_id(user_id) + if email != None: + self.failUnless('@' in email, email) + + def make_vcs_testcase_subclasses(vcs_class, namespace): + c = vcs_class() + if c.installed(): + if c.versioned == True: + libbe.storage.base.make_versioned_storage_testcase_subclasses( + vcs_class, namespace) + else: + libbe.storage.base.make_storage_testcase_subclasses( + vcs_class, namespace) + + if namespace != sys.modules[__name__]: + # Make VCSTestCase subclasses for vcs_class in the namespace. + vcs_testcase_classes = [ + c for c in ( + ob for ob in globals().values() if isinstance(ob, type)) + if issubclass(c, VCSTestCase) \ + and c.Class == VCS] + + for base_class in vcs_testcase_classes: + testcase_class_name = vcs_class.__name__ + base_class.__name__ + testcase_class_bases = (base_class,) + testcase_class_dict = dict(base_class.__dict__) + testcase_class_dict['Class'] = vcs_class + testcase_class = type( + testcase_class_name, testcase_class_bases, testcase_class_dict) + setattr(namespace, testcase_class_name, testcase_class) + + make_vcs_testcase_subclasses(VCS, sys.modules[__name__]) + + unitsuite =unittest.TestLoader().loadTestsFromModule(sys.modules[__name__]) + suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()]) diff --git a/libbe/storage/vcs/bzr.py b/libbe/storage/vcs/bzr.py new file mode 100644 index 0000000..7335861 --- /dev/null +++ b/libbe/storage/vcs/bzr.py @@ -0,0 +1,196 @@ +# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc. +# Ben Finney <benf@cybersource.com.au> +# Gianluca Montecchi <gian@grys.it> +# Marien Zwart <marienz@gentoo.org> +# W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +""" +Bazaar (bzr) backend. +""" + +try: + import bzrlib + import bzrlib.branch + import bzrlib.builtins + import bzrlib.config + import bzrlib.errors + import bzrlib.option +except ImportError: + bzrlib = None +import os +import os.path +import re +import shutil +import StringIO + +import libbe +import base + +if libbe.TESTING == True: + import doctest + import sys + import unittest + + +def new(): + return Bzr() + +class Bzr(base.VCS): + name = 'bzr' + client = None # bzrlib module + + def __init__(self, *args, **kwargs): + base.VCS.__init__(self, *args, **kwargs) + self.versioned = True + + def _vcs_version(self): + if bzrlib == None: + return None + return bzrlib.__version__ + + def _vcs_get_user_id(self): + # excerpted from bzrlib.builtins.cmd_whoami.run() + try: + c = bzrlib.branch.Branch.open_containing(self.repo)[0].get_config() + except errors.NotBranchError: + c = bzrlib.config.GlobalConfig() + return c.username() + + def _vcs_detect(self, path): + if self._u_search_parent_directories(path, '.bzr') != None : + return True + return False + + def _vcs_root(self, path): + """Find the root of the deepest repository containing path.""" + cmd = bzrlib.builtins.cmd_root() + cmd.outf = StringIO.StringIO() + cmd.run(filename=path) + return cmd.outf.getvalue().rstrip('\n') + + def _vcs_init(self, path): + cmd = bzrlib.builtins.cmd_init() + cmd.outf = StringIO.StringIO() + cmd.run(location=path) + + def _vcs_destroy(self): + vcs_dir = os.path.join(self.repo, '.bzr') + if os.path.exists(vcs_dir): + shutil.rmtree(vcs_dir) + + def _vcs_add(self, path): + path = os.path.join(self.repo, path) + cmd = bzrlib.builtins.cmd_add() + cmd.outf = StringIO.StringIO() + cmd.run(file_list=[path], file_ids_from=self.repo) + + def _vcs_remove(self, path): + # --force to also remove unversioned files. + path = os.path.join(self.repo, path) + cmd = bzrlib.builtins.cmd_remove() + cmd.outf = StringIO.StringIO() + cmd.run(file_list=[path], file_deletion_strategy='force') + + def _vcs_update(self, path): + pass + + def _parse_revision_string(self, revision=None): + if revision == None: + return revision + rev_opt = bzrlib.option.Option.OPTIONS['revision'] + try: + rev_spec = rev_opt.type(revision) + except bzrlib.errors.NoSuchRevisionSpec: + raise base.InvalidRevision(revision) + return rev_spec + + def _vcs_get_file_contents(self, path, revision=None): + if revision == None: + return base.VCS._vcs_get_file_contents(self, path, revision) + path = os.path.join(self.repo, path) + revision = self._parse_revision_string(revision) + cmd = bzrlib.builtins.cmd_cat() + cmd.outf = StringIO.StringIO() + try: + cmd.run(filename=path, revision=revision) + except bzrlib.errors.BzrCommandError, e: + if 'not present in revision' in str(e): + raise base.InvalidPath(path, root=self.repo, revision=revision) + raise + return cmd.outf.getvalue() + + def _vcs_path(self, id, revision): + return self._u_find_id(id, revision) + + def _vcs_isdir(self, path, revision): + try: + self._vcs_listdir(path, revision) + except AttributeError, e: + if 'children' in str(e): + return False + raise + return True + + def _vcs_listdir(self, path, revision): + path = os.path.join(self.repo, path) + revision = self._parse_revision_string(revision) + cmd = bzrlib.builtins.cmd_ls() + cmd.outf = StringIO.StringIO() + try: + cmd.run(revision=revision, path=path) + except bzrlib.errors.BzrCommandError, e: + if 'not present in revision' in str(e): + raise base.InvalidPath(path, root=self.repo, revision=revision) + raise + children = cmd.outf.getvalue().rstrip('\n').splitlines() + children = [self._u_rel_path(c, path) for c in children] + return children + + def _vcs_commit(self, commitfile, allow_empty=False): + cmd = bzrlib.builtins.cmd_commit() + cmd.outf = StringIO.StringIO() + cwd = os.getcwd() + os.chdir(self.repo) + try: + cmd.run(file=commitfile, unchanged=allow_empty) + except bzrlib.errors.BzrCommandError, e: + strings = ['no changes to commit.', # bzr 1.3.1 + 'No changes to commit.'] # bzr 1.15.1 + if self._u_any_in_string(strings, str(e)) == True: + raise base.EmptyCommit() + raise + finally: + os.chdir(cwd) + return self._vcs_revision_id(-1) + + def _vcs_revision_id(self, index): + cmd = bzrlib.builtins.cmd_revno() + cmd.outf = StringIO.StringIO() + cmd.run(location=self.repo) + current_revision = int(cmd.outf.getvalue()) + if index > current_revision or index < -current_revision: + return None + if index >= 0: + return str(index) # bzr commit 0 is the empty tree. + return str(current_revision+index+1) + + +if libbe.TESTING == True: + base.make_vcs_testcase_subclasses(Bzr, sys.modules[__name__]) + + unitsuite =unittest.TestLoader().loadTestsFromModule(sys.modules[__name__]) + suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()]) diff --git a/libbe/storage/vcs/darcs.py b/libbe/storage/vcs/darcs.py new file mode 100644 index 0000000..6d47ea5 --- /dev/null +++ b/libbe/storage/vcs/darcs.py @@ -0,0 +1,288 @@ +# Copyright (C) 2009 Gianluca Montecchi <gian@grys.it> +# W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +""" +Darcs backend. +""" + +import codecs +import os +import re +import shutil +import sys +import time # work around http://mercurial.selenic.com/bts/issue618 +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 libbe +import base + +if libbe.TESTING == True: + import doctest + import unittest + + +def new(): + return Darcs() + +class Darcs(base.VCS): + name='darcs' + client='darcs' + + def __init__(self, *args, **kwargs): + base.VCS.__init__(self, *args, **kwargs) + self.versioned = True + self.__updated = [] # work around http://mercurial.selenic.com/bts/issue618 + + def _vcs_version(self): + status,output,error = self._u_invoke_client('--version') + return output.rstrip('\n') + + def version_cmp(self, *args): + """ + Compare the installed darcs version V_i with another version + V_o (given in *args). Returns + 1 if V_i > V_o, + 0 if V_i == V_o, and + -1 if V_i < V_o + >>> d = Darcs(repo='.') + >>> d._vcs_version = lambda : "2.3.1 (release)" + >>> d.version_cmp(2,3,1) + 0 + >>> d.version_cmp(2,3,2) + -1 + >>> d.version_cmp(2,3,0) + 1 + >>> d.version_cmp(3) + -1 + >>> d._vcs_version = lambda : "2.0.0pre2" + >>> d._parsed_version = None + >>> d.version_cmp(3) + Traceback (most recent call last): + ... + NotImplementedError: Cannot parse "2.0.0pre2" portion of Darcs version "2.0.0pre2" + invalid literal for int() with base 10: '0pre2' + """ + if not hasattr(self, '_parsed_version') \ + or self._parsed_version == None: + num_part = self._vcs_version().split(' ')[0] + try: + self._parsed_version = [int(i) for i in num_part.split('.')] + except ValueError, e: + raise NotImplementedError( + 'Cannot parse "%s" portion of Darcs version "%s"\n %s' + % (num_part, self._vcs_version(), str(e))) + cmps = [cmp(a,b) for a,b in zip(self._parsed_version, args)] + for c in cmps: + if c != 0: + return c + return 0 + + def _vcs_get_user_id(self): + # following http://darcs.net/manual/node4.html#SECTION00410030000000000000 + # as of June 29th, 2009 + if self.repo == None: + return None + darcs_dir = os.path.join(self.repo, '_darcs') + if darcs_dir != None: + for pref_file in ['author', 'email']: + pref_path = os.path.join(darcs_dir, 'prefs', pref_file) + if os.path.exists(pref_path): + return self.get_file_contents(pref_path) + for env_variable in ['DARCS_EMAIL', 'EMAIL']: + if env_variable in os.environ: + return os.environ[env_variable] + return None + + def _vcs_detect(self, path): + if self._u_search_parent_directories(path, "_darcs") != None : + return True + return False + + def _vcs_root(self, path): + """Find the root of the deepest repository containing path.""" + # Assume that nothing funny is going on; in particular, that we aren't + # dealing with a bare repo. + if os.path.isdir(path) != True: + path = os.path.dirname(path) + darcs_dir = self._u_search_parent_directories(path, '_darcs') + if darcs_dir == None: + return None + return os.path.dirname(darcs_dir) + + def _vcs_init(self, path): + self._u_invoke_client('init', cwd=path) + + def _vcs_destroy(self): + vcs_dir = os.path.join(self.repo, '_darcs') + if os.path.exists(vcs_dir): + shutil.rmtree(vcs_dir) + + def _vcs_add(self, path): + if os.path.isdir(path): + return + self._u_invoke_client('add', path) + + def _vcs_remove(self, path): + if not os.path.isdir(self._u_abspath(path)): + os.remove(os.path.join(self.repo, path)) # darcs notices removal + + def _vcs_update(self, path): + self.__updated.append(path) # work around http://mercurial.selenic.com/bts/issue618 + pass # darcs notices changes + + def _vcs_get_file_contents(self, path, revision=None): + if revision == None: + return base.VCS._vcs_get_file_contents(self, path, revision) + if self.version_cmp(2, 0, 0) == 1: + status,output,error = self._u_invoke_client( \ + 'show', 'contents', '--patch', revision, path) + return output + # Darcs versions < 2.0.0pre2 lack the 'show contents' command + + status,output,error = self._u_invoke_client( \ + 'diff', '--unified', '--from-patch', revision, path, + unicode_output=False) + major_patch = output + status,output,error = self._u_invoke_client( \ + 'diff', '--unified', '--patch', revision, path, + unicode_output=False) + target_patch = output + + # '--output -' to be supported in GNU patch > 2.5.9 + # but that hasn't been released as of June 30th, 2009. + + # Rewrite path to status before the patch we want + args=['patch', '--reverse', path] + status,output,error = self._u_invoke(args, stdin=major_patch) + # Now apply the patch we want + args=['patch', path] + status,output,error = self._u_invoke(args, stdin=target_patch) + + if os.path.exists(os.path.join(self.repo, path)) == True: + contents = base.VCS._vcs_get_file_contents(self, path) + else: + contents = '' + + # Now restore path to it's current incarnation + args=['patch', '--reverse', path] + status,output,error = self._u_invoke(args, stdin=target_patch) + args=['patch', path] + status,output,error = self._u_invoke(args, stdin=major_patch) + current_contents = base.VCS._vcs_get_file_contents(self, path) + return contents + + def _vcs_path(self, id, revision): + return self._u_find_id(id, revision) + + def _vcs_isdir(self, path, revision): + if self.version_cmp(2, 3, 1) == 1: + # Sun Nov 15 20:32:06 EST 2009 thomashartman1@gmail.com + # * add versioned show files functionality (darcs show files -p 'some patch') + status,output,error = self._u_invoke_client( \ + 'show', 'files', '--no-files', '--patch', revision) + children = output.rstrip('\n').splitlines() + rpath = '.' + children = [self._u_rel_path(c, rpath) for c in children] + if path in children: + return True + return False + # Darcs versions <= 2.3.1 lack the --patch option for 'show files' + raise NotImplementedError + + def _vcs_listdir(self, path, revision): + if self.version_cmp(2, 3, 1) == 1: + # Sun Nov 15 20:32:06 EST 2009 thomashartman1@gmail.com + # * add versioned show files functionality (darcs show files -p 'some patch') + # Wed Dec 9 05:42:21 EST 2009 Luca Molteni <volothamp@gmail.com> + # * resolve issue835 show file with file directory arguments + path = path.rstrip(os.path.sep) + status,output,error = self._u_invoke_client( \ + 'show', 'files', '--patch', revision, path) + files = output.rstrip('\n').splitlines() + if path == '.': + descendents = [self._u_rel_path(f, path) for f in files + if f != '.'] + else: + descendents = [self._u_rel_path(f, path) for f in files + if f.startswith(path)] + return [f for f in descendents if f.count(os.path.sep) == 0] + # Darcs versions <= 2.3.1 lack the --patch option for 'show files' + raise NotImplementedError + + def _vcs_commit(self, commitfile, allow_empty=False): + id = self.get_user_id() + if id == None or '@' not in id: + id = '%s <%s@invalid.com>' % (id, id) + args = ['record', '--all', '--author', id, '--logfile', commitfile] + status,output,error = self._u_invoke_client(*args) + empty_strings = ['No changes!'] + # work around http://mercurial.selenic.com/bts/issue618 + if self._u_any_in_string(empty_strings, output) == True \ + and len(self.__updated) > 0: + time.sleep(1) + for path in self.__updated: + os.utime(os.path.join(self.repo, path), None) + status,output,error = self._u_invoke_client(*args) + self.__updated = [] + # end work around + if self._u_any_in_string(empty_strings, output) == True: + if allow_empty == False: + raise base.EmptyCommit() + # note that darcs does _not_ make an empty revision. + # this returns the last non-empty revision id... + revision = self._vcs_revision_id(-1) + else: + revline = re.compile("Finished recording patch '(.*)'") + match = revline.search(output) + assert match != None, output+error + assert len(match.groups()) == 1 + revision = match.groups()[0] + return revision + + def _vcs_revision_id(self, index): + status,output,error = self._u_invoke_client('changes', '--xml') + revisions = [] + xml_str = output.encode('unicode_escape').replace(r'\n', '\n') + element = ElementTree.XML(xml_str) + assert element.tag == 'changelog', element.tag + for patch in element.getchildren(): + assert patch.tag == 'patch', patch.tag + for child in patch.getchildren(): + if child.tag == 'name': + text = unescape(unicode(child.text).decode('unicode_escape').strip()) + revisions.append(text) + revisions.reverse() + try: + if index > 0: + return revisions[index-1] + elif index < 0: + return revisions[index] + else: + return None + except IndexError: + return None + + +if libbe.TESTING == True: + base.make_vcs_testcase_subclasses(Darcs, sys.modules[__name__]) + + unitsuite =unittest.TestLoader().loadTestsFromModule(sys.modules[__name__]) + suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()]) diff --git a/libbe/git.py b/libbe/storage/vcs/git.py index 7f6e53a..2280665 100644 --- a/libbe/git.py +++ b/libbe/storage/vcs/git.py @@ -22,130 +22,162 @@ Git backend. """ import os +import os.path import re -import sys +import shutil import unittest import libbe -import vcs +import libbe.ui.util.user +import base + if libbe.TESTING == True: import doctest + import sys def new(): return Git() -class Git(vcs.VCS): - name="git" - client="git" - versioned=True +class Git(base.VCS): + name='git' + client='git' + + def __init__(self, *args, **kwargs): + base.VCS.__init__(self, *args, **kwargs) + self.versioned = True + def _vcs_version(self): - status,output,error = self._u_invoke_client("--version") + status,output,error = self._u_invoke_client('--version') return output + + def _vcs_get_user_id(self): + status,output,error = \ + self._u_invoke_client('config', 'user.name', expect=(0,1)) + if status == 0: + name = output.rstrip('\n') + else: + name = '' + status,output,error = \ + self._u_invoke_client('config', 'user.email', expect=(0,1)) + if status == 0: + email = output.rstrip('\n') + else: + email = '' + if name != '' or email != '': # got something! + # guess missing info, if necessary + if name == '': + name = libbe.ui.util.user.get_fallback_username() + if email == '': + email = libe.ui.util.user.get_fallback_email() + return libbe.ui.util.user.create_user_id(name, email) + return None # Git has no infomation + def _vcs_detect(self, path): - if self._u_search_parent_directories(path, ".git") != None : + if self._u_search_parent_directories(path, '.git') != None : return True - return False + return False + def _vcs_root(self, path): """Find the root of the deepest repository containing path.""" # Assume that nothing funny is going on; in particular, that we aren't # dealing with a bare repo. if os.path.isdir(path) != True: path = os.path.dirname(path) - status,output,error = self._u_invoke_client("rev-parse", "--git-dir", + status,output,error = self._u_invoke_client('rev-parse', '--git-dir', cwd=path) gitdir = os.path.join(path, output.rstrip('\n')) dirname = os.path.abspath(os.path.dirname(gitdir)) return dirname + def _vcs_init(self, path): - self._u_invoke_client("init", cwd=path) - def _vcs_get_user_id(self): - status,output,error = \ - self._u_invoke_client("config", "user.name", expect=(0,1)) - if status == 0: - name = output.rstrip('\n') - else: - name = "" - status,output,error = \ - self._u_invoke_client("config", "user.email", expect=(0,1)) - if status == 0: - email = output.rstrip('\n') - else: - email = "" - if name != "" or email != "": # got something! - # guess missing info, if necessary - if name == "": - name = self._u_get_fallback_username() - if email == "": - email = self._u_get_fallback_email() - return self._u_create_id(name, email) - return None # Git has no infomation - def _vcs_set_user_id(self, value): - name,email = self._u_parse_id(value) - if email != None: - self._u_invoke_client("config", "user.email", email) - self._u_invoke_client("config", "user.name", name) + self._u_invoke_client('init', cwd=path) + + def _vcs_destroy(self): + vcs_dir = os.path.join(self.repo, '.git') + if os.path.exists(vcs_dir): + shutil.rmtree(vcs_dir) + def _vcs_add(self, path): if os.path.isdir(path): return - self._u_invoke_client("add", path) + self._u_invoke_client('add', path) + def _vcs_remove(self, path): if not os.path.isdir(self._u_abspath(path)): - self._u_invoke_client("rm", "-f", path) + self._u_invoke_client('rm', '-f', path) + def _vcs_update(self, path): self._vcs_add(path) - def _vcs_get_file_contents(self, path, revision=None, binary=False): + + def _vcs_get_file_contents(self, path, revision=None): if revision == None: - return vcs.VCS._vcs_get_file_contents(self, path, revision, binary=binary) + return base.VCS._vcs_get_file_contents(self, path, revision) else: - arg = "%s:%s" % (revision,path) - status,output,error = self._u_invoke_client("show", arg) + arg = '%s:%s' % (revision,path) + status,output,error = self._u_invoke_client('show', arg) return output - def _vcs_duplicate_repo(self, directory, revision=None): - if revision==None: - vcs.VCS._vcs_duplicate_repo(self, directory, revision) - else: - self._u_invoke_client("clone", "--no-checkout", ".", directory) - self._u_invoke_client("checkout", revision, cwd=directory) + + + def _vcs_path(self, id, revision): + return self._u_find_id(id, revision) + + def _vcs_isdir(self, path, revision): + arg = '%s:%s' % (revision,path) + args = ['ls-tree', arg] + kwargs = {'expect':(0,128)} + status,output,error = self._u_invoke_client(*args, **kwargs) + if status != 0: + if 'not a tree object' in error: + return False + raise base.CommandError(args, status, stderr=error) + return True + + def _vcs_listdir(self, path, revision): + arg = '%s:%s' % (revision,path) + status,output,error = self._u_invoke_client( + 'ls-tree', '--name-only', arg) + return output.rstrip('\n').splitlines() + def _vcs_commit(self, commitfile, allow_empty=False): args = ['commit', '--all', '--file', commitfile] if allow_empty == True: - args.append("--allow-empty") + args.append('--allow-empty') status,output,error = self._u_invoke_client(*args) else: - kwargs = {"expect":(0,1)} + kwargs = {'expect':(0,1)} status,output,error = self._u_invoke_client(*args, **kwargs) - strings = ["nothing to commit", - "nothing added to commit"] + strings = ['nothing to commit', + 'nothing added to commit'] if self._u_any_in_string(strings, output) == True: - raise vcs.EmptyCommit() - revision = None - revline = re.compile("(.*) (.*)[:\]] (.*)") - match = revline.search(output) - assert match != None, output+error - assert len(match.groups()) == 3 - revision = match.groups()[1] + raise base.EmptyCommit() full_revision = self._vcs_revision_id(-1) - assert full_revision.startswith(revision), \ - "Mismatched revisions:\n%s\n%s" % (revision, full_revision) + assert full_revision[:7] in output, \ + 'Mismatched revisions:\n%s\n%s' % (full_revision, output) return full_revision + def _vcs_revision_id(self, index): - args = ["rev-list", "--first-parent", "--reverse", "HEAD"] - kwargs = {"expect":(0,128)} + args = ['rev-list', '--first-parent', '--reverse', 'HEAD'] + kwargs = {'expect':(0,128)} status,output,error = self._u_invoke_client(*args, **kwargs) if status == 128: if error.startswith("fatal: ambiguous argument 'HEAD': unknown "): return None - raise vcs.CommandError(args, status, stderr=error) - commits = output.splitlines() + raise base.CommandError(args, status, stderr=error) + revisions = output.splitlines() try: - return commits[index] + if index > 0: + return revisions[index-1] + elif index < 0: + return revisions[index] + else: + return None except IndexError: return None - + if libbe.TESTING == True: - vcs.make_vcs_testcase_subclasses(Git, sys.modules[__name__]) + base.make_vcs_testcase_subclasses(Git, sys.modules[__name__]) unitsuite =unittest.TestLoader().loadTestsFromModule(sys.modules[__name__]) suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()]) diff --git a/libbe/storage/vcs/hg.py b/libbe/storage/vcs/hg.py new file mode 100644 index 0000000..11494a9 --- /dev/null +++ b/libbe/storage/vcs/hg.py @@ -0,0 +1,180 @@ +# Copyright (C) 2007-2009 Aaron Bentley and Panometrics, Inc. +# Ben Finney <benf@cybersource.com.au> +# Gianluca Montecchi <gian@grys.it> +# W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +""" +Mercurial (hg) backend. +""" + +try: + import mercurial + import mercurial.version + import mercurial.dispatch + import mercurial.ui +except ImportError: + mercurial = None +import os +import os.path +import re +import shutil +import StringIO +import sys +import time # work around http://mercurial.selenic.com/bts/issue618 + +import libbe +import base + +if libbe.TESTING == True: + import doctest + import unittest + + +def new(): + return Hg() + +class Hg(base.VCS): + name='hg' + client=None # mercurial module + + def __init__(self, *args, **kwargs): + base.VCS.__init__(self, *args, **kwargs) + self.versioned = True + self.__updated = [] # work around http://mercurial.selenic.com/bts/issue618 + + def _vcs_version(self): + if mercurial == None: + return None + return mercurial.version.get_version() + + def _u_invoke_client(self, *args, **kwargs): + if 'cwd' not in kwargs: + kwargs['cwd'] = self.repo + assert len(kwargs) == 1, kwargs + fullargs = ['--cwd', kwargs['cwd']] + fullargs.extend(args) + stdout = sys.stdout + tmp_stdout = StringIO.StringIO() + sys.stdout = tmp_stdout + cwd = os.getcwd() + mercurial.dispatch.dispatch(fullargs) + os.chdir(cwd) + sys.stdout = stdout + return tmp_stdout.getvalue().rstrip('\n') + + def _vcs_get_user_id(self): + return self._u_invoke_client('showconfig', 'ui.username') + + def _vcs_detect(self, path): + """Detect whether a directory is revision-controlled using Mercurial""" + if self._u_search_parent_directories(path, '.hg') != None: + return True + return False + + def _vcs_root(self, path): + return self._u_invoke_client('root', cwd=path) + + def _vcs_init(self, path): + self._u_invoke_client('init', cwd=path) + + def _vcs_destroy(self): + vcs_dir = os.path.join(self.repo, '.hg') + if os.path.exists(vcs_dir): + shutil.rmtree(vcs_dir) + + def _vcs_add(self, path): + self._u_invoke_client('add', path) + + def _vcs_remove(self, path): + self._u_invoke_client('rm', '--force', path) + + def _vcs_update(self, path): + self.__updated.append(path) # work around http://mercurial.selenic.com/bts/issue618 + + def _vcs_get_file_contents(self, path, revision=None): + if revision == None: + return base.VCS._vcs_get_file_contents(self, path, revision) + else: + return self._u_invoke_client('cat', '-r', revision, path) + + def _vcs_path(self, id, revision): + output = self._u_invoke_client('manifest', '--rev', revision) + be_dir = self._cached_path_id._spacer_dirs[0] + be_dir_sep = self._cached_path_id._spacer_dirs[0] + os.path.sep + files = [f for f in output.splitlines() if f.startswith(be_dir_sep)] + for file in files: + if not file.startswith(be_dir+os.path.sep): + continue + parts = file.split(os.path.sep) + dir = parts.pop(0) # don't add the first spacer dir + for part in parts[:-1]: + dir = os.path.join(dir, part) + if not dir in files: + files.append(dir) + for file in files: + if self._u_path_to_id(file) == id: + return file + raise base.InvalidId(id, revision=revision) + + def _vcs_isdir(self, path, revision): + output = self._u_invoke_client('manifest', '--rev', revision) + files = output.splitlines() + if path in files: + return False + return True + + def _vcs_listdir(self, path, revision): + output = self._u_invoke_client('manifest', '--rev', revision) + files = output.splitlines() + path = path.rstrip(os.path.sep) + os.path.sep + return [self._u_rel_path(f, path) for f in files if f.startswith(path)] + + def _vcs_commit(self, commitfile, allow_empty=False): + args = ['commit', '--logfile', commitfile] + output = self._u_invoke_client(*args) + # work around http://mercurial.selenic.com/bts/issue618 + strings = ['nothing changed'] + if self._u_any_in_string(strings, output) == True \ + and len(self.__updated) > 0: + time.sleep(1) + for path in self.__updated: + os.utime(os.path.join(self.repo, path), None) + output = self._u_invoke_client(*args) + self.__updated = [] + # end work around + if allow_empty == False: + strings = ['nothing changed'] + if self._u_any_in_string(strings, output) == True: + raise base.EmptyCommit() + return self._vcs_revision_id(-1) + + def _vcs_revision_id(self, index, style='id'): + if index > 0: + index -= 1 + args = ['identify', '--rev', str(int(index)), '--%s' % style] + output = self._u_invoke_client(*args) + id = output.strip() + if id == '000000000000': + return None # before initial commit. + return id + + +if libbe.TESTING == True: + base.make_vcs_testcase_subclasses(Hg, sys.modules[__name__]) + + unitsuite =unittest.TestLoader().loadTestsFromModule(sys.modules[__name__]) + suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()]) diff --git a/libbe/ui/__init__.py b/libbe/ui/__init__.py new file mode 100644 index 0000000..b98f164 --- /dev/null +++ b/libbe/ui/__init__.py @@ -0,0 +1 @@ +# Copyright diff --git a/libbe/ui/command_line.py b/libbe/ui/command_line.py new file mode 100644 index 0000000..7f74782 --- /dev/null +++ b/libbe/ui/command_line.py @@ -0,0 +1,317 @@ +# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc. +# Chris Ball <cjb@laptop.org> +# Gianluca Montecchi <gian@grys.it> +# Oleg Romanyshyn <oromanyshyn@panoramicfeedback.com> +# W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +""" +A command line interface to Bugs Everywhere. +""" + +import optparse +import os +import sys + +import libbe +import libbe.bugdir +import libbe.command +import libbe.command.util +import libbe.version +import libbe.ui.util.pager + +if libbe.TESTING == True: + import doctest + +class CallbackExit (Exception): + pass + +class CmdOptionParser(optparse.OptionParser): + def __init__(self, command): + self.command = command + optparse.OptionParser.__init__(self) + self.remove_option('-h') + self.disable_interspersed_args() + self._option_by_name = {} + for option in self.command.options: + self._add_option(option) + self.set_usage(command.usage()) + + + def _add_option(self, option): + option.validate() + self._option_by_name[option.name] = option + long_opt = '--%s' % option.name + if option.short_name != None: + short_opt = '-%s' % option.short_name + assert '_' not in option.name, \ + 'Non-reconstructable option name %s' % option.name + kwargs = {'dest':option.name.replace('-', '_'), + 'help':option.help} + if option.arg == None: # a callback option + kwargs['action'] = 'callback' + kwargs['callback'] = self.callback + elif option.arg.type == 'bool': + kwargs['action'] = 'store_true' + kwargs['metavar'] = None + kwargs['default'] = False + else: + kwargs['type'] = option.arg.type + kwargs['action'] = 'store' + kwargs['metavar'] = option.arg.metavar + kwargs['default'] = option.arg.default + if option.short_name != None: + opt = optparse.Option(short_opt, long_opt, **kwargs) + else: + opt = optparse.Option(long_opt, **kwargs) + opt._option = option + self.add_option(opt) + + def parse_args(self, args=None, values=None): + args = self._get_args(args) + options,parsed_args = optparse.OptionParser.parse_args( + self, args=args, values=values) + options = options.__dict__ + for name,value in options.items(): + if '_' in name: # reconstruct original option name + options[name.replace('_', '-')] = options.pop(name) + for name,value in options.items(): + if value == '--complete': + argument = None + option = self._option_by_name[name] + if option.arg != None: + argument = option.arg + fragment = None + indices = [i for i,arg in enumerate(args) + if arg == '--complete'] + for i in indices: + assert i > 0 # this --complete is an option value + if args[i-1] in ['--%s' % o.name + for o in self.command.options]: + name = args[i-1][2:] + if name == option.name: + break + elif option.short_name != None \ + and args[i-1].startswith('-') \ + and args[i-1].endswith(option.short_name): + break + if i+1 < len(args): + fragment = args[i+1] + self.complete(argument, fragment) + for i,arg in enumerate(parsed_args): + if arg == '--complete': + if i > 0 and self.command.name == 'be': + break # let this pass through for the command parser to handle + elif i < len(self.command.args): + argument = self.command.args[i] + elif len(self.command.args) == 0: + break # command doesn't take arguments + else: + argument = self.command.args[-1] + if argument.repeatable == False: + raise libbe.command.UserError('Too many arguments') + fragment = None + if i < len(parsed_args) - 1: + fragment = parsed_args[i+1] + self.complete(argument, fragment) + if len(parsed_args) > len(self.command.args) \ + and self.command.args[-1].repeatable == False: + raise libbe.command.UserError('Too many arguments') + for arg in self.command.args[len(parsed_args):]: + if arg.optional == False: + raise libbe.command.UserError( + 'Missing required argument %s' % arg.metavar) + return (options, parsed_args) + + def callback(self, option, opt, value, parser): + command_option = option._option + if command_option.name == 'complete': + argument = None + fragment = None + if len(parser.rargs) > 0: + fragment = parser.rargs[0] + self.complete(argument, fragment) + else: + print >> self.command.stdout, command_option.callback( + self.command, command_option, value) + raise CallbackExit + + def complete(self, argument=None, fragment=None): + comps = self.command.complete(argument, fragment) + if fragment != None: + comps = [c for c in comps if c.startswith(fragment)] + if len(comps) > 0: + print >> self.command.stdout, '\n'.join(comps) + raise CallbackExit + +class BE (libbe.command.Command): + """Class for parsing the command line arguments for `be`. + This class does not contain a useful _run() method. Call this + module's main() function instead. + + >>> ui = libbe.command.UserInterface() + >>> ui.io.stdout = sys.stdout + >>> be = BE(ui=ui) + >>> ui.io.setup_command(be) + >>> p = CmdOptionParser(be) + >>> p.exit_after_callback = False + >>> try: + ... options,args = p.parse_args(['--help']) # doctest: +ELLIPSIS +NORMALIZE_WHITESPACE + ... except CallbackExit: + ... pass + usage: be [options] [COMMAND [command-options] [COMMAND-ARGS ...]] + <BLANKLINE> + Options: + -h, --help Print a help message. + <BLANKLINE> + --complete Print a list of possible completions. + <BLANKLINE> + --version Print version string. + ... + >>> try: + ... options,args = p.parse_args(['--complete']) # doctest: +ELLIPSIS + ... except CallbackExit: + ... print ' got callback' + --help + --complete + --version + ... + subscribe + tag + target + got callback + """ + name = 'be' + + def __init__(self, *args, **kwargs): + libbe.command.Command.__init__(self, *args, **kwargs) + self.options.extend([ + libbe.command.Option(name='version', + help='Print version string.', + callback=self.version), + libbe.command.Option(name='full-version', + help='Print full version information.', + callback=self.full_version), + libbe.command.Option(name='repo', short_name='r', + help='Select BE repository (see `be help repo`) rather ' + 'than the current directory.', + arg=libbe.command.Argument( + name='repo', metavar='REPO', default='.', + completion_callback=libbe.command.util.complete_path)), + libbe.command.Option(name='paginate', + help='Pipe all output into less (or if set, $PAGER).'), + libbe.command.Option(name='no-pager', + help='Do not pipe git output into a pager.'), + ]) + self.args.extend([ + libbe.command.Argument( + name='command', optional=False, + completion_callback=libbe.command.util.complete_command), + libbe.command.Argument( + name='args', optional=True, repeatable=True) + ]) + + def usage(self): + return 'usage: be [options] [COMMAND [command-options] [COMMAND-ARGS ...]]' + + def _long_help(self): + cmdlist = [] + for name in libbe.command.commands(): + Class = libbe.command.get_command_class(command_name=name) + assert hasattr(Class, '__doc__') and Class.__doc__ != None, \ + 'Command class %s missing docstring' % Class + cmdlist.append((name, Class.__doc__.splitlines()[0])) + cmdlist.sort() + longest_cmd_len = max([len(name) for name,desc in cmdlist]) + ret = ['Bugs Everywhere - Distributed bug tracking', + '', 'Supported commands'] + for name, desc in cmdlist: + numExtraSpaces = longest_cmd_len-len(name) + ret.append('be %s%*s %s' % (name, numExtraSpaces, '', desc)) + ret.extend(['', 'Run', ' be help [command]', 'for more information.']) + return '\n'.join(ret) + + def version(self, *args): + return libbe.version.version(verbose=False) + + def full_version(self, *args): + return libbe.version.version(verbose=True) + +def dispatch(ui, command, args): + parser = CmdOptionParser(command) + try: + options,args = parser.parse_args(args) + ret = ui.run(command, options, args) + except CallbackExit: + return 0 + except libbe.command.UserError, e: + print >> ui.io.stdout, 'ERROR:\n', e + return 1 + except libbe.storage.ConnectionError, e: + print >> ui.io.stdout, 'Connection Error:\n', e + return 1 + except (libbe.util.id.MultipleIDMatches, libbe.util.id.NoIDMatches, + libbe.util.id.InvalidIDStructure), e: + print >> ui.io.stdout, 'Invalid id:\n', e + return 1 + finally: + command.cleanup() + return ret + +def main(): + io = libbe.command.StdInputOutput() + ui = libbe.command.UserInterface(io) + ui.restrict_file_access = False + ui.storage_callbacks = None + be = BE(ui=ui) + ui.setup_command(be) + + parser = CmdOptionParser(be) + try: + options,args = parser.parse_args() + except CallbackExit: + return 0 + except libbe.command.UserError, e: + print >> ui.io.stdout, 'ERROR:\n', e + return 1 + + command_name = args.pop(0) + try: + Class = libbe.command.get_command_class(command_name=command_name) + except libbe.command.UnknownCommand, e: + print >> ui.io.stdout, e + return 1 + + ui.storage_callbacks = libbe.command.StorageCallbacks(options['repo']) + command = Class(ui=ui) + ui.setup_command(command) + + if command.name in ['comment', 'commit']: + paginate = 'never' + else: + paginate = 'auto' + if options['paginate'] == True: + paginate = 'always' + if options['no-pager'] == True: + paginate = 'never' + libbe.ui.util.pager.run_pager(paginate) + + ret = dispatch(ui, command, args) + ui.cleanup() + return ret + +if __name__ == '__main__': + sys.exit(main()) diff --git a/libbe/ui/util/__init__.py b/libbe/ui/util/__init__.py new file mode 100644 index 0000000..b98f164 --- /dev/null +++ b/libbe/ui/util/__init__.py @@ -0,0 +1 @@ +# Copyright diff --git a/libbe/editor.py b/libbe/ui/util/editor.py index 859cedc..1a10fa4 100644 --- a/libbe/editor.py +++ b/libbe/ui/util/editor.py @@ -28,12 +28,12 @@ import sys import tempfile import libbe +import libbe.util.encoding + if libbe.TESTING == True: import doctest -default_encoding = sys.getfilesystemencoding() or locale.getpreferredencoding() - comment_marker = u"== Anything below this line will be ignored\n" class CantFindEditor(Exception): @@ -56,11 +56,14 @@ def editor_string(comment=None, encoding=None): >>> os.environ["VISUAL"] = "echo baz > " >>> editor_string() u'baz\\n' + >>> os.environ["VISUAL"] = "echo 'baz\\n== Anything below this line will be ignored\\nHi' > " + >>> editor_string() + u'baz\\n' >>> del os.environ["EDITOR"] >>> del os.environ["VISUAL"] """ if encoding == None: - encoding = default_encoding + encoding = libbe.util.encoding.get_filesystem_encoding() for name in ('VISUAL', 'EDITOR'): try: editor = os.environ[name] @@ -77,9 +80,9 @@ def editor_string(comment=None, encoding=None): os.close(fhandle) oldmtime = os.path.getmtime(fname) os.system("%s %s" % (editor, fname)) - f = codecs.open(fname, "r", encoding) - output = trimmed_string(f.read()) - f.close() + output = libbe.util.encoding.get_file_contents( + fname, encoding=encoding, decode=True) + output = trimmed_string(output) if output.rstrip('\n') == "": output = None finally: diff --git a/libbe/pager.py b/libbe/ui/util/pager.py index 1ddc3fa..1ddc3fa 100644 --- a/libbe/pager.py +++ b/libbe/ui/util/pager.py diff --git a/libbe/ui/util/user.py b/libbe/ui/util/user.py new file mode 100644 index 0000000..d6af89b --- /dev/null +++ b/libbe/ui/util/user.py @@ -0,0 +1,89 @@ +# Copyright + +""" +Tools for getting, setting, creating, and parsing the user's id. For +example, + 'John Doe <jdoe@example.com>' +Note that the Arch VCS backend *enforces* ids with this format. +""" + +import os +import re +from socket import gethostname + +import libbe +import libbe.storage.util.config + +def get_fallback_username(): + name = None + for env in ["LOGNAME", "USERNAME"]: + if os.environ.has_key(env): + name = os.environ[env] + break + assert name != None + return name + +def get_fallback_email(): + hostname = gethostname() + name = get_fallback_username() + return "%s@%s" % (name, hostname) + +def create_user_id(name, email=None): + """ + >>> create_user_id("John Doe", "jdoe@example.com") + 'John Doe <jdoe@example.com>' + >>> create_user_id("John Doe") + 'John Doe' + """ + assert len(name) > 0 + if email == None or len(email) == 0: + return name + else: + return "%s <%s>" % (name, email) + +def parse_user_id(value): + """ + >>> parse_user_id("John Doe <jdoe@example.com>") + ('John Doe', 'jdoe@example.com') + >>> parse_user_id("John Doe") + ('John Doe', None) + >>> try: + ... parse_user_id("John Doe <jdoe@example.com><what?>") + ... except AssertionError: + ... print "Invalid match" + Invalid match + """ + emailexp = re.compile("(.*) <([^>]*)>(.*)") + match = emailexp.search(value) + if match == None: + email = None + name = value + else: + assert len(match.groups()) == 3 + assert match.groups()[2] == "", match.groups() + email = match.groups()[1] + name = match.groups()[0] + assert name != None + assert len(name) > 0 + return (name, email) + +def get_user_id(storage=None): + """ + Sometimes the storage will also keep track of the user id (e.g. most VCSs). + """ + user = libbe.storage.util.config.get_val('user') + if user != None: + return user + if storage != None and hasattr(storage, 'get_user_id'): + user = storage.get_user_id() + if user != None: + return user + name = get_fallback_username() + email = get_fallback_email() + user = create_user_id(name, email) + return user + +def set_user_id(user_id): + """ + """ + user = libbe.storage.util.config.set_val('user', user_id) diff --git a/libbe/upgrade.py b/libbe/upgrade.py deleted file mode 100644 index dc9d54f..0000000 --- a/libbe/upgrade.py +++ /dev/null @@ -1,246 +0,0 @@ -# Copyright (C) 2009 W. Trevor King <wking@drexel.edu> -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, write to the Free Software Foundation, Inc., -# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - -""" -Handle conversion between the various on-disk images. -""" - -import os, os.path -import sys - -import libbe -import bug -import encoding -import mapfile -import vcs -if libbe.TESTING == True: - import doctest - -# a list of all past versions -BUGDIR_DISK_VERSIONS = ["Bugs Everywhere Tree 1 0", - "Bugs Everywhere Directory v1.1", - "Bugs Everywhere Directory v1.2", - "Bugs Everywhere Directory v1.3"] - -# the current version -BUGDIR_DISK_VERSION = BUGDIR_DISK_VERSIONS[-1] - -class Upgrader (object): - "Class for converting " - initial_version = None - final_version = None - def __init__(self, root): - self.root = root - # use the "None" VCS to ensure proper encoding/decoding and - # simplify path construction. - self.vcs = vcs.vcs_by_name("None") - self.vcs.root(self.root) - self.vcs.encoding = encoding.get_encoding() - - def get_path(self, *args): - """ - Return a path relative to .root. - """ - dir = os.path.join(self.root, ".be") - if len(args) == 0: - return dir - assert args[0] in ["version", "settings", "bugs"], str(args) - return os.path.join(dir, *args) - - def check_initial_version(self): - path = self.get_path("version") - version = self.vcs.get_file_contents(path).rstrip("\n") - assert version == self.initial_version, version - - def set_version(self): - path = self.get_path("version") - self.vcs.set_file_contents(path, self.final_version+"\n") - - def upgrade(self): - print >> sys.stderr, "upgrading bugdir from '%s' to '%s'" \ - % (self.initial_version, self.final_version) - self.check_initial_version() - self.set_version() - self._upgrade() - - def _upgrade(self): - raise NotImplementedError - - -class Upgrade_1_0_to_1_1 (Upgrader): - initial_version = "Bugs Everywhere Tree 1 0" - final_version = "Bugs Everywhere Directory v1.1" - def _upgrade_mapfile(self, path): - contents = self.vcs.get_file_contents(path) - old_format = False - for line in contents.splitlines(): - if len(line.split("=")) == 2: - old_format = True - break - if old_format == True: - # translate to YAML. - newlines = [] - for line in contents.splitlines(): - line = line.rstrip('\n') - if len(line) == 0: - continue - fields = line.split("=") - if len(fields) == 2: - key,value = fields - newlines.append('%s: "%s"' % (key, value.replace('"','\\"'))) - else: - newlines.append(line) - contents = '\n'.join(newlines) - # load the YAML and save - map = mapfile.parse(contents) - mapfile.map_save(self.vcs, path, map) - - def _upgrade(self): - """ - Comment value field "From" -> "Author". - Homegrown mapfile -> YAML. - """ - path = self.get_path("settings") - self._upgrade_mapfile(path) - for bug_uuid in os.listdir(self.get_path("bugs")): - path = self.get_path("bugs", bug_uuid, "values") - self._upgrade_mapfile(path) - c_path = ["bugs", bug_uuid, "comments"] - if not os.path.exists(self.get_path(*c_path)): - continue # no comments for this bug - for comment_uuid in os.listdir(self.get_path(*c_path)): - path_list = c_path + [comment_uuid, "values"] - path = self.get_path(*path_list) - self._upgrade_mapfile(path) - settings = mapfile.map_load(self.vcs, path) - if "From" in settings: - settings["Author"] = settings.pop("From") - mapfile.map_save(self.vcs, path, settings) - - -class Upgrade_1_1_to_1_2 (Upgrader): - initial_version = "Bugs Everywhere Directory v1.1" - final_version = "Bugs Everywhere Directory v1.2" - def _upgrade(self): - """ - BugDir settings field "rcs_name" -> "vcs_name". - """ - path = self.get_path("settings") - settings = mapfile.map_load(self.vcs, path) - if "rcs_name" in settings: - settings["vcs_name"] = settings.pop("rcs_name") - mapfile.map_save(self.vcs, path, settings) - -class Upgrade_1_2_to_1_3 (Upgrader): - initial_version = "Bugs Everywhere Directory v1.2" - final_version = "Bugs Everywhere Directory v1.3" - def __init__(self, *args, **kwargs): - Upgrader.__init__(self, *args, **kwargs) - self._targets = {} # key: target text,value: new target bug - path = self.get_path('settings') - settings = mapfile.map_load(self.vcs, path) - if 'vcs_name' in settings: - old_vcs = self.vcs - self.vcs = vcs.vcs_by_name(settings['vcs_name']) - self.vcs.root(self.root) - self.vcs.encoding = old_vcs.encoding - - def _target_bug(self, target_text): - if target_text not in self._targets: - _bug = bug.Bug(bugdir=self, summary=target_text) - # note: we're not a bugdir, but all Bug.save() needs is - # .root, .vcs, and .get_path(), which we have. - _bug.severity = 'target' - self._targets[target_text] = _bug - return self._targets[target_text] - - def _upgrade_bugdir_mapfile(self): - path = self.get_path('settings') - settings = mapfile.map_load(self.vcs, path) - if 'target' in settings: - settings['target'] = self._target_bug(settings['target']).uuid - mapfile.map_save(self.vcs, path, settings) - - def _upgrade_bug_mapfile(self, bug_uuid): - import becommands.depend - path = self.get_path('bugs', bug_uuid, 'values') - settings = mapfile.map_load(self.vcs, path) - if 'target' in settings: - target_bug = self._target_bug(settings['target']) - _bug = bug.Bug(bugdir=self, uuid=bug_uuid, from_disk=True) - # note: we're not a bugdir, but all Bug.load_settings() - # needs is .root, .vcs, and .get_path(), which we have. - becommands.depend.add_block(target_bug, _bug) - _bug.settings.pop('target') - _bug.save() - - def _upgrade(self): - """ - Bug value field "target" -> target bugs. - Bugdir value field "target" -> pointer to current target bug. - """ - for bug_uuid in os.listdir(self.get_path('bugs')): - self._upgrade_bug_mapfile(bug_uuid) - self._upgrade_bugdir_mapfile() - for _bug in self._targets.values(): - _bug.save() - -upgraders = [Upgrade_1_0_to_1_1, - Upgrade_1_1_to_1_2, - Upgrade_1_2_to_1_3] -upgrade_classes = {} -for upgrader in upgraders: - upgrade_classes[(upgrader.initial_version,upgrader.final_version)]=upgrader - -def upgrade(path, current_version, - target_version=BUGDIR_DISK_VERSION): - """ - Call the appropriate upgrade function to convert current_version - to target_version. If a direct conversion function does not exist, - use consecutive conversion functions. - """ - if current_version not in BUGDIR_DISK_VERSIONS: - raise NotImplementedError, \ - "Cannot handle version '%s' yet." % version - if target_version not in BUGDIR_DISK_VERSIONS: - raise NotImplementedError, \ - "Cannot handle version '%s' yet." % version - - if (current_version, target_version) in upgrade_classes: - # direct conversion - upgrade_class = upgrade_classes[(current_version, target_version)] - u = upgrade_class(path) - u.upgrade() - else: - # consecutive single-step conversion - i = BUGDIR_DISK_VERSIONS.index(current_version) - while True: - version_a = BUGDIR_DISK_VERSIONS[i] - version_b = BUGDIR_DISK_VERSIONS[i+1] - try: - upgrade_class = upgrade_classes[(version_a, version_b)] - except KeyError: - raise NotImplementedError, \ - "Cannot convert version '%s' to '%s' yet." \ - % (version_a, version_b) - u = upgrade_class(path) - u.upgrade() - if version_b == target_version: - break - i += 1 - -if libbe.TESTING == True: - suite = doctest.DocTestSuite() diff --git a/libbe/util/__init__.py b/libbe/util/__init__.py new file mode 100644 index 0000000..5604c09 --- /dev/null +++ b/libbe/util/__init__.py @@ -0,0 +1,10 @@ +# Copyright + +""" +Miscellaneous utilities. +""" + +class InvalidObject (object): + """An object that won't come up by accident.""" + pass + diff --git a/libbe/encoding.py b/libbe/util/encoding.py index d09117f..7706105 100644 --- a/libbe/encoding.py +++ b/libbe/util/encoding.py @@ -1,4 +1,3 @@ -# Bugs Everywhere, a distributed bugtracker # Copyright (C) 2008-2009 Gianluca Montecchi <gian@grys.it> # W. Trevor King <wking@drexel.edu> # @@ -23,6 +22,7 @@ Support input/output/filesystem encodings (e.g. UTF-8). import codecs import locale import sys +import types import libbe if libbe.TESTING == True: @@ -44,6 +44,15 @@ def get_encoding(): # Python 2.3 on windows doesn't know about 'XYZ' alias for 'cpXYZ' return encoding +def get_input_encoding(): + return get_encoding() + +def get_output_encoding(): + return get_encoding() + +def get_filesystem_encoding(): + return get_encoding() + def known_encoding(encoding): """ >>> known_encoding("highly-unlikely-encoding") @@ -57,10 +66,26 @@ def known_encoding(encoding): except LookupError: return False -def set_IO_stream_encodings(encoding): - sys.stdin = codecs.getreader(encoding)(sys.__stdin__) - sys.stdout = codecs.getwriter(encoding)(sys.__stdout__) - sys.stderr = codecs.getwriter(encoding)(sys.__stderr__) +def get_file_contents(path, mode='r', encoding=None, decode=False): + if decode == True: + if encoding == None: + encoding = get_filesystem_encoding() + f = codecs.open(path, mode, encoding) + else: + f = open(path, mode) + contents = f.read() + f.close() + return contents + +def set_file_contents(path, contents, mode='w', encoding=None): + if type(contents) == types.UnicodeType: + if encoding == None: + encoding = get_filesystem_encoding() + f = codecs.open(path, mode, encoding) + else: + f = open(path, mode) + f.write(contents) + f.close() if libbe.TESTING == True: suite = doctest.DocTestSuite() diff --git a/libbe/util/id.py b/libbe/util/id.py new file mode 100644 index 0000000..f229bef --- /dev/null +++ b/libbe/util/id.py @@ -0,0 +1,473 @@ +# Copyright (C) 2008-2009 Gianluca Montecchi <gian@grys.it> +# W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +""" +Handle ID creation and parsing. +""" + +import os.path +import re + +import libbe + +if libbe.TESTING == True: + import doctest + import sys + 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') + + +HIERARCHY = ['bugdir', 'bug', 'comment'] + + +class MultipleIDMatches (ValueError): + def __init__(self, id, common, matches): + msg = ('More than one id matches %s. ' + 'Please be more specific (%s/*).\n%s' % (id, common, matches)) + ValueError.__init__(self, msg) + self.id = id + self.common = common + self.matches = matches + +class NoIDMatches (KeyError): + def __init__(self, id, possible_ids, msg=None): + KeyError.__init__(self, id) + self.id = id + self.possible_ids = possible_ids + self.msg = msg + def __str__(self): + if self.msg == None: + return 'No id matches %s.\n%s' % (self.id, self.possible_ids) + return self.msg + +class InvalidIDStructure (KeyError): + def __init__(self, id, msg=None): + KeyError.__init__(self, id) + self.id = id + self.msg = msg + def __str__(self): + if self.msg == None: + return 'Invalid id structure "%s"' % self.id + return self.msg + +def _assemble(args, check_length=False): + args = list(args) + for i,arg in enumerate(args): + if arg == None: + args[i] = '' + id = '/'.join(args) + if check_length == True: + assert len(args) > 0, args + if len(args) > 3: + raise InvalidIDStructure(id, '%d > 3 levels in "%s"' % (len(args), id)) + return id + +def _split(id, check_length=False): + args = id.split('/') + for i,arg in enumerate(args): + if arg == '': + args[i] = None + if check_length == True: + assert len(args) > 0, args + if len(args) > 3: + raise InvalidIDStructure(id, '%d > 3 levels in "%s"' % (len(args), id)) + return args + +def _truncate(uuid, other_uuids, min_length=3): + chars = min_length + for id in other_uuids: + if id == uuid: + continue + while (id[:chars] == uuid[:chars]): + chars+=1 + return uuid[:chars] + +def _expand(truncated_id, common, other_ids): + other_ids = list(other_ids) + if len(other_ids) == 0: + raise NoIDMatches(truncated_id, other_ids) + if truncated_id == None: + if len(other_ids) == 1: + return other_ids[0] + raise MultipleIDMatches(truncated_id, common, other_ids) + matches = [] + other_ids = list(other_ids) + for id in other_ids: + if id.startswith(truncated_id): + matches.append(id) + if len(matches) > 1: + raise MultipleIDMatches(truncated_id, common, matches) + if len(matches) == 0: + raise NoIDMatches(truncated_id, other_ids) + return matches[0] + + +class ID (object): + """ + IDs have several formats specialized for different uses. + + In storage, all objects are represented by their uuid alone, + because that is the simplest globally unique identifier. You can + generate ids of this sort with the .storage() method. Because an + object's storage may be distributed across several chunks, and the + chunks may not have their own uuid, we generate chunk ids by + prepending the objects uuid to the chunk name. The user id types + do not support this chunk extension feature. + + For users, the full uuids are a bit overwhelming, so we truncate + them while retaining local uniqueness (with regards to the other + objects currently in storage). We also prepend truncated parent + ids for two reasons: + (1) so that a user can locate the repository containing the + referenced object. It would be hard to find bug 'XYZ' if + that's all you knew. Much easier with 'ABC/XYZ', where ABC + is the bugdir. Each project can publish a list of bugdir-id + - to - location mappings, e.g. + ABC...(full uuid)...DEF https://server.com/projectX/be/ + which is easier than publishing all-object-ids-to-location + mappings. + (2) because it's easier to generate and parse truncated ids if + you don't have to fetch all the ids in the storage + repository, but can restrict yourself to a specific branch. + You can generate ids of this sort with the .user() method, + although in order to preform the truncation, your object (and its + parents must define a .sibling_uuids() method. + + + While users can use the convenient short user ids in the short + term, the truncation will inevitably lead to name collision. To + avoid that, we provide a non-truncated form of the short user ids + via the .long_user() method. These long user ids should be + converted to short user ids by intelligent user interfaces. + + Related tools: + * get uuids back out of the user ids: + parse_user() + * scan text for user ids & convert to long user ids: + short_to_long_user() + * scan text for long user ids & convert to short user ids: + long_to_short_user() + + Supported types: 'bugdir', 'bug', 'comment' + """ + def __init__(self, object, type): + self._object = object + self._type = type + assert self._type in HIERARCHY, self._type + + def storage(self, *args): + return _assemble([self._object.uuid]+list(args)) + + def _ancestors(self): + ret = [self._object] + index = HIERARCHY.index(self._type) + if index == 0: + return ret + o = self._object + for i in range(index, 0, -1): + parent_name = HIERARCHY[i-1] + o = getattr(o, parent_name, None) + ret.insert(0, o) + return ret + + def long_user(self): + return _assemble([o.uuid for o in self._ancestors()], + check_length=True) + + def user(self): + ids = [] + for o in self._ancestors(): + if o == None: + ids.append(None) + else: + ids.append(_truncate(o.uuid, o.sibling_uuids())) + return _assemble(ids, check_length=True) + +def child_uuids(child_storage_ids): + """ + Extract uuid children from other children generated by the + ID.storage() method. + >>> list(child_uuids(['abc123/values', '123abc', '123def'])) + ['123abc', '123def'] + """ + for id in child_storage_ids: + fields = _split(id) + if len(fields) == 1: + yield fields[0] + +def long_to_short_user(bugdirs, id): + ids = _split(id, check_length=True) + bugdir = [bd for bd in bugdirs if bd.uuid == ids[0]][0] + objects = [bugdir] + if len(ids) >= 2: + bug = bugdir.bug_from_uuid(ids[1]) + objects.append(bug) + if len(ids) >= 3: + comment = bug.comment_from_uuid(ids[2]) + objects.append(comment) + for i,obj in enumerate(objects): + ids[i] = _truncate(ids[i], obj.sibling_uuids()) + return _assemble(ids) + +def short_to_long_user(bugdirs, id): + ids = _split(id, check_length=True) + ids[0] = _expand(ids[0], common=None, + other_ids=[bd.uuid for bd in bugdirs]) + if len(ids) == 1: + return _assemble(ids) + bugdir = [bd for bd in bugdirs if bd.uuid == ids[0]][0] + ids[1] = _expand(ids[1], common=bugdir.id.user(), + other_ids=bugdir.uuids()) + if len(ids) == 2: + return _assemble(ids) + bug = bugdir.bug_from_uuid(ids[1]) + ids[2] = _expand(ids[2], common=bug.id.user(), + other_ids=bug.uuids()) + return _assemble(ids) + + +REGEXP = '#([-a-f0-9]*)(/[-a-g0-9]*)?(/[-a-g0-9]*)?#' + +class IDreplacer (object): + def __init__(self, bugdirs, replace_fn): + self.bugdirs = bugdirs + self.replace_fn = replace_fn + def __call__(self, match): + ids = [] + for m in match.groups(): + if m == None: + m = '' + ids.append(m) + return '#' + self.replace_fn(self.bugdirs, ''.join(ids)) + '#' + +def short_to_long_text(bugdirs, text): + return re.sub(REGEXP, IDreplacer(bugdirs, short_to_long_user), text) + +def long_to_short_text(bugdirs, text): + return re.sub(REGEXP, IDreplacer(bugdirs, long_to_short_user), text) + +def residual(base, fragment): + """ + >>> residual('ABC/DEF/', '//GHI') + ('//', 'GHI') + >>> residual('ABC/DEF/', '/D/GHI') + ('/D/', 'GHI') + >>> residual('ABC/DEF', 'A/D/GHI') + ('A/D/', 'GHI') + >>> residual('ABC/DEF', 'A/D/GHI/JKL') + ('A/D/', 'GHI/JKL') + """ + base = base.rstrip('/') + '/' + ids = fragment.split('/') + base_count = base.count('/') + root_ids = ids[:base_count] + [''] + residual_ids = ids[base_count:] + return ('/'.join(root_ids), '/'.join(residual_ids)) + +def _parse_user(id): + """ + >>> _parse_user('ABC/DEF/GHI') == \\ + ... {'bugdir':'ABC', 'bug':'DEF', 'comment':'GHI', 'type':'comment'} + True + >>> _parse_user('ABC/DEF') == \\ + ... {'bugdir':'ABC', 'bug':'DEF', 'type':'bug'} + True + >>> _parse_user('ABC') == \\ + ... {'bugdir':'ABC', 'type':'bugdir'} + True + >>> _parse_user('') == \\ + ... {'bugdir':None, 'type':'bugdir'} + True + >>> _parse_user('/') == \\ + ... {'bugdir':None, 'bug':None, 'type':'bug'} + True + >>> _parse_user('/DEF/') == \\ + ... {'bugdir':None, 'bug':'DEF', 'comment':None, 'type':'comment'} + True + >>> _parse_user('a/b/c/d') + Traceback (most recent call last): + ... + InvalidIDStructure: 4 > 3 levels in "a/b/c/d" + """ + ret = {} + args = _split(id, check_length=True) + for i,(type,arg) in enumerate(zip(HIERARCHY, args)): + if arg != None and len(arg) == 0: + raise InvalidIDStructure( + id, 'Invalid %s part %d "%s" of id "%s"' % (type, i, arg, id)) + ret['type'] = type + ret[type] = arg + return ret + +def parse_user(bugdir, id): + long_id = short_to_long_user([bugdir], id) + return _parse_user(long_id) + +if libbe.TESTING == True: + class UUIDtestCase(unittest.TestCase): + def testUUID_gen(self): + id = uuid_gen() + self.failUnless(len(id) == 36, 'invalid UUID "%s"' % id) + + class DummyObject (object): + def __init__(self, uuid, parent=None, siblings=[]): + self.uuid = uuid + self._siblings = siblings + if parent == None: + type_i = 0 + else: + assert parent.type in HIERARCHY, parent + setattr(self, parent.type, parent) + type_i = HIERARCHY.index(parent.type) + 1 + self.type = HIERARCHY[type_i] + self.id = ID(self, self.type) + def sibling_uuids(self): + return self._siblings + + class IDtestCase(unittest.TestCase): + def setUp(self): + self.bugdir = DummyObject('1234abcd') + self.bug = DummyObject('abcdef', self.bugdir, ['a1234', 'ab9876']) + self.comment = DummyObject('12345678', self.bug, ['1234abcd', '1234cdef']) + self.bd_id = self.bugdir.id + self.b_id = self.bug.id + self.c_id = self.comment.id + def test_storage(self): + self.failUnless(self.bd_id.storage() == self.bugdir.uuid, + self.bd_id.storage()) + self.failUnless(self.b_id.storage() == self.bug.uuid, + self.b_id.storage()) + self.failUnless(self.c_id.storage() == self.comment.uuid, + self.c_id.storage()) + self.failUnless(self.bd_id.storage('x', 'y', 'z') == \ + '1234abcd/x/y/z', + self.bd_id.storage('x', 'y', 'z')) + def test_long_user(self): + self.failUnless(self.bd_id.long_user() == self.bugdir.uuid, + self.bd_id.long_user()) + self.failUnless(self.b_id.long_user() == \ + '/'.join([self.bugdir.uuid, self.bug.uuid]), + self.b_id.long_user()) + self.failUnless(self.c_id.long_user() == + '/'.join([self.bugdir.uuid, self.bug.uuid, + self.comment.uuid]), + self.c_id.long_user) + def test_user(self): + self.failUnless(self.bd_id.user() == '123', + self.bd_id.user()) + self.failUnless(self.b_id.user() == '123/abc', + self.b_id.user()) + self.failUnless(self.c_id.user() == '123/abc/12345', + self.c_id.user()) + + class ShortLongParseTestCase(unittest.TestCase): + def setUp(self): + self.bugdir = DummyObject('1234abcd') + self.bug = DummyObject('abcdef', self.bugdir, ['a1234', 'ab9876']) + self.comment = DummyObject('12345678', self.bug, ['1234abcd', '1234cdef']) + self.bd_id = self.bugdir.id + self.b_id = self.bug.id + self.c_id = self.comment.id + self.bugdir.bug_from_uuid = lambda uuid: self.bug + self.bugdir.uuids = lambda : self.bug.sibling_uuids() + [self.bug.uuid] + self.bug.comment_from_uuid = lambda uuid: self.comment + self.bug.uuids = lambda : self.comment.sibling_uuids() + [self.comment.uuid] + self.short = 'bla bla #123/abc# bla bla #123/abc/12345# bla bla' + self.long = 'bla bla #1234abcd/abcdef# bla bla #1234abcd/abcdef/12345678# bla bla' + self.short_id_parse_pairs = [ + ('', {'bugdir':'1234abcd', 'type':'bugdir'}), + ('123/abc', {'bugdir':'1234abcd', 'bug':'abcdef', + 'type':'bug'}), + ('123/abc/12345', {'bugdir':'1234abcd', 'bug':'abcdef', + 'comment':'12345678', 'type':'comment'}), + ] + self.short_id_exception_pairs = [ + ('z', NoIDMatches('z', ['1234abcd'])), + ('///', InvalidIDStructure( + '///', msg='4 > 3 levels in "///"')), + ('/', MultipleIDMatches( + None, '123', ['a1234', 'ab9876', 'abcdef'])), + ('123/', MultipleIDMatches( + None, '123', ['a1234', 'ab9876', 'abcdef'])), + ('123/abc/', MultipleIDMatches( + None, '123/abc', ['1234abcd','1234cdef','12345678'])), + ] + def test_short_to_long_text(self): + self.failUnless(short_to_long_text([self.bugdir], self.short) == self.long, + '\n' + self.short + '\n' + short_to_long_text([self.bugdir], self.short) + '\n' + self.long) + def test_long_to_short_text(self): + self.failUnless(long_to_short_text([self.bugdir], self.long) == self.short, + '\n' + long_to_short_text([self.bugdir], self.long) + '\n' + self.short) + def test_parse_user(self): + for short_id,parsed in self.short_id_parse_pairs: + ret = parse_user(self.bugdir, short_id) + self.failUnless(ret == parsed, + 'got %s\nexpected %s' % (ret, parsed)) + def test_parse_user_exceptions(self): + for short_id,exception in self.short_id_exception_pairs: + try: + ret = parse_user(self.bugdir, short_id) + self.fail('Expected parse_user(bugdir, "%s") to raise %s,' + '\n but it returned %s' + % (short_id, exception.__class__.__name__, ret)) + except exception.__class__, e: + for attr in dir(e): + if attr.startswith('_') or attr == 'args': + continue + value = getattr(e, attr) + expected = getattr(exception, attr) + self.failUnless( + value == expected, + 'Expected parse_user(bugdir, "%s") %s.%s' + '\n to be %s, but it is %s\n\n%s' + % (short_id, exception.__class__.__name__, + attr, expected, value, e)) + + unitsuite =unittest.TestLoader().loadTestsFromModule(sys.modules[__name__]) + suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()]) diff --git a/libbe/plugin.py b/libbe/util/plugin.py index 03f68fc..0326cda 100644 --- a/libbe/plugin.py +++ b/libbe/util/plugin.py @@ -26,50 +26,42 @@ import os import os.path import sys -import libbe -if libbe.TESTING == True: - import doctest -def my_import(mod_name): - module = __import__(mod_name) - components = mod_name.split('.') +_PLUGIN_PATH = os.path.realpath( + os.path.dirname( + os.path.dirname( + os.path.dirname(__file__)))) +if _PLUGIN_PATH not in sys.path: + sys.path.append(_PLUGIN_PATH) + +def import_by_name(modname): + """ + >>> mod = import_by_name('libbe.bugdir') + >>> 'BugDir' in dir(mod) + True + >>> import_by_name('libbe.highly_unlikely') + Traceback (most recent call last): + ... + ImportError: No module named highly_unlikely + """ + module = __import__(modname) + components = modname.split('.') for comp in components[1:]: module = getattr(module, comp) return module -def iter_plugins(prefix): +def modnames(prefix): """ - >>> "list" in [n for n,m in iter_plugins("becommands")] + >>> 'list' in [n for n in modnames('libbe.command')] True - >>> "plugin" in [n for n,m in iter_plugins("libbe")] + >>> 'plugin' in [n for n in modnames('libbe.util')] True """ - modfiles = os.listdir(os.path.join(plugin_path, prefix)) + components = prefix.split('.') + modfiles = os.listdir(os.path.join(_PLUGIN_PATH, *components)) modfiles.sort() for modfile in modfiles: if modfile.startswith('.'): continue # the occasional emacs temporary file - if modfile.endswith(".py") and modfile != "__init__.py": - yield modfile[:-3], my_import(prefix+"."+modfile[:-3]) - - -def get_plugin(prefix, name): - """ - >>> get_plugin("becommands", "asdf") is None - True - >>> q = repr(get_plugin("becommands", "list")) - >>> q.startswith("<module 'becommands.list' from ") - True - """ - dirprefix = os.path.join(*prefix.split('.')) - command_path = os.path.join(plugin_path, dirprefix, name+".py") - if os.path.isfile(command_path): - return my_import(prefix + "." + name) - return None - -plugin_path = os.path.realpath(os.path.dirname(os.path.dirname(__file__))) -if plugin_path not in sys.path: - sys.path.append(plugin_path) - -if libbe.TESTING == True: - suite = doctest.DocTestSuite() + if modfile.endswith('.py') and modfile != '__init__.py': + yield modfile[:-3] diff --git a/libbe/subproc.py b/libbe/util/subproc.py index 8806e26..06716b3 100644 --- a/libbe/subproc.py +++ b/libbe/util/subproc.py @@ -61,7 +61,7 @@ def invoke(args, stdin=None, stdout=PIPE, stderr=PIPE, expect=(0,), else: assert _MSWINDOWS==True, 'invalid platform' # win32 don't have os.execvp() so have to run command in a shell - q = Popen(args, stdin=PIPE, stdout=stdout, stderr=stderr, + q = Popen(args, stdin=PIPE, stdout=stdout, stderr=stderr, shell=True, cwd=cwd) except OSError, e: raise CommandError(args, status=e.args[0], stderr=e) @@ -133,7 +133,7 @@ class Pipe (object): thread.start() threads.append(thread) std_X_arrays.append(stderr_array) - + # also listen to the last processes stdout stdout_array = [] thread = Thread(target=proc._readerthread, @@ -142,11 +142,11 @@ class Pipe (object): thread.start() threads.append(thread) std_X_arrays.append(stdout_array) - + # join threads as they die for thread in threads: thread.join() - + # read output from reader threads std_X_strings = [] for std_X_array in std_X_arrays: diff --git a/libbe/tree.py b/libbe/util/tree.py index 1daac44..1daac44 100644 --- a/libbe/tree.py +++ b/libbe/util/tree.py diff --git a/libbe/utility.py b/libbe/util/utility.py index f954422..31d4c14 100644 --- a/libbe/utility.py +++ b/libbe/util/utility.py @@ -51,7 +51,7 @@ def search_parent_directories(path, filename): """ Find the file (or directory) named filename in path or in any of path's parents. - + e.g. search_parent_directories("/a/b/c", ".be") will return the path to the first existing file from @@ -112,7 +112,7 @@ def str_to_time(str_time): time_val = calendar.timegm(time.strptime(str_time, RFC_2822_TIME_FMT)) timesign = -int(timezone_str[0]+"1") # "+" -> time_val ahead of GMT timezone_tuple = time.strptime(timezone_str[1:], "%H%M") - timezone = timezone_tuple.tm_hour*3600 + timezone_tuple.tm_min*60 + timezone = timezone_tuple.tm_hour*3600 + timezone_tuple.tm_min*60 return time_val + timesign*timezone def handy_time(time_val): @@ -147,5 +147,14 @@ def iterable_full_of_strings(value, alternative=None): return False return True +def underlined(instring): + """Produces a version of a string that is underlined with '=' + + >>> underlined("Underlined String") + 'Underlined String\\n=================' + """ + + return "%s\n%s" % (instring, "="*len(instring)) + if libbe.TESTING == True: suite = doctest.DocTestSuite() diff --git a/libbe/vcs.py b/libbe/vcs.py deleted file mode 100644 index 44643a4..0000000 --- a/libbe/vcs.py +++ /dev/null @@ -1,941 +0,0 @@ -# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc. -# Alexander Belchenko <bialix@ukr.net> -# Ben Finney <benf@cybersource.com.au> -# Chris Ball <cjb@laptop.org> -# Gianluca Montecchi <gian@grys.it> -# W. Trevor King <wking@drexel.edu> -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, write to the Free Software Foundation, Inc., -# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - -""" -Define the base VCS (Version Control System) class, which should be -subclassed by other Version Control System backends. The base class -implements a "do not version" VCS. -""" - -import codecs -import os -import os.path -import re -from socket import gethostname -import shutil -import sys -import tempfile - -import libbe -from utility import Dir, search_parent_directories -from subproc import CommandError, invoke -from plugin import get_plugin - -if libbe.TESTING == True: - import unittest - import doctest - - -# List VCS modules in order of preference. -# Don't list this module, it is implicitly last. -VCS_ORDER = ['arch', 'bzr', 'darcs', 'git', 'hg'] - -def set_preferred_vcs(name): - global VCS_ORDER - assert name in VCS_ORDER, \ - 'unrecognized VCS %s not in\n %s' % (name, VCS_ORDER) - VCS_ORDER.remove(name) - VCS_ORDER.insert(0, name) - -def _get_matching_vcs(matchfn): - """Return the first module for which matchfn(VCS_instance) is true""" - for submodname in VCS_ORDER: - module = get_plugin('libbe', submodname) - vcs = module.new() - if matchfn(vcs) == True: - return vcs - vcs.cleanup() - return VCS() - -def vcs_by_name(vcs_name): - """Return the module for the VCS with the given name""" - return _get_matching_vcs(lambda vcs: vcs.name == vcs_name) - -def detect_vcs(dir): - """Return an VCS instance for the vcs being used in this directory""" - return _get_matching_vcs(lambda vcs: vcs.detect(dir)) - -def installed_vcs(): - """Return an instance of an installed VCS""" - return _get_matching_vcs(lambda vcs: vcs.installed()) - - - -class SettingIDnotSupported(NotImplementedError): - pass - -class VCSnotRooted(Exception): - def __init__(self): - msg = "VCS not rooted" - Exception.__init__(self, msg) - -class PathNotInRoot(Exception): - def __init__(self, path, root): - msg = "Path '%s' not in root '%s'" % (path, root) - Exception.__init__(self, msg) - self.path = path - self.root = root - -class NoSuchFile(Exception): - def __init__(self, pathname, root="."): - path = os.path.abspath(os.path.join(root, pathname)) - Exception.__init__(self, "No such file: %s" % path) - -class EmptyCommit(Exception): - def __init__(self): - Exception.__init__(self, "No changes to commit") - - -def new(): - return VCS() - -class VCS(object): - """ - This class implements a 'no-vcs' interface. - - Support for other VCSs can be added by subclassing this class, and - overriding methods _vcs_*() with code appropriate for your VCS. - - The methods _u_*() are utility methods available to the _vcs_*() - methods. - """ - name = "None" - client = "" # command-line tool for _u_invoke_client - versioned = False - def __init__(self, paranoid=False, encoding=sys.getdefaultencoding()): - self.paranoid = paranoid - self.verboseInvoke = False - self.rootdir = None - self._duplicateBasedir = None - self._duplicateDirname = None - self.encoding = encoding - def __str__(self): - return "<%s %s>" % (self.__class__.__name__, id(self)) - def __repr__(self): - return str(self) - def _vcs_version(self): - """ - Return the VCS version string. - """ - return "0.0" - def _vcs_detect(self, path=None): - """ - Detect whether a directory is revision controlled with this VCS. - """ - return True - def _vcs_root(self, path): - """ - Get the VCS root. This is the default working directory for - future invocations. You would normally set this to the root - directory for your VCS. - """ - if os.path.isdir(path)==False: - path = os.path.dirname(path) - if path == "": - path = os.path.abspath(".") - return path - def _vcs_init(self, path): - """ - Begin versioning the tree based at path. - """ - pass - def _vcs_cleanup(self): - """ - Remove any cruft that _vcs_init() created outside of the - versioned tree. - """ - pass - def _vcs_get_user_id(self): - """ - Get the VCS's suggested user id (e.g. "John Doe <jdoe@example.com>"). - If the VCS has not been configured with a username, return None. - """ - return None - def _vcs_set_user_id(self, value): - """ - Set the VCS's suggested user id (e.g "John Doe <jdoe@example.com>"). - This is run if the VCS has not been configured with a usename, so - that commits will have a reasonable FROM value. - """ - raise SettingIDnotSupported - def _vcs_add(self, path): - """ - Add the already created file at path to version control. - """ - pass - def _vcs_remove(self, path): - """ - Remove the file at path from version control. Optionally - remove the file from the filesystem as well. - """ - pass - def _vcs_update(self, path): - """ - Notify the versioning system of changes to the versioned file - at path. - """ - pass - def _vcs_get_file_contents(self, path, revision=None, binary=False): - """ - Get the file contents as they were in a given revision. - Revision==None specifies the current revision. - """ - assert revision == None, \ - "The %s VCS does not support revision specifiers" % self.name - if binary == False: - f = codecs.open(os.path.join(self.rootdir, path), "r", self.encoding) - else: - f = open(os.path.join(self.rootdir, path), "rb") - contents = f.read() - f.close() - return contents - def _vcs_duplicate_repo(self, directory, revision=None): - """ - Get the repository as it was in a given revision. - revision==None specifies the current revision. - dir specifies a directory to create the duplicate in. - """ - shutil.copytree(self.rootdir, directory, True) - def _vcs_commit(self, commitfile, allow_empty=False): - """ - Commit the current working directory, using the contents of - commitfile as the comment. Return the name of the old - revision (or None if commits are not supported). - - If allow_empty == False, raise EmptyCommit if there are no - changes to commit. - """ - return None - def _vcs_revision_id(self, index): - """ - Return the name of the <index>th revision. Index will be an - integer (possibly <= 0). The choice of which branch to follow - when crossing branches/merges is not defined. - - Return None if revision IDs are not supported, or if the - specified revision does not exist. - """ - return None - def version(self): - """Cache version string for efficiency.""" - if not hasattr(self, '_version'): - self._version = self._get_version() - return self._version - def _get_version(self): - try: - ret = self._vcs_version() - return ret - except OSError, e: - if e.errno == errno.ENOENT: - return None - else: - raise OSError, e - except CommandError: - return None - def installed(self): - if self.version() != None: - return True - return False - def detect(self, path="."): - """ - Detect whether a directory is revision controlled with this VCS. - """ - return self._vcs_detect(path) - def root(self, path): - """ - Set the root directory to the path's VCS root. This is the - default working directory for future invocations. - """ - self.rootdir = self._vcs_root(path) - def init(self, path): - """ - Begin versioning the tree based at path. - Also roots the vcs at path. - """ - if os.path.isdir(path)==False: - path = os.path.dirname(path) - self._vcs_init(path) - self.root(path) - def cleanup(self): - self._vcs_cleanup() - def get_user_id(self): - """ - Get the VCS's suggested user id (e.g. "John Doe <jdoe@example.com>"). - If the VCS has not been configured with a username, return the user's - id. You can override the automatic lookup procedure by setting the - VCS.user_id attribute to a string of your choice. - """ - if hasattr(self, "user_id"): - if self.user_id != None: - return self.user_id - id = self._vcs_get_user_id() - if id == None: - name = self._u_get_fallback_username() - email = self._u_get_fallback_email() - id = self._u_create_id(name, email) - print >> sys.stderr, "Guessing id '%s'" % id - try: - self.set_user_id(id) - except SettingIDnotSupported: - pass - return id - def set_user_id(self, value): - """ - Set the VCS's suggested user id (e.g "John Doe <jdoe@example.com>"). - This is run if the VCS has not been configured with a usename, so - that commits will have a reasonable FROM value. - """ - self._vcs_set_user_id(value) - def add(self, path): - """ - Add the already created file at path to version control. - """ - self._vcs_add(self._u_rel_path(path)) - def remove(self, path): - """ - Remove a file from both version control and the filesystem. - """ - self._vcs_remove(self._u_rel_path(path)) - if os.path.exists(path): - os.remove(path) - def recursive_remove(self, dirname): - """ - Remove a file/directory and all its decendents from both - version control and the filesystem. - """ - if not os.path.exists(dirname): - raise NoSuchFile(dirname) - for dirpath,dirnames,filenames in os.walk(dirname, topdown=False): - filenames.extend(dirnames) - for path in filenames: - fullpath = os.path.join(dirpath, path) - if os.path.exists(fullpath) == False: - continue - self._vcs_remove(self._u_rel_path(fullpath)) - if os.path.exists(dirname): - shutil.rmtree(dirname) - def update(self, path): - """ - Notify the versioning system of changes to the versioned file - at path. - """ - self._vcs_update(self._u_rel_path(path)) - def get_file_contents(self, path, revision=None, allow_no_vcs=False, binary=False): - """ - Get the file as it was in a given revision. - Revision==None specifies the current revision. - - allow_no_vcs==True allows direct access to files through - codecs.open() or open() if the vcs decides it can't handle the - given path. - """ - if not os.path.exists(path): - raise NoSuchFile(path) - if self._use_vcs(path, allow_no_vcs): - relpath = self._u_rel_path(path) - contents = self._vcs_get_file_contents(relpath,revision,binary=binary) - else: - if binary == True: - f = codecs.open(path, "r", self.encoding) - else: - f = open(path, "rb") - contents = f.read() - f.close() - return contents - def set_file_contents(self, path, contents, allow_no_vcs=False, binary=False): - """ - Set the file contents under version control. - """ - add = not os.path.exists(path) - if binary == False: - f = codecs.open(path, "w", self.encoding) - else: - f = open(path, "wb") - f.write(contents) - f.close() - - if self._use_vcs(path, allow_no_vcs): - if add: - self.add(path) - else: - self.update(path) - def mkdir(self, path, allow_no_vcs=False, check_parents=True): - """ - Create (if neccessary) a directory at path under version - control. - """ - if check_parents == True: - parent = os.path.dirname(path) - if not os.path.exists(parent): # recurse through parents - self.mkdir(parent, allow_no_vcs, check_parents) - if not os.path.exists(path): - os.mkdir(path) - if self._use_vcs(path, allow_no_vcs): - self.add(path) - else: - assert os.path.isdir(path) - if self._use_vcs(path, allow_no_vcs): - #self.update(path)# Don't update directories. Changing files - pass # underneath them should be sufficient. - - def duplicate_repo(self, revision=None): - """ - Get the repository as it was in a given revision. - revision==None specifies the current revision. - Return the path to the arbitrary directory at the base of the new repo. - """ - # Dirname in Basedir to protect against simlink attacks. - if self._duplicateBasedir == None: - self._duplicateBasedir = tempfile.mkdtemp(prefix='BEvcs') - self._duplicateDirname = \ - os.path.join(self._duplicateBasedir, "duplicate") - self._vcs_duplicate_repo(directory=self._duplicateDirname, - revision=revision) - return self._duplicateDirname - def remove_duplicate_repo(self): - """ - Clean up a duplicate repo created with duplicate_repo(). - """ - if self._duplicateBasedir != None: - shutil.rmtree(self._duplicateBasedir) - self._duplicateBasedir = None - self._duplicateDirname = None - def commit(self, summary, body=None, allow_empty=False): - """ - Commit the current working directory, with a commit message - string summary and body. Return the name of the old revision - (or None if versioning is not supported). - - If allow_empty == False (the default), raise EmptyCommit if - there are no changes to commit. - """ - summary = summary.strip()+'\n' - if body is not None: - summary += '\n' + body.strip() + '\n' - descriptor, filename = tempfile.mkstemp() - revision = None - try: - temp_file = os.fdopen(descriptor, 'wb') - temp_file.write(summary) - temp_file.flush() - self.precommit() - revision = self._vcs_commit(filename, allow_empty=allow_empty) - temp_file.close() - self.postcommit() - finally: - os.remove(filename) - return revision - def precommit(self): - """ - Executed before all attempted commits. - """ - pass - def postcommit(self): - """ - Only executed after successful commits. - """ - pass - def revision_id(self, index=None): - """ - Return the name of the <index>th revision. The choice of - which branch to follow when crossing branches/merges is not - defined. - - Return None if index==None, revision IDs are not supported, or - if the specified revision does not exist. - """ - if index == None: - return None - return self._vcs_revision_id(index) - def _u_any_in_string(self, list, string): - """ - Return True if any of the strings in list are in string. - Otherwise return False. - """ - for list_string in list: - if list_string in string: - return True - return False - def _u_invoke(self, *args, **kwargs): - if 'cwd' not in kwargs: - kwargs['cwd'] = self.rootdir - if 'verbose' not in kwargs: - kwargs['verbose'] = self.verboseInvoke - if 'encoding' not in kwargs: - kwargs['encoding'] = self.encoding - return invoke(*args, **kwargs) - def _u_invoke_client(self, *args, **kwargs): - cl_args = [self.client] - cl_args.extend(args) - return self._u_invoke(cl_args, **kwargs) - def _u_search_parent_directories(self, path, filename): - """ - Find the file (or directory) named filename in path or in any - of path's parents. - - e.g. - search_parent_directories("/a/b/c", ".be") - will return the path to the first existing file from - /a/b/c/.be - /a/b/.be - /a/.be - /.be - or None if none of those files exist. - """ - return search_parent_directories(path, filename) - def _use_vcs(self, path, allow_no_vcs): - """ - Try and decide if _vcs_add/update/mkdir/etc calls will - succeed. Returns True is we think the vcs_call would - succeeed, and False otherwise. - """ - use_vcs = True - exception = None - if self.rootdir != None: - if self.path_in_root(path) == False: - use_vcs = False - exception = PathNotInRoot(path, self.rootdir) - else: - use_vcs = False - exception = VCSnotRooted - if use_vcs == False and allow_no_vcs==False: - raise exception - return use_vcs - def path_in_root(self, path, root=None): - """ - Return the relative path to path from root. - >>> vcs = new() - >>> vcs.path_in_root("/a.b/c/.be", "/a.b/c") - True - >>> vcs.path_in_root("/a.b/.be", "/a.b/c") - False - """ - if root == None: - if self.rootdir == None: - raise VCSnotRooted - root = self.rootdir - path = os.path.abspath(path) - absRoot = os.path.abspath(root) - absRootSlashedDir = os.path.join(absRoot,"") - if not path.startswith(absRootSlashedDir): - return False - return True - def _u_rel_path(self, path, root=None): - """ - Return the relative path to path from root. - >>> vcs = new() - >>> vcs._u_rel_path("/a.b/c/.be", "/a.b/c") - '.be' - """ - if root == None: - if self.rootdir == None: - raise VCSnotRooted - root = self.rootdir - path = os.path.abspath(path) - absRoot = os.path.abspath(root) - absRootSlashedDir = os.path.join(absRoot,"") - if not path.startswith(absRootSlashedDir): - raise PathNotInRoot(path, absRootSlashedDir) - assert path != absRootSlashedDir, \ - "file %s == root directory %s" % (path, absRootSlashedDir) - relpath = path[len(absRootSlashedDir):] - return relpath - def _u_abspath(self, path, root=None): - """ - Return the absolute path from a path realtive to root. - >>> vcs = new() - >>> vcs._u_abspath(".be", "/a.b/c") - '/a.b/c/.be' - """ - if root == None: - assert self.rootdir != None, "VCS not rooted" - root = self.rootdir - return os.path.abspath(os.path.join(root, path)) - def _u_create_id(self, name, email=None): - """ - >>> vcs = new() - >>> vcs._u_create_id("John Doe", "jdoe@example.com") - 'John Doe <jdoe@example.com>' - >>> vcs._u_create_id("John Doe") - 'John Doe' - """ - assert len(name) > 0 - if email == None or len(email) == 0: - return name - else: - return "%s <%s>" % (name, email) - def _u_parse_id(self, value): - """ - >>> vcs = new() - >>> vcs._u_parse_id("John Doe <jdoe@example.com>") - ('John Doe', 'jdoe@example.com') - >>> vcs._u_parse_id("John Doe") - ('John Doe', None) - >>> try: - ... vcs._u_parse_id("John Doe <jdoe@example.com><what?>") - ... except AssertionError: - ... print "Invalid match" - Invalid match - """ - emailexp = re.compile("(.*) <([^>]*)>(.*)") - match = emailexp.search(value) - if match == None: - email = None - name = value - else: - assert len(match.groups()) == 3 - assert match.groups()[2] == "", match.groups() - email = match.groups()[1] - name = match.groups()[0] - assert name != None - assert len(name) > 0 - return (name, email) - def _u_get_fallback_username(self): - name = None - for envariable in ["LOGNAME", "USERNAME"]: - if os.environ.has_key(envariable): - name = os.environ[envariable] - break - assert name != None - return name - def _u_get_fallback_email(self): - hostname = gethostname() - name = self._u_get_fallback_username() - return "%s@%s" % (name, hostname) - def _u_parse_commitfile(self, commitfile): - """ - Split the commitfile created in self.commit() back into - summary and header lines. - """ - f = codecs.open(commitfile, "r", self.encoding) - summary = f.readline() - body = f.read() - body.lstrip('\n') - if len(body) == 0: - body = None - f.close() - return (summary, body) - - -if libbe.TESTING == True: - def setup_vcs_test_fixtures(testcase): - """Set up test fixtures for VCS test case.""" - testcase.vcs = testcase.Class() - testcase.dir = Dir() - testcase.dirname = testcase.dir.path - - vcs_not_supporting_uninitialized_user_id = [] - vcs_not_supporting_set_user_id = ["None", "hg"] - testcase.vcs_supports_uninitialized_user_id = ( - testcase.vcs.name not in vcs_not_supporting_uninitialized_user_id) - testcase.vcs_supports_set_user_id = ( - testcase.vcs.name not in vcs_not_supporting_set_user_id) - - if not testcase.vcs.installed(): - testcase.fail( - "%(name)s VCS not found" % vars(testcase.Class)) - - if testcase.Class.name != "None": - testcase.failIf( - testcase.vcs.detect(testcase.dirname), - "Detected %(name)s VCS before initialising" - % vars(testcase.Class)) - - testcase.vcs.init(testcase.dirname) - - class VCSTestCase(unittest.TestCase): - """Test cases for base VCS class.""" - - Class = VCS - - def __init__(self, *args, **kwargs): - super(VCSTestCase, self).__init__(*args, **kwargs) - self.dirname = None - - def setUp(self): - super(VCSTestCase, self).setUp() - setup_vcs_test_fixtures(self) - - def tearDown(self): - self.vcs.cleanup() - self.dir.cleanup() - super(VCSTestCase, self).tearDown() - - def full_path(self, rel_path): - return os.path.join(self.dirname, rel_path) - - - class VCS_init_TestCase(VCSTestCase): - """Test cases for VCS.init method.""" - - def test_detect_should_succeed_after_init(self): - """Should detect VCS in directory after initialization.""" - self.failUnless( - self.vcs.detect(self.dirname), - "Did not detect %(name)s VCS after initialising" - % vars(self.Class)) - - def test_vcs_rootdir_in_specified_root_path(self): - """VCS root directory should be in specified root path.""" - rp = os.path.realpath(self.vcs.rootdir) - dp = os.path.realpath(self.dirname) - vcs_name = self.Class.name - self.failUnless( - dp == rp or rp == None, - "%(vcs_name)s VCS root in wrong dir (%(dp)s %(rp)s)" % vars()) - - - class VCS_get_user_id_TestCase(VCSTestCase): - """Test cases for VCS.get_user_id method.""" - - def test_gets_existing_user_id(self): - """Should get the existing user ID.""" - if not self.vcs_supports_uninitialized_user_id: - return - - user_id = self.vcs.get_user_id() - self.failUnless( - user_id is not None, - "unable to get a user id") - - - class VCS_set_user_id_TestCase(VCSTestCase): - """Test cases for VCS.set_user_id method.""" - - def setUp(self): - super(VCS_set_user_id_TestCase, self).setUp() - - if self.vcs_supports_uninitialized_user_id: - self.prev_user_id = self.vcs.get_user_id() - else: - self.prev_user_id = "Uninitialized identity <bogus@example.org>" - - if self.vcs_supports_set_user_id: - self.test_new_user_id = "John Doe <jdoe@example.com>" - self.vcs.set_user_id(self.test_new_user_id) - - def tearDown(self): - if self.vcs_supports_set_user_id: - self.vcs.set_user_id(self.prev_user_id) - super(VCS_set_user_id_TestCase, self).tearDown() - - def test_raises_error_in_unsupported_vcs(self): - """Should raise an error in a VCS that doesn't support it.""" - if self.vcs_supports_set_user_id: - return - self.assertRaises( - SettingIDnotSupported, - self.vcs.set_user_id, "foo") - - def test_updates_user_id_in_supporting_vcs(self): - """Should update the user ID in an VCS that supports it.""" - if not self.vcs_supports_set_user_id: - return - user_id = self.vcs.get_user_id() - self.failUnlessEqual( - self.test_new_user_id, user_id, - "user id not set correctly (expected %s, got %s)" - % (self.test_new_user_id, user_id)) - - - def setup_vcs_revision_test_fixtures(testcase): - """Set up revision test fixtures for VCS test case.""" - testcase.test_dirs = ['a', 'a/b', 'c'] - for path in testcase.test_dirs: - testcase.vcs.mkdir(testcase.full_path(path)) - - testcase.test_files = ['a/text', 'a/b/text'] - - testcase.test_contents = { - 'rev_1': "Lorem ipsum", - 'uncommitted': "dolor sit amet", - } - - - class VCS_mkdir_TestCase(VCSTestCase): - """Test cases for VCS.mkdir method.""" - - def setUp(self): - super(VCS_mkdir_TestCase, self).setUp() - setup_vcs_revision_test_fixtures(self) - - def tearDown(self): - for path in reversed(sorted(self.test_dirs)): - self.vcs.recursive_remove(self.full_path(path)) - super(VCS_mkdir_TestCase, self).tearDown() - - def test_mkdir_creates_directory(self): - """Should create specified directory in filesystem.""" - for path in self.test_dirs: - full_path = self.full_path(path) - self.failUnless( - os.path.exists(full_path), - "path %(full_path)s does not exist" % vars()) - - - class VCS_commit_TestCase(VCSTestCase): - """Test cases for VCS.commit method.""" - - def setUp(self): - super(VCS_commit_TestCase, self).setUp() - setup_vcs_revision_test_fixtures(self) - - def tearDown(self): - for path in reversed(sorted(self.test_dirs)): - self.vcs.recursive_remove(self.full_path(path)) - super(VCS_commit_TestCase, self).tearDown() - - def test_file_contents_as_specified(self): - """Should set file contents as specified.""" - test_contents = self.test_contents['rev_1'] - for path in self.test_files: - full_path = self.full_path(path) - self.vcs.set_file_contents(full_path, test_contents) - current_contents = self.vcs.get_file_contents(full_path) - self.failUnlessEqual(test_contents, current_contents) - - def test_file_contents_as_committed(self): - """Should have file contents as specified after commit.""" - test_contents = self.test_contents['rev_1'] - for path in self.test_files: - full_path = self.full_path(path) - self.vcs.set_file_contents(full_path, test_contents) - revision = self.vcs.commit("Initial file contents.") - current_contents = self.vcs.get_file_contents(full_path) - self.failUnlessEqual(test_contents, current_contents) - - def test_file_contents_as_set_when_uncommitted(self): - """Should set file contents as specified after commit.""" - if not self.vcs.versioned: - return - for path in self.test_files: - full_path = self.full_path(path) - self.vcs.set_file_contents( - full_path, self.test_contents['rev_1']) - revision = self.vcs.commit("Initial file contents.") - self.vcs.set_file_contents( - full_path, self.test_contents['uncommitted']) - current_contents = self.vcs.get_file_contents(full_path) - self.failUnlessEqual( - self.test_contents['uncommitted'], current_contents) - - def test_revision_file_contents_as_committed(self): - """Should get file contents as committed to specified revision.""" - if not self.vcs.versioned: - return - for path in self.test_files: - full_path = self.full_path(path) - self.vcs.set_file_contents( - full_path, self.test_contents['rev_1']) - revision = self.vcs.commit("Initial file contents.") - self.vcs.set_file_contents( - full_path, self.test_contents['uncommitted']) - committed_contents = self.vcs.get_file_contents( - full_path, revision) - self.failUnlessEqual( - self.test_contents['rev_1'], committed_contents) - - def test_revision_id_as_committed(self): - """Check for compatibility between .commit() and .revision_id()""" - if not self.vcs.versioned: - self.failUnlessEqual(self.vcs.revision_id(5), None) - return - committed_revisions = [] - for path in self.test_files: - full_path = self.full_path(path) - self.vcs.set_file_contents( - full_path, self.test_contents['rev_1']) - revision = self.vcs.commit("Initial %s contents." % path) - committed_revisions.append(revision) - self.vcs.set_file_contents( - full_path, self.test_contents['uncommitted']) - revision = self.vcs.commit("Altered %s contents." % path) - committed_revisions.append(revision) - for i,revision in enumerate(committed_revisions): - self.failUnlessEqual(self.vcs.revision_id(i), revision) - i += -len(committed_revisions) # check negative indices - self.failUnlessEqual(self.vcs.revision_id(i), revision) - i = len(committed_revisions) - self.failUnlessEqual(self.vcs.revision_id(i), None) - self.failUnlessEqual(self.vcs.revision_id(-i-1), None) - - def test_revision_id_as_committed(self): - """Check revision id before first commit""" - if not self.vcs.versioned: - self.failUnlessEqual(self.vcs.revision_id(5), None) - return - committed_revisions = [] - for path in self.test_files: - self.failUnlessEqual(self.vcs.revision_id(0), None) - - - class VCS_duplicate_repo_TestCase(VCSTestCase): - """Test cases for VCS.duplicate_repo method.""" - - def setUp(self): - super(VCS_duplicate_repo_TestCase, self).setUp() - setup_vcs_revision_test_fixtures(self) - - def tearDown(self): - self.vcs.remove_duplicate_repo() - for path in reversed(sorted(self.test_dirs)): - self.vcs.recursive_remove(self.full_path(path)) - super(VCS_duplicate_repo_TestCase, self).tearDown() - - def test_revision_file_contents_as_committed(self): - """Should match file contents as committed to specified revision. - """ - if not self.vcs.versioned: - return - for path in self.test_files: - full_path = self.full_path(path) - self.vcs.set_file_contents( - full_path, self.test_contents['rev_1']) - revision = self.vcs.commit("Commit current status") - self.vcs.set_file_contents( - full_path, self.test_contents['uncommitted']) - dup_repo_path = self.vcs.duplicate_repo(revision) - dup_file_path = os.path.join(dup_repo_path, path) - dup_file_contents = file(dup_file_path, 'rb').read() - self.failUnlessEqual( - self.test_contents['rev_1'], dup_file_contents) - self.vcs.remove_duplicate_repo() - - - def make_vcs_testcase_subclasses(vcs_class, namespace): - """Make VCSTestCase subclasses for vcs_class in the namespace.""" - vcs_testcase_classes = [ - c for c in ( - ob for ob in globals().values() if isinstance(ob, type)) - if issubclass(c, VCSTestCase)] - - for base_class in vcs_testcase_classes: - testcase_class_name = vcs_class.__name__ + base_class.__name__ - testcase_class_bases = (base_class,) - testcase_class_dict = dict(base_class.__dict__) - testcase_class_dict['Class'] = vcs_class - testcase_class = type( - testcase_class_name, testcase_class_bases, testcase_class_dict) - setattr(namespace, testcase_class_name, testcase_class) - - - unitsuite =unittest.TestLoader().loadTestsFromModule(sys.modules[__name__]) - suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()]) diff --git a/libbe/version.py b/libbe/version.py index f8eebbd..ddff5a5 100644 --- a/libbe/version.py +++ b/libbe/version.py @@ -23,7 +23,10 @@ be bothered setting version strings" and the "I want complete control over the version strings" workflows. """ +import copy + import libbe._version as _version +import libbe.storage # Manually set a version string (optional, defaults to bzr revision id) #_VERSION = "1.2.3" @@ -39,11 +42,14 @@ def version(verbose=False): else: string = _version.version_info["revision_id"] if verbose == True: + info = copy.copy(_version.version_info) + info['storage'] = libbe.storage.STORAGE_VERSION string += ("\n" "revision: %(revno)d\n" "nick: %(branch_nick)s\n" - "revision id: %(revision_id)s" - % _version.version_info) + "revision id: %(revision_id)s\n" + "storage version: %(storage)s" + % info) return string if __name__ == "__main__": |