formatting with segment tree

This commit is contained in:
Manuel Barkhau 2020-09-24 11:16:02 +00:00
parent 8af5047244
commit 5a64983b8e
14 changed files with 325 additions and 154 deletions

View file

@ -24,6 +24,7 @@ from . import rewrite
from . import version from . import version
from . import v1version from . import v1version
from . import v2version from . import v2version
from . import v1patterns
_VERBOSE = 0 _VERBOSE = 0
@ -104,13 +105,14 @@ def test(
) -> None: ) -> None:
"""Increment a version number for demo purposes.""" """Increment a version number for demo purposes."""
_configure_logging(verbose=max(_VERBOSE, verbose)) _configure_logging(verbose=max(_VERBOSE, verbose))
raw_pattern = pattern
if release: if release:
_validate_release_tag(release) _validate_release_tag(release)
new_version = _incr( new_version = _incr(
old_version, old_version,
raw_pattern=pattern, raw_pattern=raw_pattern,
release=release, release=release,
major=major, major=major,
minor=minor, minor=minor,
@ -118,7 +120,7 @@ def test(
pin_date=pin_date, pin_date=pin_date,
) )
if new_version is None: 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) sys.exit(1)
pep440_version = version.to_pep440(new_version) pep440_version = version.to_pep440(new_version)
@ -185,7 +187,7 @@ def _print_diff(cfg: config.Config, new_version: str) -> None:
def _incr( def _incr(
old_version: str, old_version: str,
raw_pattern: str = "{pycalver}", raw_pattern: str,
*, *,
release : str = None, release : str = None,
major : bool = False, major : bool = False,
@ -193,9 +195,10 @@ def _incr(
patch : bool = False, patch : bool = False,
pin_date: bool = False, pin_date: bool = False,
) -> typ.Optional[str]: ) -> typ.Optional[str]:
is_new_pattern = "{" in raw_pattern and "}" in raw_pattern v1_parts = list(v1patterns.PART_PATTERNS) + list(v1patterns.FULL_PART_FORMATS)
if is_new_pattern: has_v1_part = any("{" + part + "}" in raw_pattern for part in v1_parts)
return v2version.incr( if has_v1_part:
return v1version.incr(
old_version, old_version,
raw_pattern=raw_pattern, raw_pattern=raw_pattern,
release=release, release=release,
@ -205,7 +208,7 @@ def _incr(
pin_date=pin_date, pin_date=pin_date,
) )
else: else:
return v1version.incr( return v2version.incr(
old_version, old_version,
raw_pattern=raw_pattern, raw_pattern=raw_pattern,
release=release, release=release,

View file

@ -1,10 +1,11 @@
# This file is part of the pycalver project # This file is part of the pycalver project
# https://gitlab.com/mbarkhau/pycalver # 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 # SPDX-License-Identifier: MIT
"""Parse setup.cfg or pycalver.cfg files.""" """Parse setup.cfg or pycalver.cfg files."""
import re
import glob import glob
import typing as typ import typing as typ
import logging import logging
@ -125,7 +126,7 @@ def _debug_str(cfg: Config) -> str:
"\n file_patterns={", "\n file_patterns={",
] ]
for filepath, patterns in cfg.file_patterns.items(): for filepath, patterns in sorted(cfg.file_patterns.items()):
for pattern in patterns: for pattern in patterns:
cfg_str_parts.append(f"\n '{filepath}': '{pattern.raw_pattern}',") 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 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 # TODO (mb 2020-09-18): Validate Pattern
# detect YY with WW or UU -> suggest GG with VV # detect YY with WW or UU -> suggest GG with VV
# detect YYMM -> suggest YY0M # detect YYMM -> suggest YY0M
@ -372,17 +381,17 @@ def _parse_raw_config(ctx: ProjectContext) -> RawConfig:
def parse(ctx: ProjectContext) -> MaybeConfig: def parse(ctx: ProjectContext) -> MaybeConfig:
"""Parse config file if available.""" """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}") logger.warning(f"File not found: {ctx.config_rel_path}")
return None 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 = """ DEFAULT_CONFIGPARSER_BASE_TMPL = """
[pycalver] [pycalver]

View file

@ -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 typing as typ

View file

@ -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 typing as typ
import difflib import difflib

View file

@ -27,7 +27,8 @@ def update_cfg_from_vcs(cfg: config.Config, all_tags: typ.List[str]) -> config.C
return cfg return cfg
version_tags.sort(reverse=True) 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_tag = version_tags[0]
latest_version_pep440 = version.to_pep440(latest_version_tag) latest_version_pep440 = version.to_pep440(latest_version_tag)
if latest_version_tag <= cfg.current_version: if latest_version_tag <= cfg.current_version:

View file

@ -34,6 +34,7 @@ import re
import typing as typ import typing as typ
import logging import logging
from . import utils
from .patterns import RE_PATTERN_ESCAPES from .patterns import RE_PATTERN_ESCAPES
from .patterns import Pattern from .patterns import Pattern
@ -80,8 +81,8 @@ PART_PATTERNS = {
'month' : r"(?:0[0-9]|1[0-2])", 'month' : r"(?:0[0-9]|1[0-2])",
'month_short': r"(?:1[0-2]|[1-9])", 'month_short': r"(?:1[0-2]|[1-9])",
'build_no' : r"\d{4,}", 'build_no' : r"\d{4,}",
'pep440_tag' : r"(?:a|b|dev|rc|post)?\d*", 'pep440_tag' : r"(?:post|dev|rc|a|b)?\d*",
'tag' : r"(?:alpha|beta|dev|rc|post|final)", 'tag' : r"(?:preview|final|alpha|beta|post|pre|dev|rc|a|b|c|r)",
'yy' : r"\d{2}", 'yy' : r"\d{2}",
'yyyy' : r"\d{4}", 'yyyy' : r"\d{4}",
'quarter' : r"[1-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})" named_part_pattern = f"(?P<{part_name}>{part_pattern})"
placeholder = "\u005c{" + part_name + "\u005c}" placeholder = "\u005c{" + part_name + "\u005c}"
pattern = pattern.replace(placeholder, named_part_pattern) pattern = pattern.replace(placeholder, named_part_pattern)
return 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) return re.compile(pattern_str)
@utils.memo
def compile_pattern(version_pattern: str, raw_pattern: typ.Optional[str] = None) -> Pattern: 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 _raw_pattern = version_pattern if raw_pattern is None else raw_pattern
regexp = _compile_pattern_re(version_pattern, _raw_pattern) regexp = _compile_pattern_re(version_pattern, _raw_pattern)

View file

@ -37,11 +37,11 @@ def rewrite_lines(
>>> rewrite_lines(patterns, new_vinfo, ['__version__ = "201809.2b0"']) >>> rewrite_lines(patterns, new_vinfo, ['__version__ = "201809.2b0"'])
['__version__ = "201811.123b0"'] ['__version__ = "201811.123b0"']
""" """
new_lines = old_lines[:] found_patterns: typ.Set[Pattern] = set()
found_patterns = set()
new_lines = old_lines[:]
for match in parse.iter_matches(old_lines, patterns): 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) replacement = v1version.format_version(new_vinfo, match.pattern.raw_pattern)
span_l, span_r = match.span span_l, span_r = match.span
new_line = match.line[:span_l] + replacement + match.line[span_r:] new_line = match.line[:span_l] + replacement + match.line[span_r:]

View file

@ -9,6 +9,8 @@ import typing as typ
import logging import logging
import datetime as dt import datetime as dt
import lexid
from . import version from . import version
from . import v1patterns from . import v1patterns
@ -18,15 +20,19 @@ logger = logging.getLogger("pycalver.v1version")
CalInfo = typ.Union[version.V1CalendarInfo, version.V1VersionInfo] CalInfo = typ.Union[version.V1CalendarInfo, version.V1VersionInfo]
def _is_later_than(old: CalInfo, new: CalInfo) -> bool: def _is_cal_gt(left: CalInfo, right: CalInfo) -> bool:
"""Is old > new based on non None fields.""" """Is left > right for non-None fields."""
lvals = []
rvals = []
for field in version.V1CalendarInfo._fields: for field in version.V1CalendarInfo._fields:
aval = getattr(old, field) lval = getattr(left , field)
bval = getattr(new, field) rval = getattr(right, field)
if not (aval is None or bval is None): if not (lval is None or rval is None):
if aval > bval: lvals.append(lval)
return True rvals.append(rval)
return False
return lvals > rvals
def _ver_to_cal_info(vnfo: version.V1VersionInfo) -> version.V1CalendarInfo: 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: if match is None:
err_msg = ( err_msg = (
f"Invalid version string '{version_str}' " 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) raise version.PatternError(err_msg)
else: else:
@ -386,19 +392,23 @@ def incr(
cur_cinfo = _ver_to_cal_info(old_vinfo) if pin_date else cal_info() cur_cinfo = _ver_to_cal_info(old_vinfo) if pin_date else cal_info()
if _is_later_than(old_vinfo, cur_cinfo): if _is_cal_gt(old_vinfo, cur_cinfo):
cur_vinfo = old_vinfo._replace(**cur_cinfo._asdict()) logger.warning(f"Old version appears to be from the future '{old_version}'")
else:
logger.warning(f"Version appears to be from the future '{old_version}'")
cur_vinfo = old_vinfo 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) new_version = format_version(cur_vinfo, raw_pattern)
if new_version == old_version: if new_version == old_version:
logger.error("Invalid arguments or pattern, version did not change.") logger.error("Invalid arguments or pattern, version did not change.")

View file

@ -27,7 +27,8 @@ def update_cfg_from_vcs(cfg: config.Config, all_tags: typ.List[str]) -> config.C
return cfg return cfg
version_tags.sort(reverse=True) 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_tag = version_tags[0]
latest_version_pep440 = version.to_pep440(latest_version_tag) latest_version_pep440 = version.to_pep440(latest_version_tag)
if latest_version_tag <= cfg.current_version: if latest_version_tag <= cfg.current_version:

View file

@ -35,6 +35,7 @@ import re
import typing as typ import typing as typ
import logging import logging
from . import utils
from .patterns import RE_PATTERN_ESCAPES from .patterns import RE_PATTERN_ESCAPES
from .patterns import Pattern from .patterns import Pattern
@ -84,8 +85,8 @@ PART_PATTERNS = {
'PATCH': r"[0-9]+", 'PATCH': r"[0-9]+",
'BUILD': r"[0-9]+", 'BUILD': r"[0-9]+",
'BLD' : r"[1-9][0-9]*", 'BLD' : r"[1-9][0-9]*",
'TAG' : r"(?:alpha|beta|dev|pre|rc|post|final)", 'TAG' : r"(?:preview|final|alpha|beta|post|pre|dev|rc|a|b|c|r)",
'PYTAG': r"(?:a|b|dev|rc|post)", 'PYTAG': r"(?:post|dev|rc|a|b)",
'NUM' : r"[0-9]+", '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: def _replace_pattern_parts(pattern: str) -> str:
# The pattern is escaped, so that everything besides the format # The pattern is escaped, so that everything besides the format
# string variables is treated literally. # string variables is treated literally.
if "[" in pattern and "]" in pattern: while True:
pattern = pattern.replace("[", "(?:") new_pattern, n = re.subn(r"([^\\]|^)\[", r"\1(?:", pattern)
pattern = pattern.replace("]", ")?") 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(): for part_name, part_pattern in PART_PATTERNS.items():
start_idx = pattern.find(part_name) start_idx = pattern.find(part_name)
if start_idx < 0: if start_idx >= 0:
continue field = PATTERN_PART_FIELDS[part_name]
named_part_pattern = f"(?P<{field}>{part_pattern})"
field = PATTERN_PART_FIELDS[part_name] end_idx = start_idx + len(part_name)
named_part_pattern = f"(?P<{field}>{part_pattern})" sort_key = (-end_idx, -len(part_name))
end_idx = start_idx + len(part_name) part_patterns_by_index[sort_key] = (start_idx, end_idx, named_part_pattern)
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: # NOTE (mb 2020-09-17): The sorting is done so that we process items:
# - right before left # - 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]: 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: for char, escaped in RE_PATTERN_ESCAPES:
# [] braces are used for optional parts, such as [-TAG]/[-beta] # [] 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: if not is_semantic_char:
# escape it so it is a literal in the re pattern # escape it so it is a literal in the re pattern
escaped_pattern = escaped_pattern.replace(char, escaped) 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) pattern_str = _replace_pattern_parts(escaped_pattern)
return re.compile(pattern_str) return re.compile(pattern_str)
@utils.memo
def compile_pattern(version_pattern: str, raw_pattern: typ.Optional[str] = None) -> Pattern: 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 _raw_pattern = version_pattern if raw_pattern is None else raw_pattern
regexp = _compile_pattern_re(version_pattern, _raw_pattern) regexp = _compile_pattern_re(version_pattern, _raw_pattern)

View file

@ -14,6 +14,7 @@ from . import config
from . import rewrite from . import rewrite
from . import version from . import version
from . import v2version from . import v2version
from . import v2patterns
from .patterns import Pattern from .patterns import Pattern
logger = logging.getLogger("pycalver.v2rewrite") logger = logging.getLogger("pycalver.v2rewrite")
@ -41,12 +42,15 @@ def rewrite_lines(
>>> rewrite_lines(patterns, new_vinfo, old_lines) >>> rewrite_lines(patterns, new_vinfo, old_lines)
['__version__ = "201811.123b0"'] ['__version__ = "201811.123b0"']
""" """
new_lines = old_lines[:] found_patterns: typ.Set[Pattern] = set()
found_patterns = set()
new_lines = old_lines[:]
for match in parse.iter_matches(old_lines, patterns): for match in parse.iter_matches(old_lines, patterns):
found_patterns.add(match.pattern.raw_pattern) found_patterns.add(match.pattern)
replacement = v2version.format_version(new_vinfo, match.pattern.raw_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 span_l, span_r = match.span
new_line = match.line[:span_l] + replacement + match.line[span_r:] new_line = match.line[:span_l] + replacement + match.line[span_r:]
new_lines[match.lineno] = new_line new_lines[match.lineno] = new_line
@ -93,6 +97,18 @@ def rfd_from_content(
return rewrite.RewrittenFileData(path, line_sep, old_lines, new_lines) 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( def iter_rewritten(
file_patterns: config.PatternsByFile, file_patterns: config.PatternsByFile,
new_vinfo : version.V2VersionInfo, new_vinfo : version.V2VersionInfo,
@ -159,13 +175,6 @@ def diff(
with file_path.open(mode="rt", encoding="utf-8") as fobj: with file_path.open(mode="rt", encoding="utf-8") as fobj:
content = fobj.read() 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: try:
rfd = rfd_from_content(patterns, new_vinfo, content) rfd = rfd_from_content(patterns, new_vinfo, content)
except rewrite.NoPatternMatch: except rewrite.NoPatternMatch:
@ -175,6 +184,8 @@ def diff(
rfd = rfd._replace(path=str(file_path)) rfd = rfd._replace(path=str(file_path))
lines = rewrite.diff_lines(rfd) 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: if len(lines) == 0 and patterns_with_change > 0:
errmsg = f"No patterns matched for '{file_path}'" errmsg = f"No patterns matched for '{file_path}'"
raise rewrite.NoPatternMatch(errmsg) raise rewrite.NoPatternMatch(errmsg)

View file

@ -9,6 +9,8 @@ import typing as typ
import logging import logging
import datetime as dt import datetime as dt
import lexid
from . import version from . import version
from . import v2patterns from . import v2patterns
@ -18,15 +20,19 @@ logger = logging.getLogger("pycalver.v2version")
CalInfo = typ.Union[version.V2CalendarInfo, version.V2VersionInfo] CalInfo = typ.Union[version.V2CalendarInfo, version.V2VersionInfo]
def _is_later_than(old: CalInfo, new: CalInfo) -> bool: def _is_cal_gt(left: CalInfo, right: CalInfo) -> bool:
"""Is old > new based on non None fields.""" """Is left > right for non-None fields."""
for field in version.V1CalendarInfo._fields:
aval = getattr(old, field) lvals = []
bval = getattr(new, field) rvals = []
if not (aval is None or bval is None): for field in version.V2CalendarInfo._fields:
if aval > bval: lval = getattr(left , field)
return True rval = getattr(right, field)
return False 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: 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]] 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. """Generate kwargs for template from minimal V2VersionInfo.
The V2VersionInfo Tuple only has the minimal representation 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'. representation '09' for '0M'.
>>> vinfo = parse_version_info("v200709.1033-beta", pattern="vYYYY0M.BUILD[-TAG]") >>> 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']) >>> (kwargs['YYYY'], kwargs['0M'], kwargs['BUILD'], kwargs['TAG'])
('2007', '09', '1033', 'beta') ('2007', '09', '1033', 'beta')
>>> (kwargs['YY'], kwargs['0Y'], kwargs['MM'], kwargs['PYTAG']) >>> (kwargs['YY'], kwargs['0Y'], kwargs['MM'], kwargs['PYTAG'])
('7', '07', '9', 'b') ('7', '07', '9', 'b')
>>> vinfo = parse_version_info("200709.1033b1", pattern="YYYY0M.BLD[PYTAGNUM]") >>> 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']) >>> (kwargs['YYYY'], kwargs['0M'], kwargs['BUILD'], kwargs['PYTAG'], kwargs['NUM'])
('2007', '09', '1033', 'b', '1') ('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] format_fn = v2patterns.PART_FORMATS[part]
kwargs[part] = format_fn(field_val) 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]: def _make_segments(raw_pattern: str) -> typ.List[str]:
@ -345,12 +352,63 @@ def _clear_zero_segments(
return non_zero_segs 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( def _format_segments(
vinfo : version.V2VersionInfo,
pattern_segs: typ.List[str], pattern_segs: typ.List[str],
part_values : PartValues,
) -> typ.List[str]: ) -> typ.List[str]:
kwargs = _format_part_values(vinfo) # NOTE (mb 2020-09-21): Old implementaion that doesn't cover corner
part_values = sorted(kwargs.items(), key=lambda item: -len(item[0])) # cases relating to escaped braces.
is_zero_segment = [True] * len(pattern_segs) is_zero_segment = [True] * len(pattern_segs)
@ -361,23 +419,25 @@ def _format_segments(
idx_r = len(pattern_segs) - 1 idx_r = len(pattern_segs) - 1
while idx_l <= idx_r: while idx_l <= idx_r:
# NOTE (mb 2020-09-18): All segments are optional, # NOTE (mb 2020-09-18): All segments are optional,
# except the most left and the most right, # except the most left and the most right.
# i.e the ones NOT surrounded by braces. # In other words the ones NOT surrounded by braces are
# Empty string is a valid segment. # required. Empty string is a valid segment.
is_optional = idx_l > 0 is_required_seg = idx_l == 0
seg_l = pattern_segs[idx_l] seg_l = pattern_segs[idx_l]
seg_r = pattern_segs[idx_r] seg_r = pattern_segs[idx_r]
for part, part_value in part_values: for part, part_value in part_values:
if part in seg_l: if part in seg_l:
seg_l = seg_l.replace(part, part_value) seg_l = seg_l.replace(part, part_value)
if not (is_optional and str(part_value) == version.ZERO_VALUES.get(part)): 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 is_zero_segment[idx_l] = False
if part in seg_r: if part in seg_r:
seg_r = seg_r.replace(part, part_value) seg_r = seg_r.replace(part, part_value)
if not (is_optional and str(part_value) == version.ZERO_VALUES[part]): 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 is_zero_segment[idx_r] = False
formatted_segs_l.append(seg_l) formatted_segs_l.append(seg_l)
@ -391,6 +451,43 @@ def _format_segments(
return _clear_zero_segments(formatted_segs, is_zero_segment) 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: def format_version(vinfo: version.V2VersionInfo, raw_pattern: str) -> str:
"""Generate version string. """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]]]]"') >>> format_version(vinfo_d, pattern='__version__ = "vMAJOR[.MINOR[.PATCH[-TAG[NUM]]]]"')
'__version__ = "v1.0.0-rc2"' '__version__ = "v1.0.0-rc2"'
""" """
pattern_segs = _make_segments(raw_pattern) part_values = _format_part_values(vinfo)
formatted_segs = _format_segments(vinfo, pattern_segs)
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( def incr(
@ -505,19 +607,30 @@ def incr(
cur_cinfo = _ver_to_cal_info(old_vinfo) if pin_date else cal_info() cur_cinfo = _ver_to_cal_info(old_vinfo) if pin_date else cal_info()
if _is_later_than(old_vinfo, cur_cinfo): if _is_cal_gt(old_vinfo, cur_cinfo):
logger.warning(f"Version appears to be from the future '{old_version}'") logger.warning(f"Old version appears to be from the future '{old_version}'")
cur_vinfo = old_vinfo cur_vinfo = old_vinfo
else: else:
cur_vinfo = old_vinfo._replace(**cur_cinfo._asdict()) cur_vinfo = old_vinfo._replace(**cur_cinfo._asdict())
cur_vinfo = version.incr_non_cal_parts( # prevent truncation of leading zeros
cur_vinfo, if int(cur_vinfo.bid) < 1000:
release, cur_vinfo = cur_vinfo._replace(bid=str(int(cur_vinfo.bid) + 1000))
major,
minor, cur_vinfo = cur_vinfo._replace(bid=lexid.next_id(cur_vinfo.bid))
patch,
) 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) new_version = format_version(cur_vinfo, raw_pattern)
if new_version == old_version: if new_version == old_version:
logger.error("Invalid arguments or pattern, version did not change.") logger.error("Invalid arguments or pattern, version did not change.")

View file

@ -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 typing as typ
import datetime as dt import datetime as dt
import lexid
import pkg_resources import pkg_resources
MaybeInt = typ.Optional[int] MaybeInt = typ.Optional[int]
@ -71,9 +75,6 @@ class V2VersionInfo(typ.NamedTuple):
pytag : str pytag : str
VersionInfoType = typ.TypeVar('VersionInfoType', V1VersionInfo, V2VersionInfo)
# The test suite may replace this. # The test suite may replace this.
TODAY = dt.datetime.utcnow().date() TODAY = dt.datetime.utcnow().date()
@ -81,7 +82,7 @@ TODAY = dt.datetime.utcnow().date()
TAG_BY_PEP440_TAG = { TAG_BY_PEP440_TAG = {
'a' : 'alpha', 'a' : 'alpha',
'b' : 'beta', 'b' : 'beta',
"" : 'final', '' : 'final',
'rc' : 'rc', 'rc' : 'rc',
'dev' : 'dev', 'dev' : 'dev',
'post': 'post', 'post': 'post',
@ -89,13 +90,19 @@ TAG_BY_PEP440_TAG = {
PEP440_TAG_BY_TAG = { PEP440_TAG_BY_TAG = {
'alpha': "a", 'a' : 'a',
'beta' : "b", 'b' : 'b',
'final': "", 'dev' : 'dev',
'pre' : "rc", 'alpha' : 'a',
'rc' : "rc", 'beta' : 'b',
'dev' : "dev", 'preview': 'rc',
'post' : "post", '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()) 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' '201811.7b0'
""" """
return str(pkg_resources.parse_version(version)) 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

View file

@ -218,8 +218,8 @@ def test_make_segments():
def test_v2_format_version(): def test_v2_format_version():
version_pattern = "vYYYY0M.BUILD[-TAG[NUM]]" version_pattern = "vYYYY0M.BUILD[-TAG[NUM]]"
in_version = "v200701.0033-beta" in_version = "v200701.0033-beta"
vinfo = v2version.parse_version_info(in_version, raw_pattern=version_pattern) vinfo = v2version.parse_version_info(in_version, raw_pattern=version_pattern)
out_version = v2version.format_version(vinfo, raw_pattern=version_pattern) out_version = v2version.format_version(vinfo, raw_pattern=version_pattern)