diff --git a/.github/scripts/pr_summary.py b/.github/scripts/pr_summary.py index dbf2c92..fe0e35c 100644 --- a/.github/scripts/pr_summary.py +++ b/.github/scripts/pr_summary.py @@ -1,4 +1,5 @@ """Build one concise PR summary from .apiverity-artifacts/*.json.""" + from __future__ import annotations import glob @@ -16,13 +17,13 @@ removed = sum(1 for f in findings if f["rule_id"] == "BRK-OP-REMOVED") added = sum(1 for f in findings if f["rule_id"] == "BRK-OP-ADDED") semver = [f for f in findings if f["rule_id"].startswith("SEMVER-")] - verdict = ("✅ respected" if not semver - else "❌ " + "; ".join(f["rule_id"] for f in semver)) + verdict = "✅ respected" if not semver else "❌ " + "; ".join(f["rule_id"] for f in semver) lines.append(f"### `{pathlib.Path(path).stem.replace('_', '/')}`") lines.append(f"- Breaking: **{len(breaking)}** · Warnings: **{len(warnings)}**") lines.append(f"- Removed endpoints: {removed} · New endpoints: {added}") - lines.append(f"- Semver ({data.get('old_version', '?')} → " - f"{data.get('new_version', '?')}): {verdict}") + lines.append( + f"- Semver ({data.get('old_version', '?')} → {data.get('new_version', '?')}): {verdict}" + ) for f in (breaking + warnings)[:10]: lines.append(f" - `{f['rule_id']}` ({f['severity']}) {f['message']}") diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d4b4e6..9d2534c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,9 +24,9 @@ jobs: python -m pip install --upgrade pip pip install -e ".[dev]" - name: Ruff lint - run: ruff check apiverity tests scripts + run: ruff check apiverity tests scripts .github/scripts - name: Ruff format check - run: ruff format --check apiverity tests scripts + run: ruff format --check apiverity tests scripts .github/scripts - name: Type check run: mypy apiverity - name: Tests with coverage diff --git a/apiverity/__init__.py b/apiverity/__init__.py index 4e479a0..2f95758 100644 --- a/apiverity/__init__.py +++ b/apiverity/__init__.py @@ -1,5 +1,32 @@ """api-verity-lab: unified API contract governance and reliability testing.""" -__version__ = "0.1.0" +from __future__ import annotations + + +def _installed_version() -> str: + """The version actually installed, not a literal in this file. + + This was hardcoded to "0.1.0" while ``pyproject.toml`` said 0.2.0, so + ``apiverity --version`` (which the bug-report template asks reporters to + run) would have reported a release that does not exist. Deriving it from + the distribution's own metadata makes that drift unrepresentable. + """ + from importlib.metadata import PackageNotFoundError, version + + try: + return version("api-verity-lab") + except PackageNotFoundError: # source tree, not installed + import pathlib + + pyproject = pathlib.Path(__file__).resolve().parent.parent / "pyproject.toml" + try: + import tomllib + + return str(tomllib.loads(pyproject.read_text(encoding="utf-8"))["project"]["version"]) + except Exception: + return "unknown" + + +__version__ = _installed_version() PLUGIN_API_VERSION = "1" diff --git a/apiverity/cli/main.py b/apiverity/cli/main.py index cbaa5a8..eaf4cae 100644 --- a/apiverity/cli/main.py +++ b/apiverity/cli/main.py @@ -14,6 +14,7 @@ import sys from typing import Any +from apiverity import __version__ from apiverity.cli.commands.artifacts import cmd_export, cmd_report, cmd_serve from apiverity.cli.commands.common import EXIT_INTERNAL, EXIT_OK from apiverity.cli.commands.governance import ( @@ -68,6 +69,7 @@ def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="apiverity", description=__doc__) + parser.add_argument("--version", action="version", version=f"apiverity {__version__}") sub = parser.add_subparsers(dest="command", required=True) p = sub.add_parser("validate") diff --git a/tests/unit/test_version_is_reportable.py b/tests/unit/test_version_is_reportable.py new file mode 100644 index 0000000..2ddc702 --- /dev/null +++ b/tests/unit/test_version_is_reportable.py @@ -0,0 +1,50 @@ +"""The version has to be reportable, and it has to be the real one. + +`apiverity --version` did not exist: argparse rejected the flag and exited 2 — +while `.github/ISSUE_TEMPLATE/bug_report.md` asks every reporter to run exactly +that. Separately, `__version__` was pinned to "0.1.0" in the package while +`pyproject.toml` said 0.2.0, so even once the flag worked it would have named a +release that was never cut. + +These two tests are what makes both unrepresentable going forward. +""" + +from __future__ import annotations + +import pathlib +import tomllib + +import pytest + +from apiverity import __version__ +from apiverity.cli.main import main + +PYPROJECT = pathlib.Path(__file__).resolve().parents[2] / "pyproject.toml" + + +def _declared_version() -> str: + data = tomllib.loads(PYPROJECT.read_text(encoding="utf-8")) + return str(data["project"]["version"]) + + +def test_package_version_matches_pyproject() -> None: + assert __version__ == _declared_version() + + +def test_version_flag_exits_zero_and_prints_the_version( + capsys: pytest.CaptureFixture[str], +) -> None: + with pytest.raises(SystemExit) as excinfo: + main(["--version"]) + assert excinfo.value.code == 0 + assert __version__ in capsys.readouterr().out + + +def test_bug_report_template_asks_for_a_command_that_works() -> None: + """The template is the reason this flag has to exist.""" + template = PYPROJECT.parent / ".github/ISSUE_TEMPLATE/bug_report.md" + if "apiverity --version" not in template.read_text(encoding="utf-8"): + pytest.skip("template no longer asks for --version") + with pytest.raises(SystemExit) as excinfo: + main(["--version"]) + assert excinfo.value.code == 0