view extensions/kwarchive.py @ 125:fb7e1e4e4d2c

Provide some SCCS markers also to be able to search with "what(1)" also
author Franz Glasner <hg@dom66.de>
date Fri, 17 Aug 2018 09:07:02 +0200
parents 46b7d34ef0b2
children 0a6681db2772
line wrap: on
line source

# -*- coding: utf-8 -*-
# @(#) $HGheader$
# $HGnodeid$
#
"""like :hg:`archive` but with keyword expansion within selected files

The patterns of files to to expanded are configured in an versioned
``.hgkwarchive`` configuration file found in the root of the working
directory.

The ``.hgkwarchive`` file uses the same syntax as all other Mercurial
configuration files.

The ``[patterns]`` section specifies which files should have the keywords
expanded.


Example::

   #
   # Determine which files are consideres for keyword expansion in
   # which style
   #
   [patterns]
    #
    # Expand keywords in every python file except those matching "x*"
    # using the default RCS style expansion format
    #
    **.py =
    x*    = NO
    # This file has reStructuredText style keyword expansion "|VCS<kw>|" only
    path:VERSION = reST
    # This file has reStructuredText style and RCS style keyword expansion
    path:README = reST, RCS

    #
    # Determine which keywords are expanded.
    # A missing and/or empty section means that all predefined keywords
    # are expanded.
    #
    # Consisting of key-value pairs with of the form
    #
    #     Alias = PreDefinedKeyword
    #
    # An empty `PreDefinedKeyword' means the identity.
    #
    [keywords]
    # `Revision' and possibly the `VCSRevision' keyword is expanded
    Revision =
    #
    # Additionally: `MyKeyword' is expanded with the contents of the
    # pre-defined `JustDate'
    #
    MyKeyword = JustDate

A non-existing ``.hgkwarchive`` file deactivates keyword expansion as does
an empty ``[patterns]`` section.

"""

from __future__ import absolute_import


__revision__ = "$Revision$"

__author__ = "Franz Glasner"


import os
import itertools
import inspect

from mercurial.i18n import _
from mercurial import (archival, config, cmdutil, error, match,
                       pycompat, scmutil, templatefilters, util, node)


testedwith = "4.3.1 4.3.2 4.4.1 4.4.2 4.5.2 4.6.1"


SHORTENED_HG_SCHEMES = {
    "ssh": "hg+ssh",
    "http": "hg+http",
    "https": "hg+https",
    "file": "file",
}


cmdtable = {}

command = cmdutil.command(cmdtable)


@command(
    'kwarchive',
    [
        ('', 'no-decode', None, _('do not pass files through decoders')),
        ('p', 'prefix', '', _('directory prefix for files in archive'), _('PREFIX')),
        ('r', 'rev', '', _('revision to distribute'), _('REV')),
        ('t', 'type', '', _('type of distribution to create'), _('TYPE')),
        ('', 'path', 'default', _('the canonical repository to use'), _('PATH')),
        ('', "kwconfig", '', _('an alternate pattern configuration configuration file'), _('PATTERNCONFIG')),
        ('', "no-shorten-path", None, _("don't shorten the path urls")),
        ('', "path-filter", "short", _("determine how the path will be printed")),
        ('', "user-filter", "user", _("the part of the user to be printed"), _("USERFILTER"))
    ] + cmdutil.subrepoopts + cmdutil.walkopts,
    _('[OPTION]... DEST'))
def kwarchive(ui, repo, dest, **opts):
    '''create an unversioned archive of a repository revision with some keywords expanded

    By default, the revision used is the parent of the working
    directory; use -r/--rev to specify a different revision.

    The archive type is automatically detected based on file
    extension (to override, use -t/--type).

    .. container:: verbose

      Examples:

      - create a zip file containing the 1.0 release::

          hg archive -r 1.0 project-1.0.zip

      - create a tarball excluding .hg files::

          hg archive project.tar.gz -X ".hg*"

    Valid types are:

    :``files``: a directory full of files (default)
    :``tar``:   tar archive, uncompressed
    :``tbz2``:  tar archive, compressed using bzip2
    :``tgz``:   tar archive, compressed using gzip
    :``uzip``:  zip archive, uncompressed
    :``zip``:   zip archive, compressed using deflate

    The exact name of the destination archive or directory is given
    using a format string; see :hg:`help export` for details.

    Each member added to an archive file has a directory prefix
    prepended. Use -p/--prefix to specify a format string for the
    prefix. The default is the basename of the archive, with suffixes
    removed.

    Use --path to specify named path information from :hg:`paths` as
    the canonical repository location.  Use ``.`` for the current
    repository root root.  If no path is given then ``default`` is
    assumed.

    Full path URLs are printed somewhat shortened by default. To use
    them as-is use the --no-shorten-path option.

    Allowed --user-filter values are:

    :``user``:    the short representation of a user name or email address
    :``person``:  the name before an email address as per RFC 5322
    :``email``:   the email address
    :``full``:    the complete user information w/o filtering
    :``none``:    an alias for ``full``

    The --path-filter values detemine how the path will be printed:

    :``full``:    the path will be printed as is (including passwords et al.)
    :``nopwd``:   like ``full`` but without passwords
    :``short``:   shorten the path somehow: no user/password information and
                  only a short server name
    :``last``:    shorten the path to the last component

    Returns 0 on success.

    '''

    opts = pycompat.byteskwargs(opts)
    ctx = scmutil.revsingle(repo, opts.get('rev'))
    if not ctx:
        raise error.Abort(_('no working directory: please specify a revision'))
    node = ctx.node()
    dest = makefilename_compat(ctx, dest)
    if os.path.realpath(dest) == repo.root:
        raise error.Abort(_('repository root cannot be destination'))

    kind = opts.get('type') or archival.guesskind(dest) or 'files'
    prefix = opts.get('prefix')

    if dest == '-':
        if kind == 'files':
            raise error.Abort(_('cannot archive plain files to stdout'))
        dest = makefileobj_compat(ctx, dest)
        if not prefix:
            prefix = os.path.basename(repo.root) + '-%h'

    prefix = makefilename_compat(ctx, prefix)
    matchfn = scmutil.match(ctx, [], opts)

    #
    # Monkey patch archival's archivers classes so that an archiver's "addfile()"
    # expands keywords
    #
    for ac in ("fileit", "tarit", "zipit",):
        patch_archiver_class(
            ac,
            make_keyword_filter(
                ui,
                repo,
                ctx,
                ac,
                archival.tidyprefix(dest, kind, prefix),
                hgpath=opts.get("path"),
                path_filter=get_checked_path_filter(opts),
                user_filter=get_checked_user_filter(opts),
                kwconfig=opts.get("kwconfig")))

    archival.archive(repo, dest, node, kind, not opts.get('no_decode'),
                     matchfn, prefix, subrepos=opts.get('subrepos'))

    # XXX FIXME: Should the original methods be restored here?

    # XXX FIXME: Should we automatically amend ".hg_archival.txt" with a
    #            path item? But this means shat we should know the complete
    #            configuration ("ui.archivemeta") and it's name and some
    #            output match filters (see archival.archive()).


@command(
    'kwprint',
    [
        ('r', 'rev', '', _('revision to distribute'), _('REV')),
        ('', 'path', 'default', _('the canonical repository to use'), _('PATH')),
        ('', "no-shorten-path", None, _("don't shorten the path urls")),
        ('', "path-filter", "short", _("determine how the path will be printed")),
        ('', "user-filter", "user", _("the part of the user to be printed"), _("USERFILTER")),
        ('', "reST", None, _("output in reST substitution definition syntax")),
        ('', "no-file", None, _("don't show file-dependent keywords")),
    ] + cmdutil.subrepoopts + cmdutil.walkopts,
    _('[OPTION]...'))
def kwprint(ui, repo, **opts):
    '''print the file-independent keywords and for an example file-dependent
    keywords

    By default, the revision used is the parent of the working
    directory; use -r/--rev to specify a different revision.

    Use --path to specify named path information from :hg:`paths` as
    the canonical repository location.  Use ``.`` for the current
    repository root root.  If no path is given then ``default`` is
    assumed.

    Full path URLs are printed somewhat shortened by default. To use
    them as-is use the --no-shorten-path option.

    Allowed --user-filter values are:

    :``user``:    the short representation of a user name or email address
    :``person``:  the name before an email address as per RFC 5322
    :``email``:   the email address
    :``full``:    the complete user information w/o filtering
    :``none``:    an alias for ``full``

    The --path-filter values detemine how the path will be printed:

    :``full``:    the path will be printed as is (including passwords et al.)
    :``nopwd``:   like ``full`` but without passwords
    :``short``:   shorten the path somehow: no user/password information and
                  only a short server name
    :``last``:    shorten the path to the last component

    Use --reST to output the keyword in a format suitable for including
    in reStructuredText (reST) documents by using it's substitution feature.
    All revision keyword names have a ``VCS`` prefix.

    Use --no-file to suppress the output of file-dependent keywords with an
    example file.

    Returns 0 on success.

    '''

    opts = pycompat.byteskwargs(opts)
    ctx = scmutil.revsingle(repo, opts.get('rev'))
    if not ctx:
        raise error.Abort(_('no working directory: please specify a revision'))
    node = ctx.node()

    prefix = makefilename_compat(ctx, "")

    keywords = make_node_keywords(
        ui, repo, ctx, prefix,
        hgpath=opts.get("path"),
        path_filter=get_checked_path_filter(opts),
        user_filter=get_checked_user_filter(opts))
    # make file-dependent keywords for an example file
    file_keywords = make_file_keywords(
        keywords,
        "dir1/dir2/test.file",
        "a4dd6f4b22e11fec41158eec187630c24a43120a")
    for key in sorted(keywords.keys()):
        if opts.get("reST"):
            ui.write(".. |VCS%s| replace:: %s\n" % (key, keywords[key]))
        else:
            ui.write("$%s: %s $\n" % (key, keywords[key]))
    if not opts.get("no_file"):
        ui.write("\n")
        for key in sorted(file_keywords.keys()):
            if opts.get("reST"):
                ui.write(".. |VCS%s| replace:: %s\n" % (key, file_keywords[key]))
            else:
                ui.write("$%s: %s $\n" % (key, file_keywords[key]))


def patch_archiver_class(archivername, filter):
    """Patch an archiver class and return the original unbound method"""

    archiver_class = getattr(archival, archivername)
    orig_addfile = getattr(archiver_class, "addfile")

    def new_addfile(self, name, mode, isline, data):
        return orig_addfile(self, name, mode, isline, filter(name, data))

    setattr(archiver_class, "addfile", new_addfile)
    return orig_addfile


def make_keyword_filter(ui, repo, ctx, archive_class, prefix,
                        hgpath="default",
                        path_filter="short",
                        user_filter="user",
                        kwconfig=""):
    keywords = make_node_keywords(ui, repo, ctx, prefix,
                                  hgpath=hgpath,
                                  path_filter=path_filter,
                                  user_filter=user_filter)
    kwconfigdata = kwconfigname = None
    if kwconfig:
        kwconfigdata = open(kwconfig, "rb").read()
        kwconfigname = kwconfig
    try:
        if kwconfigdata is None:
            # .hgkwarchive
            kwconfigdata = ctx[".hgkwarchive"].data()
            kwconfigname = ".kwarchive"
    except (IOError, LookupError):
        def _filter(name, data):
            return data
    else:
        #
        # Parse the data in ".hgkwarchive" and generate a
        # Mercurial matcher
        #
        cfg = config.config()
        cfg.parse(kwconfigname, kwconfigdata)
        include = []
        exclude = []
        patterns = []
        patterns_rcs = []
        patterns_rst = []
        if cfg.items("patterns"):
            for pattern, styles in cfg.items("patterns"):
                styles = [s.strip() for s in styles.upper().split(",")]
                if "YES" in styles or "INCLUDE" in styles:
                    include.append(pattern)
                elif "NO" in styles or "EXCLUDE" in styles:
                    exclude.append(pattern)
                else:
                    patterns.append(pattern)
                if "REST" in styles or "RST" in styles \
                        or "RCS" in styles:
                    if "RCS" in styles:
                        patterns_rcs.append(pattern)
                    if "REST" in styles or "RST" in styles:
                        patterns_rst.append(pattern)
                else:
                    # default to RCS if no keyword style is given
                    patterns_rcs.append(pattern)
            matcher = match.match(repo.root, '', patterns=patterns,
                                  include=include, exclude=exclude)
        else:
            matcher = match.never(repo.root, '')

        #
        # An empty patterns_rcs does not mean that match_rcs is always
        # true.
        #
        if not patterns_rcs:
            matcher_rcs = match.never(repo.root, '')
        else:
            matcher_rcs = match.match(repo.root, '', patterns=patterns_rcs,
                                      include=[], exclude=[])
        #
        # An empty patterns_rst does not mean that match_rst is always
        # true.
        #
        if not patterns_rst:
            matcher_rst = match.never(repo.root, '')
        else:
            matcher_rst = match.match(repo.root, '', patterns=patterns_rst,
                                      include=[], exclude=[])

        #
        # This is the mapping from all the (white-listed) pre-defined
        # keywords to the keywords to be expanded in files (with aliases).
        #
        keyword_aliases = {}
        for alias, keyword in cfg.items("keywords"):
            keyword_aliases.setdefault(keyword or alias, []).append(alias)

        #
        # Get the manifest to be able to determine a file's NodeId
        #
        manifest = ctx.manifest()

        def _filter(name, data):
            real_name = name
            if archive_class != "fileit":
                if prefix:
                    assert name.startswith(prefix)
                    real_name = name[len(prefix):]
            if not matcher(real_name):
                return data
            try:
                nodeid = node.hex(manifest[real_name])
            except LookupError:
                nodeid = None
            # file specific keywords
            file_keywords = make_file_keywords(keywords, real_name, nodeid)
            # This prevents unwanted keyword expansion here
            _MARKER_RCS = '$'
            _MARKER_RST = '|'
            for kw, value in itertools.chain(keywords.items(), file_keywords.items()):
                # an empty database means: all keywords allowed, no aliases
                if keyword_aliases:
                    kwds = keyword_aliases.get(kw, [])
                else:
                    kwds = [kw]
                for kw in kwds:
                    if matcher_rcs(real_name):
                        filekw = "%s%s%s" % (_MARKER_RCS, kw, _MARKER_RCS)
                        filevalue = "%s%s: %s %s" \
                                    % (_MARKER_RCS, kw, value, _MARKER_RCS)
                        data = data.replace(filekw, filevalue)
                    if matcher_rst(real_name):
                        filekw = "%sVCS%s%s" % (_MARKER_RST, kw, _MARKER_RST)
                        filevalue = value
                        data = data.replace(filekw, filevalue)
            return data

    return _filter


def make_node_keywords(ui, repo, ctx, prefix,
                       hgpath="default",
                       path_filter="short",
                       user_filter="user"):
    """Make all the node-specific (i.e. file-path independent) keywords

    """
    if hgpath:
        if hgpath == '.':
            path_uri = repo.root
        else:
            try:
                if path_filter == "full":
                    path_uri = bytes(util.url(ui.paths[hgpath].loc))
                elif path_filter == "nopwd":
                    path_uri = util.hidepassword(ui.paths[hgpath].loc)
                elif path_filter == "short":
                    path_url = util.url(ui.paths[hgpath].loc)
                    path_url.scheme = SHORTENED_HG_SCHEMES.get(path_url.scheme,
                                                               "hg")
                    path_url.user = None
                    path_url.passwd = None
                    path_url.host = stripped_hostname(path_url.host)
                    path_url.port = None
                    path_uri = bytes(path_url)
                elif path_filter == "last":
                    path_url = str(util.url(ui.paths[hgpath].loc)).split("/")
                    path_uri = bytes(b".../"+path_url[-1])
                else:
                    raise error.Abort("path-filter `%s' not implemented"
                                      % path_filter)
            except LookupError:
                raise error.Abort(_('repository %s not found') % hgpath)
    else:
        path_uri = repo.root
    if path_uri.startswith(b"\\\\"):
        # Make an URL from a Windows UNC path
        path_uri = b"file:///" + path_uri.replace(b'\\', b'/')
    elif len(path_uri) >= 2 and (b'A' <= path_uri[0].upper() <= b'Z') \
            and path_uri[1] == b':':
        # make an URL from a Windows path with drive letter
        path_uri = b"file:///" + path_uri.replace(b'\\', b'/')
    elif path_uri.startswith(b'/'):
        # an absolute POSIX path
        path_uri = b"file://" + path_uri
    keywords = {
        "HGpath": path_uri,
        "HGrevision": ctx.hex(),
        "Revision": templatefilters.short(ctx.hex()),
        "Date": templatefilters.isodatesec(ctx.date()),
        "JustDate": templatefilters.shortdate(ctx.date()),
        # compatibility alias for `JustDate'
        "HGshortdate": templatefilters.shortdate(ctx.date()),
    }
    if user_filter == "user":
        keywords["Author"] = templatefilters.emailuser(ctx.user())
    elif user_filter == "person":
        keywords["Author"] = templatefilters.utf8(
            templatefilters.person(ctx.user()).replace(' ', '+'))
    elif user_filter == "email":
        keywords["Author"] = templatefilters.email(ctx.user())
    elif user_filter in ("full", "none",):
        #
        # "none" is retained for compatibility reasons and now an
        # alias for "full".
        # But make it **one** word because that is meant in the RCS spec.
        #
        keywords["Author"] = templatefilters.utf8(ctx.user().replace(' ', '+'))
    else:
        raise error.Abort(_("unknown user filter"))

    return keywords


def make_file_keywords(keywords, real_name, nodeid):
    return {
        "HGsource": keywords["HGpath"] + '/' + real_name,
        "Source": real_name,
        "File": templatefilters.basename(real_name),
        "Header": "%s %s %s %s" % (real_name, keywords["Revision"], keywords["Date"], keywords["Author"]),
        "HGid": "%s %s %s %s" % (keywords["HGpath"] + '/' + real_name, keywords["Revision"], keywords["Date"], keywords["Author"]),
        "HGheader": "%s %s %s %s" % (keywords["HGpath"] + '/' + real_name, keywords["HGrevision"], keywords["Date"], keywords["Author"]),
        "Id": "%s %s %s %s" % (templatefilters.basename(real_name), keywords["Revision"], keywords["Date"], keywords["Author"]),
        "HGnodeid": nodeid or "",
    }


def stripped_hostname(hostname):
    """Return `hostname` without any domain port information"""
    if not hostname:
        return hostname
    idx = hostname.find('.')
    if idx < 0:
        return hostname
    return hostname[:idx]


def get_checked_user_filter(opts):
    uf = opts.get("user_filter")
    # "none" is retained for compatibility reasons and now an alias for "full"
    if uf not in ("person", "user", "email", "full", "none"):
        raise error.Abort(
            _("user filter must be any of `user', `person' or `email'"))
    return uf


def get_checked_path_filter(opts):
    pf = opts.get("path_filter")
    if pf not in ("full", "nopwd", "short", "last"):
        raise error.Abort(
            _("path filter must be any of `full', `nopwd', `short' or `last'"))
    return pf


if "ctx" in inspect.getargspec(cmdutil.makefilename).args:

    # Mercurial >= 4.6

    makefilename_compat = cmdutil.makefilename
    makefileobj_compat = cmdutil.makefileobj

else:

    # Mercurial < 4.6

    def makefilename_compat(ctx, pat, **props):
        return cmdutil.makefilename(ctx.repo(), pat, ctx.node(), **props)

    def makefileobj_compat(ctx, pat, **props):
        return cmdutil.makefileobj(ctx.repo(), pat, ctx.node(), **props)