diff --git a/license.header b/license.header index 6b5623a..cb08b33 100644 --- a/license.header +++ b/license.header @@ -1,9 +1,9 @@ Individual files contain the following tag instead of the full license text. This file is part of the pycalver project - https://gitlab.com/mbarkhau/pycalver + https://github.com/mbarkhau/pycalver - Copyright (c) 2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License + Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License SPDX-License-Identifier: MIT This enables machine processing of license information based on the SPDX diff --git a/makefile b/makefile index 25099b5..61364ab 100644 --- a/makefile +++ b/makefile @@ -61,3 +61,5 @@ test_compat: $(COMPAT_TEST_FILES) ENABLE_BACKTRACE=0 PYTHONPATH="" ENV=$${ENV-dev} \ $${env_py} -m pytest --verbose compat_test/; \ done; + + rm -rf compat_test/ diff --git a/pycalver1k.svg b/pycalver1k.svg index 156e809..b7133c9 100644 --- a/pycalver1k.svg +++ b/pycalver1k.svg @@ -14,8 +14,8 @@ version="1.1" id="svg8" inkscape:version="1.0 (b51213c273, 2020-08-10)" - sodipodi:docname="pycalver_1k.svg" - inkscape:export-filename="C:\Users\ManuelBarkhau\Dropbox\projects\pycalver\pycalver_128.png" + sodipodi:docname="pycalver1k.svg" + inkscape:export-filename="/home/mbarkhau/foss/pycalver/pycalver1k2_128.png" inkscape:export-xdpi="96" inkscape:export-ydpi="96"> + gradientTransform="matrix(1.0541065,0,0,0.87055183,-0.916204,-223.64659)" /> @@ -201,6 +201,59 @@ + + + + + + + + + + + + + - + id="layer6" + inkscape:label="bg" + style="display:inline"> + + + + + style="display:inline"> v20202020 + + + + + + .1001 + id="tspan919" + x="4.7286582" + y="13.507061" + style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:16.9333px;font-family:'Iosevka Fixed SS05';-inkscape-font-specification:'Iosevka Fixed SS05 Medium';fill:#ffffff;stroke-width:0.265;stroke-miterlimit:4;stroke-dasharray:none">ver + x="1.5910856" + y="16.622082" /> + + + style="display:none" + sodipodi:insensitive="true"> + x="7.3927369" + y="1.7978847" /> + x="24.886431" + y="1.7978847" /> diff --git a/pycalver1k2_128.png b/pycalver1k2_128.png new file mode 100644 index 0000000..30ea018 Binary files /dev/null and b/pycalver1k2_128.png differ diff --git a/requirements/pypi.txt b/requirements/pypi.txt index 0504771..0bd5192 100644 --- a/requirements/pypi.txt +++ b/requirements/pypi.txt @@ -12,3 +12,4 @@ typing; python_version < "3.5" click toml six +lexid diff --git a/scratch.py b/scratch.py deleted file mode 100644 index cfdbc6d..0000000 --- a/scratch.py +++ /dev/null @@ -1,3 +0,0 @@ -import sys - -print(sys.version) diff --git a/setup.py b/setup.py index 78c823f..8cf0107 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ # This file is part of the pycalver project -# https://gitlab.com/mbarkhau/pycalver +# https://github.com/mbarkhau/pycalver # -# Copyright (c) 2019 Manuel Barkhau (mbarkhau@gmail.com) - MIT License +# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License # SPDX-License-Identifier: MIT import os @@ -26,10 +26,42 @@ install_requires = [ ] +long_description = "\n\n".join((read("README.md"), read("CHANGELOG.md"))) + + +# See https://pypi.python.org/pypi?%3Aaction=list_classifiers +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Environment :: Other Environment", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: Unix", + "Operating System :: POSIX", + "Operating System :: Microsoft :: Windows", + "Operating System :: MacOS :: MacOS X", + "Programming Language :: Python", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Libraries :: Python Modules", +] + package_dir = {"": "src"} -if any(arg.startswith("bdist") for arg in sys.argv): +is_lib3to6_fix_required = ( + any(arg.startswith("bdist") for arg in sys.argv) + and ( + "Programming Language :: Python :: 2.7" in classifiers + or "Programming Language :: Python :: 2" in classifiers + ) +) + + +if is_lib3to6_fix_required: try: import lib3to6 package_dir = lib3to6.fix(package_dir) @@ -43,9 +75,6 @@ if any(arg.startswith("bdist") for arg in sys.argv): )) -long_description = "\n\n".join((read("README.md"), read("CHANGELOG.md"))) - - setuptools.setup( name="pycalver", license="MIT", @@ -57,32 +86,14 @@ setuptools.setup( description="CalVer for python libraries.", long_description=long_description, long_description_content_type="text/markdown", - packages=['pycalver'], + packages=setuptools.find_packages("src/"), package_dir=package_dir, install_requires=install_requires, entry_points=""" [console_scripts] pycalver=pycalver.cli:cli """, + python_requires=">=2.7", zip_safe=True, - - # See https://pypi.python.org/pypi?%3Aaction=list_classifiers - classifiers=[ - "Development Status :: 4 - Beta", - "Environment :: Console", - "Environment :: Other Environment", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Operating System :: Unix", - "Operating System :: POSIX", - "Operating System :: Microsoft :: Windows", - "Operating System :: MacOS :: MacOS X", - "Programming Language :: Python", - "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: Implementation :: CPython", - "Programming Language :: Python :: Implementation :: PyPy", - "Topic :: Software Development :: Libraries", - "Topic :: Software Development :: Libraries :: Python Modules", - ], + classifiers=classifiers, ) diff --git a/src/pycalver/__init__.py b/src/pycalver/__init__.py index c1658bf..0d7e37b 100644 --- a/src/pycalver/__init__.py +++ b/src/pycalver/__init__.py @@ -1,7 +1,7 @@ # This file is part of the pycalver project -# https://gitlab.com/mbarkhau/pycalver +# https://github.com/mbarkhau/pycalver # -# Copyright (c) 2019 Manuel Barkhau (mbarkhau@gmail.com) - MIT License +# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License # SPDX-License-Identifier: MIT """PyCalVer: CalVer for Python Packages.""" diff --git a/src/pycalver/__main__.py b/src/pycalver/__main__.py index 8e8a3e9..80130c2 100755 --- a/src/pycalver/__main__.py +++ b/src/pycalver/__main__.py @@ -1,8 +1,8 @@ #!/usr/bin/env python # This file is part of the pycalver project -# https://gitlab.com/mbarkhau/pycalver +# https://github.com/mbarkhau/pycalver # -# Copyright (c) 2019 Manuel Barkhau (mbarkhau@gmail.com) - MIT License +# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License # SPDX-License-Identifier: MIT """ __main__ module for PyCalVer. diff --git a/src/pycalver/cli.py b/src/pycalver/cli.py index 0316382..47b305a 100755 --- a/src/pycalver/cli.py +++ b/src/pycalver/cli.py @@ -1,8 +1,8 @@ #!/usr/bin/env python # This file is part of the pycalver project -# https://gitlab.com/mbarkhau/pycalver +# https://github.com/mbarkhau/pycalver # -# Copyright (c) 2019 Manuel Barkhau (mbarkhau@gmail.com) - MIT License +# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License # SPDX-License-Identifier: MIT """ CLI module for PyCalVer. diff --git a/src/pycalver/config.py b/src/pycalver/config.py index a214d0c..e5b41a8 100644 --- a/src/pycalver/config.py +++ b/src/pycalver/config.py @@ -234,6 +234,8 @@ def _parse_config(raw_cfg: RawConfig) -> Config: version_str: str = raw_cfg['current_version'] version_str = raw_cfg['current_version'] = version_str.strip("'\" ") + # TODO (mb 2020-09-06): new style pattern by default + # version_pattern: str = raw_cfg.get('version_pattern', "vYYYY0M.BUILD[-TAG]") version_pattern: str = raw_cfg.get('version_pattern', "{pycalver}") version_pattern = raw_cfg['version_pattern'] = version_pattern.strip("'\" ") diff --git a/src/pycalver/lex_id.py b/src/pycalver/lex_id.py deleted file mode 100644 index 5c6af28..0000000 --- a/src/pycalver/lex_id.py +++ /dev/null @@ -1,169 +0,0 @@ -# This file is part of the pycalver project -# https://gitlab.com/mbarkhau/pycalver -# -# Copyright (c) 2019 Manuel Barkhau (mbarkhau@gmail.com) - MIT License -# SPDX-License-Identifier: MIT - -"""A scheme for lexically ordered numerical ids. - -Throughout the sequence this expression remains true, whether you -are dealing with integers or strings: - - older_id < newer_id - -The left most character/digit is only used to maintain lexical -order, so that the position in the sequence is maintained in the -remaining digits. - - sequence_pos = int(idval[1:], 10) - -lexical sequence_pos -0 0 -11 1 -12 2 -... -19 9 -220 20 -221 21 -... -298 98 -299 99 -3300 300 -3301 301 -... -3998 998 -3999 999 -44000 4000 -44001 4001 -... -899999998 99999998 -899999999 99999999 -9900000000 900000000 -9900000001 900000001 -... -9999999998 999999998 -9999999999 999999999 # maximum value - -You can add leading zeros to delay the expansion and/or increase -the maximum possible value. - -lexical sequence_pos -0001 1 -0002 2 -0003 3 -... -0999 999 -11000 1000 -11001 1001 -11002 1002 -... -19998 9998 -19999 9999 -220000 20000 -220001 20001 -... -899999999998 99999999998 -899999999999 99999999999 -9900000000000 900000000000 -9900000000001 900000000001 -... -9999999999998 999999999998 -9999999999999 999999999999 # maximum value - -This scheme is useful when you just want an ordered sequence of -numbers, but the numbers don't have any particular meaning or -arithmetical relation. The only relation they have to each other -is that numbers generated later in the sequence are greater than -ones generated earlier. -""" - - -MINIMUM_ID = "0" - - -def next_id(prev_id: str) -> str: - """Generate next lexical id. - - Increments by one and adds padding if required. - - >>> next_id("0098") - '0099' - >>> next_id("0099") - '0100' - >>> next_id("0999") - '11000' - >>> next_id("11000") - '11001' - """ - - num_digits = len(prev_id) - - if prev_id.count("9") == num_digits: - raise OverflowError("max lexical version reached: " + prev_id) - - _prev_id_val = int(prev_id, 10) - _maybe_next_id_val = int(_prev_id_val) + 1 - _maybe_next_id_str = f"{_maybe_next_id_val:0{num_digits}}" - - _is_padding_ok = prev_id[0] == _maybe_next_id_str[0] - _next_id_str: str - - if _is_padding_ok: - _next_id_str = _maybe_next_id_str - else: - _next_id_str = str(_maybe_next_id_val * 11) - return _next_id_str - - -def ord_val(lex_id: str) -> int: - """Parse the ordinal value of a lexical id. - - The ordinal value is the position in the sequence, - from repeated calls to next_id. - - >>> ord_val("0098") - 98 - >>> ord_val("0099") - 99 - >>> ord_val("0100") - 100 - >>> ord_val("11000") - 1000 - >>> ord_val("11001") - 1001 - """ - if len(lex_id) == 1: - return int(lex_id, 10) - else: - return int(lex_id[1:], 10) - - -def _main() -> None: - _curr_id = "01" - print(f"{'lexical':<13} {'numerical':>12}") - - while True: - print(f"{_curr_id:<13} {ord_val(_curr_id):>12}") - _next_id = next_id(_curr_id) - - if _next_id.count("9") == len(_next_id): - # all nines, we're done - print(f"{_next_id:<13} {ord_val(_next_id):>12}") - break - - if _next_id[0] != _curr_id[0] and len(_curr_id) > 1: - print(f"{_next_id:<13} {ord_val(_next_id):>12}") - _next_id = next_id(_next_id) - print(f"{_next_id:<13} {ord_val(_next_id):>12}") - _next_id = next_id(_next_id) - - print("...") - - # skip ahead - _next_id = _next_id[:1] + "9" * (len(_next_id) - 2) + "8" - - _curr_id = _next_id - - -if __name__ == '__main__': - _main() diff --git a/src/pycalver/parse.py b/src/pycalver/parse.py index 402ad39..aceb631 100644 --- a/src/pycalver/parse.py +++ b/src/pycalver/parse.py @@ -1,7 +1,7 @@ # This file is part of the pycalver project -# https://gitlab.com/mbarkhau/pycalver +# https://github.com/mbarkhau/pycalver # -# Copyright (c) 2019 Manuel Barkhau (mbarkhau@gmail.com) - MIT License +# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License # SPDX-License-Identifier: MIT """Parse PyCalVer strings from files.""" @@ -22,6 +22,8 @@ class PatternMatch(typ.NamedTuple): PatternMatches = typ.Iterable[PatternMatch] +RegexpPatterns = typ.List[typ.Pattern[str]] + def _iter_for_pattern(lines: typ.List[str], pattern: str) -> PatternMatches: # The pattern is escaped, so that everything besides the format diff --git a/src/pycalver/patterns.py b/src/pycalver/patterns.py index b9b097f..23af859 100644 --- a/src/pycalver/patterns.py +++ b/src/pycalver/patterns.py @@ -1,7 +1,7 @@ # This file is part of the pycalver project -# https://gitlab.com/mbarkhau/pycalver +# https://github.com/mbarkhau/pycalver # -# Copyright (c) 2019 Manuel Barkhau (mbarkhau@gmail.com) - MIT License +# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License # SPDX-License-Identifier: MIT """Compose Regular Expressions from Patterns. diff --git a/src/pycalver/rewrite.py b/src/pycalver/rewrite.py index 2a566c3..c06fa7d 100644 --- a/src/pycalver/rewrite.py +++ b/src/pycalver/rewrite.py @@ -1,7 +1,7 @@ # This file is part of the pycalver project -# https://gitlab.com/mbarkhau/pycalver +# https://github.com/mbarkhau/pycalver # -# Copyright (c) 2019 Manuel Barkhau (mbarkhau@gmail.com) - MIT License +# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License # SPDX-License-Identifier: MIT """Rewrite files, updating occurences of version strings.""" @@ -13,8 +13,9 @@ import logging import pathlib2 as pl -from . import parse -from . import config +from pycalver import parse +from pycalver import config + from . import version from . import patterns @@ -53,6 +54,28 @@ class NoPatternMatch(Exception): """ +class RewrittenFileData(typ.NamedTuple): + """Container for line-wise content of rewritten files.""" + + path : str + line_sep : str + old_lines: typ.List[str] + new_lines: typ.List[str] + + +def iter_file_paths( + file_patterns: config.PatternsByGlob, +) -> typ.Iterable[typ.Tuple[pl.Path, config.Patterns]]: + for globstr, pattern_strs in file_patterns.items(): + file_paths = glob.glob(globstr) + if not any(file_paths): + errmsg = f"No files found for path/glob '{globstr}'" + raise IOError(errmsg) + for file_path_str in file_paths: + file_path = pl.Path(file_path_str) + yield (file_path, pattern_strs) + + def rewrite_lines( pattern_strs: typ.List[str], new_vinfo: version.VersionInfo, old_lines: typ.List[str] ) -> typ.List[str]: @@ -88,15 +111,6 @@ def rewrite_lines( return new_lines -class RewrittenFileData(typ.NamedTuple): - """Container for line-wise content of rewritten files.""" - - path : str - line_sep : str - old_lines: typ.List[str] - new_lines: typ.List[str] - - def rfd_from_content( pattern_strs: typ.List[str], new_vinfo: version.VersionInfo, content: str ) -> RewrittenFileData: @@ -122,19 +136,6 @@ def rfd_from_content( return RewrittenFileData("", line_sep, old_lines, new_lines) -def _iter_file_paths( - file_patterns: config.PatternsByGlob, -) -> typ.Iterable[typ.Tuple[pl.Path, config.Patterns]]: - for globstr, pattern_strs in file_patterns.items(): - file_paths = glob.glob(globstr) - if not any(file_paths): - errmsg = f"No files found for path/glob '{globstr}'" - raise IOError(errmsg) - for file_path_str in file_paths: - file_path = pl.Path(file_path_str) - yield (file_path, pattern_strs) - - def iter_rewritten( file_patterns: config.PatternsByGlob, new_vinfo: version.VersionInfo ) -> typ.Iterable[RewrittenFileData]: @@ -144,23 +145,23 @@ def iter_rewritten( >>> new_vinfo = version.parse_version_info("v201809.0123") >>> rewritten_datas = iter_rewritten(file_patterns, new_vinfo) >>> rfd = list(rewritten_datas)[0] - >>> assert rfd.new_lines == [ + >>> expected = [ ... '# This file is part of the pycalver project', - ... '# https://gitlab.com/mbarkhau/pycalver', + ... '# https://github.com/mbarkhau/pycalver', ... '#', - ... '# Copyright (c) 2019 Manuel Barkhau (mbarkhau@gmail.com) - MIT License', + ... '# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License', ... '# SPDX-License-Identifier: MIT', ... '"""PyCalVer: CalVer for Python Packages."""', ... '', ... '__version__ = "v201809.0123"', ... '', ... ] - >>> + >>> assert rfd.new_lines[:len(expected)] == expected ''' fobj: typ.IO[str] - for file_path, pattern_strs in _iter_file_paths(file_patterns): + for file_path, pattern_strs in iter_file_paths(file_patterns): with file_path.open(mode="rt", encoding="utf-8") as fobj: content = fobj.read() @@ -204,7 +205,7 @@ def diff(new_vinfo: version.VersionInfo, file_patterns: config.PatternsByGlob) - full_diff = "" fobj: typ.IO[str] - for file_path, pattern_strs in sorted(_iter_file_paths(file_patterns)): + for file_path, pattern_strs in sorted(iter_file_paths(file_patterns)): with file_path.open(mode="rt", encoding="utf-8") as fobj: content = fobj.read() diff --git a/src/pycalver/vcs.py b/src/pycalver/vcs.py index 3430ba2..3ca6102 100644 --- a/src/pycalver/vcs.py +++ b/src/pycalver/vcs.py @@ -1,7 +1,7 @@ # This file is part of the pycalver project -# https://gitlab.com/mbarkhau/pycalver +# https://github.com/mbarkhau/pycalver # -# Copyright (c) 2019 Manuel Barkhau (mbarkhau@gmail.com) - MIT License +# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License # SPDX-License-Identifier: MIT # # pycalver/vcs.py (this file) is based on code from the @@ -15,11 +15,14 @@ mercurial, then the git terms are used. For example "fetch" """ import os +import sys import typing as typ import logging import tempfile import subprocess as sp +from pycalver import config + logger = logging.getLogger("pycalver.vcs") @@ -179,3 +182,57 @@ def get_vcs_api() -> VCSAPI: return vcs_api raise OSError("No such directory .git/ or .hg/ ") + + +# cli helper methods + + +def assert_not_dirty(vcs_api: VCSAPI, filepaths: typ.Set[str], allow_dirty: bool) -> None: + dirty_files = vcs_api.status(required_files=filepaths) + + if dirty_files: + logger.warning(f"{vcs_api.name} working directory is not clean. Uncomitted file(s):") + for dirty_file in dirty_files: + logger.warning(" " + dirty_file) + + if not allow_dirty and dirty_files: + sys.exit(1) + + dirty_pattern_files = set(dirty_files) & filepaths + if dirty_pattern_files: + logger.error("Not commiting when pattern files are dirty:") + for dirty_file in dirty_pattern_files: + logger.warning(" " + dirty_file) + sys.exit(1) + + +def commit( + cfg : config.Config, + vcs_api : VCSAPI, + filepaths : typ.Set[str], + new_version : str, + commit_message: str, +) -> None: + for filepath in filepaths: + vcs_api.add(filepath) + + vcs_api.commit(commit_message) + + if cfg.commit and cfg.tag: + vcs_api.tag(new_version) + + if cfg.commit and cfg.tag and cfg.push: + vcs_api.push(new_version) + + +def get_tags(fetch: bool) -> typ.List[str]: + try: + vcs_api = get_vcs_api() + logger.debug(f"vcs found: {vcs_api.name}") + if fetch: + logger.info("fetching tags from remote (to turn off use: -n / --no-fetch)") + vcs_api.fetch() + return vcs_api.ls_tags() + except OSError: + logger.debug("No vcs found") + return [] diff --git a/src/pycalver/version.py b/src/pycalver/version.py index ab2bd2c..25afe53 100644 --- a/src/pycalver/version.py +++ b/src/pycalver/version.py @@ -1,7 +1,7 @@ # This file is part of the pycalver project -# https://gitlab.com/mbarkhau/pycalver +# https://github.com/mbarkhau/pycalver # -# Copyright (c) 2019 Manuel Barkhau (mbarkhau@gmail.com) - MIT License +# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License # SPDX-License-Identifier: MIT """Functions related to version string manipulation.""" @@ -9,9 +9,9 @@ import typing as typ import logging import datetime as dt +import lexid import pkg_resources -from . import lex_id from . import patterns logger = logging.getLogger("pycalver.version") @@ -482,7 +482,7 @@ def incr( else: logger.warning(f"Version appears to be from the future '{old_version}'") - cur_vinfo = cur_vinfo._replace(bid=lex_id.next_id(cur_vinfo.bid)) + cur_vinfo = cur_vinfo._replace(bid=lexid.next_id(cur_vinfo.bid)) if major: cur_vinfo = cur_vinfo._replace(major=cur_vinfo.major + 1, minor=0, patch=0) diff --git a/src/pycalver2/__init__.py b/src/pycalver2/__init__.py new file mode 100644 index 0000000..094d463 --- /dev/null +++ b/src/pycalver2/__init__.py @@ -0,0 +1,8 @@ +# This file is part of the pycalver project +# https://github.com/mbarkhau/pycalver +# +# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License +# SPDX-License-Identifier: MIT +"""PyCalVer: CalVer for Python Packages.""" + +__version__ = "v202007.1036" diff --git a/src/pycalver2/cli.py b/src/pycalver2/cli.py new file mode 100644 index 0000000..107d4b8 --- /dev/null +++ b/src/pycalver2/cli.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python +# This file is part of the pycalver project +# https://github.com/mbarkhau/pycalver +# +# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License +# SPDX-License-Identifier: MIT +""" +CLI module for PyCalVer. + +Provided subcommands: show, test, init, bump +""" +import typing as typ +import logging + +import pycalver2.rewrite as v2rewrite +import pycalver2.version as v2version +from pycalver import config + +logger = logging.getLogger("pycalver2.cli") + + +def update_cfg_from_vcs(cfg: config.Config, all_tags: typ.List[str]) -> config.Config: + version_tags = [tag for tag in all_tags if v2version.is_valid(tag, cfg.version_pattern)] + if not version_tags: + logger.debug("no vcs tags found") + return cfg + + version_tags.sort(reverse=True) + logger.debug(f"found {len(version_tags)} tags: {version_tags[:2]}") + latest_version_tag = version_tags[0] + latest_version_pep440 = v2version.to_pep440(latest_version_tag) + if latest_version_tag <= cfg.current_version: + return cfg + + logger.info(f"Working dir version : {cfg.current_version}") + logger.info(f"Latest version from VCS tag: {latest_version_tag}") + return cfg._replace( + current_version=latest_version_tag, + pep440_version=latest_version_pep440, + ) + + +def rewrite( + cfg : config.Config, + new_version: str, +) -> None: + new_vinfo = v2version.parse_version_info(new_version, cfg.version_pattern) + v2rewrite.rewrite(cfg.file_patterns, new_vinfo) + + +def get_diff(cfg: config.Config, new_version: str) -> str: + new_vinfo = v2version.parse_version_info(new_version, cfg.version_pattern) + return v2rewrite.diff(new_vinfo, cfg.file_patterns) diff --git a/src/pycalver2/patterns.py b/src/pycalver2/patterns.py new file mode 100644 index 0000000..07bae57 --- /dev/null +++ b/src/pycalver2/patterns.py @@ -0,0 +1,205 @@ +# This file is part of the pycalver project +# https://github.com/mbarkhau/pycalver +# +# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - 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("), + (")" , "\u005c)"), +] + +# NOTE (mb 2020-09-04): These are depricated in favour of explicit patterns +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 = { + # recommended (based on calver.org) + 'YYYY': r"[1-9]\d{3}", + 'YY' : r"\d{1,2}", + '0Y' : r"\d{2}", + 'Q' : r"[1-4]", + 'MM' : r"(?:[1-9]|1[0-2])", + '0M' : r"(?:0[1-9]|1[0-2])", + 'DD' : r"([1-9]|[1-2][0-9]|3[0-1])", + '0D' : r"(0[1-9]|[1-2][0-9]|3[0-1])", + 'JJJ' : r"(?:[1-9]\d|[1-9]|[1-2]\d\d|3[0-5][0-9]|36[0-6])", + '00J' : r"(?:[0-2]\d\d|3[0-5][0-9]|36[0-6])", + 'WW' : r"(?:[1-9]|[1-4]\d|5[0-2])", + '0W' : r"(?:[0-4]\d|5[0-2])", + 'UU' : r"(?:[1-9]|[0-4]\d|5[0-2])", + '0U' : r"(?:[0-4]\d|5[0-2])", + 'VV' : r"(?:[1-9]|[1-4]\d|5[0-3])", + '0V' : r"(?:[0-4]\d|5[0-3])", + 'GGGG': r"[1-9]\d{3}", + 'GG' : r"\d{1,2}", + '0G' : r"\d{2}", + # non calver parts + 'MAJOR': r"\d+", + 'MINOR': r"\d+", + 'PATCH': r"\d+", + 'MICRO': r"\d+", + 'BUILD': r"\d+", + 'TAG' : r"(?:alpha|beta|dev|rc|post|final)", + 'PYTAG': r"(?:a|b|dev|rc|post)?\d*", + # supported (but legacy) + 'year' : r"\d{4}", + 'month' : r"(?:0[0-9]|1[0-2])", + 'month_short': r"(?:1[0-2]|[1-9])", + '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])", + 'dom_short' : r"([1-9]|[1-2][0-9]|3[0-1])", + 'doy' : r"(?:[0-2]\d\d|3[0-5][0-9]|36[0-6])", + 'doy_short' : r"(?:[0-2]\d\d|3[0-5][0-9]|36[0-6])", + 'bid' : r"\d{4,}", + # dropped support (never documented) + # 'BID' : r"[1-9]\d*", + # 'MM' : r"\d{2,}", + # 'MMM' : r"\d{3,}", + # 'MMMM' : r"\d{4,}", + # 'MMMMM' : r"\d{5,}", + # 'PP' : r"\d{2,}", + # 'PPP' : r"\d{3,}", + # 'PPPP' : r"\d{4,}", + # 'PPPPP' : r"\d{5,}", + # '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 treated specially in version.format + # 'release' : "-{tag}", + 'month' : "{month:02}", + 'month_short': "{month}", + 'build_no' : "{bid}", + 'iso_week' : "{iso_week:02}", + 'us_week' : "{us_week:02}", + 'dom' : "{dom:02}", + 'doy' : "{doy:03}", + 'dom_short' : "{dom}", + 'doy_short' : "{doy}", + # depricated + 'pep440_version': "{year}{month:02}.{BID}{pep440_tag}", + 'version' : "v{year}{month:02}.{bid}{release}", +} + + +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_str(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_str(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/pycalver2/rewrite.py b/src/pycalver2/rewrite.py new file mode 100644 index 0000000..7595099 --- /dev/null +++ b/src/pycalver2/rewrite.py @@ -0,0 +1,175 @@ +# This file is part of the pycalver project +# https://github.com/mbarkhau/pycalver +# +# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License +# SPDX-License-Identifier: MIT +"""Rewrite files, updating occurences of version strings.""" + +import io +import typing as typ +import logging + +from pycalver import parse +from pycalver import config +from pycalver import rewrite as v1rewrite +from pycalver2 import version +from pycalver2 import patterns + +logger = logging.getLogger("pycalver2.rewrite") + + +def rewrite_lines( + pattern_strs: typ.List[str], new_vinfo: version.VersionInfo, old_lines: typ.List[str] +) -> typ.List[str]: + """TODO reenable doctest""" + pass + + """Replace occurances of pattern_strs in old_lines with new_vinfo. + + >>> new_vinfo = version.parse_version_info("v201811.0123-beta") + >>> pattern_strs = ['__version__ = "{pycalver}"'] + >>> rewrite_lines(pattern_strs, new_vinfo, ['__version__ = "v201809.0002-beta"']) + ['__version__ = "v201811.0123-beta"'] + + >>> pattern_strs = ['__version__ = "{pep440_version}"'] + >>> rewrite_lines(pattern_strs, new_vinfo, ['__version__ = "201809.2b0"']) + ['__version__ = "201811.123b0"'] + """ + new_lines = old_lines[:] + found_patterns = set() + + re_patterns = [patterns.compile_pattern(p) for p in pattern_strs] + for match in parse.iter_matches(old_lines, re_patterns): + found_patterns.add(match.pattern) + replacement = version.format_version(new_vinfo, match.pattern) + span_l, span_r = match.span + new_line = match.line[:span_l] + replacement + match.line[span_r:] + new_lines[match.lineno] = new_line + + non_matched_patterns = set(pattern_strs) - found_patterns + if non_matched_patterns: + for non_matched_pattern in non_matched_patterns: + logger.error(f"No match for pattern '{non_matched_pattern}'") + compiled_pattern_str = patterns.compile_pattern_str(non_matched_pattern) + logger.error(f"Pattern compiles to regex '{compiled_pattern_str}'") + raise v1rewrite.NoPatternMatch("Invalid pattern(s)") + else: + return new_lines + + +def rfd_from_content( + pattern_strs: typ.List[str], new_vinfo: version.VersionInfo, content: str +) -> v1rewrite.RewrittenFileData: + """TODO reenable doctest""" + pass + + r"""Rewrite pattern occurrences with version string. + + >>> new_vinfo = version.parse_version_info("v201809.0123") + >>> pattern_strs = ['__version__ = "{pycalver}"'] + >>> content = '__version__ = "v201809.0001-alpha"' + >>> rfd = rfd_from_content(pattern_strs, new_vinfo, content) + >>> rfd.new_lines + ['__version__ = "v201809.0123"'] + >>> + >>> new_vinfo = version.parse_version_info("v1.2.3", "v{semver}") + >>> pattern_strs = ['__version__ = "v{semver}"'] + >>> content = '__version__ = "v1.2.2"' + >>> rfd = rfd_from_content(pattern_strs, new_vinfo, content) + >>> rfd.new_lines + ['__version__ = "v1.2.3"'] + """ + line_sep = v1rewrite.detect_line_sep(content) + old_lines = content.split(line_sep) + new_lines = rewrite_lines(pattern_strs, new_vinfo, old_lines) + return v1rewrite.RewrittenFileData("", line_sep, old_lines, new_lines) + + +def iter_rewritten( + file_patterns: config.PatternsByGlob, new_vinfo: version.VersionInfo +) -> typ.Iterable[v1rewrite.RewrittenFileData]: + """TODO reenable doctest""" + pass + + r'''Iterate over files with version string replaced. + + >>> file_patterns = {"src/pycalver/__init__.py": ['__version__ = "{pycalver}"']} + >>> new_vinfo = version.parse_version_info("v201809.0123") + >>> rewritten_datas = iter_rewritten(file_patterns, new_vinfo) + >>> rfd = list(rewritten_datas)[0] + >>> assert rfd.new_lines == [ + ... '# This file is part of the pycalver project', + ... '# https://gitlab.com/mbarkhau/pycalver', + ... '#', + ... '# Copyright (c) 2019 Manuel Barkhau (mbarkhau@gmail.com) - MIT License', + ... '# SPDX-License-Identifier: MIT', + ... '"""PyCalVer: CalVer for Python Packages."""', + ... '', + ... '__version__ = "v201809.0123"', + ... '', + ... ] + >>> + ''' + + fobj: typ.IO[str] + + for file_path, pattern_strs in v1rewrite.iter_file_paths(file_patterns): + with file_path.open(mode="rt", encoding="utf-8") as fobj: + content = fobj.read() + + rfd = rfd_from_content(pattern_strs, new_vinfo, content) + yield rfd._replace(path=str(file_path)) + + +def diff(new_vinfo: version.VersionInfo, file_patterns: config.PatternsByGlob) -> str: + """TODO reenable doctest""" + pass + + r"""Generate diffs of rewritten files. + + >>> new_vinfo = version.parse_version_info("v201809.0123") + >>> file_patterns = {"src/pycalver/__init__.py": ['__version__ = "{pycalver}"']} + >>> diff_str = diff(new_vinfo, file_patterns) + >>> lines = diff_str.split("\n") + >>> lines[:2] + ['--- src/pycalver/__init__.py', '+++ src/pycalver/__init__.py'] + >>> assert lines[6].startswith('-__version__ = "v2') + >>> assert not lines[6].startswith('-__version__ = "v201809.0123"') + >>> lines[7] + '+__version__ = "v201809.0123"' + """ + + full_diff = "" + fobj: typ.IO[str] + + for file_path, pattern_strs in sorted(v1rewrite.iter_file_paths(file_patterns)): + with file_path.open(mode="rt", encoding="utf-8") as fobj: + content = fobj.read() + + try: + rfd = rfd_from_content(pattern_strs, new_vinfo, content) + except v1rewrite.NoPatternMatch: + # pylint:disable=raise-missing-from ; we support py2, so not an option + errmsg = f"No patterns matched for '{file_path}'" + raise v1rewrite.NoPatternMatch(errmsg) + + rfd = rfd._replace(path=str(file_path)) + lines = v1rewrite.diff_lines(rfd) + if len(lines) == 0: + errmsg = f"No patterns matched for '{file_path}'" + raise v1rewrite.NoPatternMatch(errmsg) + + full_diff += "\n".join(lines) + "\n" + + full_diff = full_diff.rstrip("\n") + return full_diff + + +def rewrite(file_patterns: config.PatternsByGlob, new_vinfo: version.VersionInfo) -> None: + """Rewrite project files, updating each with the new version.""" + fobj: typ.IO[str] + + for file_data in iter_rewritten(file_patterns, new_vinfo): + new_content = file_data.line_sep.join(file_data.new_lines) + with io.open(file_data.path, mode="wt", encoding="utf-8") as fobj: + fobj.write(new_content) diff --git a/src/pycalver2/version.py b/src/pycalver2/version.py new file mode 100644 index 0000000..4abaacf --- /dev/null +++ b/src/pycalver2/version.py @@ -0,0 +1,590 @@ +# This file is part of the pycalver project +# https://github.com/mbarkhau/pycalver +# +# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License +# SPDX-License-Identifier: MIT +"""Functions related to version string manipulation.""" + +import typing as typ +import logging +import datetime as dt + +import lexid +import pkg_resources + +from . import patterns + +logger = logging.getLogger("pycalver.version") + + +# The test suite may replace this. +TODAY = dt.datetime.utcnow().date() + + +PATTERN_PART_FIELDS = { + 'YYYY' : 'year_y', + 'YY' : 'year_y', + '0Y' : 'year_y', + 'Q' : 'quarter', + 'MM' : 'month', + '0M' : 'month', + 'DD' : 'dom', + '0D' : 'dom', + 'JJJ' : 'doy', + '00J' : 'doy', + 'MAJOR': 'major', + 'MINOR': 'minor', + 'PATCH': 'patch', + 'MICRO': 'patch', + 'BUILD': 'bid', + 'TAG' : 'tag', + 'PYTAG': 'pytag', + 'WW' : 'week_w', + '0W' : 'week_w', + 'UU' : 'week_u', + '0U' : 'week_u', + 'VV' : 'week_v', + '0V' : 'week_v', + 'GGGG' : 'year_g', + 'GG' : 'year_g', + '0G' : 'year_g', +} + +ID_FIELDS_BY_PART = { + 'MAJOR': 'major', + 'MINOR': 'minor', + 'PATCH': 'patch', + 'MICRO': 'patch', +} + + +ZERO_VALUES = { + 'major': "0", + 'minor': "0", + 'patch': "0", + 'TAG' : "final", + 'PYTAG': "", +} + + +class CalendarInfo(typ.NamedTuple): + """Container for calendar components of version strings.""" + + year_y : int + year_g : int + quarter: int + month : int + dom : int + doy : int + week_w : int + week_u : int + week_v : 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: + """TODO reenable doctest""" + pass + + """Generate calendar components for current date. + + >>> from datetime import date + + >>> c = cal_info(date(2019, 1, 5)) + >>> (c.year_y, 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_y, 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_y, 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_y, 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 + + kwargs = { + 'year_y' : date.year, + 'year_g' : int(date.strftime("%G"), base=10), + 'quarter': _quarter_from_month(date.month), + 'month' : date.month, + 'dom' : date.day, + 'doy' : int(date.strftime("%j"), base=10), + 'week_w' : int(date.strftime("%W"), base=10), + 'week_u' : int(date.strftime("%U"), base=10), + 'week_v' : int(date.strftime("%V"), base=10), + } + + return CalendarInfo(**kwargs) + + +class VersionInfo(typ.NamedTuple): + """Container for parsed version string.""" + + year_y : typ.Optional[int] + year_g : typ.Optional[int] + quarter: typ.Optional[int] + month : typ.Optional[int] + dom : typ.Optional[int] + doy : typ.Optional[int] + week_w : typ.Optional[int] + week_u : typ.Optional[int] + week_v : typ.Optional[int] + major : int + minor : int + patch : int + bid : str + tag : str + pytag : str + + +FieldKey = str +MatchGroupKey = str +MatchGroupStr = str + +PatternGroups = typ.Dict[MatchGroupKey, MatchGroupStr] +FieldValues = typ.Dict[FieldKey , MatchGroupStr] + + +def _parse_field_values(field_values: FieldValues) -> VersionInfo: + fvals = field_values + tag = fvals.get('tag') + if tag is None: + tag = "final" + + tag = TAG_ALIASES.get(tag, tag) + assert tag is not None + # TODO (mb 2020-09-06): parts of course + pytag = "TODO" + + bid = fvals['bid'] if 'bid' in fvals else "1001" + + year_y = int(fvals['year_y']) if 'year_y' in fvals else None + year_g = int(fvals['year_g']) if 'year_g' in fvals else None + doy = int(fvals['doy' ]) if 'doy' in fvals else None + + date: typ.Optional[dt.date] = None + + month: typ.Optional[int] = None + dom : typ.Optional[int] = None + + week_w: typ.Optional[int] = None + week_u: typ.Optional[int] = None + week_v: typ.Optional[int] = None + + if year_y and doy: + date = _date_from_doy(year_y, doy) + month = date.month + dom = date.day + else: + month = int(fvals['month']) if 'month' in fvals else None + dom = int(fvals['dom' ]) if 'dom' in fvals else None + + quarter = int(fvals['quarter']) if 'quarter' in fvals else None + if quarter is None and month: + quarter = _quarter_from_month(month) + + if year_y and month and dom: + date = dt.date(year_y, month, dom) + + if date: + # derive all fields from other previous values + doy = int(date.strftime("%j"), base=10) + week_w = int(date.strftime("%W"), base=10) + week_u = int(date.strftime("%U"), base=10) + week_v = int(date.strftime("%V"), base=10) + year_g = int(date.strftime("%G"), base=10) + + major = int(fvals['major']) if 'major' in fvals else 0 + minor = int(fvals['minor']) if 'minor' in fvals else 0 + patch = int(fvals['patch']) if 'patch' in fvals else 0 + + return VersionInfo( + year_y=year_y, + year_g=year_g, + quarter=quarter, + month=month, + dom=dom, + doy=doy, + week_w=week_w, + week_u=week_u, + week_v=week_v, + major=major, + minor=minor, + patch=patch, + bid=bid, + tag=tag, + pytag=pytag, + ) + + +def _is_calver(nfo: typ.Union[CalendarInfo, VersionInfo]) -> bool: + """TODO reenable doctest""" + pass + + """Check pattern for any calendar based parts. + + >>> _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 + """ + for field in CalendarInfo._fields: + maybe_val: typ.Any = getattr(nfo, field, None) + if isinstance(maybe_val, int): + return True + + return False + + +TAG_ALIASES: typ.Dict[str, str] = { + 'a' : "alpha", + 'b' : "beta", + 'pre': "rc", +} + + +PEP440_TAGS: typ.Dict[str, str] = { + 'alpha': "a", + 'beta' : "b", + 'final': "", + 'rc' : "rc", + 'dev' : "dev", + 'post' : "post", +} + + +VersionInfoKW = typ.Dict[str, typ.Union[str, int, None]] + + +class PatternError(Exception): + pass + + +def _parse_pattern_groups(pattern_groups: PatternGroups) -> FieldValues: + 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 + ) + if not is_valid_part_name: + err_msg = f"Invalid part '{part_name}'" + raise PatternError(err_msg) + + field_value_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 field_value_items] + unique_fields = set(all_fields) + duplicate_fields = [f for f in unique_fields if all_fields.count(f) > 1] + + if any(duplicate_fields): + err_msg = f"Multiple parts for same field {duplicate_fields}." + raise PatternError(err_msg) + + return dict(field_value_items) + + +def _parse_version_info(pattern_groups: PatternGroups) -> VersionInfo: + """TODO reenable doctest""" + pass + + """Parse normalized VersionInfo from groups of a matched pattern. + + >>> vnfo = _parse_version_info({'year': "2018", 'month': "11", 'bid': "0099"}) + >>> (vnfo.year_y, 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_y, 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) + """ + field_values = _parse_pattern_groups(pattern_groups) + return _parse_field_values(field_values) + + +def parse_version_info(version_str: str, pattern: str = "{pycalver}") -> VersionInfo: + """TODO reenable doctest""" + pass + + """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 PatternError(err_msg) + + return _parse_version_info(match.groupdict()) + + +def is_valid(version_str: str, pattern: str = "{pycalver}") -> bool: + """TODO reenable doctest""" + pass + + """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 PatternError: + return False + + +TemplateKwargs = typ.Dict[str, typ.Union[str, int, None]] + + +def _derive_template_kwargs(vinfo: VersionInfo) -> TemplateKwargs: + """Generate kwargs for template from minimal VersionInfo. + + The VersionInfo Tuple only has the minimal representation + of a parsed version, not the values suitable for formatting. + It may for example have month=9, but not the formatted + representation '09' for '0M'. + """ + kwargs: TemplateKwargs = vinfo._asdict() + + tag = vinfo.tag + kwargs['TAG'] = tag + if tag == 'final': + kwargs['PYTAG'] = "" + else: + kwargs['PYTAG'] = PEP440_TAGS[tag] + "0" + + year_y = vinfo.year_y + if year_y: + kwargs['0Y' ] = str(year_y)[-2:] + kwargs['YY' ] = int(str(year_y)[-2:]) + kwargs['YYYY'] = year_y + + year_g = vinfo.year_g + if year_g: + kwargs['0G' ] = str(year_g)[-2:] + kwargs['GG' ] = int(str(year_g)[-2:]) + kwargs['GGGG'] = year_g + + kwargs['BUILD'] = int(vinfo.bid, 10) + + for part_name, field in ID_FIELDS_BY_PART.items(): + val = kwargs[field] + if part_name.lower() == field.lower(): + if isinstance(val, str): + kwargs[part_name] = int(val, base=10) + else: + kwargs[part_name] = val + else: + assert len(set(part_name)) == 1 + padded_len = len(part_name) + kwargs[part_name] = str(val).zfill(padded_len) + + return kwargs + + +def _compile_format_template(pattern: str, kwargs: TemplateKwargs) -> str: + # NOTE (mb 2020-09-04): Some parts are optional, we need the kwargs to + # determine if part is set to its zero value + format_tmpl = pattern + for part_name, full_part_format in patterns.FULL_PART_FORMATS.items(): + format_tmpl = format_tmpl.replace("{" + part_name + "}", full_part_format) + return format_tmpl + + +def format_version(vinfo: VersionInfo, pattern: str) -> str: + """TODO reenable doctest""" + pass + + """Generate version string. + + >>> import datetime as dt + >>> vinfo = parse_version_info("v201712.0033-beta", pattern="{pycalver}") + >>> vinfo_a = vinfo._replace(**cal_info(date=dt.date(2017, 1, 1))._asdict()) + >>> vinfo_b = vinfo._replace(**cal_info(date=dt.date(2017, 12, 31))._asdict()) + + >>> format_version(vinfo_a, pattern="v{yy}.{BID}{release}") + 'v17.33-beta' + >>> format_version(vinfo_a, pattern="vYY.BUILD[-TAG]") + 'v17.33-beta' + >>> format_version(vinfo_a, pattern="YYYY0M.BUILD[PYTAG]") + '201701.33b0' + + >>> format_version(vinfo_a, pattern="{pycalver}") + 'v201701.0033-beta' + >>> format_version(vinfo_b, pattern="{pycalver}") + 'v201712.0033-beta' + + >>> format_version(vinfo_a, pattern="v{year}w{iso_week}.{BID}{release}") + 'v2017w00.33-beta' + >>> format_version(vinfo_a, pattern="vYYYYwWW.BUILD[-TAG]") + 'v2017w00.33-beta' + >>> format_version(vinfo_b, pattern="v{year}w{iso_week}.{BID}{release}") + 'v2017w52.33-beta' + >>> format_version(vinfo_b, pattern="vYYYYwWW.BUILD[-TAG]") + 'v2017w52.33-beta' + + >>> format_version(vinfo_a, pattern="v{year}d{doy}.{bid}{release}") + 'v2017d001.0033-beta' + >>> format_version(vinfo_b, pattern="v{year}d{doy}.{bid}{release}") + 'v2017d365.0033-beta' + >>> format_version(vinfo_a, pattern="vYYYYdJJJ.BUILD[-TAG]") + 'v2017d001.0033-beta' + >>> format_version(vinfo_b, pattern="vYYYYdJJJ.BUILD[-TAG]") + 'v2017d365.0033-beta' + + >>> format_version(vinfo_a, pattern="vGGGGwVV.BUILD[-TAG]") + 'v2016w52.0033-beta' + + >>> vinfo_c = vinfo_b._replace(major=1, minor=2, patch=34, tag='final') + + >>> format_version(vinfo_c, pattern="v{year}w{iso_week}.{BID}-{tag}") + 'v2017w52.33-final' + >>> format_version(vinfo_c, pattern="v{year}w{iso_week}.{BID}{release}") + 'v2017w52.33' + >>> format_version(vinfo_c, pattern="vYYYYwWW.BUILD-TAG") + 'v2017w52.33-final' + >>> format_version(vinfo_c, pattern="vYYYYwWW.BUILD[-TAG]") + 'v2017w52.33' + + >>> format_version(vinfo_c, pattern="v{MAJOR}.{MINOR}.{PATCH}") + 'v1.2.34' + >>> format_version(vinfo_c, pattern="vMAJOR.MINOR.PATCH") + 'v1.2.34' + + >>> vinfo_d = vinfo_b._replace(major=1, minor=0, patch=0, tag='final') + >>> format_version(vinfo_d, pattern="vMAJOR.MINOR.PATCH-TAG") + 'v1.0.0-final' + >>> format_version(vinfo_d, pattern="vMAJOR.MINOR.PATCH[-TAG]") + 'v1.0.0' + >>> format_version(vinfo_d, pattern="vMAJOR.MINOR[.PATCH[-TAG]]") + 'v1.0' + >>> format_version(vinfo_d, pattern="vMAJOR.MINOR[.MICRO[-TAG]]") + 'v1.0' + >>> format_version(vinfo_d, pattern="vMAJOR[.MINOR[.PATCH[-TAG]]]") + 'v1' + """ + kwargs = _derive_template_kwargs(vinfo) + format_tmpl = _compile_format_template(pattern, kwargs) + + return format_tmpl.format(**kwargs) + + +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' + """ + try: + old_vinfo = parse_version_info(old_version, pattern) + except PatternError as ex: + logger.error(str(ex)) + return None + + cur_vinfo = old_vinfo + + cur_cal_nfo = cal_info() + + old_date = (old_vinfo.year_y or 0, old_vinfo.month or 0, old_vinfo.dom or 0) + cur_date = (cur_cal_nfo.year_y , cur_cal_nfo.month , cur_cal_nfo.dom) + + if old_date <= cur_date: + cur_vinfo = cur_vinfo._replace(**cur_cal_nfo._asdict()) + else: + logger.warning(f"Version appears to be from the future '{old_version}'") + + cur_vinfo = cur_vinfo._replace(bid=lexid.incr(cur_vinfo.bid)) + + if major: + cur_vinfo = cur_vinfo._replace(major=cur_vinfo.major + 1, minor=0, patch=0) + if minor: + cur_vinfo = cur_vinfo._replace(minor=cur_vinfo.minor + 1, patch=0) + if patch: + cur_vinfo = cur_vinfo._replace(patch=cur_vinfo.patch + 1) + + if release: + cur_vinfo = cur_vinfo._replace(tag=release) + + new_version = format_version(cur_vinfo, pattern) + if new_version == old_version: + logger.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. + + >>> to_pep440("v201811.0007-beta") + '201811.7b0' + """ + return str(pkg_resources.parse_version(version)) diff --git a/test/fixtures/project_d/pyproject.toml b/test/fixtures/project_d/pyproject.toml new file mode 100644 index 0000000..65faaf4 --- /dev/null +++ b/test/fixtures/project_d/pyproject.toml @@ -0,0 +1,6 @@ +[pycalver] +current_version = "v2017q1.54321" +version_pattern = "vYYYYqQ.BUILD" +commit = true +tag = true +push = true diff --git a/test/test_config.py b/test/test_config.py index 4d15e36..ab8c4ba 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -2,11 +2,10 @@ # pylint:disable=protected-access ; allowed for test code import io +from test import util from pycalver import config -from . import util - PYCALVER_TOML_FIXTURE_1 = """ [pycalver] current_version = "v201808.0123-alpha" diff --git a/test/test_lex_id.py b/test/test_lex_id.py deleted file mode 100644 index 198e3ed..0000000 --- a/test/test_lex_id.py +++ /dev/null @@ -1,67 +0,0 @@ -# pylint:disable=protected-access ; allowed for test code - -import random - -from pycalver import lex_id - - -def test_next_id_basic(): - assert lex_id.next_id("01") == "02" - assert lex_id.next_id("09") == "110" - - -def test_next_id_overflow(): - try: - prev_id = "9999" - next_id = lex_id.next_id(prev_id) - assert False, (prev_id, "->", next_id) - except OverflowError: - pass - - -def test_next_id_random(): - for _ in range(1000): - prev_id = str(random.randint(1, 100 * 1000)) - try: - next_id = lex_id.next_id(prev_id) - assert prev_id < next_id - except OverflowError: - assert len(prev_id) == prev_id.count("9") - - -def test_ord_val(): - assert lex_id.ord_val("1" ) == 1 - assert lex_id.ord_val("01" ) == 1 - assert lex_id.ord_val("02" ) == 2 - assert lex_id.ord_val("09" ) == 9 - assert lex_id.ord_val("110") == 10 - - -def test_main(capsys): - lex_id._main() - captured = capsys.readouterr() - assert len(captured.err) == 0 - - lines = iter(captured.out.splitlines()) - header = next(lines) - - assert "lexical" in header - assert "numerical" in header - - ids = [] - ord_vals = [] - - for line in lines: - if "..." in line: - continue - _id, _ord_val = line.split() - - assert _id.endswith(_ord_val) - assert int(_ord_val) == int(_ord_val, 10) - - ids.append(_id.strip()) - ord_vals.append(int(_ord_val.strip())) - - assert len(ids) > 0 - assert sorted(ids) == ids - assert sorted(ord_vals) == ord_vals diff --git a/test/test_patterns.py b/test/test_patterns.py index 541bbfe..61d8d48 100644 --- a/test/test_patterns.py +++ b/test/test_patterns.py @@ -2,14 +2,17 @@ import re import pytest -from pycalver import patterns +import pycalver.patterns as v1patterns +import pycalver2.patterns as v2patterns + +# TODO (mb 2020-09-06): test for v2patterns def _part_re_by_name(name): - return re.compile(patterns.PART_PATTERNS[name]) + return re.compile(v1patterns.PART_PATTERNS[name]) -@pytest.mark.parametrize("part_name", patterns.PART_PATTERNS.keys()) +@pytest.mark.parametrize("part_name", v1patterns.PART_PATTERNS.keys()) def test_part_compilation(part_name): assert _part_re_by_name(part_name) @@ -64,7 +67,7 @@ PATTERN_CASES = [ @pytest.mark.parametrize("pattern_str, line, expected", PATTERN_CASES) def test_patterns(pattern_str, line, expected): - pattern_re = patterns.compile_pattern(pattern_str) + pattern_re = v1patterns.compile_pattern(pattern_str) result = pattern_re.search(line) if result is None: assert expected is None, (pattern_str, line) @@ -82,7 +85,7 @@ CLI_MAIN_FIXTURE = """ def test_pattern_escapes(): pattern = 'click.version_option(version="{pycalver}")' - pattern_re = patterns.compile_pattern(pattern) + pattern_re = v1patterns.compile_pattern(pattern) match = pattern_re.search(CLI_MAIN_FIXTURE) expected = 'click.version_option(version="v201812.0123-beta")' assert match.group(0) == expected @@ -95,7 +98,7 @@ 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) + pattern_re = v1patterns.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 872fd71..1eb0dd7 100644 --- a/test/test_rewrite.py +++ b/test/test_rewrite.py @@ -1,12 +1,13 @@ # pylint:disable=protected-access ; allowed for test code import copy +from test import util from pycalver import config -from pycalver import rewrite -from pycalver import version - -from . import util +from pycalver import rewrite as v1rewrite +from pycalver import version as v1version +from pycalver2 import rewrite as v2rewrite +from pycalver2 import version as v2version REWRITE_FIXTURE = """ # SPDX-License-Identifier: MIT @@ -17,8 +18,8 @@ __version__ = "v201809.0002-beta" def test_rewrite_lines(): old_lines = REWRITE_FIXTURE.splitlines() patterns = ['__version__ = "{pycalver}"'] - new_vinfo = version.parse_version_info("v201911.0003") - new_lines = rewrite.rewrite_lines(patterns, new_vinfo, old_lines) + new_vinfo = v1version.parse_version_info("v201911.0003") + new_lines = v1rewrite.rewrite_lines(patterns, new_vinfo, old_lines) assert len(new_lines) == len(old_lines) assert "v201911.0003" not in "\n".join(old_lines) @@ -31,8 +32,8 @@ def test_rewrite_final(): old_lines = REWRITE_FIXTURE.splitlines() patterns = ['__version__ = "v{year}{month}.{build_no}-{release_tag}"'] - new_vinfo = version.parse_version_info("v201911.0003") - new_lines = rewrite.rewrite_lines(patterns, new_vinfo, old_lines) + new_vinfo = v1version.parse_version_info("v201911.0003") + new_lines = v1rewrite.rewrite_lines(patterns, new_vinfo, old_lines) assert len(new_lines) == len(old_lines) assert "v201911.0003" not in "\n".join(old_lines) @@ -46,9 +47,8 @@ def test_iter_file_paths(): cfg = config.parse(ctx) assert cfg - file_paths = { - str(file_path) for file_path, patterns in rewrite._iter_file_paths(cfg.file_patterns) - } + _paths_and_patterns = v1rewrite.iter_file_paths(cfg.file_patterns) + file_paths = {str(file_path) for file_path, patterns in _paths_and_patterns} assert file_paths == {"pycalver.toml", "README.md"} @@ -59,9 +59,8 @@ def test_iter_file_globs(): cfg = config.parse(ctx) assert cfg - file_paths = { - str(file_path) for file_path, patterns in rewrite._iter_file_paths(cfg.file_patterns) - } + _paths_and_patterns = v1rewrite.iter_file_paths(cfg.file_patterns) + file_paths = {str(file_path) for file_path, patterns in _paths_and_patterns} assert file_paths == { "setup.cfg", @@ -80,7 +79,7 @@ def test_error_bad_path(): (project.dir / "setup.py").unlink() try: - list(rewrite._iter_file_paths(cfg.file_patterns)) + list(v1rewrite.iter_file_paths(cfg.file_patterns)) assert False, "expected IOError" except IOError as ex: assert "setup.py" in str(ex) @@ -96,10 +95,10 @@ def test_error_bad_pattern(): patterns["setup.py"] = patterns["setup.py"][0] + "invalid" try: - new_vinfo = version.parse_version_info("v201809.1234") - list(rewrite.diff(new_vinfo, patterns)) - assert False, "expected rewrite.NoPatternMatch" - except rewrite.NoPatternMatch as ex: + new_vinfo = v1version.parse_version_info("v201809.1234") + list(v1rewrite.diff(new_vinfo, patterns)) + assert False, "expected v1rewrite.NoPatternMatch" + except v1rewrite.NoPatternMatch as ex: assert "setup.py" in str(ex) @@ -109,21 +108,21 @@ __version__ = "2018.0002-beta" """ -def test_optional_release(): +def test_v1_optional_release(): old_lines = OPTIONAL_RELEASE_FIXTURE.splitlines() pattern = "{year}.{build_no}{release}" patterns = ['__version__ = "{year}.{build_no}{release}"'] - new_vinfo = version.parse_version_info("2019.0003", pattern) - new_lines = rewrite.rewrite_lines(patterns, new_vinfo, old_lines) + new_vinfo = v1version.parse_version_info("2019.0003", pattern) + new_lines = v1rewrite.rewrite_lines(patterns, new_vinfo, old_lines) assert len(new_lines) == len(old_lines) assert "2019.0003" not in "\n".join(old_lines) new_text = "\n".join(new_lines) assert "2019.0003" in new_text - new_vinfo = version.parse_version_info("2019.0004-beta", pattern) - new_lines = rewrite.rewrite_lines(patterns, new_vinfo, old_lines) + new_vinfo = v1version.parse_version_info("2019.0004-beta", pattern) + new_lines = v1rewrite.rewrite_lines(patterns, new_vinfo, old_lines) # make sure optional release tag is added back on assert len(new_lines) == len(old_lines)