# Reading List — Contract Source of truth for all workers. **Only the lead edits this file.** If something here is ambiguous or you need a file you don't own: STOP and report it in your HANDOFF. ## Stack - Python 3.12, FastAPI, uvicorn. Tooling: **uv only** (`uv sync`, `uv run ...`). - Tests: pytest + FastAPI `TestClient` (backed by `httpx2`). Fixture `client` in `tests/conftest.py` gives a fresh app, and therefore a fresh empty store, per test. - In-memory storage, no auth, no frontend frameworks. No new dependencies without the lead. - Deploy: Hugging Face Docker Space, port 7860, `python:3.12-slim` base. ## Data ### Status Exactly these three string values (`app.models.Status`, a `StrEnum`): | Enum member | Wire value | |---|---| | `Status.TO_READ` | `"to-read"` | | `Status.READING` | `"reading"` | | `Status.DONE` | `"done"` | Anything else is rejected with 422, in request bodies and in the `status` query parameter. ### Book (response) ```json {"id": 1, "title": "Dune", "author": "Frank Herbert", "status": "reading"} ``` - `id`: int, auto-incremented from 1 per store, never reused. - `title`: string, stored after trimming. - `author`: string or `null`. - `status`: a Status value. ### BookCreate (POST body) — validation owned by **add** | Field | Required | Rules | |---|---|---| | `title` | yes | Must be a string. Leading/trailing whitespace is trimmed; **after trimming** it must be 1–200 characters (so `""` and `" "` are rejected). | | `author` | no | String or `null`. Trimmed; empty after trimming → stored as `null`. Max 200 characters after trimming. | | `status` | no | One of the Status values. Defaults to `"to-read"`. | Unknown fields are ignored (FastAPI default). Duplicate titles are allowed. ### BookStatusUpdate (PATCH body) ```json {"status": "done"} ``` `status` is required and must be a Status value. ### BookStats ```json {"total": 3, "by_status": {"to-read": 1, "reading": 0, "done": 2}} ``` All three `by_status` keys are always present, including zero counts. `total` equals the sum. ## Endpoints Routers declare full paths (e.g. `@router.post("/api/books")`); `app/main.py` includes them with no prefix. Routers get the store via `Depends(get_store)` from `app/store.py`. ### `POST /api/books` — owner: add Request: ```json {"title": " Dune ", "author": "Frank Herbert", "status": "reading"} ``` Response **201**: ```json {"id": 1, "title": "Dune", "author": "Frank Herbert", "status": "reading"} ``` Minimal request `{"title": "Dune"}` → **201** `{"id": 1, "title": "Dune", "author": null, "status": "to-read"}`. Errors: **422** for missing/blank/too-long/non-string title, too-long author, invalid status, or a malformed JSON body. ### `GET /api/books` — owner: query Query parameter: `status` (optional, a Status value). - `GET /api/books` → **200**, all books. - `GET /api/books?status=reading` → **200**, only books with that status. Response **200**: always a JSON array in insertion order (ascending `id`); `[]` when empty. ```json [{"id": 1, "title": "Dune", "author": "Frank Herbert", "status": "reading"}] ``` Errors: **422** if `status` is not a Status value. ### `PATCH /api/books/{id}` — owner: query Request: ```json {"status": "done"} ``` Response **200**: the updated Book. ```json {"id": 1, "title": "Dune", "author": "Frank Herbert", "status": "done"} ``` Errors: **404** `{"detail": "Book not found"}` if no book has that id; **422** if the body is missing `status`, `status` is invalid, or `id` is not an integer. ### `GET /api/stats` — owner: query Response **200**: BookStats (see above). An empty store gives `{"total": 0, "by_status": {"to-read": 0, "reading": 0, "done": 0}}`. ### `GET /` — owner: ui Response **200**, `Content-Type: text/html`, the contents of `app/static/index.html` (served with `FileResponse`). ## Error formats FastAPI defaults, no custom exception handlers. **422** (validation): ```json {"detail": [{"type": "string_too_short", "loc": ["body", "title"], "msg": "String should have at least 1 character", "input": ""}]} ``` Clients should rely on `detail` being a list with `loc` and `msg`; exact `type`/`msg` text is not part of the contract. **404**: ```json {"detail": "Book not found"} ``` UI rule: if `detail` is a list, show `detail[0].msg` (optionally prefixed by the last `loc` element); if it's a string, show it as-is. ## Store API (`app/store.py`) One `BookStore` per app, created in `create_app()` and stored on `app.state.store`. **Every public method must do its work inside `with self._lock:`.** State: `self._books: dict[int, Book]`, `self._next_id: int` (starts at 1). | Method | Owner | Behaviour | |---|---|---| | `add(data: BookCreate) -> Book` | add | Assigns the next id, stores and returns the Book. `data` is already validated. | | `list_books(status: Status \| None = None) -> list[Book]` | query | All books, or only those with `status`, ascending id. | | `update_status(book_id: int, status: Status) -> Book \| None` | query | Updates and returns the Book; returns `None` if the id is missing (router maps to 404). | | `counts() -> BookStats` | query | Total and per-status counts, all three keys present. | ## UI (`app/static/index.html`) One page, inline CSS and JS, no frameworks, no build step. Uses only the endpoints above. - Add form: title (required), author (optional), status select (default "to-read"). - Book list, with a status filter (All / to-read / reading / done). - Per-row status select that sends `PATCH /api/books/{id}` on change. - Stats line from `GET /api/stats`, refreshed after every add/update. - Feedback area with `aria-live="polite"`: a success message after add/update, and an error message (per the UI rule above) on failure. Labels on all form controls. - Display labels are presentation only: the UI may show "To read" / "Reading" / "Done", but always sends and filters by the exact Status wire values. - The status filter may be a select or an accessible radio-group of tabs; filter counts come from `GET /api/stats` (totals, independent of the active filter). - The add form's status control may likewise be a select or an accessible radio group (`name="status"`, wire values, default `to-read`). ## File ownership | Owner | Files | |---|---| | lead | `docs/*`, `CLAUDE.md`, `pyproject.toml`, `uv.lock`, `.python-version`, `app/__init__.py`, `app/api/__init__.py`, `app/main.py`, `app/models.py` (except BookCreate validation), `app/store.py` (`__init__`, `get_store`), `tests/conftest.py`, `tests/test_boot.py` | | add | BookCreate validation in `app/models.py`, `BookStore.add` in `app/store.py`, `app/api/add.py`, `tests/test_add.py` | | query | `BookStore.list_books`, `BookStore.update_status`, `BookStore.counts` in `app/store.py`, `app/api/query.py`, `tests/test_query.py` | | ui | `app/static/index.html`, `app/api/ui.py`, `tests/test_ui.py` | | tests | `tests/test_contract.py` | | deploy | `Dockerfile`, `.dockerignore`, `README.md`, `scripts/smoke.sh` | Notes: - add and query both edit `app/store.py`, but only their own method bodies. Don't reformat or reorder anything else; the lead reconciles at merge. - **tests**: `tests/test_contract.py` is black-box (HTTP only, via the `client` fixture) and covers this whole document. On the skeleton it is **expected to fail** (routes return 404/405 until add and query are merged). Commit the red tests and paste the failing output in the HANDOFF; do not implement features to make them pass. This is the project's red→green demonstration. - **deploy**: `README.md` must start with HF Space frontmatter (`sdk: docker`, `app_port: 7860`). The Dockerfile uses uv (`uv sync --frozen --no-dev`), runs as a non-root user with uid 1000, and starts `uvicorn app.main:app --host 0.0.0.0 --port 7860`. `scripts/smoke.sh` takes `BASE_URL` (default `http://localhost:7860`), exercises every endpoint including one 422 and one 404, and exits non-zero on any failure. Deploy must not push anywhere; only the lead touches Hugging Face or git remotes. ## Ports Run locally with `uv run uvicorn app.main:app --port ` (`app.main` exposes `app`): | Worker | Port | |---|---| | add | 8001 | | query | 8002 | | ui | 8003 | | deploy | 7860 (Docker) | ## Workflow - Each worker works in its own git worktree on branch `worker/`, branched from main. Run `uv sync` first. - TDD: failing test first, run it and show the expected failure, minimal implementation, then `uv run pytest -q`. Commit after each red→green. Small conventional commits. - Merge order (lead): tests → add → query → ui → deploy. ## HANDOFF format Every worker ends with exactly this: ``` ## HANDOFF — @ worker/ Files changed: Provides: Assumptions: Test command + output: `uv run pytest -q` Gaps / blockers: ```