Skip to content

Runnable components and the console root

Secondary components are subcommands of one console root, not separate scripts

Id: 3d60ab14-8ff9-47db-b326-d7e82947a6cf Type: decision Status: active Evidence: confirmed Source: ADR-019 (docs/adr/019-components-as-cli-subcommands.md), including its 2026-09 amendment; issue #203 Revisit when: a new kind of runnable component is added, or typer becomes unacceptable on the minimal launcher See: architecture-contracts.md#one-exhaustive-import-linter-layers-contract-cli-above-independent-components-above-core-above-utils — 3b7cdbee-7c66-4e52-9d7e-8ab0ffe32e56 — as of 2026-10-11 See: components.md#the-web-component-gets-run-and-dev-launch-verbs-and-therefore-always-a-console-root — 3daefc4a-ee36-44cb-b137-bc9224f2a262 — as of 2026-10-11

The highest-precedence enabled component (CLI > GUI > TUI > web > MCP > worker, spelled once as primary_component in copier.yml) owns the bare <pkg> command. Every other enabled component is a lazily imported subcommand (interactive, gui, web, mcp, worker); no <pkg>-<name> console script is emitted. A root exists only where it buys something, captured by the hidden include_console_root: with include_cli, with web, or with two or more components. With no root, bare <pkg> dispatches to the sole component from __main__. GUI uses [project.gui-scripts] only as the sole runnable component; a GUI primary sharing a root gets a normal console script.

Reason: a multi-component project installed four or five sibling executables, a separate script is invisible until you know its name, and the CLI already exposed interactive/gui/web subcommands beside their suffixed scripts while MCP and worker had none. A windowless launcher as the shared root would swallow console I/O and --help of the subcommands on Windows. One computed variable is the single source for the cli/ package, the typer dependency, the import-linter orchestrator layer and the __main__ branch, so typer stays off a single-component non-CLI app.

Rejected alternative: a universal root for every app — forces typer onto single-component non-CLI apps and dilutes what include_cli means.

Rejected alternative: keep suffixed scripts when no CLI is present — leaves the PATH and discoverability problem for non-CLI multi-component apps.

Consequence: the package directory is named cli/ even in a launcher-only project; adopters updating lose the suffixed scripts and switch wrappers to the subcommand form.

cli_framework offers argparse beside Typer; the Typer dependency is gated on root_uses_typer

Id: de9f8183-4328-4678-8611-5e715cffd70d Type: decision Status: active Evidence: confirmed Source: ADR-020 (docs/adr/020-cli-framework-choice.md); issue #205 Revisit when: demand appears for click as a third choice, or a no-CLI launcher needs to be framework-free See: components.md#secondary-components-are-subcommands-of-one-console-root-not-separate-scripts — 3d60ab14-8ff9-47db-b326-d7e82947a6cf — as of 2026-10-11 See: package-distribution.md#homebrew-and-scoop-are-opt-in-dispatches-the-tap-or-bucket-owns-the-manifest-logic — 462ac46c-c9db-4a4a-b8ae-bbe5c14af09f — as of 2026-10-11

A cli_framework sub-question (typer default, argparse), asked only with include_cli, mirrors web_framework. The argparse root is standard-library only and contract-equivalent: same version/info commands, same lazily imported component subcommands, help on no arguments, non-zero exit on missing metadata. A no-CLI launcher keeps the Typer root. The typer entry in dependencies and the cli/app.py branch both read one hidden root_uses_typer variable.

Reason: include_cli was hardwired to Typer, so a genuine command-line tool that wanted no framework had no supported path to is_app and therefore none to Homebrew/Scoop packaging; forcing toggles via --data did not persist. The dependency is gated on root_uses_typer rather than on the framework answer because Copier retains unasked answers: a project that set argparse, then turned the CLI off while keeping two components, carries the stored answer forward while rendering the Typer root — a launcher importing typer without declaring it, broken even in a synced environment. An earlier revision of the record claimed the simpler guard was correct in every case; it was not.

Rejected alternative: widen the is_app gate for libraries — a framework-free CLI is made a first-class way to be an app instead.

Rejected alternative: click as a third choice — a dependency without Typer's ergonomics; earns its keep only on later demand.

Consequence: the root now has two rendered variants (app and test module) to maintain, and dependencies may be empty for an argparse-only CLI, which is valid. The argparse root reproduces the version/info shape and the component subcommands for parity; a project wanting one no-subcommand action deletes what it does not need.

Launcher imports are guarded with exact-match, preflighted dependency errors

Id: 70e54068-d43c-40a2-9097-95e7cc14c668 Type: decision Status: active Evidence: confirmed Source: ADR-028 (docs/adr/028-actionable-component-dependency-guard.md); issues #172 and #268 Revisit when: generated projects gain per-component extras, or a dependency stops importing lazily after the guarded block See: components.md#secondary-components-are-subcommands-of-one-console-root-not-separate-scripts — 3d60ab14-8ff9-47db-b326-d7e82947a6cf — as of 2026-10-11 See: components.md#component-dependencies-are-core-dependencies-there-is-no-empty-all-extra — e9b25624-f621-4071-bd99-a7cde620189a — as of 2026-10-11

Each launcher's lazy import is wrapped in a context manager that turns a ModuleNotFoundError whose name is exactly one of the component's declared dependency modules into an exit-1 message naming the component, the module and the fix (uv sync). Dependencies that import late (uvicorn, Tkinter, textual) or re-export behind their own guard with no module name (faststream brokers, mcp's transitive imports) are imported eagerly inside the block by a preflight that re-raises with the caller-supplied name. Tkinter gets a system-package hint (including a version-qualified Homebrew formula) because it is not a distribution. The root's own module-scope imports (typer, pydantic via the settings stack) and the sole-component entrypoint in __main__.py get the same treatment; all derived facts are when: false computed variables in copier.yml. The guard is emitted only when the root actually lazy-imports something (derived from primary_component), so a CLI-only project renders byte-identical to before, and a pure-argparse root without settings renders without it (root_dependencies is computed from the enabled toggles).

Reason: there are no extras to name — component deps are core dependencies — so a missing module means the environment is out of sync with the metadata and uv sync is the honest remedy. The first-party import always succeeds after a copier update; the third-party failure happens one frame deeper, so the guard must classify what comes out of the import rather than replace it. Exact matching protects application defects (a typo'd import below an installed dependency) from being mislabelled as a stale environment. The sole-component entrypoint is what frozen binaries target, whose user has no CLI to fall back on. Shared computed variables matter most for the two guards rendered into different files, only one of which exists in any project.

Rejected alternative: a shared require(module, feature, extra) helper as the issue proposed — no extra exists, and replacing the first-party import would not catch the deeper failure.

Rejected alternative: guard every component's module scope so direct library imports also get the message — the reader is already in a traceback in their own code, the guard would run on every successful import forever, and component modules are what adopters edit most.

Rejected alternative: let faststream's own ImportError through — its advice (pip install "faststream[<broker>]") bypasses the lockfile.

Edge cases the guard keeps on purpose. A nameless ModuleNotFoundError propagates untouched, because the guard cannot tell whose failure it is. faststream.<broker> re-exports the broker client behind faststream's own import guard, which raises a plain ImportError with no module name and advises pip install "faststream[<broker>]", advice that bypasses the project's lockfile. So the worker allowlist names faststream.<broker> and not the client (aiokafka, aio_pika): faststream intercepts the client's own error, and a client entry would be dead. The Tk allowlist covers both tkinter and the _tkinter C extension, because on an interpreter built without tk-dev the pure-Python package still ships and _tkinter is the name that actually fails; the GUI hint also names python-tk@X.Y built from sys.version_info, since Homebrew's bare python-tk aliases the newest Python's formula. _preflight catches ImportError broadly, so a broken install is also reported as unavailable, which is the right summary at the launcher boundary. The error paths are unit-tested with no blanket pragma, driving the deferred and nameless cases through sys.meta_path finders so they reproduce on any runner.

Component dependencies are core dependencies; there is no empty all extra

Id: e9b25624-f621-4071-bd99-a7cde620189a Type: decision Type: constraint Status: active Evidence: confirmed Source: ADR-028 (docs/adr/028-no-empty-all-extra.md) Revisit when: a component gains a genuinely optional dependency

Generated projects declare no [project.optional-dependencies] table. A runnable component's runtime dependency goes into the core dependencies list under its toggle guard. The permanently empty all = [], the <pkg>[all] dev self-reference, six extras = ["all"] tox settings and four --extra all workflow flags were removed.

Reason: a component is chosen at generation time and __main__ imports it unconditionally, so a plain install must pull its dependency or the console script fails at launch. The leftover shell installed nothing yet three documents cited it as load-bearing — a reader auditing the packaging would have concluded the wrong thing about what guarantees what.

Rejected alternative: keep all = [] as a pre-wired seam for a future optional dependency — a one-time edit if ever needed is cheaper than a permanent misleading declaration.

Consequence: adopters applying this update must re-lock, because the metadata recorded in uv.lock changes and every uv run --locked hard-fails until they do. Renovate's copier manager re-renders but does not re-lock, and CI, the prek system hooks and the mise tasks all use uv run --locked, so the update PR stays red until uv lock is committed in it; a hand-added uv run --extra all breaks. The <pkg>[all] dev self-reference was dropped outright rather than reduced to <pkg>, since uv sync and tox already install the project. Adding a real optional extra is now an explicit act: declare it, add it to the dev group, and add extras only to the tox envs that need it, which is the right blast radius to reason about. Other docs had attributed dependency availability to the extra (one claimed dropping it would break automodule of the web module, another used it to justify eager broker imports); they were corrected to name the core dependencies.

The web component gets run and dev launch verbs, and therefore always a console root

Id: 3daefc4a-ee36-44cb-b137-bc9224f2a262 Type: decision Status: active Evidence: confirmed Source: ADR-032 (docs/adr/032-uniform-run-dev-launch-verbs.md); issue #177 Revisit when: FastStream's reload stops requiring its CLI extra, or a second component acquires a prod/dev distinction See: components.md#secondary-components-are-subcommands-of-one-console-root-not-separate-scripts — 3d60ab14-8ff9-47db-b326-d7e82947a6cf — as of 2026-10-11 See: components.md#launcher-imports-are-guarded-with-exact-match-preflighted-dependency-errors — 70e54068-d43c-40a2-9097-95e7cc14c668 — as of 2026-10-11

<pkg> run [module:attribute] starts the web app on the configured bind; <pkg> dev adds autoreload and forces a loopback bind. Both share one private runner and the dependency guard; <pkg> web stays as an alias where web is not primary. The target is passed to uvicorn as an import string, validated for shape only — no module search, no framework sniffing. include_console_root now includes include_web, so a web-only project reaches its component through the minimal launcher.

Reason: varying the app object or reload meant dropping to a different framework CLI per framework that the project's own help never mentioned. uvicorn is already a web runtime dependency and takes an import string with reload natively; the string form is the only one that can reload in a child process, so one code path serves both modes. A reloading server executes unreviewed code on every save and belongs on loopback. A web-only project had no root to hang a verb on, and standalone <pkg>-run/<pkg>-dev scripts are the second entry scheme the subcommand decision exists to prevent.

Rejected alternative: verbs for the worker too — reload needs faststream[cli] at runtime, which the template deliberately keeps out of dependencies, and type-sniffing the target is the framework detection avoided here. faststream run <pkg>.worker.app:app --reload stays the documented dev path for the worker and <pkg> worker stays the production one.

Rejected alternative: verbs for gui, tui or mcp — no meaningful prod/dev distinction and nothing to point a target at. Because only web owns the verbs, run is never ambiguous and needs no --component selector.

Rejected alternative: replace <pkg> web with <pkg> run — breaks the one naming rule the subcommand decision makes.

Rejected alternative: let uvicorn parse the target — its error names no project command and is not testable the way a cheap shape check is.

Consequence: web-only Litestar projects gain typer (FastAPI already ships it); sole_component can never be web, so a web project's executable entrypoint is the console root. The web preset's tree gains a cli/ package and a test-cli/coverage-cli CI job pair. The verbs and their runner are unit-tested with the launch stubbed, so only the uvicorn-blocking run_server carries a coverage pragma.

One application payload in core/app.py; components are adapters over it

Id: 8bb1e55e-f468-44fc-80a5-d3b3719e5ddc Type: decision Status: active Evidence: confirmed Source: ADR-033 (docs/adr/033-shared-app-service-components-as-adapters.md); issues #178 and #321 Revisit when: a component needs a fourth failure behaviour, or a second provider of the payload appears See: architecture-contracts.md#one-exhaustive-import-linter-layers-contract-cli-above-independent-components-above-core-above-utils — 3b7cdbee-7c66-4e52-9d7e-8ab0ffe32e56 — as of 2026-10-11 See: template-updates.md#retired-template-modules-become-adopter-owned — 4dd6729a-697e-4588-a7a7-d37152018b6b — as of 2026-10-11

core/app.py exposes three functions — version() and info(), which raise MetadataUnavailableError, and info_or_unknown(), which degrades, logs and never raises — plus the error type and an unknown-version constant. Each component imports the module as service and adapts: the CLI exits 1, the web app answers 503, the MCP tool returns error text, GUI and TUI show "unknown", the worker falls back to 0.0.0. The module renders only when a runnable component is enabled. One missing_metadata fixture patching core.app.Distribution replaces six per-module stubs.

Reason: a full project carried eight copies of the distribution lookup and six of the platform payload, with nothing enforcing agreement — GUI and TUI nearly drifted on field names and fatality. The import contract forbids components importing each other, so sharing had to live below them, and core is already that layer. Functions rather than a class because it would hold no state and ruff's no-self-use rule flags exactly that shape; no Protocol because it would have one implementation and no type-checking caller. Three functions because there are three real behaviours, each with more than one caller. Logging stays in the adapters because it genuinely differs; the worker's wire model keeps its own platform fields because routing them through a dict adds indirection for no sharing.

Rejected alternative: a new base/ package with an AppService class and a VersionProvider protocol, as proposed — each adds a name without adding a constraint.

Rejected alternative: a transport/presentation split per component — more files than the three f-strings they would hold.

Rejected alternative: keep the copies and add a conformance test — tells you they drifted after the sixth copy is written; removing them means they cannot.