update tests for new defaults

This commit is contained in:
Manuel Barkhau 2020-10-14 22:17:18 +00:00
parent 54ab1151f1
commit 145401de33
30 changed files with 495 additions and 417 deletions

View file

@ -0,0 +1,8 @@
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
"""PyCalVer: CalVer for Python Packages."""
__version__ = "v2020.1041-beta"

15
src/pycalver2/__main__.py Normal file
View file

@ -0,0 +1,15 @@
#!/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
"""
__main__ module for PyCalVer.
Enables use as module: $ python -m pycalver --version
"""
from . import cli
if __name__ == '__main__':
cli.cli()

718
src/pycalver2/cli.py Executable file
View file

@ -0,0 +1,718 @@
#!/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."""
import io
import sys
import typing as typ
import logging
import datetime as dt
import subprocess as sp
import click
import colorama
import pkg_resources
from . import vcs
from . import config
from . import rewrite
from . import version
from . import patterns
from . import regexfmt
from . import v1rewrite
from . import v1version
from . import v2rewrite
from . import v2version
from . import v1patterns
from . import v2patterns
try:
import pretty_traceback
pretty_traceback.install()
except ImportError:
pass # no need to fail because of missing dev dependency
click.disable_unicode_literals_warning = True
logger = logging.getLogger("pycalver2.cli")
_VERBOSE = 0
def _configure_logging(verbose: int = 0) -> None:
# pylint:disable=global-statement; global flag is global.
global _VERBOSE
_VERBOSE = verbose
if verbose >= 2:
log_format = "%(asctime)s.%(msecs)03d %(levelname)-7s %(name)-17s - %(message)s"
log_level = logging.DEBUG
elif verbose == 1:
log_format = "%(levelname)-7s - %(message)s"
log_level = logging.INFO
else:
log_format = "%(levelname)-7s - %(message)s"
log_level = logging.INFO
logging.basicConfig(level=log_level, format=log_format, datefmt="%Y-%m-%dT%H:%M:%S")
logger.debug("Logging configured.")
VALID_RELEASE_TAG_VALUES = ("alpha", "beta", "rc", "post", "final")
_current_date = dt.date.today().isoformat()
def _validate_date(date: typ.Optional[str], pin_date: bool) -> typ.Optional[dt.date]:
if date and pin_date:
logger.error(f"Can only use either --pin-date or --date='{date}', not both.")
sys.exit(1)
if date is None:
return None
try:
dt_val = dt.datetime.strptime(date, "%Y-%m-%d")
return dt_val.date()
except ValueError:
logger.error(
f"Invalid parameter --date='{date}', must match format YYYY-0M-0D.", exc_info=True
)
sys.exit(1)
def _validate_release_tag(tag: typ.Optional[str]) -> None:
if tag is None:
return
if tag in VALID_RELEASE_TAG_VALUES:
return
logger.error(f"Invalid argument --tag={tag}")
logger.error(f"Valid arguments are: {', '.join(VALID_RELEASE_TAG_VALUES)}")
sys.exit(1)
def _validate_flags(
raw_pattern: str,
major : bool,
minor : bool,
patch : bool,
) -> None:
if "{" in raw_pattern and "}" in raw_pattern:
# only validate for new style patterns
return
valid = True
if major and "MAJOR" not in raw_pattern:
logger.error(f"Flag --major is not applicable to pattern '{raw_pattern}'")
valid = False
if minor and "MINOR" not in raw_pattern:
logger.error(f"Flag --minor is not applicable to pattern '{raw_pattern}'")
valid = False
if patch and "PATCH" not in raw_pattern:
logger.error(f"Flag --patch is not applicable to pattern '{raw_pattern}'")
valid = False
if not valid:
sys.exit(1)
def _log_no_change(subcmd: str, version_pattern: str, old_version: str) -> None:
msg = f"Invalid version '{old_version}' and/or pattern '{version_pattern}'."
logger.error(msg)
is_semver = "{semver}" in version_pattern or (
"MAJOR" in version_pattern and "MAJOR" in version_pattern and "PATCH" in version_pattern
)
if is_semver:
logger.warning(f"calver {subcmd} [--major/--minor/--patch] required for use with SemVer.")
else:
available_flags = [
"--" + part.lower() for part in ['MAJOR', 'MINOR', 'PATCH'] if part in version_pattern
]
if available_flags:
available_flags_str = "/".join(available_flags)
logger.info(f"Perhaps try: calver {subcmd} {available_flags_str} ")
def _get_normalized_pattern(raw_pattern: str, version_pattern: typ.Optional[str]) -> str:
is_version_pattern_required = "{version}" in raw_pattern or "{pep440_version}" in raw_pattern
if is_version_pattern_required and version_pattern is None:
logger.error(
"Argument --version-pattern=<PATTERN> is required"
" for placeholders: {version}/{pep440_version}."
)
sys.exit(1)
elif version_pattern is None:
_version_pattern = "INVALID" # pacify mypy, it's not referenced in raw_pattern
else:
_version_pattern = version_pattern
if is_version_pattern_required:
return v2patterns.normalize_pattern(_version_pattern, raw_pattern)
else:
return raw_pattern
@click.group()
@click.version_option(version="v2020.1041-beta")
@click.help_option()
@click.option('-v', '--verbose', count=True, help="Control log level. -vv for debug level.")
def cli(verbose: int = 0) -> None:
"""Automatically update CalVer version strings in plaintext files."""
if verbose:
_configure_logging(verbose=max(_VERBOSE, verbose))
@cli.command()
@click.argument("old_version")
@click.argument("pattern", default="vYYYY.BUILD[-TAG]")
@click.option('-v' , '--verbose', count=True, help="Control log level. -vv for debug level.")
@click.option("--major", is_flag=True, default=False, help="Increment major component.")
@click.option("-m" , "--minor", is_flag=True, default=False, help="Increment minor component.")
@click.option("-p" , "--patch", is_flag=True, default=False, help="Increment patch component.")
@click.option(
"--tag",
default=None,
metavar="<NAME>",
help=(
f"Override release tag of current_version. Valid options are: "
f"{', '.join(VALID_RELEASE_TAG_VALUES)}."
),
)
@click.option(
"--tag-num",
is_flag=True,
default=False,
help="Increment release tag number (rc1, rc2, rc3..).",
)
@click.option("--pin-date", is_flag=True, default=False, help="Leave date components unchanged.")
@click.option(
"--date",
default=None,
metavar="<ISODATE>",
help=f"Set explicit date in format YYYY-0M-0D (e.g. {_current_date}).",
)
def test(
old_version: str,
pattern : str = "vYYYY.BUILD[-TAG]",
verbose : int = 0,
tag : str = None,
major : bool = False,
minor : bool = False,
patch : bool = False,
tag_num : bool = False,
pin_date : bool = False,
date : typ.Optional[str] = None,
) -> None:
"""Increment a version number for demo purposes."""
_configure_logging(verbose=max(_VERBOSE, verbose))
_validate_release_tag(tag)
raw_pattern = pattern # use internal naming convention
_validate_flags(raw_pattern, major, minor, patch)
_date = _validate_date(date, pin_date)
new_version = incr_dispatch(
old_version,
raw_pattern=raw_pattern,
major=major,
minor=minor,
patch=patch,
tag=tag,
tag_num=tag_num,
pin_date=pin_date,
date=_date,
)
if new_version is None:
_log_no_change('test', raw_pattern, old_version)
sys.exit(1)
pep440_version = version.to_pep440(new_version)
click.echo(f"New Version: {new_version}")
click.echo(f"PEP440 : {pep440_version}")
def _grep_text(pattern: patterns.Pattern, text: str, color: bool) -> typ.Iterable[str]:
all_lines = text.splitlines()
for match in pattern.regexp.finditer(text):
match_start, match_end = match.span()
line_idx = text[:match_start].count("\n")
line_start = text.rfind("\n", 0, match_start) + 1
line_end = text.find("\n", match_end, -1)
if color:
matched_line = (
text[line_start:match_start]
+ colorama.Style.BRIGHT
+ text[match_start:match_end]
+ colorama.Style.RESET_ALL
+ text[match_end:line_end]
)
else:
matched_line = (
text[line_start:match_start]
+ text[match_start:match_end]
+ text[match_end:line_end]
)
lines_offset = max(0, line_idx - 1) + 1
lines = all_lines[line_idx - 1 : line_idx + 2]
if line_idx == 0:
lines[0] = matched_line
else:
lines[1] = matched_line
prefixed_lines = [f"{lines_offset + i:>4}: {line}" for i, line in enumerate(lines)]
yield "\n".join(prefixed_lines)
def _grep(
raw_pattern: str,
file_ios : typ.Tuple[io.TextIOWrapper],
color : bool,
) -> None:
pattern = v2patterns.compile_pattern(raw_pattern)
match_count = 0
for file_io in file_ios:
text = file_io.read()
match_strs = list(_grep_text(pattern, text, color))
if len(match_strs) > 0:
print(file_io.name)
for match_str in match_strs:
print(match_str)
print()
match_count += len(match_strs)
if match_count == 0:
logger.error(f"Pattern not found: '{raw_pattern}'")
if match_count == 0 or _VERBOSE:
pyexpr_regex = regexfmt.pyexpr_regex(pattern.regexp.pattern)
print("# " + regexfmt.regex101_url(pattern.regexp.pattern))
print(pyexpr_regex)
print()
if match_count == 0:
sys.exit(1)
@cli.command()
@click.option(
"-v",
"--verbose",
count=True,
help="Control log level. -vv for debug level.",
)
@click.option(
"--version-pattern",
default=None,
metavar="<PATTERN>",
help="Pattern to use for placeholders: {version}/{pep440_version}",
)
@click.argument("pattern")
@click.argument('files', nargs=-1, type=click.File('r'))
def grep(
pattern : str,
files : typ.Tuple[io.TextIOWrapper],
version_pattern: typ.Optional[str] = None,
verbose : int = 0,
) -> None:
"""Search file(s) for a version pattern."""
verbose = max(_VERBOSE, verbose)
_configure_logging(verbose)
raw_pattern = pattern # use internal naming convention
normalized_pattern = _get_normalized_pattern(raw_pattern, version_pattern)
isatty = getattr(sys.stdout, 'isatty', lambda: False)
if isatty():
colorama.init()
try:
_grep(normalized_pattern, files, color=True)
finally:
colorama.deinit()
else:
_grep(normalized_pattern, files, color=False)
@cli.command()
@click.option('-v', '--verbose', count=True, help="Control log level. -vv for debug level.")
@click.option(
"-f/-n", "--fetch/--no-fetch", is_flag=True, default=True, help="Sync tags from remote origin."
)
def show(verbose: int = 0, fetch: bool = True) -> None:
"""Show current version of your project."""
_configure_logging(verbose=max(_VERBOSE, verbose))
_, cfg = config.init(project_path=".")
if cfg is None:
logger.error("Could not parse configuration. Perhaps try 'calver init'.")
sys.exit(1)
cfg = _update_cfg_from_vcs(cfg, fetch)
click.echo(f"Current Version: {cfg.current_version}")
click.echo(f"PEP440 : {cfg.pep440_version}")
def _colored_diff_lines(diff: str) -> typ.Iterable[str]:
for line in diff.splitlines():
if line.startswith("+++") or line.startswith("---"):
yield line
elif line.startswith("+"):
yield "\u001b[32m" + line + "\u001b[0m"
elif line.startswith("-"):
yield "\u001b[31m" + line + "\u001b[0m"
elif line.startswith("@"):
yield "\u001b[36m" + line + "\u001b[0m"
else:
yield line
def _v2_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)
def _v1_get_diff(cfg: config.Config, new_version: str) -> str:
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)
def get_diff(cfg, new_version) -> str:
if cfg.is_new_pattern:
return _v2_get_diff(cfg, new_version)
else:
return _v1_get_diff(cfg, new_version)
def _print_diff_str(diff: str) -> None:
colored_diff = "\n".join(_colored_diff_lines(diff))
if sys.stdout.isatty():
click.echo(colored_diff)
else:
click.echo(diff)
def _print_diff(cfg: config.Config, new_version: str) -> None:
try:
diff = get_diff(cfg, new_version)
_print_diff_str(diff)
except rewrite.NoPatternMatch as ex:
logger.error(str(ex))
sys.exit(1)
def incr_dispatch(
old_version: str,
raw_pattern: str,
*,
major : bool = False,
minor : bool = False,
patch : bool = False,
tag : str = None,
tag_num : bool = False,
pin_date: bool = False,
date : typ.Optional[dt.date] = None,
) -> typ.Optional[str]:
v1_parts = list(v1patterns.PART_PATTERNS) + list(v1patterns.FULL_PART_FORMATS)
has_v1_part = any("{" + part + "}" in raw_pattern for part in v1_parts)
if _VERBOSE:
if has_v1_part:
pattern = v1patterns.compile_pattern(raw_pattern)
else:
pattern = v2patterns.compile_pattern(raw_pattern)
logger.info("Using pattern " + raw_pattern)
logger.info("regex = " + regexfmt.pyexpr_regex(pattern.regexp.pattern))
if has_v1_part:
new_version = v1version.incr(
old_version,
raw_pattern=raw_pattern,
major=major,
minor=minor,
patch=patch,
tag=tag,
tag_num=tag_num,
pin_date=pin_date,
date=date,
)
else:
new_version = v2version.incr(
old_version,
raw_pattern=raw_pattern,
major=major,
minor=minor,
patch=patch,
tag=tag,
tag_num=tag_num,
pin_date=pin_date,
date=date,
)
if new_version is None:
return None
elif pkg_resources.parse_version(new_version) <= pkg_resources.parse_version(old_version):
logger.error("Invariant violated: New version must be greater than old version ")
logger.error(f" Failed Invariant: '{new_version}' > '{old_version}'")
return None
else:
return new_version
def _bump(
cfg : config.Config,
new_version : str,
commit_message: str,
allow_dirty : bool = False,
) -> None:
vcs_api: typ.Optional[vcs.VCSAPI] = None
if cfg.commit:
try:
vcs_api = vcs.get_vcs_api()
except OSError:
logger.warning("Version Control System not found, skipping commit.")
filepaths = set(cfg.file_patterns.keys())
if vcs_api:
vcs.assert_not_dirty(vcs_api, filepaths, allow_dirty)
try:
if cfg.is_new_pattern:
new_v2_vinfo = v2version.parse_version_info(new_version, cfg.version_pattern)
v2rewrite.rewrite_files(cfg.file_patterns, new_v2_vinfo)
else:
new_v1_vinfo = v1version.parse_version_info(new_version, cfg.version_pattern)
v1rewrite.rewrite_files(cfg.file_patterns, new_v1_vinfo)
except rewrite.NoPatternMatch as ex:
logger.error(str(ex))
sys.exit(1)
if vcs_api:
vcs.commit(cfg, vcs_api, filepaths, new_version, commit_message)
def _try_bump(
cfg : config.Config,
new_version : str,
commit_message: str,
allow_dirty : bool = False,
) -> None:
try:
_bump(cfg, new_version, commit_message, allow_dirty)
except sp.CalledProcessError as ex:
logger.error(f"Error running subcommand: {ex.cmd}")
if ex.stdout:
sys.stdout.write(ex.stdout.decode('utf-8'))
if ex.stderr:
sys.stderr.write(ex.stderr.decode('utf-8'))
sys.exit(1)
@cli.command()
@click.option('-v', '--verbose', count=True, help="Control log level. -vv for debug level.")
@click.option(
'-d', "--dry", default=False, is_flag=True, help="Display diff of changes, don't rewrite files."
)
def init(verbose: int = 0, dry: bool = False) -> None:
"""Initialize [pycalver] configuration."""
_configure_logging(verbose=max(_VERBOSE, verbose))
ctx, cfg = config.init(project_path=".", cfg_missing_ok=True)
if cfg:
logger.error(f"Configuration already initialized in {ctx.config_rel_path}")
sys.exit(1)
if dry:
click.echo(f"Exiting because of '-d/--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)
config.write_content(ctx)
def get_latest_vcs_version_tag(cfg: config.Config, fetch: bool) -> typ.Optional[str]:
all_tags = vcs.get_tags(fetch=fetch)
if cfg.is_new_pattern:
version_tags = [tag for tag in all_tags if v2version.is_valid(tag, cfg.version_pattern)]
else:
version_tags = [tag for tag in all_tags if v1version.is_valid(tag, cfg.version_pattern)]
if version_tags:
version_tags.sort(key=pkg_resources.parse_version, reverse=True)
_debug_tags = ", ".join(version_tags[:3])
logger.debug(f"found tags: {_debug_tags} ... ({len(version_tags)} in total)")
return version_tags[0]
else:
return None
def _update_cfg_from_vcs(cfg: config.Config, fetch: bool) -> config.Config:
latest_version_tag = get_latest_vcs_version_tag(cfg, fetch)
if latest_version_tag is None:
logger.debug("no vcs tags found")
return cfg
else:
latest_version_pep440 = version.to_pep440(latest_version_tag)
if latest_version_tag <= cfg.current_version:
# current_version already newer/up-to-date
return cfg
else:
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,
)
@cli.command()
@click.option(
"-v",
"--verbose",
count=True,
help="Control log level. -vv for debug level.",
)
@click.option(
"-f/-n",
"--fetch/--no-fetch",
is_flag=True,
default=True,
help="Sync tags from remote origin.",
)
@click.option(
"-d",
"--dry",
default=False,
is_flag=True,
help="Display diff of changes, don't rewrite files.",
)
@click.option(
"--allow-dirty",
default=False,
is_flag=True,
help=(
"Commit even when working directory is has uncomitted changes. "
"(WARNING: The commit will still be aborted if there are uncomitted "
"to files with version strings."
),
)
@click.option("--major", is_flag=True, default=False, help="Increment major component.")
@click.option("-m", "--minor", is_flag=True, default=False, help="Increment minor component.")
@click.option("-p", "--patch", is_flag=True, default=False, help="Increment patch component.")
@click.option(
"-t",
"--tag",
default=None,
metavar="<NAME>",
help=(
f"Override release tag of current_version. Valid options are: "
f"{', '.join(VALID_RELEASE_TAG_VALUES)}."
),
)
@click.option(
"--tag-num",
is_flag=True,
default=False,
help="Increment release tag number (rc1, rc2, rc3..).",
)
@click.option("--pin-date", is_flag=True, default=False, help="Leave date components unchanged.")
@click.option(
"--date",
default=None,
metavar="<ISODATE>",
help=f"Set explicit date in format YYYY-0M-0D (e.g. {_current_date}).",
)
def bump(
verbose : int = 0,
dry : bool = False,
allow_dirty: bool = False,
fetch : bool = True,
major : bool = False,
minor : bool = False,
patch : bool = False,
tag : typ.Optional[str] = None,
tag_num : bool = False,
pin_date : bool = False,
date : typ.Optional[str] = None,
) -> None:
"""Increment the current version string and update project files."""
verbose = max(_VERBOSE, verbose)
_configure_logging(verbose)
_validate_release_tag(tag)
_date = _validate_date(date, pin_date)
_, cfg = config.init(project_path=".")
if cfg is None:
logger.error("Could not parse configuration. Perhaps try 'pycalver init'.")
sys.exit(1)
cfg = _update_cfg_from_vcs(cfg, fetch)
old_version = cfg.current_version
new_version = incr_dispatch(
old_version,
raw_pattern=cfg.version_pattern,
major=major,
minor=minor,
patch=patch,
tag=tag,
tag_num=tag_num,
pin_date=pin_date,
date=_date,
)
if new_version is None:
_log_no_change('bump', cfg.version_pattern, old_version)
sys.exit(1)
logger.info(f"Old Version: {old_version}")
logger.info(f"New Version: {new_version}")
if dry or verbose >= 2:
_print_diff(cfg, new_version)
if dry:
return
commit_message_kwargs = {
'new_version' : new_version,
'old_version' : 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)
_try_bump(cfg, new_version, commit_message, allow_dirty)
if __name__ == '__main__':
cli()

581
src/pycalver2/config.py Normal file
View file

@ -0,0 +1,581 @@
# This file is part of the pycalver project
# https://gitlab.com/mbarkhau/pycalver
#
# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
"""Parse setup.cfg or pycalver.cfg files."""
import re
import glob
import typing as typ
import logging
import datetime as dt
import configparser
import toml
import pathlib2 as pl
from . import version
from . import v1version
from . import v2version
from . import v1patterns
from . import v2patterns
from .patterns import Pattern
logger = logging.getLogger("pycalver2.config")
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"]
DEFAULT_COMMIT_MESSAGE = "bump version to {new_version}"
class ProjectContext(typ.NamedTuple):
"""Container class for project info."""
path : pl.Path
config_filepath: pl.Path
config_rel_path: str
config_format : str
vcs_type : typ.Optional[str]
def init_project_ctx(project_path: typ.Union[str, pl.Path, None] = ".") -> ProjectContext:
"""Initialize ProjectContext from a path."""
if isinstance(project_path, pl.Path):
path = project_path
elif project_path is None:
path = pl.Path(".")
else:
# assume it's a str/unicode
path = pl.Path(project_path)
if (path / "pycalver.toml").exists():
config_filepath = path / "pycalver.toml"
config_format = 'toml'
elif (path / "pyproject.toml").exists():
config_filepath = path / "pyproject.toml"
config_format = 'toml'
elif (path / "setup.cfg").exists():
config_filepath = path / "setup.cfg"
config_format = 'cfg'
else:
# fallback to creating a new pycalver.toml
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():
vcs_type = 'git'
elif (path / ".hg").exists():
vcs_type = 'hg'
else:
vcs_type = None
return ProjectContext(path, config_filepath, config_rel_path, config_format, vcs_type)
RawConfig = typ.Dict[str, typ.Any]
MaybeRawConfig = typ.Optional[RawConfig]
class Config(typ.NamedTuple):
"""Container for parameters parsed from a config file."""
current_version: str
version_pattern: str
pep440_version : str
commit_message : str
commit : bool
tag : bool
push : bool
is_new_pattern: bool
file_patterns: PatternsByFile
MaybeConfig = typ.Optional[Config]
def _debug_str(cfg: Config) -> str:
cfg_str_parts = [
"Config Parsed: Config(",
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 sorted(cfg.file_patterns.items()):
for pattern in patterns:
cfg_str_parts.append(f"\n '{filepath}': '{pattern.raw_pattern}',")
cfg_str_parts += ["\n }\n)"]
return "".join(cfg_str_parts)
def _parse_cfg_file_patterns(
cfg_parser: configparser.RawConfigParser,
) -> typ.Iterable[FileRawPatternsItem]:
if not cfg_parser.has_section("pycalver:file_patterns"):
return
file_pattern_items: typ.List[typ.Tuple[str, str]] = cfg_parser.items("pycalver:file_patterns")
for filepath, patterns_str in file_pattern_items:
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):
# pylint:disable=too-many-ancestors ; from our perspective, it's just one
"""Custom parser, simply to override optionxform behaviour."""
def optionxform(self, optionstr: str) -> str:
"""Non-xforming (ie. uppercase preserving) override.
This is important because our option names are actually
filenames, so case sensitivity is relevant. The default
behaviour is to do optionstr.lower()
"""
return optionstr
OptionVal = typ.Union[str, bool, None]
BOOL_OPTIONS: typ.Mapping[str, OptionVal] = {'commit': False, 'tag': None, 'push': None}
def _parse_cfg(cfg_buffer: typ.IO[str]) -> RawConfig:
cfg_parser = _ConfigParser()
if hasattr(cfg_parser, 'read_file'):
cfg_parser.read_file(cfg_buffer)
else:
cfg_parser.readfp(cfg_buffer) # python2 compat
if not cfg_parser.has_section("pycalver"):
raise ValueError("Missing [pycalver] section.")
raw_cfg: RawConfig = dict(cfg_parser.items("pycalver"))
for option, default_val in BOOL_OPTIONS.items():
val: OptionVal = raw_cfg.get(option, default_val)
if isinstance(val, (bytes, str)):
val = val.lower() in ("yes", "true", "1", "on")
raw_cfg[option] = val
raw_cfg['file_patterns'] = dict(_parse_cfg_file_patterns(cfg_parser))
_set_raw_config_defaults(raw_cfg)
return raw_cfg
def _parse_toml(cfg_buffer: typ.IO[str]) -> RawConfig:
raw_full_cfg: typ.Any = toml.load(cfg_buffer)
raw_cfg : RawConfig = raw_full_cfg.get('pycalver', {})
for option, default_val in BOOL_OPTIONS.items():
raw_cfg[option] = raw_cfg.get(option, default_val)
_set_raw_config_defaults(raw_cfg)
return raw_cfg
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.
"""
# current_version: str = raw_cfg['current_version']
# current_pep440_version = version.pep440_version(current_version)
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 = v1patterns.compile_patterns(version_pattern, raw_patterns)
yield filepath, compiled_patterns
def _compile_v2_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_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_patterns(version_pattern, raw_patterns)
yield filepath, compiled_patterns
def _compile_file_patterns(raw_cfg: RawConfig, is_new_pattern: bool) -> PatternsByFile:
if is_new_pattern:
_file_pattern_items = _compile_v2_file_patterns(raw_cfg)
else:
_file_pattern_items = _compile_v1_file_patterns(raw_cfg)
# NOTE (mb 2020-10-03): There can be multiple items for the same
# path, so this is not an option:
#
# return dict(_file_pattern_items)
file_patterns: PatternsByFile = {}
for path, patterns in _file_pattern_items:
if path in file_patterns:
file_patterns[path].extend(patterns)
else:
file_patterns[path] = patterns
return file_patterns
def _validate_version_with_pattern(
current_version: str,
version_pattern: str,
is_new_pattern : bool,
) -> None:
"""Provoke ValueError if version_pattern and current_version are not compatible."""
if is_new_pattern:
v2version.parse_version_info(current_version, version_pattern)
else:
v1version.parse_version_info(current_version, version_pattern)
if is_new_pattern:
invalid_chars = re.search(r"([\s]+)", version_pattern)
if invalid_chars:
errmsg = (
f"Invalid character(s) '{invalid_chars.group(1)}'"
f' in pycalver.version_pattern = "{version_pattern}"'
)
raise ValueError(errmsg)
if not v2version.is_valid_week_pattern(version_pattern):
errmsg = f"Invalid week number pattern: {version_pattern}"
raise ValueError(errmsg)
def _parse_config(raw_cfg: RawConfig) -> Config:
"""Parse configuration which was loaded from an .ini/.cfg or .toml file."""
commit_message: str = raw_cfg.get('commit_message', DEFAULT_COMMIT_MESSAGE)
commit_message = raw_cfg['commit_message'] = commit_message.strip("'\" ")
current_version: str = raw_cfg['current_version']
current_version = raw_cfg['current_version'] = current_version.strip("'\" ")
version_pattern: str = raw_cfg['version_pattern']
version_pattern = raw_cfg['version_pattern'] = version_pattern.strip("'\" ")
is_new_pattern = "{" not in version_pattern and "}" not in version_pattern
_validate_version_with_pattern(current_version, version_pattern, is_new_pattern)
pep440_version = version.to_pep440(current_version)
file_patterns = _compile_file_patterns(raw_cfg, is_new_pattern)
commit = raw_cfg['commit']
tag = raw_cfg['tag']
push = raw_cfg['push']
if tag is None:
tag = raw_cfg['tag'] = False
if push is None:
push = raw_cfg['push'] = False
if tag and not commit:
raise ValueError("pycalver.commit = true required if pycalver.tag = true")
if push and not commit:
raise ValueError("pycalver.commit = true required if pycalver.push = true")
cfg = Config(
current_version=current_version,
version_pattern=version_pattern,
pep440_version=pep440_version,
commit_message=commit_message,
commit=commit,
tag=tag,
push=push,
is_new_pattern=is_new_pattern,
file_patterns=file_patterns,
)
logger.debug(_debug_str(cfg))
return cfg
def _parse_current_version_default_pattern(raw_cfg: RawConfig, raw_cfg_text: str) -> str:
is_pycalver_section = False
for line in raw_cfg_text.splitlines():
if is_pycalver_section and line.startswith("current_version"):
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
elif line and line[0] == "[" and line[-1] == "]":
is_pycalver_section = False
raise ValueError("Could not parse pycalver.current_version")
def _set_raw_config_defaults(raw_cfg: RawConfig) -> None:
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'] = {}
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 ctx.config_rel_path not in raw_cfg['file_patterns']:
with ctx.config_filepath.open(mode="rt", encoding="utf-8") as fobj:
raw_cfg_text = fobj.read()
# 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(raw_cfg, raw_cfg_text)
raw_cfg['file_patterns'][ctx.config_rel_path] = [raw_version_pattern]
return raw_cfg
def parse(ctx: ProjectContext, cfg_missing_ok: bool = False) -> MaybeConfig:
"""Parse config file if available."""
if ctx.config_filepath.exists():
try:
raw_cfg = _parse_raw_config(ctx)
return _parse_config(raw_cfg)
except (TypeError, ValueError) as ex:
logger.warning(f"Couldn't parse {ctx.config_rel_path}: {str(ex)}")
return None
else:
if not cfg_missing_ok:
logger.warning(f"File not found: {ctx.config_rel_path}")
return None
def init(
project_path : typ.Union[str, pl.Path, None] = ".",
cfg_missing_ok: bool = False,
) -> typ.Tuple[ProjectContext, MaybeConfig]:
ctx = init_project_ctx(project_path)
cfg = parse(ctx, cfg_missing_ok)
return (ctx, cfg)
DEFAULT_CONFIGPARSER_BASE_TMPL = """
[pycalver]
current_version = "{initial_version}"
version_pattern = "vYYYY.BUILD[-TAG]"
commit_message = "bump version {{old_version}} -> {{new_version}}"
commit = True
tag = True
push = True
[pycalver:file_patterns]
""".lstrip()
DEFAULT_CONFIGPARSER_SETUP_CFG_STR = """
setup.cfg =
current_version = "{version}"
""".lstrip()
DEFAULT_CONFIGPARSER_SETUP_PY_STR = """
setup.py =
"{version}"
"{pep440_version}"
""".lstrip()
DEFAULT_CONFIGPARSER_README_RST_STR = """
README.rst =
{version}
{pep440_version}
""".lstrip()
DEFAULT_CONFIGPARSER_README_MD_STR = """
README.md =
{version}
{pep440_version}
""".lstrip()
DEFAULT_TOML_BASE_TMPL = """
[pycalver]
current_version = "{initial_version}"
version_pattern = "vYYYY.BUILD[-TAG]"
commit_message = "bump version {{old_version}} -> {{new_version}}"
commit = true
tag = true
push = true
[pycalver.file_patterns]
""".lstrip()
DEFAULT_TOML_PYCALVER_STR = """
"pycalver.toml" = [
'current_version = "{version}"',
]
""".lstrip()
DEFAULT_TOML_PYPROJECT_STR = """
"pyproject.toml" = [
'current_version = "{version}"',
]
""".lstrip()
DEFAULT_TOML_SETUP_PY_STR = """
"setup.py" = [
"{version}",
"{pep440_version}",
]
""".lstrip()
DEFAULT_TOML_README_RST_STR = """
"README.rst" = [
"{version}",
"{pep440_version}",
]
""".lstrip()
DEFAULT_TOML_README_MD_STR = """
"README.md" = [
"{version}",
"{pep440_version}",
]
""".lstrip()
def _initial_version() -> str:
return dt.datetime.utcnow().strftime("v%Y.1001-alpha")
def _initial_version_pep440() -> str:
return dt.datetime.utcnow().strftime("%Y.1001a0")
def default_config(ctx: ProjectContext) -> str:
"""Generate initial default config."""
fmt = ctx.config_format
if fmt == 'cfg':
base_tmpl = DEFAULT_CONFIGPARSER_BASE_TMPL
default_pattern_strs_by_filename = {
"setup.cfg" : DEFAULT_CONFIGPARSER_SETUP_CFG_STR,
"setup.py" : DEFAULT_CONFIGPARSER_SETUP_PY_STR,
"README.rst": DEFAULT_CONFIGPARSER_README_RST_STR,
"README.md" : DEFAULT_CONFIGPARSER_README_MD_STR,
}
elif fmt == 'toml':
base_tmpl = DEFAULT_TOML_BASE_TMPL
default_pattern_strs_by_filename = {
"pyproject.toml": DEFAULT_TOML_PYPROJECT_STR,
"pycalver.toml" : DEFAULT_TOML_PYCALVER_STR,
"setup.py" : DEFAULT_TOML_SETUP_PY_STR,
"README.rst" : DEFAULT_TOML_README_RST_STR,
"README.md" : DEFAULT_TOML_README_MD_STR,
}
else:
raise ValueError(f"Invalid config_format='{fmt}', must be either 'toml' or 'cfg'.")
cfg_str = base_tmpl.format(initial_version=_initial_version())
for filename, default_str in default_pattern_strs_by_filename.items():
if (ctx.path / filename).exists():
cfg_str += default_str
has_config_file = any((ctx.path / fn).exists() for fn in SUPPORTED_CONFIGS)
if not has_config_file:
if ctx.config_format == 'cfg':
cfg_str += DEFAULT_CONFIGPARSER_SETUP_CFG_STR
if ctx.config_format == 'toml':
cfg_str += DEFAULT_TOML_PYCALVER_STR
cfg_str += "\n"
return cfg_str
def write_content(ctx: ProjectContext) -> None:
"""Update project config file with initial default config."""
fobj: typ.IO[str]
cfg_content = default_config(ctx)
if ctx.config_filepath.exists():
cfg_content = "\n" + cfg_content
with ctx.config_filepath.open(mode="at", encoding="utf-8") as fobj:
fobj.write(cfg_content)
print(f"Updated {ctx.config_rel_path}")

85
src/pycalver2/parse.py Normal file
View file

@ -0,0 +1,85 @@
# 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
"""Parse PyCalVer strings from files."""
import typing as typ
from .patterns import Pattern
LineNo = int
Start = int
End = int
class LineSpan(typ.NamedTuple):
lineno: LineNo
start : Start
end : End
LineSpans = typ.List[LineSpan]
def _has_overlap(needle: LineSpan, haystack: LineSpans) -> bool:
for span in haystack:
# assume needle is in the center
has_overlap = (
span.lineno == needle.lineno
# needle starts before (or at) span end
and needle.start <= span.end
# needle ends after (or at) span start
and needle.end >= span.start
)
if has_overlap:
return True
return False
class PatternMatch(typ.NamedTuple):
"""Container to mark a version string in a file."""
lineno : LineNo # zero based
line : str
pattern: Pattern
span : typ.Tuple[Start, End]
match : str
PatternMatches = typ.Iterable[PatternMatch]
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[Pattern]) -> PatternMatches:
"""Iterate over all matches of any pattern on any line.
>>> from . import v1patterns
>>> lines = ["__version__ = 'v201712.0002-alpha'"]
>>> 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(version_pattern),
... span = (15, 33),
... match = "v201712.0002-alpha",
... )
"""
matched_spans: LineSpans = []
for pattern in patterns:
for match in _iter_for_pattern(lines, pattern):
needle_span = LineSpan(match.lineno, *match.span)
if not _has_overlap(needle_span, matched_spans):
yield match
matched_spans.append(needle_span)

29
src/pycalver2/patterns.py Normal file
View file

@ -0,0 +1,29 @@
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
import typing as typ
class Pattern(typ.NamedTuple):
version_pattern: str # "{pycalver}", "{year}.{month}", "vYYYY0M.BUILD"
raw_pattern : str # '__version__ = "{version}"', "Copyright (c) YYYY"
regexp : typ.Pattern[str]
RE_PATTERN_ESCAPES = [
("\u005c", "\u005c\u005c"),
("-" , "\u005c-"),
("." , "\u005c."),
("+" , "\u005c+"),
("*" , "\u005c*"),
("?" , "\u005c?"),
("{" , "\u005c{"),
("}" , "\u005c}"),
("[" , "\u005c["),
("]" , "\u005c]"),
("(" , "\u005c("),
(")" , "\u005c)"),
]

47
src/pycalver2/pysix.py Normal file
View file

@ -0,0 +1,47 @@
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
import sys
import typing as typ
PY2 = sys.version_info.major < 3
try:
from urllib.parse import quote as py3_stdlib_quote
except ImportError:
from urllib import quote as py2_stdlib_quote # type: ignore
# NOTE (mb 2016-05-23): quote in python2 expects bytes argument.
def quote(
string : str,
safe : str = "/",
encoding: typ.Optional[str] = None,
errors : typ.Optional[str] = None,
) -> str:
if not isinstance(string, str):
errmsg = f"Expected str/unicode but got {type(string)}" # type: ignore
raise TypeError(errmsg)
if encoding is None:
_encoding = "utf-8"
else:
_encoding = encoding
if errors is None:
_errors = "strict"
else:
_errors = errors
if PY2:
data = string.encode(_encoding)
res = py2_stdlib_quote(data, safe=safe.encode(_encoding))
return res.decode(_encoding, errors=_errors)
else:
return py3_stdlib_quote(string, safe=safe, encoding=_encoding, errors=_errors)

76
src/pycalver2/regexfmt.py Normal file
View file

@ -0,0 +1,76 @@
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
import re
import logging
import textwrap
from . import pysix
logger = logging.getLogger("pycalver2.regexfmt")
def format_regex(regex: str) -> str:
r"""Format a regex pattern suitible for flags=re.VERBOSE.
>>> regex = r"\[CalVer v(?P<year_y>[1-9][0-9]{3})(?P<month>(?:1[0-2]|0[1-9]))"
>>> print(format_regex(regex))
\[CalVer[ ]v
(?P<year_y>[1-9][0-9]{3})
(?P<month>
(?:1[0-2]|0[1-9])
)
"""
# provoke error for invalid regex
re.compile(regex)
tmp_regex = regex.replace(" ", r"[ ]")
tmp_regex = tmp_regex.replace('"', r'\"')
tmp_regex, _ = re.subn(r"([^\\])?\)(\?)?", "\\1)\\2\n", tmp_regex)
tmp_regex, _ = re.subn(r"([^\\])\(" , "\\1\n(" , tmp_regex)
tmp_regex, _ = re.subn(r"^\)\)" , ")\n)" , tmp_regex, flags=re.MULTILINE)
lines = tmp_regex.splitlines()
indented_lines = []
level = 0
for line in lines:
if line.strip():
increment = line.count("(") - line.count(")")
if increment >= 0:
line = " " * level + line
level += increment
else:
level += increment
line = " " * level + line
indented_lines.append(line)
formatted_regex = "\n".join(indented_lines)
# provoke error if there is a bug in the formatting code
re.compile(formatted_regex)
return formatted_regex
def pyexpr_regex(regex: str) -> str:
try:
formatted_regex = format_regex(regex)
formatted_regex = textwrap.indent(formatted_regex.rstrip(), " ")
return 're.compile(r"""\n' + formatted_regex + '\n""", flags=re.VERBOSE)'
except re.error:
return f"re.compile({repr(regex)})"
def regex101_url(regex_pattern: str) -> str:
try:
regex_pattern = format_regex(regex_pattern)
except re.error:
logger.warning(f"Error formatting regex '{repr(regex_pattern)}'")
return "".join(
(
"https://regex101.com/",
"?flavor=python",
"&flags=gmx" "&regex=" + pysix.quote(regex_pattern),
)
)

90
src/pycalver2/rewrite.py Normal file
View file

@ -0,0 +1,90 @@
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
import typing as typ
import difflib
import pathlib2 as pl
from . import config
from .patterns import Pattern
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:
r"""Parse line separator from content.
>>> detect_line_sep('\r\n')
'\r\n'
>>> detect_line_sep('\r')
'\r'
>>> detect_line_sep('\n')
'\n'
>>> detect_line_sep('')
'\n'
"""
if "\r\n" in content:
return "\r\n"
elif "\r" in content:
return "\r"
else:
return "\n"
class RewrittenFileData(typ.NamedTuple):
"""Container for line-wise content of rewritten files."""
path : str
line_sep : str
old_lines: typ.List[str]
new_lines: typ.List[str]
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)
def diff_lines(rfd: RewrittenFileData) -> typ.List[str]:
r"""Generate unified diff.
>>> rfd = RewrittenFileData(
... path = "<path>",
... line_sep = "\n",
... old_lines = ["foo"],
... new_lines = ["bar"],
... )
>>> diff_lines(rfd)
['--- <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,
)
return list(lines)

24
src/pycalver2/utils.py Normal file
View file

@ -0,0 +1,24 @@
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
import typing as typ
import functools
# NOTE (mb 2020-09-24): The main use of the memo function is
# not as a performance optimization, but to reduce logging
# spam.
def memo(func: typ.Callable) -> typ.Callable:
cache = {}
@functools.wraps(func)
def wrapper(*args):
key = str(args)
if key not in cache:
cache[key] = func(*args)
return cache[key]
return wrapper

239
src/pycalver2/v1patterns.py Normal file
View file

@ -0,0 +1,239 @@
# 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 . import utils
from .patterns import RE_PATTERN_ESCAPES
from .patterns import Pattern
logger = logging.getLogger("pycalver2.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 treated specially in v1version.format_version
# '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(normalized_pattern: str) -> typ.Pattern[str]:
escaped_pattern = normalized_pattern
for char, escaped in RE_PATTERN_ESCAPES:
escaped_pattern = escaped_pattern.replace(char, escaped)
pattern_str = _replace_pattern_parts(escaped_pattern)
return re.compile(pattern_str)
def _normalized_pattern(version_pattern: str, raw_pattern: str) -> str:
res = raw_pattern.replace(r"{version}", version_pattern)
if version_pattern == r"{pycalver}":
res = res.replace(r"{pep440_version}", r"{pep440_pycalver}")
elif version_pattern == r"{semver}":
res = res.replace(r"{pep440_version}", r"{semver}")
elif version_pattern == r"v{year}{month}{build}{release}":
res = res.replace(r"{pep440_version}", r"{year}{month}.{BID}{pep440_tag}")
elif version_pattern == r"{year}{month}{build}{release}":
res = res.replace(r"{pep440_version}", r"{year}{month}.{BID}{pep440_tag}")
elif version_pattern == r"v{year}{build}{release}":
res = res.replace(r"{pep440_version}", r"{year}.{BID}{pep440_tag}")
elif version_pattern == r"{year}{build}{release}":
res = res.replace(r"{pep440_version}", r"{year}.{BID}{pep440_tag}")
elif r"{pep440_version}" in raw_pattern:
logger.warning(f"No mapping of '{version_pattern}' to '{{pep440_version}}'")
return res
@utils.memo
def compile_pattern(version_pattern: str, raw_pattern: typ.Optional[str] = None) -> Pattern:
_raw_pattern = version_pattern if raw_pattern is None else raw_pattern
normalized_pattern = _normalized_pattern(version_pattern, _raw_pattern)
regexp = _compile_pattern_re(normalized_pattern)
return Pattern(version_pattern, normalized_pattern, regexp)
def compile_patterns(version_pattern: str, raw_patterns: typ.List[str]) -> typ.List[Pattern]:
return [compile_pattern(version_pattern, raw_pattern) for raw_pattern in raw_patterns]

154
src/pycalver2/v1rewrite.py Normal file
View file

@ -0,0 +1,154 @@
# 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 regexfmt
from . import v1version
from .patterns import Pattern
logger = logging.getLogger("pycalver2.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."""
found_patterns: typ.Set[Pattern] = set()
new_lines = old_lines[:]
for match in parse.iter_matches(old_lines, patterns):
found_patterns.add(match.pattern)
replacement = v1version.format_version(new_vinfo, match.pattern.raw_pattern)
span_l, span_r = match.span
new_line = match.line[:span_l] + replacement + match.line[span_r:]
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}'")
msg = (
"\n# "
+ regexfmt.regex101_url(nmp.regexp.pattern)
+ "\nregex = "
+ regexfmt.pyexpr_regex(nmp.regexp.pattern)
)
logger.error(msg)
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.
>>> version_pattern = "{pycalver}"
>>> new_vinfo = v1version.parse_version_info("v201809.0123")
>>> from .v1patterns import compile_pattern
>>> patterns = [compile_pattern(version_pattern, '__version__ = "{pycalver}"')]
>>> 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]:
"""Iterate over files with version string replaced."""
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:
"""Generate diffs of rewritten files."""
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 '{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 '{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)

428
src/pycalver2/v1version.py Normal file
View file

@ -0,0 +1,428 @@
# 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
from . import version
from . import v1patterns
logger = logging.getLogger("pycalver2.v1version")
CalInfo = typ.Union[version.V1CalendarInfo, version.V1VersionInfo]
def _is_cal_gt(left: CalInfo, right: CalInfo) -> bool:
"""Is left > right for non-None fields."""
lvals = []
rvals = []
for field in version.V1CalendarInfo._fields:
lval = getattr(left , field)
rval = getattr(right, field)
if not (lval is None or rval is None):
lvals.append(lval)
rvals.append(rval)
return lvals > rvals
def _ver_to_cal_info(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
if year is not None and year < 100:
year += 2000
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': "18", 'month': "11"})
>>> (vnfo.year, vnfo.month, vnfo.quarter)
(2018, 11, 4)
>>> 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.pattern}'"
)
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()
release_tag = vinfo.tag
if release_tag == 'final':
kwargs['release' ] = ""
kwargs['pep440_tag'] = ""
else:
kwargs['release' ] = "-" + release_tag
kwargs['pep440_tag'] = version.PEP440_TAG_BY_TAG[release_tag] + "0"
kwargs['release_tag'] = release_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}",
*,
major : bool = False,
minor : bool = False,
patch : bool = False,
tag : typ.Optional[str] = None,
tag_num : bool = False,
pin_date: bool = False,
date : typ.Optional[dt.date] = None,
) -> 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(date)
if _is_cal_gt(old_vinfo, cur_cinfo):
logger.warning(f"Old version appears to be from the future '{old_version}'")
cur_vinfo = old_vinfo
else:
cur_vinfo = old_vinfo._replace(**cur_cinfo._asdict())
cur_vinfo = cur_vinfo._replace(bid=lexid.next_id(cur_vinfo.bid))
if 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 tag_num:
raise NotImplementedError("--tag-num not supported for old style patterns")
if tag:
cur_vinfo = cur_vinfo._replace(tag=tag)
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

344
src/pycalver2/v2patterns.py Normal file
View file

@ -0,0 +1,344 @@
# 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() == {
... "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() == {
... "year_y" : "2017",
... "month" : "12",
... "bid" : "1234",
... "tag" : None,
... }
"""
import re
import typing as typ
import logging
from . import utils
from .patterns import RE_PATTERN_ESCAPES
from .patterns import Pattern
logger = logging.getLogger("pycalver2.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"preview|final|alpha|beta|post|rc",
'PYTAG': r"post|rc|a|b",
'NUM' : r"[0-9]+",
'INC0' : r"[0-9]+",
'INC1' : r"[1-9][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',
'INC0' : 'inc0',
'INC1' : 'inc1',
'WW' : 'week_w',
'0W' : 'week_w',
'UU' : 'week_u',
'0U' : 'week_u',
'VV' : 'week_v',
'0V' : 'week_v',
}
PEP440_PART_SUBSTITUTIONS = {
'0W' : "WW",
'0U' : "UU",
'0V' : "VV",
'0M' : "MM",
'0D' : "DD",
'00J' : "JJJ",
'BUILD': "BLD",
'TAG' : "PYTAG",
}
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))
FormatterFunc = typ.Callable[[FieldValue], str]
PART_FORMATS: typ.Dict[str, FormatterFunc] = {
'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,
'INC0' : _fmt_num,
'INC1' : _fmt_num,
'WW' : _fmt_num,
'0W' : _fmt_0w,
'UU' : _fmt_num,
'0U' : _fmt_0u,
'VV' : _fmt_num,
'0V' : _fmt_0v,
}
def _convert_to_pep440(version_pattern: str) -> str:
# NOTE (mb 2020-09-20): This does not support some
# corner cases as specified in PEP440, in particular
# related to post and dev releases.
pep440_pattern = version_pattern
if pep440_pattern.startswith("v"):
pep440_pattern = pep440_pattern[1:]
pep440_pattern = pep440_pattern.replace(r"\[", "")
pep440_pattern = pep440_pattern.replace(r"\]", "")
pep440_pattern, _ = re.subn(r"[^a-zA-Z0-9\.\[\]]", "", pep440_pattern)
part_names = list(PATTERN_PART_FIELDS.keys())
part_names.sort(key=len, reverse=True)
for part_name in part_names:
if part_name not in version_pattern:
continue
if part_name not in PEP440_PART_SUBSTITUTIONS:
continue
substitution = PEP440_PART_SUBSTITUTIONS[part_name]
is_numerical_part = part_name not in ('TAG', 'PYTAG')
if is_numerical_part:
part_index = pep440_pattern.find(part_name)
is_zero_truncation_part = part_index == 0 or pep440_pattern[part_index - 1] == "."
if is_zero_truncation_part:
pep440_pattern = pep440_pattern.replace(part_name, substitution)
else:
pep440_pattern = pep440_pattern.replace(part_name, substitution)
# PYTAG and NUM must be adjacent and also be the last (optional) part
if 'PYTAGNUM' not in pep440_pattern:
pep440_pattern = pep440_pattern.replace("PYTAG", "")
pep440_pattern = pep440_pattern.replace("NUM" , "")
pep440_pattern = pep440_pattern.replace("[]" , "")
pep440_pattern += "[PYTAGNUM]"
return pep440_pattern
def normalize_pattern(version_pattern: str, raw_pattern: str) -> str:
normalized_pattern = raw_pattern
if "{version}" in raw_pattern:
normalized_pattern = normalized_pattern.replace("{version}", version_pattern)
if "{pep440_version}" in normalized_pattern:
pep440_version_pattern = _convert_to_pep440(version_pattern)
normalized_pattern = normalized_pattern.replace("{pep440_version}", pep440_version_pattern)
return normalized_pattern
def _replace_pattern_parts(pattern: str) -> str:
# The pattern is escaped, so that everything besides the format
# string variables is treated literally.
while True:
new_pattern, _n = re.subn(r"([^\\]|^)\[", r"\1(?:", pattern)
new_pattern, _m = re.subn(r"([^\\]|^)\]", r"\1)?" , new_pattern)
pattern = new_pattern
if _n + _m == 0:
break
SortKey = typ.Tuple[int, int]
PostitionedPart = typ.Tuple[int, int, str]
part_patterns_by_index: typ.Dict[SortKey, PostitionedPart] = {}
for part_name, part_pattern in PART_PATTERNS.items():
start_idx = pattern.find(part_name)
if start_idx >= 0:
field = PATTERN_PART_FIELDS[part_name]
named_part_pattern = f"(?P<{field}>{part_pattern})"
end_idx = start_idx + len(part_name)
sort_key = (-end_idx, -len(part_name))
part_patterns_by_index[sort_key] = (start_idx, end_idx, named_part_pattern)
# NOTE (mb 2020-09-17): The sorting is done so that we process items:
# - right before left
# - 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(normalized_pattern: str) -> typ.Pattern[str]:
escaped_pattern = normalized_pattern
for char, escaped in RE_PATTERN_ESCAPES:
# [] braces are used for optional parts, such as [-TAG]/[-beta]
# and need to be escaped manually.
is_semantic_char = char in "[]\\"
if not is_semantic_char:
# escape it so it is a literal in the re pattern
escaped_pattern = escaped_pattern.replace(char, escaped)
pattern_str = _replace_pattern_parts(escaped_pattern)
return re.compile(pattern_str)
@utils.memo
def compile_pattern(version_pattern: str, raw_pattern: typ.Optional[str] = None) -> Pattern:
_raw_pattern = version_pattern if raw_pattern is None else raw_pattern
normalized_pattern = normalize_pattern(version_pattern, _raw_pattern)
regexp = _compile_pattern_re(normalized_pattern)
return Pattern(version_pattern, normalized_pattern, regexp)
def compile_patterns(version_pattern: str, raw_patterns: typ.List[str]) -> typ.List[Pattern]:
return [compile_pattern(version_pattern, raw_pattern) for raw_pattern in raw_patterns]

163
src/pycalver2/v2rewrite.py Normal file
View file

@ -0,0 +1,163 @@
# 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 regexfmt
from . import v2version
from . import v2patterns
from .patterns import Pattern
logger = logging.getLogger("pycalver2.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."""
found_patterns: typ.Set[Pattern] = set()
new_lines = old_lines[:]
for match in parse.iter_matches(old_lines, patterns):
found_patterns.add(match.pattern)
normalized_pattern = v2patterns.normalize_pattern(
match.pattern.version_pattern, match.pattern.raw_pattern
)
replacement = v2version.format_version(new_vinfo, normalized_pattern)
span_l, span_r = match.span
new_line = match.line[:span_l] + replacement + match.line[span_r:]
new_lines[match.lineno] = new_line
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}'")
msg = (
"\n# "
+ regexfmt.regex101_url(nmp.regexp.pattern)
+ "\nregex = "
+ regexfmt.pyexpr_regex(nmp.regexp.pattern)
)
logger.error(msg)
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.
>>> from .v2patterns import compile_pattern
>>> version_pattern = "vYYYY0M.BUILD[-TAG]"
>>> new_vinfo = v2version.parse_version_info("v201809.0123", version_pattern)
>>> patterns = [compile_pattern(version_pattern, '__version__ = "vYYYY0M.BUILD[-TAG]"')]
>>> 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)
>>> patterns = [compile_pattern(version_pattern, '__version__ = "vMAJOR.MINOR.PATCH"')]
>>> 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 _patterns_with_change(
old_vinfo: version.V2VersionInfo, new_vinfo: version.V2VersionInfo, patterns: typ.List[Pattern]
) -> int:
patterns_with_change = 0
for pattern in patterns:
old_str = v2version.format_version(old_vinfo, pattern.raw_pattern)
new_str = v2version.format_version(new_vinfo, pattern.raw_pattern)
if old_str != new_str:
patterns_with_change += 1
return patterns_with_change
def iter_rewritten(
file_patterns: config.PatternsByFile,
new_vinfo : version.V2VersionInfo,
) -> typ.Iterable[rewrite.RewrittenFileData]:
"""Iterate over files with version string replaced."""
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."""
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()
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 '{file_path}'"
raise rewrite.NoPatternMatch(errmsg)
rfd = rfd._replace(path=str(file_path))
lines = rewrite.diff_lines(rfd)
patterns_with_change = _patterns_with_change(old_vinfo, new_vinfo, patterns)
if len(lines) == 0 and patterns_with_change > 0:
errmsg = f"No patterns matched for file '{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)

727
src/pycalver2/v2version.py Normal file
View file

@ -0,0 +1,727 @@
# 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
from . import version
from . import v2patterns
logger = logging.getLogger("pycalver2.v2version")
CalInfo = typ.Union[version.V2CalendarInfo, version.V2VersionInfo]
def _is_cal_gt(left: CalInfo, right: CalInfo) -> bool:
"""Is left > right for non-None fields."""
lvals = []
rvals = []
for field in version.V2CalendarInfo._fields:
lval = getattr(left , field)
rval = getattr(right, field)
if not (lval is None or rval is None):
lvals.append(lval)
rvals.append(rval)
return lvals > rvals
def _ver_to_cal_info(vinfo: version.V2VersionInfo) -> version.V2CalendarInfo:
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_field_values_to_cinfo(field_values: FieldValues) -> version.V2CalendarInfo:
"""Parse normalized V2CalendarInfo from groups of a matched pattern.
>>> cinfo = parse_field_values_to_cinfo({'year_y': "2021", 'week_w': "02"})
>>> (cinfo.year_y, cinfo.week_w)
(2021, 2)
>>> cinfo = parse_field_values_to_cinfo({'year_y': "2021", 'week_u': "02"})
>>> (cinfo.year_y, cinfo.week_u)
(2021, 2)
>>> cinfo = parse_field_values_to_cinfo({'year_g': "2021", 'week_v': "02"})
>>> (cinfo.year_g, cinfo.week_v)
(2021, 2)
>>> cinfo = parse_field_values_to_cinfo({'year_y': "2021", 'month': "01", 'dom': "03"})
>>> (cinfo.year_y, cinfo.month, cinfo.dom)
(2021, 1, 3)
>>> (cinfo.year_y, cinfo.week_w, cinfo.year_y, cinfo.week_u,cinfo.year_g, cinfo.week_v)
(2021, 0, 2021, 1, 2020, 53)
"""
fvals = field_values
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
if year_y is not None and year_y < 1000:
year_y += 2000
if year_g is not None and year_g < 1000:
year_g += 2000
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)
return version.V2CalendarInfo(
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,
)
def parse_field_values_to_vinfo(field_values: FieldValues) -> version.V2VersionInfo:
"""Parse normalized V2VersionInfo from groups of a matched pattern.
>>> vinfo = parse_field_values_to_vinfo({'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_field_values_to_vinfo({'year_y': "18", 'month': "11"})
>>> (vinfo.year_y, vinfo.month, vinfo.quarter)
(2018, 11, 4)
>>> vinfo = parse_field_values_to_vinfo({'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_field_values_to_vinfo({'year_y': "2018", 'month': "6", 'dom': "15"})
>>> (vinfo.year_y, vinfo.month, vinfo.dom, vinfo.doy)
(2018, 6, 15, 166)
>>> vinfo = parse_field_values_to_vinfo({'major': "1", 'minor': "23", 'patch': "45"})
>>> (vinfo.major, vinfo.minor, vinfo.patch)
(1, 23, 45)
>>> vinfo = parse_field_values_to_vinfo({'major': "1", 'minor': "023", 'patch': "0045"})
>>> (vinfo.major, vinfo.minor, vinfo.patch, vinfo.tag)
(1, 23, 45, 'final')
"""
# pylint:disable=dangerous-default-value; We don't mutate args, mypy would fail if we did.
for key in field_values:
assert key in VALID_FIELD_KEYS, key
cinfo = parse_field_values_to_cinfo(field_values)
fvals = field_values
tag = fvals.get('tag' ) or ""
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]
if not tag:
tag = "final"
# 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"
inc0 = int(fvals.get('inc0') or 0)
inc1 = int(fvals.get('inc1') or 1)
return version.V2VersionInfo(
year_y=cinfo.year_y,
year_g=cinfo.year_g,
quarter=cinfo.quarter,
month=cinfo.month,
dom=cinfo.dom,
doy=cinfo.doy,
week_w=cinfo.week_w,
week_u=cinfo.week_u,
week_v=cinfo.week_v,
major=major,
minor=minor,
patch=patch,
num=num,
bid=bid,
tag=tag,
pytag=pytag,
inc0=inc0,
inc1=inc1,
)
def parse_version_info(
version_str: str, raw_pattern: str = "vYYYY0M.BUILD[-TAG]"
) -> version.V2VersionInfo:
"""Parse normalized V2VersionInfo.
>>> vinfo = parse_version_info("v201712.0033-beta", raw_pattern="vYYYY0M.BUILD[-TAG]")
>>> fvals = {'year_y': 2017, 'month': 12, 'bid': "0033", 'tag': "beta"}
>>> assert vinfo == parse_field_values_to_vinfo(fvals)
>>> vinfo = parse_version_info("v201712.0033", raw_pattern="vYYYY0M.BUILD[-TAG]")
>>> fvals = {'year_y': 2017, 'month': 12, 'bid': "0033"}
>>> assert vinfo == parse_field_values_to_vinfo(fvals)
>>> vinfo = parse_version_info("201712.33b0", raw_pattern="YYYY0M.BLD[PYTAGNUM]")
>>> fvals = {'year_y': 2017, 'month': 12, 'bid': "33", 'tag': "beta", 'num': 0}
>>> assert vinfo == parse_field_values_to_vinfo(fvals)
>>> vinfo = parse_version_info("1.23.456", raw_pattern="MAJOR.MINOR.PATCH")
>>> fvals = {'major': "1", 'minor': "23", 'patch': "456"}
>>> assert vinfo == parse_field_values_to_vinfo(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)
elif len(match.group()) < len(version_str):
err_msg = (
f"Incomplete match '{match.group()}' for version string '{version_str}' "
f"with pattern '{raw_pattern}'/'{pattern.regexp.pattern}'"
)
raise version.PatternError(err_msg)
else:
field_values = match.groupdict()
return parse_field_values_to_vinfo(field_values)
def is_valid(version_str: str, raw_pattern: str = "vYYYY.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]]
PartValues = typ.List[typ.Tuple[str, str]]
def _format_part_values(vinfo: version.V2VersionInfo) -> PartValues:
"""Generate kwargs for template from minimal V2VersionInfo.
The V2VersionInfo Tuple only has the minimal representation
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", raw_pattern="vYYYY0M.BUILD[-TAG]")
>>> kwargs = dict(_format_part_values(vinfo))
>>> (kwargs['YYYY'], kwargs['0M'], kwargs['BUILD'], kwargs['TAG'])
('2007', '09', '1033', 'beta')
>>> (kwargs['YY'], kwargs['0Y'], kwargs['MM'], kwargs['PYTAG'])
('7', '07', '9', 'b')
>>> vinfo = parse_version_info("200709.1033b1", raw_pattern="YYYY0M.BLD[PYTAGNUM]")
>>> kwargs = dict(_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 sorted(kwargs.items(), key=lambda item: -len(item[0]))
Segment = str
# mypy limitation wrt. cyclic definition
# SegmentTree = typ.List[typ.Union[Segment, "SegmentTree"]]
SegmentTree = typ.Any
def _parse_segtree(raw_pattern: str) -> SegmentTree:
"""Generate segment tree from pattern string.
>>> _parse_segtree('aa[bb[cc]]')
['aa', ['bb', ['cc']]]
>>> _parse_segtree('aa[bb[cc]dd[ee]ff]gg')
['aa', ['bb', ['cc'], 'dd', ['ee'], 'ff'], 'gg']
"""
internal_root: SegmentTree = []
branch_stack : typ.List[SegmentTree] = [internal_root]
segment_start_index = -1
raw_pattern = "[" + raw_pattern + "]"
for i, char in enumerate(raw_pattern):
is_escaped = i > 0 and raw_pattern[i - 1] == "\\"
if char in "[]" and not is_escaped:
start = segment_start_index + 1
end = i
if start < end:
branch_stack[-1].append(raw_pattern[start:end])
if char == "[":
new_branch: SegmentTree = []
branch_stack[-1].append(new_branch)
branch_stack.append(new_branch)
segment_start_index = i
elif char == "]":
if len(branch_stack) == 1:
err = f"Unbalanced brace(s) in '{raw_pattern}'"
raise ValueError(err)
branch_stack.pop()
segment_start_index = i
else:
raise NotImplementedError("Unreachable")
if len(branch_stack) > 1:
err = f"Unclosed brace in '{raw_pattern}'"
raise ValueError(err)
return internal_root[0]
FormattedSegmentParts = typ.List[str]
class FormatedSeg(typ.NamedTuple):
is_literal: bool
is_zero : bool
result : str
def _format_segment(seg: Segment, part_values: PartValues) -> FormatedSeg:
zero_part_count = 0
# find all parts, regardless of zero value
used_parts: typ.List[typ.Tuple[str, str]] = []
for part, part_value in part_values:
if part in seg:
used_parts.append((part, part_value))
if version.is_zero_val(part, part_value):
zero_part_count += 1
result = seg
# unescape braces
result = result.replace(r"\[", r"[")
result = result.replace(r"\]", r"]")
for part, part_value in used_parts:
result = result.replace(part, part_value)
# If a segment has no parts at all, it is a literal string
# (typically a prefix or sufix) and should be output as is.
is_literal_seg = len(used_parts) == 0
if is_literal_seg:
return FormatedSeg(True, False, result)
elif zero_part_count > 0 and zero_part_count == len(used_parts):
# all zero, omit segment completely
return FormatedSeg(False, True, result)
else:
return FormatedSeg(False, False, result)
def _format_segment_tree(
segtree : SegmentTree,
part_values: PartValues,
) -> FormatedSeg:
# NOTE (mb 2020-10-02): starting from the right, if there is any non-zero
# part, all further parts going left will be used. In other words, a part
# is only omitted, if all parts to the right of it were also omitted.
result_parts: typ.List[str] = []
is_zero = True
for seg in segtree:
if isinstance(seg, list):
formatted_seg = _format_segment_tree(seg, part_values)
else:
formatted_seg = _format_segment(seg, part_values)
if formatted_seg.is_literal:
result_parts.append(formatted_seg.result)
else:
is_zero = is_zero and formatted_seg.is_zero
result_parts.append(formatted_seg.result)
result = "" if is_zero else "".join(result_parts)
return FormatedSeg(False, is_zero, result)
def format_version(vinfo: version.V2VersionInfo, raw_pattern: str) -> str:
"""Generate version string.
>>> import datetime as dt
>>> vinfo = parse_version_info("v200712.0033-beta", raw_pattern="vYYYY0M.BUILD[-TAG]")
>>> 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, raw_pattern="vYY.BLD[-PYTAGNUM]")
'v7.33-b0'
>>> format_version(vinfo_a, raw_pattern="vYY.BLD[-PYTAGNUM]")
'v7.33-b0'
>>> format_version(vinfo_a, raw_pattern="YYYY0M.BUILD[PYTAG[NUM]]")
'200701.0033b'
>>> format_version(vinfo_a, raw_pattern="v0Y.BLD[-TAG]")
'v07.33-beta'
>>> format_version(vinfo_a, raw_pattern="vYYYY0M.BUILD[-TAG]")
'v200701.0033-beta'
>>> format_version(vinfo_b, raw_pattern="vYYYY0M.BUILD[-TAG]")
'v200712.0033-beta'
>>> format_version(vinfo_a, raw_pattern="vYYYYw0W.BUILD[-TAG]")
'v2007w01.0033-beta'
>>> format_version(vinfo_a, raw_pattern="vYYYYwWW.BLD[-TAG]")
'v2007w1.33-beta'
>>> format_version(vinfo_b, raw_pattern="vYYYYw0W.BUILD[-TAG]")
'v2007w53.0033-beta'
>>> format_version(vinfo_a, raw_pattern="vYYYYd00J.BUILD[-TAG]")
'v2007d001.0033-beta'
>>> format_version(vinfo_a, raw_pattern="vYYYYdJJJ.BUILD[-TAG]")
'v2007d1.0033-beta'
>>> format_version(vinfo_b, raw_pattern="vYYYYd00J.BUILD[-TAG]")
'v2007d365.0033-beta'
>>> format_version(vinfo_a, raw_pattern="vGGGGwVV.BLD[PYTAGNUM]")
'v2007w1.33b0'
>>> format_version(vinfo_a, raw_pattern="vGGGGw0V.BUILD[-TAG]")
'v2007w01.0033-beta'
>>> format_version(vinfo_b, raw_pattern="vGGGGw0V.BUILD[-TAG]")
'v2008w01.0033-beta'
>>> vinfo_c = vinfo_b._replace(major=1, minor=2, patch=34, tag='final')
>>> format_version(vinfo_c, raw_pattern="vYYYYwWW.BUILD-TAG")
'v2007w53.0033-final'
>>> format_version(vinfo_c, raw_pattern="vYYYYwWW.BUILD[-TAG]")
'v2007w53.0033'
>>> format_version(vinfo_c, raw_pattern="vMAJOR.MINOR.PATCH")
'v1.2.34'
>>> vinfo_d = vinfo_b._replace(major=1, minor=0, patch=0, tag='final')
>>> format_version(vinfo_d, raw_pattern="vMAJOR.MINOR.PATCH-TAGNUM")
'v1.0.0-final0'
>>> format_version(vinfo_d, raw_pattern="vMAJOR.MINOR.PATCH-TAG")
'v1.0.0-final'
>>> format_version(vinfo_d, raw_pattern="vMAJOR.MINOR.PATCH-TAG")
'v1.0.0-final'
>>> format_version(vinfo_d, raw_pattern="vMAJOR.MINOR.PATCH[-TAG]")
'v1.0.0'
>>> format_version(vinfo_d, raw_pattern="vMAJOR.MINOR[.PATCH[-TAG]]")
'v1.0'
>>> format_version(vinfo_d, raw_pattern="vMAJOR[.MINOR[.PATCH[-TAG]]]")
'v1'
>>> vinfo_d = vinfo_b._replace(major=1, minor=0, patch=2, tag='rc', pytag='rc', num=0)
>>> format_version(vinfo_d, raw_pattern="vMAJOR[.MINOR[.PATCH]]")
'v1.0.2'
>>> format_version(vinfo_d, raw_pattern="vMAJOR[.MINOR[.PATCH[-TAG]]]")
'v1.0.2-rc'
>>> format_version(vinfo_d, raw_pattern="vMAJOR[.MINOR[.PATCH[PYTAGNUM]]]")
'v1.0.2rc0'
>>> format_version(vinfo_d, raw_pattern="vMAJOR[.MINOR[.PATCH]]")
'v1.0.2'
>>> vinfo_d = vinfo_b._replace(major=1, minor=0, patch=0, tag='rc', num=2)
>>> format_version(vinfo_d, raw_pattern="vMAJOR[.MINOR[.PATCH[-TAGNUM]]]")
'v1.0.0-rc2'
>>> vinfo_d = vinfo_b._replace(major=1, minor=0, patch=0, tag='rc', num=2)
>>> format_version(vinfo_d, raw_pattern='__version__ = "vMAJOR[.MINOR[.PATCH[-TAGNUM]]]"')
'__version__ = "v1.0.0-rc2"'
"""
part_values = _format_part_values(vinfo)
segtree = _parse_segtree(raw_pattern)
formatted_seg = _format_segment_tree(segtree, part_values)
return formatted_seg.result
def _iter_flat_segtree(segtree: SegmentTree) -> typ.Iterable[Segment]:
"""Flatten a SegmentTree (mixed nested list of lists or str).
>>> list(_iter_flat_segtree(['aa', ['bb', ['cc'], 'dd', ['ee'], 'ff'], 'gg']))
['aa', 'bb', 'cc', 'dd', 'ee', 'ff', 'gg']
"""
for subtree in segtree:
if isinstance(subtree, list):
for seg in _iter_flat_segtree(subtree):
yield seg
else:
yield subtree
def _parse_pattern_fields(raw_pattern: str) -> typ.List[str]:
parts = list(v2patterns.PATTERN_PART_FIELDS.keys())
parts.sort(key=len, reverse=True)
segtree = _parse_segtree(raw_pattern)
segments = _iter_flat_segtree(segtree)
fields_by_index = {}
for segment_index, segment in enumerate(segments):
for part in parts:
part_index = segment.find(part)
if part_index >= 0:
field = v2patterns.PATTERN_PART_FIELDS[part]
fields_by_index[segment_index, part_index] = field
return [field for _, field in sorted(fields_by_index.items())]
def _iter_reset_field_items(
fields : typ.List[str],
old_vinfo: version.V2VersionInfo,
cur_vinfo: version.V2VersionInfo,
) -> typ.Iterable[typ.Tuple[str, str]]:
# Any field to the left of another can reset all to the right
has_reset = False
for field in fields:
initial_val = version.V2_FIELD_INITIAL_VALUES.get(field)
if has_reset and initial_val is not None:
yield field, initial_val
elif getattr(old_vinfo, field) != getattr(cur_vinfo, field):
has_reset = True
def _incr_numeric(
raw_pattern: str,
old_vinfo : version.V2VersionInfo,
cur_vinfo : version.V2VersionInfo,
major : bool,
minor : bool,
patch : bool,
tag : typ.Optional[str],
tag_num : bool,
) -> version.V2VersionInfo:
# Reset major/minor/patch/num/inc to zero if any part to the left of it is incremented
fields = _parse_pattern_fields(raw_pattern)
reset_fields = dict(_iter_reset_field_items(fields, old_vinfo, cur_vinfo))
cur_kwargs = cur_vinfo._asdict()
cur_kwargs.update(reset_fields)
cur_vinfo = version.V2VersionInfo(**cur_kwargs)
# prevent truncation of leading zeros
if int(cur_vinfo.bid) < 1000:
cur_vinfo = cur_vinfo._replace(bid=str(int(cur_vinfo.bid) + 1000))
cur_vinfo = cur_vinfo._replace(bid=lexid.next_id(cur_vinfo.bid))
if 'inc0' in reset_fields:
cur_vinfo = cur_vinfo._replace(inc0=0)
else:
cur_vinfo = cur_vinfo._replace(inc0=cur_vinfo.inc0 + 1)
if 'inc1' in reset_fields:
cur_vinfo = cur_vinfo._replace(inc1=1)
else:
cur_vinfo = cur_vinfo._replace(inc1=cur_vinfo.inc1 + 1)
if major and 'major' not in reset_fields:
cur_vinfo = cur_vinfo._replace(major=cur_vinfo.major + 1, minor=0, patch=0)
if minor and 'minor' not in reset_fields:
cur_vinfo = cur_vinfo._replace(minor=cur_vinfo.minor + 1, patch=0)
if patch and 'patch' not in reset_fields:
cur_vinfo = cur_vinfo._replace(patch=cur_vinfo.patch + 1)
if tag_num and 'tag_num' not in reset_fields:
cur_vinfo = cur_vinfo._replace(num=cur_vinfo.num + 1)
if tag and 'tag' not in reset_fields:
if tag != cur_vinfo.tag:
cur_vinfo = cur_vinfo._replace(num=0)
cur_vinfo = cur_vinfo._replace(tag=tag)
return cur_vinfo
def is_valid_week_pattern(raw_pattern: str) -> bool:
has_yy_part = any(part in raw_pattern for part in ["YYYY", "YY", "0Y"])
has_ww_part = any(part in raw_pattern for part in ["WW" , "0W", "UU", "0U"])
has_gg_part = any(part in raw_pattern for part in ["GGGG", "GG", "0G"])
has_vv_part = any(part in raw_pattern for part in ["VV" , "0V"])
if has_yy_part and has_vv_part:
alt1 = raw_pattern.replace("V", "W")
alt2 = raw_pattern.replace("Y", "G")
logger.error(f"Invalid pattern: '{raw_pattern}'. Maybe try {alt1} or {alt2}")
return False
elif has_gg_part and has_ww_part:
alt1 = raw_pattern.replace("W", "V").replace("U", "V")
alt2 = raw_pattern.replace("G", "Y")
logger.error(f"Invalid pattern: '{raw_pattern}'. Maybe try {alt1} or {alt2}")
return False
else:
return True
def incr(
old_version: str,
raw_pattern: str = "vYYYY0M.BUILD[-TAG]",
*,
major : bool = False,
minor : bool = False,
patch : bool = False,
tag : typ.Optional[str] = None,
tag_num : bool = False,
pin_date: bool = False,
date : typ.Optional[dt.date] = None,
) -> typ.Optional[str]:
"""Increment version string.
'old_version' is assumed to be a string that matches 'raw_pattern'
"""
if not is_valid_week_pattern(raw_pattern):
return None
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(date)
if _is_cal_gt(old_vinfo, cur_cinfo):
logger.warning(f"Old version appears to be from the future '{old_version}'")
cur_vinfo = old_vinfo
else:
cur_vinfo = old_vinfo._replace(**cur_cinfo._asdict())
cur_vinfo = _incr_numeric(
raw_pattern,
old_vinfo,
cur_vinfo,
major=major,
minor=minor,
patch=patch,
tag=tag,
tag_num=tag_num,
)
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

245
src/pycalver2/vcs.py Normal file
View file

@ -0,0 +1,245 @@
# 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/vcs.py (this file) is based on code from the
# bumpversion project: https://github.com/peritus/bumpversion
# Copyright (c) 2013-2014 Filip Noetzel - MIT License
"""Minimal Git and Mercirial API.
If terminology for similar concepts differs between git and
mercurial, then the git terms are used. For example "fetch"
(git) instead of "pull" (hg) .
"""
import os
import sys
import shlex
import typing as typ
import logging
import tempfile
import subprocess as sp
from pycalver import config
logger = logging.getLogger("pycalver2.vcs")
VCS_SUBCOMMANDS_BY_NAME = {
'git': {
'is_usable' : "git rev-parse --git-dir",
'fetch' : "git fetch",
'ls_tags' : "git tag --list",
'status' : "git status --porcelain",
'add_path' : "git add --update {path}",
'commit' : "git commit --message '{message}'",
'tag' : "git tag --annotate {tag} --message {tag}",
'push_tag' : "git push origin --follow-tags {tag} HEAD",
'show_remotes': "git config --get remote.origin.url",
},
'hg': {
'is_usable' : "hg root",
'fetch' : "hg pull",
'ls_tags' : "hg tags",
'status' : "hg status -umard",
'add_path' : "hg add {path}",
'commit' : "hg commit --logfile {path}",
'tag' : "hg tag {tag} --message {tag}",
'push_tag' : "hg push {tag}",
'show_remotes': "hg paths",
},
}
Env = typ.Dict[str, str]
class VCSAPI:
"""Absraction for git and mercurial."""
def __init__(self, name: str, subcommands: typ.Dict[str, str] = None):
self.name = name
if subcommands is None:
self.subcommands = VCS_SUBCOMMANDS_BY_NAME[name]
else:
self.subcommands = subcommands
def __call__(self, cmd_name: str, env: Env = None, **kwargs: str) -> str:
"""Invoke subcommand and return output."""
cmd_tmpl = self.subcommands[cmd_name]
cmd_str = cmd_tmpl.format(**kwargs)
if cmd_name in ("commit", "tag", "push_tag"):
logger.info(cmd_str)
else:
logger.debug(cmd_str)
cmd_parts = shlex.split(cmd_str)
output_data: bytes = sp.check_output(cmd_parts, env=env, stderr=sp.STDOUT)
return output_data.decode("utf-8")
@property
def is_usable(self) -> bool:
"""Detect availability of subcommand."""
if not os.path.exists(f".{self.name}"):
return False
cmd = self.subcommands['is_usable'].split()
try:
retcode = sp.call(cmd, stderr=sp.PIPE, stdout=sp.PIPE)
return retcode == 0
except OSError as err:
if err.errno == 2:
# git/mercurial is not installed.
return False
else:
raise
@property
def has_remote(self) -> bool:
# pylint:disable=broad-except; Not sure how to anticipate all cases.
try:
output = self('show_remotes')
if output.strip() == "":
return False
else:
return True
except Exception:
return False
def fetch(self) -> None:
"""Fetch updates from remote origin."""
if self.has_remote:
self('fetch')
def status(self, required_files: typ.Set[str]) -> typ.List[str]:
"""Get status lines."""
status_output = self('status')
status_items = [line.split(" ", 1) for line in status_output.splitlines()]
return [
filepath.strip()
for status, filepath in status_items
if filepath.strip() in required_files or status != "??"
]
def ls_tags(self) -> typ.List[str]:
"""List vcs tags on all branches."""
ls_tag_lines = self('ls_tags').splitlines()
logger.debug(f"ls_tags output {ls_tag_lines}")
return [line.strip().split(" ", 1)[0] for line in ls_tag_lines]
def add(self, path: str) -> None:
"""Add updates to be included in next commit."""
try:
self('add_path', path=path)
except sp.CalledProcessError as ex:
if "already tracked!" in str(ex):
# mercurial
return
else:
raise
def commit(self, message: str) -> None:
"""Commit added files."""
env: Env = os.environ.copy()
if self.name == 'git':
self('commit', env=env, message=message)
else:
message_data = message.encode("utf-8")
tmp_file = tempfile.NamedTemporaryFile("wb", delete=False)
try:
assert " " not in tmp_file.name
fobj: typ.IO[bytes]
with tmp_file as fobj:
fobj.write(message_data)
env['HGENCODING'] = "utf-8"
self('commit', env=env, path=tmp_file.name)
finally:
os.unlink(tmp_file.name)
def tag(self, tag_name: str) -> None:
"""Create an annotated tag."""
self('tag', tag=tag_name)
def push(self, tag_name: str) -> None:
"""Push changes to origin."""
if self.has_remote:
self('push_tag', tag=tag_name)
def __repr__(self) -> str:
"""Generate string representation."""
return f"VCSAPI(name='{self.name}')"
def get_vcs_api() -> VCSAPI:
"""Detect the appropriate VCS for a repository.
raises OSError if the directory doesn't use a supported VCS.
"""
for vcs_name in VCS_SUBCOMMANDS_BY_NAME:
vcs_api = VCSAPI(name=vcs_name)
if vcs_api.is_usable:
return vcs_api
raise OSError("No such directory .git/ or .hg/ ")
# cli helper methods
def assert_not_dirty(vcs_api: VCSAPI, filepaths: typ.Set[str], allow_dirty: bool) -> None:
dirty_files = vcs_api.status(required_files=filepaths)
if dirty_files:
logger.warning(f"{vcs_api.name} working directory is not clean. Uncomitted file(s):")
for dirty_file in dirty_files:
logger.warning(" " + dirty_file)
if not allow_dirty and dirty_files:
sys.exit(1)
dirty_pattern_files = set(dirty_files) & filepaths
if dirty_pattern_files:
logger.error("Not commiting when pattern files are dirty:")
for dirty_file in dirty_pattern_files:
logger.warning(" " + dirty_file)
sys.exit(1)
def commit(
cfg : config.Config,
vcs_api : VCSAPI,
filepaths : typ.Set[str],
new_version : str,
commit_message: str,
) -> None:
for filepath in filepaths:
vcs_api.add(filepath)
vcs_api.commit(commit_message)
if cfg.commit and cfg.tag:
vcs_api.tag(new_version)
if cfg.commit and cfg.tag and cfg.push:
vcs_api.push(new_version)
def get_tags(fetch: bool) -> typ.List[str]:
try:
vcs_api = get_vcs_api()
logger.debug(f"vcs found: {vcs_api.name}")
if fetch:
logger.info("fetching tags from remote (to turn off use: -n / --no-fetch)")
vcs_api.fetch()
return vcs_api.ls_tags()
except OSError:
logger.debug("No vcs found")
return []

174
src/pycalver2/version.py Normal file
View file

@ -0,0 +1,174 @@
# This file is part of the pycalver project
# https://github.com/mbarkhau/pycalver
#
# Copyright (c) 2018-2020 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
# SPDX-License-Identifier: MIT
import typing as typ
import datetime as dt
import pkg_resources
MaybeInt = typ.Optional[int]
class V1CalendarInfo(typ.NamedTuple):
"""Container for calendar components of version strings."""
year : MaybeInt
quarter : MaybeInt
month : MaybeInt
dom : MaybeInt
doy : MaybeInt
iso_week: MaybeInt
us_week : MaybeInt
class V1VersionInfo(typ.NamedTuple):
"""Container for parsed version string."""
year : MaybeInt
quarter : MaybeInt
month : MaybeInt
dom : MaybeInt
doy : MaybeInt
iso_week: MaybeInt
us_week : MaybeInt
major : int
minor : int
patch : int
bid : str
tag : str
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
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
inc0 : int
inc1 : int
# 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 = {
'a' : 'a',
'b' : 'b',
'dev' : 'dev',
'alpha' : 'a',
'beta' : 'b',
'preview': 'rc',
'pre' : 'rc',
'rc' : 'rc',
'c' : 'rc',
'final' : '',
'post' : 'post',
'r' : 'post',
'rev' : 'post',
}
assert set(TAG_BY_PEP440_TAG.keys()) == set(PEP440_TAG_BY_TAG.values())
assert set(TAG_BY_PEP440_TAG.values()) < set(PEP440_TAG_BY_TAG.keys())
PART_ZERO_VALUES = {
'MAJOR': "0",
'MINOR': "0",
'PATCH': "0",
'TAG' : "final",
'PYTAG': "",
'NUM' : "0",
'INC0' : "0",
}
V2_FIELD_INITIAL_VALUES = {
'major': "0",
'minor': "0",
'patch': "0",
'num' : "0",
'inc0' : "0",
'inc1' : "1",
}
def is_zero_val(part: str, part_value: str) -> bool:
return part in PART_ZERO_VALUES and part_value == PART_ZERO_VALUES[part]
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]
>>> assert [(d.month, d.day) for d in dates] == [
... (1, 1), (1, 31), (2, 1), (2, 29), (3, 1),
... (1, 1), (1, 31), (2, 1), (2, 28), (3, 1),
... ]
"""
return dt.date(year, 1, 1) + dt.timedelta(days=doy - 1)
def quarter_from_month(month: int) -> int:
"""Calculate quarter (1 indexed) from month (1 indexed).
>>> [quarter_from_month(month) for month in range(1, 13)]
[1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4]
"""
return ((month - 1) // 3) + 1
def to_pep440(version: str) -> str:
"""Derive pep440 compliant version string from PyCalVer version string.
>>> to_pep440("v201811.0007-beta")
'201811.7b0'
"""
return str(pkg_resources.parse_version(version))