diff --git a/pyvolca/README.md b/pyvolca/README.md index 4220186b..c9fd1b4c 100644 --- a/pyvolca/README.md +++ b/pyvolca/README.md @@ -13,6 +13,23 @@ pip install pyvolca Requires Python ≥ 3.10 and a running VoLCA engine. Use `Server` (below) to run one as a child process, or point `Client` at any reachable instance. +## Compatibility + +pyvolca speaks one revision of the engine's JSON wire format; the engine advertises its revision as `wireVersion` on `/api/v1/version`. pyvolca checks it the first time it talks to the engine — too old fails with a clear error, newer than this client knows warns. pyvolca and engine version numbers move independently: `wireVersion` carries compatibility, not the version numbers. + +| pyvolca | wire | compatible engine | +|---------|------|-------------------| +| `0.5.x` | (pre-`wireVersion`) | `v0.5.0` … `v0.7.x` | +| `0.6.x` | `1` | `≥ v0.8.0` | + + + +_Generated from `volca._compat` — run `python scripts/gen_api_md.py` to regenerate._ + +This build of **pyvolca 0.6.0** speaks wire format **1** and requires a VoLCA engine **≥ v0.8.0**. + + + ## First choose: connect to an existing server, or start one locally `pyvolca` is only the Python client library. It does not contain the VoLCA databases and it does not install the VoLCA engine binary. @@ -631,6 +648,10 @@ Also regenerates the `.pyi` type stubs in the installed pyvolca package directory so IDE autocomplete reflects the current engine. Useful when the engine is upgraded without reinstalling pyvolca. +This is the explicit "the engine was upgraded" path — the likeliest +place to meet a wire mismatch — so it runs the same one-shot gate as +:meth:`_load_operations`, refusing a spec pyvolca can't decode. + ##### `Client.relink(dep_db: str, mapping_csv: str, db_name: str | None = None) -> dict` Re-link a database against a dependency using a name→name alias CSV. @@ -1376,6 +1397,8 @@ Server build metadata returned by :meth:`Client.get_version`. ``git_tag`` is None for untagged dev builds. ``build_target`` names the platform triple the binary was compiled for (e.g. ``"x86_64-linux"``). +``wire_version`` is the engine's advertised JSON wire-format revision, or +None for engines that predate it (everything up to v0.7.x). | Field | Type | Default | |-------|------|---------| @@ -1383,6 +1406,7 @@ platform triple the binary was compiled for (e.g. ``"x86_64-linux"``). | `git_hash` | `str` | — | | `git_tag` | `str \| None` | — | | `build_target` | `str` | — | +| `wire_version` | `int \| None` | None | ### `Substitution` diff --git a/pyvolca/scripts/gen_api_md.py b/pyvolca/scripts/gen_api_md.py index 70ff05aa..4548ba03 100644 --- a/pyvolca/scripts/gen_api_md.py +++ b/pyvolca/scripts/gen_api_md.py @@ -1,15 +1,14 @@ #!/usr/bin/env python3 -"""Generate the pyvolca API reference as markdown. +"""Generate the pyvolca README's machine-written blocks. -Walks every name in ``volca.__all__`` and emits a structured reference, -spliced into README.md between the api-reference markers so PyPI sees the -full package reference. The same body is also written as a standalone -artifact under ``www/generated/pyvolca/`` so the Starlight guide can pull -it in. +Two blocks are spliced into README.md between marker pairs so PyPI sees them: +the API reference (every name in ``volca.__all__``, rendered structurally) and +the one code-derived compatibility line (from ``volca._compat``). Owning the +splice here keeps the on-disk copies from drifting from the source. Usage: - python scripts/gen_api_md.py # write the reference block to stdout - python scripts/gen_api_md.py --write # splice it into README.md in place + python scripts/gen_api_md.py # write the generated blocks to stdout + python scripts/gen_api_md.py --write # splice them into README.md in place python scripts/gen_api_md.py --check # exit non-zero if README.md is stale Stdlib only — pyvolca's only dependency stays ``requests``. @@ -38,6 +37,8 @@ README = ROOT / "README.md" BEGIN_MARKER = "" END_MARKER = "" +COMPAT_BEGIN_MARKER = "" +COMPAT_END_MARKER = "" def _first_line(doc: str | None) -> str: @@ -310,45 +311,80 @@ def render_reference() -> str: return buf.getvalue() -def splice_readme(readme_text: str, body: str) -> str: - """Replace the api-reference block between the markers with ``body``. +def render_compatibility() -> str: + """Render the generated compatibility line from ``volca._compat``. + + Its own README block so the one code-derived fact — which wire this build + speaks and the minimum engine it needs — can't drift from the policy in + :mod:`volca._compat`. The static history table beside it is hand-written. + """ + from importlib.metadata import version + + from volca import _compat + + return ( + "_Generated from `volca._compat` — run " + "`python scripts/gen_api_md.py` to regenerate._\n\n" + f"This build of **pyvolca {version('pyvolca')}** speaks wire format " + f"**{_compat.REQUIRED_WIRE}** and requires a VoLCA engine " + f"**≥ v{_compat.MIN_ENGINE_HINT}**." + ) + + +def splice(text: str, begin: str, end: str, body: str) -> str: + """Replace the block between the ``begin`` and ``end`` markers with ``body``. Pure: returns the rewritten text and raises ``ValueError`` if the markers are missing (a corrupt README must fail loudly, never silently no-op). """ - pattern = re.compile( - re.escape(BEGIN_MARKER) + r".*?" + re.escape(END_MARKER), re.DOTALL - ) - if pattern.search(readme_text) is None: - raise ValueError(f"{BEGIN_MARKER} / {END_MARKER} markers missing from README") - replacement = f"{BEGIN_MARKER}\n\n{body.rstrip()}\n\n{END_MARKER}" - return pattern.sub(lambda _m: replacement, readme_text) + pattern = re.compile(re.escape(begin) + r".*?" + re.escape(end), re.DOTALL) + if pattern.search(text) is None: + raise ValueError(f"{begin} / {end} markers missing from README") + replacement = f"{begin}\n\n{body.rstrip()}\n\n{end}" + return pattern.sub(lambda _m: replacement, text) + + +def splice_readme(readme_text: str, body: str) -> str: + """Splice the api-reference ``body`` into the README (back-compat wrapper).""" + return splice(readme_text, BEGIN_MARKER, END_MARKER, body) + + +def _blocks() -> list[tuple[str, str, str]]: + """The ``(begin, end, body)`` generated blocks the README hosts.""" + return [ + (BEGIN_MARKER, END_MARKER, render_reference()), + (COMPAT_BEGIN_MARKER, COMPAT_END_MARKER, render_compatibility()), + ] def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description="Generate the pyvolca API reference.") + parser = argparse.ArgumentParser( + description="Generate the pyvolca README's machine-written blocks." + ) mode = parser.add_mutually_exclusive_group() mode.add_argument( - "--write", action="store_true", help="splice the reference into README.md in place" + "--write", action="store_true", help="splice the generated blocks into README.md in place" ) mode.add_argument( "--check", action="store_true", - help="exit non-zero if README.md's reference block is stale (no write)", + help="exit non-zero if README.md's generated blocks are stale (no write)", ) ns = parser.parse_args(argv) - body = render_reference() + blocks = _blocks() if not (ns.write or ns.check): - sys.stdout.write(body) + sys.stdout.write("\n\n".join(body for _, _, body in blocks)) return 0 current = README.read_text(encoding="utf-8") - updated = splice_readme(current, body) + updated = current + for begin, end, body in blocks: + updated = splice(updated, begin, end, body) if ns.check: if updated != current: sys.stderr.write( - "README.md api-reference block is stale — run " + "README.md generated blocks are stale — run " "`python scripts/gen_api_md.py --write` and commit the result.\n" ) return 1 diff --git a/pyvolca/scripts/release_precheck.py b/pyvolca/scripts/release_precheck.py new file mode 100644 index 00000000..5fa2b1d7 --- /dev/null +++ b/pyvolca/scripts/release_precheck.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""Pre-release gate for pyvolca — run before tagging ``pyvolca-v``. + +Answers, locally and automatically, "can I release pyvolca right now?". It +checks that the version is new, the changelog records it, the tree is clean, +and — the decisive one — that the engine release this pyvolca requires (its +wire floor, from :data:`volca._compat.MIN_ENGINE_HINT`) is already tagged. The +last point is what would have answered "no, not yet" when the engine carrying +the wire hadn't shipped. + + python scripts/release_precheck.py # full gate + python scripts/release_precheck.py --no-tests # skip the slow pytest+build leg + +Prints a PASS/WARN/FAIL table and the exact tag commands when green. Exit +non-zero on any FAIL. Stdlib only, plus the ``volca`` package it ships beside. +""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import re +import subprocess +import sys +import urllib.error +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent # the pyvolca/ package dir +sys.path.insert(0, str(ROOT / "src")) + +from volca._compat import MIN_ENGINE_HINT # noqa: E402 + +PASS, WARN, FAIL = "PASS", "WARN", "FAIL" +Row = tuple[str, str, str] # (status, check name, detail) + +_SEMVER_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$") # tolerate a leading "v" + + +def _semver(text: str) -> tuple[int, int, int] | None: + m = _SEMVER_RE.match(text.strip()) + return (int(m[1]), int(m[2]), int(m[3])) if m else None + + +def _git(*args: str) -> str: + """Stripped stdout of ``git -C pyvolca `` ('' on any error).""" + proc = subprocess.run(["git", "-C", str(ROOT), *args], capture_output=True, text=True) + return proc.stdout.strip() + + +def _have(module: str) -> bool: + return importlib.util.find_spec(module) is not None + + +def _project_version() -> str: + text = (ROOT / "pyproject.toml").read_text(encoding="utf-8") + m = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE) + return m.group(1) if m else "" + + +# -- individual checks -------------------------------------------------------- + + +def check_tag_absent(v: str) -> Row: + if _git("tag", "--list", f"pyvolca-v{v}"): + return FAIL, "tag is free", f"pyvolca-v{v} already exists" + return PASS, "tag is free", f"pyvolca-v{v} not yet created" + + +def check_newer_than_pypi(v: str) -> Row: + try: + req = urllib.request.Request( + "https://pypi.org/pypi/pyvolca/json", headers={"Accept": "application/json"} + ) + with urllib.request.urlopen(req, timeout=10) as resp: + data = json.loads(resp.read()) + except urllib.error.HTTPError as e: + if e.code == 404: + return PASS, "newer than PyPI", "package not on PyPI yet (first release)" + return WARN, "newer than PyPI", f"PyPI query failed (HTTP {e.code})" + except (urllib.error.URLError, TimeoutError, OSError) as e: + return WARN, "newer than PyPI", f"PyPI unreachable ({e})" + if v in data.get("releases", {}): + return FAIL, "newer than PyPI", f"{v} is already published" + latest = data.get("info", {}).get("version", "") + vt, lt = _semver(v), _semver(latest) + if latest and (vt is None or lt is None): + # Don't claim "newer" on a comparison we couldn't actually make. + return WARN, "newer than PyPI", f"can't compare {v} to non-semver latest {latest!r}" + if vt and lt and vt <= lt: + return FAIL, "newer than PyPI", f"{v} is not newer than {latest}" + return PASS, "newer than PyPI", f"{v} > {latest or '(none)'}" + + +def check_changelog(v: str) -> Row: + text = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + if any(line.startswith(f"## [{v}]") for line in text.splitlines()): + return PASS, "changelog entry", f"## [{v}] present" + return FAIL, "changelog entry", f"no '## [{v}]' section in CHANGELOG.md" + + +def check_clean_tree() -> Row: + dirty = _git("status", "--porcelain", "--", ".") + if dirty: + n = len(dirty.splitlines()) + return FAIL, "clean tree", f"{n} uncommitted path(s) under pyvolca/" + return PASS, "clean tree", "no uncommitted changes under pyvolca/" + + +def check_engine_released() -> Row: + """The pivotal gate: the engine that speaks our wire floor must be tagged.""" + floor = _semver(MIN_ENGINE_HINT) + tags = [t for t in (_semver(t) for t in _git("tag", "--list", "v[0-9]*").splitlines()) if t] + if floor and tags and max(tags) >= floor: + newest = ".".join(map(str, max(tags))) + return PASS, "engine released", f"v{newest} >= v{MIN_ENGINE_HINT}" + return FAIL, "engine released", ( + f"needs engine >= v{MIN_ENGINE_HINT}; no such tag yet — release the engine first" + ) + + +def _leg(name: str, cmd: list[str]) -> Row: + proc = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True) + if proc.returncode == 0: + return PASS, name, "ok" + tail = (proc.stdout + proc.stderr).strip().splitlines()[-3:] + return FAIL, name, " / ".join(tail) or f"exit {proc.returncode}" + + +def check_tests_and_build(no_tests: bool, v: str) -> list[Row]: + if no_tests: + return [(WARN, "tests + build", "skipped (--no-tests)")] + rows: list[Row] = [] + if _have("pytest"): + rows.append(_leg("pytest", [sys.executable, "-m", "pytest", "-q"])) + else: + rows.append((WARN, "pytest", "not installed — `pip install pytest`")) + if _have("build"): + rows.append(_leg("python -m build", [sys.executable, "-m", "build"])) + else: + rows.append((WARN, "python -m build", "not installed — `pip install build`")) + artifacts = sorted((ROOT / "dist").glob(f"pyvolca-{v}*")) + if not _have("twine"): + rows.append((WARN, "twine check", "not installed — `pip install twine`")) + elif not artifacts: + rows.append((WARN, "twine check", "no built artifacts for this version in dist/")) + else: + rows.append(_leg("twine check", [sys.executable, "-m", "twine", "check", *map(str, artifacts)])) + return rows + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Pre-release gate for pyvolca.") + parser.add_argument( + "--no-tests", action="store_true", help="skip the slow pytest + build + twine leg" + ) + ns = parser.parse_args(argv) + + v = _project_version() + if not v: + print("FAIL could not read version from pyproject.toml") + return 1 + + print(f"pyvolca release precheck — version {v}\n") + rows: list[Row] = [ + check_tag_absent(v), + check_newer_than_pypi(v), + check_changelog(v), + check_clean_tree(), + check_engine_released(), + *check_tests_and_build(ns.no_tests, v), + ] + + width = max(len(name) for _, name, _ in rows) + for status, name, detail in rows: + print(f" {status} {name.ljust(width)} {detail}") + + if any(s == FAIL for s, _, _ in rows): + print("\nNOT READY — fix the FAILs above before tagging.") + return 1 + print(f"\nREADY. To publish pyvolca {v}:") + print(f" git tag pyvolca-v{v}") + print(f" git push origin pyvolca-v{v}") + print("(the tag triggers pyvolca-release.yml → PyPI)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pyvolca/src/volca/_compat.py b/pyvolca/src/volca/_compat.py new file mode 100644 index 00000000..d3902ee6 --- /dev/null +++ b/pyvolca/src/volca/_compat.py @@ -0,0 +1,60 @@ +"""Engine wire-compatibility policy — the single source of truth. + +pyvolca speaks one revision of the JSON wire format; the engine advertises its +own revision as ``wireVersion`` on ``/api/v1/version``. This module owns the +comparison: the wire this client requires, the engine hint shown when the check +fails, and the check itself. + +It is deliberately import-cheap — only the stdlib at runtime — so the release +preflight can read :data:`MIN_ENGINE_HINT` without pulling in ``requests`` or +the client, and so importing it can never cycle back through the client. +""" + +from __future__ import annotations + +import os +import warnings +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .types import ServerVersion + +REQUIRED_WIRE = 1 +"""The JSON wire-format revision this pyvolca speaks.""" + +MIN_ENGINE_HINT = "0.8.0" +"""First engine release that advertises :data:`REQUIRED_WIRE`. Used only for the +error message and the release preflight — it is not, by itself, a runtime gate +(the engine's ``wireVersion`` is).""" + + +def check(sv: ServerVersion) -> None: + """Raise :class:`VoLCAError` if the engine's wire is too old; warn if newer. + + The opt-out is read here, not in the caller, so every entry point — the + client's first operation and :meth:`volca.server.Server.start` alike — + honours it. It is deliberately noisy: silencing the check is a foot-gun. + """ + if os.environ.get("VOLCA_SKIP_COMPAT_CHECK"): + warnings.warn( + "VOLCA_SKIP_COMPAT_CHECK set — skipping the engine " + "wire-compatibility check." + ) + return + + w = sv.wire_version + if w is None or w < REQUIRED_WIRE: + from .client import VoLCAError # deferred: keep this module client-free + + spoken = "pre-1" if w is None else str(w) + raise VoLCAError( + f"This engine (v{sv.version}) speaks an older wire than pyvolca " + f"needs (wire {spoken} < {REQUIRED_WIRE}). Upgrade the engine to " + f">= v{MIN_ENGINE_HINT}, or `pip install 'pyvolca<0.6'`." + ) + if w > REQUIRED_WIRE: + warnings.warn( + f"Engine v{sv.version} speaks wire {w}; this pyvolca knows up to " + f"wire {REQUIRED_WIRE}. Some responses may not decode — upgrade " + "pyvolca." + ) diff --git a/pyvolca/src/volca/client.py b/pyvolca/src/volca/client.py index 1b87be30..87351995 100644 --- a/pyvolca/src/volca/client.py +++ b/pyvolca/src/volca/client.py @@ -76,6 +76,7 @@ SupplyChain, parse_exchange_detail, ) +from . import _compat class VoLCAError(Exception): @@ -374,23 +375,44 @@ def __init__( self._session.headers["Authorization"] = f"Bearer {password}" # Lazily fetched on first _call() invocation. self._operations: dict[str, _Operation] | None = None + # One-shot wire-compatibility gate (see _ensure_compatible). + self._checked = False # -- Spec / dispatch plumbing -- def _load_operations(self) -> dict[str, _Operation]: """Fetch the OpenAPI spec and build the dispatch table (cached).""" if self._operations is None: + self._ensure_compatible() spec = self._json(self._session.get(f"{self.base_url}/api/v1/openapi.json")) self._operations = _parse_spec(spec) return self._operations + def _ensure_compatible(self) -> None: + """One-shot wire-compatibility gate, run before the first real call. + + Placed inside the spec-fetch branch so it fires once per client, right + before we first depend on the engine's wire — and never for a client + that was handed a preloaded operation table (the offline fixtures). + ``get_version`` is a direct GET, so this does not recurse. + """ + if self._checked: + return + _compat.check(self.get_version()) + self._checked = True + def refresh_stubs(self) -> None: """Fetch the OpenAPI spec from the server and refresh the dispatch table. Also regenerates the `.pyi` type stubs in the installed pyvolca package directory so IDE autocomplete reflects the current engine. Useful when the engine is upgraded without reinstalling pyvolca. + + This is the explicit "the engine was upgraded" path — the likeliest + place to meet a wire mismatch — so it runs the same one-shot gate as + :meth:`_load_operations`, refusing a spec pyvolca can't decode. """ + self._ensure_compatible() spec = self._json(self._session.get(f"{self.base_url}/api/v1/openapi.json")) self._operations = _parse_spec(spec) from . import _stub_gen diff --git a/pyvolca/src/volca/server.py b/pyvolca/src/volca/server.py index 341199bf..86095194 100644 --- a/pyvolca/src/volca/server.py +++ b/pyvolca/src/volca/server.py @@ -13,7 +13,7 @@ except ModuleNotFoundError: import tomli as tomllib # type: ignore[no-redef] -from . import _download +from . import _compat, _download class Server: @@ -122,6 +122,17 @@ def is_alive(self) -> bool: except requests.ConnectionError: return False + def _check_wire(self) -> None: + """Verify the running engine speaks a wire pyvolca understands. + + Local import of :class:`Client` keeps the server/client modules free of + an import cycle. ``get_version`` is ungated, so this works even against + an incompatible engine (the point is to fail with a clear message). + """ + from .client import Client + + _compat.check(Client(self.base_url, password=self.password).get_version()) + def start(self, idle_timeout: int = 300, wait_timeout: int = 120) -> None: """Spawn the engine process if it is not already serving, and wait until ready. @@ -134,6 +145,9 @@ def start(self, idle_timeout: int = 300, wait_timeout: int = 120) -> None: No-op if a healthy server is already reachable on ``base_url``. """ if self.is_alive(): + # A server is already up — we didn't spawn it, so verify the wire + # but leave it running on a mismatch; it isn't ours to stop. + self._check_wire() return binary = self._find_binary() @@ -155,6 +169,13 @@ def start(self, idle_timeout: int = 300, wait_timeout: int = 120) -> None: deadline = time.monotonic() + wait_timeout while time.monotonic() < deadline: if self.is_alive(): + # We spawned this engine: if it speaks an incompatible wire, + # tear it down rather than leave an unusable process running. + try: + self._check_wire() + except Exception: + self.stop() + raise return time.sleep(0.5) diff --git a/pyvolca/src/volca/types.py b/pyvolca/src/volca/types.py index 1716b827..4fc1d3ba 100644 --- a/pyvolca/src/volca/types.py +++ b/pyvolca/src/volca/types.py @@ -1210,12 +1210,15 @@ class ServerVersion: ``git_tag`` is None for untagged dev builds. ``build_target`` names the platform triple the binary was compiled for (e.g. ``"x86_64-linux"``). + ``wire_version`` is the engine's advertised JSON wire-format revision, or + None for engines that predate it (everything up to v0.7.x). """ version: str git_hash: str git_tag: str | None build_target: str + wire_version: int | None = None @classmethod def from_json(cls, d: dict) -> "ServerVersion": @@ -1224,6 +1227,9 @@ def from_json(cls, d: dict) -> "ServerVersion": git_hash=d["gitHash"], git_tag=d.get("gitTag") or None, build_target=d["buildTarget"], + # Plain .get (not "... or None"): wire 0 is a distinct value, not + # "absent". Absent (old engine) → None; present → the int verbatim. + wire_version=d.get("wireVersion"), ) diff --git a/pyvolca/tests/test_compat.py b/pyvolca/tests/test_compat.py new file mode 100644 index 00000000..48562229 --- /dev/null +++ b/pyvolca/tests/test_compat.py @@ -0,0 +1,129 @@ +"""Engine wire-compatibility gate: volca._compat plus its client hooks. + +All offline — no engine binary. The gate's logic is exercised directly on +synthetic :class:`ServerVersion` values, and the client integration through a +mocked session. +""" + +from __future__ import annotations + +import warnings +from unittest.mock import MagicMock + +import pytest + +from volca import _compat +from volca.client import Client, VoLCAError +from volca.types import ServerVersion + + +def _sv(wire: int | None) -> ServerVersion: + """A ServerVersion advertising wire ``wire`` (None = pre-wireVersion engine).""" + return ServerVersion( + version="9.9.9", + git_hash="deadbee", + git_tag=None, + build_target="x86_64-linux", + wire_version=wire, + ) + + +def _version_body(wire: int | None) -> dict: + """The /api/v1/version JSON an engine at wire ``wire`` would return.""" + body = {"version": "9.9.9", "gitHash": "deadbee", "buildTarget": "x86_64-linux"} + if wire is not None: + body["wireVersion"] = wire + return body + + +# -- check(): the pure policy ------------------------------------------------- + + +@pytest.mark.parametrize("wire", [None, 0]) +def test_check_rejects_too_old_wire(wire: int | None) -> None: + with pytest.raises(VoLCAError) as exc: + _compat.check(_sv(wire)) + # names the engine to upgrade to, and an older-pyvolca fallback + assert f"v{_compat.MIN_ENGINE_HINT}" in str(exc.value) + assert "pyvolca<0.6" in str(exc.value) + + +def test_check_message_distinguishes_absent_from_zero() -> None: + """wire 0 is a real value, not 'absent' — the messages must differ.""" + with pytest.raises(VoLCAError) as none_exc: + _compat.check(_sv(None)) + with pytest.raises(VoLCAError) as zero_exc: + _compat.check(_sv(0)) + assert "pre-1" in str(none_exc.value) + assert "wire 0" in str(zero_exc.value) + + +def test_check_is_silent_on_exact_wire() -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error") # any warning would fail the test + _compat.check(_sv(_compat.REQUIRED_WIRE)) # neither raises nor warns + + +def test_check_warns_on_newer_wire() -> None: + with pytest.warns(UserWarning, match="upgrade pyvolca"): + _compat.check(_sv(_compat.REQUIRED_WIRE + 1)) + + +@pytest.mark.parametrize("wire", [None, 0]) +def test_skip_env_downgrades_error_to_warning( + wire: int | None, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("VOLCA_SKIP_COMPAT_CHECK", "1") + with pytest.warns(UserWarning, match="VOLCA_SKIP_COMPAT_CHECK"): + _compat.check(_sv(wire)) # opt-out: warns instead of raising + + +# -- client integration ------------------------------------------------------- + + +def _client_with_version(make_response, wire: int | None) -> tuple[Client, MagicMock]: + c = Client(base_url="http://test.local") + session = MagicMock() + session.get.return_value = make_response(_version_body(wire)) + c._session = session + return c, session + + +def test_get_version_stays_ungated(make_response) -> None: + """A bad engine must remain inspectable — get_version never gates.""" + c, _ = _client_with_version(make_response, wire=None) + sv = c.get_version() # must not raise despite the missing wireVersion + assert sv.wire_version is None + + +def test_load_operations_gate_rejects_old_engine(make_response) -> None: + c, _ = _client_with_version(make_response, wire=None) + with pytest.raises(VoLCAError): + c._load_operations() + + +def test_refresh_stubs_is_gated(make_response) -> None: + """The explicit engine-upgrade path honours the wire gate, and refuses + before fetching the spec it would otherwise fail to decode.""" + c, session = _client_with_version(make_response, wire=None) + with pytest.raises(VoLCAError): + c.refresh_stubs() + assert session.get.call_count == 1 # version checked; openapi.json never fetched + + +def test_ensure_compatible_is_one_shot(make_response) -> None: + c, session = _client_with_version(make_response, wire=_compat.REQUIRED_WIRE) + c._ensure_compatible() + c._ensure_compatible() + assert c._checked is True + assert session.get.call_count == 1 # version fetched once, then cached + + +def test_preloaded_operations_skip_the_gate(mocked_client) -> None: + """Clients handed a preloaded operation table (the offline fixtures) must + never trigger a version fetch — that is what keeps the dispatch tests + engine-free.""" + client, session = mocked_client + client._load_operations() + assert session.get.call_count == 0 + assert client._checked is False diff --git a/pyvolca/tests/test_compat_doc_drift.py b/pyvolca/tests/test_compat_doc_drift.py new file mode 100644 index 00000000..c151ed31 --- /dev/null +++ b/pyvolca/tests/test_compat_doc_drift.py @@ -0,0 +1,35 @@ +"""The README compatibility block must stay in sync with volca._compat. + +``scripts/gen_api_md.py`` derives the block from the wire-compatibility policy; +this test asserts the committed README already reflects it, so a change to +``REQUIRED_WIRE`` / ``MIN_ENGINE_HINT`` (or the pyvolca version) that wasn't +regenerated fails CI instead of silently shipping a stale claim. Pure Python: +no engine binary required. +""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +# The generator lives in scripts/, outside the importable package, so load it +# by path rather than via a regular import. +_SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "gen_api_md.py" +_spec = importlib.util.spec_from_file_location("gen_api_md", _SCRIPT) +assert _spec is not None and _spec.loader is not None +gen_api_md = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(gen_api_md) + + +def test_readme_compatibility_in_sync() -> None: + readme = gen_api_md.README.read_text(encoding="utf-8") + expected = gen_api_md.splice( + readme, + gen_api_md.COMPAT_BEGIN_MARKER, + gen_api_md.COMPAT_END_MARKER, + gen_api_md.render_compatibility(), + ) + assert expected == readme, ( + "pyvolca/README.md compatibility block is stale. Run " + "`python scripts/gen_api_md.py --write` and commit the result." + )