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
6 changes: 6 additions & 0 deletions src/pyatv_http/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from importlib.metadata import PackageNotFoundError, version

try:
__version__ = version("pyatv-http")
except PackageNotFoundError:
__version__ = "0+unknown"
10 changes: 8 additions & 2 deletions src/pyatv_http/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions src/pyatv_http/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)
Expand Down
9 changes: 8 additions & 1 deletion tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
16 changes: 16 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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)

Expand Down