diff -r b9f2c0853da3 -r 7a775a8f6fb9 hgkw/keyword.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hgkw/keyword.py Wed Jul 18 22:45:50 2007 +0200 @@ -0,0 +1,427 @@ +# keyword.py - keyword expansion for Mercurial +# +# Copyright 2007 Christian Ebert +# +# 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 +# . +# +# Keyword expansion is based on Mercurial's changeset template mappings. +# The extension provides an additional UTC-date filter ({date|utcdate}). +# +# 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 or archive distribution. + +Configuration is done in the [keyword] and [keywordmaps] sections of +hgrc files. + +Example: + [extensions] + hgext.keyword = + + [keyword] + # expand keywords in every python file except those matching "x*" + **.py = + x* = ignore + +Note: the more specific you are in your [keyword] filename patterns + the less you lose speed in huge repos. + +For a [keywordmaps] template mapping and expansion demonstration +run "hg kwdemo". + +An additional date template filter {date|utcdate} is provided. + +You can replace the default template mappings with customized keywords +and templates of your choice. +Again, run "hg kwdemo" to control the results of your config changes. + +When you change keyword configuration, especially the active keywords, +and do not want to store expanded keywords in change history, run +"hg kwshrink", and then change configuration. + +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 gettext as _ +# findcmd might be in cmdutil or commands +# depending on mercurial version +if hasattr(cmdutil, 'findcmd'): + findcmd = cmdutil.findcmd +else: + findcmd = commands.findcmd +import os, re, shutil, sys, tempfile, time + +commands.optionalrepo += ' kwdemo' + +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}', +} + +nokwcommands = ('add', 'addremove', 'bundle', 'clone', 'copy', 'export', + 'incoming', 'outgoing', 'push', 'remove', 'rename', 'rollback') + +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 = findcmd(ui, cmd) + return aliases[0] + +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 not inc: + return None + return util.matcher(repo.root, inc=inc, exc=exc)[1] + +class kwtemplater(object): + ''' + Sets up keyword templates, corresponding keyword regex, and + provides keyword substitution functions. + ''' + def __init__(self, ui, repo, path='', node=None, expand=True): + self.ui = ui + self.repo = repo + 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 deftemplates + escaped = [re.escape(k) for k in self.templates.keys()] + self.re_kw = re.compile(r'\$(%s)[^$]*?\$' % '|'.join(escaped)) + if expand: + 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) + else: + self.t = None + + 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=True): + '''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] + files = [] + for f in candidates: + fp = self.repo.file(f, kwcnt=True, kwexp=expand) + data, cnt = fp.read(man[f]) + if cnt: + notify(_('overwriting %s %s keywords\n') % (f, action)) + try: + self.repo.wwrite(f, data, man.flags(f)) + except AttributeError: + # older versions want file descriptor as 3. optional arg + self.repo.wwrite(f, data) + files.append(f) + if files: + self.repo.dirstate.update(files, 'n') + +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 overwrite(ui, repo, files=None, expand=True): + '''Expands/shrinks keywords in working directory.''' + wlock = repo.wlock() + try: + ctx = repo.changectx() + if not ctx: + raise hg.RepoError(_('no changeset found')) + for changed in repo.status()[:4]: + if changed: + raise util.Abort(_('local changes detected')) + 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 os.path.islink(f)] + if not files: + ui.warn(_('given files not tracked or ' + 'not configured for expansion\n')) + return + kwt = kwtemplater(ui, repo, node=ctx.node(), expand=expand) + kwt.overwrite(files, m, commit=False) + finally: + wlock.release() + + +def shrink(ui, repo, *args): + '''revert expanded keywords in working directory + + run before: + disabling keyword expansion + changing keyword expansion configuration + or if you experience problems with "hg import" + ''' + overwrite(ui, repo, files=args, expand=False) + +def expand(ui, repo, *args): + '''expand keywords in working directory + + run after (re)enabling keyword expansion + ''' + overwrite(ui, repo, files=args) + +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' + tmpdir = tempfile.mkdtemp('', 'kwdemo.') + ui.note(_('creating temporary repo at %s\n') % tmpdir) + _repo = localrepo.localrepository(ui, path=tmpdir, create=True) + # for backwards compatibility + ui = _repo.ui + ui.setconfig('keyword', fn, '') + if opts['default']: + kwstatus = 'default' + kwmaps = deftemplates + 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 deftemplates + if ui.configitems('keywordmaps'): + for k, v in kwmaps.items(): + ui.setconfig('keywordmaps', k, v) + reposetup(ui, _repo) + ui.status(_('config with %s keyword template maps:\n') % kwstatus) + ui.write('[keyword]\n%s =\n[keywordmaps]\n' % 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 --repository '%s' commit\n") % tmpdir) + _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.''' + + # for backwards compatibility + ui = repo.ui + + if not repo.local() or getcmd(ui) in nokwcommands: + return + + kwfmatcher = keywordmatcher(ui, repo) + if kwfmatcher is None: + return + + class kwrepo(repo.__class__): + def file(self, f, kwcnt=False, kwexp=True): + if f[0] == '/': + f = f[1:] + if kwfmatcher(f): + kwt = kwtemplater(ui, self, path=f, expand=kwexp) + return kwfilelog(self.sopener, f, kwt, kwcnt) + 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={}): + wrelease = False + if not wlock: + wlock = self.wlock() + wrelease = True + try: + removed = self.status(node1=p1, node2=p2, files=files, + match=match, wlock=wlock)[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 + + cl = self.changelog.read(node) + candidates = [f for f in cl[3] if kwfmatcher(f) + and f not in removed + and not os.path.islink(self.wjoin(f))] + if candidates: + m = self.manifest.read(cl[0]) + kwt = kwtemplater(ui, self, node=node) + kwt.overwrite(candidates, m) + return node + finally: + if wrelease: + wlock.release() + + 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] ...')), +}