Running your app
duho.main
duho.main(cls, argv=None, *, setup_logging=True, config=None) is the one-call
entry point. It builds the parser, parses argv (defaulting to sys.argv), sets
up logging if the class mixes in LoggingArgs, and calls the parsed
instance's __call__(). Subclass duho.Cmd to make a class runnable this way.
import duho
from duho import Cmd
class Greet(Cmd):
"""Print a greeting."""
name: str = "world"
"Who to greet"
def __call__(self):
print(f"Hello, {self.name}!")
# returning None counts as success (exit code 0)
if __name__ == "__main__":
raise SystemExit(duho.main(Greet))
__call__ returns the process exit code; None means 0. SystemExit raised by
argparse (bad arguments, --help, --version) propagates normally. Dispatching a
class with no __call__ (a data-only Args, or a Cmd that never overrode it)
raises a clear NotImplementedError naming it — see
Commands: Args vs Cmd in
the README.
Exit codes as an IntEnum
Because the return value is mapped with 0 if result is None else result, any
int works as an exit code — including an enum.IntEnum member, which is an
int. This is a clean, self-documenting way to name your exit codes without a
dedicated feature:
import enum
import duho
class Exit(enum.IntEnum):
OK = 0
CONFIG_ERROR = 78 # name your codes; each member is a real int
class Deploy(duho.Cmd):
def __call__(self) -> Exit:
if not self.config_ok():
return Exit.CONFIG_ERROR # propagates as process exit code 78
return Exit.OK
raise SystemExit(duho.main(Deploy))
Errors and exit status
duho.run(root, argv=None, *, exit=True) is the program entry. It calls duho.main,
prints the command's answer and exits with the status; duho.main returns the status
for a caller that wants it. duho.run(root, argv, exit=False) does everything run
does and returns the status as an int instead of exiting, in every case: a
SystemExit becomes its status (None is 0, an int itself, anything else is printed
to standard error and is 1), Ctrl-C is 130 and a broken pipe is 1. That includes the
parser's own exits: --help and --version return 0 and a usage error returns 2, where
duho.main raises SystemExit. A program whose public main(argv) is documented or
tested to raise for those three passes exit="parse": a SystemExit raised while the
command line is parsed then propagates as main raises it, and everything else is still
returned (a command's own sys.exit(3) is 3). exit may also be a function
f(status, error) returning True, False or "parse", where error is the
SystemExit, KeyboardInterrupt or BrokenPipeError caught, else None. A program that has
its own main(argv) -> int for a console script and its tests can call it from there. None or an int is the exit status; any other
value is an answer, printed to stdout (a str as it is, anything else as JSON)
with status 0. A KeyboardInterrupt exits 130, silently unless DUHO_TRACEBACK is set,
which re-raises it. A closed stdout, from the command, from the printing or from the final
flush of standard output, exits 1 silently with or without the variable, before the root's
_errors_ is consulted. Windows reports a reader that left as OSError(EINVAL) rather
than BrokenPipeError, so an OSError with errno EPIPE or EINVAL that names no file
and whose standard output also fails a flush counts the same; an EINVAL from anywhere
else still reaches _errors_.
Raise duho.CommandError(message, code=1) from a command to fail with a message:
main and app print <prog>: error: <message> to stderr and return the code
(duho.UsageError is the same with code 2). The same holds for an error raised while
the command is constructed during the parse (a validating __init__): it is reported
the same way and ends the parse with that code as a SystemExit; any other exception
raised there, and argparse's own errors, are untouched. Under DUHO_TRACEBACK the
exception propagates, unless the root has a _report_error_ (below). Over MCP the
message becomes the error result's text, or what the root's _report_error_ wrote
(below), followed by exit code: N (meaning) when the root's _exit_codes_ declares
that code.
A program with its own way of reporting an error, a logger or a format, sets
_report_error_ on the root class: a function called in place of the default line
for every error main, app or run catch, a CommandError or an _errors_
exception. It is called as f(message, code), as f(message, code, error) when it
can take three positional arguments (error is the exception), or as
f(message, code, error, command) when it can take four (command is the dispatched
instance, so the hook can read the command's own -v flag; None for an error raised
while the command was built). It is also called for an empty message. The status
returned is the same. Under DUHO_TRACEBACK a root with a hook is called exactly as
without the variable and duho prints no traceback: the hook owns the report, and one that
wants a traceback prints it (duho.logging.traceback_enabled() says whether to); a root
without a hook re-raises. An MCP tool call calls the hook too, with running_mode()
reading "mcp" and standard error and log output going to the call's buffer: what it
writes there is the error text, in place of the bare message, and a hook that writes
nothing leaves the message. The hook is also called for an exception raised while the
config file is chosen or read (a _config_loader_'s) that is a CommandError or matches
_errors_, and for duho's own one-line config file error, as f(message, 2, error) with a
duho.exceptions.ConfigError (the root's _errors_ does not remap it); without a hook
that error prints the same <prog>: error: <message> line as before:
class App(duho.Cli):
_report_error_ = staticmethod(lambda message, code: log.error(message))
duho.status_for(root, exc) is the status main and app end with for exc: a
CommandError's code, else the first _errors_ match, else None. Code that runs
commands itself, once per target, uses it instead of repeating the lookup.
A program that keeps its own traceback variable sets _traceback_env_ = "APP_TRACEBACK"
on the root. While main, app or run (or an MCP tool call) runs it, duho reads that
variable where it would read DUHO_TRACEBACK, and so does
duho.logging.traceback_enabled() inside a command or a hook; DUHO_TRACEBACK alone
then does nothing, and outside the run it is read again.
import duho
class Lookup(duho.Cmd):
"""Find a value."""
key: str
"The key to find"
def __call__(self):
if self.key != "known":
raise duho.CommandError(f"no value for {self.key!r}", code=3)
return "found"
if __name__ == "__main__":
duho.run(Lookup)
python lookup.py --key known
python lookup.py --key other # error
To turn your own exception types into statuses without raising CommandError,
set _errors_ on the root class: a mapping from exception type to exit status,
matched by isinstance in the mapping's order. None (the default) catches
nothing.
import duho
class Open(duho.Cli):
"""Open a file."""
_errors_ = {FileNotFoundError: 66}
path: str
"The file to open"
def __call__(self):
with open(self.path) as handle:
return handle.read()
if __name__ == "__main__":
duho.run(Open)
python open_file.py --path missing.txt # error
A command that has an answer and a status, or words only an MCP client should
see, returns a duho.Result. It is an int (its code), so sys.exit(result)
works; value is the answer that run prints and an MCP client receives,
text is for an MCP client only and is never printed, and is_error overrides
the default of code != 0.
import duho
class Find(duho.Cmd):
"""Look a key up; exit 1 when it is absent."""
key: str
"The key to find"
def __call__(self):
if self.key == "known":
return duho.Result(0, value={"key": self.key})
return duho.Result(1, text=f"no value for {self.key!r}", is_error=False)
if __name__ == "__main__":
duho.run(Find)
python find.py --key known
python find.py --key other # error
Async commands
__call__ may be async def. When it returns a coroutine, duho.main (and
duho.run_command) drive it to completion with asyncio.run at the call site,
so the awaited value becomes the exit code:
class Fetch(Cmd):
"""Fetch a URL."""
async def __call__(self) -> int:
await do_async_work()
return 0
asyncio is imported lazily, only when a command actually returns a coroutine —
a synchronous app never pays its import cost. A command dispatched once per
target via duho.fanout gets its own asyncio.run per call.
Module-command lifecycle hooks (init/main/success/finally_) stay
synchronous — async support is class-command __call__ only.
Building the parser yourself
If you'd rather drive argparse directly:
parser = duho.parser(Greet) # a real argparse.ArgumentParser
args = parser.parse_args() # -> a Greet instance
duho.parser(cls) is the module-level form of cls._parser_(). Because it hands
back a genuine ArgumentParser, you can add arguments to it, wrap it, or embed it
however you like.
Parsing in one call
duho.parse(spec, argv=None) builds and parses together:
args = duho.parse(Greet) # from sys.argv
args = duho.parse(Greet, ["--name", "Bo"]) # from an explicit list
Layering over an existing instance
Pass an instance instead of a class and its current field values become the defaults. CLI arguments still win, the original is never mutated, and you get back a new instance of the same type:
base = Greet(name="staging")
result = duho.parse(base, []) # -> name == "staging"
result = duho.parse(base, ["--name", "prod"]) # -> name == "prod"
assert base.name == "staging" # untouched
Precedence is CLI > instance values > class defaults. A required field with no class default becomes effectively optional for that call if the instance already supplies a value. See Configuration layers for env vars and config files, which slot into the same ladder.
Subcommands
Set _subcommands_ to a sequence of Cmd subclasses. duho wires up
add_subparsers() for you and dispatches to the selected one's __call__:
import duho
from duho import Cmd
class Serve(Cmd):
"""Start the development server."""
port: int = 8000
def __call__(self):
print(f"serving on {self.port}")
class Build(Cmd):
"""Build the project."""
output: str = "dist"
def __call__(self):
print(f"building to {self.output}")
class App(Cmd):
"""Example multi-command app."""
_subcommands_ = [Serve, Build]
if __name__ == "__main__":
raise SystemExit(duho.main(App))
$ python app.py serve --port 3000
serving on 3000
A subcommand can declare its own _subcommands_, composing into multi-level
trees; main always dispatches to the deepest selected class.
Global options go before the subcommand here. With this static
_subcommands_ tree (whether run through duho.main or through duho.app
with no commands=/source=/entry_points= given), a root/global option must
be given before the subcommand name — myapp -v serve works, myapp serve -v
does not. duho.app resolving its commands through commands=/source=/
entry_points= is more permissive: a root option (required or not) is accepted
either before or after the subcommand (given on both sides, the later one wins,
so a counting option such as -v does not add up: -v serve -v is 1, -vv is 2) — see
Discovering commands from files below and
Customizing a subcommand parser
in the README.
A required root option follows the same rule: its value goes before the subcommand
name (myapp --token X build). After the name the root still reports it missing, and
a subcommand that inherits the root's option is satisfied by the value given before
the name.
Options
declared on a parent (say -v from LoggingArgs) remain available.
A class with no own _parsername_ is named after the kebab-case of its class
name (Serve → serve, BuildPyz → build-pyz). To name a command something
else entirely, pass name=:
Serve._parser_(subparsers, name="run-server")
The application itself is named differently: see The application's name.
Registering a subcommand from its own file
@duho.subcommand(parent) attaches the decorated class to any Cmd group, so a
command can live in its own file instead of the group listing it in _subcommands_.
The class is returned unchanged, registering twice is a no-op, and parent need not
be a Cli (@Root.subcommand is the same thing for a Cli):
import duho
from duho import Cmd
class Tools(Cmd):
"""A group of tools."""
@duho.subcommand(Tools)
class Build(Cmd):
"""Build the project."""
def __call__(self):
return 7
assert duho.parse(Tools, ["build"])() == 7
A default subcommand
Set _default_subcommand_ on a group to the name (or alias) of the subcommand to use
when the user leaves it out:
import duho
from duho import Cli, Cmd
class Resolve(Cmd):
"""Resolve a user."""
user: str
("user",)
def __call__(self):
print("resolving", self.user)
class Tool(Cli):
"""Tool with a default subcommand."""
verbose: bool = False
("-v",)
_default_subcommand_ = "resolve"
_subcommands_ = [Resolve]
assert duho.parse(Tool, ["-v", "bob"]).user == "bob" # runs as: tool -v resolve bob
duho skips the group's own options, their attached values and exactly one separate
value for an option that takes one; if the first other token is not a registered
subcommand name or alias, the default name is inserted before it. Nothing changes,
so the subcommand stays required, when there is no such token, or when a --, an
unregistered option, a variable-arity option or a missing value comes first
(tool --unknown bob is still an error). An unknown name, or the attribute on a
class with no subcommands, raises ValueError naming the class when the parser is
built. It works on a nested group too.
Refusing a passthrough tail
Everything after the first -- is captured as _passthrough_. A command with no use
for a tail can refuse one with _allow_passthrough_ = False: a non-empty tail is then
a usage error naming the command (exit 2), while no tail, or a bare --, still
parses. A subcommand sets its own.
import duho
from duho import Cmd
class Strict(Cmd):
_allow_passthrough_ = False
try:
duho.parse(Strict, ["--", "x"])
except SystemExit as exc:
assert exc.code == 2
Mode flags instead of positional commands
Some established command-line interfaces select a mode with a flag rather than
a positional command, such as tar's -c and -x. Keep that application-specific
vocabulary at the entry-point boundary by normalizing argv before passing it to
duho.main; the command classes and generated subcommand help remain unchanged:
import sys
MODES = {"-c": "create", "-x": "extract"}
def main(argv=None):
args = list(sys.argv[1:] if argv is None else argv)
if args and args[0] in MODES:
args[0] = MODES[args[0]]
return duho.main(App, args)
This can coexist with ordinary forms such as app create: only an exact leading
mode flag is translated, so flags belonging to the selected command are left
alone. Use _parseraliases_ instead when the alternate spelling is itself a
positional command (for example, create and c).
Discovering commands from files
Instead of declaring _subcommands_ by hand, point duho.app at a directory
(or dotted package) of loose command files with source=, or call
duho.discover_commands(source) directly to get the resolved list:
import duho
if __name__ == "__main__":
raise SystemExit(duho.app(source="cmds", name="myapp"))
Each .py file under cmds/ contributes a module command (a plain
main/register pair, plus optional init/success/finally_ hooks) and/or
any Cmd subclass it defines (a class command). Discovery is resilient:
a file that fails to import (missing optional
dependency) is logged and skipped, so one broken command never blocks the
rest — see examples/discovery_app.py and examples/discovery_cmds/ for a
complete, runnable version of this pattern (a module command with
register/main, one with a full init/success/finally_ lifecycle, and
a class command, all in one loose directory with no __init__.py).
What gets scanned:
- A bare string source that names a directory without an
__init__.pyis scanned like a path: its files are loose command files that may import one another. A bare string that names a package is imported as one. - A command file reaches a sibling helper with a plain
import _helpersorfrom _helpers import x, at the top of the file or inside a function. Each directory has one module per helper, shared by all of its command files and kept for the life of the process, so a base class, a registry list or a cached value in a helper exists once; two directories' same-named helpers stay apart. The directory is not put onsys.pathand a helper is not importable by its bare name from outside the directory. - The standard library, installed packages and anything the application has already
imported win over a same-named file in the directory (
json.py, orexport.pyimportingcolorsys), so a command file does not shadow them for its siblings. - A launcher script that lives in the scanned directory is not registered as a command, so a script can scan its own directory.
- A file with an upper-case
.PYsuffix is ignored on every platform. - A module whose
main,runorcallis a decorator-wrapped function defined elsewhere is not a command. Discovery logs a warning onduho.discoverynaming the module and the function; list the name in the module's__all__to accept it. A plain imported callable is skipped silently.
When duho.app resolves no commands at all (no commands=, source=,
entry_points=, CMDS_PATH or root _subcommands_), it runs the root if the root is
a Cmd, and otherwise exits with status 2 and a "no commands are available" message.
A directory with no __init__.py whose files are instead named
NN-name.py (numbered steps) is a different, opt-in shape entirely — see
RunPath: ordered step directories.
The application's name, and tool calls
duho.app_name(obj) is the name the application goes by, the one the usage line shows
(see The application's name).
Given a command a parser produced, a subcommand included, it returns the root parser's
prog, which is duho.app(name=...) when that was given. Given a class, or an instance
built directly, it returns the class's application name.
duho.running_mode() says how the command running now was started: "mcp" while
duho.mcp.call_tool is running it, its constructor and an async command included, and
"cli" otherwise.
A command that prints for a person can answer a tool call differently without looking
at what standard output is:
class Report(duho.Cmd):
def __call__(self):
if duho.running_mode() == "mcp":
return {"name": duho.app_name(self), "ok": True}
print(f"{duho.app_name(self)}: ok")
Version flag
Set _version_ and duho adds a --version flag that prints
"<prog> <version>" and exits:
class App(Args):
_version_ = "1.2.3"
With no _version_, a class-level __version__ string is used the same way.
_version_ wins when both are set, and a __version__ that is not a string is
ignored.
To read the version from installed package metadata instead of hardcoding it, use
the duho.AUTO sentinel:
import duho
class App(duho.Args):
_version_ = duho.AUTO
_distribution_ = "my-package" # only if it differs from the import name
AUTO resolves via importlib.metadata.version(), defaulting the distribution
name to the class's top-level import package. If the distribution isn't installed
(a source checkout, say), duho adds no --version flag at all and logs a
debug message — rather than printing a bogus version or crashing.
Choosing text or JSON output: OutputArgs
OutputArgs is a data mixin, like LoggingArgs, that adds --format {text,json} and
--json (the same as --format json), and the method self._output_(value, *, text=None):
class Info(duho.OutputArgs, duho.Cmd):
def __call__(self):
data = {"name": "web", "ports": [80, 443]}
self._output_(data, text=lambda d: f"{d['name']}: {len(d['ports'])} ports")
With JSON chosen it prints the value as indented JSON (ensure_ascii=False, default=str
for anything JSON cannot hold). Otherwise it prints text, a string or a function of
the value, or, with no text, the value as duho.run renders it. Nothing is printed
unless the command calls _output_. A tool call passes {"format": "json"} or
{"json": true}. A class that declares format or json itself cannot use the mixin
(TypeError at class creation).
Output encoding
duho.main/duho.app call duho.utf8_stdio() before anything else runs. It
reconfigures sys.stdout/sys.stderr to UTF-8 in place unless one of a few
conditions already guarantees correct encoding: a real terminal (isatty()),
a stream already UTF-8, a stream with no .reconfigure() (pytest's capture,
io.StringIO), Python's own UTF-8 mode, or an explicit PYTHONIOENCODING.
This mainly targets Windows, where piped/redirected output defaults to the
console's ANSI code page (cp1252) with strict errors — printing (or
--version-ing) a non-ASCII character used to raise UnicodeEncodeError,
producing empty output and exit code 1. UTF-8 can't raise, so this default
removes that failure mode entirely.
Opt out with _utf8_stdio_ = False on the root class, or utf8_stdio=False
passed to main/app (the kwarg wins over the class attribute):
class App(duho.Cli):
_utf8_stdio_ = False # duho leaves stdio alone; call duho.utf8_stdio()
# yourself, or do nothing
Even opted out (or when a duho-built parser is driven outside main/app),
--version and duho's own error messages can't crash: they fall back to
errors="backslashreplace" instead of raising when a character doesn't fit
the stream's encoding.
If you're inspecting piped UTF-8 output from PowerShell, set the console
itself to UTF-8 too, or Select-String/> file/capture will still decode
using the console's own (non-UTF-8) code page:
[Console]::OutputEncoding = [Text.UTF8Encoding]::new()
Prettier help: defaults & color
Opt into a richer --help by setting a class-level _help_formatter_. duho ships
three argparse.HelpFormatter subclasses (all off by default — plain help is
unchanged unless you set the attribute):
duho.DefaultsFormatter— appends(default: X)to each option's help, skippingNone/""/Falsedefaults (an unset optional, astore_trueflag) that argparse's ownArgumentDefaultsHelpFormatterwould render as noise.duho.ColorHelpFormatter— ANSI-colors section headings and option flags. Color is gated: it is emitted only to a TTY,NO_COLORdisables it, andFORCE_COLORforces it on. When color is off the output is byte-identical to the plain formatter, so piped/redirected help stays clean and aligned.duho.ColorDefaultsFormatter— both, composed.
import duho
class App(duho.Cli):
_help_formatter_ = duho.ColorDefaultsFormatter
region: str = "us-east"
"Target region"
A root's _help_formatter_ propagates to its _subcommands_ tree, so a single
setting styles the whole app; a subcommand that sets its own _help_formatter_
keeps it. You may also point _help_formatter_ at any custom HelpFormatter
subclass of your own.
Note
argparse renders a default only for options that already have help text, so
give a field a docstring to see its (default: …) suffix.