Developer guide#

Everything you need to go from a fresh clone to a green make test.

For the fastest path, use the devcontainer — it pins Python, installs uv and pre-commit, and gives you a Claude Code setup with a default-deny egress firewall. The rest of this page assumes you’re setting up directly on your host.

1. Check out the code#

git clone git@github.com:rodekruis/qualitative-feedback-analysis.git
cd qualitative-feedback-analysis

Make a branch off main for any non-trivial work (see Project workflow).

2. Set up your local environment#

We use direnv to load per-project environment variables automatically when you cd into the repo. The repo ships a .envrc that sources .env, so once direnv is allowed, opening a shell in the project will export everything you need.

# install direnv (macOS: brew install direnv; Debian/Ubuntu: apt install direnv)
# then hook it into your shell — see https://direnv.net/docs/hook.html

cp .env.example .env
$EDITOR .env            # fill in the values you actually need locally
direnv allow            # one-time approval for this directory

.env.example is the starter template — copy it, edit it, never commit .env (it’s gitignored). Required variables and defaults are listed in Settings reference.

If you’d rather not use direnv, export the same variables manually or load .env from your shell profile. The application itself reads settings via pydantic-settings, so any mechanism that puts the variables into the process environment works.

3. Install dependencies and hooks#

uv sync                       # creates .venv/ and installs project + dev deps
uv run pre-commit install     # wires pre-commit into .git/hooks/pre-commit

pre-commit install only needs to run once per clone — after that, the hooks fire on every git commit. To run them ad-hoc:

make pre_commit               # runs all hooks on all files
uv run pre-commit run         # runs hooks on staged files only

The configured hooks are in .pre-commit-config.yaml: ruff (lint + format), yamllint, nbstripout for notebooks, ty type-checking, and lint-imports for the hexagonal layer contracts.

4. Verify the setup#

make test                     # unit tier — should pass on a fresh clone
make lint                     # ruff + ty + import-linter

Running the full test suite#

The suite is split into three tiers. The default make test runs only the fast unit tier; integration and e2e are gated behind pytest markers and a running Postgres.

Tier

Marker

Needs

Command

Unit

(none)

make test

Integration

integration

Postgres

make db-up && make test-integration

E2E

e2e

Postgres

make db-up && make test-integration

make test-integration runs both integration and e2e markers in one pass. The first invocation also runs alembic upgrade head once via the session-scoped pg_engine fixture.

All three tiers run in CI on every push: unit in the test job, integration + e2e in a dedicated integration job that brings up a Postgres 16 service container (see .github/workflows/ci.yaml).

Postgres for tiers 2 and 3#

make db-up        # start docker-compose Postgres on localhost:5432
make db-down      # stop the container, keep the volume (fast restart)
make db-reset     # nuke the volume and start fresh (~5s)
make migrate      # apply migrations manually (rarely needed; tests do this)

The default URL is postgresql+asyncpg://qfa:qfa@localhost:5432/qfa. Point at a different host with the INTEGRATION_DB_URL env var:

INTEGRATION_DB_URL=postgresql+asyncpg://user:pw@host:5432/db make test-integration

Running a specific tier or test#

uv run pytest -m integration                          # tier 2 only
uv run pytest -m e2e                                  # tier 3 only
uv run pytest tests/integration/test_db_postgres.py   # specific file

Coding style and conventions#

  • Follow the project guidelines. They cover package management (uv, not pip), commit messages (conventional commits), and the hexagonal layer rules.

  • Every adapter explicitly inherits from its port. Even though Python Protocols support structural typing, we require class LiteLLMClient(LLMPort): and class PresidioAnonymizer(AnonymizationPort): so that “go to definition” in an IDE jumps from adapter to contract. Structural conformance is reserved for ad-hoc test fakes. See Components for the full ports/adapters layout.

  • Import directions are enforced. qfa.domain must not import third-party infra (litellm, presidio_*, fastapi, …); qfa.services may only import qfa.domain; the composition root in qfa.api.app is the only place that wires concrete adapters into ports. make lint runs lint-imports to enforce this.

Where to go next#