Spaces:
Sleeping
Sleeping
File size: 9,000 Bytes
06b349d af51a32 eab7052 06b349d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 | # 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">
```
|