diff --git a/src/backend/Dockerfile b/src/backend/Dockerfile index d2ee178..bd9e65d 100644 --- a/src/backend/Dockerfile +++ b/src/backend/Dockerfile @@ -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 diff --git a/src/backend/app/main.py b/src/backend/app/main.py index e41327b..c580575 100644 --- a/src/backend/app/main.py +++ b/src/backend/app/main.py @@ -1,5 +1,6 @@ import logging import os +import tomllib from collections.abc import AsyncIterator from contextlib import asynccontextmanager from importlib.metadata import PackageNotFoundError @@ -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() diff --git a/src/backend/tests/unit/test_app_version.py b/src/backend/tests/unit/test_app_version.py new file mode 100644 index 0000000..9b042c9 --- /dev/null +++ b/src/backend/tests/unit/test_app_version.py @@ -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