diff --git a/docs/index.md b/docs/index.md index eb30a097..76696dfd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1309,24 +1309,25 @@ class ImplicitSettings(BaseSettings, cli_parse_args=True, cli_implicit_flags=Tru """ ``` -#### Ignore Unknown Arguments +#### Ignore and Retrieve Unknown Arguments Change whether to ignore unknown CLI arguments and only parse known ones using `cli_ignore_unknown_args`. By default, the CLI -does not ignore any args. +does not ignore any args. Ignored arguments can then be retrieved using the `CliUnknownArgs` annotation. ```py import sys -from pydantic_settings import BaseSettings +from pydantic_settings import BaseSettings, CliUnknownArgs class Settings(BaseSettings, cli_parse_args=True, cli_ignore_unknown_args=True): good_arg: str + ignored_args: CliUnknownArgs sys.argv = ['example.py', '--bad-arg=bad', 'ANOTHER_BAD_ARG', '--good_arg=hello world'] print(Settings().model_dump()) -#> {'good_arg': 'hello world'} +#> {'good_arg': 'hello world', 'ignored_args': ['--bad-arg=bad', 'ANOTHER_BAD_ARG']} ``` #### CLI Kebab Case for Arguments diff --git a/pydantic_settings/__init__.py b/pydantic_settings/__init__.py index 739afef0..60990a8f 100644 --- a/pydantic_settings/__init__.py +++ b/pydantic_settings/__init__.py @@ -11,6 +11,7 @@ CliSettingsSource, CliSubCommand, CliSuppress, + CliUnknownArgs, DotEnvSettingsSource, EnvSettingsSource, ForceDecode, @@ -40,6 +41,7 @@ 'CliSettingsSource', 'CliSubCommand', 'CliSuppress', + 'CliUnknownArgs', 'DotEnvSettingsSource', 'EnvSettingsSource', 'ForceDecode', diff --git a/pydantic_settings/sources/__init__.py b/pydantic_settings/sources/__init__.py index 5c7d3d8c..42027afc 100644 --- a/pydantic_settings/sources/__init__.py +++ b/pydantic_settings/sources/__init__.py @@ -18,6 +18,7 @@ CliSettingsSource, CliSubCommand, CliSuppress, + CliUnknownArgs, ) from .providers.dotenv import DotEnvSettingsSource, read_env_file from .providers.env import EnvSettingsSource @@ -41,6 +42,7 @@ 'CliSettingsSource', 'CliSubCommand', 'CliSuppress', + 'CliUnknownArgs', 'DefaultSettingsSource', 'DotEnvSettingsSource', 'DotenvType', diff --git a/pydantic_settings/sources/providers/cli.py b/pydantic_settings/sources/providers/cli.py index 15773852..ac08c778 100644 --- a/pydantic_settings/sources/providers/cli.py +++ b/pydantic_settings/sources/providers/cli.py @@ -35,7 +35,7 @@ ) import typing_extensions -from pydantic import BaseModel +from pydantic import BaseModel, Field from pydantic._internal._repr import Representation from pydantic._internal._utils import is_model_class from pydantic.dataclasses import is_pydantic_dataclass @@ -47,7 +47,7 @@ from ...exceptions import SettingsError from ...utils import _lenient_issubclass, _WithArgsTypes -from ..types import _CliExplicitFlag, _CliImplicitFlag, _CliPositionalArg, _CliSubCommand +from ..types import NoDecode, _CliExplicitFlag, _CliImplicitFlag, _CliPositionalArg, _CliSubCommand, _CliUnknownArgs from ..utils import ( _annotation_contains_types, _annotation_enum_val_to_name, @@ -86,6 +86,7 @@ class CliMutuallyExclusiveGroup(BaseModel): CliExplicitFlag = Annotated[_CliBoolFlag, _CliExplicitFlag] CLI_SUPPRESS = SUPPRESS CliSuppress = Annotated[T, CLI_SUPPRESS] +CliUnknownArgs = Annotated[list[str], Field(default=[]), _CliUnknownArgs, NoDecode] class CliSettingsSource(EnvSettingsSource, Generic[T]): @@ -364,6 +365,8 @@ def _load_env_vars( if not any(field_name for field_name in parsed_args.keys() if f'{last_selected_subcommand}.' in field_name): parsed_args[last_selected_subcommand] = '{}' + parsed_args.update(self._cli_unknown_args) + self.env_vars = parse_env_vars( cast(Mapping[str, str], parsed_args), self.case_sensitive, @@ -630,8 +633,13 @@ def _connect_root_parser( add_subparsers_method: Callable[..., Any] | None = ArgumentParser.add_subparsers, formatter_class: Any = RawDescriptionHelpFormatter, ) -> None: + self._cli_unknown_args: dict[str, list[str]] = {} + def _parse_known_args(*args: Any, **kwargs: Any) -> Namespace: - return ArgumentParser.parse_known_args(*args, **kwargs)[0] + args, unknown_args = ArgumentParser.parse_known_args(*args, **kwargs) + for dest in self._cli_unknown_args: + self._cli_unknown_args[dest] = unknown_args + return cast(Namespace, args) self._root_parser = root_parser if parse_args_method is None: @@ -756,10 +764,7 @@ def _add_parser_args( if not arg_names or (kwargs['dest'] in added_args): continue - if is_append_action: - kwargs['action'] = 'append' - if _annotation_contains_types(field_info.annotation, (dict, Mapping), is_strip_annotated=True): - self._cli_dict_args[kwargs['dest']] = field_info.annotation + self._convert_append_action(kwargs, field_info, is_append_action) if _CliPositionalArg in field_info.metadata: arg_names, flag_prefix = self._convert_positional_arg( @@ -785,6 +790,8 @@ def _add_parser_args( model_default=model_default, is_model_suppressed=is_model_suppressed, ) + elif _CliUnknownArgs in field_info.metadata: + self._cli_unknown_args[kwargs['dest']] = [] elif not is_alias_path_only: if group is not None: if isinstance(group, dict): @@ -807,6 +814,12 @@ def _check_kebab_name(self, name: str) -> str: return name.replace('_', '-') return name + def _convert_append_action(self, kwargs: dict[str, Any], field_info: FieldInfo, is_append_action: bool) -> None: + if is_append_action: + kwargs['action'] = 'append' + if _annotation_contains_types(field_info.annotation, (dict, Mapping), is_strip_annotated=True): + self._cli_dict_args[kwargs['dest']] = field_info.annotation + def _convert_bool_flag(self, kwargs: dict[str, Any], field_info: FieldInfo, model_default: Any) -> None: if kwargs['metavar'] == 'bool': if (self.cli_implicit_flags or _CliImplicitFlag in field_info.metadata) and ( diff --git a/pydantic_settings/sources/types.py b/pydantic_settings/sources/types.py index a2104815..2c00d0e2 100644 --- a/pydantic_settings/sources/types.py +++ b/pydantic_settings/sources/types.py @@ -57,6 +57,10 @@ class _CliExplicitFlag: pass +class _CliUnknownArgs: + pass + + __all__ = [ 'DEFAULT_PATH', 'ENV_FILE_SENTINEL', @@ -70,4 +74,5 @@ class _CliExplicitFlag: '_CliImplicitFlag', '_CliPositionalArg', '_CliSubCommand', + '_CliUnknownArgs', ] diff --git a/tests/test_source_cli.py b/tests/test_source_cli.py index c6b25d6e..6c523ffe 100644 --- a/tests/test_source_cli.py +++ b/tests/test_source_cli.py @@ -36,6 +36,7 @@ CliSettingsSource, CliSubCommand, CliSuppress, + CliUnknownArgs, get_subcommand, ) @@ -1730,14 +1731,26 @@ def test_cli_ignore_unknown_args(): class Cfg(BaseSettings, cli_ignore_unknown_args=True): this: str = 'hello' that: int = 123 + ignored_args: CliUnknownArgs + + cfg = CliApp.run(Cfg, cli_args=['--this=hi', '--that=456']) + assert cfg.model_dump() == {'this': 'hi', 'that': 456, 'ignored_args': []} cfg = CliApp.run(Cfg, cli_args=['not_my_positional_arg', '--not-my-optional-arg=456']) - assert cfg.model_dump() == {'this': 'hello', 'that': 123} + assert cfg.model_dump() == { + 'this': 'hello', + 'that': 123, + 'ignored_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', '--this=goodbye', '--that=789'] ) - assert cfg.model_dump() == {'this': 'goodbye', 'that': 789} + assert cfg.model_dump() == { + 'this': 'goodbye', + 'that': 789, + 'ignored_args': ['not_my_positional_arg', '--not-my-optional-arg=456'], + } def test_cli_flag_prefix_char():