Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package python-pydantic-settings for 
openSUSE:Factory checked in at 2024-09-13 14:26:07
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-pydantic-settings (Old)
 and      /work/SRC/openSUSE:Factory/.python-pydantic-settings.new.29891 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-pydantic-settings"

Fri Sep 13 14:26:07 2024 rev:4 rq:1200237 version:2.4.0

Changes:
--------
--- 
/work/SRC/openSUSE:Factory/python-pydantic-settings/python-pydantic-settings.changes
        2024-07-09 20:02:55.886368524 +0200
+++ 
/work/SRC/openSUSE:Factory/.python-pydantic-settings.new.29891/python-pydantic-settings.changes
     2024-09-13 14:26:32.804363950 +0200
@@ -1,0 +2,13 @@
+Thu Sep 12 05:57:59 UTC 2024 - Steve Kowalik <[email protected]>
+
+- Update to 2.4.0:
+  * Fix regex flags accidentally passed as count
+  * Deprecate read_env_file and move it to DotEnvSettingsSource
+  * Fix a bug when loading empty yaml file
+  * feat: Enable access to the current state in settings sources
+  * Add support for short options
+  * Add Azure Key Vault settings source
+  * Add cli_exit_on_error config option
+- Add in multibuild.
+
+-------------------------------------------------------------------

Old:
----
  pydantic_settings-2.3.4.tar.gz

New:
----
  _multibuild
  pydantic_settings-2.4.0.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ python-pydantic-settings.spec ++++++
--- /var/tmp/diff_new_pack.dM2xvk/_old  2024-09-13 14:26:33.504393152 +0200
+++ /var/tmp/diff_new_pack.dM2xvk/_new  2024-09-13 14:26:33.508393319 +0200
@@ -16,9 +16,17 @@
 #
 
 
+%global flavor @BUILD_FLAVOR@%{nil}
+%if "%{flavor}" == "test"
+%define psuffix -test
+%bcond_without test
+%else
+%define psuffix %{nil}
+%bcond_with test
+%endif
 %{?sle15_python_module_pythons}
-Name:           python-pydantic-settings
-Version:        2.3.4
+Name:           python-pydantic-settings%{psuffix}
+Version:        2.4.0
 Release:        0
 Summary:        Settings management using Pydantic
 License:        MIT
@@ -30,11 +38,13 @@
 BuildRequires:  %{python_module pip}
 BuildRequires:  python-rpm-macros
 # SECTION test requirements
-BuildRequires:  %{python_module pydantic >= 2.3.0}
+%if %{with test}
+BuildRequires:  %{python_module azure-identity >= 1.16}
+BuildRequires:  %{python_module pydantic-settings == %{version}}
 BuildRequires:  %{python_module pytest-examples}
 BuildRequires:  %{python_module pytest-mock}
 BuildRequires:  %{python_module pytest}
-BuildRequires:  %{python_module python-dotenv >= 0.21.0}
+%endif
 # /SECTION
 BuildRequires:  fdupes
 Requires:       python-pydantic >= 2.3.0
@@ -54,15 +64,21 @@
 %pyproject_wheel
 
 %install
+%if !%{with test}
 %pyproject_install
 %python_expand %fdupes %{buildroot}%{$python_sitelib}
+%endif
 
 %check
+%if %{with test}
 %pytest
+%endif
 
+%if !%{with test}
 %files %{python_files}
 %license LICENSE
 %doc README.md
 %{python_sitelib}/pydantic_settings
 %{python_sitelib}/pydantic_settings-%{version}.dist-info
+%endif
 

++++++ _multibuild ++++++
<multibuild>
  <package>test</package>
</multibuild>

++++++ pydantic_settings-2.3.4.tar.gz -> pydantic_settings-2.4.0.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/pydantic_settings-2.3.4/Makefile 
new/pydantic_settings-2.4.0/Makefile
--- old/pydantic_settings-2.3.4/Makefile        2020-02-02 01:00:00.000000000 
+0100
+++ new/pydantic_settings-2.4.0/Makefile        2020-02-02 01:00:00.000000000 
+0100
@@ -13,7 +13,7 @@
        find requirements/ -name '*.txt' ! -name 'all.txt' -type f -delete
        pip-compile -q --no-emit-index-url --resolver backtracking -o 
requirements/linting.txt requirements/linting.in
        pip-compile -q --no-emit-index-url --resolver backtracking -o 
requirements/testing.txt requirements/testing.in
-       pip-compile -q --no-emit-index-url --resolver backtracking --extra toml 
--extra yaml -o requirements/pyproject.txt pyproject.toml
+       pip-compile -q --no-emit-index-url --resolver backtracking --extra toml 
--extra yaml --extra azure-key-vault -o requirements/pyproject.txt 
pyproject.toml
        pip install --dry-run -r requirements/all.txt
 
 .PHONY: format
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/pydantic_settings-2.3.4/PKG-INFO 
new/pydantic_settings-2.4.0/PKG-INFO
--- old/pydantic_settings-2.3.4/PKG-INFO        2020-02-02 01:00:00.000000000 
+0100
+++ new/pydantic_settings-2.4.0/PKG-INFO        2020-02-02 01:00:00.000000000 
+0100
@@ -1,6 +1,6 @@
 Metadata-Version: 2.3
 Name: pydantic-settings
-Version: 2.3.4
+Version: 2.4.0
 Summary: Settings management using Pydantic
 Project-URL: Homepage, https://github.com/pydantic/pydantic-settings
 Project-URL: Funding, https://github.com/sponsors/samuelcolvin
@@ -34,6 +34,9 @@
 Requires-Python: >=3.8
 Requires-Dist: pydantic>=2.7.0
 Requires-Dist: python-dotenv>=0.21.0
+Provides-Extra: azure-key-vault
+Requires-Dist: azure-identity>=1.16.0; extra == 'azure-key-vault'
+Requires-Dist: azure-keyvault-secrets>=4.8.0; extra == 'azure-key-vault'
 Provides-Extra: toml
 Requires-Dist: tomli>=2.0.1; extra == 'toml'
 Provides-Extra: yaml
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/pydantic_settings-2.3.4/docs/index.md 
new/pydantic_settings-2.4.0/docs/index.md
--- old/pydantic_settings-2.3.4/docs/index.md   2020-02-02 01:00:00.000000000 
+0100
+++ new/pydantic_settings-2.4.0/docs/index.md   2020-02-02 01:00:00.000000000 
+0100
@@ -526,7 +526,7 @@
 ```
 
 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 provided the args 
to parse at time of instantiation:
+defined in `argparse`. Alternatively, we can also directly provide the args to 
parse at time of instantiation:
 
 ```py
 from pydantic_settings import BaseSettings
@@ -619,7 +619,7 @@
 
 These can be used in conjunction with list forms as well, e.g:
 
-  * `--field k1=1,k2=2 --field k3=3 --field '{"k4: 4}'` etc.
+  * `--field k1=1,k2=2 --field k3=3 --field '{"k4": 4}'` etc.
 
 ```py
 import sys
@@ -671,7 +671,7 @@
 
 #### Aliases
 
-Pydantic field aliases are added as CLI argument aliases.
+Pydantic field aliases are added as CLI argument aliases. Aliases of length 
one are converted into short options.
 
 ```py
 import sys
@@ -683,15 +683,21 @@
 
 class User(BaseSettings, cli_parse_args=True):
     first_name: str = Field(
-        validation_alias=AliasChoices('fname', AliasPath('name', 0))
+        validation_alias=AliasChoices('f', 'fname', AliasPath('name', 0))
+    )
+    last_name: str = Field(
+        validation_alias=AliasChoices('l', 'lname', AliasPath('name', 1))
     )
-    last_name: str = Field(validation_alias=AliasChoices('lname', 
AliasPath('name', 1)))
 
 
 sys.argv = ['example.py', '--fname', 'John', '--lname', 'Doe']
 print(User().model_dump())
 #> {'first_name': 'John', 'last_name': 'Doe'}
 
+sys.argv = ['example.py', '-f', 'John', '-l', 'Doe']
+print(User().model_dump())
+#> {'first_name': 'John', 'last_name': 'Doe'}
+
 sys.argv = ['example.py', '--name', 'John,Doe']
 print(User().model_dump())
 #> {'first_name': 'John', 'last_name': 'Doe'}
@@ -862,6 +868,28 @@
 """
 ```
 
+#### 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
@@ -878,10 +906,15 @@
 
 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):
+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')
 
 
@@ -890,13 +923,9 @@
 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
@@ -1167,6 +1196,65 @@
 docker service create --name pydantic-with-secrets --secret my_secret_data 
pydantic-app:latest
 ```
 
+## Azure Key Vault
+
+You must set two parameters:
+
+- `url`: For example, `https://my-resource.vault.azure.net/`.
+- `credential`: If you use `DefaultAzureCredential`, in local you can execute 
`az login` to get your identity credentials. The identity must have a role 
assignment (the recommended one is `Key Vault Secrets User`), so you can access 
the secrets.
+
+You must have the same naming convention in the field name as in the Key Vault 
secret name. For example, if the secret is named `SqlServerPassword`, the field 
name must be the same. You can use an alias too.
+
+In Key Vault, nested models are supported with the `--` separator. For 
example, `SqlServer--Password`.
+
+Key Vault arrays (e.g. `MySecret--0`, `MySecret--1`) are not supported.
+
+```py
+import os
+from typing import Tuple, Type
+
+from azure.identity import DefaultAzureCredential
+from pydantic import BaseModel
+
+from pydantic_settings import (
+    AzureKeyVaultSettingsSource,
+    BaseSettings,
+    PydanticBaseSettingsSource,
+)
+
+
+class SubModel(BaseModel):
+    a: str
+
+
+class AzureKeyVaultSettings(BaseSettings):
+    foo: str
+    bar: int
+    sub: SubModel
+
+    @classmethod
+    def settings_customise_sources(
+        cls,
+        settings_cls: Type[BaseSettings],
+        init_settings: PydanticBaseSettingsSource,
+        env_settings: PydanticBaseSettingsSource,
+        dotenv_settings: PydanticBaseSettingsSource,
+        file_secret_settings: PydanticBaseSettingsSource,
+    ) -> Tuple[PydanticBaseSettingsSource, ...]:
+        az_key_vault_settings = AzureKeyVaultSettingsSource(
+            settings_cls,
+            os.environ['AZURE_KEY_VAULT_URL'],
+            DefaultAzureCredential(),
+        )
+        return (
+            init_settings,
+            env_settings,
+            dotenv_settings,
+            file_secret_settings,
+            az_key_vault_settings,
+        )
+```
+
 ## Other settings source
 
 Other settings sources are available for common configuration files:
@@ -1486,6 +1574,36 @@
 #> foobar='test'
 ```
 
+#### Accesing the result of previous sources
+
+Each source of settings can access the output of the previous ones.
+
+```python
+from typing import Any, Dict, Tuple
+
+from pydantic.fields import FieldInfo
+
+from pydantic_settings import PydanticBaseSettingsSource
+
+
+class MyCustomSource(PydanticBaseSettingsSource):
+    def get_field_value(
+        self, field: FieldInfo, field_name: str
+    ) -> Tuple[Any, str, bool]: ...
+
+    def __call__(self) -> Dict[str, Any]:
+        # Retrieve the aggregated settings from previous sources
+        current_state = self.current_state
+        current_state.get('some_setting')
+
+        # Retrive settings from all sources individually
+        # self.settings_sources_data["SettingsSourceName"]: Dict[str, Any]
+        settings_sources_data = self.settings_sources_data
+        settings_sources_data['SomeSettingsSource'].get('some_setting')
+
+        # Your code here...
+```
+
 ### Removing sources
 
 You might also want to disable a source:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/pydantic_settings-2.3.4/pydantic_settings/__init__.py 
new/pydantic_settings-2.4.0/pydantic_settings/__init__.py
--- old/pydantic_settings-2.3.4/pydantic_settings/__init__.py   2020-02-02 
01:00:00.000000000 +0100
+++ new/pydantic_settings-2.4.0/pydantic_settings/__init__.py   2020-02-02 
01:00:00.000000000 +0100
@@ -1,5 +1,6 @@
 from .main import BaseSettings, SettingsConfigDict
 from .sources import (
+    AzureKeyVaultSettingsSource,
     CliPositionalArg,
     CliSettingsSource,
     CliSubCommand,
@@ -10,6 +11,7 @@
     PydanticBaseSettingsSource,
     PyprojectTomlConfigSettingsSource,
     SecretsSettingsSource,
+    SettingsError,
     TomlConfigSettingsSource,
     YamlConfigSettingsSource,
 )
@@ -28,8 +30,10 @@
     'PydanticBaseSettingsSource',
     'SecretsSettingsSource',
     'SettingsConfigDict',
+    'SettingsError',
     'TomlConfigSettingsSource',
     'YamlConfigSettingsSource',
+    'AzureKeyVaultSettingsSource',
     '__version__',
 )
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/pydantic_settings-2.3.4/pydantic_settings/main.py 
new/pydantic_settings-2.4.0/pydantic_settings/main.py
--- old/pydantic_settings-2.3.4/pydantic_settings/main.py       2020-02-02 
01:00:00.000000000 +0100
+++ new/pydantic_settings-2.4.0/pydantic_settings/main.py       2020-02-02 
01:00:00.000000000 +0100
@@ -38,6 +38,7 @@
     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 @@
         _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 @@
         _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 @@
                 _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 @@
         _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 @@
             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 @@
                         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,
                     )
@@ -308,7 +318,19 @@
                 )
                 sources = (cli_settings,) + sources
         if sources:
-            return deep_update(*reversed([source() for source in sources]))
+            state: dict[str, Any] = {}
+            states: dict[str, dict[str, Any]] = {}
+            for source in sources:
+                if isinstance(source, PydanticBaseSettingsSource):
+                    source._set_current_state(state)
+                    source._set_settings_sources_data(states)
+
+                source_name = source.__name__ if hasattr(source, '__name__') 
else type(source).__name__
+                source_state = source()
+
+                states[source_name] = source_state
+                state = deep_update(source_state, state)
+            return state
         else:
             # no one should mean to do this, but I think returning an empty 
dict is marginally preferable
             # to an informative error and much better than a confusing error
@@ -334,6 +356,7 @@
         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 -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/pydantic_settings-2.3.4/pydantic_settings/sources.py 
new/pydantic_settings-2.4.0/pydantic_settings/sources.py
--- old/pydantic_settings-2.3.4/pydantic_settings/sources.py    2020-02-02 
01:00:00.000000000 +0100
+++ new/pydantic_settings-2.4.0/pydantic_settings/sources.py    2020-02-02 
01:00:00.000000000 +0100
@@ -19,8 +19,11 @@
     Any,
     Callable,
     Generic,
+    Iterator,
     List,
     Mapping,
+    NoReturn,
+    Optional,
     Sequence,
     Tuple,
     TypeVar,
@@ -83,6 +86,21 @@
         import tomllib
 
 
+def import_azure_key_vault() -> None:
+    global TokenCredential
+    global SecretClient
+    global ResourceNotFoundError
+
+    try:
+        from azure.core.credentials import TokenCredential
+        from azure.core.exceptions import ResourceNotFoundError
+        from azure.keyvault.secrets import SecretClient
+    except ImportError as e:
+        raise ImportError(
+            'Azure Key Vault dependencies are not installed, run `pip install 
pydantic-settings[azure-key-vault]`'
+        ) from e
+
+
 DotenvType = Union[Path, str, List[Union[Path, str]], Tuple[Union[Path, str], 
...]]
 PathType = Union[Path, str, List[Union[Path, str]], Tuple[Union[Path, str], 
...]]
 DEFAULT_PATH: PathType = Path('')
@@ -93,6 +111,10 @@
 ENV_FILE_SENTINEL: DotenvType = Path('')
 
 
+class SettingsError(ValueError):
+    pass
+
+
 class _CliSubCommand:
     pass
 
@@ -102,7 +124,14 @@
 
 
 class _CliInternalArgParser(ArgumentParser):
-    pass
+    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) -> NoReturn:
+        if not self._cli_exit_on_error:
+            raise SettingsError(f'error parsing CLI: {message}')
+        super().error(message)
 
 
 T = TypeVar('T')
@@ -114,10 +143,6 @@
     pass
 
 
-class SettingsError(ValueError):
-    pass
-
-
 class PydanticBaseSettingsSource(ABC):
     """
     Abstract base class for settings sources, every settings source classes 
should inherit from it.
@@ -126,6 +151,36 @@
     def __init__(self, settings_cls: type[BaseSettings]):
         self.settings_cls = settings_cls
         self.config = settings_cls.model_config
+        self._current_state: dict[str, Any] = {}
+        self._settings_sources_data: dict[str, dict[str, Any]] = {}
+
+    def _set_current_state(self, state: dict[str, Any]) -> None:
+        """
+        Record the state of settings from the previous settings sources. This 
should
+        be called right before __call__.
+        """
+        self._current_state = state
+
+    def _set_settings_sources_data(self, states: dict[str, dict[str, Any]]) -> 
None:
+        """
+        Record the state of settings from all previous settings sources. This 
should
+        be called right before __call__.
+        """
+        self._settings_sources_data = states
+
+    @property
+    def current_state(self) -> dict[str, Any]:
+        """
+        The current state of the settings, populated by the previous settings 
sources.
+        """
+        return self._current_state
+
+    @property
+    def settings_sources_data(self) -> dict[str, dict[str, Any]]:
+        """
+        The state of all previous settings sources.
+        """
+        return self._settings_sources_data
 
     @abstractmethod
     def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, 
str, bool]:
@@ -753,6 +808,30 @@
     def _load_env_vars(self) -> Mapping[str, str | None]:
         return self._read_env_files()
 
+    @staticmethod
+    def _static_read_env_file(
+        file_path: Path,
+        *,
+        encoding: str | None = None,
+        case_sensitive: bool = False,
+        ignore_empty: bool = False,
+        parse_none_str: str | None = None,
+    ) -> Mapping[str, str | None]:
+        file_vars: dict[str, str | None] = dotenv_values(file_path, 
encoding=encoding or 'utf8')
+        return parse_env_vars(file_vars, case_sensitive, ignore_empty, 
parse_none_str)
+
+    def _read_env_file(
+        self,
+        file_path: Path,
+    ) -> Mapping[str, str | None]:
+        return self._static_read_env_file(
+            file_path,
+            encoding=self.env_file_encoding,
+            case_sensitive=self.case_sensitive,
+            ignore_empty=self.env_ignore_empty,
+            parse_none_str=self.env_parse_none_str,
+        )
+
     def _read_env_files(self) -> Mapping[str, str | None]:
         env_files = self.env_file
         if env_files is None:
@@ -765,15 +844,7 @@
         for env_file in env_files:
             env_path = Path(env_file).expanduser()
             if env_path.is_file():
-                dotenv_vars.update(
-                    read_env_file(
-                        env_path,
-                        encoding=self.env_file_encoding,
-                        case_sensitive=self.case_sensitive,
-                        ignore_empty=self.env_ignore_empty,
-                        parse_none_str=self.env_parse_none_str,
-                    )
-                )
+                dotenv_vars.update(self._read_env_file(env_path))
 
         return dotenv_vars
 
@@ -830,6 +901,8 @@
         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
@@ -856,6 +929,7 @@
         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,
@@ -890,6 +964,11 @@
             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
@@ -910,7 +989,9 @@
         )
 
         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
         )
@@ -1382,10 +1463,10 @@
                     if isinstance(group, dict):
                         group = self._add_argument_group(parser, **group)
                     added_args += list(arg_names)
-                    self._add_argument(group, *(f'{arg_flag}{name}' for name 
in arg_names), **kwargs)
+                    self._add_argument(group, 
*(f'{arg_flag[:len(name)]}{name}' for name in arg_names), **kwargs)
                 else:
                     added_args += list(arg_names)
-                    self._add_argument(parser, *(f'{arg_flag}{name}' for name 
in arg_names), **kwargs)
+                    self._add_argument(parser, 
*(f'{arg_flag[:len(name)]}{name}' for name in arg_names), **kwargs)
 
         self._add_parser_alias_paths(parser, alias_path_args, added_args, 
arg_prefix, subcommand_prefix, group)
         return parser
@@ -1676,7 +1757,71 @@
     def _read_file(self, file_path: Path) -> dict[str, Any]:
         import_yaml()
         with open(file_path, encoding=self.yaml_file_encoding) as yaml_file:
-            return yaml.safe_load(yaml_file)
+            return yaml.safe_load(yaml_file) or {}
+
+
+class AzureKeyVaultMapping(Mapping[str, Optional[str]]):
+    _loaded_secrets: dict[str, str | None]
+    _secret_client: SecretClient  # type: ignore
+    _secret_names: list[str]
+
+    def __init__(
+        self,
+        secret_client: SecretClient,  # type: ignore
+    ) -> None:
+        self._loaded_secrets = {}
+        self._secret_client = secret_client
+        self._secret_names: list[str] = [secret.name for secret in 
self._secret_client.list_properties_of_secrets()]
+
+    def __getitem__(self, key: str) -> str | None:
+        if key not in self._loaded_secrets:
+            try:
+                self._loaded_secrets[key] = 
self._secret_client.get_secret(key).value
+            except ResourceNotFoundError:  # type: ignore
+                raise KeyError(key)
+
+        return self._loaded_secrets[key]
+
+    def __len__(self) -> int:
+        return len(self._secret_names)
+
+    def __iter__(self) -> Iterator[str]:
+        return iter(self._secret_names)
+
+
+class AzureKeyVaultSettingsSource(EnvSettingsSource):
+    _url: str
+    _credential: TokenCredential  # type: ignore
+    _secret_client: SecretClient  # type: ignore
+
+    def __init__(
+        self,
+        settings_cls: type[BaseSettings],
+        url: str,
+        credential: TokenCredential,  # type: ignore
+        env_prefix: str | None = None,
+        env_parse_none_str: str | None = None,
+        env_parse_enums: bool | None = None,
+    ) -> None:
+        import_azure_key_vault()
+        self._url = url
+        self._credential = credential
+        super().__init__(
+            settings_cls,
+            case_sensitive=True,
+            env_prefix=env_prefix,
+            env_nested_delimiter='--',
+            env_ignore_empty=False,
+            env_parse_none_str=env_parse_none_str,
+            env_parse_enums=env_parse_enums,
+        )
+
+    def _load_env_vars(self) -> Mapping[str, Optional[str]]:
+        secret_client = SecretClient(vault_url=self._url, 
credential=self._credential)  # type: ignore
+        return AzureKeyVaultMapping(secret_client)
+
+    def __repr__(self) -> str:
+        return f'AzureKeyVaultSettingsSource(url={self._url!r}, ' 
f'env_nested_delimiter={self.env_nested_delimiter!r})'
 
 
 def _get_env_var_key(key: str, case_sensitive: bool = False) -> str:
@@ -1708,8 +1853,17 @@
     ignore_empty: bool = False,
     parse_none_str: str | None = None,
 ) -> Mapping[str, str | None]:
-    file_vars: dict[str, str | None] = dotenv_values(file_path, 
encoding=encoding or 'utf8')
-    return parse_env_vars(file_vars, case_sensitive, ignore_empty, 
parse_none_str)
+    warnings.warn(
+        'read_env_file will be removed in the next version, use 
DotEnvSettingsSource._static_read_env_file if you must',
+        DeprecationWarning,
+    )
+    return DotEnvSettingsSource._static_read_env_file(
+        file_path,
+        encoding=encoding,
+        case_sensitive=case_sensitive,
+        ignore_empty=ignore_empty,
+        parse_none_str=parse_none_str,
+    )
 
 
 def _annotation_is_complex(annotation: type[Any] | None, metadata: list[Any]) 
-> bool:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/pydantic_settings-2.3.4/pydantic_settings/version.py 
new/pydantic_settings-2.4.0/pydantic_settings/version.py
--- old/pydantic_settings-2.3.4/pydantic_settings/version.py    2020-02-02 
01:00:00.000000000 +0100
+++ new/pydantic_settings-2.4.0/pydantic_settings/version.py    2020-02-02 
01:00:00.000000000 +0100
@@ -1 +1 @@
-VERSION = '2.3.4'
+VERSION = '2.4.0'
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/pydantic_settings-2.3.4/pyproject.toml 
new/pydantic_settings-2.4.0/pyproject.toml
--- old/pydantic_settings-2.3.4/pyproject.toml  2020-02-02 01:00:00.000000000 
+0100
+++ new/pydantic_settings-2.4.0/pyproject.toml  2020-02-02 01:00:00.000000000 
+0100
@@ -48,6 +48,7 @@
 [project.optional-dependencies]
 yaml = ["pyyaml>=6.0.1"]
 toml = ["tomli>=2.0.1"]
+azure-key-vault = ["azure-keyvault-secrets>=4.8.0", "azure-identity>=1.16.0"]
 
 [project.urls]
 Homepage = 'https://github.com/pydantic/pydantic-settings'
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/pydantic_settings-2.3.4/requirements/linting.txt 
new/pydantic_settings-2.4.0/requirements/linting.txt
--- old/pydantic_settings-2.3.4/requirements/linting.txt        2020-02-02 
01:00:00.000000000 +0100
+++ new/pydantic_settings-2.4.0/requirements/linting.txt        2020-02-02 
01:00:00.000000000 +0100
@@ -4,7 +4,7 @@
 #
 #    pip-compile --no-emit-index-url --output-file=requirements/linting.txt 
requirements/linting.in
 #
-black==24.4.0
+black==24.4.2
     # via -r requirements/linting.in
 cfgv==3.4.0
     # via pre-commit
@@ -12,35 +12,35 @@
     # via black
 distlib==0.3.8
     # via virtualenv
-filelock==3.13.4
+filelock==3.15.3
     # via virtualenv
 identify==2.5.36
     # via pre-commit
-mypy==1.9.0
+mypy==1.10.0
     # via -r requirements/linting.in
 mypy-extensions==1.0.0
     # via
     #   black
     #   mypy
-nodeenv==1.8.0
+nodeenv==1.9.1
     # via pre-commit
-packaging==24.0
+packaging==24.1
     # via black
 pathspec==0.12.1
     # via black
-platformdirs==4.2.0
+platformdirs==4.2.2
     # via
     #   black
     #   virtualenv
 pre-commit==3.5.0
     # via -r requirements/linting.in
-pyupgrade==3.15.2
+pyupgrade==3.16.0
     # via -r requirements/linting.in
 pyyaml==6.0.1
     # via
     #   -r requirements/linting.in
     #   pre-commit
-ruff==0.4.1
+ruff==0.4.10
     # via -r requirements/linting.in
 tokenize-rt==5.2.0
     # via pyupgrade
@@ -50,12 +50,9 @@
     #   mypy
 types-pyyaml==6.0.12.20240311
     # via -r requirements/linting.in
-typing-extensions==4.11.0
+typing-extensions==4.12.2
     # via
     #   black
     #   mypy
-virtualenv==20.25.3
+virtualenv==20.26.2
     # via pre-commit
-
-# The following packages are considered to be unsafe in a requirements file:
-# setuptools
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/pydantic_settings-2.3.4/requirements/pyproject.txt 
new/pydantic_settings-2.4.0/requirements/pyproject.txt
--- old/pydantic_settings-2.3.4/requirements/pyproject.txt      2020-02-02 
01:00:00.000000000 +0100
+++ new/pydantic_settings-2.4.0/requirements/pyproject.txt      2020-02-02 
01:00:00.000000000 +0100
@@ -2,22 +2,74 @@
 # This file is autogenerated by pip-compile with Python 3.8
 # by the following command:
 #
-#    pip-compile --extra=toml --extra=yaml --no-emit-index-url 
--output-file=requirements/pyproject.txt pyproject.toml
+#    pip-compile --extra=azure-key-vault --extra=toml --extra=yaml 
--no-emit-index-url --output-file=requirements/pyproject.txt pyproject.toml
 #
-annotated-types==0.6.0
+annotated-types==0.7.0
     # via pydantic
-pydantic==2.7.0
+azure-core==1.30.2
+    # via
+    #   azure-identity
+    #   azure-keyvault-secrets
+azure-identity==1.17.0
+    # via pydantic-settings (pyproject.toml)
+azure-keyvault-secrets==4.8.0
+    # via pydantic-settings (pyproject.toml)
+certifi==2024.6.2
+    # via requests
+cffi==1.16.0
+    # via cryptography
+charset-normalizer==3.3.2
+    # via requests
+cryptography==42.0.8
+    # via
+    #   azure-identity
+    #   msal
+    #   pyjwt
+idna==3.7
+    # via requests
+isodate==0.6.1
+    # via azure-keyvault-secrets
+msal==1.28.1
+    # via
+    #   azure-identity
+    #   msal-extensions
+msal-extensions==1.1.0
+    # via azure-identity
+packaging==24.1
+    # via msal-extensions
+portalocker==2.8.2
+    # via msal-extensions
+pycparser==2.22
+    # via cffi
+pydantic==2.7.4
     # via pydantic-settings (pyproject.toml)
-pydantic-core==2.18.1
+pydantic-core==2.18.4
     # via pydantic
+pyjwt[crypto]==2.8.0
+    # via
+    #   msal
+    #   pyjwt
 python-dotenv==1.0.1
     # via pydantic-settings (pyproject.toml)
 pyyaml==6.0.1
     # via pydantic-settings (pyproject.toml)
+requests==2.32.3
+    # via
+    #   azure-core
+    #   msal
+six==1.16.0
+    # via
+    #   azure-core
+    #   isodate
 tomli==2.0.1
     # via pydantic-settings (pyproject.toml)
-typing-extensions==4.11.0
+typing-extensions==4.12.2
     # via
     #   annotated-types
+    #   azure-core
+    #   azure-identity
+    #   azure-keyvault-secrets
     #   pydantic
     #   pydantic-core
+urllib3==2.2.2
+    # via requests
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/pydantic_settings-2.3.4/requirements/testing.txt 
new/pydantic_settings-2.4.0/requirements/testing.txt
--- old/pydantic_settings-2.3.4/requirements/testing.txt        2020-02-02 
01:00:00.000000000 +0100
+++ new/pydantic_settings-2.4.0/requirements/testing.txt        2020-02-02 
01:00:00.000000000 +0100
@@ -4,11 +4,11 @@
 #
 #    pip-compile --no-emit-index-url --output-file=requirements/testing.txt 
requirements/testing.in
 #
-black==24.4.0
+black==24.4.2
     # via pytest-examples
 click==8.1.7
     # via black
-coverage[toml]==7.4.4
+coverage[toml]==7.5.3
     # via -r requirements/testing.in
 exceptiongroup==1.2.1
     # via pytest
@@ -20,19 +20,19 @@
     # via markdown-it-py
 mypy-extensions==1.0.0
     # via black
-packaging==24.0
+packaging==24.1
     # via
     #   black
     #   pytest
 pathspec==0.12.1
     # via black
-platformdirs==4.2.0
+platformdirs==4.2.2
     # via black
 pluggy==1.5.0
     # via pytest
-pygments==2.17.2
+pygments==2.18.0
     # via rich
-pytest==8.1.1
+pytest==8.2.2
     # via
     #   -r requirements/testing.in
     #   pytest-examples
@@ -46,14 +46,14 @@
     # via -r requirements/testing.in
 rich==13.7.1
     # via pytest-pretty
-ruff==0.4.1
+ruff==0.4.10
     # via pytest-examples
 tomli==2.0.1
     # via
     #   black
     #   coverage
     #   pytest
-typing-extensions==4.11.0
+typing-extensions==4.12.2
     # via
     #   black
     #   rich
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/pydantic_settings-2.3.4/tests/test_settings.py 
new/pydantic_settings-2.4.0/tests/test_settings.py
--- old/pydantic_settings-2.3.4/tests/test_settings.py  2020-02-02 
01:00:00.000000000 +0100
+++ new/pydantic_settings-2.4.0/tests/test_settings.py  2020-02-02 
01:00:00.000000000 +0100
@@ -34,7 +34,7 @@
 from pydantic._internal._repr import Representation
 from pydantic.fields import FieldInfo
 from pytest_mock import MockerFixture
-from typing_extensions import Annotated, Literal
+from typing_extensions import Annotated, Literal, override
 
 from pydantic_settings import (
     BaseSettings,
@@ -49,7 +49,7 @@
     TomlConfigSettingsSource,
     YamlConfigSettingsSource,
 )
-from pydantic_settings.sources import CliPositionalArg, CliSettingsSource, 
CliSubCommand, SettingsError, read_env_file
+from pydantic_settings.sources import CliPositionalArg, CliSettingsSource, 
CliSubCommand, SettingsError
 
 try:
     import dotenv
@@ -1039,15 +1039,15 @@
     p = tmp_path / '.env'
     p.write_text('a="test"\nB=123')
 
-    assert read_env_file(p) == {'a': 'test', 'b': '123'}
-    assert read_env_file(p, case_sensitive=True) == {'a': 'test', 'B': '123'}
+    assert DotEnvSettingsSource._static_read_env_file(p) == {'a': 'test', 'b': 
'123'}
+    assert DotEnvSettingsSource._static_read_env_file(p, case_sensitive=True) 
== {'a': 'test', 'B': '123'}
 
 
 def test_read_env_file_syntax_wrong(tmp_path):
     p = tmp_path / '.env'
     p.write_text('NOT_AN_ASSIGNMENT')
 
-    assert read_env_file(p, case_sensitive=True) == {'NOT_AN_ASSIGNMENT': None}
+    assert DotEnvSettingsSource._static_read_env_file(p, case_sensitive=True) 
== {'NOT_AN_ASSIGNMENT': None}
 
 
 def test_env_file_example(tmp_path):
@@ -1188,6 +1188,37 @@
     )
 
 
+def test_dotenvsource_override(env):
+    class StdinDotEnvSettingsSource(DotEnvSettingsSource):
+        @override
+        def _read_env_file(self, file_path: Path) -> Dict[str, str]:
+            assert str(file_path) == '-'
+            return {'foo': 'stdin_foo', 'bar': 'stdin_bar'}
+
+        @override
+        def _read_env_files(self) -> Dict[str, str]:
+            return self._read_env_file(Path('-'))
+
+    source = StdinDotEnvSettingsSource(BaseSettings())
+    assert source._read_env_files() == {'foo': 'stdin_foo', 'bar': 'stdin_bar'}
+
+
+# test that calling read_env_file issues a DeprecationWarning
+# TODO: remove this test once read_env_file is removed
+def test_read_env_file_deprecation(tmp_path):
+    from pydantic_settings.sources import read_env_file
+
+    base_env = tmp_path / '.env'
+    base_env.write_text(test_default_env_file)
+
+    with pytest.deprecated_call():
+        assert read_env_file(base_env) == {
+            'debug_mode': 'true',
+            'host': 'localhost',
+            'port': '8000',
+        }
+
+
 @pytest.mark.skipif(yaml, reason='PyYAML is installed')
 def test_yaml_not_installed(tmp_path):
     p = tmp_path / '.env'
@@ -2239,9 +2270,9 @@
 
     cfg = Cfg(
         _cli_parse_args=[
-            '--a',
+            '-a',
             'a',
-            '--b',
+            '-b',
             'b',
             '--str',
             'str',
@@ -2298,7 +2329,7 @@
 
 
 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
@@ -2307,19 +2338,17 @@
             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():
-    class Cfg(BaseSettings):
+def test_cli_case_insensitive_arg():
+    class Cfg(BaseSettings, cli_exit_on_error=False):
         Foo: str
         Bar: str
 
@@ -2329,12 +2358,11 @@
     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, 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) 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):
@@ -2350,7 +2378,7 @@
             Cfg(_cli_parse_args=True)
 
         assert (
-            re.sub(r'0x\w+', '0xffffffff', capsys.readouterr().out, 
re.MULTILINE)
+            re.sub(r'0x\w+', '0xffffffff', capsys.readouterr().out, 
flags=re.MULTILINE)
             == f"""usage: example.py [-h] [--foo str] [--bar int] [--boo int]
 
 {ARGPARSE_OPTIONS_TEXT}:
@@ -2377,7 +2405,7 @@
             Cfg(_cli_parse_args=True)
 
         assert (
-            re.sub(r'0x\w+', '0xffffffff', capsys.readouterr().out, 
re.MULTILINE)
+            re.sub(r'0x\w+', '0xffffffff', capsys.readouterr().out, 
flags=re.MULTILINE)
             == f"""usage: example.py [-h] [--date_str str]
 
 {argparse_options_text}:
@@ -2570,13 +2598,11 @@
         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():
@@ -2709,18 +2735,16 @@
     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():
@@ -2846,63 +2870,66 @@
     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 'str'>")
+    ):
 
         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 <class 'str'>"
 
 
 def test_cli_avoid_json(capsys, monkeypatch):
@@ -3083,7 +3110,7 @@
 
 
 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')
@@ -3092,10 +3119,31 @@
         '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()
 
 
+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):
@@ -3226,24 +3274,20 @@
     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
@@ -3252,12 +3296,11 @@
         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(
@@ -3454,6 +3497,29 @@
     assert s.model_dump() == {}
 
 
[email protected](yaml is None, reason='pyYaml is not installed')
+def test_yaml_empty_file(tmp_path):
+    p = tmp_path / '.env'
+    p.write_text('')
+
+    class Settings(BaseSettings):
+        model_config = SettingsConfigDict(yaml_file=p)
+
+        @classmethod
+        def settings_customise_sources(
+            cls,
+            settings_cls: Type[BaseSettings],
+            init_settings: PydanticBaseSettingsSource,
+            env_settings: PydanticBaseSettingsSource,
+            dotenv_settings: PydanticBaseSettingsSource,
+            file_secret_settings: PydanticBaseSettingsSource,
+        ) -> Tuple[PydanticBaseSettingsSource, ...]:
+            return (YamlConfigSettingsSource(settings_cls),)
+
+    s = Settings()
+    assert s.model_dump() == {}
+
+
 @pytest.mark.skipif(sys.version_info <= (3, 11) and tomli is None, 
reason='tomli/tomllib is not installed')
 def test_toml_file(tmp_path):
     p = tmp_path / '.env'
@@ -3971,3 +4037,76 @@
     env.set('nested__FOO', '["string1", "string2"]')
     s = Settings()
     assert s.model_dump() == {'nested': {'FOO': ['string1', 'string2']}}
+
+
+def test_settings_source_current_state(env):
+    class SettingsSource(PydanticBaseSettingsSource):
+        def get_field_value(self, field: FieldInfo, field_name: str) -> Any:
+            pass
+
+        def __call__(self) -> Dict[str, Any]:
+            current_state = self.current_state
+            if current_state.get('one') == '1':
+                return {'two': '1'}
+
+            return {}
+
+    class Settings(BaseSettings):
+        one: bool = False
+        two: bool = False
+
+        @classmethod
+        def settings_customise_sources(
+            cls,
+            settings_cls: Type[BaseSettings],
+            init_settings: PydanticBaseSettingsSource,
+            env_settings: PydanticBaseSettingsSource,
+            dotenv_settings: PydanticBaseSettingsSource,
+            file_secret_settings: PydanticBaseSettingsSource,
+        ) -> Tuple[PydanticBaseSettingsSource, ...]:
+            return (env_settings, SettingsSource(settings_cls))
+
+    env.set('one', '1')
+    s = Settings()
+    assert s.two is True
+
+
+def test_settings_source_settings_sources_data(env):
+    class SettingsSource(PydanticBaseSettingsSource):
+        def get_field_value(self, field: FieldInfo, field_name: str) -> Any:
+            pass
+
+        def __call__(self) -> Dict[str, Any]:
+            settings_sources_data = self.settings_sources_data
+            if settings_sources_data == {
+                'InitSettingsSource': {'one': True, 'two': True},
+                'EnvSettingsSource': {'one': '1'},
+                'function_settings_source': {'three': 'false'},
+            }:
+                return {'four': '1'}
+
+            return {}
+
+    def function_settings_source():
+        return {'three': 'false'}
+
+    class Settings(BaseSettings):
+        one: bool = False
+        two: bool = False
+        three: bool = False
+        four: bool = False
+
+        @classmethod
+        def settings_customise_sources(
+            cls,
+            settings_cls: Type[BaseSettings],
+            init_settings: PydanticBaseSettingsSource,
+            env_settings: PydanticBaseSettingsSource,
+            dotenv_settings: PydanticBaseSettingsSource,
+            file_secret_settings: PydanticBaseSettingsSource,
+        ) -> Tuple[PydanticBaseSettingsSource, ...]:
+            return (env_settings, init_settings, function_settings_source, 
SettingsSource(settings_cls))
+
+    env.set('one', '1')
+    s = Settings(one=True, two=True)
+    assert s.four is True
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/pydantic_settings-2.3.4/tests/test_sources.py 
new/pydantic_settings-2.4.0/tests/test_sources.py
--- old/pydantic_settings-2.3.4/tests/test_sources.py   2020-02-02 
01:00:00.000000000 +0100
+++ new/pydantic_settings-2.4.0/tests/test_sources.py   2020-02-02 
01:00:00.000000000 +0100
@@ -6,15 +6,31 @@
 from typing import TYPE_CHECKING
 
 import pytest
+from pydantic import BaseModel, Field
 
 from pydantic_settings.main import BaseSettings, SettingsConfigDict
-from pydantic_settings.sources import PyprojectTomlConfigSettingsSource
+from pydantic_settings.sources import (
+    AzureKeyVaultSettingsSource,
+    PydanticBaseSettingsSource,
+    PyprojectTomlConfigSettingsSource,
+    import_azure_key_vault,
+)
 
 try:
     import tomli
 except ImportError:
     tomli = None
 
+
+try:
+    azure_key_vault = True
+    import_azure_key_vault()
+    from azure.core.exceptions import ResourceNotFoundError
+    from azure.identity import DefaultAzureCredential
+    from azure.keyvault.secrets import KeyVaultSecret, SecretProperties
+except ImportError:
+    azure_key_vault = False
+
 if TYPE_CHECKING:
     from pathlib import Path
 
@@ -97,3 +113,100 @@
         assert obj.toml_table_header == ('some', 'table')
         assert obj.toml_data == {'field': 'some'}
         assert obj.toml_file_path == tmp_path / 'pyproject.toml'
+
+
[email protected](not azure_key_vault, 
reason='pydantic-settings[azure-key-vault] is not installed')
+class TestAzureKeyVaultSettingsSource:
+    """Test AzureKeyVaultSettingsSource."""
+
+    def test___init__(self, mocker: MockerFixture) -> None:
+        """Test __init__."""
+
+        class AzureKeyVaultSettings(BaseSettings):
+            """AzureKeyVault settings."""
+
+        mocker.patch(f'{MODULE}.SecretClient.list_properties_of_secrets', 
return_value=[])
+
+        AzureKeyVaultSettingsSource(
+            AzureKeyVaultSettings, 'https://my-resource.vault.azure.net/', 
DefaultAzureCredential()
+        )
+
+    def test___call__(self, mocker: MockerFixture) -> None:
+        """Test __call__."""
+
+        class SqlServer(BaseModel):
+            password: str = Field(..., alias='Password')
+
+        class AzureKeyVaultSettings(BaseSettings):
+            """AzureKeyVault settings."""
+
+            SqlServerUser: str
+            sql_server_user: str = Field(..., alias='SqlServerUser')
+            sql_server: SqlServer = Field(..., alias='SqlServer')
+
+        expected_secrets = [type('', (), {'name': 'SqlServerUser'}), type('', 
(), {'name': 'SqlServer--Password'})]
+        expected_secret_value = 'SecretValue'
+        mocker.patch(f'{MODULE}.SecretClient.list_properties_of_secrets', 
return_value=expected_secrets)
+        mocker.patch(
+            f'{MODULE}.SecretClient.get_secret',
+            
side_effect=self._raise_resource_not_found_when_getting_parent_secret_name,
+        )
+        obj = AzureKeyVaultSettingsSource(
+            AzureKeyVaultSettings, 'https://my-resource.vault.azure.net/', 
DefaultAzureCredential()
+        )
+
+        settings = obj()
+
+        assert settings['SqlServerUser'] == expected_secret_value
+        assert settings['SqlServer']['Password'] == expected_secret_value
+
+    def test_azure_key_vault_settings_source(self, mocker: MockerFixture) -> 
None:
+        """Test AzureKeyVaultSettingsSource."""
+
+        class SqlServer(BaseModel):
+            password: str = Field(..., alias='Password')
+
+        class AzureKeyVaultSettings(BaseSettings):
+            """AzureKeyVault settings."""
+
+            SqlServerUser: str
+            sql_server_user: str = Field(..., alias='SqlServerUser')
+            sql_server: SqlServer = Field(..., alias='SqlServer')
+
+            @classmethod
+            def settings_customise_sources(
+                cls,
+                settings_cls: type[BaseSettings],
+                init_settings: PydanticBaseSettingsSource,
+                env_settings: PydanticBaseSettingsSource,
+                dotenv_settings: PydanticBaseSettingsSource,
+                file_secret_settings: PydanticBaseSettingsSource,
+            ) -> tuple[PydanticBaseSettingsSource, ...]:
+                return (
+                    AzureKeyVaultSettingsSource(
+                        settings_cls, 'https://my-resource.vault.azure.net/', 
DefaultAzureCredential()
+                    ),
+                )
+
+        expected_secrets = [type('', (), {'name': 'SqlServerUser'}), type('', 
(), {'name': 'SqlServer--Password'})]
+        expected_secret_value = 'SecretValue'
+        mocker.patch(f'{MODULE}.SecretClient.list_properties_of_secrets', 
return_value=expected_secrets)
+        mocker.patch(
+            f'{MODULE}.SecretClient.get_secret',
+            
side_effect=self._raise_resource_not_found_when_getting_parent_secret_name,
+        )
+
+        settings = AzureKeyVaultSettings()  # type: ignore
+
+        assert settings.SqlServerUser == expected_secret_value
+        assert settings.sql_server_user == expected_secret_value
+        assert settings.sql_server.password == expected_secret_value
+
+    def _raise_resource_not_found_when_getting_parent_secret_name(self, 
secret_name: str) -> KeyVaultSecret:
+        expected_secret_value = 'SecretValue'
+        key_vault_secret = KeyVaultSecret(SecretProperties(), 
expected_secret_value)
+
+        if secret_name == 'SqlServer':
+            raise ResourceNotFoundError()
+
+        return key_vault_secret

Reply via email to