# # # add_file "tracvc/mtn/cache.py" # content [74ba6b640de58de20e8922c97eda6f77aecc2a27] # # patch "README" # from [2e3b139ee66a3c3aff269a7c7fe539883d10a056] # to [9f8f3733a57d238f540764b3ad8bd7ed0cf27c10] # # patch "tracvc/mtn/automate.py" # from [3a6fd378274704dc490d493ade8877863a68174b] # to [519b5f3566f5b1f4ca1f2fca849dc0daad99cf98] # # patch "tracvc/mtn/backend.py" # from [4a8df4f9b6a798e8ad95aeea23779217767a9544] # to [7d4ce5f25ac3c1fb2e0cd18e27157630fd4b2f28] # ============================================================ --- tracvc/mtn/cache.py 74ba6b640de58de20e8922c97eda6f77aecc2a27 +++ tracvc/mtn/cache.py 74ba6b640de58de20e8922c97eda6f77aecc2a27 @@ -0,0 +1,176 @@ +""" +Trac Plugin for Monotone + +Copyright 2006, Thomas Moschny (address@hidden) + +{{{ +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 +}}} + +""" + +try: + from threading import Lock +except ImportError: + from dummy_threading import Lock + + +class Cache(object): + '''The (abstract) base class of the cache implementations.''' + + backends = {} + + @classmethod + def add_backend(self, name, factory): + """Add a backend.""" + self.backends[name] = factory + + @classmethod + def get_backends(self): + """Return the names of all available backends.""" + for backend in self.backends: + yield backend + + @classmethod + def get_cache(self, realm, cachespec): + """ + Make a new cache (or cache connection, depending on the cache + backend.) The cachespec consists of name of the backend and + possibly arguments to be passed to the backend, separated by a + colon. + """ + cachespec = cachespec.split(':') + backend = cachespec[0] + args = cachespec[1:] + cache = self.backends[backend](realm, *args) + return cache + + # the cache interface + def set(self, key, value): + """Unconditionally set the key's value.""" + raise NotImplementedError + + def get(self, key): + """Return the key's value, or None.""" + raise NotImplementedError + + def delete(self, key): + """Delete the (key, value) pair from the cache. Fails silently + if key was not in the cache.""" + raise NotImplementedError + + def add(self, key, value): + """If key is not already in the cache, add the (key, value) + pair. Fails silently otherwise.""" + raise NotImplementedError + + def replace(self, key, value): + """If key is already in the cache, replace it's value. Fails + silently otherwise.""" + raise NotImplementedError + + def flush_all(self): + """Flush the cache.""" + raise NotImplementedError + + +class CacheManager(object): + """Keeps one cache per realm.""" + + caches = {} + locks = {} + + @classmethod + def get_cache(self, realm, cachespec): + """Return the cache for realm, creating a new cache if there + isn't already one.""" + self.locks.setdefault(realm, Lock()).acquire() + try: + cache = self.caches[realm] + except KeyError: + cache = Cache.get_cache(realm, cachespec) + self.caches[realm] = cache + self.locks[realm].release() + return cache + + +class LocalMem(Cache): + """A simple cache implementation using local memory only.""" + + def __init__(self, realm): + # realm is unused + self.cache = {} + self.lock = Lock() + + def set(self, key, value): + self.cache[key] = value + + def get(self, key): + return self.cache[key] + + def delete(self, key): + del self.cache[key] + + def add(self, key, value): + self.lock.acquire() + if key not in self.cache: + self.cache[key] = value + self.lock.release() + + def replace(self, key, value): + self.lock.acquire() + if key in self.cache: + self.cache[key] = value + self.lock.release() + + def flush_all(self): + self.cache = {} + +Cache.add_backend('localmem', LocalMem) + + +def memoize(get_cachespec, realm = None): + """Decorates a method with the Memoize decorator. get_cachespec is + a method of the same class which returns the desired cachespec, + realm is used to differentiate between different caches.""" + return lambda function: Memoize(function, get_cachespec, realm) + + +class Memoize(object): + """Caches return values of the decorated method.""" + + def __init__(self, function, get_cachespec, realm = None): + self.function = function + self.get_cachespec = get_cachespec + self.realm = realm or function.__name__ + + def __get__(self, instance, clazz = None): + self.instance = instance + return self + + def __call__(self, *args): + try: + return self.cache.get(args) + except KeyError: + # not yet in the cache + value = self.function(self.instance, *args) + self.cache.add(args, value) + return value + except AttributeError: + # oops, no cache yet, get one and restart + self.cache = CacheManager.get_cache(self.realm, \ + self.get_cachespec(self.instance)) + return self.__call__(*args) ============================================================ --- README 2e3b139ee66a3c3aff269a7c7fe539883d10a056 +++ README 9f8f3733a57d238f540764b3ad8bd7ed0cf27c10 @@ -6,18 +6,19 @@ The plugin is currently neither stable nor optimized for high performance. Use at your own risk! Some things work, others don't. - Feedback is welcome! + However, your feedback is welcome! Prerequisites - * A Monotone 0.26 binary. Currently, it is expected in - /usr/bin/mtn. In the future this will be configurable. + * A Monotone 0.26 (or later) binary. - * An installation of Trac. A very recent (development) version is - needed. Trunk revision 3239 should work. + * An installation of Trac. A recent (development) version is + needed. Rrevision 3239 (or later) from trunk should work. + * Python 2.4. + Installation * Create a Python egg: run 'python ./setup.py bdist_egg' in the @@ -25,26 +26,40 @@ * Put the generated egg into the plugins directory of your project. - * Add 'repository_type=mtn' and 'repository_dir=path_to_monotone_db' + * Add 'repository_type = mtn' and 'repository_dir = path_to_mtn_db' options to the [trac] section of the conf/trac.init file of your project. * Enable the plugin by adding the option 'tracvc.mtn.* = enabled' to the [components] section of the conf/trac.ini file. - * That's it. + * (Re-)start the webserver or tracd. + * That's it :) + +Configuration + + Currently, there's not much to configure. All configuration options + have to be placed in the [mtn] section of the conf/trac.ini file. + + * The full path to the Monotone binary can be specified using the + 'mtn_binary' option. + + Known Problems/Missing Features * SECURITY: The monotone select functionality is exposed. Selectors - are susceptible for SQL injections. Needs to be fixed. + are susceptible for SQL injections. Monotone binaries build from + the current development tree are no longer vulnerable in this + respect. * Revisions are almost always printed as complete 40-char string. * Changeset displays don't show attr changes. - * Log on per-file basis is missing. + * Log on per-file basis is missing, and the browser view shows the + same revision for all files. * Some operations are very slow. ============================================================ --- tracvc/mtn/automate.py 3a6fd378274704dc490d493ade8877863a68174b +++ tracvc/mtn/automate.py 519b5f3566f5b1f4ca1f2fca849dc0daad99cf98 @@ -22,18 +22,24 @@ """ -import os, re, time, calendar +import os +import re +import time +import calendar try: import threading as _threading except ImportError: import dummy_threading as _threading import basic_io +from cache import memoize + TAGS_RULE = re.compile(r"^(?P.*?) (?P[0-9a-f]{40}) (?P.*)\n", re.MULTILINE) REVID_RULE = re.compile(r'^[0-9a-f]{40}$') HEAD_RULE = re.compile(r'^h:[a-zA-Z0-9.-]+$') TAG_RULE = re.compile(r'^t:[a-zA-Z0-9.-]+$') + class Connection: """Starts monotone and communicates with it through a pipe.""" @@ -120,14 +126,15 @@ class MTN: - def __init__(self, db, log, binary): + def __init__(self, db, log, binary, cachespec): self.automate = Automate(db, binary) self.list = List(db, binary) self.log = log - self.manifest_cache = {} - self.certs_cache = {} self.roots_cache = [] - self.changeset_cache = {} + self.cachespec = cachespec + + def get_cachespec(self): + return self.cachespec def leaves(self): """Returns a list containing the current leaves.""" @@ -195,6 +202,7 @@ return result.splitlines() return [] + @memoize(get_cachespec) def manifest(self, rev): """ Returns a processed manifest for rev. @@ -203,8 +211,6 @@ with kind being 'file' or 'dir', and attrs being a dictionary attr_name -> attr_value. """ - if rev in self.manifest_cache: - return self.manifest_cache[rev] status, result = self.automate.command("get_manifest_of", rev) manifest = {} if status != 0: return manifest @@ -218,7 +224,6 @@ path = '/' + value kind = key if path: manifest[path] = (kind, content, attrs) - self.manifest_cache[rev] = manifest return manifest @staticmethod @@ -229,14 +234,13 @@ """ return unicode(x, "utf-8", "replace") + @memoize(get_cachespec) def certs(self, rev): """ 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. """ - if rev in self.certs_cache: - return self.certs_cache[rev] status, result = self.automate.command("certs", rev) certs = {} if status != 0: return certs @@ -248,7 +252,6 @@ certs[name] = [value] else: certs[name].append(value) - self.certs_cache[rev] = certs return certs def dates(self, rev): @@ -268,6 +271,7 @@ status, result = self.automate.command("get_file", id) if status == 0: return result + @memoize(get_cachespec) def changeset(self, rev): """ Fetches and pre-processes a changeset. @@ -278,13 +282,39 @@ (i.e. unknown/irrelevant), and change can be 'add', 'move' or 'edit'. Deletions are put in a list for newpath==None. """ - if rev in self.changeset_cache: - return self.changeset_cache[rev] status, result = self.automate.command("get_revision", rev) if status != 0: return {} changeset = {} - def add_slash(path): return '/' + path + def add_slash(path): + """Prepend a slash. Throughout the this plugin, all paths + including the root dir, start with a slash.""" + return '/' + path + + def get_parent(path): + """Returns the name of the directory containing path, or + None if there is none (because path is '/').""" + + path = path and path.rstrip('/') + return path and (path[0:path.rfind('/')] or '/') or None + + def get_oldpath(path, renames): + """Find out the name of a path in the parent revision + currently in question.""" + + if path in renames: + # simple: path itself changes + return renames[path] + # one of the parents might have changed it's name + parent = get_parent(path) + while parent: + if parent in renames: + oldparent = renames[parent] + oldpath = path.replace(parent.rstrip('/'), + oldparent.rstrip('/'), 1) + return oldpath + parent = get_parent(parent) + return path for stanza in basic_io.get_stanzas(result): entry = basic_io.get_hash_from_stanza(stanza) @@ -310,20 +340,17 @@ changeset[oldrev][newpath] = (None, 'move', oldpath) elif entry.has_key('patch'): path = add_slash(entry['patch']) - if path in renames: - oldpath = renames[path] - changeset[oldrev][path] = ('file', 'edit', oldpath) - else: - changeset[oldrev][path] = ('file', 'edit', path) + oldpath = get_oldpath(path, renames) + changeset[oldrev][path] = ('file', 'edit', oldpath) # fixme: what about 'set' and 'clear'? These are edits, # but not if applied to new files. - - self.changeset_cache[rev] = changeset return changeset def branches(self): - """Returns a list of (branch, oneoftheheads) tuples. Caveat: very slow.""" - branchnames = map(self._u, self.list.get_list("branches").splitlines()) + """Returns a list of (branch, oneoftheheads) tuples. Caveat: + very slow.""" + branchnames = map(self._u, + self.list.get_list("branches").splitlines()) branches = [] for branch in branchnames: revs = self.heads(branch) @@ -346,3 +373,4 @@ for match in TAGS_RULE.finditer(self.list.get_list("tags")): tags.append((self._u(match.group('tag')), match.group('rev'))) return tags + ============================================================ --- tracvc/mtn/backend.py 4a8df4f9b6a798e8ad95aeea23779217767a9544 +++ tracvc/mtn/backend.py 7d4ce5f25ac3c1fb2e0cd18e27157630fd4b2f28 @@ -22,11 +22,13 @@ """ -from trac.versioncontrol.api import Node, Repository, Changeset, IRepositoryConnector, NoSuchNode, NoSuchChangeset +from cStringIO import StringIO +from trac.versioncontrol.api import Repository, Node, Changeset, \ + IRepositoryConnector, NoSuchNode, NoSuchChangeset from trac.wiki import IWikiSyntaxProvider from trac.util import shorten_line, escape from trac.core import * -from cStringIO import StringIO +from trac.config import Option from automate import MTN @@ -34,6 +36,10 @@ implements(IRepositoryConnector, IWikiSyntaxProvider) + # Configuration options + mtn_binary = Option('mtn', 'mtn_binary', '/usr/bin/mtn' + '''Full path to the monotone binary.''') + # IRepositoryConnector methods def __init__(self): self.repos = {} @@ -45,15 +51,10 @@ def get_repository(self, type, path, authname): """Return a monotone repository.""" - # get options - options = {} - for (key, val) in self.config.options(type): - options[key] = val - # 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, options) + self.repos[path] = MonotoneRepository(path, self.log, self.mtn_binary) return self.repos[path] # IWikiSyntaxProvider methods @@ -74,6 +75,7 @@ yield('branch', self._format_link) # branch head yield('revtag', self._format_link) + # Internal methods def _format_link(self, formatter, ns, rev, label): """Format a changeset link.""" repos = self.env.get_repository() @@ -93,9 +95,9 @@ class MonotoneRepository(Repository): - def __init__(self, path, log, options): - mtn_binary = options.get('mtn_binary', '/usr/bin/mtn') - self.mtn = MTN(path, log, mtn_binary) + def __init__(self, path, log, binary): + cachespec = 'localmem' + self.mtn = MTN(path, log, binary, cachespec) Repository.__init__(self, 'mtn:%s' % path, None, log) def get_changeset(self, rev): @@ -200,14 +202,14 @@ def normalize_rev(self, rev): """ Return a canonical representation of a revision in the repos. - 'None' is a valid revision value and represents the youngest revision. + 'None' is a valid revision value and represents the youngest + revision. It should simply be passed through. + """ - if not rev: - return self.get_youngest_rev() # fixme: necessary? - revs = self.mtn.select(rev) - if revs: - return revs[0] - return rev # not normalizable + if rev != None: + revs = self.mtn.select(rev) + rev = revs and revs[0] or None + return rev def short_rev(self, rev): """