Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,29 @@ ConfitValidationError: 2 validation errors for __main__.func()

Visit the [documentation](https://aphp.github.io/confit/) for more information!

### Subcommands

Confit applications can be composed into groups of subcommands:

```python
app = Cli()
training = Cli()


@training.command(name="run")
def run(epochs: int = 10):
print(f"Training for {epochs} epochs")


app.add_subcommands(training, name="training")
```

Run the nested command with:

```bash
python script.py training run --config config.yml --epochs 20
```

## Acknowledgement

We would like to thank [Assistance Publique – Hôpitaux de Paris](https://www.aphp.fr/)
Expand Down
4 changes: 4 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

- Restore nested CLI applications with `Cli.add_subcommands()`. Confit CLI objects
can temporarily still be mounted under Typer applications without making Typer
a Confit dependency. `Cli.add_typer()` and Typer mounting now emit a visible
deprecation warning and will be removed in the next major release.
- When a class instantiation is recorded by multiple factories (e.g. edsnlp's `load_pipe` which in turns calls the pipe architecture factory), we don't overwrite the instance origin and instead make it accessible to the config serializer via `__confit_serialization_origin__ = 'first'|'last'` on registered objects to decide which factory config to use when serializing the object.

## v0.11.1 (2026-06-15)
Expand Down
235 changes: 216 additions & 19 deletions confit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import inspect
import re
import sys
import warnings
from pathlib import Path
from types import UnionType
from typing import (
Expand All @@ -19,7 +20,7 @@

from .config import Config, merge_from_disk
from .errors import ConfitValidationError, LegacyValidationError, patch_errors
from .registry import validate_arguments
from .registry import VisibleDeprecationWarning, validate_arguments
from .utils.random import set_seed
from .utils.settings import is_debug
from .utils.xjson import loads
Expand Down Expand Up @@ -75,6 +76,11 @@ class Cli:

def __init__(self, *args: Any, **kwargs: Any):
self.commands = {}
self.subcommands = []
self.typer_args = args
self.typer_kwargs = kwargs
self.typer_cli = None
self.typer_warning_emitted = False

# User code calls this as a decorator to register one command.
# The stored metadata is used by main.
Expand Down Expand Up @@ -104,40 +110,231 @@ def wrapper(fn):
"fn": fn,
"validated": validated,
"help": help,
"typer_options": {
"cls": cls,
"context_settings": context_settings,
"help": help,
"epilog": epilog,
"short_help": short_help,
"options_metavar": options_metavar,
"add_help_option": add_help_option,
"no_args_is_help": no_args_is_help,
"hidden": hidden,
"deprecated": deprecated,
"rich_help_panel": rich_help_panel,
},
"registry": registry,
"default_config": default_config,
"merge_with_default_config": merge_with_default_config,
}
self.typer_cli = None
return validated

return wrapper

def add_subcommands(
self,
cli: "Cli",
*,
name: Optional[str] = None,
help: Optional[str] = None,
short_help: Optional[str] = None,
hidden: bool = False,
deprecated: bool = False,
) -> None:
"""Add another Confit CLI as a group or flattened command entries"""
if not isinstance(cli, Cli):
raise TypeError("add_subcommands expects a Confit Cli instance")
self.subcommands.append(
{
"cli": cli,
"name": name,
"help": help,
"short_help": short_help,
"hidden": hidden,
"deprecated": deprecated,
}
)
self.typer_cli = None

def add_typer(
self,
cli: "Cli",
*,
name: Optional[str] = None,
help: Optional[str] = None,
short_help: Optional[str] = None,
hidden: bool = False,
deprecated: bool = False,
) -> None:
"""Add Confit subcommands through the deprecated Typer compatible name"""
warnings.warn(
"Cli.add_typer() is deprecated, use Cli.add_subcommands() instead.",
VisibleDeprecationWarning,
stacklevel=2,
)
self.add_subcommands(
cli,
name=name,
help=help,
short_help=short_help,
hidden=hidden,
deprecated=deprecated,
)

def command_entries(self):
"""Return the command and group entries used by dispatch and help"""
entries = {
name: {"kind": "command", "cli": self, "command": command}
for name, command in self.commands.items()
}
for group in self.subcommands:
if group["name"] is None:
entries.update(group["cli"].command_entries())
else:
entries[group["name"]] = {
"kind": "group",
"cli": group["cli"],
"group": group,
}
return entries

def format_commands_help(self):
"""Render the visible commands and groups for the current CLI level"""
lines = ["Commands:"]
for name, entry in self.command_entries().items():
if entry["kind"] == "command":
options = entry["command"]["typer_options"]
else:
options = entry["group"]
if options["hidden"]:
continue
description = options.get("short_help") or options.get("help")
suffix = " (deprecated)" if options["deprecated"] else ""
if description:
description = inspect.cleandoc(description).splitlines()[0]
lines.append(f" {name}{suffix} {description}")
else:
lines.append(f" {name}{suffix}")
return "\n".join(lines)

def get_typer_cli(self):
"""Build and cache the Typer representation read by external Typer apps"""
if self.typer_cli is not None:
return self.typer_cli
try:
typer = importlib.import_module("typer")
except ImportError as e:
raise ImportError(
"Typer interoperability requires the application to install "
"and declare a dependency on 'typer'."
) from e

if not self.typer_warning_emitted:
warnings.warn(
"Adapting a Confit Cli for Typer is deprecated and will be removed "
"in the next major Confit release.",
VisibleDeprecationWarning,
stacklevel=3,
)
self.typer_warning_emitted = True

typer_cli = typer.Typer(*self.typer_args, **self.typer_kwargs)
for name, command in self.commands.items():
options = dict(command["typer_options"])
options["context_settings"] = {
**(options["context_settings"] or {}),
"ignore_unknown_options": True,
"allow_extra_args": True,
}

def create_callback(command_name, command_record):
def callback(
ctx: typer.Context,
config: Optional[List[Path]] = None,
):
return self.run_command(
command_name,
command_record,
config,
list(ctx.args),
)

return callback

typer_cli.command(name=name, **options)(create_callback(name, command))

for group in self.subcommands:
typer_cli.add_typer(
group["cli"],
name=group["name"],
help=group["help"],
short_help=group["short_help"],
hidden=group["hidden"],
deprecated=group["deprecated"],
)
self.typer_cli = typer_cli
return typer_cli

@property
def registered_commands(self):
return self.get_typer_cli().registered_commands

@property
def registered_groups(self):
return self.get_typer_cli().registered_groups

@property
def registered_callback(self):
return self.get_typer_cli().registered_callback

@property
def info(self):
return self.get_typer_cli().info

def __call__(self, args: Optional[List[str]] = None):
return self.main(args=args)

# Script entry points call this to handle argv.
# It either prints help or executes the selected command.
def main(self, args: Optional[List[str]] = None):
args = list(sys.argv[1:] if args is None else args)
commands_help = "\n".join(
["Commands:", *(f" {name}" for name in self.commands)]
)

# The command name is optional for single command apps.
# args is left with only config paths and overrides.
if args and args[0] in self.commands:
name = args.pop(0)
command = self.commands[name]
elif len(self.commands) == 1:
name = next(iter(self.commands))
command = self.commands[name]
elif args and args[0] in {"--help", "-h"}:
print(commands_help)
raise SystemExit(0)
else:
raise Exception("Missing command")
cli = self
command = None
name = None
entry_count = 0
group_help = None
while command is None:
entries = cli.command_entries()
commands_help = cli.format_commands_help()
if group_help:
commands_help = f"{inspect.cleandoc(group_help)}\n\n{commands_help}"
implicit_command = (
cli is self and not self.subcommands and len(entries) == 1
)
if args and args[0] in {"--help", "-h"} and not implicit_command:
print(commands_help)
raise SystemExit(0)
if args and args[0] in entries:
name = args.pop(0)
entry = entries[name]
if entry["kind"] == "group":
cli = entry["cli"]
group_help = entry["group"]["help"]
continue
command = entry["command"]
entry_count = len(entries)
elif implicit_command:
name, entry = next(iter(entries.items()))
command = entry["command"]
entry_count = 1
else:
if not args:
print(commands_help)
raise SystemExit(0)
raise Exception("Missing command")

if not args and len(self.commands) > 1:
if not args and entry_count > 1:
print(commands_help)
raise SystemExit(0)

Expand Down
29 changes: 29 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,3 +181,32 @@ func(
seed=seed,
)
```

## Subcommands

Compose Confit applications with `add_subcommands`:

```python
from confit import Cli

app = Cli()
training = Cli()


@training.command(name="run")
def run(epochs: int = 10):
print(f"Training for {epochs} epochs")


app.add_subcommands(training, name="training", help="Training commands")
```

The command accepts the same configuration and override arguments as a top-level
Confit command:

```bash
python script.py training run --config config.yml --epochs 20
```

Omit `name` to expose the child commands directly on the parent. Configuration
sections continue to use the leaf command name, such as `run` in this example.
Loading
Loading