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.7.8 - 2026-10-21
Added
duho.config_source(parsed)andduho.ConfigSource(path, source): the config file read for a parsed command, root or subcommand, and where its path came from ("argument","field","env"or"default");Nonewhen no file was read.duho.OutputArgs, a data mixin adding--format {text,json}and--jsonand the method_output_(value, *, text=None), which prints indented JSON or the text._env_prefix_on a class (a string, or a function of the class) gives every field without aMeta(env=)the variablePREFIX + FIELD_NAME.upper(); the preset fields keep none._traceback_env_on a root names the environment variable that replacesDUHO_TRACEBACKfor that program: whilemain,app,runor an MCP tool call run it, duho andduho.logging.traceback_enabled()read that variable, so a_report_error_hook sees the program's own switch. Outside such a runDUHO_TRACEBACKis read as before.duho.status_for(root, exc)returns the exit statusmainandappend with for an exception: aCommandError's code, else the first_errors_match, elseNone.duho.fanout.current_label, aContextVarholding the label text of the target a worker is running (Nonewithoutlabel=).duho.testing.Result.stdout_bytes: the standard output as written.invokecaptures standard output as a UTF-8 text stream over a byte buffer, so a command may writesys.stdout.buffer.
Changed
- A closed standard output ends silently with status 1 on Windows too: an
OSErrorwith errnoEPIPEorEINVALthat names no file and whose standard output also fails a flush counts as aBrokenPipeError, undermain,appandrun.runflushes standard output before it exits or returns, so a pipe that fails only there is the same silent status 1. AnEINVALfrom anywhere else still reaches_errors_. - An exception raised while the config file is chosen or read, a
_config_loader_'s included, is reported like a command's error when it is aCommandErroror matches the root's_errors_(the_report_error_hook or the default line, the same status, the sameDUHO_TRACEBACKrule), once the parse reaches it, so--helpstill works. Any other loader exception propagates as before. duho's own one-line config error calls the root's_report_error_with aduho.exceptions.ConfigErrorand status 2; without a hook its text is unchanged. In an MCP tool call theConfigErroris raised for the tool call to report.
Fixed
- A program whose standard output was silenced after a closed pipe no longer reports an unclosed file at exit when warnings are on: the null stream is closed before the interpreter's final flush.
0.7.7 - 2026-10-20
Fixed
run_targetsandfan_out_commandname a target by itslabeltext, not its repr, in the lines for a target that raised, returned a non-int or exited with a non-int code, so a target holding a credential no longer prints it. Withoutlabelthe lines are unchanged.add_logging_levelinstalls the level's method onlogging.Logger, on the current logger class and onlogging.LoggerAdapter, sologger.trace(...)works on an adapter and on every logger when a library set its own logger class before duho was imported.
0.7.6 - 2026-10-16
Changed
- A
BrokenPipeErrorraised while a command runs, or whilerunprints its answer, ends with status 1 and no output undermain,appandrun, with or withoutDUHO_TRACEBACKand before the root's_errors_is consulted, so anOSErrorentry no longer routes it to_report_error_. Ctrl-C still re-raises under the variable. - Under
DUHO_TRACEBACKa root with a_report_error_no longer gets a traceback printed by duho before the hook: the hook is called as without the variable and owns the report. A hook that wants a traceback prints it; a root without a hook re-raises as before. - In an MCP tool call a
CommandErroror an_errors_match on a root with a_report_error_calls the hook, with standard error and log output going to the call's buffer; what it writes is the error text in place of the bare message (which is kept when it writes nothing). Results for a root without a hook are unchanged.
0.7.5 - 2026-10-14
Added
duho.runpath.registration()returns the registration as a named tuple,RunPathRegistration(base, step_adapter, registered).duho.runpath.step_wants_context(entrypoint), the step runner's own test of whether a step's entrypoint takes the lifecycle context.duho.run(root, argv, exit="parse")lets aSystemExitraised while the command line is parsed (--help,--version, a usage error) propagate asduho.mainraises it, and returns the status for everything else.exitmay also be a functionf(status, error)that returnsTrue,Falseor"parse"._report_error_may take four positional arguments,f(message, code, error, command);commandis the dispatched instance,Nonefor an error raised while it was built.
Changed
- A root with a
_report_error_no longer re-raises underDUHO_TRACEBACK: the traceback is printed to standard error, the hook is called and the status is kept. A root without one re-raises as before. - A
CommandError, or an_errors_exception, raised while the selected command is constructed during the parse is reported like one raised while it runs, and ends the parse with its status; before, it propagated as a traceback. In an MCP tool call it is reported as one raised while the command runs, as before. duho.running_mode()is"mcp"while a tool call parses the command's arguments, including in its constructor, as well as while it runs.- A tool call's error result for a
CommandErroror an_errors_exception adds theexit code: N (meaning)line when the root's_exit_codes_declares that code. - A
_report_error_declared with*argsreceives four arguments, the command last.
0.7.4 - 2026-10-14
Added
duho.run(root, argv, exit=False)returns the status as anintinstead of exiting, in every case: aSystemExitbecomes its status, Ctrl-C is 130 and a broken pipe is 1. The default still exits._report_error_on a root class: a function called in place of the defaultPROG: error: MESSAGEline whenmain,apporruncatch aCommandErroror an_errors_exception, asf(message, code)orf(message, code, error). The status is unchanged.discover_commands(..., sort=False)keeps discovery order instead of sorting by name.duho.runtime.build_command_parser(command, root=None)builds the subparserappwould build for one resolved command.duho.runpath.preserve_registration(), a context manager that restores the base, the step adapter and whether the provider is registered.call_tool(output_path=...)withoutoutput_fdis aValueError: the path opens nothing.
Changed
- A config file that cannot be read, is not a table, has no backend or parser library, or
trips
"strict"or a_config_validate_function now ends with one line,PROG: error: MESSAGE, and status 2, without the usage lines. A value that fails to convert for a field keeps the usage form. - The strict unknown-key line cuts each key to 120 characters, escapes it, and shows at
most 20 followed by
and N more. A_config_validate_function still gets every path. - The field
_config_field_names (configwithConfigArgs) is never a settings key: a key of that name in the file is not applied and counts as unknown. - On Windows a relative
APPDATAis ignored byuser_path, as a relativeXDG_CONFIG_HOMEis elsewhere.
Fixed
--helpand--versionwork when the config file is malformed, is not a table or is in a format no backend reads, as the documentation said. The error was raised before the command line was read; it is now reported when a command would run. A tree served over MCP with such a file starts, and each tool call returns the error.
0.7.3 - 2026-10-12
Added
_config_validate_on a root class, to decide what happens to a key in the config file that nothing applies:"warn"logs one warning,"strict"is a usage error, and a functionf(args, config, unknown)orf(args, config)validates the whole file once the command line is parsed. The default still ignores such keys.- An empty config path means no file is read:
config="",--config ""(or--config=), and the_config_env_variable or the field's own variable set to the empty string._config_, a loader and_config_validate_are all skipped then. - A
_config_loader_that takes two arguments is called asloader(path, source);sourceis"argument","field","env"or"default", the place the path came from. duho.config.user_path(app, *names), a path in the user's configuration directory that is resolved each time it is used, so_config_ = user_path("tool", "config.toml")followsAPPDATAorXDG_CONFIG_HOMEas they are when the file is read.duho.app_name(obj), the name an application goes by, andduho.running_mode(), which is"mcp"whileduho.mcp.call_toolis running a command and"cli"otherwise.- An MCP tool can write output that is not UTF-8. The server sends it to file
descriptor 3 when it was started with that open, or to what the launch variable plus
_OUTPUT(TOOL_MCP_OUTPUT) holds: a descriptor number (3 or above), or the name of a file or of a pipe that the server opens for appending. The tool's result says how many bytes went where and at what offset. With no target the call fails with a message that says how to give one; a bad variable stops the server with status 2.call_tooltakesoutput_fd,output_varandoutput_path, andservetakesoutput_var. Output that is UTF-8 is returned as before. _mcp_output_on a command (a module-level name for a module command) chooses where its standard output goes as an MCP tool:"auto"(the default),"stream"(all of it to the output target as written) or"result"(all of it in the result, bytes that are not text as an embedded base64 resource).
Changed
- In an MCP tool call
sys.stdoutnow has abuffer, so a command can write bytes. A program that told a tool call bysys.stdouthaving nobufferusesduho.running_mode(). - A
_config_env_variable set to the empty string now means no file, where it was skipped and_config_was read.
Fixed
- The documentation said a
ConfigErrormessage never holds text of the document. It never holds a value; it can name a key, a section, or a YAML alias or tag, cut to 120 characters. The messages themselves are unchanged. class App(ConfigArgs, LoggingArgs, Cli)type-checks:ConfigArgsdeclares_config_field_with the typeCligives it.- An INI read error states its line in the message (
not a key and value (at line 3)), as the other formats do; it was only inlineno. - The documentation said that neither
_config_field_nor_config_env_is applied to a tree served over MCP, and left out that the file_config_names is read there. It now says so.
0.7.2 - 2026-10-09
Added
duho.config, a module that reads and writes JSON, TOML, YAML and INI:ConfigBackend(the contract),JSONBackend,TOMLBackend,YAMLBackend,INIBackend, andload,loads,dump,dumps,backend_for,get_backendandbackend_names. AConfigBackendsubclass that setsnameregisters itself;replace=Truetakes over a built-in. Its errors areConfigError(withpath,lineno,colnoand no document text),UnsupportedFormatErrorandConfigDependencyError, induho.exceptions.duho.ConfigArgs, a preset that adds--config/-c FILEand reads the file it names. The path must exist, or the command line is a usage error._config_backends_on a root class, to limit the formats a config file may be in, or to read them under other names and suffixes; the module functions take the same set asbackends=.- The
yamlextra (PyYAML), andtomli-win theconfigextra, for writing TOML.
Changed
- A config path ending
.yaml,.yml,.inior.cfgis read as that format; it was read as TOML. A name no format claims is still TOML. - A class gets the reversible
--no-*form of itsboolfields when any of_config_,_config_env_or_config_field_is set; it was only_config_.
0.7.1 - 2026-10-07
Added
duho.InitParserKwargs, aTypedDictof the keywords_parser_passes to_initparser_. An override annotates its keywords with it and forwards them, and a type checker follows them to the base method:
@classmethod
def _initparser_(cls, parser, **kwargs: Unpack[InitParserKwargs]):
parser = super()._initparser_(parser, **kwargs)
...
return parser
Changed
_initparser_(parser, **kwargs)takes its build keywords by name only; they were positional-or-keyword parameters. duho always passed them by name, and an override that forwards*args, **kwargskeeps working unchanged. The base method raisesTypeErrorfor a keywordInitParserKwargsdoes not list. A keyword duho adds later is a new key of thatTypedDict, not a new parameter, so an override with the signature above needs no change.
0.7.0 - 2026-10-07
Removed
NSis gone fromduhoandduho.args. Field metadata is written asMeta(...), or as a plaindictfor a keyMetadoes not have.
Changed
Meta(...)takes keyword arguments only. A positional argument is aTypeError; when the first one looks like a flag, the message says to pass it asflags=(...). An unknown keyword is still aTypeError, and now names the closest field.help(Meta)andinspect.signature(Meta)list the keyword-only parameters.Choice,Const,Count,AppendandExtendreturn aMeta, andMetahas asplitfield, the oneExtendsets.- Field metadata has two forms. A
Meta, or a subclass of it, is strict. Anything else that carries keys is permissive and read like adict: adict, or any other object with attributes, such as anargparse.Namespace, whose public attributes are its keys. The keys duho knows are used. A key that nothing claims is ignored and logged once per field as a WARNING onduho.args, naming the key and the closestMetafield; keys that a custom argument's own builder declares are passed to it without a warning. A class placed in a field's metadata is not read. - The warning for a flag-shaped
helporenvonMetais gone: the mistake it reported,Meta("-n", "--name"), is now an error.
Migrating from 0.6
- Replace
NS(withMeta(and fix the import:from duho import NSbecomesfrom duho import Meta. - A
TypeErrorfromMetanaming an unknown field means that key was being ignored before: delete it. If the key belongs to a custom argument you wrote, write that field's metadata as adictinstead:dict(shout=True). - A
Meta("...")call with positional arguments names them:Meta(help="..."), and a flag goes inflags=("-n", "--name"). - A field that gives an
argparse.Namespaceas its metadata directly keeps working, as the permissive form.Meta(...)is the form that catches a misspelled key.
0.6.5 - 2026-10-07
Added
All of these are optional and default to the behaviour you already have.
discover_commandstakes a list of sources as well as one. Each is discovered on its own and a command with the same name in a later source replaces the earlier one.on_error=is a function(source, exc)called for any exception raised while importing or building one command; return to skip it, raise to stop. Without it, onlyImportErrorandNotImplementedErrorare skipped, with a warning, as before.providers=Truealso offers each directory under a filesystem source to the registered command providers.duho.app(source=[...], on_error=..., adapter=...)takes the same lists and error policy.on_erroris also called, with(command, exc), when one command's parser orregisterhook fails, and that command is dropped.adapter=is a function that receives a module command's entry point and returns the callable to run in its place (a falsy return keeps the entry point);duho.run_command(command, instance, adapter=...)takes it too. Combiningadapter=withdispatch=raisesValueError: pass it torun_commandfrom yourdispatchfunction.ModuleCommand.entrypointreturns the callable a module command runs, andduho.runtime.accepts_positional(func, count)tells whether a callable takes at leastcountpositional arguments (or*args).duho.command_name(command)gives a command's name.duho.subcommand(Group)registers the decorated class under anyCmdgroup, not only aCli.unregister_command_provider,is_class_command,is_module_commandandimport_from_pathare now importable fromduho,ClsArgDeclarationfromduho.args, andmainis listed induho.scaffold.__all__._allow_passthrough_ = Falseon a command makes a non-empty--tail a usage error (exit 2) naming the command. The default,True, captures the tail in_passthrough_as before._default_subcommand_ = "name"on a group names the subcommand to use when the first word after the group's own options is not a subcommand or alias:tool bobrunstool resolve bob. A name that is not a registered subcommand raisesValueErrornaming the class when the parser is built.Meta(enum_by="value")(orNS(enum_by="value")) matches anEnumfield by the text of its value instead of its member name, on the command line, in the environment and in config. Help, completion, agent help and the MCP schema list the value text and the field still holds the member. The default is"name".Meta(literal_value=True)(orNS(literal_value=True)) on an option that takes one value makes it always take the next word, so--key --and--key -xgive the option that value. The default isFalse. An abbreviated long flag and a short flag inside a cluster (-vk -x) are not covered.- A converter you pass as
type=inNS(...)orMeta(...)that raisesValueErrororTypeErrorwith a message now has that message in the usage error, for exampleargument --port: port must be 1..65535, instead ofinvalid parse_port value. Builtin types, duho's own converters, an empty message andargparse.ArgumentTypeErrorkeep their text. - Two mistakes are now reported once, as a
WARNINGon theduho.argslogger, when the parser is first built: a class attribute such as_verison_that nearly matches one duho reads (did you mean '_version_'?), and anNS(...)key such ashlepthat is not aMetafield. Neither is an error and the key is still ignored. duho.text.parse_bool(text)(alsoduho.parse_bool) parses a strict boolean:1/true/yes/on/y/tand0/false/no/off/n/f/(empty), ignoring case and surrounding space; anything else raisesValueErrorlisting the accepted words.duho.text.BOOL_TRUEandBOOL_FALSEare those tables.run_targetsandfan_out_commandtakelabel=, a function from a target to the text of its[...]log prefix. The default isstr(target).LoggingArgs._base_loglevel_(defaultlogging.INFO) is the level-vand-qstep from. A plainCmdunder aLoggingArgsroot uses the root's value.duho.testing.invoke(root, argv, env=, stdin=)runs a command line in the current process and returnsResult(status, stdout, stderr), for tests. It is imported only when you use it. Extra keyword arguments go toduho.app._completion_command_ = True(or a name) on a rootCliadds a subcommand,completionby default, that prints the completion script forbash,zsh,fishorpowershell. The default,False, adds nothing;--print-completionis unchanged._config_env_names an environment variable that holds the config file's path, and_config_field_names a declared field that holds it. Order, highest first: an explicitconfig=, the field (when given on the command line or in its own variable), the variable, then_config_. A path chosen by the field or the variable must exist, like an explicitconfig=.- On Python 3.13 and later the first parser build of a class in a large file reads that class's own source instead of parsing the whole file. This applies to the first few classes of a file; after that the file is indexed once, as before, and a small file is always indexed. Results are the same.
- A command can end with a message and a status by raising
duho.CommandError(message, code=1)orduho.UsageError(message)(status 2).duho.mainandduho.appprint<prog>: error: <message>to stderr and return the status. A root can map its own exception types the same way with_errors_ = {ValueError: 2, OSError: 1}; the default,None, catches nothing. WithDUHO_TRACEBACKset the exception is raised instead. duho.run(App)is a program entry: it runs the app, prints what the command returned when that is not a status (astras it is, anything else as JSON) and exits 0, exits with the status otherwise, and ends Ctrl-C (130) and a closed output pipe (1) silently.duho.mainandduho.appstill return the command's value unchanged for a caller that wants it.duho.Result(code, value=..., text=..., is_error=...)is an exit status that carries an answer. It is anint, sosys.exit(result)works.duho.runprintsvalue; an MCP client receivestext, orvalue, as the result, withisErrortaken fromis_error(by default, a non-zero code).- Over MCP, a
CommandErroris reported as its message, without the exception's type name, and when the root declares the meaning of an exit code in_exit_codes_, the result's last line readsexit code: 1 (<meaning>). - The MCP server negotiates protocol revision
2025-11-25. In a connection on that revision, arguments that fail a tool's schema come back as a tool result (isError: true, the message as text) that the model can read and correct; on older revisions they stay a JSON-RPC-32602error, and an unknown tool is a-32602error on every revision. - A RunPath
__main__.pythat defines__all__has only theinit,successandfinally_names listed there treated as lifecycle hooks. Without__all__, every such name is a hook, an imported one included, as before. Meta("-n", "--name"), where flags were given by position and becamehelpandenv, logs oneWARNINGon theduho.argslogger saying so. The values are used as before.
Changed
- An application now has one name, used for the usage line, the
--print-completionscript, the<NAME>_MCPlaunch variable, MCP tool names andserverInfo.name, and the default logger. It isduho.app(name=...), else the root's own_parsername_, else the top-level package the root class is defined in, else the kebab-case of the class name. It no longer depends on how the program is launched. What you may see change: - A root with no
_parsername_that lives in packagepkgis now namedpkg, not the kebab-case of its class (my-app), in the usage line and in MCP tool names (pkg.deploy, notmy-app.deploy). A script run directly keeps its class name. - The completion script and the
<NAME>_MCPvariable follow the app's name, not the file name of the script:python app.py,python tools/run.py,python -m pkgand an installed console script all give the same result. - A command's
-v/-qnow raises the application's logger. This was the command's own name for a command that is aLoggingArgs, and the root's name for a plainCmd. - A
_logger_name_on a root now applies to every command it dispatches. Declare_parsername_(and_logger_name_for the logger) to keep a name fixed wherever the class lives. duho.args,duho.mcp,duho.runtime,duho.runpath,duho.completionandduho.discoveryare now packages of private submodules instead of single files. Every import path, public or private, still resolves, andpython -m duho.mcpruns as before. What you may see change:- An object's
__module__(and so itsrepr) names the private submodule that defines it, for exampleduho.args._argsclass, notduho.args. - Code that rebinds the private
duho.args._AUTO_VERSION_CACHEon the package no longer affects duho: the cache lives induho.args._naming. - A malformed JSON or TOML config file, or one whose top level is not a table,
is now a usage error (exit 2) naming the file and position under
duho.main,duho.parseandduho.app, where it used to end in a traceback or aValueError. An exception raised by your own_config_loader_is not converted: it reaches the caller as before. A loader that returns something that is not a mapping is a usage error naming the file. With no TOML reader installed,--helpand--versionstill work and any other run reports thatduho[config]is needed;duho.appstill raisesRuntimeErrorfor that. - A parser from
duho.parser(cls)orcls._parser_()now applies the class's own_config_file, asduho.parseandduho.maindo. Before, it applied onlyNS(env=...). - A subcommand alias equal to a sibling's name or alias now raises
ValueErrornaming both classes when the parser is built, as two equal names already did. Python 3.9 used to dispatch silently to the wrong command, and 3.14 raised an unnamed argparse error. A class listed twice in_subcommands_is registered once. - A string literal after a field that is only a flag,
("--dry-run")without the trailing comma, is now a build error naming the field and the missing comma. A help string written after a flag tuple is now used as the help. Consecutive string literals after a field are joined with a space instead of all but the first being dropped. - A bare
Noneannotation is refused when the parser is built, naming the field. AnAnyorobjectannotation used to build and then reject every value; it now takes the text as given. - Instances are more alike however they are made. Every
ArgsandCmdinstance has_passthrough_ == [], a parsed subcommand instance no longer carries a private_duho_command_attribute, so it equals the same instance built directly, and an optional positional with no default isNoneon a directly built instance, as on a parsed one. A field with no default is still left unset on a direct instance. duho.parse(instance)now keeps a field you assigned after building the instance. A field counts as set when it was passed to the constructor or its value differs from the default.- Directory discovery appends the command directory to
sys.pathinstead of putting it first, so the standard library and installed packages win over a command file with the same name as one of their modules. A helper that shadowed an installed module on purpose no longer does. - Directory discovery skips the script that is running, so a launcher that
scans its own directory no longer lists itself as a subcommand, and ignores
a command file with an upper-case
.PYsuffix on Windows, as it already did elsewhere. - Over MCP, an error from parsing a tool's arguments now returns only the
error line, without the usage line. A command excluded with
_mcp_ = Falseis never named in it. A root's_mcp_ = False(which only disables the environment trigger) no longer removes the commands that subclass the root from the tool list; a command's own_mcp_ = Falsestill does. tools/listand the agent-help document no longer publish a default for an option added directly to an argparse parser, for example by a module command'sregisterhook. Fields declared with duho keep theirs.- Colour in log output is now off when
TERM=dumb, even on a terminal, unlessFORCE_COLORforces it. An emptyNO_COLORstill turns it off. add_logging_levelnow raisesValueErrorwhen a name it installed is registered again at a different number (force=Truerenumbers). A repeat at the same number is still a no-op.expandraisesValueError, notIndexErrororKeyError, for a format suffix containing braces; the:specsuffix (host[1-3:02d]) is documented.- The
coloramaextra now requirescolorama>=0.4.6,<0.5, theconfigextratomli>=2.0,<3(Python below 3.11), and building needshatchling>=1.27. The package metadata links the changelog and no longer carries theSystem :: Shellsclassifier. - An MCP client that asks for a protocol revision the server does not know,
or for none, is answered with
2025-11-25, the newest it supports. It used to be answered2025-06-18. - A helper module next to the command files of a scanned directory
(
_helpers.py) is loaded once and kept for the life of the process, so re-scanning the directory no longer runs it again. Its__name__is a private name underduho._discovered, and it is not insys.modulesunder its bare name. A command file and its helpers seebuiltinsas renewed before each command runs.
Fixed
_version_ = duho.AUTOon a root that also sets_mcp_command_looked up duho's own distribution and reported duho's version; it now looks up the application's own distribution, like any other root.- A
help=callable that reads a module-level name raisedNameErrorwhen help was shown, on Python 3.10 and later, in a module that starts withfrom __future__ import annotations. It now works there too. - Options and
--handling: - An option written after a subcommand name now always binds to the subcommand, also when the root declares an optional or variadic positional. It used to be taken by the root's same-named or prefix-matching option.
- A required option declared on a root and inherited by a subcommand is satisfied by a value given before the subcommand name; it used to be demanded a second time.
- A cluster of short options such as
-vvor-vaobetween positionals now parses as it does anywhere else. duho.parse_globalsno longer reads an option written after the subcommand name as a root option when the root has a static_subcommands_tree, matchingduho.parse.- Under
duho.app, a command that redeclares a root option's default no longer changes that option for the other commands, and a value given before the subcommand is no longer overwritten by a sibling's default.duho.appalso no longer builds the root's static subcommand parsers a second time. - A subcommand group that inherits
_subcommands_from a base class now has those subcommands when nested under another command, as it does as a root. duho.appwith no resolved commands runs a runnable rootCmd, and otherwise says no command is available instead ofrequired: {}.- A bad env or config value for a module command fails only that command
(exit 2), not every invocation including
--help. Aregisterhook of a module command is no longer called withargs=Nonewhen the root has a required option that was not given; the real parse reports the usage error or the help text. - The
Append,Choice,Const,CountandExtendhelpers accepthelp,env,group,conflicts,conflicts_requiredandflags, and a rawkwargs={"help": ...}overrides the derived help instead of failing. - The
"--"flag shorthand now also expands insideMeta(flags=...)andNS(flags=...), and list flags are accepted. An empty or setflagsvalue raises aValueErrornaming the field. The error forMeta(dest=...)now says an argument'sdestis always its field name; it used to point toNS(dest=...), which does nothing. LoggingArgs.loglevelsread from a config table now accepts level names and numbers per logger, converted like--loglevel NAME:LEVEL. A malformed integer log level such as--5, or one with a non-ASCII digit, is an ordinary invalid-value usage error naming it, not a bareValueError.- A field named
selfcan be declared, parsed and constructed like any other. A field named like its own builtin type (bool: bool = False) builds from the class source on every Python instead of raising depending on its sibling fields. - On Python 3.9 and 3.10 a quoted type inside a builtin generic
(
list["Color"],dict[str, "int"]) now resolves as on 3.11 and later, and an unresolvable name is reported naming the field. On 3.14 a class whose source cannot be read (a frozen app) no longer loses all its fields when one private annotation cannot be resolved. On 3.13 and later a decorated class defined twice under one name (if/else) resolves its flags and help from the branch that ran. - A custom type that defines
_argbuilder_keeps its own builder (factory, metavar, choices) when used as anOptionalorUnionmember, alist/set/tupleelement or adictvalue, not only as a top-level field. - A bad environment or config value for a field with a custom action is now a usage error that does not echo the value, instead of a traceback showing it.
- A bare-string source naming a directory without
__init__.py, such assource="cmds", now loads its files as loose command files, so sibling imports like_helpers.pywork. - Importing a command or step file from several threads at once runs its module body once, not once per thread.
- A command file whose
main,runorcallis a wrapper from a decorator defined in another file now logs a warning that names the__all__escape hatch, instead of being ignored silently. - The step order
RunPathuses when it breaks dependency cycles no longer varies between runs. import duhono longer fails when another library already definedlogging.TRACEorLogger.trace; an existing integerTRACElevel is reused. An explicitadd_logging_levelcall still raises on a foreign name.- Logging an exception from a fan-out target through a
QueueHandlerno longer loses its traceback. - The log handler duho installs writes to the current
sys.stderr, socapsysand a swappedsys.stderrsee its output.handler.setStream(...)still pins a stream. - MCP argument handling:
- A tool value starting with the parser's
fromfile_prefix_charscharacter is refused as an invalid argument instead of being read as an argument file. - A tool below a command with an optional single-value positional runs without the client sending that positional; its default is passed. A variadic or env/config-layered positional is not pinned, and a shifted dispatch is still refused.
- A module command whose
registerhook adds its own subparsers is listed as one tool, and can be called by choosing the subparser with a property named after the subparsers'dest. The hand-made subparsers are not listed separately and their own options are not served. - A command served over MCP no longer sees a private path-marker entry in its instance variables.
- An
app()dispatch=callable now also wraps every command served through the<NAME>_MCP=stdiotrigger. - With that trigger, output printed while commands are discovered goes to stderr, not the protocol stream.
- The server rejects
NaNandInfinityas a parse error, answers-32600for a missing or wrongjsonrpcmember or anidthat is not a string, a number or null, ignores client response objects, and accepts a whole-number float such as5.0for an integer field. python -m duho.mcpbehaves like a duho CLI:--helpand--versionprint to stdout and exit 0, and a missing app, an unknown option or an extra argument exits 2 with nothing on stdout. It closes its two protocol streams when the app spec does not resolve.- Completion scripts: zsh no longer breaks, or runs text, when a flag name
contains a colon, bracket, backslash or whitespace, and offers a choice that
begins with
=as written. fish shows a subcommand description with its percent signs as written instead of doubled. python -m duho.scaffoldreports an invalidapp,libdirorpythonvalue as a one-line usage error (exit 2) instead of a traceback.- Every public function and method is fully annotated, and its annotations
resolve with
typing.get_type_hintson Python 3.9 as on 3.14, so tools that read signatures at run time work.duho.run_commandand thedispatch=callback are typed as returningAny, likeduho.mainandduho.app: a command's return value that is not anintpasses through. - Helper modules of a command directory are one module for the whole
directory. Two command files that import the same helper share its state
and its classes; a command that inherits flags and help from a base class
in a helper shows and parses them; and an
importof a helper inside a function works when the command runs. Each used to get its own copy of the helper, the base class's flags and help were lost, and the late import raisedModuleNotFoundError. - The wheel and sdist no longer ship
*.local.*orCLAUDE*files when built from a checkout whose path makes hatchling ignore.gitignore.
Notes for readers upgrading from 0.6.1 or earlier
Since 0.6.2 a command name derived from a class name is kebab-case
(DeployAll is deploy-all). That also changed, without an error, the
config table key ([Deploy] is now [deploy]), the name in
--loglevel NAME:LEVEL, and MCP tool names. Declare _parsername_ on the
class to keep an earlier name.
0.6.4 - 2026-10-06
Fixed
--flag=--and-f--now give the field the value--on every supported Python. On Python 3.9 through 3.12.6, and on 3.13.0, argparse dropped it: the field silently received an empty list (constfor anargs="?"option, and a list field lost the item). Python 3.12.7, 3.13.1 and later already kept it.- An MCP tool call may pass
"--"as an option value; it was refused with an invalid-arguments error. Still refused:--as a positional value, and for a field that has only a short flag.
0.6.3 - 2026-10-03
Fixed
- A
choicesvalue containing a NUL byte no longer corrupts the generated fish or PowerShell completion script as a whole (every other candidate stopped completing too). The NUL candidate is now dropped; every other choice keeps completing normally. - A layered (env/config) conversion error for a field whose
type=is a bound__getitem__/.getlookup on a mapping (e.g.NS(type=COLORS.__getitem__)) now describes what it accepts — "one of: green, red" for a small mapping, or "a key of<OwnerType>" for a larger one — instead of "expected__getitem__", the factory's own internal name. The rejected value is still never echoed. - An MCP server no longer reports duho's own version as the served app's
version.
initialize'sserverInfo.versionis the app's own_version_when it resolves to a string, otherwise the empty string — never duho's. - A command class created at runtime with
type(...)in an ordinary source module no longer logs a "no source ClassDef found" WARNING per class; it never had a class body to read, so nothing is lost and it is logged at DEBUG. The WARNING stays for a module with no readable source at all (a frozen app, a.pyc-only install, a zipapp, the REPL), where class-body flags and help text really are lost.
Added
- A dev-only test drives
python -m duho.mcpthrough the officialmcpSDK's own client (initialize, list tools, call a tool, call an unknown tool) as a conformance check against duho's hand-rolled stdio server. Requires themcppackage (Python 3.10+ only); skips cleanly without it.mcpis never a runtime dependency.
Changed
- The CI benchmark regression gate (
benchmarks/check_baseline.py) now normalises for runner speed: two fixed, duho-independent calibration workloads (one in-process, one a subprocess spawn — each matching one gated group's own measurement domain) are measured alongside the gated metrics each run, and every metric's ratio to its baseline is divided by its group's own calibration ratio before the 1.5x/1.3x thresholds apply. A uniformly slower or faster shared CI runner no longer trips the gate on its own; a regression confined to duho's own code still does. Dev/CI tooling only — no runtime behavior change.
0.6.2 - 2026-10-01
Added
duho.text.kebabcase(s) -> str(alsoduho.kebabcase) — acronym-aware kebab-case: splits on a lower/digit→Upper boundary, on an Upper letter followed by Upper+lower (an acronym run stays together up to its last letter:ShowHTTPStatus→show-http-status, unlikesnakecase's letter-by-letter handling), and on one or more_(never a leading, trailing, or doubled-:_Private→private). This is the rule now behind a class-derived command name and a field's default long flag (see Fixed, below).- A flag tuple may use
"--"as shorthand for a field's own default long flag:("--",)→("--dry-run",),("-n", "--")→("-n", "--name"). Any other entry passes through unchanged. A tuple with"--"more than once raises a build-timeValueErrornaming the field. duho.utf8_stdio(streams=None) -> list[str]— reconfiguressys.stdout/sys.stderr(or an explicitstreams=mapping) to UTF-8 in place, skipping a real terminal, a stream already UTF-8, a stream with no.reconfigure(), Python's own UTF-8 mode, or an explicitPYTHONIOENCODING. Never raises; idempotent.duho.main/duho.appcall it first thing, before the MCP launch trigger and beforeargvis parsed. Opt out with a root class attribute_utf8_stdio_ = False(declared onCli, defaultTrue) or amain(..., utf8_stdio=...)/app(..., utf8_stdio=...)kwarg (wins over the class attribute).
Changed
- Piped/redirected stdout and stderr on a non-UTF-8 host locale (Windows'
default
cp1252for captured output, most notably) are now reconfigured to UTF-8 by default instead of raisingUnicodeEncodeErroron the first non-ASCII character. Seeduho.utf8_stdioabove for the opt-out.
Fixed
- A class with no own
_parsername_is now named with the kebab-case of its class name, as intended, instead of the exact (mixed-case) class name:BuildPyz→build-pyz. Visible everywhere a resolved command name is used — the--helpusage/prog, the subcommand name on the CLI, andLoggingArgs's default logger name. An explicit_parsername_, anapp(name=...), a_parseraliases_alias, and a discovered MODULE command's own file-stem name are unaffected — only the bare class-name fallback was ever wrong. Two sibling commands whose names now collide (FooBarandFoo_Barboth kebab tofoo-bar) raise a clear build-timeValueErrornaming both classes, the same as an explicit duplicate_parsername_on two siblings does. - A field's default long flag (no declared flag tuple) is likewise
kebab-case of the field name, not the older plain
name.replace("_", "-"):testMe→--test-me,HTTPPort→--http-port. An already-snake_case name is unaffected (dry_run→--dry-run, same as before), and an explicitly spelled flag, the field's own attribute/dest name, a config/env key, and a positional name are all untouched. --versionno longer crashes withUnicodeEncodeErrorwhen the resolved version or program name contains a character outside the stdout stream's encoding and UTF-8 stdio wasn't already in effect (opted out, or a duho-built parser used outsidemain/app). The injected--versionaction now writes through the same crash-proof path--helpalready used, falling back toerrors="backslashreplace"instead of raising. duho's own stderr messages (an unsupported MCP transport, aduho.mcp/duho.scaffoldCLI error) go through the same path instead of a rawprint(..., file=sys.stderr).
0.6.1 - 2026-09-29
Fixed
--loglevel app:LEVELnow also reaches the dispatched command's own logger when that logger is inside the named subtree (for exampleapp.scan). Previously the-v/-qdefault applied to the command's logger overrode the ancestor you named, so--loglevel app:DEBUG scanstill loggedapp.scanat INFO. When no ancestor is named,-v/-qset the command's logger as before.
0.6.0 - 2026-09-28
Added
- Launch MCP from the CLI itself, with zero app code. Every
duho.main(cls)/duho.app(...)call now checks a<PREFIX>MCP(anEnv(prefix)app's own prefix) or<NAME>_MCP(derived from a declared_parsername_,app(name=...), the program name, or the class name) environment variable before parsingargv: set tostdio, it serves the app's full tool tree over stdio instead of running any command; set to anything else, the process exits2naming the unsupported transport. The variable is always removed fromos.environthe moment it is seen (present or not), so a served command's own child processes never inherit it. On by default; a root class attribute_mcp_ = False(declared onCli) orapp(..., mcp=False)disables it. Neither trigger — nor a normal CLI run in general — importsduho.mcpunless it actually fires. duho.mcp.McpCmd— a readyCmd(--transport {stdio}) whose__call__callsduho.mcp.serve_running_app(transport="stdio"), which serves the CLI currently being dispatched (reading the running app's own already-built tree from a contextduho.main/duho.apprecord around their own dispatch step — no rediscovery). Register anMcpCmdsubclass under any name for a self-serving MCP subcommand, or let the new opt-in_mcp_command_class attribute (declared onCli,Union[str, bool], defaultFalse) do it for you under eitherduho.mainorduho.app—app(..., mcp_command=...)overrides the class attribute per call (duho.mainhas no such kwarg; it always reads the class attribute). Either way:Trueregisters it as"mcp", a non-emptystrregisters it under that exact name, and its--helprow reads "Serve this CLI as an MCP server". Requires the app/class to already have at least one other subcommand, and a name colliding with an existing command/alias is a build-timeValueError— both checked before anything is registered, with the exact same validation and message text under either entry point. A node whose class is (or subclasses)McpCmdis never itself listed as (or callable as) an MCP tool.duho.mcp.describe_tools/call_tool/servenow also accept a fullduho.app()-built tree, not just a class's static_subcommands_: a module command — with its own declaredArgs, or none at all — is listed and callable exactly like a class command, through the same one dispatch path and the same security checks (dispatch-path verification, safe argv synthesis, schema validation) the static-tree path already had. Tool names now useapp(name=...)as the root segment when given (falling back to a declared_parsername_/the class name otherwise), instead of always the class-derived name —app(Dotagents, name="dotagents")producesdotagents.*tools, notDotagents.*.initialize'sserverInforeports the served app's own resolved name (the same one) and its own_version_when one resolves to a string, rather than a fixed{"name": "duho.mcp", "version": <duho's own version>}for every app — duho's own version remains the fallback when the app declares none.- A command (class or module) can opt itself out of the MCP tool surface
entirely with
_mcp_ = Falseon a NON-rootCmd/Cli(a module command via a module-level_mcp_ = False) — hidden fromtools/listtogether with its whole subtree, and refused bytools/callthe same way an unknown name is, disclosing no existence either way. A subclass of an excluded command is excluded too, without redeclaring it. The ROOT's own_mcp_keeps its separate, pre-existing meaning (the launch-trigger opt-out above) and is never treated as this per-command exclusion. AGENTS_HELPis now accepted alongsideAGENT_HELPas the env var that flips--help/agent-help into machine-readable output — either truthy triggers it.duho.agenthelp.DEFAULT_ENVSis the new("AGENT_HELP", "AGENTS_HELP")tuple; an explicit_agent_help_env_still names exactly one variable, replacing both defaults (no aliasing).duho.finish_parse(namespace)— builds the real command instance from aNamespaceproduced by attaching a duho subparser to a plain, hand-built argparse root (the documented "manual subparsers" recipe).duho.parsersgains four public functions:find_subparsers,strip_subparsers,unique_subcommands,command_name— the shared subparser-lookup/alias-dedup walk previously duplicated by hand across several modules.duho.discoverygains two public functions:import_from_path(base_name, path)(unique-name-guaranteed, mtime-cached file import) andunregister_command_provider(predicate, builder)(the counterpart toregister_command_provider, for test isolation / plugin reload).- [minor]
duho.mcpgains two public exception classes,UnknownToolErrorandInvalidArgumentsError(bothValueErrorsubclasses carrying a JSON-RPC.codeof-32602).call_toolnow raisesUnknownToolErrorfor a tool name that does not resolve, andInvalidArgumentsErrorwhenargumentsis not a JSON object or fails the tool's own schema — previously a malformed request like this was returned as an ordinaryisError: truetool result indistinguishable from the dispatched command's own failure. A problem in the dispatched command itself (a raised exception, a non-zero exit,sys.exit) is still reported as anisError: truetool result, not an exception. duho.completion.spec(parser, prog=None) -> CompletionSpec— public completion-spec builder (bash/zsh/fish/PowerShell already shared its private predecessor). AnEnum-typed field now offers its member names as completion candidates. An option or subcommand hidden viahelp=argparse.SUPPRESSis no longer offered by any of the four completion scripts.duho.print_completion,duho.parser, andduho.parseaccept an explicitprog=override (parser_kwargs={"prog": ...}forparser/parse).duho.command()gained an optionalmodule=keyword-only override (the caller's module is now inferred correctly by default; only needed to override it explicitly).LoggingArgsgained long flag spellings--verbose/--quietalongside-v/-q.Metagainedflags=anddefault=fields — typed equivalents ofNS(flags=...)/NS(default=...).duho.value_sourcesreports a new"instance"source for a field that came from an instance passed toduho.parse.duho.parse_globalsaccepts aconfig=keyword argument, mirroringduho.parse/duho.main.- A module command's own declared fields (a module-level
Argsclass) now supportNS(conflicts=...)(mutually-exclusive groups) andNS(group=...)(titled groups) — previously only a class command's fields could use either. Cli.subcommand/command()/duho.parse/duho.parse_globals/duho.parserare now generic under a type checker: they return the caller's own class/type instead of widening toArgs/Cmd/Any. No runtime change.- CI now runs on Python 3.14 in addition to 3.9–3.13, with a
black --checkformatting gate and a job that installs the built wheel and verifiespy.typed/README.md/AGENTS.mdreached it. benchmarks/results/is no longer gitignored, so saved results can be committed;benchmarks/README.mddocuments the result schema and reproduce commands.bench_discovery.pynow also supports--save/--json.
Changed
duho.app()now lists subcommands in alphabetical order in the usage line's{...}choices, where 0.5.4 used registration order. Dispatch is unchanged. (Added after the 0.6.0 release; found by a downstream smoke test that compared the exact usage text.)pyproject.tomlnow declareslicense = "MIT"/license-files = ["LICENSE"](PEP 639) instead of the legacylicense = {file = "LICENSE"}table plus classifier; the built wheel's metadata now carriesLicense-Expression: MIT. The wheel's file layout is unchanged, but it now relies on hatchling's ownsrc/-layout auto-detection instead of a pinned package list.- The release workflow's test gate now reuses the test workflow in full instead of a narrower copy, so the coverage/benchmark/shell-completion gates run on every release. GitHub Pages deploys are now owned entirely by the docs workflow (including on a published release); the release workflow only gates on a strict docs build.
DUHO_TRACEBACK=n/=fare now read as "off" (they used to enable tracebacks) — the traceback-toggle env var now shares its truthy/falsy token table with every other boolean in the framework.Env.boolnow strips whitespace before matching and shares its truthy table with the rest of duho, instead of a separately hand-kept copy.boolis no longer accepted as a CLI text factory wherever it appears as aLiteralmember, aUnionmember, or alist/set/tuple/dictelement/value type — such a value now parses through the same strict token table as a plainboolfield and the env/config layer.- A
list[T]/set[T]/tuple[T, ...]/dict[str, V]element or value type now goes through the full type ladder instead of being converted raw: anEnumelement matches by NAME, adate/datetime/timeelement parses ISO text, and aLiteralelement enforces its declared choices. - A
Literalcomposed into a multi-memberUnion(e.g.Union[Literal["auto"], int]) now enforces its declared choices before falling through to a later member. - An env/config value that cannot convert without losing information (a
fractional
floatinto anintfield, alist/dictinto a scalar field, aboolinto a non-bool field) now raises instead of silently truncating, stringifying, or passing through unchanged. AUnion[int, str](or similar) field's config value that cannot losslessly become itsintmember now falls through to itsstrmember instead (1.5->"1.5", not the previous silently truncated1). A config value fed into apathlib.Path-typed field is now converted to aPathinstead of staying an unconvertedstr/int. - A conversion error from an enum/dict/
Literal/Unionfield now shows duho's own message on the CLI (e.g.invalid choice: 'PURPLE' (choose from RED, GREEN, BLUE)) instead of argparse's generic error or a raw object repr. - An annotation the type ladder doesn't recognize now either works correctly
(
frozenset[str]) or fails with a clear message naming the field at parser-build time (an unsupported generic, a PEP 695type X = ...alias, atyping.NewType, orAnnotated/Arg[...]nested inside aUnion), instead of silently misconverting values or crashing with an unrelated error. datetime/timefields now accept a trailingZ(RFC 3339's UTC marker) on Python 3.9/3.10 too, matchingduho.mcp'sformat: date-timehint, which already advertised it on every version. Adatefield never accepts a trailingZon any version (a date has no time/UTC component). A basic, no-dash format (20240101) works fordate/datetimefields on Python 3.11+ (nativefromisoformataccepts it there); it remains unsupported on 3.9/3.10.- An
Enumfield's CLI value now matches againstenum_cls.__members__, so a declared alias name is accepted, and aFlagcomposite member's name is accepted on Python 3.11+ too (previously rejected there). - A
Literalof specificEnummembers (e.g.Literal[Color.RED, Color.BLUE]) now resolves an input by member NAME, matching the{RED,BLUE}metavar it already advertised (a name previously raised). - A
list/dict[str, V]option's first CLI occurrence now REPLACES a class/env/config/instance default instead of appending/merging onto it, matching howset/tuplefields already behaved and restoring the documented CLI > instance > env > config > class-default ladder. Repeated flags still accumulate as before. Aduho.Append()-marked field is unaffected — it always accumulates onto its default, by design. duho.Extend()no longer discards a field's declared default when the flag is absent, and now composes with the field's own declared element type and collection kind instead of forcing a list-only action:Arg[list[int], Extend(",")]now yields ints, andExtend()on aset/tuplefield now produces that collection.- The documented
NS(nargs="*")escape hatch on alist[T]option now actually restores space-separated multi-value input (--x a b->['a', 'b']). Alist[T]field made positional viaNS(flags=(...))now correctly accepts multiple values too. - A
boolfield that can receiveTruefrom an env var or a config file now also gets a--no-*flag, so it can be turned back off from the command line — previously only aTrueclass-level default got one. - A
boolfield defaulting toTruewhose flag already starts with--no-(or carries an explicitmetavar=) no longer crashes parser construction on Python 3.14+; on every version such a field is now a plain--no-<name>flag instead of a confusing--no-<name>/--no-no-<name>pair. - A variadic (
nargs="*")list[T]positional restricted viaChoice(...)/NS(choices=...)can be omitted again on every supported Python version; an invalid value on such a field now shows duho's own message rather than argparse's. --print-completion/duho.print_completionnow register the completion script under the invoked command name (sys.argv[0]'s stem) instead of the root class's name, when no_parsername_/duho.app(name=...)was declared.- A field named
help,version(when_version_is set), orprint_completion(when_completion_is set) now raises a build-timeValueErrornaming the collision instead of silently being dropped from the CLI. - [minor] A class's derived subcommand name is no longer written back
onto the class; a subclass that does not declare its own
_parsername_always gets its own class name now, even after a sibling/base's parser has already been built. - [minor]
app()'s internal subcommand-dispatch dest is renamed fromcommandto a private_duho_command_;instance.commandno longer exists as a side effect of dispatch. A root field literally namedcommandis no longer silently overwritten. --loglevel's metavar/help now show the real[NAME:]LEVEL[,...]grammar;-v's help text is now "Increase verbosity (repeatable)".parse_loglevelsis now case-insensitive, accepts a plain integer, strips whitespace, and raises a clear error naming the bad token instead of silently dropping it. [minor] code that fed it deliberately malformed input now gets a parser error (exit 2) instead of a silent no-op.--loglevel app:LEVELnow applies to the wholeapp.*logger subtree, including a descendant logger (e.g.app.cli) that already had its own explicit level set.- Log output (
init_stderr_logging, and the default handlerduho.main/duho.appinstall) is no longer unconditionally ANSI: color now follows the sameNO_COLOR/FORCE_COLOR/TTY rule the--helpformatters already used, andcolorama.just_fix_windows_console()is called when colorama is installed and color is enabled. add_logging_levelnow immediately refreshes the-v/-qverbosity table (previously onlyinit_stderr_loggingdid); the table's displayed alias per level now prefers the canonical stdlib name (WARNING) over a deprecated one (WARN). It also refuses (ValueError) to silently replace an unrelated existinglogging/Loggerattribute (force=Truestill allows a deliberate override).init_stderr_loggingis now idempotent (a repeat call skips adding a second handler);duho.main/duho.appnow call it unconditionally rather than only when the root logger has no handlers yet — a caller managing its own logging entirely should passsetup_logging=False.- [minor]
duho.logging's attribute-forwarding__getattr__no longer answers for dunder/private names (e.g.__path__,__version__); public stdlibloggingnames still forward normally. LoggingArgsnow seeds_duho_constants_and declares each field's flags directly as metadata instead of an AST scan of its own source — duho's own-v/-q/--loglevel/--verbose/--quietkeep their documented shape even when duho ships without.pysource (a PyInstaller build, a.pyc-only install, Nuitka).- (security)
Env.paths(andduho.app'sCMDS_PATHresolution) no longer treats an empty/whitespace-only path-list segment as the current directory; it is dropped instead. An explicit"."segment is still honored. - A stale/missing/non-directory
CMDS_PATHentry is now logged at WARNING and skipped instead of crashing every invocation; the other entries are still used. - (security) [minor] The
CMDS_PATHseparator can no longer be overridden by an unprefixed, process-widePATHSEPenv var; use the app-prefixed<PREFIX>PATHSEPinstead. The override must be a single character other than/,\or., and while it is in effect every segment must be absolute or explicitly relative (.,./dir); a segment such as theCleft by splittingC:\toolson:is rejected instead of being resolved against the current directory. Env.__init__no longer autoloads a companion module for an empty prefix, or for a prefix whose normalized form has a character outside[A-Za-z0-9_]. Its companion-module autoload now only swallows the companion module's own absence — an error raised by code inside an existing companion module now propagates instead of silently dropping every shipped default.del env[key]/env.pop(key)/env.clear()on anos.environ- or companion-default-backed key now behaves consistently (an in-object tombstone;os.environitself is never mutated).- A module command's entrypoint (
main/run/call) and lifecycle hooks (register/init/success/finally_) are no longer satisfied by an imported callable of the same name; only a callable defined in the module (or listed in its__all__) counts. - (security)
CmdBuilderon a package directory now verifies the qualname actually resolves (viasys.path) to that directory before importing it, instead of silently wrapping a different same-named package found elsewhere onsys.path.CmdBuilderon a loose file always uses a private namespace now, so it can never register under a real module's bare name. - (security)
discover_commands/duho.app(source=...)on a bare package name now prefers an importable package over a same-named directory relative to the current working directory. - (security)
discover_commands("")andduho.app(source="")now raiseValueErrorinstead of silently scanning and importing every file in the current working directory."."still means the current directory explicitly; so doesPath(""), because Python normalises it toPath("."). - A class whose name starts with
_is no longer discovered/listed as a runnable subcommand. A package's own module command is now named after the package, not--init--. An entry point's advertised name is now used only when the loaded module declares no_parsername_of its own. - Overriding a subcommand (via
CMDS_PATH, an explicitcommands=/source=/entry_points=override, or two independently-resolved commands sharing a name) now also deregisters every alias of the command it replaces. - The "CMDS_PATH overrides a built-in" notice is now logged once, at INFO,
after
app()has actually configured logging; a genuine collision between two independently-resolved (non-CMDS_PATH) commands still warns. commands=/source=/entry_points=passed toapp()have always ADDED to a root's own declared_subcommands_rather than replacing them; this is now documented and tested (only the documentation was previously misleading).- RunPath step ordering is now a proper stable topological sort; a step
reordered by
REQUIRED/BEFORE/AFTERcan no longer jump past unrelated later steps. RunPathCmd.__call__used to always return0regardless of step outcomes (0.5.4 ignores every step's return value entirely); a step returning a non-zerointis now a failure, and the command's own exit code ranks every step's code by magnitude (the same aggregatorduho.fanoutuses), so a negative step code (e.g. a signal-killed subprocess) is no longer silently hidden behind an earlier or later0. A resilient run whose steps all failed non-fatally now returns non-zero instead of always0. A step returning a non-zero code under the default strict mode logs the failure, still runs__main__.py'sfinally_hook, and returns that step's own code. Breaking an unresolved dependency cycle now forces through the lowest-ranked step that is actually part of the cycle, not merely the lowest-ranked stuck step overall. A disabled duplicate step file no longer claims a step name ahead of an enabled file of the same name, regardless of file-listing order; two enabled files sharing a name still raise the existing duplicate error.- [minor] The
__main__.pylifecycle now runssuccessbeforefinally_(previously the reverse), gated on no failure;finally_errors are logged and swallowed rather than replacing the real step error. - [minor] Disabled/deselected RunPath step files are no longer imported (their module body no longer executes); only enabled, selected steps are imported.
- [minor]
REQUIREDis now a real hard dependency: a dependency that ran and failed (or failed to import) now also skips the dependent step, following the dependent's own strict setting — previously it was ordering-only. - [minor]
register(base=<a Cli app root>)no longer lets a RunPath command inherit the root's unrelated_subcommands_/_version_/etc., or replace the step runner's own__call__. duho.mcp'sinitializenow negotiates the client'sprotocolVersionagainst a small supported set instead of echoing back any value verbatim. [minor] a "namespace" tool (a node whose own subcommand is mandatory) is no longer listed bydescribe_tools/tools/list; its own fields are instead merged into every one of its descendants'inputSchema.duho.mcpcommand dispatch now goes through one shared, cached, layered root parser per root class, so env/config values, root globals, passthrough args, andLoggingArgsverbosity setup all reach an MCP-dispatched command exactly as they would underduho.main/duho.parse.serverInfo.versionnow reports duho's own version. Arguments are validated before anything runs: a value that would make the parse select a different subcommand than the tool names, a counting flag above 10, an array or object over 1000 items, a missing required property or a value outside the schema'senumis rejected with a JSON-RPC-32602error (InvalidArgumentsErrorfromcall_tool), and a request whose parse still resolves to another command is refused as anisErrorresult. A field name declared at several levels is set only at the deepest one.- (security) An app's own import-time output (e.g. a stray top-level
print) can no longer corrupt the MCP stdio protocol stream; stdio is isolated before the target app is even resolved. - (security) An invalid-UTF-8 request line no longer kills the MCP
stdio server process on a UTF-8 stdio (the default on Linux and macOS); it
is now answered with a JSON-RPC parse error (
-32700) and the server keeps running. store_falseandargparse.BooleanOptionalActionbool fields (including one whoseTruecomes from an env/config layer) now round-trip correctly over MCP — previously such a field could be inverted (a client asking forfalsecould set ittrue, or vice versa).- A dict field with a custom
type=override (e.g.LoggingArgs.loglevels,--loglevel app=DEBUGstyle) now works when called over MCP instead of always failing with an invalid-value error. - Logging emitted by a command dispatched over MCP is now captured and reported; each call's own log output is isolated to that call.
- A
nargs="+"positional with a declared default is now correctly published as a required property in the tool's schema (it was previously advertised as optional). - A dispatched command's non-zero
intreturn now includes any captured stderr text in theisErrorresult, not just an "exit code: N" line. - A JSON-RPC batch request (a JSON array of request objects) is now
dispatched element by element instead of crashing the server; an
empty batch array gets its own
-32600error. - Fan-out's default
aggregatenow ranks a negative exit code (e.g. a signal-killed subprocess) as a failure instead of a plainmaxhiding it behind a succeeding target. On Python 3.12+,duho.fanout's per-target[<target>]log prefix no longer mutates the sharedLogRecordin place, so a handler on the same logger that carries no filter no longer sees the prefix too; the record attribute the prefix filter sets is also renamed to_duho_target_tagged_(private; no known external reader). On Python 3.9–3.11 the stdlib gives aFilterno way to substitute a per-handler record, so an unfiltered sibling handler still sees the prefix there, same as before. pysafenow always produces a valid identifier. Output is unchanged for every input that already produced one; inputs that produced an invalid identifier now differ — e.g. a leading digit is prefixed ("1abc"→"_1abc") and a result that is a keyword gets a trailing underscore ("!"→"not_").snakecaseno longer emits a doubled underscore after a separator (snakecase("My-App")was"my__app", is now"my_app").expand/range validation now raisesValueErrorfor a reversed, mismatched-kind, multi-character, or mixed-case range instead of silently yielding nothing or an ASCII-punctuation walk; the range expansion is computed iteratively, removing a recursion-depth limit on large ranges.- [minor]
text.range/text.unicode_rangeare no longer onduho.text.__all__(still reachable asduho.text.range/duho.text.unicode_range), sofrom duho.text import *can no longer shadow the builtinrange. QualName.relative_to'sValueErrormessage is now a readable sentence instead of a raw tuple/list repr.PythonName.new()called on a subclass now returns an instance of that subclass.duho.scaffold.generate_launchersnow validatesapp,libdir, andpython(ASCII, no shell/batch metacharacters, no path traversal), raisingValueErrorinstead of silently producing a broken or command-injectable launcher. The generated POSIX launcher now clearsCDPATHaround itscdcalls.- Shell completion: zsh completion now works below the root command at any
depth (previously root-only) and no longer errors on a command with a
positional argument. bash, PowerShell, and zsh no longer mistake a
positional value for a subcommand name.
--opt=valuenow completes correctly in bash. (security) a choice/subcommand-name value containing shell metacharacters ($(...),;, a backslash, a single quote) can no longer execute or corrupt the script when zsh or fish actually complete it. fish's per-command gating no longer leaks a nested subcommand's flags/names into its parent, or root names/flags after a subcommand is chosen. A free-value option no longer offers the surrounding flags/subcommand names as its value in bash and PowerShell.Path-typed options/positionals get real native file completion in bash and PowerShell. PowerShell candidates containing whitespace or a metacharacter are now inserted as one quoted literal, and matching is now case-sensitive. A non-ASCII completion choice no longer gets mangled by the console's code page under the documented PowerShell install one-liner. The generated bash function name is now namespaced/hashed so two programs with similar names no longer redefine each other's completion function. bash no longer merges every completion choice after one containing a'or"into a single mangled candidate;--opt=with nothing typed yet no longer falls back to filename completion. (security) PowerShell now always single-quotes an inserted completion candidate, closing a break-out via Unicode "smart quotes" (U+2018-U+201B) that the previous ASCII-only quoting check missed; a completion choice containing#/@is also now quoted instead of inserted bare. PowerShell no longer skips an earlier, already-typed word that happens to repeat the text of the word currently being completed, and can now dispatch to a subcommand name that itself needs quoting. A positional hidden viahelp=argparse.SUPPRESSno longer shifts every later positional's completions one slot early; a hidden subcommand's alias is now hidden too (previously only its primary name was). zsh now completes correctly for a node that has both its own positional and a subcommand table (previously the subcommand table was never reached); zsh sibling subcommands whose names sanitize identically (e.g.a-b/a_b) no longer collide on the same generated shell function; zsh can also now dispatch to a subcommand name that needs quoting. fish no longer leaks a shallower, same-named subcommand's flags into a deeper one (e.g. rootrunvs. nesteddb run). - [minor] Agent-help JSON (
--help-agents,AGENT_HELP=1 --help) and the human--helpoutput no longer show a live environment- or config-file value as a field's default. They show the field's declared (class) default, plus — only when the shown value actually came from env or config — a value-free provenance note instead of any value (JSON: a new"default_source"key, e.g."env DEPLOY_TOKEN"/"config"; human help:"(from env DEPLOY_TOKEN)"/"(from config)"in place of"(default: X)"). This also now covers a module command's own env/config-bound fields. DefaultsFormatternow also skips the(default: X)suffix for an emptylist/tuple/set/frozenset/dictdefault.ColorHelpFormatter's colored help no longer misaligns columns on Python 3.9–3.13 (ANSI escape bytes were being counted toward column width) and no longer nests its own ANSI codes around argparse's native color on 3.14+.- [minor] An agent-help document's
"type"string is now identical on every supported interpreter: a union renders as"int | None", and a generic renders with its arguments whether it is spelledlist[str]ortyping.List[str]("list[str]","dict[str, int]","tuple[int, ...]"; Python 3.9/3.10 previously dropped the arguments of a barelist[str]). ALiteralargument value is shown with Python repr quoting (e.g.Literal['x, y', 'z']) instead of losing its quotes, which previously made a comma-containing value indistinguishable from multiple separate literal members. - The synthesized "minimal invocation" example in agent-help now always
includes
<command>when the app has subcommands, and prefers a long (--flag) spelling over a short one when both are declared. A subcommand-scoped agent-help document now reports the app's ownversion/exit_codes, not the subcommand's, and scopes its examples to the current subcommand. - Agent-help JSON and human
--helpoutput no longer raise aUnicodeEncodeError(empty stdout, exit 1) when piped/redirected on Windows with a non-ASCII character in a docstring or field help. Agent-help JSON is now ASCII-escaped and written as raw UTF-8 bytes; human help is written using the stream's own encoding with a lossy fallback so an unencodable character is escaped rather than crashing the whole document.duho.scaffold's CLI reports each written launcher path the same crash-proof way. - An agent-help document's
"usage"field is now always plain text, even when argparse's own native color (3.14+) is active. - [minor]
Metano longer acceptsdest=(raisesTypeError; it was silently ignored before — a field's dest is always its declared name). duho.Argis nowtyping.Annotatedvia a realfrom typing import Annotated as Arg(the same object at runtime; fixesArg[...]failing under mypy). No runtime behavior change.duho.main/duho.app's documented return type is nowAny, notint(both always returned whatever the selected command returned; the annotation was simply wrong before). Notobjecteither: an intermediateobjectannotation brokesys.exit(duho.main(App))under a strict-mypy consumer, sincesys.exitdoes not acceptobject—Anyis honest about the pass-through while keepingsys.exit(...)clean.import duhono longer importsduho.completion/shlexor (transitively, viaduho.discovery)importlib.util/pkgutileagerly; all three now load on first actual use, same asjson/importlib.metadataalready did. No API change.- Internal (no public API change):
src/duho/args.pyis split intosrc/duho/args.py(Args/Cmd/Cli/ArgumentBuilderand public functions),src/duho/_fieldspec.py(the type-to-argparse-spec ladder), andsrc/duho/_layers.py(the env/config/instance layering pipeline). Every existing PUBLIC import path is unchanged; the names the two new modules need from each other are re-exported fromargs.py(a private helper that lived onargs.pybefore the split is not guaranteed to still be reachable there — it may have moved to_fieldspec.py/_layers.py).duho.mcp's JSON-Schema ISO-format type map is now derived from a single shared source instead of a second, independently hand-kept list. bench_startup.py's gated end-to-end timing metrics now measure from a real temporary.pyfile instead ofpython -c, so they actually exercise duho's source-reading path; the old-c-based number survives as a separate, informational metric and is not comparable to the new one. Cold-build benchmarks no longer delete a seed that duho's own base classes pre-populate, which previously inflated every "cold" sample by re-parsing duho's own source; any previously quoted cold/warm ratio predates this fix.compare_cache.py's cold-vs-warm summary line direction is corrected (it previously read backwards).- A subcommand's
--help/usage no longer lists its_parseraliases_inside the{...}choices brace (e.g.{create,c}is now{create}); the row underneath still showscreate (c)and the alias still dispatches normally. Side effect of the private_duho_command_dispatch dest (see below): the brace is now built explicitly from each subcommand's primary name only, to keep the private dest out of argparse's own error text — it never included aliases. - The missing-subcommand usage error changed from
... required: command(0.5.4, the parser's old publiccommanddest) to... required: {build,test,...}(the explicit choices brace set to keep the new PRIVATE_duho_command_dest itself from leaking into the error text instead).instance.commandno longer existing is an already- documented [minor] break; this is that same change's effect on the error message's wording, not a new one.
Fixed
duho.value_sourcesno longer merges sibling subcommands' provenance together, no longer reports a stale parser inherited via the MRO for a class that was never itself parsed, and a subcommand instance's report now also includes its inherited root/global fields (previously omitted).- An env/config value for a
Literal/Choice(...)field is now validated the same way a CLI value is. A class-level_config_file that doesn't exist yet is now skipped (with a debug log) instead of crashing every invocation; an explicitconfig=kwarg stays strict. An env/config/ instance-supplied value is no longer run through a field's type factory a second time.duho.parse(instance)no longer treats a fieldArgs.__init__seeded as a placeholder as an explicit override that would outrank env/config. - An env/config value now correctly satisfies a required mutually-exclusive group, and is dropped in favor of a sibling given on the CLI instead of coexisting with it. An invalid env/config value belonging to an unselected subcommand no longer crashes the whole invocation.
- A bad env or config value is now reported the same way a bad
CLI value is — usage text and exit code 2 — instead of a raised
ValueError; catchSystemExitinstead. An env var set to the empty string is now treated as unset, except for a field whose values are plain strings (str,Optional[str], or aLiteral/Choiceof strings), which keeps"". - (security) An env/config value that fails conversion is never echoed:
the error names the variable (or config key), the field and the expected
type only. This also holds when the field's own
type=raisesargparse.ArgumentTypeError,KeyErroror any other exception; 0.5.4 printed such a value in a traceback. - A config file whose top level is not a table/object now raises a clear
ValueErrornaming the file (a custom_config_loader_returningNonemeans "no config").duho.app()now threads env/config layering into a class command's own nested_subcommands_tree, and into a module command's declaredArgsclass fields.duho.app()now honors a subcommand's deliberately redeclared default for a root field, and — for a command reached throughcommands=/source=/entry_points=/CMDS_PATHdiscovery — now accepts a required global option given after the subcommand. A statically declared_subcommands_tree is unaffected: a required root option there must still come before the subcommand name. Underduho.app(), a required root global that this relaxation makes internally optional now still displays as required (no[...]brackets) in--help/usage text, instead of the enforcement and the displayed usage disagreeing with each other. - A set[T]/list[T] positional given zero tokens no longer crashes or
duplicates its declared default.
duho.Append()on aset/tuplefield now raises a clear error at parser-build time instead of crashing at parse time. A mutually-exclusive member with no explicitrequired=/default no longer fails the parser build. Declaring the same conflicts= key under two different group titles now raises a clear build-time error. - A parser built once and reused across multiple
parse_args()calls no longer shares (and, on mutation, leaks through) the same mutable list/set/dict default object between those calls; a directly constructed instance with a mutable class-level default now gets its own copy. Args.__init__now seeds EVERY declared field's effective default onto a directly constructed instance when the caller didn't pass it (not only fields with an explicit class-level default) — astore_true/store_falsebool or any other field whose default only used to materialize via argparse now gets the same attribute surface as a parsed instance. This changesrepr()/==for a directly constructed instance built with fewer keyword arguments than the class declares fields.- An
Optional[T]positional with no explicit default is no longer required. A non-required option with no declared default now reportsNoneas its effective default on a directly constructed instance. On Python 3.9/3.10, aboolfield defaulting toTrueno longer has a spurious(default: %(default)s)appended to its help text (this previously also defeatedNS(help=argparse.SUPPRESS)). - The flag/positional reorder pass now also recognizes an attached
short-option value (
-fVALUE) and an unambiguous long-option prefix, and now also covers a single variable-arity positional with no sibling positional.Count()/store_const/append_const/store_falsefields with no declared default are no longer mandatory options.const=/version=supplied throughNS(kwargs={...})is no longer ignored by build-time checks.NS(nargs="*")on adict[str, V]field no longer crashes. - A class source file saved as UTF-8 with a byte-order mark, or one using a
non-UTF-8 coding cookie, no longer silently loses every field's flags/
aliases/docstrings. The same class name declared twice in one file (an
if/elseversion guard, atry/except ImportErrorfallback) now uses the branch Python actually ran. A private field's unresolvable type annotation no longer crashes parser build for the whole class. A subclass that redeclares only an inherited field's flags (or only its help text) now correctly keeps the base's help text (or flags) instead of losing it. - A PEP 695 type alias wrapping
Annotated, andOptional[Arg[T, NS(...)]]or anyUnionwith exactly oneAnnotatedmember, now resolve and apply their metadata instead of crashing (Python 3.11+: on 3.9 and 3.10typing.Unionitself rejects the unhashableNS(...)metadata, which now surfaces as an error naming the field); aUnionwith more than oneAnnotatedmember now raises a clear error naming the field. duho.app's advisoryregisterprepass no longer fails silently when the app root already declares built-in_subcommands_; a module command'sregister(parser, args)hook now receives the real parsed globals instead ofNonein that shape.duho.appno longer prints--versiontwice, concatenates two--print-completionscripts, or shows a spurious "arguments are required" error before a module subcommand's own--help.duho.parsers.disable_subparser_check/enable_subparser_checkare now safe to call in a nested pair on the same action.insert_actionnow defaults to appending (previously inserted before the last action) and re-adds the action to its help group;pop_actionnow also removes an action from any mutually-exclusive groups it belonged to, and no longer leaves a straySUPPRESSkey on the parsed namespace when the subparsers action had no explicitdest=.- On Python 3.14,
Env.bool's own parameter/return annotations no longer resolve to theEnv.boolmethod itself. Directory-based discovery now supports a_helpers.pysibling-import convention. Repeated discovery of an unchanged command file reuses the already-imported module instead of re-executing it. A module command's hooks now get the"duho"fallback logger even when the root has noLoggingArgs-based logger. - A literal
%in a field docstring, a module command's docstring, or a resolved--versionstring no longer crashes parser build or--help/--version._help_formatter_now styles the whole subcommand tree (previously only direct children). A subcommand that subclasses its own root no longer recurses forever._version_ = duho.AUTOnow resolves correctly underpython -m pkg, is cached per distribution name, and retries a normalized distribution name on Python 3.9. - A custom
Argumenttype's own parsing logic is no longer bypassed when the field also carriesNS(...)/Meta(...)metadata.parse_intermixed_args/parse_known_intermixed_argsnow raise a clear error instead of crashing or silently returning an unusable result. An async command's result is now driven to completion whether or not it is a native coroutine object; an async-generator result now raises a clear error; calling from inside an already-running event loop now raises a clear error. The agent-help JSON document now expands%(default)s-style placeholders in help text instead of copying the raw template, and no longer corrupts a description containing a literal%%.duho.command()-built classes now carry the caller's own module. - A register hook's
ArgumentErroris now only reworded as "collides with a global flag" when the option string actually belongs to the root's global options. A register hook with a keyword-onlyloggerparameter is now called correctly; hook arity detection now inspects afunctools.wraps-decorated hook's own signature. A module command's hook returning a coroutine is now closed immediately and raisesTypeErrornaming the hook. Passing a non-command object inapp(commands=[...])now raisesTypeErrornaming the bad object. - A
--rcoptsentry carrying an unrecognized token no longer silently forces that step strict. Duplicate RunPath step names are now detected (warn/error, naming both files).REQUIRED/BEFORE/AFTERgiven as a bare string is now normalized to a single-element list with a warning; a non-integerPRIORITYnow names the offending step/file. Anasync defRunPath step or__main__.pyhook now raisesTypeErrorimmediately instead of silently never running. Astep_adaptershim wrapped withfunctools.wrapsis now inspected on its own signature. Assorted filename/pattern-parsing edge cases (a non-decimal Unicode digit prefix, an uppercase.PYextension, unstripped whitespace in a--rcoptspattern) are fixed. duho.mcptool arguments are now synthesized into argv so a value can never be reinterpreted as a flag or the--passthrough separator. Aboolfield's actual argparse action now drives argv synthesis instead of a re-derived guess.call_toolnow validatesargumentsagainst the tool's own schema before dispatch, and treats a JSONnullas "field omitted". A command's ownsys.exit()/SystemExitno longer kills the MCP server process. The stdio JSON-RPC server no longer crashes on a malformed-but-valid JSON request. The real stdio transport now isolates file descriptors 0/1 from anything a dispatched command does, and forces UTF-8/LF on the protocol channel.pingis now answered with an empty result instead of "method not found".input_schema_for_command'srequired/descriptionnow match what argparse itself enforces, and never leak an internal sentinel.duho.scaffold's CLI now reports any filesystem error as a one-line message instead of a traceback, and self-describes with its own name/version.python -m duho.mcp -h/--helpnow prints usage instead of failing.- Fan-out: Ctrl-C now cancels queued (not-yet-started) targets instead of
running the whole list to completion first. A target's
SystemExitis normalized into an exit code instead of discarding every other target's result. A raising target's failure log now honorsDUHO_TRACEBACKlike every other resilient code path.run_targets/fan_out_commandnow reject a barestr/bytestarget and a non-positivemax_workersup front; anasync deftarget callable is now awaited. duho.scaffold's generated CLI now prints a one-line error (naming--force) and exits 1 on a re-run without--force, instead of an unhandled traceback.duho.main/duho.appno longer add a duho stderr handler to the root logger when the root already has a handler of its own (basicConfig, pytest's own capture handler, etc.) — restores 0.5.4's guard, which a later change made unconditional. Verbosity (-v/-q/--loglevel) still always applies regardless of whether the handler was installed.--loglevel app:LEVEL(and any other explicitly-named--loglevelentry) still forces its level onto every already-existingapp.*descendant logger, but the -v/-q-derived (or bare--loglevel LEVEL) entry for the app's own default logger no longer does — that entry applies on EVERY ordinary dispatch, and forcing it onto descendants pinned a library's own child loggers (e.g.logging.getLogger("app.child").setLevel(...)) after a single in-process dispatch, breaking hierarchical control. The descendant walk also no longer touches aNOTSETchild (already inherits for free) or promotes aPlaceHolderregistry entry into a realLogger.- Human-facing console text (
--help, an agent-help fallback,duho.scaffold's path report) now gets the platform's normal newline translation again (CRLF on Windows) for the common, fully-representable-character case, instead of always being LF-only. A prior fix for a real Windows crash (UnicodeEncodeErrorwriting a non-ASCII docstring through a piped console's code page) madewrite_humanroute every write through the stream's raw, untranslated.bufferunconditionally, which also silently dropped the newline translation for the overwhelming majority of output that never had an encoding problem in the first place.write_humannow tries the normalstream.write(text)first (translated, and correctly encoded for anything representable) and only falls back to the raw.bufferwrite for a character genuinely outside the stream's encoding.
Removed
- [minor] The undocumented
_cli_namealias for_parsername_(module- level override on a command module). Only_parsername_is honored now. benchmarks/bench_parsing.py(superseded byrun.py, which already reports the same metrics as min/median/max instead of a single average).
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.tests/test_version_sync.pynow fails 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:
RunPathCmd._runpath_logger_documented a pre-0.5.3"duho"fallback (it returns theduho.runpathmodule logger;ModuleCommand._logger_for's own"duho"fallback, handed to user hook code, is unaffected), andLoggingArgs._verbose_loglevel_documented returning a log level name when it returns the numeric level. No behavior change. - Removed a dead
abortedflag fromRunPathCmd.__call__; a strict step failure already raised before the laternot abortedguard could run it. No behavior change.
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. The default isNone, which calls steps exactly as before. Unlikebase, 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;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. - 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. - 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. - 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. - 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. 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.- 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. - 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. - 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. - 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.
Changed
- 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. - Documented using an
enum.IntEnumas exit codes — anIntEnumreturn from__call__propagates as the process exit code unchanged (it is anint); added a test. 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.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.- 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. - 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. 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, these changes 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).
- 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 committed benchmarks/baseline.json with
update_baseline.py/check_baseline.py, and a CI regression gate (fails at
1.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. - BREAKINGduho.Env.listreturns[]for a missing or empty value instead of the previous[ty("")]single-empty-element contract.
Fixed
CMDS_PATHcommand-search-path resolution now splits on the platform path separator (os.pathsep—;on Windows,:on POSIX; overridable via an app-prefixed<PREFIX>PATHSEPenv var, e.g.MYAPP_PATHSEP— never a bare/ globalPATHSEP, which used to let any unrelated process'sPATHSEPbypass this for every duho app on the system) 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.paths()also rejects a bare drive-letter segment (C:) and any other segment that resolves to the current working directory unless it is spelled exactly., and drops an empty/whitespace-only segment instead of treating it as the current directory.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 entry-points-discovery-only apps, which commonly run with no explicit root. 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.- 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. - 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. - 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. 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).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.- 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). - 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. - 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. - 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. ClassVar[...]andFinal[...]annotations are skipped instead of becoming broken CLI flags.Literal[True, False]builds and parses (goes throughtype=+choices=) instead of raising an argparseTypeErrorat build.datetime.date/datetime.datetime/datetime.timefields parse viafromisoformat(a bad value is a clean argparse error, not a traceback).- A
setused as a flags container is now a clear build-time error instead of a crash / nondeterministic flag order. argparse.SUPPRESSinAnnotatedmetadata hides the field wherever it appears, not only as the first metadata item.- A missing
<PREFIX>_CMDS_PATHno longer glob-imports every.pyin the current working directory (Env.listreturns[]andapp()guards on a non-empty value). Envcompanion-module autoload seeds only upper-case, non-underscore variables throughstr()coercion, and acceptsEnv(prefix, autoload=False)to disable thesys.path/CWD import.- 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.
-
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. -
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. pop_actionalso removes the action from its argument group's_group_actions, so a popped flag no longer lingers informat_help().duho.snakecaselower-cases interior upper-case letters with an underscore (CamelCaseName->camel_case_name) instead of dropping them, and returns""for empty input.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".logging._getcolorresolves the documented"fore+back"syntax and returns""(never the raw compound string) when colorama is absent or a name does not resolve._parser_(name="alias")no longer permanently writes_parsername_onto the class; the alias is a one-off.- Source is read as UTF-8, and
UnicodeDecodeError/ValueErrorare caught so non-ASCII source under a non-UTF-8 locale no longer crashes. - The
_CollectionActionsidecar (_duho_items_<dest>) is dropped before instance construction, so it no longer leaks intovars(instance). _suppress_inherited_defaultskeeps a child's deliberately overridden default (a re-declared field with a different default) instead of discarding it.- 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.
QualName.relative_towith an empty base returns the name unchanged instead of dropping the first part.- 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. - 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. - 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. - 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.
0.3.3 - 2026-07-18
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_CHECKINGinloggingre-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 theregister_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). - 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.