diff --git a/README.rst b/README.rst index 5236df9..6983f7e 100644 --- a/README.rst +++ b/README.rst @@ -52,8 +52,8 @@ which is compatible with python packaging software The PyCalVer package provides the ``pycalver`` command and -module to generate version strings which follow the format: -``v{calendar_version}.{build_number}[-{release_tag}]`` +module to generate version strings which follow the following +format: ``v{calendar_version}.{build_number}[-{release_tag}]`` Some examples: @@ -69,9 +69,9 @@ Some examples: v202207.18134 -The ``pycalver bump`` command will parse specified/configured -files for such strings and rewrite them with an updated version -string. +The ``pycalver bump`` command will parse the files you configure +in ``setup.cfg`` for such strings and rewrite them with an +updated version string. The format accepted by PyCalVer can be parsed with this regular expression: diff --git a/src/pycalver/__init__.py b/src/pycalver/__init__.py index ba36471..378e88d 100644 --- a/src/pycalver/__init__.py +++ b/src/pycalver/__init__.py @@ -8,4 +8,4 @@ import os __version__ = "v201809.0002-beta" -DEBUG = os.environ.get("PYCALVER_DEBUG", "0") == "1" \ No newline at end of file +DEBUG = os.environ.get("PYCALVER_DEBUG", "0") == "1" diff --git a/src/pycalver/__main__.py b/src/pycalver/__main__.py index a73be68..74d0a98 100644 --- a/src/pycalver/__main__.py +++ b/src/pycalver/__main__.py @@ -10,8 +10,6 @@ import os import sys import click import logging -import difflib -import typing as typ from . import DEBUG from . import vcs @@ -56,13 +54,13 @@ def cli(): def show() -> None: _init_loggers(verbose=False) - cfg = config.parse() + cfg: config.MaybeConfig = config.parse() if cfg is None: log.error("Could not parse configuration from setup.cfg") sys.exit(1) - print(f"Current Version: {cfg['current_version']}") - print(f"PEP440 Version: {cfg['pep440_version']}") + print(f"Current Version: {cfg.current_version}") + print(f"PEP440 Version: {cfg.pep440_version}") @cli.command() @@ -99,7 +97,7 @@ def init(dry: bool) -> None: """Initialize [pycalver] configuration in setup.cfg""" _init_loggers(verbose=False) - cfg = config.parse() + cfg: config.MaybeConfig = config.parse() if cfg: log.error("Configuration already initialized in setup.cfg") sys.exit(1) @@ -179,16 +177,14 @@ def bump( log.error(f"Valid arguments are: {', '.join(parse.VALID_RELESE_VALUES)}") sys.exit(1) - cfg = config.parse() + cfg: config.MaybeConfig = config.parse() if cfg is None: log.error("Could not parse configuration from setup.cfg") sys.exit(1) - old_version = cfg["current_version"] + old_version = cfg.current_version new_version = version.bump(old_version, release=release) - new_version_nfo = parse.parse_version_info(new_version) - new_version_fmt_kwargs = new_version_nfo._asdict() log.info(f"Old Version: {old_version}") log.info(f"New Version: {new_version}") @@ -196,53 +192,12 @@ def bump( if dry: log.info("Running with '--dry', showing diffs instead of updating files.") + file_patterns = cfg.file_patterns + filepaths = set(file_patterns.keys()) + _vcs = vcs.get_vcs() - dirty_files = _vcs.dirty_files() - - if dirty_files: - log.warn(f"{_vcs.__name__} working directory is not clean:") - for file in dirty_files: - log.warn(" " + file) - - if not allow_dirty and dirty_files: - sys.exit(1) - - pattern_files = cfg["file_patterns"].keys() - dirty_pattern_files = set(dirty_files) & set(pattern_files) - if dirty_pattern_files: - log.error("Not commiting when pattern files are dirty:") - for file in dirty_pattern_files: - log.warn(" " + file) - sys.exit(1) - - matches: typ.List[parse.PatternMatch] - for filepath, patterns in cfg["file_patterns"].items(): - with io.open(filepath, mode="rt", encoding="utf-8") as fh: - content = fh.read() - - old_lines = content.splitlines() - new_lines = old_lines.copy() - - matches = parse.parse_patterns(old_lines, patterns) - for m in matches: - replacement = m.pattern.format(**new_version_fmt_kwargs) - span_l, span_r = m.span - new_line = m.line[:span_l] + replacement + m.line[span_r:] - new_lines[m.lineno] = new_line - - if dry or verbose: - print("\n".join(difflib.unified_diff( - old_lines, - new_lines, - lineterm="", - fromfile="a/" + filepath, - tofile="b/" + filepath, - ))) - - if not dry: - new_content = "\n".join(new_lines) - with io.open(filepath, mode="wt", encoding="utf-8") as fh: - fh.write(new_content) + _vcs.assert_not_dirty(filepaths, allow_dirty) + rewrite.rewrite(new_version, file_patterns, dry, verbose) if dry: return diff --git a/src/pycalver/config.py b/src/pycalver/config.py index 83d1aa6..061cbe2 100644 --- a/src/pycalver/config.py +++ b/src/pycalver/config.py @@ -18,38 +18,48 @@ from .parse import PYCALVER_RE log = logging.getLogger("pycalver.config") -def parse(config_file="setup.cfg") -> typ.Optional[typ.Dict[str, typ.Any]]: - if not os.path.exists(config_file): - log.error("File not found: setup.cfg") - return None +class Config(typ.NamedTuple): + current_version : str + pep440_version : str + + tag : bool + commit : bool + + file_patterns : typ.Dict[str, typ.List[str]] + + +MaybeConfig = typ.Optional[Config] + + +def _parse_buffer(cfg_buffer: io.StringIO) -> MaybeConfig: cfg_parser = configparser.RawConfigParser("") - with io.open(config_file, mode="rt", encoding="utf-8") as fh: - cfg_parser.readfp(fh) + cfg_parser.readfp(cfg_buffer) if "pycalver" not in cfg_parser: log.error("setup.cfg does not contain a [pycalver] section.") return None - cfg = dict(cfg_parser.items("pycalver")) + base_cfg = dict(cfg_parser.items("pycalver")) - if "current_version" not in cfg: + if "current_version" not in base_cfg: log.error("setup.cfg does not have 'pycalver.current_version'") return None - current_version = cfg["current_version"] + current_version = base_cfg["current_version"] if PYCALVER_RE.match(current_version) is None: log.error(f"setup.cfg 'pycalver.current_version is invalid") log.error(f"current_version = {current_version}") return None - cfg["pep440_version"] = str(pkg_resources.parse_version(current_version)) + pep440_version = str(pkg_resources.parse_version(current_version)) - cfg["tag"] = cfg.get("tag", "").lower() in ("yes", "true", "1", "on") - cfg["commit"] = cfg.get("commit", "").lower() in ("yes", "true", "1", "on") + tag = base_cfg.get("tag", "").lower() in ("yes", "true", "1", "on") + commit = base_cfg.get("commit", "").lower() in ("yes", "true", "1", "on") - cfg["file_patterns"] = {} + file_patterns: typ.Dict[str, typ.List[str]] = {} + section_name: str for section_name in cfg_parser.sections(): if not section_name.startswith("pycalver:file:"): continue @@ -59,25 +69,39 @@ def parse(config_file="setup.cfg") -> typ.Optional[typ.Dict[str, typ.Any]]: log.error(f"No such file: {filepath} from {section_name} in setup.cfg") return None - section = dict(cfg_parser.items(section_name)) + section: typ.Dict[str, str] = dict(cfg_parser.items(section_name)) + patterns = section.get("patterns") - if "patterns" in section: - cfg["file_patterns"][filepath] = [ + if patterns is None: + file_patterns[filepath] = ["{version}", "{pep440_version}"] + else: + file_patterns[filepath] = [ line.strip() - for line in section["patterns"].splitlines() + for line in patterns.splitlines() if line.strip() ] - else: - cfg["file_patterns"][filepath] = ["{version}", "{pep440_version}"] - if not cfg["file_patterns"]: - cfg["file_patterns"]["setup.cfg"] = ["{version}", "{pep440_version}"] + if not file_patterns: + file_patterns["setup.cfg"] = ["{version}", "{pep440_version}"] + cfg = Config(current_version, pep440_version, tag, commit, file_patterns) log.debug(f"Config Parsed: {cfg}") - return cfg +def parse(config_file="setup.cfg") -> MaybeConfig: + if not os.path.exists(config_file): + log.error("File not found: setup.cfg") + return None + + cfg_buffer = io.StringIO() + with io.open(config_file, mode="rt", encoding="utf-8") as fh: + cfg_buffer.write(fh.read()) + + cfg_buffer.seek(0) + return _parse_buffer(cfg_buffer) + + def default_config_lines() -> typ.List[str]: initial_version = dt.datetime.now().strftime("v%Y%m.0001-dev") diff --git a/src/pycalver/lex_id.py b/src/pycalver/lex_id.py index 00b8453..3fcc3c6 100644 --- a/src/pycalver/lex_id.py +++ b/src/pycalver/lex_id.py @@ -85,6 +85,9 @@ MINIMUM_ID = "0" def next_id(prev_id: str) -> str: num_digits = len(prev_id) + if prev_id.count("9") == num_digits: + raise OverflowError("max lexical version reached: " + prev_id) + _prev_id = int(prev_id, 10) _next_id = int(_prev_id) + 1 next_id = f"{_next_id:0{num_digits}}" diff --git a/src/pycalver/rewrite.py b/src/pycalver/rewrite.py index f9649b8..a783a66 100644 --- a/src/pycalver/rewrite.py +++ b/src/pycalver/rewrite.py @@ -5,17 +5,52 @@ # (C) 2018 Manuel Barkhau (@mbarkhau) # SPDX-License-Identifier: MIT -import logging +import io import difflib +import logging +import typing as typ + +from . import parse log = logging.getLogger("pycalver.rewrite") -def rewrite_file(file: str, pattern: str, dry=False) -> None: - difflib.unified_diff( - file_content_before.splitlines(), - file_content_after.splitlines(), - lineterm="", - fromfile="a/" + file, - tofile="b/" + file, - ) +def rewrite( + new_version: str, + file_patterns: typ.Dict[str, str], + dry=False, + verbose=False, +) -> None: + new_version_nfo = parse.parse_version_info(new_version) + new_version_fmt_kwargs = new_version_nfo._asdict() + + matches: typ.List[parse.PatternMatch] + for filepath, patterns in file_patterns.items(): + with io.open(filepath, mode="rt", encoding="utf-8") as fh: + content = fh.read() + + old_lines = content.splitlines() + new_lines = old_lines.copy() + + matches = parse.parse_patterns(old_lines, patterns) + for m in matches: + replacement = m.pattern.format(**new_version_fmt_kwargs) + span_l, span_r = m.span + new_line = m.line[:span_l] + replacement + m.line[span_r:] + new_lines[m.lineno] = new_line + + if dry or verbose: + print("\n".join(difflib.unified_diff( + old_lines, + new_lines, + lineterm="", + fromfile="a/" + filepath, + tofile="b/" + filepath, + ))) + + if dry: + continue + + new_content = "\n".join(new_lines) + with io.open(filepath, mode="wt", encoding="utf-8") as fh: + fh.write(new_content) diff --git a/src/pycalver/vcs.py b/src/pycalver/vcs.py index 2be6797..78aec0c 100644 --- a/src/pycalver/vcs.py +++ b/src/pycalver/vcs.py @@ -9,8 +9,10 @@ # MIT License - (C) 2013-2014 Filip Noetzel import os +import sys import logging import tempfile +import typing as typ import subprocess as sp @@ -52,6 +54,25 @@ class BaseVCS: if not line.strip().startswith(b"??") ] + @classmethod + def assert_not_dirty(cls, filepaths: typ.Set[str], allow_dirty=False) -> None: + dirty_files = cls.dirty_files() + + if dirty_files: + log.warn(f"{cls.__name__} working directory is not clean:") + for file in dirty_files: + log.warn(" " + file) + + if not allow_dirty and dirty_files: + sys.exit(1) + + dirty_pattern_files = set(dirty_files) & filepaths + if dirty_pattern_files: + log.error("Not commiting when pattern files are dirty:") + for file in dirty_pattern_files: + log.warn(" " + file) + sys.exit(1) + class Git(BaseVCS): diff --git a/src/pycalver/version.py b/src/pycalver/version.py index 80a517c..b2bd2dc 100644 --- a/src/pycalver/version.py +++ b/src/pycalver/version.py @@ -14,15 +14,25 @@ log = logging.getLogger("pycalver.version") def current_calver() -> str: - return dt.datetime.utcnow().strftime("v%Y%m") + return dt.date.today().strftime("v%Y%m") -def bump(old_version: str, release: str=None) -> str: +def bump(old_version: str, *, release: str=None) -> str: # old_version is assumed to be a valid calver string, # validated in pycalver.config.parse. old_ver = parse.parse_version_info(old_version) new_calver = current_calver() + + if old_ver.calver > new_calver: + log.warning( + f"'version.bump' called with '{old_version}', " + + f"which is from the future, " + + f"maybe your system clock is out of sync." + ) + # leave calver as is (don't go back in time) + new_calver = old_ver.calver + new_build = lex_id.next_id(old_ver.build[1:]) if release is None: if old_ver.release: @@ -30,6 +40,8 @@ def bump(old_version: str, release: str=None) -> str: new_release = old_ver.release[1:] else: new_release = None + elif release == "final": + new_release = None else: new_release = release @@ -37,38 +49,3 @@ def bump(old_version: str, release: str=None) -> str: if new_release: new_version += "-" + new_release return new_version - - -def incr_version(old_version: str, *, tag: str="__sentinel__") -> str: - maybe_match: MaybeMatch = VERSION_RE.search(old_version) - - if maybe_match is None: - raise ValueError(f"Invalid version string: {old_version}") - - prev_version_info: PyCalVerInfo = maybe_match.groupdict() - - prev_calver: str = prev_version_info["calver"] - next_calver: str = current_calver() - - prev_build: str = prev_version_info["build"] - if prev_calver > next_calver: - log.warning( - f"'incr_version' called with '{old_version}', " + - f"which is from the future, " + - f"maybe your system clock is out of sync." - ) - next_calver = prev_calver # leave calver as is - - next_build = lex_id.next_id(prev_build) - new_version = f"{next_calver}.{next_build}" - if tag != "__sentinel__": - if tag is None: - pass # tag explicitly ignored/removed - else: - new_version += "-" + tag - elif "tag" in prev_version_info: - # preserve previous tag - new_version += "-" + prev_version_info["tag"] - - assert old_version < new_version, f"{old_version} {new_version}" - return new_version diff --git a/test/test_config.py b/test/test_config.py index e69de29..f65452f 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -0,0 +1,5 @@ +from pycalver import config + + +def test_parse(): + pass diff --git a/test/test_lex_id.py b/test/test_lex_id.py index 37a2a4a..d454fb8 100644 --- a/test/test_lex_id.py +++ b/test/test_lex_id.py @@ -7,11 +7,23 @@ def test_next_id_basic(): assert lex_id.next_id("09") == "110" +def test_next_id_overflow(): + try: + prev_id = "9999" + next_id = lex_id.next_id(prev_id) + assert False, (prev_id, "->", next_id) + except OverflowError: + pass + + def test_next_id_random(): for i in range(1000): prev_id = str(random.randint(1, 100000)) - next_id = lex_id.next_id(prev_id) - assert prev_id < next_id + try: + next_id = lex_id.next_id(prev_id) + assert prev_id < next_id + except OverflowError: + assert len(prev_id) == prev_id.count("9") def test_ord_val(): diff --git a/test/test_version.py b/test/test_version.py index 089a3a5..e2408c8 100644 --- a/test/test_version.py +++ b/test/test_version.py @@ -1,4 +1,6 @@ +import random import datetime as dt + from pycalver import version @@ -9,29 +11,47 @@ def test_current_calver(): assert v[1:].isdigit() -# def test_calver(): -# import random +def test_bump_beta(): + calver = version.current_calver() + cur_version = calver + ".0001-beta" + assert cur_version < version.bump(cur_version) + assert version.bump(cur_version).endswith("-beta") + assert version.bump(cur_version, release="alpha").endswith("-alpha") + assert version.bump(cur_version, release="final").endswith("0002") -# first_version_str = "v201808.0001-dev" -# padding = len(first_version_str) + 3 -# version_str = first_version_str -# def _current_calver() -> str: -# _current_calver.delta += dt.timedelta(days=int(random.random() * 5)) +def test_bump_final(): + calver = version.current_calver() + cur_version = calver + ".0001" + assert cur_version < version.bump(cur_version) + assert version.bump(cur_version, release="alpha").endswith("-alpha") + assert version.bump(cur_version, release="final").endswith("0002") + assert version.bump(cur_version).endswith("0002") -# return (dt.datetime.utcnow() + _current_calver.delta).strftime("v%Y%m") -# _current_calver.delta = dt.timedelta(days=1) +def test_bump_future(): + future_date = dt.datetime.today() + dt.timedelta(days=300) + future_calver = future_date.strftime("v%Y%m") + cur_version = future_calver + ".0001" + assert cur_version < version.bump(cur_version) -# global current_calver -# current_calver = _current_calver -# for i in range(1050): -# version_str = incr_version(version_str, tag=random.choice([ -# None, "alpha", "beta", "rc" -# ])) -# print(f"{version_str:<{padding}}", end=" ") -# if (i + 1) % 8 == 0: -# print() +def test_bump_random(): + cur_date = dt.date.today() + cur_version = cur_date.strftime("v%Y%m") + ".0001-dev" -# print() + def _mock_current_calver(): + return cur_date.strftime("v%Y%m") + + _orig_current_calver = version.current_calver + version.current_calver = _mock_current_calver + try: + for i in range(1000): + cur_date += dt.timedelta(days=int((1 + random.random()) ** 10)) + new_version = version.bump(cur_version, release=random.choice([ + None, "alpha", "beta", "rc", "final" + ])) + assert cur_version < new_version + cur_version = new_version + finally: + version.current_calver = _orig_current_calver