From b15235e7092abd84e72115f992e95544634a2739 Mon Sep 17 00:00:00 2001 From: aplowman Date: Wed, 22 May 2024 14:18:58 +0100 Subject: [PATCH] feat: add `SemanticVersionSpec` to later support comparing env versions in command/action rules --- hpcflow/sdk/core/environment.py | 132 ++++++++++++++++++++++++- hpcflow/sdk/core/errors.py | 4 + hpcflow/tests/unit/test_environment.py | 77 +++++++++++++++ 3 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 hpcflow/tests/unit/test_environment.py diff --git a/hpcflow/sdk/core/environment.py b/hpcflow/sdk/core/environment.py index 4cbad9869..79e051983 100644 --- a/hpcflow/sdk/core/environment.py +++ b/hpcflow/sdk/core/environment.py @@ -2,11 +2,12 @@ from dataclasses import dataclass from typing import List, Any - +from itertools import zip_longest from textwrap import dedent +import re from hpcflow.sdk import app -from hpcflow.sdk.core.errors import DuplicateExecutableError +from hpcflow.sdk.core.errors import DuplicateExecutableError, SemanticVersionSpecError from hpcflow.sdk.core.json_like import ChildObjectSpec, JSONLike from hpcflow.sdk.core.object_list import ExecutablesList from hpcflow.sdk.core.utils import check_valid_py_identifier, get_duplicate_items @@ -165,3 +166,130 @@ def _validate(self): f"Executables must have unique `label`s within each environment, but " f"found label(s) multiple times: {dup_labels!r}" ) + + +class SortableVersionSpec: + def __init__(self, value) -> None: + self.value = value + + def __eq__(self, __value: object) -> bool: + return self.value == __value.value + + def __lt__(self, other) -> bool: + return self.value < other.value + + +class SemanticVersionSpec(SortableVersionSpec): + + # used to indicate a given environment definition version specifier should use this + # version spec class: + id_ = "semantic" + + # from https://semver.org/ + RE_PATTERN = ( + r"^(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)" + r"(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)" + r"(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?" + r"(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$" + ) + + def __init__(self, value) -> None: + super().__init__(value) + self._parts = self._get_parts() + + @property + def parts(self): + return self._parts + + def _get_parts(self): + match = re.match(self.RE_PATTERN, self.value) + if not match: + raise SemanticVersionSpecError( + f"Version {self.value!r} does not seem to conform to the semantic " + f"versioning specification as defined at https://semver.org/." + ) + dct = match.groupdict() + dct["major"] = int(dct["major"]) + dct["minor"] = int(dct["minor"]) + dct["patch"] = int(dct["patch"]) + if dct["prerelease"]: + # split on dots, and try to cast to integers: + dct["prerelease"] = dct["prerelease"].split(".") + for idx, i in enumerate(dct["prerelease"]): + try: + i_int = int(i) + except ValueError: + pass + else: + dct["prerelease"][idx] = i_int + + return dct + + def __eq__(self, __value: object) -> bool: + if not isinstance(__value, SemanticVersionSpec): + __value = SemanticVersionSpec(__value) + parts = {k: v for k, v in self.parts.items() if k != "buildmetadata"} + parts_other = {k: v for k, v in __value.parts.items() if k != "buildmetadata"} + return parts == parts_other + + def __gt__(self, other) -> bool: + if not isinstance(other, SemanticVersionSpec): + other = SemanticVersionSpec(other) + return other <= self + + def __ge__(self, other) -> bool: + if not isinstance(other, SemanticVersionSpec): + other = SemanticVersionSpec(other) + return self == other or self > other + + def __le__(self, other) -> bool: + if not isinstance(other, SemanticVersionSpec): + other = SemanticVersionSpec(other) + return self == other or self < other + + def __lt__(self, other) -> bool: + if not isinstance(other, SemanticVersionSpec): + other = SemanticVersionSpec(other) + parts = self.parts + parts_o = other.parts + if parts["major"] < parts_o["major"]: + return True + elif parts["major"] > parts_o["major"]: + return False + elif parts["minor"] < parts_o["minor"]: + return True + elif parts["minor"] > parts_o["minor"]: + return False + elif parts["patch"] < parts_o["patch"]: + return True + elif parts["patch"] > parts_o["patch"]: + return False + else: + # same (major, minor, patch), look at prerelease (buildmetadata not + # considered) + + # prerelease has lower precedence than normal release: + if parts["prerelease"] and parts_o["prerelease"] is None: + return True + elif parts["prerelease"] is None and parts_o["prerelease"]: + return False + else: + # both have some prerelease defined + for i, j in zip_longest(parts["prerelease"], parts_o["prerelease"]): + if i is None: + return True + elif j is None: + return False + try: + if i < j: + return True + elif j < i: + return False + else: + continue + except TypeError: + # numeric identifiers have lower precedence: + if isinstance(i, int): + return True + elif isinstance(j, int): + return False diff --git a/hpcflow/sdk/core/errors.py b/hpcflow/sdk/core/errors.py index 593fcd334..16ecc549d 100644 --- a/hpcflow/sdk/core/errors.py +++ b/hpcflow/sdk/core/errors.py @@ -431,3 +431,7 @@ class MultipleEnvironmentsError(ValueError): class MissingElementGroup(ValueError): pass + + +class SemanticVersionSpecError(ValueError): + pass diff --git a/hpcflow/tests/unit/test_environment.py b/hpcflow/tests/unit/test_environment.py new file mode 100644 index 000000000..48bfebfef --- /dev/null +++ b/hpcflow/tests/unit/test_environment.py @@ -0,0 +1,77 @@ +import pytest +from hpcflow.sdk.core.environment import SemanticVersionSpec +from hpcflow.sdk.core.errors import SemanticVersionSpecError + + +def test_precedence_maj_min_patch(): + assert ( + SemanticVersionSpec("1.0.0") + < SemanticVersionSpec("2.0.0") + < SemanticVersionSpec("2.1.0") + < SemanticVersionSpec("2.1.1") + ) + + +def test_precedence_patch(): + assert SemanticVersionSpec("0.0.1") < SemanticVersionSpec("0.0.10") + + +def test_precedence_prerelease_simple(): + assert SemanticVersionSpec("1.0.0-alpha") < SemanticVersionSpec("1.0.0") + + +def test_precedence_prerelease_complex(): + assert ( + SemanticVersionSpec("1.0.0-alpha") + < SemanticVersionSpec("1.0.0-alpha.1") + < SemanticVersionSpec("1.0.0-alpha.beta") + < SemanticVersionSpec("1.0.0-beta") + < SemanticVersionSpec("1.0.0-beta.2") + < SemanticVersionSpec("1.0.0-beta.11") + < SemanticVersionSpec("1.0.0-rc.1") + < SemanticVersionSpec("1.0.0") + ) + + +def test_equality(): + assert SemanticVersionSpec("1.0.0") == SemanticVersionSpec("1.0.0") + + +def test_equality_with_build_metadata(): + assert SemanticVersionSpec("1.0.0") == SemanticVersionSpec("1.0.0+xyz") + + +def test_equality_with_prerelease_and_build_metadata(): + assert SemanticVersionSpec("1.0.0-beta.11") == SemanticVersionSpec( + "1.0.0-beta.11+xyz" + ) + + +def test_equality_str(): + assert SemanticVersionSpec("1.0.0") == "1.0.0" + + +def test_lt_str(): + assert SemanticVersionSpec("1.0.0") < "1.1.0" + + +def test_gt_str(): + assert SemanticVersionSpec("1.1.0") > "1.0.0" + + +def test_ge_str(): + assert SemanticVersionSpec("1.1.0") >= "1.0.0" + + +def test_le_str(): + assert SemanticVersionSpec("1.0.0") <= "1.1.0" + + +def test_raise_semver_error_missing_buildmetadata(): + with pytest.raises(SemanticVersionSpecError): + SemanticVersionSpec("1.0.0+") + + +def test_raise_semver_error_missing_min_or_patch(): + with pytest.raises(SemanticVersionSpecError): + SemanticVersionSpec("1.0")