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
41 changes: 41 additions & 0 deletions .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: Run Tests

on:
push:
branches: [main]
pull_request:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true

- name: Install dependencies
run: uv sync --locked

- name: Lint
run: uv run ruff check .

- name: Check formatting
run: uv run ruff format --check .

- name: Type check
run: uv run pyright

- name: Dead code
run: uv run vulture

# The e2e tier needs a running DSS; the `e2e` marker keeps it out of the
# default run (pyproject.toml).
- name: Test
run: uv run pytest
51 changes: 51 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Install once per clone:
# uv run pre-commit install --hook-type pre-commit --hook-type pre-push
# Local hooks can be bypassed with `--no-verify`, so CI runs the same checks.

# Every hook runs on commit unless it names its own stage.
default_stages: [pre-commit]

repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.4
hooks:
- id: ruff-check
args: [--fix]
- id: ruff-format

- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-toml
- id: check-merge-conflict
- id: check-added-large-files

- repo: local
hooks:
# Types on every commit. `always_run`, because a change to pyproject.toml
# alone can loosen the check. Runs from the project venv, not a hook venv,
# so pyright sees the real dependencies.
- id: pyright
name: pyright
entry: uv run pyright
language: system
pass_filenames: false
always_run: true
# Dead code on every commit, whatever changed, for the same reason.
- id: vulture
name: vulture
entry: uv run vulture
language: system
pass_filenames: false
always_run: true
# The full test suite before code leaves the machine (CONVENTIONS.md).
- id: pytest
name: pytest
entry: uv run pytest -q
language: system
pass_filenames: false
always_run: true
stages: [pre-push]
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Changelog

All notable changes to this project are recorded here, in the format of
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions follow
`MAJOR.MINOR.PATCH`, as `CONVENTIONS.md` sets out.

## [Unreleased]

### Added
- The repo skeleton: uv, ruff, pyright, vulture, pytest, pre-commit and CI.
- `create_app()`, the composition root.
- `GET /healthz`, for Docker and the front proxy.
- A test that enforces the architecture's dependency rule.
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,39 @@ request, calls the DSS, and streams the answer back.

## Run it

Needs [uv](https://docs.astral.sh/uv/). It installs Python 3.13 itself.

```bash
uv sync
uv run ruff check . && uv run ruff format --check . && uv run pytest
uv run uvicorn --factory experience_api.app:create_app --port 8078
```

The API listens on `http://localhost:8078`.

| Path | What |
|---|---|
| `GET /healthz` | `{"status": "ok"}` when the process is up. For Docker and the proxy |
| `GET /docs` | The interactive OpenAPI page |

```bash
curl -i http://localhost:8078/healthz
```

Add `--reload` to restart on every file change while developing.

## Check it

```bash
uv run ruff check . && uv run ruff format --check . # lint, format
uv run pyright # types
uv run vulture # dead code
uv run pytest # tests, with coverage
```

Once per clone, install the git hooks. Lint, types and dead code run on every
commit; the test suite runs before every push. CI runs the same checks, so
`--no-verify` only moves a failure later.

```bash
uv run pre-commit install --hook-type pre-commit --hook-type pre-push
```
43 changes: 33 additions & 10 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,16 @@ packages = ["src/experience_api"]

[dependency-groups]
dev = [
"asgi-lifespan>=2.1.0",
"pre-commit>=4.0.0",
"pyright>=1.1.400",
"pytest>=9.1.1",
"pytest-asyncio>=1.0.0",
# Coverage is reported on every run (report-only, no gate) so regressions
# are visible without blocking a merge — see addopts below.
"pytest-cov>=7.1.0",
"ruff>=0.16.4",
"vulture>=2.14",
]

# ---------------------------------------------------------------------------
Expand All @@ -39,7 +43,7 @@ dev = [
# ---------------------------------------------------------------------------
[tool.ruff]
line-length = 88
src = ["src", "tests", "scripts"]
src = ["src", "tests"]
target-version = "py313"
# Docs contain illustrative Python in fenced blocks whose alignment is
# deliberate. Formatting them rewrites the prose's meaning, so they are excluded.
Expand All @@ -55,20 +59,27 @@ select = [
"B", # flake8-bugbear
]

[tool.ruff.lint.isort]
# `scripts/` is ours but is not packaged (only `src/experience_api` ships), so
# isort has no way to infer it and files it under third-party beside pytest.
known-first-party = ["experience_api", "scripts", "tests"]
# Absolute imports only. A relative import hides which layer it reaches into,
# and `from .adapters import x` inside a feature is the easiest way to break the
# dependency rule. tests/test_boundaries.py resolves them anyway; this stops
# them being written.
[tool.ruff.lint.per-file-ignores]
# The vulture whitelist is `_.name` lines by design (see its docstring).
"vulture_whitelist.py" = ["B018", "F821"]

[tool.ruff.lint.flake8-tidy-imports]
ban-relative-imports = "all"

# ---------------------------------------------------------------------------
# Editors default to the system interpreter and then cannot resolve pytest or
# pydantic. This points them at the project venv. Type checking is not a gate;
# ruff is the only enforced tool.
# Static analysis. pyright in `standard` mode is a gate, in pre-commit and CI.
# `venvPath`/`venv` point editors and the CLI at the project venv, since both
# default to the system interpreter and then cannot resolve the dependencies.
# ---------------------------------------------------------------------------
[tool.pyright]
venvPath = "."
venv = ".venv"
include = ["src", "tests"]
typeCheckingMode = "standard"

# ---------------------------------------------------------------------------
# Tests. The `e2e` marker is excluded from the default run: that tier needs a
Expand All @@ -80,7 +91,6 @@ include = ["src", "tests"]
[tool.pytest.ini_options]
minversion = "9.0"
testpaths = ["tests"]
pythonpath = ["."]
# --import-mode=importlib: test folders mirror src/, so sibling packages may
# each hold a `test_mapping.py`. Under the default prepend mode those collide on
# the module name; importlib gives each its own full path.
Expand All @@ -99,6 +109,19 @@ addopts = [
# test regardless of marker.
asyncio_mode = "auto"
markers = [
"e2e: one turn against a real DSS; needs XAPI_DSS_BASE_URL to point at one",
"e2e: one turn against a real DSS; needs a running DSS",
]
strict_markers = true

# ---------------------------------------------------------------------------
# Dead code. vulture reports unused functions, classes, imports and variables,
# at its default confidence (60%). A false positive earns a line in
# `vulture_whitelist.py`, not a lower bar. The whitelist is one of the paths, so
# every name it mentions counts as used.
# ---------------------------------------------------------------------------
[tool.vulture]
paths = ["src", "tests", "vulture_whitelist.py"]
# FastAPI calls a decorated route; our code never names it. A router's HTTP-verb
# decorator therefore counts as a use. Extend this when another kind of
# decorated hook arrives (exception handlers, websockets).
ignore_decorators = ["@*.get", "@*.post", "@*.put", "@*.patch", "@*.delete"]
37 changes: 37 additions & 0 deletions src/experience_api/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""The composition root: `create_app()` builds the FastAPI app.

`uvicorn --factory` calls it with no arguments, so it must boot with nothing
configured. Objects the app needs for its whole life, such as an HTTP client
pool, are built in `lifespan`; none exist yet.

`/healthz` lives here because it belongs to the running service, not to any
feature.
"""

from __future__ import annotations

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager

from fastapi import APIRouter, FastAPI

health = APIRouter()


@health.get("/healthz", include_in_schema=False)
async def healthz() -> dict[str, str]:
"""For Docker and the front proxy. Says the process is up and serving. It
does not probe the DSS, so a DSS outage never takes the API out of rotation
with it."""

return {"status": "ok"}


def create_app() -> FastAPI:
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
yield

app = FastAPI(title="Experience API", lifespan=lifespan)
app.include_router(health)
return app
11 changes: 11 additions & 0 deletions tests/api/test_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""`create_app()` must boot with no arguments and no environment, because that
is how `uvicorn --factory` calls it."""

import httpx


async def test_app_boots_and_describes_itself(running: httpx.AsyncClient) -> None:
response = await running.get("/openapi.json")

assert response.status_code == 200
assert response.json()["info"]["title"] == "Experience API"
19 changes: 19 additions & 0 deletions tests/api/test_health.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""`GET /healthz` is for whoever runs the service, such as Docker or the front
proxy. It is not for the client, so it is not in the contract."""

import httpx


async def test_healthz_says_ok(running: httpx.AsyncClient) -> None:
response = await running.get("/healthz")

assert response.status_code == 200
assert response.json() == {"status": "ok"}


async def test_healthz_is_not_in_the_openapi_document(
running: httpx.AsyncClient,
) -> None:
paths = (await running.get("/openapi.json")).json()["paths"]

assert "/healthz" not in paths
35 changes: 35 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Fixtures shared by every tier."""

from collections.abc import AsyncIterator

import httpx
import pytest
from asgi_lifespan import LifespanManager
from fastapi import FastAPI

from experience_api.app import create_app


@pytest.fixture
def app() -> FastAPI:
"""The app as `uvicorn --factory` builds it. A module that needs different
wiring overrides this fixture with its own `create_app(...)` call."""

return create_app()


@pytest.fixture
async def running(app: FastAPI) -> AsyncIterator[httpx.AsyncClient]:
"""The app with its lifespan started, behind an in-process HTTP client.

httpx's ASGI transport does not run lifespan events, and everything the app
needs is built there, so a test that skips it would hit an empty `app.state`.
"""

async with (
LifespanManager(app),
httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://test"
) as client,
):
yield client
Loading
Loading