changeset 2155:ff255b41b4aa

support hooks written in python. to write hook in python, create module with hook function inside. make sure mercurial can import module (put it in $PYTHONPATH or load it as extension). hook function should look like this: def myhook(ui, repo, hooktype, **kwargs): if hook_passes: return True elif hook_explicitly_fails: return False elif some_other_failure: import util raise util.Abort('helpful failure message') else: return # implicit return of None makes hook fail! then in .hgrc, add hook with "python:" prefix: [hooks] commit = python:mymodule.myhook
author Vadim Gelfer <vadim.gelfer@gmail.com>
date Fri, 28 Apr 2006 15:50:22 -0700
parents 635653cd73ab
children 628bf85f07ee
files doc/hgrc.5.txt mercurial/localrepo.py tests/test-hook tests/test-hook.out
diffstat 4 files changed, 207 insertions(+), 7 deletions(-) [+]
line wrap: on
line diff
--- a/doc/hgrc.5.txt	Fri Apr 28 14:50:23 2006 -0700
+++ b/doc/hgrc.5.txt	Fri Apr 28 15:50:22 2006 -0700
@@ -131,11 +131,11 @@
     **.txt = tempfile: unix2dos -n INFILE OUTFILE
 
 hooks::
-  Commands that get automatically executed by various actions such as
-  starting or finishing a commit. Multiple commands can be run for
-  the same action by appending a suffix to the action. Overriding a
-  site-wide hook can be done by changing its value or setting it to
-  an empty string.
+  Commands or Python functions that get automatically executed by
+  various actions such as starting or finishing a commit. Multiple
+  hooks can be run for the same action by appending a suffix to the
+  action. Overriding a site-wide hook can be done by changing its
+  value or setting it to an empty string.
 
   Example .hg/hgrc:
 
@@ -211,6 +211,21 @@
   the environment for backwards compatibility, but their use is
   deprecated, and they will be removed in a future release.
 
+  The syntax for Python hooks is as follows:
+
+    hookname = python:modulename.submodule.callable
+
+  Python hooks are run within the Mercurial process.  Each hook is
+  called with at least three keyword arguments: a ui object (keyword
+  "ui"), a repository object (keyword "repo"), and a "hooktype"
+  keyword that tells what kind of hook is used.  Arguments listed as
+  environment variables above are passed as keyword arguments, with no
+  "HG_" prefix, and names in lower case.
+
+  A Python hook must return a "true" value to succeed.  Returning a
+  "false" value or raising an exception is treated as failure of the
+  hook.
+
 http_proxy::
   Used to access web-based Mercurial repositories through a HTTP
   proxy.
--- a/mercurial/localrepo.py	Fri Apr 28 14:50:23 2006 -0700
+++ b/mercurial/localrepo.py	Fri Apr 28 15:50:22 2006 -0700
@@ -11,7 +11,8 @@
 from i18n import gettext as _
 from demandload import *
 demandload(globals(), "appendfile changegroup")
-demandload(globals(), "re lock transaction tempfile stat mdiff errno ui revlog")
+demandload(globals(), "re lock transaction tempfile stat mdiff errno ui")
+demandload(globals(), "revlog sys traceback")
 
 class localrepository(object):
     def __del__(self):
@@ -71,7 +72,59 @@
             os.mkdir(self.join("data"))
 
         self.dirstate = dirstate.dirstate(self.opener, self.ui, self.root)
+
     def hook(self, name, throw=False, **args):
+        def callhook(hname, funcname):
+            '''call python hook. hook is callable object, looked up as
+            name in python module. if callable returns "true", hook
+            passes, else fails. if hook raises exception, treated as
+            hook failure. exception propagates if throw is "true".'''
+
+            self.ui.note(_("calling hook %s: %s\n") % (hname, funcname))
+            d = funcname.rfind('.')
+            if d == -1:
+                raise util.Abort(_('%s hook is invalid ("%s" not in a module)')
+                                 % (hname, funcname))
+            modname = funcname[:d]
+            try:
+                obj = __import__(modname)
+            except ImportError:
+                raise util.Abort(_('%s hook is invalid '
+                                   '(import of "%s" failed)') %
+                                 (hname, modname))
+            try:
+                for p in funcname.split('.')[1:]:
+                    obj = getattr(obj, p)
+            except AttributeError, err:
+                raise util.Abort(_('%s hook is invalid '
+                                   '("%s" is not defined)') %
+                                 (hname, funcname))
+            if not callable(obj):
+                raise util.Abort(_('%s hook is invalid '
+                                   '("%s" is not callable)') %
+                                 (hname, funcname))
+            try:
+                r = obj(ui=ui, repo=repo, hooktype=name, **args)
+            except (KeyboardInterrupt, util.SignalInterrupt):
+                raise
+            except Exception, exc:
+                if isinstance(exc, util.Abort):
+                    self.ui.warn(_('error: %s hook failed: %s\n') %
+                                 (hname, exc.args[0] % exc.args[1:]))
+                else:
+                    self.ui.warn(_('error: %s hook raised an exception: '
+                                   '%s\n') % (hname, exc))
+                if throw:
+                    raise
+                if "--traceback" in sys.argv[1:]:
+                    traceback.print_exc()
+                return False
+            if not r:
+                if throw:
+                    raise util.Abort(_('%s hook failed') % hname)
+                self.ui.warn(_('error: %s hook failed\n') % hname)
+            return r
+
         def runhook(name, cmd):
             self.ui.note(_("running hook %s: %s\n") % (name, cmd))
             env = dict([('HG_' + k.upper(), v) for k, v in args.iteritems()] +
@@ -90,7 +143,10 @@
                  if hname.split(".", 1)[0] == name and cmd]
         hooks.sort()
         for hname, cmd in hooks:
-            r = runhook(hname, cmd) and r
+            if cmd.startswith('python:'):
+                r = callhook(hname, cmd[7:].strip()) and r
+            else:
+                r = runhook(hname, cmd) and r
         return r
 
     def tags(self):
--- a/tests/test-hook	Fri Apr 28 14:50:23 2006 -0700
+++ b/tests/test-hook	Fri Apr 28 15:50:22 2006 -0700
@@ -87,4 +87,93 @@
 echo 'preoutgoing.forbid = echo preoutgoing.forbid hook; exit 1' >> ../a/.hg/hgrc
 hg pull ../a
 
+cat > hooktests.py <<EOF
+from mercurial import util
+
+uncallable = 0
+
+def printargs(args):
+    args.pop('ui', None)
+    args.pop('repo', None)
+    a = list(args.items())
+    a.sort()
+    print 'hook args:'
+    for k, v in a:
+       print ' ', k, v
+    return True
+
+def passhook(**args):
+    printargs(args)
+    return True
+
+def failhook(**args):
+    printargs(args)
+
+class LocalException(Exception):
+    pass
+
+def raisehook(**args):
+    raise LocalException('exception from hook')
+
+def aborthook(**args):
+    raise util.Abort('raise abort from hook')
+
+def brokenhook(**args):
+    return 1 + {}
+
+class container:
+    unreachable = 1
+EOF
+
+echo '# test python hooks'
+PYTHONPATH="$PWD:$PYTHONPATH"
+export PYTHONPATH
+
+echo '[hooks]' > ../a/.hg/hgrc
+echo 'preoutgoing.broken = python:hooktests.brokenhook' >> ../a/.hg/hgrc
+hg pull ../a 2>&1 | grep 'raised an exception'
+
+echo '[hooks]' > ../a/.hg/hgrc
+echo 'preoutgoing.raise = python:hooktests.raisehook' >> ../a/.hg/hgrc
+hg pull ../a 2>&1 | grep 'raised an exception'
+
+echo '[hooks]' > ../a/.hg/hgrc
+echo 'preoutgoing.abort = python:hooktests.aborthook' >> ../a/.hg/hgrc
+hg pull ../a
+
+echo '[hooks]' > ../a/.hg/hgrc
+echo 'preoutgoing.fail = python:hooktests.failhook' >> ../a/.hg/hgrc
+hg pull ../a
+
+echo '[hooks]' > ../a/.hg/hgrc
+echo 'preoutgoing.uncallable = python:hooktests.uncallable' >> ../a/.hg/hgrc
+hg pull ../a
+
+echo '[hooks]' > ../a/.hg/hgrc
+echo 'preoutgoing.nohook = python:hooktests.nohook' >> ../a/.hg/hgrc
+hg pull ../a
+
+echo '[hooks]' > ../a/.hg/hgrc
+echo 'preoutgoing.nomodule = python:nomodule' >> ../a/.hg/hgrc
+hg pull ../a
+
+echo '[hooks]' > ../a/.hg/hgrc
+echo 'preoutgoing.badmodule = python:nomodule.nowhere' >> ../a/.hg/hgrc
+hg pull ../a
+
+echo '[hooks]' > ../a/.hg/hgrc
+echo 'preoutgoing.unreachable = python:hooktests.container.unreachable' >> ../a/.hg/hgrc
+hg pull ../a
+
+echo '[hooks]' > ../a/.hg/hgrc
+echo 'preoutgoing.pass = python:hooktests.passhook' >> ../a/.hg/hgrc
+hg pull ../a
+
+echo '# make sure --traceback works'
+echo '[hooks]' > .hg/hgrc
+echo 'commit.abort = python:hooktests.aborthook' >> .hg/hgrc
+
+echo a >> a
+hg --traceback commit -A -m a 2>&1 | grep '^Traceback'
+
 exit 0
--- a/tests/test-hook.out	Fri Apr 28 14:50:23 2006 -0700
+++ b/tests/test-hook.out	Fri Apr 28 15:50:22 2006 -0700
@@ -89,3 +89,43 @@
 pulling from ../a
 searching for changes
 abort: preoutgoing.forbid hook exited with status 1
+# test python hooks
+error: preoutgoing.broken hook raised an exception: unsupported operand type(s) for +: 'int' and 'dict'
+error: preoutgoing.raise hook raised an exception: exception from hook
+pulling from ../a
+searching for changes
+error: preoutgoing.abort hook failed: raise abort from hook
+abort: raise abort from hook
+pulling from ../a
+searching for changes
+hook args:
+  hooktype preoutgoing
+  source pull
+abort: preoutgoing.fail hook failed
+pulling from ../a
+searching for changes
+abort: preoutgoing.uncallable hook is invalid ("hooktests.uncallable" is not callable)
+pulling from ../a
+searching for changes
+abort: preoutgoing.nohook hook is invalid ("hooktests.nohook" is not defined)
+pulling from ../a
+searching for changes
+abort: preoutgoing.nomodule hook is invalid ("nomodule" not in a module)
+pulling from ../a
+searching for changes
+abort: preoutgoing.badmodule hook is invalid (import of "nomodule" failed)
+pulling from ../a
+searching for changes
+abort: preoutgoing.unreachable hook is invalid (import of "hooktests.container" failed)
+pulling from ../a
+searching for changes
+hook args:
+  hooktype preoutgoing
+  source pull
+adding changesets
+adding manifests
+adding file changes
+added 1 changesets with 1 changes to 1 files
+(run 'hg update' to get a working copy)
+# make sure --traceback works
+Traceback (most recent call last):