Xclif — Internal Architecture

This document describes how Xclif works internally. It is aimed at contributors who want to fix bugs, add features, or understand the codebase.

Module Dependency Graph

__init__.py  (Cli, public API)
    ├── command.py   (Command, @command, extract_parameters)
    │   ├── annotations.py  (type → converter registry)
    │   ├── definition.py   (Argument, Option, IMPLICIT_OPTIONS)
    │   ├── constants.py    (NO_DESC, padding values)
    │   └── parser.py       (parse_and_execute_impl, _parse_token_stream)
    │       └── definition.py
    └── importer.py  (route discovery via pkgutil)

There are no circular imports. parser.py uses TYPE_CHECKING to reference Command without importing it at runtime.

Data Flow

A CLI invocation goes through four phases:

1. Route Discovery (Cli.from_routes)

routes/ package  →  importer.get_modules()  →  inspect.getmembers()  →  Command tree

get_modules uses pkgutil.walk_packages to recursively find every module under the routes package. Each module is expected to export exactly one Command (created via @command()). The dotted module path determines placement in the command tree:

routes/__init__.py        →  root command
routes/greet.py           →  root.subcommands["greet"]
routes/config/__init__.py →  root.subcommands["config"]  (namespace)
routes/config/set.py      →  root.subcommands["config"].subcommands["set"]

Intermediate namespace commands (like config) are auto-created with a default action that prints short help.

2. Command Construction (@command decorator)

The @command() decorator calls extract_parameters(func) which introspects the function signature via inspect.signature:

Signature pattern

Result

name: str

Positional Argument

greeting: str = "hi"

Named Option with default

*files: str

Variadic Argument (must be last)

dry_run: bool = False

Boolean flag Option

Type annotations are resolved to converter callables through annotations.py’s registry (str, int, float, bool).

Auto-generated short aliases (e.g. -n for --name) are assigned during extraction, skipping any letters already claimed by implicit options (-h, -v).

3. Token Parsing (_parse_token_stream)

When command.execute(args) is called, it delegates to parse_and_execute_impl, which calls _parse_token_stream to do the actual token scanning.

The scanner is a single left-to-right pass over the token list. It recognises:

  • Long options: --name value, --name=value

  • Short options: -n value, -v (aliases resolved via _build_alias_map)

  • Boolean flags: --verbose, -v (no value consumed)

  • -- separator: everything after it becomes a raw positional

  • Subcommand names: scanning stops immediately; the index is returned

  • Positionals: anything else is collected in order

Options and positionals can be interspersedAlice --template "Hi, {}!" and --template "Hi, {}!" Alice both work. The greedy consumption rule means --format json always eats json as the option value, even if json is also a subcommand name.

The scanner returns a triple:

(positionals: list[str], parsed_opts: dict[str, list], subcmd_index: int | None)

4. Dispatch and Execution (parse_and_execute_impl)

After scanning, parse_and_execute_impl handles the result in order:

  1. Implicit options: --help prints help and returns 0. --version (root-only) prints the version and returns 0.

  2. Cascading context: Cascading implicit options (like --verbose) are accumulated into a context dict that is passed down to child commands. This context is not forwarded as kwargs to run().

  3. Subcommand dispatch: If a subcommand was detected, recursively call parse_and_execute_impl with the remaining args and updated context.

  4. Namespace default: If the command has subcommands but received no positionals and no user options, print short help (the “did you mean?” experience).

  5. Leaf execution: Convert positionals to typed arguments, resolve option defaults, and call command.run(*args, **kwargs). A None return is coerced to 0.

Key Abstractions

Command (command.py)

The central node type. Every command — root, namespace, or leaf — is a Command. Key fields:

Field

Purpose

name

Display name (used in help text, version output)

run

The callable to invoke

arguments

Ordered list of positional Arguments

options

User-defined Options (forwarded to run())

subcommands

Child commands (mutually exclusive with variadic args)

implicit_options

Framework options like --help, --verbose

version

Set only on root command by Cli

Argument / Option (definition.py)

Simple dataclasses. Argument has a variadic flag. Option has aliases (short forms), cascading (propagates to children), and default.

Implicit vs User Options

This is the most important architectural boundary. Every Command has two option namespaces:

  • implicit_options: Framework-owned. --help, --verbose, --colors. Handled by the parser before dispatch. Never passed to run().

  • options: User-defined. Declared in the function signature. Passed as kwargs to run().

--version is a special case: it is injected into implicit_options by Cli on the root command only. Subcommands don’t recognise it.

Cli (init.py)

The top-level entry point. Responsibilities:

  • Owns the root Command and the version string

  • Auto-detects version from package metadata (fallback: explicit version= kwarg)

  • Injects the completions subcommand

  • Injects --version into root’s implicit options

  • cli() calls sys.exit(root.execute())

IMPLICIT_OPTIONS (definition.py)

A module-level dict that serves as the default implicit options for every new Command. The Command.__post_init__ copies these if none are provided. This ensures a fresh dict per command (avoiding shared mutable state) while keeping a single source of truth for the defaults.

How to Add a New Feature

New option type (e.g. Path)

  1. Add the converter to _default_converters in annotations.py

  2. Add the type to the ScalarParameterTypes union

  3. Write tests in test_command.py for extract_parameters

New implicit option (e.g. --quiet)

  1. Add it to IMPLICIT_OPTIONS in definition.py

  2. Handle it in parse_and_execute_impl in parser.py (after the help/version block)

  3. If cascading, add context accumulation logic

  4. Reserve its short alias (e.g. -q) in the IMPLICIT_OPTIONS entry

New CLI-level feature (e.g. --no-color)

Follow the --version pattern:

  1. Add the option in Cli.__post_init__ → inject into root_command.implicit_options

  2. Handle it in parse_and_execute_impl

Testing Strategy

Tests live in tests/ and are run with uv run pytest.

File

Scope

test_parser.py

_parse_token_stream + parse_and_execute_impl unit tests

test_command.py

extract_parameters, Command, Argument/Option

test_cli.py

Cli construction, routing, from_routes

test_integration_greeter.py

Full-stack tests against the greeter experiment

The greeter experiment (experiments/greeter/) serves as both an example app and an integration test fixture. conftest.py adds it to sys.path so the test suite can import it directly.

Conventions

  • Unit tests should construct Command objects directly — don’t go through Cli unless testing Cli itself.

  • Integration tests use root.execute([...]) with explicit arg lists (never sys.argv).

  • Use capsys for output assertions rather than mocking print.