diff --git a/docs/index.md b/docs/index.md index 39548bc..dcf6597 100644 --- a/docs/index.md +++ b/docs/index.md @@ -979,6 +979,26 @@ class ImplicitSettings(BaseSettings, cli_parse_args=True, cli_implicit_flags=Tru """ ``` +#### Ignore 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. + +```py +import sys + +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings, cli_parse_args=True, cli_ignore_unknown_args=True): + good_arg: str + + +sys.argv = ['example.py', '--bad-arg=bad', 'ANOTHER_BAD_ARG', '--good_arg=hello world'] +print(Settings().model_dump()) +#> {'good_arg': 'hello world'} +``` + #### Change Whether CLI Should Exit on Error Change whether the CLI internal parser will exit on error or raise a `SettingsError` exception by using diff --git a/pydantic_settings/main.py b/pydantic_settings/main.py index 19d97ea..2370ed1 100644 --- a/pydantic_settings/main.py +++ b/pydantic_settings/main.py @@ -42,6 +42,7 @@ class SettingsConfigDict(ConfigDict, total=False): cli_exit_on_error: bool cli_prefix: str cli_implicit_flags: bool | None + cli_ignore_unknown_args: bool | None secrets_dir: PathType | None json_file: PathType | None json_file_encoding: str | None @@ -120,6 +121,7 @@ class BaseSettings(BaseModel): _cli_prefix: The root parser command line arguments prefix. Defaults to "". _cli_implicit_flags: Whether `bool` fields should be implicitly converted into CLI boolean flags. (e.g. --flag, --no-flag). Defaults to `False`. + _cli_ignore_unknown_args: Whether to ignore unknown CLI args and parse only known ones. Defaults to `False`. _secrets_dir: The secret files directory or a sequence of directories. Defaults to `None`. """ @@ -145,6 +147,7 @@ def __init__( _cli_exit_on_error: bool | None = None, _cli_prefix: str | None = None, _cli_implicit_flags: bool | None = None, + _cli_ignore_unknown_args: bool | None = None, _secrets_dir: PathType | None = None, **values: Any, ) -> None: @@ -172,6 +175,7 @@ def __init__( _cli_exit_on_error=_cli_exit_on_error, _cli_prefix=_cli_prefix, _cli_implicit_flags=_cli_implicit_flags, + _cli_ignore_unknown_args=_cli_ignore_unknown_args, _secrets_dir=_secrets_dir, ) ) @@ -223,6 +227,7 @@ def _settings_build_values( _cli_exit_on_error: bool | None = None, _cli_prefix: str | None = None, _cli_implicit_flags: bool | None = None, + _cli_ignore_unknown_args: bool | None = None, _secrets_dir: PathType | None = None, ) -> dict[str, Any]: # Determine settings config values @@ -280,6 +285,11 @@ def _settings_build_values( cli_implicit_flags = ( _cli_implicit_flags if _cli_implicit_flags is not None else self.model_config.get('cli_implicit_flags') ) + cli_ignore_unknown_args = ( + _cli_ignore_unknown_args + if _cli_ignore_unknown_args is not None + else self.model_config.get('cli_ignore_unknown_args') + ) secrets_dir = _secrets_dir if _secrets_dir is not None else self.model_config.get('secrets_dir') @@ -339,6 +349,7 @@ def _settings_build_values( cli_exit_on_error=cli_exit_on_error, cli_prefix=cli_prefix, cli_implicit_flags=cli_implicit_flags, + cli_ignore_unknown_args=cli_ignore_unknown_args, case_sensitive=case_sensitive, ) if cli_settings_source is None @@ -388,6 +399,7 @@ def _settings_build_values( cli_exit_on_error=True, cli_prefix='', cli_implicit_flags=False, + cli_ignore_unknown_args=False, json_file=None, json_file_encoding=None, yaml_file=None, diff --git a/pydantic_settings/sources.py b/pydantic_settings/sources.py index 9bb5424..ae1484f 100644 --- a/pydantic_settings/sources.py +++ b/pydantic_settings/sources.py @@ -1028,6 +1028,7 @@ class CliSettingsSource(EnvSettingsSource, Generic[T]): cli_prefix: Prefix for command line arguments added under the root parser. Defaults to "". cli_implicit_flags: Whether `bool` fields should be implicitly converted into CLI boolean flags. (e.g. --flag, --no-flag). Defaults to `False`. + cli_ignore_unknown_args: Whether to ignore unknown CLI args and parse only known ones. Defaults to `False`. case_sensitive: Whether CLI "--arg" names should be read with case-sensitivity. Defaults to `True`. Note: Case-insensitive matching is only supported on the internal root parser and does not apply to CLI subcommands. @@ -1056,9 +1057,10 @@ def __init__( cli_exit_on_error: bool | None = None, cli_prefix: str | None = None, cli_implicit_flags: bool | None = None, + cli_ignore_unknown_args: bool | None = None, case_sensitive: bool | None = True, root_parser: Any = None, - parse_args_method: Callable[..., Any] | None = ArgumentParser.parse_args, + parse_args_method: Callable[..., Any] | None = None, add_argument_method: Callable[..., Any] | None = ArgumentParser.add_argument, add_argument_group_method: Callable[..., Any] | None = ArgumentParser.add_argument_group, add_parser_method: Callable[..., Any] | None = _SubParsersAction.add_parser, @@ -1104,6 +1106,11 @@ def __init__( if cli_implicit_flags is not None else settings_cls.model_config.get('cli_implicit_flags', False) ) + self.cli_ignore_unknown_args = ( + cli_ignore_unknown_args + if cli_ignore_unknown_args is not None + else settings_cls.model_config.get('cli_ignore_unknown_args', False) + ) case_sensitive = case_sensitive if case_sensitive is not None else True if not case_sensitive and root_parser is not None: @@ -1519,14 +1526,19 @@ def none_parser_method(*args: Any, **kwargs: Any) -> Any: def _connect_root_parser( self, root_parser: T, - parse_args_method: Callable[..., Any] | None = ArgumentParser.parse_args, + parse_args_method: Callable[..., Any] | None, add_argument_method: Callable[..., Any] | None = ArgumentParser.add_argument, add_argument_group_method: Callable[..., Any] | None = ArgumentParser.add_argument_group, add_parser_method: Callable[..., Any] | None = _SubParsersAction.add_parser, add_subparsers_method: Callable[..., Any] | None = ArgumentParser.add_subparsers, formatter_class: Any = RawDescriptionHelpFormatter, ) -> None: + def _parse_known_args(*args: Any, **kwargs: Any) -> Namespace: + return ArgumentParser.parse_known_args(*args, **kwargs)[0] + self._root_parser = root_parser + if parse_args_method is None: + parse_args_method = _parse_known_args if self.cli_ignore_unknown_args else ArgumentParser.parse_args self._parse_args = self._connect_parser_method(parse_args_method, 'parsed_args_method') self._add_argument = self._connect_parser_method(add_argument_method, 'add_argument_method') self._add_argument_group = self._connect_parser_method(add_argument_group_method, 'add_argument_group_method') diff --git a/tests/test_settings.py b/tests/test_settings.py index 3ffa543..c10d470 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -3981,6 +3981,18 @@ class Settings(BaseSettings, cli_parse_args=True): ... Settings(_cli_exit_on_error=False) +def test_cli_ignore_unknown_args(): + 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']) + 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']) + assert cfg.model_dump() == {'this': 'goodbye', 'that': 789} + + @pytest.mark.parametrize('parser_type', [pytest.Parser, argparse.ArgumentParser, CliDummyParser]) @pytest.mark.parametrize('prefix', ['', 'cfg']) def test_cli_user_settings_source(parser_type, prefix):