Source code for polyvers.pvproject

#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# Copyright 2015-2018 European Commission (JRC);
# Licensed under the EUPL 1.2+ (the 'Licence');
# You may not use this work except in compliance with the Licence.
# You may obtain a copy of the Licence at: http://ec.europa.eu/idabc/eupl
#
"""
The main structures of *polyvers*::

    Project 1-->* Engrave 1-->* Graft
"""
from pathlib import Path
from typing import List, Optional, Match, Sequence, Union, Pattern
import logging
import re

import polyversion as pvlib
import textwrap as tw

from . import vermath
from ._vendor.traitlets import traitlets as trt
from ._vendor.traitlets.traitlets import (
    List as ListTrait, Set as SetTrait, Tuple as TupleTrait, Union as UnionTrait)
from ._vendor.traitlets.traitlets import Bool, Unicode, Instance
from .cmdlet import cmdlets, autotrait
from .cmdlet.slicetrait import Slice as SliceTrait
from .utils import yamlutil as yu
from .utils.oscmd import cmd


log = logging.getLogger(__name__)


PatternClass = type(re.compile('.*'))  # For traitlets
MatchClass = type(re.match('.*', ''))  # For traitlets

FPaths = List[Path]
FLike = Union[str, Path]
FLikeList = Sequence[FLike]


def _slices_to_ids(slices, thelist):
    from boltons.setutils import IndexedSet as iset

    all_ids = list(range(len(thelist)))
    mask_ids = iset()
    for aslice in slices:
        mask_ids.update(all_ids[aslice])

    return list(mask_ids)


[docs]class Graft(cmdlets.Replaceable, cmdlets.Printable, yu.YAMLable, cmdlets.Spec): """Instructions on how to search'n replace some text.""" name = Unicode( config=True, help="Describe it for readable printouts.") regex: str = Unicode( # type: ignore read_only=True, config=True, help="The regular-expressions to search within the byte-contents of files." ) @trt.validate('regex') def _is_valid_regex(self, proposal): value = proposal.value try: v = self.interp(value, _stub_keys=lambda k: '<%s>' % k, # real values from project _escaped_for='regex') re.compile(v.encode(self.encoding)) except Exception as ex: proposal.trait.error(None, value, ex) return value def regex_resolved(self, project: 'Project') -> Pattern: v = project.interp(self.regex, _escaped_for='regex') assert v, (self.regex, project) return re.compile(v.encode(self.encoding)) subst = Unicode( allow_none=True, default_value='', help=""" What to replace with; if `None`, no substitution happens. Inside them, supported extensions are: - captured groups with '\\1 or '\g<foo>' expressions (see Python's regex documentation) - interpolation variables; Keys available (apart from env-vars prefixed with '$'): {ikeys} - WARN: put x2 all '{' and '}' chars like this: `\\w{{1,5}}` or else, interpolation will scream. """ ) def subst_resolved(self, project: 'Project') -> bytes: v = project.interp(self.subst) assert v, (self.subst, project) return v.encode(self.encoding) slices = UnionTrait( (SliceTrait(), ListTrait(SliceTrait())), read_only=True, config=True, help=""" Which of the `hits` to substitute, in "slice" notation(s); all if not given. Example:: gs = Graft() gs.slices = 0 ## Only the 1st match. gs.slices = '1:' ## Everything except the 1st match gs.slices = ['1:3', '-1:'] ## Only 2nd, 3rd and the last match. " """ ) encoding = Unicode( 'utf-8', config=True, help="""How to encode regex into bytes for matching file contents.""")
[docs] def collect_matches(self, fbytes: bytes, project: 'Project') -> List[Match]: """ :return: all `hits`; use :meth:`sliced_matches` to apply any slices """ with self.errlogged(token='scan', doing="scanning %s" % self): regex = self.regex_resolved(project) matches = list(regex.finditer(fbytes)) return matches
def sliced_matches(self, matches: List[Match]) -> List[Match]: slices = self.slices if not slices or not matches: return matches else: if not isinstance(slices, list): slices = [slices] match_indices = _slices_to_ids(slices, matches) return [matches[i] for i in match_indices] def __str__(self): if self.name: return '%s(%s)' % (type(self).__name__, self.name) return super().__str__()
[docs]class Engrave(cmdlets.Replaceable, cmdlets.Printable, yu.YAMLable, cmdlets.Spec): """Muliple file-patterns to search'n replace.""" name = Unicode( config=True, help=""" Describe it for readable printouts, and refer to it from `Project.active_engraves` list. """) globs = ListTrait( Unicode(), read_only=True, config=True, help="A list of POSIX file patterns (.gitgnore-like) to search and replace" ).tag(printable=True) grafts = ListTrait( autotrait.AutoInstance(Graft), read_only=True, config=True, help=""" A list of `Graft` for engraving (search & replace) version-ids or other infos. Use `{appname} config desc Graft` to see its syntax. """ ) def __str__(self): if self.name: return '%s(%s)' % (type(self).__name__, self.name) return super().__str__()
[docs]class Project(cmdlets.Replaceable, cmdlets.Printable, yu.YAMLable, cmdlets.Spec): """Configurations for projects, in general, and specifically for each one.""" pname = Unicode( config=True, help="""The name of the project, used in interpolations and pvtags, among others.""" ).tag(printable=True) basepath = Instance( Path, default_value=None, allow_none=True, castable=str, config=True, help=""" The root-dir of this project. - Usually this the folder where `setup.py` resides. - Projects may be nested but not exactly overlap. - Searched and substitutions for a project stop when reaching the basepath of other projects. """ ).tag(printable=True) printable_traits = 'pname basepath'.split() start_version_id = Unicode( '0.0.0', config=True, help="""If no pvtag found, use this as the base for relative versions.""") current_version = vermath.Pep440Version( None, allow_none=True, help="The previous version, auto-discovered.") release_date = Unicode( help="The automatic release date, to interpolate it.") @trt.default('release_date') def _get_now(self): from datetime import datetime return datetime.now().isoformat() ## TODO: rename version-->new_version version = vermath.Pep440Version( None, allow_none=True, help="The new absolute version to bump to.") def load_current_version_from_history(self, vtag_index=0): try: tag = self.pvtags_history[vtag_index] self.current_version = self.version_from_pvtag(tag) except IndexError: self.log.debug("No vtags history for %s.", self) self.current_version = self.start_version_id
[docs] def set_new_version(self, version_bump: str = None): """ :param version_bump: relative or absolute """ if not version_bump: version_bump = self.default_version_bump if vermath.is_version_id_relative(version_bump): self.version = vermath.add_versions(self.current_version, version_bump) else: self.version = version_bump
tag_vprefixes = TupleTrait( Unicode(), Unicode(), default_value=pvlib.tag_vprefixes, config=True, help=""" A 2-tuple containing the ``{vprefix}`` interpolation values, one for *version-tags* and one for *release-tags*, respectively. """) pvtag_format = Unicode( help=""" The pattern to generate new *pvtags*. It is interpolated with this class's traits as :pep:`3101` parameters; among others ``{pname}`` and ``{version}``; use ``{ikeys}`` to receive all available keys. .. Important:: If you change this, ensure the :func:`polyversion.polyversion()` gets invoked from project's sources with the same value in `pvtag_format` kw-arg. """).tag(config=True) def _format_vtag(self, version, is_release=False): return self.interp(self.pvtag_format, version=version, vprefix=self.tag_vprefixes[int(is_release)])
[docs] def tag_fnmatch(self, is_release=False): """ The glob-pattern finding *pvtags* with ``git describe --match <pattern>`` cmd. :param is_release: `False` for version-tags, `True` for release-tags By default, it is interpolated with two :pep:`3101` parameters:: {pname} <-- this Project.pname {version} <-- '*' """ vprefix = self.tag_vprefixes[int(is_release)] return self.interp(self.pvtag_format, vprefix=vprefix, version='*', _escaped_for='glob')
pvtag_regex = Unicode( help=""" The regex pattern breaking *pvtags* and/or ``git-describe`` output into 3 named capturing groups: - ``pname``, - ``version`` (without the 'v'), - ``descid`` (optional) anything following the dash('-') after the version in ``git-describe`` result. It is interpolated with this class's traits as :pep:`3101` parameters; among others ``{pname}``, and **maybe** ``{version}``; use ``{ikeys}`` to receive all available keys. See :pep:`0426` for project-name characters and format. .. Important:: If you change this, ensure the :func:`polyversion.polyversion()` gets invoked from project's sources with the same value in `pvtag_regex` kw-arg. """).tag(config=True) @trt.validate('pvtag_regex') def _is_valid_pvtag_regex(self, proposal): value = proposal.value try: for vprefix in self.tag_vprefixes: v = self.interpolations.interp( value, pname='<pname>', vprefix=vprefix) re.compile(v) except Exception as ex: proposal.trait.error(None, value, ex) return value
[docs] def is_good(self): "If format patterns are missing, spurious NPEs will happen when using project." return bool(self.tag_vprefixes and self.pvtag_format and self.pvtag_regex)
tag = Bool( config=True, help=""" Enable tagging, per-project. Adds a signed tag with name/msg from `tag_name`/`message` (commit implied). """) message_summary = Unicode( "{pname}-{vprefix}{current_version} -> {version}", config=True, help=""" The commit & tag message's summary-line part for this project. Available interpolations (apart from env-vars prefixed with '$'): {vprefix}, {ikeys} """) def summary_interped(self, is_release=False): return self.interp(self.message_summary, vprefix=self.tag_vprefixes[int(is_release)]) message_body = Unicode( config=True, help=""" The commit & tag message-body part for this project. Available interpolations (apart from env-vars prefixed with '$'): {ikeys} """)
[docs] def tag_regex(self, is_release=False) -> Pattern: """ Interpolate and compile as regex. :param is_release: `False` for version-tags, `True` for release-tags """ vprefix = self.tag_vprefixes[int(is_release)] regex = self.interp(self.pvtag_regex, vprefix=vprefix, _escaped_for='regex') return re.compile(regex)
_pvtags_collected = ListTrait( Unicode(), allow_none=True, default_value=None, help="Populated internally by `populate_pvtags_history()`.") @property def pvtags_history(self) -> List[str]: """ Return the full *pvtag* history for the project, if any. :raise AssertionError: If used before :func:`populate_pvtags_history()` applied on this project. """ if self._pvtags_collected is None: raise AssertionError("Call first `populate_pvtags_history()` on %s!") return self._pvtags_collected
[docs] def version_from_pvtag(self, pvtag: str, is_release: Optional[bool] = None) -> Optional[str]: """Extract the version from a *pvtag*.""" release_flags = [0, 1] if is_release is None else [bool(is_release), ] for r in release_flags: m = self.tag_regex(r).match(pvtag) if m: mg = m.groupdict() return mg['version']
[docs] def git_describe(self, *git_args: str, include_lightweight=False, is_release=None, **git_flags: str): """ Gets sub-project's version as derived from ``git describe`` on its *pvtag*. :param include_lightweight: Consider also non-annotated tags when derriving description; equivalent to ``git describe --tags`` flag. :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 git_args: CLI options passed to ``git describe`` command. See :class:`.oscmd.PopenCmd` on how to specify cli options using python functions, e.g. ``('*-v*', '*-r*')`` :param git_flags: CLI flags passed to ``git describe`` command. - See :class:`.oscmd.PopenCmd` on how to specify cli flags using python functions, e.g. ``(all=True)``. - See https://git-scm.com/docs/git-describe :return: the *pvtag* of the current project, or raise :raise GitVoidError: if sub-project is not *pvtagged*. :raise NoGitRepoError: if CWD not within a git repo. :raise sbp.CalledProcessError: for any other error while executing *git*. .. Tip:: There is also the python==2.7 & python<3.6 safe :func:`polyvers.polyversion()`` for extracting just the version part from a *pvtag*; use this one from within project sources. .. Tip:: Same results can be retrieved by this `git` command:: git describe --tags --match <PROJECT>-v* where ``--tags`` is needed to consider also non-annotated tags, as ``git tag`` does. """ from .import pvtags ## TODO: Project should reuse pvlib as entry-points release_flags = [0, 1] if is_release is None else [bool(is_release), ] tag_patterns = [self.tag_fnmatch(i) for i in release_flags] ## TODO: move to pvtags with pvtags.git_project_errors_handled(self.pname): out = cmd.git.describe._( tags=(include_lightweight) or None, *git_args, **git_flags)(*('--match=%s' % i for i in tag_patterns)) version = out ## `git describe --all` fetches 'tags/` prefix. if 'all' in git_flags: version = version.lstrip('tags/') if not self.version_from_pvtag(version, is_release): raise trt.TraitError( "Project-version '%s' fetched by %i patterns (%s) " "was unparsable by regex:%s" % (version, len(tag_patterns), ', '.join(tag_patterns), ','.join('\n%r' % self.tag_regex(r).pattern for r in release_flags))) return version
[docs] def last_commit_tstamp(self): """ Report the timestamp of the last commit of the git repo. :return: last commit's timestamp in :rfc:`2822` format :raise GitVoidError: if there arn't any commits yet or CWD not within a git repo. :raise sbp.CalledProcessError: for any other error while executing *git*. .. NOTE:: Same results can be retrieved by this `git` command:: git describe --tags --match <PROJECT>-v* where ``--tags`` is needed to consider also unannotated tags, as ``git tag`` does. """ from .import pvtags with pvtags.git_project_errors_handled(self.pname): out = cmd.git.log(n=1, format='format:%cD') # TODO: traitize log-date format return out
[docs] def tag_version_commit(self, msg, *, is_release=False, amend=False, sign_tag=None, sign_user=None): """ Make a tag on current commit denoting a version-bump. :param is_release: `False` for version-tags, `True` for release-tags """ import subprocess as sbp from . import pvtags tag_name = self._format_vtag(self.version, is_release) ## TODO: move all git-cmds to pvtags? try: out = cmd.git.tag( tag_name, message=msg, force=amend or self.is_forced('tag') or None, sign=sign_tag or None, local_user=sign_user or None, ) if self.dry_run: self.log.warning('PRETEND tag: %s' % out) except sbp.CalledProcessError as ex: if "already exists" in str(ex.stderr): raise pvtags.GitError( "Cannot bump, tag '%s' already exists!" "\n Add `--force=tag` if you must, or you can --amend." % tag_name) raise
engraves = ListTrait( autotrait.AutoInstance(Engrave), default_value=[ { 'name': 'setup.py', ## TODO: add global engrave/project Excludes 'globs': ['!*egg*', 'setup.py'], 'grafts': [{ 'name': 'version-comma', 'regex': tw.dedent(r''' (?xm) \bversion (\ *=\ *) .+?(, \ *[\n\r])+ '''), 'subst': r"version\1'{version}'\2" }], }, { 'name': 'py-version', 'globs': ['!*egg*', '__init__.py', '_version.py'], 'grafts': [{ 'name': '__version__', 'regex': tw.dedent(r''' (?xm) ^__version__ (\ *=\ *) (.+?)$ '''), 'subst': r"__version__\1'{version}'" }, { 'name': '__updated__', 'regex': tw.dedent(r''' (?xm) ^__updated__ (\ *=\ *) (.+?)$ '''), 'subst': r"__updated__\1'{release_date}'" }], }, { 'name': 'readme', 'globs': ['!*egg*', 'README.rst'], 'grafts': [{ 'name': '|version|', 'regex': r'\|version\|', 'subst': "{version}" }, { 'name': '|today|', 'regex': r'\|today\|', 'subst': "{release_date}" }], }, ], config=True, help=""" """) enabled_engraves = SetTrait( Unicode(), config=True, help=""" Reference by name Engraves that should be active. If empty, all are active! """ ) def active_engraves(self): actives = self.enabled_engraves if not actives: return self.engraves return [e for e in self.engraves if e.name in actives] default_version_bump: str = Unicode( # type: ignore # noqa: E704 #@IgnorePep8 '^1', config=True, help="Which relative version to imply if not given in the cmd-line." ) @trt.validate('default_version_bump') def _require_relative_version(self, change): if not vermath.is_version_id_relative(change.new): raise trt.TraitError( "Expected a relative version for '%s.%s', but got '%s'!" "\n Relative versions start either with '+' or '^'." % change.owner, change.trait.name, change.new)