reading-list / docs /contract.md
pardeep-singh's picture
Deploy polished warm-library UI from main b09a3f3
eab7052 verified
|
Raw History Blame Contribute Delete
9 kB
# 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 <N>` (`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/<name>`, 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> @ worker/<worker> <short-sha>
Files changed: <list>
Provides: <what now works: endpoints / functions>
Assumptions: <any interpretation of this contract>
Test command + output: `uv run pytest -q`
<pasted output>
Gaps / blockers: <ambiguities, files needed but not owned, or "none">
```