diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d45e90..e442bee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Better support for week numbering. - Better support for optional parts. - New: Start `BUILD` parts at `1000` to avoid leading zero truncation. + - New: `MAJOR`/`MINOR`/`PATCH`/`INC` will roll over when a date part changes to their left. - New gitlab #2: Added `grep` subcommand to find and debug patterns. - New: Added better error messages to debug regular expressions. - New gitlab #10: `--pin-date` to keep date parts unchanged, and only increment non-date parts. diff --git a/pylint-ignore.md b/pylint-ignore.md index c20c367..d4e5c53 100644 --- a/pylint-ignore.md +++ b/pylint-ignore.md @@ -63,56 +63,39 @@ The recommended approach to using `pylint-ignore` is: ``` -## File src/pycalver/__main__.py - Line 419 - W0511 (fixme) +## File src/pycalver/__main__.py - Line 421 - W0511 (fixme) - `message: TODO (mb 2020-09-18): Investigate error messages` - `author : Manuel Barkhau ` - `date : 2020-09-19T16:24:10` ``` - 389: def _bump( + 391: def _bump( ... - 417: sys.exit(1) - 418: except Exception as ex: -> 419: # TODO (mb 2020-09-18): Investigate error messages - 420: logger.error(str(ex)) - 421: sys.exit(1) -``` - - -## File src/pycalver/v2version.py - Line 641 - W0511 (fixme) - -- `message: TODO (mb 2020-09-20): New Rollover Behaviour:` -- `author : Manuel Barkhau ` -- `date : 2020-10-03T19:31:28` - -``` - 599: def incr( - ... - 639: ) - 640: -> 641: # TODO (mb 2020-09-20): New Rollover Behaviour: - 642: # Reset major, minor, patch to zero if any part to the left of it is incremented - 643: + 419: sys.exit(1) + 420: except Exception as ex: +> 421: # TODO (mb 2020-09-18): Investigate error messages + 422: logger.error(str(ex)) + 423: sys.exit(1) ``` # W0703: broad-except -## File src/pycalver/__main__.py - Line 418 - W0703 (broad-except) +## File src/pycalver/__main__.py - Line 420 - W0703 (broad-except) - `message: Catching too general exception Exception` - `author : Manuel Barkhau ` - `date : 2020-09-05T14:30:17` ``` - 389: def _bump( + 391: def _bump( ... - 416: logger.error(str(ex)) - 417: sys.exit(1) -> 418: except Exception as ex: - 419: # TODO (mb 2020-09-18): Investigate error messages - 420: logger.error(str(ex)) + 418: logger.error(str(ex)) + 419: sys.exit(1) +> 420: except Exception as ex: + 421: # TODO (mb 2020-09-18): Investigate error messages + 422: logger.error(str(ex)) ``` diff --git a/src/pycalver/v2version.py b/src/pycalver/v2version.py index 745c8fc..3af15e1 100644 --- a/src/pycalver/v2version.py +++ b/src/pycalver/v2version.py @@ -318,36 +318,19 @@ def _format_part_values(vinfo: version.V2VersionInfo) -> PartValues: return sorted(kwargs.items(), key=lambda item: -len(item[0])) -def _clear_zero_segments( - formatted_segs: typ.List[str], is_zero_segment: typ.List[bool] -) -> typ.List[str]: - non_zero_segs = list(formatted_segs) - - has_val_to_right = False - for idx, is_zero in reversed(list(enumerate(is_zero_segment))): - is_optional = 0 < idx < len(formatted_segs) - 1 - if is_optional: - if is_zero and not has_val_to_right: - non_zero_segs[idx] = "" - else: - has_val_to_right = True - - return non_zero_segs - - Segment = str # mypy limitation wrt. cyclic definition # SegmentTree = typ.List[typ.Union[Segment, "SegmentTree"]] SegmentTree = typ.Any -def _parse_segment_tree(raw_pattern: str) -> SegmentTree: +def _parse_segtree(raw_pattern: str) -> SegmentTree: """Generate segment tree from pattern string. - >>> tree = _parse_segment_tree("aa[bb[cc]]") - >>> assert tree == ["aa", ["bb", ["cc"]]] - >>> tree = _parse_segment_tree("aa[bb[cc]dd[ee]ff]gg") - >>> assert tree == ["aa", ["bb", ["cc"], "dd", ["ee"], "ff"], "gg"] + >>> _parse_segtree('aa[bb[cc]]') + ['aa', ['bb', ['cc']]] + >>> _parse_segtree('aa[bb[cc]dd[ee]ff]gg') + ['aa', ['bb', ['cc'], 'dd', ['ee'], 'ff'], 'gg'] """ internal_root: SegmentTree = [] @@ -428,7 +411,7 @@ def _format_segment(seg: Segment, part_values: PartValues) -> FormatedSeg: def _format_segment_tree( - seg_tree : SegmentTree, + segtree : SegmentTree, part_values: PartValues, ) -> FormatedSeg: # print("??>>>", seg_tree) @@ -437,7 +420,7 @@ def _format_segment_tree( # is only omitted, if all parts to the right of it were also omitted. result_parts: typ.List[str] = [] is_zero = True - for seg in seg_tree: + for seg in segtree: if isinstance(seg, list): formatted_seg = _format_segment_tree(seg, part_values) else: @@ -541,38 +524,95 @@ def format_version(vinfo: version.V2VersionInfo, raw_pattern: str) -> str: '__version__ = "v1.0.0-rc2"' """ part_values = _format_part_values(vinfo) - seg_tree = _parse_segment_tree(raw_pattern) - formatted_seg = _format_segment_tree(seg_tree, part_values) + segtree = _parse_segtree(raw_pattern) + formatted_seg = _format_segment_tree(segtree, part_values) return formatted_seg.result +def _iter_flat_segtree(segtree: SegmentTree) -> typ.Iterable[Segment]: + """Flatten a SegmentTree (mixed nested list of lists or str). + + >>> list(_iter_flat_segtree(['aa', ['bb', ['cc'], 'dd', ['ee'], 'ff'], 'gg'])) + ['aa', 'bb', 'cc', 'dd', 'ee', 'ff', 'gg'] + """ + for subtree in segtree: + if isinstance(subtree, list): + for seg in _iter_flat_segtree(subtree): + yield seg + else: + yield subtree + + +def _parse_pattern_fields(raw_pattern: str) -> typ.List[str]: + parts = list(v2patterns.PATTERN_PART_FIELDS.keys()) + parts.sort(key=len, reverse=True) + + segtree = _parse_segtree(raw_pattern) + segments = _iter_flat_segtree(segtree) + + fields_by_index = {} + for segment_index, segment in enumerate(segments): + for part in parts: + part_index = segment.find(part) + if part_index >= 0: + field = v2patterns.PATTERN_PART_FIELDS[part] + fields_by_index[segment_index, part_index] = field + + return [field for _, field in sorted(fields_by_index.items())] + + +def _iter_reset_field_items( + fields : typ.List[str], + old_vinfo: version.V2VersionInfo, + cur_vinfo: version.V2VersionInfo, +) -> typ.Iterable[typ.Tuple[str, str]]: + # Any field to the left of another can reset all to the right + has_reset = False + for field in fields: + zero_val = version.V2_FIELD_ZERO_VALUES.get(field) + if has_reset and zero_val is not None: + yield field, zero_val + elif getattr(old_vinfo, field) != getattr(cur_vinfo, field): + has_reset = True + + def _incr_numeric( - vinfo : version.V2VersionInfo, + raw_pattern: str, + old_vinfo : version.V2VersionInfo, + cur_vinfo : version.V2VersionInfo, major : bool, minor : bool, patch : bool, - release : typ.Optional[str], + tag : typ.Optional[str], release_num: bool, ) -> version.V2VersionInfo: + # Reset major/minor/patch/num/inc to zero if any part to the left of it is incremented + fields = _parse_pattern_fields(raw_pattern) + reset_fields = dict(_iter_reset_field_items(fields, old_vinfo, cur_vinfo)) + + cur_kwargs = cur_vinfo._asdict() + cur_kwargs.update(reset_fields) + cur_vinfo = version.V2VersionInfo(**cur_kwargs) + # prevent truncation of leading zeros - if int(vinfo.bid) < 1000: - vinfo = vinfo._replace(bid=str(int(vinfo.bid) + 1000)) + if int(cur_vinfo.bid) < 1000: + cur_vinfo = cur_vinfo._replace(bid=str(int(cur_vinfo.bid) + 1000)) - vinfo = vinfo._replace(bid=lexid.next_id(vinfo.bid)) + cur_vinfo = cur_vinfo._replace(bid=lexid.next_id(cur_vinfo.bid)) - if major: - vinfo = vinfo._replace(major=vinfo.major + 1, minor=0, patch=0) - if minor: - vinfo = vinfo._replace(minor=vinfo.minor + 1, patch=0) - if patch: - vinfo = vinfo._replace(patch=vinfo.patch + 1) - if release_num: - vinfo = vinfo._replace(num=vinfo.num + 1) - if release: - if release != vinfo.tag: - vinfo = vinfo._replace(num=0) - vinfo = vinfo._replace(tag=release) - return vinfo + if major and 'major' not in reset_fields: + cur_vinfo = cur_vinfo._replace(major=cur_vinfo.major + 1, minor=0, patch=0) + if minor and 'minor' not in reset_fields: + cur_vinfo = cur_vinfo._replace(minor=cur_vinfo.minor + 1, patch=0) + if patch and 'patch' not in reset_fields: + cur_vinfo = cur_vinfo._replace(patch=cur_vinfo.patch + 1) + if release_num and 'release_num' not in reset_fields: + cur_vinfo = cur_vinfo._replace(num=cur_vinfo.num + 1) + if tag and 'tag' not in reset_fields: + if tag != cur_vinfo.tag: + cur_vinfo = cur_vinfo._replace(num=0) + cur_vinfo = cur_vinfo._replace(tag=tag) + return cur_vinfo def is_valid_week_pattern(raw_pattern) -> bool: @@ -580,9 +620,7 @@ def is_valid_week_pattern(raw_pattern) -> bool: has_ww_part = any(part in raw_pattern for part in ["WW" , "0W", "UU", "0U"]) has_gg_part = any(part in raw_pattern for part in ["GGGG", "GG", "0G"]) has_vv_part = any(part in raw_pattern for part in ["VV" , "0V"]) - if not ((has_yy_part or has_gg_part) and (has_ww_part or has_vv_part)): - return True - elif has_yy_part and has_vv_part: + if has_yy_part and has_vv_part: alt1 = raw_pattern.replace("V", "W") alt2 = raw_pattern.replace("Y", "G") logger.error(f"Invalid pattern: '{raw_pattern}'. Maybe try {alt1} or {alt2}") @@ -630,17 +668,16 @@ def incr( cur_vinfo = old_vinfo._replace(**cur_cinfo._asdict()) cur_vinfo = _incr_numeric( + raw_pattern, + old_vinfo, cur_vinfo, major=major, minor=minor, patch=patch, - release=release, + tag=tag, release_num=release_num, ) - # TODO (mb 2020-09-20): New Rollover Behaviour: - # Reset major, minor, patch to zero if any part to the left of it is incremented - new_version = format_version(cur_vinfo, raw_pattern) if new_version == old_version: logger.error("Invalid arguments or pattern, version did not change.") diff --git a/src/pycalver/version.py b/src/pycalver/version.py index dfe7c1f..fb42532 100644 --- a/src/pycalver/version.py +++ b/src/pycalver/version.py @@ -109,18 +109,30 @@ assert set(RELEASE_BY_PEP440_TAG.keys()) == set(PEP440_TAG_BY_RELEASE.values()) assert set(RELEASE_BY_PEP440_TAG.values()) < set(PEP440_TAG_BY_RELEASE.keys()) -ZERO_VALUES = { +PART_ZERO_VALUES = { 'MAJOR' : "0", 'MINOR' : "0", 'PATCH' : "0", 'RELEASE': "final", 'PYTAG' : "", 'NUM' : "0", + 'INC' : "0", +} + + +V2_FIELD_ZERO_VALUES = { + 'major': "0", + 'minor': "0", + 'patch': "0", + 'tag' : "final", + 'pytag': "", + 'num' : "0", + 'inc' : "0", } def is_zero_val(part: str, part_value: str) -> bool: - return part in ZERO_VALUES and part_value == ZERO_VALUES[part] + return part in PART_ZERO_VALUES and part_value == PART_ZERO_VALUES[part] class PatternError(Exception): diff --git a/test/test_cli.py b/test/test_cli.py index f225101..a9b15be 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -10,6 +10,7 @@ import re import time import shlex import shutil +import datetime as dt import subprocess as sp import pytest @@ -21,6 +22,7 @@ from pycalver import v2cli from pycalver import config from pycalver import v1patterns from pycalver.__main__ import cli +from pycalver.__main__ import incr_dispatch # pylint:disable=redefined-outer-name ; pytest fixtures # pylint:disable=protected-access ; allowed for test code @@ -764,3 +766,32 @@ def test_multimatch_file_patterns(runner): assert "Hello World v202010.1003-beta !" in readme_text assert "[aka. 202010.1003b0 !]" in readme_text + + +def _kwargs(month, minor): + return {'date': dt.date(2020, month, 1), 'minor': minor} + + +ROLLOVER_TEST_CASES = [ + # v1 cases + ["{year}.{month}.{MINOR}", "2020.10.3", "2020.10.4", _kwargs(10, True)], + ["{year}.{month}.{MINOR}", "2020.10.3", None, _kwargs(10, False)], + ["{year}.{month}.{MINOR}", "2020.10.3", "2020.11.4", _kwargs(11, True)], + ["{year}.{month}.{MINOR}", "2020.10.3", "2020.11.3", _kwargs(11, False)], + # v2 cases + ["YYYY.MM.MINOR" , "2020.10.3", "2020.10.4", _kwargs(10, True)], + ["YYYY.MM.MINOR" , "2020.10.3", None, _kwargs(10, False)], + ["YYYY.MM.MINOR" , "2020.10.3", "2020.11.0", _kwargs(11, True)], + ["YYYY.MM.MINOR" , "2020.10.3", "2020.11.0", _kwargs(11, False)], + ["YYYY.MM[.MINOR]", "2020.10.3", "2020.10.4", _kwargs(10, True)], + ["YYYY.MM[.MINOR]", "2020.10.3", "2020.11", _kwargs(11, False)], +] + + +@pytest.mark.parametrize("version_pattern, old_version, expected, kwargs", ROLLOVER_TEST_CASES) +def test_rollover(version_pattern, old_version, expected, kwargs): + new_version = incr_dispatch(old_version, raw_pattern=version_pattern, **kwargs) + if new_version is None: + assert expected is None + else: + assert new_version == expected