Changelog
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
0.5.4 - 2026-08-16
Fixed
duho.__version__now matches the released version. It had been left at0.5.1whilepyproject.tomlwent to0.5.2and then0.5.3, so an installedduho==0.5.3reported0.5.1from a public, documented attribute. Both places still state the version literally (pyproject.tomldeliberately keeps saying it out loud rather than deferring to[tool.hatch.version] path = ...), and a newtests/test_version_sync.pyfails whenever the two disagree.Env.boolacceptson. The layered env/config bool converter has always takenon, so a variable spelledONread asTruethrough a declared field and silently asFalsethroughEnv.bool. The truthy set is now1/true/yes/y/t/onin both places; they still differ in strictness only (Env.booltreats an unrecognized value asFalse, the layered converter raises).
Changed
- Corrected two stale docstrings that described behavior the code had already
moved past:
RunPathCmd._runpath_logger_still documented the pre-0.5.3"duho"fallback (the code correctly returns theduho.runpathmodule logger, and deliberately does not mirrorModuleCommand._logger_for, whose"duho"fallback is intentional because it is handed to user hook code), andLoggingArgs._verbose_loglevel_claimed to return "a log level name" when it returns the numeric level. No behavior change; each had been inviting a "fix" that would have reverted correct code. - Removed a dead
abortedflag fromRunPathCmd.__call__. A strict step failure raised on the line immediately after setting it, so the laternot abortedguard could never observe it asTrue; thesuccess()hook gating already worked purely by exception propagation.
0.5.3 - 2026-08-05
Added
DUHO_TRACEBACK— full tracebacks for swallowed framework failures. duho's resilient paths deliberately log-and-continue (a command file that fails to import is skipped, a non-strict RunPath step that raises is logged and the run goes on, a fan-out target's exception fails only that target), which means the log line is the only record of the failure — and a one-line message says that something broke, never where. SetDUHO_TRACEBACKto a truthy value and every such site logs fullexc_infoinstead. Off by default (0/false/no/off/ empty also count as off), re-read per call so it can be exported for a single run. Behavior is unchanged either way — this only controls logged detail.
New public helpers in duho.logging: traceback_enabled() and
log_exception(logger, msg, *args, level=ERROR), plus the TRACEBACK_ENV
constant. Wired into runpath (step import + step run + init failure),
discovery (skipped command module/file, failed entry point), runtime
(the advisory register prepass, previously swallowed with no log at all), and
mcp (a tool exception previously reached the client as a bare Type: message
string with no server-side stack).
Changed
- Framework log records now carry their own module's logger name —
duho.runpath,duho.discovery,duho.runtime,duho.fanout,duho.mcpinstead of a flatduho— so a handler or--loglevel duho.discovery:DEBUGcan target one subsystem. All remain children ofduho, so--loglevel duho:DEBUGis unchanged.
This affects records the framework emits as itself. Records tied to a
running command are unchanged: they still go through the command's own
_logger_, named after its parser. A RunPath's per-step messages, for
instance, keep logging under the directory name (steps), not
duho.runpath — which is the fallback only for a bare RunPathCmd with no
LoggingArgs mixin. Likewise a module command's hooks and a 3-arg
register(parser, args, logger) still fall back to the plain duho logger,
never to a framework-internal module logger.
- Redundant source prefixes removed from log messages — a record logged
under duho.runpath no longer also begins with "duho.runpath: ", and the
per-step case no longer reads steps: duho.runpath: running step boom, where
the prefix actively contradicted the logger name. The ValueError messages
that share their text with a log line keep their prefix (an exception carries
no logger name to identify it).
0.5.2 - 2026-08-03
Added
duho.runpath.register(step_adapter=...)— an app-supplied callable applied to each step's entrypoint just before it runs (adapter(entrypoint) -> callable). It lets an app accept step signatures of its own rather than duho's(cmd)/(cmd, ctx)— an app whose module commands takerun(client, args, logger)can let steps be written that way too, without a decorator in every step file. The adapted callable is what arity detection inspects, so a wrapper may change the signature; returning the entrypoint unchanged leaves duho-native steps alone. PassNoneto clear it, omit the argument to leave it unchanged.
Purely additive: the default is None, which calls steps exactly as before.
Unlike base, it is consulted per step run, so it also affects
already-built commands.
0.5.1 - 2026-07-24
Fixed
- A module whose
__file__doesn't exist on disk (e.g. a zipapp, where__file__is a zip-internal path) now still recovers its declared flags/positionals and docstrings instead of silently losing them and falling back to name-derived option flags.getclsdeftried its file-index lookup and theinspect.getsourcefallback inside a singletry, so anOSErrorfrom the first (a nonexistent path) skipped the second, which would have worked fine (inspect.getsourcereads zipimport modules via the loader'sget_source). The file-indexOSErroris now caught narrowly so the fallback still runs. (GH #1)
0.5.0 - 2026-07-24
Changed
- BREAKING:
list/set/tuplefields used as an OPTION no longer accept space-separated multi-value in one occurrence.--x a bused to accumulate["a", "b"]in one flag occurrence (nargs="*"); an option field now defaults to ONE value per occurrence (nargs=None) — repeat the flag for more (--x a --x b), matching howdictfields already work. The POSITIONAL case is unaffected (stillnargs="*", space-separated — that's the whole point of a trailing variadic positional). Pass an explicitNS(nargs="*")on a specific option field to restore the old space-separated behavior.
Fixed
- A flag placed between two positionals no longer breaks a trailing
variadic positional. argparse's own greedy positional-run matching
(bpo-15112) settles a run containing a fixed positional followed by a
variadic one against the argv slice before the NEXT optional token — so
<command> <name> -f value <targets...>used to fail with "unrecognized arguments" pointing at the targets, even though<command> <name> <targets...> -f valueand<command> -f value <name> <targets...>both worked. Duho now detects this shape at parser-build time and transparently reorders recognized flags ahead of the positional run before the real parse; a genuinely unrecognized/misspelled flag still raises argparse's own honest error, never silently absorbed as a phantom positional value. (This fix is what makes thelist/set/tuple-as-option default change above safe and necessary:nargs="*"on an interspersed option is itself ambiguous even in ALREADY-correctly-ordered argv — confirmed against bare stdlib argparse — so the reorder fix could not have covered that case regardless; downgrading the option default tonargs=Noneremoves the ambiguity at its source instead.) - The reorder fix above now also applies to module command subparsers.
A module command's subparser (
discover_commands/ModuleCommand, built via a plainsubparsers.add_parser(...)) was never patched with the fix — only duho's own declarativeCmd/Argssubcommand tree was. This meant<module-command> <positional> -f value <targets...>still raised "unrecognized arguments" on a module command even though the identical shape already worked on a declarative subcommand. Module command subparsers now get the same detect-and-reorder treatment once all of their fields (declaredArgs+ anyregister()-added arguments) are in place. - A field whose name is identical to its own type annotation (e.g.
bool: bool = False) now raises a clearTypeErrorat class-definition time instead of silently corrupting later argparse behavior. Python's own class-body execution order stores the field's value BEFORE the annotation expression is evaluated, so the name shadows itself within the same statement — this is unfixable at the annotation-reading level; duho now detects the symptom and raises with an actionable message naming the field, rather than the confusing crash it produced before.
0.4.1 - 2026-07-24
Added
- Module commands can declare their own
Argsclass. Previously a module command's fields could only be added imperatively viaregister()— a module-levelArgsclass was silently ignored. A module may now defineclass Args: ...(orclass Args(SomeSharedRoot): ..., the common convention) with annotated fields; they're added to the subparser declaratively, BEFOREregister()runs (so aregister-added positional still lands last, e.g. a shared trailing-positional helper). If the module'sArgsdoesn't already subclass the app's root class, it's mixed with it on the fly so the module's own fields work AND the parsed instance still carries the root's shared fields/methods. Does not (yet) supportNS(conflicts=...)/NS(group=...)for these declared fields — useregister()for those.
Fixed
CMDS_PATH(env=) is now a layer, not a mutually-exclusive branch.app's command-set resolution used to return early for an explicitcommands=/source=/entry_points=, soCMDS_PATHwas only ever consulted in the fallbackroot._subcommands_branch — passing any other source silently disabledCMDS_PATHentirely, even withenv=also given, with no warning.env-discovered commands now always merge on top of whichever base source produced the list (a discovered command still wins on a name clash, still logged, never silent).- A wrapped
command.registeron a module command is no longer silently skipped.app()'s module-command registration gated and introspected arity on a freshgetattr(module, "register", ...)re-fetch instead ofcommand.register(the object actually called) — so a caller that wraps or reassignscommand.registerdirectly (a documented-looking seam) saw its wrapper silently skipped for any module defining noregisterhook of its own (module.registerisNonethere → not callable → gate never passed). Now gated/introspected oncommand.registeritself. Envcompanion-module defaults no longer override the real environment.Env's autoloaded<prefix>envcompanion module seeds genuine defaults (lowest precedence) instead of shadowing a real exported<PREFIX>_<KEY>variable — the class docstring already called these "defaults"; the implementation now matches. Precedence:**envkwargs / a runtimeenv[k] = vwrite, thenos.environ, then the companion module.
0.4.0 - 2026-07-23
Added
- RunPath
__main__.pylifecycle, filename-encoded per-step options, andBEFORE/AFTERsoft ordering (duho.runpath, opt-in). A RunPath directory may now define an optional__main__.pywith up to three callables --init(cmd, logger) -> ctx(once, before any step; raising is always fatal, regardless of--rcopts strict, since every step depends onctx),success(ctx, cmd, logger)(once, after a clean run),finally_(ctx, cmd, logger)(once, unconditionally) -- and a step entrypoint written(cmd, ctx)(arity-detected) receives thatctx; a(cmd)step is unaffected, so every existing step file keeps working unchanged. Step filenames now also accept a leading!(disables the step by default, stripped before theNN-namesplit) plus:/;-separated option tokens (key/!key/key=value;:and;are fully interchangeable everywhere -- NOT an OS-conditional split -- so a Windows-authored filename can use;instead, since:is an invalid Windows filename character). Two tokens are special:strict/!strict(a step's own default, absent the token, is strict-on-failure;!strictopts that ONE step out) andenable/!enable(an explicit, more-specific alternative to the leading!; wins if both are somehow present). This is the SAME token grammar--rcoptsnow uses per comma-entry -- one shared parser, including a new per-pattern--rcoptsstrict override (e.g.build:!strict) scoped to matching steps only, distinct from the pre-existing barestrict/!strictrun-wide toggle. Precedence for a step's strict setting: filename default -> a matching per-pattern--rcoptstoken -> an explicit bare--rcopts strict/!strict(run-wide, wins last). Step modules may setBEFORE: list[str]/AFTER: list[str](soft ordering only -- a missing or disabled name is silently a no-op, unlike the existing hardREQUIRED, whose missing/disabled-dep warning is unchanged) alongside the existingREQUIRED: list[str], resolved together in one merged predecessor graph before_order_steps's existing topological pass. - MCP tool surface (
duho.mcp, opt-in) A zero-dependency stdio JSON-RPC 2.0 server that exposes a duho CLI'sCmd/Cliclasses as MCP (Model Context Protocol) tools, with zero redeclaration:input_schema_for_command(cls)/json_schema_for_field(decl, builder)map each field's declared type (str/int/float/bool,Literal/Enum,list/set/tuple/dict,Optional/Union,pathlib.Path) to a JSON Schema fragment, reusingduho._introspect.get_clsargs+cls._getargs_()(the same per-field dataduho.agenthelpcollects, per Decision 2 --agenthelpitself is untouched).describe_tools(root_cls)walks the built parser tree (reusingduho.agenthelp.describe_parser's alias-dedup-by-identity) so everyCmdin a_subcommands_tree -- root included -- becomes one tool, namespacedparent.childwhen nested.call_tool(root_cls, name, arguments)synthesizes an argv from the JSONarguments(a repeatable field becomes a repeated flag; adictfield becomes repeatedKEY=VALUEtokens; a positional a bare token, in declared order) and reuses the target class's own_parser_()+duho.run_commandto dispatch, capturing stdout. Return convention:None/0-> success (captured stdout as onetextblock); a non-zero int ->isError: true(stdout + a trailingexit code: Nline); a JSON-serialisable object/list return -> passed through as onetextblock holding its JSON dump.python -m duho.mcp <app>(<app>a dottedmodule:ClassNameormodule.ClassNamequalname, resolved via the stdlibpkgutil.resolve_name) runs the stdio server against real stdin/stdout. Likeduho.runpath/duho.fanout/duho.scaffold, this is a standalone opt-in submodule -- coreduhonever imports it, and it is not on the top-levelduho.*surface;json/importlib.metadatastay lazily imported soimport duho.mcpalone never pays their cost. v1 limitations (documented, not silently wrong): a customaction=/type=field with no registered override is passed through as a plain string;NS(conflicts=...)exclusive groups are noted in the tool description text only (nooneOf/notencoding yet); a module command (no duho class behind it) can be listed but not called; one request maps to exactly one result (no streaming/long-running commands). - Agent help A detailed, machine-readable (JSON) description of a CLI, built
for AI agents, on top of duho's existing introspection (
get_clsargs/ClsArgDeclaration+ per-fieldArgumentBuilder). Two triggers: the always-onAGENT_HELPenvironment variable flips-h/--helpinto agent mode (human help is byte-identical when it is unset; the var name is overridable via_agent_help_env_), and the opt-in_agent_help_ = Trueadds a discoverable--help-agentsflag. The document (schemaduho/agent-help@1) covers every subcommand (with aliases), each option's type/default/required/repeatable/ choices, positionals, per-field env-var bindings, mutually-exclusive conflict groups, examples (author-declared_examples_or a synthesized minimal invocation), and exit codes (_exit_codes_overrides). New moduleduho.agenthelp(parser-tree walk, mirrorsduho.completion), plusduho.print_agent_help(cls).jsonstays lazily imported. - F1 First-class
dict[str, V]fields. Adict-annotated field collectsKEY=VALUEtokens; repeated flags merge into one dict viaUpdateAction, and the value half is converted withV(baredict==dict[str, str]). Only the first=splits; a token with no=is a clear argparse error; a non-strkey type is a build-time error. Default{}. Env (k=v→ one-pair dict) and TOML-table config layers are supported.UpdateActionnow makes a shallow per-occurrence copy instead of adeepcopy.duho.Count()counted flags (-vvv→3) are documented in the README type table. - F2 Required mutually-exclusive groups:
NS(conflicts="grp", conflicts_required=True)on any member makes the whole group required (argparse requires exactly one). Omitting all members errors; the group'srequiredflag is set at build time. - F3 Titled argument groups:
NS(group="Section title")buckets a field under a named--helpsection (lazily created per title). A field combininggroup=andconflicts=nests the mutually-exclusive group inside the titled section. - F4 Async
__call__support: aCmdwhose__call__isasync defis driven to completion viaasyncio.runat the call site (duho.mainandduho.run_command), so the awaited value is the exit code.asynciois imported lazily. Module-command lifecycle hooks stay synchronous. - F5
duho.Meta: a typed, typo-safe dataclass alternative toNS(...)for field metadata. An unknown keyword is aTypeErrorat class-definition time (anNS(...)typo silently vanishes); only the fields you set are merged.NSkeeps working. PEP-727Docduck-typing (a metadata object with a str.documentationattr contributes help) is documented. - F6 Entry-points plugin discovery:
duho.app(root, entry_points="group")loads commands advertised by installed distributions' entry points ingroup, coercing each to a command (aCmdsubclass → class command; a module → module command) through the same path as every other source. Loading is resilient — a plugin that fails to import or does not resolve to a command warns and is skipped. New publicduho.discover_entry_points(group).importlib.metadatastays lazily imported (only entry-point discovery loads it). Sits inapp's source precedence aftersource=and before theCMDS_PATHenv layer. - F7 JSON config files + a pluggable loader. A
_config_/config=path ending in.jsonis parsed as JSON (stdlibjson, imported lazily; a malformed file raises a clear error naming it); any other suffix stays TOML. JSON yields the same nested-dict shape as TOML, so subcommand tables layer identically. A new class-level_config_loader_(Callable[[Path], dict], declared onCli) is used instead of the built-in dispatch when set, letting users plug any format (e.g. YAML) without duho depending on it — the zero-runtime-deps contract holds. - F8 Opt-in help formatters via a class-level
_help_formatter_(plumbed into argparse'sformatter_class, and propagated across a_subcommands_tree). New publicduho.DefaultsFormatter(append(default: X), skippingNone/""/False),duho.ColorHelpFormatter(ANSI section headings + flags, gated on TTY/NO_COLOR/FORCE_COLOR— byte-identical to plain when off), andduho.ColorDefaultsFormatter(both composed). All ANSI reuses the logging color codes (nocoloramaimport). Off by default; plain help is unchanged. - F9 PowerShell completion: a new
duho.completion.powershell(parser)emitter walks the sameCompletionSpectree and emits aRegister-ArgumentCompleter -Nativescript block resolving the subcommand path to flags/subcommands/choices (file completion falls through to PowerShell's defaults)."powershell"is added to the--print-completionchoices and toduho.print_completion. A new_psqhelper applies PowerShell single-quote doubling so a hostile choice can neither break out of the script nor be expanded.
Documentation
- Documented that negative numbers work as values out of the box (option
values and positionals) via argparse's
_negative_number_matcher, with theNS(kwargs=...)escape hatch for the rare-1-style flag; added a regression test. (Plan 04 rejected "negative-number handling" as a feature.) - Documented using an
enum.IntEnumas exit codes — anIntEnumreturn from__call__propagates as the process exit code unchanged (it is anint); added a test. (Plan 04 rejected "exit-code enum" as a feature.)
Performance
- P1
importlib.metadatais now imported lazily, inside_resolve_version's_version_ = duho.AUTObranch, instead of at module top. A plainimport duhono longer pays its ~20-30 ms cost; only a class that opts intoAUTOtriggers the load, at parser-build time. - P4
coloramais now imported lazily on first use (a named color spec such as"red"/"red+white"), not atduho.loggingimport.import duhono longer pays colorama's ~3-5 ms when it is installed; built-in level colors are hard-coded ANSI and never need it. - P2 duho no longer AST-parses its own
args.pyon every parser build:Args/Cmd/Cliseed an empty_duho_constants_class attribute so the class-body scan short-circuits for framework base classes. - P3 The qualname walk in
_introspect._module_indexnow recurses only into statement containers (class/function bodies,if/for/while/with/tryclauses) instead of every AST node, cutting the per-file walk time ~30x. - P5
getclsdefreturnsNoneimmediately when a file's module index was built successfully but the class qualname is absent (a dynamically-created class), skipping a redundantinspect.getsourcere-parse that would fail anyway. The REPL/execno-module-file case still uses the fallback.
Combined, P1-P5 cut fresh-process import duho from ~75 ms to ~51 ms and
end-to-end import+build+parse from ~90 ms to ~55 ms, and the cold 10-subcommand
tree build from ~41 ms to ~10 ms (min, reference machine).
Changed
- P6 Benchmark harness upgraded so the wins stay visible: fresh-process
startup deltas (
benchmarks/bench_startup.py), subcommand-tree scaling and a field-type matrix (benchmarks/run.py), a command-discovery benchmark (benchmarks/bench_discovery.py), a committedbenchmarks/baseline.jsonwithupdate_baseline.py/check_baseline.py, and a CI regression gate (fails at1.5x on warm-metric medians / >1.3x on startup deltas).
compare_cache.pyoutput is re-labelled cold-vs-warm (the cold path is what real invocations pay). All benchmark tooling is stdlib-only and stays excluded from the sdist.
Fixed
CMDS_PATHcommand-search-path resolution now splits on the platform path separator (os.pathsep—;on Windows,:on POSIX; overridable via aPATHSEPenv var) via the newEnv.paths(). Previously it split on a hard-coded:, so on Windows an absolute path's drive-letter colon (C:\…) was mis-split into a bogusCentry (ImportError: not a directory: C).Env.list()'s generic:default is unchanged.- Building a parser for a bare framework base class used directly as a root
(
duho.app(root=None)buildsArgs._parser_()) no longer persists_parsername_onto the sharedArgs/Cmd/Clibase. Previously that name leaked via inheritance to every subclass, so a laterapp(root=None, ...)mis-derived subcommand names (invalid choice: 'Deploy' (choose from 'Args')). Surfaced by F6's plugin-only apps, which commonly run with no explicit root. - C1
boolenv/config values now parse correctly:false/0/no/offmap toFalse(previouslybool("false")wasTrue); an unknown string is a clear error naming the field and source. - C2 An env var / TOML string on a
list/set/tuplefield now becomes a single-element collection (FILES=a.txt->["a.txt"]), matching one CLI occurrence, instead of running the element factory over the whole string. - C3 A subcommand's
set_defaultsno longer clobbers a root option's value given before the subcommand: layered defaults skip dests suppressed on the child parser. - M14 Non-string config (TOML) values are now converted to the field type
(
timeout = 30for afloatfield ->30.0; alist[Path]array ->[Path(...), ...]) instead of being installed unconverted. - C4
duho.app()now suppresses the root's inherited option defaults on every registered subcommand parser, so a global given before the subcommand (myapp -v deploy) and root env/config values survive to the dispatched command. Required inherited globals are also un-required on the child (the root parser still enforces them). - C5
app()loads config once and applies the root layer before its advisory prepass, and degrades to no-prepass on aSystemExit, so a required global supplied by config no longer hard-exits with a usage error. - M6 A command name registered by more than one source (e.g. a module and a
class command) now logs a warning naming both; the last registration wins and
dispatch resolves through the same single registry (previously argparse raised
conflicting subparser). - C6 Union members now recurse through the full type ladder:
Optional[list[int]]gets element conversion + the extend action (no more char-splitting),Optional[Literal[...]]gets choices, and a multi-member union with a collection member is a clear build-time error. - C7 Collection defaults (
list/set/dict) are copied per parse/build, so mutating one parsed instance's list no longer leaks into the next parse or a directly-constructed instance. - C8 Foreign
Annotatedmetadata (a bareAnnotated[int, "doc"]string, or any non-namespace object) no longer crashes_getargs_; a PEP-727-style object with a str.documentationcontributes help text, everything else is ignored. - C9
ClassVar[...]andFinal[...]annotations are skipped instead of becoming broken CLI flags. - C10
Literal[True, False]builds and parses (goes throughtype=+choices=) instead of raising an argparseTypeErrorat build. - C15
datetime.date/datetime.datetime/datetime.timefields parse viafromisoformat(a bad value is a clean argparse error, not a traceback). - M15 A
setused as a flags container is now a clear build-time error instead of a crash / nondeterministic flag order. - M17
argparse.SUPPRESSinAnnotatedmetadata hides the field wherever it appears, not only as the first metadata item. - C11 A missing
<PREFIX>_CMDS_PATHno longer glob-imports every.pyin the current working directory (Env.listreturns[]andapp()guards on a non-empty value). - M3
Envcompanion-module autoload seeds only upper-case, non-underscore variables throughstr()coercion, and acceptsEnv(prefix, autoload=False)to disable thesys.path/CWD import. - M5 A fan-out target returning a non-int, non-None value is logged and isolated (counts as exit code 1) instead of aborting the whole fan-out.
-
M4 A RunPath step whose import raises
ImportError/NotImplementedErroris skipped with a warning (resilient) or re-raised (strict); an enabled step whoseREQUIREDnames a disabled step warns/raises; aREQUIREDcycle raises under strict. Non-environmental errors (e.g.SyntaxError) still surface. -
M1
prerun_parseno longer patchesargparse._SubParsersAction.__call__/_HelpAction.__call__process-globally; it swaps the specific action instances' classes (restored infinally), so it is thread-safe and reentrant. - M20
pop_actionalso removes the action from its argument group's_group_actions, so a popped flag no longer lingers informat_help(). - C13
duho.snakecaselower-cases interior upper-case letters with an underscore (CamelCaseName->camel_case_name) instead of dropping them, and returns""for empty input. - C14
duho.value_sourcescompares against each field's effective default (so an undeclared-defaultstore_trueleft off the CLI is"default", not"cli") and merges subcommand parsers' provenance up to the root, so a config-supplied subcommand field is labeled"config". - M9
logging._getcolorresolves the documented"fore+back"syntax and returns""(never the raw compound string) when colorama is absent or a name does not resolve. - M10
_parser_(name="alias")no longer permanently writes_parsername_onto the class; the alias is a one-off. - M11 Source is read as UTF-8, and
UnicodeDecodeError/ValueErrorare caught so non-ASCII source under a non-UTF-8 locale no longer crashes. - M12 The
_CollectionActionsidecar (_duho_items_<dest>) is dropped before instance construction, so it no longer leaks intovars(instance). - M16
_suppress_inherited_defaultskeeps a child's deliberately overridden default (a re-declared field with a different default) instead of discarding it. - M18 A non-literal class-body expression resets docstring attribution (no misattribution to the previous field), and a class whose source can't be located emits a one-time debug diagnostic.
- M19
QualName.relative_towith an empty base returns the name unchanged instead of dropping the first part. - M22 A module command's
successhook runs only on a successful exit (not for a non-zero exit code), and a raisingfinally_no longer masks the original exception. - C12 The zsh emitter emits valid multi-flag optspecs
(
'(-v --verbose)'{-v,--verbose}'[option]'), rebuilds the command path from non-option words, and drops the dead_describecall. - M2 Completion scripts escape every interpolated value: bash word lists
neutralise command substitution (a hostile choice like
$(...)no longer runs at Tab-press), zsh/fish single-quoted contexts escape embedded quotes, and a program name with whitespace/metacharacters is rejected. - M8 The bash emitter skips the value following a value-taking flag when
reconstructing the command path (
myapp --env prod deploy <TAB>now completes). - fish Single-dash multi-char flags are emitted with
-o(old-style) rather than-s, and a subcommand's-ddescription is its one-line help.
Changed
- C11 (breaking-ish)
duho.Env.listreturns[]for a missing or empty value instead of the previous[ty("")]single-empty-element contract.
Changed
- Internal tidy-up (no behavior change): removed unused imports (
typingincompletion,argparseinpresets,statinscaffold) and an orphaned dead helper (_zsh_value_spec) plus its unused-result callers incompletion. Thefrom logging import *underTYPE_CHECKINGinloggingis intentional (re-exports stdlib logging names for type checkers) and is retained.
0.3.2 - 2026-07-18
Fixed
- A literal
%in aCmddocstring no longer crashes parser build. Docstring-deriveddescription/helpare escaped (%→%%) before argparse, which%-expands help strings; previously a docstring mentioning e.g. an RPM%fileslist raisedValueError: badly formed help stringat parser-build time. - A global option given before a subcommand is no longer shadowed. When a subcommand
inherits an option the root also declares, the child's inherited default was clobbering
the root's parsed value (so
app --db X sublost--db). The child's inherited optional defaults are now suppressed for root-declared dests, so the pre-subcommand value survives; passing the flag after the subcommand still overrides, and absent it uses the root default. - Constructing a
Cmddirectly now seeds declared field defaults. A directly-built or self-cloned instance (type(self)(**self._get_kwargs())) previously lacked any field not passed — notablystore_truebools, whose default only materialized via argparse.Argsnow fills those gaps with each field's effective default; passed/parsed values always win.
Added
parser.exclusive_groupsis exposed on a built parser, so a_parser_override can add extra options into aconflicts=-built mutually-exclusive group.
0.3.1 - 2026-07-18
Changed
- Clearer error when a module
registerhook collides with a global flag. Because every subcommand parser inherits the root's global options (parent-arg inheritance), aregister(parser, args)hook that adds an inherited flag (e.g.-qfrom aLoggingArgsroot) previously crashed with argparse's bareconflicting option string: -q.duho.appnow catches that and re-raises naming the command and pointing at the global-flag cause. The README also documents the root's reserved flags to avoid inregister.
0.3.0 - 2026-07-17
Added
duho.scaffoldopt-in launcher generator: an opt-in, stdlib-only module (not on the coreduho.*surface — core never imports it) that generates a cross-platform launcher pair so an app laid out asbin/+ alib//src/package can run from a checkout without an install.generate_launchers(app, root, *, libdir="lib", python=None, overwrite=False)writesbin/<app>(POSIXsh) +bin/<app>.cmd(Windows), each of which prepends<root>/<libdir>toPYTHONPATHand runspython -m <app>, honoring aPYTHONenvironment override. The generator writes plain files (never symlinks), sets the POSIX launcher executable best-effort, and refuses to overwrite an existing launcher unlessoverwrite=True. A thin CLI (python -m duho.scaffold <app> [--root DIR] [--libdir lib] [--python PY] [--force]) dogfoods duho — it is itself aduho.Clicommand.set/set[T]andtuple[T, ...]/tuplecollection fields: annotate a field withset,set[T], baretuple, or a variadic homogeneoustuple[T, ...]and it parses like alistfield — both--x a --x b(repeated) and--x a b(space-separated) forms, per-element type conversion, bare forms usestrelements — but the final value is aset(dedups; iteration order not guaranteed) ortuple(order preserved). Defaults areset()/()when the field has no explicit default. A fixed-length heterogeneoustuple[A, B]is not supported and raises a clear error at parser build, naming the field and pointing totuple[T, ...].- Module
registerhook now accepts a 3-arg(parser, args, logger)form in addition to the existing 2-arg(parser, args).duho.appinspects the hook's signature and, for a 3-arg (or*args) hook, passeslogger = getattr(args, "_logger_", logging.getLogger("duho")); a 2-arg or non-introspectable hook is called unchanged. Fully backward-compatible — existing 2-arg hooks are unaffected. duho.parse_globals(cls, argv=None, **parser_kwargs): parse only a root command's global args, ignoring/relaxing the subcommand tree, so a consumer can resolve config-file-driven command search paths (or any other global) before building the full subcommand parser. A missing subcommand does not error and an unknown trailing token does not crash the parse; it returns the parsed root instance (globals only). This is the public form of the help-suppressed, subcommand-relaxed prepassduho.appalready runs internally. Additive.duho.fanoutopt-in target fan-out: an opt-in, stdlib-only module (not on the coreduho.*surface — core never imports it) for running one command against many targets concurrently and rolling their exit codes into one.run_targets(func, targets, *, max_workers=None, aggregate=max)runsfunc(target)for each target on aThreadPoolExecutorand returns an aggregated exit code (None→0, an int as-is, an unhandled exception → logged and treated as1so one failing target never aborts the rest; defaultmaxpolicy —0only if all succeed; empty targets →0; passaggregate=anyor a custom reducer to change it). Log records a target emits while it runs are tagged with a[<target>]prefix via a filter installed on the app's existing stderr handler for the duration and removed afterwards (no leaked filter, no per-target handler churn).fan_out_command(command, make_instance, targets, ...)is thin sugar dispatching one resolved command once per target viaduho.run_command. Public API:run_targets,fan_out_command,target_logging,TargetPrefixFilter,current_target. Additive.duho.app(dispatch=...)seam:app()now accepts an optionaldispatch(command, instance) -> intcallback that replaces only the final "run the one selected command" step, whileapp()keeps owning discovery, parser build, registration, config/env thread-down, parsing, and logging setup. A consumer that needs a custom run contract (build a per-invocation context, fan the command out over targets viaduho.fanout) reuses everythingapp()resolved instead of re-deriving it.dispatchreceives the resolvedCommand(aCmdsubclass, or theModuleCommand) and the parsed instance and returns the exit code. Withdispatch=None(the default) behavior is unchanged —app()callsrun_commandas before.duho.runpathopt-in RunPath step-runner: an opt-in module (not on the coreduho.*surface) that turns a directory of numberedNN-name.pyfiles into one command running them in order.import duho.runpathregisters a command provider on the Plan-13register_command_providerhook (its first consumer) — coreduhonever imports it. Steps declare ordering via theNNprefix or a module-levelPRIORITY, and dependencies viaREQUIRED; a--rcopts/-Oflag selects steps with comma-separated fnmatch patterns (!disables,!*,x= "only x") and astrictmarker that turns unmatched-pattern / failed-step warnings into errors (resilient by default, matching discovery). Public API:RunPathCmd,register,unregister. Additive; nothing on the existing surface changes.duho.Cliapplication root: an opt-in mixin overCmdfor the root of a multi-command app. It types and documents the app-wide, sandwich-named config attributes a leafCmddoesn't declare —_version_,_distribution_,_completion_,_config_,_subcommands_— without changing how any of them is read (purely additive; a plainCmdroot still works). Recommended batteries- included recipe:class MyApp(LoggingArgs, Cli).LoggingArgsstays orthogonal.@MyApp.subcommandself-registration: a leaf command file can attach itself to aCliroot's subcommand tree with the@Root.subcommanddecorator (orRoot._register_subcmd_(child)), instead of the root centrally listing every child in_subcommands_. Registration is per-class (copy-on-write — twoClisubclasses never cross-contaminate, a parent's list is never mutated) and composes with a statically-declared_subcommands_(union + dedup — a child listed both ways appears once).duho.appconfig/env thread-down:app(root, ..., env=, config=)now layers aCliroot's_config_(or an explicitconfig=) TOML defaults onto the root and each class command's fields (top-level keys → root,[<Subcommand>]table → subcommand), and attaches the resolvedEnvto the dispatched instance as the sandwich-named_env_handle so a command can read app-wide settings viaself._env_. Precedence is unchanged: CLI > env > config > class default.duho.Env(prefix): a prefixed, typed, app-wide view overos.environ. Reads keys sharing a normalized<PREFIX>_prefix (Env("my-app")→MY_APP_*), with.bool(key)and.list(key, sep=":", ty=str)accessors and an optional autoloaded<prefix>envdefaults module. It is aMutableMapping. Distinct from the per-fieldNS(env="VAR")default layer — this is the app-level settings accessor.- Text/name utilities (
duho.expand,pysafe,camelcase,snakecase,gettext):expand("web[01-03]")expands[a-b]brace ranges into concrete strings (cartesian product for multiple ranges; not zero-padded);pysafecoerces text to a Python-safe dotted identifier;camelcase/snakecaseconvert case;gettextis agettextshim. duho.PythonName/duho.QualName: dotted-name algebra (parts, parent, join/split,/composition, path mapping) for building command qualnames;PythonNameruns each part throughpysafe.- Command discovery (
discovery.py):duho.discover_commands(source)walks a dotted package name or a directory and returns alist[Command], collecting BOTH class commands (Cmdsubclasses) and module commands (ModuleCommand). It is resilient — a command that fails withImportError(missing optional dep) orNotImplementedError(not a command) is logged and skipped so the rest still load, while a real bug (e.g.SyntaxError) still propagates.duho.CmdBuilder(qualname, source=None)resolves a single import path / filesystem path / module to aCommand;duho.ModuleCommandadapts a.pymodule (entrypointmain/run/call, docstring help, optionalregister/init/success/finally_lifecycle hooks) to theCommandprotocol without subclassingModuleType. Theduho.Commandprotocol is the shape dispatch needs. duho.register_command_provider(predicate, builder): an injection seam letting an external package teachCmdBuilderhow to build a command from a directory shape core duho doesn't understand (e.g. an ordered run-path of numbered step files), without core importing that package. Consulted newest-first before a normal import.Cmdcommand type: a newduho.Cmd(Args)base carries the executable contract. Define__call__(self)on aCmdsubclass — a dunder, so it never collides with a CLI field (a plainmainmethod would clash with a--mainflag). ACmdinstance stays directly callable.Cmd.__call__'s base raisesNotImplementedErrornaming the class when a subclass doesn't override it.duho.command(args_cls, func, *, name=None): build aCmdsubclass from an existing dataArgsclass and a callable —func(self)receives the parsed instance and its return value is the command result.namesets the subcommand name (_parsername_)._passthrough_: argv after a literal--separator is captured at parse time and exposed on the parsed instance as_passthrough_: list[str](empty when no--; only the first--splits). Useful for forwarding trailing args to a wrapped command.duho.app()/duho.run_command()(runtime.py): a multi-command app runner.app(root=None, *, commands=None, source=None, argv=None, name=None, description=None, env=None, setup_logging=True) -> intbuilds a top-level parser for arootcommand, resolves a command set (explicitcommands>discover_commands(source)>env.list("CMDS_PATH", ty=Path)>root._subcommands_), registers each under a subparsers tree, parsesargv, and dispatches one command. Class commands and module commands (ModuleCommand) are both supported; global options are inherited by every subcommand, a moduleregister(parser, args)hook can add arguments directly,_passthrough_reaches the dispatched command, and discovery is resilient (one bad command is skipped).run_command(command, instance, *, context=None) -> intdispatches a single resolved command: a class command viainstance(), a module command through theinit -> main -> success / finally_lifecycle with a shared context (hooks read the args instance's_logger_; no separateloggerargument).Nonemaps to exit code0; a returned int is propagated.
Fixed
duho.camelcasecrashed on a trailing, doubled, or leading separator (camelcase("global_")raisedIndexError). Empty segments from the split are now skipped. This surfaced constantly in code generation, wherepysafeturns a Python keyword (e.g. a namespace namedglobal) intoglobal_and camelcasing that name hit the trailing underscore.
Changed
- BREAKING:
Argsis now pure data and no longer runnable on its own — "everyArgsis callable" (from 0.2.0) is reversed. To run a command, subclassduho.Cmdand implement__call__(self)(or build one withduho.command(...)). Dispatching a bare dataArgsviaduho.mainnow raises a clearNotImplementedErrorinstead of silently doing nothing. TheLoggingArgspreset stays a data mixin; combine it asclass App(LoggingArgs, Cmd)(recommended base order) to get logging + a runnable command. ACmd's command body is__call__(dunder, collision-free); if you used amain-method draft during pre-release, rename it to__call__.
0.2.0 - 2026-07-16
Added
- Subcommand aliases: set
_parseraliases_on anArgssubclass to register short/alternate names for it in a_subcommands_tree (e.g._parseraliases_ = ["c"]soapp cruns the same command asapp create). Aliases dispatch to the same__call__. Absence of the attr is the unchanged default (no aliases). __version__fallback for--version: when_version_is unset, a class-level__version__string is now used to populate the--versionflag, so an app already carrying the conventional__version__gets--versionfor free._version_still wins when both are set (and remains the only form that accepts theduho.AUTOsentinel).
Changed
- BREAKING: the command-dispatch hook is renamed from
__run__to__call__. AnArgsinstance is now directly callable —instance()runs the command — andduho.main()dispatches toinstance.__call__(). Renamedef __run__(self)todef __call__(self)on your command classes.
0.1.1 - 2026-07-14
Added
- Documentation site at https://jose-pr.github.io/duho/ — guides for declaring arguments, types and conversion, running your app, configuration layers, logging, and shell completion, plus a generated API reference.
Changed
- Corrected the performance figures in the release notes to numbers measured on a fixed CI runner. Parser construction is 40–70× faster than the uncached path (10.5–11.0 ms → 0.15–0.27 ms, median, on Python 3.9 and 3.13); the previously published multiplier came from a noisy development machine.
Fixed
- README links to
LICENSEare absolute, so they resolve on the PyPI project page rather than 404ing.
0.1.0 - 2026-07-14
Initial release.
Added
- Declarative
Argsclasses — define a CLI by annotating class fields. The field's docstring becomes its help text and a following tuple literal declares its flags (("--name", "-n")); with no tuple, the flag is derived from the field name (dry_run→--dry-run). - Type-driven conversion from annotations:
str/int/float/bool,typing.Literal(→choices),enum.Enum(members matched by name),list[T](repeated or space-separated),Optional[T], andUnion[A, B](including PEP 604A | Bon 3.10+). Enums inside aUnion/Optionalare matched by member name, consistently with bare enum fields. - Positional arguments — a flag tuple with no leading dash (
("source",)); a positional with a default becomes optional (nargs="?"). - Full argparse passthrough via
Arg[T, NS(...)]—action,nargs,const,metavar,dest,choices, and any otheradd_argumentkeyword, plusNS(conflicts="group")for mutually exclusive groups. - Argument helpers:
Count(),Append(),Const(),Choice(),Extend(), and theUpdateActionaction. - Entry points:
duho.parser(cls)builds a parser;duho.parse(spec, argv)builds and parses in one call — passing an instance layers CLI overrides on top of its field values (CLI > instance > class default) and returns a new instance without mutating the original. - Command dispatch:
duho.main(cls, argv=None)builds, parses, sets up logging, and calls the selected instance's__run__()._subcommands_builds nested subparser trees automatically and dispatches to the deepest selected class. - Layered defaults: per-field environment variables via
NS(env="VAR")and TOML config files via_config_/config=, with the precedence ladder CLI > env > config > class default. Any layer supplying a value also un-requires that field.duho.value_sources(parsed)reports which layer won for each field. --version: set_version_to a string, or toduho.AUTOto resolve it from installed package metadata (_distribution_overrides the distribution name). When the distribution isn't installed, no--versionflag is added rather than printing a bogus version.- Shell completion: opt in with
_completion_ = Trueto add--print-completion {bash,zsh,fish}, or callduho.print_completion(). Scripts are generated statically — no runtime dependency and no re-invoking your program on every keypress. LoggingArgspreset —-v/-qcounted verbosity (offsetting, clamped at each end of the scale),--loglevelfor global or per-module levels, colored stderr output (optionalcolorama), and aTRACElevel.- Type hints ship with the package (
py.typed).
Notes
- Zero required runtime dependencies. Optional extras:
colorama(colored logging) andconfig(TOML on Python 3.9/3.10, wheretomllibisn't stdlib). - Supports Python 3.9 through 3.13.