The unified diff between revisions [22d5754a..] and [9b895b2c..] is displayed below. It can also be downloaded as a raw diff.

#
#
# add_file "automate.py"
#  content [391729a1cc7e1fb0d63cdcce168e57e421de28c0]
#
============================================================
--- automate.py	391729a1cc7e1fb0d63cdcce168e57e421de28c0
+++ automate.py	391729a1cc7e1fb0d63cdcce168e57e421de28c0
@@ -0,0 +1,348 @@
+# -*- coding: utf-8 -*-
+"""
+Trac Plugin for Monotone
+
+Copyright 2006-2008 Thomas Moschny (thomas.moschny@gmx.de)
+
+{{{
+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
+}}}
+
+"""
+
+from subprocess import Popen, PIPE
+try:
+    from threading import Lock
+except ImportError:
+    from dummy_threading import Lock #IGNORE:E0611
+from tracmtn import basic_io
+from tracmtn.util import add_slash, to_unicode, natsort_key
+
+
+class AutomateException(Exception):
+    """Thrown when the status of an automate command
+    is not null, indicating that there is no valid result."""
+
+
+class Automate(object):
+    """General interface to the 'automate stdio' command."""
+
+    def __init__(self, database, binary):
+        self.process = Popen(
+            (binary, '--norc', '--root=.', '--automate-stdio-size=1048576',
+             '--db=%s' % database, 'automate', 'stdio'),
+            stdin=PIPE, stdout=PIPE, stderr=None)
+        self.lock = Lock()
+
+    def _write(self, data):
+        """Write data to automate process."""
+        return self.process.stdin.write(data)
+
+    def _flush(self):
+        """Send flush to automate process."""
+        return self.process.stdin.flush()
+
+    def _read(self, maxlen = -1):
+        """Read maxlen bytes from automate process."""
+        return self.process.stdout.read(maxlen)
+
+    def _read_until_colon(self):
+        """Return bytes until and excluding next colon."""
+        result = ''
+        while True:
+            char = self._read(1)
+            if char == ':':
+                break
+            elif not char:
+                raise AutomateException("EOF while reading from Monotone")
+            result += char
+        return result
+
+    def _read_packet(self):
+        """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())
+        val    = self._read(size)
+        return status, cont, val
+
+    def _get_result(self):
+        """Read and concatenate the result packets."""
+        result = ''
+        while True:
+            status, cont, val = self._read_packet()
+            result += val
+            if cont == 'l':
+                break
+        return status, result
+
+    def _write_cmd(self, cmd, args, opts):
+        """Assemble the cmdline from command, args and opts and send
+        it to mtn."""
+
+        def lstring(string):
+            """Prepend string with its length followed by a colon."""
+            return "%d:%s" % (len(string), string)
+
+        cmdstring = ""
+
+        if opts:
+            cmdstring += "o"
+            for name, val in opts.iteritems():
+                cmdstring += lstring(name) + lstring(val)
+            cmdstring += "e"
+
+        cmdstring += "l"
+        cmdstring += lstring(cmd)
+        for arg in args:
+            cmdstring += lstring(arg)
+        cmdstring += "e"
+        self._write(cmdstring)
+        self._flush()
+
+    def command(self, cmd, args=[], opts={}):
+        """Send a command to mtn. Returns a tuple (status, result)."""
+        # critical region: only one thread may send a command and read
+        # back the result at a time
+        self.lock.acquire()
+        try:
+            if self.process.poll():
+                raise AutomateException("Monotone process died")
+            self._write_cmd(cmd, args, opts)
+            status, result = self._get_result()
+        finally:
+            self.lock.release()
+        if status == 0:
+            return result
+        raise AutomateException("Monotone error code %d: %s (%s)" %
+                                (status, to_unicode(result), cmd))
+
+
+class MTN(object):
+    """Connect to a Monotone repository using the automation interface."""
+
+    def __init__(self, database, log, binary):
+        self.automate = Automate(database, binary)
+        self.log = log
+        self.roots_cache = []
+        self.interface_version = None
+
+    def leaves(self):
+        """Returns a list containing the current leaves."""
+        return self.automate.command("leaves").splitlines()
+
+    def heads(self, name):
+        """Returns a list containing the head revs of branch 'name'."""
+        return self.automate.command("heads", [name]).splitlines()
+
+    def children(self, rev):
+        """Returns a list of the children of rev."""
+        return self.automate.command("children", [rev]).splitlines()
+
+    def parents(self, rev):
+        """Returns a list of the parents of rev."""
+        return self.automate.command("parents", [rev]).splitlines()
+
+    def ancestors(self, revs):
+        """Returns a list of the ancestors of rev."""
+        return self.automate.command("ancestors", revs).splitlines()
+
+    def toposort(self, revs):
+        """Sorts revisions topologically."""
+        return self.automate.command("toposort", revs).splitlines()
+
+    def all_revs(self):
+        """Returns a list of all revs in the repository."""
+        return self.automate.command("select", ['']).splitlines()
+
+    def roots(self):
+        """Returns a list of all root revisions."""
+        if self.roots_cache:
+            return self.roots_cache
+        if self.min_interface_version('4.3'):
+            roots = self.automate.command("roots").splitlines()
+        else:
+            roots = []
+            for line in self.automate.command("graph").splitlines():
+                rev_and_parents = line.split(' ')
+                if len(rev_and_parents) == 1:
+                    roots.append(rev_and_parents[0])
+        self.roots_cache = roots
+        return roots
+
+    def select(self, selector):
+        """Returns a list of revisions selected by the selector."""
+        return self.automate.command("select",
+                 [selector.encode('utf-8')]).splitlines()
+
+    def manifest(self, 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."""
+        raw_manifest = self.automate.command("get_manifest_of", [rev])
+        manifest = {}
+
+        # stanzas have variable length, trigger on next 'path' ...
+        path, kind, content, attrs = None, None, None, {}
+        for key, values in basic_io.items(raw_manifest):
+            if key == 'dir' or key == 'file':
+                if path:
+                    manifest[path] = (kind, content, attrs)
+                path = add_slash(to_unicode(values[0]))
+                kind, content, attrs = key, None, {}
+            elif key == 'content':
+                content = values[0]
+            elif key == 'attrs':
+                attrs[to_unicode(values[0])] = to_unicode(values[1])
+        if path: #  ... or eof
+            manifest[path] = (kind, content, attrs)
+        return manifest
+
+    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."""
+        raw_certs = self.automate.command("certs", [rev])
+        certs = {}
+
+        for key, values in basic_io.items(raw_certs):
+            if key == 'name':
+                name = to_unicode(values[0])
+            elif key == 'value':
+                value = to_unicode(values[0])
+                certs.setdefault(name, []).append(value)
+        return certs
+
+    def get_file(self, file_id):
+        """Returns the file contents for a given file id."""
+        return self.automate.command("get_file", [file_id])
+
+    def file_length(self, file_id):
+        """Return the file length."""
+        return len(self.get_file(file_id))
+
+    def changesets(self, rev):
+        """Parses a textual changeset into an instance of the
+        Changeset class."""
+        raw_changesets = self.automate.command("get_revision", [rev])
+
+        changesets = []
+        oldpath = None
+        for key, values in basic_io.items(raw_changesets):
+            if key == 'old_revision':
+                # start a new changeset
+                changeset = Changeset(values[0])
+                changesets.append(changeset)
+                oldpath = None
+            elif key == 'delete':
+                path = add_slash(to_unicode(values[0]))
+                changeset.deleted.append(path)
+            elif key == 'rename':
+                oldpath = add_slash(to_unicode(values[0]))
+            elif key == 'to':
+                if oldpath != None:
+                    newpath = add_slash(to_unicode(values[0]))
+                    changeset.renamed[newpath] = oldpath
+                    oldpath = None
+            elif key == 'add_dir':
+                path = add_slash(to_unicode(values[0]))
+                changeset.added[path] = 'dir'
+            elif key == 'add_file':
+                path = add_slash(to_unicode(values[0]))
+                changeset.added[path] = 'file'
+            elif key == 'patch':
+                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
+
+    def branchnames(self):
+        """Returns a list of branch names."""
+        return map(to_unicode, #IGNORE:W0141
+           self.automate.command("branches").splitlines())
+
+    def branches(self):
+        """Returns a list of (branch, oneoftheheads) tuples. Caveat:
+        this method is really slow."""
+        branches = []
+        for branch in self.branchnames():
+            revs = self.heads(branch)
+            if revs:
+                branches.append((branch, revs[0]))
+                # multiple heads not supported
+        return branches
+
+    def non_merged_branches(self):
+        """Returns a list of (branch, rev) tuples for all leave revs."""
+        leaves = []
+        for leave in self.leaves():
+            branches = self.certs(leave)['branch']
+            for branch in branches:
+                leaves.append((branch, leave))
+        leaves.sort(key=lambda i: i[0])
+        return leaves
+
+    def tags(self):
+        """Returns a list of tags and their revs."""
+        raw_tags = self.automate.command("tags")
+        tags = []
+
+        for key, values in basic_io.items(raw_tags):
+            if key == 'tag':
+                tag = to_unicode(values[0])
+            elif key == 'revision':
+                revision = values[0]
+                tags.append((tag, revision))
+        tags.sort(key=lambda i: natsort_key(i[0]))
+        return tags
+
+    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."""
+        raw_content_changed = self.automate.command("get_content_changed",
+                                [rev, path[1:]])
+        revs = []
+        for key, values in basic_io.items(raw_content_changed):
+            if key == 'content_mark':
+                revs.append(values[0])
+        return revs
+
+    def get_interface_version(self):
+        """Returns the automation interface version."""
+        if not self.interface_version:
+            self.interface_version = self.automate.command(
+                "interface_version").strip()
+        return self.interface_version
+
+    def min_interface_version(self, v):
+        return natsort_key(self.get_interface_version()) \
+            >= natsort_key(v)
+
+class Changeset(object):
+    """Represents a monotone changeset in parsed form."""
+
+    def __init__(self, oldrev):
+        self.oldrev = oldrev             # the old rev this cs is against
+        self.added = {}                  # nodename -> kind
+        self.renamed = {}                # newname -> oldname
+        self.patched = []                # list of newnames
+        self.deleted = []                # list of oldnames