[PATCH] contrib: add keyword extension including test

Christian Ebert blacktrash at gmx.net
Wed May 2 07:37:31 CDT 2007


# HG changeset patch
# User Christian Ebert <blacktrash at gmx.net>
# Date 1178109317 -7200
# Node ID 646b5593f32a989c32013373f0d4366bfb4bc84a
# Parent  15289406f89c3866e5a1e1ce7d666a7555a23631
contrib: add keyword extension including test

caveat:
Importing changesets that were exported from repos with different
keyword settings may result in conflicts in case the diff contains
lines containing active keywords.
This can be avoided by:
a) using "hg (un)bundle".
b) syncronizing keyword settings of repos in question.
Dirty workaround:
Disable keyword expansion in working dir; remove files in question;
hg update -C; hg import; remove files; reenable expansion; update.

todo:
Expand keywords when downloading compressed archives.
May not be feasable via extension.

diff --git a/contrib/keyword.py b/contrib/keyword.py
new file mode 100644
--- /dev/null
+++ b/contrib/keyword.py
@@ -0,0 +1,278 @@
+# keyword.py - keyword expansion for Mercurial
+#
+# Copyright 2007 Christian Ebert <blacktrash at gmx.net>
+#
+# This software may be used and distributed according to the terms
+# of the GNU General Public License, incorporated herein by reference.
+#
+# Keyword expansion hack against the grain of a DSCM
+#
+# There are many good reasons why this is not needed in a distributed
+# SCM, still it may be useful in very small projects based on single
+# files (like LaTeX packages), that are mostly addressed to an audience
+# not running a version control system.
+#
+# For in-depth discussion refer to
+# <http://www.selenic.com/mercurial/wiki/index.cgi/KeywordPlan>.
+#
+# Keyword expansion is based on Mercurial's changeset template mappings.
+# The extension provides an additional UTC-date filter ({date|utcdate}).
+#
+# The user has the choice either to create his own keywords and their
+# expansions or to use the CVS-like default ones.
+#
+# Expansions spanning more than one line are truncated to their first line.
+# Incremental expansion (like CVS' $Log$) is not supported.
+#
+# Binary files are not touched.
+#
+# Setup in hgrc:
+#
+#     # enable extension
+#     keyword = /full/path/to/keyword.py
+#     # or, if script in hgext folder:
+#     # hgext.keyword =
+
+'''keyword expansion in local repositories
+
+This extension expands RCS/CVS-like or self-customized keywords in
+the text files selected by your configuration.
+
+Keywords are only expanded in local repositories and not logged by
+Mercurial internally. The mechanism can be regarded as a convenience
+for the current user and may be turned off anytime.
+
+The exansion works in 2 modes:
+    1) working mode: substitution takes place on every commit and
+       update of the working repository.
+    2) archive mode: substitution is only triggered by "hg archive".
+
+Caveat: "hg import" might fail if the patches were exported from a
+repo with a different/no keyword setup, whereas "hg unbundle" is
+safe.
+
+Configuration is done in the [keyword] and [keywordmaps] sections of
+hgrc files.
+
+Example:
+     [keyword]
+     # filename patterns for expansion are configured in this section
+     **.py =          ## expand keywords in all python files
+     x* = ignore      ## but ignore files matching "x*"
+     ** = archive     ## keywords in all textfiles are expanded
+                      ## when creating a distribution
+     y* = noarchive   ## keywords in files matching "y*" are not expanded
+                      ## on archive creation
+     ...
+     [keywordmaps]
+     # custom hg template maps _replace_ the CVS-like default ones
+     HGdate = {date|rfc822date}
+     lastlog = {desc} ## same as {desc|firstline} in this context
+     checked in by = {author}
+     ...
+
+If no [keywordmaps] are configured the extension falls back on the
+following defaults:
+
+     Revision: changeset id
+     Author: username
+     Date: %Y/%m/%d %H:%M:%S      ## [UTC]
+     RCSFile: basename,v
+     Source: /path/to/basename,v
+     Id: basename,v csetid %Y/%m/%d %H:%M:%S username
+     Header: /path/to/basename,v csetid %Y/%m/%d %H:%M:%S username
+'''
+
+from mercurial.i18n import _
+from mercurial import commands, fancyopts, templater, util
+from mercurial import cmdutil, context, filelog
+import re, sys, time
+
+deftemplates = {
+        'Revision': '{node|short}',
+        'Author': '{author|user}',
+        'Date': '{date|utcdate}',
+        'RCSFile': '{file|basename},v',
+        'Source': '{root}/{file},v',
+        'Id': '{file|basename},v {node|short} {date|utcdate} {author|user}',
+        'Header': '{root}/{file},v {node|short} {date|utcdate} {author|user}',
+        }
+
+def utcdate(date):
+    '''Returns hgdate in cvs-like UTC format.'''
+    return time.strftime('%Y/%m/%d %H:%M:%S', time.gmtime(date[0]))
+
+def getcmd(ui):
+    '''Returns current hg command.'''
+    # commands.parse(ui, sys.argv[1:])[0] breaks "hg diff -r"
+    try:
+        args = fancyopts.fancyopts(sys.argv[1:], commands.globalopts, {})
+    except fancyopts.getopt.GetoptError, inst:
+        raise commands.ParseError(None, inst)
+    if args:
+        cmd = args[0]
+        aliases, i = commands.findcmd(ui, cmd)
+        return aliases[0]
+
+class kwtemplater(object):
+    '''
+    Sets up keyword templates, corresponding keyword regex, and
+    provides keyword substitution functions.
+    '''
+    def __init__(self, ui, repo, path='', node=None):
+        self.ui = ui
+        self.repo = repo
+        self.path = path
+        self.node = node
+        templates = dict(ui.configitems('keywordmaps'))
+        if templates:
+            # parse templates here for less overhead in kwsub matchfunc
+            for k in templates.keys():
+                templates[k] = templater.parsestring(templates[k],
+                        quoted=False)
+        self.templates = templates or deftemplates
+        self.re_kw = re.compile(r'\$(%s)[^$]*?\$' %
+                '|'.join([re.escape(k) for k in self.templates.keys()]))
+        templater.common_filters['utcdate'] = utcdate
+        self.t = cmdutil.changeset_templater(ui, repo, False, '', False)
+
+    def kwsub(self, mobj):
+        '''Substitutes keyword using corresponding template.'''
+        kw = mobj.group(1)
+        self.t.use_template(self.templates[kw])
+        self.ui.pushbuffer()
+        self.t.show(changenode=self.node, root=self.repo.root, file=self.path)
+        keywordsub = templater.firstline(self.ui.popbuffer())
+        return '$%s: %s $' % (kw, keywordsub)
+
+    def expand(self, node, flog, data):
+        '''Returns data with expanded keywords.'''
+        if util.binary(data):
+            return data
+        c = context.filectx(self.repo, self.path, fileid=node, filelog=flog)
+        self.node = c.node()
+        return self.re_kw.sub(self.kwsub, data)
+
+    def shrink(self, text):
+        '''Returns text with all keyword substitutions removed.'''
+        if util.binary(text):
+            return text
+        return self.re_kw.sub(r'$\1$', text)
+
+    def overwrite(self, candidates):
+        '''Overwrites candidates in working dir expanding keywords.'''
+        files = []
+        for f in candidates:
+            data = self.repo.wfile(f).read()
+            if not util.binary(data):
+                self.path = f
+                data, kwct = self.re_kw.subn(self.kwsub, data)
+                if kwct:
+                    self.ui.debug(_('overwriting %s expanding keywords\n') % f)
+                    self.repo.wfile(f, 'w').write(data)
+                    files.append(f)
+        if files:
+            self.repo.dirstate.update(files, 'n')
+
+class kwfilelog(filelog.filelog):
+    '''
+    Superclass over filelog to customize its read, add, cmp methods.
+    Keywords are "stored" unexpanded, and expanded on reading.
+    '''
+    def __init__(self, opener, path, kwtemplater):
+        super(kwfilelog, self).__init__(opener, path)
+        self.path = path
+        self.kwtemplater = kwtemplater
+
+    def read(self, node):
+        '''Substitutes keywords when reading filelog.'''
+        data = super(kwfilelog, self).read(node)
+        return self.kwtemplater.expand(node, self, data)
+
+    def add(self, text, meta, tr, link, p1=None, p2=None):
+        '''Removes keyword substitutions when adding to filelog.'''
+        text = self.kwtemplater.shrink(text)
+        return super(kwfilelog, self).add(text,
+                        meta, tr, link, p1=p1, p2=p2)
+
+    def cmp(self, node, text):
+        '''Removes keyword substitutions for comparison.'''
+        text = self.kwtemplater.shrink(text)
+        return super(kwfilelog, self).cmp(node, text)
+
+
+def reposetup(ui, repo):
+    '''Sets up repo as kwrepo for keyword substitution.
+    Overrides file method to return kwfilelog instead of filelog
+    if file matches user configuration.
+    Wraps commit to overwrite configured files with updated
+    keyword substitutions.
+    This is done for local repos only, and only if there are
+    files configured at all for keyword substitution.'''
+
+    if not repo.local():
+        return
+
+    archivemode = (getcmd(ui) == 'archive')
+
+    inc, exc, archive, noarchive = [], ['.hg*'], [], ['.hg*']
+    for pat, opt in ui.configitems('keyword'):
+        if opt == 'archive':
+            archive.append(pat)
+        elif opt == 'noarchive':
+            noarchive.append(pat)
+        elif opt == 'ignore':
+            exc.append(pat)
+        else:
+            inc.append(pat)
+    if archivemode:
+        inc, exc = archive, noarchive
+    if not inc:
+        return
+
+    repo.kwfmatcher = util.matcher(repo.root, inc=inc, exc=exc)[1]
+
+    class kwrepo(repo.__class__):
+        def file(self, f):
+            if f[0] == '/':
+                f = f[1:]
+            # only use kwfilelog when needed
+            if self.kwfmatcher(f):
+                kwt = kwtemplater(self.ui, self, path=f)
+                return kwfilelog(self.sopener, f, kwt)
+            else:
+                return filelog.filelog(self.sopener, f)
+
+        def commit(self, files=None, text="", user=None, date=None,
+                match=util.always, force=False, lock=None, wlock=None,
+                force_editor=False, p1=None, p2=None, extra={}):
+            '''Wraps commit, expanding keywords of committed and
+            configured files in working directory.'''
+            wrelease = False
+            if not wlock:
+                wlock = self.wlock()
+                wrelease = True
+            try:
+                removed = self.status()[2]
+
+                node = super(kwrepo, self).commit(files=files,
+                        text=text, user=user, date=date,
+                        match=match, force=force, lock=lock, wlock=wlock,
+                        force_editor=force_editor, p1=p1, p2=p2, extra=extra)
+                if node is None:
+                    return node
+
+                candidates = self.changelog.read(node)[3]
+                candidates = [f for f in candidates if self.kwfmatcher(f)
+                        and f not in removed and not self._link(f)]
+                if not candidates:
+                    return node
+
+                kwt = kwtemplater(self.ui, self, node=node)
+                kwt.overwrite(candidates)
+                return node
+            finally:
+                if wrelease:
+                    wlock.release()
+
+    repo.__class__ = kwrepo
diff --git a/tests/test-keyword b/tests/test-keyword
new file mode 100755
--- /dev/null
+++ b/tests/test-keyword
@@ -0,0 +1,107 @@
+#!/bin/sh
+
+cat <<EOF >> $HGRCPATH
+[extensions]
+keyword = ${TESTDIR}/../contrib/keyword.py
+[keyword]
+a =
+b = ignore
+c = archive
+EOF
+
+echo % help
+hg help keyword
+
+hg init Test
+cd Test
+echo 'expand $Id$' > a
+echo 'ignore $Id$' > b
+echo 'archive $Id$' > c
+ln -s a sym
+echo % cat
+cat a b c sym
+
+echo % default keyword expansion
+echo % commit
+hg --debug commit -A -mabcsym -d '0 0' -u 'User Name <user at example.com>'
+echo % status
+hg status
+
+echo % cat
+cat a b c sym
+echo % hg cat
+hg cat a b c sym
+
+echo
+rm a b c sym
+echo % update
+hg update
+echo % cat
+cat a b c sym
+
+echo % archive
+hg archive ../Archive
+cd ../Archive
+echo % cat
+cat a b c sym
+echo
+cd -
+
+echo % custom keyword expansion
+cat <<EOF >>$HGRCPATH
+[keywordmaps]
+Id = {file} {node|short} {date|rfc822date} {author|user}
+Xinfo = {author}: {desc}
+EOF
+
+echo % cat
+cat a b c sym
+echo % hg cat
+hg cat a b c sym
+
+echo
+echo '$Xinfo$' >> a
+cat <<EOF >> log
+firstline
+secondline
+EOF
+
+echo % commit
+hg --debug commit -l log -d '1 0' -u 'User Name <user at example.com>'
+rm log
+echo % status
+hg status
+
+echo % cat
+cat a b c sym
+echo % hg cat
+hg cat a b c sym
+
+echo
+cd ..
+hg clone -r0 Test Test-a
+cd Test-a
+cat <<EOF >> .hg/hgrc
+[paths]
+default = ../Test
+EOF
+echo % incoming
+# remove path to temp dir
+hg incoming | sed -e 's/^\(comparing with \).*\(test-keyword.*\)/\1\2/'
+
+echo % switch off expansion
+rm $HGRCPATH
+
+cd ../Test
+echo % cat
+cat a b c sym
+echo % hg cat
+hg cat a b c sym
+
+echo
+echo % update
+rm a b c sym
+hg update
+
+echo % cat
+cat a b c sym
diff --git a/tests/test-keyword.out b/tests/test-keyword.out
new file mode 100644
--- /dev/null
+++ b/tests/test-keyword.out
@@ -0,0 +1,157 @@
+% help
+keyword extension - keyword expansion in local repositories
+
+This extension expands RCS/CVS-like or self-customized keywords in
+the text files selected by your configuration.
+
+Keywords are only expanded in local repositories and not logged by
+Mercurial internally. The mechanism can be regarded as a convenience
+for the current user and may be turned off anytime.
+
+The exansion works in 2 modes:
+    1) working mode: substitution takes place on every commit and
+       update of the working repository.
+    2) archive mode: substitution is only triggered by "hg archive".
+
+Caveat: "hg import" might fail if the patches were exported from a
+repo with a different/no keyword setup, whereas "hg unbundle" is
+safe.
+
+Configuration is done in the [keyword] and [keywordmaps] sections of
+hgrc files.
+
+Example:
+     [keyword]
+     # filename patterns for expansion are configured in this section
+     **.py =          ## expand keywords in all python files
+     x* = ignore      ## but ignore files matching "x*"
+     ** = archive     ## keywords in all textfiles are expanded
+                      ## when creating a distribution
+     y* = noarchive   ## keywords in files matching "y*" are not expanded
+                      ## on archive creation
+     ...
+     [keywordmaps]
+     # custom hg template maps _replace_ the CVS-like default ones
+     HGdate = {date|rfc822date}
+     lastlog = {desc} ## same as {desc|firstline} in this context
+     checked in by = {author}
+     ...
+
+If no [keywordmaps] are configured the extension falls back on the
+following defaults:
+
+     Revision: changeset id
+     Author: username
+     Date: %Y/%m/%d %H:%M:%S      ## [UTC]
+     RCSFile: basename,v
+     Source: /path/to/basename,v
+     Id: basename,v csetid %Y/%m/%d %H:%M:%S username
+     Header: /path/to/basename,v csetid %Y/%m/%d %H:%M:%S username
+
+no commands defined
+% cat
+expand $Id$
+ignore $Id$
+archive $Id$
+expand $Id$
+% default keyword expansion
+% commit
+adding a
+adding b
+adding c
+adding sym
+a
+b
+c
+sym
+overwriting a expanding keywords
+% status
+% cat
+expand $Id: a,v 99e97605039e 1970/01/01 00:00:00 user $
+ignore $Id$
+archive $Id$
+expand $Id: a,v 99e97605039e 1970/01/01 00:00:00 user $
+% hg cat
+expand $Id: a,v 99e97605039e 1970/01/01 00:00:00 user $
+ignore $Id$
+archive $Id$
+a
+% update
+4 files updated, 0 files merged, 0 files removed, 0 files unresolved
+% cat
+expand $Id: a,v 99e97605039e 1970/01/01 00:00:00 user $
+ignore $Id$
+archive $Id$
+expand $Id: a,v 99e97605039e 1970/01/01 00:00:00 user $
+% archive
+% cat
+expand $Id$
+ignore $Id$
+archive $Id: c,v 99e97605039e 1970/01/01 00:00:00 user $
+a
+% custom keyword expansion
+% cat
+expand $Id: a,v 99e97605039e 1970/01/01 00:00:00 user $
+ignore $Id$
+archive $Id$
+expand $Id: a,v 99e97605039e 1970/01/01 00:00:00 user $
+% hg cat
+expand $Id: a 99e97605039e Thu, 01 Jan 1970 00:00:00 +0000 user $
+ignore $Id$
+archive $Id$
+a
+% commit
+a
+overwriting a expanding keywords
+% status
+% cat
+expand $Id: a 001dbcdc5258 Thu, 01 Jan 1970 00:00:01 +0000 user $
+$Xinfo: User Name <user at example.com>: firstline $
+ignore $Id$
+archive $Id$
+expand $Id: a 001dbcdc5258 Thu, 01 Jan 1970 00:00:01 +0000 user $
+$Xinfo: User Name <user at example.com>: firstline $
+% hg cat
+expand $Id: a 001dbcdc5258 Thu, 01 Jan 1970 00:00:01 +0000 user $
+$Xinfo: User Name <user at example.com>: firstline $
+ignore $Id$
+archive $Id$
+a
+requesting all changes
+adding changesets
+adding manifests
+adding file changes
+added 1 changesets with 4 changes to 4 files
+4 files updated, 0 files merged, 0 files removed, 0 files unresolved
+% incoming
+comparing with test-keyword/Test-a/../Test
+searching for changes
+changeset:   1:001dbcdc5258
+tag:         tip
+user:        User Name <user at example.com>
+date:        Thu Jan 01 00:00:01 1970 +0000
+summary:     firstline
+
+% switch off expansion
+% cat
+expand $Id: a 001dbcdc5258 Thu, 01 Jan 1970 00:00:01 +0000 user $
+$Xinfo: User Name <user at example.com>: firstline $
+ignore $Id$
+archive $Id$
+expand $Id: a 001dbcdc5258 Thu, 01 Jan 1970 00:00:01 +0000 user $
+$Xinfo: User Name <user at example.com>: firstline $
+% hg cat
+expand $Id$
+$Xinfo$
+ignore $Id$
+archive $Id$
+a
+% update
+4 files updated, 0 files merged, 0 files removed, 0 files unresolved
+% cat
+expand $Id$
+$Xinfo$
+ignore $Id$
+archive $Id$
+expand $Id$
+$Xinfo$


More information about the Mercurial-devel mailing list