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
4 changes: 4 additions & 0 deletions src/backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ COPY --chown=app:app alembic alembic
COPY --chown=app:app app app
COPY --chown=app:app scripts scripts
COPY --chown=app:app alembic.ini alembic.ini
# The project itself isn't installed as a distribution above (--no-install-project),
# so no dist-info exists for importlib.metadata to read. Ship pyproject.toml so
# app.main can fall back to parsing the version straight from it.
COPY --chown=app:app pyproject.toml pyproject.toml

EXPOSE 8000

Expand Down
22 changes: 17 additions & 5 deletions src/backend/app/main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
import os
import tomllib
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from importlib.metadata import PackageNotFoundError
Expand Down Expand Up @@ -42,15 +43,26 @@


def _resolve_app_version() -> str:
"""Return the installed package version, the single source of truth.
"""Return the app version, reading from a single source of truth.

Falls back gracefully when the app runs from a source tree that has not
been installed as a distribution (e.g. some local dev setups).
Prefers installed package metadata (present in dev venvs where the
project is installed editable). The production Docker image installs
only dependencies as a distinct layer and copies app source on top
without installing the project itself, so no dist-info exists there —
fall back to parsing `pyproject.toml` directly in that case.
"""
try:
return _package_version("guard-proxy-backend")
except PackageNotFoundError: # pragma: no cover - dev-only fallback
return "0.0.0+unknown"
except PackageNotFoundError:
pyproject_path = Path(__file__).resolve().parent.parent / "pyproject.toml"
try:
with pyproject_path.open("rb") as f:
version = tomllib.load(f)["project"]["version"]
if not isinstance(version, str):
raise KeyError("project.version is not a string")
return version
except (OSError, KeyError): # pragma: no cover - defensive fallback
return "0.0.0+unknown"


APP_VERSION = _resolve_app_version()
Expand Down
42 changes: 42 additions & 0 deletions src/backend/tests/unit/test_app_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import tomllib
from collections.abc import Iterator
from importlib.metadata import PackageNotFoundError
from pathlib import Path

import pytest

import app.main as main_module

_PYPROJECT_VERSION = tomllib.loads(
(Path(main_module.__file__).resolve().parent.parent / "pyproject.toml").read_text()
)["project"]["version"]


@pytest.fixture
def package_metadata_missing() -> Iterator[None]:
"""Simulate the production Docker image, where the project itself is
never installed as a distribution (only its dependencies are), so
importlib.metadata has no dist-info to read.
"""

def _raise(name: str) -> str:
raise PackageNotFoundError(name)

original = main_module._package_version
main_module._package_version = _raise
try:
yield
finally:
main_module._package_version = original


def test_resolves_from_installed_package_metadata_when_present() -> None:
assert main_module._resolve_app_version() == main_module._package_version(
"guard-proxy-backend"
)


def test_falls_back_to_pyproject_toml_when_package_metadata_missing(
package_metadata_missing: None,
) -> None:
assert main_module._resolve_app_version() == _PYPROJECT_VERSION
Loading