You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
New src/readme2demo/formats.py — a small table in the shape of llm.PROVIDERS (llm.py:94-140):
a frozen FormatSpec dataclass with at minimum name, implemented: bool, and a short description (used in error/help text);
a pure parse_formats(value: str) -> list[str] that splits on commas, strips whitespace, lowercases, drops empty segments, de-duplicates while preserving order, and raises a ValueError subclass for an unknown name (listing the known ones) or for a declared-but-unimplemented name (a distinct "not implemented yet — tracked in #NNN" message).
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.
--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.
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).
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).
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.
src/readme2demo/config.py:54 — skip_video: bool = False, the current per-format switch. Add formats near it, in the "Stages" block.
src/readme2demo/config.py:22 — model_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.
src/readme2demo/config.py:89 — data.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.
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.
src/readme2demo/cli.py:294-372 (the run signature), cli.py:312 — skip_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.
src/readme2demo/llm.py:94-140 — ProviderSpec + 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.
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.)
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.
src/readme2demo/manifest.py:21 — STAGES = [...] 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.py — git 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.
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.
User story
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:
src/readme2demo/formats.py— a small table in the shape ofllm.PROVIDERS(llm.py:94-140):FormatSpecdataclass with at minimumname,implemented: bool, and a shortdescription(used in error/help text);FORMATS: dict[str, FormatSpec]containingdemo(demo.mp4) andgif(demo.gif) asimplemented=True, andpodcast,promo,socialasimplemented=Falseplaceholders declared by the v0.8 milestone issues (--podcast: audio walkthrough generated from the verified run (epic) #111, --promo-video: cinematic promo cut distinct from the terminal demo (epic) #114, --social-cut: vertical 9:16 short + thumbnail frame from the existing demo (ffmpeg only) #116) but shipping nothing today;parse_formats(value: str) -> list[str]that splits on commas, strips whitespace, lowercases, drops empty segments, de-duplicates while preserving order, and raises aValueErrorsubclass for an unknown name (listing the known ones) or for a declared-but-unimplemented name (a distinct "not implemented yet — tracked in #NNN" message).Config.formats: list[str]defaulting to["demo", "gif"]— today's exact output set, so nothing changes for existing users — with a pydantic v2field_validatorrunning the same registry check so a badformats = [...]inreadme2demo.tomlis rejected too.--formatsonrun— astroption parsed withparse_formats, converted totyper.BadParameteron 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 asNonewhen unpassed, soConfig.load's None-filter keeps the TOML value.[dim]startup notices inrun,rich.markup.escape()-d like every other dynamic console string (failure class 15).docs/usage.mdflag table (after--skip-video,docs/usage.md:38) andformats = ["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:
produce(run_dir, manifest, cfg)hook, dependency graph between formats, or optional-extra requirements.render.py,orchestrator._stage_render, ormanifest.py.cfg.formatsmust have zero effect on what the pipeline renders in this PR.--formatswith--skip-video(e.g. deciding what--formats demo --skip-videomeans), and the--skip-videodeprecation path.skip_videostays the sole render switch until the wiring slice lands.cli.py:630-633.--formatstoresume(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 aFormatSpecrow and flipimplemented=Trueinstead of touchingcli.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, ordemo.tape, and nothing touchesdistill.py/tutorial.py/normalize.py/engines//prompts//templates/. Worth stating for the reviewer of the next slice: whenproduce()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).src/readme2demo/render.py:29-33— the only formats that exist today: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.src/readme2demo/config.py:54—skip_video: bool = False, the current per-format switch. Addformatsnear it, in the "Stages" block.src/readme2demo/config.py:22—model_config = ConfigDict(extra="forbid"), so the new field must be declared onConfigor the toml key is rejected outright.config.py:64-73shows the existingmodel_validator(mode="before")if you want a validator precedent.src/readme2demo/config.py:89—data.update({k: v for k, v in overrides.items() if v is not None}). This is why the CLI option must stayOptional[str] = Nonewhen unpassed: an unpassed flag must not clobber a tomlformats.src/readme2demo/cli.py:136-153—_load_configcatchesValidationErrorand printsf"Invalid configuration in {source}..."wheresource = config_file or Path("readme2demo.toml")(cli.py:142). Today norunflag can reach a pydantic validator, so this misattribution is harmless; a bad--formatsvalue 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 raisetyper.BadParameterin the command body before_build_config, and let theConfigvalidator cover only the toml path.src/readme2demo/cli.py:294-372(therunsignature),cli.py:312—skip_video: Optional[bool] = typer.Option(None, "--skip-video/--with-video")— put--formatsnext to it.cli.py:110-133is_build_config, whose parameter list and_load_config(...)kwargs both need the new field; the call site iscli.py:382-385.src/readme2demo/llm.py:94-140—ProviderSpec+PROVIDERS, the frozen-dataclass-plus-dict table to copy. NoteProviderSpeccarries per-entry documentation fields (models_url,default_model) used purely to build good error messages;FormatSpecshould do the same with its tracking-issue reference.src/readme2demo/engines/base.py:98-116— the alternative decorator-registry style (_REGISTRY,@register,get_engineraisingf"Unknown engine {name!r}. Available: {sorted(_REGISTRY)}"). Prefer thePROVIDERS-style static table here: formats need to be enumerable for--helpand 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.)src/readme2demo/orchestrator.py:266-268—_stage_renderstarts withif self.cfg.skip_video: self.manifest.stage_skip("render", reason="--skip-video"). Shown so you can see exactly where the wiring slice will eventually readcfg.formats. Do not modify it in this PR.src/readme2demo/manifest.py:21—STAGES = [...]is unchanged by this issue; formats are not stages.Acceptance criteria
src/readme2demo/formats.pyexists withFormatSpec,FORMATS(demo,gifimplemented;podcast,promo,socialnot), and a pureparse_formats. Public functions carry type hints and docstrings; the module imports nothing fromconfig.pyorcli.py(no cycles).Config.formatsdefaults to["demo", "gif"]and afield_validatorrejects unknown and unimplemented names.readme2demo run <url> --formats demo,gifis accepted;--formats "demo, gif"and--formats DEMO,demo,gifnormalize to["demo", "gif"].--formats podcastexits non-zero with a message containing "not implemented yet" and the tracking issue number — not "unknown format".--formats bananaexits non-zero with a message listing the implemented format names.--formatsvalue is reported as a bad option, never asInvalid configuration in readme2demo.toml(regression guard for Pointer 5).formats = ["banana"]in a toml is rejected byConfig.load;formats = ["demo"]loads.--formatsdoes not clobber a tomlformatsvalue.rich.markup.escape().cfg.formatsis read nowhere outsidecli.py/config.py/formats.py—git grep -n "\.formats" src/shows no hits inorchestrator.py,render.py,distill.py,tutorial.py, ornormalize.py.tests/test_config.py(field default, validator, toml round-trip, None-does-not-clobber) andtests/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.mdflag table,README.mdtoml example, anddocs/configuration.mdtoml example updated.ruffclean.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.
Prerequisites are typer/click option basics and pydantic v2
field_validator(the repo is pydantic v2 throughout —field_validator, not the v1validator). Readllm.py:94-140first; the table you are writing is a smaller version of it.Two traps worth naming up front:
typer.BadParameterfor the flag, a pydantic validator for the toml. Share the logic viaparse_formatsso the two messages cannot drift.--formats demoactually skip the gif is a two-line change in_stage_renderand it is explicitly not wanted here — it changes pipeline behavior, needs the--skip-videoreconciliation 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.--formatsproducing 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.