module reorg

This commit is contained in:
Manuel Barkhau 2020-09-19 22:35:48 +00:00
parent e1aaf7629b
commit 8af5047244
23 changed files with 1658 additions and 1532 deletions

View file

@ -16,14 +16,14 @@ import subprocess as sp
import click
import pycalver.cli as v1cli
import pycalver2.cli as v2cli
import pycalver.version as v1version
import pycalver2.version as v2version
import pycalver.rewrite as v1rewrite
from pycalver import vcs
from pycalver import config
from . import vcs
from . import v1cli
from . import v2cli
from . import config
from . import rewrite
from . import version
from . import v1version
from . import v2version
_VERBOSE = 0
@ -110,7 +110,7 @@ def test(
new_version = _incr(
old_version,
pattern=pattern,
raw_pattern=pattern,
release=release,
major=major,
minor=minor,
@ -121,9 +121,7 @@ def test(
logger.error(f"Invalid version '{old_version}' and/or pattern '{pattern}'.")
sys.exit(1)
# TODO (mb 2020-09-05): version switch
pep440_version = v1version.to_pep440(new_version)
# pep440_version = v2version.to_pep440(new_version)
pep440_version = version.to_pep440(new_version)
click.echo(f"New Version: {new_version}")
click.echo(f"PEP440 : {pep440_version}")
@ -150,7 +148,7 @@ def show(verbose: int = 0, fetch: bool = True) -> None:
click.echo(f"PEP440 : {cfg.pep440_version}")
def _print_diff(diff: str) -> None:
def _print_diff_str(diff: str) -> None:
if sys.stdout.isatty():
for line in diff.splitlines():
if line.startswith("+++") or line.startswith("---"):
@ -167,9 +165,27 @@ def _print_diff(diff: str) -> None:
click.echo(diff)
def _print_diff(cfg: config.Config, new_version: str) -> None:
try:
if cfg.is_new_pattern:
diff = v2cli.get_diff(cfg, new_version)
else:
diff = v1cli.get_diff(cfg, new_version)
_print_diff_str(diff)
except rewrite.NoPatternMatch as ex:
logger.error(str(ex))
sys.exit(1)
except Exception as ex:
# pylint:disable=broad-except; Mostly we expect IOError here, but
# could be other things and there's no option to recover anyway.
logger.error(str(ex))
sys.exit(1)
def _incr(
old_version: str,
pattern : str = "{pycalver}",
raw_pattern: str = "{pycalver}",
*,
release : str = None,
major : bool = False,
@ -177,11 +193,11 @@ def _incr(
patch : bool = False,
pin_date: bool = False,
) -> typ.Optional[str]:
is_v1_pattern = "{" in pattern
if is_v1_pattern:
return v1version.incr(
is_new_pattern = "{" in raw_pattern and "}" in raw_pattern
if is_new_pattern:
return v2version.incr(
old_version,
pattern=pattern,
raw_pattern=raw_pattern,
release=release,
major=major,
minor=minor,
@ -189,9 +205,9 @@ def _incr(
pin_date=pin_date,
)
else:
return v2version.incr(
return v1version.incr(
old_version,
pattern=pattern,
raw_pattern=raw_pattern,
release=release,
major=major,
minor=minor,
@ -221,10 +237,10 @@ def _bump(
try:
if cfg.is_new_pattern:
v2cli.rewrite(cfg, new_version)
v2cli.rewrite_files(cfg, new_version)
else:
v1cli.rewrite(cfg, new_version)
except v1rewrite.NoPatternMatch as ex:
v1cli.rewrite_files(cfg, new_version)
except rewrite.NoPatternMatch as ex:
logger.error(str(ex))
sys.exit(1)
except Exception as ex:
@ -266,11 +282,11 @@ def init(verbose: int = 0, dry: bool = False) -> None:
cfg: config.MaybeConfig = config.parse(ctx)
if cfg:
logger.error(f"Configuration already initialized in {ctx.config_filepath}")
logger.error(f"Configuration already initialized in {ctx.config_rel_path}")
sys.exit(1)
if dry:
click.echo(f"Exiting because of '--dry'. Would have written to {ctx.config_filepath}:")
click.echo(f"Exiting because of '--dry'. Would have written to {ctx.config_rel_path}:")
cfg_text: str = config.default_config(ctx)
click.echo("\n " + "\n ".join(cfg_text.splitlines()))
sys.exit(0)
@ -362,7 +378,7 @@ def bump(
old_version = cfg.current_version
new_version = _incr(
old_version,
pattern=cfg.version_pattern,
raw_pattern=cfg.version_pattern,
release=release,
major=major,
minor=minor,
@ -387,20 +403,7 @@ def bump(
logger.info(f"New Version: {new_version}")
if dry or verbose >= 2:
try:
if cfg.is_new_pattern:
diff = v2cli.get_diff(cfg, new_version)
else:
diff = v1cli.get_diff(cfg, new_version)
_print_diff(diff)
except v1rewrite.NoPatternMatch as ex:
logger.error(str(ex))
sys.exit(1)
except Exception as ex:
# pylint:disable=broad-except; Mostly we expect IOError here, but
# could be other things and there's no option to recover anyway.
logger.error(str(ex))
sys.exit(1)
_print_diff(cfg, new_version)
if dry:
return
@ -408,8 +411,8 @@ def bump(
commit_message_kwargs = {
'new_version' : new_version,
'old_version' : old_version,
'new_version_pep440': v1version.to_pep440(new_version),
'old_version_pep440': v1version.to_pep440(old_version),
'new_version_pep440': version.to_pep440(new_version),
'old_version_pep440': version.to_pep440(old_version),
}
commit_message = cfg.commit_message.format(**commit_message_kwargs)

View file

@ -14,13 +14,22 @@ import configparser
import toml
import pathlib2 as pl
import pycalver.version as v1version
import pycalver2.version as v2version
from . import version
from . import v1version
from . import v2version
from . import v1patterns
from . import v2patterns
from .patterns import Pattern
logger = logging.getLogger("pycalver.config")
Patterns = typ.List[str]
PatternsByGlob = typ.Dict[str, Patterns]
RawPatterns = typ.List[str]
RawPatternsByFile = typ.Dict[str, RawPatterns]
FileRawPatternsItem = typ.Tuple[str, RawPatterns]
PatternsByFile = typ.Dict[str, typ.List[Pattern]]
FilePatternsItem = typ.Tuple[str, typ.List[Pattern]]
SUPPORTED_CONFIGS = ["setup.cfg", "pyproject.toml", "pycalver.toml"]
@ -32,6 +41,7 @@ class ProjectContext(typ.NamedTuple):
path : pl.Path
config_filepath: pl.Path
config_rel_path: str
config_format : str
vcs_type : typ.Optional[str]
@ -60,6 +70,12 @@ def init_project_ctx(project_path: typ.Union[str, pl.Path, None] = ".") -> Proje
config_filepath = path / "pycalver.toml"
config_format = 'toml'
if config_filepath.is_absolute():
config_rel_path = str(config_filepath.relative_to(path.absolute()))
else:
config_rel_path = str(config_filepath)
config_filepath = pl.Path.cwd() / config_filepath
vcs_type: typ.Optional[str]
if (path / ".git").exists():
@ -69,10 +85,11 @@ def init_project_ctx(project_path: typ.Union[str, pl.Path, None] = ".") -> Proje
else:
vcs_type = None
return ProjectContext(path, config_filepath, config_format, vcs_type)
return ProjectContext(path, config_filepath, config_rel_path, config_format, vcs_type)
RawConfig = typ.Dict[str, typ.Any]
RawConfig = typ.Dict[str, typ.Any]
MaybeRawConfig = typ.Optional[RawConfig]
class Config(typ.NamedTuple):
@ -88,56 +105,46 @@ class Config(typ.NamedTuple):
push : bool
is_new_pattern: bool
file_patterns: PatternsByGlob
file_patterns: PatternsByFile
MaybeConfig = typ.Optional[Config]
def _debug_str(cfg: Config) -> str:
cfg_str_parts = [
"Config Parsed: Config(",
f"current_version='{cfg.current_version}'",
f"version_pattern='{cfg.version_pattern}'",
f"pep440_version='{cfg.pep440_version}'",
f"commit_message='{cfg.commit_message}'",
f"commit={cfg.commit}",
f"tag={cfg.tag}",
f"push={cfg.push}",
f"is_new_pattern={cfg.is_new_pattern}",
"file_patterns={",
f"\n current_version='{cfg.current_version}',",
f"\n version_pattern='{cfg.version_pattern}',",
f"\n pep440_version='{cfg.pep440_version}',",
f"\n commit_message='{cfg.commit_message}',",
f"\n commit={cfg.commit},",
f"\n tag={cfg.tag},",
f"\n push={cfg.push},",
f"\n is_new_pattern={cfg.is_new_pattern},",
"\n file_patterns={",
]
for filepath, patterns in cfg.file_patterns.items():
for pattern in patterns:
cfg_str_parts.append(f"\n '{filepath}': '{pattern}'")
cfg_str_parts.append(f"\n '{filepath}': '{pattern.raw_pattern}',")
cfg_str_parts += ["\n})"]
return ", ".join(cfg_str_parts)
cfg_str_parts += ["\n }\n)"]
return "".join(cfg_str_parts)
MaybeConfig = typ.Optional[Config]
MaybeRawConfig = typ.Optional[RawConfig]
def _parse_cfg_file_patterns(
cfg_parser: configparser.RawConfigParser,
) -> typ.Iterable[FileRawPatternsItem]:
if not cfg_parser.has_section("pycalver:file_patterns"):
return
FilePatterns = typ.Dict[str, typ.List[str]]
def _parse_cfg_file_patterns(cfg_parser: configparser.RawConfigParser) -> FilePatterns:
file_patterns: FilePatterns = {}
file_pattern_items: typ.List[typ.Tuple[str, str]]
if cfg_parser.has_section("pycalver:file_patterns"):
file_pattern_items = cfg_parser.items("pycalver:file_patterns")
else:
file_pattern_items = []
file_pattern_items: typ.List[typ.Tuple[str, str]] = cfg_parser.items("pycalver:file_patterns")
for filepath, patterns_str in file_pattern_items:
patterns: typ.List[str] = []
for line in patterns_str.splitlines():
pattern = line.strip()
if pattern:
patterns.append(pattern)
file_patterns[filepath] = patterns
return file_patterns
maybe_patterns = (line.strip() for line in patterns_str.splitlines())
patterns = [p for p in maybe_patterns if p]
yield filepath, patterns
class _ConfigParser(configparser.RawConfigParser):
@ -178,7 +185,7 @@ def _parse_cfg(cfg_buffer: typ.IO[str]) -> RawConfig:
val = val.lower() in ("yes", "true", "1", "on")
raw_cfg[option] = val
raw_cfg['file_patterns'] = _parse_cfg_file_patterns(cfg_parser)
raw_cfg['file_patterns'] = dict(_parse_cfg_file_patterns(cfg_parser))
return raw_cfg
@ -193,64 +200,66 @@ def _parse_toml(cfg_buffer: typ.IO[str]) -> RawConfig:
return raw_cfg
def _normalize_file_patterns(raw_cfg: RawConfig) -> FilePatterns:
"""Create consistent representation of file_patterns.
def _iter_glob_expanded_file_patterns(
raw_patterns_by_file: RawPatternsByFile,
) -> typ.Iterable[FileRawPatternsItem]:
for filepath_glob, raw_patterns in raw_patterns_by_file.items():
filepaths = glob.glob(filepath_glob)
if filepaths:
for filepath in filepaths:
yield filepath, raw_patterns
else:
logger.warning(f"Invalid config, no such file: {filepath_glob}")
# fallback to treating it as a simple path
yield filepath_glob, raw_patterns
def _compile_v1_file_patterns(raw_cfg: RawConfig) -> typ.Iterable[FilePatternsItem]:
"""Create inernal/compiled representation of the file_patterns config field.
The result the same, regardless of the config format.
"""
version_str : str = raw_cfg['current_version']
version_pattern: str = raw_cfg['version_pattern']
pep440_version : str = v1version.to_pep440(version_str)
# current_version: str = raw_cfg['current_version']
# current_pep440_version = version.pep440_version(current_version)
file_patterns: FilePatterns
if 'file_patterns' in raw_cfg:
file_patterns = raw_cfg['file_patterns']
else:
file_patterns = {}
version_pattern : str = raw_cfg['version_pattern']
raw_patterns_by_file: RawPatternsByFile = raw_cfg['file_patterns']
for filepath_glob, patterns in list(file_patterns.items()):
filepaths = glob.glob(filepath_glob)
if not filepaths:
logger.warning(f"Invalid config, no such file: {filepath_glob}")
# fallback to treating it as a simple path
filepaths = [filepath_glob]
for filepath, raw_patterns in _iter_glob_expanded_file_patterns(raw_patterns_by_file):
compiled_patterns = [
v1patterns.compile_pattern(version_pattern, raw_pattern) for raw_pattern in raw_patterns
]
yield filepath, compiled_patterns
normalized_patterns: typ.List[str] = []
for pattern in patterns:
normalized_pattern = pattern.replace("{version}", version_pattern)
if version_pattern == "{pycalver}":
normalized_pattern = normalized_pattern.replace(
"{pep440_version}", "{pep440_pycalver}"
)
elif version_pattern == "{semver}":
normalized_pattern = normalized_pattern.replace("{pep440_version}", "{semver}")
elif "{pep440_version}" in pattern:
logger.warning(f"Invalid config, cannot match '{pattern}' for '{filepath_glob}'.")
logger.warning(f"No mapping of '{version_pattern}' to '{pep440_version}'")
normalized_patterns.append(normalized_pattern)
for filepath in filepaths:
file_patterns[filepath] = normalized_patterns
def _compile_v2_file_patterns(raw_cfg: RawConfig) -> typ.Iterable[FilePatternsItem]:
"""Create inernal/compiled representation of the file_patterns config field.
return file_patterns
The result the same, regardless of the config format.
"""
version_pattern : str = raw_cfg['version_pattern']
raw_patterns_by_file: RawPatternsByFile = raw_cfg['file_patterns']
for filepath, raw_patterns in _iter_glob_expanded_file_patterns(raw_patterns_by_file):
compiled_patterns = [
v2patterns.compile_pattern(version_pattern, raw_pattern) for raw_pattern in raw_patterns
]
yield filepath, compiled_patterns
def _parse_config(raw_cfg: RawConfig) -> Config:
"""Parse configuration which was loaded from an .ini/.cfg or .toml file."""
if 'current_version' not in raw_cfg:
raise ValueError("Missing 'pycalver.current_version'")
current_version: str = raw_cfg['current_version']
current_version = raw_cfg['current_version'] = current_version.strip("'\" ")
version_str: str = raw_cfg['current_version']
version_str = raw_cfg['current_version'] = version_str.strip("'\" ")
version_pattern: str = raw_cfg.get('version_pattern', "{pycalver}")
version_pattern: str = raw_cfg['version_pattern']
version_pattern = raw_cfg['version_pattern'] = version_pattern.strip("'\" ")
commit_message: str = raw_cfg.get('commit_message', DEFAULT_COMMIT_MESSAGE)
commit_message = raw_cfg['commit_message'] = commit_message.strip("'\" ")
is_new_pattern = not ("{" in version_pattern or "}" in version_pattern)
is_new_pattern = "{" not in version_pattern and "}" not in version_pattern
# TODO (mb 2020-09-18): Validate Pattern
# detect YY with WW or UU -> suggest GG with VV
@ -260,11 +269,11 @@ def _parse_config(raw_cfg: RawConfig) -> Config:
# NOTE (mb 2019-01-05): Provoke ValueError if version_pattern
# and current_version are not compatible.
if is_new_pattern:
v2version.parse_version_info(version_str, version_pattern)
v2version.parse_version_info(current_version, version_pattern)
else:
v1version.parse_version_info(version_str, version_pattern)
v1version.parse_version_info(current_version, version_pattern)
pep440_version = v1version.to_pep440(version_str)
pep440_version = version.to_pep440(current_version)
commit = raw_cfg['commit']
tag = raw_cfg['tag']
@ -281,10 +290,13 @@ def _parse_config(raw_cfg: RawConfig) -> Config:
if push and not commit:
raise ValueError("pycalver.commit = true required if pycalver.push = true")
file_patterns = _normalize_file_patterns(raw_cfg)
if is_new_pattern:
file_patterns = dict(_compile_v2_file_patterns(raw_cfg))
else:
file_patterns = dict(_compile_v1_file_patterns(raw_cfg))
cfg = Config(
current_version=version_str,
current_version=current_version,
version_pattern=version_pattern,
pep440_version=pep440_version,
commit_message=commit_message,
@ -298,11 +310,18 @@ def _parse_config(raw_cfg: RawConfig) -> Config:
return cfg
def _parse_current_version_default_pattern(cfg: Config, raw_cfg_text: str) -> str:
def _parse_current_version_default_pattern(ctx: ProjectContext, raw_cfg: RawConfig) -> str:
fobj: typ.IO[str]
with ctx.config_filepath.open(mode="rt", encoding="utf-8") as fobj:
raw_cfg_text = fobj.read()
is_pycalver_section = False
for line in raw_cfg_text.splitlines():
if is_pycalver_section and line.startswith("current_version"):
return line.replace(cfg.current_version, cfg.version_pattern)
current_version: str = raw_cfg['current_version']
version_pattern: str = raw_cfg['version_pattern']
return line.replace(current_version, version_pattern)
if line.strip() == "[pycalver]":
is_pycalver_section = True
@ -312,44 +331,56 @@ def _parse_current_version_default_pattern(cfg: Config, raw_cfg_text: str) -> st
raise ValueError("Could not parse pycalver.current_version")
def _parse_raw_config(ctx: ProjectContext) -> RawConfig:
with ctx.config_filepath.open(mode="rt", encoding="utf-8") as fobj:
if ctx.config_format == 'toml':
raw_cfg = _parse_toml(fobj)
elif ctx.config_format == 'cfg':
raw_cfg = _parse_cfg(fobj)
else:
err_msg = (
f"Invalid config_format='{ctx.config_format}'."
"Supported formats are 'setup.cfg' and 'pyproject.toml'"
)
raise RuntimeError(err_msg)
if 'current_version' in raw_cfg:
if not isinstance(raw_cfg['current_version'], str):
err = f"Invalid type for pycalver.current_version = {raw_cfg['current_version']}"
raise TypeError(err)
else:
raise ValueError("Missing 'pycalver.current_version'")
if 'version_pattern' in raw_cfg:
if not isinstance(raw_cfg['version_pattern'], str):
err = f"Invalid type for pycalver.version_pattern = {raw_cfg['version_pattern']}"
raise TypeError(err)
else:
raw_cfg['version_pattern'] = "{pycalver}"
if 'file_patterns' not in raw_cfg:
raw_cfg['file_patterns'] = {}
if ctx.config_rel_path not in raw_cfg['file_patterns']:
# NOTE (mb 2020-09-19): By default we always add
# a pattern for the config section itself.
raw_version_pattern = _parse_current_version_default_pattern(ctx, raw_cfg)
raw_cfg['file_patterns'][ctx.config_rel_path] = [raw_version_pattern]
return raw_cfg
def parse(ctx: ProjectContext) -> MaybeConfig:
"""Parse config file if available."""
if not ctx.config_filepath.exists():
logger.warning(f"File not found: {ctx.config_filepath}")
logger.warning(f"File not found: {ctx.config_rel_path}")
return None
fobj: typ.IO[str]
cfg_path: str
if ctx.config_filepath.is_absolute():
cfg_path = str(ctx.config_filepath.relative_to(ctx.path.absolute()))
else:
cfg_path = str(ctx.config_filepath)
raw_cfg: RawConfig
try:
with ctx.config_filepath.open(mode="rt", encoding="utf-8") as fobj:
if ctx.config_format == 'toml':
raw_cfg = _parse_toml(fobj)
elif ctx.config_format == 'cfg':
raw_cfg = _parse_cfg(fobj)
else:
err_msg = "Invalid config_format='{ctx.config_format}'"
raise RuntimeError(err_msg)
cfg: Config = _parse_config(raw_cfg)
if cfg_path not in cfg.file_patterns:
fobj.seek(0)
raw_cfg_text = fobj.read()
cfg.file_patterns[cfg_path] = [
_parse_current_version_default_pattern(cfg, raw_cfg_text)
]
return cfg
except ValueError as ex:
logger.warning(f"Couldn't parse {cfg_path}: {str(ex)}")
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
@ -445,11 +476,11 @@ DEFAULT_TOML_README_MD_STR = """
def _initial_version() -> str:
return dt.datetime.now().strftime("v%Y%m.0001-alpha")
return dt.datetime.now().strftime("v%Y%m.1001-alpha")
def _initial_version_pep440() -> str:
return dt.datetime.now().strftime("%Y%m.1a0")
return dt.datetime.now().strftime("%Y%m.1001a0")
def default_config(ctx: ProjectContext) -> str:
@ -506,4 +537,4 @@ def write_content(ctx: ProjectContext) -> None:
with ctx.config_filepath.open(mode="at", encoding="utf-8") as fobj:
fobj.write(cfg_content)
print(f"Updated {ctx.config_filepath}")
print(f"Updated {ctx.config_rel_path}")

View file

@ -7,7 +7,7 @@
import typing as typ
import pycalver.patterns as v1patterns
from .patterns import Pattern
class PatternMatch(typ.NamedTuple):
@ -15,7 +15,7 @@ class PatternMatch(typ.NamedTuple):
lineno : int # zero based
line : str
pattern: v1patterns.Pattern
pattern: Pattern
span : typ.Tuple[int, int]
match : str
@ -23,25 +23,26 @@ class PatternMatch(typ.NamedTuple):
PatternMatches = typ.Iterable[PatternMatch]
def _iter_for_pattern(lines: typ.List[str], pattern: v1patterns.Pattern) -> PatternMatches:
def _iter_for_pattern(lines: typ.List[str], pattern: Pattern) -> PatternMatches:
for lineno, line in enumerate(lines):
match = pattern.regexp.search(line)
if match:
yield PatternMatch(lineno, line, pattern, match.span(), match.group(0))
def iter_matches(lines: typ.List[str], patterns: typ.List[v1patterns.Pattern]) -> PatternMatches:
def iter_matches(lines: typ.List[str], patterns: typ.List[Pattern]) -> PatternMatches:
"""Iterate over all matches of any pattern on any line.
>>> import pycalver.patterns as v1patterns
>>> from . import v1patterns
>>> lines = ["__version__ = 'v201712.0002-alpha'"]
>>> patterns = ["{pycalver}", "{pep440_pycalver}"]
>>> patterns = [v1patterns.compile_pattern(p) for p in patterns]
>>> version_pattern = "{pycalver}"
>>> raw_patterns = ["{pycalver}", "{pep440_pycalver}"]
>>> patterns = [v1patterns.compile_pattern(version_pattern, p) for p in raw_patterns]
>>> matches = list(iter_matches(lines, patterns))
>>> assert matches[0] == PatternMatch(
... lineno = 0,
... line = "__version__ = 'v201712.0002-alpha'",
... pattern= v1patterns.compile_pattern("{pycalver}"),
... pattern= v1patterns.compile_pattern(version_pattern),
... span = (15, 33),
... match = "v201712.0002-alpha",
... )

View file

@ -1,62 +1,14 @@
# 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<pycalver>
(?P<vYYYYMM>
v # "v" version prefix
(?P<year>\d{4})
(?P<month>\d{2})
)
(?P<build>
\. # "." build nr prefix
(?P<build_no>\d{4,})
)
(?P<release>
\- # "-" release prefix
(?P<release_tag>alpha|beta|dev|rc|post)
)?
)(?:\s|$)
"""
PYCALVER_RE: typ.Pattern[str] = re.compile(PYCALVER_PATTERN, flags=re.VERBOSE)
class Pattern(typ.NamedTuple):
version_pattern: str # "{pycalver}", "{year}.{month}", "vYYYY0M.BUILD"
raw_pattern : str # '__version__ = "{version}"', "Copyright (c) YYYY"
regexp : typ.Pattern[str]
PATTERN_ESCAPES = [
RE_PATTERN_ESCAPES = [
("\u005c", "\u005c\u005c"),
("-" , "\u005c-"),
("." , "\u005c."),
@ -70,158 +22,3 @@ PATTERN_ESCAPES = [
("(" , "\u005c("),
(")" , "\u005c)"),
]
COMPOSITE_PART_PATTERNS = {
'pep440_pycalver': r"{year}{month}\.{BID}(?:{pep440_tag})?",
'pycalver' : r"v{year}{month}\.{bid}(?:-{tag})?",
'calver' : r"v{year}{month}",
'semver' : r"{MAJOR}\.{MINOR}\.{PATCH}",
'release_tag' : r"{tag}",
'build' : r"\.{bid}",
'release' : r"(?:-{tag})?",
# depricated
'pep440_version': r"{year}{month}\.{BID}(?:{pep440_tag})?",
}
PART_PATTERNS = {
'year' : r"\d{4}",
'month' : r"(?:0[0-9]|1[0-2])",
'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])",
'MAJOR' : r"\d+",
'MINOR' : r"\d+",
'MM' : r"\d{2,}",
'MMM' : r"\d{3,}",
'MMMM' : r"\d{4,}",
'MMMMM' : r"\d{5,}",
'PATCH' : r"\d+",
'PP' : r"\d{2,}",
'PPP' : r"\d{3,}",
'PPPP' : r"\d{4,}",
'PPPPP' : r"\d{5,}",
'bid' : r"\d{4,}",
'BID' : r"[1-9]\d*",
'BB' : r"[1-9]\d{1,}",
'BBB' : r"[1-9]\d{2,}",
'BBBB' : r"[1-9]\d{3,}",
'BBBBB' : r"[1-9]\d{4,}",
'BBBBBB' : r"[1-9]\d{5,}",
'BBBBBBB' : r"[1-9]\d{6,}",
}
PATTERN_PART_FIELDS = {
'year' : 'year',
'month' : 'month',
'month_short': 'month',
'pep440_tag' : 'tag',
'tag' : 'tag',
'yy' : 'year',
'yyyy' : 'year',
'quarter' : 'quarter',
'iso_week' : 'iso_week',
'us_week' : 'us_week',
'dom' : 'dom',
'doy' : 'doy',
'dom_short' : 'dom',
'doy_short' : 'doy',
'MAJOR' : 'major',
'MINOR' : 'minor',
'MM' : 'minor',
'MMM' : 'minor',
'MMMM' : 'minor',
'MMMMM' : 'minor',
'PP' : 'patch',
'PPP' : 'patch',
'PPPP' : 'patch',
'PPPPP' : 'patch',
'PATCH' : 'patch',
'build_no' : 'bid',
'bid' : 'bid',
'BID' : 'bid',
'BB' : 'bid',
'BBB' : 'bid',
'BBBB' : 'bid',
'BBBBB' : 'bid',
'BBBBBB' : 'bid',
'BBBBBBB' : 'bid',
}
FULL_PART_FORMATS = {
'pep440_pycalver': "{year}{month:02}.{BID}{pep440_tag}",
'pycalver' : "v{year}{month:02}.{bid}{release}",
'calver' : "v{year}{month:02}",
'semver' : "{MAJOR}.{MINOR}.{PATCH}",
'release_tag' : "{tag}",
'build' : ".{bid}",
# NOTE (mb 2019-01-04): since release is optional, it
# is treates specially in version.format
# 'release' : "-{tag}",
'month' : "{month:02}",
'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}",
}
class Pattern(typ.NamedTuple):
raw : str # "{pycalver}", "{year}.{month}", "YYYY0M.BUILD"
regexp: typ.Pattern[str]
Patterns = typ.List[typ.Pattern[str]]
def _replace_pattern_parts(pattern: str) -> str:
# The pattern is escaped, so that everything besides the format
# string variables is treated literally.
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) -> Pattern:
pattern_str = compile_pattern_str(pattern)
pattern_re = re.compile(pattern_str)
return Pattern(pattern, pattern_re)
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()

View file

@ -1,24 +1,22 @@
# 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 glob
import typing as typ
import difflib
import logging
import pathlib2 as pl
import pycalver.version as v1version
import pycalver.patterns as v1patterns
from pycalver import parse
from pycalver import config
from . import config
from .patterns import Pattern
logger = logging.getLogger("pycalver.rewrite")
class NoPatternMatch(Exception):
"""Pattern not found in content.
logger.error is used to show error info about the patterns so
that users can debug what is wrong with them. The class
itself doesn't capture that info. This approach is used so
that all patter issues can be shown, rather than bubbling
all the way up the stack on the very first pattern with no
matches.
"""
def detect_line_sep(content: str) -> str:
@ -41,18 +39,6 @@ def detect_line_sep(content: str) -> str:
return "\n"
class NoPatternMatch(Exception):
"""Pattern not found in content.
logger.error is used to show error info about the patterns so
that users can debug what is wrong with them. The class
itself doesn't capture that info. This approach is used so
that all patter issues can be shown, rather than bubbling
all the way up the stack on the very first pattern with no
matches.
"""
class RewrittenFileData(typ.NamedTuple):
"""Container for line-wise content of rewritten files."""
@ -62,117 +48,19 @@ class RewrittenFileData(typ.NamedTuple):
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}'"
PathPatternsItem = typ.Tuple[pl.Path, typ.List[Pattern]]
def iter_path_patterns_items(
file_patterns: config.PatternsByFile,
) -> typ.Iterable[PathPatternsItem]:
for filepath_str, patterns in file_patterns.items():
filepath_obj = pl.Path(filepath_str)
if filepath_obj.exists():
yield (filepath_obj, patterns)
else:
errmsg = f"File does not exist: '{filepath_str}'"
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 : v1version.VersionInfo,
old_lines : typ.List[str],
) -> typ.List[str]:
"""Replace occurances of pattern_strs in old_lines with new_vinfo.
>>> new_vinfo = v1version.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()
patterns = [v1patterns.compile_pattern(p) for p in pattern_strs]
matches = parse.iter_matches(old_lines, patterns)
for match in matches:
found_patterns.add(match.pattern.raw)
replacement = v1version.format_version(new_vinfo, match.pattern.raw)
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 = v1patterns.compile_pattern_str(non_matched_pattern)
logger.error(f"Pattern compiles to regex '{compiled_pattern_str}'")
raise NoPatternMatch("Invalid pattern(s)")
else:
return new_lines
def rfd_from_content(
pattern_strs: typ.List[str],
new_vinfo : v1version.VersionInfo,
content : str,
) -> RewrittenFileData:
r"""Rewrite pattern occurrences with version string.
>>> new_vinfo = v1version.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 = v1version.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 = detect_line_sep(content)
old_lines = content.split(line_sep)
new_lines = rewrite_lines(pattern_strs, new_vinfo, old_lines)
return RewrittenFileData("<path>", line_sep, old_lines, new_lines)
def iter_rewritten(
file_patterns: config.PatternsByGlob,
new_vinfo : v1version.VersionInfo,
) -> typ.Iterable[RewrittenFileData]:
r'''Iterate over files with version string replaced.
>>> file_patterns = {"src/pycalver/__init__.py": ['__version__ = "{pycalver}"']}
>>> new_vinfo = v1version.parse_version_info("v201809.0123")
>>> rewritten_datas = iter_rewritten(file_patterns, new_vinfo)
>>> rfd = list(rewritten_datas)[0]
>>> expected = [
... '# 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__ = "v201809.0123"',
... '',
... ]
>>> assert rfd.new_lines[:len(expected)] == expected
'''
fobj: typ.IO[str]
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()
rfd = rfd_from_content(pattern_strs, new_vinfo, content)
yield rfd._replace(path=str(file_path))
def diff_lines(rfd: RewrittenFileData) -> typ.List[str]:
@ -188,57 +76,10 @@ def diff_lines(rfd: RewrittenFileData) -> typ.List[str]:
['--- <path>', '+++ <path>', '@@ -1 +1 @@', '-foo', '+bar']
"""
lines = difflib.unified_diff(
a=rfd.old_lines, b=rfd.new_lines, lineterm="", fromfile=rfd.path, tofile=rfd.path
a=rfd.old_lines,
b=rfd.new_lines,
lineterm="",
fromfile=rfd.path,
tofile=rfd.path,
)
return list(lines)
def diff(new_vinfo: v1version.VersionInfo, file_patterns: config.PatternsByGlob) -> str:
r"""Generate diffs of rewritten files.
>>> new_vinfo = v1version.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(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 NoPatternMatch:
# pylint:disable=raise-missing-from ; we support py2, so not an option
errmsg = f"No patterns matched for '{file_path}'"
raise NoPatternMatch(errmsg)
rfd = rfd._replace(path=str(file_path))
lines = diff_lines(rfd)
if len(lines) == 0:
errmsg = f"No patterns matched for '{file_path}'"
raise 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: v1version.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)

View file

@ -12,11 +12,12 @@ Provided subcommands: show, test, init, bump
import typing as typ
import logging
import pycalver.rewrite as v1rewrite
import pycalver.version as v1version
from pycalver import config
from . import config
from . import version
from . import v1rewrite
from . import v1version
logger = logging.getLogger("pycalver.cli")
logger = logging.getLogger("pycalver.v1cli")
def update_cfg_from_vcs(cfg: config.Config, all_tags: typ.List[str]) -> config.Config:
@ -28,7 +29,7 @@ def update_cfg_from_vcs(cfg: config.Config, all_tags: typ.List[str]) -> config.C
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 = v1version.to_pep440(latest_version_tag)
latest_version_pep440 = version.to_pep440(latest_version_tag)
if latest_version_tag <= cfg.current_version:
return cfg
@ -40,14 +41,15 @@ def update_cfg_from_vcs(cfg: config.Config, all_tags: typ.List[str]) -> config.C
)
def rewrite(
def rewrite_files(
cfg : config.Config,
new_version: str,
) -> None:
new_vinfo = v1version.parse_version_info(new_version, cfg.version_pattern)
v1rewrite.rewrite(cfg.file_patterns, new_vinfo)
v1rewrite.rewrite_files(cfg.file_patterns, new_vinfo)
def get_diff(cfg: config.Config, new_version: str) -> str:
new_vinfo = v1version.parse_version_info(new_version, cfg.version_pattern)
return v1rewrite.diff(new_vinfo, cfg.file_patterns)
old_vinfo = v1version.parse_version_info(cfg.current_version, cfg.version_pattern)
new_vinfo = v1version.parse_version_info(new_version , cfg.version_pattern)
return v1rewrite.diff(old_vinfo, new_vinfo, cfg.file_patterns)

220
src/pycalver/v1patterns.py Normal file
View file

@ -0,0 +1,220 @@
# 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
import logging
from .patterns import RE_PATTERN_ESCAPES
from .patterns import Pattern
logger = logging.getLogger("pycalver.v1patterns")
# https://regex101.com/r/fnj60p/10
PYCALVER_PATTERN = r"""
\b
(?P<pycalver>
(?P<vYYYYMM>
v # "v" version prefix
(?P<year>\d{4})
(?P<month>\d{2})
)
(?P<build>
\. # "." build nr prefix
(?P<build_no>\d{4,})
)
(?P<release>
\- # "-" release prefix
(?P<release_tag>alpha|beta|dev|rc|post)
)?
)(?:\s|$)
"""
PYCALVER_RE: typ.Pattern[str] = re.compile(PYCALVER_PATTERN, flags=re.VERBOSE)
COMPOSITE_PART_PATTERNS = {
'pep440_pycalver': r"{year}{month}\.{BID}(?:{pep440_tag})?",
'pycalver' : r"v{year}{month}\.{bid}(?:-{tag})?",
'calver' : r"v{year}{month}",
'semver' : r"{MAJOR}\.{MINOR}\.{PATCH}",
'release_tag' : r"{tag}",
'build' : r"\.{bid}",
'release' : r"(?:-{tag})?",
# depricated
'pep440_version': r"{year}{month}\.{BID}(?:{pep440_tag})?",
}
PART_PATTERNS = {
'year' : r"\d{4}",
'month' : r"(?:0[0-9]|1[0-2])",
'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])",
'MAJOR' : r"\d+",
'MINOR' : r"\d+",
'MM' : r"\d{2,}",
'MMM' : r"\d{3,}",
'MMMM' : r"\d{4,}",
'MMMMM' : r"\d{5,}",
'PATCH' : r"\d+",
'PP' : r"\d{2,}",
'PPP' : r"\d{3,}",
'PPPP' : r"\d{4,}",
'PPPPP' : r"\d{5,}",
'bid' : r"\d{4,}",
'BID' : r"[1-9]\d*",
'BB' : r"[1-9]\d{1,}",
'BBB' : r"[1-9]\d{2,}",
'BBBB' : r"[1-9]\d{3,}",
'BBBBB' : r"[1-9]\d{4,}",
'BBBBBB' : r"[1-9]\d{5,}",
'BBBBBBB' : r"[1-9]\d{6,}",
}
PATTERN_PART_FIELDS = {
'year' : 'year',
'month' : 'month',
'month_short': 'month',
'pep440_tag' : 'tag',
'tag' : 'tag',
'yy' : 'year',
'yyyy' : 'year',
'quarter' : 'quarter',
'iso_week' : 'iso_week',
'us_week' : 'us_week',
'dom' : 'dom',
'doy' : 'doy',
'dom_short' : 'dom',
'doy_short' : 'doy',
'MAJOR' : 'major',
'MINOR' : 'minor',
'MM' : 'minor',
'MMM' : 'minor',
'MMMM' : 'minor',
'MMMMM' : 'minor',
'PP' : 'patch',
'PPP' : 'patch',
'PPPP' : 'patch',
'PPPPP' : 'patch',
'PATCH' : 'patch',
'build_no' : 'bid',
'bid' : 'bid',
'BID' : 'bid',
'BB' : 'bid',
'BBB' : 'bid',
'BBBB' : 'bid',
'BBBBB' : 'bid',
'BBBBBB' : 'bid',
'BBBBBBB' : 'bid',
}
FULL_PART_FORMATS = {
'pep440_pycalver': "{year}{month:02}.{BID}{pep440_tag}",
'pycalver' : "v{year}{month:02}.{bid}{release}",
'calver' : "v{year}{month:02}",
'semver' : "{MAJOR}.{MINOR}.{PATCH}",
'release_tag' : "{tag}",
'build' : ".{bid}",
# NOTE (mb 2019-01-04): since release is optional, it
# is treates specially in version.format
# 'release' : "-{tag}",
'month' : "{month:02}",
'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:
# The pattern is escaped, so that everything besides the format
# string variables is treated literally.
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 _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()
def _compile_pattern_re(version_pattern: str, raw_pattern: str) -> typ.Pattern[str]:
normalized_pattern = raw_pattern.replace(r"{version}", version_pattern)
if version_pattern == r"{pycalver}":
normalized_pattern = normalized_pattern.replace(r"{pep440_version}", r"{pep440_pycalver}")
elif version_pattern == r"{semver}":
normalized_pattern = normalized_pattern.replace(r"{pep440_version}", r"{semver}")
elif r"{pep440_version}" in raw_pattern:
logger.warning(f"No mapping of '{version_pattern}' to '{{pep440_version}}'")
escaped_pattern = normalized_pattern
for char, escaped in RE_PATTERN_ESCAPES:
escaped_pattern = escaped_pattern.replace(char, escaped)
# TODO (mb 2020-09-19): replace {version} etc with version_pattern
pattern_str = _replace_pattern_parts(escaped_pattern)
return re.compile(pattern_str)
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)
return Pattern(version_pattern, _raw_pattern, regexp)

193
src/pycalver/v1rewrite.py Normal file
View file

@ -0,0 +1,193 @@
# 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 . import parse
from . import config
from . import rewrite
from . import version
from . import v1version
from .patterns import Pattern
logger = logging.getLogger("pycalver.v1rewrite")
def rewrite_lines(
patterns : typ.List[Pattern],
new_vinfo: version.V1VersionInfo,
old_lines: typ.List[str],
) -> typ.List[str]:
"""Replace occurances of patterns in old_lines with new_vinfo.
>>> from .v1patterns import compile_pattern
>>> version_pattern = "{pycalver}"
>>> new_vinfo = v1version.parse_version_info("v201811.0123-beta", version_pattern)
>>> patterns = [compile_pattern(version_pattern, '__version__ = "{pycalver}"')]
>>> rewrite_lines(patterns, new_vinfo, ['__version__ = "v201809.0002-beta"'])
['__version__ = "v201811.0123-beta"']
>>> patterns = [compile_pattern(version_pattern, '__version__ = "{pep440_version}"')]
>>> rewrite_lines(patterns, new_vinfo, ['__version__ = "201809.2b0"'])
['__version__ = "201811.123b0"']
"""
new_lines = old_lines[:]
found_patterns = set()
for match in parse.iter_matches(old_lines, patterns):
found_patterns.add(match.pattern.raw_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:]
new_lines[match.lineno] = new_line
non_matched_patterns = set(patterns) - found_patterns
if non_matched_patterns:
for nmp in non_matched_patterns:
logger.error(f"No match for pattern '{nmp.raw_pattern}'")
logger.error(f"Pattern compiles to regex '{nmp.regexp.pattern}'")
raise rewrite.NoPatternMatch("Invalid pattern(s)")
else:
return new_lines
def rfd_from_content(
patterns : typ.List[Pattern],
new_vinfo: version.V1VersionInfo,
content : str,
path : str = "<path>",
) -> rewrite.RewrittenFileData:
r"""Rewrite pattern occurrences with version string.
>>> from .v1patterns import compile_pattern
>>> patterns = [compile_pattern("{pycalver}", '__version__ = "{pycalver}"']
>>> new_vinfo = v1version.parse_version_info("v201809.0123")
>>> content = '__version__ = "v201809.0001-alpha"'
>>> rfd = rfd_from_content(patterns, new_vinfo, content)
>>> rfd.new_lines
['__version__ = "v201809.0123"']
>>> patterns = [compile_pattern('{semver}', '__version__ = "v{semver}"')]
>>> new_vinfo = v1version.parse_version_info("v1.2.3", "v{semver}")
>>> content = '__version__ = "v1.2.2"'
>>> rfd = rfd_from_content(patterns, new_vinfo, content)
>>> rfd.new_lines
['__version__ = "v1.2.3"']
"""
line_sep = rewrite.detect_line_sep(content)
old_lines = content.split(line_sep)
new_lines = rewrite_lines(patterns, new_vinfo, old_lines)
return rewrite.RewrittenFileData(path, line_sep, old_lines, new_lines)
def iter_rewritten(
file_patterns: config.PatternsByFile,
new_vinfo : version.V1VersionInfo,
) -> typ.Iterable[rewrite.RewrittenFileData]:
r'''Iterate over files with version string replaced.
>>> version_pattern = "{pycalver}"
>>> file_patterns = {"src/pycalver/__init__.py": ['__version__ = "{pycalver}"']}
>>> new_vinfo = v1version.parse_version_info("v201809.0123")
>>> rewritten_datas = iter_rewritten(version_pattern, file_patterns, new_vinfo)
>>> rfd = list(rewritten_datas)[0]
>>> expected = [
... '# 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__ = "v201809.0123"',
... '',
... ]
>>> assert rfd.new_lines == expected
'''
fobj: typ.IO[str]
for file_path, pattern_strs in rewrite.iter_path_patterns_items(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(
old_vinfo : version.V1VersionInfo,
new_vinfo : version.V1VersionInfo,
file_patterns: config.PatternsByFile,
) -> str:
r"""Generate diffs of rewritten files.
>>> old_vinfo = v1version.parse_version_info("v201809.0123")
>>> new_vinfo = v1version.parse_version_info("v201810.1124")
>>> file_patterns = {"src/pycalver/__init__.py": ['__version__ = "{pycalver}"']}
>>> diff_str = diff(old_vinfo, 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"'
>>> file_patterns = {"LICENSE": ['Copyright (c) 2018-{year}']}
>>> diff_str = diff(old_vinfo, new_vinfo, file_patterns)
>>> assert not diff_str
"""
full_diff = ""
fobj: typ.IO[str]
for file_path, patterns in sorted(rewrite.iter_path_patterns_items(file_patterns)):
with file_path.open(mode="rt", encoding="utf-8") as fobj:
content = fobj.read()
has_updated_version = False
for pattern in patterns:
old_str = v1version.format_version(old_vinfo, pattern.raw_pattern)
new_str = v1version.format_version(new_vinfo, pattern.raw_pattern)
if old_str != new_str:
has_updated_version = True
try:
rfd = rfd_from_content(patterns, new_vinfo, content)
except rewrite.NoPatternMatch:
# pylint:disable=raise-missing-from ; we support py2, so not an option
errmsg = f"No patterns matched for '{file_path}'"
raise rewrite.NoPatternMatch(errmsg)
rfd = rfd._replace(path=str(file_path))
lines = rewrite.diff_lines(rfd)
if len(lines) == 0 and has_updated_version:
errmsg = f"No patterns matched for '{file_path}'"
raise rewrite.NoPatternMatch(errmsg)
full_diff += "\n".join(lines) + "\n"
full_diff = full_diff.rstrip("\n")
return full_diff
def rewrite_files(
file_patterns: config.PatternsByFile,
new_vinfo : version.V1VersionInfo,
) -> 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)

407
src/pycalver/v1version.py Normal file
View file

@ -0,0 +1,407 @@
# 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
from . import version
from . import v1patterns
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."""
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 _ver_to_cal_info(vnfo: version.V1VersionInfo) -> version.V1CalendarInfo:
return version.V1CalendarInfo(
vnfo.year,
vnfo.quarter,
vnfo.month,
vnfo.dom,
vnfo.doy,
vnfo.iso_week,
vnfo.us_week,
)
def cal_info(date: dt.date = None) -> version.V1CalendarInfo:
"""Generate calendar components for current date.
>>> from datetime import date
>>> c = cal_info(date(2019, 1, 5))
>>> (c.year, c.quarter, c.month, c.dom, c.doy, c.iso_week, c.us_week)
(2019, 1, 1, 5, 5, 0, 0)
>>> c = cal_info(date(2019, 1, 6))
>>> (c.year, c.quarter, c.month, c.dom, c.doy, c.iso_week, c.us_week)
(2019, 1, 1, 6, 6, 0, 1)
>>> c = cal_info(date(2019, 1, 7))
>>> (c.year, c.quarter, c.month, c.dom, c.doy, c.iso_week, c.us_week)
(2019, 1, 1, 7, 7, 1, 1)
>>> c = cal_info(date(2019, 4, 7))
>>> (c.year, c.quarter, c.month, c.dom, c.doy, c.iso_week, c.us_week)
(2019, 2, 4, 7, 97, 13, 14)
"""
if date is None:
date = version.TODAY
kwargs = {
'year' : date.year,
'quarter' : version.quarter_from_month(date.month),
'month' : date.month,
'dom' : date.day,
'doy' : int(date.strftime("%j"), base=10),
'iso_week': int(date.strftime("%W"), base=10),
'us_week' : int(date.strftime("%U"), base=10),
}
return version.V1CalendarInfo(**kwargs)
FieldKey = str
MatchGroupKey = str
MatchGroupStr = str
PatternGroups = typ.Dict[MatchGroupKey, MatchGroupStr]
FieldValues = typ.Dict[FieldKey , MatchGroupStr]
def _parse_field_values(field_values: FieldValues) -> version.V1VersionInfo:
fvals = field_values
tag = fvals.get('tag')
if tag is None:
tag = "final"
tag = version.TAG_BY_PEP440_TAG.get(tag, tag)
assert tag is not None
bid = fvals['bid'] if 'bid' in fvals else "0001"
year = int(fvals['year']) if 'year' in fvals else None
doy = int(fvals['doy' ]) if 'doy' in fvals else None
month: typ.Optional[int]
dom : typ.Optional[int]
if year and doy:
date = version.date_from_doy(year, 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
iso_week: typ.Optional[int]
us_week : typ.Optional[int]
if year and month and dom:
date = dt.date(year, month, dom)
doy = int(date.strftime("%j"), base=10)
iso_week = int(date.strftime("%W"), base=10)
us_week = int(date.strftime("%U"), base=10)
else:
iso_week = None
us_week = None
quarter = int(fvals['quarter']) if 'quarter' in fvals else None
if quarter is None and month:
quarter = version.quarter_from_month(month)
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 version.V1VersionInfo(
year=year,
quarter=quarter,
month=month,
dom=dom,
doy=doy,
iso_week=iso_week,
us_week=us_week,
major=major,
minor=minor,
patch=patch,
bid=bid,
tag=tag,
)
def _is_calver(cinfo: CalInfo) -> bool:
"""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 version.V1CalendarInfo._fields:
maybe_val: typ.Any = getattr(cinfo, field, None)
if isinstance(maybe_val, int):
return True
return False
VersionInfoKW = typ.Dict[str, typ.Union[str, int, None]]
def _parse_pattern_groups(pattern_groups: PatternGroups) -> FieldValues:
for part_name in pattern_groups.keys():
is_valid_part_name = (
part_name in v1patterns.COMPOSITE_PART_PATTERNS
or part_name in v1patterns.PATTERN_PART_FIELDS
)
if not is_valid_part_name:
err_msg = f"Invalid part '{part_name}'"
raise version.PatternError(err_msg)
field_value_items = [
(field_name, pattern_groups[part_name])
for part_name, field_name in v1patterns.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 version.PatternError(err_msg)
else:
return dict(field_value_items)
def _parse_version_info(pattern_groups: PatternGroups) -> version.V1VersionInfo:
"""Parse normalized V1VersionInfo from groups of a matched pattern.
>>> vnfo = _parse_version_info({'year': "2018", 'month': "11", 'bid': "0099"})
>>> (vnfo.year, vnfo.month, vnfo.quarter, vnfo.bid, vnfo.tag)
(2018, 11, 4, '0099', 'final')
>>> vnfo = _parse_version_info({'year': "2018", 'doy': "11", 'bid': "099", 'tag': "b"})
>>> (vnfo.year, vnfo.month, vnfo.dom, vnfo.bid, vnfo.tag)
(2018, 1, 11, '099', 'beta')
>>> vnfo = _parse_version_info({'MAJOR': "1", 'MINOR': "23", 'PATCH': "45"})
>>> (vnfo.major, vnfo.minor, vnfo.patch)
(1, 23, 45)
>>> vnfo = _parse_version_info({'MAJOR': "1", 'MMM': "023", 'PPPP': "0045"})
>>> (vnfo.major, vnfo.minor, vnfo.patch)
(1, 23, 45)
"""
field_values = _parse_pattern_groups(pattern_groups)
return _parse_field_values(field_values)
def parse_version_info(version_str: str, raw_pattern: str = "{pycalver}") -> version.V1VersionInfo:
"""Parse normalized V1VersionInfo.
>>> vnfo = parse_version_info("v201712.0033-beta", raw_pattern="{pycalver}")
>>> assert vnfo == _parse_version_info({'year': 2017, 'month': 12, 'bid': "0033", 'tag': "beta"})
>>> vnfo = parse_version_info("1.23.456", raw_pattern="{semver}")
>>> assert vnfo == _parse_version_info({'MAJOR': "1", 'MINOR': "23", 'PATCH': "456"})
"""
pattern = v1patterns.compile_pattern(raw_pattern)
match = pattern.regexp.match(version_str)
if match is None:
err_msg = (
f"Invalid version string '{version_str}' "
f"for pattern '{raw_pattern}'/'{pattern.regexp}'"
)
raise version.PatternError(err_msg)
else:
return _parse_version_info(match.groupdict())
def is_valid(version_str: str, raw_pattern: str = "{pycalver}") -> bool:
"""Check if a version matches a pattern.
>>> is_valid("v201712.0033-beta", raw_pattern="{pycalver}")
True
>>> is_valid("v201712.0033-beta", raw_pattern="{semver}")
False
>>> is_valid("1.2.3", raw_pattern="{semver}")
True
>>> is_valid("v201712.0033-beta", raw_pattern="{semver}")
False
"""
try:
parse_version_info(version_str, raw_pattern)
return True
except version.PatternError:
return False
ID_FIELDS_BY_PART = {
'MAJOR' : 'major',
'MINOR' : 'minor',
'MM' : 'minor',
'MMM' : 'minor',
'MMMM' : 'minor',
'MMMMM' : 'minor',
'MMMMMM' : 'minor',
'MMMMMMM': 'minor',
'PATCH' : 'patch',
'PP' : 'patch',
'PPP' : 'patch',
'PPPP' : 'patch',
'PPPPP' : 'patch',
'PPPPPP' : 'patch',
'PPPPPPP': 'patch',
'BID' : 'bid',
'BB' : 'bid',
'BBB' : 'bid',
'BBBB' : 'bid',
'BBBBB' : 'bid',
'BBBBBB' : 'bid',
'BBBBBBB': 'bid',
}
def format_version(vinfo: version.V1VersionInfo, raw_pattern: str) -> str:
"""Generate version string.
>>> import datetime as dt
>>> vinfo = parse_version_info("v201712.0033-beta", raw_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())
>>> vinfo_c = vinfo_b._replace(major=1, minor=2, patch=34, tag='final')
>>> format_version(vinfo_a, raw_pattern="v{yy}.{BID}{release}")
'v17.33-beta'
>>> format_version(vinfo_a, raw_pattern="{pep440_version}")
'201701.33b0'
>>> format_version(vinfo_a, raw_pattern="{pycalver}")
'v201701.0033-beta'
>>> format_version(vinfo_b, raw_pattern="{pycalver}")
'v201712.0033-beta'
>>> format_version(vinfo_a, raw_pattern="v{year}w{iso_week}.{BID}{release}")
'v2017w00.33-beta'
>>> format_version(vinfo_b, raw_pattern="v{year}w{iso_week}.{BID}{release}")
'v2017w52.33-beta'
>>> format_version(vinfo_a, raw_pattern="v{year}d{doy}.{bid}{release}")
'v2017d001.0033-beta'
>>> format_version(vinfo_b, raw_pattern="v{year}d{doy}.{bid}{release}")
'v2017d365.0033-beta'
>>> format_version(vinfo_c, raw_pattern="v{year}w{iso_week}.{BID}-{tag}")
'v2017w52.33-final'
>>> format_version(vinfo_c, raw_pattern="v{year}w{iso_week}.{BID}{release}")
'v2017w52.33'
>>> format_version(vinfo_c, raw_pattern="v{MAJOR}.{MINOR}.{PATCH}")
'v1.2.34'
>>> format_version(vinfo_c, raw_pattern="v{MAJOR}.{MM}.{PPP}")
'v1.02.034'
"""
full_pattern = raw_pattern
for part_name, full_part_format in v1patterns.FULL_PART_FORMATS.items():
full_pattern = full_pattern.replace("{" + part_name + "}", full_part_format)
kwargs: typ.Dict[str, typ.Union[str, int, None]] = vinfo._asdict()
tag = vinfo.tag
if tag == 'final':
kwargs['release' ] = ""
kwargs['pep440_tag'] = ""
else:
kwargs['release' ] = "-" + tag
kwargs['pep440_tag'] = version.PEP440_TAG_BY_TAG[tag] + "0"
kwargs['release_tag'] = tag
year = vinfo.year
if year:
kwargs['yy' ] = str(year)[-2:]
kwargs['yyyy'] = year
kwargs['BID'] = 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 full_pattern.format(**kwargs)
def incr(
old_version: str,
raw_pattern: str = "{pycalver}",
*,
release : typ.Optional[str] = None,
major : bool = False,
minor : bool = False,
patch : bool = False,
pin_date: 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, raw_pattern)
except version.PatternError as ex:
logger.error(str(ex))
return None
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}'")
cur_vinfo = old_vinfo
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.")
return None
else:
return new_version

55
src/pycalver/v2cli.py Normal file
View file

@ -0,0 +1,55 @@
#!/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
from . import config
from . import version
from . import v2rewrite
from . import v2version
logger = logging.getLogger("pycalver.v2cli")
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 = version.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_files(
cfg : config.Config,
new_version: str,
) -> None:
new_vinfo = v2version.parse_version_info(new_version, cfg.version_pattern)
v2rewrite.rewrite_files(cfg.file_patterns, new_vinfo)
def get_diff(cfg: config.Config, new_version: str) -> str:
old_vinfo = v2version.parse_version_info(cfg.current_version, cfg.version_pattern)
new_vinfo = v2version.parse_version_info(new_version , cfg.version_pattern)
return v2rewrite.diff(old_vinfo, new_vinfo, cfg.file_patterns)

264
src/pycalver/v2patterns.py Normal file
View file

@ -0,0 +1,264 @@
# 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.
>>> pattern = compile_pattern("vYYYY0M.BUILD[-TAG]")
>>> version_info = pattern.regexp.match("v201712.0123-alpha")
>>> assert version_info.groupdict() == {
... "version": "v201712.0123-alpha",
... "year_y" : "2017",
... "month" : "12",
... "bid" : "0123",
... "tag" : "alpha",
... }
>>>
>>> version_info = pattern.regexp.match("201712.1234")
>>> assert version_info is None
>>> version_info = pattern.regexp.match("v201713.1234")
>>> assert version_info is None
>>> version_info = pattern.regexp.match("v201712.1234")
>>> assert version_info.groupdict() == {
... "version": "v201712.1234",
... "year_y" : "2017",
... "month" : "12",
... "bid" : "1234",
... "tag" : None,
... }
"""
import re
import typing as typ
import logging
from .patterns import RE_PATTERN_ESCAPES
from .patterns import Pattern
logger = logging.getLogger("pycalver.v2patterns")
# NOTE (mb 2020-09-17): For patterns with different options '(AAA|BB|C)', the
# patterns with more digits should be first/left of those with fewer digits:
#
# good: (?:1[0-2]|[1-9])
# bad: (?:[1-9]|1[0-2])
#
# This ensures that the longest match is done for a pattern.
#
# This implies that patterns for smaller numbers sometimes must be right of
# those for larger numbers. To be consistent we use this ordering not
# sometimes but always (even though in theory it wouldn't matter):
#
# good: (?:3[0-1]|[1-2][0-9]|[1-9])
# bad: (?:[1-2][0-9]|3[0-1]|[1-9])
PART_PATTERNS = {
# Based on calver.org
'YYYY': r"[1-9][0-9]{3}",
'YY' : r"[1-9][0-9]?",
'0Y' : r"[0-9]{2}",
'GGGG': r"[1-9][0-9]{3}",
'GG' : r"[1-9][0-9]?",
'0G' : r"[0-9]{2}",
'Q' : r"[1-4]",
'MM' : r"(?:1[0-2]|[1-9])",
'0M' : r"(?:1[0-2]|0[1-9])",
'DD' : r"(?:3[0-1]|[1-2][0-9]|[1-9])",
'0D' : r"(?:3[0-1]|[1-2][0-9]|0[1-9])",
'JJJ' : r"(?:36[0-6]|3[0-5][0-9]|[1-2][0-9][0-9]|[1-9][0-9]|[1-9])",
'00J' : r"(?:36[0-6]|3[0-5][0-9]|[1-2][0-9][0-9]|0[1-9][0-9]|00[1-9])",
# week numbering parts
'WW': r"(?:5[0-2]|[1-4][0-9]|[0-9])",
'0W': r"(?:5[0-2]|[0-4][0-9])",
'UU': r"(?:5[0-2]|[1-4][0-9]|[0-9])",
'0U': r"(?:5[0-2]|[0-4][0-9])",
'VV': r"(?:5[0-3]|[1-4][0-9]|[1-9])",
'0V': r"(?:5[0-3]|[1-4][0-9]|0[1-9])",
# non calver parts
'MAJOR': r"[0-9]+",
'MINOR': r"[0-9]+",
'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)",
'NUM' : r"[0-9]+",
}
PATTERN_PART_FIELDS = {
'YYYY' : 'year_y',
'YY' : 'year_y',
'0Y' : 'year_y',
'GGGG' : 'year_g',
'GG' : 'year_g',
'0G' : 'year_g',
'Q' : 'quarter',
'MM' : 'month',
'0M' : 'month',
'DD' : 'dom',
'0D' : 'dom',
'JJJ' : 'doy',
'00J' : 'doy',
'MAJOR': 'major',
'MINOR': 'minor',
'PATCH': 'patch',
'BUILD': 'bid',
'BLD' : 'bid',
'TAG' : 'tag',
'PYTAG': 'pytag',
'NUM' : 'num',
'WW' : 'week_w',
'0W' : 'week_w',
'UU' : 'week_u',
'0U' : 'week_u',
'VV' : 'week_v',
'0V' : 'week_v',
}
FieldValue = typ.Union[str, int]
def _fmt_num(val: FieldValue) -> str:
return str(val)
def _fmt_bld(val: FieldValue) -> str:
return str(int(val))
def _fmt_yy(year_y: FieldValue) -> str:
return str(int(str(year_y)[-2:]))
def _fmt_0y(year_y: FieldValue) -> str:
return "{0:02}".format(int(str(year_y)[-2:]))
def _fmt_gg(year_g: FieldValue) -> str:
return str(int(str(year_g)[-2:]))
def _fmt_0g(year_g: FieldValue) -> str:
return "{0:02}".format(int(str(year_g)[-2:]))
def _fmt_0m(month: FieldValue) -> str:
return "{0:02}".format(int(month))
def _fmt_0d(dom: FieldValue) -> str:
return "{0:02}".format(int(dom))
def _fmt_00j(doy: FieldValue) -> str:
return "{0:03}".format(int(doy))
def _fmt_0w(week_w: FieldValue) -> str:
return "{0:02}".format(int(week_w))
def _fmt_0u(week_u: FieldValue) -> str:
return "{0:02}".format(int(week_u))
def _fmt_0v(week_v: FieldValue) -> str:
return "{0:02}".format(int(week_v))
PART_FORMATS: typ.Dict[str, typ.Callable[[FieldValue], str]] = {
'YYYY' : _fmt_num,
'YY' : _fmt_yy,
'0Y' : _fmt_0y,
'GGGG' : _fmt_num,
'GG' : _fmt_gg,
'0G' : _fmt_0g,
'Q' : _fmt_num,
'MM' : _fmt_num,
'0M' : _fmt_0m,
'DD' : _fmt_num,
'0D' : _fmt_0d,
'JJJ' : _fmt_num,
'00J' : _fmt_00j,
'MAJOR': _fmt_num,
'MINOR': _fmt_num,
'PATCH': _fmt_num,
'BUILD': _fmt_num,
'BLD' : _fmt_bld,
'TAG' : _fmt_num,
'PYTAG': _fmt_num,
'NUM' : _fmt_num,
'WW' : _fmt_num,
'0W' : _fmt_0w,
'UU' : _fmt_num,
'0U' : _fmt_0u,
'VV' : _fmt_num,
'0V' : _fmt_0v,
}
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("]", ")?")
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)
# NOTE (mb 2020-09-17): The sorting is done so that we process items:
# - right before left
# - longer before shorter
last_start_idx = len(pattern) + 1
result_pattern = pattern
for _, (start_idx, end_idx, named_part_pattern) in sorted(part_patterns_by_index.items()):
if end_idx <= last_start_idx:
result_pattern = (
result_pattern[:start_idx] + named_part_pattern + result_pattern[end_idx:]
)
last_start_idx = start_idx
return result_pattern
def _compile_pattern_re(version_pattern: str, raw_pattern: str) -> typ.Pattern[str]:
escaped_pattern = raw_pattern
for char, escaped in RE_PATTERN_ESCAPES:
# [] braces are used for optional parts, such as [-TAG]/[-beta]
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)
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)
return Pattern(version_pattern, _raw_pattern, regexp)

198
src/pycalver/v2rewrite.py Normal file
View file

@ -0,0 +1,198 @@
# 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 . import parse
from . import config
from . import rewrite
from . import version
from . import v2version
from .patterns import Pattern
logger = logging.getLogger("pycalver.v2rewrite")
def rewrite_lines(
patterns : typ.List[Pattern],
new_vinfo: version.V2VersionInfo,
old_lines: typ.List[str],
) -> typ.List[str]:
"""Replace occurances of patterns in old_lines with new_vinfo.
>>> from .v2patterns import compile_pattern
>>> version_pattern = "vYYYY0M.BUILD[-TAG]"
>>> new_vinfo = v2version.parse_version_info("v201811.0123-beta", version_pattern)
>>> patterns = [compile_pattern(version_pattern, '__version__ = "{version}"')]
>>> rewrite_lines(patterns, new_vinfo, ['__version__ = "v201809.0002-alpha" '])
['__version__ = "v201811.0123-beta" ']
>>> rewrite_lines(patterns, new_vinfo, ['__version__ = "v201809.0002-alpha" # comment'])
['__version__ = "v201811.0123-beta" # comment']
>>> patterns = [compile_pattern(version_pattern, '__version__ = "{pep440_version}"')]
>>> old_lines = ['__version__ = "201809.2a0"']
>>> rewrite_lines(patterns, new_vinfo, old_lines)
['__version__ = "201811.123b0"']
"""
new_lines = old_lines[:]
found_patterns = set()
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)
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(patterns) - found_patterns
if non_matched_patterns:
for nmp in non_matched_patterns:
logger.error(f"No match for pattern '{nmp.raw_pattern}'")
logger.error(f"Pattern compiles to regex '{nmp.regexp.pattern}'")
raise rewrite.NoPatternMatch("Invalid pattern(s)")
else:
return new_lines
def rfd_from_content(
patterns : typ.List[Pattern],
new_vinfo: version.V2VersionInfo,
content : str,
path : str = "<path>",
) -> rewrite.RewrittenFileData:
r"""Rewrite pattern occurrences with version string.
>>> version_pattern = "vYYYY0M.BUILD[-TAG]"
>>> new_vinfo = v2version.parse_version_info("v201809.0123", version_pattern)
>>> raw_patterns = ['__version__ = "vYYYY0M.BUILD[-TAG]"']
>>> patterns =
>>> content = '__version__ = "v201809.0001-alpha"'
>>> rfd = rfd_from_content(patterns, new_vinfo, content)
>>> rfd.new_lines
['__version__ = "v201809.0123"']
>>> version_pattern = "vMAJOR.MINOR.PATCH"
>>> new_vinfo = v2version.parse_version_info("v1.2.3", version_pattern)
>>> raw_patterns = ['__version__ = "vMAJOR.MINOR.PATCH"']
>>> patterns =
>>> content = '__version__ = "v1.2.2"'
>>> rfd = rfd_from_content(patterns, new_vinfo, content)
>>> rfd.new_lines
['__version__ = "v1.2.3"']
"""
line_sep = rewrite.detect_line_sep(content)
old_lines = content.split(line_sep)
new_lines = rewrite_lines(patterns, new_vinfo, old_lines)
return rewrite.RewrittenFileData(path, line_sep, old_lines, new_lines)
def iter_rewritten(
file_patterns: config.PatternsByFile,
new_vinfo : version.V2VersionInfo,
) -> typ.Iterable[rewrite.RewrittenFileData]:
r'''Iterate over files with version string replaced.
>>> version_pattern = "vYYYY0M.BUILD[-TAG]"
>>> file_patterns = {"src/pycalver/__init__.py": ['__version__ = "vYYYY0M.BUILD[-TAG]"']}
>>> new_vinfo = v2version.parse_version_info("v201809.0123", version_pattern)
>>> rewritten_datas = iter_rewritten(file_patterns, new_vinfo)
>>> rfd = list(rewritten_datas)[0]
>>> expected = [
... '# 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__ = "v201809.0123"',
... '',
... ]
>>> assert rfd.new_lines == expected
'''
fobj: typ.IO[str]
for file_path, patterns in rewrite.iter_path_patterns_items(file_patterns):
with file_path.open(mode="rt", encoding="utf-8") as fobj:
content = fobj.read()
rfd = rfd_from_content(patterns, new_vinfo, content)
yield rfd._replace(path=str(file_path))
def diff(
old_vinfo : version.V2VersionInfo,
new_vinfo : version.V2VersionInfo,
file_patterns: config.PatternsByFile,
) -> str:
r"""Generate diffs of rewritten files.
>>> old_vinfo = v2version.parse_version_info("v201809.0123", version_pattern)
>>> new_vinfo = v2version.parse_version_info("v201810.1124", version_pattern)
>>> file_patterns = {"src/pycalver/__init__.py": ['__version__ = "vYYYY0M.BUILD[-TAG]"']}
>>> diff_str = diff(old_vinfo, 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__ = "v201810.1124"')
>>> lines[7]
'+__version__ = "v201810.1124"'
>>> file_patterns = {"LICENSE": ['Copyright (c) 2018-YYYY']}
>>> diff_str = diff(old_vinfo, new_vinfo, file_patterns)
>>> assert not diff_str
"""
full_diff = ""
fobj: typ.IO[str]
for file_path, patterns in sorted(rewrite.iter_path_patterns_items(file_patterns)):
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:
# pylint:disable=raise-missing-from ; we support py2, so not an option
errmsg = f"No patterns matched for '{file_path}'"
raise rewrite.NoPatternMatch(errmsg)
rfd = rfd._replace(path=str(file_path))
lines = rewrite.diff_lines(rfd)
if len(lines) == 0 and patterns_with_change > 0:
errmsg = f"No patterns matched for '{file_path}'"
raise rewrite.NoPatternMatch(errmsg)
full_diff += "\n".join(lines) + "\n"
full_diff = full_diff.rstrip("\n")
return full_diff
def rewrite_files(
file_patterns: config.PatternsByFile,
new_vinfo : version.V2VersionInfo,
) -> 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)

526
src/pycalver/v2version.py Normal file
View file

@ -0,0 +1,526 @@
# 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
from . import version
from . import v2patterns
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 _ver_to_cal_info(vinfo: version.V2VersionInfo) -> version.V2CalendarInfo:
return version.V2CalendarInfo(
vinfo.year_y,
vinfo.year_g,
vinfo.quarter,
vinfo.month,
vinfo.dom,
vinfo.doy,
vinfo.week_w,
vinfo.week_u,
vinfo.week_v,
)
def cal_info(date: dt.date = None) -> version.V2CalendarInfo:
"""Generate calendar components for current date.
>>> import datetime as dt
>>> c = cal_info(dt.date(2019, 1, 5))
>>> (c.year_y, c.quarter, c.month, c.dom, c.doy, c.week_w, c.week_u, c.week_v)
(2019, 1, 1, 5, 5, 0, 0, 1)
>>> c = cal_info(dt.date(2019, 1, 6))
>>> (c.year_y, c.quarter, c.month, c.dom, c.doy, c.week_w, c.week_u, c.week_v)
(2019, 1, 1, 6, 6, 0, 1, 1)
>>> c = cal_info(dt.date(2019, 1, 7))
>>> (c.year_y, c.quarter, c.month, c.dom, c.doy, c.week_w, c.week_u, c.week_v)
(2019, 1, 1, 7, 7, 1, 1, 2)
>>> c = cal_info(dt.date(2019, 4, 7))
>>> (c.year_y, c.quarter, c.month, c.dom, c.doy, c.week_w, c.week_u, c.week_v)
(2019, 2, 4, 7, 97, 13, 14, 14)
"""
if date is None:
date = version.TODAY
kwargs = {
'year_y' : date.year,
'year_g' : int(date.strftime("%G"), base=10),
'quarter': version.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 version.V2CalendarInfo(**kwargs)
VALID_FIELD_KEYS = set(version.V2VersionInfo._fields) | {'version'}
MaybeInt = typ.Optional[int]
FieldKey = str
MatchGroupKey = str
MatchGroupStr = str
PatternGroups = typ.Dict[FieldKey, MatchGroupStr]
FieldValues = typ.Dict[FieldKey, MatchGroupStr]
VersionInfoKW = typ.Dict[str, typ.Union[str, int, None]]
def _parse_version_info(field_values: FieldValues) -> version.V2VersionInfo:
"""Parse normalized V2VersionInfo from groups of a matched pattern.
>>> vinfo = _parse_version_info({'year_y': "2018", 'month': "11", 'bid': "0099"})
>>> (vinfo.year_y, vinfo.month, vinfo.quarter, vinfo.bid, vinfo.tag)
(2018, 11, 4, '0099', 'final')
>>> vinfo = _parse_version_info({'year_y': "2018", 'doy': "11", 'bid': "099", 'tag': "beta"})
>>> (vinfo.year_y, vinfo.month, vinfo.dom, vinfo.doy, vinfo.bid, vinfo.tag)
(2018, 1, 11, 11, '099', 'beta')
>>> vinfo = _parse_version_info({'year_y': "2018", 'month': "6", 'dom': "15"})
>>> (vinfo.year_y, vinfo.month, vinfo.dom, vinfo.doy)
(2018, 6, 15, 166)
>>> vinfo = _parse_version_info({'major': "1", 'minor': "23", 'patch': "45"})
>>> (vinfo.major, vinfo.minor, vinfo.patch)
(1, 23, 45)
>>> vinfo = _parse_version_info({'major': "1", 'minor': "023", 'patch': "0045"})
>>> (vinfo.major, vinfo.minor, vinfo.patch)
(1, 23, 45)
>>> vinfo = _parse_version_info({'year_y': "2021", 'week_w': "02"})
>>> (vinfo.year_y, vinfo.week_w)
(2021, 2)
>>> vinfo = _parse_version_info({'year_y': "2021", 'week_u': "02"})
>>> (vinfo.year_y, vinfo.week_u)
(2021, 2)
>>> vinfo = _parse_version_info({'year_g': "2021", 'week_v': "02"})
>>> (vinfo.year_g, vinfo.week_v)
(2021, 2)
>>> vinfo = _parse_version_info({'year_y': "2021", 'month': "01", 'dom': "03"})
>>> (vinfo.year_y, vinfo.month, vinfo.dom, vinfo.tag)
(2021, 1, 3, 'final')
>>> (vinfo.year_y, vinfo.week_w, vinfo.year_y, vinfo.week_u,vinfo.year_g, vinfo.week_v)
(2021, 0, 2021, 1, 2020, 53)
"""
for key in field_values:
assert key in VALID_FIELD_KEYS, key
fvals = field_values
tag = fvals.get('tag' ) or "final"
pytag = fvals.get('pytag') or ""
if tag and not pytag:
pytag = version.PEP440_TAG_BY_TAG[tag]
elif pytag and not tag:
tag = version.TAG_BY_PEP440_TAG[pytag]
date: typ.Optional[dt.date] = None
year_y: MaybeInt = int(fvals['year_y']) if 'year_y' in fvals else None
year_g: MaybeInt = int(fvals['year_g']) if 'year_g' in fvals else None
month: MaybeInt = int(fvals['month']) if 'month' in fvals else None
doy : MaybeInt = int(fvals['doy' ]) if 'doy' in fvals else None
dom : MaybeInt = int(fvals['dom' ]) if 'dom' in fvals else None
week_w: MaybeInt = int(fvals['week_w']) if 'week_w' in fvals else None
week_u: MaybeInt = int(fvals['week_u']) if 'week_u' in fvals else None
week_v: MaybeInt = int(fvals['week_v']) if 'week_v' in fvals else None
if year_y and doy:
date = version.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
if year_y and month and dom:
date = dt.date(year_y, month, dom)
if date:
# derive all fields from other previous values
year_y = int(date.strftime("%Y"), base=10)
year_g = int(date.strftime("%G"), base=10)
month = int(date.strftime("%m"), base=10)
dom = int(date.strftime("%d"), base=10)
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)
quarter = int(fvals['quarter']) if 'quarter' in fvals else None
if quarter is None and month:
quarter = version.quarter_from_month(month)
# NOTE (mb 2020-09-18): If a part is optional, fvals[<field>] may be None
major = int(fvals.get('major') or 0)
minor = int(fvals.get('minor') or 0)
patch = int(fvals.get('patch') or 0)
num = int(fvals.get('num' ) or 0)
bid = fvals['bid'] if 'bid' in fvals else "1000"
vinfo = version.V2VersionInfo(
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,
num=num,
bid=bid,
tag=tag,
pytag=pytag,
)
return vinfo
def parse_version_info(
version_str: str, raw_pattern: str = "vYYYY0M.BUILD[-TAG[NUM]]"
) -> version.V2VersionInfo:
"""Parse normalized V2VersionInfo.
>>> vinfo = parse_version_info("v201712.0033-beta0", raw_pattern="vYYYY0M.BUILD[-TAG[NUM]]")
>>> fvals = {'year_y': 2017, 'month': 12, 'bid': "0033", 'tag': "beta", 'num': 0}
>>> assert vinfo == _parse_version_info(fvals)
>>> vinfo = parse_version_info("v201712.0033-beta", raw_pattern="vYYYY0M.BUILD[-TAG[NUM]]")
>>> fvals = {'year_y': 2017, 'month': 12, 'bid': "0033", 'tag': "beta"}
>>> assert vinfo == _parse_version_info(fvals)
>>> vinfo = parse_version_info("v201712.0033", raw_pattern="vYYYY0M.BUILD[-TAG[NUM]]")
>>> fvals = {'year_y': 2017, 'month': 12, 'bid': "0033"}
>>> assert vinfo == _parse_version_info(fvals)
>>> vinfo = parse_version_info("1.23.456", raw_pattern="MAJOR.MINOR.PATCH")
>>> fvals = {'major': "1", 'minor': "23", 'patch': "456"}
>>> assert vinfo == _parse_version_info(fvals)
"""
pattern = v2patterns.compile_pattern(raw_pattern)
match = pattern.regexp.match(version_str)
if match is None:
err_msg = (
f"Invalid version string '{version_str}' "
f"for pattern '{raw_pattern}'/'{pattern.regexp.pattern}'"
)
raise version.PatternError(err_msg)
else:
field_values = match.groupdict()
return _parse_version_info(field_values)
def is_valid(version_str: str, raw_pattern: str = "vYYYY0M.BUILD[-TAG]") -> bool:
"""Check if a version matches a pattern.
>>> is_valid("v201712.0033-beta", raw_pattern="vYYYY0M.BUILD[-TAG]")
True
>>> is_valid("v201712.0033-beta", raw_pattern="MAJOR.MINOR.PATCH")
False
>>> is_valid("1.2.3", raw_pattern="MAJOR.MINOR.PATCH")
True
>>> is_valid("v201712.0033-beta", raw_pattern="MAJOR.MINOR.PATCH")
False
"""
try:
parse_version_info(version_str, raw_pattern)
return True
except version.PatternError:
return False
TemplateKwargs = typ.Dict[str, typ.Union[str, int, None]]
def _format_part_values(vinfo: version.V2VersionInfo) -> typ.Dict[str, str]:
"""Generate kwargs for template from minimal V2VersionInfo.
The V2VersionInfo 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'.
>>> vinfo = parse_version_info("v200709.1033-beta", pattern="vYYYY0M.BUILD[-TAG]")
>>> kwargs = _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['YYYY'], kwargs['0M'], kwargs['BUILD'], kwargs['PYTAG'], kwargs['NUM'])
('2007', '09', '1033', 'b', '1')
"""
vnfo_kwargs: TemplateKwargs = vinfo._asdict()
kwargs : typ.Dict[str, str] = {}
for part, field in v2patterns.PATTERN_PART_FIELDS.items():
field_val = vnfo_kwargs[field]
if field_val is not None:
format_fn = v2patterns.PART_FORMATS[part]
kwargs[part] = format_fn(field_val)
return kwargs
def _make_segments(raw_pattern: str) -> typ.List[str]:
pattern_segs_l: typ.List[str] = []
pattern_segs_r: typ.List[str] = []
pattern_rest = raw_pattern
while "[" in pattern_rest and "]" in pattern_rest:
try:
seg_l , pattern_rest = pattern_rest.split("[", 1)
pattern_rest, seg_r = pattern_rest.rsplit("]", 1)
except ValueError as val_err:
if "values to unpack" in str(val_err):
err = f"Unbalanced braces [] in '{raw_pattern}'"
pat_err = version.PatternError(err)
pat_err.__cause__ = val_err
raise pat_err
else:
raise
pattern_segs_l.append(seg_l)
pattern_segs_r.append(seg_r)
pattern_segs_l.append(pattern_rest)
return pattern_segs_l + list(reversed(pattern_segs_r))
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
def _format_segments(
vinfo : version.V2VersionInfo,
pattern_segs: typ.List[str],
) -> typ.List[str]:
kwargs = _format_part_values(vinfo)
part_values = sorted(kwargs.items(), key=lambda item: -len(item[0]))
is_zero_segment = [True] * len(pattern_segs)
formatted_segs_l: typ.List[str] = []
formatted_segs_r: typ.List[str] = []
idx_l = 0
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
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)):
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]):
is_zero_segment[idx_r] = False
formatted_segs_l.append(seg_l)
if idx_l < idx_r:
formatted_segs_r.append(seg_r)
idx_l += 1
idx_r -= 1
formatted_segs = formatted_segs_l + list(reversed(formatted_segs_r))
return _clear_zero_segments(formatted_segs, is_zero_segment)
def format_version(vinfo: version.V2VersionInfo, raw_pattern: str) -> str:
"""Generate version string.
>>> import datetime as dt
>>> vinfo = parse_version_info("v200712.0033-beta", pattern="vYYYY0M.BUILD[-TAG[NUM]]")
>>> vinfo_a = vinfo._replace(**cal_info(date=dt.date(2007, 1, 1))._asdict())
>>> vinfo_b = vinfo._replace(**cal_info(date=dt.date(2007, 12, 31))._asdict())
>>> format_version(vinfo_a, pattern="vYY.BLD[-PYTAGNUM]")
'v7.33-b0'
>>> format_version(vinfo_a, pattern="YYYY0M.BUILD[PYTAG[NUM]]")
'200701.0033b'
>>> format_version(vinfo_a, pattern="vYY.BLD[-PYTAGNUM]")
'v7.33-b0'
>>> format_version(vinfo_a, pattern="v0Y.BLD[-TAG]")
'v07.33-beta'
>>> format_version(vinfo_a, pattern="vYYYY0M.BUILD[-TAG]")
'v200701.0033-beta'
>>> format_version(vinfo_b, pattern="vYYYY0M.BUILD[-TAG]")
'v200712.0033-beta'
>>> format_version(vinfo_a, pattern="vYYYYw0W.BUILD[-TAG]")
'v2007w01.0033-beta'
>>> format_version(vinfo_a, pattern="vYYYYwWW.BLD[-TAG]")
'v2007w1.33-beta'
>>> format_version(vinfo_b, pattern="vYYYYw0W.BUILD[-TAG]")
'v2007w53.0033-beta'
>>> format_version(vinfo_a, pattern="vYYYYd00J.BUILD[-TAG]")
'v2007d001.0033-beta'
>>> format_version(vinfo_a, pattern="vYYYYdJJJ.BUILD[-TAG]")
'v2007d1.0033-beta'
>>> format_version(vinfo_b, pattern="vYYYYd00J.BUILD[-TAG]")
'v2007d365.0033-beta'
>>> format_version(vinfo_a, pattern="vGGGGwVV.BLD[PYTAGNUM]")
'v2007w1.33b0'
>>> format_version(vinfo_a, pattern="vGGGGw0V.BUILD[-TAG]")
'v2007w01.0033-beta'
>>> format_version(vinfo_b, pattern="vGGGGw0V.BUILD[-TAG]")
'v2008w01.0033-beta'
>>> vinfo_c = vinfo_b._replace(major=1, minor=2, patch=34, tag='final')
>>> format_version(vinfo_c, pattern="vYYYYwWW.BUILD-TAG")
'v2007w53.0033-final'
>>> format_version(vinfo_c, pattern="vYYYYwWW.BUILD[-TAG]")
'v2007w53.0033'
>>> 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-TAGNUM")
'v1.0.0-final0'
>>> format_version(vinfo_d, pattern="vMAJOR.MINOR.PATCH-TAG[NUM]")
'v1.0.0-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[.PATCH[-TAG]]]")
'v1'
>>> vinfo_d = vinfo_b._replace(major=1, minor=0, patch=1, tag='rc', num=0)
>>> format_version(vinfo_d, pattern="vMAJOR[.MINOR[.PATCH]]")
'v1.0.1'
>>> format_version(vinfo_d, pattern="vMAJOR[.MINOR[.PATCH[-TAG[NUM]]]]")
'v1.0.1-rc'
>>> format_version(vinfo_d, pattern="vMAJOR[.MINOR[.PATCH[-TAGNUM]]]")
'v1.0.1-rc0'
>>> format_version(vinfo_d, pattern="vMAJOR[.MINOR[.PATCH]]")
'v1.0.1'
>>> vinfo_d = vinfo_b._replace(major=1, minor=0, patch=0, tag='rc', num=2)
>>> format_version(vinfo_d, pattern="vMAJOR[.MINOR[.PATCH[-TAG[NUM]]]]")
'v1.0.0-rc2'
>>> vinfo_d = vinfo_b._replace(major=1, minor=0, patch=0, tag='rc', num=2)
>>> 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)
return "".join(formatted_segs)
def incr(
old_version: str,
raw_pattern: str = "vYYYY0M.BUILD[-TAG]",
*,
release : typ.Optional[str] = None,
major : bool = False,
minor : bool = False,
patch : bool = False,
pin_date: bool = False,
) -> typ.Optional[str]:
"""Increment version string.
'old_version' is assumed to be a string that matches 'raw_pattern'
"""
try:
old_vinfo = parse_version_info(old_version, raw_pattern)
except version.PatternError as ex:
logger.error(str(ex))
return None
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}'")
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,
)
new_version = format_version(cur_vinfo, raw_pattern)
if new_version == old_version:
logger.error("Invalid arguments or pattern, version did not change.")
return None
else:
return new_version

View file

@ -1,30 +1,13 @@
# 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
import pycalver.patterns as v1patterns
logger = logging.getLogger("pycalver.version")
# The test suite may replace this.
TODAY = dt.datetime.utcnow().date()
MaybeInt = typ.Optional[int]
class CalendarInfo(typ.NamedTuple):
class V1CalendarInfo(typ.NamedTuple):
"""Container for calendar components of version strings."""
year : MaybeInt
@ -36,7 +19,7 @@ class CalendarInfo(typ.NamedTuple):
us_week : MaybeInt
class VersionInfo(typ.NamedTuple):
class V1VersionInfo(typ.NamedTuple):
"""Container for parsed version string."""
year : MaybeInt
@ -53,26 +36,94 @@ class VersionInfo(typ.NamedTuple):
tag : str
def _ver_to_cal_info(vinfo: VersionInfo) -> CalendarInfo:
return CalendarInfo(
vinfo.year,
vinfo.quarter,
vinfo.month,
vinfo.dom,
vinfo.doy,
vinfo.iso_week,
vinfo.us_week,
)
class V2CalendarInfo(typ.NamedTuple):
"""Container for calendar components of version strings."""
year_y : MaybeInt
year_g : MaybeInt
quarter: MaybeInt
month : MaybeInt
dom : MaybeInt
doy : MaybeInt
week_w : MaybeInt
week_u : MaybeInt
week_v : MaybeInt
def _date_from_doy(year: int, doy: int) -> dt.date:
class V2VersionInfo(typ.NamedTuple):
"""Container for parsed version string."""
year_y : MaybeInt
year_g : MaybeInt
quarter: MaybeInt
month : MaybeInt
dom : MaybeInt
doy : MaybeInt
week_w : MaybeInt
week_u : MaybeInt
week_v : MaybeInt
major : int
minor : int
patch : int
num : int
bid : str
tag : str
pytag : str
VersionInfoType = typ.TypeVar('VersionInfoType', V1VersionInfo, V2VersionInfo)
# The test suite may replace this.
TODAY = dt.datetime.utcnow().date()
TAG_BY_PEP440_TAG = {
'a' : 'alpha',
'b' : 'beta',
"" : 'final',
'rc' : 'rc',
'dev' : 'dev',
'post': 'post',
}
PEP440_TAG_BY_TAG = {
'alpha': "a",
'beta' : "b",
'final': "",
'pre' : "rc",
'rc' : "rc",
'dev' : "dev",
'post' : "post",
}
assert set(TAG_BY_PEP440_TAG.keys()) == set(PEP440_TAG_BY_TAG.values())
assert set(TAG_BY_PEP440_TAG.values()) < set(PEP440_TAG_BY_TAG.keys())
ZERO_VALUES = {
'MAJOR': "0",
'MINOR': "0",
'PATCH': "0",
'TAG' : "final",
'PYTAG': "",
'NUM' : "0",
}
class PatternError(Exception):
pass
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]
>>> 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),
@ -81,407 +132,15 @@ def _date_from_doy(year: int, doy: int) -> dt.date:
return dt.date(year, 1, 1) + dt.timedelta(days=doy - 1)
def _quarter_from_month(month: int) -> int:
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)]
>>> [quarter_from_month(month) for month in range(1, 13)]
[1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4]
"""
return ((month - 1) // 3) + 1
def cal_info(date: dt.date = None) -> CalendarInfo:
"""Generate calendar components for current date.
>>> from datetime import date
>>> c = cal_info(date(2019, 1, 5))
>>> (c.year, c.quarter, c.month, c.dom, c.doy, c.iso_week, c.us_week)
(2019, 1, 1, 5, 5, 0, 0)
>>> c = cal_info(date(2019, 1, 6))
>>> (c.year, c.quarter, c.month, c.dom, c.doy, c.iso_week, c.us_week)
(2019, 1, 1, 6, 6, 0, 1)
>>> c = cal_info(date(2019, 1, 7))
>>> (c.year, c.quarter, c.month, c.dom, c.doy, c.iso_week, c.us_week)
(2019, 1, 1, 7, 7, 1, 1)
>>> c = cal_info(date(2019, 4, 7))
>>> (c.year, c.quarter, c.month, c.dom, c.doy, c.iso_week, c.us_week)
(2019, 2, 4, 7, 97, 13, 14)
"""
if date is None:
date = TODAY
kwargs = {
'year' : date.year,
'quarter' : _quarter_from_month(date.month),
'month' : date.month,
'dom' : date.day,
'doy' : int(date.strftime("%j"), base=10),
'iso_week': int(date.strftime("%W"), base=10),
'us_week' : int(date.strftime("%U"), base=10),
}
return CalendarInfo(**kwargs)
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
bid = fvals['bid'] if 'bid' in fvals else "0001"
year = int(fvals['year']) if 'year' in fvals else None
doy = int(fvals['doy' ]) if 'doy' in fvals else None
month: typ.Optional[int]
dom : typ.Optional[int]
if year and doy:
date = _date_from_doy(year, doy)
month = date.month
dom = date.day
else:
month = int(fvals['month']) if 'month' in fvals else None
dom = int(fvals['dom' ]) if 'dom' in fvals else None
iso_week: typ.Optional[int]
us_week : typ.Optional[int]
if year and month and dom:
date = dt.date(year, month, dom)
doy = int(date.strftime("%j"), base=10)
iso_week = int(date.strftime("%W"), base=10)
us_week = int(date.strftime("%U"), base=10)
else:
iso_week = None
us_week = None
quarter = int(fvals['quarter']) if 'quarter' in fvals else None
if quarter is None and month:
quarter = _quarter_from_month(month)
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=year,
quarter=quarter,
month=month,
dom=dom,
doy=doy,
iso_week=iso_week,
us_week=us_week,
major=major,
minor=minor,
patch=patch,
bid=bid,
tag=tag,
)
def _is_calver(nfo: typ.Union[CalendarInfo, VersionInfo]) -> bool:
"""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 v1patterns.COMPOSITE_PART_PATTERNS
or part_name in v1patterns.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 v1patterns.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)
else:
return dict(field_value_items)
def _parse_version_info(pattern_groups: PatternGroups) -> VersionInfo:
"""Parse normalized VersionInfo from groups of a matched pattern.
>>> vnfo = _parse_version_info({'year': "2018", 'month': "11", 'bid': "0099"})
>>> (vnfo.year, vnfo.month, vnfo.quarter, vnfo.bid, vnfo.tag)
(2018, 11, 4, '0099', 'final')
>>> vnfo = _parse_version_info({'year': "2018", 'doy': "11", 'bid': "099", 'tag': "b"})
>>> (vnfo.year, vnfo.month, vnfo.dom, vnfo.bid, vnfo.tag)
(2018, 1, 11, '099', 'beta')
>>> vnfo = _parse_version_info({'MAJOR': "1", 'MINOR': "23", 'PATCH': "45"})
>>> (vnfo.major, vnfo.minor, vnfo.patch)
(1, 23, 45)
>>> vnfo = _parse_version_info({'MAJOR': "1", 'MMM': "023", 'PPPP': "0045"})
>>> (vnfo.major, vnfo.minor, vnfo.patch)
(1, 23, 45)
"""
field_values = _parse_pattern_groups(pattern_groups)
return _parse_field_values(field_values)
def parse_version_info(version_str: str, pattern: str = "{pycalver}") -> VersionInfo:
"""Parse normalized VersionInfo.
>>> vnfo = parse_version_info("v201712.0033-beta", pattern="{pycalver}")
>>> assert vnfo == _parse_version_info({'year': 2017, 'month': 12, 'bid': "0033", 'tag': "beta"})
>>> vnfo = parse_version_info("1.23.456", pattern="{semver}")
>>> assert vnfo == _parse_version_info({'MAJOR': "1", 'MINOR': "23", 'PATCH': "456"})
"""
pattern_tup = v1patterns.compile_pattern(pattern)
match = pattern_tup.regexp.match(version_str)
if match is None:
err_msg = (
f"Invalid version string '{version_str}' "
f"for pattern '{pattern}'/'{pattern_tup.regexp.pattern}'"
)
raise PatternError(err_msg)
else:
return _parse_version_info(match.groupdict())
def is_valid(version_str: str, pattern: str = "{pycalver}") -> bool:
"""Check if a version matches a pattern.
>>> is_valid("v201712.0033-beta", pattern="{pycalver}")
True
>>> is_valid("v201712.0033-beta", pattern="{semver}")
False
>>> is_valid("1.2.3", pattern="{semver}")
True
>>> is_valid("v201712.0033-beta", pattern="{semver}")
False
"""
try:
parse_version_info(version_str, pattern)
return True
except PatternError:
return False
ID_FIELDS_BY_PART = {
'MAJOR' : 'major',
'MINOR' : 'minor',
'MM' : 'minor',
'MMM' : 'minor',
'MMMM' : 'minor',
'MMMMM' : 'minor',
'MMMMMM' : 'minor',
'MMMMMMM': 'minor',
'PATCH' : 'patch',
'PP' : 'patch',
'PPP' : 'patch',
'PPPP' : 'patch',
'PPPPP' : 'patch',
'PPPPPP' : 'patch',
'PPPPPPP': 'patch',
'BID' : 'bid',
'BB' : 'bid',
'BBB' : 'bid',
'BBBB' : 'bid',
'BBBBB' : 'bid',
'BBBBBB' : 'bid',
'BBBBBBB': 'bid',
}
def format_version(vinfo: VersionInfo, pattern: str) -> str:
"""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())
>>> vinfo_c = vinfo_b._replace(major=1, minor=2, patch=34, tag='final')
>>> format_version(vinfo_a, pattern="v{yy}.{BID}{release}")
'v17.33-beta'
>>> format_version(vinfo_a, pattern="{pep440_version}")
'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_b, pattern="v{year}w{iso_week}.{BID}{release}")
'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_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="v{MAJOR}.{MINOR}.{PATCH}")
'v1.2.34'
>>> format_version(vinfo_c, pattern="v{MAJOR}.{MM}.{PPP}")
'v1.02.034'
"""
full_pattern = pattern
for part_name, full_part_format in v1patterns.FULL_PART_FORMATS.items():
full_pattern = full_pattern.replace("{" + part_name + "}", full_part_format)
kwargs: typ.Dict[str, typ.Union[str, int, None]] = vinfo._asdict()
tag = vinfo.tag
if tag == 'final':
kwargs['release' ] = ""
kwargs['pep440_tag'] = ""
else:
kwargs['release' ] = "-" + tag
kwargs['pep440_tag'] = PEP440_TAGS[tag] + "0"
kwargs['release_tag'] = tag
year = vinfo.year
if year:
kwargs['yy' ] = str(year)[-2:]
kwargs['yyyy'] = year
kwargs['BID'] = 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 full_pattern.format(**kwargs)
def incr(
old_version: str,
pattern : str = "{pycalver}",
*,
release : str = None,
major : bool = False,
minor : bool = False,
patch : bool = False,
pin_date: 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 = _ver_to_cal_info(old_vinfo) if pin_date else cal_info()
old_date = (old_vinfo.year or 0 , old_vinfo.month or 0 , old_vinfo.dom or 0)
cur_date = (cur_cal_nfo.year or 0, cur_cal_nfo.month or 0, cur_cal_nfo.dom or 0)
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.next_id(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.
@ -489,3 +148,28 @@ 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