diff --git a/setup.cfg b/setup.cfg index c446e1a..8d4dee0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -75,15 +75,15 @@ push = True [pycalver:file_patterns] bootstrapit.sh = - PACKAGE_VERSION="{version}" + PACKAGE_VERSION="{pycalver}" setup.cfg = - current_version = {version} + current_version = {pycalver} setup.py = - version="{pep440_version}" + version="{pep440_pycalver}" src/pycalver/__init__.py = - __version__ = "{version}" + __version__ = "{pycalver}" src/pycalver/__main__.py = - click.version_option(version="{version}") + click.version_option(version="{pycalver}") README.md = [PyCalVer {version}] https://img.shields.io/badge/PyCalVer-{calver}{build}-blue.svg diff --git a/src/pycalver/__main__.py b/src/pycalver/__main__.py index 503894e..ca8001c 100644 --- a/src/pycalver/__main__.py +++ b/src/pycalver/__main__.py @@ -16,7 +16,6 @@ import logging import typing as typ from . import vcs -from . import parse from . import config from . import version from . import rewrite @@ -25,19 +24,22 @@ from . import rewrite _VERBOSE = 0 -try: - import backtrace +# try: +# import backtrace - # To enable pretty tracebacks: - # echo "export ENABLE_BACKTRACE=1;" >> ~/.bashrc - backtrace.hook(align=True, strip_path=True, enable_on_envvar_only=True) -except ImportError: - pass +# # To enable pretty tracebacks: +# # echo "export ENABLE_BACKTRACE=1;" >> ~/.bashrc +# backtrace.hook(align=True, strip_path=True, enable_on_envvar_only=True) +# except ImportError: +# pass click.disable_unicode_literals_warning = True +VALID_RELEASE_VALUES = ("alpha", "beta", "dev", "rc", "post", "final") + + log = logging.getLogger("pycalver.cli") @@ -57,11 +59,11 @@ def _init_logging(verbose: int = 0) -> None: def _validate_release_tag(release: str) -> None: - if release in parse.VALID_RELEASE_VALUES: + if release in VALID_RELEASE_VALUES: return log.error(f"Invalid argument --release={release}") - log.error(f"Valid arguments are: {', '.join(parse.VALID_RELEASE_VALUES)}") + log.error(f"Valid arguments are: {', '.join(VALID_RELEASE_VALUES)}") sys.exit(1) @@ -77,22 +79,40 @@ def cli(verbose: int = 0): @cli.command() @click.argument("old_version") +@click.argument("pattern", default="{pycalver}") @click.option('-v', '--verbose', count=True, help="Control log level. -vv for debug level.") @click.option( "--release", default=None, metavar="", help="Override release name of current_version" ) -def test(old_version: str, verbose: int = 0, release: str = None) -> None: +@click.option("--major", is_flag=True, default=False, help="Increment major component.") +@click.option("--minor", is_flag=True, default=False, help="Increment minor component.") +@click.option("--patch", is_flag=True, default=False, help="Increment patch component.") +def test( + old_version: str, + pattern : str = "{pycalver}", + verbose : int = 0, + release : str = None, + major : bool = False, + minor : bool = False, + patch : bool = False, +) -> None: """Increment a version number for demo purposes.""" _init_logging(verbose=max(_VERBOSE, verbose)) if release: _validate_release_tag(release) - new_version = version.incr(old_version, release=release) - pep440_version = version.pycalver_to_pep440(new_version) + new_version = version.incr( + old_version, pattern=pattern, release=release, major=major, minor=minor, patch=patch + ) + if new_version is None: + log.error(f"Invalid version '{old_version}' and/or pattern '{pattern}'.") + sys.exit(1) - print("PyCalVer Version:", new_version) - print("PEP440 Version :", pep440_version) + pep440_version = version.to_pep440(new_version) + + print("New Version:", new_version) + print("PEP440 :", pep440_version) def _update_cfg_from_vcs(cfg: config.Config, fetch: bool) -> config.Config: @@ -103,12 +123,12 @@ def _update_cfg_from_vcs(cfg: config.Config, fetch: bool) -> config.Config: log.info(f"fetching tags from remote (to turn off use: -n / --no-fetch)") _vcs.fetch() - version_tags = [tag for tag in _vcs.ls_tags() if version.PYCALVER_RE.match(tag)] + version_tags = [tag for tag in _vcs.ls_tags() if version.is_valid(tag, cfg.version_pattern)] if version_tags: version_tags.sort(reverse=True) log.debug(f"found {len(version_tags)} tags: {version_tags[:2]}") latest_version_tag = version_tags[0] - latest_version_pep440 = version.pycalver_to_pep440(latest_version_tag) + latest_version_pep440 = version.to_pep440(latest_version_tag) if latest_version_tag > cfg.current_version: log.info(f"Working dir version : {cfg.current_version}") log.info(f"Latest version from {_vcs.name:>3} tag: {latest_version_tag}") @@ -143,7 +163,7 @@ def show(verbose: int = 0, fetch: bool = True) -> None: cfg = _update_cfg_from_vcs(cfg, fetch=fetch) print(f"Current Version: {cfg.current_version}") - print(f"PEP440 Version : {cfg.pep440_version}") + print(f"PEP440 : {cfg.pep440_version}") @cli.command() @@ -235,7 +255,7 @@ def _bump(cfg: config.Config, new_version: str, allow_dirty: bool = False) -> No metavar="", help=( f"Override release name of current_version. Valid options are: " - f"{', '.join(parse.VALID_RELEASE_VALUES)}." + f"{', '.join(VALID_RELEASE_VALUES)}." ), ) @click.option( @@ -248,12 +268,18 @@ def _bump(cfg: config.Config, new_version: str, allow_dirty: bool = False) -> No "to files with version strings." ), ) +@click.option("--major", is_flag=True, default=False, help="Increment major component.") +@click.option("--minor", is_flag=True, default=False, help="Increment minor component.") +@click.option("--patch", is_flag=True, default=False, help="Increment patch component.") def bump( release : typ.Optional[str] = None, verbose : int = 0, dry : bool = False, allow_dirty: bool = False, fetch : bool = True, + major : bool = False, + minor : bool = False, + patch : bool = False, ) -> None: """Increment the current version string and update project files.""" verbose = max(_VERBOSE, verbose) @@ -272,7 +298,17 @@ def bump( cfg = _update_cfg_from_vcs(cfg, fetch=fetch) old_version = cfg.current_version - new_version = version.incr(old_version, release=release) + new_version = version.incr( + old_version, + pattern=cfg.version_pattern, + release=release, + major=major, + minor=minor, + patch=patch, + ) + if new_version is None: + log.error(f"Invalid version '{old_version}' and/or pattern '{cfg.version_pattern}'.") + sys.exit(1) log.info(f"Old Version: {old_version}") log.info(f"New Version: {new_version}") diff --git a/src/pycalver/config.py b/src/pycalver/config.py index 65f7082..002d2dc 100644 --- a/src/pycalver/config.py +++ b/src/pycalver/config.py @@ -77,6 +77,7 @@ class Config(typ.NamedTuple): """Container for parameters parsed from a config file.""" current_version: str + version_pattern: str pep440_version : str commit: bool @@ -90,6 +91,7 @@ def _debug_str(cfg: Config) -> str: cfg_str_parts = [ f"Config Parsed: Config(", f"current_version='{cfg.current_version}'", + f"version_pattern='{{pycalver}}'", f"pep440_version='{cfg.pep440_version}'", f"commit={cfg.commit}", f"tag={cfg.tag}", @@ -179,17 +181,52 @@ def _parse_toml(cfg_buffer: typ.TextIO) -> RawConfig: return raw_cfg +def _normalize_file_patterns(raw_cfg: RawConfig) -> FilePatterns: + version_str = raw_cfg['current_version'] + version_pattern = raw_cfg['version_pattern'] + pep440_version = version.to_pep440(version_str) + file_patterns = raw_cfg['file_patterns'] + + for filepath, patterns in list(file_patterns.items()): + if not os.path.exists(filepath): + log.warning(f"Invalid config, no such file: {filepath}") + + normalized_patterns: typ.List[str] = [] + for pattern in patterns: + normalized_pattern = pattern.replace("{version}", version_pattern) + if version_pattern == "{pycalver}": + normalized_pattern = normalized_pattern.replace( + "{pep440_version}", "{pep440_pycalver}" + ) + elif version_pattern == "{semver}": + normalized_pattern = normalized_pattern.replace("{pep440_version}", "{semver}") + elif "{pep440_version}" in pattern: + log.warning(f"Invalid config, cannot match '{pattern}' for '{filepath}'.") + log.warning(f"No mapping of '{version_pattern}' to '{pep440_version}'") + normalized_patterns.append(normalized_pattern) + + file_patterns[filepath] = normalized_patterns + + return file_patterns + + def _parse_config(raw_cfg: RawConfig) -> Config: + """Parse configuration which was loaded from an .ini/.cfg or .toml file.""" + if 'current_version' not in raw_cfg: raise ValueError("Missing 'pycalver.current_version'") version_str = raw_cfg['current_version'] version_str = raw_cfg['current_version'] = version_str.strip("'\" ") - if version.PYCALVER_RE.match(version_str) is None: - raise ValueError(f"Invalid current_version = {version_str}") + version_pattern = raw_cfg.get('version_pattern', "{pycalver}") + version_pattern = raw_cfg['version_pattern'] = version_pattern.strip("'\" ") - pep440_version = version.pycalver_to_pep440(version_str) + # NOTE (mb 2019-01-05): trigger ValueError if version_pattern + # and current_version don't work together. + version.parse_version_info(version_str, version_pattern) + + pep440_version = version.to_pep440(version_str) commit = raw_cfg['commit'] tag = raw_cfg['tag'] @@ -206,13 +243,9 @@ def _parse_config(raw_cfg: RawConfig) -> Config: if push and not commit: raise ValueError("pycalver.commit = true required if pycalver.push = true") - file_patterns = raw_cfg['file_patterns'] + file_patterns = _normalize_file_patterns(raw_cfg) - for filepath in file_patterns.keys(): - if not os.path.exists(filepath): - log.warning(f"Invalid configuration, no such file: {filepath}") - - cfg = Config(version_str, pep440_version, tag, commit, push, file_patterns) + cfg = Config(version_str, version_pattern, pep440_version, tag, commit, push, file_patterns) log.debug(_debug_str(cfg)) return cfg @@ -241,6 +274,7 @@ def parse(ctx: ProjectContext) -> MaybeConfig: DEFAULT_CONFIGPARSER_BASE_TMPL = """ [pycalver] current_version = "{initial_version}" +version_pattern = "{{pycalver}}" commit = True tag = True push = True @@ -279,6 +313,7 @@ README.md = DEFAULT_TOML_BASE_TMPL = """ [pycalver] current_version = "{initial_version}" +version_pattern = "{{pycalver}}" commit = true tag = true push = true diff --git a/src/pycalver/parse.py b/src/pycalver/parse.py index 0364ac5..37ab352 100644 --- a/src/pycalver/parse.py +++ b/src/pycalver/parse.py @@ -5,50 +5,14 @@ # SPDX-License-Identifier: MIT """Parse PyCalVer strings from files.""" -import re import logging import typing as typ +from . import patterns + log = logging.getLogger("pycalver.parse") -VALID_RELEASE_VALUES = ("alpha", "beta", "dev", "rc", "post", "final") - - -PATTERN_ESCAPES = [ - ("\u005c", "\u005c\u005c"), - ("-" , "\u005c-"), - ("." , "\u005c."), - ("+" , "\u005c+"), - ("*" , "\u005c*"), - ("{" , "\u005c{{"), - ("}" , "\u005c}}"), - ("[" , "\u005c["), - ("]" , "\u005c]"), - ("(" , "\u005c("), - (")" , "\u005c)"), -] - -# NOTE (mb 2018-09-03): These are matchers for parts, which are -# used in the patterns, they're not for validation. This means -# that they may find strings, which are not valid pycalver -# strings, when parsed in their full context. For such cases, -# the patterns should be expanded. - - -RE_PATTERN_PARTS = { - 'pep440_version': r"\d{6}\.[1-9]\d*(a|b|dev|rc|post)?\d*", - 'version' : r"v\d{6}\.\d{4,}(\-(alpha|beta|dev|rc|post|final))?", - 'calver' : r"v\d{6}", - 'year' : r"\d{4}", - 'month' : r"\d{2}", - 'build' : r"\.\d{4,}", - 'build_no' : r"\d{4,}", - 'release' : r"(\-(alpha|beta|dev|rc|post|final))?", - 'release_tag' : r"(alpha|beta|dev|rc|post|final)?", -} - - class PatternMatch(typ.NamedTuple): """Container to mark a version string in a file.""" @@ -62,26 +26,10 @@ class PatternMatch(typ.NamedTuple): PatternMatches = typ.Iterable[PatternMatch] -def compile_pattern(pattern: str) -> typ.Pattern[str]: - pattern_tmpl = pattern - - for char, escaped in PATTERN_ESCAPES: - pattern_tmpl = pattern_tmpl.replace(char, escaped) - - # undo escaping only for valid part names - for part_name in RE_PATTERN_PARTS.keys(): - pattern_tmpl = pattern_tmpl.replace( - "\u005c{{" + part_name + "\u005c}}", "{" + part_name + "}" - ) - - pattern_str = pattern_tmpl.format(**RE_PATTERN_PARTS) - return re.compile(pattern_str) - - def _iter_for_pattern(lines: typ.List[str], pattern: str) -> PatternMatches: # The pattern is escaped, so that everything besides the format # string variables is treated literally. - pattern_re = compile_pattern(pattern) + pattern_re = patterns.compile_pattern(pattern) for lineno, line in enumerate(lines): match = pattern_re.search(line) @@ -93,12 +41,12 @@ def iter_matches(lines: typ.List[str], patterns: typ.List[str]) -> PatternMatche """Iterate over all matches of any pattern on any line. >>> lines = ["__version__ = 'v201712.0002-alpha'"] - >>> patterns = ["{version}", "{pep440_version}"] + >>> patterns = ["{pycalver}", "{pep440_pycalver}"] >>> matches = list(iter_matches(lines, patterns)) >>> assert matches[0] == PatternMatch( ... lineno = 0, ... line = "__version__ = 'v201712.0002-alpha'", - ... pattern= "{version}", + ... pattern= "{pycalver}", ... span = (15, 33), ... match = "v201712.0002-alpha", ... ) diff --git a/src/pycalver/patterns.py b/src/pycalver/patterns.py new file mode 100644 index 0000000..87c92a0 --- /dev/null +++ b/src/pycalver/patterns.py @@ -0,0 +1,200 @@ +# This file is part of the pycalver project +# https://github.com/mbarkhau/pycalver +# +# Copyright (c) 2018 Manuel Barkhau (@mbarkhau) - MIT License +# SPDX-License-Identifier: MIT +"""Compose Regular Expressions from Patterns. + +>>> version_info = PYCALVER_RE.match("v201712.0123-alpha").groupdict() +>>> assert version_info == { +... "pycalver" : "v201712.0123-alpha", +... "vYYYYMM" : "v201712", +... "year" : "2017", +... "month" : "12", +... "build" : ".0123", +... "build_no" : "0123", +... "release" : "-alpha", +... "release_tag" : "alpha", +... } +>>> +>>> version_info = PYCALVER_RE.match("v201712.0033").groupdict() +>>> assert version_info == { +... "pycalver" : "v201712.0033", +... "vYYYYMM" : "v201712", +... "year" : "2017", +... "month" : "12", +... "build" : ".0033", +... "build_no" : "0033", +... "release" : None, +... "release_tag": None, +... } +""" + +import re +import typing as typ + +# https://regex101.com/r/fnj60p/10 +PYCALVER_PATTERN = r""" +\b +(?P + (?P + v # "v" version prefix + (?P\d{4}) + (?P\d{2}) + ) + (?P + \. # "." build nr prefix + (?P\d{4,}) + ) + (?P + \- # "-" release prefix + (?Palpha|beta|dev|rc|post) + )? +)(?:\s|$) +""" + +PYCALVER_RE: typ.Pattern[str] = re.compile(PYCALVER_PATTERN, flags=re.VERBOSE) + + +PATTERN_ESCAPES = [ + ("\u005c", "\u005c\u005c"), + ("-" , "\u005c-"), + ("." , "\u005c."), + ("+" , "\u005c+"), + ("*" , "\u005c*"), + ("{" , "\u005c{"), + ("}" , "\u005c}"), + ("[" , "\u005c["), + ("]" , "\u005c]"), + ("(" , "\u005c("), + (")" , "\u005c)"), +] + +COMPOSITE_PART_PATTERNS = { + 'pep440_pycalver': r"{year}{month}\.{BID}(?:{pep440_tag})?", + 'pycalver' : r"v{year}{month}\.{bid}(?:-{tag})?", + 'calver' : r"v{year}{month}", + 'semver' : r"{MAJOR}\.{MINOR}\.{PATCH}", + 'release_tag' : r"{tag}", + 'build' : r"\.{bid}", + 'release' : r"-{tag}", + # depricated + 'pep440_version': r"{year}{month}\.{BID}(?:{pep440_tag})?", +} + + +PART_PATTERNS = { + 'year' : r"\d{4}", + 'month' : r"(?:0[0-9]|1[0-2])", + 'build_no' : r"\d{4,}", + 'pep440_tag': r"(?:a|b|dev|rc|post)?\d*", + 'tag' : r"(?:alpha|beta|dev|rc|post|final)", + 'yy' : r"\d{2}", + 'yyyy' : r"\d{4}", + 'quarter' : r"[1-4]", + 'iso_week' : r"(?:[0-4]\d|5[0-3])", + 'us_week' : r"(?:[0-4]\d|5[0-3])", + 'dom' : r"(0[1-9]|[1-2][0-9]|3[0-1])", + 'doy' : r"(?:[0-2]\d\d|3[0-5][0-9]|36[0-6])", + 'MAJOR' : r"\d+", + 'MINOR' : r"\d+", + 'MM' : r"\d{2,}", + 'MMM' : r"\d{3,}", + 'MMMM' : r"\d{4,}", + 'MMMMM' : r"\d{5,}", + 'PATCH' : r"\d+", + 'PP' : r"\d{2,}", + 'PPP' : r"\d{3,}", + 'PPPP' : r"\d{4,}", + 'PPPPP' : r"\d{5,}", + 'bid' : r"\d{4,}", + 'BID' : r"[1-9]\d*", + 'BB' : r"[1-9]\d{1,}", + 'BBB' : r"[1-9]\d{2,}", + 'BBBB' : r"[1-9]\d{3,}", + 'BBBBB' : r"[1-9]\d{4,}", + 'BBBBBB' : r"[1-9]\d{5,}", + 'BBBBBBB' : r"[1-9]\d{6,}", +} + + +FULL_PART_FORMATS = { + 'pep440_pycalver': "{year}{month:02}.{BID}{pep440_tag}", + 'pycalver' : "v{year}{month:02}.{bid}{release}", + 'calver' : "v{year}{month:02}", + 'semver' : "{MAJOR}.{MINOR}.{PATCH}", + 'release_tag' : "{tag}", + 'build' : ".{bid}", + # NOTE (mb 2019-01-04): since release is optional, it + # is treates specially in version.format + # 'release' : "-{tag}", + 'month' : "{month:02}", + 'build_no': "{bid}", + 'iso_week': "{iso_week:02}", + 'us_week' : "{us_week:02}", + 'dom' : "{dom:02}", + 'doy' : "{doy:03}", + # depricated + 'pep440_version': "{year}{month:02}.{BID}{pep440_tag}", + 'version' : "v{year}{month:02}.{bid}{release}", +} + + +PART_FORMATS = { + 'major' : "[0-9]+", + 'minor' : "[0-9]{3,}", + 'patch' : "[0-9]{3,}", + 'bid' : "[0-9]{4,}", + 'MAJOR' : "[0-9]+", + 'MINOR' : "[0-9]+", + 'MM' : "[0-9]{2,}", + 'MMM' : "[0-9]{3,}", + 'MMMM' : "[0-9]{4,}", + 'MMMMM' : "[0-9]{5,}", + 'MMMMMM' : "[0-9]{6,}", + 'MMMMMMM': "[0-9]{7,}", + 'PATCH' : "[0-9]+", + 'PP' : "[0-9]{2,}", + 'PPP' : "[0-9]{3,}", + 'PPPP' : "[0-9]{4,}", + 'PPPPP' : "[0-9]{5,}", + 'PPPPPP' : "[0-9]{6,}", + 'PPPPPPP': "[0-9]{7,}", + 'BID' : "[1-9][0-9]*", + 'BB' : "[1-9][0-9]{1,}", + 'BBB' : "[1-9][0-9]{2,}", + 'BBBB' : "[1-9][0-9]{3,}", + 'BBBBB' : "[1-9][0-9]{4,}", + 'BBBBBB' : "[1-9][0-9]{5,}", + 'BBBBBBB': "[1-9][0-9]{6,}", +} + + +def _replace_pattern_parts(pattern: str) -> str: + for part_name, part_pattern in PART_PATTERNS.items(): + named_part_pattern = f"(?P<{part_name}>{part_pattern})" + placeholder = "\u005c{" + part_name + "\u005c}" + pattern = pattern.replace(placeholder, named_part_pattern) + return pattern + + +def _compile_pattern(pattern: str) -> str: + for char, escaped in PATTERN_ESCAPES: + pattern = pattern.replace(char, escaped) + + return _replace_pattern_parts(pattern) + + +def compile_pattern(pattern: str) -> typ.Pattern[str]: + pattern_str = _compile_pattern(pattern) + return re.compile(pattern_str) + + +def _init_composite_patterns() -> None: + for part_name, part_pattern in COMPOSITE_PART_PATTERNS.items(): + part_pattern = part_pattern.replace("{", "\u005c{").replace("}", "\u005c}") + pattern_str = _replace_pattern_parts(part_pattern) + PART_PATTERNS[part_name] = pattern_str + + +_init_composite_patterns() diff --git a/src/pycalver/rewrite.py b/src/pycalver/rewrite.py index a9bb7fa..39dc799 100644 --- a/src/pycalver/rewrite.py +++ b/src/pycalver/rewrite.py @@ -43,18 +43,20 @@ def rewrite_lines( ) -> typ.List[str]: """Replace occurances of patterns in old_lines with new_version. - >>> old_lines = ['__version__ = "v201809.0002-beta"'] - >>> patterns = ['__version__ = "{version}"'] - >>> new_lines = rewrite_lines(patterns, "v201811.0123-beta", old_lines) - >>> assert new_lines == ['__version__ = "v201811.0123-beta"'] + >>> patterns = ['__version__ = "{pycalver}"'] + >>> rewrite_lines(patterns, "v201811.0123-beta", ['__version__ = "v201809.0002-beta"']) + ['__version__ = "v201811.0123-beta"'] + + >>> patterns = ['__version__ = "{pep440_version}"'] + >>> rewrite_lines(patterns, "v201811.0123-beta", ['__version__ = "201809.2b0"']) + ['__version__ = "201811.123b0"'] """ - new_version_nfo = version.parse_version_info(new_version) - new_version_fmt_kwargs = new_version_nfo._asdict() + new_version_nfo = version.parse_version_info(new_version) new_lines = old_lines[:] for m in parse.iter_matches(old_lines, patterns): - replacement = m.pattern.format(**new_version_fmt_kwargs) + replacement = version.format_version(new_version_nfo, m.pattern) span_l, span_r = m.span new_line = m.line[:span_l] + replacement + m.line[span_r:] new_lines[m.lineno] = new_line @@ -74,10 +76,11 @@ class RewrittenFileData(typ.NamedTuple): def rfd_from_content(patterns: typ.List[str], new_version: str, content: str) -> RewrittenFileData: r"""Rewrite pattern occurrences with version string. - >>> patterns = ['__version__ = "{version}"'] + >>> patterns = ['__version__ = "{pycalver}"'] >>> content = '__version__ = "v201809.0001-alpha"' >>> rfd = rfd_from_content(patterns, "v201809.0123", content) - >>> assert rfd.new_lines == ['__version__ = "v201809.0123"'] + >>> rfd.new_lines + ['__version__ = "v201809.0123"'] """ line_sep = detect_line_sep(content) old_lines = content.split(line_sep) @@ -90,7 +93,7 @@ def iter_rewritten( ) -> typ.Iterable[RewrittenFileData]: r'''Iterate over files with version string replaced. - >>> file_patterns = {"src/pycalver/__init__.py": ['__version__ = "{version}"']} + >>> file_patterns = {"src/pycalver/__init__.py": ['__version__ = "{pycalver}"']} >>> rewritten_datas = iter_rewritten(file_patterns, "v201809.0123") >>> rfd = list(rewritten_datas)[0] >>> assert rfd.new_lines == [ @@ -135,7 +138,7 @@ def diff_lines(rfd: RewrittenFileData) -> typ.List[str]: def diff(new_version: str, file_patterns: config.PatternsByFilePath) -> str: r"""Generate diffs of rewritten files. - >>> file_patterns = {"src/pycalver/__init__.py": ['__version__ = "{version}"']} + >>> file_patterns = {"src/pycalver/__init__.py": ['__version__ = "{pycalver}"']} >>> diff_str = diff("v201809.0123", file_patterns) >>> lines = diff_str.split("\n") >>> lines[:2] diff --git a/src/pycalver/version.py b/src/pycalver/version.py index 99a7314..f0ca298 100644 --- a/src/pycalver/version.py +++ b/src/pycalver/version.py @@ -3,164 +3,470 @@ # # Copyright (c) 2018 Manuel Barkhau (@mbarkhau) - MIT License # SPDX-License-Identifier: MIT -"""Functions related to version string manipulation. +"""Functions related to version string manipulation.""" ->>> version_info = PYCALVER_RE.match("v201712.0123-alpha").groupdict() ->>> assert version_info == { -... "version" : "v201712.0123-alpha", -... "calver" : "v201712", -... "year" : "2017", -... "month" : "12", -... "build" : ".0123", -... "build_no" : "0123", -... "release" : "-alpha", -... "release_tag" : "alpha", -... } ->>> ->>> version_info = PYCALVER_RE.match("v201712.0033").groupdict() ->>> assert version_info == { -... "version" : "v201712.0033", -... "calver" : "v201712", -... "year" : "2017", -... "month" : "12", -... "build" : ".0033", -... "build_no" : "0033", -... "release" : None, -... "release_tag": None, -... } -""" - -import re import logging import pkg_resources import typing as typ import datetime as dt from . import lex_id +from . import patterns log = logging.getLogger("pycalver.version") -# https://regex101.com/r/fnj60p/10 -PYCALVER_PATTERN = r""" -\b -(?P - (?P - v # "v" version prefix - (?P\d{4}) - (?P\d{2}) - ) - (?P - \. # "." build nr prefix - (?P\d{4,}) - ) - (?P - \- # "-" release prefix - (?Palpha|beta|dev|rc|post) - )? -)(?:\s|$) -""" +# The test suite may replace this. +TODAY = dt.datetime.utcnow().date() -PYCALVER_RE: typ.Pattern[str] = re.compile(PYCALVER_PATTERN, flags=re.VERBOSE) + +PATTERN_PART_FIELDS = { + 'year' : 'year', + 'month' : 'month', + 'pep440_tag': 'tag', + 'tag' : 'tag', + 'yy' : 'year', + 'yyyy' : 'year', + 'quarter' : 'quarter', + 'iso_week' : 'iso_week', + 'us_week' : 'us_week', + 'dom' : 'dom', + 'doy' : 'doy', + 'MAJOR' : 'major', + 'MINOR' : 'minor', + 'MM' : 'minor', + 'MMM' : 'minor', + 'MMMM' : 'minor', + 'MMMMM' : 'minor', + 'PP' : 'patch', + 'PPP' : 'patch', + 'PPPP' : 'patch', + 'PPPPP' : 'patch', + 'PATCH' : 'patch', + 'build_no' : 'bid', + 'bid' : 'bid', + 'BID' : 'bid', + 'BB' : 'bid', + 'BBB' : 'bid', + 'BBBB' : 'bid', + 'BBBBB' : 'bid', + 'BBBBBB' : 'bid', + 'BBBBBBB' : 'bid', +} + + +class CalendarInfo(typ.NamedTuple): + """Container for calendar components of version strings.""" + + year : int + quarter : int + month : int + dom : int + doy : int + iso_week: int + us_week : int + + +def _date_from_doy(year: int, doy: int) -> dt.date: + """Parse date from year and day of year (1 indexed). + + >>> cases = [ + ... (2016, 1), (2016, 31), (2016, 31 + 1), (2016, 31 + 29), (2016, 31 + 30), + ... (2017, 1), (2017, 31), (2017, 31 + 1), (2017, 31 + 28), (2017, 31 + 29), + ... ] + >>> dates = [_date_from_doy(year, month) for year, month in cases] + >>> assert [(d.month, d.day) for d in dates] == [ + ... (1, 1), (1, 31), (2, 1), (2, 29), (3, 1), + ... (1, 1), (1, 31), (2, 1), (2, 28), (3, 1), + ... ] + """ + return dt.date(year, 1, 1) + dt.timedelta(days=doy - 1) + + +def _quarter_from_month(month: int) -> int: + """Calculate quarter (1 indexed) from month (1 indexed). + + >>> [_quarter_from_month(month) for month in range(1, 13)] + [1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4] + """ + return ((month - 1) // 3) + 1 + + +def cal_info(date: dt.date = None) -> CalendarInfo: + """Generate calendar components for current date. + + >>> from datetime import date + + >>> c = cal_info(date(2019, 1, 5)) + >>> (c.year, c.quarter, c.month, c.dom, c.doy, c.iso_week, c.us_week) + (2019, 1, 1, 5, 5, 0, 0) + + >>> c = cal_info(date(2019, 1, 6)) + >>> (c.year, c.quarter, c.month, c.dom, c.doy, c.iso_week, c.us_week) + (2019, 1, 1, 6, 6, 0, 1) + + >>> c = cal_info(date(2019, 1, 7)) + >>> (c.year, c.quarter, c.month, c.dom, c.doy, c.iso_week, c.us_week) + (2019, 1, 1, 7, 7, 1, 1) + + >>> c = cal_info(date(2019, 4, 7)) + >>> (c.year, c.quarter, c.month, c.dom, c.doy, c.iso_week, c.us_week) + (2019, 2, 4, 7, 97, 13, 14) + """ + if date is None: + date = TODAY + + kw = { + 'year' : date.year, + 'quarter' : _quarter_from_month(date.month), + 'month' : date.month, + 'dom' : date.day, + 'doy' : int(date.strftime("%j"), base=10), + 'iso_week': int(date.strftime("%W"), base=10), + 'us_week' : int(date.strftime("%U"), base=10), + } + + return CalendarInfo(**kw) class VersionInfo(typ.NamedTuple): """Container for parsed version string.""" - version : str - pep440_version: str - calver : str - year : str - month : str - build : str - build_no : str - release : typ.Optional[str] - release_tag : typ.Optional[str] + year : typ.Optional[int] + quarter : typ.Optional[int] + month : typ.Optional[int] + dom : typ.Optional[int] + doy : typ.Optional[int] + iso_week: typ.Optional[int] + us_week : typ.Optional[int] + major : int + minor : int + patch : int + bid : str + tag : str -def parse_version_info(version_str: str) -> VersionInfo: - """Parse a PyCalVer string. +def _is_calver(nfo: typ.Union[CalendarInfo, VersionInfo]) -> bool: + """Check pattern for any calendar based parts. - >>> vnfo = parse_version_info("v201712.0033-beta") - >>> assert vnfo == VersionInfo( - ... version ="v201712.0033-beta", - ... pep440_version="201712.33b0", - ... calver ="v201712", - ... year ="2017", - ... month ="12", - ... build =".0033", - ... build_no ="0033", - ... release ="-beta", - ... release_tag ="beta", - ... ) + >>> _is_calver(cal_info()) + True + + >>> vnfo = _parse_version_info({'year': "2018", 'month': "11", 'bid': "0018"}) + >>> _is_calver(vnfo) + True + + >>> vnfo = _parse_version_info({'MAJOR': "1", 'MINOR': "023", 'PATCH': "45"}) + >>> _is_calver(vnfo) + False """ - match = PYCALVER_RE.match(version_str) - if match is None: - raise ValueError(f"Invalid PyCalVer string: {version_str}") + for field in CalendarInfo._fields: + if isinstance(getattr(nfo, field, None), int): + return True - kwargs = match.groupdict() - kwargs['pep440_version'] = pycalver_to_pep440(kwargs['version']) - if kwargs['release'] is None: - kwargs['release'] = "-final" - if kwargs['release_tag'] is None: - kwargs['release_tag'] = "final" - return VersionInfo(**kwargs) + return False -def current_calver() -> str: - """Generate calver version string based on current date. - - example result: "v201812" - """ - return dt.date.today().strftime("v%Y%m") +TAG_ALIASES: typ.Dict[str, str] = {'a': "alpha", 'b': "beta", 'pre': "rc"} -def incr(old_version: str, *, release: str = None) -> str: - """Increment a full PyCalVer version string. +PEP440_TAGS: typ.Dict[str, str] = {'alpha': "a", 'beta': "b", 'final': "", 'rc': "rc", 'dev': "dev"} - Old_version is assumed to be a valid calver string, - already validated in pycalver.config.parse. - """ - old_ver = parse_version_info(old_version) - new_calver = current_calver() +VersionInfoKW = typ.Dict[str, typ.Union[str, int, None]] - if old_ver.calver > new_calver: - log.warning( - f"'version.incr' called with '{old_version}', " - + f"which is from the future, " - + f"maybe your system clock is out of sync." + +def _parse_pattern_groups(pattern_groups: typ.Dict[str, str]) -> typ.Dict[str, str]: + for part_name in pattern_groups.keys(): + is_valid_part_name = ( + part_name in patterns.COMPOSITE_PART_PATTERNS or part_name in PATTERN_PART_FIELDS ) - # leave calver as is (don't go back in time) - new_calver = old_ver.calver + if not is_valid_part_name: + err_msg = f"Invalid part '{part_name}'" + raise ValueError(err_msg) - new_build = lex_id.next_id(old_ver.build[1:]) - new_release: typ.Optional[str] = None + items = [ + (field_name, pattern_groups[part_name]) + for part_name, field_name in PATTERN_PART_FIELDS.items() + if part_name in pattern_groups.keys() + ] + all_fields = [field_name for field_name, _ in items] + unique_fields = set(all_fields) + duplicate_fields = [f for f in unique_fields if all_fields.count(f) > 1] - if release is None: - if old_ver.release: - # preserve existing release - new_release = old_ver.release[1:] - else: - new_release = None - elif release == 'final': - new_release = None + if any(duplicate_fields): + err_msg = f"Multiple parts for same field {duplicate_fields}." + raise ValueError(err_msg) + + return dict(items) + + +def _parse_version_info(pattern_groups: typ.Dict[str, str]) -> VersionInfo: + """Parse normalized VersionInfo from groups of a matched pattern. + + >>> vnfo = _parse_version_info({'year': "2018", 'month': "11", 'bid': "0099"}) + >>> (vnfo.year, vnfo.month, vnfo.quarter, vnfo.bid, vnfo.tag) + (2018, 11, 4, '0099', 'final') + + >>> vnfo = _parse_version_info({'year': "2018", 'doy': "11", 'bid': "099", 'tag': "b"}) + >>> (vnfo.year, vnfo.month, vnfo.dom, vnfo.bid, vnfo.tag) + (2018, 1, 11, '099', 'beta') + + >>> vnfo = _parse_version_info({'MAJOR': "1", 'MINOR': "23", 'PATCH': "45"}) + >>> (vnfo.major, vnfo.minor, vnfo.patch) + (1, 23, 45) + + >>> vnfo = _parse_version_info({'MAJOR': "1", 'MMM': "023", 'PPPP': "0045"}) + >>> (vnfo.major, vnfo.minor, vnfo.patch) + (1, 23, 45) + """ + kw = _parse_pattern_groups(pattern_groups) + + tag = kw.get('tag') + if tag is None: + tag = "final" + tag = TAG_ALIASES.get(tag, tag) + assert tag is not None + + bid = kw['bid'] if 'bid' in kw else "0001" + + year = int(kw['year']) if 'year' in kw else None + doy = int(kw['doy' ]) if 'doy' in kw else None + + month: typ.Optional[int] + dom : typ.Optional[int] + + if year and doy: + date = _date_from_doy(year, doy) + month = date.month + dom = date.day else: - new_release = release + month = int(kw['month']) if 'month' in kw else None + dom = int(kw['dom' ]) if 'dom' in kw else None - if new_release == 'final': - new_release = None + iso_week: typ.Optional[int] + us_week : typ.Optional[int] - new_version = new_calver + "." + new_build - if new_release: - new_version += "-" + new_release - return new_version + if year and month and dom: + date = dt.date(year, month, dom) + doy = int(date.strftime("%j"), base=10) + iso_week = int(date.strftime("%W"), base=10) + us_week = int(date.strftime("%U"), base=10) + else: + iso_week = None + us_week = None + + quarter = int(kw['quarter']) if 'quarter' in kw else None + if quarter is None and month: + quarter = _quarter_from_month(month) + + major = int(kw['major']) if 'major' in kw else 0 + minor = int(kw['minor']) if 'minor' in kw else 0 + patch = int(kw['patch']) if 'patch' in kw else 0 + + return VersionInfo( + year=year, + quarter=quarter, + month=month, + dom=dom, + doy=doy, + iso_week=iso_week, + us_week=us_week, + major=major, + minor=minor, + patch=patch, + bid=bid, + tag=tag, + ) -def pycalver_to_pep440(version: str) -> str: +def parse_version_info(version_str: str, pattern: str = "{pycalver}") -> VersionInfo: + """Parse normalized VersionInfo. + + >>> vnfo = parse_version_info("v201712.0033-beta", pattern="{pycalver}") + >>> assert vnfo == _parse_version_info({'year': 2017, 'month': 12, 'bid': "0033", 'tag': "beta"}) + + >>> vnfo = parse_version_info("1.23.456", pattern="{semver}") + >>> assert vnfo == _parse_version_info({'MAJOR': "1", 'MINOR': "23", 'PATCH': "456"}) + """ + regex = patterns.compile_pattern(pattern) + match = regex.match(version_str) + if match is None: + err_msg = ( + f"Invalid version string '{version_str}' for pattern '{pattern}'/'{regex.pattern}'" + ) + raise ValueError(err_msg) + + return _parse_version_info(match.groupdict()) + + +def is_valid(version_str: str, pattern: str = "{pycalver}") -> bool: + """Check if a version matches a pattern. + + >>> is_valid("v201712.0033-beta", pattern="{pycalver}") + True + >>> is_valid("v201712.0033-beta", pattern="{semver}") + False + >>> is_valid("1.2.3", pattern="{semver}") + True + >>> is_valid("v201712.0033-beta", pattern="{semver}") + False + """ + try: + parse_version_info(version_str, pattern) + return True + except ValueError: + return False + + +ID_FIELDS_BY_PART = { + 'MAJOR' : 'major', + 'MINOR' : 'minor', + 'MM' : 'minor', + 'MMM' : 'minor', + 'MMMM' : 'minor', + 'MMMMM' : 'minor', + 'MMMMMM' : 'minor', + 'MMMMMMM': 'minor', + 'PATCH' : 'patch', + 'PP' : 'patch', + 'PPP' : 'patch', + 'PPPP' : 'patch', + 'PPPPP' : 'patch', + 'PPPPPP' : 'patch', + 'PPPPPPP': 'patch', + 'BID' : 'bid', + 'BB' : 'bid', + 'BBB' : 'bid', + 'BBBB' : 'bid', + 'BBBBB' : 'bid', + 'BBBBBB' : 'bid', + 'BBBBBBB': 'bid', +} + + +def format_version(ver_nfo: VersionInfo, pattern: str) -> str: + """Generate version string. + + >>> import datetime as dt + >>> ver_nfo = parse_version_info("v201712.0033-beta", pattern="{pycalver}") + >>> ver_nfo_a = ver_nfo._replace(**cal_info(date=dt.date(2017, 1, 1))._asdict()) + >>> ver_nfo_b = ver_nfo._replace(**cal_info(date=dt.date(2017, 12, 31))._asdict()) + >>> ver_nfo_c = ver_nfo_b._replace(major=1, minor=2, patch=34, tag='final') + + >>> format_version(ver_nfo_a, pattern="v{yy}.{BID}{release}") + 'v17.33-beta' + >>> format_version(ver_nfo_a, pattern="{pep440_version}") + '201701.33b0' + + >>> format_version(ver_nfo_a, pattern="{pycalver}") + 'v201701.0033-beta' + >>> format_version(ver_nfo_b, pattern="{pycalver}") + 'v201712.0033-beta' + + >>> format_version(ver_nfo_a, pattern="v{year}w{iso_week}.{BID}{release}") + 'v2017w00.33-beta' + >>> format_version(ver_nfo_b, pattern="v{year}w{iso_week}.{BID}{release}") + 'v2017w52.33-beta' + + >>> format_version(ver_nfo_a, pattern="v{year}d{doy}.{bid}{release}") + 'v2017d001.0033-beta' + >>> format_version(ver_nfo_b, pattern="v{year}d{doy}.{bid}{release}") + 'v2017d365.0033-beta' + + >>> format_version(ver_nfo_c, pattern="v{year}w{iso_week}.{BID}-{tag}") + 'v2017w52.33-final' + >>> format_version(ver_nfo_c, pattern="v{year}w{iso_week}.{BID}{release}") + 'v2017w52.33' + + >>> format_version(ver_nfo_c, pattern="v{MAJOR}.{MINOR}.{PATCH}") + 'v1.2.34' + >>> format_version(ver_nfo_c, pattern="v{MAJOR}.{MM}.{PPP}") + 'v1.02.034' + """ + full_pattern = pattern + for part_name, full_part_format in patterns.FULL_PART_FORMATS.items(): + full_pattern = full_pattern.replace("{" + part_name + "}", full_part_format) + + kw = ver_nfo._asdict() + if kw['tag'] == 'final': + kw['release' ] = "" + kw['pep440_tag'] = "" + else: + kw['release' ] = "-" + kw['tag'] + kw['pep440_tag'] = PEP440_TAGS[kw['tag']] + "0" + + kw['release_tag'] = kw['tag'] + + kw['yy' ] = str(kw['year'])[-2:] + kw['yyyy'] = kw['year'] + kw['BID' ] = int(kw['bid'], 10) + + for part_name, field in ID_FIELDS_BY_PART.items(): + val = kw[field] + if part_name.lower() == field.lower(): + if isinstance(val, str): + kw[part_name] = int(val, base=10) + else: + kw[part_name] = val + else: + assert len(set(part_name)) == 1 + padded_len = len(part_name) + kw[part_name] = str(val).zfill(padded_len) + + return full_pattern.format(**kw) + + +def incr( + old_version: str, + pattern : str = "{pycalver}", + *, + release: str = None, + major : bool = False, + minor : bool = False, + patch : bool = False, +) -> typ.Optional[str]: + """Increment version string. + + 'old_version' is assumed to be a string that matches 'pattern' + """ + old_ver_nfo = parse_version_info(old_version, pattern) + cur_ver_nfo = old_ver_nfo + + cur_cal_nfo = cal_info() + + old_date = (old_ver_nfo.year or 0, old_ver_nfo.month or 0, old_ver_nfo.dom or 0) + cur_date = (cur_cal_nfo.year , cur_cal_nfo.month , cur_cal_nfo.dom) + + if old_date <= cur_date: + cur_ver_nfo = cur_ver_nfo._replace(**cur_cal_nfo._asdict()) + else: + log.warning(f"Version appears to be from the future '{old_version}'") + + cur_ver_nfo = cur_ver_nfo._replace(bid=lex_id.next_id(cur_ver_nfo.bid)) + + if major: + cur_ver_nfo = cur_ver_nfo._replace(major=cur_ver_nfo.major + 1, minor=0, patch=0) + if minor: + cur_ver_nfo = cur_ver_nfo._replace(minor=cur_ver_nfo.minor + 1, patch=0) + if patch: + cur_ver_nfo = cur_ver_nfo._replace(patch=cur_ver_nfo.patch + 1) + + if release: + cur_ver_nfo = cur_ver_nfo._replace(tag=release) + + new_version = format_version(cur_ver_nfo, pattern) + if new_version == old_version: + log.error("Invalid arguments or pattern, version did not change.") + return None + else: + return new_version + + +def to_pep440(version: str) -> str: """Derive pep440 compliant version string from PyCalVer version string. - >>> pycalver_to_pep440("v201811.0007-beta") + >>> to_pep440("v201811.0007-beta") '201811.7b0' """ return str(pkg_resources.parse_version(version)) diff --git a/test/test_cli.py b/test/test_cli.py index a9c5239..0e882e6 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -10,7 +10,7 @@ import pytest from click.testing import CliRunner import pycalver.config as config -import pycalver.version as version +import pycalver.patterns as patterns import pycalver.__main__ as pycalver @@ -80,18 +80,52 @@ def test_version(runner): result = runner.invoke(pycalver.cli, ['--version', "--verbose"]) assert result.exit_code == 0 assert " version v20" in result.output - match = version.PYCALVER_RE.search(result.output) + match = patterns.PYCALVER_RE.search(result.output) assert match -def test_incr(runner): +def test_incr_default(runner): old_version = "v201701.0999-alpha" initial_version = config._initial_version() - result = runner.invoke(pycalver.cli, ['test', old_version, "--verbose"]) + result = runner.invoke(pycalver.cli, ['test', "--verbose", old_version]) assert result.exit_code == 0 new_version = initial_version.replace(".0001-alpha", ".11000-alpha") - assert f"PyCalVer Version: {new_version}\n" in result.output + assert f"Version: {new_version}\n" in result.output + + +def test_incr_semver(runner): + semver_pattern = "{MAJOR}.{MINOR}.{PATCH}" + old_version = "0.1.0" + new_version = "0.1.1" + + result = runner.invoke(pycalver.cli, ['test', "--verbose", "--patch", old_version, "{semver}"]) + assert result.exit_code == 0 + assert f"Version: {new_version}\n" in result.output + + result = runner.invoke( + pycalver.cli, ['test', "--verbose", "--patch", old_version, semver_pattern] + ) + assert result.exit_code == 0 + assert f"Version: {new_version}\n" in result.output + + old_version = "0.1.1" + new_version = "0.2.0" + + result = runner.invoke( + pycalver.cli, ['test', "--verbose", "--minor", old_version, semver_pattern] + ) + assert result.exit_code == 0 + assert f"Version: {new_version}\n" in result.output + + old_version = "0.1.1" + new_version = "1.0.0" + + result = runner.invoke( + pycalver.cli, ['test', "--verbose", "--major", old_version, semver_pattern] + ) + assert result.exit_code == 0 + assert f"Version: {new_version}\n" in result.output def test_incr_to_beta(runner): @@ -101,7 +135,7 @@ def test_incr_to_beta(runner): result = runner.invoke(pycalver.cli, ['test', old_version, "--verbose", "--release", "beta"]) assert result.exit_code == 0 new_version = initial_version.replace(".0001-alpha", ".11000-beta") - assert f"PyCalVer Version: {new_version}\n" in result.output + assert f"Version: {new_version}\n" in result.output def test_incr_to_final(runner): @@ -111,7 +145,7 @@ def test_incr_to_final(runner): result = runner.invoke(pycalver.cli, ['test', old_version, "--verbose", "--release", "final"]) assert result.exit_code == 0 new_version = initial_version.replace(".0001-alpha", ".11000") - assert f"PyCalVer Version: {new_version}\n" in result.output + assert f"Version: {new_version}\n" in result.output def test_incr_invalid(runner, caplog): @@ -164,7 +198,7 @@ def test_novcs_nocfg_init(runner): result = runner.invoke(pycalver.cli, ['show', "--verbose"]) assert result.exit_code == 0 assert f"Current Version: {config._initial_version()}\n" in result.output - assert f"PEP440 Version : {config._initial_version_pep440()}\n" in result.output + assert f"PEP440 : {config._initial_version_pep440()}\n" in result.output def test_novcs_setupcfg_init(runner): @@ -184,7 +218,7 @@ def test_novcs_setupcfg_init(runner): result = runner.invoke(pycalver.cli, ['show', "--verbose"]) assert result.exit_code == 0 assert f"Current Version: {config._initial_version()}\n" in result.output - assert f"PEP440 Version : {config._initial_version_pep440()}\n" in result.output + assert f"PEP440 : {config._initial_version_pep440()}\n" in result.output def test_novcs_pyproject_init(runner): @@ -202,7 +236,7 @@ def test_novcs_pyproject_init(runner): result = runner.invoke(pycalver.cli, ['show']) assert result.exit_code == 0 assert f"Current Version: {config._initial_version()}\n" in result.output - assert f"PEP440 Version : {config._initial_version_pep440()}\n" in result.output + assert f"PEP440 : {config._initial_version_pep440()}\n" in result.output def _vcs_init(vcs): @@ -224,7 +258,7 @@ def test_git_init(runner): result = runner.invoke(pycalver.cli, ['show']) assert result.exit_code == 0 assert f"Current Version: {config._initial_version()}\n" in result.output - assert f"PEP440 Version : {config._initial_version_pep440()}\n" in result.output + assert f"PEP440 : {config._initial_version_pep440()}\n" in result.output def test_hg_init(runner): @@ -237,7 +271,7 @@ def test_hg_init(runner): result = runner.invoke(pycalver.cli, ['show']) assert result.exit_code == 0 assert f"Current Version: {config._initial_version()}\n" in result.output - assert f"PEP440 Version : {config._initial_version_pep440()}\n" in result.output + assert f"PEP440 : {config._initial_version_pep440()}\n" in result.output def test_git_tag_eval(runner): @@ -257,7 +291,7 @@ def test_git_tag_eval(runner): result = runner.invoke(pycalver.cli, ['show', "--verbose"]) assert result.exit_code == 0 assert f"Current Version: {tag_version}\n" in result.output - assert f"PEP440 Version : {tag_version_pep440}\n" in result.output + assert f"PEP440 : {tag_version_pep440}\n" in result.output def test_hg_tag_eval(runner): @@ -277,7 +311,7 @@ def test_hg_tag_eval(runner): result = runner.invoke(pycalver.cli, ['show', "--verbose"]) assert result.exit_code == 0 assert f"Current Version: {tag_version}\n" in result.output - assert f"PEP440 Version : {tag_version_pep440}\n" in result.output + assert f"PEP440 : {tag_version_pep440}\n" in result.output def test_novcs_bump(runner): diff --git a/test/test_config.py b/test/test_config.py index b30468e..22c86b0 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -58,8 +58,8 @@ def test_parse_toml(): assert cfg.push is True assert "pycalver.toml" in cfg.file_patterns - assert cfg.file_patterns["README.md" ] == ["{version}", "{pep440_version}"] - assert cfg.file_patterns["pycalver.toml"] == ['current_version = "{version}"'] + assert cfg.file_patterns["README.md" ] == ["{pycalver}", "{pep440_pycalver}"] + assert cfg.file_patterns["pycalver.toml"] == ['current_version = "{pycalver}"'] def test_parse_cfg(): @@ -74,8 +74,8 @@ def test_parse_cfg(): assert cfg.push is True assert "setup.cfg" in cfg.file_patterns - assert cfg.file_patterns["setup.py" ] == ["{version}", "{pep440_version}"] - assert cfg.file_patterns["setup.cfg"] == ['current_version = "{version}"'] + assert cfg.file_patterns["setup.py" ] == ["{pycalver}", "{pep440_pycalver}"] + assert cfg.file_patterns["setup.cfg"] == ['current_version = "{pycalver}"'] def test_parse_default_toml(): @@ -168,8 +168,8 @@ def test_parse_toml_file(tmpdir): assert cfg.push is True assert cfg.file_patterns == { - "README.md" : ["{version}", "{pep440_version}"], - "pycalver.toml": ['current_version = "{version}"'], + "README.md" : ["{pycalver}", "{pep440_pycalver}"], + "pycalver.toml": ['current_version = "{pycalver}"'], } @@ -190,8 +190,8 @@ def test_parse_cfg_file(tmpdir): assert cfg.push is True assert cfg.file_patterns == { - "setup.py" : ["{version}", "{pep440_version}"], - "setup.cfg": ['current_version = "{version}"'], + "setup.py" : ["{pycalver}", "{pep440_pycalver}"], + "setup.cfg": ['current_version = "{pycalver}"'], } diff --git a/test/test_parse.py b/test/test_parse.py index 8b38975..0e3be41 100644 --- a/test/test_parse.py +++ b/test/test_parse.py @@ -1,50 +1,6 @@ -import re from pycalver import parse -def test_re_pattern_parts(): - part_re_by_name = { - part_name: re.compile(part_re_str) - for part_name, part_re_str in parse.RE_PATTERN_PARTS.items() - } - - cases = [ - ("pep440_version", "201712.31" , "201712.31"), - ("pep440_version", "v201712.0032" , None), - ("pep440_version", "201712.0033-alpha" , None), - ("version" , "v201712.0034" , "v201712.0034"), - ("version" , "v201712.0035-alpha" , "v201712.0035-alpha"), - ("version" , "v201712.0036-alpha0", "v201712.0036-alpha"), - ("version" , "v201712.0037-pre" , "v201712.0037"), - ("version" , "201712.38a0" , None), - ("version" , "201712.0039" , None), - ("calver" , "v201712" , "v201712"), - ("calver" , "v201799" , "v201799"), # maybe date validation should be a thing - ("calver" , "201712" , None), - ("calver" , "v20171" , None), - ("build" , ".0012" , ".0012"), - ("build" , ".11012" , ".11012"), - ("build" , ".012" , None), - ("build" , "11012" , None), - ("release" , "-alpha" , "-alpha"), - ("release" , "-beta" , "-beta"), - ("release" , "-dev" , "-dev"), - ("release" , "-rc" , "-rc"), - ("release" , "-post" , "-post"), - ("release" , "-pre" , ""), - ("release" , "alpha" , ""), - ] - - for part_name, line, expected in cases: - part_re = part_re_by_name[part_name] - result = part_re.search(line) - if result is None: - assert expected is None, (part_name, line) - else: - result_val = result.group(0) - assert result_val == expected, (part_name, line) - - SETUP_PY_FIXTURE = """ # setup.py import setuptools @@ -57,7 +13,7 @@ setuptools.setup( def test_default_parse_patterns(): lines = SETUP_PY_FIXTURE.splitlines() - patterns = ["{version}", "{pep440_version}"] + patterns = ["{pycalver}", "{pep440_pycalver}"] matches = list(parse.iter_matches(lines, patterns)) assert len(matches) == 2 @@ -75,7 +31,7 @@ def test_default_parse_patterns(): def test_explicit_parse_patterns(): lines = SETUP_PY_FIXTURE.splitlines() - patterns = ["__version__ = '{version}'", "version='{pep440_version}'"] + patterns = ["__version__ = '{pycalver}'", "version='{pep440_pycalver}'"] matches = list(parse.iter_matches(lines, patterns)) assert len(matches) == 2 @@ -102,7 +58,7 @@ README_RST_FIXTURE = """ def test_badge_parse_patterns(): lines = README_RST_FIXTURE.splitlines() - patterns = ["badge/CalVer-{calver}{build}-{release}-blue.svg", ":alt: CalVer {version}"] + patterns = ["badge/CalVer-{calver}{build}-{release}-blue.svg", ":alt: CalVer {pycalver}"] matches = list(parse.iter_matches(lines, patterns)) assert len(matches) == 2 @@ -115,30 +71,3 @@ def test_badge_parse_patterns(): assert matches[0].match == "badge/CalVer-v201809.0002--beta-blue.svg" assert matches[1].match == ":alt: CalVer v201809.0002-beta" - - -CLI_MAIN_FIXTURE = """ -@click.group() -@click.version_option(version="v201812.0123-beta") -@click.help_option() -""" - - -def test_pattern_escapes(): - pattern_re = parse.compile_pattern(r'click.version_option(version="{version}")') - match = pattern_re.search(CLI_MAIN_FIXTURE) - assert match.group(0) == 'click.version_option(version="v201812.0123-beta")' - - -CURLY_BRACE_FIXTURE = """ -package_metadata = {"name": "mypackage", "version": "v201812.0123-beta"} -""" - - -def test_curly_escapes(): - pattern = r'package_metadata = {"name": "mypackage", "version": "{version}"}' - pattern_re = parse.compile_pattern(pattern) - match = pattern_re.search(CURLY_BRACE_FIXTURE) - assert ( - match.group(0) == 'package_metadata = {"name": "mypackage", "version": "v201812.0123-beta"}' - ) diff --git a/test/test_patterns.py b/test/test_patterns.py new file mode 100644 index 0000000..f0d0716 --- /dev/null +++ b/test/test_patterns.py @@ -0,0 +1,81 @@ +import re +import pytest + +from pycalver import patterns + + +def _part_re_by_name(name): + return re.compile(patterns.PART_PATTERNS[name]) + + +@pytest.mark.parametrize("part_name", patterns.PART_PATTERNS.keys()) +def test_part_compilation(part_name): + assert _part_re_by_name(part_name) + + +PATTERN_PART_CASES = [ + ("pep440_pycalver", "201712.31" , "201712.31"), + ("pep440_pycalver", "v201712.0032" , None), + ("pep440_pycalver", "201712.0033-alpha" , None), + ("pycalver" , "v201712.0034" , "v201712.0034"), + ("pycalver" , "v201712.0035-alpha" , "v201712.0035-alpha"), + ("pycalver" , "v201712.0036-alpha0", "v201712.0036-alpha"), + ("pycalver" , "v201712.0037-pre" , "v201712.0037"), + ("pycalver" , "201712.38a0" , None), + ("pycalver" , "201712.0039" , None), + ("semver" , "1.23.456" , "1.23.456"), + ("calver" , "v201712" , "v201712"), + ("calver" , "v201799" , None), # invalid date + ("calver" , "201712" , None), + ("calver" , "v20171" , None), + ("build" , ".0012" , ".0012"), + ("build" , ".11012" , ".11012"), + ("build" , ".012" , None), + ("build" , "11012" , None), + ("release" , "-alpha" , "-alpha"), + ("release" , "-beta" , "-beta"), + ("release" , "-dev" , "-dev"), + ("release" , "-rc" , "-rc"), + ("release" , "-post" , "-post"), + ("release" , "-pre" , None), + ("release" , "alpha" , None), +] + + +@pytest.mark.parametrize("part_name, line, expected", PATTERN_PART_CASES) +def test_re_pattern_parts(part_name, line, expected): + part_re = _part_re_by_name(part_name) + result = part_re.search(line) + if result is None: + assert expected is None, (part_name, line) + else: + result_val = result.group(0) + assert result_val == expected, (part_name, line) + + +CLI_MAIN_FIXTURE = """ +@click.group() +@click.version_option(version="v201812.0123-beta") +@click.help_option() +""" + + +def test_pattern_escapes(): + pattern = 'click.version_option(version="{pycalver}")' + pattern_re = patterns.compile_pattern(pattern) + match = pattern_re.search(CLI_MAIN_FIXTURE) + expected = 'click.version_option(version="v201812.0123-beta")' + assert match.group(0) == expected + + +CURLY_BRACE_FIXTURE = """ +package_metadata = {"name": "mypackage", "version": "v201812.0123-beta"} +""" + + +def test_curly_escapes(): + pattern = 'package_metadata = {"name": "mypackage", "version": "{pycalver}"}' + pattern_re = patterns.compile_pattern(pattern) + match = pattern_re.search(CURLY_BRACE_FIXTURE) + expected = 'package_metadata = {"name": "mypackage", "version": "v201812.0123-beta"}' + assert match.group(0) == expected diff --git a/test/test_rewrite.py b/test/test_rewrite.py index f5a5ded..747bc05 100644 --- a/test/test_rewrite.py +++ b/test/test_rewrite.py @@ -9,7 +9,7 @@ __version__ = "v201809.0002-beta" def test_rewrite_lines(): old_lines = REWRITE_FIXTURE.splitlines() - patterns = ['__version__ = "{version}"'] + patterns = ['__version__ = "{pycalver}"'] new_lines = rewrite.rewrite_lines(patterns, "v201911.0003", old_lines) assert len(new_lines) == len(old_lines) diff --git a/test/test_version.py b/test/test_version.py index c106365..4128fdc 100644 --- a/test/test_version.py +++ b/test/test_version.py @@ -2,18 +2,11 @@ import random import datetime as dt from pycalver import version - - -def test_current_calver(): - v = version.current_calver() - assert len(v) == 7 - assert v.startswith("v") - assert v[1:].isdigit() +from pycalver import patterns def test_bump_beta(): - calver = version.current_calver() - cur_version = calver + ".0001-beta" + cur_version = "v201712.0001-beta" assert cur_version < version.incr(cur_version) assert version.incr(cur_version).endswith("-beta") assert version.incr(cur_version, release="alpha").endswith("-alpha") @@ -21,8 +14,7 @@ def test_bump_beta(): def test_bump_final(): - calver = version.current_calver() - cur_version = calver + ".0001" + cur_version = "v201712.0001" assert cur_version < version.incr(cur_version) assert version.incr(cur_version).endswith(".0002") assert version.incr(cur_version, release="alpha").endswith("-alpha") @@ -34,20 +26,19 @@ def test_bump_final(): def test_bump_future(): + """Test that versions don't go back in time.""" future_date = dt.datetime.today() + dt.timedelta(days=300) future_calver = future_date.strftime("v%Y%m") cur_version = future_calver + ".0001" - assert cur_version < version.incr(cur_version) + new_version = version.incr(cur_version) + assert cur_version < new_version def test_bump_random(monkeypatch): - cur_date = dt.date.today() + cur_date = dt.date(2016, 1, 1) + dt.timedelta(days=random.randint(1, 2000)) cur_version = cur_date.strftime("v%Y%m") + ".0001-dev" - def _mock_current_calver(): - return cur_date.strftime("v%Y%m") - - monkeypatch.setattr(version, 'current_calver', _mock_current_calver) + monkeypatch.setattr(version, 'TODAY', cur_date) for i in range(1000): cur_date += dt.timedelta(days=int((1 + random.random()) ** 10)) @@ -62,37 +53,31 @@ def test_parse_version_info(): version_str = "v201712.0001-alpha" version_nfo = version.parse_version_info(version_str) - assert version_nfo.pep440_version == "201712.1a0" - assert version_nfo.version == "v201712.0001-alpha" - assert version_nfo.calver == "v201712" - assert version_nfo.year == "2017" - assert version_nfo.month == "12" - assert version_nfo.build == ".0001" - assert version_nfo.release == "-alpha" - assert version_nfo.build_no == "0001" - assert version_nfo.release_tag == "alpha" + # assert version_nfo.pep440_version == "201712.1a0" + # assert version_nfo.version == "v201712.0001-alpha" + assert version_nfo.year == 2017 + assert version_nfo.month == 12 + assert version_nfo.bid == "0001" + assert version_nfo.tag == "alpha" version_str = "v201712.0001" version_nfo = version.parse_version_info(version_str) - assert version_nfo.pep440_version == "201712.1" - assert version_nfo.version == "v201712.0001" - assert version_nfo.calver == "v201712" - assert version_nfo.year == "2017" - assert version_nfo.month == "12" - assert version_nfo.build == ".0001" - assert version_nfo.release == "-final" - assert version_nfo.build_no == "0001" - assert version_nfo.release_tag == "final" + # assert version_nfo.pep440_version == "201712.1" + # assert version_nfo.version == "v201712.0001" + assert version_nfo.year == 2017 + assert version_nfo.month == 12 + assert version_nfo.bid == "0001" + assert version_nfo.tag == "final" def test_readme_pycalver1(): version_str = "v201712.0001-alpha" - version_info = version.PYCALVER_RE.match(version_str).groupdict() + version_info = patterns.PYCALVER_RE.match(version_str).groupdict() assert version_info == { - 'version' : "v201712.0001-alpha", - 'calver' : "v201712", + 'pycalver' : "v201712.0001-alpha", + 'vYYYYMM' : "v201712", 'year' : "2017", 'month' : "12", 'build' : ".0001", @@ -104,11 +89,11 @@ def test_readme_pycalver1(): def test_readme_pycalver2(): version_str = "v201712.0033" - version_info = version.PYCALVER_RE.match(version_str).groupdict() + version_info = patterns.PYCALVER_RE.match(version_str).groupdict() assert version_info == { - 'version' : "v201712.0033", - 'calver' : "v201712", + 'pycalver' : "v201712.0033", + 'vYYYYMM' : "v201712", 'year' : "2017", 'month' : "12", 'build' : ".0033", @@ -140,3 +125,19 @@ def test_parse_error_nopadding(): assert False except ValueError as err: pass + + +def test_part_field_mapping(): + a_names = set(version.PATTERN_PART_FIELDS.keys()) + b_names = set(patterns.PART_PATTERNS.keys()) + c_names = set(patterns.COMPOSITE_PART_PATTERNS.keys()) + + extra_names = a_names - b_names + assert not any(extra_names) + missing_names = b_names - a_names + assert missing_names == c_names + + a_fields = set(version.PATTERN_PART_FIELDS.values()) + b_fields = set(version.VersionInfo._fields) + + assert a_fields == b_fields