Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions pyvolca/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

<!-- BEGIN: compatibility -->

_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**.

<!-- END: compatibility -->

## 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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -1376,13 +1397,16 @@ 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 |
|-------|------|---------|
| `version` | `str` | — |
| `git_hash` | `str` | — |
| `git_tag` | `str \| None` | — |
| `build_target` | `str` | — |
| `wire_version` | `int \| None` | None |

### `Substitution`

Expand Down
84 changes: 60 additions & 24 deletions pyvolca/scripts/gen_api_md.py
Original file line number Diff line number Diff line change
@@ -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``.
Expand Down Expand Up @@ -38,6 +37,8 @@
README = ROOT / "README.md"
BEGIN_MARKER = "<!-- BEGIN: api-reference -->"
END_MARKER = "<!-- END: api-reference -->"
COMPAT_BEGIN_MARKER = "<!-- BEGIN: compatibility -->"
COMPAT_END_MARKER = "<!-- END: compatibility -->"


def _first_line(doc: str | None) -> str:
Expand Down Expand Up @@ -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
Expand Down
190 changes: 190 additions & 0 deletions pyvolca/scripts/release_precheck.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""Pre-release gate for pyvolca — run before tagging ``pyvolca-v<version>``.

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 <args>`` ('' 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())
Loading
Loading