Source code for polyversion

#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
"""
Python-2.7-safe, no-deps code to discover sub-project versions in Git *polyvers* monorepos.

The *polyvers* version-configuration tool is generating **pvtags** like::

    proj-foo-v0.1.0

And assuming :func:`polyversion()` is invoked from within a Git repo, it may return
either ``0.1.0`` or ``0.1.0+2.gcaffe00``, if 2 commits have passed since
last *pvtag*.

Also, this library function as a *setuptools* "plugin" (see :mod:`setuplugin`).

Finally, the wheel can be executed like that::

    python polyversion-*.whl --help

"""
from __future__ import print_function

import logging
import os
import sys

import os.path as osp
import subprocess as sbp


__all__ = 'polyversion polytime decide_vprefixes'.split()


PY2 = sys.version_info < (3, )
log = logging.getLogger(__name__)
_log_stack = {} if PY2 else {'stack_info': True}


#: A 2-tuple containing 2 ``{vprefix}`` values for the patterns below,for
#: for *version-tags* and *release-tags* respectively.
tag_vprefixes = ('v', 'r')

#: The default pattern for *monorepos* version-tags,
#: receiving 3 :pep:`3101` interpolation parameters::
#:
#:     {pname}, {version} = '*', {vprefix} = tag_vprefixes[0 | 1]
#:
#: The match patterns for ``git describe --match <pattern>`` are generated by this.
pvtag_format = '{pname}-{vprefix}{version}'
#: Like :data:`pvtag_format` but for *mono-project* version-tags.
vtag_format = '{vprefix}{version}'

#: The default regex pattern breaking :term:`monorepo` version-tags
#: and/or ``git-describe`` output into 3 capturing groups:
#:
#:   - ``pname``,
#:   - ``version`` (without the ``{vprefix)``),
#:   - ``descid`` (optional) anything following the dash('-') after
#:     the version in ``git-describe`` result.
#:
#: It is given 2 :pep:`3101` interpolation parameters::
#:
#:     {pname}, {vprefix} = tag_vprefixes[0 | 1]
#:
#: See :pep:`0426` for project-name characters and format.
pvtag_regex = r"""(?xmi)
    ^(?P<pname>{pname})
    -
    {vprefix}(?P<version>\d[^-]*)
    (?:-(?P<descid>\d+-g[a-f\d]+))?$
"""
#: Like :data:`pvtag_format` but for :term:`mono-project` version-tags.
vtag_regex = r"""(?xmi)
    ^(?P<pname>)
    {vprefix}(?P<version>\d[^-]*)
    (?:-(?P<descid>\d+-g[a-f\d]+))?$
"""


def _clean_cmd_result(res):  # type: (bytes) -> str
    """
    :return:
        only if there is something in `res`, as utf-8 decoded string
    """
    res = res and res.strip()
    if res:
        return res.decode('utf-8', errors='surrogateescape')


def rfc2822_tstamp(nowdt=None):
    """Py2.7 code from https://stackoverflow.com/a/3453277/548792"""
    from datetime import datetime
    import time
    from email import utils

    if nowdt is None:
        nowdt = datetime.now()
    nowtuple = nowdt.timetuple()
    nowtimestamp = time.mktime(nowtuple)
    now = utils.formatdate(nowtimestamp, localtime=True)

    return now


class MyCalledProcessError(sbp.CalledProcessError):
    """
    "A :class:`sbp.CalledProcessError` that includes STDOUT/STDERR on its message.
    """
    def __init__(self, returncode, cmd, output=None, stderr=None, cwd=None):
        try:
            super(MyCalledProcessError, self).__init__(returncode, cmd, output, stderr)
            self.cwd = cwd
        except TypeError:
            ## In PY < 3.5 Ex has no output/stderr attributes.
            super(MyCalledProcessError, self).__init__(returncode, cmd)
            self.output = self.stdout = output
            self.stderr = stderr

    def __str__(self):
        out = getattr(self, 'stdout', None)  # strangely not always there...
        err = getattr(self, 'stderr', None)
        cwd = getattr(self, 'cwd', None)
        tail = ('\n  STDERR: %s' % err) if err else ''
        tail += ('\n  STDOUT: %s' % out) if out else ''
        tail += ('\n     CWD: %s' % cwd) if cwd else ''

        err = super(MyCalledProcessError, self).__str__()

        return err + tail


def _my_run(cmd, cwd='.'):
    "For commands with small output/stderr."
    if not isinstance(cmd, (list, tuple)):
        cmd = cmd.split()
    try:
        proc = sbp.Popen(cmd, stdout=sbp.PIPE, stderr=sbp.PIPE,
                         cwd=str(cwd), bufsize=-1)
    except FileNotFoundError as ex:
        ## On Windows you don't see the command attempted to run:
        #  FileNotFoundError: [WinError 2] The system cannot find the file specified
        #
        if not ex.filename:
            ex.filename = cmd[0]
        raise

    out, err = proc.communicate()

    if proc.returncode != 0:
        raise MyCalledProcessError(proc.returncode, cmd, out, err, cwd)
    else:
        return _clean_cmd_result(out)


def _parse_metadata(fp):
    ## Method found in :mod:`pkginfo.distribution`.
    from email.parser import Parser

    return Parser().parse(fp, headersonly=True)


def pkg_metadata_version(pname, basepath=None):
    """Get the version from package metadata if present.

    :param pname:
        package-name
    :param basepath:
        The path of the outermost package inside the git repo hosting the project
        if missing, cwd assumed.

    :return:
      `None` if nothing found

    It will retrieve the version from these ``<basepath>`` filepaths (see :pep:`0376`),
    and in this order:

      - ``../<pname>-<version>.dist-info/METADATA``: for packages
        installed in PYTHONPATH from a *wheel*.
      - ``../<pname>-<version>.egg-info/PKG-INFO``: for packages
        installed in PYTHONPATH from an *(bdist) egg*.
      - ``METADATA``: when launched from within for *wheels*.
      - ``PKG-INFO``: when launched from within for *sdists*,
    """
    import glob
    import io

    pkg_metadata_fpaths = [
        osp.join('..', '%s-*.dist-info' % pname, 'METADATA'),  # wheel
        osp.join('..', '%s-*.egg-info' % pname, 'PKG-INFO'),   # egg
        'METADATA',
        'PKG-INFO',
    ]
    pkg_metadata = {}
    for fpath in pkg_metadata_fpaths:
        fpath = osp.join(str(basepath) or '.', fpath)
        try:
            matches = glob.glob(fpath)
            if len(matches) == 1:
                fpath = matches[0]
            else:
                if len(matches) > 1:
                    log.warning("Many matches while searching version in '%s': %s",
                                osp.realpath(fpath), matches)
                continue

            with io.open(fpath, 'r', errors='ignore') as fp:
                pkg_metadata = _parse_metadata(fp)

            break
        except Exception as ex:
            log.warning("Ignored error while searching version in '%s': %s",
                        osp.realpath(fpath), ex)

    ## Check to make sure we're in our own dir
    #
    meta_pname = pkg_metadata.get('Name', None)
    if meta_pname == pname:
        return pkg_metadata.get('Version', None)
    elif meta_pname is not None:
        log.warning("Skipping version '%s' from foreign project '%s' (expecting '%s').",
                    pkg_metadata.get('Version', None), meta_pname, pname)


def _caller_module_name(nframes_back=2):
    import inspect

    frame = inspect.currentframe()
    try:
        for _ in range(nframes_back):
            frame = frame.f_back
        modname = frame.f_globals['__name__']
        name = modname.split('.')[-1]
        if name.startswith('_'):  # eg: _version, __init__, __main__
            raise ValueError(
                "Auto-derived project-name from module '%s' starts with underscore!" %
                modname)
        return name
    finally:
        del frame


def _caller_basepath(nframes_back=2):
    # TODO: MERGE WITH ABOVE inspect func
    import inspect

    frame = inspect.currentframe()
    try:
        for _ in range(nframes_back):
            frame = frame.f_back
        mod = inspect.getmodule(frame)
        if not mod:
            basepath = '.'
        else:
            topackage = __import__(mod.__name__.split('.')[0])
            basepath = osp.dirname(inspect.getfile(topackage))

        return basepath
    finally:
        del frame


def split_pvtag(pvtag, tag_regexes):
    ## TODO: parse descids like `setuptools_scm` plugin:
    #  https://pypi.org/project/setuptools_scm/#default-versioning-scheme
    if not isinstance(tag_regexes, (list, tuple)):
        raise ValueError("Expected `tag_regexes` as list-of-str, got: %r" %
                         tag_regexes)

    for tregex in tag_regexes:
        try:
            m = tregex.match(pvtag)
            if m:
                mg = m.groupdict()
                return mg['pname'], mg['version'], mg['descid']
        except Exception as ex:
            raise ValueError("Matching pvtag '%s' by '%s' failed due to: %s" %
                             (pvtag, tregex.pattern, ex))

    raise ValueError(
        "Unparseable pvtag %r from pvtag_regexes: %s!" %
        (pvtag, ''.join('\n- %s' % tregex.pattern
                        for tregex in tag_regexes)))


def _version_from_descid(version, descid):
    """
    Combine ``git-describe`` parts in a :pep:`440` version with "local" part.

    :param: version:
        anythng after the project and ``'-v`'`` i,
        e.g it is ``1.7.4.post0``. ``foo-project-v1.7.4.post0-2-g79ceebf8``
    :param: descid:
        the part after the *pvtag* and the 1st dash('-'), which must not be empty,
        e.g it is ``2-g79ceebf8`` for ``foo-project-v1.7.4.post0-2-g79ceebf8``.
    :return:
        something like this: ``1.7.4.post0+2.g79ceebf8`` or ``1.7.4.post0``
    """
    assert descid, (version, descid)
    local_part = descid.replace('-', '.')
    return '%s+%s' % (version, local_part)


def _interp_fnmatch(tag_format, vprefix, pname):
    return tag_format.format(pname=pname,
                             version='*',
                             vprefix=vprefix)


def _interp_regex(tag_regex, vprefix, pname):
    return tag_regex.format(pname=pname,
                            vprefix=vprefix)


def _git_version():
    def _int(i):
        try:
            i = int(i)
        except ValueError:
            pass
        return i

    gitver = _my_run(['git', 'version'])
    ## Git's versions like ``'git version 2.17.0.windows.1'``
    ver = gitver.lstrip('git version ')
    return tuple(_int(i) for i in ver.split('.'))


def _is_git_describe_accept_signle_pattern():
    """Buggy git < 2.15.0 ignores multiple match-patterns but the last."""
    return _git_version()[:2] < (2, 15)


def _git_describe(cmd, tag_patterns, basepath):
    if _is_git_describe_accept_signle_pattern():
        for i, tp in enumerate(tag_patterns):
            try:
                pvtag = _my_run(cmd + ['--match=%s' % tp], cwd=basepath)
                break
            ## Catching overriden MyCalledProcessError here
            #  bc error we want to ignore is raised after communicate
            except MyCalledProcessError as ex:
                ## Raise only at the very last pattern.
                #
                if ('No names found, cannot describe anything' not in str(ex) or
                        i >= len(tag_patterns) - 1):
                    raise

    else:
        cmd.extend('--match=' + tp for tp in tag_patterns)
        pvtag = _my_run(cmd, cwd=basepath)

    return pvtag


def _git_describe_parsed(pname,
                         default_version,        # if None, raise
                         tag_format, tag_regex,
                         vprefixes,
                         basepath, git_options):
    """
    Parse git-desc as `pvtag, version, descid` or raise when no `default_version`.

    :param vprefixes:
        a sequence of str; no surprises, just make that many match-patterns
    """
    assert not isinstance(vprefixes, str), "req list-of-str, got: %r" % vprefixes

    import re

    if git_options:
        if isinstance(git_options, str):
            git_options = git_options.split()
        else:
            try:
                git_options = [str(s) for s in git_options]
            except Exception as ex:
                raise TypeError(
                    "invalid `git_options` due to: %s"
                    "\n  must be a str or an iterable, got: %r" %
                    (ex, git_options))
    tag_patterns, tag_regexes = zip(
        *((_interp_fnmatch(tag_format, vp, pname),
           re.compile(_interp_regex(tag_regex, vp, pname)))
          for vp in vprefixes))

    #
    ## Guard against git's runtime errors, below,
    #  and not configuration-ones, above.
    #
    pvtag = version = descid = None
    try:
        cmd = 'git describe'.split()
        if git_options:
            cmd.extend(git_options)

        pvtag = _git_describe(cmd, tag_patterns, basepath)

        matched_project, version, descid = split_pvtag(pvtag, tag_regexes)
        if matched_project and matched_project != pname:
            log.warning("Matched  pvtag project '%s' different from expected '%s'!",
                        matched_project, pname)
        if descid:
            version = _version_from_descid(version, descid)
    except Exception as ex:
        if default_version is None:
            raise
        else:
            log.warning(
                "polyversion(): falling back to default-version '%s' "
                "due to ignored error: %s",
                default_version, ex, exc_info=1)

    if not version:
        version = default_version

    return pvtag, version, descid


[docs]def decide_vprefixes(vprefixes, is_release): "Decide v-tag, r-tag or both; no surprises params, return always an array." if vprefixes is None: vprefixes = tag_vprefixes if len(vprefixes) != 2: raise ValueError( "Args 'vprefixes' in `polyversion()` must be a 2 element str-array" ", got: %r" % (vprefixes, )) if is_release is not None: vprefixes = (vprefixes[bool(is_release)], ) return vprefixes
[docs]def polyversion(**kw): """ Report the *pvtag* of the `pname` in the git repo hosting the source-file calling this. :param str pname: The project-name, used as the prefix of pvtags when searching them. If not given, defaults to the *last segment of the module-name of the caller*. .. Attention:: when calling it from ``setup.py`` files, auto-deduction above will not work; you must supply a project name. :param str default_version: What *version* to return if git cmd fails. Set it to `None` to raise if no *vtag* found. .. Tip:: For cases where a shallow git-clone does not finds any *vtags* back in history, or simply because the project is new, and there are no *vtags*, we set default-version to empty-string, to facilitate pip-installing these projects from sources. :param str default_version_env_var: Override which env-var to read *version* from, if git cmd fails [Default: ``<pname>_VERSION``] :param bool mono_project: - false: (default) :term:`monorepo`, ie multiple sub-projects per git-repo. Tags formatted by *pvtags* :data:`pvtag_format` & :data:`pvtag_regex` (like ``pname-v1.2.3``). - true: :term:`mono-project`, ie only one project in git-repo Tags formatted as *vtags* :data:`vtag_format` & :data:`vtag_regex`. (like ``v1.2.3``). :param str tag_format: The :pep:`3101` pattern for creating *pvtags* (or *vtags*). - It receives 3 parameters to interpolate: ``{pname}, {vprefix}, {version} = '*'``. - It is used also to generate the match patterns for ``git describe --match <pattern>`` command. - It overrides `mono_project` arg. - See :data:`pvtag_format` & :data:`vtag_format` :param regex tag_regex: The regex pattern breaking apart *pvtags*, with 3 named capturing groups: - ``pname``, - ``version`` (without the 'v'), - ``descid`` (optional) anything following the dash('-') after the version in ``git-describe`` result. - It is given 2 :pep:`3101` parameters ``{pname}, {vprefix}`` to interpolate. - It overrides `mono_project` arg. - See :pep:`0426` for project-name characters and format. - See :data:`pvtag_regex` & :data:`vtag_regex` :param str vprefixes: a 2-element array of str - :data:`tag_vprefixes` assumed when not specified :param is_release: a 3-state boolean used as index into :data:`tag_vprefixes`: - false: v-tags searched; - true: r-tags searched; - None: both tags searched. :param str basepath: The path of the outermost package inside the git repo hosting the project; if missing, assumed as the dirname of the calling code's package, or (if no module) cwd(`.`). :param git_options: a str or an iterator of (converted to str) options to pass to ``git describe`` command (empty by default). If a string, it is splitted by spaces. :param return_all: when true, return the 3-tuple (tag, version, desc-id) (not just version) :return: The version-id (or 3-tuple) derived from the *pvtag*, or `default` if command failed/returned nothing, unless None, in which case, it raises. :raise CalledProcessError: if it cannot find any vtag and `default_version` is None (e.g. no git cmd/repo, no valid tags) .. Tip:: It is to be used, for example, in package ``__init__.py`` files like this:: __version__ = polyversion() Or from any other file:: __version__ = polyversion('myproj') .. Note:: This is a python==2.7 & python<3.6 safe function; there is also the similar function with elaborate error-handling :func:`polyvers.pvtags.describe_project()` in the full-blown tool `polyvers`. """ pname = kw.get('pname') default_version = kw.get('default_version') basepath = kw.get('basepath') mono_project = kw.get('mono_project') tag_format = kw.get('tag_format') tag_regex = kw.get('tag_regex') vprefixes = kw.get('vprefixes') is_release = kw.get('is_release') git_options = kw.get('git_options') return_all = kw.get('return_all') if not pname: pname = _caller_module_name() if not basepath: basepath = _caller_basepath() if not basepath: basepath = '.' version = pkg_metadata_version(pname, basepath) if version: if return_all: return None, version, None return version if not default_version: defver_envvar = kw.get('default_version_env_var', '%s_VERSION' % pname) ## Ignore empty/none envvars # to preserve empty (but not none) `default-version` kwd. # env_ver = os.environ.get(defver_envvar) if env_ver: default_version = env_ver if tag_format is None: tag_format = vtag_format if mono_project else pvtag_format if tag_regex is None: tag_regex = vtag_regex if mono_project else pvtag_regex vprefixes = decide_vprefixes(vprefixes, is_release) tag, version, descid = _git_describe_parsed(pname, default_version, tag_format, tag_regex, vprefixes, basepath, git_options) if return_all: return tag, version, descid return version
[docs]def polytime(**kw): """ The timestamp of last commit in git repo hosting the source-file calling this. :param str no_raise: If true, never fail and return current-time. Assumed true if a :term:`default version env-var` is found. :param str basepath: The path of the outermost package inside the git repo hosting the project; if missing, assumed as the dirname of the calling code's package. :param str pname: The project-name used only as the prefix for :term:`default version env-var`. If not given, defaults to the *last segment of the module-name of the caller*. Another alternative is to use directly the `default_version_env_var` kwd. .. Attention:: when calling it from ``setup.py`` files, auto-deduction above will not work; you must supply a project name. :param str default_version_env_var: Override which env-var to read *version* from, if git cmd fails [Default: ``<pname>_VERSION``] :return: the commit-date if in git repo, or now; :rfc:`2822` formatted """ no_raise = kw.get('no_raise', False) basepath = kw.get('basepath') pname = kw.get('pname') if not pname: pname = _caller_module_name() if not basepath: basepath = _caller_basepath() cdate = None if not pkg_metadata_version(pname, basepath): defver_envvar = kw.get('default_version_env_var', '%s_VERSION' % pname) if os.environ.get(defver_envvar): no_raise = True cmd = "git log -n1 --format=format:%cD" try: cdate = _my_run(cmd, cwd=basepath) except Exception as ex: if not no_raise: raise else: log.warning( "polytime(): falling back to current-time " "due to ignored error: %s", ex, exc_info=1) if not cdate: cdate = rfc2822_tstamp() return cdate
def _init_logging(): level = os.environ.get('POLYVERSION_LOG_LEVEL') if level: try: level = int(level) except ValueError: pass else: level = logging.INFO logging.basicConfig( level=level, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') ## Initialize logging before my own version-setting. # if 'POLYVERSION_LOG_LEVEL' in os.environ: _init_logging() __version__ = polyversion(default_version='0.0.0') __updated__ = polytime(no_raise=True) def run(*args): """ Describe the version of a *polyvers* projects from git tags. USAGE: %(prog)s [-t] [PROJ-1] ... %(prog)s [-v | -V ] # print my version information - See http://polyvers.readthedocs.io - In order to set cmd-line arguments, invoke directly the function above. - With a single project, it raises any problems (e.g. no tags). - Use env-var[POLYVERSION_LOG_LEVEL] to control verbosity (0: show all, 10: DEBUG, 30: INFO, 40: WARN, 50: ERROR, 60=FATAL). :param argv: Cmd-line arguments, nothing assumed if nothing given, so it nvokes :func:`polyversion.run()` with ``sys.argv[1:]``. """ for o in ('-h', '--help'): if o in args: import textwrap as tw cmdname = osp.basename(sys.argv[0]) doc = tw.dedent('\n'.join(run.__doc__.split('\n')[1:-5])) print(doc % {'prog': cmdname}) return if '-v' in args: print(__version__, end='') return if '-V' in args: print("version: %s\nupdated: %s\nfile: %s" % ( __version__, __updated__, __file__)) return print_tag = None if '-t' in args: print_tag = True args = list(args) del args[args.index('-t')] _init_logging() if len(args) == 1: res = polyversion(pname=args[0], basepath=os.curdir, return_all=print_tag) # fetces either 1-triplet or screams. if print_tag: res = res[0] else: versions = [(pname, polyversion(pname=pname, default_version='', basepath=os.curdir, return_all=print_tag)) for pname in args] if print_tag: versions = [(pname, ver[0]) for pname, ver in versions] res = '\n'.join('%s: %s' % (pname, ver or '') for pname, ver in versions) if res: print(res)