Skip to content

Commit e3e0a44

Browse files
NiteshDhanpalclaude
andcommitted
fix(compat): order prereleases below stable in version guard
SemVer §11: a prerelease precedes its stable release (0.1.0-rc.1 < 0.1.0). The old _parse dropped the suffix, so a release-candidate backend compared equal to a stable floor and slipped past the guard even though it may lack the final contract. Parse the prerelease and compare via a SemVer precedence key; a prerelease of a higher version (0.2.0-rc.1) still clears a 0.1.0 floor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent adbaf46 commit e3e0a44

2 files changed

Lines changed: 52 additions & 7 deletions

File tree

src/agentex/lib/core/compat/version_guard.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,43 @@
3434

3535
SKIP_ENV = "AGENTEX_SKIP_VERSION_CHECK"
3636

37-
_VERSION_RE = re.compile(r"^\s*v?(\d+)\.(\d+)\.(\d+)")
37+
# major.minor.patch, optional `-prerelease`; build metadata (after `+`) is ignored.
38+
_VERSION_RE = re.compile(r"^\s*v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?")
3839

3940

4041
class IncompatibleBackendError(RuntimeError):
4142
"""Raised when the agentex backend is older than this SDK's minimum supported contract."""
4243

4344

44-
def _parse(version: str | None) -> tuple[int, int, int] | None:
45+
def _parse(version: str | None) -> tuple[int, int, int, str | None] | None:
46+
"""Parse ``major.minor.patch[-prerelease]`` → ``(major, minor, patch, prerelease)``.
47+
48+
``prerelease`` is the raw dot-separated identifier string (e.g. ``"rc.1"``), or None for
49+
a stable release. Build metadata (after ``+``) is ignored. Returns None if unparseable.
50+
"""
4551
m = _VERSION_RE.match(version or "")
46-
return (int(m.group(1)), int(m.group(2)), int(m.group(3))) if m else None
52+
if not m:
53+
return None
54+
return (int(m.group(1)), int(m.group(2)), int(m.group(3)), m.group(4) or None)
55+
56+
57+
def _precedence_key(parsed: tuple[int, int, int, str | None]):
58+
"""SemVer §11 precedence key (directly comparable with ``<``).
59+
60+
A stable release outranks any prerelease of the same triplet (``0.1.0-rc.1 < 0.1.0``);
61+
among prereleases, numeric identifiers rank below alphanumeric and compare field-by-field,
62+
with a longer identifier list outranking a shorter prefix-equal one.
63+
"""
64+
major, minor, patch, prerelease = parsed
65+
if prerelease is None:
66+
return (major, minor, patch, (1,)) # stable sorts above every prerelease
67+
identifiers = []
68+
for ident in prerelease.split("."):
69+
if ident.isdigit():
70+
identifiers.append((0, int(ident), "")) # numeric: lowest class, numeric order
71+
else:
72+
identifiers.append((1, 0, ident)) # alphanumeric: higher class, lexical order
73+
return (major, minor, patch, (0, identifiers))
4774

4875

4976
def _truthy(name: str) -> bool:
@@ -108,7 +135,7 @@ async def assert_backend_compatible(
108135
)
109136
return
110137

111-
if backend < minimum:
138+
if _precedence_key(backend) < _precedence_key(minimum):
112139
raise IncompatibleBackendError(
113140
f"agentex-sdk {sdk_version} requires agentex backend >= {min_version}, "
114141
f"but {base_url} reports {backend_version}. "

tests/test_version_guard.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,22 @@ def _run(coro):
1414

1515

1616
def test_parse_versions():
17-
assert vg._parse("0.2.1") == (0, 2, 1)
18-
assert vg._parse("v1.4.0") == (1, 4, 0)
19-
assert vg._parse("0.2.1-rc.1+build5") == (0, 2, 1)
17+
assert vg._parse("0.2.1") == (0, 2, 1, None)
18+
assert vg._parse("v1.4.0") == (1, 4, 0, None)
19+
assert vg._parse("0.2.1-rc.1+build5") == (0, 2, 1, "rc.1") # build metadata ignored
2020
assert vg._parse("garbage") is None
2121
assert vg._parse(None) is None
2222

2323

24+
def test_prerelease_precedence():
25+
k = lambda v: vg._precedence_key(vg._parse(v)) # noqa: E731
26+
assert k("0.1.0-rc.1") < k("0.1.0") # prerelease precedes its stable release (SemVer §11)
27+
assert k("0.1.0-rc.1") < k("0.1.0-rc.2") # numeric prerelease identifiers compare numerically
28+
assert k("0.1.0-alpha") < k("0.1.0-rc") # numeric/alpha ordering by identifier
29+
assert k("0.1.0") < k("0.1.1-rc.1") # patch bump outranks prior stable
30+
assert k("0.2.0-rc.1") > k("0.1.0") # prerelease of a higher version still clears the floor
31+
32+
2433
def test_compatible_backend_passes(monkeypatch):
2534
async def fake(url, **kw):
2635
return "0.2.0"
@@ -41,6 +50,15 @@ async def fake(url, **kw):
4150
assert "0.13.0" in msg and "0.1.0" in msg and "0.0.9" in msg # actionable message
4251

4352

53+
def test_prerelease_backend_below_stable_floor_raises(monkeypatch):
54+
async def fake(url, **kw):
55+
return "0.1.0-rc.1" # release candidate: precedes the stable 0.1.0 contract
56+
57+
monkeypatch.setattr(vg, "fetch_backend_version", fake)
58+
with pytest.raises(vg.IncompatibleBackendError):
59+
_run(vg.assert_backend_compatible("http://backend", min_version="0.1.0", sdk_version="0.13.0"))
60+
61+
4462
def test_skip_env_bypasses(monkeypatch):
4563
async def fake(url, **kw):
4664
raise AssertionError("must not fetch when skip env is set")

0 commit comments

Comments
 (0)