[PATCH 1 of 2] keyword (contrib): extension expanding $Keywords$ in working directories

Christian Ebert blacktrash at gmx.net
Sun Jul 22 08:54:10 CDT 2007


# HG changeset patch
# User Christian Ebert <blacktrash at gmx.net>
# Date 1185111980 -7200
# Node ID 341a71fb11dccec8ca59e9926c66cfb0a85fe89d
# Parent  cf67b5f3743d827c70f3d26da2b010f30d647522
keyword (contrib): extension expanding $Keywords$ in working directories

Leaves change history and binary files untouched.
Can be turned on/off anytime.
Flexible interface for customized keywords and expansions
via template mapping.

Apart from doing its main work in the background the extension
provides the following additional commands:
kwdemo:
    show current/default/customized template maps, keywords,
    and expansion
    (documentation of templates is rather sparse)
kwexpand:
    force keyword expansion (in selected files)
    useful after (re)enabling keyword expansion
kwshrink:
    unexpand keywords (in selected files)
    useful before disabling keyword expansion
    or in case of problems with import

diff --git a/contrib/keyword.py b/contrib/keyword.py
new file mode 100644
--- /dev/null
+++ b/contrib/keyword.py
@@ -0,0 +1,406 @@
+# 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.
+#
+# $Id$
+#
+# 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.
+#
+# Binary files are not touched.
+#
+# Setup in hgrc:
+#
+#   [extensions]
+#   # enable extension
+#   keyword = /full/path/to/keyword.py
+#   # or, if script in hgext folder:
+#   # hgext.keyword =
+#
+# Files to act upon/ignore are specified in the [keyword] section.
+# Customized keyword template mappings in the [keywordmaps] section.
+#
+# Run "hg help keyword" and "hg kwdemo" to get info on configuration.
+
+'''keyword expansion in local repositories
+
+This extension expands RCS/CVS-like or self-customized $Keywords$
+in tracked text files selected by your configuration.
+
+Keywords are only expanded in local repositories and not stored in
+the change history. The mechanism can be regarded as a convenience
+for the current user or for archive distribution.
+
+Configuration is done in the [keyword] and [keywordmaps] sections
+of hgrc files.
+
+Example:
+
+    [keyword]
+    # expand keywords in every python file except those matching "x*"
+    **.py =
+    x*    = ignore
+
+Note: the more specific you are in your filename patterns
+      the less you lose speed in huge repos.
+
+For [keywordmaps] template mapping and expansion demonstration and
+control run "hg kwdemo".
+
+An additional date template filter {date|utcdate} is provided.
+
+The default template mappings (view with "hg kwdemo -d") can be replaced
+with customized keywords and templates.
+Again, run "hg kwdemo" to control the results of your config changes.
+
+Before changing/disabling active keywords, run "hg kwshrink" to avoid
+the risk of inadvertedly storing expanded keywords in the change history.
+
+Expansions spanning more than one line and incremental expansions,
+like CVS' $Log$, are not supported. A keyword template map
+"Log = {desc}" expands to the first line of the changeset description.
+
+Caveat: "hg import" fails if the patch context contains an active
+        keyword. In that case run "hg kwshrink", reimport, and then
+        "hg kwexpand".
+        Or, better, use bundle/unbundle to share changes.
+'''
+
+from mercurial import commands, cmdutil, context, fancyopts
+from mercurial import filelog, localrepo, templater, util, hg
+from mercurial.i18n import _
+import re, shutil, sys, tempfile, time
+
+commands.optionalrepo += ' kwdemo'
+
+def utcdate(date):
+    '''Returns hgdate in cvs-like UTC format.'''
+    return time.strftime('%Y/%m/%d %H:%M:%S', time.gmtime(date[0]))
+
+class kwtemplater(object):
+    '''
+    Sets up keyword templates, corresponding keyword regex, and
+    provides keyword substitution functions.
+    '''
+    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 __init__(self, ui, repo, expand, path='', node=None):
+        self.ui = ui
+        self.repo = repo
+        self.t = expand or None
+        self.path = path
+        self.node = node
+
+        templates = dict(ui.configitems('keywordmaps'))
+        if templates:
+            for k in templates.keys():
+                templates[k] = templater.parsestring(templates[k],
+                                                     quoted=False)
+        self.templates = templates or self.deftemplates
+        escaped = [re.escape(k) for k in self.templates.keys()]
+        rawkeyword = r'\$(%s)[^$\n\r]*?\$'
+        self.re_kw = re.compile(rawkeyword % '|'.join(escaped))
+        if self.t:
+            templater.common_filters['utcdate'] = utcdate
+            self.t = cmdutil.changeset_templater(self.ui, self.repo,
+                                                 False, '', False)
+
+    def _ctxnode(self, node):
+        '''Obtains missing node from file context.'''
+        if not self.node:
+            c = context.filectx(self.repo, self.path, fileid=node)
+            self.node = c.node()
+
+    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, data):
+        '''Returns data with keywords expanded.'''
+        if util.binary(data):
+            return data
+        self._ctxnode(node)
+        return self.re_kw.sub(self._kwsub, data)
+
+    def process(self, node, data):
+        '''Returns a tuple: data, count.
+        Count is number of keywords/keyword substitutions.
+        Keywords in data are expanded, if templater was initialized.'''
+        if util.binary(data):
+            return data, None
+        if self.t:
+            self._ctxnode(node)
+            return self.re_kw.subn(self._kwsub, data)
+        return data, self.re_kw.search(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, man, commit):
+        '''Overwrites files in working directory if keywords are detected.
+        Keywords are expanded if keyword templater is initialized,
+        otherwise their substitution is removed.'''
+        expand = self.t is not None
+        action = ('shrinking', 'expanding')[expand]
+        notify = (self.ui.note, self.ui.debug)[commit]
+        for f in candidates:
+            fp = self.repo.file(f, kwexp=expand, kwcnt=True)
+            data, kwfound = fp.read(man[f])
+            if kwfound:
+                notify(_('overwriting %s %s keywords\n') % (f, action))
+                self.repo.wwrite(f, data, man.flags(f))
+                self.repo.dirstate.normal(f)
+
+class kwfilelog(filelog.filelog):
+    '''
+    Subclass of filelog to hook into its read, add, cmp methods.
+    Keywords are "stored" unexpanded, and processed on reading.
+    '''
+    def __init__(self, opener, path, kwtemplater, kwcnt):
+        super(kwfilelog, self).__init__(opener, path)
+        self.kwtemplater = kwtemplater
+        self.kwcnt = kwcnt
+
+    def read(self, node):
+        '''Passes data through kwemplater methods for
+        either unconditional keyword expansion
+        or counting of keywords and substitution method
+        set by the calling overwrite function.'''
+        data = super(kwfilelog, self).read(node)
+        if not self.kwcnt:
+            return self.kwtemplater.expand(node, data)
+        return self.kwtemplater.process(node, 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)
+        if self.renamed(node):
+            t2 = super(kwfilelog, self).read(node)
+            return t2 != text
+        return super(kwfilelog, self).cmp(node, text)
+
+def _keywordmatcher(ui, repo):
+    '''Collects include/exclude filename patterns for expansion
+    candidates of current configuration. Returns filename matching
+    function if include patterns exist, None otherwise.'''
+    inc, exc = [], ['.hg*']
+    for pat, opt in ui.configitems('keyword'):
+        if opt != 'ignore':
+            inc.append(pat)
+        else:
+            exc.append(pat)
+    if inc:
+        return util.matcher(repo.root, inc=inc, exc=exc)[1]
+    return None
+
+def _overwrite(ui, repo, files, expand):
+    '''Expands/shrinks keywords in working directory.'''
+    wlock = lock = None
+    try:
+        wlock = repo.wlock()
+        lock = repo.lock()
+        cmdutil.bail_if_changed(repo)
+        ctx = repo.changectx()
+        if not ctx:
+            raise hg.RepoError(_('no revision checked out'))
+        kwfmatcher = _keywordmatcher(ui, repo)
+        if kwfmatcher is None:
+            ui.warn(_('no files configured for keyword expansion\n'))
+            return
+        m = ctx.manifest()
+        if files:
+            files = [f for f in files if f in m.keys()]
+        else:
+            files = m.keys()
+        files = [f for f in files if kwfmatcher(f) and not repo._link(f)]
+        if not files:
+            ui.warn(_('files not configured for expansion or untracked\n'))
+            return
+        commit = False
+        kwt = kwtemplater(ui, repo, expand, node=ctx.node())
+        kwt.overwrite(files, m, commit)
+    finally:
+        del wlock, lock
+
+
+def shrink(ui, repo, *args):
+    '''revert expanded keywords in working directory
+
+    run before changing/disabling active keywords
+    or if you experience problems with "hg import" or "hg merge"
+    '''
+    expand = False
+    _overwrite(ui, repo, args, expand)
+
+def expand(ui, repo, *args):
+    '''expand keywords in working directory
+
+    run after (re)enabling keyword expansion
+    '''
+    expand = True
+    _overwrite(ui, repo, args, expand)
+
+def demo(ui, repo, *args, **opts):
+    '''print [keywordmaps] configuration and an expansion example
+
+    show current, custom, or default keyword template maps and their expansion
+    '''
+    msg = 'hg keyword config and expansion example'
+    kwstatus = 'current'
+    fn = 'demo.txt'
+    ui.setconfig('keyword', fn, '')
+    if opts['default']:
+        kwstatus = 'default'
+        kwmaps = kwtemplater.deftemplates
+        if ui.configitems('keywordmaps'):
+            for k, v in kwmaps.items():
+                ui.setconfig('keywordmaps', k, v)
+    else:
+        if args or opts['rcfile']:
+            kwstatus = 'custom'
+        for tmap in args:
+            k, v = tmap.split('=', 1)
+            ui.setconfig('keywordmaps', k.strip(), v.strip())
+        if opts['rcfile']:
+            ui.readconfig(opts['rcfile'])
+        kwmaps = (dict(ui.configitems('keywordmaps')) or
+                  kwtemplater.deftemplates)
+    for k, v in ui.configitems('extensions'):
+        if k.endswith('keyword'):
+            extension = '%s = %s' % (k, v)
+            break
+    tmpdir = tempfile.mkdtemp('', 'kwdemo.')
+    ui.note(_('creating temporary repo at %s\n') % tmpdir)
+    repo = localrepo.localrepository(ui, path=tmpdir, create=True)
+    reposetup(ui, repo)
+    ui.status(_('config using %s keyword template maps:\n') % kwstatus)
+    ui.write('[extensions]\n%s\n'
+             '[keyword]\n%s =\n'
+             '[keywordmaps]\n' % (extension, fn))
+    for k, v in kwmaps.items():
+        ui.write('%s = %s\n' % (k, v))
+    path = repo.wjoin(fn)
+    keywords = '$' + '$\n$'.join(kwmaps.keys()) + '$\n'
+    repo.wopener(fn, 'w').write(keywords)
+    repo.add([fn])
+    ui.note(_('\n%s keywords written to %s:\n') % (kwstatus, path))
+    ui.note(keywords)
+    ui.note('\nhg -R "%s" ci -m "%s"\n' % (tmpdir, msg))
+    repo.commit(text=msg)
+    pathinfo = ('', ' in %s' % path)[ui.verbose]
+    ui.status(_('\n%s keywords expanded%s:\n') % (kwstatus, pathinfo))
+    ui.write(repo.wread(fn))
+    ui.debug(_('\nremoving temporary repo %s\n') % tmpdir)
+    shutil.rmtree(tmpdir)
+
+
+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.'''
+
+    nokwcommands = ['add', 'addremove', 'bundle', 'clone', 'copy', 'export',
+                    'grep', 'identify', 'incoming', 'init', 'outgoing', 'push',
+                    'remove', 'rename', 'rollback']
+
+    def _getcmd():
+        # cmdutil.parse(ui, sys.argv[1:])[0] doesn't work for "hg diff -r"
+        args = fancyopts.fancyopts(sys.argv[1:], commands.globalopts, {})
+        if args:
+            cmd = args[0]
+            aliases, i = cmdutil.findcmd(ui, cmd)
+            return aliases[0]
+
+    if not repo.local() or _getcmd() in nokwcommands:
+        return
+
+    kwfmatcher = _keywordmatcher(ui, repo)
+    if kwfmatcher is None:
+        return
+
+    class kwrepo(repo.__class__):
+        def file(self, f, kwexp=True, kwcnt=False):
+            if f[0] == '/':
+                f = f[1:]
+            if kwfmatcher(f):
+                kwt = kwtemplater(ui, self, kwexp, path=f)
+                return kwfilelog(self.sopener, f, kwt, kwcnt)
+            return filelog.filelog(self.sopener, f)
+
+        def commit(self, files=None, text='', user=None, date=None,
+                   match=util.always, force=False, force_editor=False,
+                   p1=None, p2=None, extra={}):
+            wlock = lock = None
+            try:
+                wlock = self.wlock()
+                lock = self.lock()
+                removed = self.status(node1=p1, node2=p2,
+                                      files=files, match=match)[2]
+
+                node = super(kwrepo,
+                             self).commit(files=files, text=text, user=user,
+                                          date=date, match=match, force=force,
+                                          force_editor=force_editor,
+                                          p1=p1, p2=p2, extra=extra)
+                if node is not None:
+                    cl = self.changelog.read(node)
+                    candidates = [f for f in cl[3] if kwfmatcher(f)
+                                  and f not in removed and not self._link(f)]
+                    if candidates:
+                        mn = self.manifest.read(cl[0])
+                        expand = commit = True
+                        kwt = kwtemplater(ui, self, expand, node=node)
+                        kwt.overwrite(candidates, mn, commit)
+                return node
+            finally:
+                del wlock, lock
+
+    repo.__class__ = kwrepo
+
+
+cmdtable = {
+    'kwdemo':
+        (demo,
+         [('d', 'default', None, _('show default keyword template maps')),
+          ('f', 'rcfile', [], _('read maps from RCFILE'))],
+         _('hg kwdemo [-d || [-f RCFILE] TEMPLATEMAP ...]')),
+    'kwshrink': (shrink, [], _('hg kwshrink [NAME] ...')),
+    'kwexpand': (expand, [], _('hg kwexpand [NAME] ...')),
+}


More information about the Mercurial-devel mailing list