diff --git a/src/pycalver/__main__.py b/src/pycalver/__main__.py index 2806a45..8f3dc41 100755 --- a/src/pycalver/__main__.py +++ b/src/pycalver/__main__.py @@ -24,6 +24,7 @@ from . import rewrite from . import version from . import v1version from . import v2version +from . import v1patterns _VERBOSE = 0 @@ -104,13 +105,14 @@ def test( ) -> None: """Increment a version number for demo purposes.""" _configure_logging(verbose=max(_VERBOSE, verbose)) + raw_pattern = pattern if release: _validate_release_tag(release) new_version = _incr( old_version, - raw_pattern=pattern, + raw_pattern=raw_pattern, release=release, major=major, minor=minor, @@ -118,7 +120,7 @@ def test( pin_date=pin_date, ) if new_version is None: - logger.error(f"Invalid version '{old_version}' and/or pattern '{pattern}'.") + logger.error(f"Invalid version '{old_version}' and/or pattern '{raw_pattern}'.") sys.exit(1) pep440_version = version.to_pep440(new_version) @@ -185,7 +187,7 @@ def _print_diff(cfg: config.Config, new_version: str) -> None: def _incr( old_version: str, - raw_pattern: str = "{pycalver}", + raw_pattern: str, *, release : str = None, major : bool = False, @@ -193,9 +195,10 @@ def _incr( patch : bool = False, pin_date: bool = False, ) -> typ.Optional[str]: - is_new_pattern = "{" in raw_pattern and "}" in raw_pattern - if is_new_pattern: - return v2version.incr( + v1_parts = list(v1patterns.PART_PATTERNS) + list(v1patterns.FULL_PART_FORMATS) + has_v1_part = any("{" + part + "}" in raw_pattern for part in v1_parts) + if has_v1_part: + return v1version.incr( old_version, raw_pattern=raw_pattern, release=release, @@ -205,7 +208,7 @@ def _incr( pin_date=pin_date, ) else: - return v1version.incr( + return v2version.incr( old_version, raw_pattern=raw_pattern, release=release, diff --git a/src/pycalver/config.py b/src/pycalver/config.py index d1ff1ea..ecfb774 100644 --- a/src/pycalver/config.py +++ b/src/pycalver/config.py @@ -1,10 +1,11 @@ # This file is part of the pycalver project # https://gitlab.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 setup.cfg or pycalver.cfg files.""" +import re import glob import typing as typ import logging @@ -125,7 +126,7 @@ def _debug_str(cfg: Config) -> str: "\n file_patterns={", ] - for filepath, patterns in cfg.file_patterns.items(): + for filepath, patterns in sorted(cfg.file_patterns.items()): for pattern in patterns: cfg_str_parts.append(f"\n '{filepath}': '{pattern.raw_pattern}',") @@ -261,6 +262,14 @@ def _parse_config(raw_cfg: RawConfig) -> Config: is_new_pattern = "{" not in version_pattern and "}" not in version_pattern + if is_new_pattern: + invalid_chars = re.search(r"([\s]+)", raw_cfg['version_pattern']) + if invalid_chars: + raise ValueError( + f"Invalid character(s) '{invalid_chars.group(1)}'" + f" in pycalver.version_pattern = {raw_cfg['version_pattern']}" + ) + # TODO (mb 2020-09-18): Validate Pattern # detect YY with WW or UU -> suggest GG with VV # detect YYMM -> suggest YY0M @@ -372,17 +381,17 @@ def _parse_raw_config(ctx: ProjectContext) -> RawConfig: def parse(ctx: ProjectContext) -> MaybeConfig: """Parse config file if available.""" - if not ctx.config_filepath.exists(): + if ctx.config_filepath.exists(): + try: + raw_cfg = _parse_raw_config(ctx) + return _parse_config(raw_cfg) + except (TypeError, ValueError) as ex: + logger.warning(f"Couldn't parse {ctx.config_rel_path}: {str(ex)}") + return None + else: logger.warning(f"File not found: {ctx.config_rel_path}") return None - try: - raw_cfg = _parse_raw_config(ctx) - return _parse_config(raw_cfg) - except (TypeError, ValueError) as ex: - logger.warning(f"Couldn't parse {ctx.config_rel_path}: {str(ex)}") - return None - DEFAULT_CONFIGPARSER_BASE_TMPL = """ [pycalver] diff --git a/src/pycalver/patterns.py b/src/pycalver/patterns.py index b5ad928..157d62b 100644 --- a/src/pycalver/patterns.py +++ b/src/pycalver/patterns.py @@ -1,3 +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 import typing as typ diff --git a/src/pycalver/rewrite.py b/src/pycalver/rewrite.py index cbd1a1a..93a9b53 100644 --- a/src/pycalver/rewrite.py +++ b/src/pycalver/rewrite.py @@ -1,3 +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 import typing as typ import difflib diff --git a/src/pycalver/v1cli.py b/src/pycalver/v1cli.py index d33ba12..b34d0b2 100755 --- a/src/pycalver/v1cli.py +++ b/src/pycalver/v1cli.py @@ -27,7 +27,8 @@ def update_cfg_from_vcs(cfg: config.Config, all_tags: typ.List[str]) -> config.C return cfg version_tags.sort(reverse=True) - logger.debug(f"found {len(version_tags)} tags: {version_tags[:2]}") + _debug_tags = ", ".join(version_tags[:3]) + logger.debug(f"found tags: {_debug_tags} ... ({len(version_tags)} in total)") latest_version_tag = version_tags[0] latest_version_pep440 = version.to_pep440(latest_version_tag) if latest_version_tag <= cfg.current_version: diff --git a/src/pycalver/v1patterns.py b/src/pycalver/v1patterns.py index 2171ac3..a3ace0c 100644 --- a/src/pycalver/v1patterns.py +++ b/src/pycalver/v1patterns.py @@ -34,6 +34,7 @@ import re import typing as typ import logging +from . import utils from .patterns import RE_PATTERN_ESCAPES from .patterns import Pattern @@ -80,8 +81,8 @@ PART_PATTERNS = { '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)", + 'pep440_tag' : r"(?:post|dev|rc|a|b)?\d*", + 'tag' : r"(?:preview|final|alpha|beta|post|pre|dev|rc|a|b|c|r)", 'yy' : r"\d{2}", 'yyyy' : r"\d{4}", 'quarter' : r"[1-4]", @@ -183,6 +184,7 @@ def _replace_pattern_parts(pattern: str) -> str: named_part_pattern = f"(?P<{part_name}>{part_pattern})" placeholder = "\u005c{" + part_name + "\u005c}" pattern = pattern.replace(placeholder, named_part_pattern) + return pattern @@ -214,6 +216,7 @@ def _compile_pattern_re(version_pattern: str, raw_pattern: str) -> typ.Pattern[s return re.compile(pattern_str) +@utils.memo def compile_pattern(version_pattern: str, raw_pattern: typ.Optional[str] = None) -> Pattern: _raw_pattern = version_pattern if raw_pattern is None else raw_pattern regexp = _compile_pattern_re(version_pattern, _raw_pattern) diff --git a/src/pycalver/v1rewrite.py b/src/pycalver/v1rewrite.py index 2cdb487..1bf652c 100644 --- a/src/pycalver/v1rewrite.py +++ b/src/pycalver/v1rewrite.py @@ -37,11 +37,11 @@ def rewrite_lines( >>> rewrite_lines(patterns, new_vinfo, ['__version__ = "201809.2b0"']) ['__version__ = "201811.123b0"'] """ - new_lines = old_lines[:] - found_patterns = set() + found_patterns: typ.Set[Pattern] = set() + new_lines = old_lines[:] for match in parse.iter_matches(old_lines, patterns): - found_patterns.add(match.pattern.raw_pattern) + found_patterns.add(match.pattern) replacement = v1version.format_version(new_vinfo, match.pattern.raw_pattern) span_l, span_r = match.span new_line = match.line[:span_l] + replacement + match.line[span_r:] diff --git a/src/pycalver/v1version.py b/src/pycalver/v1version.py index d007cdd..11fdb82 100644 --- a/src/pycalver/v1version.py +++ b/src/pycalver/v1version.py @@ -9,6 +9,8 @@ import typing as typ import logging import datetime as dt +import lexid + from . import version from . import v1patterns @@ -18,15 +20,19 @@ logger = logging.getLogger("pycalver.v1version") CalInfo = typ.Union[version.V1CalendarInfo, version.V1VersionInfo] -def _is_later_than(old: CalInfo, new: CalInfo) -> bool: - """Is old > new based on non None fields.""" +def _is_cal_gt(left: CalInfo, right: CalInfo) -> bool: + """Is left > right for non-None fields.""" + + lvals = [] + rvals = [] for field in version.V1CalendarInfo._fields: - aval = getattr(old, field) - bval = getattr(new, field) - if not (aval is None or bval is None): - if aval > bval: - return True - return False + lval = getattr(left , field) + rval = getattr(right, field) + if not (lval is None or rval is None): + lvals.append(lval) + rvals.append(rval) + + return lvals > rvals def _ver_to_cal_info(vnfo: version.V1VersionInfo) -> version.V1CalendarInfo: @@ -235,7 +241,7 @@ def parse_version_info(version_str: str, raw_pattern: str = "{pycalver}") -> ver if match is None: err_msg = ( f"Invalid version string '{version_str}' " - f"for pattern '{raw_pattern}'/'{pattern.regexp}'" + f"for pattern '{raw_pattern}'/'{pattern.regexp.pattern}'" ) raise version.PatternError(err_msg) else: @@ -386,19 +392,23 @@ def incr( cur_cinfo = _ver_to_cal_info(old_vinfo) if pin_date else cal_info() - if _is_later_than(old_vinfo, cur_cinfo): - cur_vinfo = old_vinfo._replace(**cur_cinfo._asdict()) - else: - logger.warning(f"Version appears to be from the future '{old_version}'") + if _is_cal_gt(old_vinfo, cur_cinfo): + logger.warning(f"Old version appears to be from the future '{old_version}'") cur_vinfo = old_vinfo + else: + cur_vinfo = old_vinfo._replace(**cur_cinfo._asdict()) + + cur_vinfo = cur_vinfo._replace(bid=lexid.next_id(cur_vinfo.bid)) + + if release: + cur_vinfo = cur_vinfo._replace(tag=release) + 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) - cur_vinfo = version.incr_non_cal_parts( - cur_vinfo, - release, - major, - minor, - patch, - ) 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/v2cli.py b/src/pycalver/v2cli.py index 243f2f5..f29ecac 100644 --- a/src/pycalver/v2cli.py +++ b/src/pycalver/v2cli.py @@ -27,7 +27,8 @@ def update_cfg_from_vcs(cfg: config.Config, all_tags: typ.List[str]) -> config.C return cfg version_tags.sort(reverse=True) - logger.debug(f"found {len(version_tags)} tags: {version_tags[:2]}") + _debug_tags = ", ".join(version_tags[:3]) + logger.debug(f"found tags: {_debug_tags} ... ({len(version_tags)} in total)") latest_version_tag = version_tags[0] latest_version_pep440 = version.to_pep440(latest_version_tag) if latest_version_tag <= cfg.current_version: diff --git a/src/pycalver/v2patterns.py b/src/pycalver/v2patterns.py index 57a52b9..8001208 100644 --- a/src/pycalver/v2patterns.py +++ b/src/pycalver/v2patterns.py @@ -35,6 +35,7 @@ import re import typing as typ import logging +from . import utils from .patterns import RE_PATTERN_ESCAPES from .patterns import Pattern @@ -84,8 +85,8 @@ PART_PATTERNS = { 'PATCH': r"[0-9]+", 'BUILD': r"[0-9]+", 'BLD' : r"[1-9][0-9]*", - 'TAG' : r"(?:alpha|beta|dev|pre|rc|post|final)", - 'PYTAG': r"(?:a|b|dev|rc|post)", + 'TAG' : r"(?:preview|final|alpha|beta|post|pre|dev|rc|a|b|c|r)", + 'PYTAG': r"(?:post|dev|rc|a|b)", 'NUM' : r"[0-9]+", } @@ -203,24 +204,56 @@ PART_FORMATS: typ.Dict[str, typ.Callable[[FieldValue], str]] = { } +def _convert_to_pep440(version_pattern: str) -> str: + # NOTE (mb 2020-09-20): This does not support some + # corner cases as specified in PEP440, in particular + # related to post and dev releases. + + version_pattern = version_pattern.lstrip("v") + + part_names = list(PATTERN_PART_FIELDS.keys()) + part_names.sort(key=len, reverse=True) + if version_pattern == "vYYYY0M.BUILD[-TAG]": + return "YYYY0M.BLD[PYTAGNUM]" + + # TODO (mb 2020-09-20) + raise NotImplementedError + + +def normalize_pattern(version_pattern: str, raw_pattern: str) -> str: + normalized_pattern = raw_pattern + if "{version}" in raw_pattern: + normalized_pattern = normalized_pattern.replace("{version}", version_pattern) + + if "{pep440_version}" in normalized_pattern: + pep440_version_pattern = _convert_to_pep440(version_pattern) + normalized_pattern = normalized_pattern.replace("{pep440_version}", pep440_version_pattern) + + return normalized_pattern + + def _replace_pattern_parts(pattern: str) -> str: # The pattern is escaped, so that everything besides the format # string variables is treated literally. - if "[" in pattern and "]" in pattern: - pattern = pattern.replace("[", "(?:") - pattern = pattern.replace("]", ")?") + while True: + new_pattern, n = re.subn(r"([^\\]|^)\[", r"\1(?:", pattern) + new_pattern, m = re.subn(r"([^\\]|^)\]", r"\1)?" , new_pattern) + pattern = new_pattern + if n + m == 0: + break + + SortKey = typ.Tuple[int, int] + PostitionedPart = typ.Tuple[int, int, str] + part_patterns_by_index: typ.Dict[SortKey, PostitionedPart] = {} - part_patterns_by_index: typ.Dict[typ.Tuple[int, int], typ.Tuple[int, int, str]] = {} for part_name, part_pattern in PART_PATTERNS.items(): start_idx = pattern.find(part_name) - if start_idx < 0: - continue - - field = PATTERN_PART_FIELDS[part_name] - named_part_pattern = f"(?P<{field}>{part_pattern})" - end_idx = start_idx + len(part_name) - sort_key = (-end_idx, -len(part_name)) - part_patterns_by_index[sort_key] = (start_idx, end_idx, named_part_pattern) + if start_idx >= 0: + field = PATTERN_PART_FIELDS[part_name] + named_part_pattern = f"(?P<{field}>{part_pattern})" + end_idx = start_idx + len(part_name) + sort_key = (-end_idx, -len(part_name)) + part_patterns_by_index[sort_key] = (start_idx, end_idx, named_part_pattern) # NOTE (mb 2020-09-17): The sorting is done so that we process items: # - right before left @@ -238,26 +271,21 @@ def _replace_pattern_parts(pattern: str) -> str: def _compile_pattern_re(version_pattern: str, raw_pattern: str) -> typ.Pattern[str]: - escaped_pattern = raw_pattern + normalized_pattern = normalize_pattern(version_pattern, raw_pattern) + escaped_pattern = normalized_pattern for char, escaped in RE_PATTERN_ESCAPES: # [] braces are used for optional parts, such as [-TAG]/[-beta] - is_semantic_char = char in "[]" + # and need to be escaped manually. + is_semantic_char = char in "[]\\" if not is_semantic_char: # escape it so it is a literal in the re pattern escaped_pattern = escaped_pattern.replace(char, escaped) - escaped_pattern = raw_pattern.replace("[", "\u005c[").replace("]", "\u005c]") - normalized_pattern = escaped_pattern.replace("{version}", version_pattern) - print(">>>>", (raw_pattern ,)) - print("....", (escaped_pattern ,)) - print("....", (normalized_pattern,)) - print("<<<<", (normalized_pattern,)) - - # TODO (mb 2020-09-19): replace {version} etc with version_pattern pattern_str = _replace_pattern_parts(escaped_pattern) return re.compile(pattern_str) +@utils.memo def compile_pattern(version_pattern: str, raw_pattern: typ.Optional[str] = None) -> Pattern: _raw_pattern = version_pattern if raw_pattern is None else raw_pattern regexp = _compile_pattern_re(version_pattern, _raw_pattern) diff --git a/src/pycalver/v2rewrite.py b/src/pycalver/v2rewrite.py index c82222a..49e4c22 100644 --- a/src/pycalver/v2rewrite.py +++ b/src/pycalver/v2rewrite.py @@ -14,6 +14,7 @@ from . import config from . import rewrite from . import version from . import v2version +from . import v2patterns from .patterns import Pattern logger = logging.getLogger("pycalver.v2rewrite") @@ -41,12 +42,15 @@ def rewrite_lines( >>> rewrite_lines(patterns, new_vinfo, old_lines) ['__version__ = "201811.123b0"'] """ - new_lines = old_lines[:] - found_patterns = set() + found_patterns: typ.Set[Pattern] = set() + new_lines = old_lines[:] for match in parse.iter_matches(old_lines, patterns): - found_patterns.add(match.pattern.raw_pattern) - replacement = v2version.format_version(new_vinfo, match.pattern.raw_pattern) + found_patterns.add(match.pattern) + normalized_pattern = v2patterns.normalize_pattern( + match.pattern.version_pattern, match.pattern.raw_pattern + ) + replacement = v2version.format_version(new_vinfo, normalized_pattern) span_l, span_r = match.span new_line = match.line[:span_l] + replacement + match.line[span_r:] new_lines[match.lineno] = new_line @@ -93,6 +97,18 @@ def rfd_from_content( return rewrite.RewrittenFileData(path, line_sep, old_lines, new_lines) +def _patterns_with_change( + old_vinfo: version.V2VersionInfo, new_vinfo: version.V2VersionInfo, patterns: typ.List[Pattern] +) -> int: + patterns_with_change = 0 + for pattern in patterns: + old_str = v2version.format_version(old_vinfo, pattern.raw_pattern) + new_str = v2version.format_version(new_vinfo, pattern.raw_pattern) + if old_str != new_str: + patterns_with_change += 1 + return patterns_with_change + + def iter_rewritten( file_patterns: config.PatternsByFile, new_vinfo : version.V2VersionInfo, @@ -159,13 +175,6 @@ def diff( with file_path.open(mode="rt", encoding="utf-8") as fobj: content = fobj.read() - patterns_with_change = 0 - for pattern in patterns: - old_str = v2version.format_version(old_vinfo, pattern.raw_pattern) - new_str = v2version.format_version(new_vinfo, pattern.raw_pattern) - if old_str != new_str: - patterns_with_change += 1 - try: rfd = rfd_from_content(patterns, new_vinfo, content) except rewrite.NoPatternMatch: @@ -175,6 +184,8 @@ def diff( rfd = rfd._replace(path=str(file_path)) lines = rewrite.diff_lines(rfd) + + patterns_with_change = _patterns_with_change(old_vinfo, new_vinfo, patterns) if len(lines) == 0 and patterns_with_change > 0: errmsg = f"No patterns matched for '{file_path}'" raise rewrite.NoPatternMatch(errmsg) diff --git a/src/pycalver/v2version.py b/src/pycalver/v2version.py index 72c7e9b..22a93af 100644 --- a/src/pycalver/v2version.py +++ b/src/pycalver/v2version.py @@ -9,6 +9,8 @@ import typing as typ import logging import datetime as dt +import lexid + from . import version from . import v2patterns @@ -18,15 +20,19 @@ logger = logging.getLogger("pycalver.v2version") CalInfo = typ.Union[version.V2CalendarInfo, version.V2VersionInfo] -def _is_later_than(old: CalInfo, new: CalInfo) -> bool: - """Is old > new based on non None fields.""" - for field in version.V1CalendarInfo._fields: - aval = getattr(old, field) - bval = getattr(new, field) - if not (aval is None or bval is None): - if aval > bval: - return True - return False +def _is_cal_gt(left: CalInfo, right: CalInfo) -> bool: + """Is left > right for non-None fields.""" + + lvals = [] + rvals = [] + for field in version.V2CalendarInfo._fields: + lval = getattr(left , field) + rval = getattr(right, field) + if not (lval is None or rval is None): + lvals.append(lval) + rvals.append(rval) + + return lvals > rvals def _ver_to_cal_info(vinfo: version.V2VersionInfo) -> version.V2CalendarInfo: @@ -268,9 +274,10 @@ def is_valid(version_str: str, raw_pattern: str = "vYYYY0M.BUILD[-TAG]") -> bool TemplateKwargs = typ.Dict[str, typ.Union[str, int, None]] +PartValues = typ.List[typ.Tuple[str, str]] -def _format_part_values(vinfo: version.V2VersionInfo) -> typ.Dict[str, str]: +def _format_part_values(vinfo: version.V2VersionInfo) -> PartValues: """Generate kwargs for template from minimal V2VersionInfo. The V2VersionInfo Tuple only has the minimal representation @@ -279,14 +286,14 @@ def _format_part_values(vinfo: version.V2VersionInfo) -> typ.Dict[str, str]: representation '09' for '0M'. >>> vinfo = parse_version_info("v200709.1033-beta", pattern="vYYYY0M.BUILD[-TAG]") - >>> kwargs = _format_part_values(vinfo) + >>> kwargs = dict(_format_part_values(vinfo)) >>> (kwargs['YYYY'], kwargs['0M'], kwargs['BUILD'], kwargs['TAG']) ('2007', '09', '1033', 'beta') >>> (kwargs['YY'], kwargs['0Y'], kwargs['MM'], kwargs['PYTAG']) ('7', '07', '9', 'b') >>> vinfo = parse_version_info("200709.1033b1", pattern="YYYY0M.BLD[PYTAGNUM]") - >>> kwargs = _format_part_values(vinfo) + >>> kwargs = dict(_format_part_values(vinfo)) >>> (kwargs['YYYY'], kwargs['0M'], kwargs['BUILD'], kwargs['PYTAG'], kwargs['NUM']) ('2007', '09', '1033', 'b', '1') """ @@ -299,7 +306,7 @@ def _format_part_values(vinfo: version.V2VersionInfo) -> typ.Dict[str, str]: format_fn = v2patterns.PART_FORMATS[part] kwargs[part] = format_fn(field_val) - return kwargs + return sorted(kwargs.items(), key=lambda item: -len(item[0])) def _make_segments(raw_pattern: str) -> typ.List[str]: @@ -345,12 +352,63 @@ def _clear_zero_segments( 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: + """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"] + """ + + internal_root: SegmentTree = [] + branch_stack : typ.List[SegmentTree] = [internal_root] + segment_start_index = -1 + + raw_pattern = "[" + raw_pattern + "]" + + for i, char in enumerate(raw_pattern): + is_escaped = i > 0 and raw_pattern[i - 1] == "\\" + if char in "[]" and not is_escaped: + start = segment_start_index + 1 + end = i + if start < end: + branch_stack[-1].append(raw_pattern[start:end]) + + if char == "[": + new_branch: SegmentTree = [] + branch_stack[-1].append(new_branch) + branch_stack.append(new_branch) + segment_start_index = i + elif char == "]": + if len(branch_stack) == 1: + err = f"Unbalanced brace(s) in '{raw_pattern}'" + raise ValueError(err) + + branch_stack.pop() + segment_start_index = i + else: + raise NotImplementedError("Unreachable") + + if len(branch_stack) > 1: + err = f"Unclosed brace in '{raw_pattern}'" + raise ValueError(err) + + return internal_root[0] + + def _format_segments( - vinfo : version.V2VersionInfo, pattern_segs: typ.List[str], + part_values : PartValues, ) -> typ.List[str]: - kwargs = _format_part_values(vinfo) - part_values = sorted(kwargs.items(), key=lambda item: -len(item[0])) + # NOTE (mb 2020-09-21): Old implementaion that doesn't cover corner + # cases relating to escaped braces. is_zero_segment = [True] * len(pattern_segs) @@ -361,23 +419,25 @@ def _format_segments( idx_r = len(pattern_segs) - 1 while idx_l <= idx_r: # NOTE (mb 2020-09-18): All segments are optional, - # except the most left and the most right, - # i.e the ones NOT surrounded by braces. - # Empty string is a valid segment. - is_optional = idx_l > 0 + # except the most left and the most right. + # In other words the ones NOT surrounded by braces are + # required. Empty string is a valid segment. + is_required_seg = idx_l == 0 seg_l = pattern_segs[idx_l] seg_r = pattern_segs[idx_r] for part, part_value in part_values: if part in seg_l: - seg_l = seg_l.replace(part, part_value) - if not (is_optional and str(part_value) == version.ZERO_VALUES.get(part)): + seg_l = seg_l.replace(part, part_value) + is_zero_seg = str(part_value) == version.ZERO_VALUES.get(part) + if is_required_seg or not is_zero_seg: is_zero_segment[idx_l] = False if part in seg_r: - seg_r = seg_r.replace(part, part_value) - if not (is_optional and str(part_value) == version.ZERO_VALUES[part]): + seg_r = seg_r.replace(part, part_value) + is_zero_seg = str(part_value) == version.ZERO_VALUES.get(part) + if is_required_seg or not is_zero_seg: is_zero_segment[idx_r] = False formatted_segs_l.append(seg_l) @@ -391,6 +451,43 @@ def _format_segments( return _clear_zero_segments(formatted_segs, is_zero_segment) +FormattedSegmentParts = typ.List[str] + + +def _format_segment_tree( + seg_tree: SegmentTree, + part_values : PartValues, +) -> FormattedSegmentParts: + result_parts = [] + for seg in seg_tree: + if isinstance(seg, list): + result_parts.extend(_format_segment_tree(seg, part_values)) + else: + # NOTE (mb 2020-09-24): If a segment has any zero parts, + # the whole segment is skipped. + is_zero_seg = False + formatted_seg = seg + # unescape braces + formatted_seg = formatted_seg.replace(r"\[", r"[") + formatted_seg = formatted_seg.replace(r"\]", r"]") + # replace non zero parts + for part, part_value in part_values: + if part in formatted_seg: + is_zero_part = ( + part in version.ZERO_VALUES + and str(part_value) == version.ZERO_VALUES[part] + ) + if is_zero_part: + is_zero_seg = True + else: + formatted_seg = formatted_seg.replace(part, part_value) + + if not is_zero_seg: + result_parts.append(formatted_seg) + + return result_parts + + def format_version(vinfo: version.V2VersionInfo, raw_pattern: str) -> str: """Generate version string. @@ -477,10 +574,15 @@ def format_version(vinfo: version.V2VersionInfo, raw_pattern: str) -> str: >>> format_version(vinfo_d, pattern='__version__ = "vMAJOR[.MINOR[.PATCH[-TAG[NUM]]]]"') '__version__ = "v1.0.0-rc2"' """ - pattern_segs = _make_segments(raw_pattern) - formatted_segs = _format_segments(vinfo, pattern_segs) + part_values = _format_part_values(vinfo) - return "".join(formatted_segs) + # pattern_segs = _make_segments(raw_pattern) + # formatted_segs = _format_segments(pattern_segs, part_values) + # version_str = "".join(formatted_segs) + + seg_tree = _parse_segment_tree(raw_pattern) + version_str_parts = _format_segment_tree(seg_tree, part_values) + return "".join(version_str_parts) def incr( @@ -505,19 +607,30 @@ def incr( cur_cinfo = _ver_to_cal_info(old_vinfo) if pin_date else cal_info() - if _is_later_than(old_vinfo, cur_cinfo): - logger.warning(f"Version appears to be from the future '{old_version}'") + if _is_cal_gt(old_vinfo, cur_cinfo): + logger.warning(f"Old version appears to be from the future '{old_version}'") cur_vinfo = old_vinfo else: cur_vinfo = old_vinfo._replace(**cur_cinfo._asdict()) - cur_vinfo = version.incr_non_cal_parts( - cur_vinfo, - release, - major, - minor, - patch, - ) + # prevent truncation of leading zeros + if int(cur_vinfo.bid) < 1000: + cur_vinfo = cur_vinfo._replace(bid=str(int(cur_vinfo.bid) + 1000)) + + cur_vinfo = cur_vinfo._replace(bid=lexid.next_id(cur_vinfo.bid)) + + if release: + cur_vinfo = cur_vinfo._replace(tag=release) + 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) + + # 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 76a9acb..c3b06bf 100644 --- a/src/pycalver/version.py +++ b/src/pycalver/version.py @@ -1,7 +1,11 @@ +# 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 import typing as typ import datetime as dt -import lexid import pkg_resources MaybeInt = typ.Optional[int] @@ -71,9 +75,6 @@ class V2VersionInfo(typ.NamedTuple): pytag : str -VersionInfoType = typ.TypeVar('VersionInfoType', V1VersionInfo, V2VersionInfo) - - # The test suite may replace this. TODAY = dt.datetime.utcnow().date() @@ -81,7 +82,7 @@ TODAY = dt.datetime.utcnow().date() TAG_BY_PEP440_TAG = { 'a' : 'alpha', 'b' : 'beta', - "" : 'final', + '' : 'final', 'rc' : 'rc', 'dev' : 'dev', 'post': 'post', @@ -89,13 +90,19 @@ TAG_BY_PEP440_TAG = { PEP440_TAG_BY_TAG = { - 'alpha': "a", - 'beta' : "b", - 'final': "", - 'pre' : "rc", - 'rc' : "rc", - 'dev' : "dev", - 'post' : "post", + 'a' : 'a', + 'b' : 'b', + 'dev' : 'dev', + 'alpha' : 'a', + 'beta' : 'b', + 'preview': 'rc', + 'pre' : 'rc', + 'rc' : 'rc', + 'c' : 'rc', + 'final' : '', + 'post' : 'post', + 'r' : 'post', + 'rev' : 'post', } assert set(TAG_BY_PEP440_TAG.keys()) == set(PEP440_TAG_BY_TAG.values()) @@ -148,28 +155,3 @@ def to_pep440(version: str) -> str: '201811.7b0' """ return str(pkg_resources.parse_version(version)) - - -def incr_non_cal_parts( - vinfo : VersionInfoType, - release: typ.Optional[str], - major : bool, - minor : bool, - patch : bool, -) -> VersionInfoType: - _bid = vinfo.bid - if int(_bid) < 1000: - # prevent truncation of leading zeros - _bid = str(int(_bid) + 1000) - - vinfo = vinfo._replace(bid=lexid.next_id(_bid)) - - if release: - vinfo = vinfo._replace(tag=release) - 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) - return vinfo diff --git a/test/test_version.py b/test/test_version.py index db90f24..6e469cf 100644 --- a/test/test_version.py +++ b/test/test_version.py @@ -218,8 +218,8 @@ def test_make_segments(): def test_v2_format_version(): - version_pattern = "vYYYY0M.BUILD[-TAG[NUM]]" - in_version = "v200701.0033-beta" + version_pattern = "vYYYY0M.BUILD[-TAG[NUM]]" + in_version = "v200701.0033-beta" vinfo = v2version.parse_version_info(in_version, raw_pattern=version_pattern) out_version = v2version.format_version(vinfo, raw_pattern=version_pattern)