Runtime
The multi-command app runner: duho.app and duho.run_command, re-exported
at the top level. duho.utf8_stdio, which main and app call first, is below.
duho.runtime
Multi-command app runner: wire discovered commands into a runnable app.
This is the driver layer that turns a set of :class:~duho.discovery.Command
objects (class commands -- Cmd subclasses -- and module commands --
:class:~duho.discovery.ModuleCommand) into a real subcommand app:
- build a top-level parser for a root
Cmd/Args(global options); - add a subparsers tree and register every command under it;
- parse
argv(with_passthrough_and the nested-help / shared-namespace behaviors preserved); - dispatch exactly one selected command through the lifecycle
init -> main -> success / finally_with a shared context.
Composed on the shipped parser, not a parallel one. The whole point of this
layer is that it reuses duho's existing _parser_/_initparser_/"#cls"
machinery rather than introducing a second parser class. The parser
behaviors clients rely on are carried over on that path:
- Parent-arg inheritance -- every subcommand parser is built with argparse
parents=[<root parser>]so global/root options appear on each subcommand. - Shared namespace -- class commands already carry the
"#cls"deepest-selection contract (_initparser_), which yields one merged instance of the deepest selected class. A module command's parsed instance stays the ROOT instance (plus any fields a moduleregisterhook, or its own declaredArgsclass, added directly) -- it is never itself constructed as a duho class, unlike a class command. - Nested-help suppression -- the optional two-pass prepass uses the existing
:func:
duho.parsers.prerun_parse(quiet=True), which detaches the subparsers action and silences every terminal action (help, version, print-completion, help-agents) and any parse error for the duration of the call, restoring all of it before returning. No hand-patching. -
registerhook -- a module command may defineregister(parser, args)(or the arity-tolerantregister(parser, args, logger)) to add arguments directly on the argparse object of its subcommand. -
_passthrough_-- argv after the first literal--is captured by the root parser's patchedparse_known_argsand reaches the dispatched command.
No target fan-out / thread pools live here -- a single command is dispatched.
Parallel fan-out is in :mod:duho.fanout.
__all__ = ['run_command', 'app', 'run', 'accepts_positional', 'build_command_parser']
module-attribute
accepts_positional(func, count)
True if func can be called with count positional arguments.
That holds when it declares at least count positional parameters (with
or without defaults) or a *args catch-all. A keyword-only parameter does
not count. func is read on its own signature, not the one a
functools.wraps wrapper points to. A callable whose signature cannot be
read (a builtin, a C callable) gives False, so a caller never supplies
an argument it cannot take.
Source code in src/duho/runtime/_arity.py
7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | |
app(root=None, *, commands=None, source=None, entry_points=None, argv=None, name=None, description=None, env=None, config=None, setup_logging=True, dispatch=None, mcp=None, mcp_command=None, utf8_stdio=None, on_error=None, adapter=None)
Build a multi-command app, parse argv, and dispatch one command.
root is a Cmd/Args/LoggingArgs subclass supplying the app's
global options (None -> a bare data root, for an app whose commands all
come from discovery). The BASE command set is resolved by precedence
(:func:_resolve_commands): commands > discover_commands(source) >
discover_entry_points(entry_points) > root._subcommands_.
env.paths("CMDS_PATH", ty=Path) then ALWAYS merges on top of whichever
base was used -- a layer, not a branch reachable only when no other source
is given -- extending the base rather than replacing it, with a discovered
command overriding a same-named base command (logged, never silent).
Additive, not exclusive. root's own declared
_subcommands_ are ALWAYS registered too (app reuses the
subparsers action root_cls._parser_() already built for them), no
matter what commands/source/entry_points was passed --
passing one of those does not remove or replace a root's built-ins, it
only adds alongside them (see :func:_resolve_commands for the exact
contract). Give root no _subcommands_ of its own (or pass
root=None) for an app whose ONLY commands are the ones explicitly
resolved here.
entry_points is an installed-distribution entry-point group name
(e.g. "myapp.commands"): every entry point advertised in that group by an
installed distribution is loaded and registered as a subcommand, so a
separately-installed plugin package can contribute commands without the app
knowing about it. Loading is resilient -- a broken plugin warns and is
skipped, the rest still load. importlib.metadata is imported lazily, so
an app that does not use entry_points= never pays its import cost.
Each command is registered under a title="command" subparsers action:
- a class command via its own
_parser_(subparsers, parents=[root])-- the shipped path, so"#cls"deepest-selection and any nested_subcommands_keep working, with global options inherited viaparents=; - a module command as a subparser (help/description from the module
docstring), inheriting global options via
parents=; if the module definesregister(parser, args)(orregister(parser, args, logger)) it is called so the module adds its own arguments directly.
Parsing goes through the root parser's patched parse_known_args (from
_initparser_), so "#cls" selection, _passthrough_ capture, and
the layered instance construction all apply. When setup_logging,
stderr logging is initialised and verbosity applied -- identical to
duho.main, including its fallback for a plain Cmd command
selected under a LoggingArgs root (see _setup_instance_logging).
Config/env thread-down. Before parsing, env/config-file defaults are layered onto the root and every class command's fields (precedence CLI > env
config > class default):
config(or, if omitted, aCliroot's_config_) is loaded once; its top-level keys apply to the root and each[<subcommand>]table to that command. This is app()'s analogue of the_apply_default_layerscallduho.main/duho.parsemake -- needed here because commands come from sources not reachable viaroot._subcommands_. The resolvedenv(if any) is attached to the dispatched instance as the sandwich-named_env_handle, so a command can read app-wide settings viaself._env_.
The selected command is dispatched via :func:run_command; its return is
this function's return (success -> 0, a main returning 2 ->
2, and any OTHER non-None value a command returns passes straight
through unchanged -- hence the Any return type, not int). Discovery
is resilient: a single unimportable command drops out with a warning and
the rest still run.
The dispatch seam. app owns discovery, parser build, command
registration, config/env thread-down, parsing, and logging setup. The final
"run the one selected command" step is the ONE point a consumer can override:
pass dispatch to replace it. The callable receives the resolved
:class:~duho.discovery.Command (a Cmd subclass for a class command; the
:class:~duho.discovery.ModuleCommand for a module command) and the parsed
instance, and must return an int exit code, which becomes app's
return. A dispatch may call :func:run_command itself (the default when
dispatch is None), fan the command out over targets via
:mod:duho.fanout, build a per-invocation context threaded ahead of args, or
anything else -- everything app already resolved (the same command and
instance the default path would run) is reused rather than re-derived. When
dispatch is None the behavior is byte-identical to calling
:func:run_command directly, so existing callers are unaffected.
source may also be a list or tuple of sources (a later source replaces
an earlier command of the same name; see :func:~duho.discovery.discover_commands).
on_error(source, exc) is the per-command error policy: it is passed to
discovery (source is then the file or module that failed) and called
when registering one command raises (source is then the command); returning
skips that command, raising aborts. None keeps the default: discovery skips
only ImportError/NotImplementedError with a warning and registration
errors propagate.
adapter(entrypoint) is applied to a module command's entrypoint by the
default run step (see :func:run_command). It cannot be combined with
dispatch, which replaces that step: a custom dispatch passes adapter
to :func:run_command itself (ValueError otherwise).
MCP launch trigger. Checked FIRST, before argv
is parsed or anything else here runs: a <PREFIX>MCP/<NAME>_MCP
environment variable (name derived from env's prefix, else from
root/name/argv[0]/root's class name -- see
duho.args._mcp_env_var_name) set to "stdio" serves this app's
FULL resolved tree (class and module commands alike) as an MCP server
over stdio instead of running any command, returning the server's own
exit code. mcp=False (or root's own _mcp_ = False) disables
this trigger entirely -- the variable, if set, is left untouched and a
normal run proceeds. See :func:duho.args._maybe_serve_mcp_trigger for
the full contract (env var removal, unsupported-transport handling,
lazy duho.mcp import).
Opt-in MCP subcommand (mcp_command). None (the
default) uses root's own _mcp_command_ class attribute
(False unless declared); an explicit True/False/str here
wins over it. See :func:_resolve_mcp_command_name for the exact
name/validation rules. When resolved to a name, duho.mcp.McpCmd is
registered under it like any other class command -- it goes through the
SAME collision/override accounting every other resolved command does
(:func:_register_commands), except a collision with an EXISTING name
is a build-time ValueError here (an explicit opt-in must not
silently lose to it), and it requires this app to already have at least
one other subcommand (a subparsers action that would ONLY ever offer
mcp is not a meaningful CLI). duho.mcp is imported only once a
name is actually resolved.
UTF-8 stdio. :func:duho.utf8_stdio runs FIRST, before the MCP
launch trigger and before anything else here, unless opted out --
utf8_stdio=False here, or root's own _utf8_stdio_ = False
when this kwarg is left at its default None (a bare root=None
root is treated as opted in, matching _mcp_'s own default-root
handling). Opted out, duho does not touch stdio at all.
Source code in src/duho/runtime/_app.py
140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 | |
build_command_parser(command, root=None)
Build the subparser :func:app would build for one resolved command.
command is a Cmd subclass or a :class:~duho.discovery.ModuleCommand;
root is the app's root class, whose options the subparser inherits (a bare
Args when None). The parser is built into a throwaway parent, and a
module command's register hook runs with None for the parsed globals.
Whatever the build raises propagates, so a caller can drop a command that
cannot build; TypeError for anything that is not a command.
Source code in src/duho/runtime/_register.py
222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 | |
run(root, argv=None, *, exit=True, **app_kwargs)
run(
root: type,
argv: _ty.Optional[_ty.Sequence[str]] = None,
*,
exit: _ty.Literal[True] = True,
**app_kwargs: _ty.Any
) -> _ty.NoReturn
run(
root: type,
argv: _ty.Optional[_ty.Sequence[str]] = None,
*,
exit: _ExitPolicy,
**app_kwargs: _ty.Any
) -> int
Run root as the program: dispatch, print the answer, and exit.
Calls duho.main(root, argv), or duho.app(root, argv=argv, **app_kwargs)
when keywords are given, then sys.exit with the status. None is 0; a
Result prints its rendered value (if not None) and exits with its
code; any other int is the status; any other value is printed (a str
as it is, else JSON) with status 0. KeyboardInterrupt exits 130, silently
unless DUHO_TRACEBACK is set, which re-raises it; a closed stdout (a
BrokenPipeError, or on Windows OSError(EINVAL)), from the command, from
printing or from the final flush, exits 1 silently either way. A
SystemExit passes through untouched.
exit says when to end the process instead of returning the status:
True always; False never, so a SystemExit becomes its status
(None is 0, an int itself, anything else is printed to standard error
and is 1), --help and --version included; "parse" only for a
SystemExit raised while the command line is parsed (--help,
--version, a usage error). A callable is called as f(status, error),
error being the SystemExit, KeyboardInterrupt or
BrokenPipeError caught (else None), and returns one of those.
Source code in src/duho/runtime/_program.py
77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 | |
run_command(command, instance, *, context=None, adapter=None)
Dispatch one already-resolved command against a parsed instance.
instance is the parsed args/command instance produced by parsing (for a
class command it IS the command; for a module command it is the root/parent
instance carrying the parsed globals). Returns what the command returns:
None becomes 0, an int propagates, and anything else (e.g. a
JSON-serialisable object) passes through unchanged.
- Class command (a
Cmd): the parsedinstanceis itself the command, so this callsinstance()(Cmd.__call__is the entrypoint). Parsing already owns building the instance; there is no separate parse here. - Module command (:class:
ModuleCommand): runs the lifecycle --ctx = command.init(instance)(identity/no-op default returningNone), thencommand.main(instance)andcommand.success(ctx, instance)inside atrywhosefinallyalways runscommand.finally_(ctx, instance). Ifcontextis passed it overrides theinitresult (the driver builds the context once and threads it in).main's return value (None->0) is the result; an exception frommainpropagates afterfinally_runs.
adapter(entrypoint), when given, is called with a module command's
entrypoint (:attr:ModuleCommand.entrypoint) and returns the callable to
call with instance in its place; a falsy return keeps the entrypoint.
It never applies to the lifecycle hooks, nor to a class command.
No separate logger argument is threaded: hooks read instance._logger_.
For a module command, THIS driver ensures it is present before any hook
runs: when instance has no _logger_ of its own (a plain root, not
LoggingArgs-based), it is set to module_command._logger_for(instance)
(the "duho" fallback) so a hook written against the documented
args._logger_ convention never hits AttributeError. Setting
the attribute is best-effort: a root whose _logger_ is a read-only
property simply keeps using its own resolution.
A module command's init/main/success/finally_ are never
awaited (unlike a class command's __call__, see :func:_maybe_await):
a coroutine returned by any of them is closed immediately and raises
TypeError (see :func:_reject_coroutine), rather than silently never
running.
Source code in src/duho/runtime/_run.py
39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 | |
duho.utf8_stdio(streams=None)
Reconfigure text streams to UTF-8 in place; return the names switched.
streams defaults to stdout and stderr; tests pass fakes. A stream is
left alone when PYTHONIOENCODING is set, UTF-8 mode is on, it has no
reconfigure(), it is a tty, or it is already UTF-8. Errors policy:
surrogateescape for "stdout", backslashreplace otherwise. Never
raises: a failing reconfigure() skips that stream.
Source code in src/duho/_compat.py
137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 | |