Running your app
duho.main
duho.main(cls, argv=None, *, setup_logging=True, config=None) is the one-call
entry point. It builds the parser, parses argv (defaulting to sys.argv), sets
up logging if the class mixes in LoggingArgs, and calls the parsed
instance — an Args instance is directly callable, so instance() runs the
command via its __call__().
import duho
from duho import Args
class Greet(Args):
"""Print a greeting."""
name: str = "world"
"Who to greet"
("--name",)
def __call__(self) -> int | None:
print(f"Hello, {self.name}!")
# returning None counts as success (exit code 0)
if __name__ == "__main__":
raise SystemExit(duho.main(Greet))
__call__ returns the process exit code; None means 0. SystemExit raised by
argparse (bad arguments, --help, --version) propagates normally. If the
selected class has no __call__, main raises NotImplementedError naming it.
Building the parser yourself
If you'd rather drive argparse directly:
parser = duho.parser(Greet) # a real argparse.ArgumentParser
args = parser.parse_args() # -> a Greet instance
duho.parser(cls) is the module-level form of cls._parser_(). Because it hands
back a genuine ArgumentParser, you can add arguments to it, wrap it, or embed it
however you like.
Parsing in one call
duho.parse(spec, argv=None) builds and parses together:
args = duho.parse(Greet) # from sys.argv
args = duho.parse(Greet, ["--name", "Bo"]) # from an explicit list
Layering over an existing instance
Pass an instance instead of a class and its current field values become the defaults. CLI arguments still win, the original is never mutated, and you get back a new instance of the same type:
base = Greet(name="staging")
result = duho.parse(base, []) # -> name == "staging"
result = duho.parse(base, ["--name", "prod"]) # -> name == "prod"
assert base.name == "staging" # untouched
Precedence is CLI > instance values > class defaults. A required field with no class default becomes effectively optional for that call if the instance already supplies a value. See Configuration layers for env vars and config files, which slot into the same ladder.
Subcommands
Set _subcommands_ to a sequence of Args subclasses. duho wires up
add_subparsers() for you and dispatches to the selected one's __call__:
import duho
from duho import Args
class Serve(Args):
"""Start the development server."""
port: int = 8000
("--port",)
def __call__(self):
print(f"serving on {self.port}")
class Build(Args):
"""Build the project."""
output: str = "dist"
("--output",)
def __call__(self):
print(f"building to {self.output}")
class App(Args):
"""Example multi-command app."""
_subcommands_ = [Serve, Build]
if __name__ == "__main__":
raise SystemExit(duho.main(App))
$ python app.py Serve --port 3000
serving on 3000
A subcommand can declare its own _subcommands_, composing into multi-level
trees; main always dispatches to the deepest selected class. Options
declared on a parent (say -v from LoggingArgs) remain available.
To name a command something other than its class name, pass name=:
Serve._parser_(subparsers, name="serve")
Version flag
Set _version_ and duho adds a --version flag that prints
"<prog> <version>" and exits:
class App(Args):
_version_ = "1.2.3"
To read the version from installed package metadata instead of hardcoding it, use
the duho.AUTO sentinel:
import duho
class App(duho.Args):
_version_ = duho.AUTO
_distribution_ = "my-package" # only if it differs from the import name
AUTO resolves via importlib.metadata.version(), defaulting the distribution
name to the class's top-level import package. If the distribution isn't installed
(a source checkout, say), duho adds no --version flag at all and logs a
debug message — rather than printing a bogus version or crashing.