From 01c19cf76bc12da9bf87f202f183fbab0240ed33 Mon Sep 17 00:00:00 2001 From: Kyle Schwab Date: Wed, 10 Jul 2024 08:04:04 -0600 Subject: [PATCH 1/8] Remove CLI exit on error and raise SettingsError instead. --- docs/index.md | 10 ++-- pydantic_settings/__init__.py | 2 + pydantic_settings/sources.py | 11 +++-- tests/test_settings.py | 89 ++++++++++++++++------------------- 4 files changed, 52 insertions(+), 60 deletions(-) diff --git a/docs/index.md b/docs/index.md index fc48cf67..2a26dbbe 100644 --- a/docs/index.md +++ b/docs/index.md @@ -878,7 +878,7 @@ import sys from pydantic import Field -from pydantic_settings import BaseSettings +from pydantic_settings import BaseSettings, SettingsError class Settings(BaseSettings, cli_parse_args=True, cli_enforce_required=True): @@ -890,13 +890,9 @@ os.environ['MY_REQUIRED_FIELD'] = 'hello from environment' try: sys.argv = ['example.py'] Settings() -except SystemExit as e: +except SettingsError as e: print(e) - #> 2 -""" -usage: example.py [-h] --my_required_field str -example.py: error: the following arguments are required: --my_required_field -""" + #> error parsing CLI: the following arguments are required: --my_required_field ``` #### Change the None Type Parse String diff --git a/pydantic_settings/__init__.py b/pydantic_settings/__init__.py index d70ccc8a..02171ba1 100644 --- a/pydantic_settings/__init__.py +++ b/pydantic_settings/__init__.py @@ -10,6 +10,7 @@ PydanticBaseSettingsSource, PyprojectTomlConfigSettingsSource, SecretsSettingsSource, + SettingsError, TomlConfigSettingsSource, YamlConfigSettingsSource, ) @@ -28,6 +29,7 @@ 'PydanticBaseSettingsSource', 'SecretsSettingsSource', 'SettingsConfigDict', + 'SettingsError', 'TomlConfigSettingsSource', 'YamlConfigSettingsSource', '__version__', diff --git a/pydantic_settings/sources.py b/pydantic_settings/sources.py index 95e9d2b8..6721a102 100644 --- a/pydantic_settings/sources.py +++ b/pydantic_settings/sources.py @@ -93,6 +93,10 @@ def import_toml() -> None: ENV_FILE_SENTINEL: DotenvType = Path('') +class SettingsError(ValueError): + pass + + class _CliSubCommand: pass @@ -102,7 +106,8 @@ class _CliPositionalArg: class _CliInternalArgParser(ArgumentParser): - pass + def error(self, message): + raise SettingsError(f'error parsing CLI: {message}') T = TypeVar('T') @@ -114,10 +119,6 @@ class EnvNoneType(str): pass -class SettingsError(ValueError): - pass - - class PydanticBaseSettingsSource(ABC): """ Abstract base class for settings sources, every settings source classes should inherit from it. diff --git a/tests/test_settings.py b/tests/test_settings.py index 3222e50c..171350f1 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -2329,7 +2329,7 @@ class Cfg(BaseSettings, cli_avoid_json=avoid_json): def test_cli_alias_exceptions(capsys, monkeypatch): - with pytest.raises(SettingsError) as exc_info: + with pytest.raises(SettingsError, match='subcommand argument BadCliSubCommand.foo has an alias'): class SubCmd(BaseModel): v0: int @@ -2338,15 +2338,13 @@ class BadCliSubCommand(BaseSettings): foo: CliSubCommand[SubCmd] = Field(alias='bar') BadCliSubCommand(_cli_parse_args=True) - assert str(exc_info.value) == 'subcommand argument BadCliSubCommand.foo has an alias' - with pytest.raises(SettingsError) as exc_info: + with pytest.raises(SettingsError, match='positional argument BadCliPositionalArg.foo has an alias'): class BadCliPositionalArg(BaseSettings): foo: CliPositionalArg[int] = Field(alias='bar') BadCliPositionalArg(_cli_parse_args=True) - assert str(exc_info.value) == 'positional argument BadCliPositionalArg.foo has an alias' def test_cli_case_insensitve_arg(): @@ -2360,12 +2358,11 @@ class Cfg(BaseSettings): cfg = Cfg(_cli_parse_args=['--Foo=--VAL', '--Bar', '"--VAL"'], _case_sensitive=True) assert cfg.model_dump() == {'Foo': '--VAL', 'Bar': '"--VAL"'} - with pytest.raises(SystemExit): + with pytest.raises(SettingsError): Cfg(_cli_parse_args=['--FOO=--VAL', '--BAR', '"--VAL"'], _case_sensitive=True) - with pytest.raises(SettingsError) as exc_info: + with pytest.raises(SettingsError, match='Case-insensitive matching is only supported on the internal root parser'): CliSettingsSource(Cfg, root_parser=CliDummyParser(), case_sensitive=False) - assert str(exc_info.value) == 'Case-insensitive matching is only supported on the internal root parser' def test_cli_help_differentiation(capsys, monkeypatch): @@ -2601,13 +2598,11 @@ class Cfg(BaseSettings): expected['child'] = None assert cfg.model_dump() == expected - with pytest.raises(SettingsError) as exc_info: + 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']) - assert str(exc_info.value) == f'Parsing error encountered for {prefix}check_dict: Mismatched quotes' - with pytest.raises(SettingsError): + 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"']) - assert str(exc_info.value) == f'Parsing error encountered for {prefix}check_dict: Mismatched quotes' def test_cli_union_dict_arg(): @@ -2740,18 +2735,16 @@ class Cfg(BaseSettings): cfg = Cfg(_cli_parse_args=args) assert cfg.model_dump() == {'check_dict': {'k1': {'a': 1}, 'k2': {'b': 2}}} - with pytest.raises(SettingsError) as exc_info: + with pytest.raises( + SettingsError, + 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) - assert ( - str(exc_info.value) - == 'Parsing error encountered for check_dict: not enough values to unpack (expected 2, got 1)' - ) - with pytest.raises(SettingsError) as exc_info: + 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) - assert str(exc_info.value) == 'Parsing error encountered for check_dict: Missing end delimiter "}"' def test_cli_subcommand_with_positionals(): @@ -2877,63 +2870,66 @@ class SubCmd(BaseModel): with monkeypatch.context() as m: m.setattr(sys, 'argv', ['example.py', '--help']) - with pytest.raises(SettingsError) as exc_info: + with pytest.raises( + SettingsError, match='CliSubCommand is not outermost annotation for SubCommandNotOutermost.subcmd' + ): class SubCommandNotOutermost(BaseSettings, cli_parse_args=True): subcmd: Union[int, CliSubCommand[SubCmd]] SubCommandNotOutermost() - assert str(exc_info.value) == 'CliSubCommand is not outermost annotation for SubCommandNotOutermost.subcmd' - with pytest.raises(SettingsError) as exc_info: + with pytest.raises(SettingsError, match='subcommand argument SubCommandHasDefault.subcmd has a default value'): class SubCommandHasDefault(BaseSettings, cli_parse_args=True): subcmd: CliSubCommand[SubCmd] = SubCmd() SubCommandHasDefault() - assert str(exc_info.value) == 'subcommand argument SubCommandHasDefault.subcmd has a default value' - with pytest.raises(SettingsError) as exc_info: + with pytest.raises( + SettingsError, match='subcommand argument SubCommandMultipleTypes.subcmd has multiple types' + ): class SubCommandMultipleTypes(BaseSettings, cli_parse_args=True): subcmd: CliSubCommand[Union[SubCmd, SubCmdAlt]] SubCommandMultipleTypes() - assert str(exc_info.value) == 'subcommand argument SubCommandMultipleTypes.subcmd has multiple types' - with pytest.raises(SettingsError) as exc_info: + with pytest.raises( + SettingsError, match='subcommand argument SubCommandNotModel.subcmd is not derived from BaseModel' + ): class SubCommandNotModel(BaseSettings, cli_parse_args=True): subcmd: CliSubCommand[str] SubCommandNotModel() - assert str(exc_info.value) == 'subcommand argument SubCommandNotModel.subcmd is not derived from BaseModel' - with pytest.raises(SettingsError) as exc_info: + with pytest.raises( + SettingsError, match='CliPositionalArg is not outermost annotation for PositionalArgNotOutermost.pos_arg' + ): class PositionalArgNotOutermost(BaseSettings, cli_parse_args=True): pos_arg: Union[int, CliPositionalArg[str]] PositionalArgNotOutermost() - assert ( - str(exc_info.value) == 'CliPositionalArg is not outermost annotation for PositionalArgNotOutermost.pos_arg' - ) - with pytest.raises(SettingsError) as exc_info: + with pytest.raises( + SettingsError, match='positional argument PositionalArgHasDefault.pos_arg has a default value' + ): class PositionalArgHasDefault(BaseSettings, cli_parse_args=True): pos_arg: CliPositionalArg[str] = 'bad' PositionalArgHasDefault() - assert str(exc_info.value) == 'positional argument PositionalArgHasDefault.pos_arg has a default value' - with pytest.raises(SettingsError) as exc_info: + with pytest.raises( + SettingsError, match=re.escape("cli_parse_args must be List[str] or Tuple[str, ...], recieved ") + ): class InvalidCliParseArgsType(BaseSettings, cli_parse_args='invalid type'): val: int InvalidCliParseArgsType() - assert str(exc_info.value) == "cli_parse_args must be List[str] or Tuple[str, ...], recieved " def test_cli_avoid_json(capsys, monkeypatch): @@ -3123,7 +3119,9 @@ class Settings(BaseSettings): 'my_required_field': 'hello from environment' } - with pytest.raises(SystemExit): + with pytest.raises( + SettingsError, match='error parsing CLI: the following arguments are required: --my_required_field' + ): Settings(_cli_parse_args=[], _cli_enforce_required=True).model_dump() @@ -3257,24 +3255,20 @@ def test_cli_user_settings_source_exceptions(): class Cfg(BaseSettings): pet: Literal['dog', 'cat', 'bird'] = 'bird' - with pytest.raises(SettingsError) as exc_info: + with pytest.raises(SettingsError, match='`args` and `parsed_args` are mutually exclusive'): args = ['--pet', 'dog'] parsed_args = {'pet': 'dog'} cli_cfg_settings = CliSettingsSource(Cfg) Cfg(_cli_settings_source=cli_cfg_settings(args=args, parsed_args=parsed_args)) - assert str(exc_info.value) == '`args` and `parsed_args` are mutually exclusive' - with pytest.raises(SettingsError) as exc_info: + with pytest.raises(SettingsError, match='CLI settings source prefix is invalid: .cfg'): CliSettingsSource(Cfg, cli_prefix='.cfg') - assert str(exc_info.value) == 'CLI settings source prefix is invalid: .cfg' - with pytest.raises(SettingsError) as exc_info: + with pytest.raises(SettingsError, match='CLI settings source prefix is invalid: cfg.'): CliSettingsSource(Cfg, cli_prefix='cfg.') - assert str(exc_info.value) == 'CLI settings source prefix is invalid: cfg.' - with pytest.raises(SettingsError) as exc_info: + with pytest.raises(SettingsError, match='CLI settings source prefix is invalid: 123'): CliSettingsSource(Cfg, cli_prefix='123') - assert str(exc_info.value) == 'CLI settings source prefix is invalid: 123' class Food(BaseModel): fruit: FruitsEnum = FruitsEnum.kiwi @@ -3283,12 +3277,11 @@ class CfgWithSubCommand(BaseSettings): pet: Literal['dog', 'cat', 'bird'] = 'bird' food: CliSubCommand[Food] - with pytest.raises(SettingsError) as exc_info: + with pytest.raises( + SettingsError, + match='cannot connect CLI settings source root parser: add_subparsers_method is set to `None` but is needed for connecting', + ): CliSettingsSource(CfgWithSubCommand, add_subparsers_method=None) - assert ( - str(exc_info.value) - == 'cannot connect CLI settings source root parser: add_subparsers_method is set to `None` but is needed for connecting' - ) @pytest.mark.parametrize( From 678a8d6df4a59630a2baf3dce6f52b8f4ecf4296 Mon Sep 17 00:00:00 2001 From: Kyle Schwab Date: Wed, 10 Jul 2024 08:15:21 -0600 Subject: [PATCH 2/8] Lint. --- pydantic_settings/sources.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pydantic_settings/sources.py b/pydantic_settings/sources.py index 6721a102..2168e36f 100644 --- a/pydantic_settings/sources.py +++ b/pydantic_settings/sources.py @@ -106,7 +106,7 @@ class _CliPositionalArg: class _CliInternalArgParser(ArgumentParser): - def error(self, message): + def error(self, message: str): raise SettingsError(f'error parsing CLI: {message}') From 42b1742a60144b9af1271d616e18a2b33349f20f Mon Sep 17 00:00:00 2001 From: Kyle Schwab Date: Wed, 10 Jul 2024 08:17:51 -0600 Subject: [PATCH 3/8] Lint. --- pydantic_settings/sources.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pydantic_settings/sources.py b/pydantic_settings/sources.py index 2168e36f..192fcfe0 100644 --- a/pydantic_settings/sources.py +++ b/pydantic_settings/sources.py @@ -21,6 +21,7 @@ Generic, List, Mapping, + NoReturn, Sequence, Tuple, TypeVar, @@ -106,7 +107,7 @@ class _CliPositionalArg: class _CliInternalArgParser(ArgumentParser): - def error(self, message: str): + def error(self, message: str) -> NoReturn: raise SettingsError(f'error parsing CLI: {message}') From 4849f11c0ac9eda4e3f2dab4fee83cc1ca4bb982 Mon Sep 17 00:00:00 2001 From: Kyle Schwab Date: Wed, 10 Jul 2024 08:21:41 -0600 Subject: [PATCH 4/8] Add match. --- tests/test_settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_settings.py b/tests/test_settings.py index 171350f1..5e803ff2 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -2358,7 +2358,7 @@ class Cfg(BaseSettings): cfg = Cfg(_cli_parse_args=['--Foo=--VAL', '--Bar', '"--VAL"'], _case_sensitive=True) assert cfg.model_dump() == {'Foo': '--VAL', 'Bar': '"--VAL"'} - with pytest.raises(SettingsError): + with pytest.raises(SettingsError, match='error parsing CLI: unrecognized arguments: --FOO=--VAL --BAR "--VAL"'): Cfg(_cli_parse_args=['--FOO=--VAL', '--BAR', '"--VAL"'], _case_sensitive=True) with pytest.raises(SettingsError, match='Case-insensitive matching is only supported on the internal root parser'): From 259f67baf07cf47a1e499cf13ad17c7ff30f9dea Mon Sep 17 00:00:00 2001 From: Kyle Schwab Date: Wed, 10 Jul 2024 09:38:44 -0600 Subject: [PATCH 5/8] Add cli_exit_on_error config. --- docs/index.md | 30 +++++++++++++++++++++++++++++- pydantic_settings/main.py | 11 +++++++++++ pydantic_settings/sources.py | 23 +++++++++++++++++++---- tests/test_settings.py | 26 +++++++++++++++++++++++--- 4 files changed, 82 insertions(+), 8 deletions(-) diff --git a/docs/index.md b/docs/index.md index 2a26dbbe..e3760250 100644 --- a/docs/index.md +++ b/docs/index.md @@ -862,6 +862,29 @@ options: """ ``` +#### Change Whether CLI Should Exit on Error + +Change whether the CLI internal parser will exit on error or raise a `SettingsError` exception by using +`cli_exit_on_error`. By default, the CLI internal parser will exit on error. + +```py +import sys + +from pydantic_settings import BaseSettings, SettingsError + + +class Settings(BaseSettings, cli_parse_args=True, cli_exit_on_error=False): + ... + + +try: + sys.argv = ['example.py', '--bad-arg'] + Settings() +except SettingsError as e: + print(e) + #> error parsing CLI: unrecognized arguments: --bad-arg +``` + #### Enforce Required Arguments at CLI Pydantic settings is designed to pull values in from various sources when instantating a model. This means a field that @@ -881,7 +904,12 @@ from pydantic import Field from pydantic_settings import BaseSettings, SettingsError -class Settings(BaseSettings, cli_parse_args=True, cli_enforce_required=True): +class Settings( + BaseSettings, + cli_parse_args=True, + cli_enforce_required=True, + cli_exit_on_error=False, +): my_required_field: str = Field(description='a top level required field') diff --git a/pydantic_settings/main.py b/pydantic_settings/main.py index 3cee6ab0..1dd4ac76 100644 --- a/pydantic_settings/main.py +++ b/pydantic_settings/main.py @@ -38,6 +38,7 @@ class SettingsConfigDict(ConfigDict, total=False): cli_avoid_json: bool cli_enforce_required: bool cli_use_class_docs_for_groups: bool + cli_exit_on_error: bool cli_prefix: str secrets_dir: str | Path | None json_file: PathType | None @@ -110,6 +111,8 @@ class BaseSettings(BaseModel): _cli_enforce_required: Enforce required fields at the CLI. Defaults to `False`. _cli_use_class_docs_for_groups: Use class docstrings in CLI group help text instead of field descriptions. Defaults to `False`. + _cli_exit_on_error: Determines whether or not the internal parser exits with error info when an error occurs. + Defaults to `True`. _cli_prefix: The root parser command line arguments prefix. Defaults to "". _secrets_dir: The secret files directory. Defaults to `None`. """ @@ -132,6 +135,7 @@ def __init__( _cli_avoid_json: bool | None = None, _cli_enforce_required: bool | None = None, _cli_use_class_docs_for_groups: bool | None = None, + _cli_exit_on_error: bool | None = None, _cli_prefix: str | None = None, _secrets_dir: str | Path | None = None, **values: Any, @@ -156,6 +160,7 @@ def __init__( _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, _secrets_dir=_secrets_dir, ) @@ -204,6 +209,7 @@ def _settings_build_values( _cli_avoid_json: bool | None = None, _cli_enforce_required: bool | None = None, _cli_use_class_docs_for_groups: bool | None = None, + _cli_exit_on_error: bool | None = None, _cli_prefix: str | None = None, _secrets_dir: str | Path | None = None, ) -> dict[str, Any]: @@ -250,6 +256,9 @@ def _settings_build_values( if _cli_use_class_docs_for_groups is not None else self.model_config.get('cli_use_class_docs_for_groups') ) + cli_exit_on_error = ( + _cli_exit_on_error if _cli_exit_on_error is not None else self.model_config.get('cli_exit_on_error') + ) cli_prefix = _cli_prefix if _cli_prefix is not None else self.model_config.get('cli_prefix') secrets_dir = _secrets_dir if _secrets_dir is not None else self.model_config.get('secrets_dir') @@ -300,6 +309,7 @@ def _settings_build_values( 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, case_sensitive=case_sensitive, ) @@ -346,6 +356,7 @@ def _settings_build_values( cli_avoid_json=False, cli_enforce_required=False, cli_use_class_docs_for_groups=False, + cli_exit_on_error=True, cli_prefix='', json_file=None, json_file_encoding=None, diff --git a/pydantic_settings/sources.py b/pydantic_settings/sources.py index 192fcfe0..5f116096 100644 --- a/pydantic_settings/sources.py +++ b/pydantic_settings/sources.py @@ -21,7 +21,6 @@ Generic, List, Mapping, - NoReturn, Sequence, Tuple, TypeVar, @@ -107,8 +106,14 @@ class _CliPositionalArg: class _CliInternalArgParser(ArgumentParser): - def error(self, message: str) -> NoReturn: - raise SettingsError(f'error parsing CLI: {message}') + def __init__(self, cli_exit_on_error: bool = True, **kwargs) -> None: + super().__init__(**kwargs) + self._cli_exit_on_error = cli_exit_on_error + + def error(self, message: str) -> None: + if not self._cli_exit_on_error: + raise SettingsError(f'error parsing CLI: {message}') + super().error(message) T = TypeVar('T') @@ -878,6 +883,8 @@ class CliSettingsSource(EnvSettingsSource, Generic[T]): cli_enforce_required: Enforce required fields at the CLI. Defaults to `False`. cli_use_class_docs_for_groups: Use class docstrings in CLI group help text instead of field descriptions. Defaults to `False`. + cli_exit_on_error: Determines whether or not the internal parser exits with error info when an error occurs. + Defaults to `True`. cli_prefix: Prefix for command line arguments added under the root parser. Defaults to "". 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 @@ -904,6 +911,7 @@ def __init__( cli_avoid_json: bool | None = None, cli_enforce_required: bool | None = None, cli_use_class_docs_for_groups: bool | None = None, + cli_exit_on_error: bool | None = None, cli_prefix: str | None = None, case_sensitive: bool | None = True, root_parser: Any = None, @@ -938,6 +946,11 @@ def __init__( if cli_use_class_docs_for_groups is not None else settings_cls.model_config.get('cli_use_class_docs_for_groups', False) ) + self.cli_exit_on_error = ( + cli_exit_on_error + if cli_exit_on_error is not None + else settings_cls.model_config.get('cli_exit_on_error', True) + ) self.cli_prefix = cli_prefix if cli_prefix is not None else settings_cls.model_config.get('cli_prefix', '') if self.cli_prefix: if cli_prefix.startswith('.') or cli_prefix.endswith('.') or not cli_prefix.replace('.', '').isidentifier(): # type: ignore @@ -958,7 +971,9 @@ def __init__( ) root_parser = ( - _CliInternalArgParser(prog=self.cli_prog_name, description=settings_cls.__doc__) + _CliInternalArgParser( + cli_exit_on_error=self.cli_exit_on_error, prog=self.cli_prog_name, description=settings_cls.__doc__ + ) if root_parser is None else root_parser ) diff --git a/tests/test_settings.py b/tests/test_settings.py index 5e803ff2..da1fe89e 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -2347,8 +2347,8 @@ class BadCliPositionalArg(BaseSettings): BadCliPositionalArg(_cli_parse_args=True) -def test_cli_case_insensitve_arg(): - class Cfg(BaseSettings): +def test_cli_case_insensitive_arg(): + class Cfg(BaseSettings, cli_exit_on_error=False): Foo: str Bar: str @@ -3110,7 +3110,7 @@ class Settings(BaseSettings): def test_cli_enforce_required(env): - class Settings(BaseSettings): + class Settings(BaseSettings, cli_exit_on_error=False): my_required_field: str env.set('MY_REQUIRED_FIELD', 'hello from environment') @@ -3125,6 +3125,26 @@ class Settings(BaseSettings): Settings(_cli_parse_args=[], _cli_enforce_required=True).model_dump() +def test_cli_exit_on_error(capsys, monkeypatch): + class Settings(BaseSettings, cli_parse_args=True): + ... + + with monkeypatch.context() as m: + m.setattr(sys, 'argv', ['example.py', '--bad-arg']) + + with pytest.raises(SystemExit): + Settings() + assert ( + capsys.readouterr().err + == """usage: example.py [-h] +example.py: error: unrecognized arguments: --bad-arg +""" + ) + + with pytest.raises(SettingsError, match='error parsing CLI: unrecognized arguments: --bad-arg'): + Settings(_cli_exit_on_error=False) + + @pytest.mark.parametrize('parser_type', [pytest.Parser, argparse.ArgumentParser, CliDummyParser]) @pytest.mark.parametrize('prefix', ['', 'cfg']) def test_cli_user_settings_source(parser_type, prefix): From 42e4c16ed9b602974ce97748733d682f3f84ac17 Mon Sep 17 00:00:00 2001 From: Kyle Schwab Date: Wed, 10 Jul 2024 09:42:05 -0600 Subject: [PATCH 6/8] Lint. --- pydantic_settings/sources.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pydantic_settings/sources.py b/pydantic_settings/sources.py index 5f116096..dac402fe 100644 --- a/pydantic_settings/sources.py +++ b/pydantic_settings/sources.py @@ -21,6 +21,7 @@ Generic, List, Mapping, + NoReturn, Sequence, Tuple, TypeVar, @@ -106,11 +107,11 @@ class _CliPositionalArg: class _CliInternalArgParser(ArgumentParser): - def __init__(self, cli_exit_on_error: bool = True, **kwargs) -> None: + def __init__(self, cli_exit_on_error: bool = True, **kwargs: Any) -> None: super().__init__(**kwargs) self._cli_exit_on_error = cli_exit_on_error - def error(self, message: str) -> None: + def error(self, message: str) -> NoReturn: if not self._cli_exit_on_error: raise SettingsError(f'error parsing CLI: {message}') super().error(message) From fbdce00e342611b712ac88c4fca562d629aeb1fa Mon Sep 17 00:00:00 2001 From: Kyle Schwab Date: Wed, 10 Jul 2024 09:44:10 -0600 Subject: [PATCH 7/8] Lint. --- docs/index.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/index.md b/docs/index.md index e3760250..f5b73a3e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -873,8 +873,7 @@ import sys from pydantic_settings import BaseSettings, SettingsError -class Settings(BaseSettings, cli_parse_args=True, cli_exit_on_error=False): - ... +class Settings(BaseSettings, cli_parse_args=True, cli_exit_on_error=False): ... try: From 57e416cf7ccb19603056d0f3298201b08af7ff26 Mon Sep 17 00:00:00 2001 From: Kyle Schwab Date: Wed, 10 Jul 2024 09:45:29 -0600 Subject: [PATCH 8/8] More lint. --- tests/test_settings.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_settings.py b/tests/test_settings.py index da1fe89e..73d53362 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -3126,8 +3126,7 @@ class Settings(BaseSettings, cli_exit_on_error=False): def test_cli_exit_on_error(capsys, monkeypatch): - class Settings(BaseSettings, cli_parse_args=True): - ... + class Settings(BaseSettings, cli_parse_args=True): ... with monkeypatch.context() as m: m.setattr(sys, 'argv', ['example.py', '--bad-arg'])