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.