read.markets/Dockerfile
Giorgio Gilestro 2b9cd875b4 deps: add requirements.lock for reproducible builds
pyproject.toml uses range pins (>=) for all dependencies; without a
lockfile, a fresh `pip install .` on a different day could pull
materially different versions of fastapi, sqlalchemy, httpx, etc.
For a production-shaped service that's a reproducibility risk —
especially since we don't run a CI pipeline that would catch
"works on yesterday's container, fails on today's."

requirements.lock pins every transitive dep (60 packages) to the
exact versions running in the test container today. Dockerfile is
updated so both stages install from the lockfile first, then install
the project itself with --no-deps:

  pip install -r requirements.lock
  pip install --no-deps .

That way pyproject.toml's range pins document our compatible
upper-and-lower bounds, but the lockfile is what actually gets
installed on every build.

To bump deps later: bump pyproject.toml ranges, rebuild a fresh
venv, `pip freeze` it, save back to requirements.lock.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 00:07:38 +02:00

72 lines
2.4 KiB
Docker

# syntax=docker/dockerfile:1.7
FROM python:3.13-slim AS builder
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1 \
PYTHONDONTWRITEBYTECODE=1
WORKDIR /build
COPY pyproject.toml requirements.lock ./
COPY app ./app
# requirements.lock pins every transitive dependency to the known-good
# versions captured by `pip freeze` against a clean install. Install
# from it first, then add the project itself with --no-deps so the
# lockfile is the single source of truth and pyproject's range pins
# (>=) can't drift on rebuild.
RUN python -m venv /opt/venv \
&& /opt/venv/bin/pip install --upgrade pip \
&& /opt/venv/bin/pip install -r requirements.lock \
&& /opt/venv/bin/pip install --no-deps .
FROM python:3.13-slim AS runtime
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PATH="/opt/venv/bin:$PATH" \
TZ=UTC
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /opt/venv /opt/venv
WORKDIR /app
COPY app ./app
COPY alembic ./alembic
COPY alembic.ini ./
# Default command is the web app; scheduler container overrides via `command:`.
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
# ---------------------------------------------------------------------------
# Test stage — same Python, same prod deps, plus dev extras (pytest +
# aiosqlite). Built and run only via docker-compose.test.yml; never shipped.
# ---------------------------------------------------------------------------
FROM python:3.13-slim AS test
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PATH="/opt/venv/bin:$PATH" \
TZ=UTC \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1
COPY --from=builder /opt/venv /opt/venv
WORKDIR /app
COPY pyproject.toml requirements.lock ./
COPY app ./app
COPY alembic ./alembic
COPY alembic.ini ./
# tests/ is excluded by .dockerignore (prod-correct: never bake tests into
# a shipped image). docker-compose.test.yml bind-mounts ./tests:/app/tests
# at run time, so the suite is always available without baking it in.
# The lockfile already contains the dev extras (pytest, ruff, aiosqlite,
# ...) because it was generated against a test-stage install. Same
# install pattern as the builder stage: lockfile first, project --no-deps.
RUN /opt/venv/bin/pip install -r requirements.lock \
&& /opt/venv/bin/pip install --no-deps .
CMD ["pytest", "tests/", "-v"]