Skip to content

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__.py is 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 _helpers or from _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 on sys.path and 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, or export.py importing colorsys), 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 .PY suffix is ignored on every platform.
  • A module whose main, run or call is a decorator-wrapped function defined elsewhere is not a command. Discovery logs a warning on duho.discovery naming 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, skipping None/""/False defaults (an unset optional, a store_true flag) that argparse's own ArgumentDefaultsHelpFormatter would render as noise.
  • duho.ColorHelpFormatter — ANSI-colors section headings and option flags. Color is gated: it is emitted only to a TTY, NO_COLOR disables it, and FORCE_COLOR forces 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.