[PATCH 1 of 1 hglib POC] hglib: a Python wrapper around hg's cmdserver

Idan Kamara idankk86 at gmail.com
Sun Jul 3 16:30:38 CDT 2011


# HG changeset patch
# User Idan Kamara <idankk86 at gmail.com>
# Date 1309728553 -10800
# Node ID 30c9c93c4174859af7dbb499c7c0c7b1d363dd3b
# Parent  0000000000000000000000000000000000000000
hglib: a Python wrapper around hg's cmdserver

diff --git a/.hgignore b/.hgignore
new file mode 100644
--- /dev/null
+++ b/.hgignore
@@ -0,0 +1,3 @@
+syntax: glob
+
+*.pyc
diff --git a/Makefile b/Makefile
new file mode 100644
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,11 @@
+PYTHON=python
+help:
+				@echo 'Commonly used make targets:'
+				@echo '  tests              - run all tests in the automatic test suite'
+
+all: help
+
+.PHONY: tests
+
+tests:
+				cd tests && $(PYTHON) $(HGREPO)/tests/run-tests.py -l $(TESTFLAGS)
diff --git a/hglib/__init__.py b/hglib/__init__.py
new file mode 100644
diff --git a/hglib/error.py b/hglib/error.py
new file mode 100644
--- /dev/null
+++ b/hglib/error.py
@@ -0,0 +1,15 @@
+class CommandError(Exception):
+    def __init__(self, args, ret, out, err):
+        self.args = args
+        self.ret = ret
+        self.out = out
+        self.err = err
+
+class ServerError(Exception):
+    pass
+
+class ResponseError(ServerError, ValueError):
+    pass
+
+class CapabilityError(ServerError):
+    pass
diff --git a/hglib/hglib.py b/hglib/hglib.py
new file mode 100644
--- /dev/null
+++ b/hglib/hglib.py
@@ -0,0 +1,311 @@
+import subprocess, os, struct, cStringIO, collections
+import error, util
+
+HGPATH = 'hg'
+
+def connect(path=None, encoding=None, configs=None):
+    ''' starts a cmdserver for the given path (or for a repository found in the
+    cwd). HGENCODING is set to the given encoding. configs is a list of key, value,
+    similar to those passed to hg --config. '''
+    return hgclient(path, encoding, configs)
+
+class hgclient(object):
+    inputfmt = '>I'
+    outputfmt = '>cI'
+    outputfmtsize = struct.calcsize(outputfmt)
+    retfmt = '>i'
+
+    # XXX fix this hack
+    _stylesdir = os.path.join(os.path.dirname(__file__), 'styles')
+    revstyle = ['--style', os.path.join(_stylesdir, 'rev.style')]
+
+    revision = collections.namedtuple('revision', 'rev, node, tags, '
+                                                  'branch, author, desc')
+
+    def __init__(self, path, encoding, configs):
+        args = [HGPATH, 'serve', '--cmdserver', 'pipe']
+        if path:
+            args += ['-R', path]
+        if configs:
+            args += ['--config'] + configs
+        env = dict(os.environ)
+        if encoding:
+            env['HGENCODING'] = encoding
+
+        self.server = subprocess.Popen(args, stdin=subprocess.PIPE,
+                                       stdout=subprocess.PIPE, env=env)
+
+        self._readhello()
+        self._config = {}
+
+    def _readhello(self):
+        """ read the hello message the server sends when started """
+        ch, msg = self._readchannel()
+        assert ch == 'o'
+
+        msg = msg.split('\n')
+
+        self.capabilities = msg[0][len('capabilities: '):]
+        if not self.capabilities:
+            raise error.ResponseError("bad hello message: expected 'capabilities: '"
+                                      ", got %r" % msg[0])
+
+        self.capabilities = set(self.capabilities.split())
+
+        # at the very least the server should be able to run commands
+        assert 'runcommand' in self.capabilities
+
+        self._encoding = msg[1][len('encoding: '):]
+        if not self._encoding:
+            raise error.ResponseError("bad hello message: expected 'encoding: '"
+                                      ", got %r" % msg[1])
+
+    def _readchannel(self):
+        data = self.server.stdout.read(hgclient.outputfmtsize)
+        if not data:
+            raise error.ServerError()
+        channel, length = struct.unpack(hgclient.outputfmt, data)
+        if channel in 'IL':
+            return channel, length
+        else:
+            return channel, self.server.stdout.read(length)
+
+    def _parserevs(self, splitted):
+        ''' splitted is a list of fields according to our rev.style, where each 6
+        fields compose one revision. '''
+        return [self.revision._make(rev) for rev in util.grouper(6, splitted)]
+
+    def _eatlines(self, s, n):
+        idx = 0
+        for i in xrange(n):
+            idx = s.find('\n', idx) + 1
+
+        return s[idx:]
+
+    def runcommand(self, args, inchannels, outchannels):
+        def writeblock(data):
+            self.server.stdin.write(struct.pack(self.inputfmt, len(data)))
+            self.server.stdin.write(data)
+            self.server.stdin.flush()
+
+        if not self.server:
+            raise ValueError("server not connected")
+
+        self.server.stdin.write('runcommand\n')
+        writeblock('\0'.join(args))
+
+        while True:
+            channel, data = self._readchannel()
+
+            # input channels
+            if channel in inchannels:
+                writeblock(inchannels[channel](data))
+            # output channels
+            elif channel in outchannels:
+                outchannels[channel](data)
+            # result channel, command finished
+            elif channel == 'r':
+                return struct.unpack(hgclient.retfmt, data)[0]
+            # a channel that we don't know and can't ignore
+            elif channel.isupper():
+                raise error.ResponseError("unexpected data on required channel '%s'"
+                                          % channel)
+            # optional channel
+            else:
+                pass
+
+    def outputruncommand(self, args, inchannels = {}, raiseonerror=True):
+        ''' run the command specified by args, returning (ret, output, error) '''
+        out, err = cStringIO.StringIO(), cStringIO.StringIO()
+        outchannels = {'o' : out.write, 'e' : err.write}
+        ret = self.runcommand(args, inchannels, outchannels)
+        if ret and raiseonerror:
+            raise error.CommandError(args, ret, out.getvalue(), err.getvalue())
+        return ret, out.getvalue(), err.getvalue()
+
+    def close(self):
+        self.server.stdin.close()
+        self.server.wait()
+        ret = self.server.returncode
+        self.server = None
+        return ret
+
+    @property
+    def encoding(self):
+        """ get the servers encoding """
+        if not 'getencoding' in self.capabilities:
+            raise CapabilityError('getencoding')
+
+        if not self._encoding:
+            self.server.stdin.write('getencoding\n')
+            self._encoding = self._readfromchannel('r')
+
+        return self._encoding
+
+    def config(self, refresh=False):
+        if not self._config or refresh:
+            self._config.clear()
+
+            ret, out, err = self.outputruncommand(['showconfig'])
+            if ret:
+                raise error.CommandError(['showconfig'], ret, out, err)
+
+            for entry in cStringIO.StringIO(out):
+                k, v = entry.rstrip().split('=', 1)
+                section, name = k.split('.', 1)
+                self._config.setdefault(section, {})[name] = v
+
+        return self._config
+
+    def status(self):
+        ret, out = self.outputruncommand(['status', '-0'])
+
+        d = dict((c, []) for c in 'MARC!?I')
+
+        for entry in out.split('\0'):
+            if entry:
+                t, f = entry.split(' ', 1)
+                d[t].append(f)
+
+        return d
+
+    def log(self, revrange=None):
+        args = ['log'] + self.revstyle
+        if revrange:
+            args.append('-r')
+            args += revrange
+
+        out = self.outputruncommand(args)[1]
+        out = out.split('\0')[:-1]
+
+        return self._parserevs(out)
+
+    def incoming(self, revrange=None, path=None):
+        args = ['incoming'] + self.revstyle
+        if revrange:
+            args.append('-r')
+            args += revrange
+
+        if path:
+            args += [path]
+
+        ret, out, err = self.outputruncommand(args, raiseonerror=False)
+        if not ret:
+            out = self._eatlines(out, 2).split('\0')[:-1]
+            return self._parserevs(out)
+        elif ret == 1:
+            return []
+        else:
+            raise error.CommandError(args, ret, out, err)
+
+    def outgoing(self, revrange=None, path=None):
+        args = ['outgoing'] + self.revstyle
+        if revrange:
+            args.append('-r')
+            args += revrange
+
+        if path:
+            args += [path]
+
+        ret, out, err = self.outputruncommand(args, raiseonerror=False)
+        if not ret:
+            out = self._eatlines(out, 2).split('\0')[:-1]
+            return self._parserevs(out)
+        elif ret == 1:
+            return []
+        else:
+            raise error.CommandError(args, ret, out, err)
+
+    def commit(self, message, addremove=False):
+        args = ['commit', '-m', message]
+
+        if addremove:
+            args += ['-A']
+
+        self.outputruncommand(args)
+
+        # hope the tip hasn't changed since we committed
+        return self.tip()
+
+    def import_(self, patch):
+        if isinstance(patch, str):
+            fp = open(patch)
+        else:
+            assert hasattr(patch, 'read')
+            assert hasattr(patch, 'readline')
+
+            fp = patch
+
+        try:
+            inchannels = {'I' : fp.read, 'L' : fp.readline}
+            self.outputruncommand(['import', '-'], inchannels)
+        finally:
+            if fp != patch:
+                fp.close()
+
+    def root(self):
+        return self.outputruncommand(['root'])[1].rstrip()
+
+    def clone(self, source='.', dest=None, branch=None, updaterev=None,
+              revrange=None):
+        args = ['clone']
+
+        if branch:
+            args += ['-b', branch]
+        if updaterev:
+            args += ['-u', updaterev]
+        if revrange:
+            args.append('-r')
+            args += revrange
+        args.append(source)
+
+        if dest:
+            args.append(dest)
+
+        self.outputruncommand(args)
+
+    def tip(self):
+        out = self.outputruncommand(['tip'] + self.revstyle)[1]
+        out = out.split('\0')
+
+        return self._parserevs(out)[0]
+
+    def branch(self, name=None):
+        if not name:
+            return self.outputruncommand(['branch'])[1].rstrip()
+
+    def branches(self):
+        out = self.outputruncommand(['branches'])[1]
+        branches = {}
+        for line in out.rstrip().split('\n'):
+            branch, revnode = line.split()
+            branches[branch] = self.log(revrange=[revnode.split(':')[0]])[0]
+
+        return branches
+
+    def paths(self, name=None):
+        if not name:
+            out = self.outputruncommand(['paths'])[1]
+            if not out:
+                return {}
+
+            return dict([s.split(' = ') for s in out.rstrip().split('\n')])
+        else:
+            args = ['paths', name]
+            ret, out, err = self.outputruncommand(args, raiseonerror=False)
+            if ret:
+                raise error.CommandError(args, ret, out, err)
+            return out.rstrip()
+
+    def cat(self, files, rev=None, output=None):
+        args = ['cat']
+        if rev:
+            args += ['-r', rev]
+        if output:
+            args += ['-o', output]
+
+        args += files
+        ret, out, err = self.outputruncommand(args)
+
+        if not output:
+            return out
diff --git a/hglib/styles/rev.style b/hglib/styles/rev.style
new file mode 100644
--- /dev/null
+++ b/hglib/styles/rev.style
@@ -0,0 +1,1 @@
+changeset = '{rev}\0{node}\0{tags}\0{branch}\0{author}\0{desc}\0'
diff --git a/hglib/util.py b/hglib/util.py
new file mode 100644
--- /dev/null
+++ b/hglib/util.py
@@ -0,0 +1,6 @@
+import itertools
+
+def grouper(n, iterable):
+    ''' list(grouper(2, range(4))) -> [(0, 1), (2, 3)] '''
+    args = [iter(iterable)] * n
+    return itertools.izip(*args)
diff --git a/tests/test-hglib.py b/tests/test-hglib.py
new file mode 100644
--- /dev/null
+++ b/tests/test-hglib.py
@@ -0,0 +1,114 @@
+#!/usr/bin/env python
+
+import unittest
+
+import sys, os, subprocess, cStringIO, shutil, tempfile
+
+# XXX fix this hack
+sys.path.append(os.path.dirname(os.path.abspath(__file__)) + '/../')
+from hglib import hglib
+
+class test_hglib(unittest.TestCase):
+    def setUp(self):
+        self._tmpdir = tempfile.mkdtemp()
+        os.chdir(self._tmpdir)
+        # until we can run norepo commands in the cmdserver
+        os.system('hg init')
+        self.client = hglib.connect()
+
+    def tearDown(self):
+        shutil.rmtree(self._tmpdir)
+
+    def append(self, path, *args):
+        f = open(path, 'a')
+        for a in args:
+            f.write(str(a))
+        f.close()
+
+    def test_log(self):
+        self.append('a', 'a')
+        rev0 = self.client.commit('first', addremove=True)
+        self.append('a', 'a')
+        rev1 = self.client.commit('second')
+
+        revs = self.client.log()
+        revs.reverse()
+
+        self.assertTrue(len(revs) == 2)
+        self.assertEquals(revs[1], rev1)
+
+        self.assertEquals(revs[0], self.client.log('0')[0])
+
+    def test_outgoing_incoming(self):
+        self.append('a', 'a')
+        self.client.commit('first', addremove=True)
+        self.append('a', 'a')
+        self.client.commit('second')
+
+        self.client.clone(dest='bar')
+        bar = hglib.connect('bar')
+
+        self.assertEquals(self.client.log(), bar.log())
+        self.assertEquals(self.client.outgoing(path='bar'), bar.incoming())
+
+        self.append('a', 'a')
+        rev = self.client.commit('third')
+        out = self.client.outgoing(path='bar')
+
+        self.assertEquals(len(out), 1)
+        self.assertEquals(out[0], rev)
+
+        self.assertEquals(out, bar.incoming())
+
+    def test_branch(self):
+        self.assertEquals(self.client.branch(), 'default')
+        self.append('a', 'a')
+        rev = self.client.commit('first', addremove=True)
+        branches = self.client.branches()
+
+        self.assertEquals(rev, branches[rev.branch])
+
+    def test_encoding(self):
+        self.client = hglib.connect(encoding='utf-8')
+        self.assertEquals(self.client.encoding, 'utf-8')
+
+    def test_paths(self):
+        open('.hg/hgrc', 'a').write('[paths]\nfoo = bar\n')
+
+        # hgrc isn't watched for changes yet, have to reconnect
+        self.client = hglib.connect()
+        paths = self.client.paths()
+        self.assertEquals(len(paths), 1)
+        self.assertEquals(paths['foo'], os.path.abspath('bar'))
+        self.assertEquals(self.client.paths('foo'), os.path.abspath('bar'))
+
+    def test_import(self):
+        patch = """
+# HG changeset patch
+# User test
+# Date 0 0
+# Node ID c103a3dec114d882c98382d684d8af798d09d857
+# Parent  0000000000000000000000000000000000000000
+1
+
+diff -r 000000000000 -r c103a3dec114 a
+--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
++++ b/a	Thu Jan 01 00:00:00 1970 +0000
+@@ -0,0 +1,1 @@
++1
+"""
+        self.client.import_(cStringIO.StringIO(patch))
+        self.assertEquals(self.client.cat(['a']), '1\n')
+
+if __name__ == '__main__':
+    stream = cStringIO.StringIO()
+    runner = unittest.TextTestRunner(stream=stream, verbosity=0)
+
+    # XXX fix this
+    module = __import__('__main__')
+    loader = unittest.TestLoader()
+    ret = not runner.run(loader.loadTestsFromModule(module)).wasSuccessful()
+    if ret:
+        print stream.getvalue()
+
+    sys.exit(ret)


More information about the Mercurial-devel mailing list