From 4a016ceb7b3949f9a69eef58eab032d894c930a5 Mon Sep 17 00:00:00 2001 From: Hugo Haas Date: Wed, 22 Jul 2026 15:11:24 -0500 Subject: [PATCH] feat(observability): expose build version via header, /health, and startup log Reads __version__ from installed package metadata (hatch-vcs bakes it in from the release tag), so header/log/health all reflect the same released version with no hardcoding. --- src/pyatv_http/__init__.py | 6 ++++++ src/pyatv_http/app.py | 10 ++++++++-- src/pyatv_http/cli.py | 6 ++++++ tests/test_app.py | 9 ++++++++- tests/test_cli.py | 16 ++++++++++++++++ 5 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/pyatv_http/__init__.py b/src/pyatv_http/__init__.py index e69de29..92f55aa 100644 --- a/src/pyatv_http/__init__.py +++ b/src/pyatv_http/__init__.py @@ -0,0 +1,6 @@ +from importlib.metadata import PackageNotFoundError, version + +try: + __version__ = version("pyatv-http") +except PackageNotFoundError: + __version__ = "0+unknown" diff --git a/src/pyatv_http/app.py b/src/pyatv_http/app.py index 2cfd055..bcea8da 100644 --- a/src/pyatv_http/app.py +++ b/src/pyatv_http/app.py @@ -9,14 +9,14 @@ from starlette.requests import Request from starlette.responses import JSONResponse -from pyatv_http import atv +from pyatv_http import __version__, atv from pyatv_http.atv import DeviceUnreachableError from pyatv_http.auth import require_token from pyatv_http.config import AppConfig async def _health(_request: Request) -> JSONResponse: - return JSONResponse({"status": "ok"}) + return JSONResponse({"status": "ok", "version": __version__}) class PowerStateRequest(BaseModel): @@ -30,6 +30,12 @@ class PowerStateRequest(BaseModel): def create_app(config: AppConfig) -> FastAPI: app = FastAPI(dependencies=[Depends(require_token(config))]) + @app.middleware("http") + async def _add_version_header(request: Request, call_next): + response = await call_next(request) + response.headers["App-Version"] = __version__ + return response + # Added as a raw Starlette route (like FastAPI's own /docs, /openapi.json) # so it bypasses the app-wide bearer-token dependency above -- health # probes (load balancers, uptime checks) shouldn't need a token. diff --git a/src/pyatv_http/cli.py b/src/pyatv_http/cli.py index 511b249..6a551dc 100644 --- a/src/pyatv_http/cli.py +++ b/src/pyatv_http/cli.py @@ -2,18 +2,22 @@ import argparse import asyncio +import logging import os import sys from pathlib import Path import uvicorn +from pyatv_http import __version__ from pyatv_http.app import create_app from pyatv_http.config import load_config from pyatv_http.gen_config import DeviceNotFoundError, generate_config_block DEFAULT_STORAGE_PATH = Path.home() / ".pyatv.conf" +logger = logging.getLogger(__name__) + def default_config_path() -> Path: xdg_config_home = os.environ.get("XDG_CONFIG_HOME") @@ -22,6 +26,8 @@ def default_config_path() -> Path: def _serve(args: argparse.Namespace) -> None: + logging.basicConfig(level=logging.INFO) + logger.info("pyatv-http %s starting up", __version__) config_path = args.config or default_config_path() config = load_config(config_path) app = create_app(config) diff --git a/tests/test_app.py b/tests/test_app.py index d6feb9b..0513691 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -4,6 +4,7 @@ from httpx import ASGITransport, AsyncClient from pyatv.const import PowerState +from pyatv_http import __version__ from pyatv_http.app import create_app from pyatv_http.atv import DeviceUnreachableError from pyatv_http.config import AppConfig, DeviceConfig @@ -169,7 +170,13 @@ async def test_health_returns_ok_without_token(unauthenticated_client): response = await unauthenticated_client.get("/health") assert response.status_code == 200 - assert response.json() == {"status": "ok"} + assert response.json() == {"status": "ok", "version": __version__} + + +async def test_response_includes_version_header(unauthenticated_client): + response = await unauthenticated_client.get("/health") + + assert response.headers["App-Version"] == __version__ async def test_requests_without_token_are_rejected(unauthenticated_client): diff --git a/tests/test_cli.py b/tests/test_cli.py index 5a0d2e8..276be2f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,8 @@ +import logging from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch +from pyatv_http import __version__ from pyatv_http.cli import build_parser, default_config_path, main @@ -20,6 +22,20 @@ def test_serve_subcommand_runs_uvicorn(): mock_run.assert_called_once_with(fake_app, host="0.0.0.0", port=8080) +def test_serve_logs_version_at_startup(caplog): + fake_config = MagicMock(port=8080) + + with ( + patch("pyatv_http.cli.load_config", return_value=fake_config), + patch("pyatv_http.cli.create_app", return_value=MagicMock()), + patch("pyatv_http.cli.uvicorn.run"), + caplog.at_level(logging.INFO, logger="pyatv_http.cli"), + ): + main(["serve", "--config", "config.toml"]) + + assert any(__version__ in record.message for record in caplog.records) + + def test_serve_uses_default_config_path_when_not_given(): fake_config = MagicMock(port=8080)