hgkw/keyword.py
branchself_initializing_hook
changeset 157 64dce6787d82
parent 98 121da9c0a325
child 192 0d2a6c9f8343
--- a/hgkw/keyword.py	Mon Jan 15 15:38:36 2007 +0100
+++ b/hgkw/keyword.py	Fri Mar 30 09:08:48 2007 +0200
@@ -1,145 +1,260 @@
-# keyword.py - keyword expansion for mercurial
+# keyword.py - keyword expansion for Mercurial
+#
+# Copyright 2007 Christian Ebert <blacktrash@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
+#
+# 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 =
 
-This extension lets you expand RCS/CVS-like keywords in a Mercurial
-repository.
+'''keyword expansion in local repositories
 
-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.
+This extension expands RCS/CVS-like or self-customized keywords in
+the text files selected by your configuration.
 
-Supported $keywords$ and their $keyword: substition $ are:
-    Revision: changeset id
-    Author:   short 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 shortname
-    Header:   /path/to/basename,v csetid %Y/%m/%d %H:%M:%S shortname
+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.
 
-Simple setup in hgrc:
+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}
+     ...
 
-    # enable extension
-    hgext.keyword =
-    # or, if script not in hgext folder:
-    # hgext.keyword = /full/path/to/script
-    
-    # filename patterns for expansion are configured in this section
-    [keyword]
-    **.py = expand
-    ...
+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 import context, util
-import os.path, re, sys, time
-
-re_kw = re.compile(
-        r'\$(Id|Header|Author|Date|Revision|RCSFile|Source)[^$]*?\$')
+from mercurial.node import *
+try:
+    from mercurial.demandload import * # stable
+    from mercurial.i18n import gettext as _
+    demandload(globals(), 'mercurial:commands,fancyopts,templater,util')
+    demandload(globals(), 'mercurial:cmdutil,context,filelog')
+    demandload(globals(), 'os re sys time')
+except ImportError:                    # demandimport
+    from mercurial.i18n import _
+    from mercurial import commands, fancyopts, templater, util
+    from mercurial import cmdutil, context, filelog
+    import os, re, sys, time
 
-def kwfmatches(ui, repo, files):
-    '''Selects candidates for keyword substitution
-    configured in keyword section in hgrc.'''
-    files = [f for f in files if not f.startswith('.hg')]
-    if not files:
-        return []
-    candidates = []
-    kwfmatchers = [util.matcher(repo.root, '', [pat], [], [])[1]
-            for pat, opt in ui.configitems('keyword') if opt == 'expand']
-    for f in files:
-        for mf in kwfmatchers:
-            if mf(f):
-                candidates.append(f)
-                break
-    return candidates
+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 kwfilectx(context.filectx):
+class kwtemplater(object):
     '''
-    Provides keyword expansion functions based on file context.
+    Sets up keyword templates, corresponding keyword regex, and
+    provides keyword substitution functions.
     '''
-    def __init__(self, repo, path, changeid=None, fileid=None, filelog=None):
-        context.filectx.__init__(self, repo, path, changeid, fileid, filelog)
-    def Revision(self):
-        return str(self.changectx())
-    def Author(self):
-        return util.shortuser(self.user())
-    def Date(self):
-        return utcdate(self.date())
-    def RCSFile(self):
-        return os.path.basename(self._path)+',v'
-    def Source(self):
-        return self._repo.wjoin(self._path)+',v'
-    def Header(self):
-        return ' '.join(
-                [self.Source(), self.Revision(), self.Date(), self.Author()])
-    def Id(self):
-        return ' '.join(
-                [self.RCSFile(), self.Revision(), self.Date(), self.Author()])
-    def expand(self, mobj):
-        '''Called from kwexpand, evaluates keyword.'''
+    def __init__(self, ui, repo):
+        self.ui = ui
+        self.repo = repo
+        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
+        try:
+            self.t = cmdutil.changeset_templater(ui, repo,
+                    False, '', False)
+        except TypeError:
+            # depending on hg rev changeset_templater has extra "brinfo" arg
+            self.t = cmdutil.changeset_templater(ui, repo,
+                    False, None, '', False)
+
+    def kwsub(self, mobj, path, node):
+        '''Substitutes keyword using corresponding template.'''
         kw = mobj.group(1)
-        return '$%s: %s $' % (kw, eval('self.%s()' % kw))
+        self.t.use_template(self.templates[kw])
+        self.ui.pushbuffer()
+        self.t.show(changenode=node, root=self.repo.root, file=path)
+        keywordsub = templater.firstline(self.ui.popbuffer())
+        return '$%s: %s $' % (kw, keywordsub)
+
+    def expand(self, path, node, filelog, data):
+        '''Returns data with expanded keywords.'''
+        if util.binary(data):
+            return data
+        c = context.filectx(self.repo, path, fileid=node, filelog=filelog)
+        cnode = c.node()
+        return self.re_kw.sub(lambda m: self.kwsub(m, path, cnode), 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, node):
+        '''Overwrites candidates in working dir expanding keywords.'''
+        for f in candidates:
+            data = self.repo.wfile(f).read()
+            if not util.binary(data):
+                data, kwct = self.re_kw.subn(lambda m:
+                        self.kwsub(m, f, node), data)
+                if kwct:
+                    self.ui.debug(_('overwriting %s expanding keywords\n') % f)
+                    self.repo.wfile(f, 'w').write(data)
+
+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(self.path, 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):
-    from mercurial import filelog, revlog
+    '''Sets up repo as kwrepo for keyword substitution.
+    Overrides file method to return kwfilelog instead of filelog
+    if file matches user configuration.
+    Uses self-initializing pretxncommit-hook 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(repo.ui) == 'archive')
+
+    inc, exc, archive, noarchive = [], ['.hg*'], [], ['.hg*']
+    for pat, opt in repo.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:]
-            return filelog.filelog(self.sopener, f, self, self.revlogversion)
-
-    class kwfilelog(filelog.filelog):
-        '''
-        Superclass over filelog to customize it's read, add, cmp methods.
-        Keywords are "stored" unexpanded, and expanded on reading.
-        '''
-        def __init__(self, opener, path, repo,
-                     defversion=revlog.REVLOG_DEFAULT_VERSION):
-            super(kwfilelog, self).__init__(opener, path, defversion)
-            self._repo = repo
-            self._path = path
-
-        def iskwcandidate(self, data):
-            '''Decides whether to act on keywords.'''
-            return (kwfmatches(ui, self._repo, [self._path])
-                    and not util.binary(data))
+            # only use kwfilelog when needed
+            if self.kwfmatcher(f):
+                kwt = kwtemplater(self.ui, self)
+                return kwfilelog(self.sopener, f, kwt)
+            else:
+                return filelog.filelog(self.sopener, f)
 
-        def read(self, node):
-            '''Substitutes keywords when reading filelog.'''
-            data = super(kwfilelog, self).read(node)
-            if self.iskwcandidate(data):
-                kwfctx = kwfilectx(self._repo, self._path,
-                            fileid=node, filelog=self)
-                return re_kw.sub(kwfctx.expand, data)
-            return data
+    repo.__class__ = kwrepo
 
-        def add(self, text, meta, tr, link, p1=None, p2=None):
-            '''Removes keyword substitutions when adding to filelog.'''
-            if self.iskwcandidate(text):
-                text = re_kw.sub(r'$\1$', text)
-            return super(kwfilelog, self).add(text,
-                        meta, tr, link, p1=p1, p2=p2)
-
-        def cmp(self, node, text):
-            '''Removes keyword substitutions for comparison.'''
-            if self.iskwcandidate(text):
-                text = re_kw.sub(r'$\1$', text)
-            return super(kwfilelog, self).cmp(node, text)
-
-    filelog.filelog = kwfilelog
-    repo.__class__ = kwrepo
     # make pretxncommit hook import kwmodule regardless of where it's located
     for k, v in sys.modules.iteritems():
         if v is None:
@@ -159,11 +274,6 @@
 def pretxnkw(ui, repo, hooktype, **args):
     '''pretxncommit hook that collects candidates for keyword expansion
     on commit and expands keywords in working dir.'''
-    from mercurial.i18n import gettext as _
-    # above line for backwards compatibility; can be changed to
-    #   from mercurial.i18n import _
-    # some day
-    from mercurial import cmdutil, commands
 
     cmd, sysargs, globalopts, cmdopts = commands.parse(ui, sys.argv[1:])[1:]
     if repr(cmd).split()[1] in ('tag', 'import_'):
@@ -171,12 +281,9 @@
 
     files, match, anypats = cmdutil.matchpats(repo, sysargs, cmdopts)
     modified, added = repo.status(files=files, match=match)[:2]
+    candidates = [f for f in modified + added if repo.kwfmatcher(f)
+            and not os.path.islink(repo.wjoin(f))]
 
-    for f in kwfmatches(ui, repo, modified+added):
-        data = repo.wfile(f).read()
-        if not util.binary(data):
-            kwfctx = kwfilectx(repo, f, changeid=args['node'])
-            data, kwct = re_kw.subn(kwfctx.expand, data)
-            if kwct:
-                ui.debug(_('overwriting %s expanding keywords\n' % f))
-                repo.wfile(f, 'w').write(data)
+    if candidates:
+        kwt = kwtemplater(ui, repo)
+        kwt.overwrite(candidates, bin(args['node']))