From e7a6b1011eac6ce14f62e1cde37c60f64de3b4d7 Mon Sep 17 00:00:00 2001 From: Perceval Wajsburt Date: Sat, 11 Jul 2026 11:07:52 +0000 Subject: [PATCH] fix: add back subcommands and add_typer (alias) --- README.md | 23 ++++ changelog.md | 4 + confit/cli.py | 235 ++++++++++++++++++++++++++++++++++++---- docs/getting-started.md | 29 +++++ tests/test_cli.py | 171 ++++++++++++++++++++++++++++- 5 files changed, 442 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index f1ad8bc..644acaf 100644 --- a/README.md +++ b/README.md @@ -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/) diff --git a/changelog.md b/changelog.md index 581960a..282f672 100644 --- a/changelog.md +++ b/changelog.md @@ -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) diff --git a/confit/cli.py b/confit/cli.py index 1ba8c8c..42a9eec 100644 --- a/confit/cli.py +++ b/confit/cli.py @@ -2,6 +2,7 @@ import inspect import re import sys +import warnings from pathlib import Path from types import UnionType from typing import ( @@ -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 @@ -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. @@ -104,14 +110,188 @@ 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) @@ -119,25 +299,42 @@ def __call__(self, args: Optional[List[str]] = None): # 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) diff --git a/docs/getting-started.md b/docs/getting-started.md index d1bdebd..4f6b0ff 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -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. diff --git a/tests/test_cli.py b/tests/test_cli.py index 64f5549..7df9757 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,8 +1,10 @@ import datetime +import importlib import io import os import random import re +import warnings from contextlib import redirect_stderr, redirect_stdout from dataclasses import dataclass from typing import List, Literal, Optional, Union @@ -10,7 +12,12 @@ import pytest from confit import Cli, Config, Registry -from confit.registry import PYDANTIC_V1, RegistryCollection, set_default_registry +from confit.registry import ( + PYDANTIC_V1, + RegistryCollection, + VisibleDeprecationWarning, + set_default_registry, +) @dataclass @@ -196,6 +203,168 @@ def test_cli_multi_command_without_command_args_shows_help(): assert "Commands:\n first\n second" in result.stdout +def test_cli_subcommands_support_groups_flattening_nesting_and_help(): + root = Cli() + training = Cli() + conversions = Cli() + admin = Cli() + jobs = Cli() + + @training.command(name="run") + def run(value: int = 1): + print(f"run: {value}") + + @conversions.command(name="convert") + def convert(value: int): + print(f"converted: {value}") + + @jobs.command(name="execute") + def execute(): + print("executed") + + root.add_subcommands( + training, + name="training", + help="Manage training runs", + short_help="Training commands", + deprecated=True, + ) + root.add_subcommands(conversions) + admin.add_subcommands(jobs, name="jobs") + root.add_subcommands(admin, name="admin") + root.add_subcommands(Cli(), name="internal", hidden=True) + + result = runner.invoke(root, ["training", "run", "--value", "3"]) + assert result.exit_code == 0, result_text(result) + assert result.stdout == "run: 3\n" + + result = runner.invoke(root, ["convert", "--value", "4"]) + assert result.exit_code == 0, result_text(result) + assert result.stdout == "converted: 4\n" + + result = runner.invoke(root, ["admin", "jobs", "execute"]) + assert result.exit_code == 0, result_text(result) + assert result.stdout == "executed\n" + + result = runner.invoke(root, ["--help"]) + assert result.exit_code == 0 + assert "training (deprecated) Training commands" in result.stdout + assert "convert" in result.stdout + assert "internal" not in result.stdout + + result = runner.invoke(root, ["training", "--help"]) + assert result.exit_code == 0 + assert result.stdout == "Manage training runs\n\nCommands:\n run\n" + + result = runner.invoke(root, []) + assert result.exit_code == 0 + assert result.stdout.startswith("Commands:\n") + + result = runner.invoke(root, ["unknown"]) + assert result.exit_code == 1 + assert str(result.exception) == "Missing command" + + +def test_cli_add_typer_alias_and_native_paths_do_not_import_typer(monkeypatch): + root = Cli() + child = Cli() + native = Cli() + + @child.command(name="run") + def run(): + print("legacy") + + @native.command(name="run") + def native_run(): + print("native") + + with pytest.warns(VisibleDeprecationWarning, match="add_subcommands"): + root.add_typer(child, name="legacy") + + original_import_module = importlib.import_module + + def reject_typer_import(name, package=None): + if name == "typer": + raise AssertionError("native execution imported typer") + return original_import_module(name, package) + + monkeypatch.setattr(importlib, "import_module", reject_typer_import) + + result = runner.invoke(root, ["legacy", "run"]) + assert result.exit_code == 0, result_text(result) + assert result.stdout == "legacy\n" + + result = runner.invoke(native, []) + assert result.exit_code == 0, result_text(result) + assert result.stdout == "native\n" + + +def test_cli_typer_bridge_is_lazy_nested_rebuildable_and_reports_missing_dependency( + monkeypatch, +): + typer = pytest.importorskip("typer") + typer_testing = pytest.importorskip("typer.testing") + confit_app = Cli(add_completion=False) + nested_app = Cli(add_completion=False) + + @confit_app.command(name="first") + def first(value: int = 1): + print(f"first: {value}") + + @nested_app.command(name="deep") + def deep(): + print("deep") + + confit_app.add_subcommands(nested_app, name="nested") + + typer_app = typer.Typer(add_completion=False) + typer_app.add_typer(confit_app, name="config") + assert confit_app.typer_cli is None + + with pytest.warns(VisibleDeprecationWarning, match="next major") as warning_record: + result = typer_testing.CliRunner().invoke( + typer_app, + ["config", "first", "--value", "2"], + ) + assert result.exit_code == 0, result.output + assert result.output == "first: 2\n" + assert len(warning_record) == 2 + + result = typer_testing.CliRunner().invoke( + typer_app, + ["config", "nested", "deep"], + ) + assert result.exit_code == 0, result.output + assert result.output == "deep\n" + + @confit_app.command(name="second") + def second(): + print("second") + + assert confit_app.typer_cli is None + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + result = typer_testing.CliRunner().invoke( + typer_app, + ["config", "second"], + ) + assert result.exit_code == 0, result.output + assert result.output == "second\n" + assert not [w for w in caught if isinstance(w.message, VisibleDeprecationWarning)] + + app_without_typer = Cli() + original_import_module = importlib.import_module + + def reject_typer_import(name, package=None): + if name == "typer": + raise ImportError("missing") + return original_import_module(name, package) + + monkeypatch.setattr(importlib, "import_module", reject_typer_import) + with pytest.raises(ImportError, match="declare a dependency on 'typer'"): + app_without_typer.get_typer_cli() + + def test_cli_accepts_config_equals_path(change_test_dir): result = runner.invoke( app,