mirror of
https://github.com/TECHNOFAB11/bumpver.git
synced 2025-12-16 00:03:51 +01:00
update tests for new defaults
This commit is contained in:
parent
54ab1151f1
commit
145401de33
30 changed files with 495 additions and 417 deletions
8
src/pycalver2/__init__.py
Normal file
8
src/pycalver2/__init__.py
Normal 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
15
src/pycalver2/__main__.py
Normal 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
718
src/pycalver2/cli.py
Executable 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
581
src/pycalver2/config.py
Normal 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
85
src/pycalver2/parse.py
Normal 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
29
src/pycalver2/patterns.py
Normal 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
47
src/pycalver2/pysix.py
Normal 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
76
src/pycalver2/regexfmt.py
Normal 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" "®ex=" + pysix.quote(regex_pattern),
|
||||
)
|
||||
)
|
||||
90
src/pycalver2/rewrite.py
Normal file
90
src/pycalver2/rewrite.py
Normal 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
24
src/pycalver2/utils.py
Normal 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
239
src/pycalver2/v1patterns.py
Normal 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
154
src/pycalver2/v1rewrite.py
Normal 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
428
src/pycalver2/v1version.py
Normal 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
344
src/pycalver2/v2patterns.py
Normal 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
163
src/pycalver2/v2rewrite.py
Normal 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
727
src/pycalver2/v2version.py
Normal 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
245
src/pycalver2/vcs.py
Normal 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
174
src/pycalver2/version.py
Normal 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))
|
||||
Loading…
Add table
Add a link
Reference in a new issue