diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bb7f7e..4c74b86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ ## Unreleased +* Added the `dunamai check` command and the corresponding `check_version` + function. * Added the option to check just the latest tag or to keep checking tags until a match is found. The default behavior is now to keep checking. * Added enforcement of Semantic Versioning rule against numeric segments diff --git a/README.md b/README.md index e8789a2..c2dc008 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,10 @@ v0.2.0+7.g29045e8 $ dunamai from any --format "v{base}" --style pep440 Version 'v0.2.0' does not conform to the PEP 440 style +# Validate your own freeform versions: +$ dunamai check 0.01.0 --style semver +Version '0.01.0' does not conform to the Semantic Versioning style + # More info $ dunamai --help ``` diff --git a/dunamai/__init__.py b/dunamai/__init__.py index 87aaa56..a31fd2a 100644 --- a/dunamai/__init__.py +++ b/dunamai/__init__.py @@ -1,4 +1,4 @@ -__all__ = ["get_version", "Style", "Version"] +__all__ = ["check_version", "get_version", "Style", "Version"] import os import pkg_resources @@ -221,7 +221,7 @@ def serialize( dirty="dirty" if self.dirty else "clean", ) if style is not None: - self._validate(out, style) + check_version(out, style) return out if style is None: @@ -299,26 +299,9 @@ def serialize( if metadata_segment: out += "-{}".format(metadata_segment) - self._validate(out, style) + check_version(out, style) return out - def _validate(self, serialized: str, style: Style) -> None: - if style is None: - return - groups = { - Style.Pep440: ("PEP 440", _VALID_PEP440), - Style.SemVer: ("Semantic Versioning", _VALID_SEMVER), - Style.Pvp: ("PVP", _VALID_PVP), - } - name, pattern = groups[style] - failure_message = "Version '{}' does not conform to the {} style".format(serialized, name) - if not re.search(pattern, serialized): - raise ValueError(failure_message) - if style == Style.SemVer: - parts = re.split(r"[.-]", serialized.split("+", 1)[0]) - if any(re.search(r"^0[0-9]+$", x) for x in parts): - raise ValueError(failure_message) - @classmethod def from_git(cls, pattern: str = _VERSION_PATTERN, latest_tag: bool = False) -> "Version": r""" @@ -522,6 +505,27 @@ def from_any_vcs(cls, pattern: str = None, latest_tag: bool = False) -> "Version raise RuntimeError("Unable to detect version control system.") +def check_version(version: str, style: Style = Style.Pep440) -> None: + """ + Check if a version is valid for a style. + + :param version: Version to check. + :param style: Style against which to check. + """ + name, pattern = { + Style.Pep440: ("PEP 440", _VALID_PEP440), + Style.SemVer: ("Semantic Versioning", _VALID_SEMVER), + Style.Pvp: ("PVP", _VALID_PVP), + }[style] + failure_message = "Version '{}' does not conform to the {} style".format(version, name) + if not re.search(pattern, version): + raise ValueError(failure_message) + if style == Style.SemVer: + parts = re.split(r"[.-]", version.split("+", 1)[0]) + if any(re.search(r"^0[0-9]+$", x) for x in parts): + raise ValueError(failure_message) + + def get_version( name: str, first_choice: Callable[[], Optional[Version]] = None, diff --git a/dunamai/__main__.py b/dunamai/__main__.py index 0346b5f..b51bd18 100644 --- a/dunamai/__main__.py +++ b/dunamai/__main__.py @@ -3,7 +3,7 @@ from enum import Enum from typing import Mapping, Optional -from dunamai import Version, Style, _VERSION_PATTERN +from dunamai import check_version, Version, Style, _VERSION_PATTERN class Vcs(Enum): @@ -108,7 +108,24 @@ class Vcs(Enum): ], }, }, - } + }, + "check": { + "description": "Check if a version is valid for a style", + "args": [ + { + "triggers": [], + "dest": "version", + "help": "Version to check; may be piped in", + "nargs": "?", + }, + { + "triggers": ["--style"], + "choices": [x.value for x in Style], + "default": Style.Pep440.value, + "help": "Style against which to check", + }, + ], + }, }, } @@ -141,6 +158,16 @@ def parse_args(argv=None) -> argparse.Namespace: return build_parser(cli_spec).parse_args(argv) +def from_stdin(value: Optional[str]) -> Optional[str]: + if value is not None: + return value + + if not sys.stdin.isatty(): + return sys.stdin.readline().strip() + + return None + + def from_vcs( vcs: Vcs, pattern: str, @@ -180,6 +207,11 @@ def main() -> None: args.latest_tag, tag_dir, ) + elif args.command == "check": + version = from_stdin(args.version) + if version is None: + raise ValueError("A version must be specified") + check_version(version, Style(args.style)) except Exception as e: print(e, file=sys.stderr) sys.exit(1) diff --git a/tests/test_dunamai.py b/tests/test_dunamai.py index 25b63b9..983bae3 100644 --- a/tests/test_dunamai.py +++ b/tests/test_dunamai.py @@ -7,7 +7,7 @@ import pytest -from dunamai import get_version, Version, Style, _run_cmd +from dunamai import check_version, get_version, Version, Style, _run_cmd @contextmanager @@ -351,46 +351,9 @@ def test__version__serialize__format(): ) == "2,1,a,3,4,5,abc,dirty" ) - - assert Version("0.1.0").serialize(format="{base}", style=Style.Pep440) == "0.1.0" with pytest.raises(ValueError): Version("0.1.0").serialize(format="v{base}", style=Style.Pep440) - assert Version("0.1.0").serialize(format="{base}", style=Style.SemVer) == "0.1.0" - with pytest.raises(ValueError): - Version("0.1.0").serialize(format="v{base}", style=Style.SemVer) - - # "-" is a valid identifier. - Version("0.1.0").serialize(style=Style.SemVer, format="0.1.0--") - Version("0.1.0").serialize(style=Style.SemVer, format="0.1.0--.-") - - -def test__version__serialize__error_conditions(): - with pytest.raises(ValueError): - Version("x").serialize() - with pytest.raises(ValueError): - v = Version("1", pre=("a", 3)) - v.pre_type = "x" - v.serialize() - - # No leading zeroes in numeric segments: - with pytest.raises(ValueError): - Version("00.0.0").serialize(style=Style.SemVer) - with pytest.raises(ValueError): - Version("0.1.0").serialize(style=Style.SemVer, format="0.01.0") - with pytest.raises(ValueError): - Version("0.1.0").serialize(style=Style.SemVer, format="0.1.0-alpha.02") - # But leading zeroes are fine for non-numeric parts: - Version("0.1.0").serialize(style=Style.SemVer, format="0.1.0-alpha.02a") - - # Identifiers can't be empty: - with pytest.raises(ValueError): - Version("0.1.0").serialize(style=Style.SemVer, format="0.1.0-.") - with pytest.raises(ValueError): - Version("0.1.0").serialize(style=Style.SemVer, format="0.1.0-a.") - with pytest.raises(ValueError): - Version("0.1.0").serialize(style=Style.SemVer, format="0.1.0-.a") - def test__get_version__from_name(): assert get_version("dunamai") == Version(pkg_resources.get_distribution("dunamai").version) @@ -596,3 +559,76 @@ def test__version__from_any_vcs(tmp_path): with chdir(tmp_path): with pytest.raises(RuntimeError): Version.from_any_vcs() + + +def test__check_version__pep440(): + check_version("0.1.0") + check_version("0.01.0") + + check_version("2!0.1.0") + check_version("0.1.0a1") + check_version("0.1.0b1") + check_version("0.1.0rc1") + with pytest.raises(ValueError): + check_version("0.1.0x1") + + check_version("0.1.0.post0") + check_version("0.1.0.dev0") + check_version("0.1.0.post0.dev0") + with pytest.raises(ValueError): + check_version("0.1.0.other0") + + check_version("0.1.0+abc.dirty") + + check_version("2!0.1.0a1.post0.dev0+abc.dirty") + + +def test__check_version__semver(): + style = Style.SemVer + + check_version("0.1.0", style=style) + check_version("0.1.0-alpha.1", style=style) + check_version("0.1.0+abc", style=style) + check_version("0.1.0-alpha.1.beta.2+abc.dirty", style=style) + + with pytest.raises(ValueError): + check_version("1", style=style) + with pytest.raises(ValueError): + check_version("0.1", style=style) + with pytest.raises(ValueError): + check_version("0.0.0.1", style=style) + + # "-" is a valid identifier. + Version("0.1.0--").serialize(style=style) + Version("0.1.0--.-").serialize(style=style) + + # No leading zeroes in numeric segments: + with pytest.raises(ValueError): + Version("00.0.0").serialize(style=style) + with pytest.raises(ValueError): + Version("0.01.0").serialize(style=style) + with pytest.raises(ValueError): + Version("0.1.0-alpha.02").serialize(style=style) + # But leading zeroes are fine for non-numeric parts: + Version("0.1.0-alpha.02a").serialize(style=style) + + # Identifiers can't be empty: + with pytest.raises(ValueError): + Version("0.1.0-.").serialize(style=style) + with pytest.raises(ValueError): + Version("0.1.0-a.").serialize(style=style) + with pytest.raises(ValueError): + Version("0.1.0-.a").serialize(style=style) + + +def test__check_version__pvp(): + style = Style.Pvp + + check_version("1", style=style) + check_version("0.1", style=style) + check_version("0.0.1", style=style) + check_version("0.0.0.1", style=style) + check_version("0.1.0-alpha-1", style=style) + + with pytest.raises(ValueError): + check_version("0.1.0-a.1", style=style) diff --git a/tests/test_main.py b/tests/test_main.py index 383b01b..4753ab0 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,5 +1,8 @@ from argparse import Namespace +import pytest + +from dunamai import _run_cmd from dunamai.__main__ import parse_args, _VERSION_PATTERN @@ -28,3 +31,23 @@ def test__parse_args__from(): assert parse_args(["from", "any", "--style", "semver"]).style == "semver" assert parse_args(["from", "any", "--latest-tag"]).latest_tag is True assert parse_args(["from", "subversion", "--tag-dir", "foo"]).tag_dir == "foo" + + with pytest.raises(SystemExit): + parse_args(["from", "unknown"]) + + +def test__parse_args__check(): + assert parse_args(["check", "0.1.0"]) == Namespace( + command="check", version="0.1.0", style="pep440" + ) + assert parse_args(["check", "0.1.0", "--style", "semver"]).style == "semver" + assert parse_args(["check", "0.1.0", "--style", "pvp"]).style == "pvp" + + with pytest.raises(SystemExit): + parse_args(["check", "0.1.0", "--style", "unknown"]) + + +def test__cli_check(): + _run_cmd("dunamai check 0.01.0") + _run_cmd("dunamai check v0.1.0", codes=[1]) + _run_cmd("dunamai check 0.01.0 --style semver", codes=[1])