hgkw/keyword.py
branchself_initializing_hook
changeset 157 64dce6787d82
parent 98 121da9c0a325
child 192 0d2a6c9f8343
equal deleted inserted replaced
98:121da9c0a325 157:64dce6787d82
     1 # keyword.py - keyword expansion for mercurial
     1 # keyword.py - keyword expansion for Mercurial
       
     2 #
       
     3 # Copyright 2007 Christian Ebert <blacktrash@gmx.net>
       
     4 #
       
     5 # This software may be used and distributed according to the terms
       
     6 # of the GNU General Public License, incorporated herein by reference.
       
     7 #
     2 # $Id$
     8 # $Id$
     3 
     9 #
     4 '''keyword expansion hack against the grain of a DSCM
    10 # Keyword expansion hack against the grain of a DSCM
     5 
    11 #
     6 This extension lets you expand RCS/CVS-like keywords in a Mercurial
    12 # There are many good reasons why this is not needed in a distributed
     7 repository.
    13 # SCM, still it may be useful in very small projects based on single
     8 
    14 # files (like LaTeX packages), that are mostly addressed to an audience
     9 There are many good reasons why this is not needed in a distributed
    15 # not running a version control system.
    10 SCM, still it may be useful in very small projects based on single
    16 #
    11 files (like LaTeX packages), that are mostly addressed to an audience
    17 # For in-depth discussion refer to
    12 not running a version control system.
    18 # <http://www.selenic.com/mercurial/wiki/index.cgi/KeywordPlan>.
    13 
    19 #
    14 Supported $keywords$ and their $keyword: substition $ are:
    20 # Keyword expansion is based on Mercurial's changeset template mappings.
    15     Revision: changeset id
    21 # The extension provides an additional UTC-date filter ({date|utcdate}).
    16     Author:   short username
    22 #
    17     Date:     %Y/%m/%d %H:%M:%S [UTC]
    23 # The user has the choice either to create his own keywords and their
    18     RCSFile:  basename,v
    24 # expansions or to use the CVS-like default ones.
    19     Source:   /path/to/basename,v
    25 #
    20     Id:       basename,v csetid %Y/%m/%d %H:%M:%S shortname
    26 # Expansions spanning more than one line are truncated to their first line.
    21     Header:   /path/to/basename,v csetid %Y/%m/%d %H:%M:%S shortname
    27 # Incremental expansion (like CVS' $Log$) is not supported.
    22 
    28 #
    23 Simple setup in hgrc:
    29 # Binary files are not touched.
    24 
    30 #
    25     # enable extension
    31 # Setup in hgrc:
    26     hgext.keyword =
    32 #
    27     # or, if script not in hgext folder:
    33 #     # enable extension
    28     # hgext.keyword = /full/path/to/script
    34 #     keyword = /full/path/to/keyword.py
    29     
    35 #     # or, if script in hgext folder:
    30     # filename patterns for expansion are configured in this section
    36 #     # hgext.keyword =
    31     [keyword]
    37 
    32     **.py = expand
    38 '''keyword expansion in local repositories
    33     ...
    39 
       
    40 This extension expands RCS/CVS-like or self-customized keywords in
       
    41 the text files selected by your configuration.
       
    42 
       
    43 Keywords are only expanded in local repositories and not logged by
       
    44 Mercurial internally. The mechanism can be regarded as a convenience
       
    45 for the current user and may be turned off anytime.
       
    46 
       
    47 The exansion works in 2 modes:
       
    48     1) working mode: substitution takes place on every commit and
       
    49        update of the working repository.
       
    50     2) archive mode: substitution is only triggered by "hg archive".
       
    51 
       
    52 Caveat: "hg import" might fail if the patches were exported from a
       
    53 repo with a different/no keyword setup, whereas "hg unbundle" is
       
    54 safe.
       
    55 
       
    56 Configuration is done in the [keyword] and [keywordmaps] sections of
       
    57 hgrc files.
       
    58 
       
    59 Example:
       
    60      [keyword]
       
    61      # filename patterns for expansion are configured in this section
       
    62      **.py =          ## expand keywords in all python files
       
    63      x* = ignore      ## but ignore files matching "x*"
       
    64      ** = archive     ## keywords in all textfiles are expanded
       
    65                       ## when creating a distribution
       
    66      y* = noarchive   ## keywords in files matching "y*" are not expanded
       
    67                       ## on archive creation
       
    68      ...
       
    69      [keywordmaps]
       
    70      # custom hg template maps _replace_ the CVS-like default ones
       
    71      HGdate = {date|rfc822date}
       
    72      lastlog = {desc} ## same as {desc|firstline} in this context
       
    73      checked in by = {author}
       
    74      ...
       
    75 
       
    76 If no [keywordmaps] are configured the extension falls back on the
       
    77 following defaults:
       
    78 
       
    79      Revision: changeset id
       
    80      Author: username
       
    81      Date: %Y/%m/%d %H:%M:%S      ## [UTC]
       
    82      RCSFile: basename,v
       
    83      Source: /path/to/basename,v
       
    84      Id: basename,v csetid %Y/%m/%d %H:%M:%S username
       
    85      Header: /path/to/basename,v csetid %Y/%m/%d %H:%M:%S username
    34 '''
    86 '''
    35 
    87 
    36 from mercurial import context, util
    88 from mercurial.node import *
    37 import os.path, re, sys, time
    89 try:
    38 
    90     from mercurial.demandload import * # stable
    39 re_kw = re.compile(
    91     from mercurial.i18n import gettext as _
    40         r'\$(Id|Header|Author|Date|Revision|RCSFile|Source)[^$]*?\$')
    92     demandload(globals(), 'mercurial:commands,fancyopts,templater,util')
    41 
    93     demandload(globals(), 'mercurial:cmdutil,context,filelog')
    42 def kwfmatches(ui, repo, files):
    94     demandload(globals(), 'os re sys time')
    43     '''Selects candidates for keyword substitution
    95 except ImportError:                    # demandimport
    44     configured in keyword section in hgrc.'''
    96     from mercurial.i18n import _
    45     files = [f for f in files if not f.startswith('.hg')]
    97     from mercurial import commands, fancyopts, templater, util
    46     if not files:
    98     from mercurial import cmdutil, context, filelog
    47         return []
    99     import os, re, sys, time
    48     candidates = []
   100 
    49     kwfmatchers = [util.matcher(repo.root, '', [pat], [], [])[1]
   101 deftemplates = {
    50             for pat, opt in ui.configitems('keyword') if opt == 'expand']
   102         'Revision': '{node|short}',
    51     for f in files:
   103         'Author': '{author|user}',
    52         for mf in kwfmatchers:
   104         'Date': '{date|utcdate}',
    53             if mf(f):
   105         'RCSFile': '{file|basename},v',
    54                 candidates.append(f)
   106         'Source': '{root}/{file},v',
    55                 break
   107         'Id': '{file|basename},v {node|short} {date|utcdate} {author|user}',
    56     return candidates
   108         'Header': '{root}/{file},v {node|short} {date|utcdate} {author|user}',
       
   109         }
    57 
   110 
    58 def utcdate(date):
   111 def utcdate(date):
    59     '''Returns hgdate in cvs-like UTC format.'''
   112     '''Returns hgdate in cvs-like UTC format.'''
    60     return time.strftime('%Y/%m/%d %H:%M:%S', time.gmtime(date[0]))
   113     return time.strftime('%Y/%m/%d %H:%M:%S', time.gmtime(date[0]))
    61 
   114 
    62 
   115 def getcmd(ui):
    63 class kwfilectx(context.filectx):
   116     '''Returns current hg command.'''
    64     '''
   117     # commands.parse(ui, sys.argv[1:])[0] breaks "hg diff -r"
    65     Provides keyword expansion functions based on file context.
   118     try:
    66     '''
   119         args = fancyopts.fancyopts(sys.argv[1:], commands.globalopts, {})
    67     def __init__(self, repo, path, changeid=None, fileid=None, filelog=None):
   120     except fancyopts.getopt.GetoptError, inst:
    68         context.filectx.__init__(self, repo, path, changeid, fileid, filelog)
   121         raise commands.ParseError(None, inst)
    69     def Revision(self):
   122     if args:
    70         return str(self.changectx())
   123         cmd = args[0]
    71     def Author(self):
   124         aliases, i = commands.findcmd(ui, cmd)
    72         return util.shortuser(self.user())
   125         return aliases[0]
    73     def Date(self):
   126 
    74         return utcdate(self.date())
   127 class kwtemplater(object):
    75     def RCSFile(self):
   128     '''
    76         return os.path.basename(self._path)+',v'
   129     Sets up keyword templates, corresponding keyword regex, and
    77     def Source(self):
   130     provides keyword substitution functions.
    78         return self._repo.wjoin(self._path)+',v'
   131     '''
    79     def Header(self):
   132     def __init__(self, ui, repo):
    80         return ' '.join(
   133         self.ui = ui
    81                 [self.Source(), self.Revision(), self.Date(), self.Author()])
   134         self.repo = repo
    82     def Id(self):
   135         templates = dict(ui.configitems('keywordmaps'))
    83         return ' '.join(
   136         if templates:
    84                 [self.RCSFile(), self.Revision(), self.Date(), self.Author()])
   137             # parse templates here for less overhead in kwsub matchfunc
    85     def expand(self, mobj):
   138             for k in templates.keys():
    86         '''Called from kwexpand, evaluates keyword.'''
   139                 templates[k] = templater.parsestring(templates[k],
       
   140                         quoted=False)
       
   141         self.templates = templates or deftemplates
       
   142         self.re_kw = re.compile(r'\$(%s)[^$]*?\$' %
       
   143                 '|'.join([re.escape(k) for k in self.templates.keys()]))
       
   144         templater.common_filters['utcdate'] = utcdate
       
   145         try:
       
   146             self.t = cmdutil.changeset_templater(ui, repo,
       
   147                     False, '', False)
       
   148         except TypeError:
       
   149             # depending on hg rev changeset_templater has extra "brinfo" arg
       
   150             self.t = cmdutil.changeset_templater(ui, repo,
       
   151                     False, None, '', False)
       
   152 
       
   153     def kwsub(self, mobj, path, node):
       
   154         '''Substitutes keyword using corresponding template.'''
    87         kw = mobj.group(1)
   155         kw = mobj.group(1)
    88         return '$%s: %s $' % (kw, eval('self.%s()' % kw))
   156         self.t.use_template(self.templates[kw])
       
   157         self.ui.pushbuffer()
       
   158         self.t.show(changenode=node, root=self.repo.root, file=path)
       
   159         keywordsub = templater.firstline(self.ui.popbuffer())
       
   160         return '$%s: %s $' % (kw, keywordsub)
       
   161 
       
   162     def expand(self, path, node, filelog, data):
       
   163         '''Returns data with expanded keywords.'''
       
   164         if util.binary(data):
       
   165             return data
       
   166         c = context.filectx(self.repo, path, fileid=node, filelog=filelog)
       
   167         cnode = c.node()
       
   168         return self.re_kw.sub(lambda m: self.kwsub(m, path, cnode), data)
       
   169 
       
   170     def shrink(self, text):
       
   171         '''Returns text with all keyword substitutions removed.'''
       
   172         if util.binary(text):
       
   173             return text
       
   174         return self.re_kw.sub(r'$\1$', text)
       
   175 
       
   176     def overwrite(self, candidates, node):
       
   177         '''Overwrites candidates in working dir expanding keywords.'''
       
   178         for f in candidates:
       
   179             data = self.repo.wfile(f).read()
       
   180             if not util.binary(data):
       
   181                 data, kwct = self.re_kw.subn(lambda m:
       
   182                         self.kwsub(m, f, node), data)
       
   183                 if kwct:
       
   184                     self.ui.debug(_('overwriting %s expanding keywords\n') % f)
       
   185                     self.repo.wfile(f, 'w').write(data)
       
   186 
       
   187 class kwfilelog(filelog.filelog):
       
   188     '''
       
   189     Superclass over filelog to customize its read, add, cmp methods.
       
   190     Keywords are "stored" unexpanded, and expanded on reading.
       
   191     '''
       
   192     def __init__(self, opener, path, kwtemplater):
       
   193         super(kwfilelog, self).__init__(opener, path)
       
   194         self.path = path
       
   195         self.kwtemplater = kwtemplater
       
   196 
       
   197     def read(self, node):
       
   198         '''Substitutes keywords when reading filelog.'''
       
   199         data = super(kwfilelog, self).read(node)
       
   200         return self.kwtemplater.expand(self.path, node, self, data)
       
   201 
       
   202     def add(self, text, meta, tr, link, p1=None, p2=None):
       
   203         '''Removes keyword substitutions when adding to filelog.'''
       
   204         text = self.kwtemplater.shrink(text)
       
   205         return super(kwfilelog, self).add(text,
       
   206                         meta, tr, link, p1=p1, p2=p2)
       
   207 
       
   208     def cmp(self, node, text):
       
   209         '''Removes keyword substitutions for comparison.'''
       
   210         text = self.kwtemplater.shrink(text)
       
   211         return super(kwfilelog, self).cmp(node, text)
    89 
   212 
    90 
   213 
    91 def reposetup(ui, repo):
   214 def reposetup(ui, repo):
    92     from mercurial import filelog, revlog
   215     '''Sets up repo as kwrepo for keyword substitution.
       
   216     Overrides file method to return kwfilelog instead of filelog
       
   217     if file matches user configuration.
       
   218     Uses self-initializing pretxncommit-hook to overwrite configured files with
       
   219     updated keyword substitutions.
       
   220     This is done for local repos only, and only if there are
       
   221     files configured at all for keyword substitution.'''
    93 
   222 
    94     if not repo.local():
   223     if not repo.local():
    95         return
   224         return
       
   225 
       
   226     archivemode = (getcmd(repo.ui) == 'archive')
       
   227 
       
   228     inc, exc, archive, noarchive = [], ['.hg*'], [], ['.hg*']
       
   229     for pat, opt in repo.ui.configitems('keyword'):
       
   230         if opt == 'archive':
       
   231             archive.append(pat)
       
   232         elif opt == 'noarchive':
       
   233             noarchive.append(pat)
       
   234         elif opt == 'ignore':
       
   235             exc.append(pat)
       
   236         else:
       
   237             inc.append(pat)
       
   238     if archivemode:
       
   239         inc, exc = archive, noarchive
       
   240     if not inc:
       
   241         return
       
   242 
       
   243     repo.kwfmatcher = util.matcher(repo.root, inc=inc, exc=exc)[1]
    96 
   244 
    97     class kwrepo(repo.__class__):
   245     class kwrepo(repo.__class__):
    98         def file(self, f):
   246         def file(self, f):
    99             if f[0] == '/':
   247             if f[0] == '/':
   100                 f = f[1:]
   248                 f = f[1:]
   101             return filelog.filelog(self.sopener, f, self, self.revlogversion)
   249             # only use kwfilelog when needed
   102 
   250             if self.kwfmatcher(f):
   103     class kwfilelog(filelog.filelog):
   251                 kwt = kwtemplater(self.ui, self)
   104         '''
   252                 return kwfilelog(self.sopener, f, kwt)
   105         Superclass over filelog to customize it's read, add, cmp methods.
   253             else:
   106         Keywords are "stored" unexpanded, and expanded on reading.
   254                 return filelog.filelog(self.sopener, f)
   107         '''
   255 
   108         def __init__(self, opener, path, repo,
       
   109                      defversion=revlog.REVLOG_DEFAULT_VERSION):
       
   110             super(kwfilelog, self).__init__(opener, path, defversion)
       
   111             self._repo = repo
       
   112             self._path = path
       
   113 
       
   114         def iskwcandidate(self, data):
       
   115             '''Decides whether to act on keywords.'''
       
   116             return (kwfmatches(ui, self._repo, [self._path])
       
   117                     and not util.binary(data))
       
   118 
       
   119         def read(self, node):
       
   120             '''Substitutes keywords when reading filelog.'''
       
   121             data = super(kwfilelog, self).read(node)
       
   122             if self.iskwcandidate(data):
       
   123                 kwfctx = kwfilectx(self._repo, self._path,
       
   124                             fileid=node, filelog=self)
       
   125                 return re_kw.sub(kwfctx.expand, data)
       
   126             return data
       
   127 
       
   128         def add(self, text, meta, tr, link, p1=None, p2=None):
       
   129             '''Removes keyword substitutions when adding to filelog.'''
       
   130             if self.iskwcandidate(text):
       
   131                 text = re_kw.sub(r'$\1$', text)
       
   132             return super(kwfilelog, self).add(text,
       
   133                         meta, tr, link, p1=p1, p2=p2)
       
   134 
       
   135         def cmp(self, node, text):
       
   136             '''Removes keyword substitutions for comparison.'''
       
   137             if self.iskwcandidate(text):
       
   138                 text = re_kw.sub(r'$\1$', text)
       
   139             return super(kwfilelog, self).cmp(node, text)
       
   140 
       
   141     filelog.filelog = kwfilelog
       
   142     repo.__class__ = kwrepo
   256     repo.__class__ = kwrepo
       
   257 
   143     # make pretxncommit hook import kwmodule regardless of where it's located
   258     # make pretxncommit hook import kwmodule regardless of where it's located
   144     for k, v in sys.modules.iteritems():
   259     for k, v in sys.modules.iteritems():
   145         if v is None:
   260         if v is None:
   146             continue
   261             continue
   147         if not hasattr(v, '__file__'):
   262         if not hasattr(v, '__file__'):
   157 
   272 
   158 
   273 
   159 def pretxnkw(ui, repo, hooktype, **args):
   274 def pretxnkw(ui, repo, hooktype, **args):
   160     '''pretxncommit hook that collects candidates for keyword expansion
   275     '''pretxncommit hook that collects candidates for keyword expansion
   161     on commit and expands keywords in working dir.'''
   276     on commit and expands keywords in working dir.'''
   162     from mercurial.i18n import gettext as _
       
   163     # above line for backwards compatibility; can be changed to
       
   164     #   from mercurial.i18n import _
       
   165     # some day
       
   166     from mercurial import cmdutil, commands
       
   167 
   277 
   168     cmd, sysargs, globalopts, cmdopts = commands.parse(ui, sys.argv[1:])[1:]
   278     cmd, sysargs, globalopts, cmdopts = commands.parse(ui, sys.argv[1:])[1:]
   169     if repr(cmd).split()[1] in ('tag', 'import_'):
   279     if repr(cmd).split()[1] in ('tag', 'import_'):
   170         return
   280         return
   171 
   281 
   172     files, match, anypats = cmdutil.matchpats(repo, sysargs, cmdopts)
   282     files, match, anypats = cmdutil.matchpats(repo, sysargs, cmdopts)
   173     modified, added = repo.status(files=files, match=match)[:2]
   283     modified, added = repo.status(files=files, match=match)[:2]
   174 
   284     candidates = [f for f in modified + added if repo.kwfmatcher(f)
   175     for f in kwfmatches(ui, repo, modified+added):
   285             and not os.path.islink(repo.wjoin(f))]
   176         data = repo.wfile(f).read()
   286 
   177         if not util.binary(data):
   287     if candidates:
   178             kwfctx = kwfilectx(repo, f, changeid=args['node'])
   288         kwt = kwtemplater(ui, repo)
   179             data, kwct = re_kw.subn(kwfctx.expand, data)
   289         kwt.overwrite(candidates, bin(args['node']))
   180             if kwct:
       
   181                 ui.debug(_('overwriting %s expanding keywords\n' % f))
       
   182                 repo.wfile(f, 'w').write(data)