Skip to content

--formats: comma-separated CLI option validated against a format registry table (surface-only slice of #117) #192

Description

@alphacrack

User story

As an operator who will soon be asking for podcasts, promos and social cuts alongside the demo video, I want one --formats option whose values are checked against a registry of formats that actually exist, so that I learn "podcast isn't implemented yet" from a one-second CLI error instead of from a run that silently produces nothing.

What

Build only the selection surface described in #117: the flag, the registry table, the validation, and where the answer is stored. No format is produced or skipped differently as a result.

In scope:

  1. New src/readme2demo/formats.py — a small table in the shape of llm.PROVIDERS (llm.py:94-140):
  2. Config.formats: list[str] defaulting to ["demo", "gif"] — today's exact output set, so nothing changes for existing users — with a pydantic v2 field_validator running the same registry check so a bad formats = [...] in readme2demo.toml is rejected too.
  3. --formats on run — a str option parsed with parse_formats, converted to typer.BadParameter on failure so the error is attributed to the flag and not to a TOML file (see Pointers, item 5), then threaded through _build_config. Left as None when unpassed, so Config.load's None-filter keeps the TOML value.
  4. One echo line so the flag is not entirely inert: print the resolved format list next to the other [dim] startup notices in run, rich.markup.escape()-d like every other dynamic console string (failure class 15).
  5. Docs: one row in the docs/usage.md flag table (after --skip-video, docs/usage.md:38) and formats = ["demo", "gif"] in the two toml examples (README.md:154, docs/configuration.md:21).

Explicitly out of scope — do not do these here, they are the rest of #117:

  • Any produce(run_dir, manifest, cfg) hook, dependency graph between formats, or optional-extra requirements.
  • Any change to render.py, orchestrator._stage_render, or manifest.py. cfg.formats must have zero effect on what the pipeline renders in this PR.
  • Reconciling --formats with --skip-video (e.g. deciding what --formats demo --skip-video means), and the --skip-video deprecation path. skip_video stays the sole render switch until the wiring slice lands.
  • Changing the artifact list printed at cli.py:630-633.
  • Adding --formats to resume (resume re-runs stages, it does not re-select formats).

Why

#117 (milestone v0.8 — Multi-format outputs, labeled tech-debt) argues the registry should land before more than two new formats do, so the CLI does not accumulate one boolean per format. Four format issues are already open behind it — #111 (--podcast), #114/#169/#170/#171 (promo video), #115 (--narrate), #116 (--social-cut) — and each currently implies its own flag. Landing the surface first means those PRs add a FormatSpec row and flip implemented=True instead of touching cli.py's signature again.

Splitting it this way also keeps #117 itself from being an all-or-nothing refactor: this slice is pure Python with no Docker, no API key, and no grounding-path code.

Grounding implication: none, deliberately. Nothing here reads or writes tutorial.md, step_by_step.md, commands.sh, or demo.tape, and nothing touches distill.py / tutorial.py / normalize.py / engines/ / prompts/ / templates/. Worth stating for the reviewer of the next slice: when produce() hooks arrive, a format must consume already-verified artifacts and must never become a second path by which unverified content reaches a published file.

Pointers

All verified by reading the files at main (64a3cbd).

  1. src/readme2demo/render.py:29-33 — the only formats that exist today:
    PRIMARY_ARTIFACT = "demo.mp4"
    GIF_PREVIEW = "demo.gif"
    and render.validate_outputs (render.py:262-266) returns [mp4] plus the gif only when it exists and is big enough, with the comment # preview is best-effort, never required. That best-effort contract is the one output-format registry: one --formats surface for demo/gif/podcast/promo/social #117 wants generalized — nothing to change here now.
  2. src/readme2demo/config.py:54skip_video: bool = False, the current per-format switch. Add formats near it, in the "Stages" block.
  3. src/readme2demo/config.py:22model_config = ConfigDict(extra="forbid"), so the new field must be declared on Config or the toml key is rejected outright. config.py:64-73 shows the existing model_validator(mode="before") if you want a validator precedent.
  4. src/readme2demo/config.py:89data.update({k: v for k, v in overrides.items() if v is not None}). This is why the CLI option must stay Optional[str] = None when unpassed: an unpassed flag must not clobber a toml formats.
  5. src/readme2demo/cli.py:136-153_load_config catches ValidationError and prints f"Invalid configuration in {source}..." where source = config_file or Path("readme2demo.toml") (cli.py:142). Today no run flag can reach a pydantic validator, so this misattribution is harmless; a bad --formats value would be the first to hit it and would be reported as an error in a toml file the user may not even have. Parse and raise typer.BadParameter in the command body before _build_config, and let the Config validator cover only the toml path.
  6. src/readme2demo/cli.py:294-372 (the run signature), cli.py:312skip_video: Optional[bool] = typer.Option(None, "--skip-video/--with-video") — put --formats next to it. cli.py:110-133 is _build_config, whose parameter list and _load_config(...) kwargs both need the new field; the call site is cli.py:382-385.
  7. src/readme2demo/llm.py:94-140ProviderSpec + PROVIDERS, the frozen-dataclass-plus-dict table to copy. Note ProviderSpec carries per-entry documentation fields (models_url, default_model) used purely to build good error messages; FormatSpec should do the same with its tracking-issue reference.
  8. src/readme2demo/engines/base.py:98-116 — the alternative decorator-registry style (_REGISTRY, @register, get_engine raising f"Unknown engine {name!r}. Available: {sorted(_REGISTRY)}"). Prefer the PROVIDERS-style static table here: formats need to be enumerable for --help and for the "not implemented yet" message before any implementation module exists, which a decorator registry populated by imports cannot do. (engines: registry is populated by a hardcoded import list — third-party engines can't register #61 tracks making the engine registry open to third parties; that argument does not apply to a five-row table of first-party outputs.)
  9. src/readme2demo/orchestrator.py:266-268_stage_render starts with if self.cfg.skip_video: self.manifest.stage_skip("render", reason="--skip-video"). Shown so you can see exactly where the wiring slice will eventually read cfg.formats. Do not modify it in this PR.
  10. src/readme2demo/manifest.py:21STAGES = [...] is unchanged by this issue; formats are not stages.

Acceptance criteria

  • src/readme2demo/formats.py exists with FormatSpec, FORMATS (demo, gif implemented; podcast, promo, social not), and a pure parse_formats. Public functions carry type hints and docstrings; the module imports nothing from config.py or cli.py (no cycles).
  • Config.formats defaults to ["demo", "gif"] and a field_validator rejects unknown and unimplemented names.
  • readme2demo run <url> --formats demo,gif is accepted; --formats "demo, gif" and --formats DEMO,demo,gif normalize to ["demo", "gif"].
  • --formats podcast exits non-zero with a message containing "not implemented yet" and the tracking issue number — not "unknown format".
  • --formats banana exits non-zero with a message listing the implemented format names.
  • A bad --formats value is reported as a bad option, never as Invalid configuration in readme2demo.toml (regression guard for Pointer 5).
  • formats = ["banana"] in a toml is rejected by Config.load; formats = ["demo"] loads.
  • Omitting --formats does not clobber a toml formats value.
  • The resolved format list is echoed once at startup and passed through rich.markup.escape().
  • cfg.formats is read nowhere outside cli.py / config.py / formats.pygit grep -n "\.formats" src/ shows no hits in orchestrator.py, render.py, distill.py, tutorial.py, or normalize.py.
  • Tests added to tests/test_config.py (field default, validator, toml round-trip, None-does-not-clobber) and tests/test_cli.py (accepted value, unknown name, unimplemented name, misattribution guard), each carrying a """Regression: ...""" docstring where it locks in a specific failure.
  • docs/usage.md flag table, README.md toml example, and docs/configuration.md toml example updated.
  • Full suite green; ruff clean.

Notes for contributors

Good first issue, honestly: pure Python, no Docker, no API key, no network, no LLM call. Nothing here runs a container or spends money — you can develop and test the whole change offline.

pip install -e ".[dev]"
python -m pytest tests/ -q     # ~1.5s, no docker/network/API needed

Prerequisites are typer/click option basics and pydantic v2 field_validator (the repo is pydantic v2 throughout — field_validator, not the v1 validator). Read llm.py:94-140 first; the table you are writing is a smaller version of it.

Two traps worth naming up front:

  • Where validation lives. It has to happen twice, in two idioms: typer.BadParameter for the flag, a pydantic validator for the toml. Share the logic via parse_formats so the two messages cannot drift.
  • The temptation to wire it up. Making --formats demo actually skip the gif is a two-line change in _stage_render and it is explicitly not wanted here — it changes pipeline behavior, needs the --skip-video reconciliation this slice defers, and would make the PR much harder to review. Leave the plumbing for the output-format registry: one --formats surface for demo/gif/podcast/promo/social #117 follow-up.

--formats producing no behavior change is the point of the slice, not an oversight: it lets #111/#114/#115/#116 land their formats as one table row plus a flag flip instead of another CLI signature change each.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:clicli.py, config.py, UXenhancementNew feature or improvementgood first issueSmall, self-contained, newcomer-friendlymulti-formatNew output formats (podcast/video/social) derived from a verified run

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions