aboutsummaryrefslogtreecommitdiffstats
path: root/libbe
diff options
context:
space:
mode:
Diffstat (limited to 'libbe')
-rw-r--r--libbe/arch.py60
-rw-r--r--libbe/beuuid.py23
-rw-r--r--libbe/bug.py150
-rw-r--r--libbe/bugdir.py444
-rw-r--r--libbe/bzr.py63
-rw-r--r--libbe/cmdutil.py109
-rw-r--r--libbe/comment.py444
-rw-r--r--libbe/config.py23
-rw-r--r--libbe/darcs.py82
-rw-r--r--libbe/diff.py492
-rw-r--r--libbe/editor.py29
-rw-r--r--libbe/encoding.py28
-rw-r--r--libbe/git.py71
-rw-r--r--libbe/hg.py52
-rw-r--r--libbe/mapfile.py27
-rw-r--r--libbe/plugin.py23
-rw-r--r--libbe/properties.py90
-rw-r--r--libbe/rcs.py151
-rw-r--r--libbe/settings_object.py83
-rw-r--r--libbe/tree.py56
-rw-r--r--libbe/utility.py66
21 files changed, 1791 insertions, 775 deletions
diff --git a/libbe/arch.py b/libbe/arch.py
index 3051b34..63e39e3 100644
--- a/libbe/arch.py
+++ b/libbe/arch.py
@@ -2,21 +2,20 @@
# Ben Finney <ben+python@benfinney.id.au>
# James Rowe <jnrowe@ukfsn.org>
# W. Trevor King <wking@drexel.edu>
-# <abentley@panoramicfeedback.com>
#
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 2 of the License, or
-# (at your option) any later version.
+# This program is 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.
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+# 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 os
@@ -63,7 +62,11 @@ class Arch(RCS):
self._create_project(path)
self._add_project_code(path)
def _create_archive(self, path):
- # Create a new archive
+ """
+ Create a temporary Arch archive in the directory PATH. This
+ archive will be removed by
+ __del__->cleanup->_rcs_cleanup->_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()
@@ -177,7 +180,6 @@ class Arch(RCS):
self._get_archive_project_name(root)
return root
-
def _get_archive_name(self, root):
status,output,error = self._u_invoke_client("archives")
lines = output.split('\n')
@@ -189,7 +191,6 @@ class Arch(RCS):
if os.path.realpath(location) == os.path.realpath(root):
self._archive_name = archive
assert self._archive_name != None
-
def _get_archive_project_name(self, root):
# get project names
status,output,error = self._u_invoke_client("tree-version", directory=root)
@@ -261,16 +262,17 @@ class Arch(RCS):
else:
status,output,error = \
self._u_invoke_client("get", revision,directory)
- def _rcs_commit(self, commitfile):
+ def _rcs_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:
+ raise rcs.EmptyCommit()
summary,body = self._u_parse_commitfile(commitfile)
- #status,output,error = self._invoke_client("make-log")
- if body == None:
- status,output,error \
- = self._u_invoke_client("commit","--summary",summary)
- else:
- status,output,error \
- = self._u_invoke_client("commit","--summary",summary,
- "--log-message",body)
+ 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)
@@ -281,6 +283,16 @@ class Arch(RCS):
assert revpath.startswith(self._archive_project_name()+'--')
revision = revpath[len(self._archive_project_name()+'--'):]
return revpath
+ def _rcs_revision_id(self, index):
+ status,output,error = self._u_invoke_client("logs")
+ logs = output.splitlines()
+ first_log = logs.pop(0)
+ assert first_log == "base-0", first_log
+ try:
+ log = logs[index]
+ except IndexError:
+ return None
+ return "%s--%s" % (self._archive_project_name(), log)
class CantAddFile(Exception):
def __init__(self, file):
diff --git a/libbe/beuuid.py b/libbe/beuuid.py
index de67cb7..bc47208 100644
--- a/libbe/beuuid.py
+++ b/libbe/beuuid.py
@@ -1,19 +1,18 @@
# Copyright (C) 2008-2009 W. Trevor King <wking@drexel.edu>
-# <abentley@panoramicfeedback.com>
#
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 2 of the License, or
-# (at your option) any later version.
+# This program is 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.
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""
Backwards compatibility support for Python 2.4. Once people give up
on 2.4 ;), the uuid call should be merged into bugdir.py
diff --git a/libbe/bug.py b/libbe/bug.py
index dfa49f2..7554318 100644
--- a/libbe/bug.py
+++ b/libbe/bug.py
@@ -1,21 +1,20 @@
# Copyright (C) 2008-2009 Chris Ball <cjb@laptop.org>
# Thomas Habets <thomas@habets.pp.se>
# W. Trevor King <wking@drexel.edu>
-# <abentley@panoramicfeedback.com>
#
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 2 of the License, or
-# (at your option) any later version.
+# This program is 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.
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
import os
import os.path
import errno
@@ -34,6 +33,11 @@ import comment
import utility
+class DiskAccessRequired (Exception):
+ def __init__(self, goal):
+ msg = "Cannot %s without accessing the disk" % goal
+ Exception.__init__(self, msg)
+
### Define and describe valid bug categories
# Use a tuple of (category, description) tuples since we don't have
# ordered dicts in Python yet http://www.python.org/dev/peps/pep-0372/
@@ -68,7 +72,7 @@ def load_severities(severity_def):
global severity_values
global severity_description
global severity_index
- if severity_def == settings_object.EMPTY:
+ if severity_def == None:
return
severity_values = tuple([val for val,description in severity_def])
severity_description = dict(severity_def)
@@ -88,9 +92,9 @@ def load_status(active_status_def, inactive_status_def):
global status_values
global status_description
global status_index
- if active_status_def == settings_object.EMPTY:
+ if active_status_def == None:
active_status_def = globals()["active_status_def"]
- if inactive_status_def == settings_object.EMPTY:
+ 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])
@@ -178,7 +182,7 @@ class Bug(settings_object.SavedSettingsObject):
def time_string(): return {}
def _get_time(self):
- if self.time_string == None or self.time_string == settings_object.EMPTY:
+ if self.time_string == None:
return None
return utility.str_to_time(self.time_string)
def _set_time(self, value):
@@ -188,15 +192,8 @@ class Bug(settings_object.SavedSettingsObject):
doc="An integer version of .time_string")
def _extra_strings_check_fn(value):
- "Require an iterable full of strings"
- if value == settings_object.EMPTY:
- return True
- elif not hasattr(value, "__iter__"):
- return False
- for x in value:
- if type(x) not in types.StringTypes:
- return False
- return True
+ 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)
@@ -253,12 +250,19 @@ class Bug(settings_object.SavedSettingsObject):
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 == settings_object.EMPTY:
+ if value == None:
return ""
- else:
- return str(value)
+ return str(value)
def xml(self, show_comments=False):
if self.bugdir == None:
@@ -283,7 +287,7 @@ class Bug(settings_object.SavedSettingsObject):
("summary", self.summary)]
ret = '<bug>\n'
for (k,v) in info:
- if v is not settings_object.EMPTY:
+ if v is not None:
ret += ' <%s>%s</%s>\n' % (k,xml.sax.saxutils.escape(v),k)
for estr in self.extra_strings:
ret += ' <extra-string>%s</extra-string>\n' % estr
@@ -335,11 +339,7 @@ class Bug(settings_object.SavedSettingsObject):
output = bugout
return output
- def __str__(self):
- return self.string(shortlist=True)
-
- def __cmp__(self, other):
- return cmp_full(self, other)
+ # methods for saving/loading/acessing settings and properties.
def get_path(self, name=None):
my_dir = os.path.join(self.bugdir.get_path("bugs"), self.uuid)
@@ -348,11 +348,47 @@ class Bug(settings_object.SavedSettingsObject):
assert name in ["values", "comments"]
return os.path.join(my_dir, name)
+ def set_sync_with_disk(self, value):
+ self.sync_with_disk = value
+ for comment in self.comments():
+ comment.set_sync_with_disk(value)
+
def load_settings(self):
+ if self.sync_with_disk == False:
+ raise DiskAccessRequired("load settings")
self.settings = mapfile.map_load(self.rcs, self.get_path("values"))
self._setup_saved_settings()
+ def save_settings(self):
+ if self.sync_with_disk == False:
+ raise DiskAccessRequired("save settings")
+ assert self.summary != None, "Can't save blank bug"
+ self.rcs.mkdir(self.get_path())
+ path = self.get_path("values")
+ mapfile.map_save(self.rcs, path, self._get_saved_settings())
+
+ def save(self):
+ """
+ Save any loaded contents to disk. Because of lazy loading of
+ comments, this is actually not too inefficient.
+
+ However, if self.sync_with_disk = True, then any changes are
+ automatically written to disk as soon as they happen, so
+ calling this method will just waste time (unless something
+ else has been messing with your on-disk files).
+ """
+ sync_with_disk = self.sync_with_disk
+ if sync_with_disk == False:
+ self.set_sync_with_disk(True)
+ self.save_settings()
+ if len(self.comment_root) > 0:
+ comment.saveComments(self)
+ if sync_with_disk == False:
+ self.set_sync_with_disk(False)
+
def load_comments(self, load_full=True):
+ if self.sync_with_disk == False:
+ raise DiskAccessRequired("load comments")
if load_full == True:
# Force a complete load of the whole comment tree
self.comment_root = self._get_comment_root(load_full=True)
@@ -365,25 +401,15 @@ class Bug(settings_object.SavedSettingsObject):
self.comment_root = None
self.sync_with_disk = True
- def save_settings(self):
- assert self.summary != None, "Can't save blank bug"
-
- self.rcs.mkdir(self.get_path())
- path = self.get_path("values")
- mapfile.map_save(self.rcs, path, self._get_saved_settings())
-
- def save(self):
- self.save_settings()
-
- if len(self.comment_root) > 0:
- self.rcs.mkdir(self.get_path("comments"))
- comment.saveComments(self)
-
def remove(self):
+ if self.sync_with_disk == False:
+ raise DiskAccessRequired("remove")
self.comment_root.remove()
path = self.get_path()
self.rcs.recursive_remove(path)
+ # methods for managing comments
+
def comments(self):
for comment in self.comment_root.traverse():
yield comment
@@ -477,8 +503,8 @@ def cmp_attr(bug_1, bug_2, attr, invert=False):
return 1
val_1 = getattr(bug_1, attr)
val_2 = getattr(bug_2, attr)
- if val_1 == settings_object.EMPTY: val_1 = None
- if val_2 == settings_object.EMPTY: val_2 = None
+ if val_1 == None: val_1 = None
+ if val_2 == None: val_2 = None
if invert == True :
return -cmp(val_1, val_2)
@@ -486,13 +512,35 @@ def cmp_attr(bug_1, bug_2, attr, invert=False):
return cmp(val_1, val_2)
# alphabetical rankings (a < z)
+cmp_uuid = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "uuid")
cmp_creator = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "creator")
cmp_assigned = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "assigned")
+cmp_target = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "target")
+cmp_reporter = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "reporter")
+cmp_summary = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "summary")
# chronological rankings (newer < older)
cmp_time = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "time", invert=True)
+def cmp_comments(bug_1, bug_2):
+ """
+ Compare two bugs' comments lists. Doesn't load any new comments,
+ so you should call each bug's .load_comments() first if you want a
+ full comparison.
+ """
+ comms_1 = sorted(bug_1.comments(), key = lambda comm : comm.uuid)
+ comms_2 = sorted(bug_2.comments(), key = lambda comm : comm.uuid)
+ result = cmp(len(comms_1), len(comms_2))
+ if result != 0:
+ return result
+ for c_1,c_2 in zip(comms_1, comms_2):
+ result = cmp(c_1, c_2)
+ if result != 0:
+ return result
+ return 0
+
DEFAULT_CMP_FULL_CMP_LIST = \
- (cmp_status,cmp_severity,cmp_assigned,cmp_time,cmp_creator)
+ (cmp_status, cmp_severity, cmp_assigned, cmp_time, cmp_creator,
+ cmp_reporter, cmp_target, cmp_comments, cmp_summary, cmp_uuid)
class BugCompoundComparator (object):
def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST):
diff --git a/libbe/bugdir.py b/libbe/bugdir.py
index d4a39cb..0eb4a6c 100644
--- a/libbe/bugdir.py
+++ b/libbe/bugdir.py
@@ -3,26 +3,26 @@
# Chris Ball <cjb@laptop.org>
# Oleg Romanyshyn <oromanyshyn@panoramicfeedback.com>
# W. Trevor King <wking@drexel.edu>
-# <abentley@panoramicfeedback.com>
#
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 2 of the License, or
-# (at your option) any later version.
+# This program is 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.
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+import copy
+import errno
import os
import os.path
-import errno
+import sys
import time
-import copy
import unittest
import doctest
@@ -52,7 +52,7 @@ class NoRootEntry(Exception):
class AlreadyInitialized(Exception):
def __init__(self, path):
self.path = path
- Exception.__init__(self,
+ Exception.__init__(self,
"Specified root is already initialized: %s" % path)
class MultipleBugMatches(ValueError):
@@ -63,6 +63,17 @@ class MultipleBugMatches(ValueError):
self.shortname = shortname
self.matches = matches
+class NoBugMatches(KeyError):
+ def __init__(self, shortname):
+ msg = "No bug matches %s" % shortname
+ KeyError.__init__(self, msg)
+ self.shortname = shortname
+
+class DiskAccessRequired (Exception):
+ def __init__(self, goal):
+ msg = "Cannot %s without accessing the disk" % goal
+ Exception.__init__(self, msg)
+
TREE_VERSION_STRING = "Bugs Everywhere Tree 1 0\n"
@@ -71,7 +82,7 @@ class BugDir (list, settings_object.SavedSettingsObject):
"""
Sink to existing root
======================
-
+
Consider the following usage case:
You have a bug directory rooted in
/path/to/source
@@ -86,23 +97,35 @@ class BugDir (list, settings_object.SavedSettingsObject):
/path/to/source/GUI/.be miss
/path/to/source/.be hit!
So it still roots itself appropriately without much work for you.
-
+
File-system access
==================
-
- When rooted in non-bugdir directory, BugDirs live completely in
- memory until the first call to .save(). This creates a '.be'
- sub-directory containing configurations options, bugs, comments,
- etc. Once this sub-directory has been created (possibly by
- another BugDir instance) any changes to the BugDir in memory will
- be flushed to the file system automatically. However, the BugDir
- will only load information from the file system when it loads new
- bugs/comments that it doesn't already have in memory, or when it
- explicitly asked to do so (e.g. .load() or __init__(from_disk=True)).
-
+
+ BugDirs live completely in memory when .sync_with_disk is False.
+ This is the default configuration setup by BugDir(from_disk=False).
+ If .sync_with_disk == True (e.g. BugDir(from_disk=True)), then
+ any changes to the BugDir will be immediately written to disk.
+
+ If you want to change .sync_with_disk, we suggest you use
+ .set_sync_with_disk(), which propogates the new setting through to
+ all bugs/comments/etc. that have been loaded into memory. If
+ you've been living in memory and want to move to
+ .sync_with_disk==True, but you're not sure if anything has been
+ changed in memory, a call to save() immediately before the
+ .set_sync_with_disk(True) call is a safe move.
+
+ Regardless of .sync_with_disk, a call to .save() will write out
+ all the contents that the BugDir instance has loaded into memory.
+ If sync_with_disk has been True over the course of all interesting
+ changes, this .save() call will be a waste of time.
+
+ The BugDir will only load information from the file system when it
+ loads new settings/bugs/comments that it doesn't already have in
+ memory and .sync_with_disk == True.
+
Allow RCS initialization
========================
-
+
This one is for testing purposes. Setting it to True allows the
BugDir to search for an installed RCS backend and initialize it in
the root directory. This is a convenience option for supporting
@@ -110,7 +133,7 @@ class BugDir (list, settings_object.SavedSettingsObject):
Disable encoding manipulation
=============================
-
+
This one is for testing purposed. You might have non-ASCII
Unicode in your bugs, comments, files, etc. BugDir instances try
and support your preferred encoding scheme (e.g. "utf-8") when
@@ -136,16 +159,17 @@ class BugDir (list, settings_object.SavedSettingsObject):
return settings_object.versioned_property(**kwargs)
@_versioned_property(name="target",
- doc="The current project development target")
+ doc="The current project development target.")
def target(): return {}
def _guess_encoding(self):
return encoding.get_encoding()
def _check_encoding(value):
- if value != None and value != settings_object.EMPTY:
+ if value != None:
return encoding.known_encoding(value)
def _setup_encoding(self, new_encoding):
- if new_encoding != None and new_encoding != settings_object.EMPTY:
+ # change hook called before generator.
+ if new_encoding not in [None, settings_object.EMPTY]:
if self._manipulate_encodings == True:
encoding.set_IO_stream_encodings(new_encoding)
def _set_encoding(self, old_encoding, new_encoding):
@@ -160,7 +184,7 @@ class BugDir (list, settings_object.SavedSettingsObject):
def encoding(): return {}
def _setup_user_id(self, user_id):
- self.rcs.user_id = user_id
+ self.rcs.user_id = user_id
def _guess_user_id(self):
return self.rcs.get_user_id()
def _set_user_id(self, old_user_id, new_user_id):
@@ -215,7 +239,21 @@ settings easy. Don't set this attribute. Set .rcs instead, and
if uuid not in map:
map[uuid] = None
self._bug_map_value = map # ._bug_map_value used by @local_property
-
+
+ def _extra_strings_check_fn(value):
+ return utility.iterable_full_of_strings(value, \
+ alternative=settings_object.EMPTY)
+ def _extra_strings_change_hook(self, old, new):
+ self.extra_strings.sort() # to make merging easier
+ self._prop_save_settings(old, new)
+ @_versioned_property(name="extra_strings",
+ doc="Space for an array of extra strings. Useful for storing state for functionality implemented purely in becommands/<some_function>.py.",
+ default=[],
+ check_fn=_extra_strings_check_fn,
+ change_hook=_extra_strings_change_hook,
+ mutable=True)
+ def extra_strings(): return {}
+
@Property
@primed_property(primer=_bug_map_gen)
@local_property("bug_map")
@@ -223,7 +261,7 @@ settings easy. Don't set this attribute. Set .rcs instead, and
def _bug_map(): return {}
def _setup_severities(self, severities):
- if severities != None and severities != settings_object.EMPTY:
+ 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)
@@ -254,8 +292,7 @@ settings easy. Don't set this attribute. Set .rcs instead, and
def __init__(self, root=None, sink_to_existing_root=True,
assert_new_BugDir=False, allow_rcs_init=False,
- manipulate_encodings=True,
- from_disk=False, rcs=None):
+ manipulate_encodings=True, from_disk=False, rcs=None):
list.__init__(self)
settings_object.SavedSettingsObject.__init__(self)
self._manipulate_encodings = manipulate_encodings
@@ -270,7 +307,7 @@ settings easy. Don't set this attribute. Set .rcs instead, and
# get a temporary rcs until we've loaded settings
self.sync_with_disk = False
self.rcs = self._guess_rcs()
-
+
if from_disk == True:
self.sync_with_disk = True
self.load()
@@ -284,10 +321,19 @@ settings easy. Don't set this attribute. Set .rcs instead, and
self.rcs = rcs
self._setup_user_id(self.user_id)
+ def __del__(self):
+ self.cleanup()
+
+ def cleanup(self):
+ self.rcs.cleanup()
+
+ # methods for getting the BugDir situated in the filesystem
+
def _find_root(self, path):
"""
Search for an existing bug database dir and it's ancestors and
- return a BugDir rooted there.
+ return a BugDir rooted there. Only called by __init__, and
+ then only if sink_to_existing_root == True.
"""
if not os.path.exists(path):
raise NoRootEntry(path)
@@ -302,8 +348,78 @@ settings easy. Don't set this attribute. Set .rcs instead, and
if beroot == None:
raise NoBugDir(path)
return beroot
-
+
+ def _guess_rcs(self, allow_rcs_init=False):
+ """
+ Only called by __init__.
+ """
+ deepdir = self.get_path()
+ if not os.path.exists(deepdir):
+ deepdir = os.path.dirname(deepdir)
+ new_rcs = rcs.detect_rcs(deepdir)
+ install = False
+ if new_rcs.name == "None":
+ if allow_rcs_init == True:
+ new_rcs = rcs.installed_rcs()
+ new_rcs.init(self.root)
+ return new_rcs
+
+ # methods for saving/loading/accessing settings and properties.
+
+ def get_path(self, *args):
+ """
+ Return a path relative to .root.
+ """
+ my_dir = os.path.join(self.root, ".be")
+ if len(args) == 0:
+ return my_dir
+ assert args[0] in ["version", "settings", "bugs"], str(args)
+ return os.path.join(my_dir, *args)
+
+ def _get_settings(self, settings_path, for_duplicate_bugdir=False):
+ allow_no_rcs = not self.rcs.path_in_root(settings_path)
+ if allow_no_rcs == True:
+ assert for_duplicate_bugdir == True
+ if self.sync_with_disk == False and for_duplicate_bugdir == False:
+ # duplicates can ignore this bugdir's .sync_with_disk status
+ raise DiskAccessRequired("_get settings")
+ try:
+ settings = mapfile.map_load(self.rcs, settings_path, allow_no_rcs)
+ except rcs.NoSuchFile:
+ settings = {"rcs_name": "None"}
+ return settings
+
+ def _save_settings(self, settings_path, settings,
+ for_duplicate_bugdir=False):
+ allow_no_rcs = not self.rcs.path_in_root(settings_path)
+ if allow_no_rcs == True:
+ assert for_duplicate_bugdir == True
+ if self.sync_with_disk == False and for_duplicate_bugdir == False:
+ # duplicates can ignore this bugdir's .sync_with_disk status
+ raise DiskAccessRequired("_save settings")
+ self.rcs.mkdir(self.get_path(), allow_no_rcs)
+ mapfile.map_save(self.rcs, settings_path, settings, allow_no_rcs)
+
+ def load_settings(self):
+ self.settings = self._get_settings(self.get_path("settings"))
+ self._setup_saved_settings()
+ self._setup_user_id(self.user_id)
+ self._setup_encoding(self.encoding)
+ self._setup_severities(self.severities)
+ self._setup_status(self.active_status, self.inactive_status)
+ self.rcs = rcs.rcs_by_name(self.rcs_name)
+ self._setup_user_id(self.user_id)
+
+ def save_settings(self):
+ settings = self._get_saved_settings()
+ self._save_settings(self.get_path("settings"), settings)
+
def get_version(self, path=None, use_none_rcs=False):
+ """
+ Requires disk access.
+ """
+ if self.sync_with_disk == False:
+ raise DiskAccessRequired("get version")
if use_none_rcs == True:
RCS = rcs.rcs_by_name("None")
RCS.root(self.root)
@@ -317,29 +433,31 @@ settings easy. Don't set this attribute. Set .rcs instead, and
return tree_version
def set_version(self):
+ """
+ Requires disk access.
+ """
+ if self.sync_with_disk == False:
+ raise DiskAccessRequired("set version")
+ self.rcs.mkdir(self.get_path())
self.rcs.set_file_contents(self.get_path("version"),
TREE_VERSION_STRING)
- def get_path(self, *args):
- my_dir = os.path.join(self.root, ".be")
- if len(args) == 0:
- return my_dir
- assert args[0] in ["version", "settings", "bugs"], str(args)
- return os.path.join(my_dir, *args)
+ # methods controlling disk access
- def _guess_rcs(self, allow_rcs_init=False):
- deepdir = self.get_path()
- if not os.path.exists(deepdir):
- deepdir = os.path.dirname(deepdir)
- new_rcs = rcs.detect_rcs(deepdir)
- install = False
- if new_rcs.name == "None":
- if allow_rcs_init == True:
- new_rcs = rcs.installed_rcs()
- new_rcs.init(self.root)
- return new_rcs
+ def set_sync_with_disk(self, value):
+ """
+ Adjust .sync_with_disk for the BugDir and all it's children.
+ See the BugDir docstring for a description of the role of
+ .sync_with_disk.
+ """
+ self.sync_with_disk = value
+ for bug in self:
+ bug.set_sync_with_disk(value)
def load(self):
+ """
+ Reqires disk access
+ """
version = self.get_version(use_none_rcs=True)
if version != TREE_VERSION_STRING:
raise NotImplementedError, \
@@ -348,52 +466,44 @@ settings easy. Don't set this attribute. Set .rcs instead, and
if not os.path.exists(self.get_path()):
raise NoBugDir(self.get_path())
self.load_settings()
-
- self.rcs = rcs.rcs_by_name(self.rcs_name)
- self._setup_user_id(self.user_id)
def load_all_bugs(self):
- "Warning: this could take a while."
+ """
+ Requires disk access.
+ Warning: this could take a while.
+ """
+ if self.sync_with_disk == False:
+ raise DiskAccessRequired("load all bugs")
self._clear_bugs()
for uuid in self.list_uuids():
self._load_bug(uuid)
def save(self):
- self.rcs.mkdir(self.get_path())
+ """
+ Note that this command writes to disk _regardless_ of the
+ status of .sync_with_disk.
+
+ Save any loaded contents to disk. Because of lazy loading of
+ bugs and comments, this is actually not too inefficient.
+
+ However, if .sync_with_disk = True, then any changes are
+ automatically written to disk as soon as they happen, so
+ calling this method will just waste time (unless something
+ else has been messing with your on-disk files).
+
+ Requires disk access.
+ """
+ sync_with_disk = self.sync_with_disk
+ if sync_with_disk == False:
+ self.set_sync_with_disk(True)
self.set_version()
self.save_settings()
- self.rcs.mkdir(self.get_path("bugs"))
for bug in self:
bug.save()
+ if sync_with_disk == False:
+ self.set_sync_with_disk(sync_with_disk)
- def load_settings(self):
- self.settings = self._get_settings(self.get_path("settings"))
- self._setup_saved_settings()
- self._setup_user_id(self.user_id)
- self._setup_encoding(self.encoding)
- self._setup_severities(self.severities)
- self._setup_status(self.active_status, self.inactive_status)
-
- def _get_settings(self, settings_path):
- allow_no_rcs = not self.rcs.path_in_root(settings_path)
- # allow_no_rcs=True should only be for the special case of
- # configuring duplicate bugdir settings
-
- try:
- settings = mapfile.map_load(self.rcs, settings_path, allow_no_rcs)
- except rcs.NoSuchFile:
- settings = {"rcs_name": "None"}
- return settings
-
- def save_settings(self):
- settings = self._get_saved_settings()
- self._save_settings(self.get_path("settings"), settings)
-
- def _save_settings(self, settings_path, settings):
- allow_no_rcs = not self.rcs.path_in_root(settings_path)
- # allow_no_rcs=True should only be for the special case of
- # configuring duplicate bugdir settings
- mapfile.map_save(self.rcs, settings_path, settings, allow_no_rcs)
+ # methods for managing duplicate BugDirs
def duplicate_bugdir(self, revision):
duplicate_path = self.rcs.duplicate_repo(revision)
@@ -402,23 +512,27 @@ settings easy. Don't set this attribute. Set .rcs instead, and
# initialized for versioning
duplicate_settings_path = os.path.join(duplicate_path,
".be", "settings")
- duplicate_settings = self._get_settings(duplicate_settings_path)
+ duplicate_settings = self._get_settings(duplicate_settings_path,
+ for_duplicate_bugdir=True)
if "rcs_name" in duplicate_settings:
duplicate_settings["rcs_name"] = "None"
duplicate_settings["user_id"] = self.user_id
if "disabled" in bug.status_values:
# Hack to support old versions of BE bugs
duplicate_settings["inactive_status"] = self.inactive_status
- self._save_settings(duplicate_settings_path, duplicate_settings)
+ self._save_settings(duplicate_settings_path, duplicate_settings,
+ for_duplicate_bugdir=True)
return BugDir(duplicate_path, from_disk=True, manipulate_encodings=self._manipulate_encodings)
def remove_duplicate_bugdir(self):
self.rcs.remove_duplicate_repo()
+ # methods for managing bugs
+
def list_uuids(self):
uuids = []
- if os.path.exists(self.get_path()):
+ if self.sync_with_disk == True and os.path.exists(self.get_path()):
# list the uuids on disk
for uuid in os.listdir(self.get_path("bugs")):
if not (uuid.startswith('.')):
@@ -436,6 +550,8 @@ settings easy. Don't set this attribute. Set .rcs instead, and
self._bug_map_gen()
def _load_bug(self, uuid):
+ if self.sync_with_disk == False:
+ raise DiskAccessRequired("_load bug")
bg = bug.Bug(bugdir=self, uuid=uuid, from_disk=True)
self.append(bg)
self._bug_map_gen()
@@ -443,20 +559,24 @@ settings easy. Don't set this attribute. Set .rcs instead, and
def new_bug(self, uuid=None, summary=None):
bg = bug.Bug(bugdir=self, uuid=uuid, summary=summary)
+ bg.set_sync_with_disk(self.sync_with_disk)
+ if bg.sync_with_disk == True:
+ bg.save()
self.append(bg)
self._bug_map_gen()
return bg
def remove_bug(self, bug):
self.remove(bug)
- bug.remove()
+ if bug.sync_with_disk == True:
+ bug.remove()
def bug_shortname(self, bug):
"""
Generate short names from uuids. Picks the minimum number of
characters (>=3) from the beginning of the uuid such that the
short names are unique.
-
+
Obviously, as the number of bugs in the database grows, these
short names will cease to be unique. The complete uuid should be
used for long term reference.
@@ -471,12 +591,13 @@ settings easy. Don't set this attribute. Set .rcs instead, and
def bug_from_shortname(self, shortname):
"""
- >>> bd = simple_bug_dir()
+ >>> bd = SimpleBugDir(sync_with_disk=False)
>>> bug_a = bd.bug_from_shortname('a')
>>> print type(bug_a)
<class 'libbe.bug.Bug'>
>>> print bug_a
a:om: Bug A
+ >>> bd.cleanup()
"""
matches = []
self._bug_map_gen()
@@ -487,7 +608,7 @@ settings easy. Don't set this attribute. Set .rcs instead, and
raise MultipleBugMatches(shortname, matches)
if len(matches) == 1:
return self.bug_from_uuid(matches[0])
- raise KeyError("No bug matches %s" % shortname)
+ raise NoBugMatches(shortname)
def bug_from_uuid(self, uuid):
if not self.has_bug(uuid):
@@ -503,43 +624,58 @@ settings easy. Don't set this attribute. Set .rcs instead, and
if bug_uuid not in self._bug_map:
return False
return True
-
-def simple_bug_dir():
+
+class SimpleBugDir (BugDir):
"""
- For testing
- >>> bugdir = simple_bug_dir()
- >>> ls = list(bugdir.list_uuids())
- >>> ls.sort()
- >>> print ls
+ For testing. Set sync_with_disk==False for a memory-only bugdir.
+ >>> bugdir = SimpleBugDir()
+ >>> uuids = list(bugdir.list_uuids())
+ >>> uuids.sort()
+ >>> print uuids
['a', 'b']
+ >>> bugdir.cleanup()
"""
- dir = utility.Dir()
- assert os.path.exists(dir.path)
- bugdir = BugDir(dir.path, sink_to_existing_root=False, allow_rcs_init=True,
+ def __init__(self, sync_with_disk=True):
+ if sync_with_disk == True:
+ dir = utility.Dir()
+ assert os.path.exists(dir.path)
+ root = dir.path
+ assert_new_BugDir = True
+ rcs_init = True
+ else:
+ root = "/"
+ assert_new_BugDir = False
+ rcs_init = False
+ BugDir.__init__(self, root, sink_to_existing_root=False,
+ assert_new_BugDir=assert_new_BugDir,
+ allow_rcs_init=rcs_init,
manipulate_encodings=False)
- bugdir._dir_ref = dir # postpone cleanup since dir.__del__() removes dir.
- bug_a = bugdir.new_bug("a", summary="Bug A")
- bug_a.creator = "John Doe <jdoe@example.com>"
- bug_a.time = 0
- bug_b = bugdir.new_bug("b", summary="Bug B")
- bug_b.creator = "Jane Doe <jdoe@example.com>"
- bug_b.time = 0
- bug_b.status = "closed"
- bugdir.save()
- return bugdir
-
+ if sync_with_disk == True: # postpone cleanup since dir.__del__() removes dir.
+ self._dir_ref = dir
+ bug_a = self.new_bug("a", summary="Bug A")
+ bug_a.creator = "John Doe <jdoe@example.com>"
+ bug_a.time = 0
+ bug_b = self.new_bug("b", summary="Bug B")
+ bug_b.creator = "Jane Doe <jdoe@example.com>"
+ bug_b.time = 0
+ bug_b.status = "closed"
+ if sync_with_disk == True:
+ self.save()
+ self.set_sync_with_disk(True)
+ def cleanup(self):
+ if hasattr(self, "_dir_ref"):
+ self._dir_ref.cleanup()
+ BugDir.cleanup(self)
class BugDirTestCase(unittest.TestCase):
- def __init__(self, *args, **kwargs):
- unittest.TestCase.__init__(self, *args, **kwargs)
def setUp(self):
self.dir = utility.Dir()
self.bugdir = BugDir(self.dir.path, sink_to_existing_root=False,
allow_rcs_init=True)
self.rcs = self.bugdir.rcs
def tearDown(self):
- self.rcs.cleanup()
+ self.bugdir.cleanup()
self.dir.cleanup()
def fullPath(self, path):
return os.path.join(self.dir.path, path)
@@ -592,24 +728,29 @@ class BugDirTestCase(unittest.TestCase):
self.failUnless(bugA == bugAprime, "%s != %s" % (bugA, bugAprime))
self.bugdir.save()
self.versionTest()
- def testComments(self):
+ 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.")
- self.bugdir.save()
- self.bugdir._clear_bugs()
+ 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 == True,
+ self.failUnless(comment.sync_with_disk == sync_with_disk,
comment.sync_with_disk)
- #load_settings()
self.failUnless(comment.content_type == "text/plain",
comment.content_type)
self.failUnless(repLoaded.settings["Content-type"]=="text/plain",
@@ -623,6 +764,49 @@ class BugDirTestCase(unittest.TestCase):
comment.body)
else:
self.failIf(True, "Invalid comment: %d\n%s" % (index, comment))
+ def testSyncedComments(self):
+ self.testComments(sync_with_disk=True)
-unitsuite = unittest.TestLoader().loadTestsFromTestCase(BugDirTestCase)
-suite = unittest.TestSuite([unitsuite])#, doctest.DocTestSuite()])
+class SimpleBugDirTestCase (unittest.TestCase):
+ def setUp(self):
+ # create a pre-existing bugdir in a temporary directory
+ self.dir = utility.Dir()
+ self.original_working_dir = os.getcwd()
+ os.chdir(self.dir.path)
+ self.bugdir = BugDir(self.dir.path, sink_to_existing_root=False,
+ allow_rcs_init=True)
+ self.bugdir.new_bug("preexisting", summary="Hopefully not imported")
+ self.bugdir.save()
+ def tearDown(self):
+ os.chdir(self.original_working_dir)
+ self.bugdir.cleanup()
+ self.dir.cleanup()
+ def testOnDiskCleanLoad(self):
+ """SimpleBugDir(sync_with_disk==True) should not import preexisting bugs."""
+ bugdir = SimpleBugDir(sync_with_disk=True)
+ self.failUnless(bugdir.sync_with_disk==True, bugdir.sync_with_disk)
+ uuids = sorted([bug.uuid for bug in bugdir])
+ self.failUnless(uuids == ['a', 'b'], uuids)
+ bugdir._clear_bugs()
+ uuids = sorted([bug.uuid for bug in bugdir])
+ self.failUnless(uuids == [], uuids)
+ bugdir.load_all_bugs()
+ uuids = sorted([bug.uuid for bug in bugdir])
+ self.failUnless(uuids == ['a', 'b'], uuids)
+ bugdir.cleanup()
+ def testInMemoryCleanLoad(self):
+ """SimpleBugDir(sync_with_disk==False) should not import preexisting bugs."""
+ bugdir = SimpleBugDir(sync_with_disk=False)
+ self.failUnless(bugdir.sync_with_disk==False, bugdir.sync_with_disk)
+ uuids = sorted([bug.uuid for bug in bugdir])
+ self.failUnless(uuids == ['a', 'b'], uuids)
+ self.failUnlessRaises(DiskAccessRequired, bugdir.load_all_bugs)
+ uuids = sorted([bug.uuid for bug in bugdir])
+ self.failUnless(uuids == ['a', 'b'], uuids)
+ bugdir._clear_bugs()
+ uuids = sorted([bug.uuid for bug in bugdir])
+ self.failUnless(uuids == [], uuids)
+ bugdir.cleanup()
+
+unitsuite = unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
+suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
diff --git a/libbe/bzr.py b/libbe/bzr.py
index d73392a..9eb7de6 100644
--- a/libbe/bzr.py
+++ b/libbe/bzr.py
@@ -2,21 +2,20 @@
# Ben Finney <ben+python@benfinney.id.au>
# Marien Zwart <marienz@gentoo.org>
# W. Trevor King <wking@drexel.edu>
-# <abentley@panoramicfeedback.com>
#
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 2 of the License, or
-# (at your option) any later version.
+# This program is 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.
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+# 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
@@ -72,9 +71,21 @@ class Bzr(RCS):
else:
self._u_invoke_client("branch", "--revision", revision,
".", directory)
- def _rcs_commit(self, commitfile):
- status,output,error = self._u_invoke_client("commit", "--unchanged",
- "--file", commitfile)
+ def _rcs_commit(self, commitfile, allow_empty=False):
+ args = ["commit", "--file", commitfile]
+ if allow_empty == True:
+ args.append("--unchanged")
+ status,output,error = self._u_invoke_client(*args)
+ else:
+ kwargs = {"expect":(0,3)}
+ status,output,error = self._u_invoke_client(*args, **kwargs)
+ if status != 0:
+ strings = ["ERROR: no changes to commit.", # bzr 1.3.1
+ "ERROR: No changes to commit."] # bzr 1.15.1
+ if self._u_any_in_string(strings, error) == True:
+ raise rcs.EmptyCommit()
+ else:
+ raise rcs.CommandError(args, status, error)
revision = None
revline = re.compile("Committed revision (.*)[.]")
match = revline.search(error)
@@ -82,20 +93,14 @@ class Bzr(RCS):
assert len(match.groups()) == 1
revision = match.groups()[0]
return revision
- def postcommit(self):
- try:
- self._u_invoke_client('merge')
- except rcs.CommandError, e:
- if ('No merge branch known or specified' in e.err_str or
- 'No merge location known or specified' in e.err_str):
- pass
- else:
- self._u_invoke_client('revert', '--no-backup',
- directory=directory)
- self._u_invoke_client('resolve', '--all', directory=directory)
- raise
- if len(self._u_invoke_client('status', directory=directory)[1]) > 0:
- self.commit('Merge from upstream')
+ def _rcs_revision_id(self, index):
+ status,output,error = self._u_invoke_client("revno")
+ current_revision = int(output)
+ if index >= current_revision or index < -current_revision:
+ return None
+ if index >= 0:
+ return str(index+1) # bzr commit 0 is the empty tree.
+ return str(current_revision+index+1)
rcs.make_rcs_testcase_subclasses(Bzr, sys.modules[__name__])
diff --git a/libbe/cmdutil.py b/libbe/cmdutil.py
index edc6442..94a6856 100644
--- a/libbe/cmdutil.py
+++ b/libbe/cmdutil.py
@@ -1,21 +1,21 @@
# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
# Oleg Romanyshyn <oromanyshyn@panoramicfeedback.com>
# W. Trevor King <wking@drexel.edu>
-# <abentley@panoramicfeedback.com>
#
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 2 of the License, or
-# (at your option) any later version.
+# This program is 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.
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+# 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 optparse
import os
from textwrap import TextWrapper
@@ -32,10 +32,10 @@ class UserError(Exception):
def __init__(self, msg):
Exception.__init__(self, msg)
-class UserErrorWrap(UserError):
- def __init__(self, exception):
- UserError.__init__(self, str(exception))
- self.exception = exception
+class UnknownCommand(UserError):
+ def __init__(self, cmd):
+ Exception.__init__(self, "Unknown command '%s'" % cmd)
+ self.cmd = cmd
class UsageError(Exception):
pass
@@ -56,24 +56,30 @@ def iter_commands():
def get_command(command_name):
"""Retrieves the module for a user command
- >>> get_command("asdf")
- Traceback (most recent call last):
- UserError: Unknown command asdf
+ >>> try:
+ ... get_command("asdf")
+ ... except UnknownCommand, e:
+ ... print e
+ Unknown command 'asdf'
>>> repr(get_command("list")).startswith("<module 'becommands.list' from ")
True
"""
cmd = plugin.get_plugin("becommands", command_name.replace("-", "_"))
if cmd is None:
- raise UserError("Unknown command %s" % command_name)
+ raise UnknownCommand(command_name)
return cmd
-def execute(cmd, args):
+def execute(cmd, args, manipulate_encodings=True):
enc = encoding.get_encoding()
- get_command(cmd).execute([a.decode(enc) for a in args])
- return 0
-
-def help(cmd=None):
+ cmd = get_command(cmd)
+ ret = cmd.execute([a.decode(enc) for a in args],
+ manipulate_encodings=manipulate_encodings)
+ if ret == None:
+ ret = 0
+ return ret
+
+def help(cmd=None, parser=None):
if cmd != None:
return get_command(cmd).help()
else:
@@ -82,17 +88,15 @@ def help(cmd=None):
cmdlist.append((name, module.__desc__))
longest_cmd_len = max([len(name) for name,desc in cmdlist])
ret = ["Bugs Everywhere - Distributed bug tracking",
- "",
- "usage: be [command] [command_options ...] [command_args ...]",
- "or: be help",
- "or: be help [command]",
- "",
- "Supported commands"]
+ "", "Supported commands"]
for name, desc in cmdlist:
numExtraSpaces = longest_cmd_len-len(name)
ret.append("be %s%*s %s" % (name, numExtraSpaces, "", desc))
-
- return "\n".join(ret)
+ ret.extend(["", "Run", " be help [command]", "for more information."])
+ longhelp = "\n".join(ret)
+ if parser == None:
+ return longhelp
+ return parser.help_str() + "\n" + longhelp
def completions(cmd):
parser = get_command(cmd).get_parser()
@@ -106,6 +110,13 @@ def raise_get_help(option, opt, value, parser):
def raise_get_completions(option, opt, value, parser):
print "got completion arg"
+ if hasattr(parser, "command") and parser.command == "be":
+ comps = []
+ for command, module in iter_commands():
+ comps.append(command)
+ for opt in parser.option_list:
+ comps.append(opt.get_opt_string())
+ raise GetCompletions(comps)
raise GetCompletions(completions(sys.argv[1]))
class CmdOptionParser(optparse.OptionParser):
@@ -141,7 +152,7 @@ def option_value_pairs(options, parser):
def default_complete(options, args, parser, bugid_args={}):
"""
- A dud complete implementation for becommands to that the
+ A dud complete implementation for becommands so that the
--complete argument doesn't cause any problems. Use this
until you've set up a command-specific complete function.
@@ -149,15 +160,25 @@ def default_complete(options, args, parser, bugid_args={}):
arguments taking bug shortnames and the values are functions for
filtering, since that's a common enough operation.
e.g. for "be open [options] BUGID"
- bugid_args = {0: lambda bug : bug.active == False}
+ bugid_args = {0: lambda bug : bug.active == False}
+ A positional argument of -1 specifies all remaining arguments
+ (e.g in the case of "be show BUGID BUGID ...").
"""
for option,value in option_value_pairs(options, parser):
if value == "--complete":
- raise cmdutil.GetCompletions()
+ raise GetCompletions()
+ if len(bugid_args.keys()) > 0:
+ max_pos_arg = max(bugid_args.keys())
+ else:
+ max_pos_arg = -1
for pos,value in enumerate(args):
if value == "--complete":
+ filter = None
if pos in bugid_args:
filter = bugid_args[pos]
+ if pos > max_pos_arg and -1 in bugid_args:
+ filter = bugid_args[-1]
+ if filter != None:
bugshortnames = []
try:
bd = bugdir.BugDir(from_disk=True,
@@ -170,6 +191,13 @@ def default_complete(options, args, parser, bugid_args={}):
raise GetCompletions(bugshortnames)
raise GetCompletions()
+def complete_path(path):
+ """List possible path completions for path."""
+ comps = glob.glob(path+"*") + glob.glob(path+"/*")
+ if len(comps) == 1 and os.path.isdir(comps[0]):
+ comps.extend(glob.glob(comps[0]+"/*"))
+ return comps
+
def underlined(instring):
"""Produces a version of a string that is underlined with '='
@@ -179,6 +207,15 @@ def underlined(instring):
return "%s\n%s" % (instring, "="*len(instring))
+def bug_from_shortname(bugdir, shortname):
+ """
+ Exception translation for the command-line interface.
+ """
+ try:
+ bug = bugdir.bug_from_shortname(shortname)
+ except (bugdir.MultipleBugMatches, bugdir.NoBugMatches), e:
+ raise UserError(e.message)
+ return bug
def _test():
import doctest
diff --git a/libbe/comment.py b/libbe/comment.py
index 9d4f744..b2fc556 100644
--- a/libbe/comment.py
+++ b/libbe/comment.py
@@ -2,28 +2,31 @@
# Copyright (C) 2008-2009 Chris Ball <cjb@laptop.org>
# Thomas Habets <thomas@habets.pp.se>
# W. Trevor King <wking@drexel.edu>
-# <abentley@panoramicfeedback.com>
#
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 2 of the License, or
-# (at your option) any later version.
+# This program is 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.
+# 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 email.mime.base, email.encoders
+# 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 base64
import os
import os.path
+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 textwrap
import doctest
from beuuid import uuid_gen
@@ -43,16 +46,38 @@ class InvalidShortname(KeyError):
self.shortname = shortname
self.shortnames = shortnames
+class InvalidXML(ValueError):
+ def __init__(self, element, comment):
+ msg = "Invalid comment xml: %s\n %s\n" \
+ % (comment, ElementTree.tostring(element))
+ ValueError.__init__(self, msg)
+ self.element = element
+ self.comment = comment
+
+class MissingReference(ValueError):
+ def __init__(self, comment):
+ msg = "Missing reference to %s" % (comment.in_reply_to)
+ 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 _list_to_root(comments, bug):
+def list_to_root(comments, bug, root=None,
+ ignore_missing_references=False):
"""
- Convert a raw list of comments to single (dummy) root comment. We
- use a dummy root comment, because there can be several comment
- threads rooted on the same parent bug. To simplify comment
- interaction, we condense these threads into a single thread with a
- Comment dummy root.
+ Convert a raw list of comments to single root comment. 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. Can also be used to append
+ a list of subcomments to a non-dummy root comment, so long as
+ all the new comments are descendants of the root comment.
No Comment method should use the dummy comment.
"""
@@ -61,23 +86,42 @@ def _list_to_root(comments, bug):
for comment in comments:
assert comment.uuid != None
uuid_map[comment.uuid] = comment
+ for comment in comments:
+ if comment.alt_id != None and comment.alt_id not in uuid_map:
+ uuid_map[comment.alt_id] = comment
+ if root == None:
+ root = Comment(bug, uuid=INVALID_UUID)
+ else:
+ uuid_map[root.uuid] = root
for comm in comments:
+ if comm.in_reply_to == INVALID_UUID:
+ comm.in_reply_to = None
rep = comm.in_reply_to
- if rep == None or rep == settings_object.EMPTY or rep == bug.uuid:
+ if rep == None or rep == bug.uuid:
root_comments.append(comm)
else:
parentUUID = comm.in_reply_to
- parent = uuid_map[parentUUID]
- parent.add_reply(comm)
- dummy_root = Comment(bug, uuid=INVALID_UUID)
- dummy_root.extend(root_comments)
- return dummy_root
+ try:
+ parent = uuid_map[parentUUID]
+ parent.add_reply(comm)
+ except KeyError, e:
+ if ignore_missing_references == True:
+ print >> sys.stderr, \
+ "Ignoring missing reference to %s" % parentUUID
+ comm.in_reply_to = None
+ root_comments.append(comm)
+ else:
+ raise MissingReference(comm)
+ root.extend(root_comments)
+ return root
def loadComments(bug, load_full=False):
"""
Set load_full=True when you want to load the comment completely
from disk *now*, rather than waiting and lazy loading as required.
"""
+ if bug.sync_with_disk == False:
+ raise DiskAccessRequired("load comments")
path = bug.get_path("comments")
if not os.path.isdir(path):
return Comment(bug, uuid=INVALID_UUID)
@@ -86,15 +130,16 @@ def loadComments(bug, load_full=False):
if uuid.startswith('.'):
continue
comm = Comment(bug, uuid, from_disk=True)
+ comm.set_sync_with_disk(bug.sync_with_disk)
if load_full == True:
comm.load_settings()
dummy = comm.body # force the body to load
comments.append(comm)
- return _list_to_root(comments, bug)
+ return list_to_root(comments, bug)
def saveComments(bug):
- path = bug.get_path("comments")
- bug.rcs.mkdir(path)
+ if bug.sync_with_disk == False:
+ raise DiskAccessRequired("save comments")
for comment in bug.comment_root.traverse():
comment.save()
@@ -122,9 +167,13 @@ class Comment(Tree, settings_object.SavedSettingsObject):
kwargs["required_saved_properties"]=required_saved_properties
return settings_object.versioned_property(**kwargs)
- @_versioned_property(name="From",
+ @_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 From(): return {}
+ def author(): return {}
@_versioned_property(name="In-reply-to",
doc="UUID for parent comment or bug")
@@ -138,17 +187,17 @@ class Comment(Tree, settings_object.SavedSettingsObject):
@_versioned_property(name="Date",
doc="An RFC 2822 timestamp for comment creation")
- def time_string(): return {}
+ def date(): return {}
def _get_time(self):
- if self.time_string == None:
+ if self.date == None:
return None
- return utility.str_to_time(self.time_string)
+ return utility.str_to_time(self.date)
def _set_time(self, value):
- self.time_string = utility.time_to_str(value)
+ self.date = utility.time_to_str(value)
time = property(fget=_get_time,
fset=_set_time,
- doc="An integer version of .time_string")
+ doc="An integer version of .date")
def _get_comment_body(self):
if self.rcs != None and self.sync_with_disk == True:
@@ -178,6 +227,20 @@ class Comment(Tree, settings_object.SavedSettingsObject):
@doc_property(doc="A revision control system instance.")
def rcs(): 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_disk=False,
in_reply_to=None, body=None):
"""
@@ -204,10 +267,29 @@ class Comment(Tree, settings_object.SavedSettingsObject):
self.uuid = uuid_gen()
self.time = int(time.time()) # only save to second precision
if self.rcs != None:
- self.From = self.rcs.get_user_id()
+ self.author = self.rcs.get_user_id()
self.in_reply_to = in_reply_to
self.body = body
+ def __cmp__(self, other):
+ return cmp_full(self, other)
+
+ def __str__(self):
+ """
+ >>> comm = Comment(bug=None, body="Some insightful remarks")
+ >>> comm.uuid = "com-1"
+ >>> comm.date = "Thu, 20 Nov 2008 15:55:11 +0000"
+ >>> comm.author = "Jane Doe <jdoe@example.com>"
+ >>> print comm
+ --------- Comment ---------
+ Name: com-1
+ From: Jane Doe <jdoe@example.com>
+ Date: Thu, 20 Nov 2008 15:55:11 +0000
+ <BLANKLINE>
+ Some insightful remarks
+ """
+ return self.string()
+
def traverse(self, *args, **kwargs):
"""Avoid working with the possible dummy root comment"""
for comment in Tree.traverse(self, *args, **kwargs):
@@ -215,23 +297,24 @@ class Comment(Tree, settings_object.SavedSettingsObject):
continue
yield comment
+ # serializing methods
+
def _setting_attr_string(self, setting):
value = getattr(self, setting)
- if value == settings_object.EMPTY:
+ if value == None:
return ""
- else:
- return str(value)
+ return str(value)
def xml(self, indent=0, shortname=None):
"""
>>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
>>> comm.uuid = "0123"
- >>> comm.time_string = "Thu, 01 Jan 1970 00:00:00 +0000"
+ >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
>>> print comm.xml(indent=2, shortname="com-1")
<comment>
<uuid>0123</uuid>
<short-name>com-1</short-name>
- <from></from>
+ <author></author>
<date>Thu, 01 Jan 1970 00:00:00 +0000</date>
<content-type>text/plain</content-type>
<body>Some
@@ -248,27 +331,88 @@ class Comment(Tree, settings_object.SavedSettingsObject):
msg = email.mime.base.MIMEBase(maintype, subtype)
msg.set_payload(self.body or "")
email.encoders.encode_base64(msg)
- body = msg.as_string()
+ body = base64.encodestring(self.body or "")
info = [("uuid", self.uuid),
+ ("alt-id", self.alt_id),
("short-name", shortname),
("in-reply-to", self.in_reply_to),
- ("from", self._setting_attr_string("From")),
- ("date", self.time_string),
+ ("author", self._setting_attr_string("author")),
+ ("date", self.date),
("content-type", self.content_type),
("body", body)]
lines = ["<comment>"]
for (k,v) in info:
- if v not in [settings_object.EMPTY, None]:
+ if v != None:
lines.append(' <%s>%s</%s>' % (k,xml.sax.saxutils.escape(v),k))
lines.append("</comment>")
istring = ' '*indent
sep = '\n' + istring
return istring + sep.join(lines).rstrip('\n')
+ def from_xml(self, xml_string, verbose=True):
+ """
+ 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"
+ >>> xml = commA.xml(shortname="com-1")
+ >>> commB = Comment()
+ >>> commB.from_xml(xml)
+ >>> attrs=['uuid','alt_id','in_reply_to','author','date','content_type','body']
+ >>> for attr in attrs: # doctest: +ELLIPSIS
+ ... if getattr(commB, attr) != getattr(commA, attr):
+ ... estr = "Mismatch on %s: '%s' should be '%s'"
+ ... args = (attr, getattr(commB, attr), getattr(commA, attr))
+ ... print estr % args
+ Mismatch on uuid: '...' should be '0123'
+ Mismatch on alt_id: '0123' should be 'None'
+ >>> print commB.alt_id
+ 0123
+ >>> commA.author
+ >>> commB.author
+ """
+ if type(xml_string) == types.UnicodeType:
+ xml_string = xml_string.strip().encode("unicode_escape")
+ comment = ElementTree.XML(xml_string)
+ if comment.tag != "comment":
+ raise InvalidXML(comment, "root element must be <comment>")
+ tags=['uuid','alt-id','in-reply-to','author','date','content-type','body']
+ uuid = None
+ body = None
+ 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 = unicode(text).decode("unicode_escape").strip()
+ if child.tag == "uuid":
+ uuid = text
+ continue # don't set the bug's uuid tag.
+ if child.tag == "body":
+ body = text
+ continue # don't set the bug's body yet.
+ else:
+ attr_name = child.tag.replace('-','_')
+ setattr(self, attr_name, text)
+ elif verbose == True:
+ print >> sys.stderr, "Ignoring unknown tag %s in %s" \
+ % (child.tag, comment.tag)
+ if self.alt_id == None and uuid not in [None, self.uuid]:
+ self.alt_id = uuid
+ if body != None:
+ if self.content_type.startswith("text/"):
+ self.body = body+"\n" # restore trailing newline
+ else:
+ self.body = base64.decodestring(body)
+
def string(self, indent=0, shortname=None):
"""
>>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
- >>> comm.time_string = "Thu, 01 Jan 1970 00:00:00 +0000"
+ >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
>>> print comm.string(indent=2, shortname="com-1")
--------- Comment ---------
Name: com-1
@@ -284,87 +428,18 @@ class Comment(Tree, settings_object.SavedSettingsObject):
lines = []
lines.append("--------- Comment ---------")
lines.append("Name: %s" % shortname)
- lines.append("From: %s" % (self._setting_attr_string("From")))
- lines.append("Date: %s" % self.time_string)
+ lines.append("From: %s" % (self._setting_attr_string("author")))
+ lines.append("Date: %s" % self.date)
lines.append("")
- #lines.append(textwrap.fill(self.body or "",
- # width=(79-indent)))
if self.content_type.startswith("text/"):
lines.extend((self.body or "").splitlines())
else:
lines.append("Content type %s not printable. Try XML output instead" % self.content_type)
- # some comments shouldn't be wrapped...
istring = ' '*indent
sep = '\n' + istring
return istring + sep.join(lines).rstrip('\n')
- def __str__(self):
- """
- >>> comm = Comment(bug=None, body="Some insightful remarks")
- >>> comm.uuid = "com-1"
- >>> comm.time_string = "Thu, 20 Nov 2008 15:55:11 +0000"
- >>> comm.From = "Jane Doe <jdoe@example.com>"
- >>> print comm
- --------- Comment ---------
- Name: com-1
- From: Jane Doe <jdoe@example.com>
- Date: Thu, 20 Nov 2008 15:55:11 +0000
- <BLANKLINE>
- Some insightful remarks
- """
- return self.string()
-
- def get_path(self, name=None):
- my_dir = os.path.join(self.bug.get_path("comments"), self.uuid)
- if name is None:
- return my_dir
- assert name in ["values", "body"]
- return os.path.join(my_dir, name)
-
- def load_settings(self):
- self.settings = mapfile.map_load(self.rcs, self.get_path("values"))
- self._setup_saved_settings()
-
- def save_settings(self):
- parent_dir = os.path.dirname(self.get_path())
- self.rcs.mkdir(parent_dir)
- self.rcs.mkdir(self.get_path())
- path = self.get_path("values")
- mapfile.map_save(self.rcs, path, self._get_saved_settings())
-
- def save(self):
- assert self.body != None, "Can't save blank comment"
- #if self.in_reply_to == None:
- # raise Exception, str(self)+'\n'+str(self.settings)+'\n'+str(self._settings_loaded)
- #assert self.in_reply_to != None, "Comment must be a reply to something"
- self.save_settings()
- self._set_comment_body(new=self.body, force=True)
-
- def remove(self):
- for comment in self.traverse():
- path = comment.get_path()
- self.rcs.recursive_remove(path)
-
- def add_reply(self, reply, allow_time_inversion=False):
- if self.uuid != INVALID_UUID:
- reply.in_reply_to = self.uuid
- self.append(reply)
- #raise Exception, "adding reply \n%s\n%s" % (self, reply)
-
- def new_reply(self, body=None):
- """
- >>> comm = Comment(bug=None, body="Some insightful remarks")
- >>> repA = comm.new_reply("Critique original comment")
- >>> repB = repA.new_reply("Begin flamewar :p")
- >>> repB.in_reply_to == repA.uuid
- True
- """
- reply = Comment(self.bug, body=body)
- self.add_reply(reply)
- #raise Exception, "new reply added (%s),\n%s\n%s\n\t--%s--" % (body, self, reply, reply.in_reply_to)
- return reply
-
def string_thread(self, string_method_name="string", name_map={},
indent=0, flatten=True,
auto_name_map=False, bug_shortname=None):
@@ -382,7 +457,7 @@ class Comment(Tree, settings_object.SavedSettingsObject):
name_map = {}
for shortname,comment in comm.comment_shortnames(bug_shortname):
name_map[comment.uuid] = shortname
- comm.sort(key=lambda c : c.From) # your sort
+ comm.sort(key=lambda c : c.author) # your sort
comm.string_thread(name_map=name_map)
>>> a = Comment(bug=None, uuid="a", body="Insightful remarks")
@@ -469,6 +544,80 @@ class Comment(Tree, settings_object.SavedSettingsObject):
indent=indent, auto_name_map=auto_name_map,
bug_shortname=bug_shortname)
+ # methods for saving/loading/acessing settings and properties.
+
+ def get_path(self, name=None):
+ my_dir = os.path.join(self.bug.get_path("comments"), self.uuid)
+ if name is None:
+ return my_dir
+ assert name in ["values", "body"]
+ return os.path.join(my_dir, name)
+
+ def set_sync_with_disk(self, value):
+ self.sync_with_disk = value
+
+ def load_settings(self):
+ if self.sync_with_disk == False:
+ raise DiskAccessRequired("load settings")
+ self.settings = mapfile.map_load(self.rcs, self.get_path("values"))
+ # hack to deal with old BE comments:
+ if "From" in self.settings:
+ self.settings["Author"] = self.settings.pop("From")
+ self._setup_saved_settings()
+
+ def save_settings(self):
+ if self.sync_with_disk == False:
+ raise DiskAccessRequired("save settings")
+ self.rcs.mkdir(self.get_path())
+ path = self.get_path("values")
+ mapfile.map_save(self.rcs, path, self._get_saved_settings())
+
+ def save(self):
+ """
+ Save any loaded contents to disk.
+
+ However, if self.sync_with_disk = True, then any changes are
+ automatically written to disk as soon as they happen, so
+ calling this method will just waste time (unless something
+ else has been messing with your on-disk files).
+ """
+ sync_with_disk = self.sync_with_disk
+ if sync_with_disk == False:
+ self.set_sync_with_disk(True)
+ assert self.body != None, "Can't save blank comment"
+ self.save_settings()
+ self._set_comment_body(new=self.body, force=True)
+ if sync_with_disk == False:
+ self.set_sync_with_disk(False)
+
+ def remove(self):
+ if self.sync_with_disk == False and self.uuid != INVALID_UUID:
+ raise DiskAccessRequired("remove")
+ for comment in self.traverse():
+ path = comment.get_path()
+ self.rcs.recursive_remove(path)
+
+ def add_reply(self, reply, allow_time_inversion=False):
+ if self.uuid != INVALID_UUID:
+ reply.in_reply_to = self.uuid
+ self.append(reply)
+
+ def new_reply(self, body=None):
+ """
+ >>> comm = Comment(bug=None, body="Some insightful remarks")
+ >>> repA = comm.new_reply("Critique original comment")
+ >>> repB = repA.new_reply("Begin flamewar :p")
+ >>> repB.in_reply_to == repA.uuid
+ True
+ """
+ reply = Comment(self.bug, body=body)
+ if self.bug != None:
+ reply.set_sync_with_disk(self.bug.sync_with_disk)
+ if reply.sync_with_disk == True:
+ reply.save()
+ self.add_reply(reply)
+ return reply
+
def comment_shortnames(self, bug_shortname=None):
"""
Iterate through (id, comment) pairs, in time order.
@@ -535,4 +684,59 @@ class Comment(Tree, settings_object.SavedSettingsObject):
return comment
raise KeyError(uuid)
+def cmp_attr(comment_1, comment_2, attr, invert=False):
+ """
+ Compare a general attribute between two comments using the conventional
+ comparison rule for that attribute type. If invert == True, sort
+ *against* that convention.
+ >>> attr="author"
+ >>> commentA = Comment()
+ >>> commentB = Comment()
+ >>> commentA.author = "John Doe"
+ >>> commentB.author = "Jane Doe"
+ >>> cmp_attr(commentA, commentB, attr) > 0
+ True
+ >>> cmp_attr(commentA, commentB, attr, invert=True) < 0
+ True
+ >>> commentB.author = "John Doe"
+ >>> cmp_attr(commentA, commentB, attr) == 0
+ True
+ """
+ if not hasattr(comment_2, attr) :
+ return 1
+ val_1 = getattr(comment_1, attr)
+ val_2 = getattr(comment_2, attr)
+ if val_1 == None: val_1 = None
+ if val_2 == None: val_2 = None
+
+ if invert == True :
+ return -cmp(val_1, val_2)
+ else :
+ return cmp(val_1, val_2)
+
+# alphabetical rankings (a < z)
+cmp_uuid = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "uuid")
+cmp_author = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "author")
+cmp_in_reply_to = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "in_reply_to")
+cmp_content_type = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "content_type")
+cmp_body = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "body")
+# chronological rankings (newer < older)
+cmp_time = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "time", invert=True)
+
+DEFAULT_CMP_FULL_CMP_LIST = \
+ (cmp_time, cmp_author, cmp_content_type, cmp_body, cmp_in_reply_to,
+ cmp_uuid)
+
+class CommentCompoundComparator (object):
+ def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST):
+ self.cmp_list = cmp_list
+ def __call__(self, comment_1, comment_2):
+ for comparison in self.cmp_list :
+ val = comparison(comment_1, comment_2)
+ if val != 0 :
+ return val
+ return 0
+
+cmp_full = CommentCompoundComparator()
+
suite = doctest.DocTestSuite()
diff --git a/libbe/config.py b/libbe/config.py
index 7f600a5..5e343b9 100644
--- a/libbe/config.py
+++ b/libbe/config.py
@@ -1,20 +1,19 @@
# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
# W. Trevor King <wking@drexel.edu>
-# <abentley@panoramicfeedback.com>
#
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 2 of the License, or
-# (at your option) any later version.
+# This program is 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.
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+# 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 ConfigParser
import codecs
import locale
diff --git a/libbe/darcs.py b/libbe/darcs.py
index 43af99a..0720ed9 100644
--- a/libbe/darcs.py
+++ b/libbe/darcs.py
@@ -1,25 +1,30 @@
# Copyright (C) 2009 W. Trevor King <wking@drexel.edu>
#
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 2 of the License, or
-# (at your option) any later version.
+# This program is 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.
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+# 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 os
import re
import sys
-import unittest
+try: # import core module, Python >= 2.5
+ from xml.etree import ElementTree
+except ImportError: # look for non-core module
+ from elementtree import ElementTree
+from xml.sax.saxutils import unescape
import doctest
+import unittest
import rcs
from rcs import RCS
@@ -131,30 +136,43 @@ class Darcs(RCS):
RCS._rcs_duplicate_repo(self, directory, revision)
else:
self._u_invoke_client("put", "--to-patch", revision, directory)
- def _rcs_commit(self, commitfile):
+ def _rcs_commit(self, commitfile, allow_empty=False):
id = self.get_user_id()
if '@' not in id:
id = "%s <%s@invalid.com>" % (id, id)
- # Darcs doesn't like commitfiles without trailing endlines.
- f = codecs.open(commitfile, 'r', self.encoding)
- contents = f.read()
- f.close()
- if contents[-1] != '\n':
- f = codecs.open(commitfile, 'a', self.encoding)
- f.write('\n')
- f.close()
- status,output,error = self._u_invoke_client('record', '--all',
- '--author', id,
- '--logfile', commitfile)
- revision = None
-
- revline = re.compile("Finished recording patch '(.*)'")
- match = revline.search(output)
- assert match != None, output+error
- assert len(match.groups()) == 1
- revision = match.groups()[0]
+ args = ['record', '--all', '--author', id, '--logfile', commitfile]
+ status,output,error = self._u_invoke_client(*args)
+ empty_strings = ["No changes!"]
+ if self._u_any_in_string(empty_strings, output) == True:
+ if allow_empty == False:
+ raise rcs.EmptyCommit()
+ # note that darcs does _not_ make an empty revision.
+ # this returns the last non-empty revision id...
+ revision = self._rcs_revision_id(-1)
+ else:
+ revline = re.compile("Finished recording patch '(.*)'")
+ match = revline.search(output)
+ assert match != None, output+error
+ assert len(match.groups()) == 1
+ revision = match.groups()[0]
return revision
-
+ def _rcs_revision_id(self, index):
+ status,output,error = self._u_invoke_client("changes", "--xml")
+ revisions = []
+ xml_str = output.encode("unicode_escape").replace(r"\n", "\n")
+ element = ElementTree.XML(xml_str)
+ assert element.tag == "changelog", element.tag
+ for patch in element.getchildren():
+ assert patch.tag == "patch", patch.tag
+ for child in patch.getchildren():
+ if child.tag == "name":
+ text = unescape(unicode(child.text).decode("unicode_escape").strip())
+ revisions.append(text)
+ revisions.reverse()
+ try:
+ return revisions[index]
+ except IndexError:
+ return None
rcs.make_rcs_testcase_subclasses(Darcs, sys.modules[__name__])
diff --git a/libbe/diff.py b/libbe/diff.py
index a349e14..1b52620 100644
--- a/libbe/diff.py
+++ b/libbe/diff.py
@@ -1,117 +1,415 @@
# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
# W. Trevor King <wking@drexel.edu>
-# <abentley@panoramicfeedback.com>
#
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 2 of the License, or
-# (at your option) any later version.
+# This program is 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.
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+# 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.
"""Compare two bug trees"""
-from libbe import cmdutil, bugdir, bug
+from libbe import bugdir, bug, settings_object, tree
from libbe.utility import time_to_str
+import difflib
import doctest
-def diff(old_bugdir, new_bugdir):
- added = []
- removed = []
- modified = []
- for uuid in old_bugdir.list_uuids():
- old_bug = old_bugdir.bug_from_uuid(uuid)
- try:
- new_bug = new_bugdir.bug_from_uuid(uuid)
- if old_bug != new_bug:
- modified.append((old_bug, new_bug))
- except KeyError:
- removed.append(old_bug)
- for uuid in new_bugdir.list_uuids():
- if not old_bugdir.has_bug(uuid):
- new_bug = new_bugdir.bug_from_uuid(uuid)
- added.append(new_bug)
- return (removed, modified, added)
+class DiffTree (tree.Tree):
+ """
+ A tree holding difference data for easy report generation.
+ >>> 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):
+ 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):
+ return "\n".join(self.report())
+ def report(self, root=None, parent=None, depth=0):
+ if root == None:
+ root = self.make_root()
+ if self.masked == True:
+ return None
+ data_part = self.data_part(depth)
+ if self.requires_children == True and len(self) == 0:
+ pass
+ else:
+ self.join(root, parent, data_part)
+ if data_part != None:
+ depth += 1
+ for child in self:
+ 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
-def diff_report(diff_data, bug_dir):
- (removed, modified, added) = diff_data
- def modified_cmp(left, right):
- return bug.cmp_severity(left[1], right[1])
+class Diff (object):
+ """
+ Difference tree generator for BugDirs.
+ >>> import copy
+ >>> bd = bugdir.SimpleBugDir(sync_with_disk=False)
+ >>> bd.user_id = "John Doe <j@doe.com>"
+ >>> bd_new = copy.deepcopy(bd)
+ >>> bd_new.target = "1.0"
+ >>> a = bd_new.bug_from_uuid("a")
+ >>> rep = a.comment_root.new_reply("I'm closing this bug")
+ >>> rep.uuid = "acom"
+ >>> rep.date = "Thu, 01 Jan 1970 00:00:00 +0000"
+ >>> a.status = "closed"
+ >>> b = bd_new.bug_from_uuid("b")
+ >>> bd_new.remove_bug(b)
+ >>> c = bd_new.new_bug("c", "Bug C")
+ >>> d = Diff(bd, bd_new)
+ >>> r = d.report_tree()
+ >>> print "\\n".join(r.paths())
+ bugdir
+ bugdir/settings
+ bugdir/bugs
+ bugdir/bugs/new
+ bugdir/bugs/new/c
+ bugdir/bugs/rem
+ bugdir/bugs/rem/b
+ bugdir/bugs/mod
+ bugdir/bugs/mod/a
+ bugdir/bugs/mod/a/settings
+ bugdir/bugs/mod/a/comments
+ bugdir/bugs/mod/a/comments/new
+ bugdir/bugs/mod/a/comments/new/acom
+ bugdir/bugs/mod/a/comments/rem
+ bugdir/bugs/mod/a/comments/mod
+ >>> print r.report_string()
+ Changed bug directory settings:
+ target: None -> 1.0
+ New bugs:
+ c:om: Bug C
+ Removed bugs:
+ b:cm: Bug B
+ Modified bugs:
+ a:cm: Bug A
+ Changed bug settings:
+ status: open -> closed
+ New comments:
+ from John Doe <j@doe.com> on Thu, 01 Jan 1970 00:00:00 +0000
+ I'm closing this bug...
+ >>> bd.cleanup()
+ """
+ def __init__(self, old_bugdir, new_bugdir):
+ self.old_bugdir = old_bugdir
+ self.new_bugdir = new_bugdir
- added.sort(bug.cmp_severity)
- removed.sort(bug.cmp_severity)
- modified.sort(modified_cmp)
- lines = []
-
- if len(added) > 0:
- lines.append("New bug reports:")
- for bg in added:
- lines.extend(bg.string(shortlist=True).splitlines())
- lines.append("")
+ # data assembly methods
- if len(modified) > 0:
- printed = False
- for old_bug, new_bug in modified:
- change_str = bug_changes(old_bug, new_bug, bug_dir)
- if change_str is None:
- continue
- if not printed:
- printed = True
- lines.append("Modified bug reports:")
- lines.extend(change_str.splitlines())
- if printed == True:
- lines.append("")
-
- if len(removed) > 0:
- lines.append("Removed bug reports:")
- for bg in removed:
- lines.extend(bg.string(shortlist=True).splitlines())
- lines.append("")
-
- return '\n'.join(lines)
-
-def change_lines(old, new, attributes):
- change_list = []
- for attr in attributes:
- old_attr = getattr(old, attr)
- new_attr = getattr(new, attr)
- if old_attr != new_attr:
- change_list.append((attr, old_attr, new_attr))
- if len(change_list) >= 0:
- return change_list
- else:
+ def _changed_bugs(self):
+ """
+ Search for differences in all bugs between .old_bugdir and
+ .new_bugdir. Returns
+ (added_bugs, modified_bugs, removed_bugs)
+ where added_bugs and removed_bugs are lists of added and
+ removed bugs respectively. modified_bugs is a list of
+ (old_bug,new_bug) pairs.
+ """
+ if hasattr(self, "__changed_bugs"):
+ return self.__changed_bugs
+ added = []
+ removed = []
+ modified = []
+ for uuid in self.new_bugdir.list_uuids():
+ new_bug = self.new_bugdir.bug_from_uuid(uuid)
+ try:
+ old_bug = self.old_bugdir.bug_from_uuid(uuid)
+ except KeyError:
+ added.append(new_bug)
+ else:
+ if old_bug.sync_with_disk == True:
+ old_bug.load_comments()
+ if new_bug.sync_with_disk == True:
+ new_bug.load_comments()
+ if old_bug != new_bug:
+ modified.append((old_bug, new_bug))
+ for uuid in self.old_bugdir.list_uuids():
+ if not self.new_bugdir.has_bug(uuid):
+ old_bug = self.old_bugdir.bug_from_uuid(uuid)
+ removed.append(old_bug)
+ added.sort()
+ removed.sort()
+ modified.sort(self._bug_modified_cmp)
+ self.__changed_bugs = (added, modified, removed)
+ return self.__changed_bugs
+ def _bug_modified_cmp(self, left, right):
+ return cmp(left[1], right[1])
+ def _changed_comments(self, old, new):
+ """
+ Search for differences in all loaded comments between the bugs
+ old and new. Returns
+ (added_comments, modified_comments, removed_comments)
+ analogous to ._changed_bugs.
+ """
+ if hasattr(self, "__changed_comments"):
+ if new.uuid in self.__changed_comments:
+ return self.__changed_comments[new.uuid]
+ else:
+ self.__changed_comments = {}
+ added = []
+ removed = []
+ modified = []
+ old.comment_root.sort(key=lambda comm : comm.time)
+ new.comment_root.sort(key=lambda comm : comm.time)
+ old_comment_ids = [c.uuid for c in old.comments()]
+ new_comment_ids = [c.uuid for c in new.comments()]
+ for uuid in new_comment_ids:
+ new_comment = new.comment_from_uuid(uuid)
+ try:
+ old_comment = old.comment_from_uuid(uuid)
+ except KeyError:
+ added.append(new_comment)
+ else:
+ if old_comment != new_comment:
+ modified.append((old_comment, new_comment))
+ for uuid in old_comment_ids:
+ if uuid not in new_comment_ids:
+ new_comment = new.comment_from_uuid(uuid)
+ removed.append(new_comment)
+ self.__changed_comments[new.uuid] = (added, modified, removed)
+ return self.__changed_comments[new.uuid]
+ def _attribute_changes(self, old, new, attributes):
+ """
+ Take two objects old and new, and compare the value of *.attr
+ for attr in the list attribute names. Returns a list of
+ (attr_name, old_value, new_value)
+ tuples.
+ """
+ change_list = []
+ for attr in attributes:
+ old_value = getattr(old, attr)
+ new_value = getattr(new, attr)
+ if old_value != new_value:
+ change_list.append((attr, old_value, new_value))
+ if len(change_list) >= 0:
+ return change_list
return None
+ def _settings_properties_attribute_changes(self, old, new,
+ hidden_properties=[]):
+ properties = sorted(new.settings_properties)
+ for p in hidden_properties:
+ properties.remove(p)
+ attributes = [settings_object.setting_name_to_attr_name(None, p)
+ for p in properties]
+ return self._attribute_changes(old, new, attributes)
+ def _bugdir_attribute_changes(self):
+ return self._settings_properties_attribute_changes( \
+ self.old_bugdir, self.new_bugdir,
+ ["rcs_name"]) # tweaked by bugdir.duplicate_bugdir
+ def _bug_attribute_changes(self, old, new):
+ return self._settings_properties_attribute_changes(old, new)
+ def _comment_attribute_changes(self, old, new):
+ return self._settings_properties_attribute_changes(old, new)
-def bug_changes(old, new, bugs):
- change_list = change_lines(old, new, ("time", "creator", "severity",
- "target", "summary", "status", "assigned"))
+ # report generation methods
- old_comment_ids = [c.uuid for c in old.comments()]
- new_comment_ids = [c.uuid for c in new.comments()]
- change_strings = ["%s: %s -> %s" % f for f in change_list]
- for comment_id in new_comment_ids:
- if comment_id not in old_comment_ids:
- summary = comment_summary(new.comment_from_uuid(comment_id), "new")
- change_strings.append(summary)
- for comment_id in old_comment_ids:
- if comment_id not in new_comment_ids:
- summary = comment_summary(new.comment_from_uuid(comment_id),
- "removed")
- change_strings.append(summary)
+ def report_tree(self, diff_tree=DiffTree):
+ """
+ Pretty bare to make it easy to adjust to specific cases. You
+ can pass in a DiffTree subclass via diff_tree to override the
+ default report assembly process.
+ """
+ if hasattr(self, "__report_tree"):
+ return self.__report_tree
+ bugdir_settings = sorted(self.new_bugdir.settings_properties)
+ bugdir_settings.remove("rcs_name") # tweaked by bugdir.duplicate_bugdir
+ root = diff_tree("bugdir")
+ bugdir_attribute_changes = self._bugdir_attribute_changes()
+ if len(bugdir_attribute_changes) > 0:
+ bugdir = diff_tree("settings", bugdir_attribute_changes,
+ self.bugdir_attribute_change_string)
+ root.append(bugdir)
+ bug_root = diff_tree("bugs")
+ root.append(bug_root)
+ add,mod,rem = self._changed_bugs()
+ bnew = diff_tree("new", "New bugs:", requires_children=True)
+ bug_root.append(bnew)
+ for bug in add:
+ b = diff_tree(bug.uuid, bug, self.bug_add_string)
+ bnew.append(b)
+ brem = diff_tree("rem", "Removed bugs:", requires_children=True)
+ bug_root.append(brem)
+ for bug in rem:
+ b = diff_tree(bug.uuid, bug, self.bug_rem_string)
+ brem.append(b)
+ bmod = diff_tree("mod", "Modified bugs:", requires_children=True)
+ bug_root.append(bmod)
+ for old,new in mod:
+ b = diff_tree(new.uuid, (old,new), self.bug_mod_string)
+ bmod.append(b)
+ bug_attribute_changes = self._bug_attribute_changes(old, new)
+ if len(bug_attribute_changes) > 0:
+ bset = diff_tree("settings", bug_attribute_changes,
+ self.bug_attribute_change_string)
+ b.append(bset)
+ if old.summary != new.summary:
+ data = (old.summary, new.summary)
+ bsum = diff_tree("summary", data, self.bug_summary_change_string)
+ b.append(bsum)
+ cr = diff_tree("comments")
+ b.append(cr)
+ a,m,d = self._changed_comments(old, new)
+ cnew = diff_tree("new", "New comments:", requires_children=True)
+ for comment in a:
+ c = diff_tree(comment.uuid, comment, self.comment_add_string)
+ cnew.append(c)
+ crem = diff_tree("rem", "Removed comments:",requires_children=True)
+ for comment in d:
+ c = diff_tree(comment.uuid, comment, self.comment_rem_string)
+ crem.append(c)
+ cmod = diff_tree("mod","Modified comments:",requires_children=True)
+ for o,n in m:
+ c = diff_tree(n.uuid, (o,n), self.comment_mod_string)
+ cmod.append(c)
+ comm_attribute_changes = self._comment_attribute_changes(o, n)
+ if len(comm_attribute_changes) > 0:
+ cset = diff_tree("settings", comm_attribute_changes,
+ self.comment_attribute_change_string)
+ if o.body != n.body:
+ data = (o.body, n.body)
+ cbody = diff_tree("cbody", data,
+ self.comment_body_change_string)
+ c.append(cbody)
+ cr.extend([cnew, crem, cmod])
+ self.__report_tree = root
+ return self.__report_tree
- if len(change_strings) == 0:
- return None
- return "%s\n %s" % (new.string(shortlist=True),
- " \n".join(change_strings))
+ # change data -> string methods.
+ # Feel free to play with these in subclasses.
+ def attribute_change_string(self, attribute_changes, indent=0):
+ indent_string = " "*indent
+ change_strings = [u"%s: %s -> %s" % f for f in attribute_changes]
+ for i,change_string in enumerate(change_strings):
+ change_strings[i] = indent_string+change_string
+ return u"\n".join(change_strings)
+ def bugdir_attribute_change_string(self, attribute_changes):
+ return "Changed bug directory settings:\n%s" % \
+ self.attribute_change_string(attribute_changes, indent=1)
+ def bug_attribute_change_string(self, attribute_changes):
+ return "Changed bug settings:\n%s" % \
+ self.attribute_change_string(attribute_changes, indent=1)
+ def comment_attribute_change_string(self, attribute_changes):
+ return "Changed comment settings:\n%s" % \
+ self.attribute_change_string(attribute_changes, indent=1)
+ def bug_add_string(self, bug):
+ return bug.string(shortlist=True)
+ def bug_rem_string(self, bug):
+ return bug.string(shortlist=True)
+ def bug_mod_string(self, bugs):
+ old_bug,new_bug = bugs
+ return new_bug.string(shortlist=True)
+ def bug_summary_change_string(self, summaries):
+ old_summary,new_summary = summaries
+ return "summary changed:\n %s\n %s" % (old_summary, new_summary)
+ def _comment_summary_string(self, comment):
+ return "from %s on %s" % (comment.author, time_to_str(comment.time))
+ def comment_add_string(self, comment):
+ summary = self._comment_summary_string(comment)
+ first_line = comment.body.splitlines()[0]
+ return "%s\n %s..." % (summary, first_line)
+ def comment_rem_string(self, comment):
+ 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 difflib.unified_diff(old_body, new_body)
-def comment_summary(comment, status):
- return "%8s comment from %s on %s" % (status, comment.From,
- time_to_str(comment.time))
suite = doctest.DocTestSuite()
diff --git a/libbe/editor.py b/libbe/editor.py
index 6638ed9..8f2bdbf 100644
--- a/libbe/editor.py
+++ b/libbe/editor.py
@@ -1,21 +1,19 @@
# Bugs Everywhere, a distributed bugtracker
# Copyright (C) 2008-2009 W. Trevor King <wking@drexel.edu>
-# <abentley@panoramicfeedback.com>
#
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 2 of the License, or
-# (at your option) any later version.
+# This program is 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.
+# 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
+# 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 locale
@@ -33,7 +31,7 @@ class CantFindEditor(Exception):
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
+ """Invokes the editor, and returns the user-produced text as a string
>>> if "EDITOR" in os.environ:
... del os.environ["EDITOR"]
@@ -64,7 +62,8 @@ def editor_string(comment=None, encoding=None):
fhandle, fname = tempfile.mkstemp()
try:
if comment is not None:
- os.write(fhandle, '\n'+comment_string(comment))
+ 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))
diff --git a/libbe/encoding.py b/libbe/encoding.py
index bdac98e..4af864e 100644
--- a/libbe/encoding.py
+++ b/libbe/encoding.py
@@ -1,31 +1,33 @@
# Bugs Everywhere, a distributed bugtracker
# Copyright (C) 2008-2009 W. Trevor King <wking@drexel.edu>
-# <abentley@panoramicfeedback.com>
#
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 2 of the License, or
-# (at your option) any later version.
+# This program is 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.
+# 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
+# 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 locale
import sys
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
diff --git a/libbe/git.py b/libbe/git.py
index 31bbe32..53bc030 100644
--- a/libbe/git.py
+++ b/libbe/git.py
@@ -2,19 +2,19 @@
# Chris Ball <cjb@laptop.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 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.
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+# 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
@@ -53,10 +53,18 @@ class Git(RCS):
def _rcs_init(self, path):
self._u_invoke_client("init", directory=path)
def _rcs_get_user_id(self):
- status,output,error = self._u_invoke_client("config", "user.name")
- name = output.rstrip('\n')
- status,output,error = self._u_invoke_client("config", "user.email")
- email = output.rstrip('\n')
+ 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 == "":
@@ -93,16 +101,41 @@ class Git(RCS):
#self._u_invoke_client("archive", revision, directory) # makes tarball
self._u_invoke_client("clone", "--no-checkout",".",directory)
self._u_invoke_client("checkout", revision, directory=directory)
- def _rcs_commit(self, commitfile):
- status,output,error = self._u_invoke_client('commit', '-a',
- '-F', commitfile)
+ def _rcs_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 rcs.EmptyCommit()
revision = None
revline = re.compile("(.*) (.*)[:\]] (.*)")
match = revline.search(output)
assert match != None, output+error
assert len(match.groups()) == 3
revision = match.groups()[1]
- return revision
+ full_revision = self._rcs_revision_id(-1)
+ assert full_revision.startswith(revision), \
+ "Mismatched revisions:\n%s\n%s" % (revision, full_revision)
+ return full_revision
+ def _rcs_revision_id(self, index):
+ args = ["rev-list", "--first-parent", "--reverse", "HEAD"]
+ 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 rcs.CommandError(args, status, error)
+ commits = output.splitlines()
+ try:
+ return commits[index]
+ except IndexError:
+ return None
rcs.make_rcs_testcase_subclasses(Git, sys.modules[__name__])
diff --git a/libbe/hg.py b/libbe/hg.py
index 30b0470..31df1d0 100644
--- a/libbe/hg.py
+++ b/libbe/hg.py
@@ -2,19 +2,19 @@
# Ben Finney <ben+python@benfinney.id.au>
# 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 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.
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+# 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
@@ -58,7 +58,7 @@ class Hg(RCS):
def _rcs_add(self, path):
self._u_invoke_client("add", path)
def _rcs_remove(self, path):
- self._u_invoke_client("rm", path)
+ self._u_invoke_client("rm", "--force", path)
def _rcs_update(self, path):
pass
def _rcs_get_file_contents(self, path, revision=None, binary=False):
@@ -73,16 +73,24 @@ class Hg(RCS):
return RCS._rcs_duplicate_repo(self, directory, revision)
else:
self._u_invoke_client("archive", "--rev", revision, directory)
- def _rcs_commit(self, commitfile):
- self._u_invoke_client('commit', '--logfile', commitfile)
- status,output,error = self._u_invoke_client('identify')
- revision = None
- revline = re.compile("(.*) tip")
- match = revline.search(output)
- assert match != None, output+error
- assert len(match.groups()) == 1
- revision = match.groups()[0]
- return revision
+ def _rcs_commit(self, commitfile, allow_empty=False):
+ args = ['commit', '--logfile', commitfile]
+ status,output,error = self._u_invoke_client(*args)
+ if allow_empty == False:
+ strings = ["nothing changed"]
+ if self._u_any_in_string(strings, output) == True:
+ raise rcs.EmptyCommit()
+ return self._rcs_revision_id(-1)
+ def _rcs_revision_id(self, index, style="id"):
+ args = ["identify", "--rev", str(int(index)), "--%s" % style]
+ kwargs = {"expect": (0,255)}
+ status,output,error = self._u_invoke_client(*args, **kwargs)
+ if status == 0:
+ id = output.strip()
+ if id == '000000000000':
+ return None # before initial commit.
+ return id
+ return None
rcs.make_rcs_testcase_subclasses(Hg, sys.modules[__name__])
diff --git a/libbe/mapfile.py b/libbe/mapfile.py
index 9ff2215..b959d76 100644
--- a/libbe/mapfile.py
+++ b/libbe/mapfile.py
@@ -1,20 +1,19 @@
# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
# W. Trevor King <wking@drexel.edu>
-# <abentley@panoramicfeedback.com>
#
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 2 of the License, or
-# (at your option) any later version.
+# This program is 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.
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+# 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 yaml
import os.path
import errno
@@ -68,9 +67,9 @@ def generate(map):
assert(':' not in key)
assert(len(key) > 0)
except AssertionError:
- raise IllegalKey(key.encode('string_escape'))
+ raise IllegalKey(unicode(key).encode('unicode_escape'))
if "\n" in map[key]:
- raise IllegalValue(map[key].encode('string_escape'))
+ raise IllegalValue(unicode(map[key]).encode('unicode_escape'))
lines = []
for key in keys:
diff --git a/libbe/plugin.py b/libbe/plugin.py
index 3c7c753..0545fd7 100644
--- a/libbe/plugin.py
+++ b/libbe/plugin.py
@@ -1,21 +1,20 @@
# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
# Marien Zwart <marienz@gentoo.org>
# W. Trevor King <wking@drexel.edu>
-# <abentley@panoramicfeedback.com>
#
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 2 of the License, or
-# (at your option) any later version.
+# This program is 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.
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
import os
import os.path
import sys
diff --git a/libbe/properties.py b/libbe/properties.py
index 37204d6..09dd20e 100644
--- a/libbe/properties.py
+++ b/libbe/properties.py
@@ -1,18 +1,19 @@
# Bugs Everywhere - a distributed bugtracker
# Copyright (C) 2008-2009 W. Trevor King <wking@drexel.edu>
#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
+# 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.
+# 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, see <http://www.gnu.org/licenses/>.
+# 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.
"""
This module provides a series of useful decorators for defining
@@ -51,7 +52,7 @@ def Property(funcs):
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)
@@ -76,6 +77,9 @@ 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__"):
@@ -156,20 +160,25 @@ def _get_cached_mutable_property(self, cacher_name, property_name, default=None)
if (cacher_name, property_name) not in self._mutable_property_cache_copy:
return default
return self._mutable_property_cache_copy[(cacher_name, property_name)]
-def _cmp_cached_mutable_property(self, cacher_name, property_name, value):
+def _cmp_cached_mutable_property(self, cacher_name, property_name, value, default=None):
_init_mutable_property_cache(self)
if (cacher_name, property_name) not in self._mutable_property_cache_hash:
- return 1 # any value > non-existant old hash
+ _set_cached_mutable_property(self, cacher_name, property_name, default)
old_hash = self._mutable_property_cache_hash[(cacher_name, property_name)]
return cmp(_hash_mutable_value(value), old_hash)
def defaulting_property(default=None, null=None,
- default_mutable=False,
- null_mutable=False):
+ 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__"):
@@ -180,17 +189,14 @@ def defaulting_property(default=None, null=None,
def _fget(self):
value = fget(self)
if value == null:
- if default_mutable == True:
+ if mutable_default == True:
return copy.deepcopy(default)
else:
return default
return value
def _fset(self, value):
if value == default:
- if null_mutable == True:
- value = copy.deepcopy(null)
- else:
- value = null
+ value = null
fset(self, value)
funcs["fget"] = _fget
funcs["fset"] = _fset
@@ -260,7 +266,7 @@ def cached_property(generator, initVal=None, mutable=False):
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.
@@ -269,7 +275,7 @@ def cached_property(generator, initVal=None, mutable=False):
In the case that mutable == True, all caching is disabled and the
generator is called whenever the cached value would otherwise be
- used. This avoids uncertainties in the value of stored mutables.
+ used.
"""
def decorator(funcs):
if hasattr(funcs, "__call__"):
@@ -295,7 +301,7 @@ def cached_property(generator, initVal=None, mutable=False):
def primed_property(primer, initVal=None):
"""
- Just like a generator_property, except that instead of returning a
+ Just like a cached_property, except that instead of returning a
new value and running fset to cache it, the primer performs some
background manipulation (e.g. loads data into instance.settings)
such that a _second_ pass through fget succeeds.
@@ -321,7 +327,7 @@ def primed_property(primer, initVal=None):
return funcs
return decorator
-def change_hook_property(hook, mutable=False):
+def change_hook_property(hook, mutable=False, default=None):
"""
Call the function hook(instance, old_value, new_value) whenever a
value different from the current value is set (instance is a a
@@ -330,6 +336,17 @@ def change_hook_property(hook, mutable=False):
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 woln't be a problem.
+ t.x.append(5) # external modification
+ t.x # dummy access notices change and triggers hook
+ See testChangeHookMutableProperty for an example of the expected
+ behavior.
"""
def decorator(funcs):
if hasattr(funcs, "__call__"):
@@ -338,10 +355,13 @@ def change_hook_property(hook, mutable=False):
fset = funcs.get("fset")
name = funcs.get("name", "<unknown>")
def _fget(self, new_value=None, from_fset=False): # only used if mutable == True
- value = fget(self)
- if _cmp_cached_mutable_property(self, "change hook property", name, value) != 0:
+ 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)
+ 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
@@ -361,7 +381,7 @@ def change_hook_property(hook, mutable=False):
funcs["fset"] = _fset
return funcs
return decorator
-
+
class DecoratorTests(unittest.TestCase):
def testLocalDoc(self):
@@ -405,7 +425,7 @@ class DecoratorTests(unittest.TestCase):
@local_property(name="DEFAULT", null=5)
def x(): return {}
t = Test()
- self.failUnless(t.x == 5, str(t.x))
+ self.failUnless(t.x == 5, str(t.x))
t.x = 'x'
self.failUnless(t.x == 'y', str(t.x))
t.x = 'y'
@@ -574,14 +594,17 @@ class DecoratorTests(unittest.TestCase):
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 =
@@ -589,25 +612,26 @@ class DecoratorTests(unittest.TestCase):
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 == 6, t.hook_calls)
+ 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 == 6, t.hook_calls)
+ 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 == 7, t.hook_calls)
+ 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 == 8, t.hook_calls)
+ self.failUnless(t.hook_calls == 6, t.hook_calls)
suite = unittest.TestLoader().loadTestsFromTestCase(DecoratorTests)
diff --git a/libbe/rcs.py b/libbe/rcs.py
index 2c416f4..0206bf6 100644
--- a/libbe/rcs.py
+++ b/libbe/rcs.py
@@ -3,21 +3,20 @@
# Ben Finney <ben+python@benfinney.id.au>
# Chris Ball <cjb@laptop.org>
# W. Trevor King <wking@drexel.edu>
-# <abentley@panoramicfeedback.com>
#
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 2 of the License, or
-# (at your option) any later version.
+# This program is 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.
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+# 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.
from subprocess import Popen, PIPE
import codecs
@@ -62,10 +61,13 @@ def installed_rcs():
class CommandError(Exception):
- def __init__(self, err_str, status):
- Exception.__init__(self, "Command failed (%d): %s" % (status, err_str))
- self.err_str = err_str
+ def __init__(self, command, status, err_str):
+ strerror = ["Command failed (%d):\n %s\n" % (status, err_str),
+ "while executing\n %s" % command]
+ Exception.__init__(self, "\n".join(strerror))
+ self.command = command
self.status = status
+ self.err_str = err_str
class SettingIDnotSupported(NotImplementedError):
pass
@@ -87,6 +89,10 @@ class NoSuchFile(Exception):
path = os.path.abspath(os.path.join(root, pathname))
Exception.__init__(self, "No such file: %s" % path)
+class EmptyCommit(Exception):
+ def __init__(self):
+ Exception.__init__(self, "No changes to commit")
+
def new():
return RCS()
@@ -187,7 +193,7 @@ class RCS(object):
if binary == False:
f = codecs.open(os.path.join(self.rootdir, path), "r", self.encoding)
else:
- f = open(path, "rb")
+ f = open(os.path.join(self.rootdir, path), "rb")
contents = f.read()
f.close()
return contents
@@ -198,11 +204,24 @@ class RCS(object):
dir specifies a directory to create the duplicate in.
"""
shutil.copytree(self.rootdir, directory, True)
- def _rcs_commit(self, commitfile):
+ def _rcs_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.
+ revision (or None if commits are not supported).
+
+ If allow_empty == False, raise EmptyCommit if there are no
+ changes to commit.
+ """
+ return None
+ def _rcs_revision_id(self, index):
+ """
+ Return the name of the <index>th revision. Index will be an
+ integer (possibly <= 0). The choice of which branch to follow
+ when crossing branches/merges is not defined.
+
+ Return None if revision IDs are not supported, or if the
+ specified revision does not exist.
"""
return None
def installed(self):
@@ -330,11 +349,15 @@ class RCS(object):
self.add(path)
else:
self.update(path)
- def mkdir(self, path, allow_no_rcs=False):
+ def mkdir(self, path, allow_no_rcs=False, check_parents=True):
"""
Create (if neccessary) a directory at path under version
control.
"""
+ if check_parents == True:
+ parent = os.path.dirname(path)
+ if not os.path.exists(parent): # recurse through parents
+ self.mkdir(parent, allow_no_rcs, check_parents)
if not os.path.exists(path):
os.mkdir(path)
if self._use_rcs(path, allow_no_rcs):
@@ -342,7 +365,9 @@ class RCS(object):
else:
assert os.path.isdir(path)
if self._use_rcs(path, allow_no_rcs):
- self.update(path)
+ #self.update(path)# Don't update directories. Changing files
+ pass # underneath them should be sufficient.
+
def duplicate_repo(self, revision=None):
"""
Get the repository as it was in a given revision.
@@ -365,30 +390,67 @@ class RCS(object):
shutil.rmtree(self._duplicateBasedir)
self._duplicateBasedir = None
self._duplicateDirname = None
- def commit(self, summary, body=None):
+ def commit(self, summary, body=None, allow_empty=False):
"""
Commit the current working directory, with a commit message
string summary and body. Return the name of the old revision
(or None if versioning is not supported).
+
+ If allow_empty == False (the default), raise EmptyCommit if
+ there are no changes to commit.
"""
+ summary = summary.strip()+'\n'
if body is not None:
- summary += '\n' + body
+ 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._rcs_commit(filename)
+ self.precommit()
+ revision = self._rcs_commit(filename, allow_empty=allow_empty)
temp_file.close()
+ self.postcommit()
finally:
os.remove(filename)
return revision
- def precommit(self, directory):
+ def precommit(self):
+ """
+ Executed before all attempted commits.
+ """
pass
- def postcommit(self, directory):
+ def postcommit(self):
+ """
+ Only executed after successful commits.
+ """
pass
+ def revision_id(self, index=None):
+ """
+ Return the name of the <index>th revision. The choice of
+ which branch to follow when crossing branches/merges is not
+ defined.
+
+ Return None if index==None, revision IDs are not supported, or
+ if the specified revision does not exist.
+ """
+ if index == None:
+ return None
+ return self._rcs_revision_id(index)
+ def _u_any_in_string(self, list, string):
+ """
+ Return True if any of the strings in list are in string.
+ Otherwise return False.
+ """
+ for list_string in list:
+ if list_string in string:
+ return True
+ return False
def _u_invoke(self, args, stdin=None, expect=(0,), cwd=None):
+ """
+ expect should be a tuple of allowed exit codes. cwd should be
+ the directory from which the command will be executed.
+ """
if cwd == None:
cwd = self.rootdir
if self.verboseInvoke == True:
@@ -401,15 +463,13 @@ class RCS(object):
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 CommandError(strerror, e.args[0])
+ raise CommandError(args, e.args[0], e)
output, error = q.communicate(input=stdin)
status = q.wait()
if self.verboseInvoke == True:
print >> sys.stderr, "%d\n%s%s" % (status, output, error)
if status not in expect:
- strerror = "%s\nwhile executing %s\n%s" % (args[1], args, error)
- raise CommandError(strerror, status)
+ raise CommandError(args, status, error)
return status, output, error
def _u_invoke_client(self, *args, **kwargs):
directory = kwargs.get('directory',None)
@@ -784,6 +844,39 @@ class RCS_commit_TestCase(RCSTestCase):
self.failUnlessEqual(
self.test_contents['rev_1'], committed_contents)
+ def test_revision_id_as_committed(self):
+ """Check for compatibility between .commit() and .revision_id()"""
+ if not self.rcs.versioned:
+ self.failUnlessEqual(self.rcs.revision_id(5), None)
+ return
+ committed_revisions = []
+ for path in self.test_files:
+ full_path = self.full_path(path)
+ self.rcs.set_file_contents(
+ full_path, self.test_contents['rev_1'])
+ revision = self.rcs.commit("Initial %s contents." % path)
+ committed_revisions.append(revision)
+ self.rcs.set_file_contents(
+ full_path, self.test_contents['uncommitted'])
+ revision = self.rcs.commit("Altered %s contents." % path)
+ committed_revisions.append(revision)
+ for i,revision in enumerate(committed_revisions):
+ self.failUnlessEqual(self.rcs.revision_id(i), revision)
+ i += -len(committed_revisions) # check negative indices
+ self.failUnlessEqual(self.rcs.revision_id(i), revision)
+ i = len(committed_revisions)
+ self.failUnlessEqual(self.rcs.revision_id(i), None)
+ self.failUnlessEqual(self.rcs.revision_id(-i-1), None)
+
+ def test_revision_id_as_committed(self):
+ """Check revision id before first commit"""
+ if not self.rcs.versioned:
+ self.failUnlessEqual(self.rcs.revision_id(5), None)
+ return
+ committed_revisions = []
+ for path in self.test_files:
+ self.failUnlessEqual(self.rcs.revision_id(0), None)
+
class RCS_duplicate_repo_TestCase(RCSTestCase):
"""Test cases for RCS.duplicate_repo method."""
diff --git a/libbe/settings_object.py b/libbe/settings_object.py
index 7326d1b..ceea9d5 100644
--- a/libbe/settings_object.py
+++ b/libbe/settings_object.py
@@ -1,18 +1,19 @@
# Bugs Everywhere - a distributed bugtracker
# Copyright (C) 2008-2009 W. Trevor King <wking@drexel.edu>
#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
+# 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.
+# 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, see <http://www.gnu.org/licenses/>.
+# 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.
"""
This module provides a base class implementing settings-dict based
@@ -95,7 +96,7 @@ def versioned_property(name, doc,
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
@@ -103,49 +104,57 @@ def versioned_property(name, doc,
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.
"""
settings_properties.append(name)
if require_save == True:
required_saved_properties.append(name)
def decorator(funcs):
fulldoc = doc
- if default != None:
+ if default != None or generator == None:
defaulting = defaulting_property(default=default, null=EMPTY,
- default_mutable=mutable)
- fulldoc += "\n\nThis property defaults to %s" % default
+ 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
+ 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
+ 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)
+ hooked = change_hook_property(hook=change_hook, mutable=mutable,
+ default=EMPTY)
primed = primed_property(primer=primer, initVal=UNPRIMED)
settings = settings_property(name=name, null=UNPRIMED)
docp = doc_property(doc=fulldoc)
deco = hooked(primed(settings(docp(funcs))))
- if default != None:
+ if default != None or generator == None:
deco = defaulting(deco)
if generator != None:
deco = cached(deco)
@@ -179,7 +188,7 @@ class SavedSettingsObject(object):
# Override. Must call ._setup_saved_settings() after loading.
self.settings = {}
self._setup_saved_settings()
-
+
def _setup_saved_settings(self, flag_as_loaded=True):
"""
To be run after setting self.settings up from disk. Marks all
@@ -207,7 +216,7 @@ class SavedSettingsObject(object):
for 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:
@@ -235,7 +244,7 @@ class SavedSettingsObjectTests(unittest.TestCase):
# access missing setting
self.failUnless(t._settings_loaded == False, t._settings_loaded)
self.failUnless(len(t.settings) == 0, len(t.settings))
- self.failUnless(t.content_type == EMPTY, t.content_type)
+ self.failUnless(t.content_type == None, t.content_type)
# accessing t.content_type triggers the priming, which runs
# t._setup_saved_settings, which fills out t.settings with
# EMPTY data. t._settings_loaded is still false though, since
@@ -250,16 +259,16 @@ class SavedSettingsObjectTests(unittest.TestCase):
self.failUnless(t._settings_loaded == True, t._settings_loaded)
self.failUnless(t.settings["Content-type"] == EMPTY,
t.settings["Content-type"])
- self.failUnless(t.content_type == EMPTY, t.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"])
# now we set a value
- t.content_type = None
- self.failUnless(t.settings["Content-type"] == None,
+ t.content_type = 5
+ self.failUnless(t.settings["Content-type"] == 5,
t.settings["Content-type"])
- self.failUnless(t.content_type == None, t.content_type)
- self.failUnless(t.settings["Content-type"] == None,
+ self.failUnless(t.content_type == 5, t.content_type)
+ self.failUnless(t.settings["Content-type"] == 5,
t.settings["Content-type"])
# now we set another value
t.content_type = "text/plain"
@@ -273,7 +282,7 @@ class SavedSettingsObjectTests(unittest.TestCase):
self.failUnless(t._settings_loaded == True, t._settings_loaded)
self.failUnless(t.settings["Content-type"] == EMPTY,
t.settings["Content-type"])
- self.failUnless(t.content_type == EMPTY, t.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"])
@@ -376,33 +385,25 @@ class SavedSettingsObjectTests(unittest.TestCase):
t.load_settings()
self.failUnless(SAVES == [], SAVES)
self.failUnless(t._settings_loaded == True, t._settings_loaded)
- self.failUnless(t.list_type == EMPTY, t.list_type)
- self.failUnless(SAVES == [
- "'None' -> '<class 'libbe.settings_object.EMPTY'>'"
- ], SAVES)
+ self.failUnless(t.list_type == None, t.list_type)
+ self.failUnless(SAVES == [], SAVES)
self.failUnless(t.settings["List-type"]==EMPTY,t.settings["List-type"])
t.list_type = []
self.failUnless(t.settings["List-type"] == [], t.settings["List-type"])
self.failUnless(SAVES == [
- "'None' -> '<class 'libbe.settings_object.EMPTY'>'",
"'<class 'libbe.settings_object.EMPTY'>' -> '[]'"
], SAVES)
t.list_type.append(5)
self.failUnless(SAVES == [
- "'None' -> '<class 'libbe.settings_object.EMPTY'>'",
"'<class 'libbe.settings_object.EMPTY'>' -> '[]'",
- "'<class 'libbe.settings_object.EMPTY'>' -> '[]'" # <- TODO. Where did this come from?
], SAVES)
self.failUnless(t.settings["List-type"] == [5],t.settings["List-type"])
self.failUnless(SAVES == [ # the append(5) has not yet been saved
- "'None' -> '<class 'libbe.settings_object.EMPTY'>'",
- "'<class 'libbe.settings_object.EMPTY'>' -> '[]'",
"'<class 'libbe.settings_object.EMPTY'>' -> '[]'",
], SAVES)
self.failUnless(t.list_type == [5], t.list_type) # <-get triggers saved
+
self.failUnless(SAVES == [ # now the append(5) has been saved.
- "'None' -> '<class 'libbe.settings_object.EMPTY'>'",
- "'<class 'libbe.settings_object.EMPTY'>' -> '[]'",
"'<class 'libbe.settings_object.EMPTY'>' -> '[]'",
"'[]' -> '[5]'"
], SAVES)
diff --git a/libbe/tree.py b/libbe/tree.py
index 54b927e..45ae085 100644
--- a/libbe/tree.py
+++ b/libbe/tree.py
@@ -1,20 +1,19 @@
# Bugs Everywhere, a distributed bugtracker
# Copyright (C) 2008-2009 W. Trevor King <wking@drexel.edu>
#
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 2 of the License, or
-# (at your option) any later version.
+# This program is 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.
+# 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
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
import doctest
@@ -36,7 +35,7 @@ class Tree(list):
>>> a = Tree(); a.n = "a"
>>> a.append(c)
>>> a.append(b)
-
+
>>> a.branch_len()
5
>>> a.sort(key=lambda node : -node.branch_len())
@@ -45,7 +44,7 @@ class Tree(list):
>>> a.sort(key=lambda node : node.branch_len())
>>> "".join([node.n for node in a.traverse()])
'abdgcefhi'
- >>> "".join([node.n for node in a.traverse(depthFirst=False)])
+ >>> "".join([node.n for node in a.traverse(depth_first=False)])
'abcdefghi'
>>> for depth,node in a.thread():
... print "%*s" % (2*depth+1, node.n)
@@ -69,7 +68,18 @@ class Tree(list):
f
h
i
+ >>> a.has_descendant(g)
+ True
+ >>> c.has_descendant(g)
+ False
+ >>> a.has_descendant(a)
+ False
+ >>> a.has_descendant(a, match_self=True)
+ True
"""
+ def __eq__(self, other):
+ return id(self) == id(other)
+
def branch_len(self):
"""
Exhaustive search every time == SLOW.
@@ -98,11 +108,11 @@ class Tree(list):
for child in self:
child.sort(*args, **kwargs)
- def traverse(self, depthFirst=True):
+ def traverse(self, depth_first=True):
"""
Note: you might want to sort() your tree first.
"""
- if depthFirst == True:
+ if depth_first == True:
yield self
for child in self:
for descendant in child.traverse():
@@ -120,7 +130,7 @@ class Tree(list):
When flatten==False, the depth of any node is one greater than
the depth of its parent. That way the inheritance is
explicit, but you can end up with highly indented threads.
-
+
When flatten==True, the depth of any node is only greater than
the depth of its parent when there is a branch, and the node
is not the last child. This can lead to ancestry ambiguity,
@@ -139,8 +149,8 @@ class Tree(list):
stack = [] # ancestry of the current node
if flatten == True:
depthDict = {}
-
- for node in self.traverse(depthFirst=True):
+
+ for node in self.traverse(depth_first=True):
while len(stack) > 0 \
and id(node) not in [id(c) for c in stack[-1]]:
stack.pop(-1)
@@ -158,4 +168,12 @@ class Tree(list):
yield (depth,node)
stack.append(node)
+ def has_descendant(self, descendant, depth_first=True, match_self=False):
+ if descendant == self:
+ return match_self
+ for d in self.traverse(depth_first):
+ if descendant == d:
+ return True
+ return False
+
suite = doctest.DocTestSuite()
diff --git a/libbe/utility.py b/libbe/utility.py
index 8a0f318..3df06b4 100644
--- a/libbe/utility.py
+++ b/libbe/utility.py
@@ -1,29 +1,28 @@
# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
# W. Trevor King <wking@drexel.edu>
-# <abentley@panoramicfeedback.com>
#
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 2 of the License, or
-# (at your option) any later version.
+# This program is 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.
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+# 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 calendar
import codecs
import os
import shutil
import tempfile
import time
+import types
import doctest
-
def search_parent_directories(path, filename):
"""
Find the file (or directory) named filename in path or in any
@@ -77,17 +76,54 @@ def time_to_str(time_val):
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 falue.
+ """Convert an RFC 2822-fomatted string into a time value.
>>> 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
"""
- return calendar.timegm(time.strptime(str_time, RFC_2822_TIME_FMT))
+ 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):
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.
+ >>> 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.
+ >>> 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
suite = doctest.DocTestSuite()