Typed arguments
runspec stubs makes your editor and type checker understand each runnable's
arguments. After running it, args = parse("deploy") is precisely typed at the
call site — autocomplete, hover types, and typo-catching — with no annotation
and no cast:
from runspec import parse
args = parse("deploy")
args.workers.value # int
args.env.value # Literal["dev", "prod"]
args.wrokers # error: "_DeployArgs" has no attribute "wrokers"; maybe "workers"?
The type is keyed off the literal name you pass to parse(). A typo — or an arg
you haven't regenerated for yet — is a hard error, not a silent Any.
TL;DR
runspec stubs # generate types from the installed runnables
runspec stubs --check # CI / pre-commit: fail if missing or stale
runspec stubs --off # remove the stub, back to plain Any
It writes one file: typings/runspec/__init__.pyi at your project root.
pyright / Pylance (VS Code) read typings/ automatically — zero config. For
mypy, add one line (see Editor setup).
What you get
Without the stub, a parsed arg is typed Any — it works, but the checker can't
help you:
args = parse("deploy")
args.workers.value # Any — no autocomplete, no checking
args.wrokers.value # Any — typo sails through
After runspec stubs, every runnable has a precise type:
In runspec.toml |
args.<name>.value becomes |
|---|---|
workers = { default = 4 } |
int |
name = { type = "str" } |
str |
out = { type = "path" } |
Path |
format = { options = ["json", "yaml"] } |
Literal["json", "yaml"] |
tags = { type = "str", multiple = true } |
list[str] |
The runnable's metadata surface still works too — args.runspec_agent,
args.get("maybe").value, "force" in args. Subcommand args are all available
(the view is the superset across the command tree); read presence-dependent ones
with args.get(...).
The everyday workflow
This is the loop you'll repeat. If you scaffolded with runspec init, steps 1
and the editor config are already done for you.
-
Scaffold (once).
runspec initdrops a placeholder stub, so your editor immediately says runrunspec stubsinstead of silently typing everythingAny. With--write-projectit also wires the mypy line and a pre-commit hook. -
Install your package. Discovery only sees installed runnables:
pip install -e . # or: uv sync / poetry install
- Generate. The first
runspec localafter install auto-upgrades the placeholder to real types, or run it explicitly:
runspec stubs
- Edit a
runspec.toml— add/rename/retype an arg. The stub is now stale. Your editor flags any new arg you reference (no attribute), andrunspec localprints a nudge:
ℹ typings/runspec/__init__.pyi is out of date — run 'runspec stubs' to refresh.
- Regenerate and commit. Run
runspec stubsagain, then commit the updatedtypings/. If you wired the pre-commit hook, it regenerates for you and the commit fails until you re-stage — so a stale stub can't slip through.
The mental model: the stub is generated from your runspec.toml, like a lock
file or any other codegen. Schema changed → regenerate. The reflex is the same as
re-running poetry install after editing dependencies.
Editor setup
Nothing to configure — pyright reads ./typings by default. Reload the
window after the first generation if types don't appear immediately.
Point mypy at the directory (once):
[tool.mypy]
mypy_path = "typings"
runspec init --write-project adds this for you.
Keeping it fresh
A stub is only as good as its last generation. Three mechanisms keep it current, from most to least automatic:
- pre-commit hook (wired by
runspec init --write-project): regenerates the stub whenever arunspec.tomlchanges and fails the commit if it had to rewrite — re-stage and commit again. This is the "I don't have to remember" path. runspec local: auto-upgrades theinitplaceholder to real types, and nudges (without writing) when a real stub is stale.- CI gate:
runspec stubs --checkexits non-zero if the committed stub is missing or out of date:
# .github/workflows/ci.yml
- name: Check runspec stubs are current
run: runspec stubs --check
A stale stub never affects runtime.
parse()reads yourrunspec.toml, not the stub. The worst a stale or missing stub does is show an out-of-date hint in your editor — never a crash, never wrong behavior. So it's safe to treat regeneration as a convenience, not a correctness requirement.
Commands
| Command | What it does |
|---|---|
runspec stubs |
Scan the installed venv and write typings/runspec/__init__.pyi. |
runspec stubs --check |
Verify the stub is up to date; write nothing. Exit 1 if missing/stale. For CI and pre-commit. |
runspec stubs --off |
Remove the stub, restoring plain Any-typed arg access. |
Discovery uses importlib.metadata — the same set of runnables runspec local
sees. A package must be installed (pip install -e .) to be included.
Should I commit typings/?
Yes. Commit typings/runspec/__init__.pyi like any generated artifact you
want teammates and CI to have without each person regenerating. The --check
gate then guards it against drift. (If you'd rather not, gitignore it and
generate in each environment — both work, but committing is simplest.)
Troubleshooting
Types still show as Any.
Most often mypy isn't reading typings/ — add mypy_path = "typings". For
pyright, reload the editor window. Otherwise the stub may be stale or missing —
run runspec stubs.
"No runspec-aware runnables found."
The package isn't installed in this environment. Run pip install -e . (or uv
sync / poetry install) from your project, then runspec stubs again.
A brand-new arg shows no attribute.
That's the feature working — you added the arg to runspec.toml but haven't
regenerated. Run runspec stubs.
The editor shows _StubsNotGenerated.
That's the runspec init placeholder: you haven't generated real types yet. Run
runspec stubs (or runspec local after installing the package).
Next: Testing — test runnables with the built-in harness.