diff options
Diffstat (limited to 'libbe')
63 files changed, 18216 insertions, 0 deletions
diff --git a/libbe/__init__.py b/libbe/__init__.py new file mode 100644 index 0000000..d32716f --- /dev/null +++ b/libbe/__init__.py @@ -0,0 +1,53 @@ +# Copyright (C) 2005-2010 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. + +"""The libbe module does all the legwork for bugs-everywhere_ (BE). + +.. _bugs-everywhere: http://bugseverywhere.org + +To facilitate faster loading, submodules are not imported by default. +The available submodules are: + +* :mod:`libbe.bugdir` +* :mod:`libbe.bug` +* :mod:`libbe.comment` +* :mod:`libbe.command` +* :mod:`libbe.diff` +* :mod:`libbe.error` +* :mod:`libbe.storage` +* :mod:`libbe.ui` +* :mod:`libbe.util` +* :mod:`libbe.version` +* :mod:`libbe._version` +""" + +TESTING = False +"""Flag controlling test-suite generation. + +To reduce module load time, test suite generation is turned of by +default. If you *do* want to generate the test suites, set +``TESTING=True`` before loading any :mod:`libbe` submodules. + +Examples +-------- + +>>> import libbe +>>> libbe.TESTING = True +>>> import libbe.bugdir +>>> 'SimpleBugDir' in dir(libbe.bugdir) +True +""" diff --git a/libbe/bug.py b/libbe/bug.py new file mode 100644 index 0000000..8bf32dd --- /dev/null +++ b/libbe/bug.py @@ -0,0 +1,853 @@ +# Copyright (C) 2008-2010 Gianluca Montecchi <gian@grys.it> +# Thomas Habets <thomas@habets.pp.se> +# W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +"""Define the :class:`Bug` class for representing bugs. +""" + +import copy +import os +import os.path +import errno +import sys +import time +import types +try: # import core module, Python >= 2.5 + from xml.etree import ElementTree +except ImportError: # look for non-core module + from elementtree import ElementTree +import xml.sax.saxutils + +import libbe +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 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 + + +class DiskAccessRequired (Exception): + def __init__(self, goal): + msg = "Cannot %s without accessing the disk" % goal + Exception.__init__(self, msg) + +### Define and describe valid bug categories +# Use a tuple of (category, description) tuples since we don't have +# ordered dicts in Python yet http://www.python.org/dev/peps/pep-0372/ + +# in order of increasing severity. (name, description) pairs +severity_def = ( + ("target", "The issue is a target or milestone, not a bug."), + ("wishlist","A feature that could improve usefulness, but not a bug."), + ("minor","The standard bug level."), + ("serious","A bug that requires workarounds."), + ("critical","A bug that prevents some features from working at all."), + ("fatal","A bug that makes the package unusable.")) + +# in order of increasing resolution +# roughly following http://www.bugzilla.org/docs/3.2/en/html/lifecycle.html +active_status_def = ( + ("unconfirmed","A possible bug which lacks independent existance confirmation."), + ("open","A working bug that has not been assigned to a developer."), + ("assigned","A working bug that has been assigned to a developer."), + ("test","The code has been adjusted, but the fix is still being tested.")) +inactive_status_def = ( + ("closed", "The bug is no longer relevant."), + ("fixed", "The bug should no longer occur."), + ("wontfix","It's not a bug, it's a feature.")) + + +### Convert the description tuples to more useful formats + +severity_values = () +severity_description = {} +severity_index = {} +def load_severities(severity_def): + global severity_values + global severity_description + global severity_index + if severity_def == None: + return + severity_values = tuple([val for val,description in severity_def]) + severity_description = dict(severity_def) + severity_index = {} + for i,severity in enumerate(severity_values): + severity_index[severity] = i +load_severities(severity_def) + +active_status_values = [] +inactive_status_values = [] +status_values = [] +status_description = {} +status_index = {} +def load_status(active_status_def, inactive_status_def): + global active_status_values + global inactive_status_values + global status_values + global status_description + global status_index + if active_status_def == None: + active_status_def = globals()["active_status_def"] + if inactive_status_def == None: + inactive_status_def = globals()["inactive_status_def"] + active_status_values = tuple([val for val,description in active_status_def]) + inactive_status_values = tuple([val for val,description in inactive_status_def]) + status_values = active_status_values + inactive_status_values + status_description = dict(tuple(active_status_def) + tuple(inactive_status_def)) + status_index = {} + for i,status in enumerate(status_values): + status_index[status] = i +load_status(active_status_def, inactive_status_def) + + +class Bug (settings_object.SavedSettingsObject): + """A bug (or issue) is a place to store attributes and attach + :class:`~libbe.comment.Comment`\s. In mailing-list terms, a bug is + analogous to a thread. Bugs are normally stored in + :class:`~libbe.bugdir.BugDir`\s. + + >>> b = Bug() + >>> print b.status + open + >>> print b.severity + minor + + There are two formats for time, int and string. Setting either + one will adjust the other appropriately. The string form is the + one stored in the bug's settings file on disk. + + >>> print type(b.time) + <type 'int'> + >>> print type(b.time_string) + <type 'str'> + >>> b.time = 0 + >>> print b.time_string + Thu, 01 Jan 1970 00:00:00 +0000 + >>> b.time_string="Thu, 01 Jan 1970 00:01:00 +0000" + >>> b.time + 60 + >>> print b.settings["time"] + Thu, 01 Jan 1970 00:01:00 +0000 + """ + settings_properties = [] + required_saved_properties = [] + _prop_save_settings = settings_object.prop_save_settings + _prop_load_settings = settings_object.prop_load_settings + def _versioned_property(settings_properties=settings_properties, + required_saved_properties=required_saved_properties, + **kwargs): + if "settings_properties" not in kwargs: + kwargs["settings_properties"] = settings_properties + if "required_saved_properties" not in kwargs: + kwargs["required_saved_properties"]=required_saved_properties + return settings_object.versioned_property(**kwargs) + + @_versioned_property(name="severity", + doc="A measure of the bug's importance", + default="minor", + check_fn=lambda s: s in severity_values, + require_save=True) + def severity(): return {} + + @_versioned_property(name="status", + doc="The bug's current status", + default="open", + check_fn=lambda s: s in status_values, + require_save=True) + def status(): return {} + + @property + def active(self): + return self.status in active_status_values + + @_versioned_property(name="creator", + doc="The user who entered the bug into the system") + def creator(): return {} + + @_versioned_property(name="reporter", + doc="The user who reported the bug") + def reporter(): return {} + + @_versioned_property(name="assigned", + doc="The developer in charge of the bug") + def assigned(): return {} + + @_versioned_property(name="time", + doc="An RFC 2822 timestamp for bug creation") + def time_string(): return {} + + def _get_time(self): + if self.time_string == None: + return None + return utility.str_to_time(self.time_string) + def _set_time(self, value): + self.time_string = utility.time_to_str(value) + time = property(fget=_get_time, + fset=_set_time, + doc="An integer version of .time_string") + + def _extra_strings_check_fn(value): + return utility.iterable_full_of_strings(value, \ + alternative=settings_object.EMPTY) + def _extra_strings_change_hook(self, old, new): + self.extra_strings.sort() # to make merging easier + self._prop_save_settings(old, new) + @_versioned_property(name="extra_strings", + doc="Space for an array of extra strings. Useful for storing state for functionality implemented purely in becommands/<some_function>.py.", + default=[], + check_fn=_extra_strings_check_fn, + change_hook=_extra_strings_change_hook, + mutable=True) + def extra_strings(): return {} + + @_versioned_property(name="summary", + doc="A one-line bug description") + def summary(): return {} + + def _get_comment_root(self, load_full=False): + if self.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) + + @Property + @cached_property(generator=_get_comment_root) + @local_property("comment_root") + @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 __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 + self.id = libbe.util.id.ID(self, 'bug') + if from_storage == False: + if uuid == None: + self.uuid = libbe.util.id.uuid_gen() + self.time = int(time.time()) # only save to second precision + 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 + + def __str__(self): + return self.string(shortlist=True) + + def __cmp__(self, other): + return cmp_full(self, other) + + # serializing methods + + def _setting_attr_string(self, setting): + value = getattr(self, setting) + if value == None: + return "" + if type(value) not in types.StringTypes: + return str(value) + return value + + def string(self, shortlist=False, show_comments=False): + 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", 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', self.id.user()), + ('severity', self.severity), + ('status', self.status), + ('assigned', self.assigned), + ('reporter', self.reporter), + ('creator', self.creator), + ('created', timestring), + ('summary', self.summary)] + lines = ['<bug>'] + for (k,v) in info: + if v is not None: + lines.append(' <%s>%s</%s>' % (k,xml.sax.saxutils.escape(v),k)) + 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) + if len(comout) > 0: + lines.append(comout) + lines.append('</bug>') + istring = ' '*indent + sep = '\n' + istring + return istring + sep.join(lines).rstrip('\n') + + def from_xml(self, xml_string, verbose=True): + u""" + Note: If a bug uuid is given, set .alt_id to it's value. + >>> bugA = Bug(uuid="0123", summary="Need to test Bug.from_xml()") + >>> bugA.date = "Thu, 01 Jan 1970 00:00:00 +0000" + >>> bugA.creator = u'Fran\xe7ois' + >>> bugA.extra_strings += ['TAG: very helpful'] + >>> commA = bugA.comment_root.new_reply(body='comment A') + >>> commB = bugA.comment_root.new_reply(body='comment B') + >>> commC = commA.new_reply(body='comment C') + >>> xml = bugA.xml(show_comments=True) + >>> bugB = Bug() + >>> bugB.from_xml(xml, verbose=True) + >>> 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(show_comments=True) == xml + True + >>> bugB.explicit_attrs # doctest: +NORMALIZE_WHITESPACE + ['severity', 'status', 'creator', 'created', 'summary'] + >>> len(list(bugB.comments())) + 3 + """ + if type(xml_string) == types.UnicodeType: + xml_string = xml_string.strip().encode('unicode_escape') + if hasattr(xml_string, 'getchildren'): # already an ElementTree Element + bug = xml_string + else: + bug = ElementTree.XML(xml_string) + if bug.tag != 'bug': + raise utility.InvalidXML( \ + 'bug', bug, 'root element must be <comment>') + tags=['uuid','short-name','severity','status','assigned', + 'reporter', 'creator','created','summary','extra-string'] + self.explicit_attrs = [] + uuid = None + estrs = [] + comments = [] + for child in bug.getchildren(): + if child.tag == 'short-name': + pass + elif child.tag == 'comment': + comm = comment.Comment(bug=self) + comm.from_xml(child) + comments.append(comm) + continue + elif child.tag in tags: + if child.text == None or len(child.text) == 0: + text = settings_object.EMPTY + else: + text = xml.sax.saxutils.unescape(child.text) + text = text.decode('unicode_escape').strip() + if child.tag == 'uuid': + uuid = text + continue # don't set the bug's uuid tag. + elif child.tag == 'extra-string': + estrs.append(text) + continue # don't set the bug's extra_string yet. + attr_name = child.tag.replace('-','_') + self.explicit_attrs.append(attr_name) + setattr(self, attr_name, text) + elif verbose == True: + 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, ignore_missing_references=True) + + def add_comment(self, comment, *args, **kwargs): + """ + Add a comment too the current bug, under the parent specified + by comment.in_reply_to. + Note: If a bug uuid is given, set .alt_id to it's value. + + >>> bugA = Bug(uuid='0123', summary='Need to test Bug.add_comment()') + >>> bugA.creator = 'Jack' + >>> commA = bugA.comment_root.new_reply(body='comment A') + >>> commA.uuid = 'commA' + >>> commB = comment.Comment(body='comment B') + >>> commB.uuid = 'commB' + >>> bugA.add_comment(commB) + >>> commC = comment.Comment(body='comment C') + >>> commC.uuid = 'commC' + >>> commC.in_reply_to = commA.uuid + >>> bugA.add_comment(commC) + >>> print bugA.xml(show_comments=True) # doctest: +ELLIPSIS + <bug> + <uuid>0123</uuid> + <short-name>/012</short-name> + <severity>minor</severity> + <status>open</status> + <creator>Jack</creator> + <created>...</created> + <summary>Need to test Bug.add_comment()</summary> + <comment> + <uuid>commA</uuid> + <short-name>/012/commA</short-name> + <author></author> + <date>...</date> + <content-type>text/plain</content-type> + <body>comment A</body> + </comment> + <comment> + <uuid>commC</uuid> + <short-name>/012/commC</short-name> + <in-reply-to>commA</in-reply-to> + <author></author> + <date>...</date> + <content-type>text/plain</content-type> + <body>comment C</body> + </comment> + <comment> + <uuid>commB</uuid> + <short-name>/012/commB</short-name> + <author></author> + <date>...</date> + <content-type>text/plain</content-type> + <body>comment B</body> + </comment> + </bug> + """ + self.add_comments([comment], **kwargs) + + def add_comments(self, comments, default_parent=None, + ignore_missing_references=False): + """ + Convert a raw list of comments to single root comment. If a + comment does not specify a parent with .in_reply_to, the + parent defaults to .comment_root, but you can specify another + default parent via default_parent. + """ + uuid_map = {} + if default_parent == None: + default_parent = self.comment_root + for c in list(self.comments()) + comments: + assert c.uuid != None + assert c.uuid not in uuid_map + uuid_map[c.uuid] = c + 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.uuid + for c in comments: + if c.in_reply_to == None \ + and default_parent.uuid != comment.INVALID_UUID: + c.in_reply_to = default_parent.uuid + elif c.in_reply_to == comment.INVALID_UUID: + c.in_reply_to = None + try: + parent = uuid_map[c.in_reply_to] + except KeyError: + if ignore_missing_references == True: + print >> sys.stderr, \ + 'Ignoring missing reference to %s' % c.in_reply_to + parent = default_parent + if parent.uuid != comment.INVALID_UUID: + c.in_reply_to = parent.uuid + else: + raise comment.MissingReference(c) + c.bug = self + parent.append(c) + + def merge(self, other, accept_changes=True, + accept_extra_strings=True, accept_comments=True, + change_exception=False): + """ + Merge info from other into this bug. Overrides any attributes + in self that are listed in other.explicit_attrs. + + >>> bugA = Bug(uuid='0123', summary='Need to test Bug.merge()') + >>> bugA.date = 'Thu, 01 Jan 1970 00:00:00 +0000' + >>> bugA.creator = 'Frank' + >>> bugA.extra_strings += ['TAG: very helpful'] + >>> bugA.extra_strings += ['TAG: favorite'] + >>> commA = bugA.comment_root.new_reply(body='comment A') + >>> commA.uuid = 'uuid-commA' + >>> bugB = Bug(uuid='3210', summary='More tests for Bug.merge()') + >>> bugB.date = 'Fri, 02 Jan 1970 00:00:00 +0000' + >>> bugB.creator = 'John' + >>> bugB.explicit_attrs = ['creator', 'summary'] + >>> bugB.extra_strings += ['TAG: very helpful'] + >>> bugB.extra_strings += ['TAG: useful'] + >>> commB = bugB.comment_root.new_reply(body='comment B') + >>> commB.uuid = 'uuid-commB' + >>> bugA.merge(bugB, accept_changes=False, accept_extra_strings=False, + ... accept_comments=False, change_exception=False) + >>> print bugA.creator + Frank + >>> bugA.merge(bugB, accept_changes=False, accept_extra_strings=False, + ... accept_comments=False, change_exception=True) + Traceback (most recent call last): + ... + ValueError: Merge would change creator "Frank"->"John" for bug 0123 + >>> print bugA.creator + Frank + >>> bugA.merge(bugB, accept_changes=True, accept_extra_strings=False, + ... accept_comments=False, change_exception=True) + Traceback (most recent call last): + ... + ValueError: Merge would add extra string "TAG: useful" for bug 0123 + >>> print bugA.creator + John + >>> print bugA.extra_strings + ['TAG: favorite', 'TAG: very helpful'] + >>> bugA.merge(bugB, accept_changes=True, accept_extra_strings=True, + ... accept_comments=False, change_exception=True) + Traceback (most recent call last): + ... + ValueError: Merge would add comment uuid-commB (alt: None) to bug 0123 + >>> print bugA.extra_strings + ['TAG: favorite', 'TAG: useful', 'TAG: very helpful'] + >>> bugA.merge(bugB, accept_changes=True, accept_extra_strings=True, + ... accept_comments=True, change_exception=True) + >>> print bugA.xml(show_comments=True) # doctest: +ELLIPSIS + <bug> + <uuid>0123</uuid> + <short-name>/012</short-name> + <severity>minor</severity> + <status>open</status> + <creator>John</creator> + <created>...</created> + <summary>More tests for Bug.merge()</summary> + <extra-string>TAG: favorite</extra-string> + <extra-string>TAG: useful</extra-string> + <extra-string>TAG: very helpful</extra-string> + <comment> + <uuid>uuid-commA</uuid> + <short-name>/012/uuid-commA</short-name> + <author></author> + <date>...</date> + <content-type>text/plain</content-type> + <body>comment A</body> + </comment> + <comment> + <uuid>uuid-commB</uuid> + <short-name>/012/uuid-commB</short-name> + <author></author> + <date>...</date> + <content-type>text/plain</content-type> + <body>comment B</body> + </comment> + </bug> + """ + for attr in other.explicit_attrs: + old = getattr(self, attr) + new = getattr(other, attr) + if old != new: + if accept_changes == True: + setattr(self, attr, new) + elif change_exception == True: + raise ValueError, \ + 'Merge would change %s "%s"->"%s" for bug %s' \ + % (attr, old, new, self.uuid) + for estr in other.extra_strings: + if not estr in self.extra_strings: + if accept_extra_strings == True: + self.extra_strings.append(estr) + elif change_exception == True: + raise ValueError, \ + 'Merge would add extra string "%s" for bug %s' \ + % (estr, self.uuid) + for o_comm in other.comments(): + try: + s_comm = self.comment_root.comment_from_uuid(o_comm.uuid) + except KeyError, e: + try: + s_comm = self.comment_root.comment_from_uuid(o_comm.alt_id) + except KeyError, e: + s_comm = None + if s_comm == None: + 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, \ + 'Merge would add comment %s (alt: %s) to bug %s' \ + % (o_comm.uuid, o_comm.alt_id, self.uuid) + else: + s_comm.merge(o_comm, accept_changes=accept_changes, + accept_extra_strings=accept_extra_strings, + change_exception=change_exception) + + # methods for saving/loading/acessing settings and properties. + + def load_settings(self, settings_mapfile=None): + if settings_mapfile == None: + settings_mapfile = \ + self.storage.get(self.id.storage('values'), default='\n') + try: + 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(settings) + + def save_settings(self): + mf = mapfile.generate(self._get_saved_settings()) + self.storage.set(self.id.storage('values'), mf) + + def save(self): + """ + 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). + """ + 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.save_comments(self) + + def load_comments(self, load_full=True): + if load_full == True: + # Force a complete load of the whole comment tree + self.comment_root = self._get_comment_root(load_full=True) + else: + # Setup for fresh lazy-loading. Clear _comment_root, so + # next _get_comment_root returns a fresh version. Turn of + # writing temporarily so we don't write our blank comment + # tree to disk. + w = self.storage.writeable + self.storage.writeable = False + self.comment_root = None + self.storage.writeable = w + + def remove(self): + 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 + + def new_comment(self, body=None): + comm = self.comment_root.new_reply(body=body) + return comm + + def comment_from_uuid(self, uuid, *args, **kwargs): + return self.comment_root.comment_from_uuid(uuid, *args, **kwargs) + + # 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 +# less than "less important" bugs. This way sorting a list of bugs +# will put the most important bugs first in the list. When relative +# importance is unclear, the sorting follows some arbitrary convention +# (i.e. dictionary order). + +def cmp_severity(bug_1, bug_2): + """ + Compare the severity levels of two bugs, with more severe bugs + comparing as less. + + >>> bugA = Bug() + >>> bugB = Bug() + >>> bugA.severity = bugB.severity = "wishlist" + >>> cmp_severity(bugA, bugB) == 0 + True + >>> bugB.severity = "minor" + >>> cmp_severity(bugA, bugB) > 0 + True + >>> bugA.severity = "critical" + >>> cmp_severity(bugA, bugB) < 0 + True + """ + if not hasattr(bug_2, "severity") : + return 1 + return -cmp(severity_index[bug_1.severity], severity_index[bug_2.severity]) + +def cmp_status(bug_1, bug_2): + """ + Compare the status levels of two bugs, with more "open" bugs + comparing as less. + + >>> bugA = Bug() + >>> bugB = Bug() + >>> bugA.status = bugB.status = "open" + >>> cmp_status(bugA, bugB) == 0 + True + >>> bugB.status = "closed" + >>> cmp_status(bugA, bugB) < 0 + True + >>> bugA.status = "fixed" + >>> cmp_status(bugA, bugB) > 0 + True + """ + if not hasattr(bug_2, "status") : + return 1 + val_2 = status_index[bug_2.status] + return cmp(status_index[bug_1.status], status_index[bug_2.status]) + +def cmp_attr(bug_1, bug_2, attr, invert=False): + """ + Compare a general attribute between two bugs using the + conventional comparison rule for that attribute type. If + ``invert==True``, sort *against* that convention. + + >>> attr="severity" + >>> bugA = Bug() + >>> bugB = Bug() + >>> bugA.severity = "critical" + >>> bugB.severity = "wishlist" + >>> cmp_attr(bugA, bugB, attr) < 0 + True + >>> cmp_attr(bugA, bugB, attr, invert=True) > 0 + True + >>> bugB.severity = "critical" + >>> cmp_attr(bugA, bugB, attr) == 0 + True + """ + if not hasattr(bug_2, attr) : + return 1 + val_1 = getattr(bug_1, attr) + val_2 = getattr(bug_2, attr) + if val_1 == None: val_1 = None + if val_2 == None: val_2 = None + + if invert == True : + return -cmp(val_1, val_2) + else : + return cmp(val_1, val_2) + +# alphabetical rankings (a < z) +cmp_uuid = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "uuid") +cmp_creator = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "creator") +cmp_assigned = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "assigned") +cmp_reporter = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "reporter") +cmp_summary = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "summary") +cmp_extra_strings = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "extra_strings") +# chronological rankings (newer < older) +cmp_time = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "time", invert=True) + +def cmp_comments(bug_1, bug_2): + """ + Compare two bugs' comments lists. Doesn't load any new comments, + so you should call each bug's .load_comments() first if you want a + full comparison. + """ + comms_1 = sorted(bug_1.comments(), key = lambda comm : comm.uuid) + comms_2 = sorted(bug_2.comments(), key = lambda comm : comm.uuid) + result = cmp(len(comms_1), len(comms_2)) + if result != 0: + return result + for c_1,c_2 in zip(comms_1, comms_2): + result = cmp(c_1, c_2) + if result != 0: + return result + return 0 + +DEFAULT_CMP_FULL_CMP_LIST = \ + (cmp_status, cmp_severity, cmp_assigned, cmp_time, cmp_creator, + cmp_reporter, cmp_comments, cmp_summary, cmp_uuid, cmp_extra_strings) + +class BugCompoundComparator (object): + def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST): + self.cmp_list = cmp_list + def __call__(self, bug_1, bug_2): + for comparison in self.cmp_list : + val = comparison(bug_1, bug_2) + if val != 0 : + return val + return 0 + +cmp_full = BugCompoundComparator() + + +# define some bonus cmp_* functions +def cmp_last_modified(bug_1, bug_2): + """ + Like cmp_time(), but use most recent comment instead of bug + creation for the timestamp. + """ + def last_modified(bug): + time = bug.time + for comment in bug.comment_root.traverse(): + if comment.time > time: + time = comment.time + return time + val_1 = last_modified(bug_1) + val_2 = last_modified(bug_2) + return -cmp(val_1, val_2) + + +if libbe.TESTING == True: + suite = doctest.DocTestSuite() diff --git a/libbe/bugdir.py b/libbe/bugdir.py new file mode 100644 index 0000000..65136fe --- /dev/null +++ b/libbe/bugdir.py @@ -0,0 +1,563 @@ +# Copyright (C) 2005-2010 Aaron Bentley and Panometrics, Inc. +# Alexander Belchenko <bialix@ukr.net> +# 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. + +"""Define the :class:`BugDir` class for storing a collection of bugs. +""" + +import copy +import errno +import os +import os.path +import time + +import libbe +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 doctest + import sys + import unittest + + import libbe.storage.base + + +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 BugDir (list, settings_object.SavedSettingsObject): + """A BugDir is a container for :class:`~libbe.bug.Bug`\s, with some + additional attributes. + + Parameters + ---------- + storage : :class:`~libbe.storage.base.Storage` + Storage instance containing the bug directory. If + `from_storage` is `False`, `storage` may be `None`. + uuid : str, optional + Set the bugdir UUID (see :mod:`libbe.util.id`). + Useful if you are loading one of several bugdirs + stored in a single Storage instance. + from_storage : bool, optional + If `True`, attempt to load from storage. Otherwise, + setup in memory, saving to `storage` if it is not `None`. + + See Also + -------- + :class:`SimpleBugDir` for some bugdir manipulation exampes. + """ + + settings_properties = [] + required_saved_properties = [] + _prop_save_settings = settings_object.prop_save_settings + _prop_load_settings = settings_object.prop_load_settings + def _versioned_property(settings_properties=settings_properties, + required_saved_properties=required_saved_properties, + **kwargs): + if "settings_properties" not in kwargs: + kwargs["settings_properties"] = settings_properties + if "required_saved_properties" not in kwargs: + kwargs["required_saved_properties"]=required_saved_properties + return settings_object.versioned_property(**kwargs) + + @_versioned_property(name="target", + doc="The current project development target.") + def target(): return {} + + def _setup_severities(self, severities): + if severities not in [None, settings_object.EMPTY]: + bug.load_severities(severities) + def _set_severities(self, old_severities, new_severities): + self._setup_severities(new_severities) + self._prop_save_settings(old_severities, new_severities) + @_versioned_property(name="severities", + doc="The allowed bug severities and their descriptions.", + change_hook=_set_severities) + def severities(): return {} + + def _setup_status(self, active_status, inactive_status): + bug.load_status(active_status, inactive_status) + def _set_active_status(self, old_active_status, new_active_status): + self._setup_status(new_active_status, self.inactive_status) + self._prop_save_settings(old_active_status, new_active_status) + @_versioned_property(name="active_status", + doc="The allowed active bug states and their descriptions.", + change_hook=_set_active_status) + def active_status(): return {} + + def _set_inactive_status(self, old_inactive_status, new_inactive_status): + self._setup_status(self.active_status, new_inactive_status) + self._prop_save_settings(old_inactive_status, new_inactive_status) + @_versioned_property(name="inactive_status", + doc="The allowed inactive bug states and their descriptions.", + change_hook=_set_inactive_status) + def inactive_status(): return {} + + def _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 _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 + + @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 __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: + if self.uuid == None: + self.uuid = libbe.util.id.uuid_gen() + if self.storage != None and self.storage.is_writeable(): + self.save() + + # methods for saving/loading/accessing settings and properties. + + 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.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(settings) + self._setup_severities(self.severities) + self._setup_status(self.active_status, self.inactive_status) + + def save_settings(self): + mf = mapfile.generate(self._get_saved_settings()) + self.storage.set(self.id.storage('settings'), mf) + + def load_all_bugs(self): + """ + Warning: this could take a while. + """ + self._clear_bugs() + for uuid in self.uuids(): + self._load_bug(uuid) + + def save(self): + """ + Save any loaded contents to storage. Because of lazy loading + of bugs and 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). + """ + 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() + + # methods for managing bugs + + def uuids(self, use_cached_disk_uuids=True): + if use_cached_disk_uuids==False or not hasattr(self, '_uuids_cache'): + self._uuids_cache = [] + # list bugs that are in storage + if self.storage != None and self.storage.is_readable(): + child_uuids = libbe.util.id.child_uuids( + self.storage.children(self.id.storage())) + for id in child_uuids: + self._uuids_cache.append(id) + return list(set([bug.uuid for bug in self] + self._uuids_cache)) + + def _clear_bugs(self): + while len(self) > 0: + self.pop() + if hasattr(self, '_uuids_cache'): + del(self._uuids_cache) + self._bug_map_gen() + + def _load_bug(self, uuid): + bg = bug.Bug(bugdir=self, uuid=uuid, from_storage=True) + self.append(bg) + self._bug_map_gen() + return bg + + 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() + if hasattr(self, '_uuids_cache') and not bg.uuid in self._uuids_cache: + self._uuids_cache.append(bg.uuid) + return bg + + def remove_bug(self, bug): + if hasattr(self, '_uuids_cache') and bug.uuid in self._uuids_cache: + self._uuids_cache.remove(bug.uuid) + self.remove(bug) + if self.storage != None and self.storage.is_writeable(): + bug.remove() + + def bug_from_uuid(self, uuid): + if not self.has_bug(uuid): + 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] + + def has_bug(self, bug_uuid): + if bug_uuid not in self._bug_map: + self._bug_map_gen() + if bug_uuid not in self._bug_map: + return False + return True + + # methods for id generation + + def sibling_uuids(self): + return [] + +class RevisionedBugDir (BugDir): + """ + RevisionedBugDirs are read-only copies used for generating + diffs between revisions. + """ + def __init__(self, bugdir, revision): + storage_version = bugdir.storage.storage_version(revision) + if storage_version != libbe.storage.STORAGE_VERSION: + raise libbe.storage.InvalidStorageVersion(storage_version) + s = copy.deepcopy(bugdir.storage) + s.writeable = False + class RevisionedStorage (object): + def __init__(self, storage, default_revision): + self.s = storage + self.sget = self.s.get + self.sancestors = self.s.ancestors + self.schildren = self.s.children + self.schanged = self.s.changed + 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 ancestors(self, *args, **kwargs): + print 'getting ancestors', args, kwargs + if not 'revision' in kwargs or kwargs['revision'] == None: + kwargs['revision'] = self.r + ret = self.sancestors(*args, **kwargs) + print 'got ancestors', ret + return ret + def children(self, *args, **kwargs): + if not 'revision' in kwargs or kwargs['revision'] == None: + kwargs['revision'] = self.r + return self.schildren(*args, **kwargs) + def changed(self, *args, **kwargs): + if not 'revision' in kwargs or kwargs['revision'] == None: + kwargs['revision'] = self.r + return self.schanged(*args, **kwargs) + rs = RevisionedStorage(s, revision) + s.get = rs.get + s.ancestors = rs.ancestors + s.children = rs.children + s.changed = rs.changed + BugDir.__init__(self, s, from_storage=True) + self.revision = revision + def changed(self): + return self.storage.changed() + + +if libbe.TESTING == True: + 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: + 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.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): + if self.storage != None: + self.storage.disconnect() + self.storage.destroy() + self.dir.cleanup() + def testOnDiskCleanLoad(self): + """ + SimpleBugDir(memory==False) should not import + preexisting bugs. + """ + 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.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() + uuids = sorted([bug.uuid for bug in bugdir]) + self.failUnless(uuids == ['a', 'b'], uuids) + bugdir.cleanup() + def testInMemoryCleanLoad(self): + """ + SimpleBugDir(memory==True) should not import + preexisting bugs. + """ + 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) + 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/command/__init__.py b/libbe/command/__init__.py new file mode 100644 index 0000000..0c8d4ff --- /dev/null +++ b/libbe/command/__init__.py @@ -0,0 +1,40 @@ +# Copyright (C) 2005-2010 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..6abf05e --- /dev/null +++ b/libbe/command/assign.py @@ -0,0 +1,98 @@ +# Copyright (C) 2005-2010 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): + u"""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..f8bbb1f --- /dev/null +++ b/libbe/command/base.py @@ -0,0 +1,554 @@ +# Copyright (C) 2009-2010 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 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 modname_to_command_name(modname): + """Little hack to replicate + >>> import sys + >>> def real_modname_to_command_name(modname): + ... mod = libbe.util.plugin.import_by_name( + ... 'libbe.command.%s' % modname) + ... attrs = [getattr(mod, name) for name in dir(mod)] + ... commands = [] + ... for attr_name in dir(mod): + ... attr = getattr(mod, attr_name) + ... try: + ... if issubclass(attr, Command): + ... commands.append(attr) + ... except TypeError, e: + ... pass + ... if len(commands) == 0: + ... raise Exception('No Command classes in %s' % dir(mod)) + ... return commands[0].name + >>> real_modname_to_command_name('new') + 'new' + >>> real_modname_to_command_name('import_xml') + 'import-xml' + """ + return modname.replace('_', '-') + +def commands(command_names=False): + for modname in libbe.util.plugin.modnames('libbe.command'): + if modname not in ['base', 'util']: + if command_names == False: + yield modname + else: + yield modname_to_command_name(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.getreader(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..0532ed2 --- /dev/null +++ b/libbe/command/close.py @@ -0,0 +1,60 @@ +# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc. +# Marien Zwart <marienz@gentoo.org> +# Thomas Gerigk <tgerigk@gmx.de> +# W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Close a bug""" +from libbe import cmdutil, bugdir +__desc__ = __doc__ + +def execute(args, manipulate_encodings=True): + """ + >>> from libbe import bugdir + >>> import os + >>> bd = bugdir.SimpleBugDir() + >>> os.chdir(bd.root) + >>> print bd.bug_from_shortname("a").status + open + >>> execute(["a"], manipulate_encodings=False) + >>> bd._clear_bugs() + >>> print bd.bug_from_shortname("a").status + closed + >>> bd.cleanup() + """ + parser = get_parser() + options, args = parser.parse_args(args) + cmdutil.default_complete(options, args, parser, + bugid_args={0: lambda bug : bug.active==True}) + if len(args) == 0: + raise cmdutil.UsageError("Please specify a bug id.") + if len(args) > 1: + raise cmdutil.UsageError("Too many arguments.") + bd = bugdir.BugDir(from_disk=True, + manipulate_encodings=manipulate_encodings) + bug = cmdutil.bug_from_shortname(bd, args[0]) + bug.status = "closed" + bd.save() + +def get_parser(): + parser = cmdutil.CmdOptionParser("be close BUG-ID") + return parser + +longhelp=""" +Close the bug identified by BUG-ID. +""" + +def help(): + return get_parser().help_str() + longhelp diff --git a/libbe/command/comment.py b/libbe/command/comment.py new file mode 100644 index 0000000..5bf6acf --- /dev/null +++ b/libbe/command/comment.py @@ -0,0 +1,169 @@ +# Copyright (C) 2005-2010 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 + >>> 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 = Comment(ui=ui) + + >>> uuid_gen = libbe.util.id.uuid_gen + >>> libbe.util.id.uuid_gen = lambda: 'X' + >>> ui._user_id = u'Fran\\xe7ois' + >>> ret = ui.run(cmd, args=['/a', 'This is a comment about a']) + Created comment with ID abc/a/X + >>> libbe.util.id.uuid_gen = uuid_gen + >>> 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'] + >>> if 'VISUAL' in os.environ: + ... del os.environ['VISUAL'] + >>> 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' > " + >>> libbe.util.id.uuid_gen = lambda: 'Y' + >>> ret = ui.run(cmd, args=['/b']) + Created comment with ID abc/b/Y + >>> libbe.util.id.uuid_gen = uuid_gen + >>> 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() + >>> del os.environ["EDITOR"] + """ + 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, content_type=params['content-type']) + for key in ['alt-id', 'author']: + if params[key] != None: + setattr(new, new._setting_name_to_attr_name(key), params[key]) + print >> self.stdout, 'Created comment with ID %s' % new.id.user() + 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..fd15630 --- /dev/null +++ b/libbe/command/commit.py @@ -0,0 +1,93 @@ +# Copyright (C) 2009-2010 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, args=['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..f87657b --- /dev/null +++ b/libbe/command/depend.py @@ -0,0 +1,408 @@ +# Copyright (C) 2009-2010 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..967ab14 --- /dev/null +++ b/libbe/command/diff.py @@ -0,0 +1,139 @@ +# Copyright (C) 2005-2010 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.bugdir +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 = libbe.bugdir.RevisionedBugDir(bugdir, params['revision']) + else: + old_storage = libbe.storage.get_storage(params['repo']) + old_storage.connect() + old_bd_current = libbe.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 = libbe.bugdir.RevisionedBugDir(old_bd_current,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..4463455 --- /dev/null +++ b/libbe/command/due.py @@ -0,0 +1,117 @@ +# Copyright (C) 2009-2010 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/help.py b/libbe/command/help.py new file mode 100644 index 0000000..1fc88f0 --- /dev/null +++ b/libbe/command/help.py @@ -0,0 +1,82 @@ +# Copyright (C) 2006-2010 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'): + print >> self.stdout, self.ui.help().rstrip('\n') + 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..c9f89f3 --- /dev/null +++ b/libbe/command/html.py @@ -0,0 +1,719 @@ +# Copyright (C) 2009-2010 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.comment +import libbe.util.encoding +import libbe.util.id + + +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', 'index.html')) + True + >>> os.path.exists(os.path.join(bd.storage.repo, 'html_export', 'bugs', 'b', 'index.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='min-id-length', short_name='l', + help='Attempt to truncate bug and comment IDs to this length. Set to -1 for non-truncated IDs (%default)', + arg=libbe.command.Argument( + name='min-id-length', metavar='INT', + default=-1, type='int')), + 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'], + min_id_length=params['min-id-length'], + 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", + min_id_length=-1, + 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() + self.min_id_length = min_id_length + + 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 _truncated_bug_id(self, bug): + return libbe.util.id._truncate( + bug.uuid, bug.sibling_uuids(), + min_length=self.min_id_length) + + def _truncated_comment_id(self, comment): + return libbe.util.id._truncate( + comment.uuid, comment.sibling_uuids(), + min_length=self.min_id_length) + + 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) + dirname = self._truncated_bug_id(bug) + fullpath = os.path.join(self.out_dir_bugs, dirname, 'index.html') + 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)) + fulldir = os.path.join(self.out_dir_bugs, dirname) + if not os.path.exists(fulldir): + os.mkdir(fulldir) + 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 = [] + bug.comment_root.sort(cmp=libbe.comment.cmp_time, reverse=True) + 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) + template_info = { + 'shortname': comment.id.user(), + 'truncated_id': self._truncated_comment_id(comment)} + if depth == 0: + comment_entries.append('<div class="comment root">') + else: + comment_entries.append( + '<div class="comment" id="%s">' + % template_info['truncated_id']) + for attr in ['uuid', 'author', 'date', 'body']: + value = getattr(comment, attr) + if attr == 'body': + link_long_ids = False + save_body = False + if comment.content_type == 'text/html': + link_long_ids = True + elif comment.content_type.startswith('text/'): + value = '<pre>\n'+self._escape(value)+'\n</pre>' + link_long_ids = True + elif comment.content_type.startswith('image/'): + save_body = True + value = '<img src="./%s/%s" />' \ + % (self._truncated_bug_id(bug), + self._truncated_comment_id(comment)) + else: + save_body = True + value = '<a href="./%s/%s">Link to %s file</a>.' \ + % (self._truncated_bug_id(bug), + self._truncated_comment_id(comment), + comment.content_type) + if link_long_ids == True: + value = self._long_to_linked_user(value) + 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(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 _long_to_linked_user(self, text): + """ + >>> import libbe.bugdir + >>> bd = libbe.bugdir.SimpleBugDir(memory=False) + >>> h = HTMLGen(bd) + >>> h._long_to_linked_user('A link #abc123/a#, and a non-link #x#y#.') + 'A link <a href="./a/">abc/a</a>, and a non-link #x#y#.' + >>> bd.cleanup() + """ + replacer = libbe.util.id.IDreplacer( + [self.bd], self._long_to_linked_user_replacer, wrap=False) + return re.sub( + libbe.util.id.REGEXP, replacer, text) + + def _long_to_linked_user_replacer(self, bugdirs, long_id): + """ + >>> import libbe.bugdir + >>> import libbe.util.id + >>> bd = libbe.bugdir.SimpleBugDir(memory=False) + >>> a = bd.bug_from_uuid('a') + >>> uuid_gen = libbe.util.id.uuid_gen + >>> libbe.util.id.uuid_gen = lambda : '0123' + >>> c = a.new_comment('comment for link testing') + >>> libbe.util.id.uuid_gen = uuid_gen + >>> c.uuid + '0123' + >>> h = HTMLGen(bd) + >>> h._long_to_linked_user_replacer([bd], 'abc123') + '#abc123#' + >>> h._long_to_linked_user_replacer([bd], 'abc123/a') + '<a href="./a/">abc/a</a>' + >>> h._long_to_linked_user_replacer([bd], 'abc123/a/0123') + '<a href="./a/#0123">abc/a/012</a>' + >>> h._long_to_linked_user_replacer([bd], 'x') + '#x#' + >>> h._long_to_linked_user_replacer([bd], '') + '##' + >>> bd.cleanup() + """ + try: + p = libbe.util.id.parse_user(bugdirs[0], long_id) + except (libbe.util.id.MultipleIDMatches, + libbe.util.id.NoIDMatches, + libbe.util.id.InvalidIDStructure), e: + return '#%s#' % long_id # re-wrap failures + if p['type'] == 'bugdir': + return '#%s#' % long_id + elif p['type'] == 'bug': + bug,comment = libbe.command.util.bug_comment_from_user_id( + bugdirs[0], long_id) + return '<a href="./%s/">%s</a>' \ + % (self._truncated_bug_id(bug), bug.id.user()) + elif p['type'] == 'comment': + bug,comment = libbe.command.util.bug_comment_from_user_id( + bugdirs[0], long_id) + return '<a href="./%s/#%s">%s</a>' \ + % (self._truncated_bug_id(bug), + self._truncated_comment_id(comment), + comment.id.user()) + raise Exception('Invalid id type %s for "%s"' + % (p['type'], long_id)) + + 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)) + template_info['dir'] = self._truncated_bug_id(bug) + bug_entries.append(self.index_bug_entry % template_info) + return '\n'.join(bug_entries) + + def _escape(self, string): + if string == None: + return '' + return xml.sax.saxutils.escape(string) + + 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/%(dir)s/">%(shortname)s</a></td> + <td><a href="bugs/%(dir)s/">%(status)s</a></td> + <td><a href="bugs/%(dir)s/">%(severity)s</a></td> + <td><a href="bugs/%(dir)s/">%(summary)s</a></td> + <td><a href="bugs/%(dir)s/">%(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/> + ID: %(uuid)s<br/> + Short name: %(shortname)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..a890669 --- /dev/null +++ b/libbe/command/import_xml.py @@ -0,0 +1,541 @@ +# Copyright (C) 2009-2010 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(bugdir.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 libbe.command.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 = [] + for c in croot_comment.traverse(): + comms.append(c.uuid) + if c.alt_id != None: + comms.append(c.alt_id) + if croot_comment.uuid == libbe.comment.INVALID_UUID: + root_text = croot_bug.id.user() + else: + root_text = croot_comment.id.user() + for new in root_comments: + assert new.uuid in comms or new.alt_id in comms, \ + "comment %s (alt: %s) wasn't added to %s" \ + % (new.uuid, new.alt_id, root_text) + 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> + """ + self.root_comment_xml = """ + <be-xml> + <comment> + <uuid>c1</uuid> + <body>So long</body> + </comment> + <comment> + <uuid>c3</uuid> + <author>Jed</author> + <body>And thanks</body> + </comment> + </be-xml> + """ + def tearDown(self): + self.bugdir.cleanup() + self.ui.cleanup() + def _execute(self, xml, params={}, args=[]): + self.ui.io.set_stdin(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): + bugB = self.bugdir.bug_from_uuid('b') + self._execute(self.xml, {}, ['-']) + 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) + self.failUnless(bugB.summary == 'a test bug', bugB.summary) + 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): + bugB = self.bugdir.bug_from_uuid('b') + initial_bugB_summary = bugB.summary + self._execute(self.xml, {'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) + self.failUnless(bugB.summary == initial_bugB_summary, bugB.summary) + 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) + def testRootCommentsNotAddOnly(self): + bugB = self.bugdir.bug_from_uuid('b') + initial_bugB_summary = bugB.summary + self._execute(self.root_comment_xml, {'comment-root':'/b'}, ['-']) + uuids = list(self.bugdir.uuids()) + 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) + self.failUnless(bugB.summary == initial_bugB_summary, bugB.summary) + 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, %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 testRootCommentsAddOnly(self): + bugB = self.bugdir.bug_from_uuid('b') + initial_bugB_summary = bugB.summary + self._execute(self.root_comment_xml, + {'comment-root':'/b', '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) + self.failUnless(bugB.summary == initial_bugB_summary, bugB.summary) + 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..7b83645 --- /dev/null +++ b/libbe/command/init.py @@ -0,0 +1,132 @@ +# Copyright (C) 2005-2010 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.connect() + >>> bugdir = libbe.bugdir.BugDir(vcs, from_storage=True) + >>> 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.connect() + >>> bugdir = libbe.bugdir.BugDir(vcs, from_storage=True) + >>> 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() + self.ui.storage_callbacks.set_storage(storage) + bd = libbe.bugdir.BugDir(storage, from_storage=False) + self.ui.storage_callbacks.set_bugdir(bd) + 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..3803257 --- /dev/null +++ b/libbe/command/list.py @@ -0,0 +1,279 @@ +# Copyright (C) 2005-2010 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.depend +import libbe.command.target +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='all', severity='all', assigned='all', + target='all', extra_strings_regexps=[]): + self.status = status + self.severity = severity + self.assigned = assigned + self.target = target + self.extra_strings_regexps = extra_strings_regexps + + def __call__(self, bugdir, 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 self.target == 'all': + pass + else: + target_bug = libbe.command.target.bug_target(bugdir, bug) + if self.target in ['none', None]: + if target_bug.summary != None: + return False + else: + if target_bug.summary != self.target: + 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='important', + help='List bugs with >= "serious" severity'), + libbe.command.Option(name='assigned', short_name='a', + help='Only show bugs matching ASSIGNED', + arg=libbe.command.Argument( + name='assigned', metavar='ASSIGNED', default=None, + completion_callback=libbe.command.util.complete_assigned)), + libbe.command.Option(name='mine', short_name='m', + help='List bugs assigned to you'), + 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='ids', short_name='i', + help='Only print the bug IDS'), + 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 ids and xml are special cases of long forms +# ("w", "wishlist", "List bugs with 'wishlist' severity"), +# ("A", "active", "List all active bugs"), +# ("U", "unconfirmed", "List unconfirmed bugs"), +# ("o", "open", "List open bugs"), +# ("T", "test", "List bugs in testing"), +# 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(bugdir, params) + filter = Filter(status, severity, assigned, + extra_strings_regexps=extra_strings_regexps) + bugs = [bugdir.bug_from_uuid(uuid) for uuid in bugdir.uuids()] + bugs = [b for b in bugs if filter(bugdir, 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['ids'] == True: + for bug in bugs: + print >> self.stdout, bug.id.user() + else: + self._list_bugs(bugs, xml=params['xml']) + bugdir.storage.writeable = writeable + return 0 + + def _parse_params(self, bugdir, 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'], libbe.bug.severity_values) + # select assigned + if params['assigned'] == None: + if params['mine'] == True: + assigned = [self._get_user_id()] + else: + assigned = 'all' + else: + assigned = libbe.command.util.select_values( + params['assigned'], libbe.command.util.assignees(bugdir)) + 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, '<be-xml>' + 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, '</be-xml>' + + def _long_help(self): + return """ +This command lists bugs. Normally it prints a short string like + bea/576:om: Allow attachments +Where + bea/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..2dff59c --- /dev/null +++ b/libbe/command/merge.py @@ -0,0 +1,189 @@ +# Copyright (C) 2008-2010 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..be18306 --- /dev/null +++ b/libbe/command/new.py @@ -0,0 +1,103 @@ +# Copyright (C) 2005-2010 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' + >>> ui._user_id = u'Fran\\xe7ois' + >>> 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.creator + u'Fran\\xe7ois' + >>> bug.reporter + u'Fran\\xe7ois' + >>> 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()) + bug.creator = self._get_user_id() + 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..0c6bf05 --- /dev/null +++ b/libbe/command/open.py @@ -0,0 +1,58 @@ +# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc. +# Marien Zwart <marienz@gentoo.org> +# Thomas Gerigk <tgerigk@gmx.de> +# W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Re-open a bug""" +from libbe import cmdutil, bugdir +__desc__ = __doc__ + +def execute(args, manipulate_encodings=True): + """ + >>> import os + >>> bd = bugdir.SimpleBugDir() + >>> os.chdir(bd.root) + >>> print bd.bug_from_shortname("b").status + closed + >>> execute(["b"], manipulate_encodings=False) + >>> bd._clear_bugs() + >>> print bd.bug_from_shortname("b").status + open + >>> bd.cleanup() + """ + parser = get_parser() + options, args = parser.parse_args(args) + cmdutil.default_complete(options, args, parser, + bugid_args={0: lambda bug : bug.active==False}) + if len(args) == 0: + raise cmdutil.UsageError, "Please specify a bug id." + if len(args) > 1: + raise cmdutil.UsageError, "Too many arguments." + bd = bugdir.BugDir(from_disk=True, + manipulate_encodings=manipulate_encodings) + bug = cmdutil.bug_from_shortname(bd, args[0]) + bug.status = "open" + +def get_parser(): + parser = cmdutil.CmdOptionParser("be open BUG-ID") + return parser + +longhelp=""" +Mark a bug as 'open'. +""" + +def help(): + return get_parser().help_str() + longhelp diff --git a/libbe/command/remove.py b/libbe/command/remove.py new file mode 100644 index 0000000..8d8e641 --- /dev/null +++ b/libbe/command/remove.py @@ -0,0 +1,79 @@ +# Copyright (C) 2008-2010 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/serve.py b/libbe/command/serve.py new file mode 100644 index 0000000..7237343 --- /dev/null +++ b/libbe/command/serve.py @@ -0,0 +1,1172 @@ +# Copyright (C) 2010 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 :class:`Serve` serving BE Storage over HTTP. + +See Also +-------- +:mod:`libbe.storage.http` : the associated client +""" + +import hashlib +import logging +import os.path +import posixpath +import re +import sys +import time +import traceback +import types +import urllib +import wsgiref.simple_server +try: + # Python >= 2.6 + from urlparse import parse_qs +except ImportError: + # Python <= 2.5 + from cgi import parse_qs +try: + import cherrypy + import cherrypy.wsgiserver +except ImportError: + cherrypy = None +if cherrypy != None: + try: # CherryPy >= 3.2 + import cherrypy.wsgiserver.ssl_builtin + except ImportError: # CherryPy <= 3.1.X + cherrypy.wsgiserver.ssl_builtin = None +try: + import OpenSSL +except ImportError: + OpenSSL = None + +import libbe +import libbe.command +import libbe.command.util +import libbe.util.encoding +import libbe.version + +if libbe.TESTING == True: + import copy + import doctest + import StringIO + import unittest + import wsgiref.validate + try: + import cherrypy.test.webtest + cherrypy_test_webtest = True + except ImportError: + cherrypy_test_webtest = None + + import libbe.bugdir + +class _HandlerError (Exception): + def __init__(self, code, msg, headers=[]): + Exception.__init__(self, '%d %s' % (code, msg)) + self.code = code + self.msg = msg + self.headers = headers + +class _Unauthenticated (_HandlerError): + def __init__(self, realm, msg='User Not Authenticated', headers=[]): + _HandlerError.__init__(self, 401, msg, headers+[ + ('WWW-Authenticate','Basic realm="%s"' % realm)]) + +class _Unauthorized (_HandlerError): + def __init__(self, msg='User Not Authorized', headers=[]): + _HandlerError.__init__(self, 403, msg, headers) + +class User (object): + def __init__(self, uname=None, name=None, passhash=None, password=None): + self.uname = uname + self.name = name + self.passhash = passhash + if passhash == None: + if password != None: + self.passhash = self.hash(password) + else: + assert password == None, \ + 'Redundant password %s with passhash %s' % (password, passhash) + self.users = None + def from_string(self, string): + string = string.strip() + fields = string.split(':') + if len(fields) != 3: + raise ValueError, '%d!=3 fields in "%s"' % (len(fields), string) + self.uname,self.name,self.passhash = fields + def __str__(self): + return ':'.join([self.uname, self.name, self.passhash]) + def __cmp__(self, other): + return cmp(self.uname, other.uname) + def hash(self, password): + return hashlib.sha1(password).hexdigest() + def valid_login(self, password): + if self.hash(password) == self.passhash: + return True + return False + def set_name(self, name): + self._set_property('name', name) + def set_password(self, password): + self._set_property('passhash', self.hash(password)) + def _set_property(self, property, value): + if self.uname == 'guest': + raise _Unauthorized('guest user not allowed to change %s' % property) + if getattr(self, property) != value \ + and self.users != None: + self.users.changed = True + setattr(self, property, value) + +class Users (dict): + def __init__(self, filename=None): + dict.__init__(self) + self.filename = filename + self.changed = False + def load(self): + if self.filename == None: + return + user_file = libbe.util.encoding.get_file_contents( + self.filename, decode=True) + self.clear() + for line in user_file.splitlines(): + user = User() + user.from_string(line) + self.add_user(user) + def save(self): + if self.filename != None and self.changed == True: + lines = [] + for user in sorted(self.users): + lines.append(str(user)) + libbe.util.encoding.set_file_contents(self.filename) + self.changed = False + def add_user(self, user): + assert user.users == None, user.users + user.users = self + self[user.uname] = user + def valid_login(self, uname, password): + if uname in self and \ + self[uname].valid_login(password) == True: + return True + return False + +class WSGI_Object (object): + """Utility class for WGSI clients and middleware. + + For details on WGSI, see `PEP 333`_ + + .. _PEP 333: http://www.python.org/dev/peps/pep-0333/ + """ + def __init__(self, logger=None, log_level=logging.INFO, log_format=None): + self.logger = logger + self.log_level = log_level + if log_format == None: + self.log_format = ( + '%(REMOTE_ADDR)s - %(REMOTE_USER)s [%(time)s] ' + '"%(REQUEST_METHOD)s %(REQUEST_URI)s %(HTTP_VERSION)s" ' + '%(status)s %(bytes)s "%(HTTP_REFERER)s" "%(HTTP_USER_AGENT)s"') + else: + self.log_format = log_format + + def __call__(self, environ, start_response): + """The main WSGI entry point.""" + raise NotImplementedError + # start_response() is a callback for setting response headers + # start_response(status, response_headers, exc_info=None) + # status is an HTTP status string (e.g., "200 OK"). + # response_headers is a list of 2-tuples, the HTTP headers in + # key-value format. + # exc_info is used in exception handling. + # + # The application function then returns an iterable of body chunks. + + def error(self, environ, start_response, error, message, headers=[]): + """Make it easy to call start_response for errors.""" + response = '%d %s' % (error, message) + self.log_request(environ, status=response, bytes=len(message)) + start_response(response, + [('Content-Type', 'text/plain')]+headers) + return [message] + + def log_request(self, environ, status='-1 OK', bytes=-1): + if self.logger == None: + return + req_uri = urllib.quote(environ.get('SCRIPT_NAME', '') + + environ.get('PATH_INFO', '')) + if environ.get('QUERY_STRING'): + req_uri += '?'+environ['QUERY_STRING'] + start = time.localtime() + if time.daylight: + offset = time.altzone / 60 / 60 * -100 + else: + offset = time.timezone / 60 / 60 * -100 + if offset >= 0: + offset = "+%0.4d" % (offset) + elif offset < 0: + offset = "%0.4d" % (offset) + d = { + 'REMOTE_ADDR': environ.get('REMOTE_ADDR') or '-', + 'REMOTE_USER': environ.get('REMOTE_USER') or '-', + 'REQUEST_METHOD': environ['REQUEST_METHOD'], + 'REQUEST_URI': req_uri, + 'HTTP_VERSION': environ.get('SERVER_PROTOCOL'), + 'time': time.strftime('%d/%b/%Y:%H:%M:%S ', start) + offset, + 'status': status.split(None, 1)[0], + 'bytes': bytes, + 'HTTP_REFERER': environ.get('HTTP_REFERER', '-'), + 'HTTP_USER_AGENT': environ.get('HTTP_USER_AGENT', '-'), + } + self.logger.log(self.log_level, self.log_format % d) + +class ExceptionApp (WSGI_Object): + """Some servers (e.g. cherrypy) eat app-raised exceptions. + + Work around that by logging tracebacks by hand. + """ + def __init__(self, app, *args, **kwargs): + WSGI_Object.__init__(self, *args, **kwargs) + self.app = app + + def __call__(self, environ, start_response): + if self.logger != None: + self.logger.log(logging.DEBUG, 'ExceptionApp') + try: + return self.app(environ, start_response) + except Exception, e: + etype,value,tb = sys.exc_info() + trace = ''.join( + traceback.format_exception(etype, value, tb, None)) + self.logger.log(self.log_level, trace) + raise + +class UppercaseHeaderApp (WSGI_Object): + """WSGI middleware that uppercases incoming HTTP headers. + + From PEP 333, `The start_response() Callable`_ : + + A reminder for server/gateway authors: HTTP + header names are case-insensitive, so be sure + to take that into consideration when examining + application-supplied headers! + + .. _The start_response() Callable: + http://www.python.org/dev/peps/pep-0333/#id20 + """ + def __init__(self, app, *args, **kwargs): + WSGI_Object.__init__(self, *args, **kwargs) + self.app = app + + def __call__(self, environ, start_response): + if self.logger != None: + self.logger.log(logging.DEBUG, 'UppercaseHeaderApp') + for key,value in environ.items(): + if key.startswith('HTTP_'): + uppercase = key.upper() + if uppercase != key: + environ[uppercase] = environ.pop(key) + return self.app(environ, start_response) + +class AuthenticationApp (WSGI_Object): + """WSGI middleware for handling user authentication. + """ + def __init__(self, app, realm, setting='be-auth', users=None, *args, **kwargs): + WSGI_Object.__init__(self, *args, **kwargs) + self.app = app + self.realm = realm + self.setting = setting + self.users = users + + def __call__(self, environ, start_response): + if self.logger != None: + self.logger.log(logging.DEBUG, 'AuthenticationApp') + environ['%s.realm' % self.setting] = self.realm + try: + username = self.authenticate(environ) + environ['%s.user' % self.setting] = username + environ['%s.user.name' % self.setting] = \ + self.users[username].name + return self.app(environ, start_response) + except _Unauthorized, e: + return self.error(environ, start_response, + e.code, e.msg, e.headers) + + def authenticate(self, environ): + """Handle user-authentication sent in the "Authorization" header. + + This function implements ``Basic`` authentication as described in + HTTP/1.0 specification [1]_ . Do not use this module unless you + are using SSL, as it transmits unencrypted passwords. + + .. [1] http://www.w3.org/Protocols/HTTP/1.0/draft-ietf-http-spec.html#BasicAA + + Examples + -------- + + >>> users = Users() + >>> users.add_user(User('Aladdin', 'Big Al', password='open sesame')) + >>> app = AuthenticationApp(app=None, realm='Dummy Realm', users=users) + >>> app.authenticate({'HTTP_AUTHORIZATION':'Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ=='}) + 'Aladdin' + >>> app.authenticate({'HTTP_AUTHORIZATION':'Basic AAAAAAAAAAAAAAAAAAAAAAAAAA=='}) + + Notes + ----- + + Code based on authkit/authenticate/basic.py + (c) 2005 Clark C. Evans. + Released under the MIT License: + http://www.opensource.org/licenses/mit-license.php + """ + authorization = environ.get('HTTP_AUTHORIZATION', None) + if authorization == None: + raise _Unauthorized('Authorization required') + try: + authmeth,auth = authorization.split(' ',1) + except ValueError: + return None + if 'basic' != authmeth.lower(): + return None # non-basic HTTP authorization not implemented + auth = auth.strip().decode('base64') + try: + username,password = auth.split(':',1) + except ValueError: + return None + if self.authfunc(environ, username, password) == True: + return username + + def authfunc(self, environ, username, password): + if not username in self.users: + return False + if self.users[username].valid_login(password) == True: + if self.logger != None: + self.logger.log(self.log_level, + 'Authenticated %s' % self.users[username].name) + return True + return False + +class WSGI_AppObject (WSGI_Object): + """Useful WSGI utilities for handling data (POST, QUERY) and + returning responses. + """ + def __init__(self, *args, **kwargs): + WSGI_Object.__init__(self, *args, **kwargs) + + # Maximum input we will accept when REQUEST_METHOD is POST + # 0 ==> unlimited input + self.maxlen = 0 + + def ok_response(self, environ, start_response, content, + content_type='application/octet-stream', + headers=[]): + if content == None: + start_response('200 OK', []) + return [] + if type(content) == types.UnicodeType: + content = content.encode('utf-8') + for i,header in enumerate(headers): + header_name,header_value = header + if type(header_value) == types.UnicodeType: + headers[i] = (header_name, header_value.encode('ISO-8859-1')) + response = '200 OK' + content_length = len(content) + self.log_request(environ, status=response, bytes=content_length) + start_response('200 OK', [ + ('Content-Type', content_type), + ('Content-Length', str(content_length)), + ]+headers) + if self.is_head(environ) == True: + return [] + return [content] + + def query_data(self, environ): + if not environ['REQUEST_METHOD'] in ['GET', 'HEAD']: + raise _HandlerError(404, 'Not Found') + return self._parse_query(environ.get('QUERY_STRING', '')) + + def _parse_query(self, query): + if len(query) == 0: + return {} + data = parse_qs( + query, keep_blank_values=True, strict_parsing=True) + for k,v in data.items(): + if len(v) == 1: + data[k] = v[0] + return data + + def post_data(self, environ): + if environ['REQUEST_METHOD'] != 'POST': + raise _HandlerError(404, 'Not Found') + post_data = self._read_post_data(environ) + return self._parse_post(post_data) + + def _parse_post(self, post): + return self._parse_query(post) + + def _read_post_data(self, environ): + try: + clen = int(environ.get('CONTENT_LENGTH', '0')) + except ValueError: + clen = 0 + if clen != 0: + if self.maxlen > 0 and clen > self.maxlen: + raise ValueError, 'Maximum content length exceeded' + return environ['wsgi.input'].read(clen) + return '' + + def data_get_string(self, data, key, default=None, source='query'): + if not key in data or data[key] in [None, 'None']: + if default == _HandlerError: + raise _HandlerError(406, 'Missing %s key %s' % (source, key)) + return default + return data[key] + + def data_get_id(self, data, key='id', default=_HandlerError, + source='query'): + return self.data_get_string(data, key, default, source) + + def data_get_boolean(self, data, key, default=False, source='query'): + val = self.data_get_string(data, key, default, source) + if val == 'True': + return True + elif val == 'False': + return False + return val + + def is_head(self, environ): + return environ['REQUEST_METHOD'] == 'HEAD' + + +class AdminApp (WSGI_AppObject): + """WSGI middleware for managing users (changing passwords, + usernames, etc.). + """ + def __init__(self, app, users=None, url=r'^admin/?', *args, **kwargs): + WSGI_AppObject.__init__(self, *args, **kwargs) + self.app = app + self.users = users + self.url = url + + def __call__(self, environ, start_response): + if self.logger != None: + self.logger.log(logging.DEBUG, 'AdminApp') + path = environ.get('PATH_INFO', '').lstrip('/') + match = re.search(self.url, path) + if match is not None: + return self.admin(environ, start_response) + return self.app(environ, start_response) + + def admin(self, environ, start_response): + if not 'be-auth.user' in environ: + raise _Unauthenticated(realm=envirion.get('be-auth.realm')) + uname = environ.get('be-auth.user') + user = self.users[uname] + data = self.post_data(environ) + source = 'post' + name = self.data_get_string( + data, 'name', default=None, source=source) + if name != None: + self.users[uname].set_name(name) + password = self.data_get_string( + data, 'password', default=None, source=source) + if password != None: + self.users[uname].set_password(password) + self.users.save() + return self.ok_response(environ, start_response, None) + +class ServerApp (WSGI_AppObject): + """WSGI server for a BE Storage instance over HTTP. + + RESTful_ WSGI request handler for serving the + libbe.storage.http.HTTP backend with GET, POST, and HEAD commands. + For more information on authentication and REST, see John + Calcote's `Open Sourcery article`_ + + .. _RESTful: http://www.ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm + .. _Open Sourcery article: http://jcalcote.wordpress.com/2009/08/10/restful-authentication/ + + This serves files from a connected storage instance, usually + a VCS-based repository located on the local machine. + + Notes + ----- + + The GET and HEAD requests are identical except that the HEAD + request omits the actual content of the file. + """ + server_version = "BE-server/" + libbe.version.version() + + def __init__(self, storage, *args, **kwargs): + WSGI_AppObject.__init__(self, *args, **kwargs) + self.storage = storage + self.http_user_error = 418 + + self.urls = [ + (r'^add/?', self.add), + (r'^exists/?', self.exists), + (r'^remove/?', self.remove), + (r'^ancestors/?', self.ancestors), + (r'^children/?', self.children), + (r'^get/(.+)', self.get), + (r'^set/(.+)', self.set), + (r'^commit/?', self.commit), + (r'^revision-id/?', self.revision_id), + (r'^changed/?', self.changed), + (r'^version/?', self.version), + ] + + def __call__(self, environ, start_response): + """The main WSGI application. + + Dispatch the current request to the functions from above and + store the regular expression captures in the WSGI environment + as `be-server.url_args` so that the functions from above can + access the url placeholders. + + URL dispatcher from Armin Ronacher's "Getting Started with WSGI" + http://lucumr.pocoo.org/2007/5/21/getting-started-with-wsgi + """ + if self.logger != None: + self.logger.log(logging.DEBUG, 'ServerApp') + path = environ.get('PATH_INFO', '').lstrip('/') + try: + for regex, callback in self.urls: + match = re.search(regex, path) + if match is not None: + environ['be-server.url_args'] = match.groups() + try: + return callback(environ, start_response) + except libbe.storage.NotReadable, e: + raise _HandlerError(403, 'Read permission denied') + except libbe.storage.NotWriteable, e: + raise _HandlerError(403, 'Write permission denied') + except libbe.storage.InvalidID, e: + raise _HandlerError( + self.http_user_error, 'InvalidID %s' % e) + raise _HandlerError(404, 'Not Found') + except _HandlerError, e: + return self.error(environ, start_response, + e.code, e.msg, e.headers) + + # handlers + def add(self, environ, start_response): + self.check_login(environ) + data = self.post_data(environ) + source = 'post' + id = self.data_get_id(data, source=source) + parent = self.data_get_string( + data, 'parent', default=None, source=source) + directory = self.data_get_boolean( + data, 'directory', default=False, source=source) + self.storage.add(id, parent=parent, directory=directory) + return self.ok_response(environ, start_response, None) + + def exists(self, environ, start_response): + self.check_login(environ) + data = self.query_data(environ) + source = 'query' + id = self.data_get_id(data, source=source) + revision = self.data_get_string( + data, 'revision', default=None, source=source) + content = str(self.storage.exists(id, revision)) + return self.ok_response(environ, start_response, content) + + def remove(self, environ, start_response): + self.check_login(environ) + data = self.post_data(environ) + source = 'post' + id = self.data_get_id(data, source=source) + recursive = self.data_get_boolean( + data, 'recursive', default=False, source=source) + if recursive == True: + self.storage.recursive_remove(id) + else: + self.storage.remove(id) + return self.ok_response(environ, start_response, None) + + def ancestors(self, environ, start_response): + self.check_login(environ) + data = self.query_data(environ) + source = 'query' + id = self.data_get_id(data, source=source) + revision = self.data_get_string( + data, 'revision', default=None, source=source) + content = '\n'.join(self.storage.ancestors(id, revision))+'\n' + return self.ok_response(environ, start_response, content) + + def children(self, environ, start_response): + self.check_login(environ) + data = self.query_data(environ) + source = 'query' + id = self.data_get_id(data, default=None, source=source) + revision = self.data_get_string( + data, 'revision', default=None, source=source) + content = '\n'.join(self.storage.children(id, revision)) + return self.ok_response(environ, start_response, content) + + def get(self, environ, start_response): + self.check_login(environ) + data = self.query_data(environ) + source = 'query' + try: + id = environ['be-server.url_args'][0] + except: + raise _HandlerError(404, 'Not Found') + revision = self.data_get_string( + data, 'revision', default=None, source=source) + content = self.storage.get(id, revision=revision) + be_version = self.storage.storage_version(revision) + return self.ok_response(environ, start_response, content, + headers=[('X-BE-Version', be_version)]) + + def set(self, environ, start_response): + self.check_login(environ) + data = self.post_data(environ) + try: + id = environ['be-server.url_args'][0] + except: + raise _HandlerError(404, 'Not Found') + if not 'value' in data: + raise _HandlerError(406, 'Missing query key value') + value = data['value'] + self.storage.set(id, value) + return self.ok_response(environ, start_response, None) + + def commit(self, environ, start_response): + self.check_login(environ) + data = self.post_data(environ) + if not 'summary' in data: + raise _HandlerError(406, 'Missing query key summary') + summary = data['summary'] + if not 'body' in data or data['body'] == 'None': + data['body'] = None + body = data['body'] + if not 'allow_empty' in data \ + or data['allow_empty'] == 'True': + allow_empty = True + else: + allow_empty = False + try: + revision = self.storage.commit(summary, body, allow_empty) + except libbe.storage.EmptyCommit, e: + raise _HandlerError(self.http_user_error, 'EmptyCommit') + return self.ok_response(environ, start_response, revision) + + def revision_id(self, environ, start_response): + self.check_login(environ) + data = self.query_data(environ) + source = 'query' + index = int(self.data_get_string( + data, 'index', default=_HandlerError, source=source)) + content = self.storage.revision_id(index) + return self.ok_response(environ, start_response, content) + + def changed(self, environ, start_response): + self.check_login(environ) + data = self.query_data(environ) + source = 'query' + revision = self.data_get_string( + data, 'revision', default=None, source=source) + add,mod,rem = self.storage.changed(revision) + content = '\n\n'.join(['\n'.join(p) for p in (add,mod,rem)]) + return self.ok_response(environ, start_response, content) + + def version(self, environ, start_response): + self.check_login(environ) + data = self.query_data(environ) + source = 'query' + revision = self.data_get_string( + data, 'revision', default=None, source=source) + content = self.storage.storage_version(revision) + return self.ok_response(environ, start_response, content) + + # handler utility functions + def check_login(self, environ): + user = environ.get('be-auth.user', None) + if user != None: # we're running under AuthenticationApp + if environ['REQUEST_METHOD'] == 'POST': + if user == 'guest' or self.storage.is_writeable() == False: + raise _Unauthorized() # only non-guests allowed to write + # allow read-only commands for all users + + +class Serve (libbe.command.Command): + """:class:`~libbe.command.base.Command` wrapper around + :class:`ServerApp`. + """ + + name = 'serve' + + def __init__(self, *args, **kwargs): + libbe.command.Command.__init__(self, *args, **kwargs) + self.options.extend([ + libbe.command.Option(name='port', + help='Bind server to port (%default)', + arg=libbe.command.Argument( + name='port', metavar='INT', type='int', default=8000)), + libbe.command.Option(name='host', + help='Set host string (blank for localhost, %default)', + arg=libbe.command.Argument( + name='host', metavar='HOST', default='')), + libbe.command.Option(name='read-only', short_name='r', + help='Dissable operations that require writing'), + libbe.command.Option(name='ssl', short_name='s', + help='Use CherryPy to serve HTTPS (HTTP over SSL/TLS)'), + libbe.command.Option(name='auth', short_name='a', + help='Require authentication. FILE should be a file containing colon-separated UNAME:USER:sha1(PASSWORD) lines, for example: "jdoe:John Doe <jdoe@example.com>:read:d99f8e5a4b02dc25f49da2ea67c0034f61779e72"', + arg=libbe.command.Argument( + name='auth', metavar='FILE', default=None, + completion_callback=libbe.command.util.complete_path)), + ]) + + def _run(self, **params): + self._setup_logging() + storage = self._get_storage() + if params['read-only'] == True: + writeable = storage.writeable + storage.writeable = False + if params['host'] == '': + params['host'] = 'localhost' + if params['auth'] != None: + self._check_restricted_access(storage, params['auth']) + users = Users(params['auth']) + users.load() + app = ServerApp(storage=storage, logger=self.logger) + if params['auth'] != None: + app = AdminApp(app, users=users, logger=self.logger) + app = AuthenticationApp(app, realm=storage.repo, + users=users, logger=self.logger) + app = UppercaseHeaderApp(app, logger=self.logger) + server,details = self._get_server(params, app) + details['repo'] = storage.repo + try: + self._start_server(params, server, details) + except KeyboardInterrupt: + pass + self._stop_server(params, server) + if params['read-only'] == True: + storage.writeable = writeable + + def _setup_logging(self, log_level=logging.INFO): + self.logger = logging.getLogger('be-serve') + self.log_level = logging.INFO + console = logging.StreamHandler(self.stdout) + console.setFormatter(logging.Formatter('%(message)s')) + self.logger.addHandler(console) + self.logger.propagate = False + if log_level is not None: + console.setLevel(log_level) + self.logger.setLevel(log_level) + + def _get_server(self, params, app): + details = {'port':params['port']} + if params['ssl'] == True: + details['protocol'] = 'HTTPS' + if cherrypy == None: + raise libbe.command.UserError, \ + '--ssl requires the cherrypy module' + app = ExceptionApp(app, logger=self.logger) + server = cherrypy.wsgiserver.CherryPyWSGIServer( + (params['host'], params['port']), app) + #server.throw_errors = True + #server.show_tracebacks = True + private_key,certificate = get_cert_filenames( + 'be-server', logger=self.logger) + if cherrypy.wsgiserver.ssl_builtin == None: + server.ssl_module = 'builtin' + server.ssl_private_key = private_key + server.ssl_certificate = certificate + else: + server.ssl_adapter = \ + cherrypy.wsgiserver.ssl_builtin.BuiltinSSLAdapter( + certificate=certificate, private_key=private_key) + details['socket-name'] = params['host'] + else: + details['protocol'] = 'HTTP' + server = wsgiref.simple_server.make_server( + params['host'], params['port'], app) + details['socket-name'] = server.socket.getsockname()[0] + return (server, details) + + def _start_server(self, params, server, details): + self.logger.log(self.log_level, + 'Serving %(protocol)s on %(socket-name)s port %(port)s ...' \ + % details) + self.logger.log(self.log_level, + 'BE repository %(repo)s' % details) + if params['ssl'] == True: + server.start() + else: + server.serve_forever() + + def _stop_server(self, params, server): + self.logger.log(self.log_level, 'Clossing server') + if params['ssl'] == True: + server.stop() + else: + server.server_close() + + def _long_help(self): + return """ +Example usage:: + + $ be serve + +And in another terminal (or after backgrounding the server):: + + $ be --repo http://localhost:8000/ list + +If you bind your server to a public interface, take a look at the +``--read-only`` option or the combined ``--ssl --auth FILE`` +options so other people can't mess with your repository. If you do use +authentication, you'll need to send in your username and password with, +for example:: + + $ be --repo http://username:password@localhost:8000/ list +""" + +def random_string(length=256): + if os.path.exists(os.path.join('dev', 'urandom')): + return open("/dev/urandom").read(length) + else: + import array + from random import randint + d = array.array('B') + for i in xrange(1000000): + d.append(randint(0,255)) + return d.tostring() + +if libbe.TESTING == True: + class WSGITestCase (unittest.TestCase): + def setUp(self): + self.logstream = StringIO.StringIO() + self.logger = logging.getLogger('be-serve-test') + console = logging.StreamHandler(self.logstream) + console.setFormatter(logging.Formatter('%(message)s')) + self.logger.addHandler(console) + self.logger.propagate = False + console.setLevel(logging.INFO) + self.logger.setLevel(logging.INFO) + self.default_environ = { # required by PEP 333 + 'REQUEST_METHOD': 'GET', # 'POST', 'HEAD' + 'SCRIPT_NAME':'', + 'PATH_INFO': '', + #'QUERY_STRING':'', # may be empty or absent + #'CONTENT_TYPE':'', # may be empty or absent + #'CONTENT_LENGTH':'', # may be empty or absent + 'SERVER_NAME':'example.com', + 'SERVER_PORT':'80', + 'SERVER_PROTOCOL':'HTTP/1.1', + 'wsgi.version':(1,0), + 'wsgi.url_scheme':'http', + 'wsgi.input':StringIO.StringIO(), + 'wsgi.errors':StringIO.StringIO(), + 'wsgi.multithread':False, + 'wsgi.multiprocess':False, + 'wsgi.run_once':False, + } + def getURL(self, app, path='/', method='GET', data=None, + scheme='http', environ={}): + env = copy.copy(self.default_environ) + env['PATH_INFO'] = path + env['REQUEST_METHOD'] = method + env['scheme'] = scheme + if data != None: + enc_data = urllib.urlencode(data) + if method == 'POST': + env['CONTENT_LENGTH'] = len(enc_data) + env['wsgi.input'] = StringIO.StringIO(enc_data) + else: + assert method in ['GET', 'HEAD'], method + env['QUERY_STRING'] = enc_data + for key,value in environ.items(): + env[key] = value + return ''.join(app(env, self.start_response)) + def start_response(self, status, response_headers, exc_info=None): + self.status = status + self.response_headers = response_headers + self.exc_info = exc_info + + class WSGI_ObjectTestCase (WSGITestCase): + def setUp(self): + WSGITestCase.setUp(self) + self.app = WSGI_Object(self.logger) + def test_error(self): + contents = self.app.error( + environ=self.default_environ, + start_response=self.start_response, + error=123, + message='Dummy Error', + headers=[('X-Dummy-Header','Dummy Value')]) + self.failUnless(contents == ['Dummy Error'], contents) + self.failUnless(self.status == '123 Dummy Error', self.status) + self.failUnless(self.response_headers == [ + ('Content-Type','text/plain'), + ('X-Dummy-Header','Dummy Value')], + self.response_headers) + self.failUnless(self.exc_info == None, self.exc_info) + def test_log_request(self): + self.app.log_request( + environ=self.default_environ, status='-1 OK', bytes=123) + log = self.logstream.getvalue() + self.failUnless(log.startswith('- -'), log) + + class ExceptionAppTestCase (WSGITestCase): + def setUp(self): + WSGITestCase.setUp(self) + def child_app(environ, start_response): + raise ValueError('Dummy Error') + self.app = ExceptionApp(child_app, self.logger) + def test_traceback(self): + try: + self.getURL(self.app) + except ValueError, e: + pass + log = self.logstream.getvalue() + self.failUnless(log.startswith('Traceback'), log) + self.failUnless('child_app' in log, log) + self.failUnless('ValueError: Dummy Error' in log, log) + + class AdminAppTestCase (WSGITestCase): + def setUp(self): + WSGITestCase.setUp(self) + self.users = Users() + self.users.add_user( + User('Aladdin', 'Big Al', password='open sesame')) + self.users.add_user( + User('guest', 'Guest', password='guestpass')) + def child_app(environ, start_response): + pass + self.app = AdminApp( + child_app, users=self.users, logger=self.logger) + self.app = AuthenticationApp( + self.app, realm='Dummy Realm', users=self.users, + logger=self.logger) + self.app = UppercaseHeaderApp(self.app, logger=self.logger) + def basic_auth(self, uname, password): + """HTTP basic authorization string""" + return 'Basic %s' % \ + ('%s:%s' % (uname, password)).encode('base64') + def test_new_name(self): + self.getURL( + self.app, '/admin/', method='POST', + data={'name':'Prince Al'}, + environ={'HTTP_Authorization': + self.basic_auth('Aladdin', 'open sesame')}) + self.failUnless(self.status == '200 OK', self.status) + self.failUnless(self.response_headers == [], + self.response_headers) + self.failUnless(self.exc_info == None, self.exc_info) + self.failUnless(self.users['Aladdin'].name == 'Prince Al', + self.users['Aladdin'].name) + self.failUnless(self.users.changed == True, + self.users.changed) + def test_new_password(self): + self.getURL( + self.app, '/admin/', method='POST', + data={'password':'New Pass'}, + environ={'HTTP_Authorization': + self.basic_auth('Aladdin', 'open sesame')}) + self.failUnless(self.status == '200 OK', self.status) + self.failUnless(self.response_headers == [], + self.response_headers) + self.failUnless(self.exc_info == None, self.exc_info) + self.failUnless(self.users['Aladdin'].passhash == \ + self.users['Aladdin'].hash('New Pass'), + self.users['Aladdin'].passhash) + self.failUnless(self.users.changed == True, + self.users.changed) + def test_guest_name(self): + self.getURL( + self.app, '/admin/', method='POST', + data={'name':'SPAM'}, + environ={'HTTP_Authorization': + self.basic_auth('guest', 'guestpass')}) + self.failUnless(self.status.startswith('403 '), self.status) + self.failUnless(self.response_headers == [ + ('Content-Type', 'text/plain')], + self.response_headers) + self.failUnless(self.exc_info == None, self.exc_info) + self.failUnless(self.users['guest'].name == 'Guest', + self.users['guest'].name) + self.failUnless(self.users.changed == False, + self.users.changed) + def test_guest_password(self): + self.getURL( + self.app, '/admin/', method='POST', + data={'password':'SPAM'}, + environ={'HTTP_Authorization': + self.basic_auth('guest', 'guestpass')}) + self.failUnless(self.status.startswith('403 '), self.status) + self.failUnless(self.response_headers == [ + ('Content-Type', 'text/plain')], + self.response_headers) + self.failUnless(self.exc_info == None, self.exc_info) + self.failUnless(self.users['guest'].name == 'Guest', + self.users['guest'].name) + self.failUnless(self.users.changed == False, + self.users.changed) + + class ServerAppTestCase (WSGITestCase): + def setUp(self): + WSGITestCase.setUp(self) + self.bd = libbe.bugdir.SimpleBugDir(memory=False) + self.app = ServerApp(self.bd.storage, logger=self.logger) + def tearDown(self): + self.bd.cleanup() + WSGITestCase.tearDown(self) + def test_add_get(self): + self.getURL(self.app, '/add/', method='GET') + self.failUnless(self.status.startswith('404 '), self.status) + self.failUnless(self.response_headers == [ + ('Content-Type', 'text/plain')], + self.response_headers) + self.failUnless(self.exc_info == None, self.exc_info) + def test_add_post(self): + self.getURL(self.app, '/add/', method='POST', + data={'id':'123456', 'parent':'abc123', + 'directory':'True'}) + self.failUnless(self.status == '200 OK', self.status) + self.failUnless(self.response_headers == [], + self.response_headers) + self.failUnless(self.exc_info == None, self.exc_info) + # Note: other methods tested in libbe.storage.http + + # TODO: integration tests on Serve? + + unitsuite =unittest.TestLoader().loadTestsFromModule(sys.modules[__name__]) + suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()]) + + +# The following certificate-creation code is adapted From pyOpenSSL's +# examples. + +def get_cert_filenames(server_name, autogenerate=True, logger=None): + """ + Generate private key and certification filenames. + get_cert_filenames(server_name) -> (pkey_filename, cert_filename) + """ + pkey_file = '%s.pkey' % server_name + cert_file = '%s.cert' % server_name + if autogenerate == True: + for file in [pkey_file, cert_file]: + if not os.path.exists(file): + make_certs(server_name, logger) + return (pkey_file, cert_file) + +def createKeyPair(type, bits): + """Create a public/private key pair. + + Returns the public/private key pair in a PKey object. + + Parameters + ---------- + type : TYPE_RSA or TYPE_DSA + Key type. + bits : int + Number of bits to use in the key. + """ + pkey = OpenSSL.crypto.PKey() + pkey.generate_key(type, bits) + return pkey + +def createCertRequest(pkey, digest="md5", **name): + """Create a certificate request. + + Returns the certificate request in an X509Req object. + + Parameters + ---------- + pkey : PKey + The key to associate with the request. + digest : "md5" or ? + Digestion method to use for signing, default is "md5", + `**name` : + The name of the subject of the request, possible. + Arguments are: + + ============ ======================== + C Country name + ST State or province name + L Locality name + O Organization name + OU Organizational unit name + CN Common name + emailAddress E-mail address + ============ ======================== + """ + req = OpenSSL.crypto.X509Req() + subj = req.get_subject() + + for (key,value) in name.items(): + setattr(subj, key, value) + + req.set_pubkey(pkey) + req.sign(pkey, digest) + return req + +def createCertificate(req, (issuerCert, issuerKey), serial, (notBefore, notAfter), digest="md5"): + """Generate a certificate given a certificate request. + + Returns the signed certificate in an X509 object. + + Parameters + ---------- + req : + Certificate reqeust to use + issuerCert : + The certificate of the issuer + issuerKey : + The private key of the issuer + serial : + Serial number for the certificate + notBefore : + Timestamp (relative to now) when the certificate + starts being valid + notAfter : + Timestamp (relative to now) when the certificate + stops being valid + digest : + Digest method to use for signing, default is md5 + """ + cert = OpenSSL.crypto.X509() + cert.set_serial_number(serial) + cert.gmtime_adj_notBefore(notBefore) + cert.gmtime_adj_notAfter(notAfter) + cert.set_issuer(issuerCert.get_subject()) + cert.set_subject(req.get_subject()) + cert.set_pubkey(req.get_pubkey()) + cert.sign(issuerKey, digest) + return cert + +def make_certs(server_name, logger=None) : + """Generate private key and certification files. + + `mk_certs(server_name) -> (pkey_filename, cert_filename)` + """ + if OpenSSL == None: + raise libbe.command.UserError, \ + 'SSL certificate generation requires the OpenSSL module' + pkey_file,cert_file = get_cert_filenames( + server_name, autogenerate=False) + if logger != None: + logger.log(logger._server_level, + 'Generating certificates', pkey_file, cert_file) + cakey = createKeyPair(OpenSSL.crypto.TYPE_RSA, 1024) + careq = createCertRequest(cakey, CN='Certificate Authority') + cacert = createCertificate( + careq, (careq, cakey), 0, (0, 60*60*24*365*5)) # five years + open(pkey_file, 'w').write(OpenSSL.crypto.dump_privatekey( + OpenSSL.crypto.FILETYPE_PEM, cakey)) + open(cert_file, 'w').write(OpenSSL.crypto.dump_certificate( + OpenSSL.crypto.FILETYPE_PEM, cacert)) diff --git a/libbe/command/set.py b/libbe/command/set.py new file mode 100644 index 0000000..720dd0f --- /dev/null +++ b/libbe/command/set.py @@ -0,0 +1,144 @@ +# Copyright (C) 2005-2010 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..27898f7 --- /dev/null +++ b/libbe/command/severity.py @@ -0,0 +1,98 @@ +# Copyright (C) 2005-2010 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..ab3be73 --- /dev/null +++ b/libbe/command/show.py @@ -0,0 +1,207 @@ +# Copyright (C) 2005-2010 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 ids == None or 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..1659f75 --- /dev/null +++ b/libbe/command/status.py @@ -0,0 +1,108 @@ +# Copyright (C) 2008-2010 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..d1cf72e --- /dev/null +++ b/libbe/command/subscribe.py @@ -0,0 +1,385 @@ +# Copyright (C) 2009-2010 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 your 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..f4dc3ba --- /dev/null +++ b/libbe/command/tag.py @@ -0,0 +1,152 @@ +# Copyright (C) 2009-2010 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..f8a956b --- /dev/null +++ b/libbe/command/target.py @@ -0,0 +1,209 @@ +# Copyright (C) 2005-2010 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() + >>> bd.flush_reload() + >>> 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.__class__) + + 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): + """Generate all possible target bug summaries.""" + bugdir.load_all_bugs() + for bug in bugdir: + if bug.severity == 'target': + yield bug.summary + +def target_dict(bugdir): + """ + Return a dict with bug UUID keys and bug summary values for all + target bugs. + """ + ret = {} + bugdir.load_all_bugs() + for bug in bugdir: + if bug.severity == 'target': + ret[bug.uuid] = bug.summary + return ret + +def complete_target(command, argument, fragment=None): + """List possible command completions for fragment.""" + return targets(command._get_bugdir()) diff --git a/libbe/command/util.py b/libbe/command/util.py new file mode 100644 index 0000000..6e8e36c --- /dev/null +++ b/libbe/command/util.py @@ -0,0 +1,203 @@ +# Copyright (C) 2009-2010 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 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(command_names=True)) + +def comp_path(fragment=None): + """List possible path completions for fragment.""" + 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_path(command, argument, fragment=None): + """List possible path completions for fragment.""" + return comp_path(fragment) + +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 assignees(bugdir): + bugdir.load_all_bugs() + return list(set([bug.assigned for bug in bugdir + if bug.assigned != None])) + +def complete_assigned(command, argument, fragment=None): + return assignees(command._get_bugdir()) + +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 new file mode 100644 index 0000000..d8632a4 --- /dev/null +++ b/libbe/comment.py @@ -0,0 +1,769 @@ +# Copyright (C) 2008-2010 Gianluca Montecchi <gian@grys.it> +# Thomas Habets <thomas@habets.pp.se> +# W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +"""Define the :class:`Comment` class for representing bug comments. +""" + +import base64 +import os +import os.path +import sys +import time +import types +try: + from email.mime.base import MIMEBase + from email.encoders import encode_base64 +except ImportError: + # adjust to old python 2.4 + from email.MIMEBase import MIMEBase + from email.Encoders import encode_base64 +try: # import core module, Python >= 2.5 + from xml.etree import ElementTree +except ImportError: # look for non-core module + from elementtree import ElementTree +import xml.sax.saxutils + +import libbe +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 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 + + +class InvalidShortname(KeyError): + def __init__(self, shortname, shortnames): + msg = "Invalid shortname %s\n%s" % (shortname, shortnames) + KeyError.__init__(self, msg) + self.shortname = shortname + self.shortnames = shortnames + +class MissingReference(ValueError): + def __init__(self, comment): + msg = "Missing reference to %s" % (comment.in_reply_to) + ValueError.__init__(self, msg) + self.reference = comment.in_reply_to + self.comment = comment + +class DiskAccessRequired (Exception): + def __init__(self, goal): + msg = "Cannot %s without accessing the disk" % goal + Exception.__init__(self, msg) + +INVALID_UUID = "!!~~\n INVALID-UUID \n~~!!" + +def 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. + """ + uuids = [] + for id in libbe.util.id.child_uuids( + bug.storage.children( + bug.id.storage())): + uuids.append(id) + comments = [] + 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, ignore_missing_references=True) + return bug.comment_root + +def save_comments(bug): + for comment in bug.comment_root.traverse(): + comment.save() + + +class Comment (Tree, settings_object.SavedSettingsObject): + """Comments are a notes that attach to :class:`~libbe.bug.Bug`\s in + threaded trees. In mailing-list terms, a comment is analogous to + a single part of an email. + + >>> c = Comment() + >>> c.uuid != None + True + >>> c.uuid = "some-UUID" + >>> print c.content_type + text/plain + """ + + settings_properties = [] + required_saved_properties = [] + _prop_save_settings = settings_object.prop_save_settings + _prop_load_settings = settings_object.prop_load_settings + def _versioned_property(settings_properties=settings_properties, + required_saved_properties=required_saved_properties, + **kwargs): + if "settings_properties" not in kwargs: + kwargs["settings_properties"] = settings_properties + if "required_saved_properties" not in kwargs: + kwargs["required_saved_properties"]=required_saved_properties + return settings_object.versioned_property(**kwargs) + + @_versioned_property(name="Alt-id", + doc="Alternate ID for linking imported comments. Internally comments are linked (via In-reply-to) to the parent's UUID. However, these UUIDs are generated internally, so Alt-id is provided as a user-controlled linking target.") + def alt_id(): return {} + + @_versioned_property(name="Author", + doc="The author of the comment") + def author(): return {} + + @_versioned_property(name="In-reply-to", + doc="UUID for parent comment or bug") + def in_reply_to(): return {} + + @_versioned_property(name="Content-type", + doc="Mime type for comment body", + default="text/plain", + require_save=True) + def content_type(): return {} + + @_versioned_property(name="Date", + doc="An RFC 2822 timestamp for comment creation") + def date(): return {} + + def _get_time(self): + if self.date == None: + return None + return utility.str_to_time(self.date) + def _set_time(self, value): + self.date = utility.time_to_str(value) + time = property(fget=_get_time, + fset=_set_time, + doc="An integer version of .date") + + def _get_comment_body(self): + if self.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): + assert self.uuid != INVALID_UUID, self + if self.content_type.startswith('text/') \ + and 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" + self.storage.set(self.id.storage("body"), new) + + @Property + @change_hook_property(hook=_set_comment_body) + @cached_property(generator=_get_comment_body) + @local_property("body") + @doc_property(doc="The meat of the comment") + def body(): return {} + + def _extra_strings_check_fn(value): + return utility.iterable_full_of_strings(value, \ + alternative=settings_object.EMPTY) + def _extra_strings_change_hook(self, old, new): + self.extra_strings.sort() # to make merging easier + self._prop_save_settings(old, new) + @_versioned_property(name="extra_strings", + doc="Space for an array of extra strings. Useful for storing state for functionality implemented purely in becommands/<some_function>.py.", + default=[], + check_fn=_extra_strings_check_fn, + change_hook=_extra_strings_change_hook, + mutable=True) + def extra_strings(): return {} + + def __init__(self, bug=None, uuid=None, from_storage=False, + in_reply_to=None, body=None, content_type=None): + """ + 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 in_reply_to, body, and content_type options are only used + if ``from_storage==False`` (the default). When + ``from_storage==True``, they are loaded from the bug database. + ``content_type`` decides if the body should be run through + :func:`util.id.short_to_long_text` before saving. See + :meth:`_set_comment_body` for details. + + ``in_reply_to`` should be the uuid string of the parent comment. + """ + Tree.__init__(self) + settings_object.SavedSettingsObject.__init__(self) + self.bug = bug + self.storage = None + self.uuid = uuid + self.id = libbe.util.id.ID(self, 'comment') + if from_storage == False: + if uuid == None: + self.uuid = libbe.util.id.uuid_gen() + self.time = int(time.time()) # only save to second precision + self.in_reply_to = in_reply_to + if content_type != None: + self.content_type = content_type + 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) + + def __str__(self): + """ + >>> comm = Comment(bug=None, body="Some insightful remarks") + >>> comm.uuid = "com-1" + >>> comm.date = "Thu, 20 Nov 2008 15:55:11 +0000" + >>> comm.author = "Jane Doe <jdoe@example.com>" + >>> print comm + --------- Comment --------- + Name: //com + From: Jane Doe <jdoe@example.com> + Date: Thu, 20 Nov 2008 15:55:11 +0000 + <BLANKLINE> + Some insightful remarks + """ + return self.string() + + def traverse(self, *args, **kwargs): + """Avoid working with the possible dummy root comment""" + for comment in Tree.traverse(self, *args, **kwargs): + if comment.uuid == INVALID_UUID: + continue + yield comment + + # serializing methods + + def _setting_attr_string(self, setting): + value = getattr(self, setting) + if value == None: + return "" + if type(value) not in types.StringTypes: + return str(value) + return value + + def safe_in_reply_to(self): + """ + Return self.in_reply_to, except... + + * if no comment matches that id, in which case return None. + * if that id matches another comments .alt_id, in which case + return the matching comments .uuid. + """ + if self.in_reply_to == None: + return None + else: + try: + irt_comment = self.bug.comment_from_uuid( + self.in_reply_to, match_alt_id=True) + return irt_comment.uuid + except KeyError: + return 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) + <comment> + <uuid>0123</uuid> + <short-name>//012</short-name> + <author></author> + <date>Thu, 01 Jan 1970 00:00:00 +0000</date> + <content-type>text/plain</content-type> + <body>Some + insightful + remarks</body> + </comment> + >>> comm.content_type = 'image/png' + >>> print comm.xml() + <comment> + <uuid>0123</uuid> + <short-name>//012</short-name> + <author></author> + <date>Thu, 01 Jan 1970 00:00:00 +0000</date> + <content-type>image/png</content-type> + <body>U29tZQppbnNpZ2h0ZnVsCnJlbWFya3MK + </body> + </comment> + """ + if self.content_type.startswith('text/'): + body = (self.body or '').rstrip('\n') + else: + maintype,subtype = self.content_type.split('/',1) + msg = MIMEBase(maintype, subtype) + msg.set_payload(self.body or '') + encode_base64(msg) + body = base64.encodestring(self.body or '') + info = [('uuid', self.uuid), + ('alt-id', self.alt_id), + ('short-name', self.id.user()), + ('in-reply-to', self.safe_in_reply_to()), + ('author', self._setting_attr_string('author')), + ('date', self.date), + ('content-type', self.content_type), + ('body', body)] + lines = ['<comment>'] + for (k,v) in info: + if v != None: + lines.append(' <%s>%s</%s>' % (k,xml.sax.saxutils.escape(v),k)) + for estr in self.extra_strings: + lines.append(' <extra-string>%s</extra-string>' % estr) + lines.append('</comment>') + istring = ' '*indent + sep = '\n' + istring + return istring + sep.join(lines).rstrip('\n') + + def from_xml(self, xml_string, verbose=True): + u""" + Note: If alt-id is not given, translates any <uuid> fields to + <alt-id> fields. + >>> commA = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n") + >>> commA.uuid = "0123" + >>> commA.date = "Thu, 01 Jan 1970 00:00:00 +0000" + >>> commA.author = u'Fran\xe7ois' + >>> commA.extra_strings += ['TAG: very helpful'] + >>> xml = commA.xml() + >>> commB = Comment() + >>> commB.from_xml(xml, verbose=True) + >>> commB.explicit_attrs + ['author', 'date', 'content_type', 'body', 'alt_id'] + >>> commB.xml() == xml + False + >>> commB.uuid = commB.alt_id + >>> commB.alt_id = None + >>> commB.xml() == xml + True + """ + if type(xml_string) == types.UnicodeType: + xml_string = xml_string.strip().encode('unicode_escape') + if hasattr(xml_string, 'getchildren'): # already an ElementTree Element + comment = xml_string + else: + comment = ElementTree.XML(xml_string) + if comment.tag != 'comment': + raise utility.InvalidXML( \ + 'comment', comment, 'root element must be <comment>') + tags=['uuid','alt-id','in-reply-to','author','date','content-type', + 'body','extra-string'] + self.explicit_attrs = [] + uuid = None + body = None + estrs = [] + for child in comment.getchildren(): + if child.tag == 'short-name': + pass + elif child.tag in tags: + if child.text == None or len(child.text) == 0: + text = settings_object.EMPTY + else: + text = xml.sax.saxutils.unescape(child.text) + text = text.decode('unicode_escape').strip() + if child.tag == 'uuid': + uuid = text + continue # don't set the comment's uuid tag. + elif child.tag == 'body': + body = text + self.explicit_attrs.append(child.tag) + continue # don't set the comment's body yet. + elif child.tag == 'extra-string': + estrs.append(text) + continue # don't set the comment's extra_string yet. + attr_name = child.tag.replace('-','_') + self.explicit_attrs.append(attr_name) + setattr(self, attr_name, text) + elif verbose == True: + print >> sys.stderr, 'Ignoring unknown tag %s in %s' \ + % (child.tag, comment.tag) + if uuid != self.uuid and self.alt_id == None: + self.explicit_attrs.append('alt_id') + self.alt_id = uuid + if body != None: + if self.content_type.startswith('text/'): + self.body = body+'\n' # restore trailing newline + else: + self.body = base64.decodestring(body) + self.extra_strings = estrs + + def merge(self, other, accept_changes=True, + accept_extra_strings=True, change_exception=False): + """ + Merge info from other into this comment. Overrides any + attributes in self that are listed in other.explicit_attrs. + + >>> commA = Comment(bug=None, body='Some insightful remarks') + >>> commA.uuid = '0123' + >>> commA.date = 'Thu, 01 Jan 1970 00:00:00 +0000' + >>> commA.author = 'Frank' + >>> commA.extra_strings += ['TAG: very helpful'] + >>> commA.extra_strings += ['TAG: favorite'] + >>> commB = Comment(bug=None, body='More insightful remarks') + >>> commB.uuid = '3210' + >>> commB.date = 'Fri, 02 Jan 1970 00:00:00 +0000' + >>> commB.author = 'John' + >>> commB.explicit_attrs = ['author', 'body'] + >>> commB.extra_strings += ['TAG: very helpful'] + >>> commB.extra_strings += ['TAG: useful'] + >>> commA.merge(commB, accept_changes=False, + ... accept_extra_strings=False, change_exception=False) + >>> commA.merge(commB, accept_changes=False, + ... accept_extra_strings=False, change_exception=True) + Traceback (most recent call last): + ... + ValueError: Merge would change author "Frank"->"John" for comment 0123 + >>> commA.merge(commB, accept_changes=True, + ... accept_extra_strings=False, change_exception=True) + Traceback (most recent call last): + ... + ValueError: Merge would add extra string "TAG: useful" to comment 0123 + >>> print commA.author + John + >>> print commA.extra_strings + ['TAG: favorite', 'TAG: very helpful'] + >>> commA.merge(commB, accept_changes=True, + ... accept_extra_strings=True, change_exception=True) + >>> print commA.extra_strings + ['TAG: favorite', 'TAG: useful', 'TAG: very helpful'] + >>> print commA.xml() + <comment> + <uuid>0123</uuid> + <short-name>//012</short-name> + <author>John</author> + <date>Thu, 01 Jan 1970 00:00:00 +0000</date> + <content-type>text/plain</content-type> + <body>More insightful remarks</body> + <extra-string>TAG: favorite</extra-string> + <extra-string>TAG: useful</extra-string> + <extra-string>TAG: very helpful</extra-string> + </comment> + """ + for attr in other.explicit_attrs: + old = getattr(self, attr) + new = getattr(other, attr) + if old != new: + if accept_changes == True: + setattr(self, attr, new) + elif change_exception == True: + raise ValueError, \ + 'Merge would change %s "%s"->"%s" for comment %s' \ + % (attr, old, new, self.uuid) + if self.alt_id == self.uuid: + self.alt_id = None + for estr in other.extra_strings: + if not estr in self.extra_strings: + if accept_extra_strings == True: + self.extra_strings.append(estr) + elif change_exception == True: + raise ValueError, \ + 'Merge would add extra string "%s" to comment %s' \ + % (estr, self.uuid) + + 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) + --------- Comment --------- + Name: //abc + From: + Date: Thu, 01 Jan 1970 00:00:00 +0000 + <BLANKLINE> + Some + insightful + remarks + """ + lines = [] + lines.append("--------- Comment ---------") + 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/"): + 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", + 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. + + >>> a = Comment(bug=None, uuid="a", body="Insightful remarks") + >>> a.time = utility.str_to_time("Thu, 20 Nov 2008 01:00:00 +0000") + >>> b = a.new_reply("Critique original comment") + >>> b.uuid = "b" + >>> b.time = utility.str_to_time("Thu, 20 Nov 2008 02:00:00 +0000") + >>> c = b.new_reply("Begin flamewar :p") + >>> c.uuid = "c" + >>> c.time = utility.str_to_time("Thu, 20 Nov 2008 03:00:00 +0000") + >>> d = a.new_reply("Useful examples") + >>> d.uuid = "d" + >>> d.time = utility.str_to_time("Thu, 20 Nov 2008 04:00:00 +0000") + >>> a.sort(key=lambda comm : comm.time) + >>> print a.string_thread(flatten=True) + --------- Comment --------- + Name: //a + From: + Date: Thu, 20 Nov 2008 01:00:00 +0000 + <BLANKLINE> + Insightful remarks + --------- Comment --------- + Name: //b + From: + Date: Thu, 20 Nov 2008 02:00:00 +0000 + <BLANKLINE> + Critique original comment + --------- Comment --------- + Name: //c + From: + Date: Thu, 20 Nov 2008 03:00:00 +0000 + <BLANKLINE> + Begin flamewar :p + --------- Comment --------- + Name: //d + From: + Date: Thu, 20 Nov 2008 04:00:00 +0000 + <BLANKLINE> + Useful examples + >>> print a.string_thread() + --------- Comment --------- + Name: //a + From: + Date: Thu, 20 Nov 2008 01:00:00 +0000 + <BLANKLINE> + Insightful remarks + --------- Comment --------- + Name: //b + From: + Date: Thu, 20 Nov 2008 02:00:00 +0000 + <BLANKLINE> + Critique original comment + --------- Comment --------- + Name: //c + From: + Date: Thu, 20 Nov 2008 03:00:00 +0000 + <BLANKLINE> + Begin flamewar :p + --------- Comment --------- + Name: //d + From: + Date: Thu, 20 Nov 2008 04:00:00 +0000 + <BLANKLINE> + Useful examples + """ + stringlist = [] + for depth,comment in self.thread(flatten=flatten): + ind = 2*depth+indent + string_fn = getattr(comment, string_method_name) + stringlist.append(string_fn(indent=ind)) + return '\n'.join(stringlist) + + 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 load_settings(self, settings_mapfile=None): + if self.uuid == INVALID_UUID: + return + if settings_mapfile == None: + settings_mapfile = \ + self.storage.get(self.id.storage("values"), default="\n") + try: + 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(settings) + + def save_settings(self): + if self.uuid == INVALID_UUID: + return + mf = mapfile.generate(self._get_saved_settings()) + self.storage.set(self.id.storage("values"), mf) + + def save(self): + """ + 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). + """ + 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) + + def remove(self): + 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): + """ + >>> comm = Comment(bug=None, body="Some insightful remarks") + >>> repA = comm.new_reply("Critique original comment") + >>> repB = repA.new_reply("Begin flamewar :p") + >>> repB.in_reply_to == repA.uuid + True + """ + reply = Comment(self.bug, body=body, content_type=content_type) + self.add_reply(reply) + return reply + + def comment_from_uuid(self, uuid, match_alt_id=True): + """Use a uuid 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" + >>> d.alt_id = "d-alt" + >>> comm = a.comment_from_uuid("d") + >>> id(comm) == id(d) + True + >>> comm = a.comment_from_uuid("d-alt") + >>> id(comm) == id(d) + True + >>> comm = a.comment_from_uuid(None, match_alt_id=False) + Traceback (most recent call last): + ... + KeyError: None + """ + for comment in self.traverse(): + if comment.uuid == uuid: + return comment + if match_alt_id == True and uuid != None \ + and comment.alt_id == uuid: + 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 + comparison rule for that attribute type. If invert == True, sort + *against* that convention. + + >>> attr="author" + >>> commentA = Comment() + >>> commentB = Comment() + >>> commentA.author = "John Doe" + >>> commentB.author = "Jane Doe" + >>> cmp_attr(commentA, commentB, attr) > 0 + True + >>> cmp_attr(commentA, commentB, attr, invert=True) < 0 + True + >>> commentB.author = "John Doe" + >>> cmp_attr(commentA, commentB, attr) == 0 + True + """ + if not hasattr(comment_2, attr) : + return 1 + val_1 = getattr(comment_1, attr) + val_2 = getattr(comment_2, attr) + if val_1 == None: val_1 = None + if val_2 == None: val_2 = None + + if invert == True : + return -cmp(val_1, val_2) + else : + return cmp(val_1, val_2) + +# alphabetical rankings (a < z) +cmp_uuid = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "uuid") +cmp_author = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "author") +cmp_in_reply_to = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "in_reply_to") +cmp_content_type = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "content_type") +cmp_body = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "body") +cmp_extra_strings = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "extra_strings") +# chronological rankings (newer < older) +cmp_time = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "time", invert=True) + + +DEFAULT_CMP_FULL_CMP_LIST = \ + (cmp_time, cmp_author, cmp_content_type, cmp_body, cmp_in_reply_to, + cmp_uuid, cmp_extra_strings) + +class CommentCompoundComparator (object): + def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST): + self.cmp_list = cmp_list + def __call__(self, comment_1, comment_2): + for comparison in self.cmp_list : + val = comparison(comment_1, comment_2) + if val != 0 : + return val + return 0 + +cmp_full = CommentCompoundComparator() + +if libbe.TESTING == True: + suite = doctest.DocTestSuite() diff --git a/libbe/diff.py b/libbe/diff.py new file mode 100644 index 0000000..dc13b61 --- /dev/null +++ b/libbe/diff.py @@ -0,0 +1,691 @@ +# Copyright (C) 2005-2010 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. + +"""Tools for comparing two :class:`libbe.bug.BugDir`\s. +""" + +import difflib +import types + +import libbe +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 (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): + 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) + def string_tree(self, indent=0): + lines = [] + for depth,node in self.thread(): + lines.append('%s%s' % (' '*(indent+2*depth), node)) + return '\n'.join(lines) + +BUGDIR_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') + +class InvalidType (ValueError): + def __init__(self, type_name, type_root): + msg = 'Invalid type %s for tree:\n%s' \ + % (type_name, type_root.string_tree(4)) + ValueError.__init__(self, msg) + self.type_name = type_name + self.type_root = type_root + +def type_from_name(name, type_root, default=None, default_ok=False): + if name == str(type_root): + return type_root + for t in type_root.traverse(): + if name == str(t): + return t + if default_ok: + return default + raise InvalidType(name, type_root) + +class Subscription (object): + """A user subscription. + + Examples + -------- + + >>> subscriptions = [Subscription('XYZ', 'all'), + ... Subscription('DIR', 'new'), + ... Subscription('ABC', BUG_TYPE_ALL),] + >>> print sorted(subscriptions) + [<Subscription: DIR (new)>, <Subscription: ABC (all)>, <Subscription: XYZ (all)>] + """ + def __init__(self, id, subscription_type, **kwargs): + if 'type_root' not in kwargs: + if id == BUGDIR_ID: + kwargs['type_root'] = BUGDIR_TYPE_ALL + else: + kwargs['type_root'] = BUG_TYPE_ALL + if type(subscription_type) in types.StringTypes: + subscription_type = type_from_name(subscription_type, **kwargs) + self.id = id + self.type = subscription_type + def __cmp__(self, other): + for attr in 'id', 'type': + value = cmp(getattr(self, attr), getattr(other, attr)) + if value != 0: + if self.id == BUGDIR_ID: + return -1 + elif other.id == BUGDIR_ID: + return 1 + return value + def __str__(self): + return str(self.type) + def __repr__(self): + return '<Subscription: %s (%s)>' % (self.id, self.type) + +def subscriptions_from_string(string=None, subscription_sep=',', id_sep=':'): + """Provide a simple way for non-Python interfaces to read in subscriptions. + + Examples + -------- + + >>> subscriptions_from_string(None) + [<Subscription: DIR (all)>] + >>> subscriptions_from_string('DIR:new,DIR:rem,ABC:all,XYZ:all') + [<Subscription: DIR (new)>, <Subscription: DIR (rem)>, <Subscription: ABC (all)>, <Subscription: XYZ (all)>] + >>> subscriptions_from_string('DIR::new') + Traceback (most recent call last): + ... + ValueError: Invalid subscription "DIR::new", should be ID:TYPE + """ + if string == None: + return [Subscription(BUGDIR_ID, BUGDIR_TYPE_ALL)] + subscriptions = [] + for subscription in string.split(','): + fields = subscription.split(':') + if len(fields) != 2: + raise ValueError('Invalid subscription "%s", should be ID:TYPE' + % subscription) + id,type = fields + subscriptions.append(Subscription(id, type)) + return subscriptions + +class DiffTree (libbe.util.tree.Tree): + """A tree holding difference data for easy report generation. + + Examples + -------- + + >>> bugdir = DiffTree('bugdir') + >>> bdsettings = DiffTree('settings', data='target: None -> 1.0') + >>> bugdir.append(bdsettings) + >>> bugs = DiffTree('bugs', 'bug-count: 5 -> 6') + >>> bugdir.append(bugs) + >>> new = DiffTree('new', 'new bugs: ABC, DEF') + >>> bugs.append(new) + >>> rem = DiffTree('rem', 'removed bugs: RST, UVW') + >>> bugs.append(rem) + >>> print bugdir.report_string() + target: None -> 1.0 + bug-count: 5 -> 6 + new bugs: ABC, DEF + removed bugs: RST, UVW + >>> print '\\n'.join(bugdir.paths()) + bugdir + bugdir/settings + bugdir/bugs + bugdir/bugs/new + bugdir/bugs/rem + >>> bugdir.child_by_path('/') == bugdir + True + >>> bugdir.child_by_path('/bugs') == bugs + True + >>> bugdir.child_by_path('/bugs/rem') == rem + True + >>> bugdir.child_by_path('bugdir') == bugdir + True + >>> bugdir.child_by_path('bugdir/') == bugdir + True + >>> bugdir.child_by_path('bugdir/bugs') == bugs + True + >>> bugdir.child_by_path('/bugs').masked = True + >>> print bugdir.report_string() + target: None -> 1.0 + """ + def __init__(self, name, data=None, data_part_fn=str, + requires_children=False, masked=False): + libbe.util.tree.Tree.__init__(self) + self.name = name + self.data = data + self.data_part_fn = data_part_fn + self.requires_children = requires_children + self.masked = masked + def paths(self, parent_path=None): + paths = [] + if parent_path == None: + path = self.name + else: + path = '%s/%s' % (parent_path, self.name) + paths.append(path) + for child in self: + paths.extend(child.paths(path)) + return paths + def child_by_path(self, path): + if hasattr(path, 'split'): # convert string path to a list of names + names = path.split('/') + if names[0] == '': + names[0] = self.name # replace root with self + if len(names) > 1 and names[-1] == '': + names = names[:-1] # strip empty tail + else: # it was already an array + names = path + assert len(names) > 0, path + if names[0] == self.name: + if len(names) == 1: + return self + for child in self: + if names[1] == child.name: + return child.child_by_path(names[1:]) + if len(names) == 1: + raise KeyError, "%s doesn't match '%s'" % (names, self.name) + raise KeyError, '%s points to child not in %s' % (names, [c.name for c in self]) + def report_string(self): + report = self.report() + if report == None: + return '' + return '\n'.join(report) + def report(self, root=None, parent=None, depth=0): + if root == None: + root = self.make_root() + if self.masked == True: + return root + data_part = self.data_part(depth) + if self.requires_children == True \ + and len([c for c in self if c.masked == False]) == 0: + pass + else: + self.join(root, parent, data_part) + if data_part != None: + depth += 1 + for child in self: + root = child.report(root, self, depth) + return root + def make_root(self): + return [] + def join(self, root, parent, data_part): + if data_part != None: + root.append(data_part) + def data_part(self, depth, indent=True): + if self.data == None: + return None + if hasattr(self, '_cached_data_part'): + return self._cached_data_part + data_part = self.data_part_fn(self.data) + if indent == True: + data_part_lines = data_part.splitlines() + indent = ' '*(depth) + line_sep = '\n'+indent + data_part = indent+line_sep.join(data_part_lines) + self._cached_data_part = data_part + return data_part + +class Diff (object): + """Difference tree generator for BugDirs. + + Examples + -------- + + >>> import copy + >>> bd = libbe.bugdir.SimpleBugDir(memory=True) + >>> bd_new = copy.deepcopy(bd) + >>> bd_new.target = '1.0' + >>> a = bd_new.bug_from_uuid('a') + >>> rep = a.comment_root.new_reply("I'm closing this bug") + >>> rep.uuid = 'acom' + >>> rep.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('Bug C', _uuid='c') + >>> d = Diff(bd, bd_new) + >>> r = d.report_tree() + >>> print '\\n'.join(r.paths()) + bugdir + bugdir/settings + bugdir/bugs + bugdir/bugs/new + bugdir/bugs/new/c + bugdir/bugs/rem + bugdir/bugs/rem/b + bugdir/bugs/mod + bugdir/bugs/mod/a + bugdir/bugs/mod/a/settings + bugdir/bugs/mod/a/comments + bugdir/bugs/mod/a/comments/new + bugdir/bugs/mod/a/comments/new/acom + bugdir/bugs/mod/a/comments/rem + bugdir/bugs/mod/a/comments/mod + >>> print r.report_string() + Changed bug directory settings: + target: None -> 1.0 + New bugs: + abc/c:om: Bug C + Removed bugs: + abc/b:cm: Bug B + Modified bugs: + abc/a:cm: Bug A + Changed bug settings: + status: open -> closed + New comments: + from John Doe <j@doe.com> on Thu, 01 Jan 1970 00:00:00 +0000 + I'm closing this bug... + + You can also limit the report generation by providing a list of + subscriptions. + + >>> subscriptions = [Subscription('DIR', BUGDIR_TYPE_NEW), + ... Subscription('b', BUG_TYPE_ALL)] + >>> r = d.report_tree(subscriptions) + >>> print r.report_string() + New bugs: + abc/c:om: Bug C + Removed bugs: + abc/b:cm: Bug B + + While sending subscriptions to report_tree() makes the report + generation more efficient (because you may not need to compare + _all_ the bugs, etc.), sometimes you will have several sets of + subscriptions. In that case, it's better to run full_report() + first, and then use report_tree() to avoid redundant comparisons. + + >>> d.full_report() + >>> print d.report_tree([subscriptions[0]]).report_string() + New bugs: + abc/c:om: Bug C + >>> print d.report_tree([subscriptions[1]]).report_string() + Removed bugs: + abc/b:cm: Bug B + + >>> bd.cleanup() + """ + def __init__(self, old_bugdir, new_bugdir): + self.old_bugdir = old_bugdir + self.new_bugdir = new_bugdir + + # data assembly methods + + def _changed_bugs(self, subscriptions): + """ + Search for differences in all bugs between .old_bugdir and + .new_bugdir. Returns + (added_bugs, modified_bugs, removed_bugs) + where added_bugs and removed_bugs are lists of added and + removed bugs respectively. modified_bugs is a list of + (old_bug,new_bug) pairs. + """ + bugdir_types = [s.type for s in subscriptions if s.id == BUGDIR_ID] + new_uuids = [] + old_uuids = [] + for bd_type in [BUGDIR_TYPE_ALL, BUGDIR_TYPE_NEW, BUGDIR_TYPE_MOD]: + if bd_type in bugdir_types: + new_uuids = list(self.new_bugdir.uuids()) + break + for bd_type in [BUGDIR_TYPE_ALL, BUGDIR_TYPE_REM]: + if bd_type in bugdir_types: + old_uuids = list(self.old_bugdir.uuids()) + break + subscribed_bugs = [] + for s in subscriptions: + if s.id != BUGDIR_ID: + try: + 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)]) + new_uuids = sorted(set(new_uuids)) + old_uuids.extend([s for s in subscribed_bugs + if self.old_bugdir.has_bug(s)]) + old_uuids = sorted(set(old_uuids)) + + added = [] + removed = [] + modified = [] + if hasattr(self.old_bugdir, 'changed'): + # take advantage of a RevisionedBugDir-style changed() method + new_ids,mod_ids,rem_ids = self.old_bugdir.changed() + for id in new_ids: + for a_id in self.new_bugdir.storage.ancestors(id): + if a_id.count('/') == 0: + if a_id in [b.id.storage() for b in added]: + break + try: + bug = self.new_bugdir.bug_from_uuid(a_id) + added.append(bug) + except libbe.bugdir.NoBugMatches: + pass + for id in rem_ids: + for a_id in self.old_bugdir.storage.ancestors(id): + if a_id.count('/') == 0: + if a_id in [b.id.storage() for b in removed]: + break + try: + bug = self.old_bugdir.bug_from_uuid(a_id) + removed.append(bug) + except libbe.bugdir.NoBugMatches: + pass + for id in mod_ids: + for a_id in self.new_bugdir.storage.ancestors(id): + if a_id.count('/') == 0: + if a_id in [b[0].id.storage() for b in modified]: + break + try: + new_bug = self.new_bugdir.bug_from_uuid(a_id) + old_bug = self.old_bugdir.bug_from_uuid(a_id) + modified.append((old_bug, new_bug)) + except libbe.bugdir.NoBugMatches: + pass + else: + for uuid in new_uuids: + new_bug = self.new_bugdir.bug_from_uuid(uuid) + try: + old_bug = self.old_bugdir.bug_from_uuid(uuid) + except KeyError: + if BUGDIR_TYPE_ALL in bugdir_types \ + or BUGDIR_TYPE_NEW in bugdir_types \ + or uuid in subscribed_bugs: + added.append(new_bug) + continue + if BUGDIR_TYPE_ALL in bugdir_types \ + or BUGDIR_TYPE_MOD in bugdir_types \ + or uuid in subscribed_bugs: + if old_bug.storage != None and old_bug.storage.is_readable(): + old_bug.load_comments() + 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)) + for uuid in old_uuids: + if not self.new_bugdir.has_bug(uuid): + old_bug = self.old_bugdir.bug_from_uuid(uuid) + removed.append(old_bug) + added.sort() + removed.sort() + modified.sort(self._bug_modified_cmp) + return (added, modified, removed) + def _bug_modified_cmp(self, left, right): + return cmp(left[1], right[1]) + def _changed_comments(self, old, new): + """ + Search for differences in all loaded comments between the bugs + old and new. Returns + (added_comments, modified_comments, removed_comments) + analogous to ._changed_bugs. + """ + if hasattr(self, '__changed_comments'): + if new.uuid in self.__changed_comments: + return self.__changed_comments[new.uuid] + else: + self.__changed_comments = {} + added = [] + removed = [] + modified = [] + old.comment_root.sort(key=lambda comm : comm.time) + new.comment_root.sort(key=lambda comm : comm.time) + old_comment_ids = [c.uuid for c in old.comments()] + new_comment_ids = [c.uuid for c in new.comments()] + for uuid in new_comment_ids: + new_comment = new.comment_from_uuid(uuid) + try: + old_comment = old.comment_from_uuid(uuid) + except KeyError: + added.append(new_comment) + else: + if old_comment != new_comment: + modified.append((old_comment, new_comment)) + for uuid in old_comment_ids: + if uuid not in new_comment_ids: + old_comment = old.comment_from_uuid(uuid) + removed.append(old_comment) + self.__changed_comments[new.uuid] = (added, modified, removed) + return self.__changed_comments[new.uuid] + def _attribute_changes(self, old, new, attributes): + """ + Take two objects old and new, and compare the value of *.attr + for attr in the list attribute names. Returns a list of + (attr_name, old_value, new_value) + tuples. + """ + change_list = [] + for attr in attributes: + old_value = getattr(old, attr) + new_value = getattr(new, attr) + if old_value != new_value: + change_list.append((attr, old_value, new_value)) + if len(change_list) >= 0: + return change_list + return None + def _settings_properties_attribute_changes(self, old, new, + hidden_properties=[]): + properties = sorted(new.settings_properties) + for p in hidden_properties: + properties.remove(p) + attributes = [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) + def _bug_attribute_changes(self, old, new): + return self._settings_properties_attribute_changes(old, new) + def _comment_attribute_changes(self, old, new): + return self._settings_properties_attribute_changes(old, new) + + # report generation methods + + def full_report(self, diff_tree=DiffTree): + """ + Generate a full report for efficiency if you'll be using + .report_tree() with several sets of subscriptions. + """ + self._cached_full_report = self.report_tree(diff_tree=diff_tree, + allow_cached=False) + self._cached_full_report_diff_tree = diff_tree + def _sub_report(self, subscriptions): + """ + Return ._cached_full_report masked for subscriptions. + """ + root = self._cached_full_report + bugdir_types = [s.type for s in subscriptions if s.id == BUGDIR_ID] + subscribed_bugs = [s.id for s in subscriptions + if BUG_TYPE_ALL.has_descendant( \ + s.type, match_self=True)] + selected_by_bug = [node.name + for node in root.child_by_path('bugdir/bugs')] + if BUGDIR_TYPE_ALL in bugdir_types: + for node in root.traverse(): + node.masked = False + selected_by_bug = [] + else: + try: + node = root.child_by_path('bugdir/settings') + node.masked = True + except KeyError: + pass + for name,type in (('new', BUGDIR_TYPE_NEW), + ('mod', BUGDIR_TYPE_MOD), + ('rem', BUGDIR_TYPE_REM)): + if type in bugdir_types: + bugs = root.child_by_path('bugdir/bugs/%s' % name) + for bug_node in bugs: + for node in bug_node.traverse(): + node.masked = False + selected_by_bug.remove(name) + for name in selected_by_bug: + bugs = root.child_by_path('bugdir/bugs/%s' % name) + for bug_node in bugs: + if bug_node.name in subscribed_bugs: + for node in bug_node.traverse(): + node.masked = False + else: + for node in bug_node.traverse(): + node.masked = True + return root + def report_tree(self, subscriptions=None, diff_tree=DiffTree, + allow_cached=True): + """ + Pretty bare to make it easy to adjust to specific cases. You + can pass in a DiffTree subclass via diff_tree to override the + default report assembly process. + """ + if allow_cached == True \ + and hasattr(self, '_cached_full_report') \ + and diff_tree == self._cached_full_report_diff_tree: + return self._sub_report(subscriptions) + if subscriptions == None: + subscriptions = [Subscription(BUGDIR_ID, BUGDIR_TYPE_ALL)] + bugdir_settings = sorted(self.new_bugdir.settings_properties) + 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, + self.bugdir_attribute_change_string) + root.append(bugdir) + 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) + bug_root.append(bnew) + for bug in add: + b = diff_tree(bug.uuid, bug, self.bug_add_string) + bnew.append(b) + brem = diff_tree('rem', 'Removed bugs:', requires_children=True) + bug_root.append(brem) + for bug in rem: + b = diff_tree(bug.uuid, bug, self.bug_rem_string) + brem.append(b) + bmod = diff_tree('mod', 'Modified bugs:', requires_children=True) + bug_root.append(bmod) + for old,new in mod: + b = diff_tree(new.uuid, (old,new), self.bug_mod_string) + bmod.append(b) + bug_attribute_changes = self._bug_attribute_changes(old, new) + if len(bug_attribute_changes) > 0: + bset = diff_tree('settings', bug_attribute_changes, + self.bug_attribute_change_string) + b.append(bset) + if old.summary != new.summary: + data = (old.summary, new.summary) + bsum = diff_tree('summary', data, self.bug_summary_change_string) + b.append(bsum) + cr = diff_tree('comments') + b.append(cr) + a,m,d = self._changed_comments(old, new) + cnew = diff_tree('new', 'New comments:', requires_children=True) + for comment in a: + c = diff_tree(comment.uuid, comment, self.comment_add_string) + cnew.append(c) + crem = diff_tree('rem', 'Removed comments:',requires_children=True) + for comment in d: + c = diff_tree(comment.uuid, comment, self.comment_rem_string) + crem.append(c) + cmod = diff_tree('mod','Modified comments:',requires_children=True) + for o,n in m: + c = diff_tree(n.uuid, (o,n), self.comment_mod_string) + cmod.append(c) + comm_attribute_changes = self._comment_attribute_changes(o, n) + if len(comm_attribute_changes) > 0: + cset = diff_tree('settings', comm_attribute_changes, + self.comment_attribute_change_string) + if o.body != n.body: + data = (o.body, n.body) + cbody = diff_tree('cbody', data, + self.comment_body_change_string) + c.append(cbody) + cr.extend([cnew, crem, cmod]) + return root + + # change data -> string methods. + # Feel free to play with these in subclasses. + + def attribute_change_string(self, attribute_changes, indent=0): + indent_string = ' '*indent + change_strings = [u'%s: %s -> %s' % f for f in attribute_changes] + for i,change_string in enumerate(change_strings): + change_strings[i] = indent_string+change_string + return u'\n'.join(change_strings) + def bugdir_attribute_change_string(self, attribute_changes): + return 'Changed bug directory settings:\n%s' % \ + self.attribute_change_string(attribute_changes, indent=1) + def bug_attribute_change_string(self, attribute_changes): + return 'Changed bug settings:\n%s' % \ + self.attribute_change_string(attribute_changes, indent=1) + def comment_attribute_change_string(self, attribute_changes): + return 'Changed comment settings:\n%s' % \ + self.attribute_change_string(attribute_changes, indent=1) + def bug_add_string(self, bug): + return bug.string(shortlist=True) + def bug_rem_string(self, bug): + return bug.string(shortlist=True) + def bug_mod_string(self, bugs): + old_bug,new_bug = bugs + return new_bug.string(shortlist=True) + def bug_summary_change_string(self, summaries): + old_summary,new_summary = summaries + return 'summary changed:\n %s\n %s' % (old_summary, new_summary) + def _comment_summary_string(self, comment): + return 'from %s on %s' % (comment.author, time_to_str(comment.time)) + def comment_add_string(self, comment): + summary = self._comment_summary_string(comment) + first_line = comment.body.splitlines()[0] + return '%s\n %s...' % (summary, first_line) + def comment_rem_string(self, comment): + summary = self._comment_summary_string(comment) + first_line = comment.body.splitlines()[0] + return '%s\n %s...' % (summary, first_line) + def comment_mod_string(self, comments): + old_comment,new_comment = comments + return self._comment_summary_string(new_comment) + def comment_body_change_string(self, bodies): + old_body,new_body = bodies + return ''.join(difflib.unified_diff( + old_body.splitlines(True), + new_body.splitlines(True), + 'before', 'after')) diff --git a/libbe/error.py b/libbe/error.py new file mode 100644 index 0000000..798136e --- /dev/null +++ b/libbe/error.py @@ -0,0 +1,26 @@ +# Copyright (C) 2009-2010 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. + +""" +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/storage/__init__.py b/libbe/storage/__init__.py new file mode 100644 index 0000000..6bceac9 --- /dev/null +++ b/libbe/storage/__init__.py @@ -0,0 +1,74 @@ +# Copyright (C) 2009-2010 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 :class:`~libbe.storage.base.Storage` and +:class:`~libbe.storage.base.VersionedStorage` classes for storing BE +data. + +Also define assorted implementations for the Storage classes: + +* :mod:`libbe.storage.vcs` +* :mod:`libbe.storage.http` + +Also define an assortment of storage-related tools and utilities: + +* :mod:`libbe.storage.util` +""" + +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_http_storage(location): + import http + return http.HTTP(location) + +def get_vcs_storage(location): + import vcs + s = vcs.detect_vcs(location) + s.repo = location + return s + +def get_storage(location): + """ + Return a Storage instance from a repo location string. + """ + if location.startswith('http://') or location.startswith('https://'): + return get_http_storage(location) + return get_vcs_storage(location) + +__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..0ae9c53 --- /dev/null +++ b/libbe/storage/base.py @@ -0,0 +1,1070 @@ +# Copyright (C) 2009-2010 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. + +""" +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 +import libbe.version +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): + KeyError.__init__(self, id) + self.msg = msg + self.id = id + self.revision = revision + def __str__(self): + if self.msg == None: + return '%s in revision %s' % (self.id, self.revision) + return self.msg + + +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 _EMPTY (object): + """Entry has been added but has no user-set value.""" + pass + +class Entry (Tree): + def __init__(self, id, value=_EMPTY, 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 libbe.version.version() + + 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.') + if not self.exists(id): + 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 exists(self, *args, **kwargs): + """Check an entry's existence""" + if self.is_readable() == False: + raise NotReadable('Cannot check entry existence in unreadable storage.') + return self._exists(*args, **kwargs) + + def _exists(self, id, revision=None): + return id in self._data + + 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 ancestors(self, *args, **kwargs): + """Return a list of the specified entry's ancestors' ids.""" + if self.is_readable() == False: + raise NotReadable('Cannot list parents with unreadable storage.') + return self._ancestors(*args, **kwargs) + + def _ancestors(self, id=None, revision=None): + if id == None: + return [] + ancestors = [] + stack = [id] + while len(stack) > 0: + id = stack.pop(0) + parent = self._data[id].parent + if parent != None and not parent.id.startswith('__'): + ancestor = parent.id + ancestors.append(ancestor) + stack.append(ancestor) + return ancestors + + 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 and self._data[id].value != _EMPTY: + 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 _exists(self, id, revision=None): + if revision == None: + revision = -1 + else: + revision = int(revision) + return id in self._data[revision] + + 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 _ancestors(self, id=None, revision=None): + if id == None: + return [] + if revision == None: + revision = -1 + else: + revision = int(revision) + ancestors = [] + stack = [id] + while len(stack) > 0: + id = stack.pop(0) + parent = self._data[revision][id].parent + if parent != None and not parent.id.startswith('__'): + ancestor = parent.id + ancestors.append(ancestor) + stack.append(ancestor) + return ancestors + + def _children(self, id=None, revision=None): + if id == None: + id = '__ROOT__' + if revision == None: + revision = -1 + else: + revision = int(revision) + 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 + else: + revision = int(revision) + if id in self._data[revision] \ + and self._data[revision][id].value != _EMPTY: + 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 = str(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 str(index % L) + raise InvalidRevision(i) + + def changed(self, revision): + """Return a tuple of lists of ids `(new, modified, removed)` from the + specified revision to the current situation. + """ + new = [] + modified = [] + removed = [] + for id,value in self._data[int(revision)].items(): + if id.startswith('__'): + continue + if not id in self._data[-1]: + removed.append(id) + elif value.value != self._data[-1][id].value: + modified.append(id) + for id in self._data[-1]: + if not id in self._data[int(revision)]: + new.append(id) + return (new, modified, removed) + + +if TESTING == True: + class StorageTestCase (unittest.TestCase): + """Test cases for Storage class.""" + + Class = Storage + + def __init__(self, *args, **kwargs): + super(StorageTestCase, self).__init__(*args, **kwargs) + self.dirname = None + + # this class will be the basis of tests for several classes, + # so make sure we print the name of the class we're dealing with. + def _classname(self): + version = '?' + try: + if hasattr(self, 's'): + version = self.s.version() + except: + pass + return '%s:%s' % (self.Class.__name__, version) + + def fail(self, msg=None): + """Fail immediately, with the given message.""" + raise self.failureException, \ + '(%s) %s' % (self._classname(), msg) + + def failIf(self, expr, msg=None): + "Fail the test if the expression is true." + if expr: raise self.failureException, \ + '(%s) %s' % (self._classname(), msg) + + def failUnless(self, expr, msg=None): + """Fail the test unless the expression is true.""" + if not expr: raise self.failureException, \ + '(%s) %s' % (self._classname(), msg) + + 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_ancestors(self): + """Check ancestors lists. + """ + self.s.add('parent', directory=True) + for i in range(10): + i_id = str(i) + self.s.add(i_id, 'parent', directory=True) + for j in range(10): # add some grandkids + j_id = str(20*(i+1)+j) + self.s.add(j_id, i_id, directory=(i%2 == 0)) + ancestors = sorted(self.s.ancestors(j_id)) + self.failUnless(ancestors == [i_id, 'parent'], + 'Unexpected ancestors for %s/%s, "%s"' + % (i_id, j_id, ancestors)) + + 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 default before any value has been set. + """ + self.s.add(self.id, directory=False) + val = 'UNLIKELY DEFAULT' + ret = self.s.get(self.id, default=val) + self.failUnless(ret == val, + "%s.get() returned %s not %s" + % (vars(self.Class)['name'], ret, val)) + + 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_empty_get_set_persistence(self): + """After empty set, get may return either an empty string or default. + """ + self.s.add(self.id, directory=False) + self.s.set(self.id, '') + self.s.disconnect() + self.s.connect() + default = 'UNLIKELY DEFAULT' + ret = self.s.get(self.id, default=default) + self.failUnless(ret in ['', default], + "%s.get() returned %s not in %s" + % (vars(self.Class)['name'], ret, ['', default])) + + 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 VersionedStorage methods.""" + + Class = VersionedStorage + + class VersionedStorage_commit_TestCase (VersionedStorageTestCase): + """Test cases for VersionedStorage.commit and revision_ids 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') + 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 = sorted(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])) + + class VersionedStorage_changed_TestCase (VersionedStorageTestCase): + """Test cases for VersionedStorage.changed() method.""" + + def test_changed(self): + """Changed lists should reflect past activity""" + self.s.add('dir', directory=True) + self.s.add('modified', parent='dir') + self.s.set('modified', 'some value to be modified') + self.s.add('moved', parent='dir') + self.s.set('moved', 'this entry will be moved') + self.s.add('removed', parent='dir') + self.s.set('removed', 'this entry will be deleted') + revA = self.s.commit('Initial state') + self.s.add('new', parent='dir') + self.s.set('new', 'this entry is new') + self.s.set('modified', 'a new value') + self.s.remove('moved') + self.s.add('moved2', parent='dir') + self.s.set('moved2', 'this entry will be moved') + self.s.remove('removed') + revB = self.s.commit('Final state') + new,mod,rem = self.s.changed(revA) + self.failUnless(sorted(new) == ['moved2', 'new'], + 'Unexpected new: %s' % new) + self.failUnless(mod == ['modified'], + 'Unexpected modified: %s' % mod) + self.failUnless(sorted(rem) == ['moved', 'removed'], + 'Unexpected removed: %s' % rem) + + 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) + or + (issubclass(c, VersionedStorageTestCase) \ + and c.Class == VersionedStorage))] + + 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/http.py b/libbe/storage/http.py new file mode 100644 index 0000000..7ec9f54 --- /dev/null +++ b/libbe/storage/http.py @@ -0,0 +1,446 @@ +# Copyright (C) 2010 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. + +# For urllib2 information, see +# urllib2, from urllib2 - The Missing Manual +# http://www.voidspace.org.uk/python/articles/urllib2.shtml +# +# A dictionary of response codes is available in +# httplib.responses + +"""Define an HTTP-based :class:`~libbe.storage.base.VersionedStorage` +implementation. + +See Also +-------- +:mod:`libbe.command.serve` : the associated server + +""" + +import sys +import urllib +import urllib2 +import urlparse + +import libbe +import libbe.version +import base +from libbe import TESTING + +if TESTING == True: + import copy + import doctest + import StringIO + import unittest + + import libbe.bugdir + import libbe.command.serve + + +USER_AGENT = 'BE-HTTP-Storage' +HTTP_OK = 200 +HTTP_FOUND = 302 +HTTP_TEMP_REDIRECT = 307 +HTTP_USER_ERROR = 418 +"""Status returned to indicate exceptions on the server side. + +A BE-specific extension to the HTTP/1.1 protocol (See `RFC 2616`_). + +.. _RFC 2616: http://www.w3.org/Protocols/rfc2616/rfc2616-sec6.html#sec6.1.1 +""" + +HTTP_VALID = [HTTP_OK, HTTP_FOUND, HTTP_TEMP_REDIRECT, HTTP_USER_ERROR] + +class InvalidURL (Exception): + def __init__(self, error=None, url=None, msg=None): + Exception.__init__(self, msg) + self.url = url + self.error = error + self.msg = msg + def __str__(self): + if self.msg == None: + if self.error == None: + return "Unknown URL error: %s" % self.url + return self.error.__str__() + return self.msg + +def get_post_url(url, get=True, data_dict=None, headers=[]): + """Execute a GET or POST transaction. + + Parameters + ---------- + url : str + The base URL (query portion added internally, if necessary). + get : bool + Use GET if True, otherwise use POST. + data_dict : dict + Data to send, either by URL query (if GET) or by POST (if POST). + headers : list + Extra HTTP headers to add to the request. + """ + if data_dict == None: + data_dict = {} + if get == True: + if data_dict != {}: + # encode get parameters in the url + param_string = urllib.urlencode(data_dict) + url = "%s?%s" % (url, param_string) + data = None + else: + data = urllib.urlencode(data_dict) + headers = dict(headers) + headers['User-Agent'] = USER_AGENT + req = urllib2.Request(url, data=data, headers=headers) + try: + response = urllib2.urlopen(req) + except urllib2.HTTPError, e: + if hasattr(e, 'reason'): + msg = 'We failed to reach a server.\nURL: %s\nReason: %s' \ + % (url, e.reason) + elif hasattr(e, 'code'): + msg = "The server couldn't fulfill the request.\nURL: %s\nError code: %s" \ + % (url, e.code) + raise InvalidURL(error=e, url=url, msg=msg) + page = response.read() + final_url = response.geturl() + info = response.info() + response.close() + return (page, final_url, info) + + +class HTTP (base.VersionedStorage): + """:class:`~libbe.storage.base.VersionedStorage` implementation over + HTTP. + + Uses GET to retrieve information and POST to set information. + """ + name = 'HTTP' + + def __init__(self, repo, *args, **kwargs): + repo,self.uname,self.password = self.parse_repo(repo) + base.VersionedStorage.__init__(self, repo, *args, **kwargs) + + def parse_repo(self, repo): + """Grab username and password (if any) from the repo URL. + + Examples + -------- + + >>> s = HTTP('http://host.com/path/to/repo') + >>> s.repo + 'http://host.com/path/to/repo' + >>> s.uname == None + True + >>> s.password == None + True + >>> s.parse_repo('http://joe:secret@host.com/path/to/repo') + ('http://host.com/path/to/repo', 'joe', 'secret') + """ + scheme,netloc,path,params,query,fragment = urlparse.urlparse(repo) + parts = netloc.split('@', 1) + if len(parts) == 2: + uname,password = parts[0].split(':') + repo = urlparse.urlunparse( + (scheme, parts[1], path, params, query, fragment)) + else: + uname,password = (None, None) + return (repo, uname, password) + + def get_post_url(self, url, get=True, data_dict=None, headers=[]): + if self.uname != None and self.password != None: + headers.append(('Authorization','Basic %s' % \ + ('%s:%s' % (self.uname, self.password)).encode('base64'))) + return get_post_url(url, get, data_dict, headers) + + def storage_version(self, revision=None): + """Return the storage format for this backend.""" + return libbe.storage.STORAGE_VERSION + + def _init(self): + """Create a new storage repository.""" + raise base.NotSupported( + 'init', 'Cannot initialize this repository format.') + + def _destroy(self): + """Remove the storage repository.""" + raise base.NotSupported( + 'destroy', 'Cannot destroy this repository format.') + + def _connect(self): + self.check_storage_version() + + def _disconnect(self): + pass + + def _add(self, id, parent=None, directory=False): + url = urlparse.urljoin(self.repo, 'add') + page,final_url,info = self.get_post_url( + url, get=False, + data_dict={'id':id, 'parent':parent, 'directory':directory}) + + def _exists(self, id, revision=None): + url = urlparse.urljoin(self.repo, 'exists') + page,final_url,info = self.get_post_url( + url, get=True, + data_dict={'id':id, 'revision':revision}) + if page == 'True': + return True + return False + + def _remove(self, id): + url = urlparse.urljoin(self.repo, 'remove') + page,final_url,info = self.get_post_url( + url, get=False, + data_dict={'id':id, 'recursive':False}) + + def _recursive_remove(self, id): + url = urlparse.urljoin(self.repo, 'remove') + page,final_url,info = self.get_post_url( + url, get=False, + data_dict={'id':id, 'recursive':True}) + + def _ancestors(self, id=None, revision=None): + url = urlparse.urljoin(self.repo, 'ancestors') + page,final_url,info = self.get_post_url( + url, get=True, + data_dict={'id':id, 'revision':revision}) + return page.strip('\n').splitlines() + + def _children(self, id=None, revision=None): + url = urlparse.urljoin(self.repo, 'children') + page,final_url,info = self.get_post_url( + url, get=True, + data_dict={'id':id, 'revision':revision}) + return page.strip('\n').splitlines() + + def _get(self, id, default=base.InvalidObject, revision=None): + url = urlparse.urljoin(self.repo, '/'.join(['get', id])) + try: + page,final_url,info = self.get_post_url( + url, get=True, + data_dict={'revision':revision}) + except InvalidURL, e: + if not (hasattr(e.error, 'code') and e.error.code in HTTP_VALID): + raise + elif default == base.InvalidObject: + raise base.InvalidID(id) + return default + version = info['X-BE-Version'] + if version != libbe.storage.STORAGE_VERSION: + raise base.InvalidStorageVersion( + version, libbe.storage.STORAGE_VERSION) + return page + + def _set(self, id, value): + url = urlparse.urljoin(self.repo, '/'.join(['set', id])) + try: + page,final_url,info = self.get_post_url( + url, get=False, + data_dict={'value':value}) + except InvalidURL, e: + if not (hasattr(e.error, 'code') and e.error.code in HTTP_VALID): + raise + if e.error.code == HTTP_USER_ERROR \ + and not 'InvalidID' in str(e.error): + raise base.InvalidDirectory( + 'Directory %s cannot have data' % id) + raise base.InvalidID(id) + + def _commit(self, summary, body=None, allow_empty=False): + url = urlparse.urljoin(self.repo, 'commit') + try: + page,final_url,info = self.get_post_url( + url, get=False, + data_dict={'summary':summary, 'body':body, + 'allow_empty':allow_empty}) + except InvalidURL, e: + if not (hasattr(e.error, 'code') and e.error.code in HTTP_VALID): + raise + if e.error.code == HTTP_USER_ERROR: + raise base.EmptyCommit + raise base.InvalidID(id) + return page.rstrip('\n') + + 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. + + Raises + ------ + InvalidRevision + If the specified revision does not exist. + """ + if index == None: + return None + try: + if int(index) != index: + raise base.InvalidRevision(index) + except ValueError: + raise base.InvalidRevision(index) + url = urlparse.urljoin(self.repo, 'revision-id') + try: + page,final_url,info = self.get_post_url( + url, get=True, + data_dict={'index':index}) + except InvalidURL, e: + if not (hasattr(e.error, 'code') and e.error.code in HTTP_VALID): + raise + if e.error.code == HTTP_USER_ERROR: + raise base.InvalidRevision(index) + raise base.InvalidID(id) + return page.rstrip('\n') + + def changed(self, revision=None): + url = urlparse.urljoin(self.repo, 'changed') + page,final_url,info = self.get_post_url( + url, get=True, + data_dict={'revision':revision}) + lines = page.strip('\n') + new,mod,rem = [p.splitlines() for p in page.split('\n\n')] + return (new, mod, rem) + + def check_storage_version(self): + version = self.storage_version() + if version != libbe.storage.STORAGE_VERSION: + raise base.InvalidStorageVersion( + version, libbe.storage.STORAGE_VERSION) + + def storage_version(self, revision=None): + url = urlparse.urljoin(self.repo, 'version') + page,final_url,info = self.get_post_url( + url, get=True, data_dict={'revision':revision}) + return page.rstrip('\n') + +if TESTING == True: + class GetPostUrlTestCase (unittest.TestCase): + """Test cases for get_post_url()""" + def test_get(self): + url = 'http://bugseverywhere.org/be/show/HomePage' + page,final_url,info = get_post_url(url=url) + self.failUnless(final_url == url, + 'Redirect?\n Expected: "%s"\n Got: "%s"' + % (url, final_url)) + def test_get_redirect(self): + url = 'http://bugseverywhere.org' + expected = 'http://bugseverywhere.org/be/show/HomePage' + page,final_url,info = get_post_url(url=url) + self.failUnless(final_url == expected, + 'Redirect?\n Expected: "%s"\n Got: "%s"' + % (expected, final_url)) + + class TestingHTTP (HTTP): + name = 'TestingHTTP' + def __init__(self, repo, *args, **kwargs): + self._storage_backend = base.VersionedStorage(repo) + self.app = libbe.command.serve.ServerApp( + storage=self._storage_backend) + HTTP.__init__(self, repo='http://localhost:8000/', *args, **kwargs) + self.intitialized = False + # duplicated from libbe.storage.serve.WSGITestCase + self.default_environ = { + 'REQUEST_METHOD': 'GET', # 'POST', 'HEAD' + 'SCRIPT_NAME':'', + 'PATH_INFO': '', + #'QUERY_STRING':'', # may be empty or absent + #'CONTENT_TYPE':'', # may be empty or absent + #'CONTENT_LENGTH':'', # may be empty or absent + 'SERVER_NAME':'example.com', + 'SERVER_PORT':'80', + 'SERVER_PROTOCOL':'HTTP/1.1', + 'wsgi.version':(1,0), + 'wsgi.url_scheme':'http', + 'wsgi.input':StringIO.StringIO(), + 'wsgi.errors':StringIO.StringIO(), + 'wsgi.multithread':False, + 'wsgi.multiprocess':False, + 'wsgi.run_once':False, + } + def getURL(self, app, path='/', method='GET', data=None, + scheme='http', environ={}): + # duplicated from libbe.storage.serve.WSGITestCase + env = copy.copy(self.default_environ) + env['PATH_INFO'] = path + env['REQUEST_METHOD'] = method + env['scheme'] = scheme + if data != None: + enc_data = urllib.urlencode(data) + if method == 'POST': + env['CONTENT_LENGTH'] = len(enc_data) + env['wsgi.input'] = StringIO.StringIO(enc_data) + else: + assert method in ['GET', 'HEAD'], method + env['QUERY_STRING'] = enc_data + for key,value in environ.items(): + env[key] = value + return ''.join(app(env, self.start_response)) + def start_response(self, status, response_headers, exc_info=None): + self.status = status + self.response_headers = response_headers + self.exc_info = exc_info + def get_post_url(self, url, get=True, data_dict=None, headers=[]): + if get == True: + method = 'GET' + else: + method = 'POST' + scheme,netloc,path,params,query,fragment = urlparse.urlparse(url) + environ = {} + for header_name,header_value in headers: + environ['HTTP_%s' % header_name] = header_value + output = self.getURL( + self.app, path, method, data_dict, scheme, environ) + if self.status != '200 OK': + class __estr (object): + def __init__(self, string): + self.string = string + self.code = int(string.split()[0]) + def __str__(self): + return self.string + error = __estr(self.status) + raise InvalidURL(error=error, url=url, msg=output) + info = dict(self.response_headers) + return (output, url, info) + def _init(self): + try: + HTTP._init(self) + raise AssertionError + except base.NotSupported: + pass + self._storage_backend._init() + def _destroy(self): + try: + HTTP._destroy(self) + raise AssertionError + except base.NotSupported: + pass + self._storage_backend._destroy() + def _connect(self): + self._storage_backend._connect() + HTTP._connect(self) + def _disconnect(self): + HTTP._disconnect(self) + self._storage_backend._disconnect() + + + base.make_versioned_storage_testcase_subclasses( + TestingHTTP, 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/storage/util/config.py b/libbe/storage/util/config.py new file mode 100644 index 0000000..724d2d3 --- /dev/null +++ b/libbe/storage/util/config.py @@ -0,0 +1,114 @@ +# Copyright (C) 2005-2010 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. + +"""Create, save, and load the per-user config file at :func:`path`. +""" + +import ConfigParser +import codecs +import os.path + +import libbe +import libbe.util.encoding +if libbe.TESTING == True: + import doctest + + +default_encoding = libbe.util.encoding.get_filesystem_encoding() +"""Default filesystem encoding. + +Initialized with :func:`libbe.util.encoding.get_filesystem_encoding`. +""" + +def path(): + """Return the path to the per-user config file. + """ + return os.path.expanduser("~/.bugs_everywhere") + +def set_val(name, value, section="DEFAULT", encoding=None): + """Set a value in the per-user config file. + + Parameters + ---------- + name : str + The name of the value to set. + value : str or None + The new value to set (or None to delete the value). + section : str + The section to store the name/value in. + encoding : str + The config file's encoding, defaults to :data:`default_encoding`. + """ + if encoding == None: + encoding = default_encoding + config = ConfigParser.ConfigParser() + if os.path.exists(path()) == False: # touch file or config + open(path(), 'w').close() # read chokes on missing file + f = codecs.open(path(), 'r', encoding) + config.readfp(f, path()) + f.close() + if value is not None: + config.set(section, name, value) + else: + config.remove_option(section, name) + f = codecs.open(path(), 'w', encoding) + config.write(f) + f.close() + +def get_val(name, section="DEFAULT", default=None, encoding=None): + """Get a value from the per-user config file + + Parameters + ---------- + name : str + The name of the value to set. + section : str + The section to store the name/value in. + default : + The value to return if `name` is not set. + encoding : str + The config file's encoding, defaults to :data:`default_encoding`. + + Examples + -------- + + >>> get_val("junk") is None + True + >>> set_val("junk", "random") + >>> get_val("junk") + u'random' + >>> set_val("junk", None) + >>> get_val("junk") is None + True + """ + if os.path.exists(path()): + if encoding == None: + encoding = default_encoding + config = ConfigParser.ConfigParser() + f = codecs.open(path(), 'r', encoding) + config.readfp(f, path()) + f.close() + try: + return config.get(section, name) + except ConfigParser.NoOptionError: + return default + else: + return default + +if libbe.TESTING == True: + suite = doctest.DocTestSuite() diff --git a/libbe/storage/util/mapfile.py b/libbe/storage/util/mapfile.py new file mode 100644 index 0000000..55863d7 --- /dev/null +++ b/libbe/storage/util/mapfile.py @@ -0,0 +1,146 @@ +# Copyright (C) 2005-2010 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. + +"""Serializing and deserializing dictionaries of parameters. + +The serialized "mapfiles" should be clear, flat-text strings, and allow +easy merging of independent/conflicting changes. +""" + +import errno +import os.path +import types +import yaml + +import libbe +if libbe.TESTING == True: + import doctest + + +class IllegalKey(Exception): + def __init__(self, key): + Exception.__init__(self, 'Illegal key "%s"' % key) + self.key = key + +class IllegalValue(Exception): + def __init__(self, value): + Exception.__init__(self, 'Illegal value "%s"' % value) + self.value = value + +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. + + Examples + -------- + + >>> generate({'q':'p'}) + 'q: p\\n\\n' + >>> generate({'q':u'Fran\u00e7ais'}) + 'q: Fran\\xc3\\xa7ais\\n\\n' + >>> generate({'q':u'hello'}) + 'q: hello\\n\\n' + >>> generate({'q=':'p'}) + Traceback (most recent call last): + IllegalKey: Illegal key "q=" + >>> generate({'q:':'p'}) + Traceback (most recent call last): + IllegalKey: Illegal key "q:" + >>> generate({'q\\n':'p'}) + Traceback (most recent call last): + IllegalKey: Illegal key "q\\n" + >>> generate({'':'p'}) + Traceback (most recent call last): + IllegalKey: Illegal key "" + >>> generate({'>q':'p'}) + Traceback (most recent call last): + IllegalKey: Illegal key ">q" + >>> generate({'q':'p\\n'}) + Traceback (most recent call last): + IllegalValue: Illegal value "p\\n" + + See Also + -------- + parse : inverse + """ + keys = map.keys() + keys.sort() + for key in keys: + try: + assert not key.startswith('>') + assert('\n' not in key) + assert('=' not in key) + assert(':' not in key) + assert(len(key) > 0) + except AssertionError: + raise IllegalKey(unicode(key).encode('unicode_escape')) + if '\n' in map[key]: + raise IllegalValue(unicode(map[key]).encode('unicode_escape')) + + lines = [] + for key in keys: + lines.append(yaml.safe_dump({key: map[key]}, + default_flow_style=False, + allow_unicode=True)) + lines.append('') + return '\n'.join(lines) + +def parse(contents): + """Parse a YAML mapfile string. + + Examples + -------- + + >>> parse('q: p\\n\\n')['q'] + 'p' + >>> parse('q: \\'p\\'\\n\\n')['q'] + 'p' + >>> contents = generate({'a':'b', 'c':'d', 'e':'f'}) + >>> dict = parse(contents) + >>> dict['a'] + 'b' + >>> dict['c'] + 'd' + >>> dict['e'] + 'f' + >>> contents = generate({'q':u'Fran\u00e7ais'}) + >>> dict = parse(contents) + >>> dict['q'] + u'Fran\\xe7ais' + >>> dict = parse('a!') + Traceback (most recent call last): + ... + InvalidMapfileContents: Invalid YAML contents + + See Also + -------- + generate : inverse + + """ + 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/storage/util/properties.py b/libbe/storage/util/properties.py new file mode 100644 index 0000000..b5681b1 --- /dev/null +++ b/libbe/storage/util/properties.py @@ -0,0 +1,666 @@ +# Bugs Everywhere - a distributed bugtracker +# Copyright (C) 2008-2010 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. + +"""Provides a series of useful decorators for defining various types +of properties. + +For example usage, consider the unittests at the end of the module. + +Notes +----- + +See `PEP 318` and Michele Simionato's `decorator documentation` for +more information on decorators. + +.. _PEP 318: http://www.python.org/dev/peps/pep-0318/ +.. _decorator documentation: http://www.phyast.pitt.edu/~micheles/python/documentation.html + +See Also +-------- +:mod:`libbe.storage.util.settings_object` : bundle properties into a convenient package + +""" + +import copy +import types + +import libbe +if libbe.TESTING == True: + import unittest + + +class ValueCheckError (ValueError): + def __init__(self, name, value, allowed): + action = "in" # some list of allowed values + if type(allowed) == types.FunctionType: + action = "allowed by" # some allowed-value check function + msg = "%s not %s %s for %s" % (value, action, allowed, name) + ValueError.__init__(self, msg) + self.name = name + self.value = value + self.allowed = allowed + +def Property(funcs): + """ + End a chain of property decorators, returning a property. + """ + args = {} + args["fget"] = funcs.get("fget", None) + args["fset"] = funcs.get("fset", None) + args["fdel"] = funcs.get("fdel", None) + args["doc"] = funcs.get("doc", None) + + #print "Creating a property with" + #for key, val in args.items(): print key, value + return property(**args) + +def doc_property(doc=None): + """ + Add a docstring to a chain of property decorators. + """ + def decorator(funcs=None): + """ + Takes either a dict of funcs {"fget":fnX, "fset":fnY, ...} + or a function fn() returning such a dict. + """ + if hasattr(funcs, "__call__"): + funcs = funcs() # convert from function-arg to dict + funcs["doc"] = doc + return funcs + return decorator + +def local_property(name, null=None, mutable_null=False): + """ + Define get/set access to per-parent-instance local storage. Uses + ._<name>_value to store the value for a particular owner instance. + If the ._<name>_value attribute does not exist, returns null. + + If mutable_null == True, we only release deepcopies of the null to + the outside world. + """ + def decorator(funcs): + if hasattr(funcs, "__call__"): + funcs = funcs() + fget = funcs.get("fget", None) + fset = funcs.get("fset", None) + def _fget(self): + if fget is not None: + fget(self) + if mutable_null == True: + ret_null = copy.deepcopy(null) + else: + ret_null = null + value = getattr(self, "_%s_value" % name, ret_null) + return value + def _fset(self, value): + setattr(self, "_%s_value" % name, value) + if fset is not None: + fset(self, value) + funcs["fget"] = _fget + funcs["fset"] = _fset + funcs["name"] = name + return funcs + return decorator + +def settings_property(name, null=None): + """ + Similar to local_property, except where local_property stores the + value in instance._<name>_value, settings_property stores the + value in instance.settings[name]. + """ + def decorator(funcs): + if hasattr(funcs, "__call__"): + funcs = funcs() + fget = funcs.get("fget", None) + fset = funcs.get("fset", None) + def _fget(self): + if fget is not None: + fget(self) + value = self.settings.get(name, null) + return value + def _fset(self, value): + self.settings[name] = value + if fset is not None: + fset(self, value) + funcs["fget"] = _fget + funcs["fset"] = _fset + funcs["name"] = name + return funcs + return decorator + + +# Allow comparison and caching with _original_ values for mutables, +# since +# +# >>> a = [] +# >>> b = a +# >>> b.append(1) +# >>> a +# [1] +# >>> a==b +# True +def _hash_mutable_value(value): + return repr(value) +def _init_mutable_property_cache(self): + if not hasattr(self, "_mutable_property_cache_hash"): + # first call to _fget for any mutable property + self._mutable_property_cache_hash = {} + self._mutable_property_cache_copy = {} +def _set_cached_mutable_property(self, cacher_name, property_name, value): + _init_mutable_property_cache(self) + self._mutable_property_cache_hash[(cacher_name, property_name)] = \ + _hash_mutable_value(value) + self._mutable_property_cache_copy[(cacher_name, property_name)] = \ + copy.deepcopy(value) +def _get_cached_mutable_property(self, cacher_name, property_name, default=None): + _init_mutable_property_cache(self) + if (cacher_name, property_name) not in self._mutable_property_cache_copy: + return default + return self._mutable_property_cache_copy[(cacher_name, property_name)] +def _cmp_cached_mutable_property(self, cacher_name, property_name, value, default=None): + _init_mutable_property_cache(self) + if (cacher_name, property_name) not in self._mutable_property_cache_hash: + _set_cached_mutable_property(self, cacher_name, property_name, default) + old_hash = self._mutable_property_cache_hash[(cacher_name, property_name)] + return cmp(_hash_mutable_value(value), old_hash) + + +def defaulting_property(default=None, null=None, + mutable_default=False): + """ + Define a default value for get access to a property. + If the stored value is null, then default is returned. + + If mutable_default == True, we only release deepcopies of the + default to the outside world. + + null should never escape to the outside world, so don't worry + about it being a mutable. + """ + def decorator(funcs): + if hasattr(funcs, "__call__"): + funcs = funcs() + fget = funcs.get("fget") + fset = funcs.get("fset") + name = funcs.get("name", "<unknown>") + def _fget(self): + value = fget(self) + if value == null: + if mutable_default == True: + return copy.deepcopy(default) + else: + return default + return value + def _fset(self, value): + if value == default: + value = null + fset(self, value) + funcs["fget"] = _fget + funcs["fset"] = _fset + return funcs + return decorator + +def fn_checked_property(value_allowed_fn): + """ + Define allowed values for get/set access to a property. + """ + def decorator(funcs): + if hasattr(funcs, "__call__"): + funcs = funcs() + fget = funcs.get("fget") + fset = funcs.get("fset") + name = funcs.get("name", "<unknown>") + def _fget(self): + value = fget(self) + if value_allowed_fn(value) != True: + raise ValueCheckError(name, value, value_allowed_fn) + return value + def _fset(self, value): + if value_allowed_fn(value) != True: + raise ValueCheckError(name, value, value_allowed_fn) + fset(self, value) + funcs["fget"] = _fget + funcs["fset"] = _fset + return funcs + return decorator + +def checked_property(allowed=[]): + """ + Define allowed values for get/set access to a property. + """ + def decorator(funcs): + if hasattr(funcs, "__call__"): + funcs = funcs() + fget = funcs.get("fget") + fset = funcs.get("fset") + name = funcs.get("name", "<unknown>") + def _fget(self): + value = fget(self) + if value not in allowed: + raise ValueCheckError(name, value, allowed) + return value + def _fset(self, value): + if value not in allowed: + raise ValueCheckError(name, value, allowed) + fset(self, value) + funcs["fget"] = _fget + funcs["fset"] = _fset + return funcs + return decorator + +def cached_property(generator, initVal=None, mutable=False): + """ + Allow caching of values generated by generator(instance), where + instance is the instance to which this property belongs. Uses + ._<name>_cache to store a cache flag for a particular owner + instance. + + When the cache flag is True or missing and the stored value is + initVal, the first fget call triggers the generator function, + whose output is stored in _<name>_cached_value. That and + subsequent calls to fget will return this cached value. + + If the input value is no longer initVal (e.g. a value has been + loaded from disk or set with fset), that value overrides any + cached value, and this property has no effect. + + When the cache flag is False and the stored value is initVal, the + generator is not cached, but is called on every fget. + + The cache flag is missing on initialization. Particular instances + may override by setting their own flag. + + In the case that mutable == True, all caching is disabled and the + generator is called whenever the cached value would otherwise be + used. + """ + def decorator(funcs): + if hasattr(funcs, "__call__"): + funcs = funcs() + fget = funcs.get("fget") + name = funcs.get("name", "<unknown>") + def _fget(self): + cache = getattr(self, "_%s_cache" % name, True) + value = fget(self) + if value == initVal: + if cache == True and mutable == False: + if hasattr(self, "_%s_cached_value" % name): + value = getattr(self, "_%s_cached_value" % name) + else: + value = generator(self) + setattr(self, "_%s_cached_value" % name, value) + else: + value = generator(self) + return value + funcs["fget"] = _fget + return funcs + return decorator + +def primed_property(primer, initVal=None, unprimeableVal=None): + """ + Just like a cached_property, except that instead of returning a + new value and running fset to cache it, the primer attempts some + background manipulation (e.g. loads data into instance.settings) + such that a _second_ pass through fget succeeds. If the second + pass doesn't succeed (e.g. no readable storage), we give up and + return unprimeableVal. + + The 'cache' flag becomes a 'prime' flag, with priming taking place + whenever ._<name>_prime is True, or is False or missing and + value == initVal. + """ + def decorator(funcs): + if hasattr(funcs, "__call__"): + funcs = funcs() + fget = funcs.get("fget") + name = funcs.get("name", "<unknown>") + def _fget(self): + prime = getattr(self, "_%s_prime" % name, False) + if prime == False: + value = fget(self) + if prime == True or (prime == False and value == initVal): + primer(self) + value = fget(self) + if prime == False and value == initVal: + return unprimeableVal + return value + funcs["fget"] = _fget + return funcs + return decorator + +def change_hook_property(hook, mutable=False, default=None): + """Call the function `hook` whenever a value different from the + current value is set. + + This is useful for saving changes to disk, etc. This function is + called *after* the new value has been stored, allowing you to + change the stored value if you want. + + In the case of mutables, things are slightly trickier. Because + the property-owning class has no way of knowing when the value + changes. We work around this by caching a private deepcopy of the + mutable value, and checking for changes whenever the property is + set (obviously) or retrieved (to check for external changes). So + long as you're conscientious about accessing the property after + making external modifications, mutability won't be a problem:: + + t.x.append(5) # external modification + t.x # dummy access notices change and triggers hook + + See :class:`testChangeHookMutableProperty` for an example of the + expected behavior. + + Parameters + ---------- + hook : fn + `hook(instance, old_value, new_value)`, where `instance` is a + reference to the class instance to which this property belongs. + """ + def decorator(funcs): + if hasattr(funcs, "__call__"): + funcs = funcs() + fget = funcs.get("fget") + fset = funcs.get("fset") + name = funcs.get("name", "<unknown>") + def _fget(self, new_value=None, from_fset=False): # only used if mutable == True + if from_fset == True: + value = new_value # compare new value with cached + else: + value = fget(self) # compare current value with cached + if _cmp_cached_mutable_property(self, "change hook property", name, value, default) != 0: + # there has been a change, cache new value + old_value = _get_cached_mutable_property(self, "change hook property", name, default) + _set_cached_mutable_property(self, "change hook property", name, value) + if from_fset == True: # return previously cached value + value = old_value + else: # the value changed while we weren't looking + hook(self, old_value, value) + return value + def _fset(self, value): + if mutable == True: # get cached previous value + old_value = _fget(self, new_value=value, from_fset=True) + else: + old_value = fget(self) + fset(self, value) + if value != old_value: + hook(self, old_value, value) + if mutable == True: + funcs["fget"] = _fget + funcs["fset"] = _fset + return funcs + return decorator + +if libbe.TESTING == True: + class DecoratorTests(unittest.TestCase): + def testLocalDoc(self): + class Test(object): + @Property + @doc_property("A fancy property") + def x(): + return {} + self.failUnless(Test.x.__doc__ == "A fancy property", + Test.x.__doc__) + def testLocalProperty(self): + class Test(object): + @Property + @local_property(name="LOCAL") + def x(): + return {} + t = Test() + self.failUnless(t.x == None, str(t.x)) + t.x = 'z' # the first set initializes ._LOCAL_value + self.failUnless(t.x == 'z', str(t.x)) + self.failUnless("_LOCAL_value" in dir(t), dir(t)) + self.failUnless(t._LOCAL_value == 'z', t._LOCAL_value) + def testSettingsProperty(self): + class Test(object): + @Property + @settings_property(name="attr") + def x(): + return {} + def __init__(self): + self.settings = {} + t = Test() + self.failUnless(t.x == None, str(t.x)) + t.x = 'z' # the first set initializes ._LOCAL_value + self.failUnless(t.x == 'z', str(t.x)) + self.failUnless("attr" in t.settings, t.settings) + self.failUnless(t.settings["attr"] == 'z', t.settings["attr"]) + def testDefaultingLocalProperty(self): + class Test(object): + @Property + @defaulting_property(default='y', null='x') + @local_property(name="DEFAULT", null=5) + def x(): return {} + t = Test() + self.failUnless(t.x == 5, str(t.x)) + t.x = 'x' + self.failUnless(t.x == 'y', str(t.x)) + t.x = 'y' + self.failUnless(t.x == 'y', str(t.x)) + t.x = 'z' + self.failUnless(t.x == 'z', str(t.x)) + t.x = 5 + self.failUnless(t.x == 5, str(t.x)) + def testCheckedLocalProperty(self): + class Test(object): + @Property + @checked_property(allowed=['x', 'y', 'z']) + @local_property(name="CHECKED") + def x(): return {} + def __init__(self): + self._CHECKED_value = 'x' + t = Test() + self.failUnless(t.x == 'x', str(t.x)) + try: + t.x = None + e = None + except ValueCheckError, e: + pass + self.failUnless(type(e) == ValueCheckError, type(e)) + def testTwoCheckedLocalProperties(self): + class Test(object): + @Property + @checked_property(allowed=['x', 'y', 'z']) + @local_property(name="X") + def x(): return {} + + @Property + @checked_property(allowed=['a', 'b', 'c']) + @local_property(name="A") + def a(): return {} + def __init__(self): + self._A_value = 'a' + self._X_value = 'x' + t = Test() + try: + t.x = 'a' + e = None + except ValueCheckError, e: + pass + self.failUnless(type(e) == ValueCheckError, type(e)) + t.x = 'x' + t.x = 'y' + t.x = 'z' + try: + t.a = 'x' + e = None + except ValueCheckError, e: + pass + self.failUnless(type(e) == ValueCheckError, type(e)) + t.a = 'a' + t.a = 'b' + t.a = 'c' + def testFnCheckedLocalProperty(self): + class Test(object): + @Property + @fn_checked_property(lambda v : v in ['x', 'y', 'z']) + @local_property(name="CHECKED") + def x(): return {} + def __init__(self): + self._CHECKED_value = 'x' + t = Test() + self.failUnless(t.x == 'x', str(t.x)) + try: + t.x = None + e = None + except ValueCheckError, e: + pass + self.failUnless(type(e) == ValueCheckError, type(e)) + def testCachedLocalProperty(self): + class Gen(object): + def __init__(self): + self.i = 0 + def __call__(self, owner): + self.i += 1 + return self.i + class Test(object): + @Property + @cached_property(generator=Gen(), initVal=None) + @local_property(name="CACHED") + def x(): return {} + t = Test() + self.failIf("_CACHED_cache" in dir(t), + getattr(t, "_CACHED_cache", None)) + self.failUnless(t.x == 1, t.x) + self.failUnless(t.x == 1, t.x) + self.failUnless(t.x == 1, t.x) + t.x = 8 + self.failUnless(t.x == 8, t.x) + self.failUnless(t.x == 8, t.x) + t._CACHED_cache = False # Caching is off, but the stored value + val = t.x # is 8, not the initVal (None), so we + self.failUnless(val == 8, val) # get 8. + t._CACHED_value = None # Now we've set the stored value to None + val = t.x # so future calls to fget (like this) + self.failUnless(val == 2, val) # will call the generator every time... + val = t.x + self.failUnless(val == 3, val) + val = t.x + self.failUnless(val == 4, val) + t._CACHED_cache = True # We turn caching back on, and get + self.failUnless(t.x == 1, str(t.x)) # the original cached value. + del t._CACHED_cached_value # Removing that value forces a + self.failUnless(t.x == 5, str(t.x)) # single cache-regenerating call + self.failUnless(t.x == 5, str(t.x)) # to the genenerator, after which + self.failUnless(t.x == 5, str(t.x)) # we get the new cached value. + def testPrimedLocalProperty(self): + class Test(object): + def prime(self): + self.settings["PRIMED"] = self.primeVal + @Property + @primed_property(primer=prime, initVal=None, unprimeableVal=2) + @settings_property(name="PRIMED") + def x(): return {} + def __init__(self): + self.settings={} + self.primeVal = "initialized" + t = Test() + self.failIf("_PRIMED_prime" in dir(t), + getattr(t, "_PRIMED_prime", None)) + self.failUnless(t.x == "initialized", t.x) + t.x = 1 + self.failUnless(t.x == 1, t.x) + t.x = None + self.failUnless(t.x == "initialized", t.x) + t._PRIMED_prime = True + t.x = 3 + self.failUnless(t.x == "initialized", t.x) + t._PRIMED_prime = False + t.x = 3 + self.failUnless(t.x == 3, t.x) + # test unprimableVal + t.x = None + t.primeVal = None + self.failUnless(t.x == 2, t.x) + def testChangeHookLocalProperty(self): + class Test(object): + def _hook(self, old, new): + self.old = old + self.new = new + + @Property + @change_hook_property(_hook) + @local_property(name="HOOKED") + def x(): return {} + t = Test() + t.x = 1 + self.failUnless(t.old == None, t.old) + self.failUnless(t.new == 1, t.new) + t.x = 1 + self.failUnless(t.old == None, t.old) + self.failUnless(t.new == 1, t.new) + t.x = 2 + self.failUnless(t.old == 1, t.old) + self.failUnless(t.new == 2, t.new) + def testChangeHookMutableProperty(self): + class Test(object): + def _hook(self, old, new): + self.old = old + self.new = new + self.hook_calls += 1 + + @Property + @change_hook_property(_hook, mutable=True) + @local_property(name="HOOKED") + def x(): return {} + t = Test() + t.hook_calls = 0 + t.x = [] + self.failUnless(t.old == None, t.old) + self.failUnless(t.new == [], t.new) + self.failUnless(t.hook_calls == 1, t.hook_calls) + a = t.x + a.append(5) + t.x = a + self.failUnless(t.old == [], t.old) + self.failUnless(t.new == [5], t.new) + self.failUnless(t.hook_calls == 2, t.hook_calls) + t.x = [] + self.failUnless(t.old == [5], t.old) + self.failUnless(t.new == [], t.new) + self.failUnless(t.hook_calls == 3, t.hook_calls) + # now append without reassigning. this doesn't trigger the + # change, since we don't ever set t.x, only get it and mess + # with it. It does, however, update our t.new, since t.new = + # t.x and is not a static copy. + t.x.append(5) + self.failUnless(t.old == [5], t.old) + self.failUnless(t.new == [5], t.new) + self.failUnless(t.hook_calls == 3, t.hook_calls) + # however, the next t.x get _will_ notice the change... + a = t.x + self.failUnless(t.old == [], t.old) + self.failUnless(t.new == [5], t.new) + self.failUnless(t.hook_calls == 4, t.hook_calls) + t.x.append(6) # this append(6) is not noticed yet + self.failUnless(t.old == [], t.old) + self.failUnless(t.new == [5,6], t.new) + self.failUnless(t.hook_calls == 4, t.hook_calls) + # this append(7) is not noticed, but the t.x get causes the + # append(6) to be noticed + t.x.append(7) + self.failUnless(t.old == [5], t.old) + self.failUnless(t.new == [5,6,7], t.new) + self.failUnless(t.hook_calls == 5, t.hook_calls) + a = t.x # now the append(7) is noticed + self.failUnless(t.old == [5,6], t.old) + self.failUnless(t.new == [5,6,7], t.new) + self.failUnless(t.hook_calls == 6, t.hook_calls) + + suite = unittest.TestLoader().loadTestsFromTestCase(DecoratorTests) diff --git a/libbe/storage/util/settings_object.py b/libbe/storage/util/settings_object.py new file mode 100644 index 0000000..6e4da55 --- /dev/null +++ b/libbe/storage/util/settings_object.py @@ -0,0 +1,617 @@ +# Bugs Everywhere - a distributed bugtracker +# Copyright (C) 2008-2010 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. + +"""Provides :class:`SavedSettingsObject` implementing settings-dict +based property storage. + +See Also +-------- +:mod:`libbe.storage.util.properties` : underlying property definitions +""" + +import libbe +from properties import Property, doc_property, local_property, \ + defaulting_property, checked_property, fn_checked_property, \ + cached_property, primed_property, change_hook_property, \ + settings_property +if libbe.TESTING == True: + import doctest + import unittest + +class _Token (object): + """`Control' value class for properties. + + We want values that only mean something to the `settings_object` + module. + """ + pass + +class UNPRIMED (_Token): + "Property has not been primed (loaded)." + pass + +class EMPTY (_Token): + """Property has been primed but has no user-set value, so use + default/generator value. + """ + pass + + +def prop_save_settings(self, old, new): + """The default action undertaken when a property changes. + """ + if self.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. + + Attempt to run `.load_settings()`, which calls + `._setup_saved_settings()` internally. If `.storage` is + inaccessible, don't do anything. + """ + if self.storage != None and self.storage.is_readable(): + self.load_settings() + +# Some name-mangling routines for pretty printing setting names +def setting_name_to_attr_name(self, name): + """Convert keys to the `.settings` dict into their associated + SavedSettingsObject attribute names. + + Examples + -------- + + >>> print setting_name_to_attr_name(None,"User-id") + user_id + + See Also + -------- + attr_name_to_setting_name : inverse + """ + return name.lower().replace('-', '_') + +def attr_name_to_setting_name(self, name): + """Convert SavedSettingsObject attribute names to `.settings` dict + keys. + + Examples: + + >>> print attr_name_to_setting_name(None, "user_id") + User-id + + See Also + -------- + setting_name_to_attr_name : inverse + """ + return name.capitalize().replace('_', '-') + + +def versioned_property(name, doc, + default=None, generator=None, + change_hook=prop_save_settings, + mutable=False, + primer=prop_load_settings, + allowed=None, check_fn=None, + settings_properties=[], + required_saved_properties=[], + require_save=False): + """Combine the common decorators in a single function. + + Use zero or one (but not both) of default or generator, since a + working default will keep the generator from functioning. Use the + default if you know what you want the default value to be at + 'coding time'. Use the generator if you can write a function to + determine a valid default at run time. If both default and + generator are None, then the property will be a defaulting + property which defaults to None. + + allowed and check_fn have a similar relationship, although you can + use both of these if you want. allowed compares the proposed + value against a list determined at 'coding time' and check_fn + allows more flexible comparisons to take place at run time. + + Set require_save to True if you want to save the default/generated + value for a property, to protect against future changes. E.g., we + currently expect all comments to be 'text/plain' but in the future + we may want to default to 'text/html'. If we don't want the old + comments to be interpreted as 'text/html', we would require that + the content type be saved. + + change_hook, primer, settings_properties, and + required_saved_properties are only options to get their defaults + into our local scope. Don't mess with them. + + Set mutable=True if: + + * default is a mutable + * your generator function may return mutables + * you set change_hook and might have mutable property values + + See the docstrings in `libbe.properties` for details on how each of + these cases are handled. + + The value stored in `.settings[name]` will be + + * no value (or UNPRIMED) if the property has been neither set, + nor loaded as blank. + * EMPTY if the value has been loaded as blank. + * some value if the property has been either loaded or set. + """ + settings_properties.append(name) + if require_save == True: + required_saved_properties.append(name) + def decorator(funcs): + fulldoc = doc + if default != None or generator == None: + defaulting = defaulting_property(default=default, null=EMPTY, + mutable_default=mutable) + fulldoc += "\n\nThis property defaults to %s." % default + if generator != None: + cached = cached_property(generator=generator, initVal=EMPTY, + mutable=mutable) + fulldoc += "\n\nThis property is generated with %s." % generator + if check_fn != None: + fn_checked = fn_checked_property(value_allowed_fn=check_fn) + fulldoc += "\n\nThis property is checked with %s." % check_fn + if allowed != None: + checked = checked_property(allowed=allowed) + fulldoc += "\n\nThe allowed values for this property are: %s." \ + % (', '.join(allowed)) + hooked = change_hook_property(hook=change_hook, mutable=mutable, + default=EMPTY) + primed = primed_property(primer=primer, initVal=UNPRIMED, + unprimeableVal=EMPTY) + settings = settings_property(name=name, null=UNPRIMED) + docp = doc_property(doc=fulldoc) + deco = hooked(primed(settings(docp(funcs)))) + if default != None or generator == None: + deco = defaulting(deco) + if generator != None: + deco = cached(deco) + if check_fn != None: + deco = fn_checked(deco) + if allowed != None: + deco = checked(deco) + return Property(deco) + return decorator + +class SavedSettingsObject(object): + """Setup a framework for lazy saving and loading of `.settings` + properties. + + This is useful for BE objects with saved properties + (e.g. :class:`~libbe.bugdir.BugDir`, :class:`~libbe.bug.Bug`, + :class:`~libbe.comment.Comment`). For example usage, consider the + unittests at the end of the module. + + See Also + -------- + versioned_property, prop_save_settings, prop_load_settings + setting_name_to_attr_name, attr_name_to_setting_name + """ + # Keep a list of properties that may be stored in the .settings dict. + #settings_properties = [] + + # A list of properties that we save to disk, even if they were + # never set (in which case we save the default value). This + # protects against future changes in default values. + #required_saved_properties = [] + + _setting_name_to_attr_name = setting_name_to_attr_name + _attr_name_to_setting_name = attr_name_to_setting_name + + def __init__(self): + self.storage = None + self.settings = {} + + def load_settings(self): + """Load the settings from disk.""" + # Override. Must call ._setup_saved_settings({}) with + # from-storage settings. + self._setup_saved_settings({}) + + def _setup_saved_settings(self, settings=None): + """ + Sets up a settings dict loaded from storage. Fills in + all missing settings entries with EMPTY. + """ + if settings == None: + settings = {} + for property in self.settings_properties: + if property not in self.settings \ + or self.settings[property] == UNPRIMED: + if property in settings: + self.settings[property] = settings[property] + else: + self.settings[property] = EMPTY + + def save_settings(self): + """Save the settings to disk.""" + # Override. Should save the dict output of ._get_saved_settings() + settings = self._get_saved_settings() + pass # write settings to disk.... + + def _get_saved_settings(self): + """ + In order to avoid overwriting unread on-disk data, make sure + we've loaded anything sitting on the disk. In the current + implementation, all the settings are stored in a single file, + so we need to load _all_ the saved settings. Another approach + would be per-setting saves, in which case you could skip this + step, since any setting changes would have forced that setting + load already. + """ + settings = {} + for k in self.settings_properties: # force full load + if not k in self.settings or self.settings[k] == UNPRIMED: + value = getattr( + self, self._setting_name_to_attr_name(k)) + for k in self.settings_properties: + if k in self.settings and self.settings[k] != EMPTY: + settings[k] = self.settings[k] + elif k in self.required_saved_properties: + settings[k] = getattr( + self, self._setting_name_to_attr_name(k)) + return settings + + def clear_cached_setting(self, setting=None): + "If setting=None, clear *all* cached settings" + if setting != None: + if hasattr(self, "_%s_cached_value" % setting): + delattr(self, "_%s_cached_value" % setting) + else: + for setting in settings_properties: + self.clear_cached_setting(setting) + + +if libbe.TESTING == True: + import copy + + class TestStorage (list): + def __init__(self): + list.__init__(self) + self.readable = True + self.writeable = True + def is_readable(self): + return self.readable + def is_writeable(self): + return self.writeable + + class TestObject (SavedSettingsObject): + def load_settings(self): + self.load_count += 1 + if len(self.storage) == 0: + settings = {} + else: + settings = copy.deepcopy(self.storage[-1]) + self._setup_saved_settings(settings) + def save_settings(self): + settings = self._get_saved_settings() + self.storage.append(copy.deepcopy(settings)) + def __init__(self): + SavedSettingsObject.__init__(self) + self.load_count = 0 + self.storage = TestStorage() + + class SavedSettingsObjectTests(unittest.TestCase): + def testSimplePropertyDoc(self): + """Testing a minimal versioned property docstring""" + class Test (TestObject): + settings_properties = [] + required_saved_properties = [] + @versioned_property( + name="Content-type", + doc="A test property", + settings_properties=settings_properties, + required_saved_properties=required_saved_properties) + def content_type(): return {} + expected = "A test property\n\nThis property defaults to None." + self.failUnless(Test.content_type.__doc__ == expected, + Test.content_type.__doc__) + def testSimplePropertyFromMemory(self): + """Testing a minimal versioned property from memory""" + class Test (TestObject): + settings_properties = [] + required_saved_properties = [] + @versioned_property( + name="Content-type", + doc="A test property", + settings_properties=settings_properties, + required_saved_properties=required_saved_properties) + def content_type(): return {} + t = Test() + self.failUnless(len(t.settings) == 0, len(t.settings)) + # accessing t.content_type triggers the priming, but + # t.storage.is_readable() == False, so nothing happens. + t.storage.readable = False + self.failUnless(t.content_type == None, t.content_type) + self.failUnless(t.settings == {}, t.settings) + self.failUnless(len(t.settings) == 0, len(t.settings)) + self.failUnless(t.content_type == None, t.content_type) + # accessing t.content_type triggers the priming again, and + # now that t.storage.is_readable() == True, this fills out + # t.settings with EMPTY data. At this point there should + # be one load and no saves. + t.storage.readable = True + self.failUnless(t.content_type == None, t.content_type) + self.failUnless(len(t.settings) == 1, len(t.settings)) + self.failUnless(t.settings["Content-type"] == EMPTY, + t.settings["Content-type"]) + self.failUnless(t.content_type == None, t.content_type) + self.failUnless(t.load_count == 1, t.load_count) + self.failUnless(len(t.storage) == 0, len(t.storage)) + # an explicit call to load settings forces a reload, + # but nothing else changes. + t.load_settings() + self.failUnless(len(t.settings) == 1, len(t.settings)) + self.failUnless(t.settings["Content-type"] == EMPTY, + t.settings["Content-type"]) + self.failUnless(t.content_type == None, t.content_type) + self.failUnless(t.load_count == 2, t.load_count) + self.failUnless(len(t.storage) == 0, len(t.storage)) + # now we set a value + t.content_type = 5 + self.failUnless(t.settings["Content-type"] == 5, + t.settings["Content-type"]) + self.failUnless(t.load_count == 2, t.load_count) + self.failUnless(len(t.storage) == 1, len(t.storage)) + self.failUnless(t.storage == [{'Content-type':5}], t.storage) + # getting its value changes nothing + self.failUnless(t.content_type == 5, t.content_type) + self.failUnless(t.settings["Content-type"] == 5, + t.settings["Content-type"]) + self.failUnless(t.load_count == 2, t.load_count) + self.failUnless(len(t.storage) == 1, len(t.storage)) + self.failUnless(t.storage == [{'Content-type':5}], t.storage) + # now we set another value + t.content_type = "text/plain" + self.failUnless(t.content_type == "text/plain", t.content_type) + self.failUnless(t.settings["Content-type"] == "text/plain", + t.settings["Content-type"]) + self.failUnless(t.load_count == 2, t.load_count) + self.failUnless(len(t.storage) == 2, len(t.storage)) + self.failUnless(t.storage == [{'Content-type':5}, + {'Content-type':'text/plain'}], + t.storage) + # t._get_saved_settings() returns a dict of required or + # non-default values. + self.failUnless(t._get_saved_settings() == \ + {"Content-type":"text/plain"}, + t._get_saved_settings()) + # now we clear to the post-primed value + t.content_type = EMPTY + self.failUnless(t.settings["Content-type"] == EMPTY, + t.settings["Content-type"]) + self.failUnless(t.content_type == None, t.content_type) + self.failUnless(len(t.settings) == 1, len(t.settings)) + self.failUnless(t.settings["Content-type"] == EMPTY, + t.settings["Content-type"]) + self.failUnless(t._get_saved_settings() == {}, + t._get_saved_settings()) + self.failUnless(t.storage == [{'Content-type':5}, + {'Content-type':'text/plain'}, + {}], + t.storage) + def testSimplePropertyFromStorage(self): + """Testing a minimal versioned property from storage""" + class Test (TestObject): + settings_properties = [] + required_saved_properties = [] + @versioned_property( + name="prop-a", + doc="A test property", + settings_properties=settings_properties, + required_saved_properties=required_saved_properties) + def prop_a(): return {} + @versioned_property( + name="prop-b", + doc="Another test property", + settings_properties=settings_properties, + required_saved_properties=required_saved_properties) + def prop_b(): return {} + t = Test() + t.storage.append({'prop-a':'saved'}) + # setting prop-b forces a load (to check for changes), + # which also pulls in prop-a. + t.prop_b = 'new-b' + settings = {'prop-b':'new-b', 'prop-a':'saved'} + self.failUnless(t.settings == settings, t.settings) + self.failUnless(t._get_saved_settings() == settings, + t._get_saved_settings()) + # test that _get_saved_settings() works even when settings + # were _not_ loaded beforehand + t = Test() + t.storage.append({'prop-a':'saved'}) + settings ={'prop-a':'saved'} + self.failUnless(t.settings == {}, t.settings) + self.failUnless(t._get_saved_settings() == settings, + t._get_saved_settings()) + def testSimplePropertySetStorageSave(self): + """Set a property, then attach storage and save""" + class Test (TestObject): + settings_properties = [] + required_saved_properties = [] + @versioned_property( + name="prop-a", + doc="A test property", + settings_properties=settings_properties, + required_saved_properties=required_saved_properties) + def prop_a(): return {} + @versioned_property( + name="prop-b", + doc="Another test property", + settings_properties=settings_properties, + required_saved_properties=required_saved_properties) + def prop_b(): return {} + t = Test() + storage = t.storage + t.storage = None + t.prop_a = 'text/html' + t.storage = storage + t.save_settings() + self.failUnless(t.prop_a == 'text/html', t.prop_a) + self.failUnless(t.settings == {'prop-a':'text/html', + 'prop-b':EMPTY}, + t.settings) + self.failUnless(t.load_count == 1, t.load_count) + self.failUnless(len(t.storage) == 1, len(t.storage)) + self.failUnless(t.storage == [{'prop-a':'text/html'}], + t.storage) + def testDefaultingProperty(self): + """Testing a defaulting versioned property""" + class Test (TestObject): + settings_properties = [] + required_saved_properties = [] + @versioned_property( + name="Content-type", + doc="A test property", + default="text/plain", + settings_properties=settings_properties, + required_saved_properties=required_saved_properties) + def content_type(): return {} + t = Test() + self.failUnless(t.settings == {}, t.settings) + self.failUnless(t.content_type == "text/plain", t.content_type) + self.failUnless(t.settings == {"Content-type":EMPTY}, + t.settings) + self.failUnless(t.load_count == 1, t.load_count) + self.failUnless(len(t.storage) == 0, len(t.storage)) + self.failUnless(t._get_saved_settings() == {}, + t._get_saved_settings()) + t.content_type = "text/html" + self.failUnless(t.content_type == "text/html", + t.content_type) + self.failUnless(t.settings == {"Content-type":"text/html"}, + t.settings) + self.failUnless(t.load_count == 1, t.load_count) + self.failUnless(len(t.storage) == 1, len(t.storage)) + self.failUnless(t.storage == [{'Content-type':'text/html'}], + t.storage) + self.failUnless(t._get_saved_settings() == \ + {"Content-type":"text/html"}, + t._get_saved_settings()) + def testRequiredDefaultingProperty(self): + """Testing a required defaulting versioned property""" + class Test (TestObject): + settings_properties = [] + required_saved_properties = [] + @versioned_property( + name="Content-type", + doc="A test property", + default="text/plain", + settings_properties=settings_properties, + required_saved_properties=required_saved_properties, + require_save=True) + def content_type(): return {} + t = Test() + self.failUnless(t.settings == {}, t.settings) + self.failUnless(t.content_type == "text/plain", t.content_type) + self.failUnless(t.settings == {"Content-type":EMPTY}, + t.settings) + self.failUnless(t.load_count == 1, t.load_count) + self.failUnless(len(t.storage) == 0, len(t.storage)) + self.failUnless(t._get_saved_settings() == \ + {"Content-type":"text/plain"}, + t._get_saved_settings()) + t.content_type = "text/html" + self.failUnless(t.content_type == "text/html", + t.content_type) + self.failUnless(t.settings == {"Content-type":"text/html"}, + t.settings) + self.failUnless(t.load_count == 1, t.load_count) + self.failUnless(len(t.storage) == 1, len(t.storage)) + self.failUnless(t.storage == [{'Content-type':'text/html'}], + t.storage) + self.failUnless(t._get_saved_settings() == \ + {"Content-type":"text/html"}, + t._get_saved_settings()) + def testClassVersionedPropertyDefinition(self): + """Testing a class-specific _versioned property decorator""" + class Test (TestObject): + settings_properties = [] + required_saved_properties = [] + def _versioned_property( + settings_properties=settings_properties, + required_saved_properties=required_saved_properties, + **kwargs): + if "settings_properties" not in kwargs: + kwargs["settings_properties"] = settings_properties + if "required_saved_properties" not in kwargs: + kwargs["required_saved_properties"] = \ + required_saved_properties + return versioned_property(**kwargs) + @_versioned_property(name="Content-type", + doc="A test property", + default="text/plain", + require_save=True) + def content_type(): return {} + t = Test() + self.failUnless(t._get_saved_settings() == \ + {"Content-type":"text/plain"}, + t._get_saved_settings()) + self.failUnless(t.load_count == 1, t.load_count) + self.failUnless(len(t.storage) == 0, len(t.storage)) + t.content_type = "text/html" + self.failUnless(t._get_saved_settings() == \ + {"Content-type":"text/html"}, + t._get_saved_settings()) + self.failUnless(t.load_count == 1, t.load_count) + self.failUnless(len(t.storage) == 1, len(t.storage)) + self.failUnless(t.storage == [{'Content-type':'text/html'}], + t.storage) + def testMutableChangeHookedProperty(self): + """Testing a mutable change-hooked property""" + class Test (TestObject): + settings_properties = [] + required_saved_properties = [] + @versioned_property( + name="List-type", + doc="A test property", + mutable=True, + change_hook=prop_save_settings, + settings_properties=settings_properties, + required_saved_properties=required_saved_properties) + def list_type(): return {} + t = Test() + self.failUnless(len(t.storage) == 0, len(t.storage)) + self.failUnless(t.list_type == None, t.list_type) + self.failUnless(len(t.storage) == 0, len(t.storage)) + self.failUnless(t.settings["List-type"]==EMPTY, + t.settings["List-type"]) + t.list_type = [] + self.failUnless(t.settings["List-type"] == [], + t.settings["List-type"]) + self.failUnless(len(t.storage) == 1, len(t.storage)) + self.failUnless(t.storage == [{'List-type':[]}], + t.storage) + t.list_type.append(5) # external modification not detected yet + self.failUnless(len(t.storage) == 1, len(t.storage)) + self.failUnless(t.storage == [{'List-type':[]}], + t.storage) + self.failUnless(t.settings["List-type"] == [5], + t.settings["List-type"]) + self.failUnless(t.list_type == [5], t.list_type)# get triggers save + self.failUnless(len(t.storage) == 2, len(t.storage)) + self.failUnless(t.storage == [{'List-type':[]}, + {'List-type':[5]}], + t.storage) + + unitsuite = unittest.TestLoader().loadTestsFromTestCase( \ + SavedSettingsObjectTests) + suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()]) diff --git a/libbe/storage/util/upgrade.py b/libbe/storage/util/upgrade.py new file mode 100644 index 0000000..f3c4912 --- /dev/null +++ b/libbe/storage/util/upgrade.py @@ -0,0 +1,331 @@ +# Copyright (C) 2009-2010 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..552d43e --- /dev/null +++ b/libbe/storage/vcs/__init__.py @@ -0,0 +1,41 @@ +# Copyright (C) 2009-2010 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 Version Controlled System (VCS)-based +:class:`~libbe.storage.base.Storage` and +:class:`~libbe.storage.base.VersionedStorage` implementations. + +There is a base class (:class:`~libbe.storage.vcs.VCS`) translating +Storage language to VCS language, and a number of `VCS` implementations: + +* :class:`~libbe.storage.vcs.arch.Arch` +* :class:`~libbe.storage.vcs.bzr.Bzr` +* :class:`~libbe.storage.vcs.darcs.Darcs` +* :class:`~libbe.storage.vcs.git.Git` +* :class:`~libbe.storage.vcs.hg.Hg` + +The base `VCS` class also serves as a filesystem Storage backend (not +versioning) in the event that a user has no VCS installed. +""" + +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/storage/vcs/arch.py b/libbe/storage/vcs/arch.py new file mode 100644 index 0000000..3a50414 --- /dev/null +++ b/libbe/storage/vcs/arch.py @@ -0,0 +1,441 @@ +# Copyright (C) 2005-2010 Aaron Bentley and Panometrics, Inc. +# Ben Finney <benf@cybersource.com.au> +# Gianluca Montecchi <gian@grys.it> +# James Rowe <jnrowe@ukfsn.org> +# W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +"""GNU Arch_ (tla) backend. + +.. _Arch: http://www.gnu.org/software/gnu-arch/ +""" + +import codecs +import os +import os.path +import re +import shutil +import sys +import time # work around http://mercurial.selenic.com/bts/issue618 + +import libbe +import libbe.ui.util.user +import libbe.storage.util.config +from libbe.util.id import uuid_gen +from libbe.util.subproc import CommandError +import base + +if libbe.TESTING == True: + import unittest + import doctest + + +class CantAddFile(Exception): + def __init__(self, file): + self.file = file + Exception.__init__(self, "Can't automatically add file %s" % file) + +DEFAULT_CLIENT = 'tla' + +client = libbe.storage.util.config.get_val( + 'arch_client', default=DEFAULT_CLIENT) + +def new(): + return Arch() + +class Arch(base.VCS): + """:class:`base.VCS` implementation for GNU Arch. + """ + name = 'arch' + client = client + _archive_name = None + _archive_dir = None + _tmp_archive = False + _project_name = None + _tmp_project = False + _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 + self.__updated = [] # work around http://mercurial.selenic.com/bts/issue618 + + def _vcs_version(self): + status,output,error = self._u_invoke_client('--version') + version = '\n'.join(output.splitlines()[:2]) + return version + + def _vcs_detect(self, path): + """Detect whether a directory is revision-controlled using Arch""" + 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:: + + 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 = 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 + self._tmp_archive = True + 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. + """ + assert self._archive_name != None + command = args[0] + if len(args) > 1: + tailargs = args[1:] + else: + tailargs = [] + arglist = [command, '-A', self._archive_name] + arglist.extend(tailargs) + args = tuple(arglist) + return self._u_invoke_client(*args, **kwargs) + + def _remove_archive(self): + assert self._tmp_archive == True + assert self._archive_dir != None + assert self._archive_name != None + os.remove(os.path.join(self._arch_paramdir, + '=locations', self._archive_name)) + shutil.rmtree(self._archive_dir) + self._tmp_archive = False + self._archive_dir = False + self._archive_name = False + + def _create_project(self, path): + """ + Create a temporary Arch project in the directory PATH. This + project will be removed by + 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, + cwd=path) + self._tmp_project = True + + def _remove_project(self): + assert self._tmp_project == True + assert self._project_name != None + assert self._archive_dir != None + shutil.rmtree(os.path.join(self._archive_dir, self._project_name)) + self._tmp_project = False + self._project_name = False + + def _archive_project_name(self): + assert self._archive_name != None + assert self._project_name != None + return '%s/%s' % (self._archive_name, self._project_name) + + def _adjust_naming_conventions(self, path): + """Adjust `Arch naming conventions`_ so ``.be`` is considered source + code. + + By default, Arch restricts source code filenames to:: + + ^[_=a-zA-Z0-9].*$ + + 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 + + .. _Arch naming conventions: + http://regexps.srparish.net/tutorial-tla/naming-conventions.html + """ + tagpath = os.path.join(path, '{arch}', '=tagging-method') + lines_out = [] + f = codecs.open(tagpath, 'r', self.encoding) + for line in f: + if line.startswith('source '): + lines_out.append('source ^[._=a-zA-X0-9].*$\n') + else: + lines_out.append(line) + f.close() + f = codecs.open(tagpath, 'w', self.encoding) + f.write(''.join(lines_out)) + f.close() + + def _add_project_code(self, path): + # http://mwolson.org/projects/GettingStartedWithArch.html + # http://regexps.srparish.net/tutorial-tla/new-source.html + # http://regexps.srparish.net/tutorial-tla/importing-first.html + self._invoke_client('init-tree', self._project_name, + cwd=path) + self._adjust_naming_conventions(path) + self._invoke_client('import', '--summary', 'Began versioning', + cwd=path) + + 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) + root = output.rstrip('\n') + + self._get_archive_project_name(root) + + return root + + def _get_archive_name(self, root): + status,output,error = self._u_invoke_client('archives') + lines = output.split('\n') + # e.g. output: + # jdoe@example.com--bugs-everywhere-auto-2008.22.24.52 + # /tmp/BEtestXXXXXX/rootdir + # (+ repeats) + for archive,location in zip(lines[::2], lines[1::2]): + if os.path.realpath(location) == os.path.realpath(root): + self._archive_name = archive + assert self._archive_name != None + + def _get_archive_project_name(self, root): + # get project names + status,output,error = self._u_invoke_client('tree-version', 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') + return output.rstrip('\n') + except Exception, e: + if 'no arch user id set' in e.args[0]: + return None + else: + raise + + def _vcs_add(self, path): + self._u_invoke_client('add-id', path) + realpath = os.path.realpath(self._u_abspath(path)) + pathAdded = realpath in self._list_added(self.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) + inv_str = output.rstrip('\n') + return [os.path.join(root, p) for p in inv_str.split('\n')] + + def _add_dir_rule(self, rule, dirname, root): + inv_path = os.path.join(dirname, '.arch-inventory') + f = codecs.open(inv_path, 'a', self.encoding) + f.write(rule) + f.close() + if os.path.realpath(inv_path) not in self._list_added(root): + paranoid = self.paranoid + self.paranoid = False + self.add(inv_path) + self.paranoid = paranoid + + def _force_source(self, path): + rule = 'source %s\n' % self._u_rel_path(path) + self._add_dir_rule(rule, os.path.dirname(path), self.repo) + if os.path.realpath(path) not in self._list_added(self.repo): + raise CantAddFile(path) + + def _vcs_remove(self, 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): + self.__updated.append(path) # work around http://mercurial.selenic.com/bts/issue618 + + 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 base.VCS._vcs_get_file_contents(self, path, revision) + else: + relpath = self._file_find(path, revision, relpath=True) + return base.VCS._vcs_get_file_contents(self, relpath) + + def _file_find(self, path, revision, relpath=False): + try: + status,output,error = \ + self._invoke_client( + 'file-find', '--unescaped', path, revision) + path = output.rstrip('\n').splitlines()[-1] + except CommandError, e: + if e.status == 2 \ + and 'illegally formed changeset index' in e.stderr: + raise NotImplementedError( +"""Outstanding tla bug, see + https://bugs.launchpad.net/ubuntu/+source/tla/+bug/513472 +""") + raise + if relpath == True: + return path + return os.path.abspath(os.path.join(self.repo, path)) + + def _vcs_path(self, id, revision): + return self._u_find_id(id, revision) + + def _vcs_isdir(self, path, revision): + abspath = self._file_find(path, revision) + return os.path.isdir(abspath) + + def _vcs_listdir(self, path, revision): + abspath = self._file_find(path, revision) + return [p for p in os.listdir(abspath) if self._vcs_is_versioned(p)] + + def _vcs_commit(self, commitfile, allow_empty=False): + if allow_empty == False: + # arch applies empty commits without complaining, so check first + status,output,error = self._u_invoke_client('changes',expect=(0,1)) + if status == 0: + # work around http://mercurial.selenic.com/bts/issue618 + time.sleep(1) + for path in self.__updated: + os.utime(os.path.join(self.repo, path), None) + self.__updated = [] + status,output,error = self._u_invoke_client('changes',expect=(0,1)) + if status == 0: + # end work around + raise base.EmptyCommit() + summary,body = self._u_parse_commitfile(commitfile) + args = ['commit', '--summary', summary] + if body != None: + args.extend(['--log-message',body]) + status,output,error = self._u_invoke_client(*args) + revision = None + revline = re.compile('[*] committed (.*)') + match = revline.search(output) + assert match != None, output+error + assert len(match.groups()) == 1 + revpath = match.groups()[0] + assert not " " in revpath, revpath + assert revpath.startswith(self._archive_project_name()+'--') + revision = revpath[len(self._archive_project_name()+'--'):] + return revpath + + def _vcs_revision_id(self, index): + status,output,error = self._u_invoke_client('logs') + logs = output.splitlines() + first_log = logs.pop(0) + assert first_log == 'base-0', first_log + try: + 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) + + def _diff(self, revision): + status,output,error = self._u_invoke_client( + 'diff', '--summary', '--unescaped', revision, expect=(0,1)) + return output + + def _parse_diff(self, diff_text): + """ + Example diff text: + + * local directory is at ... + * build pristine tree for ... + * from import revision: ... + * patching for revision: ... + * comparing to ... + D .be/dir/bugs/.arch-ids/moved.id + D .be/dir/bugs/.arch-ids/removed.id + D .be/dir/bugs/moved + D .be/dir/bugs/removed + A .be/dir/bugs/.arch-ids/moved2.id + A .be/dir/bugs/.arch-ids/new.id + A .be/dir/bugs/moved2 + A .be/dir/bugs/new + A {arch}/bugs-everywhere/bugs-everywhere--mainline/... + M .be/dir/bugs/modified + """ + new = [] + modified = [] + removed = [] + lines = diff_text.splitlines() + for i,line in enumerate(lines): + if line.startswith('* ') or '/.arch-ids/' in line: + continue + change,file = line.split(' ',1) + if file.startswith('{arch}/'): + continue + if change == 'A': + new.append(file) + elif change == 'M': + modified.append(file) + elif change == 'D': + removed.append(file) + return (new,modified,removed) + + def _vcs_changed(self, revision): + return self._parse_diff(self._diff(revision)) + + +if libbe.TESTING == True: + 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..d85c94d --- /dev/null +++ b/libbe/storage/vcs/base.py @@ -0,0 +1,1127 @@ +# Copyright (C) 2005-2010 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 :class:`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 + +VCS_ORDER = ['arch', 'bzr', 'darcs', 'git', 'hg'] +"""List VCS modules in order of preference. + +Don't list this module, it is implicitly last. +""" + +def set_preferred_vcs(name): + """Manipulate :data:`VCS_ORDER` to place `name` first. + + This is primarily indended for testing purposes. + """ + 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. + + Searches in :data:`VCS_ORDER`. + """ + 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. + + Searches in :data:`VCS_ORDER`. + """ + 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. + + Searches in :data:`VCS_ORDER`. + """ + return _get_matching_vcs(lambda vcs: vcs._detect(dir)) + +def installed_vcs(): + """Return an instance of an installed VCS. + + Searches in :data:`VCS_ORDER`. + """ + 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): + """Cache Storage ID <-> path policy. + + Paths generated following:: + + .../.be/BUGDIR/bugs/BUG/comments/COMMENT + ^-- root path + + See :mod:`libbe.util.id` for a discussion of ID formats. + + Examples + -------- + + >>> 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 in revision None + >>> 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, verbose=True, cache=None): + """Create cache file for an existing .be directory. + + The file contains multiple lines of the form:: + + UUID\tPATH + """ + if cache == None: + self._cache = {} + else: + self._cache = 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 verbose == True and 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 + if self._cache != cache: + self._changed = True + if cache == None: + 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: + self.init(verbose=False, cache=self._cache) + 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): + """Implement 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 = '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_exists(self, path, revision=None): + """ + Does the path exist in a given revision? (True/False) + """ + raise NotImplementedError('Lazy BE developers') + + 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 relative 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 _vcs_changed(self, revision): + """ + Return a tuple of lists of ids + (new, modified, removed) + from the specified revision to the current situation. + """ + return ([], [], []) + + 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. + Consider the following usage case: + + You have a project rooted in:: + + /path/to/source/ + + by which I mean the VCS repository is in, for example:: + + /path/to/source/.bzr + + However, you're of in some subdirectory like:: + + /path/to/source/ui/testing + + and you want to comment on a bug. `root` will locate your VCS + root (``/path/to/source/``) and set the repo there. This + means that it doesn't matter where you are in your project + tree when you call "be COMMAND", it always acts as if you called + it from the VCS root. + """ + 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. + + See Also + -------- + root : called if the VCS has already been initialized. + """ + 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 path(self, id, revision=None, relpath=True): + if revision == None: + path = self._cached_path_id.path(id) + if relpath == True: + return self._u_rel_path(path) + return path + path = self._vcs_path(id, revision) + if relpath == True: + return path + return os.path.join(self.repo, path) + + 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 _exists(self, id, revision=None): + if revision == None: + try: + path = self.path(id, revision, relpath=False) + except InvalidID, e: + return False + return os.path.exists(path) + path = self.path(id, revision, relpath=True) + return self._vcs_exists(relpath, revision) + + 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 _ancestors(self, id=None, revision=None): + if id==None: + path = self.be_dir + else: + path = self.path(id, revision, relpath=False) + ancestors = [] + while True: + if not path.startswith(self.repo + os.path.sep): + break + path = os.path.dirname(path) + try: + id = self._u_path_to_id(path) + ancestors.append(id) + except (SpacerCollision, InvalidPath): + pass + return ancestors + + def _children(self, id=None, revision=None): + if revision == None: + isdir = os.path.isdir + listdir = os.listdir + else: + isdir = lambda path : self._vcs_isdir( + self._u_rel_path(path), revision) + listdir = lambda path : self._vcs_listdir( + self._u_rel_path(path), revision) + if id==None: + path = self.be_dir + else: + path = self.path(id, revision, relpath=False) + 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: + relpath = self.path(id, revision, relpath=True) + contents = self._vcs_get_file_contents(relpath, revision) + except InvalidID, e: + if default == libbe.util.InvalidObject: + raise e + return default + if contents in [libbe.storage.base.InvalidDirectory, + libbe.util.InvalidObject] \ + or len(contents) == 0: + if default == libbe.util.InvalidObject: + raise InvalidID(id, revision) + return default + return contents + + def _set(self, id, value): + try: + path = self._cached_path_id.path(id) + except InvalidID, e: + raise + 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 changed(self, revision): + new,mod,rem = self._vcs_changed(revision) + def paths_to_ids(paths): + for p in paths: + try: + id = self._u_path_to_id(p) + yield id + except (SpacerCollision, InvalidPath): + pass + new_id = list(paths_to_ids(new)) + mod_id = list(paths_to_ids(mod)) + rem_id = list(paths_to_ids(rem)) + return (new_id, mod_id, rem_id) + + 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. + """ + try: + ret = search_parent_directories(path, filename) + except AssertionError, e: + return None + return ret + + def _u_find_id_from_manifest(self, id, manifest, revision=None): + """Search for the relative path to id using manifest, a list of all + files. + + Returns None if the id is not found. + """ + 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 manifest 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: + try: + p_id = self._u_path_to_id(file) + if p_id == id: + return file + except (SpacerCollision, InvalidPath): + pass + raise InvalidID(id, revision=revision) + + 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. + + Examples: + + >>> 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. + + Examples + -------- + + >>> 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): + """Return the storage version of the on-disk files. + + See Also + -------- + :mod:`libbe.storage.util.upgrade` + """ + 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') + relpath = self._u_rel_path(path) + contents = self._vcs_get_file_contents(relpath, 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..5a62968 --- /dev/null +++ b/libbe/storage/vcs/bzr.py @@ -0,0 +1,361 @@ +# Copyright (C) 2005-2010 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. + +.. _Bazaar: http://bazaar.canonical.com/ +""" + +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 sys +import types + +import libbe +import base + +if libbe.TESTING == True: + import doctest + import unittest + + +def new(): + return Bzr() + +class Bzr(base.VCS): + """:class:`base.VCS` implementation for Bazaar. + """ + 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 version_cmp(self, *args): + """Compare the installed Bazaar version `V_i` with another version + `V_o` (given in `*args`). Returns + + === =============== + 1 if `V_i > V_o` + 0 if `V_i == V_o` + -1 if `V_i < V_o` + === =============== + + Examples + -------- + + >>> b = Bzr(repo='.') + >>> b._vcs_version = lambda : "2.3.1 (release)" + >>> b.version_cmp(2,3,1) + 0 + >>> b.version_cmp(2,3,2) + -1 + >>> b.version_cmp(2,3,0) + 1 + >>> b.version_cmp(3) + -1 + >>> b._vcs_version = lambda : "2.0.0pre2" + >>> b._parsed_version = None + >>> b.version_cmp(3) + -1 + >>> b.version_cmp(2,0,1) + Traceback (most recent call last): + ... + NotImplementedError: Cannot parse non-integer portion "0pre2" of Bzr version "2.0.0pre2" + """ + if not hasattr(self, '_parsed_version') \ + or self._parsed_version == None: + num_part = self._vcs_version().split(' ')[0] + self._parsed_version = [] + for num in num_part.split('.'): + try: + self._parsed_version.append(int(num)) + except ValueError, e: + self._parsed_version.append(num) + for current,other in zip(self._parsed_version, args): + if type(current) != types.IntType: + raise NotImplementedError( + 'Cannot parse non-integer portion "%s" of Bzr version "%s"' + % (current, self._vcs_version())) + c = cmp(current,other) + if c != 0: + return c + return 0 + + 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_exists(self, path, revision=None): + manifest = self._vcs_listdir( + self.repo, revision=revision, recursive=True) + if path in manifest: + return True + return False + + 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() + if self.version_cmp(1,6,0) < 0: + # old bzrlib cmd_cat uses sys.stdout not self.outf for output. + stdout = sys.stdout + sys.stdout = cmd.outf + 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 + finally: + if self.version_cmp(2,0,0) < 0: + cmd.outf = sys.stdout + sys.stdout = stdout + return cmd.outf.getvalue() + + def _vcs_path(self, id, revision): + manifest = self._vcs_listdir( + self.repo, revision=revision, recursive=True) + return self._u_find_id_from_manifest(id, manifest, revision=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, recursive=False): + path = os.path.join(self.repo, path) + revision = self._parse_revision_string(revision) + cmd = bzrlib.builtins.cmd_ls() + cmd.outf = StringIO.StringIO() + try: + if self.version_cmp(2,0,0) >= 0: + cmd.run(revision=revision, path=path, recursive=recursive) + else: + # Pre-2.0 Bazaar (non_recursive) + # + working around broken non_recursive+path implementation + # (https://bugs.launchpad.net/bzr/+bug/158690) + cmd.run(revision=revision, path=path, + non_recursive=False) + 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] + if self.version_cmp(2,0,0) < 0 and recursive == False: + children = [c for c in children if os.path.sep not in c] + 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) + + def _diff(self, revision): + revision = self._parse_revision_string(revision) + cmd = bzrlib.builtins.cmd_diff() + cmd.outf = StringIO.StringIO() + # for some reason, cmd_diff uses sys.stdout not self.outf for output. + stdout = sys.stdout + sys.stdout = cmd.outf + try: + status = cmd.run(revision=revision, file_list=[self.repo]) + finally: + sys.stdout = stdout + assert status in [0,1], "Invalid status %d" % status + return cmd.outf.getvalue() + + def _parse_diff(self, diff_text): + """_parse_diff(diff_text) -> (new,modified,removed) + + `new`, `modified`, and `removed` are lists of files. + + Example diff text:: + + === modified file 'dir/changed' + --- dir/changed 2010-01-16 01:54:53 +0000 + +++ dir/changed 2010-01-16 01:54:54 +0000 + @@ -1,3 +1,3 @@ + hi + -there + +everyone and + joe + + === removed file 'dir/deleted' + --- dir/deleted 2010-01-16 01:54:53 +0000 + +++ dir/deleted 1970-01-01 00:00:00 +0000 + @@ -1,3 +0,0 @@ + -in + -the + -beginning + + === removed file 'dir/moved' + --- dir/moved 2010-01-16 01:54:53 +0000 + +++ dir/moved 1970-01-01 00:00:00 +0000 + @@ -1,4 +0,0 @@ + -the + -ants + -go + -marching + + === added file 'dir/moved2' + --- dir/moved2 1970-01-01 00:00:00 +0000 + +++ dir/moved2 2010-01-16 01:54:34 +0000 + @@ -0,0 +1,4 @@ + +the + +ants + +go + +marching + + === added file 'dir/new' + --- dir/new 1970-01-01 00:00:00 +0000 + +++ dir/new 2010-01-16 01:54:54 +0000 + @@ -0,0 +1,2 @@ + +hello + +world + + """ + new = [] + modified = [] + removed = [] + for line in diff_text.splitlines(): + if not line.startswith('=== '): + continue + fields = line.split() + action = fields[1] + file = fields[-1].strip("'") + if action == 'added': + new.append(file) + elif action == 'modified': + modified.append(file) + elif action == 'removed': + removed.append(file) + return (new,modified,removed) + + def _vcs_changed(self, revision): + return self._parse_diff(self._diff(revision)) + + +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..0f23278 --- /dev/null +++ b/libbe/storage/vcs/darcs.py @@ -0,0 +1,399 @@ +# Copyright (C) 2009-2010 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. + +.. _Darcs: http://darcs.net/ +""" + +import codecs +import os +import re +import shutil +import sys +import time # work around http://mercurial.selenic.com/bts/issue618 +import types +try: # import core module, Python >= 2.5 + from xml.etree import ElementTree +except ImportError: # look for non-core module + from elementtree import ElementTree +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): + """:class:`base.VCS` implementation for Darcs. + """ + 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.strip() + + 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` + -1 if `V_i < V_o` + === =============== + + Examples + -------- + + >>> 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) + -1 + >>> d.version_cmp(2,0,1) + Traceback (most recent call last): + ... + NotImplementedError: Cannot parse non-integer portion "0pre2" of Darcs version "2.0.0pre2" + """ + if not hasattr(self, '_parsed_version') \ + or self._parsed_version == None: + num_part = self._vcs_version().split(' ')[0] + self._parsed_version = [] + for num in num_part.split('.'): + try: + self._parsed_version.append(int(num)) + except ValueError, e: + self._parsed_version.append(num) + for current,other in zip(self._parsed_version, args): + if type(current) != types.IntType: + raise NotImplementedError( + 'Cannot parse non-integer portion "%s" of Darcs version "%s"' + % (current, self._vcs_version())) + c = cmp(current,other) + 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._vcs_get_file_contents(pref_path).strip() + 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 + + patch = self._diff(revision, path=path, unicode_output=False) + + # '--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=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', path] + status,output,error = self._u_invoke(args, stdin=patch) + 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 + raise NotImplementedError( + 'Darcs versions <= 2.3.1 lack the --patch option for "show files"') + + 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 _revisions(self): + """ + Return a list of revisions in the repository. + """ + 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() + return revisions + + def _vcs_revision_id(self, index): + revisions = self._revisions() + try: + if index > 0: + return revisions[index-1] + elif index < 0: + return revisions[index] + else: + return None + except IndexError: + return None + + def _diff(self, revision, path=None, unicode_output=True): + revisions = self._revisions() + i = revisions.index(revision) + args = ['diff', '--unified'] + if i+1 < len(revisions): + next_rev = revisions[i+1] + args.extend(['--from-patch', next_rev]) + if path != None: + args.append(path) + kwargs = {'unicode_output':unicode_output} + status,output,error = self._u_invoke_client( + *args, **kwargs) + return output + + def _parse_diff(self, diff_text): + """_parse_diff(diff_text) -> (new,modified,removed) + + `new`, `modified`, and `removed` are lists of files. + + Example diff text:: + + Mon Jan 18 15:19:30 EST 2010 None <None@invalid.com> + * Final state + diff -rN --unified old-BEtestgQtDuD/.be/dir/bugs/modified new-BEtestgQtDuD/.be/dir/bugs/modified + --- old-BEtestgQtDuD/.be/dir/bugs/modified 2010-01-18 15:19:30.000000000 -0500 + +++ new-BEtestgQtDuD/.be/dir/bugs/modified 2010-01-18 15:19:30.000000000 -0500 + @@ -1 +1 @@ + -some value to be modified + \ No newline at end of file + +a new value + \ No newline at end of file + diff -rN --unified old-BEtestgQtDuD/.be/dir/bugs/moved new-BEtestgQtDuD/.be/dir/bugs/moved + --- old-BEtestgQtDuD/.be/dir/bugs/moved 2010-01-18 15:19:30.000000000 -0500 + +++ new-BEtestgQtDuD/.be/dir/bugs/moved 1969-12-31 19:00:00.000000000 -0500 + @@ -1 +0,0 @@ + -this entry will be moved + \ No newline at end of file + diff -rN --unified old-BEtestgQtDuD/.be/dir/bugs/moved2 new-BEtestgQtDuD/.be/dir/bugs/moved2 + --- old-BEtestgQtDuD/.be/dir/bugs/moved2 1969-12-31 19:00:00.000000000 -0500 + +++ new-BEtestgQtDuD/.be/dir/bugs/moved2 2010-01-18 15:19:30.000000000 -0500 + @@ -0,0 +1 @@ + +this entry will be moved + \ No newline at end of file + diff -rN --unified old-BEtestgQtDuD/.be/dir/bugs/new new-BEtestgQtDuD/.be/dir/bugs/new + --- old-BEtestgQtDuD/.be/dir/bugs/new 1969-12-31 19:00:00.000000000 -0500 + +++ new-BEtestgQtDuD/.be/dir/bugs/new 2010-01-18 15:19:30.000000000 -0500 + @@ -0,0 +1 @@ + +this entry is new + \ No newline at end of file + diff -rN --unified old-BEtestgQtDuD/.be/dir/bugs/removed new-BEtestgQtDuD/.be/dir/bugs/removed + --- old-BEtestgQtDuD/.be/dir/bugs/removed 2010-01-18 15:19:30.000000000 -0500 + +++ new-BEtestgQtDuD/.be/dir/bugs/removed 1969-12-31 19:00:00.000000000 -0500 + @@ -1 +0,0 @@ + -this entry will be deleted + \ No newline at end of file + + """ + new = [] + modified = [] + removed = [] + lines = diff_text.splitlines() + repodir = os.path.basename(self.repo) + os.path.sep + i = 0 + while i < len(lines): + line = lines[i]; i += 1 + if not line.startswith('diff '): + continue + file_a,file_b = line.split()[-2:] + assert file_a.startswith('old-'), \ + 'missformed file_a %s' % file_a + assert file_b.startswith('new-'), \ + 'missformed file_a %s' % file_b + file = file_a[4:] + assert file_b[4:] == file, \ + 'diff file missmatch %s != %s' % (file_a, file_b) + assert file.startswith(repodir), \ + 'missformed file_a %s' % file_a + file = file[len(repodir):] + lines_added = 0 + lines_removed = 0 + line = lines[i]; i += 1 + assert line.startswith('--- old-'), \ + 'missformed "---" line %s' % line + time_a = line.split('\t')[1] + line = lines[i]; i += 1 + assert line.startswith('+++ new-'), \ + 'missformed "+++" line %s' % line + time_b = line.split('\t')[1] + zero_time = time.strftime('%Y-%m-%d %H:%M:%S.000000000 ', + time.localtime(0)) + # note that zero_time is missing the trailing timezone offset + if time_a.startswith(zero_time): + new.append(file) + elif time_b.startswith(zero_time): + removed.append(file) + else: + modified.append(file) + return (new,modified,removed) + + def _vcs_changed(self, revision): + return self._parse_diff(self._diff(revision)) + + +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/storage/vcs/git.py b/libbe/storage/vcs/git.py new file mode 100644 index 0000000..4df9bc8 --- /dev/null +++ b/libbe/storage/vcs/git.py @@ -0,0 +1,269 @@ +# Copyright (C) 2008-2010 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. + +"""Git_ backend. + +.. _Git: http://git-scm.com/ +""" + +import os +import os.path +import re +import shutil +import unittest + +import libbe +import libbe.ui.util.user +import base + +if libbe.TESTING == True: + import doctest + import sys + + +def new(): + return Git() + +class Git(base.VCS): + """:class:`base.VCS` implementation for Git. + """ + 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') + return output.strip() + + 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 : + return True + return False + + def _vcs_root(self, path): + """Find the root of the deepest repository containing path.""" + # Assume that nothing funny is going on; in particular, that we aren't + # dealing with a bare repo. + if os.path.isdir(path) != True: + path = os.path.dirname(path) + status,output,error = self._u_invoke_client('rev-parse', '--git-dir', + 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_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) + + def _vcs_remove(self, path): + if not os.path.isdir(self._u_abspath(path)): + self._u_invoke_client('rm', '-f', path) + + def _vcs_update(self, path): + self._vcs_add(path) + + def _vcs_get_file_contents(self, path, revision=None): + if revision == None: + 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) + return output + + 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') + status,output,error = self._u_invoke_client(*args) + else: + kwargs = {'expect':(0,1)} + status,output,error = self._u_invoke_client(*args, **kwargs) + strings = ['nothing to commit', + 'nothing added to commit'] + if self._u_any_in_string(strings, output) == True: + raise base.EmptyCommit() + full_revision = self._vcs_revision_id(-1) + 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)} + status,output,error = self._u_invoke_client(*args, **kwargs) + if status == 128: + if error.startswith("fatal: ambiguous argument 'HEAD': unknown "): + return None + raise base.CommandError(args, status, stderr=error) + revisions = output.splitlines() + try: + if index > 0: + return revisions[index-1] + elif index < 0: + return revisions[index] + else: + return None + except IndexError: + return None + + def _diff(self, revision): + status,output,error = self._u_invoke_client('diff', revision) + return output + + def _parse_diff(self, diff_text): + """_parse_diff(diff_text) -> (new,modified,removed) + + `new`, `modified`, and `removed` are lists of files. + + Example diff text:: + + diff --git a/dir/changed b/dir/changed + index 6c3ea8c..2f2f7c7 100644 + --- a/dir/changed + +++ b/dir/changed + @@ -1,3 +1,3 @@ + hi + -there + +everyone and + joe + diff --git a/dir/deleted b/dir/deleted + deleted file mode 100644 + index 225ec04..0000000 + --- a/dir/deleted + +++ /dev/null + @@ -1,3 +0,0 @@ + -in + -the + -beginning + diff --git a/dir/moved b/dir/moved + deleted file mode 100644 + index 5ef102f..0000000 + --- a/dir/moved + +++ /dev/null + @@ -1,4 +0,0 @@ + -the + -ants + -go + -marching + diff --git a/dir/moved2 b/dir/moved2 + new file mode 100644 + index 0000000..5ef102f + --- /dev/null + +++ b/dir/moved2 + @@ -0,0 +1,4 @@ + +the + +ants + +go + +marching + diff --git a/dir/new b/dir/new + new file mode 100644 + index 0000000..94954ab + --- /dev/null + +++ b/dir/new + @@ -0,0 +1,2 @@ + +hello + +world + """ + new = [] + modified = [] + removed = [] + lines = diff_text.splitlines() + for i,line in enumerate(lines): + if not line.startswith('diff '): + continue + file_a,file_b = line.split()[-2:] + assert file_a.startswith('a/'), \ + 'missformed file_a %s' % file_a + assert file_b.startswith('b/'), \ + 'missformed file_a %s' % file_b + file = file_a[2:] + assert file_b[2:] == file, \ + 'diff file missmatch %s != %s' % (file_a, file_b) + if lines[i+1].startswith('new '): + new.append(file) + elif lines[i+1].startswith('index '): + modified.append(file) + elif lines[i+1].startswith('deleted '): + removed.append(file) + return (new,modified,removed) + + def _vcs_changed(self, revision): + return self._parse_diff(self._diff(revision)) + + +if libbe.TESTING == True: + 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..9378336 --- /dev/null +++ b/libbe/storage/vcs/hg.py @@ -0,0 +1,257 @@ +# Copyright (C) 2007-2010 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. + +.. _Mercurial: http://mercurial.selenic.com/ +""" + +try: + import mercurial + import mercurial.dispatch + import mercurial.ui +except ImportError: + mercurial = None + +try: + # mercurial >= 1.2 + from mercurial.util import version +except ImportError: + try: + # mercurial <= 1.1.2 + from mercurial.version import get_version as version + except ImportError: + version = 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): + """:class:`base.VCS` implementation for Mercurial. + """ + 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 version == None: + return None + return 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): + output = self._u_invoke_client( + 'showconfig', 'ui.username').rstrip('\n') + if output != '': + return output + return None + + 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): + manifest = self._u_invoke_client( + 'manifest', '--rev', revision).splitlines() + return self._u_find_id_from_manifest(id, manifest, 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 + + def _diff(self, revision): + return self._u_invoke_client( + 'diff', '-r', revision, '--git') + + def _parse_diff(self, diff_text): + """_parse_diff(diff_text) -> (new,modified,removed) + + `new`, `modified`, and `removed` are lists of files. + + Example diff text:: + + diff --git a/.be/dir/bugs/modified b/.be/dir/bugs/modified + --- a/.be/dir/bugs/modified + +++ b/.be/dir/bugs/modified + @@ -1,1 +1,1 @@ some value to be modified + -some value to be modified + \ No newline at end of file + +a new value + \ No newline at end of file + diff --git a/.be/dir/bugs/moved b/.be/dir/bugs/moved + deleted file mode 100644 + --- a/.be/dir/bugs/moved + +++ /dev/null + @@ -1,1 +0,0 @@ + -this entry will be moved + \ No newline at end of file + diff --git a/.be/dir/bugs/moved2 b/.be/dir/bugs/moved2 + new file mode 100644 + --- /dev/null + +++ b/.be/dir/bugs/moved2 + @@ -0,0 +1,1 @@ + +this entry will be moved + \ No newline at end of file + diff --git a/.be/dir/bugs/new b/.be/dir/bugs/new + new file mode 100644 + --- /dev/null + +++ b/.be/dir/bugs/new + @@ -0,0 +1,1 @@ + +this entry is new + \ No newline at end of file + diff --git a/.be/dir/bugs/removed b/.be/dir/bugs/removed + deleted file mode 100644 + --- a/.be/dir/bugs/removed + +++ /dev/null + @@ -1,1 +0,0 @@ + -this entry will be deleted + \ No newline at end of file + """ + new = [] + modified = [] + removed = [] + lines = diff_text.splitlines() + for i,line in enumerate(lines): + if not line.startswith('diff '): + continue + file_a,file_b = line.split()[-2:] + assert file_a.startswith('a/'), \ + 'missformed file_a %s' % file_a + assert file_b.startswith('b/'), \ + 'missformed file_a %s' % file_b + file = file_a[2:] + assert file_b[2:] == file, \ + 'diff file missmatch %s != %s' % (file_a, file_b) + if lines[i+1].startswith('new '): + new.append(file) + elif lines[i+1].startswith('deleted '): + removed.append(file) + else: + modified.append(file) + return (new,modified,removed) + + def _vcs_changed(self, revision): + return self._parse_diff(self._diff(revision)) + + +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..3b461a5 --- /dev/null +++ b/libbe/ui/__init__.py @@ -0,0 +1,15 @@ +# Copyright (C) 2009-2010 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. diff --git a/libbe/ui/command_line.py b/libbe/ui/command_line.py new file mode 100644 index 0000000..dd10954 --- /dev/null +++ b/libbe/ui/command_line.py @@ -0,0 +1,340 @@ +# Copyright (C) 2005-2010 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) + +class CommandLine (libbe.command.UserInterface): + def __init__(self, *args, **kwargs): + libbe.command.UserInterface.__init__(self, *args, **kwargs) + self.restrict_file_access = False + self.storage_callbacks = None + def help(self): + be = BE(ui=self) + self.setup_command(be) + return be.help() + +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 UnicodeDecodeError, e: + print >> ui.io.stdout, '\n'.join([ + 'ERROR:', str(e), + 'You should set a locale that supports unicode, e.g.', + ' export LANG=en_US.utf8', + 'See http://docs.python.org/library/locale.html for details', + ]) + return 1 + 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 = CommandLine(io) + 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: + if str(e).endswith('COMMAND'): + # no command given, print usage string + print >> ui.io.stdout, 'ERROR:' + print >> ui.io.stdout, be.usage(), '\n', e + print >> ui.io.stdout, 'For example, try' + print >> ui.io.stdout, ' be help' + else: + 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', 'import-xml', 'serve']: + 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..3b461a5 --- /dev/null +++ b/libbe/ui/util/__init__.py @@ -0,0 +1,15 @@ +# Copyright (C) 2009-2010 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. diff --git a/libbe/ui/util/editor.py b/libbe/ui/util/editor.py new file mode 100644 index 0000000..1a430c7 --- /dev/null +++ b/libbe/ui/util/editor.py @@ -0,0 +1,115 @@ +# Bugs Everywhere, a distributed bugtracker +# Copyright (C) 2008-2010 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 editor_string(), a function that invokes an editor to accept +user-produced text as a string. +""" + +import codecs +import locale +import os +import sys +import tempfile + +import libbe +import libbe.util.encoding + +if libbe.TESTING == True: + import doctest + + +comment_marker = u"== Anything below this line will be ignored\n" + +class CantFindEditor(Exception): + def __init__(self): + Exception.__init__(self, "Can't find editor to get string from") + +def editor_string(comment=None, encoding=None): + """Invokes the editor, and returns the user-produced text as a string + + >>> if "EDITOR" in os.environ: + ... del os.environ["EDITOR"] + >>> if "VISUAL" in os.environ: + ... del os.environ["VISUAL"] + >>> editor_string() + Traceback (most recent call last): + CantFindEditor: Can't find editor to get string from + >>> os.environ["EDITOR"] = "echo bar > " + >>> editor_string() + u'bar\\n' + >>> os.environ["VISUAL"] = "echo baz > " + >>> editor_string() + u'baz\\n' + >>> 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 = libbe.util.encoding.get_filesystem_encoding() + editor = None + for name in ('VISUAL', 'EDITOR'): + if name in os.environ and os.environ[name] != '': + editor = os.environ[name] + break + if editor == None: + raise CantFindEditor() + fhandle, fname = tempfile.mkstemp() + try: + if comment is not None: + cstring = u'\n'+comment_string(comment) + os.write(fhandle, cstring.encode(encoding)) + os.close(fhandle) + oldmtime = os.path.getmtime(fname) + os.system("%s %s" % (editor, fname)) + output = libbe.util.encoding.get_file_contents( + fname, encoding=encoding, decode=True) + output = trimmed_string(output) + if output.rstrip('\n') == "": + output = None + finally: + os.unlink(fname) + return output + + +def comment_string(comment): + """ + >>> comment_string('hello') == comment_marker+"hello" + True + """ + return comment_marker + comment + + +def trimmed_string(instring): + """ + >>> trimmed_string("hello\\n"+comment_marker) + u'hello\\n' + >>> trimmed_string("hi!\\n" + comment_string('Booga')) + u'hi!\\n' + """ + out = [] + for line in instring.splitlines(True): + if line.startswith(comment_marker): + break + out.append(line) + return ''.join(out) + +if libbe.TESTING == True: + suite = doctest.DocTestSuite() diff --git a/libbe/ui/util/pager.py b/libbe/ui/util/pager.py new file mode 100644 index 0000000..88b58af --- /dev/null +++ b/libbe/ui/util/pager.py @@ -0,0 +1,65 @@ +# Copyright (C) 2009-2010 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. + +""" +Automatic pager for terminal output (a la Git). +""" + +import sys, os, select + +# see http://nex-3.com/posts/73-git-style-automatic-paging-in-ruby +def run_pager(paginate='auto'): + """ + paginate should be one of 'never', 'auto', or 'always'. + + usage: just call this function and continue using sys.stdout like + you normally would. + """ + if paginate == 'never' \ + or sys.platform == 'win32' \ + or not hasattr(sys.stdout, 'isatty') \ + or sys.stdout.isatty() == False: + return + + if paginate == 'auto': + if 'LESS' not in os.environ: + os.environ['LESS'] = '' # += doesn't work on undefined var + # don't page if the input is short enough + os.environ['LESS'] += ' -FRX' + if 'PAGER' in os.environ: + pager = os.environ['PAGER'] + else: + pager = 'less' + + read_fd, write_fd = os.pipe() + if os.fork() == 0: + # child process + os.close(read_fd) + os.close(0) + os.dup2(write_fd, 1) + os.close(write_fd) + if hasattr(sys.stderr, 'isatty') and sys.stderr.isatty() == True: + os.dup2(1, 2) + return + + # parent process, become pager + os.close(write_fd) + os.dup2(read_fd, 0) + os.close(read_fd) + + # Wait until we have input before we start the pager + select.select([0], [], []) + os.execlp(pager, pager) diff --git a/libbe/ui/util/user.py b/libbe/ui/util/user.py new file mode 100644 index 0000000..460a1dd --- /dev/null +++ b/libbe/ui/util/user.py @@ -0,0 +1,134 @@ +# Copyright (C) 2009-2010 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. + +"""Tools for getting, setting, creating, and parsing the user's ID. + +IDs will look like 'John Doe <jdoe@example.com>'. Note that the +:mod:`libbe.storage.vcs.arch <Arch VCS backend>` *enforces* IDs with +this format. + +Do not confuse the user IDs discussed in this module, which refer to +humans, with the "user IDs" discussed in :mod:`libbe.util.id`, which +are human-readable tags refering to objects. +""" + +try: + from email.utils import formataddr, parseaddr +except ImportErrror: # adjust to old python < 2.5 + from email.Utils import formataddr, parseaddr +import os +import re +from socket import gethostname + +import libbe +import libbe.storage.util.config + +def get_fallback_username(): + """Return a username extracted from environmental variables. + """ + 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(): + """Return an email address extracted from environmental variables. + """ + hostname = gethostname() + name = get_fallback_username() + return "%s@%s" % (name, hostname) + +def create_user_id(name, email=None): + """Create a user ID string from given `name` and `email` strings. + + Examples + -------- + + >>> create_user_id("John Doe", "jdoe@example.com") + 'John Doe <jdoe@example.com>' + >>> create_user_id("John Doe") + 'John Doe' + + See Also + -------- + parse_user_id : inverse + """ + assert len(name) > 0 + if email == None or len(email) == 0: + return name + else: + return formataddr((name, email)) + +def parse_user_id(value): + """Parse a user ID string into `name` and `email` strings. + + Examples + -------- + + >>> parse_user_id("John Doe <jdoe@example.com>") + ('John Doe', 'jdoe@example.com') + >>> parse_user_id("John Doe") + ('John Doe', None) + >>> parse_user_id("John Doe <jdoe@example.com><what?>") + ('John Doe', 'jdoe@example.com') + + See Also + -------- + create_user_id : inverse + """ + if '<' not in value: + return (value, None) + return parseaddr(value) + +def get_user_id(storage=None): + """Return a user ID, checking a list of possible sources. + + The source order is: + + 1. Global BE configuration. + 2. `storage.get_user_id`, if that function is defined. + 3. :func:`get_fallback_username` and :func:`get_fallback_email`. + + Notes + ----- + Sometimes the storage will keep track of the user ID (e.g. most + VCSs, see :meth:`libbe.storage.vcs.base.VCS.get_user_id`). If so, + we prefer that ID to the fallback, since the user has likely + configured it directly. + """ + 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): + """Set the user ID in a user's BE configuration. + + See Also + -------- + libbe.storage.util.config.set_val + """ + user = libbe.storage.util.config.set_val('user', user_id) diff --git a/libbe/util/__init__.py b/libbe/util/__init__.py new file mode 100644 index 0000000..0f4850f --- /dev/null +++ b/libbe/util/__init__.py @@ -0,0 +1,24 @@ +# Copyright (C) 2009-2010 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. + +""" +Miscellaneous utilities. +""" + +class InvalidObject (object): + """An object that won't come up by accident.""" + pass + diff --git a/libbe/util/encoding.py b/libbe/util/encoding.py new file mode 100644 index 0000000..8eea438 --- /dev/null +++ b/libbe/util/encoding.py @@ -0,0 +1,91 @@ +# Copyright (C) 2008-2010 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. + +""" +Support input/output/filesystem encodings (e.g. UTF-8). +""" + +import codecs +import locale +import sys +import types + +import libbe +if libbe.TESTING == True: + import doctest + + +ENCODING = None # override get_encoding() output by setting this + +def get_encoding(): + """ + Guess a useful input/output/filesystem encoding... Maybe we need + seperate encodings for input/output and filesystem? Hmm... + """ + if ENCODING != None: + return ENCODING + encoding = locale.getpreferredencoding() or sys.getdefaultencoding() + if sys.platform != 'win32' or sys.version_info[:2] > (2, 3): + encoding = locale.getlocale(locale.LC_TIME)[1] or encoding + # Python 2.3 on windows doesn't know about 'XYZ' alias for 'cpXYZ' + return encoding + +def 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") + False + >>> known_encoding(get_encoding()) + True + """ + try: + codecs.lookup(encoding) + return True + except LookupError: + return False + +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..9192ac8 --- /dev/null +++ b/libbe/util/id.py @@ -0,0 +1,713 @@ +# Copyright (C) 2008-2010 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. + +Format +====== + +BE IDs are formatted:: + + <bug-directory>[/<bug>[/<comment>]] + +where each ``<..>`` is a UUID. For example:: + + bea86499-824e-4e77-b085-2d581fa9ccab/3438b72c-6244-4f1d-8722-8c8d41484e35 + +refers to bug ``3438b72c-6244-4f1d-8722-8c8d41484e35`` which is +located in bug directory ``bea86499-824e-4e77-b085-2d581fa9ccab``. +This is a bit of a mouthful, so you can truncate each UUID so long as +it remains unique. For example:: + + bea/343 + +If there were two bugs ``3438...`` and ``343a...`` in ``bea``, you'd +have to use:: + + bea/3438 + +BE will only truncate each UUID down to three characters to slightly +future-proof the short user ids. However, if you want to save keystrokes +and you *know* there is only one bug directory, feel free to truncate +all the way to zero characters:: + + /3438 + +Cross references +================ + +To refer to other bug-directories/bugs/comments from bug comments, simply +enclose the ID in pound signs (``#``). BE will automatically expand the +truncations to the full UUIDs before storing the comment, and the reference +will be appropriately truncated (and hyperlinked, if possible) when the +comment is displayed. + +Scope +===== + +Although bug and comment IDs always appear in compound references, +UUIDs at each level are globally unique. For example, comment +``bea/343/ba96f1c0-ba48-4df8-aaf0-4e3a3144fc46`` will *only* appear +under ``bea/343``. The prefix (``bea/343``) allows BE to reduce +caching global comment-lookup tables and enables easy error messages +("I couldn't find ``bea/343/ba9`` because I don't know where the +``bea`` bug directory is located"). +""" + +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'] +"""Keep track of the object type hierarchy. +""" + +class MultipleIDMatches (ValueError): + """Multiple IDs match the given user ID. + + Parameters + ---------- + id : str + The not-specific-enough truncated UUID. + common : str + The initial characters common to all matching UUIDs. + matches : list of str + The list of possibly matching UUIDs. + """ + 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): + """No IDs match the given user ID. + + Parameters + ---------- + id : str + The not-matching, possibly truncated UUID. + possible_ids : list of str + The list of potential UUIDs at that level. + msg : str, optional + A helpful message explaining what went wrong. + """ + 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): + """A purported ID does not have the appropriate syntax. + + Parameters + ---------- + id : str + The purported ID. + msg : str, optional + A helpful message explaining what went wrong. + """ + 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): + """Join a bunch of level UUIDs into a single ID. + + See Also + -------- + _split : inverse + """ + 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) > len(HIERARCHY): + raise InvalidIDStructure( + id, '%d > %d levels in "%s"' % (len(args), len(HIERARCHY), id)) + return id + +def _split(id, check_length=False): + """Split an ID into a list of level UUIDs. + + See Also + -------- + _assemble : inverse + """ + 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) > len(HIERARCHY): + raise InvalidIDStructure( + id, '%d > %d levels in "%s"' % (len(args), len(HIERARCHY), id)) + return args + +def _truncate(uuid, other_uuids, min_length=3): + """Truncate a UUID to the shortest length >= `min_length` such that it + is *not* a truncated form of a UUID in `other_uuids`. + + Parameters + ---------- + uuid : str + The UUID to truncate. + other_uuids : list of str + The other UUIDs which the truncation *might* (but doesn't) refer + to. + min_length : int + Avoid rapidly outdated truncations, even if they are unique now. + + See Also + -------- + _expand : inverse + """ + if min_length == -1: + return uuid + 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): + """Expand a truncated UUID. + + Parameters + ---------- + truncated_id : str + The ID to expand. + common : str + The common portion `truncated_id` shares with the UUIDs in + `other_ids`. Not used by ``_expand``, but passed on to the + matching exceptions if they occur. + other_uuids : list of str + The other UUIDs which the truncation *might* (but doesn't) refer + to. + + Raises + ------ + NoIDMatches + MultipleIDMatches + + See Also + -------- + _expand : inverse + """ + 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): + if id == truncated_id: + return 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): + """Store an object ID and produce various representations. + + Parameters + ---------- + object : :class:`~libbe.bugdir.BugDir` or :class:`~libbe.bug.Bug` or :class:`~libbe.comment.Comment` + The object that the ID applies to. + type : 'bugdir' or 'bug' or 'comment' + The type of the object. + + Notes + ----- + + 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 :meth:`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 :meth:`long_user` method. These long user ids should be + converted to short user ids by intelligent user interfaces. + + See Also + -------- + parse_user : get uuids back out of the user ids. + short_to_long_user : convert a single short user id to a long user id. + long_to_short_user : convert a single long user id to a short user id. + short_to_long_text : scan text for user ids & convert to long user ids. + long_to_short_text : scan text for long user ids & convert to short user ids. + """ + 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 + :meth:`ID.storage`. + + This is useful for separating data belonging to a particular + object directly from entries for its child objects. Since the + :class:`~libbe.storage.base.Storage` backend doesn't distinguish + between the two. + + Examples + -------- + + >>> 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): + """Convert a long user ID to a short user ID (see :class:`ID`). + The list of bugdirs allows uniqueness-maintaining truncation of + the bugdir portion of the ID. + + See Also + -------- + short_to_long_user : inverse + long_to_short_text : conversion on a block of text + """ + ids = _split(id, check_length=True) + matching_bugdirs = [bd for bd in bugdirs if bd.uuid == ids[0]] + if len(matching_bugdirs) == 0: + raise NoIDMatches(id, [bd.uuid for bd in bugdirs]) + elif len(matching_bugdirs) > 1: + raise MultipleIDMatches(id, '', [bd.uuid for bd in bugdirs]) + bugdir = matching_bugdirs[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): + """Convert a short user ID to a long user ID (see :class:`ID`). The + list of bugdirs allows uniqueness-checking during expansion of the + bugdir portion of the ID. + + See Also + -------- + long_to_short_user : inverse + short_to_long_text : conversion on a block of text + """ + 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]*)?#' +"""Regular expression for matching IDs (both short and long) in text. +""" + +class IDreplacer (object): + """Helper class for ID replacement in text. + + Reassembles the match elements from :data:`REGEXP` matching + into the original ID, for easier replacement. + + See Also + -------- + short_to_long_text, long_to_short_text + """ + def __init__(self, bugdirs, replace_fn, wrap=True): + self.bugdirs = bugdirs + self.replace_fn = replace_fn + self.wrap = wrap + def __call__(self, match): + ids = [] + for m in match.groups(): + if m == None: + m = '' + ids.append(m) + replacement = self.replace_fn(self.bugdirs, ''.join(ids)) + if self.wrap == True: + return '#%s#' % replacement + return replacement + +def short_to_long_text(bugdirs, text): + """Convert short user IDs to long user IDs in text (see :class:`ID`). + The list of bugdirs allows uniqueness-checking during expansion of + the bugdir portion of the ID. + + See Also + -------- + short_to_long_user : conversion on a single ID + long_to_short_text : inverse + """ + return re.sub(REGEXP, IDreplacer(bugdirs, short_to_long_user), text) + +def long_to_short_text(bugdirs, text): + """Convert long user IDs to short user IDs in text (see :class:`ID`). + The list of bugdirs allows uniqueness-maintaining truncation of + the bugdir portion of the ID. + + See Also + -------- + long_to_short_user : conversion on a single ID + short_to_long_text : inverse + """ + return re.sub(REGEXP, IDreplacer(bugdirs, long_to_short_user), text) + +def residual(base, fragment): + """Split the short ID `fragment` into a portion corresponding + to `base`, and a portion inside `base`. + + Examples + -------- + + >>> 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 a user ID (see :class:`ID`), returning a dict of parsed + information. + + The returned dict will contain a value for "type" (from + :data:`HIERARCHY`) and values for the levels that are defined. + + Examples + -------- + + >>> _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): + """Parse a user ID (see :class:`ID`), returning a dict of parsed + information. + + The returned dict will contain a value for "type" (from + :data:`HIERARCHY`) and values for the levels that are defined. + + Notes + ----- + This function tries to expand IDs before parsing, so it can handle + both short and long IDs successfully. + """ + 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/util/plugin.py b/libbe/util/plugin.py new file mode 100644 index 0000000..e598c34 --- /dev/null +++ b/libbe/util/plugin.py @@ -0,0 +1,67 @@ +# Copyright (C) 2005-2010 Aaron Bentley and Panometrics, Inc. +# 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. + +""" +Allow simple listing and loading of the various becommands and libbe +submodules (i.e. "plugins"). +""" + +import os +import os.path +import sys + + +_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 modnames(prefix): + """ + >>> 'list' in [n for n in modnames('libbe.command')] + True + >>> 'plugin' in [n for n in modnames('libbe.util')] + True + """ + 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] diff --git a/libbe/util/subproc.py b/libbe/util/subproc.py new file mode 100644 index 0000000..b02b8e8 --- /dev/null +++ b/libbe/util/subproc.py @@ -0,0 +1,223 @@ +# Copyright (C) 2009-2010 W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +""" +Functions for running external commands in subprocesses. +""" + +from subprocess import Popen, PIPE +import sys + +import libbe +from encoding import get_encoding +if libbe.TESTING == True: + import doctest + +_MSWINDOWS = sys.platform == 'win32' +_POSIX = not _MSWINDOWS + +if _POSIX == True: + import os + import select + +class CommandError(Exception): + def __init__(self, command, status, stdout=None, stderr=None): + strerror = ['Command failed (%d):\n %s\n' % (status, stderr), + 'while executing\n %s' % str(command)] + Exception.__init__(self, '\n'.join(strerror)) + self.command = command + self.status = status + self.stdout = stdout + self.stderr = stderr + +def invoke(args, stdin=None, stdout=PIPE, stderr=PIPE, expect=(0,), + cwd=None, unicode_output=True, verbose=False, encoding=None): + """ + expect should be a tuple of allowed exit codes. cwd should be + the directory from which the command will be executed. When + unicode_output == True, convert stdout and stdin strings to + unicode before returing them. + """ + if cwd == None: + cwd = '.' + if verbose == True: + print >> sys.stderr, '%s$ %s' % (cwd, ' '.join(args)) + try : + if _POSIX: + q = Popen(args, stdin=PIPE, stdout=stdout, stderr=stderr, cwd=cwd) + else: + assert _MSWINDOWS==True, 'invalid platform' + # win32 don't have os.execvp() so have to run command in a shell + q = Popen(args, stdin=PIPE, stdout=stdout, stderr=stderr, + shell=True, cwd=cwd) + except OSError, e: + raise CommandError(args, status=e.args[0], stderr=e) + stdout,stderr = q.communicate(input=stdin) + status = q.wait() + if unicode_output == True: + if encoding == None: + encoding = get_encoding() + if stdout != None: + stdout = unicode(stdout, encoding) + if stderr != None: + stderr = unicode(stderr, encoding) + if verbose == True: + print >> sys.stderr, '%d\n%s%s' % (status, stdout, stderr) + if status not in expect: + raise CommandError(args, status, stdout, stderr) + return status, stdout, stderr + +class Pipe (object): + """ + Simple interface for executing POSIX-style pipes based on the + subprocess module. The only complication is the adaptation of + subprocess.Popen._comminucate to listen to the stderrs of all + processes involved in the pipe, as well as the terminal process' + stdout. There are two implementations of Pipe._communicate, one + for MS Windows, and one for POSIX systems. The MS Windows + implementation is currently untested. + + >>> p = Pipe([['find', '/etc/'], ['grep', '^/etc/ssh$']]) + >>> p.stdout + '/etc/ssh\\n' + >>> p.status + 1 + >>> p.statuses + [1, 0] + >>> p.stderrs # doctest: +ELLIPSIS + [...find: ...: Permission denied..., ''] + """ + def __init__(self, cmds, stdin=None): + # spawn processes + self._procs = [] + for cmd in cmds: + if len(self._procs) != 0: + stdin = self._procs[-1].stdout + self._procs.append(Popen(cmd, stdin=stdin, stdout=PIPE, stderr=PIPE)) + + self.stdout,self.stderrs = self._communicate(input=None) + + # collect process statuses + self.statuses = [] + self.status = 0 + for proc in self._procs: + self.statuses.append(proc.wait()) + if self.statuses[-1] != 0: + self.status = self.statuses[-1] + + # Code excerpted from subprocess.Popen._communicate() + if _MSWINDOWS == True: + def _communicate(self, input=None): + assert input == None, 'stdin != None not yet supported' + # listen to each process' stderr + threads = [] + std_X_arrays = [] + for proc in self._procs: + stderr_array = [] + thread = Thread(target=proc._readerthread, + args=(proc.stderr, stderr_array)) + thread.setDaemon(True) + thread.start() + threads.append(thread) + std_X_arrays.append(stderr_array) + + # also listen to the last processes stdout + stdout_array = [] + thread = Thread(target=proc._readerthread, + args=(proc.stdout, stdout_array)) + thread.setDaemon(True) + thread.start() + threads.append(thread) + std_X_arrays.append(stdout_array) + + # join threads as they die + for thread in threads: + thread.join() + + # read output from reader threads + std_X_strings = [] + for std_X_array in std_X_arrays: + std_X_strings.append(std_X_array[0]) + + stdout = std_X_strings.pop(-1) + stderrs = std_X_strings + return (stdout, stderrs) + else: + assert _POSIX==True, 'invalid platform' + def _communicate(self, input=None): + read_set = [] + write_set = [] + read_arrays = [] + stdout = None # Return + stderr = None # Return + + if self._procs[0].stdin: + # Flush stdio buffer. This might block, if the user has + # been writing to .stdin in an uncontrolled fashion. + self._procs[0].stdin.flush() + if input: + write_set.append(self._procs[0].stdin) + else: + self._procs[0].stdin.close() + for proc in self._procs: + read_set.append(proc.stderr) + read_arrays.append([]) + read_set.append(self._procs[-1].stdout) + read_arrays.append([]) + + input_offset = 0 + while read_set or write_set: + try: + rlist, wlist, xlist = select.select(read_set, write_set, []) + except select.error, e: + if e.args[0] == errno.EINTR: + continue + raise + if self._procs[0].stdin in wlist: + # When select has indicated that the file is writable, + # we can write up to PIPE_BUF bytes without risk + # blocking. POSIX defines PIPE_BUF >= 512 + chunk = input[input_offset : input_offset + 512] + bytes_written = os.write(self.stdin.fileno(), chunk) + input_offset += bytes_written + if input_offset >= len(input): + self._procs[0].stdin.close() + write_set.remove(self._procs[0].stdin) + if self._procs[-1].stdout in rlist: + data = os.read(self._procs[-1].stdout.fileno(), 1024) + if data == '': + self._procs[-1].stdout.close() + read_set.remove(self._procs[-1].stdout) + read_arrays[-1].append(data) + for i,proc in enumerate(self._procs): + if proc.stderr in rlist: + data = os.read(proc.stderr.fileno(), 1024) + if data == '': + proc.stderr.close() + read_set.remove(proc.stderr) + read_arrays[i].append(data) + + # All data exchanged. Translate lists into strings. + read_strings = [] + for read_array in read_arrays: + read_strings.append(''.join(read_array)) + + stdout = read_strings.pop(-1) + stderrs = read_strings + return (stdout, stderrs) + +if libbe.TESTING == True: + suite = doctest.DocTestSuite() diff --git a/libbe/util/tree.py b/libbe/util/tree.py new file mode 100644 index 0000000..812b0bd --- /dev/null +++ b/libbe/util/tree.py @@ -0,0 +1,258 @@ +# Bugs Everywhere, a distributed bugtracker +# Copyright (C) 2008-2010 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 :class:`Tree`, a traversable tree structure. +""" + +import libbe +if libbe.TESTING == True: + import doctest + +class Tree(list): + """A traversable tree structure. + + Examples + -------- + + Construct:: + + +-b---d-g + a-+ +-e + +-c-+-f-h-i + + with + + >>> i = Tree(); i.n = "i" + >>> h = Tree([i]); h.n = "h" + >>> f = Tree([h]); f.n = "f" + >>> e = Tree(); e.n = "e" + >>> c = Tree([f,e]); c.n = "c" + >>> g = Tree(); g.n = "g" + >>> d = Tree([g]); d.n = "d" + >>> b = Tree([d]); b.n = "b" + >>> a = Tree(); a.n = "a" + >>> a.append(c) + >>> a.append(b) + + Get the longest branch length with + + >>> a.branch_len() + 5 + + Sort the tree recursively. Here we sort longest branch length + first. + + >>> a.sort(key=lambda node : -node.branch_len()) + >>> "".join([node.n for node in a.traverse()]) + 'acfhiebdg' + + And here we sort shortest branch length first. + + >>> a.sort(key=lambda node : node.branch_len()) + >>> "".join([node.n for node in a.traverse()]) + 'abdgcefhi' + + We can also do breadth-first traverses. + + >>> "".join([node.n for node in a.traverse(depth_first=False)]) + 'abcdefghi' + + Serialize the tree with depth marking branches. + + >>> for depth,node in a.thread(): + ... print "%*s" % (2*depth+1, node.n) + a + b + d + g + c + e + f + h + i + + Flattening the thread disables depth increases except at + branch splits. + + >>> for depth,node in a.thread(flatten=True): + ... print "%*s" % (2*depth+1, node.n) + a + b + d + g + c + e + f + h + i + + We can also check if a node is contained in a tree. + + >>> a.has_descendant(g) + True + >>> c.has_descendant(g) + False + >>> a.has_descendant(a) + False + >>> a.has_descendant(a, match_self=True) + True + """ + def __cmp__(self, other): + return cmp(id(self), id(other)) + + def __eq__(self, other): + return self.__cmp__(other) == 0 + + def __ne__(self, other): + return self.__cmp__(other) != 0 + + def branch_len(self): + """Return the largest number of nodes from root to leaf (inclusive). + + For the tree:: + + +-b---d-g + a-+ +-e + +-c-+-f-h-i + + this method returns 5. + + Notes + ----- + Exhaustive search every time == *slow*. + + Use only on small trees, or reimplement by overriding + child-addition methods to allow accurate caching. + """ + if len(self) == 0: + return 1 + else: + return 1 + max([child.branch_len() for child in self]) + + def sort(self, *args, **kwargs): + """Sort the tree recursively. + + This method extends :meth:`list.sort` to Trees. + + Notes + ----- + This method can be slow, e.g. on a :meth:`branch_len` sort, + since a node at depth `N` from the root has it's + :meth:`branch_len` method called `N` times. + """ + list.sort(self, *args, **kwargs) + for child in self: + child.sort(*args, **kwargs) + + def traverse(self, depth_first=True): + """Generate all the nodes in a tree, starting with the root node. + + Parameters + ---------- + depth_first : bool + Depth first by default, but you can set `depth_first` to + `False` for breadth first ordering. Siblings are returned + in the order they are stored, so you might want to + :meth:`sort` your tree first. + """ + if depth_first == True: + yield self + for child in self: + for descendant in child.traverse(): + yield descendant + else: # breadth first, Wikipedia algorithm + # http://en.wikipedia.org/wiki/Breadth-first_search + queue = [self] + while len(queue) > 0: + node = queue.pop(0) + yield node + queue.extend(node) + + def thread(self, flatten=False): + """Generate a (depth, node) tuple for every node in the tree. + + When `flatten` is `False`, the depth of any node is one + greater than the depth of its parent. That way the + inheritance is explicit, but you can end up with highly + indented threads. + + When `flatten` is `True`, the depth of any node is only + greater than the depth of its parent when there is a branch, + and the node is not the last child. This can lead to ancestry + ambiguity, but keeps the total indentation down. For example:: + + +-b +-b-c + a-+-c and a-+ + +-d-e-f +-d-e-f + + would both produce (after sorting by :meth:`branch_len`):: + + (0, a) + (1, b) + (1, c) + (0, d) + (0, e) + (0, f) + + """ + stack = [] # ancestry of the current node + if flatten == True: + depthDict = {} + + for node in self.traverse(depth_first=True): + while len(stack) > 0 \ + and id(node) not in [id(c) for c in stack[-1]]: + stack.pop(-1) + if flatten == False: + depth = len(stack) + else: + if len(stack) == 0: + depth = 0 + else: + parent = stack[-1] + depth = depthDict[id(parent)] + if len(parent) > 1 and node != parent[-1]: + depth += 1 + depthDict[id(node)] = depth + yield (depth,node) + stack.append(node) + + def has_descendant(self, descendant, depth_first=True, match_self=False): + """Check if a node is contained in a tree. + + Parameters + ---------- + descendant : Tree + The potential descendant. + depth_first : bool + The search order. Set this if you feel depth/breadth would + be a faster search. + match_self : bool + Set to `True` for:: + + x.has_descendant(x, match_self=True) -> True + """ + if descendant == self: + return match_self + for d in self.traverse(depth_first): + if descendant == d: + return True + return False + +if libbe.TESTING == True: + suite = doctest.DocTestSuite() diff --git a/libbe/util/utility.py b/libbe/util/utility.py new file mode 100644 index 0000000..c12e9a2 --- /dev/null +++ b/libbe/util/utility.py @@ -0,0 +1,248 @@ +# Copyright (C) 2005-2010 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. + +""" +Assorted utility functions that don't fit in anywhere else. +""" + +import calendar +import codecs +import os +import shutil +import tempfile +import time +import types + +import libbe +if libbe.TESTING == True: + import doctest + +class InvalidXML(ValueError): + """Invalid XML while parsing for a `*.from_xml()` method. + + Parameters + ---------- + type : str + String identifying `*`, e.g. "bug", "comment", ... + element : :class:`ElementTree.Element` + ElementTree.Element instance which caused the error. + error : str + Error description. + """ + def __init__(self, type, element, error): + msg = 'Invalid %s xml: %s\n %s\n' \ + % (type, error, ElementTree.tostring(element)) + ValueError.__init__(self, msg) + self.type = type + self.element = element + self.error = error + +def search_parent_directories(path, filename): + """ + Find the file (or directory) named filename in path or in any + of path's parents. For example:: + + search_parent_directories("/a/b/c", ".be") + + will return the path to the first existing file from:: + + /a/b/c/.be + /a/b/.be + /a/.be + /.be + + or `None` if none of those files exist. + """ + path = os.path.realpath(path) + assert os.path.exists(path) + old_path = None + while True: + check_path = os.path.join(path, filename) + if os.path.exists(check_path): + return check_path + if path == old_path: + return None + old_path = path + path = os.path.dirname(path) + +class Dir (object): + """A temporary directory for testing use. + + Make sure you run :meth:`cleanup` after you're done using the + directory. + """ + def __init__(self): + self.path = tempfile.mkdtemp(prefix="BEtest") + self.removed = False + def cleanup(self): + if self.removed == False: + shutil.rmtree(self.path) + self.removed = True + def __call__(self): + return self.path + +RFC_2822_TIME_FMT = "%a, %d %b %Y %H:%M:%S +0000" +"""RFC 2822 [#]_ format string for :func:`time.strftime` and +:func:`time.strptime`. + +.. [#] See `RFC 2822`_, sections 3.3 and A.1.1. +.. _RFC 2822: http://www.faqs.org/rfcs/rfc2822.html +""" + +def time_to_str(time_val): + """Convert a time number into an RFC 2822-formatted string. + + Parameters + ---------- + time_val : float + Float seconds since the Epoc, see :func:`time.time`. + Note that while `time_val` may contain sub-second data, + the output string will not. + + Examples + -------- + + >>> time_to_str(0) + 'Thu, 01 Jan 1970 00:00:00 +0000' + + See Also + -------- + str_to_time : inverse + handy_time : localtime string + """ + return time.strftime(RFC_2822_TIME_FMT, time.gmtime(time_val)) + +def str_to_time(str_time): + """Convert an RFC 2822-fomatted string into a time value. + + Parameters + ---------- + str_time : str + An RFC 2822-formatted string. + + Examples + -------- + + >>> str_to_time("Thu, 01 Jan 1970 00:00:00 +0000") + 0 + >>> q = time.time() + >>> str_to_time(time_to_str(q)) == int(q) + True + >>> str_to_time("Thu, 01 Jan 1970 00:00:00 -1000") + 36000 + + See Also + -------- + time_to_str : inverse + """ + timezone_str = str_time[-5:] + if timezone_str != "+0000": + str_time = str_time.replace(timezone_str, "+0000") + time_val = calendar.timegm(time.strptime(str_time, RFC_2822_TIME_FMT)) + timesign = -int(timezone_str[0]+"1") # "+" -> time_val ahead of GMT + timezone_tuple = time.strptime(timezone_str[1:], "%H%M") + timezone = timezone_tuple.tm_hour*3600 + timezone_tuple.tm_min*60 + return time_val + timesign*timezone + +def handy_time(time_val): + """Convert a time number into a useful localtime. + + Where :func:`time_to_str` returns GMT +0000, `handy_time` returns + a string in local time. This may be more accessible for the user. + + Parameters + ---------- + time_val : float + Float seconds since the Epoc, see :func:`time.time`. + """ + return time.strftime("%a, %d %b %Y %H:%M", time.localtime(time_val)) + +def time_to_gmtime(str_time): + """Convert an RFC 2822-fomatted string to a GMT string. + + Parameters + ---------- + str_time : str + An RFC 2822-formatted string. + + Examples + -------- + + >>> time_to_gmtime("Thu, 01 Jan 1970 00:00:00 -1000") + 'Thu, 01 Jan 1970 10:00:00 +0000' + """ + time_val = str_to_time(str_time) + return time_to_str(time_val) + +def iterable_full_of_strings(value, alternative=None): + """Require an iterable full of strings. + + This is useful, for example, in validating `*.extra_strings`. + See :attr:`libbe.bugdir.BugDir.extra_strings` + + Parameters + ---------- + value : list or None + The potential list of strings. + alternative + Allow a default (e.g. `None`), such that:: + + iterable_full_of_strings(value=x, alternative=x) -> True + + Examples + -------- + + >>> iterable_full_of_strings([]) + True + >>> iterable_full_of_strings(["abc", "def", u"hij"]) + True + >>> iterable_full_of_strings(["abc", None, u"hij"]) + False + >>> iterable_full_of_strings(None, alternative=None) + True + """ + if value == alternative: + return True + elif not hasattr(value, '__iter__'): + return False + for x in value: + if type(x) not in types.StringTypes: + return False + return True + +def underlined(string, char='='): + """Produces a version of a string that is underlined. + + Parameters + ---------- + string : str + The string to underline + char : str + The character to use for the underlining. + + Examples + -------- + + >>> underlined("Underlined String") + 'Underlined String\\n=================' + """ + assert len(char) == 1, char + return '%s\n%s' % (string, char*len(string)) + +if libbe.TESTING == True: + suite = doctest.DocTestSuite() diff --git a/libbe/version.py b/libbe/version.py new file mode 100644 index 0000000..2792de4 --- /dev/null +++ b/libbe/version.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python +# Copyright (C) 2009-2010 W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +""" +Store version info for this BE installation. By default, use the +bzr-generated information in _version.py, but allow manual overriding +by setting _VERSION. This allows support of both the "I don't want to +be bothered setting version strings" and the "I want complete control +over the version strings" workflows. +""" + +import copy + +import libbe._version as _version +import libbe.storage + +# Manually set a version string (optional, defaults to bzr revision id) +#_VERSION = "1.2.3" + +def version(verbose=False): + """ + Returns the version string for this BE installation. If + verbose==True, the string will include extra lines with more + detail (e.g. bzr branch nickname, etc.). + """ + if "_VERSION" in globals(): + string = _VERSION + else: + string = _version.version_info["revision_id"] + if verbose == True: + 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\n" + "storage version: %(storage)s" + % info) + return string + +if __name__ == "__main__": + print version(verbose=True) |