-
Notifications
You must be signed in to change notification settings - Fork 9
Expand file tree
/
Copy pathversion_guard.py
More file actions
164 lines (129 loc) · 6.64 KB
/
Copy pathversion_guard.py
File metadata and controls
164 lines (129 loc) · 6.64 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
"""Runtime SDK ↔ backend contract-version guard.
Complements the *build-time* cross-version compatibility tests (``tests/compat``):
- **Build-time** (CI): is this *client* compatible with the window of supported server
contracts (``min-supported``..``current``)?
- **Runtime** (this module): is the *server* the SDK is pointed at within that window?
It runs once at ACP/worker startup, reads the backend's contract version (the version
the server already reports via ``/openapi.json`` ``info.version``), and **fails fast with
an actionable error** if the backend is older than this SDK supports — instead of the
mismatch surfacing later as opaque 500s / missing-field errors deep in a request.
``MIN_BACKEND_CONTRACT`` is the same source of truth as the ``min-supported`` server
contract in ``tests/compat/server_specs/manifest.json``: the oldest agentex backend this
SDK version supports. Bump both together when a breaking change raises the floor.
"""
from __future__ import annotations
import os
import re
import httpx
from agentex.lib.utils.logging import make_logger
logger = make_logger(__name__)
# Oldest agentex backend contract this SDK is compatible with.
# Keep in sync with the `min-supported` spec in tests/compat (#407); the version axis
# itself comes from scale-agentex release tags (#321). Bump on a breaking SDK change.
MIN_BACKEND_CONTRACT = "0.1.0"
SKIP_ENV = "AGENTEX_SKIP_VERSION_CHECK"
# Full-string SemVer. Accepts: `1.2.3`, leading `v`, surrounding whitespace, `-prerelease`
# (captured), `+build` (ignored). Anchored at both ends so a malformed tail (`0.1.0rc1`,
# `0.1.0.1`) is rejected → None → "unknown, proceed", not silently coerced to stable `0.1.0`.
_VERSION_RE = re.compile(
r"^\s*v?(\d+)\.(\d+)\.(\d+)" # major.minor.patch
r"(?:-([0-9A-Za-z.-]+))?" # optional -prerelease (captured)
r"(?:\+[0-9A-Za-z.-]+)?" # optional +build metadata (ignored)
r"\s*$"
)
class IncompatibleBackendError(RuntimeError):
"""Raised when the agentex backend is older than this SDK's minimum supported contract."""
def _parse(version: str | None) -> tuple[int, int, int, str | None] | None:
"""Parse ``major.minor.patch[-prerelease]`` → ``(major, minor, patch, prerelease)``.
``prerelease`` is the raw dot-separated identifier string (e.g. ``"rc.1"``), or None for
a stable release. Build metadata (after ``+``) is ignored. Returns None if unparseable.
"""
m = _VERSION_RE.match(version or "")
if not m:
return None
return (int(m.group(1)), int(m.group(2)), int(m.group(3)), m.group(4) or None)
# Comparable SemVer precedence key. The 4th element keeps a uniform shape across stable and
# prerelease so the whole tuple is orderable: (rank, identifiers), where stable rank 1 > prerelease
# rank 0 (and the identifier list is only ever compared when both sides are prereleases, rank 0).
_PreKey = tuple[int, int, int, tuple[int, list[tuple[int, int, str]]]]
def _precedence_key(parsed: tuple[int, int, int, str | None]) -> _PreKey:
"""SemVer §11 precedence key (directly comparable with ``<``).
A stable release outranks any prerelease of the same triplet (``0.1.0-rc.1 < 0.1.0``);
among prereleases, numeric identifiers rank below alphanumeric and compare field-by-field,
with a longer identifier list outranking a shorter prefix-equal one.
"""
major, minor, patch, prerelease = parsed
if prerelease is None:
return (major, minor, patch, (1, [])) # stable sorts above every prerelease
identifiers: list[tuple[int, int, str]] = []
for ident in prerelease.split("."):
if ident.isdigit():
identifiers.append((0, int(ident), "")) # numeric: lowest class, numeric order
else:
identifiers.append((1, 0, ident)) # alphanumeric: higher class, lexical order
return (major, minor, patch, (0, identifiers))
def _truthy(name: str) -> bool:
return os.environ.get(name, "").strip().lower() in ("1", "true", "yes", "on")
async def fetch_backend_version(base_url: str, *, timeout: float = 5.0) -> str | None:
"""Return the backend's reported contract version (``/openapi.json`` ``info.version``), or None."""
url = base_url.rstrip("/") + "/openapi.json"
try:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.get(url)
resp.raise_for_status()
return (resp.json().get("info") or {}).get("version")
except Exception as exc: # noqa: BLE001 - any failure → unknown, handled by caller
logger.warning("backend version guard: could not fetch %s (%s)", url, exc)
return None
async def assert_backend_compatible(
base_url: str | None,
*,
min_version: str = MIN_BACKEND_CONTRACT,
sdk_version: str | None = None,
) -> None:
"""Fail fast at startup if the backend is older than ``min_version``.
No-op (warns, does not raise) when:
- ``AGENTEX_SKIP_VERSION_CHECK`` is set (explicit bypass),
- ``base_url`` is unset,
- the backend version can't be determined (unreachable / unparseable) — a transient
blip or a contract-less server shouldn't crash startup.
Raises ``IncompatibleBackendError`` only when the backend version is *known* and older
than ``min_version``.
"""
if _truthy(SKIP_ENV):
logger.warning("%s set — skipping backend version guard", SKIP_ENV)
return
if not base_url:
return
if sdk_version is None:
from agentex._version import __version__ as sdk_version # local import to avoid cycles
backend_version = await fetch_backend_version(base_url)
if backend_version is None:
logger.warning(
"backend version guard: could not determine backend version at %s; proceeding "
"(set %s=1 to silence).",
base_url,
SKIP_ENV,
)
return
backend, minimum = _parse(backend_version), _parse(min_version)
if backend is None or minimum is None:
logger.warning(
"backend version guard: unparseable version(s) backend=%r min=%r; proceeding.",
backend_version,
min_version,
)
return
if _precedence_key(backend) < _precedence_key(minimum):
raise IncompatibleBackendError(
f"agentex-sdk {sdk_version} requires agentex backend >= {min_version}, "
f"but {base_url} reports {backend_version}. "
f"Upgrade the backend, or pin agentex-sdk to a version compatible with backend "
f"{backend_version}. (Set {SKIP_ENV}=1 to bypass at your own risk.)"
)
logger.info(
"backend version guard OK: sdk=%s backend=%s (min=%s)",
sdk_version,
backend_version,
min_version,
)