A modern Python project keeps its code under src/<package>/, tests under tests/, and all metadata, dependencies and tool settings in one pyproject.toml. Tools like uv or pip install from it; pip install -e . installs your package in editable mode during development.
Automate quality: ruff (linting and formatting — very fast), mypy (types) and pytest (tests). Run them locally and in CI on every push.
In production use the logging module (not print), read configuration from environment variables, run the API with a production server (FastAPI uses Uvicorn), and package everything in a small Docker image that runs as a non-root user.
results-api/
├── pyproject.toml
├── Dockerfile
├── src/
│ └── results_api/
│ ├── __init__.py
│ ├── main.py
│ └── db.py
└── tests/
└── test_main.py[project]
name = "results-api"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"fastapi[standard]>=0.115",
"psycopg[binary,pool]>=3.2",
]
[project.optional-dependencies]
dev = ["pytest>=8", "mypy>=1.11", "ruff>=0.6"]
[tool.ruff]
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]
[tool.mypy]
strict = true
[tool.pytest.ini_options]
testpaths = ["tests"]import logging
import os
def configure_logging() -> logging.Logger:
logging.basicConfig(
level=os.environ.get("LOG_LEVEL", "INFO"),
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
return logging.getLogger("results_api")
log = configure_logging()
log.info("service starting", extra={"port": os.environ.get("PORT", "8000")})FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
COPY pyproject.toml ./
COPY src ./src
RUN pip install --no-cache-dir .
USER nobody
EXPOSE 8000
CMD ["uvicorn", "results_api.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]pip install -e ".[dev]"
ruff check . && ruff format --check . && mypy src && pytest
docker build -t results-api . && docker run -p 8000:8000 -e DATABASE_URL=... results-apiKey points
- One
pyproject.tomlholds dependencies and tool settings; use the src layout. - ruff + mypy + pytest on every push keeps quality high.
- Log with
logging, configure with env vars, run as non-root in Docker.
Exercise
Restructure your FastAPI + PostgreSQL project into the layout above, add the quality tools, make ruff, mypy and pytest pass, then build and run the Docker image locally.
Show solution
Try the exercise yourself first — then compare your approach with this one.
Move the code into src/results_api/, the tests into tests/, and declare everything in pyproject.toml (as in the lesson). pip install -e ".[dev]" installs the package in editable mode, so tests import it as results_api. Then the three quality gates must all pass before building the Docker image.
def grade_for(score: int) -> str:
"""Return the NECTA-style grade letter for a score from 0 to 100."""
if not 0 <= score <= 100:
raise ValueError("score must be between 0 and 100")
for minimum, grade in ((75, "A"), (65, "B"), (45, "C"), (30, "D")):
if score >= minimum:
return grade
return "F"import pytest
from results_api.grading import grade_for
@pytest.mark.parametrize(("score", "grade"), [(75, "A"), (74, "B"), (30, "D"), (29, "F")])
def test_grade_for(score: int, grade: str) -> None:
assert grade_for(score) == grade
def test_rejects_out_of_range() -> None:
with pytest.raises(ValueError):
grade_for(101)pip install -e ".[dev]"
ruff check . && ruff format --check . && mypy src && pytest -q
# All checks passed! ... Success: no issues found ... 5 passed
docker build -t results-api .
docker run -p 8000:8000 -e DATABASE_URL="postgresql://..." results-api