feat(server): expose APP_VERSION + MIN_COMPAT_CLI_VERSION on /api/* response headers

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.
This commit is contained in:
ZdenekSrotyr 2026-05-06 15:23:37 +02:00
parent 56483989cf
commit 57170bc556
4 changed files with 113 additions and 43 deletions

View file

@ -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:

24
app/version.py Normal file
View file

@ -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"

View file

@ -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

View file

@ -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