From 57170bc5560e6408738252dcac4af273f9df2f62 Mon Sep 17 00:00:00 2001 From: ZdenekSrotyr Date: Wed, 6 May 2026 15:23:37 +0200 Subject: [PATCH] feat(server): expose APP_VERSION + MIN_COMPAT_CLI_VERSION on /api/* response headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds X-Agnes-Latest-Version and X-Agnes-Min-Version headers to every /api/* response. CLI consumes these to hard-stop on incompatible drift. MIN_COMPAT_CLI_VERSION ships at 0.0.0 — no enforcement until a deliberate wire-protocol break bumps it. Also dedupes app version logic: app/main.py:_app_version() helper deleted, replaced by app/version.py:APP_VERSION as the single source of truth. test_app_version.py rewritten to target app.version. --- app/main.py | 25 +++----- app/version.py | 24 ++++++++ tests/test_app_version.py | 78 +++++++++++++++--------- tests/test_version_headers_middleware.py | 29 +++++++++ 4 files changed, 113 insertions(+), 43 deletions(-) create mode 100644 app/version.py create mode 100644 tests/test_version_headers_middleware.py diff --git a/app/main.py b/app/main.py index 2a5ef19..66ed650 100644 --- a/app/main.py +++ b/app/main.py @@ -23,8 +23,6 @@ except ImportError: import logging from contextlib import asynccontextmanager -from importlib.metadata import PackageNotFoundError -from importlib.metadata import version as _pkg_version from pathlib import Path from urllib.parse import quote @@ -36,18 +34,7 @@ from app.logging_config import setup_logging setup_logging("app") - -def _app_version() -> str: - """Product version for FastAPI title / OpenAPI schema. - - Single source of truth is `pyproject.toml` `[project].version`; we read - it back via `importlib.metadata` at runtime so `/docs`, `/openapi.json`, - `/api/version`, `/cli/latest`, and `da --version` can never drift. - """ - try: - return _pkg_version("agnes-the-ai-analyst") - except PackageNotFoundError: - return "dev" +from app.version import APP_VERSION, MIN_COMPAT_CLI_VERSION from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -183,7 +170,7 @@ def create_app() -> FastAPI: app = FastAPI( title="AI Data Analyst", description="Data distribution platform for AI analytical systems", - version=_app_version(), + version=APP_VERSION, lifespan=lifespan, # Intentionally NOT debug=DEBUG: FastAPI's debug=True installs # Starlette's ServerErrorMiddleware which intercepts unhandled @@ -195,6 +182,14 @@ def create_app() -> FastAPI: debug=False, ) + @app.middleware("http") + async def _add_version_headers(request, call_next): + response = await call_next(request) + if request.url.path.startswith("/api/"): + response.headers["X-Agnes-Latest-Version"] = APP_VERSION + response.headers["X-Agnes-Min-Version"] = MIN_COMPAT_CLI_VERSION + return response + # FastAPI debug toolbar — only when DEBUG=1 in env. Injects per-request # HTML overlay (headers, routes, timer, profiling, logs) on any HTML # response; harmless on JSON. Inner try/except is for the import only: diff --git a/app/version.py b/app/version.py new file mode 100644 index 0000000..f7c02b9 --- /dev/null +++ b/app/version.py @@ -0,0 +1,24 @@ +"""Single source of truth for app + CLI compat versions. + +`APP_VERSION` is read from package metadata so it tracks `pyproject.toml` +without a manual literal to keep in sync. + +`MIN_COMPAT_CLI_VERSION` is the oldest CLI version the server still accepts +on `/api/*`. Bumped manually when shipping a wire-protocol break. Day-one +value of "0.0.0" means no enforcement — set the floor the first time a +deliberate break ships. +""" + +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as _pkg_version + + +def _read_app_version() -> str: + try: + return _pkg_version("agnes-the-ai-analyst") + except PackageNotFoundError: + return "0.0.0+dev" + + +APP_VERSION = _read_app_version() +MIN_COMPAT_CLI_VERSION = "0.0.0" diff --git a/tests/test_app_version.py b/tests/test_app_version.py index cd869bd..aaa1510 100644 --- a/tests/test_app_version.py +++ b/tests/test_app_version.py @@ -1,36 +1,58 @@ -"""Pin that the FastAPI `version=` is read dynamically from package metadata. - -The OpenAPI schema (`/openapi.json`, `/docs`) advertises this version. A -hardcoded literal — the previous state — silently drifts from -`pyproject.toml` on every bump, leaving `/openapi.json` reporting a stale -version while `/api/version`, `/cli/latest`, and `da --version` all -report the bumped one. -""" +"""Pin that APP_VERSION reads from package metadata, not a hardcoded literal, +and that the FastAPI app's `version=` field surfaces it end-to-end.""" +import importlib from unittest.mock import patch - -def test_app_version_reads_package_metadata(): - """`_app_version()` must call importlib.metadata.version with the - canonical package name, not return a hardcoded literal.""" - with patch("app.main._pkg_version", return_value="9.9.9") as mock_pkg_ver: - from app.main import _app_version - assert _app_version() == "9.9.9" - mock_pkg_ver.assert_called_once_with("agnes-the-ai-analyst") +import pytest -def test_app_version_falls_back_to_dev_when_package_missing(): - """Source-checkout without install → report 'dev', not crash.""" +@pytest.fixture +def _restore_app_modules(): + """Reload-with-real-metadata so subsequent tests see the genuine + APP_VERSION / FastAPI app instance, not the patched-in fake from this + file's tests.""" + yield + import app.version + importlib.reload(app.version) + import app.main + importlib.reload(app.main) + + +def test_app_version_reads_package_metadata(_restore_app_modules): + # Patch the source `importlib.metadata.version` rather than the alias + # bound into app.version at import time — `importlib.reload(app.version)` + # re-runs the `from importlib.metadata import version as _pkg_version` + # line, which would otherwise re-fetch the unpatched original and + # silently neuter the test. + with patch("importlib.metadata.version", return_value="9.9.9") as mock_pkg_ver: + import app.version + importlib.reload(app.version) + assert app.version.APP_VERSION == "9.9.9" + # `assert_called_with` (not `assert_called_once_with`) — `import + # app.version` may have triggered an initial load before reload, + # giving two calls. We only care that the package name is canonical. + mock_pkg_ver.assert_called_with("agnes-the-ai-analyst") + + +def test_app_version_falls_back_when_package_missing(_restore_app_modules): from importlib.metadata import PackageNotFoundError - with patch("app.main._pkg_version", side_effect=PackageNotFoundError): - from app.main import _app_version - assert _app_version() == "dev" + with patch("importlib.metadata.version", side_effect=PackageNotFoundError): + import app.version + importlib.reload(app.version) + assert app.version.APP_VERSION == "0.0.0+dev" -def test_fastapi_app_version_matches_package_metadata(): - """End-to-end: what FastAPI stores in `app.version` is whatever - `_app_version()` returned — not a stale literal.""" - with patch("app.main._pkg_version", return_value="7.7.7"): - from app.main import create_app - app = create_app() - assert app.version == "7.7.7" +def test_fastapi_app_version_matches_app_version_constant(_restore_app_modules): + """End-to-end: FastAPI's app.version (consumed by /openapi.json and + /docs) must equal app.version.APP_VERSION. Guards the wiring at + `app/main.py:186 version=APP_VERSION` against accidental literal.""" + import app.version + import app.main + + # Reload both so we read post-patch values consistently. + with patch("importlib.metadata.version", return_value="7.7.7"): + importlib.reload(app.version) + importlib.reload(app.main) + assert app.main.app.version == "7.7.7" + assert app.main.app.version == app.version.APP_VERSION diff --git a/tests/test_version_headers_middleware.py b/tests/test_version_headers_middleware.py new file mode 100644 index 0000000..5501467 --- /dev/null +++ b/tests/test_version_headers_middleware.py @@ -0,0 +1,29 @@ +"""Verify /api/* responses carry X-Agnes-Latest-Version + X-Agnes-Min-Version.""" + +from fastapi.testclient import TestClient + + +def test_api_response_carries_version_headers(): + from app.main import app + from app.version import APP_VERSION, MIN_COMPAT_CLI_VERSION + client = TestClient(app) + # /api/version is unauthenticated and cheap. + resp = client.get("/api/version") + assert resp.status_code == 200 + # Headers must equal the constants in app.version, not just be parseable. + # When MIN_COMPAT_CLI_VERSION is deliberately bumped in a future PR, this + # test is updated in the same PR — the review-discipline guardrail. + assert resp.headers["X-Agnes-Latest-Version"] == APP_VERSION + assert resp.headers["X-Agnes-Min-Version"] == MIN_COMPAT_CLI_VERSION + # Day-one floor pin: drop or update this assertion when the floor moves. + assert resp.headers["X-Agnes-Min-Version"] == "0.0.0" + + +def test_non_api_response_does_not_carry_version_headers(): + from app.main import app + client = TestClient(app) + # /cli/latest is under /cli, not /api — should NOT carry the headers. + resp = client.get("/cli/latest") + assert resp.status_code == 200 + assert "X-Agnes-Latest-Version" not in resp.headers + assert "X-Agnes-Min-Version" not in resp.headers