From 8d3b7148da53ce670fd5b8376950647d873ed591 Mon Sep 17 00:00:00 2001 From: Kyle Schwab Date: Fri, 2 Aug 2024 15:53:08 -0600 Subject: [PATCH 01/10] Initial commit. --- pydantic_settings/__init__.py | 3 +- pydantic_settings/main.py | 121 +++++++++++++++++++++++++++++++++- 2 files changed, 121 insertions(+), 3 deletions(-) diff --git a/pydantic_settings/__init__.py b/pydantic_settings/__init__.py index c0d5f35d..8886efa2 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, CliPositionalArg, @@ -21,6 +21,7 @@ 'BaseSettings', 'DotEnvSettingsSource', 'EnvSettingsSource', + 'CliApp', 'CliSettingsSource', 'CliSubCommand', 'CliPositionalArg', diff --git a/pydantic_settings/main.py b/pydantic_settings/main.py index 1dd4ac76..fb1020f9 100644 --- a/pydantic_settings/main.py +++ b/pydantic_settings/main.py @@ -1,11 +1,14 @@ from __future__ import annotations as _annotations +import inspect from pathlib import Path -from typing import Any, ClassVar +from typing import Any, Callable, ClassVar, TypeVar, cast, get_args -from pydantic import ConfigDict +from pydantic import ConfigDict, create_model from pydantic._internal._config import config_keys +from pydantic._internal._decorators import unwrap_wrapped_function from pydantic._internal._utils import deep_update +from pydantic.dataclasses import is_pydantic_dataclass from pydantic.main import BaseModel from .sources import ( @@ -18,8 +21,13 @@ PathType, PydanticBaseSettingsSource, SecretsSettingsSource, + SettingsError, + _CliSubCommand, + get_subcommand, ) +Model = TypeVar('Model', bound='BaseModel') + class SettingsConfigDict(ConfigDict, total=False): case_sensitive: bool @@ -366,3 +374,112 @@ def _settings_build_values( secrets_dir=None, protected_namespaces=('model_', 'settings_'), ) + + +class CliApp: + @staticmethod + def _get_command_entrypoint(model_cls: type[Model]) -> Callable[[Any], None]: + for _, function in inspect.getmembers(model_cls, predicate=inspect.isfunction): + if hasattr(unwrap_wrapped_function(function), '_cli_app_command_entrypoint'): + return function + if hasattr(model_cls, 'cli_cmd'): + return getattr(model_cls, 'cli_cmd') + raise SettingsError(f'Error: {model_cls.__name__} class is missing AppCli.command entrypoint') + + @staticmethod + def _cli_settings_source(model_cls: type[Model]) -> CliSettingsSource[Any]: + fields = model_cls.__pydantic_fields__ if is_pydantic_dataclass(model_cls) else model_cls.model_fields + field_definitions: dict[str, tuple[type, Any]] = { + name: (info.annotation, info) for name, info in fields.items() if info.annotation is not None + } + base_settings = create_model('CliAppBaseSettings', __base__=BaseSettings, **field_definitions) # type: ignore + return CliSettingsSource(base_settings) + + @staticmethod + def _validate_main_subcommands(model_cls: type[Model]) -> None: + fields = ( + model_cls.__pydantic_fields__ + if hasattr(model_cls, '__pydantic_fields__') and is_pydantic_dataclass(model_cls) + else model_cls.model_fields + ) + for _, field_info in fields.items(): + if _CliSubCommand in field_info.metadata: + field_types = [type_ for type_ in get_args(field_info.annotation) if type_ is not type(None)] + for subcommand_cls in field_types: + if hasattr(subcommand_cls.__init__, '_cli_app_main_entrypoint'): + raise SettingsError( + f'Error: CliApp.main "{model_cls.__name__}" cannot have a ' + f'subcommand of CliApp.main "{subcommand_cls.__name__}"' + ) + + @staticmethod + def main(cli_app_main_cls: type[Model]) -> type[Model]: + if cli_app_main_cls.__init__.__name__ != '_cli_app_init': + original_init = cli_app_main_cls.__init__ + CliApp._validate_main_subcommands(cli_app_main_cls) + + def _cli_app_init(*args: Any, **kwargs: Any) -> None: + if issubclass(cli_app_main_cls, BaseSettings): + if ( + cli_app_main_cls.model_config.get('cli_settings_source') is None + and cli_app_main_cls.model_config.get('cli_parse_args') is not False + and kwargs.get('_cli_settings_source') is None + and kwargs.get('_cli_parse_args') is not False + ): + kwargs.setdefault('_cli_parse_args', True) + else: + cli_settings_source = CliApp._cli_settings_source(cli_app_main_cls)(args=True) + kwargs = deep_update(cli_settings_source(), kwargs) + original_init(*args, **kwargs) + command_entry_point = CliApp._get_command_entrypoint(cli_app_main_cls) + command_entry_point(args[0]) + + setattr(_cli_app_init, '_cli_app_main_entrypoint', True) + setattr(cli_app_main_cls, '__init__', _cli_app_init) + return cli_app_main_cls + + @staticmethod + def command(function: Callable[[Any], Any]) -> Callable[[Any], Any]: + setattr(unwrap_wrapped_function(function), '_cli_app_command_entrypoint', True) + return function + + @staticmethod + def run( + model_cls: type[Model], + cli_args: list[str] | None = None, + cli_settings_source: CliSettingsSource[Any] | None = None, + **model_args: Any, + ) -> None: + init_kwargs = model_args + cli_parse_args: list[str] | bool = True if cli_args is None else cli_args + if issubclass(model_cls, BaseSettings): + if cli_settings_source is None: + cli_settings_source = model_args.get('_cli_settings_source') + if cli_settings_source is None: + cli_settings_source = cast( + CliSettingsSource[Any], model_cls.model_config.get('cli_settings_source') + ) + if cli_settings_source is not None: + init_kwargs.setdefault('_cli_settings_source', cli_settings_source(args=cli_parse_args)) + else: + init_kwargs.setdefault('_cli_parse_args', cli_parse_args) + else: + if cli_settings_source is None: + cli_settings_source = CliApp._cli_settings_source(model_cls) + cli_settings_source = cli_settings_source(args=cli_parse_args) + init_kwargs = deep_update(cli_settings_source(), init_kwargs) + if hasattr(model_cls.__init__, '_cli_app_main_entrypoint'): + model_cls(**init_kwargs) + else: + command_entry_point = CliApp._get_command_entrypoint(model_cls) + command_entry_point(model_cls(**init_kwargs)) + + @staticmethod + def run_subcommand(model: BaseModel, is_exit_on_error: bool | None = None) -> None: + try: + subcommand = get_subcommand(model, is_required=True, is_exit_on_error=False) + CliApp._get_command_entrypoint(subcommand.__class__)(subcommand) + except SettingsError as err: + if (is_exit_on_error is not None and is_exit_on_error) or model.model_config.get('cli_exit_on_error'): + raise SystemExit(err) + raise err From e1d8520ed6786abfb7687c3d82ee0d176acf10ab Mon Sep 17 00:00:00 2001 From: Kyle Schwab Date: Sat, 17 Aug 2024 12:30:14 -0600 Subject: [PATCH 02/10] Reduce scope of implementation. Add doc strings. --- pydantic_settings/main.py | 227 +++++++++++++++++++---------------- pydantic_settings/sources.py | 20 +-- 2 files changed, 132 insertions(+), 115 deletions(-) diff --git a/pydantic_settings/main.py b/pydantic_settings/main.py index fb1020f9..14d83ebf 100644 --- a/pydantic_settings/main.py +++ b/pydantic_settings/main.py @@ -1,13 +1,12 @@ from __future__ import annotations as _annotations -import inspect from pathlib import Path -from typing import Any, Callable, ClassVar, TypeVar, cast, get_args +from typing import Any, Callable, ClassVar -from pydantic import ConfigDict, create_model +from pydantic import ConfigDict from pydantic._internal._config import config_keys -from pydantic._internal._decorators import unwrap_wrapped_function -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 @@ -23,11 +22,9 @@ SecretsSettingsSource, SettingsError, _CliSubCommand, - get_subcommand, + _get_model_fields, ) -Model = TypeVar('Model', bound='BaseModel') - class SettingsConfigDict(ConfigDict, total=False): case_sensitive: bool @@ -40,7 +37,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 @@ -95,7 +91,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`. _env_prefix: Prefix for all environment variables. Defaults to `None`. _env_file: The env file(s) to load settings values from. Defaults to `Path('')`, which means that the value from `model_config['env_file']` should be used. You can also pass @@ -358,7 +355,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, @@ -377,109 +373,130 @@ def _settings_build_values( class CliApp: + """ + A utility class for running Pydantic `BaseSettings`, `BaseModel`, or `pydantic.dataclasses.dataclass` as + CLI applications. + """ + @staticmethod - def _get_command_entrypoint(model_cls: type[Model]) -> Callable[[Any], None]: - for _, function in inspect.getmembers(model_cls, predicate=inspect.isfunction): - if hasattr(unwrap_wrapped_function(function), '_cli_app_command_entrypoint'): - return function + def _get_command_entrypoint(model_cls: type[Any]) -> Callable[[Any], None]: if hasattr(model_cls, 'cli_cmd'): return getattr(model_cls, 'cli_cmd') - raise SettingsError(f'Error: {model_cls.__name__} class is missing AppCli.command entrypoint') - - @staticmethod - def _cli_settings_source(model_cls: type[Model]) -> CliSettingsSource[Any]: - fields = model_cls.__pydantic_fields__ if is_pydantic_dataclass(model_cls) else model_cls.model_fields - field_definitions: dict[str, tuple[type, Any]] = { - name: (info.annotation, info) for name, info in fields.items() if info.annotation is not None - } - base_settings = create_model('CliAppBaseSettings', __base__=BaseSettings, **field_definitions) # type: ignore - return CliSettingsSource(base_settings) - - @staticmethod - def _validate_main_subcommands(model_cls: type[Model]) -> None: - fields = ( - model_cls.__pydantic_fields__ - if hasattr(model_cls, '__pydantic_fields__') and is_pydantic_dataclass(model_cls) - else model_cls.model_fields - ) - for _, field_info in fields.items(): - if _CliSubCommand in field_info.metadata: - field_types = [type_ for type_ in get_args(field_info.annotation) if type_ is not type(None)] - for subcommand_cls in field_types: - if hasattr(subcommand_cls.__init__, '_cli_app_main_entrypoint'): - raise SettingsError( - f'Error: CliApp.main "{model_cls.__name__}" cannot have a ' - f'subcommand of CliApp.main "{subcommand_cls.__name__}"' - ) - - @staticmethod - def main(cli_app_main_cls: type[Model]) -> type[Model]: - if cli_app_main_cls.__init__.__name__ != '_cli_app_init': - original_init = cli_app_main_cls.__init__ - CliApp._validate_main_subcommands(cli_app_main_cls) - - def _cli_app_init(*args: Any, **kwargs: Any) -> None: - if issubclass(cli_app_main_cls, BaseSettings): - if ( - cli_app_main_cls.model_config.get('cli_settings_source') is None - and cli_app_main_cls.model_config.get('cli_parse_args') is not False - and kwargs.get('_cli_settings_source') is None - and kwargs.get('_cli_parse_args') is not False - ): - kwargs.setdefault('_cli_parse_args', True) - else: - cli_settings_source = CliApp._cli_settings_source(cli_app_main_cls)(args=True) - kwargs = deep_update(cli_settings_source(), kwargs) - original_init(*args, **kwargs) - command_entry_point = CliApp._get_command_entrypoint(cli_app_main_cls) - command_entry_point(args[0]) - - setattr(_cli_app_init, '_cli_app_main_entrypoint', True) - setattr(cli_app_main_cls, '__init__', _cli_app_init) - return cli_app_main_cls - - @staticmethod - def command(function: Callable[[Any], Any]) -> Callable[[Any], Any]: - setattr(unwrap_wrapped_function(function), '_cli_app_command_entrypoint', True) - return function + raise SettingsError(f'Error: {model_cls.__name__} class is missing cli_cmd entrypoint') @staticmethod def run( - model_cls: type[Model], + model_cls: type[Any], cli_args: list[str] | None = None, cli_settings_source: CliSettingsSource[Any] | None = None, - **model_args: Any, + cli_exit_on_error: bool | None = None, + **model_init_data: Any, ) -> None: - init_kwargs = model_args + """ + 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. 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`. + model_init_data: The model init data. + + 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. + """ + cli_parse_args: list[str] | bool = True if cli_args is None else cli_args - if issubclass(model_cls, BaseSettings): - if cli_settings_source is None: - cli_settings_source = model_args.get('_cli_settings_source') - if cli_settings_source is None: - cli_settings_source = cast( - CliSettingsSource[Any], model_cls.model_config.get('cli_settings_source') - ) - if cli_settings_source is not None: - init_kwargs.setdefault('_cli_settings_source', cli_settings_source(args=cli_parse_args)) - else: - init_kwargs.setdefault('_cli_parse_args', cli_parse_args) - else: - if cli_settings_source is None: - cli_settings_source = CliApp._cli_settings_source(model_cls) - cli_settings_source = cli_settings_source(args=cli_parse_args) - init_kwargs = deep_update(cli_settings_source(), init_kwargs) - if hasattr(model_cls.__init__, '_cli_app_main_entrypoint'): - model_cls(**init_kwargs) - else: - command_entry_point = CliApp._get_command_entrypoint(model_cls) - command_entry_point(model_cls(**init_kwargs)) + 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' + ) + elif cli_args is not None and cli_settings_source is not None: + raise SettingsError('Error: `cli_args` and `cli_settings_source` are mutually exclusive') + + 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_source + if not issubclass(model_cls, BaseSettings): + + class CliAppBaseSettings(BaseSettings, model_cls): # type: ignore + model_config = SettingsConfigDict( + case_sensitive=True, + cli_hide_none_type=True, + cli_avoid_json=True, + cli_enforce_required=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) + + CliApp._get_command_entrypoint(model_cls)(model_cls(**model_init_data)) + + @staticmethod + def run_subcommand(model: Any, cli_exit_on_error: bool | None = None) -> None: + """ + 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. + If model is subclass of `BaseSettings`, defaults to BaseSettings `cli_exit_on_error` value. + Otherwise, defaults to `True`. + + 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`. + """ + + if cli_exit_on_error is None and isinstance(model, BaseSettings): + cli_exit_on_error = model.model_config.get('cli_exit_on_error') + if cli_exit_on_error is None: + cli_exit_on_error = True + + subcommand = CliApp.get_subcommand(model, is_required=True, cli_exit_on_error=cli_exit_on_error) + CliApp._get_command_entrypoint(type(subcommand))(subcommand) @staticmethod - def run_subcommand(model: BaseModel, is_exit_on_error: bool | None = None) -> None: - try: - subcommand = get_subcommand(model, is_required=True, is_exit_on_error=False) - CliApp._get_command_entrypoint(subcommand.__class__)(subcommand) - except SettingsError as err: - if (is_exit_on_error is not None and is_exit_on_error) or model.model_config.get('cli_exit_on_error'): - raise SystemExit(err) - raise err + def get_subcommand(model: Any, is_required: bool = True, cli_exit_on_error: bool = True) -> Any: + """ + Gets the model subcommand. + + Args: + model: The model to get the subcommand from. + is_required: Determines whether a model must have subcommand set and raises error if not + found. Defaults to `True`. + cli_exit_on_error: Determines whether this function exits with error if no subcommand is found. + Defaults to `True`. + + Returns: + The subcommand model if found, otherwise `None`. + + Raises: + SystemExit: When no subcommand is found and is_required=`True` and cli_exit_on_error=`True` + (the default). + SettingsError: When no subcommand is found and is_required=`True` and + cli_exit_on_error=`False`. + """ + + subcommands: list[str] = [] + for field_name, field_info in _get_model_fields(type(model)).items(): + if _CliSubCommand in field_info.metadata: + if getattr(model, field_name) is not None: + return getattr(model, field_name) + subcommands.append(field_name) + if is_required: + error_message = ( + f'Error: CLI subcommand is required {{{", ".join(subcommands)}}}' + if subcommands + else 'Error: CLI subcommand is required but no subcommands were found.' + ) + raise SystemExit(error_message) if cli_exit_on_error else SettingsError(error_message) + return None diff --git a/pydantic_settings/sources.py b/pydantic_settings/sources.py index 15a4873c..445edfce 100644 --- a/pydantic_settings/sources.py +++ b/pydantic_settings/sources.py @@ -697,11 +697,7 @@ class Cfg(BaseSettings): if type_has_key: return type_has_key elif is_model_class(annotation) or is_pydantic_dataclass(annotation): - fields = ( - annotation.__pydantic_fields__ - if is_pydantic_dataclass(annotation) and hasattr(annotation, '__pydantic_fields__') - else cast(BaseModel, annotation).model_fields - ) + fields = _get_model_fields(annotation) # `case_sensitive is None` is here to be compatible with the old behavior. # Has to be removed in V3. if (case_sensitive is None or case_sensitive) and fields.get(key): @@ -1283,11 +1279,7 @@ def _get_resolved_names( def _sort_arg_fields(self, model: type[BaseModel]) -> list[tuple[str, FieldInfo]]: positional_args, subcommand_args, optional_args = [], [], [] - fields = ( - model.__pydantic_fields__ - if hasattr(model, '__pydantic_fields__') and is_pydantic_dataclass(model) - else model.model_fields - ) + fields = _get_model_fields(model) for field_name, field_info in fields.items(): if _CliSubCommand in field_info.metadata: if not field_info.is_required(): @@ -1926,3 +1918,11 @@ def _strip_annotated(annotation: Any) -> Any: while get_origin(annotation) == Annotated: annotation = get_args(annotation)[0] return annotation + + +def _get_model_fields(model_cls: type[Any]) -> dict[str, FieldInfo]: + if is_pydantic_dataclass(model_cls) and hasattr(model_cls, '__pydantic_fields__'): + return model_cls.__pydantic_fields__ + if is_model_class(model_cls): + return model_cls.model_fields + raise SettingsError(f'Error: {model_cls.__name__} is not subclass of BaseModel or pydantic.dataclasses.dataclass') From c3e6396bbe43903137b3e5d2721a9c9b555995c5 Mon Sep 17 00:00:00 2001 From: Kyle Schwab Date: Tue, 20 Aug 2024 07:11:41 -0600 Subject: [PATCH 03/10] Implicit flags on by default for BaseModel conversions. --- pydantic_settings/main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pydantic_settings/main.py b/pydantic_settings/main.py index 0e17ae6e..fabeea72 100644 --- a/pydantic_settings/main.py +++ b/pydantic_settings/main.py @@ -441,6 +441,7 @@ class CliAppBaseSettings(BaseSettings, model_cls): # type: ignore cli_hide_none_type=True, cli_avoid_json=True, cli_enforce_required=True, + cli_implicit_flags=True, ) model = CliAppBaseSettings(**model_init_data) From dee064bbe9d257df3db4ee5397a3d3d1f474d1ee Mon Sep 17 00:00:00 2001 From: Kyle Schwab Date: Fri, 23 Aug 2024 13:54:07 -0600 Subject: [PATCH 04/10] Updates. --- pydantic_settings/main.py | 49 +++------------------------------------ 1 file changed, 3 insertions(+), 46 deletions(-) diff --git a/pydantic_settings/main.py b/pydantic_settings/main.py index d1f316bf..59dd1c4f 100644 --- a/pydantic_settings/main.py +++ b/pydantic_settings/main.py @@ -22,7 +22,7 @@ PydanticBaseSettingsSource, SecretsSettingsSource, SettingsError, - _CliSubCommand, + get_subcommand, _get_model_fields, ) @@ -480,55 +480,12 @@ def run_subcommand(model: Any, cli_exit_on_error: bool | None = None) -> None: 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. - If model is subclass of `BaseSettings`, defaults to BaseSettings `cli_exit_on_error` value. - Otherwise, defaults to `True`. + Defaults to model_config `cli_exit_on_error` value if set. Otherwise, defaults to `True`. 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`. """ - if cli_exit_on_error is None and isinstance(model, BaseSettings): - cli_exit_on_error = model.model_config.get('cli_exit_on_error') - if cli_exit_on_error is None: - cli_exit_on_error = True - - subcommand = CliApp.get_subcommand(model, is_required=True, cli_exit_on_error=cli_exit_on_error) + subcommand = get_subcommand(model, is_required=True, cli_exit_on_error=cli_exit_on_error) CliApp._get_command_entrypoint(type(subcommand))(subcommand) - - @staticmethod - def get_subcommand(model: Any, is_required: bool = True, cli_exit_on_error: bool = True) -> Any: - """ - Gets the model subcommand. - - Args: - model: The model to get the subcommand from. - is_required: Determines whether a model must have subcommand set and raises error if not - found. Defaults to `True`. - cli_exit_on_error: Determines whether this function exits with error if no subcommand is found. - Defaults to `True`. - - Returns: - The subcommand model if found, otherwise `None`. - - Raises: - SystemExit: When no subcommand is found and is_required=`True` and cli_exit_on_error=`True` - (the default). - SettingsError: When no subcommand is found and is_required=`True` and - cli_exit_on_error=`False`. - """ - - subcommands: list[str] = [] - for field_name, field_info in _get_model_fields(type(model)).items(): - if _CliSubCommand in field_info.metadata: - if getattr(model, field_name) is not None: - return getattr(model, field_name) - subcommands.append(field_name) - if is_required: - error_message = ( - f'Error: CLI subcommand is required {{{", ".join(subcommands)}}}' - if subcommands - else 'Error: CLI subcommand is required but no subcommands were found.' - ) - raise SystemExit(error_message) if cli_exit_on_error else SettingsError(error_message) - return None From 16d29788620be192181dc4e9470be6da5344d6cb Mon Sep 17 00:00:00 2001 From: Kyle Schwab Date: Sat, 24 Aug 2024 09:20:30 -0600 Subject: [PATCH 05/10] Typing updates. --- pydantic_settings/main.py | 67 ++++++++++++++++++++++----------------- 1 file changed, 38 insertions(+), 29 deletions(-) diff --git a/pydantic_settings/main.py b/pydantic_settings/main.py index 59dd1c4f..05f7fae3 100644 --- a/pydantic_settings/main.py +++ b/pydantic_settings/main.py @@ -1,7 +1,7 @@ from __future__ import annotations as _annotations from pathlib import Path -from typing import Any, Callable, ClassVar +from typing import Any, ClassVar, TypeVar from pydantic import ConfigDict from pydantic._internal._config import config_keys @@ -20,12 +20,14 @@ InitSettingsSource, PathType, PydanticBaseSettingsSource, + PydanticModel, SecretsSettingsSource, SettingsError, get_subcommand, - _get_model_fields, ) +T = TypeVar('T') + class SettingsConfigDict(ConfigDict, total=False): case_sensitive: bool @@ -331,24 +333,22 @@ 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_implicit_flags=cli_implicit_flags, - 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_implicit_flags=cli_implicit_flags, + case_sensitive=case_sensitive, ) sources = (cli_settings,) + sources if sources: @@ -410,19 +410,21 @@ class CliApp: """ @staticmethod - def _get_command_entrypoint(model_cls: type[Any]) -> Callable[[Any], None]: - if hasattr(model_cls, 'cli_cmd'): - return getattr(model_cls, 'cli_cmd') - raise SettingsError(f'Error: {model_cls.__name__} class is missing cli_cmd entrypoint') + def _run_cli_cmd(model: Any, is_required: bool) -> Any: + if hasattr(type(model), 'cli_cmd'): + getattr(type(model), 'cli_cmd')(model) + elif is_required: + raise SettingsError(f'Error: {type(model).__name__} class is missing cli_cmd entrypoint') + return model @staticmethod def run( - model_cls: type[Any], + model_cls: type[T], cli_args: list[str] | None = None, cli_settings_source: CliSettingsSource[Any] | None = None, cli_exit_on_error: bool | None = None, **model_init_data: Any, - ) -> None: + ) -> 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. @@ -437,6 +439,9 @@ def run( `True`. 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. @@ -457,6 +462,7 @@ def run( class CliAppBaseSettings(BaseSettings, model_cls): # type: ignore model_config = SettingsConfigDict( + nested_model_default_partial_update=True, case_sensitive=True, cli_hide_none_type=True, cli_avoid_json=True, @@ -469,10 +475,10 @@ class CliAppBaseSettings(BaseSettings, model_cls): # type: ignore 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) - CliApp._get_command_entrypoint(model_cls)(model_cls(**model_init_data)) + return CliApp._run_cli_cmd(model_cls(**model_init_data), is_required=False) @staticmethod - def run_subcommand(model: Any, cli_exit_on_error: bool | None = None) -> None: + def run_subcommand(model: PydanticModel, cli_exit_on_error: bool | None = None) -> PydanticModel: """ Runs the model subcommand. Running a model subcommand requires the `cli_cmd` method to be defined in the nested model subcommand class. @@ -482,10 +488,13 @@ def run_subcommand(model: Any, cli_exit_on_error: bool | None = None) -> None: 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`. + 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) - CliApp._get_command_entrypoint(type(subcommand))(subcommand) + return CliApp._run_cli_cmd(subcommand, is_required=True) From 6af5673d55e09ea0030413c1eccbf125fd28eb99 Mon Sep 17 00:00:00 2001 From: Kyle Schwab Date: Mon, 26 Aug 2024 09:04:22 -0600 Subject: [PATCH 06/10] Docs. --- docs/index.md | 111 +++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 92 insertions(+), 19 deletions(-) diff --git a/docs/index.md b/docs/index.md index 63459ea5..d6a28ac7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -560,19 +560,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): @@ -869,6 +857,88 @@ options: """ ``` +### 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, they provide +structure for running `cli_cmd` methods associated with models. + +`CliApp.run` can be used to directly provide `cli_args` for parsing 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(self.model_dump()) + #> {'this_foo': 'is such a foo'} + + 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 a recursive fashion to run the `cli_cmd` method for 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` defaults: + +* `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` + ### Customizing the CLI Experience The below flags can be used to customise the CLI experience to your needs. @@ -913,9 +983,10 @@ Additionally, the provided `CliImplicitFlag` and `CliExplicitFlag` annotations c when necessary. !!! note -For `python < 3.9`: - * The `--no-flag` option is not generated due to an underlying `argparse` limitation. - * The `CliImplicitFlag` and `CliExplicitFlag` annotations can only be applied to optional bool fields. + For `python < 3.9` the `--no-flag` option is not generated due to an underlying `argparse` limitation. + +!!! note + For `python < 3.9` the `CliImplicitFlag` and `CliExplicitFlag` annotations can only be applied to optional bool fields. ```py from pydantic_settings import BaseSettings, CliExplicitFlag, CliImplicitFlag @@ -1188,7 +1259,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']) @@ -1203,13 +1274,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(args=True)) +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_settings_source=cli_settings(parsed_args=parsed_args)) +print(s.model_dump()) #> {'name': 'ralph'} ``` From 802cf5b6c113761f6ba73b390ff1ac44d9a1ff66 Mon Sep 17 00:00:00 2001 From: Kyle Schwab Date: Tue, 27 Aug 2024 08:17:48 -0600 Subject: [PATCH 07/10] Updates. --- docs/index.md | 12 +++++++----- pydantic_settings/main.py | 19 ++++++++++++------- pydantic_settings/sources.py | 12 +++++++++--- 3 files changed, 28 insertions(+), 15 deletions(-) diff --git a/docs/index.md b/docs/index.md index 39e6ee74..5303a7e4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -808,11 +808,11 @@ assert get_subcommand(cmd).model_dump() == { ### 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, they provide -structure for running `cli_cmd` methods associated with models. +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 to directly provide `cli_args` for parsing and will run the model `cli_cmd` method (if defined) -after instantiation: +`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 @@ -822,9 +822,11 @@ 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' @@ -833,7 +835,7 @@ print(s.model_dump()) #> {'this_foo': 'ran the foo cli cmd'} ``` -Similarly, the `CliApp.run_subcommand` can be used in a recursive fashion to run the `cli_cmd` method for a subcommand: +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 diff --git a/pydantic_settings/main.py b/pydantic_settings/main.py index 05f7fae3..b8d14244 100644 --- a/pydantic_settings/main.py +++ b/pydantic_settings/main.py @@ -410,11 +410,11 @@ class CliApp: """ @staticmethod - def _run_cli_cmd(model: Any, is_required: bool) -> Any: - if hasattr(type(model), 'cli_cmd'): - getattr(type(model), 'cli_cmd')(model) + 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 entrypoint') + raise SettingsError(f'Error: {type(model).__name__} class is missing {cli_cmd_method_name} entrypoint') return model @staticmethod @@ -423,6 +423,7 @@ def run( cli_args: list[str] | 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: """ @@ -437,6 +438,7 @@ def run( 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: @@ -475,10 +477,12 @@ class CliAppBaseSettings(BaseSettings, model_cls): # type: ignore 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), is_required=False) + 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) -> PydanticModel: + 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. @@ -487,6 +491,7 @@ def run_subcommand(model: PydanticModel, cli_exit_on_error: bool | None = None) 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. @@ -497,4 +502,4 @@ def run_subcommand(model: PydanticModel, cli_exit_on_error: bool | None = None) """ subcommand = get_subcommand(model, is_required=True, cli_exit_on_error=cli_exit_on_error) - return CliApp._run_cli_cmd(subcommand, is_required=True) + 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 f3791f09..312c50a8 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,6 +17,7 @@ from enum import Enum from pathlib import Path from textwrap import dedent +from types import BuiltinFunctionType, FunctionType from typing import ( TYPE_CHECKING, Any, @@ -58,12 +58,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: @@ -156,7 +160,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 +) -> PydanticModel: """ Get the subcommand from a model. @@ -2140,4 +2146,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)) From 8dfd5667319bc54917eca117e02f28f8280aa4c9 Mon Sep 17 00:00:00 2001 From: Kyle Schwab Date: Mon, 9 Sep 2024 07:15:41 -0600 Subject: [PATCH 08/10] Add snake to kebab case alias generator. --- docs/index.md | 3 ++- pydantic_settings/main.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/index.md b/docs/index.md index 998640e3..ccdec4da 100644 --- a/docs/index.md +++ b/docs/index.md @@ -938,8 +938,9 @@ assert cmd.model_dump() == { 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` defaults: +`BaseSettings` configuration defaults: +* `alias_generator=AliasGenerator(lambda s: s.replace('_', '-'))` * `nested_model_default_partial_update=True` * `case_sensitive=True` * `cli_hide_none_type=True` diff --git a/pydantic_settings/main.py b/pydantic_settings/main.py index 88f5ab79..00c084e3 100644 --- a/pydantic_settings/main.py +++ b/pydantic_settings/main.py @@ -2,7 +2,7 @@ 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._signature import _field_name_for_signature from pydantic._internal._utils import deep_update, is_model_class @@ -463,6 +463,7 @@ def run( 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, From daf45f664c5af1096067468da6577b5f144797d2 Mon Sep 17 00:00:00 2001 From: Kyle Schwab Date: Tue, 10 Sep 2024 07:19:33 -0600 Subject: [PATCH 09/10] Doc updates and test. --- docs/index.md | 12 ++++++++++-- tests/test_settings.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/docs/index.md b/docs/index.md index ccdec4da..04536a30 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 @@ -948,6 +947,10 @@ For `BaseModel` and `pydantic.dataclasses.dataclass` types, `CliApp.run` will in * `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. @@ -1311,6 +1314,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/tests/test_settings.py b/tests/test_settings.py index 74c71db1..b3cf3f1a 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -41,6 +41,7 @@ from pydantic_settings import ( BaseSettings, + CliApp, DotEnvSettingsSource, EnvSettingsSource, InitSettingsSource, @@ -4256,6 +4257,36 @@ def test_cli_metavar_format_type_alias_312(): ) +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='Error: `cli_args` and `cli_settings_source` are mutually exclusive'): + + class Cfg(BaseModel): ... + + cli_settings = CliSettingsSource(Cfg) + CliApp.run(Cfg, cli_args=['hello'], cli_settings_source=cli_settings) + + 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( From c1ea0b82645e019799a59f59f518787685ac0e77 Mon Sep 17 00:00:00 2001 From: Kyle Schwab Date: Sat, 21 Sep 2024 12:45:27 -0600 Subject: [PATCH 10/10] Update/Add more tests and simplify run API when using cli_settings_source. --- docs/index.md | 4 +- pydantic_settings/main.py | 22 +++- pydantic_settings/sources.py | 8 +- tests/test_settings.py | 216 ++++++++++++++++++++++------------- 4 files changed, 159 insertions(+), 91 deletions(-) diff --git a/docs/index.md b/docs/index.md index d385b764..0b589eda 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1307,14 +1307,14 @@ 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'] -s = CliApp.run(Settings, cli_settings_source=cli_settings(args=True)) +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']) -s = CliApp.run(Settings, cli_settings_source=cli_settings(parsed_args=parsed_args)) +s = CliApp.run(Settings, cli_args=parsed_args, cli_settings_source=cli_settings) print(s.model_dump()) #> {'name': 'ralph'} ``` diff --git a/pydantic_settings/main.py b/pydantic_settings/main.py index cc39f5c7..b4331733 100644 --- a/pydantic_settings/main.py +++ b/pydantic_settings/main.py @@ -1,5 +1,7 @@ from __future__ import annotations as _annotations +from argparse import Namespace +from types import SimpleNamespace from typing import Any, ClassVar, TypeVar from pydantic import AliasGenerator, ConfigDict @@ -431,7 +433,7 @@ def _run_cli_cmd(model: Any, cli_cmd_method_name: str, is_required: bool) -> Any @staticmethod def run( model_cls: type[T], - cli_args: list[str] | None = None, + 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', @@ -443,7 +445,8 @@ def run( Args: model_cls: The model class to run as a CLI application. - cli_args: The list of CLI arguments to parse. Defaults to `sys.argv[1:]`. + 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 @@ -460,17 +463,24 @@ def run( SettingsError: If model_cls does not have a `cli_cmd` entrypoint defined. """ - cli_parse_args: list[str] | bool = True if cli_args is None else cli_args 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' ) - elif cli_args is not None and cli_settings_source is not None: - raise SettingsError('Error: `cli_args` and `cli_settings_source` are mutually exclusive') + + 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_source + model_init_data['_cli_settings_source'] = cli_settings if not issubclass(model_cls, BaseSettings): class CliAppBaseSettings(BaseSettings, model_cls): # type: ignore diff --git a/pydantic_settings/sources.py b/pydantic_settings/sources.py index 3dab4283..0d100eff 100644 --- a/pydantic_settings/sources.py +++ b/pydantic_settings/sources.py @@ -1179,9 +1179,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. @@ -1220,9 +1218,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. diff --git a/tests/test_settings.py b/tests/test_settings.py index a2715be8..18274a04 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -746,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): @@ -2518,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, @@ -2551,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() @@ -2568,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}} ... @@ -2591,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 @@ -2614,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', @@ -2628,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', @@ -2651,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', @@ -2661,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': { @@ -2683,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(): @@ -2698,7 +2700,7 @@ class Cfg(BaseSettings, cli_exit_on_error=False): Foo: str Bar: str - cfg = Cfg(_cli_parse_args=['--FOO=--VAL', '--BAR', '"--VAL"']) + cfg = CliApp.run(Cfg, cli_args=['--FOO=--VAL', '--BAR', '"--VAL"']) assert cfg.model_dump() == {'Foo': '--VAL', 'Bar': '"--VAL"'} cfg = Cfg(_cli_parse_args=['--Foo=--VAL', '--Bar', '"--VAL"'], _case_sensitive=True) @@ -2721,7 +2723,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) @@ -2900,7 +2902,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' @@ -2959,7 +2961,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, @@ -2971,7 +2973,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}], @@ -2983,7 +2985,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, @@ -3000,7 +3002,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, @@ -3015,8 +3017,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', @@ -3025,11 +3028,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]) @@ -3071,7 +3074,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', @@ -3115,10 +3118,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(): @@ -3127,7 +3130,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': [ @@ -3156,19 +3159,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): @@ -3176,7 +3179,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', @@ -3199,24 +3202,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', @@ -3239,7 +3242,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']} @@ -3248,7 +3251,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( @@ -3256,11 +3259,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): @@ -3284,13 +3287,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'}} @@ -3298,7 +3301,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}} ... @@ -3345,13 +3348,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} @@ -3359,7 +3362,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}} ... @@ -3406,13 +3409,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} @@ -3420,7 +3423,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}} ... @@ -3494,7 +3497,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, @@ -3506,7 +3509,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}, @@ -3515,7 +3518,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, @@ -3524,7 +3527,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, @@ -3564,7 +3567,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'}} @@ -3578,11 +3581,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', @@ -3597,7 +3600,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}}] @@ -3616,11 +3619,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'"}, @@ -3743,8 +3746,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): @@ -3766,8 +3769,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): @@ -3979,7 +3982,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(): @@ -3987,10 +3990,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} @@ -4041,6 +4046,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'} @@ -4061,6 +4068,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'} @@ -4081,6 +4090,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'} @@ -4271,6 +4283,54 @@ 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' @@ -4280,12 +4340,14 @@ class NotPydanticModel: ... CliApp.run(NotPydanticModel) - with pytest.raises(SettingsError, match='Error: `cli_args` and `cli_settings_source` are mutually exclusive'): + 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): ... - cli_settings = CliSettingsSource(Cfg) - CliApp.run(Cfg, cli_args=['hello'], cli_settings_source=cli_settings) + CliApp.run(Cfg, cli_args={'my_arg': 'hello'}) with pytest.raises(SettingsError, match='Error: Child class is missing cli_cmd entrypoint'):