Skip to content

lythonic.compose.cli

Build hierarchical command-line interfaces from type-annotated functions.

CLI: Build hierarchical command-line interfaces from type-annotated functions.

This module provides a declarative way to build CLI applications where commands are organized in a tree structure. Arguments and options are automatically derived from function signatures and Pydantic models.

Quick Start

from pydantic import BaseModel, Field
from lythonic.compose.cli import ActionTree, Main, RunContext

# 1. Create the root action tree
main = ActionTree(Main)

# 2. Define a subcommand group using a BaseModel
class Server(BaseModel):
    '''Server management commands'''
    port: int = Field(default=8080, description="Port to listen on")

server = main.actions.add(Server)

# 3. Add actions to the group using the @wrap decorator
@server.actions.wrap
def start(ctx: RunContext):
    '''Start the server'''
    config = ctx.path.get('/server')  # Access parent action's parsed values
    ctx.print(f"Starting on port {config.port}")

@server.actions.wrap
def stop():
    '''Stop the server'''
    print("Stopping server")

# 4. Run the CLI
if __name__ == "__main__":
    import sys
    result = main.run_args(sys.argv)
    sys.exit(0 if result.success else 1)

Usage: mycli server start or mycli server --port=9000 start

Key Concepts

  • ActionTree: A node in the command tree. Can have child actions and arguments.
  • Main: Default root model with --help flag. Use as the root ActionTree argument.
  • RunContext: Passed to actions that need it (declare ctx: RunContext as first param). Provides access to parent values via ctx.path.get('/path').
  • Arguments: Required params become positional args; optional params become --flags.
  • Pydantic integration: BaseModel fields provide descriptions and defaults automatically.

Argument Parsing Rules

  • Required function params → positional arguments: <arg>
  • Optional function params → options: --name=value or --name value
  • Boolean params with default=False → flags: --verbose (no value needed)
  • Pydantic models can be passed as JSON: mycli config set '{"name":"test"}'

ActionTree

Bases: Method

A node in the command tree that can have child actions.

Extends Method with the ability to register subcommands via the actions MethodDict. Call run_args(sys.argv) on the root to parse and execute.

Source code in src/lythonic/compose/cli.py
class ActionTree(Method):
    """
    A node in the command tree that can have child actions.

    Extends Method with the ability to register subcommands via the `actions`
    MethodDict. Call `run_args(sys.argv)` on the root to parse and execute.
    """

    actions: MethodDict[ActionTree]

    def __init__(self, o: Callable[..., Any] | GlobalRef):
        super().__init__(o)
        self.actions = MethodDict[ActionTree](method_type=self.__class__)

    def run_args(
        self, argv: list[str], print_func: Callable[[str], None] | None = print
    ) -> RunResult:
        cli_name = Path(argv[0]).name
        cli_args = argv[1:]
        return self._run_args(cli_args, RunContext(self, cli_name, print_func))

    def _split_ctx_args_opts(self) -> tuple[bool, list[ArgInfo], list[ArgInfo]]:
        has_ctx: bool = (
            len(self.args) > 0
            and self.args[0].name == "ctx"
            and self.args[0].annotation == RunContext
        )
        self_args = self.args[1:] if has_ctx else self.args
        return (
            has_ctx,
            [arg for arg in self_args if not arg.is_optional],
            [arg for arg in self_args if arg.is_optional],
        )

    def _run_args(self, cli_args: list[str], ctx: RunContext) -> RunResult:
        current_arg_index = 0
        arg_values = {}
        has_ctx, required_args, _ = self._split_ctx_args_opts()
        if has_ctx:
            arg_values["ctx"] = ctx
        error = None
        try:
            while current_arg_index < len(cli_args):
                arg_str = cli_args[current_arg_index]
                if required_args:
                    arg = required_args[0]
                    if arg_str.startswith("--"):
                        raise ValueError(
                            f"Argument {arg.name} is required, but getting option {arg_str}"
                        )
                    current_arg_index += 1
                    arg_values[arg.name] = arg.to_value(arg_str)
                    required_args.pop(0)
                    continue
                if arg_str.startswith("--"):
                    arg_str = arg_str[2:]
                    if "=" in arg_str:
                        k, v = arg_str.split("=", 1)
                    else:
                        k = arg_str
                        v = None
                    if k in arg_values:
                        raise ValueError(f"--{k} is already set to {arg_values[k]}")
                    if k not in self.args_by_name:
                        raise ValueError(
                            f"--{k} is not a valid option, expected one of {list(map(lambda x: f'--{x}', self.args_by_name.keys()))}"
                        )
                    arg = self.args_by_name[k]
                    if v is None:
                        if arg.is_turn_on_option():
                            v = "y"
                        else:
                            current_arg_index += 1
                            if current_arg_index >= len(cli_args):
                                raise ValueError(f"Value is not provided for --{k}")
                            v = cli_args[current_arg_index]
                    arg_values[k] = arg.to_value(v)
                    current_arg_index += 1
                    continue
                elif arg_str in self.actions:
                    action: ActionTree = self.actions[arg_str]
                    ctx.path.value = self(**arg_values)
                    ctx.add_path(arg_str)
                    return action._run_args(cli_args[current_arg_index + 1 :], ctx)
                raise ValueError(f"Argument {arg_str!r} is not a valid")
            if required_args:
                raise ValueError(
                    f"Required arguments are missing: {' '.join(f'<{arg.name}>' for arg in required_args)}"
                )
            if self.actions:
                raise ValueError(
                    f"Action need to be specified, expected one of {', '.join(self.actions.keys())}"
                )
        except Exception:
            error = str(sys.exc_info()[1])
        if error or ctx.is_print_help_selected():
            ctx.print_help(error, ctx.path, self)
        else:
            ctx.path.value = self(**arg_values)
            ctx.run_result.success = True
        return ctx.run_result

Main

Bases: BaseModel

Default root action model. Provides the --help flag.

Source code in src/lythonic/compose/cli.py
class Main(BaseModel):
    """Default root action model. Provides the `--help` flag."""

    help: bool = Field(default=False, description="Show help")

RunContext

Context passed to action functions during CLI execution.

Declare ctx: RunContext as the first parameter of an action to receive it. Use ctx.path.get('/action_name') to access parsed values from parent actions. Use ctx.print() for output that gets captured in RunResult.msgs.

Source code in src/lythonic/compose/cli.py
class RunContext:
    """
    Context passed to action functions during CLI execution.

    Declare `ctx: RunContext` as the first parameter of an action to receive it.
    Use `ctx.path.get('/action_name')` to access parsed values from parent actions.
    Use `ctx.print()` for output that gets captured in `RunResult.msgs`.
    """

    path: PathValue
    main_at: ActionTree
    run_result: RunResult
    cli_name: str

    def __init__(
        self, main_at: ActionTree, cli_name: str, print_func: Callable[[str], None] | None = print
    ):
        self.path = PathValue.root()
        self.main_at = main_at
        self.run_result = RunResult(print_func=print_func)
        self.cli_name = cli_name

    def add_path(self, path: str):
        self.path = PathValue(path, self.path)

    def is_print_help_selected(self) -> bool:
        v = self.path.get("/")
        if isinstance(v, Main):
            return v.help
        return False

    def print(self, msg: str):
        self.run_result.print(msg)

    def _recursive_help(self, print_at: ActionTree, indent: int):
        indent += 2
        _, arguments, options = print_at._split_ctx_args_opts()  # pyright: ignore[reportPrivateUsage]
        for arg in arguments:
            self.print(arg.arg_help(indent))
        for opt in options:
            self.print(opt.opt_help(indent))
        if print_at.actions:
            self.print(f"{' ' * indent}Actions:")
            indent += 2
            for action_name, action in print_at.actions.items():
                self.print(f"{' ' * indent}{action_name} - {action.doc}")
                self._recursive_help(action, indent + 2)

    def print_help(self, error: str | None, path: PathValue, current_at: ActionTree):
        if error:
            self.print(f"Error: {error}")
        self.print("Usage: ")
        self.print(f"  {self.cli_name} {' '.join(path.parts[1:])}")
        self._recursive_help(current_at, 2)

RunResult

Result of running a CLI command, with success status and collected messages.

Source code in src/lythonic/compose/cli.py
class RunResult:
    """Result of running a CLI command, with success status and collected messages."""

    success: bool
    msgs: list[str]
    print_func: Callable[[str], None] | None

    def __init__(self, success: bool = False, print_func: Callable[[str], None] | None = print):
        self.success = success
        self.msgs = []
        self.print_func = print_func

    def print(self, msg: str):
        if self.print_func is None:
            self.msgs.append(msg)
        else:
            self.print_func(msg)

PathValue

Records values along a path.

>>> root = PathValue.root()
>>> root.value = "I am Groot"
>>> foo = PathValue("foo", root, "I am foo")
>>> foo.get("/foo")
'I am foo'
>>> foo.parts
('', 'foo')
>>> root.parts
('',)
>>> foo.get("/bar")
Traceback (most recent call last):
...
ValueError: Path /bar is not contained in /foo
>>> foo.get("/foo/")
'I am foo'
>>> foo.get("/")
'I am Groot'
Source code in src/lythonic/compose/cli.py
class PathValue:
    """
    Records values along a path.

    >>> root = PathValue.root()
    >>> root.value = "I am Groot"
    >>> foo = PathValue("foo", root, "I am foo")
    >>> foo.get("/foo")
    'I am foo'
    >>> foo.parts
    ('', 'foo')
    >>> root.parts
    ('',)
    >>> foo.get("/bar")
    Traceback (most recent call last):
    ...
    ValueError: Path /bar is not contained in /foo
    >>> foo.get("/foo/")
    'I am foo'
    >>> foo.get("/")
    'I am Groot'


    """

    name: str
    parent: PathValue | None
    value: Any | None

    def __init__(self, name: str, parent: PathValue | None, value: Any | None = None) -> None:
        if name == "":
            assert parent is None
        else:
            assert parent is not None
        self.name = name
        self.parent = parent
        self.value = value

    @classmethod
    def root(cls) -> PathValue:
        return cls(name="", parent=None)

    @property
    def parts(self) -> tuple[str, ...]:
        if self.parent is None:
            return (self.name,)
        return (*self.parent.parts, self.name)

    def get_back(self, n: int) -> Any | None:
        if n == 0:
            return self.value
        if self.parent is None:
            return None
        return self.parent.get_back(n - 1)

    def get(self, path: str) -> Any | None:
        parts = self.parts
        lookup_parts = tuple(path.split("/"))
        if lookup_parts[-1] == "":
            lookup_parts = lookup_parts[:-1]
        lpl = len(lookup_parts)
        pl = len(parts)
        if parts[:lpl] != lookup_parts:
            raise ValueError(f"Path {path} is not contained in {self}")
        return self.get_back(pl - lpl)

    @override
    def __str__(self):
        return "/".join(self.parts)