Spaces:
Sleeping
Download docs/contract.md from pardeep-singh/reading-list: direct link, hf CLI and curl.
- Browser
- Download file 9 kB
-
https://huggingface.co/spaces/pardeep-singh/reading-list/resolve/main/docs/contract.md
- Command line
-
hf download hf://spaces/pardeep-singh/reading-list/docs/contract.md
-
curl -L -o contract.md https://huggingface.co/spaces/pardeep-singh/reading-list/resolve/main/docs/contract.md
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 byhttpx2). Fixtureclientintests/conftest.pygives 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-slimbase.
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)
{"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 ornull.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)
{"status": "done"}
status is required and must be a Status value.
BookStats
{"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:
{"title": " Dune ", "author": "Frank Herbert", "status": "reading"}
Response 201:
{"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.
[{"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:
{"status": "done"}
Response 200: the updated Book.
{"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):
{"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:
{"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, defaultto-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.pyis black-box (HTTP only, via theclientfixture) 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.mdmust 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 startsuvicorn app.main:app --host 0.0.0.0 --port 7860.scripts/smoke.shtakesBASE_URL(defaulthttp://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. Runuv syncfirst. - 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">