# py-08-tiny-package — make three functions into something someone else can install

You have written `chunk`, `repair_json`, `retry` and `timed` three times across Days 2–5, each
time inside one exercise folder. This is the day they become a **package**: a `pyproject.toml`,
a `src/` layout, a console entry point, a `py.typed` marker, and a wheel that installs into a
machine that has never seen your repository. Every service you ship after this — flagship F1's
RAG service included — is this same shape with more code in it. The brief is the same text as
the page (`illustrated/0-python/exercise-tiny-package.html`).

This one runs on your Mac, not in the browser: it builds a real wheel and a real virtual
environment, which Pyodide cannot do.

## What to implement

`src/llmutils/chunking.py` — `chunk(text, size, overlap) -> list[list[str]]`: split on
whitespace, then into chunks of `size` tokens with `overlap` shared, so the stride is
`size - overlap` and `out[1][0] == out[0][size - overlap]`. The last chunk is kept even when
short; whitespace-only text gives `[]`; `size < 1`, `overlap < 0` and `overlap >= size` raise
`ValueError` (py-02's arithmetic).

`src/llmutils/jsonrepair.py` — `repair_json(s) -> object`: unwrap a ```` ```json ```` fence,
turn single quotes into double quotes (only when the text has no double quotes at all), drop a
comma before `}` or `]`, and close a truncated tail — string first, then the open arrays and
objects in reverse order. Valid JSON passes through. Anything still unparseable raises
`ValueError` (py-06).

`src/llmutils/decorators.py` — `retry(times, backoff, on, *, sleep)` calls the wrapped function
up to `times` times and re-raises the last error, waiting `backoff * 2 ** i` between attempts
and retrying only the types in `on`; `timed(sink, *, clock)` reports `sink(name, seconds)`
exactly once per call, even when the call raises. Both keep `__name__` and `__doc__`
(`functools.wraps`) (py-04).

`src/llmutils/cli.py` — `main(argv=None) -> int`: `llmutils --version` prints
`llmutils <version>` and returns 0; anything else prints a usage line on stderr and returns 2.

`src/llmutils/__init__.py` is **provided**: it re-exports the four functions and reads
`__version__` from `importlib.metadata` — the installed distribution's metadata, not a second
copy of the number.

And two things that are not code at all:

- **`src/llmutils/py.typed`** — an empty file you have to create. `pyproject.toml` already
  ships it (`[tool.setuptools.package-data]`); without the file, PEP 561 says every downstream
  `mypy` must ignore your annotations.
- **the `pyproject.toml`** — read it. The build backend, the `src/` layout, the entry point and
  the two tool sections are the artifact this exercise is really about.

## Run it

```
cd exercises/py-08-tiny-package && uv sync && uv run pytest -q -m ''
```

`uv sync` creates `.venv/` with pytest, build, ruff and mypy. Plain `uv run pytest -q` runs the
five fast checks; `-m ''` clears the `-m 'not slow'` in `addopts` and adds the three that build
a real wheel and a throwaway venv (about 15 s). The same bar the reference solution clears:

```
uv run ruff check .  &&  uv run mypy --strict src  &&  uv run python -m build --wheel
```

The untouched starter fails **all eight** — including `ruff` (the imports it hands you are
unused until you use them) and `mypy` (a `...` body is `[empty-body]` in strict mode).

## The checks (`tests/test_behaviour.py`, `tests/test_packaging.py`)

- `test_chunk_shares_overlap_and_keeps_the_last_partial_chunk` — 10 tokens at `size=4,
  overlap=1` give 3 chunks, `out[1][0] == out[0][3]`, and a 6-token text gives lengths
  `[4, 3]`; the three bad argument combinations raise `ValueError`.
- `test_repair_json_fixes_fences_quotes_commas_and_a_truncated_tail` — a fenced block, single
  quotes, `{"a": 1, "b": [2, 3,],}`, `{"a": [1, 2` and `{"a": "unfinis`; prose raises
  `ValueError`.
- `test_retry_re_raises_after_n_attempts_and_timed_reports_seconds` — exactly 3 attempts and
  waits `[0.1, 0.2]`; a `KeyError` with `on=(ValueError,)` is not retried; `timed` reports once
  and `wraps` keeps the name and the docstring.
- `test_mypy_strict_clean` — `mypy --strict src` exits 0.
- `test_ruff_clean` — `ruff check .` exits 0.
- `test_wheel_contains_py_typed` *(slow)* — the built wheel holds `llmutils/py.typed`.
- `test_wheel_builds_and_installs` *(slow)* — the wheel installs into an empty venv, imports
  from `site-packages` (not from your `src/`), and `chunk` works there.
- `test_entry_point_prints_version` *(slow)* — the venv grew an `llmutils` launcher and
  `llmutils --version` prints `llmutils 0.1.0`.

Done when `uv run pytest -q -m ''` prints `8 passed`, then press "mark done" on the page.

## If you get stuck

- **`chunk`** — validate first, then `for start in range(0, len(tokens), size - overlap)`, take
  `tokens[start : start + size]`, and stop as soon as a chunk reached the end.
- **`repair_json`** — do not write one clever regex. Generate candidate texts, cheapest repair
  first, and return the first one `json.loads` accepts; `json.JSONDecodeError` is already a
  `ValueError`, so re-raising as `ValueError` is consistent.
- **`retry`** — `except on:` accepts a tuple of types directly. The attempt loop must `return`
  inside the `try`, and re-raise on the last attempt rather than falling out.
- **mypy `--strict`** — annotate the decorator factories with `ParamSpec` and a `TypeVar`
  (`Callable[[Callable[P, R]], Callable[P, R]]`), or every decorated function becomes `Any` and
  strict mode complains about the *callers*.
- **the wheel** — `uv run python -m build --wheel` then `unzip -l dist/*.whl` shows exactly what
  someone else will get. If the listing has no `llmutils/` in it, the problem is in
  `pyproject.toml`, not in your code.
