From 62da2da728287a0447409d99b4eee548075317fa Mon Sep 17 00:00:00 2001 From: zeevdr Date: Mon, 25 May 2026 07:26:43 +0300 Subject: [PATCH] fix(compat): replace custom regex version parsing with packaging.version.Version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the hand-rolled regex and tuple arithmetic in _parse_version and _satisfies in favour of packaging.version.Version and packaging.specifiers.Specifier. This handles pre-release tags (0.3.0-rc1 → 0.3.0rc1) and local segments (v0.3.0+sha) correctly per PEP 440, and removes approximately 20 lines of bespoke comparison logic. packaging is available as a transitive dep via setuptools. Closes #62 Co-Authored-By: Claude --- sdk/src/opendecree/_compat.py | 46 ++++++++++----------------------- sdk/tests/test_compat.py | 48 ++++++++++++++++++++++------------- 2 files changed, 45 insertions(+), 49 deletions(-) diff --git a/sdk/src/opendecree/_compat.py b/sdk/src/opendecree/_compat.py index ea12dd6..3136710 100644 --- a/sdk/src/opendecree/_compat.py +++ b/sdk/src/opendecree/_compat.py @@ -6,9 +6,11 @@ from __future__ import annotations -import re from typing import Any +from packaging.specifiers import InvalidSpecifier, Specifier +from packaging.version import InvalidVersion, Version + import opendecree from opendecree.errors import IncompatibleServerError from opendecree.types import ServerVersion @@ -64,44 +66,24 @@ def check_version_compatible(server_version: str, supported_range: str | None = return for constraint in supported_range.split(","): - constraint = constraint.strip() - if not _satisfies(parsed, constraint): + if not _satisfies(parsed, constraint.strip()): raise IncompatibleServerError( f"Server version {server_version} is not compatible with this SDK " f"(requires {supported_range})" ) -def _parse_version(version: str) -> tuple[int, ...] | None: - """Parse a semver string into a tuple of ints, or None if unparseable.""" - match = re.match(r"^v?(\d+(?:\.\d+)*)", version) - if not match: +def _parse_version(version: str) -> Version | None: + """Parse a version string via PEP 440, or None if unparseable.""" + try: + return Version(version) + except InvalidVersion: return None - return tuple(int(p) for p in match.group(1).split(".")) - -def _satisfies(version: tuple[int, ...], constraint: str) -> bool: - """Check if a version tuple satisfies a single constraint like '>=0.3.0'.""" - match = re.match(r"^(>=|<=|>|<|==|!=)(.+)$", constraint) - if not match: - return True - op = match.group(1) - target = _parse_version(match.group(2)) - if target is None: +def _satisfies(version: Version, constraint: str) -> bool: + """Check if a Version satisfies a single constraint like '>=0.3.0'.""" + try: + return version in Specifier(constraint, prereleases=True) + except InvalidSpecifier: return True - - # Pad to same length for comparison. - max_len = max(len(version), len(target)) - v = version + (0,) * (max_len - len(version)) - t = target + (0,) * (max_len - len(target)) - - ops = { - ">=": v >= t, - "<=": v <= t, - ">": v > t, - "<": v < t, - "==": v == t, - "!=": v != t, - } - return ops[op] diff --git a/sdk/tests/test_compat.py b/sdk/tests/test_compat.py index 12da44d..f595672 100644 --- a/sdk/tests/test_compat.py +++ b/sdk/tests/test_compat.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from packaging.version import Version from opendecree._compat import ( _parse_version, @@ -18,15 +19,25 @@ def test_parse_semver(): - assert _parse_version("0.3.1") == (0, 3, 1) + assert _parse_version("0.3.1") == Version("0.3.1") def test_parse_with_v_prefix(): - assert _parse_version("v1.2.3") == (1, 2, 3) + assert _parse_version("v1.2.3") == Version("1.2.3") def test_parse_major_only(): - assert _parse_version("2") == (2,) + assert _parse_version("2") == Version("2") + + +def test_parse_prerelease(): + assert _parse_version("0.3.0-rc1") == Version("0.3.0rc1") + + +def test_parse_local_version(): + v = _parse_version("v0.3.0+sha123") + assert v is not None + assert v == Version("0.3.0+sha123") def test_parse_dev(): @@ -41,34 +52,37 @@ def test_parse_empty(): def test_satisfies_gte(): - assert _satisfies((0, 3, 1), ">=0.3.0") is True - assert _satisfies((0, 3, 0), ">=0.3.0") is True - assert _satisfies((0, 2, 9), ">=0.3.0") is False + assert _satisfies(Version("0.3.1"), ">=0.3.0") is True + assert _satisfies(Version("0.3.0"), ">=0.3.0") is True + assert _satisfies(Version("0.2.9"), ">=0.3.0") is False def test_satisfies_lt(): - assert _satisfies((0, 9, 0), "<1.0.0") is True - assert _satisfies((1, 0, 0), "<1.0.0") is False + assert _satisfies(Version("0.9.0"), "<1.0.0") is True + assert _satisfies(Version("1.0.0"), "<1.0.0") is False def test_satisfies_eq(): - assert _satisfies((1, 2, 3), "==1.2.3") is True - assert _satisfies((1, 2, 4), "==1.2.3") is False + assert _satisfies(Version("1.2.3"), "==1.2.3") is True + assert _satisfies(Version("1.2.4"), "==1.2.3") is False def test_satisfies_neq(): - assert _satisfies((1, 2, 4), "!=1.2.3") is True - assert _satisfies((1, 2, 3), "!=1.2.3") is False + assert _satisfies(Version("1.2.4"), "!=1.2.3") is True + assert _satisfies(Version("1.2.3"), "!=1.2.3") is False + + +def test_satisfies_short_version(): + assert _satisfies(Version("1"), ">=1.0.0") is True + assert _satisfies(Version("1"), "<2.0.0") is True -def test_satisfies_padding(): - """Shorter version tuples are padded with zeros.""" - assert _satisfies((1,), ">=1.0.0") is True - assert _satisfies((1,), "<2.0.0") is True +def test_satisfies_prerelease_lt_release(): + assert _satisfies(Version("0.3.0rc1"), ">=0.3.0") is False def test_satisfies_invalid_constraint(): - assert _satisfies((1, 0, 0), "garbage") is True + assert _satisfies(Version("1.0.0"), "garbage") is True # --- check_version_compatible ---