diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c1bf30..9a5002b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to this project will be documented in this file. +## [0.4.9] 2026-02-06 +### Fixed +- `Version` now ignores buildmetadata when comparing versions. + ## [0.4.8] 2026-02-06 ### Added - `checksum_any` now supports `Enum` instances. diff --git a/CITATION.cff b/CITATION.cff index 45e46ea..0655685 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -17,5 +17,5 @@ keywords: - tools - utilities license: MIT -version: 0.4.8 +version: 0.4.9 date-released: '2026-02-06' diff --git a/src/pythonwrench/__init__.py b/src/pythonwrench/__init__.py index a9c3109..b2f3fac 100644 --- a/src/pythonwrench/__init__.py +++ b/src/pythonwrench/__init__.py @@ -9,7 +9,7 @@ __license__ = "MIT" __maintainer__ = "Étienne Labbé (Labbeti)" __status__ = "Development" -__version__ = "0.4.8" +__version__ = "0.4.9" # Re-import for language servers diff --git a/src/pythonwrench/semver.py b/src/pythonwrench/semver.py index b7f87d0..9ca3b86 100644 --- a/src/pythonwrench/semver.py +++ b/src/pythonwrench/semver.py @@ -5,7 +5,7 @@ import re import sys from dataclasses import asdict, dataclass -from typing import Any, Iterable, List, Mapping, Tuple, TypedDict, Union, overload +from typing import Any, List, Mapping, Tuple, TypedDict, Union, overload from typing_extensions import NotRequired, Self, TypeAlias @@ -40,7 +40,7 @@ class VersionDict(TypedDict): ] VersionDictLike: TypeAlias = Mapping[str, Union[int, PreRelease, BuildMetadata]] -VersionTupleLike: TypeAlias = Iterable[Union[int, PreRelease, BuildMetadata]] +VersionTupleLike: TypeAlias = Tuple[Union[int, PreRelease, BuildMetadata], ...] VersionLike: TypeAlias = Union["Version", str, VersionDictLike, VersionTupleLike] @@ -50,7 +50,7 @@ class Version: Version format is: MAJOR.MINOR.PATCH[-PRERELEASE][+BUILDMETADATA] - Based on https://semver.org/. + Based on https://semver.org/ version 2.0.0. """ major: int @@ -266,11 +266,8 @@ def to_tuple( version_tuple = tuple(self.to_dict(exclude_none).values()) return version_tuple # type: ignore - def __str__(self) -> str: - return self.to_str() - - def __eq__(self, other: Any) -> bool: - if isinstance(other, (dict, tuple, str)): + def equals(self, other: VersionLike, *, ignore_buildmetadata: bool = False) -> bool: + if isinstance(other, (Mapping, tuple, str)): other = Version(other) # note: use self.__class__ to avoid error cause by 'pytest -v test' collect elif not isinstance(other, (Version, self.__class__)): @@ -281,67 +278,80 @@ def __eq__(self, other: Any) -> bool: and self.minor == other.minor and self.patch == other.patch and self.prerelease == other.prerelease - and self.buildmetadata == other.buildmetadata + and (ignore_buildmetadata or self.buildmetadata == other.buildmetadata) ) - def __lt__(self, other: VersionLike) -> bool: - if isinstance(other, (dict, tuple, str)): - other = Version(other) - # note: use self.__class__ to avoid error cause by 'pytest -v test' collect - elif not isinstance(other, (Version, self.__class__)): - msg = f"Invalid argument type {type(other)}. (expected an instance of one of {(dict, tuple, str, Version)})" - raise TypeError(msg) - - self_tuple = self.to_tuple(exclude_none=False) - other_tuple = other.to_tuple(exclude_none=False) - - for self_v, other_v in zip(self_tuple, other_tuple): - if self_v == other_v: - continue - if self_v is None and other_v is not None: - return False - if self_v is not None and other_v is None: - return True + def __str__(self) -> str: + return self.to_str() - if isinstance(self_v, (int, str, NoneType)): - self_v = [self_v] - elif not isinstance(self_v, list): - raise TypeError(f"Invalid argument type {type(self_v)}.") - - if isinstance(other_v, (int, str, NoneType)): - other_v = [other_v] - elif not isinstance(other_v, list): - raise TypeError(f"Invalid argument type {type(other_v)}.") - - minlen = min(len(self_v), len(other_v)) - if len(self_v) != len(other_v) and self_v[:minlen] == other_v[:minlen]: - return len(self_v) < len(other_v) - - for self_vi, other_vi in zip(self_v, other_v): - if self_vi == other_vi: - continue - if isinstance(self_vi, int) and isinstance(other_vi, int): - return self_vi < other_vi - if isinstance(self_vi, int) and isinstance(other_vi, str): - return True - if isinstance(self_vi, str) and isinstance(other_vi, int): - return False - if isinstance(self_vi, str) and isinstance(other_vi, str): - return self_vi < other_vi - - msg = f"Invalid attribute type {self_vi=} and {other_vi=}." - raise TypeError(msg) + def __eq__(self, other: Any) -> bool: + return self.equals(other) - return False + def __lt__(self, other: VersionLike) -> bool: + return _compare_lt(self, other) def __le__(self, other: VersionLike) -> bool: return (self == other) or (self < other) def __gt__(self, other: VersionLike) -> bool: - return (self != other) and not (self < other) + return _compare_lt(other, self) def __ge__(self, other: VersionLike) -> bool: - return not (self < other) + return (self == other) or (self > other) + + +def _compare_lt( + x: Union[Version, Mapping, tuple, str], y: Union[Version, Mapping, tuple, str] +) -> bool: + if isinstance(x, (Mapping, tuple, str)): + x = Version(x) + if isinstance(y, (Mapping, tuple, str)): + y = Version(y) + + self_tuple = x.to_tuple(exclude_none=False) + other_tuple = y.to_tuple(exclude_none=False) + + self_tuple = self_tuple[:4] + other_tuple = other_tuple[:4] + + for self_v, other_v in zip(self_tuple, other_tuple): + if self_v == other_v: + continue + if self_v is None and other_v is not None: + return False + if self_v is not None and other_v is None: + return True + + if isinstance(self_v, (int, str, NoneType)): + self_v = [self_v] + elif not isinstance(self_v, list): + raise TypeError(f"Invalid argument type {type(self_v)}.") + + if isinstance(other_v, (int, str, NoneType)): + other_v = [other_v] + elif not isinstance(other_v, list): + raise TypeError(f"Invalid argument type {type(other_v)}.") + + minlen = min(len(self_v), len(other_v)) + if len(self_v) != len(other_v) and self_v[:minlen] == other_v[:minlen]: + return len(self_v) < len(other_v) + + for self_vi, other_vi in zip(self_v, other_v): + if self_vi == other_vi: + continue + if isinstance(self_vi, int) and isinstance(other_vi, int): + return self_vi < other_vi + if isinstance(self_vi, int) and isinstance(other_vi, str): + return True + if isinstance(self_vi, str) and isinstance(other_vi, int): + return False + if isinstance(self_vi, str) and isinstance(other_vi, str): + return self_vi < other_vi + + msg = f"Invalid attribute type {self_vi=} and {other_vi=}." + raise TypeError(msg) + + return False def _parse_version_str(version_str: str) -> VersionDict: diff --git a/tests/test_semver.py b/tests/test_semver.py index f90d030..49d1836 100644 --- a/tests/test_semver.py +++ b/tests/test_semver.py @@ -63,6 +63,15 @@ def test_versions(self) -> None: # Check if versions can be parsed Version(pw.__version__) + # buildmetadata can contains "-" symbol + v14 = Version("1.0.0+build-info") + assert v14.to_dict() == { + "major": 1, + "minor": 0, + "patch": 0, + "buildmetadata": "build-info", + } + def test_parse_invalid(self) -> None: with self.assertRaises(ValueError): Version() # type: ignore @@ -133,6 +142,27 @@ def test_parse(self) -> None: for version_str in tests: _ = Version(version_str) + def test_priority(self) -> None: + v1 = Version("1.0.0") + v2 = Version("1.0.0-alpha") + v3 = Version("1.0.0+build") + v4 = Version("1.0.0-alpha+build") + + # IMPORTANT: prerelease should have lower precedence than the associated normal version + assert v1 > v2 + assert not (v1 < v2) + assert v1 != v2 + + # IMPORTANT: build metadata should not affect version precedence + assert not (v1 > v3) + assert not (v1 < v3) + assert v1 != v3 + + # check both + assert v1 > v4 + assert not (v1 < v4) + assert v1 != v4 + if __name__ == "__main__": unittest.main()