Skip to content

Commit 9c3739c

Browse files
zeevdrclaude
andauthored
fix(compat): replace custom regex version parsing with packaging.version.Version (#98)
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 <noreply@anthropic.com>
1 parent 364c38b commit 9c3739c

2 files changed

Lines changed: 45 additions & 49 deletions

File tree

sdk/src/opendecree/_compat.py

Lines changed: 14 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66

77
from __future__ import annotations
88

9-
import re
109
from typing import Any
1110

11+
from packaging.specifiers import InvalidSpecifier, Specifier
12+
from packaging.version import InvalidVersion, Version
13+
1214
import opendecree
1315
from opendecree.errors import IncompatibleServerError
1416
from opendecree.types import ServerVersion
@@ -64,44 +66,24 @@ def check_version_compatible(server_version: str, supported_range: str | None =
6466
return
6567

6668
for constraint in supported_range.split(","):
67-
constraint = constraint.strip()
68-
if not _satisfies(parsed, constraint):
69+
if not _satisfies(parsed, constraint.strip()):
6970
raise IncompatibleServerError(
7071
f"Server version {server_version} is not compatible with this SDK "
7172
f"(requires {supported_range})"
7273
)
7374

7475

75-
def _parse_version(version: str) -> tuple[int, ...] | None:
76-
"""Parse a semver string into a tuple of ints, or None if unparseable."""
77-
match = re.match(r"^v?(\d+(?:\.\d+)*)", version)
78-
if not match:
76+
def _parse_version(version: str) -> Version | None:
77+
"""Parse a version string via PEP 440, or None if unparseable."""
78+
try:
79+
return Version(version)
80+
except InvalidVersion:
7981
return None
80-
return tuple(int(p) for p in match.group(1).split("."))
81-
8282

83-
def _satisfies(version: tuple[int, ...], constraint: str) -> bool:
84-
"""Check if a version tuple satisfies a single constraint like '>=0.3.0'."""
85-
match = re.match(r"^(>=|<=|>|<|==|!=)(.+)$", constraint)
86-
if not match:
87-
return True
8883

89-
op = match.group(1)
90-
target = _parse_version(match.group(2))
91-
if target is None:
84+
def _satisfies(version: Version, constraint: str) -> bool:
85+
"""Check if a Version satisfies a single constraint like '>=0.3.0'."""
86+
try:
87+
return version in Specifier(constraint, prereleases=True)
88+
except InvalidSpecifier:
9289
return True
93-
94-
# Pad to same length for comparison.
95-
max_len = max(len(version), len(target))
96-
v = version + (0,) * (max_len - len(version))
97-
t = target + (0,) * (max_len - len(target))
98-
99-
ops = {
100-
">=": v >= t,
101-
"<=": v <= t,
102-
">": v > t,
103-
"<": v < t,
104-
"==": v == t,
105-
"!=": v != t,
106-
}
107-
return ops[op]

sdk/tests/test_compat.py

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from unittest.mock import AsyncMock, MagicMock, patch
44

55
import pytest
6+
from packaging.version import Version
67

78
from opendecree._compat import (
89
_parse_version,
@@ -18,15 +19,25 @@
1819

1920

2021
def test_parse_semver():
21-
assert _parse_version("0.3.1") == (0, 3, 1)
22+
assert _parse_version("0.3.1") == Version("0.3.1")
2223

2324

2425
def test_parse_with_v_prefix():
25-
assert _parse_version("v1.2.3") == (1, 2, 3)
26+
assert _parse_version("v1.2.3") == Version("1.2.3")
2627

2728

2829
def test_parse_major_only():
29-
assert _parse_version("2") == (2,)
30+
assert _parse_version("2") == Version("2")
31+
32+
33+
def test_parse_prerelease():
34+
assert _parse_version("0.3.0-rc1") == Version("0.3.0rc1")
35+
36+
37+
def test_parse_local_version():
38+
v = _parse_version("v0.3.0+sha123")
39+
assert v is not None
40+
assert v == Version("0.3.0+sha123")
3041

3142

3243
def test_parse_dev():
@@ -41,34 +52,37 @@ def test_parse_empty():
4152

4253

4354
def test_satisfies_gte():
44-
assert _satisfies((0, 3, 1), ">=0.3.0") is True
45-
assert _satisfies((0, 3, 0), ">=0.3.0") is True
46-
assert _satisfies((0, 2, 9), ">=0.3.0") is False
55+
assert _satisfies(Version("0.3.1"), ">=0.3.0") is True
56+
assert _satisfies(Version("0.3.0"), ">=0.3.0") is True
57+
assert _satisfies(Version("0.2.9"), ">=0.3.0") is False
4758

4859

4960
def test_satisfies_lt():
50-
assert _satisfies((0, 9, 0), "<1.0.0") is True
51-
assert _satisfies((1, 0, 0), "<1.0.0") is False
61+
assert _satisfies(Version("0.9.0"), "<1.0.0") is True
62+
assert _satisfies(Version("1.0.0"), "<1.0.0") is False
5263

5364

5465
def test_satisfies_eq():
55-
assert _satisfies((1, 2, 3), "==1.2.3") is True
56-
assert _satisfies((1, 2, 4), "==1.2.3") is False
66+
assert _satisfies(Version("1.2.3"), "==1.2.3") is True
67+
assert _satisfies(Version("1.2.4"), "==1.2.3") is False
5768

5869

5970
def test_satisfies_neq():
60-
assert _satisfies((1, 2, 4), "!=1.2.3") is True
61-
assert _satisfies((1, 2, 3), "!=1.2.3") is False
71+
assert _satisfies(Version("1.2.4"), "!=1.2.3") is True
72+
assert _satisfies(Version("1.2.3"), "!=1.2.3") is False
73+
74+
75+
def test_satisfies_short_version():
76+
assert _satisfies(Version("1"), ">=1.0.0") is True
77+
assert _satisfies(Version("1"), "<2.0.0") is True
6278

6379

64-
def test_satisfies_padding():
65-
"""Shorter version tuples are padded with zeros."""
66-
assert _satisfies((1,), ">=1.0.0") is True
67-
assert _satisfies((1,), "<2.0.0") is True
80+
def test_satisfies_prerelease_lt_release():
81+
assert _satisfies(Version("0.3.0rc1"), ">=0.3.0") is False
6882

6983

7084
def test_satisfies_invalid_constraint():
71-
assert _satisfies((1, 0, 0), "garbage") is True
85+
assert _satisfies(Version("1.0.0"), "garbage") is True
7286

7387

7488
# --- check_version_compatible ---

0 commit comments

Comments
 (0)