aboutsummaryrefslogtreecommitdiffstats
path: root/libbe
diff options
context:
space:
mode:
Diffstat (limited to 'libbe')
-rw-r--r--libbe/arch.py26
-rw-r--r--libbe/bug.py357
-rw-r--r--libbe/bugdir.py375
-rw-r--r--libbe/bzr.py2
-rw-r--r--libbe/cmdutil.py84
-rw-r--r--libbe/comment.py295
-rw-r--r--libbe/config.py28
-rw-r--r--libbe/diff.py39
-rw-r--r--libbe/editor.py103
-rw-r--r--libbe/encoding.py53
-rw-r--r--libbe/git.py4
-rw-r--r--libbe/hg.py4
-rw-r--r--libbe/mapfile.py65
-rw-r--r--libbe/properties.py479
-rw-r--r--libbe/rcs.py37
-rw-r--r--libbe/restconvert.py130
-rw-r--r--libbe/settings_object.py353
-rw-r--r--libbe/tree.py5
-rw-r--r--libbe/utility.py73
19 files changed, 1871 insertions, 641 deletions
diff --git a/libbe/arch.py b/libbe/arch.py
index fd953a4..1173535 100644
--- a/libbe/arch.py
+++ b/libbe/arch.py
@@ -14,10 +14,11 @@
# 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
+import codecs
import os
+import re
import shutil
import time
-import re
import unittest
import doctest
@@ -133,13 +134,16 @@ class Arch(RCS):
"""
tagpath = os.path.join(path, "{arch}", "=tagging-method")
lines_out = []
- for line in file(tagpath, "rb"):
- line.decode("utf-8")
+ f = codecs.open(tagpath, "r", self.encoding)
+ for line in f:
if line.startswith("source "):
lines_out.append("source ^[._=a-zA-X0-9].*$\n")
else:
lines_out.append(line)
- file(tagpath, "wb").write("".join(lines_out).encode("utf-8"))
+ f.close()
+ f = codecs.open(tagpath, "w", self.encoding)
+ f.write("".join(lines_out))
+ f.close()
def _add_project_code(self, path):
# http://mwolson.org/projects/GettingStartedWithArch.html
@@ -215,7 +219,9 @@ class Arch(RCS):
return [os.path.join(root, p) for p in inv_str.split('\n')]
def _add_dir_rule(self, rule, dirname, root):
inv_path = os.path.join(dirname, '.arch-inventory')
- file(inv_path, "ab").write(rule)
+ f = codecs.open(inv_path, "a", self.encoding)
+ f.write(rule)
+ f.close()
if os.path.realpath(inv_path) not in self._list_added(root):
paranoid = self.paranoid
self.paranoid = False
@@ -233,12 +239,16 @@ class Arch(RCS):
pass
def _rcs_get_file_contents(self, path, revision=None):
if revision == None:
- return file(self._u_abspath(path), "rb").read()
+ return RCS._rcs_get_file_contents(self, path, revision)
else:
status,output,error = \
self._invoke_client("file-find", path, revision)
- path = output.rstrip('\n')
- return file(self._u_abspath(path), "rb").read()
+ relpath = output.rstrip('\n')
+ abspath = os.path.join(self.rootdir, relpath)
+ f = codecs.open(abspath, "r", self.encoding)
+ contents = f.read()
+ f.close()
+ return contents
def _rcs_duplicate_repo(self, directory, revision=None):
if revision == None:
RCS._rcs_duplicate_repo(self, directory, revision)
diff --git a/libbe/bug.py b/libbe/bug.py
index afa9e09..fe059fa 100644
--- a/libbe/bug.py
+++ b/libbe/bug.py
@@ -21,6 +21,10 @@ import time
import doctest
from beuuid import uuid_gen
+from properties import Property, doc_property, local_property, \
+ defaulting_property, checked_property, cached_property, \
+ primed_property, change_hook_property, settings_property
+import settings_object
import mapfile
import comment
import utility
@@ -30,9 +34,9 @@ import utility
# Use a tuple of (category, description) tuples since we don't have
# ordered dicts in Python yet http://www.python.org/dev/peps/pep-0372/
-# in order of increasing severity
-severity_level_def = (
- ("wishlist","A feature that could improve usefullness, but not a bug."),
+# in order of increasing severity. (name, description) pairs
+severity_def = (
+ ("wishlist","A feature that could improve usefulness, but not a bug."),
("minor","The standard bug level."),
("serious","A bug that requires workarounds."),
("critical","A bug that prevents some features from working at all."),
@@ -48,86 +52,189 @@ active_status_def = (
inactive_status_def = (
("closed", "The bug is no longer relevant."),
("fixed", "The bug should no longer occur."),
- ("wontfix","It's not a bug, it's a feature."),
- ("disabled", "?"))
+ ("wontfix","It's not a bug, it's a feature."))
### Convert the description tuples to more useful formats
-severity_values = tuple([val for val,description in severity_level_def])
-severity_description = dict(severity_level_def)
+severity_values = ()
+severity_description = {}
severity_index = {}
-for i in range(len(severity_values)):
- severity_index[severity_values[i]] = i
-
-active_status_values = tuple(val for val,description in active_status_def)
-inactive_status_values = tuple(val for val,description in inactive_status_def)
-status_values = active_status_values + inactive_status_values
-status_description = dict(active_status_def+inactive_status_def)
+def load_severities(severity_def):
+ global severity_values
+ global severity_description
+ global severity_index
+ if severity_def == settings_object.EMPTY:
+ return
+ severity_values = tuple([val for val,description in severity_def])
+ severity_description = dict(severity_def)
+ severity_index = {}
+ for i,severity in enumerate(severity_values):
+ severity_index[severity] = i
+load_severities(severity_def)
+
+active_status_values = []
+inactive_status_values = []
+status_values = []
+status_description = {}
status_index = {}
-for i in range(len(status_values)):
- status_index[status_values[i]] = i
-
-
-def checked_property(name, valid):
+def load_status(active_status_def, inactive_status_def):
+ global active_status_values
+ global inactive_status_values
+ global status_values
+ global status_description
+ global status_index
+ if active_status_def == settings_object.EMPTY:
+ active_status_def = globals()["active_status_def"]
+ if inactive_status_def == settings_object.EMPTY:
+ inactive_status_def = globals()["inactive_status_def"]
+ active_status_values = tuple([val for val,description in active_status_def])
+ inactive_status_values = tuple([val for val,description in inactive_status_def])
+ status_values = active_status_values + inactive_status_values
+ status_description = dict(tuple(active_status_def) + tuple(inactive_status_def))
+ status_index = {}
+ for i,status in enumerate(status_values):
+ status_index[status] = i
+load_status(active_status_def, inactive_status_def)
+
+
+class Bug(settings_object.SavedSettingsObject):
"""
- Provide access to an attribute name, testing for valid values.
+ >>> b = Bug()
+ >>> print b.status
+ open
+ >>> print b.severity
+ minor
+
+ There are two formats for time, int and string. Setting either
+ one will adjust the other appropriately. The string form is the
+ one stored in the bug's settings file on disk.
+ >>> print type(b.time)
+ <type 'int'>
+ >>> print type(b.time_string)
+ <type 'str'>
+ >>> b.time = 0
+ >>> print b.time_string
+ Thu, 01 Jan 1970 00:00:00 +0000
+ >>> b.time_string="Thu, 01 Jan 1970 00:01:00 +0000"
+ >>> b.time
+ 60
+ >>> print b.settings["time"]
+ Thu, 01 Jan 1970 00:01:00 +0000
"""
- def getter(self):
- value = getattr(self, "_"+name)
- if value not in valid:
- raise InvalidValue(name, value)
- return value
-
- def setter(self, value):
- if value not in valid:
- raise InvalidValue(name, value)
- return setattr(self, "_"+name, value)
- return property(getter, setter)
+ settings_properties = []
+ required_saved_properties = []
+ _prop_save_settings = settings_object.prop_save_settings
+ _prop_load_settings = settings_object.prop_load_settings
+ def _versioned_property(settings_properties=settings_properties,
+ required_saved_properties=required_saved_properties,
+ **kwargs):
+ if "settings_properties" not in kwargs:
+ kwargs["settings_properties"] = settings_properties
+ if "required_saved_properties" not in kwargs:
+ kwargs["required_saved_properties"]=required_saved_properties
+ return settings_object.versioned_property(**kwargs)
+
+ @_versioned_property(name="severity",
+ doc="A measure of the bug's importance",
+ default="minor",
+ check_fn=lambda s: s in severity_values,
+ require_save=True)
+ def severity(): return {}
+
+ @_versioned_property(name="status",
+ doc="The bug's current status",
+ default="open",
+ check_fn=lambda s: s in status_values,
+ require_save=True)
+ def status(): return {}
+
+ @property
+ def active(self):
+ return self.status in active_status_values
+ @_versioned_property(name="target",
+ doc="The deadline for fixing this bug")
+ def target(): return {}
+
+ @_versioned_property(name="creator",
+ doc="The user who entered the bug into the system")
+ def creator(): return {}
+
+ @_versioned_property(name="reporter",
+ doc="The user who reported the bug")
+ def reporter(): return {}
+
+ @_versioned_property(name="assigned",
+ doc="The developer in charge of the bug")
+ def assigned(): return {}
+
+ @_versioned_property(name="time",
+ doc="An RFC 2822 timestamp for bug creation")
+ def time_string(): return {}
+
+ def _get_time(self):
+ if self.time_string == None or self.time_string == settings_object.EMPTY:
+ return None
+ return utility.str_to_time(self.time_string)
+ def _set_time(self, value):
+ self.time_string = utility.time_to_str(value)
+ time = property(fget=_get_time,
+ fset=_set_time,
+ doc="An integer version of .time_string")
+
+ @_versioned_property(name="summary",
+ doc="A one-line bug description")
+ def summary(): return {}
+
+ def _get_comment_root(self, load_full=False):
+ if self.sync_with_disk:
+ return comment.loadComments(self, load_full=load_full)
+ else:
+ return comment.Comment(self, uuid=comment.INVALID_UUID)
-class Bug(object):
- severity = checked_property("severity", severity_values)
- status = checked_property("status", status_values)
+ @Property
+ @cached_property(generator=_get_comment_root)
+ @local_property("comment_root")
+ @doc_property(doc="The trunk of the comment tree")
+ def comment_root(): return {}
- def _get_active(self):
- return self.status in active_status_values
+ def _get_rcs(self):
+ if hasattr(self.bugdir, "rcs"):
+ return self.bugdir.rcs
- active = property(_get_active)
+ @Property
+ @cached_property(generator=_get_rcs)
+ @local_property("rcs")
+ @doc_property(doc="A revision control system instance.")
+ def rcs(): return {}
def __init__(self, bugdir=None, uuid=None, from_disk=False,
load_comments=False, summary=None):
+ settings_object.SavedSettingsObject.__init__(self)
self.bugdir = bugdir
- if bugdir != None:
- self.rcs = bugdir.rcs
- else:
- self.rcs = None
+ self.uuid = uuid
if from_disk == True:
- self._comments_loaded = False
- self.uuid = uuid
- self.load(load_comments=load_comments)
+ self.sync_with_disk = True
else:
- # Note: defaults should match those in Bug.load()
- self._comments_loaded = True
- if uuid != None:
- self.uuid = uuid
- else:
+ self.sync_with_disk = False
+ if uuid == None:
self.uuid = uuid_gen()
- self.summary = summary
+ self.time = int(time.time()) # only save to second precision
if self.rcs != None:
self.creator = self.rcs.get_user_id()
- else:
- self.creator = None
- self.target = None
- self.status = "open"
- self.severity = "minor"
- self.assigned = None
- self.time = int(time.time()) # only save to second precision
- self.comment_root = comment.Comment(self, uuid=comment.INVALID_UUID)
+ self.summary = summary
def __repr__(self):
return "Bug(uuid=%r)" % self.uuid
+ def _setting_attr_string(self, setting):
+ value = getattr(self, setting)
+ if value == settings_object.EMPTY:
+ return ""
+ else:
+ return str(value)
+
def xml(self, show_comments=False):
if self.bugdir == None:
shortname = self.uuid
@@ -147,6 +254,7 @@ class Bug(object):
("status", self.status),
("assigned", self.assigned),
("target", self.target),
+ ("reporter", self.reporter),
("creator", self.creator),
("created", timestring),
("summary", self.summary)]
@@ -155,9 +263,7 @@ class Bug(object):
if v is not None:
ret += ' <%s>%s</%s>\n' % (k,v,k)
- if show_comments:
- if self._comments_loaded == False:
- self.load_comments()
+ if show_comments == True:
comout = self.comment_root.xml_thread(auto_name_map=True,
bug_shortname=shortname)
ret += comout
@@ -171,27 +277,20 @@ class Bug(object):
else:
shortname = self.bugdir.bug_shortname(self)
if shortlist == False:
- if self.time == None:
- timestring = ""
+ if self.time_string == "":
+ timestring = self.time_string
else:
htime = utility.handy_time(self.time)
- ftime = utility.time_to_str(self.time)
- timestring = "%s (%s)" % (htime, ftime)
+ timestring = "%s (%s)" % (htime, self.time_string)
info = [("ID", self.uuid),
("Short name", shortname),
("Severity", self.severity),
("Status", self.status),
- ("Assigned", self.assigned),
- ("Target", self.target),
- ("Creator", self.creator),
+ ("Assigned", self._setting_attr_string("assigned")),
+ ("Target", self._setting_attr_string("target")),
+ ("Reporter", self._setting_attr_string("reporter")),
+ ("Creator", self._setting_attr_string("creator")),
("Created", timestring)]
- newinfo = []
- for k,v in info:
- if v == None:
- newinfo.append((k,""))
- else:
- newinfo.append((k,v))
- info = newinfo
longest_key_len = max([len(k) for k,v in info])
infolines = [" %*s : %s\n" %(longest_key_len,k,v) for k,v in info]
bugout = "".join(infolines) + "%s" % self.summary.rstrip('\n')
@@ -199,12 +298,13 @@ class Bug(object):
statuschar = self.status[0]
severitychar = self.severity[0]
chars = "%c%c" % (statuschar, severitychar)
- bugout = "%s:%s: %s" % (shortname, chars, self.summary.rstrip('\n'))
+ bugout = "%s:%s: %s" % (shortname,chars,self.summary.rstrip('\n'))
if show_comments == True:
- if self._comments_loaded == False:
- self.load_comments()
- comout = self.comment_root.string_thread(auto_name_map=True,
+ # take advantage of the string_thread(auto_name_map=True)
+ # SIDE-EFFECT of sorting by comment time.
+ comout = self.comment_root.string_thread(flatten=False,
+ auto_name_map=True,
bug_shortname=shortname)
output = bugout + '\n' + comout.rstrip('\n')
else :
@@ -224,75 +324,67 @@ class Bug(object):
assert name in ["values", "comments"]
return os.path.join(my_dir, name)
- def load(self, load_comments=False):
- map = mapfile.map_load(self.rcs, self.get_path("values"))
- self.summary = map.get("summary")
- self.creator = map.get("creator")
- self.target = map.get("target")
- self.status = map.get("status", "open")
- self.severity = map.get("severity", "minor")
- self.assigned = map.get("assigned")
- self.time = map.get("time")
- if self.time is not None:
- self.time = utility.str_to_time(self.time)
-
- if load_comments == True:
- self.load_comments()
-
- def load_comments(self):
- self.comment_root = comment.loadComments(self)
- self._comments_loaded = True
+ def load_settings(self):
+ self.settings = mapfile.map_load(self.rcs, self.get_path("values"))
+ self._setup_saved_settings()
- def comments(self):
- if self._comments_loaded == False:
- self.load_comments()
- for comment in self.comment_root.traverse():
- yield comment
-
- def _add_attr(self, map, name):
- value = getattr(self, name)
- if value is not None:
- map[name] = value
-
- def save(self):
+ def load_comments(self, load_full=True):
+ if load_full == True:
+ # Force a complete load of the whole comment tree
+ self.comment_root = self._get_comment_root(load_full=True)
+ else:
+ # Setup for fresh lazy-loading. Clear _comment_root, so
+ # _get_comment_root returns a fresh version. Turn of
+ # syncing temporarily so we don't write our blank comment
+ # tree to disk.
+ self.sync_with_disk = False
+ self.comment_root = None
+ self.sync_with_disk = True
+
+ def save_settings(self):
assert self.summary != None, "Can't save blank bug"
- map = {}
- self._add_attr(map, "assigned")
- self._add_attr(map, "summary")
- self._add_attr(map, "creator")
- self._add_attr(map, "target")
- self._add_attr(map, "status")
- self._add_attr(map, "severity")
- if self.time is not None:
- map["time"] = utility.time_to_str(self.time)
-
+
self.rcs.mkdir(self.get_path())
path = self.get_path("values")
- mapfile.map_save(self.rcs, path, map)
+ mapfile.map_save(self.rcs, path, self._get_saved_settings())
+
+ def save(self):
+ self.save_settings()
- if self._comments_loaded:
- if len(self.comment_root) > 0:
- self.rcs.mkdir(self.get_path("comments"))
- comment.saveComments(self)
+ if len(self.comment_root) > 0:
+ self.rcs.mkdir(self.get_path("comments"))
+ comment.saveComments(self)
def remove(self):
- self.load_comments()
self.comment_root.remove()
path = self.get_path()
self.rcs.recursive_remove(path)
+ def comments(self):
+ for comment in self.comment_root.traverse():
+ yield comment
+
def new_comment(self, body=None):
- comm = comment.comment_root.new_reply(body=body)
+ comm = self.comment_root.new_reply(body=body)
return comm
def comment_from_shortname(self, shortname, *args, **kwargs):
- return self.comment_root.comment_from_shortname(shortname, *args, **kwargs)
+ return self.comment_root.comment_from_shortname(shortname,
+ *args, **kwargs)
def comment_from_uuid(self, uuid):
return self.comment_root.comment_from_uuid(uuid)
+ def comment_shortnames(self, shortname=None):
+ """
+ SIDE-EFFECT : Comment.comment_shortnames will sort the comment
+ tree by comment.time
+ """
+ for id, comment in self.comment_root.comment_shortnames(shortname):
+ yield (id, comment)
-# the general rule for bug sorting is that "more important" bugs are
+
+# The general rule for bug sorting is that "more important" bugs are
# less than "less important" bugs. This way sorting a list of bugs
# will put the most important bugs first in the list. When relative
# importance is unclear, the sorting follows some arbitrary convention
@@ -359,10 +451,15 @@ def cmp_attr(bug_1, bug_2, attr, invert=False):
"""
if not hasattr(bug_2, attr) :
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 invert == True :
- return -cmp(getattr(bug_1, attr), getattr(bug_2, attr))
+ return -cmp(val_1, val_2)
else :
- return cmp(getattr(bug_1, attr), getattr(bug_2, attr))
+ return cmp(val_1, val_2)
# alphabetical rankings (a < z)
cmp_creator = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "creator")
diff --git a/libbe/bugdir.py b/libbe/bugdir.py
index 7e4cf3e..7885224 100644
--- a/libbe/bugdir.py
+++ b/libbe/bugdir.py
@@ -22,10 +22,16 @@ import copy
import unittest
import doctest
+from properties import Property, doc_property, local_property, \
+ defaulting_property, checked_property, fn_checked_property, \
+ cached_property, primed_property, change_hook_property, \
+ settings_property
+import settings_object
import mapfile
import bug
-import utility
import rcs
+import encoding
+import utility
class NoBugDir(Exception):
@@ -64,28 +70,29 @@ class MultipleBugMatches(ValueError):
TREE_VERSION_STRING = "Bugs Everywhere Tree 1 0\n"
-def setting_property(name, valid=None, doc=None):
- def getter(self):
- value = self.settings.get(name)
- if valid is not None:
- if value not in valid and value != None:
- raise InvalidValue(name, value)
- return value
+class BugDir (list, settings_object.SavedSettingsObject):
+ """
+ Sink to existing root
+ ======================
- def setter(self, value):
- if value != getter(self):
- if value is None:
- del self.settings[name]
- else:
- self.settings[name] = value
- self._save_settings(self.get_path("settings"), self.settings)
+ Consider the following usage case:
+ You have a bug directory rooted in
+ /path/to/source
+ by which I mean the '.be' directory is at
+ /path/to/source/.be
+ However, you're of in some subdirectory like
+ /path/to/source/GUI/testing
+ and you want to comment on a bug. Setting sink_to_root=True wen
+ you initialize your BugDir will cause it to search for the '.be'
+ file in the ancestors of the path you passed in as 'root'.
+ /path/to/source/GUI/testing/.be miss
+ /path/to/source/GUI/.be miss
+ /path/to/source/.be hit!
+ So it still roots itself appropriately without much work for you.
+
+ File-system access
+ ==================
- return property(getter, setter, doc=doc)
-
-
-class BugDir (list):
- """
- 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,
@@ -95,13 +102,166 @@ class BugDir (list):
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)).
+
+ 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
+ tests of versioning functionality (e.g. .duplicate_bugdir).
+
+ Disable encoding manipulation
+ =============================
+
+ This one is for testing purposed. You might have non-ASCII
+ Unicode in your bugs, comments, files, etc. BugDir instances try
+ and support your preferred encoding scheme (e.g. "utf-8") when
+ dealing with stream and file input/output. For stream output,
+ this involves replacing sys.stdout and sys.stderr
+ (libbe.encode.set_IO_stream_encodings). However this messes up
+ doctest's output catching. In order to support doctest tests
+ using BugDirs, set manipulate_encodings=False, and stick to ASCII
+ in your tests.
"""
+
+ settings_properties = []
+ required_saved_properties = []
+ _prop_save_settings = settings_object.prop_save_settings
+ _prop_load_settings = settings_object.prop_load_settings
+ def _versioned_property(settings_properties=settings_properties,
+ required_saved_properties=required_saved_properties,
+ **kwargs):
+ if "settings_properties" not in kwargs:
+ kwargs["settings_properties"] = settings_properties
+ if "required_saved_properties" not in kwargs:
+ kwargs["required_saved_properties"]=required_saved_properties
+ return settings_object.versioned_property(**kwargs)
+
+ @_versioned_property(name="target",
+ doc="The current project development target")
+ def target(): return {}
+
+ def _guess_encoding(self):
+ return encoding.get_encoding()
+ def _check_encoding(value):
+ if value != None and value != settings_object.EMPTY:
+ return encoding.known_encoding(value)
+ def _setup_encoding(self, new_encoding):
+ if new_encoding != None and new_encoding != settings_object.EMPTY:
+ if self._manipulate_encodings == True:
+ encoding.set_IO_stream_encodings(new_encoding)
+ def _set_encoding(self, old_encoding, new_encoding):
+ self._setup_encoding(new_encoding)
+ self._prop_save_settings(old_encoding, new_encoding)
+
+ @_versioned_property(name="encoding",
+ doc="""The default input/output encoding to use (e.g. "utf-8").""",
+ change_hook=_set_encoding,
+ generator=_guess_encoding,
+ check_fn=_check_encoding)
+ def encoding(): return {}
+
+ def _setup_user_id(self, user_id):
+ self.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):
+ self._setup_user_id(new_user_id)
+ self._prop_save_settings(old_user_id, new_user_id)
+
+ @_versioned_property(name="user_id",
+ doc=
+"""The user's prefered name, e.g. 'John Doe <jdoe@example.com>'. Note
+that the Arch RCS backend *enforces* ids with this format.""",
+ change_hook=_set_user_id,
+ generator=_guess_user_id)
+ def user_id(): return {}
+
+ @_versioned_property(name="default_assignee",
+ doc=
+"""The default assignee for new bugs e.g. 'John Doe <jdoe@example.com>'.""")
+ def default_assignee(): return {}
+
+ @_versioned_property(name="rcs_name",
+ doc="""The name of the current RCS. Kept seperate to make saving/loading
+settings easy. Don't set this attribute. Set .rcs instead, and
+.rcs_name will be automatically adjusted.""",
+ default="None",
+ allowed=["None", "Arch", "bzr", "git", "hg"])
+ def rcs_name(): return {}
+
+ def _get_rcs(self, rcs_name=None):
+ """Get and root a new revision control system"""
+ if rcs_name == None:
+ rcs_name = self.rcs_name
+ new_rcs = rcs.rcs_by_name(rcs_name)
+ self._change_rcs(None, new_rcs)
+ return new_rcs
+ def _change_rcs(self, old_rcs, new_rcs):
+ new_rcs.encoding = self.encoding
+ new_rcs.root(self.root)
+ self.rcs_name = new_rcs.name
+
+ @Property
+ @change_hook_property(hook=_change_rcs)
+ @cached_property(generator=_get_rcs)
+ @local_property("rcs")
+ @doc_property(doc="A revision control system instance.")
+ def rcs(): return {}
+
+ def _bug_map_gen(self):
+ map = {}
+ for bug in self:
+ map[bug.uuid] = bug
+ for uuid in self.list_uuids():
+ if uuid not in map:
+ map[uuid] = None
+ self._bug_map_value = map # ._bug_map_value used by @local_property
+
+ @Property
+ @primed_property(primer=_bug_map_gen)
+ @local_property("bug_map")
+ @doc_property(doc="A dict of (bug-uuid, bug-instance) pairs.")
+ def _bug_map(): return {}
+
+ def _setup_severities(self, severities):
+ if severities != None and severities != settings_object.EMPTY:
+ bug.load_severities(severities)
+ def _set_severities(self, old_severities, new_severities):
+ self._setup_severities(new_severities)
+ self._prop_save_settings(old_severities, new_severities)
+ @_versioned_property(name="severities",
+ doc="The allowed bug severities and their descriptions.",
+ change_hook=_set_severities)
+ def severities(): return {}
+
+ def _setup_status(self, active_status, inactive_status):
+ bug.load_status(active_status, inactive_status)
+ def _set_active_status(self, old_active_status, new_active_status):
+ self._setup_status(new_active_status, self.inactive_status)
+ self._prop_save_settings(old_active_status, new_active_status)
+ @_versioned_property(name="active_status",
+ doc="The allowed active bug states and their descriptions.",
+ change_hook=_set_active_status)
+ def active_status(): return {}
+
+ def _set_inactive_status(self, old_inactive_status, new_inactive_status):
+ self._setup_status(self.active_status, new_inactive_status)
+ self._prop_save_settings(old_inactive_status, new_inactive_status)
+ @_versioned_property(name="inactive_status",
+ doc="The allowed inactive bug states and their descriptions.",
+ change_hook=_set_inactive_status)
+ def inactive_status(): return {}
+
+
def __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):
list.__init__(self)
- self._save_user_id = False
- self.settings = {}
+ settings_object.SavedSettingsObject.__init__(self)
+ self._manipulate_encodings = manipulate_encodings
if root == None:
root = os.getcwd()
if sink_to_existing_root == True:
@@ -110,16 +270,22 @@ class BugDir (list):
if not os.path.exists(root):
raise NoRootEntry(root)
self.root = root
+ # 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()
else:
+ self.sync_with_disk = False
if assert_new_BugDir == True:
if os.path.exists(self.get_path()):
raise AlreadyInitialized, self.get_path()
if rcs == None:
rcs = self._guess_rcs(allow_rcs_init)
self.rcs = rcs
- user_id = self.rcs.get_user_id()
+ self._setup_user_id(self.user_id)
def _find_root(self, path):
"""
@@ -128,7 +294,8 @@ class BugDir (list):
"""
if not os.path.exists(path):
raise NoRootEntry(path)
- versionfile = utility.search_parent_directories(path, os.path.join(".be", "version"))
+ versionfile=utility.search_parent_directories(path,
+ os.path.join(".be", "version"))
if versionfile != None:
beroot = os.path.dirname(versionfile)
root = os.path.dirname(beroot)
@@ -139,11 +306,11 @@ class BugDir (list):
raise NoBugDir(path)
return beroot
- def get_version(self, path=None):
- if self.rcs_name == None:
- # Use a temporary RCS to check the version for the first time
+ def get_version(self, path=None, use_none_rcs=False):
+ if use_none_rcs == True:
RCS = rcs.rcs_by_name("None")
RCS.root(self.root)
+ RCS.encoding = encoding.get_encoding()
else:
RCS = self.rcs
@@ -156,57 +323,6 @@ class BugDir (list):
self.rcs.set_file_contents(self.get_path("version"),
TREE_VERSION_STRING)
- rcs_name = setting_property("rcs_name",
- ("None", "bzr", "git", "Arch", "hg"),
- doc=
-"""The name of the current RCS. Kept seperate to make saving/loading
-settings easy. Don't set this attribute. Set .rcs instead, and
-.rcs_name will be automatically adjusted.""")
-
- _rcs = None
-
- def _get_rcs(self):
- return self._rcs
-
- def _set_rcs(self, new_rcs):
- if new_rcs == None:
- new_rcs = rcs.rcs_by_name("None")
- self._rcs = new_rcs
- new_rcs.root(self.root)
- self.rcs_name = new_rcs.name
-
- rcs = property(_get_rcs, _set_rcs,
- doc="A revision control system (RCS) instance")
-
- _user_id = setting_property("user-id", doc=
-"""The user's prefered name. Kept seperate to make saving/loading
-settings easy. Don't set this attribute. Set .user_id instead,
-and ._user_id will be automatically adjusted. This setting is
-only saved if ._save_user_id == True""")
-
- def _get_user_id(self):
- if self._user_id == None and self.rcs != None:
- self._user_id = self.rcs.get_user_id()
- return self._user_id
-
- def _set_user_id(self, user_id):
- if self.rcs != None:
- self.rcs.user_id = user_id
- self._user_id = user_id
-
- user_id = property(_get_user_id, _set_user_id, doc=
-"""The user's prefered name, e.g 'John Doe <jdoe@example.com>'. Note
-that the Arch RCS backend *enforces* ids with this format.""")
-
- target = setting_property("target",
- doc="The current project development target")
-
- def save_user_id(self, user_id=None):
- if user_id == None:
- user_id = self.user_id
- self._save_user_id = True
- self.user_id = user_id
-
def get_path(self, *args):
my_dir = os.path.join(self.root, ".be")
if len(args) == 0:
@@ -224,24 +340,20 @@ that the Arch RCS backend *enforces* ids with this format.""")
if allow_rcs_init == True:
new_rcs = rcs.installed_rcs()
new_rcs.init(self.root)
- self.rcs = new_rcs
return new_rcs
def load(self):
- version = self.get_version()
+ version = self.get_version(use_none_rcs=True)
if version != TREE_VERSION_STRING:
raise NotImplementedError, \
"BugDir cannot handle version '%s' yet." % version
else:
if not os.path.exists(self.get_path()):
raise NoBugDir(self.get_path())
- self.settings = self._get_settings(self.get_path("settings"))
+ self.load_settings()
self.rcs = rcs.rcs_by_name(self.rcs_name)
- if self._user_id != None: # was a user name in the settings file
- self.save_user_id()
-
- self._bug_map_gen()
+ self._setup_user_id(self.user_id)
def load_all_bugs(self):
"Warning: this could take a while."
@@ -252,43 +364,35 @@ that the Arch RCS backend *enforces* ids with this format.""")
def save(self):
self.rcs.mkdir(self.get_path())
self.set_version()
- self._save_settings(self.get_path("settings"), self.settings)
+ self.save_settings()
self.rcs.mkdir(self.get_path("bugs"))
for bug in self:
bug.save()
+ 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):
- if self.rcs_name == None:
- # Use a temporary RCS to loading settings the first time
- RCS = rcs.rcs_by_name("None")
- RCS.root(self.root)
- else:
- RCS = self.rcs
-
- allow_no_rcs = not RCS.path_in_root(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(RCS, settings_path, allow_no_rcs)
+ 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):
- this_dir_path = os.path.realpath(self.get_path("settings"))
- if os.path.realpath(settings_path) == this_dir_path:
- if not os.path.exists(self.get_path()):
- # don't save settings until the bug directory has been
- # initialized. this initialization happens the first time
- # a bug directory is saved (BugDir.save()). If the user
- # is just working with a BugDir in memory, we don't want
- # to go cluttering up his file system with settings files.
- return
- if self._save_user_id == False:
- if "user-id" in settings:
- settings = copy.copy(settings)
- del settings["user-id"]
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
@@ -304,23 +408,17 @@ that the Arch RCS backend *enforces* ids with this format.""")
duplicate_settings = self._get_settings(duplicate_settings_path)
if "rcs_name" in duplicate_settings:
duplicate_settings["rcs_name"] = "None"
- duplicate_settings["user-id"] = self.user_id
- self._save_settings(duplicate_settings_path, duplicate_settings)
+ 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)
- return BugDir(duplicate_path, from_disk=True)
+ return BugDir(duplicate_path, from_disk=True, manipulate_encodings=self._manipulate_encodings)
def remove_duplicate_bugdir(self):
self.rcs.remove_duplicate_repo()
- def _bug_map_gen(self):
- map = {}
- for bug in self:
- map[bug.uuid] = bug
- for uuid in self.list_uuids():
- if uuid not in map:
- map[uuid] = None
- self._bug_map = map
-
def list_uuids(self):
uuids = []
if os.path.exists(self.get_path()):
@@ -338,6 +436,7 @@ that the Arch RCS backend *enforces* ids with this format.""")
def _clear_bugs(self):
while len(self) > 0:
self.pop()
+ self._bug_map_gen()
def _load_bug(self, uuid):
bg = bug.Bug(bugdir=self, uuid=uuid, from_disk=True)
@@ -420,7 +519,8 @@ def simple_bug_dir():
"""
dir = utility.Dir()
assert os.path.exists(dir.path)
- bugdir = BugDir(dir.path, sink_to_existing_root=False, allow_rcs_init=True)
+ bugdir = BugDir(dir.path, sink_to_existing_root=False, allow_rcs_init=True,
+ 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>"
@@ -495,6 +595,37 @@ class BugDirTestCase(unittest.TestCase):
self.failUnless(bugA == bugAprime, "%s != %s" % (bugA, bugAprime))
self.bugdir.save()
self.versionTest()
+ def testComments(self):
+ 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()
+ bug = self.bugdir.bug_from_uuid("a")
+ bug.load_comments()
+ 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,
+ comment.sync_with_disk)
+ #load_settings()
+ self.failUnless(comment.content_type == "text/plain",
+ comment.content_type)
+ self.failUnless(repLoaded.settings["Content-type"]=="text/plain",
+ repLoaded.settings)
+ self.failUnless(repLoaded.body == "Ants are small.",
+ repLoaded.body)
+ elif index == 1:
+ self.failUnless(comment.in_reply_to == repLoaded.uuid,
+ repLoaded.uuid)
+ self.failUnless(comment.body == "And they have six legs.",
+ comment.body)
+ else:
+ self.failIf(True, "Invalid comment: %d\n%s" % (index, comment))
unitsuite = unittest.TestLoader().loadTestsFromTestCase(BugDirTestCase)
suite = unittest.TestSuite([unitsuite])#, doctest.DocTestSuite()])
diff --git a/libbe/bzr.py b/libbe/bzr.py
index a0ae715..38af6bb 100644
--- a/libbe/bzr.py
+++ b/libbe/bzr.py
@@ -55,7 +55,7 @@ class Bzr(RCS):
pass
def _rcs_get_file_contents(self, path, revision=None):
if revision == None:
- return file(os.path.join(self.rootdir, path), "rb").read()
+ return RCS._rcs_get_file_contents(self, path, revision)
else:
status,output,error = \
self._u_invoke_client("cat","-r",revision,path)
diff --git a/libbe/cmdutil.py b/libbe/cmdutil.py
index 6d7ab01..6be7540 100644
--- a/libbe/cmdutil.py
+++ b/libbe/cmdutil.py
@@ -16,14 +16,15 @@
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
import optparse
import os
-import locale
from textwrap import TextWrapper
from StringIO import StringIO
+import sys
import doctest
import bugdir
import plugin
-import utility
+import encoding
+
class UserError(Exception):
def __init__(self, msg):
@@ -34,6 +35,18 @@ class UserErrorWrap(UserError):
UserError.__init__(self, str(exception))
self.exception = exception
+class UsageError(Exception):
+ pass
+
+class GetHelp(Exception):
+ pass
+
+class GetCompletions(Exception):
+ def __init__(self, completions=[]):
+ msg = "Get allowed completions"
+ Exception.__init__(self, msg)
+ self.completions = completions
+
def iter_commands():
for name, module in plugin.iter_plugins("becommands"):
yield name.replace("_", "-"), module
@@ -52,9 +65,11 @@ def get_command(command_name):
raise UserError("Unknown command %s" % command_name)
return cmd
+
def execute(cmd, args):
- encoding = locale.getpreferredencoding() or 'ascii'
- return get_command(cmd).execute([a.decode(encoding) for a in args])
+ enc = encoding.get_encoding()
+ get_command(cmd).execute([a.decode(enc) for a in args])
+ return 0
def help(cmd=None):
if cmd != None:
@@ -71,24 +86,30 @@ def help(cmd=None):
ret.append("be %s%*s %s" % (name, numExtraSpaces, "", desc))
return "\n".join(ret)
-class GetHelp(Exception):
- pass
-
-
-class UsageError(Exception):
- pass
-
+def completions(cmd):
+ parser = get_command(cmd).get_parser()
+ longopts = []
+ for opt in parser.option_list:
+ longopts.append(opt.get_opt_string())
+ return longopts
def raise_get_help(option, opt, value, parser):
raise GetHelp
-
+def raise_get_completions(option, opt, value, parser):
+ print "got completion arg"
+ raise GetCompletions(completions(sys.argv[1]))
+
class CmdOptionParser(optparse.OptionParser):
def __init__(self, usage):
optparse.OptionParser.__init__(self, usage)
+ self.disable_interspersed_args()
self.remove_option("-h")
self.add_option("-h", "--help", action="callback",
callback=raise_get_help, help="Print a help message")
+ self.add_option("--complete", action="callback",
+ callback=raise_get_completions,
+ help="Print a list of available completions")
def error(self, message):
raise UsageError(message)
@@ -102,6 +123,45 @@ class CmdOptionParser(optparse.OptionParser):
self.print_help(f)
return f.getvalue()
+def option_value_pairs(options, parser):
+ """
+ Iterate through OptionParser (option, value) pairs.
+ """
+ for option in [o.dest for o in parser.option_list if o.dest != None]:
+ value = getattr(options, option)
+ yield (option, value)
+
+def default_complete(options, args, parser, bugid_args={}):
+ """
+ A dud complete implementation for becommands to that the
+ --complete argument doesn't cause any problems. Use this
+ until you've set up a command-specific complete function.
+
+ bugid_args is an optional dict where the keys are positional
+ arguments taking bug shortnames and the values are functions for
+ filtering, since that's a common enough operation.
+ e.g. for "be open [options] BUGID"
+ bugid_args = {0: lambda bug : bug.active == False}
+ """
+ for option,value in option_value_pairs(options, parser):
+ if value == "--complete":
+ raise cmdutil.GetCompletions()
+ for pos,value in enumerate(args):
+ if value == "--complete":
+ if pos in bugid_args:
+ filter = bugid_args[pos]
+ bugshortnames = []
+ try:
+ bd = bugdir.BugDir(from_disk=True,
+ manipulate_encodings=False)
+ bd.load_all_bugs()
+ bugs = [bug for bug in bd if filter(bug) == True]
+ bugshortnames = [bd.bug_shortname(bug) for bug in bugs]
+ except bugdir.NoBugDir:
+ pass
+ raise GetCompletions(bugshortnames)
+ raise GetCompletions()
+
def underlined(instring):
"""Produces a version of a string that is underlined with '='
diff --git a/libbe/comment.py b/libbe/comment.py
index 87c1de0..e5c86c7 100644
--- a/libbe/comment.py
+++ b/libbe/comment.py
@@ -23,10 +23,23 @@ import textwrap
import doctest
from beuuid import uuid_gen
+from properties import Property, doc_property, local_property, \
+ defaulting_property, checked_property, cached_property, \
+ primed_property, change_hook_property, settings_property
+import settings_object
import mapfile
from tree import Tree
import utility
+
+class InvalidShortname(KeyError):
+ def __init__(self, shortname, shortnames):
+ msg = "Invalid shortname %s\n%s" % (shortname, shortnames)
+ KeyError.__init__(self, msg)
+ self.shortname = shortname
+ self.shortnames = shortnames
+
+
INVALID_UUID = "!!~~\n INVALID-UUID \n~~!!"
def _list_to_root(comments, bug):
@@ -45,7 +58,8 @@ def _list_to_root(comments, bug):
assert comment.uuid != None
uuid_map[comment.uuid] = comment
for comm in comments:
- if comm.in_reply_to == None:
+ rep = comm.in_reply_to
+ if rep == None or rep == settings_object.EMPTY or rep == bug.uuid:
root_comments.append(comm)
else:
parentUUID = comm.in_reply_to
@@ -55,7 +69,11 @@ def _list_to_root(comments, bug):
dummy_root.extend(root_comments)
return dummy_root
-def loadComments(bug):
+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.
+ """
path = bug.get_path("comments")
if not os.path.isdir(path):
return Comment(bug, uuid=INVALID_UUID)
@@ -64,6 +82,9 @@ def loadComments(bug):
if uuid.startswith('.'):
continue
comm = Comment(bug, uuid, from_disk=True)
+ if load_full == True:
+ comm.load_settings()
+ dummy = comm.body # force the body to load
comments.append(comm)
return _list_to_root(comments, bug)
@@ -73,12 +94,89 @@ def saveComments(bug):
for comment in bug.comment_root.traverse():
comment.save()
-class Comment(Tree):
+
+class Comment(Tree, settings_object.SavedSettingsObject):
+ """
+ >>> c = Comment()
+ >>> c.uuid != None
+ True
+ >>> c.uuid = "some-UUID"
+ >>> print c.content_type
+ text/plain
+ """
+
+ settings_properties = []
+ required_saved_properties = []
+ _prop_save_settings = settings_object.prop_save_settings
+ _prop_load_settings = settings_object.prop_load_settings
+ def _versioned_property(settings_properties=settings_properties,
+ required_saved_properties=required_saved_properties,
+ **kwargs):
+ if "settings_properties" not in kwargs:
+ kwargs["settings_properties"] = settings_properties
+ if "required_saved_properties" not in kwargs:
+ kwargs["required_saved_properties"]=required_saved_properties
+ return settings_object.versioned_property(**kwargs)
+
+ @_versioned_property(name="From",
+ doc="The author of the comment")
+ def From(): return {}
+
+ @_versioned_property(name="In-reply-to",
+ doc="UUID for parent comment or bug")
+ def in_reply_to(): return {}
+
+ @_versioned_property(name="Content-type",
+ doc="Mime type for comment body",
+ default="text/plain",
+ require_save=True)
+ def content_type(): return {}
+
+ @_versioned_property(name="Date",
+ doc="An RFC 2822 timestamp for comment creation")
+ def time_string(): return {}
+
+ def _get_time(self):
+ if self.time_string == None:
+ return None
+ return utility.str_to_time(self.time_string)
+ def _set_time(self, value):
+ self.time_string = utility.time_to_str(value)
+ time = property(fget=_get_time,
+ fset=_set_time,
+ doc="An integer version of .time_string")
+
+ def _get_comment_body(self):
+ if self.rcs != None and self.sync_with_disk == True:
+ import rcs
+ return self.rcs.get_file_contents(self.get_path("body"))
+ def _set_comment_body(self, value, force=False):
+ if (self.rcs != None and self.sync_with_disk == True) or force==True:
+ assert value != None, "Can't save empty comment"
+ self.rcs.set_file_contents(self.get_path("body"), value)
+
+ @Property
+ @change_hook_property(hook=_set_comment_body)
+ @cached_property(generator=_get_comment_body)
+ @local_property("body")
+ @doc_property(doc="The meat of the comment")
+ def body(): return {}
+
+ def _get_rcs(self):
+ if hasattr(self.bug, "rcs"):
+ return self.bug.rcs
+
+ @Property
+ @cached_property(generator=_get_rcs)
+ @local_property("rcs")
+ @doc_property(doc="A revision control system instance.")
+ def rcs(): return {}
+
def __init__(self, bug=None, uuid=None, from_disk=False,
in_reply_to=None, body=None):
"""
- Set from_disk=True to load an old bug.
- Set from_disk=False to create a new bug.
+ Set from_disk=True to load an old comment.
+ Set from_disk=False to create a new comment.
The uuid option is required when from_disk==True.
@@ -89,26 +187,19 @@ class Comment(Tree):
in_reply_to should be the uuid string of the parent comment.
"""
Tree.__init__(self)
+ settings_object.SavedSettingsObject.__init__(self)
self.bug = bug
- if bug != None:
- self.rcs = bug.rcs
- else:
- self.rcs = None
+ self.uuid = uuid
if from_disk == True:
- self.uuid = uuid
- self.load()
+ self.sync_with_disk = True
else:
- if uuid != None:
- self.uuid = uuid
- else:
+ self.sync_with_disk = False
+ if uuid == None:
self.uuid = uuid_gen()
- self.time = time.time()
+ self.time = int(time.time()) # only save to second precision
if self.rcs != None:
self.From = self.rcs.get_user_id()
- else:
- self.From = None
self.in_reply_to = in_reply_to
- self.content_type = "text/plain"
self.body = body
def traverse(self, *args, **kwargs):
@@ -118,41 +209,54 @@ class Comment(Tree):
continue
yield comment
- def _clean_string(self, value):
- """
- >>> comm = Comment()
- >>> comm._clean_string(None)
- ''
- >>> comm._clean_string("abc")
- 'abc'
- """
- if value == None:
+ def _setting_attr_string(self, setting):
+ value = getattr(self, setting)
+ if value == settings_object.EMPTY:
return ""
- return value
+ else:
+ 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"
+ >>> print comm.xml(indent=2, shortname="com-1")
+ <comment>
+ <name>com-1</name>
+ <uuid>0123</uuid>
+ <from></from>
+ <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
+ <body>Some
+ insightful
+ remarks</body>
+ </comment>
+ """
if shortname == None:
shortname = self.uuid
- ret = """<comment>
- <name>%s</name>
- <from>%s</from>
- <date>%s</date>
- <body>%s</body>
-</comment>\n""" % (shortname,
- self._clean_string(self.From),
- utility.time_to_str(self.time),
- self.body.rstrip('\n'))
- return ret
+ lines = ["<comment>",
+ " <name>%s</name>" % (shortname,),
+ " <uuid>%s</uuid>" % self.uuid,]
+ if self.in_reply_to != None:
+ lines.append(" <in_reply_to>%s</in_reply_to>" % self.in_reply_to)
+ lines.extend([
+ " <from>%s</from>" % self._setting_attr_string("From"),
+ " <date>%s</date>" % self.time_string,
+ " <body>%s</body>" % (self.body or "").rstrip('\n'),
+ "</comment>\n"])
+ istring = ' '*indent
+ sep = '\n' + istring
+ return istring + sep.join(lines).rstrip('\n')
def string(self, indent=0, shortname=None):
"""
>>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
- >>> comm.time = utility.str_to_time("Thu, 20 Nov 2008 15:55:11 +0000")
+ >>> comm.time_string = "Thu, 01 Jan 1970 00:00:00 +0000"
>>> print comm.string(indent=2, shortname="com-1")
--------- Comment ---------
Name: com-1
From:
- Date: Thu, 20 Nov 2008 15:55:11 +0000
+ Date: Thu, 01 Jan 1970 00:00:00 +0000
<BLANKLINE>
Some
insightful
@@ -163,12 +267,12 @@ class Comment(Tree):
lines = []
lines.append("--------- Comment ---------")
lines.append("Name: %s" % shortname)
- lines.append("From: %s" % self._clean_string(self.From))
- lines.append("Date: %s" % utility.time_to_str(self.time))
+ lines.append("From: %s" % (self._setting_attr_string("From")))
+ lines.append("Date: %s" % self.time_string)
lines.append("")
- #lines.append(textwrap.fill(self._clean_string(self.body),
+ #lines.append(textwrap.fill(self.body or "",
# width=(79-indent)))
- lines.extend(self._clean_string(self.body).splitlines())
+ lines.extend((self.body or "").splitlines())
# some comments shouldn't be wrapped...
istring = ' '*indent
@@ -179,7 +283,7 @@ class Comment(Tree):
"""
>>> comm = Comment(bug=None, body="Some insightful remarks")
>>> comm.uuid = "com-1"
- >>> comm.time = utility.str_to_time("Thu, 20 Nov 2008 15:55:11 +0000")
+ >>> comm.time_string = "Thu, 20 Nov 2008 15:55:11 +0000"
>>> comm.From = "Jane Doe <jdoe@example.com>"
>>> print comm
--------- Comment ---------
@@ -198,64 +302,68 @@ class Comment(Tree):
assert name in ["values", "body"]
return os.path.join(my_dir, name)
- def load(self):
- map = mapfile.map_load(self.rcs, self.get_path("values"))
- self.time = utility.str_to_time(map["Date"])
- self.From = map["From"]
- self.in_reply_to = map.get("In-reply-to")
- self.content_type = map.get("Content-type", "text/plain")
- self.body = self.rcs.get_file_contents(self.get_path("body"))
+ def load_settings(self):
+ self.settings = mapfile.map_load(self.rcs, self.get_path("values"))
+ self._setup_saved_settings()
- def save(self):
- assert self.rcs != None
- map_file = {"Date": utility.time_to_str(self.time)}
- self._add_headers(map_file, ("From", "in_reply_to", "content_type"))
+ def save_settings(self):
+ parent_dir = os.path.dirname(self.get_path())
+ self.rcs.mkdir(parent_dir)
self.rcs.mkdir(self.get_path())
- mapfile.map_save(self.rcs, self.get_path("values"), map_file)
- self.rcs.set_file_contents(self.get_path("body"), self.body)
-
- def _add_headers(self, map, names):
- map_names = {}
- for name in names:
- map_names[name] = self._pyname_to_header(name)
- self._add_attrs(map, map_names)
-
- def _pyname_to_header(self, name):
- return name.capitalize().replace('_', '-')
-
- def _add_attrs(self, map, map_names):
- for name in map_names.keys():
- value = getattr(self, name)
- if value is not None:
- map[map_names[name]] = value
+ 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(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):
- if reply.time != None and self.time != None:
- assert reply.time >= self.time
+ 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, name_map={}, indent=0,
+ def string_thread(self, string_method_name="string", name_map={},
+ indent=0, flatten=True,
auto_name_map=False, bug_shortname=None):
"""
- Return a sting displaying a thread of comments.
+ Return a string displaying a thread of comments.
bug_shortname is only used if auto_name_map == True.
+
+ string_method_name (defaults to "string") is the name of the
+ Comment method used to generate the output string for each
+ Comment in the thread. The method must take the arguments
+ indent and shortname.
+
+ SIDE-EFFECT: if auto_name_map==True, calls comment_shortnames()
+ which will sort the tree by comment.time. Avoid by calling
+ name_map = {}
+ for shortname,comment in comm.comment_shortnames(bug_shortname):
+ name_map[comment.uuid] = shortname
+ comm.sort(key=lambda c : c.From) # your sort
+ comm.string_thread(name_map=name_map)
>>> a = Comment(bug=None, uuid="a", body="Insightful remarks")
>>> a.time = utility.str_to_time("Thu, 20 Nov 2008 01:00:00 +0000")
@@ -269,7 +377,7 @@ class Comment(Tree):
>>> d.uuid = "d"
>>> d.time = utility.str_to_time("Thu, 20 Nov 2008 04:00:00 +0000")
>>> a.sort(key=lambda comm : comm.time)
- >>> print a.string_thread()
+ >>> print a.string_thread(flatten=True)
--------- Comment ---------
Name: a
From:
@@ -325,33 +433,23 @@ class Comment(Tree):
for shortname,comment in self.comment_shortnames(bug_shortname):
name_map[comment.uuid] = shortname
stringlist = []
- for depth,comment in self.thread(flatten=True):
+ for depth,comment in self.thread(flatten=flatten):
ind = 2*depth+indent
if comment.uuid in name_map:
sname = name_map[comment.uuid]
else:
sname = None
- stringlist.append(comment.string(indent=ind, shortname=sname))
+ string_fn = getattr(comment, string_method_name)
+ stringlist.append(string_fn(indent=ind, shortname=sname))
return '\n'.join(stringlist)
def xml_thread(self, name_map={}, indent=0,
auto_name_map=False, bug_shortname=None):
- if auto_name_map == True:
- name_map = {}
- for shortname,comment in self.comment_shortnames(bug_shortname):
- name_map[comment.uuid] = shortname
- stringlist = []
- for depth,comment in self.thread(flatten=True):
- ind = 2*depth+indent
- if comment.uuid in name_map:
- sname = name_map[comment.uuid]
- else:
- sname = None
- stringlist.append(comment.xml(indent=ind, shortname=sname))
- return '\n'.join(stringlist)
-
+ return self.string_thread(string_method_name="xml", name_map=name_map,
+ indent=indent, auto_name_map=auto_name_map,
+ bug_shortname=bug_shortname)
- def comment_shortnames(self, bug_shortname=""):
+ def comment_shortnames(self, bug_shortname=None):
"""
Iterate through (id, comment) pairs, in time order.
(This is a user-friendly id, not the comment uuid).
@@ -372,6 +470,8 @@ class Comment(Tree):
bug-1:3 c
bug-1:4 d
"""
+ if bug_shortname == None:
+ bug_shortname = ""
self.sort(key=lambda comm : comm.time)
for num,comment in enumerate(self.traverse()):
yield ("%s:%d" % (bug_shortname, num+1), comment)
@@ -393,7 +493,8 @@ class Comment(Tree):
for cur_name, comment in self.comment_shortnames(*args, **kwargs):
if comment_shortname == cur_name:
return comment
- raise KeyError(comment_shortname)
+ raise InvalidShortname(comment_shortname,
+ list(self.comment_shortnames(*args, **kwargs)))
def comment_from_uuid(self, uuid):
"""
diff --git a/libbe/config.py b/libbe/config.py
index 79c0d6f..94c700e 100644
--- a/libbe/config.py
+++ b/libbe/config.py
@@ -15,30 +15,40 @@
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
import ConfigParser
+import codecs
+import locale
import os.path
+import sys
import doctest
+default_encoding = sys.getfilesystemencoding() or locale.getpreferredencoding()
+
def path():
"""Return the path to the per-user config file"""
return os.path.expanduser("~/.bugs_everywhere")
-def set_val(name, value, section="DEFAULT"):
+def set_val(name, value, section="DEFAULT", encoding=None):
"""Set a value in the per-user config file
:param name: The name of the value to set
:param value: The new value to set (or None to delete the value)
:param section: The section to store the name/value in
"""
+ if encoding == None:
+ encoding = default_encoding
config = ConfigParser.ConfigParser()
- config.read(path())
+ f = codecs.open(path(), "r", encoding)
+ config.readfp(f, path())
+ f.close()
if value is not None:
config.set(section, name, value)
else:
config.remove_option(section, name)
- config.write(file(path(), "wb"))
- pass
+ f = codecs.open(path(), "w", encoding)
+ config.write(f)
+ f.close()
-def get_val(name, section="DEFAULT"):
+def get_val(name, section="DEFAULT", encoding=None):
"""
Get a value from the per-user config file
@@ -49,13 +59,17 @@ def get_val(name, section="DEFAULT"):
True
>>> set_val("junk", "random")
>>> get_val("junk")
- 'random'
+ u'random'
>>> set_val("junk", None)
>>> get_val("junk") is None
True
"""
+ if encoding == None:
+ encoding = default_encoding
config = ConfigParser.ConfigParser()
- config.read(path())
+ f = codecs.open(path(), "r", encoding)
+ config.readfp(f, path())
+ f.close()
try:
return config.get(section, name)
except ConfigParser.NoOptionError:
diff --git a/libbe/diff.py b/libbe/diff.py
index 86a91ca..17d6c50 100644
--- a/libbe/diff.py
+++ b/libbe/diff.py
@@ -15,9 +15,8 @@
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
"""Compare two bug trees"""
-from libbe import cmdutil, bugdir
+from libbe import cmdutil, bugdir, bug
from libbe.utility import time_to_str
-from libbe.bug import cmp_severity
import doctest
def diff(old_bugdir, new_bugdir):
@@ -41,17 +40,18 @@ def diff(old_bugdir, new_bugdir):
def diff_report(diff_data, bug_dir):
(removed, modified, added) = diff_data
def modified_cmp(left, right):
- return cmp_severity(left[1], right[1])
+ return bug.cmp_severity(left[1], right[1])
- added.sort(cmp_severity)
- removed.sort(cmp_severity)
+ added.sort(bug.cmp_severity)
+ removed.sort(bug.cmp_severity)
modified.sort(modified_cmp)
-
+ lines = []
+
if len(added) > 0:
- print "New bug reports:"
- for bug in added:
- print bug.string(shortlist=True)
- print ""
+ lines.append("New bug reports:")
+ for bg in added:
+ lines.extend(bg.string(shortlist=True).splitlines())
+ lines.append("")
if len(modified) > 0:
printed = False
@@ -61,15 +61,18 @@ def diff_report(diff_data, bug_dir):
continue
if not printed:
printed = True
- print "Modified bug reports:"
- print change_str
- print ""
+ lines.append("Modified bug reports:")
+ lines.extend(change_str.splitlines())
+ if printed == True:
+ lines.append("")
- if len(removed) > 0:
- print "Removed bug reports:"
- for bug in removed:
- print bug.string(shortlist=True)
- print ""
+ 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 = []
diff --git a/libbe/editor.py b/libbe/editor.py
new file mode 100644
index 0000000..4a63e5c
--- /dev/null
+++ b/libbe/editor.py
@@ -0,0 +1,103 @@
+# Bugs Everywhere, a distributed bugtracker
+# Copyright (C) 2005 Aaron Bentley and Panometrics, Inc.
+# <abentley@panoramicfeedback.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
+# MA 02110-1301, USA
+
+import codecs
+import locale
+import os
+import sys
+import tempfile
+import doctest
+
+default_encoding = sys.getfilesystemencoding() or locale.getpreferredencoding()
+
+comment_marker = u"== Anything below this line will be ignored\n"
+
+class CantFindEditor(Exception):
+ def __init__(self):
+ Exception.__init__(self, "Can't find editor to get string from")
+
+def editor_string(comment=None, encoding=None):
+ """Invokes the editor, and returns the user_produced text as a string
+
+ >>> if "EDITOR" in os.environ:
+ ... del os.environ["EDITOR"]
+ >>> if "VISUAL" in os.environ:
+ ... del os.environ["VISUAL"]
+ >>> editor_string()
+ Traceback (most recent call last):
+ CantFindEditor: Can't find editor to get string from
+ >>> os.environ["EDITOR"] = "echo bar > "
+ >>> editor_string()
+ u'bar\\n'
+ >>> os.environ["VISUAL"] = "echo baz > "
+ >>> editor_string()
+ u'baz\\n'
+ >>> del os.environ["EDITOR"]
+ >>> del os.environ["VISUAL"]
+ """
+ if encoding == None:
+ encoding = default_encoding
+ for name in ('VISUAL', 'EDITOR'):
+ try:
+ editor = os.environ[name]
+ break
+ except KeyError:
+ pass
+ else:
+ raise CantFindEditor()
+ fhandle, fname = tempfile.mkstemp()
+ try:
+ if comment is not None:
+ os.write(fhandle, '\n'+comment_string(comment))
+ os.close(fhandle)
+ oldmtime = os.path.getmtime(fname)
+ os.system("%s %s" % (editor, fname))
+ f = codecs.open(fname, "r", encoding)
+ output = trimmed_string(f.read())
+ f.close()
+ if output.rstrip('\n') == "":
+ output = None
+ finally:
+ os.unlink(fname)
+ return output
+
+
+def comment_string(comment):
+ """
+ >>> comment_string('hello') == comment_marker+"hello"
+ True
+ """
+ return comment_marker + comment
+
+
+def trimmed_string(instring):
+ """
+ >>> trimmed_string("hello\\n"+comment_marker)
+ u'hello\\n'
+ >>> trimmed_string("hi!\\n" + comment_string('Booga'))
+ u'hi!\\n'
+ """
+ out = []
+ for line in instring.splitlines(True):
+ if line.startswith(comment_marker):
+ break
+ out.append(line)
+ return ''.join(out)
+
+suite = doctest.DocTestSuite()
diff --git a/libbe/encoding.py b/libbe/encoding.py
new file mode 100644
index 0000000..7f924eb
--- /dev/null
+++ b/libbe/encoding.py
@@ -0,0 +1,53 @@
+# Bugs Everywhere, a distributed bugtracker
+# Copyright (C) 2005 Aaron Bentley and Panometrics, Inc.
+# <abentley@panoramicfeedback.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
+# MA 02110-1301, USA
+import codecs
+import locale
+import sys
+import doctest
+
+def get_encoding():
+ """
+ Guess a useful input/output/filesystem encoding... Maybe we need
+ seperate encodings for input/output and filesystem? Hmm...
+ """
+ encoding = locale.getpreferredencoding() or sys.getdefaultencoding()
+ if sys.platform != 'win32' or sys.version_info[:2] > (2, 3):
+ encoding = locale.getlocale(locale.LC_TIME)[1] or encoding
+ # Python 2.3 on windows doesn't know about 'XYZ' alias for 'cpXYZ'
+ return encoding
+
+def known_encoding(encoding):
+ """
+ >>> known_encoding("highly-unlikely-encoding")
+ False
+ >>> known_encoding(get_encoding())
+ True
+ """
+ try:
+ codecs.lookup(encoding)
+ return True
+ except LookupError:
+ return False
+
+def set_IO_stream_encodings(encoding):
+ sys.stdin = codecs.getreader(encoding)(sys.__stdin__)
+ sys.stdout = codecs.getwriter(encoding)(sys.__stdout__)
+ sys.stderr = codecs.getwriter(encoding)(sys.__stderr__)
+
+suite = doctest.DocTestSuite()
diff --git a/libbe/git.py b/libbe/git.py
index 046e72e..e57014f 100644
--- a/libbe/git.py
+++ b/libbe/git.py
@@ -69,7 +69,7 @@ class Git(RCS):
self._rcs_add(path)
def _rcs_get_file_contents(self, path, revision=None):
if revision == None:
- return file(self._u_abspath(path), "rb").read()
+ return RCS._rcs_get_file_contents(self, path, revision)
else:
arg = "%s:%s" % (revision,path)
status,output,error = self._u_invoke_client("show", arg)
@@ -85,7 +85,7 @@ class Git(RCS):
status,output,error = self._u_invoke_client('commit', '-a',
'-F', commitfile)
revision = None
- revline = re.compile("Created (.*)commit (.*):(.*)")
+ revline = re.compile("(.*) (.*)[:\]] (.*)")
match = revline.search(output)
assert match != None, output+error
assert len(match.groups()) == 3
diff --git a/libbe/hg.py b/libbe/hg.py
index 27cbb79..c00d7e2 100644
--- a/libbe/hg.py
+++ b/libbe/hg.py
@@ -58,14 +58,14 @@ class Hg(RCS):
pass
def _rcs_get_file_contents(self, path, revision=None):
if revision == None:
- return file(os.path.join(self.rootdir, path), "rb").read()
+ return RCS._rcs_get_file_contents(self, path, revision)
else:
status,output,error = \
self._u_invoke_client("cat","-r",revision,path)
return output
def _rcs_duplicate_repo(self, directory, revision=None):
if revision == None:
- RCS._rcs_duplicate_repo(self, directory, revision)
+ return RCS._rcs_duplicate_repo(self, directory, revision)
else:
self._u_invoke_client("archive", "--rev", revision, directory)
def _rcs_commit(self, commitfile):
diff --git a/libbe/mapfile.py b/libbe/mapfile.py
index 559d713..c36d454 100644
--- a/libbe/mapfile.py
+++ b/libbe/mapfile.py
@@ -14,6 +14,7 @@
# 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
+import yaml
import os.path
import errno
import utility
@@ -29,17 +30,20 @@ class IllegalValue(Exception):
Exception.__init__(self, 'Illegal value "%s"' % value)
self.value = value
-def generate(map, context=3):
- """Generate a format-2 mapfile content string. This is a simpler
- format, but should merge better, because there's no chance of
- confusion for appends, and lines are unique for both key and
- value.
-
+def generate(map):
+ """Generate a YAML mapfile content string.
>>> generate({"q":"p"})
- '\\n\\n\\nq=p\\n\\n\\n\\n'
+ 'q: p\\n\\n'
+ >>> generate({"q":u"Fran\u00e7ais"})
+ 'q: Fran\\xc3\\xa7ais\\n\\n'
+ >>> generate({"q":u"hello"})
+ 'q: hello\\n\\n'
>>> generate({"q=":"p"})
Traceback (most recent call last):
IllegalKey: Illegal key "q="
+ >>> generate({"q:":"p"})
+ Traceback (most recent call last):
+ IllegalKey: Illegal key "q:"
>>> generate({"q\\n":"p"})
Traceback (most recent call last):
IllegalKey: Illegal key "q\\n"
@@ -53,7 +57,6 @@ def generate(map, context=3):
Traceback (most recent call last):
IllegalValue: Illegal value "p\\n"
"""
- assert(context > 0)
keys = map.keys()
keys.sort()
for key in keys:
@@ -61,6 +64,7 @@ def generate(map, context=3):
assert not key.startswith('>')
assert('\n' not in key)
assert('=' not in key)
+ assert(':' not in key)
assert(len(key) > 0)
except AssertionError:
raise IllegalKey(key.encode('string_escape'))
@@ -69,20 +73,19 @@ def generate(map, context=3):
lines = []
for key in keys:
- for i in range(context):
- lines.append("")
- lines.append("%s=%s" % (key, map[key]))
- for i in range(context):
- lines.append("")
- return '\n'.join(lines) + '\n'
+ lines.append(yaml.safe_dump({key: map[key]},
+ default_flow_style=False,
+ allow_unicode=True))
+ lines.append("")
+ return '\n'.join(lines)
def parse(contents):
"""
- Parse a format-2 mapfile string.
- >>> parse('\\n\\n\\nq=p\\n\\n\\n\\n')['q']
+ Parse a YAML mapfile string.
+ >>> parse('q: p\\n\\n')['q']
+ 'p'
+ >>> parse('q: \\'p\\'\\n\\n')['q']
'p'
- >>> parse('\\n\\nq=\\'p\\'\\n\\n\\n\\n')['q']
- "\'p\'"
>>> contents = generate({"a":"b", "c":"d", "e":"f"})
>>> dict = parse(contents)
>>> dict["a"]
@@ -92,15 +95,25 @@ def parse(contents):
>>> dict["e"]
'f'
"""
- result = {}
+ old_format = False
for line in contents.splitlines():
- line = line.rstrip('\n')
- if len(line) == 0:
- continue
- name,value = [field for field in line.split('=', 1)]
- assert not result.has_key(name)
- result[name] = value
- return result
+ if len(line.split("=")) == 2:
+ old_format = True
+ break
+ if old_format: # translate to YAML. Hack to deal with old BE bugs.
+ newlines = []
+ for line in contents.splitlines():
+ line = line.rstrip('\n')
+ if len(line) == 0:
+ continue
+ fields = line.split("=")
+ if len(fields) == 2:
+ key,value = fields
+ newlines.append('%s: "%s"' % (key, value.replace('"','\\"')))
+ else:
+ newlines.append(line)
+ contents = '\n'.join(newlines)
+ return yaml.load(contents)
def map_save(rcs, path, map, allow_no_rcs=False):
"""Save the map as a mapfile to the specified path"""
diff --git a/libbe/properties.py b/libbe/properties.py
new file mode 100644
index 0000000..a8e89fb
--- /dev/null
+++ b/libbe/properties.py
@@ -0,0 +1,479 @@
+# Bugs Everywhere - a distributed bugtracker
+# Copyright (C) 2008 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 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/>.
+
+"""
+This module provides a series of useful decorators for defining
+various types of properties. For example usage, consider the
+unittests at the end of the module.
+
+See
+ http://www.python.org/dev/peps/pep-0318/
+and
+ http://www.phyast.pitt.edu/~micheles/python/documentation.html
+for more information on decorators.
+"""
+
+import unittest
+
+
+class ValueCheckError (ValueError):
+ def __init__(self, name, value, allowed):
+ msg = "%s not in %s for %s" % (value, allowed, name)
+ ValueError.__init__(self, msg)
+ self.name = name
+ self.value = value
+ self.allowed = allowed
+
+def Property(funcs):
+ """
+ End a chain of property decorators, returning a property.
+ """
+ args = {}
+ args["fget"] = funcs.get("fget", None)
+ args["fset"] = funcs.get("fset", None)
+ args["fdel"] = funcs.get("fdel", None)
+ args["doc"] = funcs.get("doc", None)
+
+ #print "Creating a property with"
+ #for key, val in args.items(): print key, value
+ return property(**args)
+
+def doc_property(doc=None):
+ """
+ Add a docstring to a chain of property decorators.
+ """
+ def decorator(funcs=None):
+ """
+ Takes either a dict of funcs {"fget":fnX, "fset":fnY, ...}
+ or a function fn() returning such a dict.
+ """
+ if hasattr(funcs, "__call__"):
+ funcs = funcs() # convert from function-arg to dict
+ funcs["doc"] = doc
+ return funcs
+ return decorator
+
+def local_property(name, null=None):
+ """
+ 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.
+ """
+ def decorator(funcs):
+ if hasattr(funcs, "__call__"):
+ funcs = funcs()
+ fget = funcs.get("fget", None)
+ fset = funcs.get("fset", None)
+ def _fget(self):
+ if fget is not None:
+ fget(self)
+ value = getattr(self, "_%s_value" % name, null)
+ return value
+ def _fset(self, value):
+ setattr(self, "_%s_value" % name, value)
+ if fset is not None:
+ fset(self, value)
+ funcs["fget"] = _fget
+ funcs["fset"] = _fset
+ funcs["name"] = name
+ return funcs
+ return decorator
+
+def settings_property(name, null=None):
+ """
+ Similar to local_property, except where local_property stores the
+ value in instance._<name>_value, settings_property stores the
+ value in instance.settings[name].
+ """
+ def decorator(funcs):
+ if hasattr(funcs, "__call__"):
+ funcs = funcs()
+ fget = funcs.get("fget", None)
+ fset = funcs.get("fset", None)
+ def _fget(self):
+ if fget is not None:
+ fget(self)
+ value = self.settings.get(name, null)
+ return value
+ def _fset(self, value):
+ self.settings[name] = value
+ if fset is not None:
+ fset(self, value)
+ funcs["fget"] = _fget
+ funcs["fset"] = _fset
+ funcs["name"] = name
+ return funcs
+ return decorator
+
+def defaulting_property(default=None, null=None):
+ """
+ Define a default value for get access to a property.
+ If the stored value is null, then default is returned.
+ """
+ def decorator(funcs):
+ if hasattr(funcs, "__call__"):
+ funcs = funcs()
+ fget = funcs.get("fget")
+ def _fget(self):
+ value = fget(self)
+ if value == null:
+ return default
+ return value
+ funcs["fget"] = _fget
+ return funcs
+ return decorator
+
+def fn_checked_property(value_allowed_fn):
+ """
+ Define allowed values for get/set access to a property.
+ """
+ def decorator(funcs):
+ if hasattr(funcs, "__call__"):
+ funcs = funcs()
+ fget = funcs.get("fget")
+ fset = funcs.get("fset")
+ name = funcs.get("name", "<unknown>")
+ def _fget(self):
+ value = fget(self)
+ if value_allowed_fn(value) != True:
+ raise ValueCheckError(name, value, value_allowed_fn)
+ return value
+ def _fset(self, value):
+ if value_allowed_fn(value) != True:
+ raise ValueCheckError(name, value, value_allowed_fn)
+ fset(self, value)
+ funcs["fget"] = _fget
+ funcs["fset"] = _fset
+ return funcs
+ return decorator
+
+def checked_property(allowed=[]):
+ """
+ Define allowed values for get/set access to a property.
+ """
+ def decorator(funcs):
+ if hasattr(funcs, "__call__"):
+ funcs = funcs()
+ fget = funcs.get("fget")
+ fset = funcs.get("fset")
+ name = funcs.get("name", "<unknown>")
+ def _fget(self):
+ value = fget(self)
+ if value not in allowed:
+ raise ValueCheckError(name, value, allowed)
+ return value
+ def _fset(self, value):
+ if value not in allowed:
+ raise ValueCheckError(name, value, allowed)
+ fset(self, value)
+ funcs["fget"] = _fget
+ funcs["fset"] = _fset
+ return funcs
+ return decorator
+
+def cached_property(generator, initVal=None):
+ """
+ Allow caching of values generated by generator(instance), where
+ instance is the instance to which this property belongs. Uses
+ ._<name>_cache to store a cache flag for a particular owner
+ instance.
+
+ When the cache flag is True or missing and the stored value is
+ initVal, the first fget call triggers the generator function,
+ whiose output is stored in _<name>_cached_value. That and
+ subsequent calls to fget will return this cached value.
+
+ If the input value is no longer initVal (e.g. a value has been
+ loaded from disk or set with fset), that value overrides any
+ cached value, and this property has no effect.
+
+ When the cache flag is False and the stored value is initVal, the
+ generator is not cached, but is called on every fget.
+
+ The cache flag is missing on initialization. Particular instances
+ may override by setting their own flag.
+ """
+ def decorator(funcs):
+ if hasattr(funcs, "__call__"):
+ funcs = funcs()
+ fget = funcs.get("fget")
+ fset = funcs.get("fset")
+ name = funcs.get("name", "<unknown>")
+ def _fget(self):
+ cache = getattr(self, "_%s_cache" % name, True)
+ value = fget(self)
+ if cache == True:
+ if value == initVal:
+ if hasattr(self, "_%s_cached_value" % name):
+ value = getattr(self, "_%s_cached_value" % name)
+ else:
+ value = generator(self)
+ setattr(self, "_%s_cached_value" % name, value)
+ else:
+ if value == initVal:
+ value = generator(self)
+ return value
+ funcs["fget"] = _fget
+ return funcs
+ return decorator
+
+def primed_property(primer, initVal=None):
+ """
+ Just like a generator_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.
+
+ The 'cache' flag becomes a 'prime' flag, with priming taking place
+ whenever ._<name>_prime is True, or is False or missing and
+ value == initVal.
+ """
+ def decorator(funcs):
+ if hasattr(funcs, "__call__"):
+ funcs = funcs()
+ fget = funcs.get("fget")
+ name = funcs.get("name", "<unknown>")
+ def _fget(self):
+ prime = getattr(self, "_%s_prime" % name, False)
+ if prime == False:
+ value = fget(self)
+ if prime == True or (prime == False and value == initVal):
+ primer(self)
+ value = fget(self)
+ return value
+ funcs["fget"] = _fget
+ return funcs
+ return decorator
+
+def change_hook_property(hook):
+ """
+ Call the function hook(instance, old_value, new_value) whenever a
+ value different from the current value is set (instance is a a
+ reference to the class instance to which this property belongs).
+ This is useful for saving changes to disk, etc.
+ """
+ def decorator(funcs):
+ if hasattr(funcs, "__call__"):
+ funcs = funcs()
+ fget = funcs.get("fget")
+ fset = funcs.get("fset")
+ name = funcs.get("name", "<unknown>")
+ def _fset(self, value):
+ old_value = fget(self)
+ if value != old_value:
+ hook(self, old_value, value)
+ fset(self, value)
+ funcs["fset"] = _fset
+ return funcs
+ return decorator
+
+
+class DecoratorTests(unittest.TestCase):
+ def testLocalDoc(self):
+ class Test(object):
+ @Property
+ @doc_property("A fancy property")
+ def x():
+ return {}
+ self.failUnless(Test.x.__doc__ == "A fancy property",
+ Test.x.__doc__)
+ def testLocalProperty(self):
+ class Test(object):
+ @Property
+ @local_property(name="LOCAL")
+ def x():
+ return {}
+ t = Test()
+ self.failUnless(t.x == None, str(t.x))
+ t.x = 'z' # the first set initializes ._LOCAL_value
+ self.failUnless(t.x == 'z', str(t.x))
+ self.failUnless("_LOCAL_value" in dir(t), dir(t))
+ self.failUnless(t._LOCAL_value == 'z', t._LOCAL_value)
+ def testSettingsProperty(self):
+ class Test(object):
+ @Property
+ @settings_property(name="attr")
+ def x():
+ return {}
+ def __init__(self):
+ self.settings = {}
+ t = Test()
+ self.failUnless(t.x == None, str(t.x))
+ t.x = 'z' # the first set initializes ._LOCAL_value
+ self.failUnless(t.x == 'z', str(t.x))
+ self.failUnless("attr" in t.settings, t.settings)
+ self.failUnless(t.settings["attr"] == 'z', t.settings["attr"])
+ def testDefaultingLocalProperty(self):
+ class Test(object):
+ @Property
+ @defaulting_property(default='y', null='x')
+ @local_property(name="DEFAULT")
+ def x(): return {}
+ t = Test()
+ self.failUnless(t.x == None, str(t.x))
+ t.x = 'x'
+ self.failUnless(t.x == 'y', str(t.x))
+ t.x = 'y'
+ self.failUnless(t.x == 'y', str(t.x))
+ t.x = 'z'
+ self.failUnless(t.x == 'z', str(t.x))
+ def testCheckedLocalProperty(self):
+ class Test(object):
+ @Property
+ @checked_property(allowed=['x', 'y', 'z'])
+ @local_property(name="CHECKED")
+ def x(): return {}
+ def __init__(self):
+ self._CHECKED_value = 'x'
+ t = Test()
+ self.failUnless(t.x == 'x', str(t.x))
+ try:
+ t.x = None
+ e = None
+ except ValueCheckError, e:
+ pass
+ self.failUnless(type(e) == ValueCheckError, type(e))
+ def testTwoCheckedLocalProperties(self):
+ class Test(object):
+ @Property
+ @checked_property(allowed=['x', 'y', 'z'])
+ @local_property(name="X")
+ def x(): return {}
+
+ @Property
+ @checked_property(allowed=['a', 'b', 'c'])
+ @local_property(name="A")
+ def a(): return {}
+ def __init__(self):
+ self._A_value = 'a'
+ self._X_value = 'x'
+ t = Test()
+ try:
+ t.x = 'a'
+ e = None
+ except ValueCheckError, e:
+ pass
+ self.failUnless(type(e) == ValueCheckError, type(e))
+ t.x = 'x'
+ t.x = 'y'
+ t.x = 'z'
+ try:
+ t.a = 'x'
+ e = None
+ except ValueCheckError, e:
+ pass
+ self.failUnless(type(e) == ValueCheckError, type(e))
+ t.a = 'a'
+ t.a = 'b'
+ t.a = 'c'
+ def testFnCheckedLocalProperty(self):
+ class Test(object):
+ @Property
+ @fn_checked_property(lambda v : v in ['x', 'y', 'z'])
+ @local_property(name="CHECKED")
+ def x(): return {}
+ def __init__(self):
+ self._CHECKED_value = 'x'
+ t = Test()
+ self.failUnless(t.x == 'x', str(t.x))
+ try:
+ t.x = None
+ e = None
+ except ValueCheckError, e:
+ pass
+ self.failUnless(type(e) == ValueCheckError, type(e))
+ def testCachedLocalProperty(self):
+ class Gen(object):
+ def __init__(self):
+ self.i = 0
+ def __call__(self, owner):
+ self.i += 1
+ return self.i
+ class Test(object):
+ @Property
+ @cached_property(generator=Gen(), initVal=None)
+ @local_property(name="CACHED")
+ def x(): return {}
+ t = Test()
+ self.failIf("_CACHED_cache" in dir(t), getattr(t, "_CACHED_cache", None))
+ self.failUnless(t.x == 1, t.x)
+ self.failUnless(t.x == 1, t.x)
+ self.failUnless(t.x == 1, t.x)
+ t.x = 8
+ self.failUnless(t.x == 8, t.x)
+ self.failUnless(t.x == 8, t.x)
+ t._CACHED_cache = False # Caching is off, but the stored value
+ val = t.x # is 8, not the initVal (None), so we
+ self.failUnless(val == 8, val) # get 8.
+ t._CACHED_value = None # Now we've set the stored value to None
+ val = t.x # so future calls to fget (like this)
+ self.failUnless(val == 2, val) # will call the generator every time...
+ val = t.x
+ self.failUnless(val == 3, val)
+ val = t.x
+ self.failUnless(val == 4, val)
+ t._CACHED_cache = True # We turn caching back on, and get
+ self.failUnless(t.x == 1, str(t.x)) # the original cached value.
+ del t._CACHED_cached_value # Removing that value forces a
+ self.failUnless(t.x == 5, str(t.x)) # single cache-regenerating call
+ self.failUnless(t.x == 5, str(t.x)) # to the genenerator, after which
+ self.failUnless(t.x == 5, str(t.x)) # we get the new cached value.
+ def testPrimedLocalProperty(self):
+ class Test(object):
+ def prime(self):
+ self.settings["PRIMED"] = "initialized"
+ @Property
+ @primed_property(primer=prime, initVal=None)
+ @settings_property(name="PRIMED")
+ def x(): return {}
+ def __init__(self):
+ self.settings={}
+ t = Test()
+ self.failIf("_PRIMED_prime" in dir(t), getattr(t, "_PRIMED_prime", None))
+ self.failUnless(t.x == "initialized", t.x)
+ t.x = 1
+ self.failUnless(t.x == 1, t.x)
+ t.x = None
+ self.failUnless(t.x == "initialized", t.x)
+ t._PRIMED_prime = True
+ t.x = 3
+ self.failUnless(t.x == "initialized", t.x)
+ t._PRIMED_prime = False
+ t.x = 3
+ self.failUnless(t.x == 3, t.x)
+ def testChangeHookLocalProperty(self):
+ class Test(object):
+ def _hook(self, old, new):
+ self.old = old
+ self.new = new
+
+ @Property
+ @change_hook_property(_hook)
+ @local_property(name="HOOKED")
+ def x(): return {}
+ t = Test()
+ t.x = 1
+ self.failUnless(t.old == None, t.old)
+ self.failUnless(t.new == 1, t.new)
+ t.x = 1
+ self.failUnless(t.old == None, t.old)
+ self.failUnless(t.new == 1, t.new)
+ t.x = 2
+ self.failUnless(t.old == 1, t.old)
+ self.failUnless(t.new == 2, t.new)
+
+suite = unittest.TestLoader().loadTestsFromTestCase(DecoratorTests)
+
diff --git a/libbe/rcs.py b/libbe/rcs.py
index 3519c3d..786f9dd 100644
--- a/libbe/rcs.py
+++ b/libbe/rcs.py
@@ -15,13 +15,14 @@
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
from subprocess import Popen, PIPE
+import codecs
import os
import os.path
-from socket import gethostname
import re
+from socket import gethostname
+import shutil
import sys
import tempfile
-import shutil
import unittest
import doctest
@@ -77,8 +78,9 @@ class PathNotInRoot(Exception):
self.root = root
class NoSuchFile(Exception):
- def __init__(self, pathname):
- Exception.__init__(self, "No such file: %s" % pathname)
+ def __init__(self, pathname, root="."):
+ path = os.path.abspath(os.path.join(root, pathname))
+ Exception.__init__(self, "No such file: %s" % path)
def new():
@@ -97,12 +99,13 @@ class RCS(object):
name = "None"
client = "" # command-line tool for _u_invoke_client
versioned = False
- def __init__(self, paranoid=False):
+ def __init__(self, paranoid=False, encoding=sys.getdefaultencoding()):
self.paranoid = paranoid
self.verboseInvoke = False
self.rootdir = None
self._duplicateBasedir = None
self._duplicateDirname = None
+ self.encoding = encoding
def __del__(self):
self.cleanup()
@@ -171,15 +174,15 @@ class RCS(object):
pass
def _rcs_get_file_contents(self, path, revision=None):
"""
- Get the file contents as they were in a given revision. Don't
- worry about decoding the contents, the RCS.get_file_contents()
- method will handle that.
-
+ Get the file contents as they were in a given revision.
Revision==None specifies the current revision.
"""
assert revision == None, \
"The %s RCS does not support revision specifiers" % self.name
- return file(os.path.join(self.rootdir, path), "rb").read()
+ f = codecs.open(os.path.join(self.rootdir, path), "r", self.encoding)
+ contents = f.read()
+ f.close()
+ return contents
def _rcs_duplicate_repo(self, directory, revision=None):
"""
Get the repository as it was in a given revision.
@@ -297,14 +300,18 @@ class RCS(object):
relpath = self._u_rel_path(path)
contents = self._rcs_get_file_contents(relpath,revision)
else:
- contents = file(path, "rb").read()
- return contents.decode("utf-8")
+ f = codecs.open(path, "r", self.encoding)
+ contents = f.read()
+ f.close()
+ return contents
def set_file_contents(self, path, contents, allow_no_rcs=False):
"""
Set the file contents under version control.
"""
add = not os.path.exists(path)
- file(path, "wb").write(contents.encode("utf-8"))
+ f = codecs.open(path, "w", self.encoding)
+ f.write(contents)
+ f.close()
if self._use_rcs(path, allow_no_rcs):
if add:
@@ -537,13 +544,13 @@ class RCS(object):
Split the commitfile created in self.commit() back into
summary and header lines.
"""
- f = file(commitfile, "rb")
+ f = codecs.open(commitfile, "r", self.encoding)
summary = f.readline()
body = f.read()
body.lstrip('\n')
if len(body) == 0:
body = None
- f.close
+ f.close()
return (summary, body)
diff --git a/libbe/restconvert.py b/libbe/restconvert.py
deleted file mode 100644
index 57148e4..0000000
--- a/libbe/restconvert.py
+++ /dev/null
@@ -1,130 +0,0 @@
-# Copyright (C) 2005, 2006 Aaron Bentley and Panometrics, Inc.
-# <abentley@panoramicfeedback.com>
-#
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 2 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
-
-import re
-from StringIO import StringIO
-from docutils import nodes
-from docutils.statemachine import StringList
-from docutils.core import publish_file
-from docutils.parsers import rst
-from docutils.parsers.rst import directives
-from docutils.parsers.rst.states import Inliner, MarkupMismatch, unescape
-try :
- from xml.etree import ElementTree # Python 2.5 (and greater?)
-except ImportError :
- from elementtree import ElementTree
-import doctest
-
-def rest_xml(rest):
- warnings = StringIO()
- parser = rst.Parser(inliner=HelpLinkInliner())
- xmltext = publish_file(rest, writer_name="html", parser=parser,
- settings_overrides={"warning_stream": warnings,
- "halt_level": 5})
- warnings.seek(0)
- return ElementTree.parse(StringIO(xmltext)).getroot(), warnings.read()
-
-class HelpLinkInliner(Inliner):
- def __init__(self, roles=None):
- Inliner.__init__(self, roles)
- regex = re.compile('\[([^|]*)\|([^]]*)\]')
- self.implicit_dispatch.append((regex, self.help_reference))
-
- def parse(self, *args, **kwargs):
- self.more_messages = []
- nodes, messages = Inliner.parse(self, *args, **kwargs)
- return nodes, (messages + self.more_messages)
-
- def help_reference(self, match, lineno):
- from wizardhelp.controllers import iter_help_pages
- text,link = match.groups()
- rawtext = match.group(0)
- text, link, rawtext = [unescape(f, 1) for f in (text, link, rawtext)]
- if link not in list(iter_help_pages()):
- msg = self.reporter.warning('Broken link to "%s".' % link,
- line=lineno)
- self.more_messages.append(msg)
- ref = "/help/%s/" % link
- unescaped = text
- node = nodes.reference(rawtext, text, refuri=ref)
- node.set_class("helplink")
- return [node]
-
-
-def rst_directive(name=None, required_args=0, optional_args=0,
- final_arg_ws=False, options=None, content='forbidden'):
- """Decorator that simplifies creating ReST directives
-
- All arguments are optional. Name is, by default, determined from the
- function name.
-
- The possible values for content are 'forbidden', 'allowed' (but not
- required), and 'required' (a warning will be generated if not present).
- """
- content_rules = {'forbidden': (False, False), 'allowed': (True, False),
- 'required': (True, True)}
- content_allowed, content_required = content_rules[content]
-
- def decorator_factory(func):
- my_name = name
- if my_name is None:
- my_name = func.__name__
-
- def decorator(name, arguments, options, content, lineno,
- content_offset, block_text, state, state_machine):
- warn = state_machine.reporter.warning
- if not content and content_required:
- warn = state_machine.reporter.warning
- warning = warn('%s is empty' % my_name,
- nodes.literal_block(block_text, block_text),
- line=lineno)
- return [warning]
- return func(name, arguments, options, content, lineno,
- content_offset, block_text, state, state_machine)
-
- decorator.arguments = (required_args, optional_args, final_arg_ws)
- decorator.options = options
- decorator.content = content_allowed
- directives.register_directive(my_name, decorator)
- return decorator
- return decorator_factory
-
-
-@rst_directive(required_args=1, final_arg_ws=True, content='required')
-def foldout(name, arguments, options, content, lineno, content_offset,
- block_text, state, state_machine):
- """\
- Generate a foldout section.
-
- On the ReST side, this merely involves marking the items with suitable
- classes. A Kid match rule will be used to insert the appropriate
- Javascript magic.
- """
- text = '\n'.join(content)
- foldout_title = nodes.paragraph([arguments[0]])
- foldout_title.set_class('foldout-title')
- state.nested_parse(StringList([arguments[0]]), 0, foldout_title)
- foldout_body = nodes.compound(text)
- foldout_body.set_class('foldout-body')
- state.nested_parse(content, content_offset, foldout_body)
- foldout = nodes.compound(text)
- foldout += foldout_title
- foldout += foldout_body
- foldout.set_class('foldout')
- return [foldout]
-
-suite = doctest.DocTestSuite()
diff --git a/libbe/settings_object.py b/libbe/settings_object.py
new file mode 100644
index 0000000..1df3e6b
--- /dev/null
+++ b/libbe/settings_object.py
@@ -0,0 +1,353 @@
+# Bugs Everywhere - a distributed bugtracker
+# Copyright (C) 2008 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 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/>.
+
+"""
+This module provides a base class implementing settings-dict based
+property storage useful for BE objects with saved properties
+(e.g. BugDir, Bug, Comment). For example usage, consider the
+unittests at the end of the module.
+"""
+
+import doctest
+import unittest
+
+from properties import Property, doc_property, local_property, \
+ defaulting_property, checked_property, fn_checked_property, \
+ cached_property, primed_property, change_hook_property, \
+ settings_property
+
+
+class _Token (object):
+ """
+ `Control' value class for properties. We want values that only
+ mean something to the settings_object module.
+ """
+ pass
+
+class UNPRIMED (_Token):
+ "Property has not been primed."
+ pass
+
+class EMPTY (_Token):
+ """
+ Property has been primed but has no user-set value, so use
+ default/generator value.
+ """
+ pass
+
+
+def prop_save_settings(self, old, new):
+ """
+ The default action undertaken when a property changes.
+ """
+ if self.sync_with_disk==True:
+ self.save_settings()
+
+def prop_load_settings(self):
+ """
+ The default action undertaken when an UNPRIMED property is accessed.
+ """
+ if self.sync_with_disk==True and self._settings_loaded==False:
+ self.load_settings()
+ else:
+ self._setup_saved_settings(flag_as_loaded=False)
+
+# Some name-mangling routines for pretty printing setting names
+def setting_name_to_attr_name(self, name):
+ """
+ Convert keys to the .settings dict into their associated
+ SavedSettingsObject attribute names.
+ >>> print setting_name_to_attr_name(None,"User-id")
+ user_id
+ """
+ return name.lower().replace('-', '_')
+
+def attr_name_to_setting_name(self, name):
+ """
+ The inverse of setting_name_to_attr_name.
+ >>> print attr_name_to_setting_name(None, "user_id")
+ User-id
+ """
+ return name.capitalize().replace('_', '-')
+
+
+def versioned_property(name, doc,
+ default=None, generator=None,
+ change_hook=prop_save_settings,
+ primer=prop_load_settings,
+ allowed=None, check_fn=None,
+ settings_properties=[],
+ required_saved_properties=[],
+ require_save=False):
+ """
+ Combine the common decorators in a single function.
+
+ Use zero or one (but not both) of default or generator, since a
+ working default will keep the generator from functioning. Use the
+ default if you know what you want the default value to be at
+ 'coding time'. Use the generator if you can write a function to
+ determine a valid default at run time. If both default and
+ generator are None, then the property will be a defaulting
+ property which defaults to None.
+
+ allowed and check_fn have a similar relationship, although you can
+ use both of these if you want. allowed compares the proposed
+ value against a list determined at 'coding time' and check_fn
+ allows more flexible comparisons to take place at run time.
+
+ Set require_save to True if you want to save the default/generated
+ value for a property, to protect against future changes. E.g., we
+ currently expect all comments to be 'text/plain' but in the future
+ we may want to default to 'text/html'. If we don't want the old
+ comments to be interpreted as 'text/html', we would require that
+ the content type be saved.
+
+ change_hook, primer, settings_properties, and
+ required_saved_properties are only options to get their defaults
+ into our local scope. Don't mess with them.
+ """
+ settings_properties.append(name)
+ if require_save == True:
+ required_saved_properties.append(name)
+ def decorator(funcs):
+ fulldoc = doc
+ if default != None:
+ defaulting = defaulting_property(default=default, null=EMPTY)
+ fulldoc += "\n\nThis property defaults to %s" % default
+ if generator != None:
+ cached = cached_property(generator=generator, initVal=EMPTY)
+ fulldoc += "\n\nThis property is generated with %s" % generator
+ if check_fn != None:
+ fn_checked = fn_checked_property(value_allowed_fn=check_fn)
+ fulldoc += "\n\nThis property is checked with %s" % check_fn
+ if allowed != None:
+ checked = checked_property(allowed=allowed)
+ fulldoc += "\n\nThe allowed values for this property are: %s." \
+ % (', '.join(allowed))
+ hooked = change_hook_property(hook=change_hook)
+ 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:
+ deco = defaulting(deco)
+ if generator != None:
+ deco = cached(deco)
+ if default != None:
+ deco = defaulting(deco)
+ if allowed != None:
+ deco = checked(deco)
+ if check_fn != None:
+ deco = fn_checked(deco)
+ return Property(deco)
+ return decorator
+
+class SavedSettingsObject(object):
+
+ # Keep a list of properties that may be stored in the .settings dict.
+ #settings_properties = []
+
+ # A list of properties that we save to disk, even if they were
+ # never set (in which case we save the default value). This
+ # protects against future changes in default values.
+ #required_saved_properties = []
+
+ _setting_name_to_attr_name = setting_name_to_attr_name
+ _attr_name_to_setting_name = attr_name_to_setting_name
+
+ def __init__(self):
+ self._settings_loaded = False
+ self.sync_with_disk = False
+ self.settings = {}
+
+ def load_settings(self):
+ """Load the settings from disk."""
+ # 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
+ settings as primed.
+ """
+ for property in self.settings_properties:
+ if property not in self.settings:
+ self.settings[property] = EMPTY
+ elif self.settings[property] == UNPRIMED:
+ self.settings[property] = EMPTY
+ if flag_as_loaded == True:
+ self._settings_loaded = True
+
+ def save_settings(self):
+ """Load the settings from disk."""
+ # Override. Should save the dict output of ._get_saved_settings()
+ settings = self._get_saved_settings()
+ pass # write settings to disk....
+
+ def _get_saved_settings(self):
+ settings = {}
+ for k,v in self.settings.items():
+ if v != None and v != EMPTY:
+ settings[k] = v
+ 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:
+ if hasattr(self, "_%s_cached_value" % setting):
+ delattr(self, "_%s_cached_value" % setting)
+ else:
+ for setting in settings_properties:
+ self.clear_cached_setting(setting)
+
+
+class SavedSettingsObjectTests(unittest.TestCase):
+ def testSimpleProperty(self):
+ class Test(SavedSettingsObject):
+ settings_properties = []
+ required_saved_properties = []
+ @versioned_property(name="Content-type",
+ doc="A test property",
+ settings_properties=settings_properties,
+ required_saved_properties=required_saved_properties)
+ def content_type(): return {}
+ def __init__(self):
+ SavedSettingsObject.__init__(self)
+ t = Test()
+ # 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)
+ # 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
+ # the default priming does not do any of the `official' loading
+ # that occurs in t.load_settings.
+ self.failUnless(len(t.settings) == 1, len(t.settings))
+ self.failUnless(t.settings["Content-type"] == EMPTY,
+ t.settings["Content-type"])
+ self.failUnless(t._settings_loaded == False, t._settings_loaded)
+ # load settings creates an EMPTY value in the settings array
+ t.load_settings()
+ 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(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.settings["Content-type"])
+ self.failUnless(t.content_type == None, t.content_type)
+ self.failUnless(t.settings["Content-type"] == None,
+ t.settings["Content-type"])
+ # now we set another value
+ t.content_type = "text/plain"
+ self.failUnless(t.content_type == "text/plain", t.content_type)
+ self.failUnless(t.settings["Content-type"] == "text/plain",
+ t.settings["Content-type"])
+ self.failUnless(t._get_saved_settings()=={"Content-type":"text/plain"},
+ t._get_saved_settings())
+ # now we clear to the post-primed value
+ t.content_type = EMPTY
+ self.failUnless(t._settings_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(len(t.settings) == 1, len(t.settings))
+ self.failUnless(t.settings["Content-type"] == EMPTY,
+ t.settings["Content-type"])
+ def testDefaultingProperty(self):
+ class Test(SavedSettingsObject):
+ settings_properties = []
+ required_saved_properties = []
+ @versioned_property(name="Content-type",
+ doc="A test property",
+ default="text/plain",
+ settings_properties=settings_properties,
+ required_saved_properties=required_saved_properties)
+ def content_type(): return {}
+ def __init__(self):
+ SavedSettingsObject.__init__(self)
+ t = Test()
+ self.failUnless(t._settings_loaded == False, t._settings_loaded)
+ self.failUnless(t.content_type == "text/plain", t.content_type)
+ self.failUnless(t._settings_loaded == False, t._settings_loaded)
+ t.load_settings()
+ self.failUnless(t._settings_loaded == True, t._settings_loaded)
+ self.failUnless(t.content_type == "text/plain", t.content_type)
+ self.failUnless(t.settings["Content-type"] == EMPTY,
+ t.settings["Content-type"])
+ self.failUnless(t._get_saved_settings() == {}, t._get_saved_settings())
+ t.content_type = "text/html"
+ self.failUnless(t.content_type == "text/html",
+ t.content_type)
+ self.failUnless(t.settings["Content-type"] == "text/html",
+ t.settings["Content-type"])
+ self.failUnless(t._get_saved_settings()=={"Content-type":"text/html"},
+ t._get_saved_settings())
+ def testRequiredDefaultingProperty(self):
+ class Test(SavedSettingsObject):
+ settings_properties = []
+ required_saved_properties = []
+ @versioned_property(name="Content-type",
+ doc="A test property",
+ default="text/plain",
+ settings_properties=settings_properties,
+ required_saved_properties=required_saved_properties,
+ require_save=True)
+ def content_type(): return {}
+ def __init__(self):
+ SavedSettingsObject.__init__(self)
+ t = Test()
+ self.failUnless(t._get_saved_settings()=={"Content-type":"text/plain"},
+ t._get_saved_settings())
+ t.content_type = "text/html"
+ self.failUnless(t._get_saved_settings()=={"Content-type":"text/html"},
+ t._get_saved_settings())
+ def testClassVersionedPropertyDefinition(self):
+ class Test(SavedSettingsObject):
+ settings_properties = []
+ required_saved_properties = []
+ def _versioned_property(settings_properties=settings_properties,
+ required_saved_properties=required_saved_properties,
+ **kwargs):
+ if "settings_properties" not in kwargs:
+ kwargs["settings_properties"] = settings_properties
+ if "required_saved_properties" not in kwargs:
+ kwargs["required_saved_properties"]=required_saved_properties
+ return versioned_property(**kwargs)
+ @_versioned_property(name="Content-type",
+ doc="A test property",
+ default="text/plain",
+ require_save=True)
+ def content_type(): return {}
+ def __init__(self):
+ SavedSettingsObject.__init__(self)
+ t = Test()
+ self.failUnless(t._get_saved_settings()=={"Content-type":"text/plain"},
+ t._get_saved_settings())
+ t.content_type = "text/html"
+ self.failUnless(t._get_saved_settings()=={"Content-type":"text/html"},
+ t._get_saved_settings())
+
+unitsuite=unittest.TestLoader().loadTestsFromTestCase(SavedSettingsObjectTests)
+suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
diff --git a/libbe/tree.py b/libbe/tree.py
index e6f144e..9e07ee3 100644
--- a/libbe/tree.py
+++ b/libbe/tree.py
@@ -39,6 +39,9 @@ class Tree(list):
>>> a.branch_len()
5
+ >>> a.sort(key=lambda node : -node.branch_len())
+ >>> "".join([node.n for node in a.traverse()])
+ 'acfhiebdg'
>>> a.sort(key=lambda node : node.branch_len())
>>> "".join([node.n for node in a.traverse()])
'abdgcefhi'
@@ -93,7 +96,7 @@ class Tree(list):
"""
list.sort(self, *args, **kwargs)
for child in self:
- child.sort()
+ child.sort(*args, **kwargs)
def traverse(self, depthFirst=True):
"""
diff --git a/libbe/utility.py b/libbe/utility.py
index 2c77fcf..30240a9 100644
--- a/libbe/utility.py
+++ b/libbe/utility.py
@@ -15,10 +15,11 @@
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
import calendar
-import time
+import codecs
import os
-import tempfile
import shutil
+import tempfile
+import time
import doctest
@@ -87,73 +88,5 @@ def str_to_time(str_time):
def handy_time(time_val):
return time.strftime("%a, %d %b %Y %H:%M", time.localtime(time_val))
-class CantFindEditor(Exception):
- def __init__(self):
- Exception.__init__(self, "Can't find editor to get string from")
-
-def editor_string(comment=None):
-
- """Invokes the editor, and returns the user_produced text as a string
-
- >>> if "EDITOR" in os.environ:
- ... del os.environ["EDITOR"]
- >>> if "VISUAL" in os.environ:
- ... del os.environ["VISUAL"]
- >>> editor_string()
- Traceback (most recent call last):
- CantFindEditor: Can't find editor to get string from
- >>> os.environ["EDITOR"] = "echo bar > "
- >>> editor_string()
- u'bar\\n'
- >>> os.environ["VISUAL"] = "echo baz > "
- >>> editor_string()
- u'baz\\n'
- >>> del os.environ["EDITOR"]
- >>> del os.environ["VISUAL"]
- """
- for name in ('VISUAL', 'EDITOR'):
- try:
- editor = os.environ[name]
- break
- except KeyError:
- pass
- else:
- raise CantFindEditor()
- fhandle, fname = tempfile.mkstemp()
- try:
- if comment is not None:
- os.write(fhandle, '\n'+comment_string(comment))
- os.close(fhandle)
- oldmtime = os.path.getmtime(fname)
- os.system("%s %s" % (editor, fname))
- output = trimmed_string(file(fname, "rb").read().decode("utf-8"))
- if output.rstrip('\n') == "":
- output = None
- finally:
- os.unlink(fname)
- return output
-
-
-def comment_string(comment):
- """
- >>> comment_string('hello')
- '== Anything below this line will be ignored ==\\nhello'
- """
- return '== Anything below this line will be ignored ==\n' + comment
-
-
-def trimmed_string(instring):
- """
- >>> trimmed_string("hello\\n== Anything below this line will be ignored")
- 'hello\\n'
- >>> trimmed_string("hi!\\n" + comment_string('Booga'))
- 'hi!\\n'
- """
- out = []
- for line in instring.splitlines(True):
- if line.startswith('== Anything below this line will be ignored'):
- break
- out.append(line)
- return ''.join(out)
suite = doctest.DocTestSuite()