Skip to main content

A declarative CLI framework for building command-line applications with minimal boilerplate

Project description

Duho

PyPI version Python versions Documentation License: MIT

Duho is a declarative CLI framework for Python that turns the complexity of building command-line applications into simple, type-safe class definitions.

Named after the sacred Taíno ceremonial stool—a symbol of power and authority—duho provides the foundation from which you command your application.

Features

  • Declarative: Define CLI arguments as class annotations—no boilerplate argparse setup
  • Type-safe: Built-in type conversion and validation from Python type hints
  • Logging: Integrated colored logging with configurable verbosity levels
  • Subcommands: Easily compose multi-command CLI applications
  • Extensible: Customize argument behavior with protocols and builders

Quick Start

from duho import Args

class MyApp(Args):
    name: str
    "The name to greet"
    ("--name",)
    
    count: int = 1
    "How many times to greet"
    ("--count",)

if __name__ == "__main__":
    parser = MyApp._parser_()
    args = parser.parse_args()
    for _ in range(args.count):
        print(f"Hello, {args.name}!")

Run it:

python app.py --name Alice --count 3
# Output:
# Hello, Alice!
# Hello, Alice!
# Hello, Alice!

Installation

pip install duho

Optional Dependencies

For colored output in logging:

pip install duho[colorama]

Core Concepts

Args: Declare Your CLI

Define arguments using class annotations. The docstring becomes the help text, and expressions after the annotation become argument flags:

from duho import Args
import typing as ty

class Deploy(Args):
    """Deploy the application to production."""
    
    environment: str
    "Target environment (prod, staging, dev)"
    ("--env",)
    
    version: ty.Optional[str] = None
    "Release version (defaults to latest)"
    ("--version",)
    
    dry_run: bool = False
    "Preview changes without applying them"
    ("--dry-run",)

Bool fields defaulting to False (or with no default) get a simple --flag switch. Bool fields defaulting to True get --flag/--no-flag (via argparse.BooleanOptionalAction) so the default can be explicitly turned back off.

Supported Field Types

Annotation Behavior
str, int, float, bool Direct conversion; bool gets store_true or --flag/--no-flag (see above)
typing.Literal["a", "b"] choices=("a", "b"); mixed-type literals (Literal["auto", 1]) try each declared value's own type and keep whichever round-trips
enum.Enum subclass choices are the member names; the parsed value is the Enum member (Color["RED"] -> Color.RED)
list / list[T] Accepts both repeated (--x a --x b) and space-separated (--x a b) forms via action="extend", nargs="*"; bare list elements are str; default is [] when no explicit default is given
typing.Optional[T] / T | None (3.10+) Not required; tries T
typing.Union[A, B] / A | B (3.10+) Tries each type in declaration order
Union/Optional containing an Enum The Enum member is matched by name, same as a bare enum.Enum field — a name match wins before falling through to a later str member, so declaration order matters (Union[Color, str] with --c RED yields Color.RED, while --c other yields the string "other")

Run your app

duho.main(cls, argv=None, *, setup_logging=True) builds the parser, parses argv (or sys.argv when omitted), optionally wires up stderr logging and verbosity (for classes mixing in LoggingArgs), and calls instance.__run__():

from duho import Args, main

class Greet(Args):
    """Print a greeting."""
    name: str = "world"
    "Who to greet"
    ("--name",)

    def __run__(self) -> int | None:
        print(f"Hello, {self.name}!")
        # returning None counts as a successful exit (code 0)

if __name__ == "__main__":
    raise SystemExit(main(Greet))

SystemExit raised by argparse (bad args, --help, --version) propagates normally. If the selected class has no __run__, main raises NotImplementedError naming the class.

Subcommands: set _subcommands_ to a sequence of Args subclasses and main/_parser_ wires up add_subparsers(dest="command", required=True) automatically — no manual subparser plumbing needed. Nested _subcommands_ (a subcommand that itself declares _subcommands_) compose naturally into multi-level command trees, and main always dispatches to the deepest selected class's __run__.

class Serve(Args):
    """Start the development server."""
    port: int = 8000
    ("--port",)
    def __run__(self):
        print(f"serving on {self.port}")

class Build(Args):
    """Build the project."""
    output: str = "dist"
    ("--output",)
    def __run__(self):
        print(f"building to {self.output}")

class App(Args):
    """Example multi-command app."""
    _subcommands_ = [Serve, Build]

if __name__ == "__main__":
    raise SystemExit(main(App))
python app.py Serve --port 3000
python app.py Build --output dist

Version flag: set _version_ on any Args subclass to add a --version flag that prints "%(prog)s <version>" and exits 0 (skipped if a version-dest action already exists, e.g. from a parent parser):

class MyApp(Args):
    _version_ = "1.2.3"

Autodetected version: set _version_ = duho.AUTO to resolve the version from installed package metadata via importlib.metadata.version(...) instead of hardcoding a string. By default the distribution name is the class's top-level import package (cls.__module__.split(".")[0]); set _distribution_ to override it when the import name differs from the distribution name on PyPI:

import duho

class MyApp(duho.Args):
    _version_ = duho.AUTO
    _distribution_ = "my-package"  # only needed if it differs from the import name

If the distribution can't be found (e.g. running from a source checkout that isn't installed), duho does not add a --version flag at all — it logs a debug message via logging.getLogger("duho") instead of printing a bogus 0.0.0+unknown-style version or raising.

Build and Parse

parser = Deploy._parser_()
args = parser.parse_args()

print(f"Deploying to {args.environment} (dry-run: {args.dry_run})")

Quick parse

duho.parser(cls, ...) is the module-level entry point for building a parser (delegates to cls._parser_(...)). duho.parse(spec, argv=None, *, parser_kwargs=None) goes one step further and parses in a single call:

import duho

# spec is a type: build + parse in one call
args = duho.parse(Deploy)

spec can also be an instance, letting you layer CLI overrides on top of config-file/programmatic defaults. The instance's current field values become the argparse defaults; CLI args still win; the original instance is left unmutated and a new instance of the same type is returned:

base = Deploy(environment="staging", dry_run=False)

# No --env on the CLI -> falls back to base.environment ("staging")
result = duho.parse(base, ["--dry-run"])

assert result.environment == "staging"   # from base
assert result.dry_run is True            # from CLI
assert base.dry_run is False             # base is untouched

Precedence: CLI args > instance field values > class defaults. This also means a required field with no class default becomes effectively optional for that call if the instance already supplies a value.

Configuration layers

Beyond instance overrides, duho.parse/duho.main support two more default layers: per-field environment variables and a TOML config file. Combined precedence ladder, highest wins:

CLI args > env var > config file > class default

A value supplied by any layer also un-requires that field — a field with no class default that's set in the config file (say) no longer needs to be passed on the CLI.

Environment variables: annotate a field with NS(env="VAR_NAME"):

from duho import Args, Arg, NS

class Deploy(Args):
    token: Arg[str, NS(env="DEPLOY_TOKEN")] = ""
    "Auth token"
    ("--token",)

Config file: set _config_ on the class, or pass config= to duho.parse/duho.main (the kwarg overrides the class attr):

class Deploy(Args):
    _config_ = "~/.config/myapp/config.toml"
    ...

result = duho.parse(Deploy, config="./deploy.toml")
result = duho.main(Deploy, config="./deploy.toml")

Top-level TOML keys map to the root command's fields; a table named after a subcommand's _parsername_ maps to that subcommand's fields:

# deploy.toml
verbose = true

[install]
target = "prod"

Reading TOML uses the stdlib tomllib on Python 3.11+; on 3.9/3.10 it falls back to the third-party tomli package if installed (pip install duho[config]) — duho stays zero-runtime-dependency by default, so this extra is only needed if you actually use _config_/config= on an older interpreter.

Debugging where a value came from: duho.value_sources(parsed) returns {field_name: "cli" | "env" | "config" | "default"} for the instance returned by duho.parse/duho.main.

result = duho.parse(Deploy, [], config="./deploy.toml")
duho.value_sources(result)  # {"token": "env", "verbose": "config", ...}

Logging Integration

Combine with LoggingArgs for structured logging:

from duho import LoggingArgs

class MyApp(LoggingArgs):
    command: str
    "The command to run"
    ("--command",)

    def __run__(self):
        logger = self._logger_
        logger.info(f"Running: {self.command}")

duho.main() calls self._set_loglevels_() for you before dispatching to __run__ (pass setup_logging=False to opt out). If you drive the parser yourself instead of using duho.main(), call self._set_loglevels_() before you start logging.

Control logging from the CLI:

python app.py mycommand -v                    # Verbose: INFO -> DEBUG
python app.py mycommand -vv                   # More verbose: -> TRACE (max)
python app.py mycommand -q                    # Quiet: INFO -> WARNING
python app.py mycommand -qq                   # Quieter: -> ERROR
python app.py mycommand --loglevel DEBUG      # Debug level
python app.py mycommand --loglevel foo:TRACE  # Module-specific level

-v/-q are counted flags that move away from/toward the default INFO level in opposite directions and can be combined (e.g. -vv -q nets one step more verbose than the default); each end of the scale (CRITICAL/TRACE) clamps rather than wrapping or erroring.

Shell completion

Generate a self-contained bash/zsh/fish completion script from your parser — static generation (no runtime dependency, no per-keystroke re-invocation of your program, unlike argcomplete):

import duho

class MyApp(duho.Args):
    _completion_ = True  # opt-in: adds --print-completion to --help
    ...
python app.py --print-completion bash > _myapp.bash && source _myapp.bash
python app.py --print-completion zsh  > _myapp   # place on your $fpath
python app.py --print-completion fish > myapp.fish && source myapp.fish

_completion_ is off by default (matches the _version_ opt-in precedent) — set it to add the --print-completion {bash,zsh,fish} flag. You can also generate a script without adding the flag at all, via the standalone function:

import sys
import duho

duho.print_completion(MyApp, "bash", file=sys.stdout)

Both paths walk the built parser tree, including nested _subcommands_: Literal/Enum fields offer their choices as completion candidates, and pathlib.Path-typed fields get the shell's native file/directory completion.

Manual subparsers

_subcommands_ (above) is the recommended way to build command trees. If you need to attach duho commands to a parser you build yourself, pass the subparsers action to _parser_:

import argparse
from duho import Args

class Serve(Args):
    """Start the development server."""
    port: int = 8000
    ("--port",)

root = argparse.ArgumentParser()
subparsers = root.add_subparsers()
Serve._parser_(subparsers, name="serve")

args = root.parse_args()

Examples

Two self-contained example CLIs under examples/ each build a small umbrella app with an install subcommand, ported from real-world scripts to show duho's full surface (they stub the actual filesystem work — the point is the CLI):

  • examples/dotagents.py — an agent-config installer (LoggingArgs, _subcommands_, --dest/--dry-run/--with-examples):

    class Install(LoggingArgs):
        """Copy the agent-config payload into the destination directory."""
    
        dest: Path = Path.home() / ".agents"
        ("--dest",)
        dry_run: bool = False
        ("--dry-run",)
    
    # ...
    if __name__ == "__main__":
        sys.exit(duho.main(Dotagents))
    
    python examples/dotagents.py install --dry-run
    
  • examples/buildutils.py — an install(1)-like file installer; exercises positionals, Union types, NS(nargs="?"), a custom action=UpdateAction, and NS(conflicts=...) mutually-exclusive grouping:

    class Install(LoggingArgs):
        """Install SOURCE at DESTINATION."""
    
        options: Arg[
            dict,
            NS(action=UpdateAction, type=lambda x: [x.split("=", maxsplit=1)]),
        ] = {}
        ("-O",)
        source: Path
        ("source",)
        destination: Path
        ("destination",)
    
    # ...
    if __name__ == "__main__":
        sys.exit(duho.main(Buildutils))
    
    python examples/buildutils.py install --type dir -O k=v src dst
    

Documentation

Full documentation: https://jose-pr.github.io/duho/

Contributing

Contributions welcome! See CONTRIBUTING.md for guidelines.

License

MIT License. See LICENSE for details.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

duho-0.1.1.tar.gz (55.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

duho-0.1.1-py3-none-any.whl (30.1 kB view details)

Uploaded Python 3

File details

Details for the file duho-0.1.1.tar.gz.

File metadata

  • Download URL: duho-0.1.1.tar.gz
  • Upload date:
  • Size: 55.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for duho-0.1.1.tar.gz
Algorithm Hash digest
SHA256 479b43cf05efe19e802de44f564b2b191e62f667d6c7cc505756c4aa39cb404b
MD5 0b53b8e77699bbbd2b66ea137c96fad5
BLAKE2b-256 981219696aed0b34ba946376f7dff28c55494c45dba9ad4b976d1624baaf4144

See more details on using hashes here.

Provenance

The following attestation bundles were made for duho-0.1.1.tar.gz:

Publisher: release.yml on jose-pr/duho

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file duho-0.1.1-py3-none-any.whl.

File metadata

  • Download URL: duho-0.1.1-py3-none-any.whl
  • Upload date:
  • Size: 30.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for duho-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 26a0403a149c35b935b0bd646244ebe1cb935475614f7c515809c35028be61c5
MD5 b09efae7c0de9172314424a9df61337e
BLAKE2b-256 5f54b57c8d2d3bbc5136f192af46083f39604c5a1d0684da085c276bc961ab71

See more details on using hashes here.

Provenance

The following attestation bundles were made for duho-0.1.1-py3-none-any.whl:

Publisher: release.yml on jose-pr/duho

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page