Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ the version and stamps it with the date.
are *generated* from the code by `scripts/gen_reference_appendices.py`: every
configuration section and field, every scene key, every overlay parameter, the
overlay against display-mode matrix, every generator and effect, every
live-tune target, every command-line flag and every packaged example. They
live-tune target, every command-line flag, every packaged example and every
optional install extra. They
read the same definitions that answer `--describe`, `--compat` and
`--print-schema`, so a table in the book cannot disagree with the program.
`make reference` renders it, `make books` renders every book, and `make
Expand Down Expand Up @@ -170,6 +171,20 @@ the version and stamps it with the date.
catalogue entries state what a *configuration* needs — the keys, the extra,
the ports — and defer the mechanism to Chapter 4, which is the rule the
introduction sets and was the one place the book broke it.
- **Every optional extra is listed in one place**, as the reference guide's new
Appendix I: what each one unlocks, the module `--doctor` looks for, and the
packages it installs — with the reason the install to ask for is
`c64cast[all]` rather than one extra at a time. The chapters have always named
an extra where a feature needs one; nothing collected them. The glossary moves
to Appendix J.

### Fixed

- `--doctor` never reported the `wled` extra, so a missing `zeroconf` — the one
thing standing between `[wled].listen` and a WLED app that can discover the
virtual device — showed up as silence in the one command whose job is to say
what is missing. All twelve extras are now probed, and a test holds the list
to the extras the package actually declares.

## [0.1.0] - 2026-07-30

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ directory of numbered Markdown plus a `book.toml` that
[scripts/build_book.py](scripts/build_book.py) renders through the one shared
[template](docs/shared/template.typ): [docs/guide/](docs/guide/README.md) (the
User's Guide, read in order), [docs/reference/](docs/reference/README.md) (the
Programmer's Reference Guide — seven chapters over nine appendices, of which A–H
Programmer's Reference Guide — seven chapters over ten appendices, of which A–I
are generated by [scripts/gen_reference_appendices.py](scripts/gen_reference_appendices.py)
and regenerated with `make reference-appendices`), and
[docs/card/](docs/card/README.md) (the two-page Performance Card, whose live-target
Expand Down
6 changes: 3 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ help:
@echo " card render docs/card/*.md to the Performance Card PDF (needs typst)"
@echo " books render every book"
@echo " guide-figures redraw the guide's placeholder figures"
@echo " reference-appendices regenerate the reference guide's appendices A-H"
@echo " reference-appendices regenerate the reference guide's appendices A-I"
@echo " check lint + typecheck + test"
@echo " clean remove build artifacts"

Expand Down Expand Up @@ -149,14 +149,14 @@ card: $(SYNC)

books: guide reference card

# Rewrite the Programmer's Reference Guide's generated appendices (A-H) and the
# Rewrite the Programmer's Reference Guide's generated appendices (A-I) and the
# performance card's live-target table from the config metadata. Unlike the
# books themselves this needs the project env, since it imports c64cast — which
# is exactly why it is a separate script from build_book.py, and why its output
# is committed: the release renders the PDFs with `uv run --no-project`.
# tests/test_reference_appendices.py fails if the committed files drift from
# this output, so run it after changing any config field, overlay, generator,
# effect, CLI flag or example config.
# effect, CLI flag, example config or install extra.
reference-appendices: $(SYNC)
$(PY) scripts/gen_reference_appendices.py

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ the portable route.
**the Programmer's Reference Guide**: the volume you open at the page you
need. The rules of the configuration language, every scene and overlay, the
display and sound paths in full, what lands in the Commodore's memory, and
nine appendices generated from the code. Typeset PDF on
ten appendices, nine of them generated from the code. Typeset PDF on
[the latest release](https://github.com/kfox/c64cast/releases/latest).
* [docs/card/](https://github.com/kfox/c64cast/tree/main/docs/card) —
**the Performance Card**: two printable pages of controls, live targets and
Expand Down
1 change: 1 addition & 0 deletions c64cast/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ class Diagnostic:
("tr", "serial", "TeensyROM serial backend"),
("wizard", "questionary", "--init config wizard"),
("yt", "yt_dlp", "cast URL playback (YouTube et al.)"),
("wled", "zeroconf", "[wled].listen virtual WLED device"),
)

# Hard dependencies (top-level module, what uses it). These are declared in
Expand Down
9 changes: 7 additions & 2 deletions docs/reference/01-introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,18 @@ Chapter 7 is the exception, and is for a different reader: it is what you need
to add a scene, an overlay, a generator or an effect to c64cast itself. Nothing
in the first six chapters depends on it.

Appendices A to H are not prose and are not written by hand. They are generated
Appendices A to I are not prose and are not written by hand. They are generated
from the same definitions the program answers `--describe`, `--compat` and
`--print-schema` from, by `scripts/gen_reference_appendices.py`, and they are
regenerated as part of the build. A table in this book cannot disagree with the
program it documents; if it ever does, the build is broken and says so.

Appendix I is a glossary, which is hand-written because a machine has no
The chapters name an optional install extra wherever a feature needs one —
`video` for a video scene, `midi` for a MIDI one, `wled` for the virtual WLED
device. Appendix I is the whole list, with what each extra unlocks and what it
installs.

Appendix J is a glossary, which is hand-written because a machine has no
opinion about which words a reader will not know.

## What Is Not
Expand Down
31 changes: 31 additions & 0 deletions docs/reference/28-appendix-i-extras.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
number: I
generated: true
---

# Optional Extras

*Generated from the code by `scripts/gen_reference_appendices.py`.
Edits here are overwritten; run `make reference-appendices`.*

The 12 groups of dependency that a plain install leaves out, what each one unlocks, the module `c64cast --doctor` imports to tell you it is there, and the packages it brings with it.

## The Extras

Extras do not accumulate. Installing `c64cast[midi]` over `c64cast[video]` leaves you with MIDI and no video, so the install worth asking for is `c64cast[all]` — or `uv sync --all-extras` from a checkout. `c64cast --doctor` says which of these are importable and which are missing.

<!-- table: fields -->
| Extra | Description |
|---|---|
| **`camera`**<br>`cv2_enumerate_cameras` | [video].device by name/VID:PID; `--list-devices` detail. `cv2-enumerate-cameras>=1.3.3,<2`. |
| **`control`**<br>`fastapi` | [control] enabled HTTP plane. `fastapi>=0.140.0,<1`, `uvicorn>=0.51.0,<1`. |
| **`logging`**<br>`rich` | colored log output. `rich>=15.0.0,<16`. |
| **`mic`**<br>`sounddevice` | [audio] enabled, mic capture. `sounddevice>=0.5.5,<0.6`. |
| **`midi`**<br>`mido` | midi scenes; [midi_control] live control. `mido>=1.3.3,<2`, `python-rtmidi>=1.5.8,<2`. |
| **`obs`**<br>`obsws_python` | obs_status overlay. `obsws-python>=1.8.0,<2`. |
| **`tr`**<br>`serial` | TeensyROM serial backend. `pyserial>=3.5,<4`. |
| **`video`**<br>`av` | video scenes, video interleaving. `av>=18.0.0,<19`. |
| **`vision`**<br>`mediapipe` | [vision] enabled gesture control. `mediapipe>=0.10.35,<0.11`. |
| **`wizard`**<br>`questionary` | `--init` config wizard. `questionary>=2.1.1,<3`. |
| **`wled`**<br>`zeroconf` | [wled].listen virtual WLED device. `zeroconf>=0.150.0,<1`, `fastapi>=0.140.0,<1`, `uvicorn>=0.51.0,<1`, `websockets>=16.1.1,<18`. |
| **`yt`**<br>`yt_dlp` | cast URL playback (YouTube et al.). `yt-dlp>=2026.7.4`. |
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
number: I
number: J
---

# Glossary
Expand Down
7 changes: 4 additions & 3 deletions docs/reference/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ make books # every book at once

## The generated appendices

Appendices A to H are **not written by hand**. They are read out of the same
Appendices A to I are **not written by hand**. They are read out of the same
definitions that answer `--describe`, `--compat` and `--print-schema`, by
[`scripts/gen_reference_appendices.py`](../../scripts/gen_reference_appendices.py):

Expand All @@ -42,14 +42,15 @@ definitions that answer `--describe`, `--compat` and `--print-schema`, by
| F — Live-tune targets | `introspect.live_targets()` |
| G — Command-line flags | the argparse parser in `cli.py` |
| H — Example configurations | the packaged `examples/`, read through `paths` |
| I — Optional extras | `doctor._EXTRAS` joined with `pyproject.toml` |

The same pass writes the [Performance Card](../card/README.md)'s live-target
table, which is the card's most drift-prone page.

They carry `generated: true` in their front matter and are committed, so the
release can render the PDFs without resolving the project environment. After
changing a config field, an overlay, a generator, an effect, a CLI flag or an
example config:
changing a config field, an overlay, a generator, an effect, a CLI flag, an
example config or an install extra:

```bash
make reference-appendices
Expand Down
54 changes: 52 additions & 2 deletions scripts/gen_reference_appendices.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
make reference-appendices # rewrite them
make reference-appendices && git diff --exit-code # the drift guard

Appendices A-H are the exhaustive tables -- every config field, every scene
Appendices A-I are the exhaustive tables -- every config field, every scene
key, every overlay parameter, every CLI flag. Written by hand they would be
wrong within a release, so they are read out of the same model that already
answers ``--describe``, ``--compat`` and ``--print-schema``:
Expand Down Expand Up @@ -35,11 +35,12 @@
import argparse
import re
import sys
import tomllib
from collections.abc import Callable, Iterable, Sequence
from pathlib import Path

from c64cast import cli as climod
from c64cast import effects, generators, introspect
from c64cast import doctor, effects, generators, introspect
from c64cast import paths as pathsmod

REPO_ROOT = Path(__file__).resolve().parent.parent
Expand Down Expand Up @@ -566,6 +567,54 @@ def appendix_examples() -> list[str]:
return out


# ---------------------------------------------------------------------------
# Appendix I -- optional install extras
# ---------------------------------------------------------------------------


def _extra_requirements() -> dict[str, list[str]]:
"""`[project.optional-dependencies]`, read out of pyproject.toml.

Not out of installed metadata: a checkout that was never installed still
has to be able to build the book, and `importlib.metadata` would answer for
whatever version happens to be on the machine rather than for this tree.
"""
with (REPO_ROOT / "pyproject.toml").open("rb") as f:
return tomllib.load(f)["project"]["optional-dependencies"]


def appendix_extras() -> list[str]:
requirements = _extra_requirements()
# `doctor._EXTRAS` already pairs each extra with the module that has to
# import and a line on what it buys -- it is what `--doctor` probes with,
# so an appendix built from it says what the program says.
# tests/test_packaging_metadata.py holds it to the pyproject key set.
extras = sorted(doctor._EXTRAS)
out = front_matter(
"I",
"Optional Extras",
f"The {len(extras)} groups of dependency that a plain install leaves out, what "
"each one unlocks, the module `c64cast --doctor` imports to tell you it is "
"there, and the packages it brings with it.",
)
out += ["## The Extras", ""]
out += [
prose(
"Extras do not accumulate. Installing `c64cast[midi]` over `c64cast[video]` "
"leaves you with MIDI and no video, so the install worth asking for is "
"`c64cast[all]` — or `uv sync --all-extras` from a checkout. `c64cast "
"--doctor` says which of these are importable and which are missing."
),
"",
]
rows = []
for name, module, used_for in extras:
installs = ", ".join(code(req) for req in requirements[name])
rows.append([identity(code(name), code(module)), f"{cell(used_for)}. {installs}."])
out += fields_table("Extra", rows)
return out


# ---------------------------------------------------------------------------
# The performance card's live-target table
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -623,6 +672,7 @@ def card_live_targets() -> list[str]:
REFERENCE_DIR / "25-appendix-f-live-targets.md": appendix_live_targets,
REFERENCE_DIR / "26-appendix-g-cli-flags.md": appendix_cli,
REFERENCE_DIR / "27-appendix-h-examples.md": appendix_examples,
REFERENCE_DIR / "28-appendix-i-extras.md": appendix_extras,
CARD_DIR / "02-live-targets.md": card_live_targets,
}

Expand Down
19 changes: 19 additions & 0 deletions tests/test_packaging_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
4. Same for the packaged examples + JSON schema: they live under the package
only so the wheel can carry them, and a data file no `package-data` glob
matches is missing for every installed user while a checkout looks fine.
5. `doctor._EXTRAS` is the table `--doctor` probes and Appendix I is printed
from. An extra missing from it is never probed and never documented —
which is exactly what happened to `wled`, silently, for as long as the two
lists were kept in sync by hand alone.
"""

from __future__ import annotations
Expand Down Expand Up @@ -125,6 +129,21 @@ def test_every_requirement_has_a_ceiling(self) -> None:
)


class TestExtrasAreProbedAndDocumented(unittest.TestCase):
def test_doctor_knows_every_extra(self) -> None:
from c64cast.doctor import _EXTRAS

declared = set(_load()["project"]["optional-dependencies"]) - {"all"}
known = {name for name, _module, _used_for in _EXTRAS}
self.assertEqual(
known,
declared,
"doctor._EXTRAS drifted from [project.optional-dependencies].\n"
f" declared but never probed: {sorted(declared - known) or 'none'}\n"
f" probed but not declared: {sorted(known - declared) or 'none'}",
)


class TestPublishedFiles(unittest.TestCase):
def test_readme_key_points_at_a_real_file(self) -> None:
readme = _load()["project"].get("readme")
Expand Down
4 changes: 2 additions & 2 deletions tests/test_reference_appendices.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,12 @@ def test_every_generated_file_says_so(self):
self.assertEqual(fields.get("generated"), "true")

def test_the_appendices_cover_the_reference_book(self):
# A-H are generated; the introduction, the six chapters and the
# A-I are generated; the introduction, the seven chapters and the
# glossary are not. If a hand-written chapter ever acquires the marker,
# the next `make reference-appendices` would not touch it and the drift
# guard above would silently pass on a file nobody generates.
generated = {p for p in gen.APPENDICES if p.parent == gen.REFERENCE_DIR}
self.assertEqual(len(generated), 8)
self.assertEqual(len(generated), 9)
for path in bb.discover_chapters(gen.REFERENCE_DIR):
fields, _, _ = bb.parse_front_matter(path.read_text(encoding="utf-8"), path)
with self.subTest(chapter=path.name):
Expand Down