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