diff --git a/docs/index.md b/docs/index.md index 95fe510..3e846c9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -507,8 +507,7 @@ models. There are two primary use cases for Pydantic settings CLI: By default, the experience is tailored towards use case #1 and builds on the foundations established in [parsing environment variables](#parsing-environment-variable-values). If your use case primarily falls into #2, you will likely -want to enable [enforcing required arguments at the CLI](#enforce-required-arguments-at-cli) and [nested model default -partial updates](#nested-model-default-partial-updates). +want to enable most of the defaults outlined at the end of [creating CLI applications](#creating-cli-applications). ### The Basics @@ -560,19 +559,7 @@ print(Settings().model_dump()) ``` To enable CLI parsing, we simply set the `cli_parse_args` flag to a valid value, which retains similar conotations as -defined in `argparse`. Alternatively, we can also directly provide the args to parse at time of instantiation: - -```py -from pydantic_settings import BaseSettings - - -class Settings(BaseSettings): - this_foo: str - - -print(Settings(_cli_parse_args=['--this_foo', 'is such a foo']).model_dump()) -#> {'this_foo': 'is such a foo'} -``` +defined in `argparse`. Note that a CLI settings source is [**the topmost source**](#field-value-priority) by default unless its [priority value is customised](#customise-settings-sources): @@ -875,6 +862,95 @@ sys.argv = ['example.py', 'gamma-cmd', '--opt-gamma=hi'] assert get_subcommand(Root()).model_dump() == {'opt_gamma': 'hi'} ``` +### Creating CLI Applications + +The `CliApp` class provides two utility methods, `CliApp.run` and `CliApp.run_subcommand`, that can be used to run a +Pydantic `BaseSettings`, `BaseModel`, or `pydantic.dataclasses.dataclass` as a CLI application. Primarily, the methods +provide structure for running `cli_cmd` methods associated with models. + +`CliApp.run` can be used in directly providing the `cli_args` to be parsed, and will run the model `cli_cmd` method (if +defined) after instantiation: + +```py +from pydantic_settings import BaseSettings, CliApp + + +class Settings(BaseSettings): + this_foo: str + + def cli_cmd(self) -> None: + # Print the parsed data + print(self.model_dump()) + #> {'this_foo': 'is such a foo'} + + # Update the parsed data showing cli_cmd ran + self.this_foo = 'ran the foo cli cmd' + + +s = CliApp.run(Settings, cli_args=['--this_foo', 'is such a foo']) +print(s.model_dump()) +#> {'this_foo': 'ran the foo cli cmd'} +``` + +Similarly, the `CliApp.run_subcommand` can be used in recursive fashion to run the `cli_cmd` method of a subcommand: + +```py +from pydantic import BaseModel + +from pydantic_settings import CliApp, CliPositionalArg, CliSubCommand + + +class Init(BaseModel): + directory: CliPositionalArg[str] + + def cli_cmd(self) -> None: + print(f'git init "{self.directory}"') + #> git init "dir" + self.directory = 'ran the git init cli cmd' + + +class Clone(BaseModel): + repository: CliPositionalArg[str] + directory: CliPositionalArg[str] + + def cli_cmd(self) -> None: + print(f'git clone from "{self.repository}" into "{self.directory}"') + self.directory = 'ran the clone cli cmd' + + +class Git(BaseModel): + clone: CliSubCommand[Clone] + init: CliSubCommand[Init] + + def cli_cmd(self) -> None: + CliApp.run_subcommand(self) + + +cmd = CliApp.run(Git, cli_args=['init', 'dir']) +assert cmd.model_dump() == { + 'clone': None, + 'init': {'directory': 'ran the git init cli cmd'}, +} +``` + +!!! note + Unlike `CliApp.run`, `CliApp.run_subcommand` requires the subcommand model to have a defined `cli_cmd` method. + +For `BaseModel` and `pydantic.dataclasses.dataclass` types, `CliApp.run` will internally use the following +`BaseSettings` configuration defaults: + +* `alias_generator=AliasGenerator(lambda s: s.replace('_', '-'))` +* `nested_model_default_partial_update=True` +* `case_sensitive=True` +* `cli_hide_none_type=True` +* `cli_avoid_json=True` +* `cli_enforce_required=True` +* `cli_implicit_flags=True` + +!!! note + The alias generator for kebab case does not propagate to subcommands or submodels and will have to be manually set + in these cases. + ### Customizing the CLI Experience The below flags can be used to customise the CLI experience to your needs. @@ -1241,7 +1317,7 @@ defined one that specifies the `root_parser` object. import sys from argparse import ArgumentParser -from pydantic_settings import BaseSettings, CliSettingsSource +from pydantic_settings import BaseSettings, CliApp, CliSettingsSource parser = ArgumentParser() parser.add_argument('--food', choices=['pear', 'kiwi', 'lime']) @@ -1256,13 +1332,15 @@ cli_settings = CliSettingsSource(Settings, root_parser=parser) # Parse and load CLI settings from the command line into the settings source. sys.argv = ['example.py', '--food', 'kiwi', '--name', 'waldo'] -print(Settings(_cli_settings_source=cli_settings(args=True)).model_dump()) +s = CliApp.run(Settings, cli_settings_source=cli_settings) +print(s.model_dump()) #> {'name': 'waldo'} # Load CLI settings from pre-parsed arguments. i.e., the parsing occurs elsewhere and we # just need to load the pre-parsed args into the settings source. parsed_args = parser.parse_args(['--food', 'kiwi', '--name', 'ralph']) -print(Settings(_cli_settings_source=cli_settings(parsed_args=parsed_args)).model_dump()) +s = CliApp.run(Settings, cli_args=parsed_args, cli_settings_source=cli_settings) +print(s.model_dump()) #> {'name': 'ralph'} ``` @@ -1281,6 +1359,11 @@ parser methods that can be customised, along with their argparse counterparts (t For a non-argparse parser the parser methods can be set to `None` if not supported. The CLI settings will only raise an error when connecting to the root parser if a parser method is necessary but set to `None`. +!!! note + The `formatter_class` is only applied to subcommands. The `CliSettingsSource` never touches or modifies any of the + external parser settings to avoid breaking changes. Since subcommands reside on their own internal parser trees, we + can safely apply the `formatter_class` settings without breaking the external parser logic. + ## Secrets Placing secret values in files is a common pattern to provide sensitive configuration to an application. diff --git a/pydantic_settings/__init__.py b/pydantic_settings/__init__.py index 696276d..3e1c8f8 100644 --- a/pydantic_settings/__init__.py +++ b/pydantic_settings/__init__.py @@ -1,4 +1,4 @@ -from .main import BaseSettings, SettingsConfigDict +from .main import BaseSettings, CliApp, SettingsConfigDict from .sources import ( AzureKeyVaultSettingsSource, CliExplicitFlag, @@ -24,6 +24,7 @@ 'BaseSettings', 'DotEnvSettingsSource', 'EnvSettingsSource', + 'CliApp', 'CliSettingsSource', 'CliSubCommand', 'CliPositionalArg', diff --git a/pydantic_settings/main.py b/pydantic_settings/main.py index fd5e361..723d6d5 100644 --- a/pydantic_settings/main.py +++ b/pydantic_settings/main.py @@ -1,10 +1,14 @@ from __future__ import annotations as _annotations -from typing import Any, ClassVar +from argparse import Namespace +from types import SimpleNamespace +from typing import Any, ClassVar, TypeVar -from pydantic import ConfigDict +from pydantic import AliasGenerator, ConfigDict from pydantic._internal._config import config_keys -from pydantic._internal._utils import deep_update +from pydantic._internal._signature import _field_name_for_signature +from pydantic._internal._utils import deep_update, is_model_class +from pydantic.dataclasses import is_pydantic_dataclass from pydantic.main import BaseModel from .sources import ( @@ -17,9 +21,14 @@ InitSettingsSource, PathType, PydanticBaseSettingsSource, + PydanticModel, SecretsSettingsSource, + SettingsError, + get_subcommand, ) +T = TypeVar('T') + class SettingsConfigDict(ConfigDict, total=False): case_sensitive: bool @@ -33,7 +42,6 @@ class SettingsConfigDict(ConfigDict, total=False): env_parse_enums: bool | None cli_prog_name: str | None cli_parse_args: bool | list[str] | tuple[str, ...] | None - cli_settings_source: CliSettingsSource[Any] | None cli_parse_none_str: str | None cli_hide_none_type: bool cli_avoid_json: bool @@ -91,7 +99,8 @@ class BaseSettings(BaseModel): All the below attributes can be set via `model_config`. Args: - _case_sensitive: Whether environment variables names should be read with case-sensitivity. Defaults to `None`. + _case_sensitive: Whether environment and CLI variable names should be read with case-sensitivity. + Defaults to `None`. _nested_model_default_partial_update: Whether to allow partial updates on nested model default object fields. Defaults to `False`. _env_prefix: Prefix for all environment variables. Defaults to `None`. @@ -345,26 +354,24 @@ def _settings_build_values( file_secret_settings=file_secret_settings, ) + (default_settings,) if not any([source for source in sources if isinstance(source, CliSettingsSource)]): - if cli_parse_args is not None or cli_settings_source is not None: - cli_settings = ( - CliSettingsSource( - self.__class__, - cli_prog_name=cli_prog_name, - cli_parse_args=cli_parse_args, - cli_parse_none_str=cli_parse_none_str, - cli_hide_none_type=cli_hide_none_type, - cli_avoid_json=cli_avoid_json, - cli_enforce_required=cli_enforce_required, - cli_use_class_docs_for_groups=cli_use_class_docs_for_groups, - cli_exit_on_error=cli_exit_on_error, - cli_prefix=cli_prefix, - cli_flag_prefix_char=cli_flag_prefix_char, - cli_implicit_flags=cli_implicit_flags, - cli_ignore_unknown_args=cli_ignore_unknown_args, - case_sensitive=case_sensitive, - ) - if cli_settings_source is None - else cli_settings_source + if isinstance(cli_settings_source, CliSettingsSource): + sources = (cli_settings_source,) + sources + elif cli_parse_args is not None: + cli_settings = CliSettingsSource[Any]( + self.__class__, + cli_prog_name=cli_prog_name, + cli_parse_args=cli_parse_args, + cli_parse_none_str=cli_parse_none_str, + cli_hide_none_type=cli_hide_none_type, + cli_avoid_json=cli_avoid_json, + cli_enforce_required=cli_enforce_required, + cli_use_class_docs_for_groups=cli_use_class_docs_for_groups, + cli_exit_on_error=cli_exit_on_error, + cli_prefix=cli_prefix, + cli_flag_prefix_char=cli_flag_prefix_char, + cli_implicit_flags=cli_implicit_flags, + cli_ignore_unknown_args=cli_ignore_unknown_args, + case_sensitive=case_sensitive, ) sources = (cli_settings,) + sources if sources: @@ -401,7 +408,6 @@ def _settings_build_values( env_parse_enums=None, cli_prog_name=None, cli_parse_args=None, - cli_settings_source=None, cli_parse_none_str=None, cli_hide_none_type=False, cli_avoid_json=False, @@ -420,3 +426,114 @@ def _settings_build_values( secrets_dir=None, protected_namespaces=('model_', 'settings_'), ) + + +class CliApp: + """ + A utility class for running Pydantic `BaseSettings`, `BaseModel`, or `pydantic.dataclasses.dataclass` as + CLI applications. + """ + + @staticmethod + def _run_cli_cmd(model: Any, cli_cmd_method_name: str, is_required: bool) -> Any: + if hasattr(type(model), cli_cmd_method_name): + getattr(type(model), cli_cmd_method_name)(model) + elif is_required: + raise SettingsError(f'Error: {type(model).__name__} class is missing {cli_cmd_method_name} entrypoint') + return model + + @staticmethod + def run( + model_cls: type[T], + cli_args: list[str] | Namespace | SimpleNamespace | dict[str, Any] | None = None, + cli_settings_source: CliSettingsSource[Any] | None = None, + cli_exit_on_error: bool | None = None, + cli_cmd_method_name: str = 'cli_cmd', + **model_init_data: Any, + ) -> T: + """ + Runs a Pydantic `BaseSettings`, `BaseModel`, or `pydantic.dataclasses.dataclass` as a CLI application. + Running a model as a CLI application requires the `cli_cmd` method to be defined in the model class. + + Args: + model_cls: The model class to run as a CLI application. + cli_args: The list of CLI arguments to parse. If `cli_settings_source` is specified, this may + also be a namespace or dictionary of pre-parsed CLI arguments. Defaults to `sys.argv[1:]`. + cli_settings_source: Override the default CLI settings source with a user defined instance. + Defaults to `None`. + cli_exit_on_error: Determines whether this function exits on error. If model is subclass of + `BaseSettings`, defaults to BaseSettings `cli_exit_on_error` value. Otherwise, defaults to + `True`. + cli_cmd_method_name: The CLI command method name to run. Defaults to "cli_cmd". + model_init_data: The model init data. + + Returns: + The ran instance of model. + + Raises: + SettingsError: If model_cls is not subclass of `BaseModel` or `pydantic.dataclasses.dataclass`. + SettingsError: If model_cls does not have a `cli_cmd` entrypoint defined. + """ + + if not (is_pydantic_dataclass(model_cls) or is_model_class(model_cls)): + raise SettingsError( + f'Error: {model_cls.__name__} is not subclass of BaseModel or pydantic.dataclasses.dataclass' + ) + + cli_settings = None + cli_parse_args = True if cli_args is None else cli_args + if cli_settings_source is not None: + if isinstance(cli_parse_args, (Namespace, SimpleNamespace, dict)): + cli_settings = cli_settings_source(parsed_args=cli_parse_args) + else: + cli_settings = cli_settings_source(args=cli_parse_args) + elif isinstance(cli_parse_args, (Namespace, SimpleNamespace, dict)): + raise SettingsError('Error: `cli_args` must be list[str] or None when `cli_settings_source` is not used') + + model_init_data['_cli_parse_args'] = cli_parse_args + model_init_data['_cli_exit_on_error'] = cli_exit_on_error + model_init_data['_cli_settings_source'] = cli_settings + if not issubclass(model_cls, BaseSettings): + + class CliAppBaseSettings(BaseSettings, model_cls): # type: ignore + model_config = SettingsConfigDict( + alias_generator=AliasGenerator(lambda s: s.replace('_', '-')), + nested_model_default_partial_update=True, + case_sensitive=True, + cli_hide_none_type=True, + cli_avoid_json=True, + cli_enforce_required=True, + cli_implicit_flags=True, + ) + + model = CliAppBaseSettings(**model_init_data) + model_init_data = {} + for field_name, field_info in model.model_fields.items(): + model_init_data[_field_name_for_signature(field_name, field_info)] = getattr(model, field_name) + + return CliApp._run_cli_cmd(model_cls(**model_init_data), cli_cmd_method_name, is_required=False) + + @staticmethod + def run_subcommand( + model: PydanticModel, cli_exit_on_error: bool | None = None, cli_cmd_method_name: str = 'cli_cmd' + ) -> PydanticModel: + """ + Runs the model subcommand. Running a model subcommand requires the `cli_cmd` method to be defined in + the nested model subcommand class. + + Args: + model: The model to run the subcommand from. + cli_exit_on_error: Determines whether this function exits with error if no subcommand is found. + Defaults to model_config `cli_exit_on_error` value if set. Otherwise, defaults to `True`. + cli_cmd_method_name: The CLI command method name to run. Defaults to "cli_cmd". + + Returns: + The ran subcommand model. + + Raises: + SystemExit: When no subcommand is found and cli_exit_on_error=`True` (the default). + SettingsError: When no subcommand is found and cli_exit_on_error=`False`. + """ + + subcommand = get_subcommand(model, is_required=True, cli_exit_on_error=cli_exit_on_error) + return CliApp._run_cli_cmd(subcommand, cli_cmd_method_name, is_required=True) diff --git a/pydantic_settings/sources.py b/pydantic_settings/sources.py index 5049eea..2384b03 100644 --- a/pydantic_settings/sources.py +++ b/pydantic_settings/sources.py @@ -1,6 +1,5 @@ from __future__ import annotations as _annotations -import inspect import json import os import re @@ -18,7 +17,7 @@ from enum import Enum from pathlib import Path from textwrap import dedent -from types import SimpleNamespace +from types import BuiltinFunctionType, FunctionType, SimpleNamespace from typing import ( TYPE_CHECKING, Any, @@ -57,12 +56,16 @@ tomllib = None import tomli import yaml + from pydantic._internal._dataclasses import PydanticDataclass from pydantic_settings.main import BaseSettings + + PydanticModel = TypeVar('PydanticModel', bound=PydanticDataclass | BaseModel) else: yaml = None tomllib = None tomli = None + PydanticModel = Any def import_yaml() -> None: @@ -155,7 +158,9 @@ def error(self, message: str) -> NoReturn: CliExplicitFlag = Annotated[_CliBoolFlag, _CliExplicitFlag] -def get_subcommand(model: BaseModel, is_required: bool = True, cli_exit_on_error: bool | None = None) -> Any: +def get_subcommand( + model: PydanticModel, is_required: bool = True, cli_exit_on_error: bool | None = None +) -> Optional[PydanticModel]: """ Get the subcommand from a model. @@ -178,7 +183,7 @@ def get_subcommand(model: BaseModel, is_required: bool = True, cli_exit_on_error model_cls = type(model) if cli_exit_on_error is None and is_model_class(model_cls): - model_default = model.model_config.get('cli_exit_on_error') + model_default = model_cls.model_config.get('cli_exit_on_error') if isinstance(model_default, bool): cli_exit_on_error = model_default if cli_exit_on_error is None: @@ -1183,9 +1188,7 @@ def __call__(self, *, args: list[str] | tuple[str, ...] | bool) -> CliSettingsSo ... @overload - def __call__( - self, *, parsed_args: Namespace | SimpleNamespace | dict[str, list[str] | str] - ) -> CliSettingsSource[T]: + def __call__(self, *, parsed_args: Namespace | SimpleNamespace | dict[str, Any]) -> CliSettingsSource[T]: """ Loads parsed command line arguments into the CLI settings source. @@ -1224,9 +1227,7 @@ def __call__( def _load_env_vars(self) -> Mapping[str, str | None]: ... @overload - def _load_env_vars( - self, *, parsed_args: Namespace | SimpleNamespace | dict[str, list[str] | str] - ) -> CliSettingsSource[T]: + def _load_env_vars(self, *, parsed_args: Namespace | SimpleNamespace | dict[str, Any]) -> CliSettingsSource[T]: """ Loads the parsed command line arguments into the CLI environment settings variables. @@ -2225,4 +2226,4 @@ def _get_model_fields(model_cls: type[Any]) -> dict[str, FieldInfo]: def _is_function(obj: Any) -> bool: - return inspect.isfunction(obj) or inspect.isbuiltin(obj) or inspect.isroutine(obj) or inspect.ismethod(obj) + return isinstance(obj, (FunctionType, BuiltinFunctionType)) diff --git a/tests/test_settings.py b/tests/test_settings.py index 8b4c2f2..c570934 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -41,6 +41,7 @@ from pydantic_settings import ( BaseSettings, + CliApp, DotEnvSettingsSource, EnvSettingsSource, InitSettingsSource, @@ -745,9 +746,9 @@ class Settings(BaseSettings, cli_exit_on_error=False): model_config = SettingsConfigDict(cli_prefix='p') with pytest.raises(SettingsError, match='error parsing CLI: unrecognized arguments: --foo bar'): - Settings(_cli_parse_args=['--foo', 'bar']) + CliApp.run(Settings, cli_args=['--foo', 'bar']) - assert Settings(_cli_parse_args=['--p.foo', 'bar']).foobar == 'bar' + assert CliApp.run(Settings, cli_args=['--p.foo', 'bar']).foobar == 'bar' def test_case_sensitive(monkeypatch): @@ -2517,7 +2518,7 @@ class Cfg(BaseSettings): args += ['--v0_union', '0'] args += ['--top.sub.sub_sub.v6', '6'] args += ['--top.sub.v4', '4'] - cfg = Cfg(_cli_parse_args=args) + cfg = CliApp.run(Cfg, cli_args=args) assert cfg.model_dump() == { 'v0': '0', 'v0_union': 0, @@ -2550,7 +2551,7 @@ def settings_customise_sources( env.set('FOO', 'FOO FROM ENV') - cfg = CfgDefault(_cli_parse_args=['--foo', 'FOO FROM CLI']) + cfg = CliApp.run(CfgDefault, cli_args=['--foo', 'FOO FROM CLI']) assert cfg.model_dump() == {'foo': 'FOO FROM CLI'} cfg = CfgPrioritized() @@ -2567,14 +2568,14 @@ class Cfg(BaseSettings): cfg = Cfg(**{'sub-cmd': {'pos-arg': 'howdy'}}) assert cfg.model_dump() == {'sub_cmd': {'pos_arg': 'howdy'}} - cfg = Cfg(_cli_parse_args=['sub-cmd', 'howdy']) + cfg = CliApp.run(Cfg, cli_args=['sub-cmd', 'howdy']) assert cfg.model_dump() == {'sub_cmd': {'pos_arg': 'howdy'}} with monkeypatch.context() as m: m.setattr(sys, 'argv', ['example.py', '--help']) with pytest.raises(SystemExit): - Cfg(_cli_parse_args=True) + CliApp.run(Cfg) assert ( capsys.readouterr().out == f"""usage: example.py [-h] {{sub-cmd}} ... @@ -2590,7 +2591,7 @@ class Cfg(BaseSettings): m.setattr(sys, 'argv', ['example.py', 'sub-cmd', '--help']) with pytest.raises(SystemExit): - Cfg(_cli_parse_args=True) + CliApp.run(Cfg) assert ( capsys.readouterr().out == f"""usage: example.py sub-cmd [-h] POS-ARG @@ -2613,8 +2614,9 @@ class Cfg(BaseSettings, cli_avoid_json=avoid_json): alias_path: str = Field(validation_alias=AliasPath('path2', 'deep', 1)) alias_str: str = Field(validation_alias='str') - cfg = Cfg( - _cli_parse_args=[ + cfg = CliApp.run( + Cfg, + cli_args=[ '-a', 'a', '-b', @@ -2627,7 +2629,7 @@ class Cfg(BaseSettings, cli_avoid_json=avoid_json): 'a1,b1,c1', '--path2', '{"deep": ["a2","b2","c2"]}', - ] + ], ) assert cfg.model_dump() == { 'alias_choice_w_path': 'a', @@ -2650,8 +2652,9 @@ class Nested(BaseModel): class Cfg(BaseSettings, cli_avoid_json=avoid_json): nest: Nested - cfg = Cfg( - _cli_parse_args=[ + cfg = CliApp.run( + Cfg, + cli_args=[ '--nest.a', 'a', '--nest.b', @@ -2660,7 +2663,7 @@ class Cfg(BaseSettings, cli_avoid_json=avoid_json): 'str', '--nest', '{"path0": ["a0","b0","c0"], "path1": ["a1","b1","c1"], "path2": {"deep": ["a2","b2","c2"]}}', - ] + ], ) assert cfg.model_dump() == { 'nest': { @@ -2682,14 +2685,14 @@ class SubCmd(BaseModel): class BadCliSubCommand(BaseSettings): foo: CliSubCommand[SubCmd] = Field(validation_alias=AliasChoices('bar', 'boo')) - BadCliSubCommand(_cli_parse_args=True) + CliApp.run(BadCliSubCommand) with pytest.raises(SettingsError, match='positional argument BadCliPositionalArg.foo has multiple alias'): class BadCliPositionalArg(BaseSettings): foo: CliPositionalArg[int] = Field(validation_alias=AliasChoices('bar', 'boo')) - BadCliPositionalArg(_cli_parse_args=True) + CliApp.run(BadCliPositionalArg) def test_cli_case_insensitive_arg(): @@ -2697,21 +2700,23 @@ class Cfg(BaseSettings, cli_exit_on_error=False): foo: str = Field(validation_alias=AliasChoices('F', 'Foo')) bar: str = Field(validation_alias=AliasChoices('B', 'Bar')) - cfg = Cfg( - _cli_parse_args=[ + cfg = CliApp.run( + Cfg, + cli_args=[ '--FOO=--VAL', '--BAR', '"--VAL"', - ] + ], ) assert cfg.model_dump() == {'foo': '--VAL', 'bar': '"--VAL"'} - cfg = Cfg( - _cli_parse_args=[ + cfg = CliApp.run( + Cfg, + cli_args=[ '-f=-V', '-b', '"-V"', - ] + ], ) assert cfg.model_dump() == {'foo': '-V', 'bar': '"-V"'} @@ -2741,7 +2746,7 @@ class Cfg(BaseSettings): m.setattr(sys, 'argv', ['example.py', '--help']) with pytest.raises(SystemExit): - Cfg(_cli_parse_args=True) + CliApp.run(Cfg) assert ( re.sub(r'0x\w+', '0xffffffff', capsys.readouterr().out, flags=re.MULTILINE) @@ -2920,7 +2925,7 @@ class MyDataclass: class Settings(BaseSettings): n: MyDataclass - s = Settings(_cli_parse_args=['--n.foo', '123', '--n.bar', 'bar value']) + s = CliApp.run(Settings, cli_args=['--n.foo', '123', '--n.bar', 'bar value']) assert isinstance(s.n, MyDataclass) assert s.n.foo == 123 assert s.n.bar == 'bar value' @@ -2979,7 +2984,7 @@ def check_answer(cfg, prefix, expected): args = [f'--{prefix}num_list', arg_spaces('[1,2]')] args += [f'--{prefix}num_list', arg_spaces('3,4')] args += [f'--{prefix}num_list', '5', f'--{prefix}num_list', '6'] - cfg = Cfg(_cli_parse_args=args) + cfg = CliApp.run(Cfg, cli_args=args) expected = { 'num_list': [1, 2, 3, 4, 5, 6], 'obj_list': None, @@ -2991,7 +2996,7 @@ def check_answer(cfg, prefix, expected): args = [f'--{prefix}obj_list', arg_spaces('[{"val":1},{"val":2}]')] args += [f'--{prefix}obj_list', arg_spaces('{"val":3},{"val":4}')] args += [f'--{prefix}obj_list', arg_spaces('{"val":5}'), f'--{prefix}obj_list', arg_spaces('{"val":6}')] - cfg = Cfg(_cli_parse_args=args) + cfg = CliApp.run(Cfg, cli_args=args) expected = { 'num_list': None, 'obj_list': [{'val': 1}, {'val': 2}, {'val': 3}, {'val': 4}, {'val': 5}, {'val': 6}], @@ -3003,7 +3008,7 @@ def check_answer(cfg, prefix, expected): args = [f'--{prefix}union_list', arg_spaces('[{"val":1},2]'), f'--{prefix}union_list', arg_spaces('[3,{"val":4}]')] args += [f'--{prefix}union_list', arg_spaces('{"val":5},6'), f'--{prefix}union_list', arg_spaces('7,{"val":8}')] args += [f'--{prefix}union_list', arg_spaces('{"val":9}'), f'--{prefix}union_list', '10'] - cfg = Cfg(_cli_parse_args=args) + cfg = CliApp.run(Cfg, cli_args=args) expected = { 'num_list': None, 'obj_list': None, @@ -3020,7 +3025,7 @@ def check_answer(cfg, prefix, expected): f'--{prefix}str_list', arg_spaces('"5,5"', has_quote_comma=True), ] - cfg = Cfg(_cli_parse_args=args) + cfg = CliApp.run(Cfg, cli_args=args) expected = { 'num_list': None, 'obj_list': None, @@ -3035,8 +3040,9 @@ def test_cli_list_json_value_parsing(arg_spaces): class Cfg(BaseSettings): json_list: List[Union[str, bool, None]] - assert Cfg( - _cli_parse_args=[ + assert CliApp.run( + Cfg, + cli_args=[ '--json_list', arg_spaces('true,"true"'), '--json_list', @@ -3045,11 +3051,11 @@ class Cfg(BaseSettings): arg_spaces('null,"null"'), '--json_list', arg_spaces('hi,"bye"'), - ] + ], ).model_dump() == {'json_list': [True, 'true', False, 'false', None, 'null', 'hi', 'bye']} - assert Cfg(_cli_parse_args=['--json_list', '"","","",""']).model_dump() == {'json_list': ['', '', '', '']} - assert Cfg(_cli_parse_args=['--json_list', ',,,']).model_dump() == {'json_list': ['', '', '', '']} + assert CliApp.run(Cfg, cli_args=['--json_list', '"","","",""']).model_dump() == {'json_list': ['', '', '', '']} + assert CliApp.run(Cfg, cli_args=['--json_list', ',,,']).model_dump() == {'json_list': ['', '', '', '']} @pytest.mark.parametrize('arg_spaces', [no_add_cli_arg_spaces, add_cli_arg_spaces]) @@ -3091,7 +3097,7 @@ class Cfg(BaseSettings): f'--{prefix}check_dict', arg_spaces('k32="x,y"', has_quote_comma=True), ] - cfg = Cfg(_cli_parse_args=args) + cfg = CliApp.run(Cfg, cli_args=args) expected: Dict[str, Any] = { 'check_dict': { 'k1': 'a', @@ -3135,10 +3141,10 @@ class Cfg(BaseSettings): assert cfg.model_dump() == expected with pytest.raises(SettingsError, match=f'Parsing error encountered for {prefix}check_dict: Mismatched quotes'): - cfg = Cfg(_cli_parse_args=[f'--{prefix}check_dict', 'k9="i']) + cfg = CliApp.run(Cfg, cli_args=[f'--{prefix}check_dict', 'k9="i']) with pytest.raises(SettingsError, match=f'Parsing error encountered for {prefix}check_dict: Mismatched quotes'): - cfg = Cfg(_cli_parse_args=[f'--{prefix}check_dict', 'k9=i"']) + cfg = CliApp.run(Cfg, cli_args=[f'--{prefix}check_dict', 'k9=i"']) def test_cli_union_dict_arg(): @@ -3147,7 +3153,7 @@ class Cfg(BaseSettings): with pytest.raises(ValidationError) as exc_info: args = ['--union_str_dict', 'hello world', '--union_str_dict', 'hello world'] - cfg = Cfg(_cli_parse_args=args) + cfg = CliApp.run(Cfg, cli_args=args) assert exc_info.value.errors(include_url=False) == [ { 'input': [ @@ -3176,19 +3182,19 @@ class Cfg(BaseSettings): ] args = ['--union_str_dict', 'hello world'] - cfg = Cfg(_cli_parse_args=args) + cfg = CliApp.run(Cfg, cli_args=args) assert cfg.model_dump() == {'union_str_dict': 'hello world'} args = ['--union_str_dict', '{"hello": "world"}'] - cfg = Cfg(_cli_parse_args=args) + cfg = CliApp.run(Cfg, cli_args=args) assert cfg.model_dump() == {'union_str_dict': {'hello': 'world'}} args = ['--union_str_dict', 'hello=world'] - cfg = Cfg(_cli_parse_args=args) + cfg = CliApp.run(Cfg, cli_args=args) assert cfg.model_dump() == {'union_str_dict': {'hello': 'world'}} args = ['--union_str_dict', '"hello=world"'] - cfg = Cfg(_cli_parse_args=args) + cfg = CliApp.run(Cfg, cli_args=args) assert cfg.model_dump() == {'union_str_dict': 'hello=world'} class Cfg(BaseSettings): @@ -3196,7 +3202,7 @@ class Cfg(BaseSettings): with pytest.raises(ValidationError) as exc_info: args = ['--union_list_dict', 'hello,world'] - cfg = Cfg(_cli_parse_args=args) + cfg = CliApp.run(Cfg, cli_args=args) assert exc_info.value.errors(include_url=False) == [ { 'input': 'hello,world', @@ -3219,24 +3225,24 @@ class Cfg(BaseSettings): ] args = ['--union_list_dict', 'hello,world', '--union_list_dict', 'hello,world'] - cfg = Cfg(_cli_parse_args=args) + cfg = CliApp.run(Cfg, cli_args=args) assert cfg.model_dump() == {'union_list_dict': ['hello', 'world', 'hello', 'world']} args = ['--union_list_dict', '[hello,world]'] - cfg = Cfg(_cli_parse_args=args) + cfg = CliApp.run(Cfg, cli_args=args) assert cfg.model_dump() == {'union_list_dict': ['hello', 'world']} args = ['--union_list_dict', '{"hello": "world"}'] - cfg = Cfg(_cli_parse_args=args) + cfg = CliApp.run(Cfg, cli_args=args) assert cfg.model_dump() == {'union_list_dict': {'hello': 'world'}} args = ['--union_list_dict', 'hello=world'] - cfg = Cfg(_cli_parse_args=args) + cfg = CliApp.run(Cfg, cli_args=args) assert cfg.model_dump() == {'union_list_dict': {'hello': 'world'}} with pytest.raises(ValidationError) as exc_info: args = ['--union_list_dict', '"hello=world"'] - cfg = Cfg(_cli_parse_args=args) + cfg = CliApp.run(Cfg, cli_args=args) assert exc_info.value.errors(include_url=False) == [ { 'input': 'hello=world', @@ -3259,7 +3265,7 @@ class Cfg(BaseSettings): ] args = ['--union_list_dict', '["hello=world"]'] - cfg = Cfg(_cli_parse_args=args) + cfg = CliApp.run(Cfg, cli_args=args) assert cfg.model_dump() == {'union_list_dict': ['hello=world']} @@ -3268,7 +3274,7 @@ class Cfg(BaseSettings): check_dict: Dict[str, Any] args = ['--check_dict', '{"k1":{"a": 1}},{"k2":{"b": 2}}'] - cfg = Cfg(_cli_parse_args=args) + cfg = CliApp.run(Cfg, cli_args=args) assert cfg.model_dump() == {'check_dict': {'k1': {'a': 1}, 'k2': {'b': 2}}} with pytest.raises( @@ -3276,11 +3282,11 @@ class Cfg(BaseSettings): match=re.escape('Parsing error encountered for check_dict: not enough values to unpack (expected 2, got 1)'), ): args = ['--check_dict', '{"k1":{"a": 1}},"k2":{"b": 2}}'] - cfg = Cfg(_cli_parse_args=args) + cfg = CliApp.run(Cfg, cli_args=args) with pytest.raises(SettingsError, match='Parsing error encountered for check_dict: Missing end delimiter "}"'): args = ['--check_dict', '{"k1":{"a": 1}},{"k2":{"b": 2}'] - cfg = Cfg(_cli_parse_args=args) + cfg = CliApp.run(Cfg, cli_args=args) def test_cli_subcommand_union(capsys, monkeypatch): @@ -3304,13 +3310,13 @@ class Root1(BaseSettings): subcommand: CliSubCommand[Union[AlphaCmd, BetaCmd, GammaCmd]] = Field(description='Field Help') - alpha = Root1(_cli_parse_args=['AlphaCmd', '-a=alpha']) + alpha = CliApp.run(Root1, cli_args=['AlphaCmd', '-a=alpha']) assert get_subcommand(alpha).model_dump() == {'a': 'alpha'} assert alpha.model_dump() == {'subcommand': {'a': 'alpha'}} - beta = Root1(_cli_parse_args=['BetaCmd', '-b=beta']) + beta = CliApp.run(Root1, cli_args=['BetaCmd', '-b=beta']) assert get_subcommand(beta).model_dump() == {'b': 'beta'} assert beta.model_dump() == {'subcommand': {'b': 'beta'}} - gamma = Root1(_cli_parse_args=['GammaCmd', '-g=gamma']) + gamma = CliApp.run(Root1, cli_args=['GammaCmd', '-g=gamma']) assert get_subcommand(gamma).model_dump() == {'g': 'gamma'} assert gamma.model_dump() == {'subcommand': {'g': 'gamma'}} @@ -3318,7 +3324,7 @@ class Root1(BaseSettings): m.setattr(sys, 'argv', ['example.py', '--help']) with pytest.raises(SystemExit): - Root1(_cli_parse_args=True) + CliApp.run(Root1) assert ( capsys.readouterr().out == f"""usage: example.py [-h] {{AlphaCmd,BetaCmd,GammaCmd}} ... @@ -3365,13 +3371,13 @@ class Root2(BaseSettings): subcommand: CliSubCommand[Union[AlphaCmd, GammaCmd]] = Field(description='Field Help') beta: CliSubCommand[BetaCmd] = Field(description='Field Beta Help') - alpha = Root2(_cli_parse_args=['AlphaCmd', '-a=alpha']) + alpha = CliApp.run(Root2, cli_args=['AlphaCmd', '-a=alpha']) assert get_subcommand(alpha).model_dump() == {'a': 'alpha'} assert alpha.model_dump() == {'subcommand': {'a': 'alpha'}, 'beta': None} - beta = Root2(_cli_parse_args=['beta', '-b=beta']) + beta = CliApp.run(Root2, cli_args=['beta', '-b=beta']) assert get_subcommand(beta).model_dump() == {'b': 'beta'} assert beta.model_dump() == {'subcommand': None, 'beta': {'b': 'beta'}} - gamma = Root2(_cli_parse_args=['GammaCmd', '-g=gamma']) + gamma = CliApp.run(Root2, cli_args=['GammaCmd', '-g=gamma']) assert get_subcommand(gamma).model_dump() == {'g': 'gamma'} assert gamma.model_dump() == {'subcommand': {'g': 'gamma'}, 'beta': None} @@ -3379,7 +3385,7 @@ class Root2(BaseSettings): m.setattr(sys, 'argv', ['example.py', '--help']) with pytest.raises(SystemExit): - Root2(_cli_parse_args=True) + CliApp.run(Root2, cli_args=True) assert ( capsys.readouterr().out == f"""usage: example.py [-h] {{AlphaCmd,GammaCmd,beta}} ... @@ -3426,13 +3432,13 @@ class Root3(BaseSettings): beta: CliSubCommand[BetaCmd] = Field(description='Field Beta Help') subcommand: CliSubCommand[Union[AlphaCmd, GammaCmd]] = Field(description='Field Help') - alpha = Root3(_cli_parse_args=['AlphaCmd', '-a=alpha']) + alpha = CliApp.run(Root3, cli_args=['AlphaCmd', '-a=alpha']) assert get_subcommand(alpha).model_dump() == {'a': 'alpha'} assert alpha.model_dump() == {'subcommand': {'a': 'alpha'}, 'beta': None} - beta = Root3(_cli_parse_args=['beta', '-b=beta']) + beta = CliApp.run(Root3, cli_args=['beta', '-b=beta']) assert get_subcommand(beta).model_dump() == {'b': 'beta'} assert beta.model_dump() == {'subcommand': None, 'beta': {'b': 'beta'}} - gamma = Root3(_cli_parse_args=['GammaCmd', '-g=gamma']) + gamma = CliApp.run(Root3, cli_args=['GammaCmd', '-g=gamma']) assert get_subcommand(gamma).model_dump() == {'g': 'gamma'} assert gamma.model_dump() == {'subcommand': {'g': 'gamma'}, 'beta': None} @@ -3440,7 +3446,7 @@ class Root3(BaseSettings): m.setattr(sys, 'argv', ['example.py', '--help']) with pytest.raises(SystemExit): - Root3(_cli_parse_args=True) + CliApp.run(Root3) assert ( capsys.readouterr().out == f"""usage: example.py [-h] {{beta,AlphaCmd,GammaCmd}} ... @@ -3514,7 +3520,7 @@ class Git(BaseSettings): init: CliSubCommand[Init] plugins: CliSubCommand[Plugins] - git = Git(_cli_parse_args=[]) + git = CliApp.run(Git, cli_args=[]) assert git.model_dump() == { 'clone': None, 'init': None, @@ -3526,7 +3532,7 @@ class Git(BaseSettings): with pytest.raises(SettingsError, match='Error: CLI subcommand is required {clone, init, plugins}'): get_subcommand(git, cli_exit_on_error=False) - git = Git(_cli_parse_args=['init', '--quiet', 'true', 'dir/path']) + git = CliApp.run(Git, cli_args=['init', '--quiet', 'true', 'dir/path']) assert git.model_dump() == { 'clone': None, 'init': {'directory': 'dir/path', 'quiet': True, 'bare': False}, @@ -3535,7 +3541,7 @@ class Git(BaseSettings): assert get_subcommand(git) == git.init assert get_subcommand(git, is_required=False) == git.init - git = Git(_cli_parse_args=['clone', 'repo', '.', '--shared', 'true']) + git = CliApp.run(Git, cli_args=['clone', 'repo', '.', '--shared', 'true']) assert git.model_dump() == { 'clone': {'repository': 'repo', 'directory': '.', 'local': False, 'shared': True}, 'init': None, @@ -3544,7 +3550,7 @@ class Git(BaseSettings): assert get_subcommand(git) == git.clone assert get_subcommand(git, is_required=False) == git.clone - git = Git(_cli_parse_args=['plugins', 'bar']) + git = CliApp.run(Git, cli_args=['plugins', 'bar']) assert git.model_dump() == { 'clone': None, 'init': None, @@ -3584,7 +3590,7 @@ class ChildB(BaseModel): class Cfg(BaseSettings): child: Union[ChildA, ChildB] - cfg = Cfg(_cli_parse_args=['--child.name', 'new name a', '--child.diff_a', 'new diff a']) + cfg = CliApp.run(Cfg, cli_args=['--child.name', 'new name a', '--child.diff_a', 'new diff a']) assert cfg.model_dump() == {'child': {'name': 'new name a', 'diff_a': 'new diff a'}} @@ -3598,11 +3604,11 @@ class Cfg(BaseSettings): pet: Pet = Pet.dog union_pet: Union[Pet, int] = 43 - cfg = Cfg(_cli_parse_args=['--pet', 'cat', '--union_pet', 'dog']) + cfg = CliApp.run(Cfg, cli_args=['--pet', 'cat', '--union_pet', 'dog']) assert cfg.model_dump() == {'pet': Pet.cat, 'union_pet': Pet.dog} with pytest.raises(ValidationError) as exc_info: - Cfg(_cli_parse_args=['--pet', 'rock']) + CliApp.run(Cfg, cli_args=['--pet', 'rock']) assert exc_info.value.errors(include_url=False) == [ { 'type': 'enum', @@ -3617,7 +3623,7 @@ class Cfg(BaseSettings): m.setattr(sys, 'argv', ['example.py', '--help']) with pytest.raises(SystemExit): - Cfg(_cli_parse_args=True) + CliApp.run(Cfg) assert ( capsys.readouterr().out == f"""usage: example.py [-h] [--pet {{dog,cat,bird}}] @@ -3636,11 +3642,11 @@ def test_cli_literals(): class Cfg(BaseSettings): pet: Literal['dog', 'cat', 'bird'] - cfg = Cfg(_cli_parse_args=['--pet', 'cat']) + cfg = CliApp.run(Cfg, cli_args=['--pet', 'cat']) assert cfg.model_dump() == {'pet': 'cat'} with pytest.raises(ValidationError) as exc_info: - Cfg(_cli_parse_args=['--pet', 'rock']) + CliApp.run(Cfg, cli_args=['--pet', 'rock']) assert exc_info.value.errors(include_url=False) == [ { 'ctx': {'expected': "'dog', 'cat' or 'bird'"}, @@ -3763,8 +3769,8 @@ class ImplicitSettings(BaseSettings, cli_implicit_flags=True, cli_enforce_requir 'implicit_opt': False, } - assert ExplicitSettings(_cli_parse_args=['--explicit_req=True']).model_dump() == expected - assert ImplicitSettings(_cli_parse_args=['--explicit_req=True']).model_dump() == expected + assert CliApp.run(ExplicitSettings, cli_args=['--explicit_req=True']).model_dump() == expected + assert CliApp.run(ImplicitSettings, cli_args=['--explicit_req=True']).model_dump() == expected else: class ExplicitSettings(BaseSettings, cli_enforce_required=enforce_required): @@ -3786,8 +3792,8 @@ class ImplicitSettings(BaseSettings, cli_implicit_flags=True, cli_enforce_requir 'implicit_opt': False, } - assert ExplicitSettings(_cli_parse_args=['--explicit_req=True', '--implicit_req']).model_dump() == expected - assert ImplicitSettings(_cli_parse_args=['--explicit_req=True', '--implicit_req']).model_dump() == expected + assert CliApp.run(ExplicitSettings, cli_args=['--explicit_req=True', '--implicit_req']).model_dump() == expected + assert CliApp.run(ImplicitSettings, cli_args=['--explicit_req=True', '--implicit_req']).model_dump() == expected def test_cli_avoid_json(capsys, monkeypatch): @@ -3999,7 +4005,7 @@ class Settings(BaseSettings, cli_parse_args=True): ... ) with pytest.raises(SettingsError, match='error parsing CLI: unrecognized arguments: --bad-arg'): - Settings(_cli_exit_on_error=False) + CliApp.run(Settings, cli_exit_on_error=False) def test_cli_ignore_unknown_args(): @@ -4007,10 +4013,12 @@ class Cfg(BaseSettings, cli_ignore_unknown_args=True): this: str = 'hello' that: int = 123 - cfg = Cfg(_cli_parse_args=['not_my_positional_arg', '--not-my-optional-arg=456']) + cfg = CliApp.run(Cfg, cli_args=['not_my_positional_arg', '--not-my-optional-arg=456']) assert cfg.model_dump() == {'this': 'hello', 'that': 123} - cfg = Cfg(_cli_parse_args=['not_my_positional_arg', '--not-my-optional-arg=456', '--this=goodbye', '--that=789']) + cfg = CliApp.run( + Cfg, cli_args=['not_my_positional_arg', '--not-my-optional-arg=456', '--this=goodbye', '--that=789'] + ) assert cfg.model_dump() == {'this': 'goodbye', 'that': 789} @@ -4072,6 +4080,8 @@ class Cfg(BaseSettings): args = ['--fruit', 'pear', '--num', '0', '--num-list', '1', '--num-list', '2', '--num-list', '3'] parsed_args = parse_args(args) + assert CliApp.run(Cfg, cli_args=parsed_args, cli_settings_source=cli_cfg_settings).model_dump() == {'pet': 'bird'} + assert CliApp.run(Cfg, cli_args=args, cli_settings_source=cli_cfg_settings).model_dump() == {'pet': 'bird'} assert Cfg(_cli_settings_source=cli_cfg_settings(parsed_args=parsed_args)).model_dump() == {'pet': 'bird'} assert Cfg(_cli_settings_source=cli_cfg_settings(args=args)).model_dump() == {'pet': 'bird'} assert Cfg(_cli_settings_source=cli_cfg_settings(args=False)).model_dump() == {'pet': 'bird'} @@ -4092,6 +4102,8 @@ class Cfg(BaseSettings): 'dog', ] parsed_args = parse_args(args) + assert CliApp.run(Cfg, cli_args=parsed_args, cli_settings_source=cli_cfg_settings).model_dump() == {'pet': 'dog'} + assert CliApp.run(Cfg, cli_args=args, cli_settings_source=cli_cfg_settings).model_dump() == {'pet': 'dog'} assert Cfg(_cli_settings_source=cli_cfg_settings(parsed_args=parsed_args)).model_dump() == {'pet': 'dog'} assert Cfg(_cli_settings_source=cli_cfg_settings(args=args)).model_dump() == {'pet': 'dog'} assert Cfg(_cli_settings_source=cli_cfg_settings(args=False)).model_dump() == {'pet': 'bird'} @@ -4112,6 +4124,9 @@ class Cfg(BaseSettings): 'cat', ] ) + assert CliApp.run(Cfg, cli_args=vars(parsed_args), cli_settings_source=cli_cfg_settings).model_dump() == { + 'pet': 'cat' + } assert Cfg(_cli_settings_source=cli_cfg_settings(parsed_args=vars(parsed_args))).model_dump() == {'pet': 'cat'} assert Cfg(_cli_settings_source=cli_cfg_settings(args=False)).model_dump() == {'pet': 'bird'} @@ -4302,6 +4317,86 @@ def test_cli_metavar_format_type_alias_312(): ) +def test_cli_app(): + class Init(BaseModel): + directory: CliPositionalArg[str] + + def cli_cmd(self) -> None: + self.directory = 'ran Init.cli_cmd' + + def alt_cmd(self) -> None: + self.directory = 'ran Init.alt_cmd' + + class Clone(BaseModel): + repository: CliPositionalArg[str] + directory: CliPositionalArg[str] + + def cli_cmd(self) -> None: + self.repository = 'ran Clone.cli_cmd' + + def alt_cmd(self) -> None: + self.repository = 'ran Clone.alt_cmd' + + class Git(BaseModel): + clone: CliSubCommand[Clone] + init: CliSubCommand[Init] + + def cli_cmd(self) -> None: + CliApp.run_subcommand(self) + + def alt_cmd(self) -> None: + CliApp.run_subcommand(self, cli_cmd_method_name='alt_cmd') + + assert CliApp.run(Git, cli_args=['init', 'dir']).model_dump() == { + 'clone': None, + 'init': {'directory': 'ran Init.cli_cmd'}, + } + assert CliApp.run(Git, cli_args=['init', 'dir'], cli_cmd_method_name='alt_cmd').model_dump() == { + 'clone': None, + 'init': {'directory': 'ran Init.alt_cmd'}, + } + assert CliApp.run(Git, cli_args=['clone', 'repo', 'dir']).model_dump() == { + 'clone': {'repository': 'ran Clone.cli_cmd', 'directory': 'dir'}, + 'init': None, + } + assert CliApp.run(Git, cli_args=['clone', 'repo', 'dir'], cli_cmd_method_name='alt_cmd').model_dump() == { + 'clone': {'repository': 'ran Clone.alt_cmd', 'directory': 'dir'}, + 'init': None, + } + + +def test_cli_app_exceptions(): + with pytest.raises( + SettingsError, match='Error: NotPydanticModel is not subclass of BaseModel or pydantic.dataclasses.dataclass' + ): + + class NotPydanticModel: ... + + CliApp.run(NotPydanticModel) + + with pytest.raises( + SettingsError, + match=re.escape('Error: `cli_args` must be list[str] or None when `cli_settings_source` is not used'), + ): + + class Cfg(BaseModel): ... + + CliApp.run(Cfg, cli_args={'my_arg': 'hello'}) + + with pytest.raises(SettingsError, match='Error: Child class is missing cli_cmd entrypoint'): + + class Child(BaseModel): + val: str + + class Root(BaseModel): + child: CliSubCommand[Child] + + def cli_cmd(self) -> None: + CliApp.run_subcommand(self) + + CliApp.run(Root, cli_args=['child', '--val=hello']) + + def test_json_file(tmp_path): p = tmp_path / '.env' p.write_text(