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">
```