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 |
|---|---|
|
Positional |
|
Named |
|
Variadic |
|
Boolean flag |
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=valueShort options:
-n value,-v(aliases resolved via_build_alias_map)Boolean flags:
--verbose,-v(no value consumed)--separator: everything after it becomes a raw positionalSubcommand names: scanning stops immediately; the index is returned
Positionals: anything else is collected in order
Options and positionals can be interspersed — Alice --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:
Implicit options:
--helpprints help and returns 0.--version(root-only) prints the version and returns 0.Cascading context: Cascading implicit options (like
--verbose) are accumulated into acontextdict that is passed down to child commands. This context is not forwarded as kwargs torun().Subcommand dispatch: If a subcommand was detected, recursively call
parse_and_execute_implwith the remaining args and updated context.Namespace default: If the command has subcommands but received no positionals and no user options, print short help (the “did you mean?” experience).
Leaf execution: Convert positionals to typed arguments, resolve option defaults, and call
command.run(*args, **kwargs). ANonereturn is coerced to0.
Key Abstractions¶
Command (command.py)¶
The central node type. Every command — root, namespace, or leaf — is a
Command. Key fields:
Field |
Purpose |
|---|---|
|
Display name (used in help text, version output) |
|
The callable to invoke |
|
Ordered list of positional |
|
User-defined |
|
Child commands (mutually exclusive with variadic args) |
|
Framework options like |
|
Set only on root command by |
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 torun().options: User-defined. Declared in the function signature. Passed as kwargs torun().
--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
Commandand the version stringAuto-detects version from package metadata (fallback: explicit
version=kwarg)Injects the
completionssubcommandInjects
--versioninto root’s implicit optionscli()callssys.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)¶
Add the converter to
_default_convertersinannotations.pyAdd the type to the
ScalarParameterTypesunionWrite tests in
test_command.pyforextract_parameters
New implicit option (e.g. --quiet)¶
Add it to
IMPLICIT_OPTIONSindefinition.pyHandle it in
parse_and_execute_implinparser.py(after the help/version block)If cascading, add context accumulation logic
Reserve its short alias (e.g.
-q) in theIMPLICIT_OPTIONSentry
New CLI-level feature (e.g. --no-color)¶
Follow the --version pattern:
Add the option in
Cli.__post_init__→ inject intoroot_command.implicit_optionsHandle it in
parse_and_execute_impl
Testing Strategy¶
Tests live in tests/ and are run with uv run pytest.
File |
Scope |
|---|---|
|
|
|
|
|
|
|
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
Commandobjects directly — don’t go throughCliunless testingCliitself.Integration tests use
root.execute([...])with explicit arg lists (neversys.argv).Use
capsysfor output assertions rather than mockingprint.