Skip to content

Logging

Mix in LoggingArgs to get verbosity flags and a configured logger for free.

import duho
from duho import Cmd, LoggingArgs

class App(LoggingArgs, Cmd):
    """Do the thing."""

    target: str
    "What to act on"

    def __call__(self):
        self._logger_.info("working on %s", self.target)
        self._logger_.debug("some detail")

if __name__ == "__main__":
    raise SystemExit(duho.main(App))

duho.main calls self._set_loglevels_() before dispatching, so by the time __call__ runs the levels are applied. (Pass setup_logging=False to opt out; if you drive the parser yourself, call _set_loglevels_() before you start logging.)

The flags

LoggingArgs adds:

Flag Effect
-v, -vv, … More verbose: INFO → DEBUG → TRACE
-q, -qq, … Quieter: INFO → WARNING → ERROR → CRITICAL
--loglevel LEVEL Set a level explicitly
--loglevel mod:LEVEL Set a level for one module
$ app --target x -v                     # DEBUG
$ app --target x -vv                    # TRACE
$ app --target x -q                     # WARNING
$ app --target x --loglevel DEBUG
$ app --target x --loglevel urllib3:WARNING,myapp:TRACE

-v and -q are counted flags that move in opposite directions from the default INFO, and they offset each other (-vv -q nets one step more verbose). Both ends of the scale clamp rather than wrapping or erroring.

Changing the starting level

_base_loglevel_ on the LoggingArgs root sets the level -v and -q step from. It is a level number or the name of a registered level; anything else is a ValueError naming it when the command is dispatched.

import logging
import duho
from duho import Cli, Cmd, LoggingArgs

class Leaf(Cmd):
    def __call__(self):
        return 0

class App(LoggingArgs, Cli):
    _base_loglevel_ = logging.WARNING
    _subcommands_ = [Leaf]

duho.main(App, ["-v", "leaf"])
assert logging.getLogger("app").level == logging.INFO   # -v: WARNING -> INFO

A plain Cmd leaf under a LoggingArgs root uses the root's value, and a command that overrides _verbose_loglevel_ still wins.

Colored output

duho.init_stderr_logging() installs a handler with DefaultFormatter, which colors the level name with raw ANSI codes — no dependency required. Color is gated the same way duho's --help formatters are: only to a TTY, off when NO_COLOR is set (to anything, including empty) or TERM=dumb, forced on with a truthy FORCE_COLOR.

The handler writes to the sys.stderr that is current when each record is emitted, so a later reassignment or redirection of sys.stderr is followed. handler.setStream(stream) pins an explicit stream instead.

colorama is not needed for color itself; when it's installed and color is enabled, colorama.just_fix_windows_console() is called so a legacy Windows console renders the codes instead of showing them literally. It's also used to resolve a named color (color="red") passed to duho.add_logging_level, below.

pip install duho[colorama]

The TRACE level

duho registers a TRACE level below DEBUG:

logger.trace("very fine detail")

Add your own levels with duho.add_logging_level:

import duho

duho.add_logging_level("NOTICE", 25, color="cyan")

The new level is usable as logger.notice(...) on any logger and on a logging.LoggerAdapter (whose process() applies), participates in the -v/-q scale, and is accepted by --loglevel. Registering the same name again at the same number does nothing; at another number it raises ValueError unless you pass force=True. import duho registers TRACE itself and tolerates a process that already defines one: an integer TRACE level is reused and a foreign trace method is kept while duho's goes on every other logger type. Only an explicit add_logging_level call raises on such a collision.

Naming the logger

-v, -q and --loglevel LEVEL raise the application's logger — the application's name (see The application's name), which is the name your own modules normally log under. self._logger_ is that logger. Declare _logger_name_ to use another one:

class App(LoggingArgs):
    _logger_name_ = "myapp.cli"

A _logger_name_ on the root applies to every command it dispatches, and one on a command applies to that command alone. A command that is itself a LoggingArgs follows the same rule as a plain Cmd.

Debugging framework failures: DUHO_TRACEBACK

Several duho paths are deliberately resilient: they log a failure and keep going rather than aborting the whole run. A command file that fails to import is skipped so one bad command never hides the rest; a non-strict RunPath step that raises is logged and the run continues; a fan-out target that raises fails only that target.

That resilience is the right default, but it means the exception is swallowed — the log line is the only record, and a one-line message tells you that something broke, never where:

ERROR steps: step boom failed: kaboom

Set DUHO_TRACEBACK to get the full stack at every one of those sites:

DUHO_TRACEBACK=1 myapp run-steps
ERROR steps: step boom failed: kaboom
Traceback (most recent call last):
  File ".../duho/runpath.py", line 999, in __call__
    entrypoint(self)
  File ".../steps/01-boom.py", line 2, in main
    raise RuntimeError("kaboom")
RuntimeError: kaboom

A program with its own variable names it with _traceback_env_ = "APP_TRACEBACK" on the root class: while main, app or run (or an MCP tool call) runs that root, this switch is read from APP_TRACEBACK instead, and DUHO_TRACEBACK alone does nothing.

The variable is read fresh on every log call, so you can export it for one run without reinstalling or re-importing anything. Only 1, true, yes, on, y and t (case-insensitive) enable it. Anything else is off — 0, false, no, off, n, f, the empty value, and an unrecognised one such as full or maybe, with no warning.

Behavior is unchanged either way — this only controls how much detail is logged. A skipped command is still skipped, and a step marked !strict that fails is still only logged. A plain RunPath step is strict by default: its failure stops the run, with or without this variable.

Logger names

Framework modules log under their own dotted name (duho.runpath, duho.discovery, duho.runtime, duho.fanout, duho.mcp), so a handler or --loglevel filter can target one subsystem:

myapp --loglevel duho.discovery:DEBUG run

All of them sit under the duho parent, so --loglevel duho:DEBUG still turns on everything at once.

A RunPath directory logs under its own name, not the module's. Its self._logger_ is named after the directory, and that is what the framework logs through wherever a run is associated with one. A steps/ directory logs its per-step messages under steps, not duho.runpath, so several RunPaths in one app stay distinguishable:

INFO steps: running step boom
ERROR steps: step boom failed: kaboom

Target those by the directory's name:

myapp --loglevel steps:DEBUG steps

The duho.runpath logger is only the fallback for a bare RunPathCmd with no LoggingArgs mixin (plus a couple of module-level messages emitted while scanning for step files, before any run is under way). Module commands work the same way — their hooks get the args instance's _logger_, falling back to the plain duho logger when the args class has none.