# # # patch "tracvc/mtn/__init__.py" # from [27bddc6ef683d74219eb84c8c73b3358ba0b16ca] # to [ae9ed0995b5f4a5451b46e9391ea6ab5fe12b795] # # patch "tracvc/mtn/automate.py" # from [a05e6383326ca6810bc7345432de6c9d366ffa25] # to [da1af6f02cfe87924a0439d49ae48598940fad11] # # patch "tracvc/mtn/backend.py" # from [7c6bcf060c9299f2e09b03825658423e33c6fffd] # to [38dc2821c10d201350acbdd7bb320db47f144d29] # # patch "tracvc/mtn/util.py" # from [e65c638466ededc3aef8e7eb96bb4e414c68a3a0] # to [0040de4d22a4d69c79972605b615881eb1b6ebd9] # ============================================================ --- tracvc/mtn/__init__.py 27bddc6ef683d74219eb84c8c73b3358ba0b16ca +++ tracvc/mtn/__init__.py ae9ed0995b5f4a5451b46e9391ea6ab5fe12b795 @@ -1,5 +1,5 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -__version__ = '0.0.3' +__version__ = '0.0.4' ============================================================ --- tracvc/mtn/automate.py a05e6383326ca6810bc7345432de6c9d366ffa25 +++ tracvc/mtn/automate.py da1af6f02cfe87924a0439d49ae48598940fad11 @@ -25,47 +25,50 @@ import os """ import os -import time -import calendar try: import threading as _threading except ImportError: import dummy_threading as _threading -import basic_io -import util -from cache import memoize +from tracvc.mtn import basic_io +from tracvc.mtn.util import add_slash, to_unicode, natsort_key +from tracvc.mtn.cache import memoize -class Automate: +class Automate(object): """General interface to the 'automate stdio' command.""" - def __init__(self, db, binary): - (self.to_child, self.from_child) = os.popen2( - "%s --norc --automate-stdio-size=1048576 --db=%s --root=. automate stdio" % - (binary, db), "b") + def __init__(self, database, binary): + self.to_child, self.from_child = os.popen2( + "%s --norc --automate-stdio-size=1048576 " + "--db=%s --root=. automate stdio" % + (binary, database), "b") self.lock = _threading.Lock() def _read(self, maxlen = -1): + """Read maxlen bytes from automate process.""" return self.from_child.read(maxlen) def _write(self, data): + """Write data to automate process.""" return self.to_child.write(data) def _flush(self): + """Send flush to automate process.""" return self.to_child.flush() def _read_until_colon(self): + """Return bytes until and excluding next colon.""" result = '' while True: char = self._read(1) - if char == ':': break + if char == ':': + break result += char return result def _read_packet(self): - # we must not block, so be careful to read only what's - # expected - cmd_nr = self._read_until_colon() + """Read exactly one chunk of Monotone automate output.""" + _ = self._read_until_colon() # ignore the cmd number status = int(self._read_until_colon()) cont = self._read_until_colon() size = int(self._read_until_colon()) @@ -78,13 +81,17 @@ class Automate: while True: status, cont, val = self._read_packet() result += val - if cont == 'l': break + if cont == 'l': + break return status, result def _write_cmd(self, cmd, args): """Assemble command and args and send it to mtn.""" - def lstring(str): - return "%d:%s" % (len(str), str) + + def lstring(string): + """Prepend string with its length followed by a colon.""" + return "%d:%s" % (len(string), string) + cmdstring = "l" + lstring(cmd) for arg in args: cmdstring += lstring(arg) @@ -103,65 +110,76 @@ class Automate: return status, result -class MTN: +class MTN(object): + """Connect to a Monotone repository using the automation interface.""" - def __init__(self, db, log, binary, cachespec): - self.automate = Automate(db, binary) + def __init__(self, database, log, binary, cachespec): + self.automate = Automate(database, binary) self.log = log self.roots_cache = [] self.cachespec = cachespec def get_cachespec(self): + """We use a method to hand the cachespec to Memoize at runtime.""" return self.cachespec def leaves(self): """Returns a list containing the current leaves.""" status, result = self.automate.command("leaves") - if status == 0: return result.splitlines() + if status == 0: + return result.splitlines() else: return [] def heads(self, name): """Returns a list containing the head revs of branch 'name'.""" status, result = self.automate.command("heads", name) - if status == 0: return result.splitlines() + if status == 0: + return result.splitlines() else: return [] def children(self, rev): """Returns a list of the children of rev.""" status, result = self.automate.command("children", rev) - if status == 0: return result.splitlines() + if status == 0: + return result.splitlines() else: return [] @memoize(get_cachespec) def parents(self, rev): """Returns a list of the parents of rev.""" status, result = self.automate.command("parents", rev) - if status == 0: return result.splitlines() + if status == 0: + return result.splitlines() else: return [] def ancestors(self, revs): """Returns a list of the ancestors of rev.""" - status, result = self.automate.command("ancestors", *revs) - if status == 0: return result.splitlines() + status, result = self.automate.command("ancestors", *revs) #IGNORE:W0142 + if status == 0: + return result.splitlines() else: return [] def toposort(self, revs): """Sorts revisions topologically.""" - status, result = self.automate.command("toposort", *revs) - if status == 0: return result.splitlines() + status, result = self.automate.command("toposort", *revs) #IGNORE:W0142 + if status == 0: + return result.splitlines() else: return [] def all_revs(self): """Returns a list of all revs in the repository.""" status, result = self.automate.command("select", '') - if status == 0: return result.splitlines() + if status == 0: + return result.splitlines() else: return [] def roots(self): """Returns a list of all root revisions.""" - if self.roots_cache: return self.roots_cache + if self.roots_cache: + return self.roots_cache status, result = self.automate.command("graph") - if status != 0: return [] + if status != 0: + return [] roots = [] for line in result.splitlines(): rev_and_parents = line.split(' ') @@ -180,102 +198,96 @@ class MTN: @memoize(get_cachespec) def manifest(self, rev): - """ - Returns a processed manifest for rev. + """ Returns a processed manifest for rev. The manifest is a dictionary: path -> (kind, file_id, attrs), with kind being 'file' or 'dir', and attrs being a dictionary - attr_name -> attr_value. - """ + attr_name -> attr_value.""" status, result = self.automate.command("get_manifest_of", rev) manifest = {} - if status != 0: return manifest + if status != 0: + return manifest # stanzas have variable length, trigger on next 'path' or eof - path = None + path, kind, content, attrs = (None,) *4 for key, values in basic_io.items(result): if key == 'dir' or key == 'file': if path: manifest[path] = (kind, content, attrs) - path = util.add_slash(util.u(values[0])) + path = add_slash(to_unicode(values[0])) kind, content, attrs = key, None, {} elif key == 'content': content = values[0] elif key == 'attrs': - attrs[util.u(values[0])] = util.u(values[1]) + attrs[to_unicode(values[0])] = to_unicode(values[1]) if path: manifest[path] = (kind, content, attrs) return manifest @memoize(get_cachespec) def certs(self, rev): - """ - Returns a dictionary of certs for rev. There might be more + """Returns a dictionary of certs for rev. There might be more than one cert of the same name, so their values are collected - in a list. - """ + in a list.""" status, result = self.automate.command("certs", rev) certs = {} - if status != 0: return certs + if status != 0: + return certs for key, values in basic_io.items(result): if key == 'name': - name = util.u(values[0]) + name = to_unicode(values[0]) elif key == 'value': - value = util.u(values[0]) + value = to_unicode(values[0]) certs.setdefault(name, []).append(value) return certs - def file(self, id): + def file(self, file_id): """Returns the file contents for a given file id.""" - status, result = self.automate.command("get_file", id) - if status == 0: return result + status, result = self.automate.command("get_file", file_id) + if status == 0: + return result @memoize(get_cachespec) - def file_length(self, id): + def file_length(self, file_id): """Return the file length.""" - return len(self.file(id)) + return len(self.file(file_id)) @memoize(get_cachespec) def changesets(self, rev): - """ - Fetches and pre-processes a changeset. - - The changeset is a dictionary oldrev->changes, with changes - itself being a dictionary newpath->(type, change, - oldpath). type is 'file' or 'dir' or None - (i.e. unknown/irrelevant), and change can be 'add', 'move' or - 'edit'. Deletions are put in a list for newpath==None. - """ + """Parses a textual changeset into an instance of the + Changeset class.""" status, result = self.automate.command("get_revision", rev) - if status != 0: return {} + if status != 0: + return {} changesets = [] + oldpath = None for key, values in basic_io.items(result): if key == 'old_revision': # start a new changeset - cs = Changeset(values[0]) - changesets.append(cs) + changeset = Changeset(values[0]) + changesets.append(changeset) oldpath = None elif key == 'delete': - path = util.add_slash(util.u(values[0])) - cs.deleted.append(path) + path = add_slash(to_unicode(values[0])) + changeset.deleted.append(path) elif key == 'rename': - oldpath = util.add_slash(util.u(values[0])) + oldpath = add_slash(to_unicode(values[0])) elif key == 'to': - if oldpath: - newpath = util.add_slash(util.u(values[0])) - cs.renamed[newpath] = oldpath + if oldpath != None: + newpath = add_slash(to_unicode(values[0])) + changeset.renamed[newpath] = oldpath oldpath = None elif key == 'add_dir': - path = util.add_slash(util.u(values[0])) - cs.added[path] = 'dir' + path = add_slash(to_unicode(values[0])) + changeset.added[path] = 'dir' elif key == 'add_file': - path = util.add_slash(util.u(values[0])) - cs.added[path] = 'file' + path = add_slash(to_unicode(values[0])) + changeset.added[path] = 'file' elif key == 'patch': - path = util.add_slash(util.u(values[0])) - cs.patched.append(path) + path = add_slash(to_unicode(values[0])) + changeset.patched.append(path) # fixme: what about 'set' and 'clear'? These are edits, # but not if applied to new files. return changesets @@ -283,7 +295,8 @@ class MTN: def branchnames(self): """Returns a list of branch names.""" status, result = self.automate.command("branches") - if status == 0: return map(util.u, result.splitlines()) + if status == 0: + return map(to_unicode, result.splitlines()) #IGNORE:W0141 else: return [] def branches(self): @@ -293,7 +306,8 @@ class MTN: for branch in self.branchnames(): revs = self.heads(branch) if revs: - branches.append((branch, revs[0])) # multiple heads not supported + branches.append((branch, revs[0])) + # multiple heads not supported return branches def non_merged_branches(self): @@ -309,25 +323,28 @@ class MTN: def tags(self): """Returns a list of tags and their revs.""" status, result = self.automate.command("tags") - if status != 0: return [] + if status != 0: + return [] tags = [] for key, values in basic_io.items(result): if key == 'tag': - tag = util.u(values[0]) + tag = to_unicode(values[0]) elif key == 'revision': revision = values[0] tags.append((tag, revision)) - tags.sort(key=lambda i: util.natsort_key(i[0])) + tags.sort(key=lambda i: natsort_key(i[0])) return tags - def content_changed(self, rev, file): + def content_changed(self, rev, path): """Returns the list of content marks for the path, starting at the specified revision. Currently returns an empty list for directories.""" - status, result = self.automate.command("get_content_changed", rev, file[1:]) - if status != 0: return [] + status, result = self.automate.command("get_content_changed", + rev, path[1:]) + if status != 0: + return [] revs = [] for key, values in basic_io.items(result): @@ -337,6 +354,7 @@ class Changeset(object): class Changeset(object): + """Represents a monotone changeset in parsed form.""" def __init__(self, oldrev): self.oldrev = oldrev # the old rev this cs is against ============================================================ --- tracvc/mtn/backend.py 7c6bcf060c9299f2e09b03825658423e33c6fffd +++ tracvc/mtn/backend.py 38dc2821c10d201350acbdd7bb320db47f144d29 @@ -29,13 +29,13 @@ from trac.util import shorten_line, esca IRepositoryConnector, NoSuchNode, NoSuchChangeset from trac.wiki import IWikiSyntaxProvider from trac.util import shorten_line, escape -from trac.core import * +from trac.core import Component, implements, TracError from trac.config import Option, ListOption from trac import __version__ as trac_version from pkg_resources import parse_version from time import strptime -from automate import MTN -import util +from tracvc.mtn.automate import MTN +from tracvc.mtn.util import get_oldpath, get_parent import re DATE_RULE = re.compile(r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}') @@ -43,35 +43,44 @@ class MonotoneConnector(Component): class MonotoneConnector(Component): - + """ + Provides this plugin's functionality. + """ implements(IRepositoryConnector, IWikiSyntaxProvider) # Configuration options mtn_binary = Option('mtn', 'mtn_binary', default='/usr/bin/mtn', doc='''Full path to the monotone binary.''') - cachespec = Option('mtn', 'cachespec', default='localmem', doc='''Select a caching mechanism.''') - xtracerts = ListOption('mtn', 'xtracerts', doc='''List of user certs to be displayed.''') # IRepositoryConnector methods def __init__(self): + Component.__init__(self) self.repos = {} def get_supported_types(self): - """Return the types of version control systems that are supported. + """ + Return the types of version control systems that are + supported. - Yields `(repotype, priority)` pairs, where `repotype` is used to - match against the configured `[trac] repository_type` value in TracIni. + Yields `(repotype, priority)` pairs, where `repotype` is used + to match against the configured `[trac] repository_type` value + in TracIni. - If multiple provider match a given type, the `priority` is used to - choose between them (highest number is highest priority). + If multiple provider match a given type, the `priority` is + used to choose between them (highest number is highest + priority). """ yield ("mtn", 0) def get_revprops(self): + """ + Gets the user-defined configuration options for displaying + non-standard revision certs. + """ revprops = {} for cert in self.xtracerts: section = 'mtn-cert-%s' % cert @@ -80,18 +89,21 @@ class MonotoneConnector(Component): self.config.get(section, 'text', '%s'), self.config.getbool(section, 'wikiflag', True), self.config.get(section, 'htmlclass', None) or None - # Config bug? Returns '' if unset instead of None. + # FIXME: Config bug? Returns '' if unset instead of None. ] return revprops def get_repository(self, type, path, authname): - """Return a Repository instance for the given repository type and dir. """ + Return a Repository instance for the given repository type and + dir. + """ # note: we don't use type or authname, therefore we can always # return the same Repository object for the same database path if not path in self.repos: self.repos[path] = MonotoneRepository(path, self.log, - self.mtn_binary, self.cachespec, self.get_revprops()) + self.mtn_binary, self.cachespec, + self.get_revprops()) return self.repos[path] # IWikiSyntaxProvider methods @@ -106,7 +118,9 @@ class MonotoneConnector(Component): fullmatch.group('revid'), match)) def get_link_resolvers(self): - """Add the cset namespace.""" + """ + Add the cset namespace. + """ yield('cset', self._format_link) yield('chgset', self._format_link) yield('branch', self._format_link) # branch head @@ -114,10 +128,14 @@ class MonotoneConnector(Component): # Internal methods def _format_link(self, formatter, ns, rev, label): - """Format a changeset link.""" + """ + Format a changeset link. + """ repos = self.env.get_repository() - if ns == 'branch': rev = "h:" + rev - elif ns == 'revtag': rev = "t:" + rev + if ns == 'branch': + rev = "h:" + rev + elif ns == 'revtag': + rev = "t:" + rev try: changeset = repos.get_changeset(rev) return '%s' \ @@ -133,22 +151,22 @@ if parse_version(trac_version) < parse_v # support both variants for a while. if parse_version(trac_version) < parse_version("0.11dev"): - def parse_date(s): + def parse_date(rawdate): """ Convert a monotone date string into a unix timestamp. """ from calendar import timegm - return timegm(strptime(s + " UTC","%Y-%m-%dT%H:%M:%S %Z")) + return timegm(strptime(rawdate + " UTC","%Y-%m-%dT%H:%M:%S %Z")) else: - def parse_date(s): + def parse_date(rawdate): """ Convert a monotone date string into a datetime object. """ from datetime import datetime from trac.util.datefmt import utc - date = datetime(*strptime(s, "%Y-%m-%dT%H:%M:%S")[:6]) + date = datetime(*strptime(rawdate, "%Y-%m-%dT%H:%M:%S")[:6]) return date.replace(tzinfo=utc) @@ -157,21 +175,24 @@ def dates(certvals): Parse the raw dates and return a sorted list. """ result = [] - for s in certvals: + for rawdate in certvals: # strip the date before parsing - s = DATE_RULE.search(s).group() - result.append(parse_date(s)) + rawdate = DATE_RULE.search(rawdate).group() + result.append(parse_date(rawdate)) result.sort() return result class MonotoneRepository(Repository): + """ + Represents a Monotone repository. + """ def __init__(self, path, log, binary, cachespec, revpropspec = None): - self.mtn = MTN(path, log, binary, cachespec) Repository.__init__(self, 'mtn:%s' % path, None, log) + self.mtn = MTN(path, log, binary, cachespec) self.revpropspec = revpropspec or {} - + def get_changeset(self, rev): """ Retrieve a Changeset object that describes the changes made in @@ -216,7 +237,8 @@ class MonotoneRepository(Repository): Return the oldest revision stored in the repository. Here: Return the oldest root. """ - roots = dict([(self.get_dates(rev)[0], rev) for rev in self.mtn.roots()]) + roots = dict([(self.get_dates(rev)[0], rev) + for rev in self.mtn.roots()]) dates = roots.keys() dates.sort() return roots[dates[0]] @@ -226,7 +248,8 @@ class MonotoneRepository(Repository): Return the youngest revision in the repository. Here: Return the youngest leave. """ - leaves = dict([(self.get_dates(rev)[-1], rev) for rev in self.mtn.leaves()]) + leaves = dict([(self.get_dates(rev)[-1], rev) + for rev in self.mtn.leaves()]) dates = leaves.keys() dates.sort() return leaves[dates[-1]] @@ -236,7 +259,7 @@ class MonotoneRepository(Repository): Return the revision immediately preceding the specified revision. """ # note: returning only one parent - parents = self.mtn.parents(rev); + parents = self.mtn.parents(rev) parents.sort() return parents and parents[0] or None @@ -246,7 +269,7 @@ class MonotoneRepository(Repository): """ # note: ignoring path for now # note: returning only one child - children = self.mtn.children(rev); + children = self.mtn.children(rev) children.sort() return children and children[0] or None @@ -300,35 +323,42 @@ class MonotoneRepository(Repository): def get_changes(self, old_path, old_rev, new_path, new_rev, ignore_ancestry=1): """ - Generator that yields change tuples (old_node, new_node, kind, change) - for each node change between the two arbitrary (path,rev) pairs. + Generator that yields change tuples (old_node, new_node, kind, + change) for each node change between the two arbitrary + (path,rev) pairs. The old_node is assumed to be None when the change is an ADD, - the new_node is assumed to be None when the change is a DELETE. + the new_node is assumed to be None when the change is a + DELETE. """ raise NotImplementedError def get_tags(self, rev): - """Generate a list of known tags, as (name, rev) pairs. - `rev` might be needed in order to retrieve the tags, - but in general it's best to produce all known tags. """ + Generate a list of known tags, as (name, rev) pairs. `rev` + might be needed in order to retrieve the tags, but in general + it's best to produce all known tags. + """ return self.mtn.tags() def get_branches(self, rev): - """Generate a list of known branches, as (name, rev) pairs. - `rev` might be needed in order to retrieve the branches, - but in general it's best to produce all known branches. """ + Generate a list of known branches, as (name, rev) pairs. + `rev` might be needed in order to retrieve the branches, but + in general it's best to produce all known branches. + """ return self.mtn.non_merged_branches() def get_quickjump_entries(self, from_rev): - """Generate a list of interesting places in the repositoy. + """ + Generate a list of interesting places in the repositoy. - `rev` might be used to restrict the list of available location, - but in general it's best to produce all known locations. + `rev` might be used to restrict the list of available + location, but in general it's best to produce all known + locations. - The generated results must be of the form (category, name, path, rev). + The generated results must be of the form (category, name, + path, rev). """ result = [] @@ -378,9 +408,9 @@ class MonotoneNode(Node): def get_content(self): """ - Return a stream for reading the content of the node. This method - will return None for directories. The returned object should provide - a read([len]) function. + Return a stream for reading the content of the node. This + method will return None for directories. The returned object + should provide a read([len]) function. """ if self.isdir: return None @@ -388,35 +418,39 @@ class MonotoneNode(Node): def get_entries(self): """ - Generator that yields the immediate child entries of a directory, in no - particular order. If the node is a file, this method returns None. + Generator that yields the immediate child entries of a + directory, in no particular order. If the node is a file, this + method returns None. """ if self.isfile: return def ischild(path): - """Returns true, if we are parent of path.""" - return util.get_parent(path) == self.path + """ + Returns true, if we are parent of path. + """ + return get_parent(path) == self.path - for path in filter(ischild, self.manifest.keys()): + for path in filter(ischild, self.manifest.keys()): # IGNORE:W0141 yield MonotoneNode(self.mtn, self.rev, path, self.manifest) def get_history(self, limit=None): """ - Generator that yields (path, rev, chg) tuples, one for each revision in which - the node was changed. This generator will follow copies and moves of a - node (if the underlying version control system supports that), which - will be indicated by the first element of the tuple (i.e. the path) - changing. - Starts with an entry for the current revision. + Generator that yields (path, rev, chg) tuples, one for each + revision in which the node was changed. This generator will + follow copies and moves of a node (if the underlying version + control system supports that), which will be indicated by the + first element of the tuple (i.e. the path) changing. Starts + with an entry for the current revision. """ - # fixme!! this is only a stub + # FIXME: this is only a stub yield (self.path, self.rev, None) def get_properties(self): """ - Returns a dictionary containing the properties (meta-data) of the node. - The set of properties depends on the version control system. + Returns a dictionary containing the properties (meta-data) of + the node. The set of properties depends on the version + control system. """ return self.manifest[self.path][2] @@ -436,7 +470,10 @@ class MonotoneChangeset(Changeset): class MonotoneChangeset(Changeset): - + """ + Represents the set of changes in one revision. + """ + def __init__(self, mtn, rev, revpropspec = None): self.certs = mtn.certs(rev) @@ -456,49 +493,54 @@ class MonotoneChangeset(Changeset): self.revpropspec = revpropspec or {} def get_changes(self): - """Generator that produces a tuple for every change in the changeset + """ + Generator that produces a tuple for every change in the + changeset - The tuple will contain `(path, kind, change, base_path, base_rev)`, - where `change` can be one of Changeset.ADD, Changeset.COPY, - Changeset.DELETE, Changeset.EDIT or Changeset.MOVE, - and `kind` is one of Node.FILE or Node.DIRECTORY. - The `path` is the targeted path for the `change` (which is - the ''deleted'' path for a DELETE change). - The `base_path` and `base_rev` are the source path and rev for the - action (`None` and `-1` in the case of an ADD change). + The tuple will contain `(path, kind, change, base_path, + base_rev)`, where `change` can be one of Changeset.ADD, + Changeset.COPY, Changeset.DELETE, Changeset.EDIT or + Changeset.MOVE, and `kind` is one of Node.FILE or + Node.DIRECTORY. The `path` is the targeted path for the + `change` (which is the ''deleted'' path for a DELETE change). + The `base_path` and `base_rev` are the source path and rev for + the action (`None` and `-1` in the case of an ADD change). """ # We do not closely implement that api, for example, we don't # know the kind of a deleted or renamed node. - for cs in self.mtn.changesets(self.rev): - oldrev = cs.oldrev + for changeset in self.mtn.changesets(self.rev): + oldrev = changeset.oldrev # deletions - for oldpath in cs.deleted: + for oldpath in changeset.deleted: yield oldpath, None, Changeset.DELETE, oldpath, oldrev # pure renames - for (path, oldpath) in cs.renamed.iteritems(): + for (path, oldpath) in changeset.renamed.iteritems(): yield path, None, Changeset.MOVE, oldpath, oldrev # additions - for (path, kind) in cs.added.iteritems(): + for (path, kind) in changeset.added.iteritems(): yield path, kind, Changeset.ADD, None, -1 # patches - for path in cs.patched: - oldpath = util.get_oldpath(path, cs.renamed) + for path in changeset.patched: + oldpath = get_oldpath(path, changeset.renamed) yield path, Node.FILE, Changeset.EDIT, oldpath, oldrev def get_properties(self): - """Generator that provides additional metadata for this changeset. + """ + Generator that provides additional metadata for this + changeset. Each additional property is a 4 element tuple: * `name` is the name of the property, * `text` its value - * `wikiflag` indicates whether the `text` should be interpreted as - wiki text or not - * `htmlclass` enables to attach special formatting to the displayed - property, e.g. `'author'`, `'time'`, `'message'` or `'changeset'`. + * `wikiflag` indicates whether the `text` should be + interpreted as wiki text or not + * `htmlclass` enables to attach special formatting to the + displayed property, e.g. `'author'`, `'time'`, `'message'` or + `'changeset'`. """ for author in self.authors[1:]: yield('+Author', author, True, 'author') ============================================================ --- tracvc/mtn/util.py e65c638466ededc3aef8e7eb96bb4e414c68a3a0 +++ tracvc/mtn/util.py 0040de4d22a4d69c79972605b615881eb1b6ebd9 @@ -79,7 +79,7 @@ def natsort_key(s): return [try_int(w) for w in re.findall(NATSORT_BIT, s)] -def u(s): +def to_unicode(s): """Convert s from utf-8 to unicode, thereby replacing unknown characters with the unicode replace char.""" return unicode(s, "utf-8", "replace")