From 8d3d0f26b3e6ea5b4b2d8e29c4069cfce00de92f Mon Sep 17 00:00:00 2001 From: Sami Tahri Date: Sat, 20 Jan 2024 05:49:38 +0100 Subject: [PATCH 01/18] feat: adding json and yaml sources --- pydantic_settings/__init__.py | 4 ++ pydantic_settings/sources.py | 83 +++++++++++++++++++++++++++++++++++ pyproject.toml | 3 ++ requirements/linting.in | 1 + requirements/linting.txt | 2 + tests/test_settings.py | 66 ++++++++++++++++++++++++++++ 6 files changed, 159 insertions(+) diff --git a/pydantic_settings/__init__.py b/pydantic_settings/__init__.py index 7b99f885..808ee0c5 100644 --- a/pydantic_settings/__init__.py +++ b/pydantic_settings/__init__.py @@ -3,8 +3,10 @@ DotEnvSettingsSource, EnvSettingsSource, InitSettingsSource, + JsonConfigSettingsSource, PydanticBaseSettingsSource, SecretsSettingsSource, + YamlConfigSettingsSource, ) from .version import VERSION @@ -13,9 +15,11 @@ 'DotEnvSettingsSource', 'EnvSettingsSource', 'InitSettingsSource', + 'JsonConfigSettingsSource', 'PydanticBaseSettingsSource', 'SecretsSettingsSource', 'SettingsConfigDict', + 'YamlConfigSettingsSource', '__version__', ) diff --git a/pydantic_settings/sources.py b/pydantic_settings/sources.py index 335ba9dd..34e38871 100644 --- a/pydantic_settings/sources.py +++ b/pydantic_settings/sources.py @@ -19,7 +19,21 @@ from pydantic_settings.utils import path_type_label if TYPE_CHECKING: + import yaml + from pydantic_settings.main import BaseSettings +else: + yaml = None + + +def import_yaml() -> None: + global yaml + if yaml is not None: + return + try: + import yaml + except ImportError as e: + raise ImportError('yaml is not installed, run `pip install pydantic-settings[yaml]`') from e DotenvType = Union[Path, str, List[Union[Path, str]], Tuple[Union[Path, str], ...]] @@ -664,6 +678,75 @@ def __repr__(self) -> str: ) +class MappingConfigSource(PydanticBaseSettingsSource): + def __init__(self, settings_cls: type[BaseSettings]): + super().__init__(settings_cls) + self.mapping_values = self._load_values() + + @abstractmethod + def _load_values(self) -> Mapping[str, str | None]: + pass + + def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]: + field_value = self.mapping_values.get(field_name) + return field_value, field_name, False + + def prepare_field_value(self, field_name: str, field: FieldInfo, value: Any, value_is_complex: bool) -> Any: + return value + + def __call__(self) -> dict[str, Any]: + d: dict[str, Any] = {} + + for field_name, field in self.settings_cls.model_fields.items(): + field_value, field_key, value_is_complex = self.get_field_value(field, field_name) + field_value = self.prepare_field_value(field_name, field, field_value, value_is_complex) + if field_value is not None: + d[field_key] = field_value + return d + + +class JsonConfigSettingsSource(MappingConfigSource): + """ + A source class that loads variables from a JSON file + """ + + def __init__(self, settings_cls: type[BaseSettings], json_file_path: str, json_file_encoding: str | None = None): + self.json_file_path = json_file_path + self.json_file_encoding = json_file_encoding + super().__init__(settings_cls) + + def _load_values(self) -> Mapping[str, str | None]: + return self._read_json() + + def _read_json(self) -> Mapping[str, str | None]: + with open(self.json_file_path, encoding=self.json_file_encoding) as json_file: + return json.load(json_file) + + +class YamlConfigSettingsSource(MappingConfigSource): + """ + A source class that loads variables from a yaml file + """ + + def __init__( + self, + settings_cls: type[BaseSettings], + yaml_file_path: str, + yaml_file_encoding: str | None = None, + ): + self.yaml_file_path = yaml_file_path + self.yaml_file_encoding = 'utf-8' if yaml_file_encoding is None else yaml_file_encoding + super().__init__(settings_cls) + + def _load_values(self) -> Mapping[str, str | None]: + return self._read_yaml() + + def _read_yaml(self) -> Mapping[str, str | None]: + with open(self.yaml_file_path, encoding=self.yaml_file_encoding) as yaml_file: + import_yaml() + return yaml.safe_load(yaml_file) + + def _get_env_var_key(key: str, case_sensitive: bool = False) -> str: return key if case_sensitive else key.lower() diff --git a/pyproject.toml b/pyproject.toml index c7a0c5ab..511f8bfb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,9 @@ dependencies = [ ] dynamic = ['version'] +[project.optional-dependencies] +yaml = ["pyyaml==6.0"] + [project.urls] Homepage = 'https://github.com/pydantic/pydantic-settings' Funding = 'https://github.com/sponsors/samuelcolvin' diff --git a/requirements/linting.in b/requirements/linting.in index 64c9833f..c158031a 100644 --- a/requirements/linting.in +++ b/requirements/linting.in @@ -2,4 +2,5 @@ black ruff pyupgrade mypy +types-PyYAML pre-commit diff --git a/requirements/linting.txt b/requirements/linting.txt index 152c7006..62ba6675 100644 --- a/requirements/linting.txt +++ b/requirements/linting.txt @@ -46,6 +46,8 @@ tomli==2.0.1 # via # black # mypy +types-pyyaml==6.0.12.12 + # via -r requirements/linting.in typing-extensions==4.6.2 # via mypy virtualenv==20.23.0 diff --git a/tests/test_settings.py b/tests/test_settings.py index b2371953..8ec5a330 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -30,9 +30,11 @@ DotEnvSettingsSource, EnvSettingsSource, InitSettingsSource, + JsonConfigSettingsSource, PydanticBaseSettingsSource, SecretsSettingsSource, SettingsConfigDict, + YamlConfigSettingsSource, ) from pydantic_settings.sources import SettingsError, read_env_file @@ -1915,3 +1917,67 @@ class Settings(BaseSettings): s = Settings() assert s.data == {'foo': 'bar'} + + +def test_json_file(tmp_path): + p = tmp_path / '.env' + p.write_text( + """ + {"foobar": "Hello", "nested": {"nested_field": "world!"}} + """ + ) + + class Nested(BaseModel): + nested_field: str + + class Settings(BaseSettings): + foobar: str + nested: Nested + + @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 (JsonConfigSettingsSource(settings_cls, p),) + + s = Settings() + assert s.foobar == 'Hello' + assert s.nested.nested_field == 'world!' + + +def test_yaml_file(tmp_path): + p = tmp_path / '.env' + p.write_text( + """ + foobar: "Hello" + nested: + nested_field: "world!" + """ + ) + + class Nested(BaseModel): + nested_field: str + + class Settings(BaseSettings): + foobar: str + nested: Nested + + @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, p),) + + s = Settings() + assert s.foobar == 'Hello' + assert s.nested.nested_field == 'world!' From ff1b693a8253fdeed6a707892566178a8fe26eff Mon Sep 17 00:00:00 2001 From: Sami Tahri Date: Sat, 20 Jan 2024 06:02:07 +0100 Subject: [PATCH 02/18] fix: requirement file --- requirements/pyproject.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/pyproject.txt b/requirements/pyproject.txt index 9b64021c..de460902 100644 --- a/requirements/pyproject.txt +++ b/requirements/pyproject.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.10 # by the following command: # # pip-compile --output-file=requirements/pyproject.txt pyproject.toml From 3c2df8971c5db1714de51ac9b4495501dbbe1729 Mon Sep 17 00:00:00 2001 From: Sami Tahri Date: Sat, 20 Jan 2024 06:08:47 +0100 Subject: [PATCH 03/18] fix: requirement file with extra --- requirements/pyproject.txt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/requirements/pyproject.txt b/requirements/pyproject.txt index de460902..da01f02f 100644 --- a/requirements/pyproject.txt +++ b/requirements/pyproject.txt @@ -2,7 +2,7 @@ # This file is autogenerated by pip-compile with Python 3.10 # by the following command: # -# pip-compile --output-file=requirements/pyproject.txt pyproject.toml +# pip-compile --extra=yaml --output-file=requirements/pyproject.txt pyproject.toml # annotated-types==0.4.0 # via pydantic @@ -12,6 +12,8 @@ pydantic-core==2.14.5 # via pydantic python-dotenv==0.21.1 # via pydantic-settings (pyproject.toml) +pyyaml==6.0 + # via pydantic-settings (pyproject.toml) typing-extensions==4.6.2 # via # pydantic From 586a0f9c2015c81f85b315933fdad556540286c3 Mon Sep 17 00:00:00 2001 From: Sami Tahri Date: Sat, 20 Jan 2024 06:17:55 +0100 Subject: [PATCH 04/18] fix: error --- pydantic_settings/sources.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pydantic_settings/sources.py b/pydantic_settings/sources.py index 34e38871..e6d481cd 100644 --- a/pydantic_settings/sources.py +++ b/pydantic_settings/sources.py @@ -33,7 +33,7 @@ def import_yaml() -> None: try: import yaml except ImportError as e: - raise ImportError('yaml is not installed, run `pip install pydantic-settings[yaml]`') from e + raise ImportError('pyyaml is not installed, run `pip install pydantic-settings[yaml]`') from e DotenvType = Union[Path, str, List[Union[Path, str]], Tuple[Union[Path, str], ...]] diff --git a/pyproject.toml b/pyproject.toml index 511f8bfb..879559ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dependencies = [ dynamic = ['version'] [project.optional-dependencies] -yaml = ["pyyaml==6.0"] +yaml = ["pyyaml>=6.0"] [project.urls] Homepage = 'https://github.com/pydantic/pydantic-settings' From e6e79ab742b207e587ba38ed3b832b8f3ddccfa5 Mon Sep 17 00:00:00 2001 From: Sami Tahri Date: Sat, 20 Jan 2024 16:45:33 +0100 Subject: [PATCH 05/18] feat: add toml --- pydantic_settings/__init__.py | 2 ++ pydantic_settings/sources.py | 43 +++++++++++++++++++++++++++++++++++ pyproject.toml | 1 + requirements/linting.in | 1 + requirements/pyproject.txt | 4 +++- tests/test_settings.py | 35 ++++++++++++++++++++++++++++ 6 files changed, 85 insertions(+), 1 deletion(-) diff --git a/pydantic_settings/__init__.py b/pydantic_settings/__init__.py index 808ee0c5..f3e4184c 100644 --- a/pydantic_settings/__init__.py +++ b/pydantic_settings/__init__.py @@ -6,6 +6,7 @@ JsonConfigSettingsSource, PydanticBaseSettingsSource, SecretsSettingsSource, + TomlConfigSettingsSource, YamlConfigSettingsSource, ) from .version import VERSION @@ -19,6 +20,7 @@ 'PydanticBaseSettingsSource', 'SecretsSettingsSource', 'SettingsConfigDict', + 'TomlConfigSettingsSource', 'YamlConfigSettingsSource', '__version__', ) diff --git a/pydantic_settings/sources.py b/pydantic_settings/sources.py index e6d481cd..4c9f5dcb 100644 --- a/pydantic_settings/sources.py +++ b/pydantic_settings/sources.py @@ -2,6 +2,7 @@ import json import os +import sys import warnings from abc import ABC, abstractmethod from collections import deque @@ -19,11 +20,16 @@ from pydantic_settings.utils import path_type_label if TYPE_CHECKING: + if sys.version_info.minor >= 11: + import tomllib # type: ignore + import tomlkit import yaml from pydantic_settings.main import BaseSettings else: yaml = None + tomllib = None + tomlkit = None def import_yaml() -> None: @@ -36,6 +42,22 @@ def import_yaml() -> None: raise ImportError('pyyaml is not installed, run `pip install pydantic-settings[yaml]`') from e +def import_toml() -> None: + global tomlkit + global tomllib + minor_version = sys.version_info.minor + if minor_version < 11: + if tomlkit is not None: + return + try: + import tomlkit + except ImportError as e: + raise ImportError('tomlkit is not installed, run `pip install pydantic-settings[toml]`') from e + else: + if tomllib is not None: + import tomllib + + DotenvType = Union[Path, str, List[Union[Path, str]], Tuple[Union[Path, str], ...]] # This is used as default value for `_env_file` in the `BaseSettings` class and @@ -723,6 +745,27 @@ def _read_json(self) -> Mapping[str, str | None]: return json.load(json_file) +class TomlConfigSettingsSource(MappingConfigSource): + """ + A source class that loads variables from a JSON file + """ + + def __init__(self, settings_cls: type[BaseSettings], toml_file_path: str, toml_file_encoding: str | None = None): + self.toml_file_path = toml_file_path + self.toml_file_encoding = toml_file_encoding + super().__init__(settings_cls) + + def _read_toml(self) -> Mapping[str, str | None]: + import_toml() + with open(self.toml_file_path, encoding=self.toml_file_encoding) as toml_file: + if sys.version_info.minor < 11: + return tomlkit.load(toml_file) + return tomllib.load(toml_file) + + def _load_values(self) -> Mapping[str, str | None]: + return self._read_toml() + + class YamlConfigSettingsSource(MappingConfigSource): """ A source class that loads variables from a yaml file diff --git a/pyproject.toml b/pyproject.toml index 879559ae..9c0f27e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ dynamic = ['version'] [project.optional-dependencies] yaml = ["pyyaml>=6.0"] +toml = ["tomlkit>=0.12"] [project.urls] Homepage = 'https://github.com/pydantic/pydantic-settings' diff --git a/requirements/linting.in b/requirements/linting.in index c158031a..d52b2fb0 100644 --- a/requirements/linting.in +++ b/requirements/linting.in @@ -3,4 +3,5 @@ ruff pyupgrade mypy types-PyYAML +tomllib pre-commit diff --git a/requirements/pyproject.txt b/requirements/pyproject.txt index da01f02f..f6f471cf 100644 --- a/requirements/pyproject.txt +++ b/requirements/pyproject.txt @@ -2,7 +2,7 @@ # This file is autogenerated by pip-compile with Python 3.10 # by the following command: # -# pip-compile --extra=yaml --output-file=requirements/pyproject.txt pyproject.toml +# pip-compile --extra=toml --extra=yaml --output-file=requirements/pyproject.txt pyproject.toml # annotated-types==0.4.0 # via pydantic @@ -14,6 +14,8 @@ python-dotenv==0.21.1 # via pydantic-settings (pyproject.toml) pyyaml==6.0 # via pydantic-settings (pyproject.toml) +tomlkit==0.12.3 + # via pydantic-settings (pyproject.toml) typing-extensions==4.6.2 # via # pydantic diff --git a/tests/test_settings.py b/tests/test_settings.py index 8ec5a330..4dbdc2aa 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -34,6 +34,7 @@ PydanticBaseSettingsSource, SecretsSettingsSource, SettingsConfigDict, + TomlConfigSettingsSource, YamlConfigSettingsSource, ) from pydantic_settings.sources import SettingsError, read_env_file @@ -1981,3 +1982,37 @@ def settings_customise_sources( s = Settings() assert s.foobar == 'Hello' assert s.nested.nested_field == 'world!' + + +def test_toml_file(tmp_path): + p = tmp_path / '.env' + p.write_text( + """ + foobar = "Hello" + + [nested] + nested_field = "world!" + """ + ) + + class Nested(BaseModel): + nested_field: str + + class Settings(BaseSettings): + foobar: str + nested: Nested + + @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 (TomlConfigSettingsSource(settings_cls, p),) + + s = Settings() + assert s.foobar == 'Hello' + assert s.nested.nested_field == 'world!' From 0e42bd25c361d29c8a58040c852225a2714036e7 Mon Sep 17 00:00:00 2001 From: Sami Tahri Date: Sat, 20 Jan 2024 17:14:15 +0100 Subject: [PATCH 06/18] fix: import --- pydantic_settings/sources.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pydantic_settings/sources.py b/pydantic_settings/sources.py index 4c9f5dcb..57ed109a 100644 --- a/pydantic_settings/sources.py +++ b/pydantic_settings/sources.py @@ -55,7 +55,8 @@ def import_toml() -> None: raise ImportError('tomlkit is not installed, run `pip install pydantic-settings[toml]`') from e else: if tomllib is not None: - import tomllib + return + import tomllib DotenvType = Union[Path, str, List[Union[Path, str]], Tuple[Union[Path, str], ...]] @@ -757,7 +758,7 @@ def __init__(self, settings_cls: type[BaseSettings], toml_file_path: str, toml_f def _read_toml(self) -> Mapping[str, str | None]: import_toml() - with open(self.toml_file_path, encoding=self.toml_file_encoding) as toml_file: + with open(self.toml_file_path, 'rb', encoding=self.toml_file_encoding) as toml_file: if sys.version_info.minor < 11: return tomlkit.load(toml_file) return tomllib.load(toml_file) From 9c9c558088caced08983e6b5eed1af269f1ba5d2 Mon Sep 17 00:00:00 2001 From: Sami Tahri Date: Sun, 21 Jan 2024 01:27:46 +0100 Subject: [PATCH 07/18] fix: apply kjithin recommandation --- pydantic_settings/sources.py | 122 ++++++++++++++++++++--------------- tests/test_settings.py | 9 ++- 2 files changed, 76 insertions(+), 55 deletions(-) diff --git a/pydantic_settings/sources.py b/pydantic_settings/sources.py index 57ed109a..2d514d8f 100644 --- a/pydantic_settings/sources.py +++ b/pydantic_settings/sources.py @@ -60,6 +60,8 @@ def import_toml() -> None: 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('') # This is used as default value for `_env_file` in the `BaseSettings` class and # `env_file` in `DotEnvSettingsSource` so the default can be distinguished from `None`. @@ -701,73 +703,84 @@ def __repr__(self) -> str: ) -class MappingConfigSource(PydanticBaseSettingsSource): - def __init__(self, settings_cls: type[BaseSettings]): - super().__init__(settings_cls) - self.mapping_values = self._load_values() +class ConfigFileSourceMixin(ABC): + def _read_files(self, files: PathType) -> dict[str, Any | None]: + if files is None: + return {} + if isinstance(files, (str, os.PathLike)): + files = [files] + vars: dict[str, Any | None] = {} + for json_file in files: + json_path = Path(json_file).expanduser() + if json_path.is_file(): + vars.update(self._read_file(json_path)) + return vars @abstractmethod - def _load_values(self) -> Mapping[str, str | None]: + def _read_file(self, path: Path) -> dict[str, Any | None]: pass - def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]: - field_value = self.mapping_values.get(field_name) - return field_value, field_name, False - - def prepare_field_value(self, field_name: str, field: FieldInfo, value: Any, value_is_complex: bool) -> Any: - return value - - def __call__(self) -> dict[str, Any]: - d: dict[str, Any] = {} - for field_name, field in self.settings_cls.model_fields.items(): - field_value, field_key, value_is_complex = self.get_field_value(field, field_name) - field_value = self.prepare_field_value(field_name, field, field_value, value_is_complex) - if field_value is not None: - d[field_key] = field_value - return d - - -class JsonConfigSettingsSource(MappingConfigSource): +class JsonConfigSettingsSource(InitSettingsSource, ConfigFileSourceMixin): """ A source class that loads variables from a JSON file """ - def __init__(self, settings_cls: type[BaseSettings], json_file_path: str, json_file_encoding: str | None = None): - self.json_file_path = json_file_path - self.json_file_encoding = json_file_encoding - super().__init__(settings_cls) - - def _load_values(self) -> Mapping[str, str | None]: - return self._read_json() + def __init__( + self, + settings_cls: type[BaseSettings], + json_file: PathType | None = DEFAULT_PATH, + json_file_encoding: str | None = None, + ): + self.json_file_path: PathType = cast( + PathType, json_file if json_file != DEFAULT_PATH else settings_cls.model_config.get('json_file') + ) + self.json_file_encoding: str | None = cast( + str | None, + json_file_encoding + if json_file_encoding is not None + else settings_cls.model_config.get('json_file_encoding'), + ) + self.json_data = self._read_files(self.json_file_path) + super().__init__(settings_cls, self.json_data) - def _read_json(self) -> Mapping[str, str | None]: - with open(self.json_file_path, encoding=self.json_file_encoding) as json_file: + def _read_file(self, file_path: Path) -> dict[str, Any | None]: + with open(file_path, encoding=self.json_file_encoding) as json_file: return json.load(json_file) -class TomlConfigSettingsSource(MappingConfigSource): +class TomlConfigSettingsSource(InitSettingsSource, ConfigFileSourceMixin): """ A source class that loads variables from a JSON file """ - def __init__(self, settings_cls: type[BaseSettings], toml_file_path: str, toml_file_encoding: str | None = None): - self.toml_file_path = toml_file_path - self.toml_file_encoding = toml_file_encoding - super().__init__(settings_cls) + def __init__( + self, + settings_cls: type[BaseSettings], + toml_file: PathType | None = DEFAULT_PATH, + toml_file_encoding: str | None = None, + ): + self.toml_file_path: PathType = cast( + PathType, toml_file if toml_file != DEFAULT_PATH else settings_cls.model_config.get('toml_file') + ) + self.toml_file_encoding: str | None = cast( + str | None, + toml_file_encoding + if toml_file_encoding is not None + else settings_cls.model_config.get('toml_file_encoding'), + ) + self.toml_data = self._read_files(self.toml_file_path) + super().__init__(settings_cls, self.toml_data) - def _read_toml(self) -> Mapping[str, str | None]: + def _read_file(self, file_path: Path) -> dict[str, Any | None]: import_toml() - with open(self.toml_file_path, 'rb', encoding=self.toml_file_encoding) as toml_file: + with open(file_path, 'rb', encoding=self.toml_file_encoding) as toml_file: if sys.version_info.minor < 11: return tomlkit.load(toml_file) return tomllib.load(toml_file) - def _load_values(self) -> Mapping[str, str | None]: - return self._read_toml() - -class YamlConfigSettingsSource(MappingConfigSource): +class YamlConfigSettingsSource(InitSettingsSource, ConfigFileSourceMixin): """ A source class that loads variables from a yaml file """ @@ -775,18 +788,23 @@ class YamlConfigSettingsSource(MappingConfigSource): def __init__( self, settings_cls: type[BaseSettings], - yaml_file_path: str, + yaml_file: PathType | None = DEFAULT_PATH, yaml_file_encoding: str | None = None, ): - self.yaml_file_path = yaml_file_path - self.yaml_file_encoding = 'utf-8' if yaml_file_encoding is None else yaml_file_encoding - super().__init__(settings_cls) - - def _load_values(self) -> Mapping[str, str | None]: - return self._read_yaml() + self.yaml_file_path: PathType = cast( + PathType, yaml_file if yaml_file != DEFAULT_PATH else settings_cls.model_config.get('yaml_file') + ) + self.yaml_file_encoding: str | None = cast( + str | None, + yaml_file_encoding + if yaml_file_encoding is not None + else settings_cls.model_config.get('yaml_file_encoding'), + ) + self.yaml_data = self._read_files(self.yaml_file_path) + super().__init__(settings_cls, self.yaml_data) - def _read_yaml(self) -> Mapping[str, str | None]: - with open(self.yaml_file_path, encoding=self.yaml_file_encoding) as yaml_file: + def _read_file(self, file_path: Path) -> dict[str, Any | None]: + with open(file_path, encoding=self.yaml_file_encoding) as yaml_file: import_yaml() return yaml.safe_load(yaml_file) diff --git a/tests/test_settings.py b/tests/test_settings.py index 4dbdc2aa..657b6d22 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1934,6 +1934,7 @@ class Nested(BaseModel): class Settings(BaseSettings): foobar: str nested: Nested + model_config = SettingsConfigDict(json_file=p) @classmethod def settings_customise_sources( @@ -1944,7 +1945,7 @@ def settings_customise_sources( dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: - return (JsonConfigSettingsSource(settings_cls, p),) + return (JsonConfigSettingsSource(settings_cls),) s = Settings() assert s.foobar == 'Hello' @@ -1967,6 +1968,7 @@ class Nested(BaseModel): class Settings(BaseSettings): foobar: str nested: Nested + model_config = SettingsConfigDict(yaml_file=p) @classmethod def settings_customise_sources( @@ -1977,7 +1979,7 @@ def settings_customise_sources( dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: - return (YamlConfigSettingsSource(settings_cls, p),) + return (YamlConfigSettingsSource(settings_cls),) s = Settings() assert s.foobar == 'Hello' @@ -2001,6 +2003,7 @@ class Nested(BaseModel): class Settings(BaseSettings): foobar: str nested: Nested + model_config = SettingsConfigDict(toml_file=p) @classmethod def settings_customise_sources( @@ -2011,7 +2014,7 @@ def settings_customise_sources( dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: - return (TomlConfigSettingsSource(settings_cls, p),) + return (TomlConfigSettingsSource(settings_cls),) s = Settings() assert s.foobar == 'Hello' From 0b5109f62a73acd4ac2355bb53143ea61d202158 Mon Sep 17 00:00:00 2001 From: Sami Tahri Date: Sun, 21 Jan 2024 01:53:08 +0100 Subject: [PATCH 08/18] fix: issue with typing --- pydantic_settings/main.py | 13 ++++++++++++ pydantic_settings/sources.py | 39 ++++++++++++++---------------------- 2 files changed, 28 insertions(+), 24 deletions(-) diff --git a/pydantic_settings/main.py b/pydantic_settings/main.py index d3ea63f2..a42954a5 100644 --- a/pydantic_settings/main.py +++ b/pydantic_settings/main.py @@ -14,6 +14,7 @@ DotenvType, EnvSettingsSource, InitSettingsSource, + PathType, PydanticBaseSettingsSource, SecretsSettingsSource, ) @@ -28,6 +29,12 @@ class SettingsConfigDict(ConfigDict, total=False): env_nested_delimiter: str | None env_parse_none_str: str | None secrets_dir: str | Path | None + json_file: PathType | None + json_file_encoding: str | None + yaml_file: PathType | None + yaml_file_encoding: str | None + toml_file: PathType | None + toml_file_encoding: str | None # Extend `config_keys` by pydantic settings config keys to @@ -195,6 +202,12 @@ def _settings_build_values( env_ignore_empty=False, env_nested_delimiter=None, env_parse_none_str=None, + json_file=None, + json_file_encoding=None, + yaml_file=None, + yaml_file_encoding=None, + toml_file=None, + toml_file_encoding=None, secrets_dir=None, protected_namespaces=('model_', 'settings_'), ) diff --git a/pydantic_settings/sources.py b/pydantic_settings/sources.py index 2d514d8f..81f823a4 100644 --- a/pydantic_settings/sources.py +++ b/pydantic_settings/sources.py @@ -704,16 +704,16 @@ def __repr__(self) -> str: class ConfigFileSourceMixin(ABC): - def _read_files(self, files: PathType) -> dict[str, Any | None]: + def _read_files(self, files: PathType | None) -> dict[str, Any | None]: if files is None: return {} if isinstance(files, (str, os.PathLike)): files = [files] vars: dict[str, Any | None] = {} - for json_file in files: - json_path = Path(json_file).expanduser() - if json_path.is_file(): - vars.update(self._read_file(json_path)) + for file in files: + file_path = Path(file).expanduser() + if file_path.is_file(): + vars.update(self._read_file(file_path)) return vars @abstractmethod @@ -732,14 +732,11 @@ def __init__( json_file: PathType | None = DEFAULT_PATH, json_file_encoding: str | None = None, ): - self.json_file_path: PathType = cast( - PathType, json_file if json_file != DEFAULT_PATH else settings_cls.model_config.get('json_file') - ) - self.json_file_encoding: str | None = cast( - str | None, + self.json_file_path = json_file if json_file != DEFAULT_PATH else settings_cls.model_config.get('json_file') + self.json_file_encoding = ( json_file_encoding if json_file_encoding is not None - else settings_cls.model_config.get('json_file_encoding'), + else settings_cls.model_config.get('json_file_encoding') ) self.json_data = self._read_files(self.json_file_path) super().__init__(settings_cls, self.json_data) @@ -760,21 +757,18 @@ def __init__( toml_file: PathType | None = DEFAULT_PATH, toml_file_encoding: str | None = None, ): - self.toml_file_path: PathType = cast( - PathType, toml_file if toml_file != DEFAULT_PATH else settings_cls.model_config.get('toml_file') - ) - self.toml_file_encoding: str | None = cast( - str | None, + self.toml_file_path = toml_file if toml_file != DEFAULT_PATH else settings_cls.model_config.get('toml_file') + self.toml_file_encoding = ( toml_file_encoding if toml_file_encoding is not None - else settings_cls.model_config.get('toml_file_encoding'), + else settings_cls.model_config.get('toml_file_encoding') ) self.toml_data = self._read_files(self.toml_file_path) super().__init__(settings_cls, self.toml_data) def _read_file(self, file_path: Path) -> dict[str, Any | None]: import_toml() - with open(file_path, 'rb', encoding=self.toml_file_encoding) as toml_file: + with open(file_path, mode='rb', encoding=self.toml_file_encoding) as toml_file: if sys.version_info.minor < 11: return tomlkit.load(toml_file) return tomllib.load(toml_file) @@ -791,14 +785,11 @@ def __init__( yaml_file: PathType | None = DEFAULT_PATH, yaml_file_encoding: str | None = None, ): - self.yaml_file_path: PathType = cast( - PathType, yaml_file if yaml_file != DEFAULT_PATH else settings_cls.model_config.get('yaml_file') - ) - self.yaml_file_encoding: str | None = cast( - str | None, + self.yaml_file_path = yaml_file if yaml_file != DEFAULT_PATH else settings_cls.model_config.get('yaml_file') + self.yaml_file_encoding = ( yaml_file_encoding if yaml_file_encoding is not None - else settings_cls.model_config.get('yaml_file_encoding'), + else settings_cls.model_config.get('yaml_file_encoding') ) self.yaml_data = self._read_files(self.yaml_file_path) super().__init__(settings_cls, self.yaml_data) From a85515f7114087b9d7dab1a51f98eab2767e94f2 Mon Sep 17 00:00:00 2001 From: Sami Tahri Date: Sun, 21 Jan 2024 02:12:37 +0100 Subject: [PATCH 09/18] test: added test for coverage --- tests/test_settings.py | 57 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/test_settings.py b/tests/test_settings.py index 657b6d22..c8f26774 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1952,6 +1952,25 @@ def settings_customise_sources( assert s.nested.nested_field == 'world!' +def test_json_no_file(): + class Settings(BaseSettings): + model_config = SettingsConfigDict(json_file=None) + + @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 (JsonConfigSettingsSource(settings_cls),) + + s = Settings() + assert s.model_dump() == {} + + def test_yaml_file(tmp_path): p = tmp_path / '.env' p.write_text( @@ -1986,6 +2005,25 @@ def settings_customise_sources( assert s.nested.nested_field == 'world!' +def test_yaml_no_file(): + class Settings(BaseSettings): + model_config = SettingsConfigDict(yaml_file=None) + + @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() == {} + + def test_toml_file(tmp_path): p = tmp_path / '.env' p.write_text( @@ -2019,3 +2057,22 @@ def settings_customise_sources( s = Settings() assert s.foobar == 'Hello' assert s.nested.nested_field == 'world!' + + +def test_toml_no_file(): + class Settings(BaseSettings): + model_config = SettingsConfigDict(toml_file=None) + + @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 (TomlConfigSettingsSource(settings_cls),) + + s = Settings() + assert s.model_dump() == {} From d84854468bd1173a97885e9320771788e4cbf402 Mon Sep 17 00:00:00 2001 From: Sami Tahri Date: Tue, 23 Jan 2024 21:41:09 +0100 Subject: [PATCH 10/18] add settings, documentation and test --- docs/index.md | 58 ++++++++++++++++++++++++++++++++ pydantic_settings/sources.py | 11 +++--- pyproject.toml | 2 +- requirements/linting.in | 2 +- requirements/linting.txt | 12 +++---- requirements/pyproject.txt | 4 +-- tests/test_settings.py | 65 +++++++++++++++++++++++++++++++++++- 7 files changed, 137 insertions(+), 17 deletions(-) diff --git a/docs/index.md b/docs/index.md index d1627c9d..819302ed 100644 --- a/docs/index.md +++ b/docs/index.md @@ -534,6 +534,64 @@ Last, run your application inside a Docker container and supply your newly creat docker service create --name pydantic-with-secrets --secret my_secret_data pydantic-app:latest ``` +## Other settings source + +Other settings sources are available for common configuration files: + +- `TomlConfigSettingsSource` using `toml_file` and toml_file_encoding arguments +- `YamlConfigSettingsSource` using `yaml_file` and yaml_file_encoding arguments +- `JsonConfigSettingsSource` using `json_file` and `json_file_encoding` arguments + +You can also provide multiple files by providing a list of path: +```py +toml_file = ['config.default.toml', 'config.custom.toml'] +``` +To use them, you can use the same mechanism described [here](#customise-settings-sources) + + +```py +from typing import Tuple, Type +from pydantic import BaseModel +from pydantic_settings import ( + BaseSettings, + PydanticBaseSettingsSource, + TomlConfigSettingsSource, + SettingsConfigDict, +) + + +class Nested(BaseModel): + nested_field: str + + +class Settings(BaseSettings): + foobar: str + nested: Nested + model_config = SettingsConfigDict( + toml_file='config.toml', toml_file_encoding='utf-8' + ) + + @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 (TomlConfigSettingsSource(settings_cls),) +``` + +This will be able to read the following "config.toml" file, located in your working directory: + +```toml +foobar = "Hello" +[nested] +nested_field = "world!" +""" +``` + ## Field value priority In the case where a value is specified for the same `Settings` field in multiple ways, diff --git a/pydantic_settings/sources.py b/pydantic_settings/sources.py index 81f823a4..6ddc7575 100644 --- a/pydantic_settings/sources.py +++ b/pydantic_settings/sources.py @@ -20,8 +20,10 @@ from pydantic_settings.utils import path_type_label if TYPE_CHECKING: - if sys.version_info.minor >= 11: - import tomllib # type: ignore + if sys.version_info >= (3, 11): + import tomllib + else: + tomllib = None import tomlkit import yaml @@ -45,8 +47,7 @@ def import_yaml() -> None: def import_toml() -> None: global tomlkit global tomllib - minor_version = sys.version_info.minor - if minor_version < 11: + if sys.version_info < (3, 11): if tomlkit is not None: return try: @@ -769,7 +770,7 @@ def __init__( def _read_file(self, file_path: Path) -> dict[str, Any | None]: import_toml() with open(file_path, mode='rb', encoding=self.toml_file_encoding) as toml_file: - if sys.version_info.minor < 11: + if sys.version_info < (3, 11): return tomlkit.load(toml_file) return tomllib.load(toml_file) diff --git a/pyproject.toml b/pyproject.toml index 9c0f27e4..d622ea9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dependencies = [ dynamic = ['version'] [project.optional-dependencies] -yaml = ["pyyaml>=6.0"] +yaml = ["pyyaml>=6.0.1"] toml = ["tomlkit>=0.12"] [project.urls] diff --git a/requirements/linting.in b/requirements/linting.in index d52b2fb0..6d25ae58 100644 --- a/requirements/linting.in +++ b/requirements/linting.in @@ -3,5 +3,5 @@ ruff pyupgrade mypy types-PyYAML -tomllib +pyyaml==6.0.1 pre-commit diff --git a/requirements/linting.txt b/requirements/linting.txt index 62ba6675..152402ae 100644 --- a/requirements/linting.txt +++ b/requirements/linting.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.10 +# This file is autogenerated by pip-compile with Python 3.11 # by the following command: # # pip-compile --output-file=requirements/linting.txt requirements/linting.in @@ -36,16 +36,14 @@ pre-commit==2.21.0 # via -r requirements/linting.in pyupgrade==3.3.2 # via -r requirements/linting.in -pyyaml==6.0 - # via pre-commit +pyyaml==6.0.1 + # via + # -r requirements/linting.in + # pre-commit ruff==0.0.270 # via -r requirements/linting.in tokenize-rt==5.0.0 # via pyupgrade -tomli==2.0.1 - # via - # black - # mypy types-pyyaml==6.0.12.12 # via -r requirements/linting.in typing-extensions==4.6.2 diff --git a/requirements/pyproject.txt b/requirements/pyproject.txt index f6f471cf..62bdac6a 100644 --- a/requirements/pyproject.txt +++ b/requirements/pyproject.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.10 +# This file is autogenerated by pip-compile with Python 3.11 # by the following command: # # pip-compile --extra=toml --extra=yaml --output-file=requirements/pyproject.txt pyproject.toml @@ -12,7 +12,7 @@ pydantic-core==2.14.5 # via pydantic python-dotenv==0.21.1 # via pydantic-settings (pyproject.toml) -pyyaml==6.0 +pyyaml==6.0.1 # via pydantic-settings (pyproject.toml) tomlkit==0.12.3 # via pydantic-settings (pyproject.toml) diff --git a/tests/test_settings.py b/tests/test_settings.py index c8f26774..ba9138b4 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,4 +1,5 @@ import dataclasses +import json import os import sys import uuid @@ -1932,9 +1933,9 @@ class Nested(BaseModel): nested_field: str class Settings(BaseSettings): + model_config = SettingsConfigDict(json_file=p) foobar: str nested: Nested - model_config = SettingsConfigDict(json_file=p) @classmethod def settings_customise_sources( @@ -2076,3 +2077,65 @@ def settings_customise_sources( s = Settings() assert s.model_dump() == {} + + +def test_multiple_file(tmp_path): + p1 = tmp_path / '.env.toml1' + p2 = tmp_path / '.env.toml2' + p3 = tmp_path / '.env.yaml3' + p4 = tmp_path / '.env.yaml4' + p5 = tmp_path / '.env.json5' + p6 = tmp_path / '.env.json6' + p1.write_text( + """ + toml1=1 + """ + ) + p2.write_text( + """ + toml2=2 + """ + ) + p3.write_text( + """ + yaml3: 3 + """ + ) + p4.write_text( + """ + yaml4: 4 + """ + ) + with open(p5, 'w') as f5: + json.dump({'json5': 5}, f5) + with open(p6, 'w') as f6: + json.dump({'json6': 6}, f6) + + class Nested(BaseModel): + nested_field: str + + class Settings(BaseSettings): + toml1: int + toml2: int + yaml3: int + yaml4: int + json5: int + json6: int + + @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 ( + TomlConfigSettingsSource(settings_cls, toml_file=[p1, p2]), + YamlConfigSettingsSource(settings_cls, yaml_file=[p3, p4]), + JsonConfigSettingsSource(settings_cls, json_file=[p5, p6]), + ) + + s = Settings() + assert s.model_dump() == {'toml1': 1, 'toml2': 2, 'yaml3': 3, 'yaml4': 4, 'json5': 5, 'json6': 6} From ed406f5b5edcfac2a2ac1c4fa935cad94225165f Mon Sep 17 00:00:00 2001 From: Sami Tahri Date: Tue, 23 Jan 2024 21:54:42 +0100 Subject: [PATCH 11/18] lint: uh, ruff is not happy ? --- docs/index.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/index.md b/docs/index.md index 819302ed..52c32674 100644 --- a/docs/index.md +++ b/docs/index.md @@ -551,12 +551,14 @@ To use them, you can use the same mechanism described [here](#customise-settings ```py from typing import Tuple, Type + from pydantic import BaseModel + from pydantic_settings import ( BaseSettings, PydanticBaseSettingsSource, - TomlConfigSettingsSource, SettingsConfigDict, + TomlConfigSettingsSource, ) @@ -567,9 +569,7 @@ class Nested(BaseModel): class Settings(BaseSettings): foobar: str nested: Nested - model_config = SettingsConfigDict( - toml_file='config.toml', toml_file_encoding='utf-8' - ) + model_config = SettingsConfigDict(toml_file='config.toml', toml_file_encoding='utf-8') @classmethod def settings_customise_sources( @@ -581,6 +581,7 @@ class Settings(BaseSettings): file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: return (TomlConfigSettingsSource(settings_cls),) + ``` This will be able to read the following "config.toml" file, located in your working directory: From d7af14469f345bddccc22ad41902398642353b36 Mon Sep 17 00:00:00 2001 From: Sami Tahri Date: Tue, 23 Jan 2024 21:59:28 +0100 Subject: [PATCH 12/18] lint: uh, ruff is not happy ? --- docs/index.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/index.md b/docs/index.md index 52c32674..dde717e5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -569,7 +569,9 @@ class Nested(BaseModel): class Settings(BaseSettings): foobar: str nested: Nested - model_config = SettingsConfigDict(toml_file='config.toml', toml_file_encoding='utf-8') + model_config = SettingsConfigDict( + toml_file='config.toml', toml_file_encoding='utf-8' + ) @classmethod def settings_customise_sources( @@ -581,7 +583,6 @@ class Settings(BaseSettings): file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: return (TomlConfigSettingsSource(settings_cls),) - ``` This will be able to read the following "config.toml" file, located in your working directory: From d6d73ee07fb9b8ac34b1212d747b41187f8d1dc9 Mon Sep 17 00:00:00 2001 From: Sami Tahri Date: Wed, 31 Jan 2024 15:20:20 +0100 Subject: [PATCH 13/18] change typing for null field --- pydantic_settings/sources.py | 14 +++++++------- tests/test_settings.py | 5 ++++- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/pydantic_settings/sources.py b/pydantic_settings/sources.py index 6ddc7575..5c2f552a 100644 --- a/pydantic_settings/sources.py +++ b/pydantic_settings/sources.py @@ -705,12 +705,12 @@ def __repr__(self) -> str: class ConfigFileSourceMixin(ABC): - def _read_files(self, files: PathType | None) -> dict[str, Any | None]: + def _read_files(self, files: PathType | None) -> dict[str, Any]: if files is None: return {} if isinstance(files, (str, os.PathLike)): files = [files] - vars: dict[str, Any | None] = {} + vars: dict[str, Any] = {} for file in files: file_path = Path(file).expanduser() if file_path.is_file(): @@ -718,7 +718,7 @@ def _read_files(self, files: PathType | None) -> dict[str, Any | None]: return vars @abstractmethod - def _read_file(self, path: Path) -> dict[str, Any | None]: + def _read_file(self, path: Path) -> dict[str, Any]: pass @@ -742,7 +742,7 @@ def __init__( self.json_data = self._read_files(self.json_file_path) super().__init__(settings_cls, self.json_data) - def _read_file(self, file_path: Path) -> dict[str, Any | None]: + def _read_file(self, file_path: Path) -> dict[str, Any]: with open(file_path, encoding=self.json_file_encoding) as json_file: return json.load(json_file) @@ -767,7 +767,7 @@ def __init__( self.toml_data = self._read_files(self.toml_file_path) super().__init__(settings_cls, self.toml_data) - def _read_file(self, file_path: Path) -> dict[str, Any | None]: + def _read_file(self, file_path: Path) -> dict[str, Any]: import_toml() with open(file_path, mode='rb', encoding=self.toml_file_encoding) as toml_file: if sys.version_info < (3, 11): @@ -795,9 +795,9 @@ def __init__( self.yaml_data = self._read_files(self.yaml_file_path) super().__init__(settings_cls, self.yaml_data) - def _read_file(self, file_path: Path) -> dict[str, Any | None]: + def _read_file(self, file_path: Path) -> dict[str, Any]: + import_yaml() with open(file_path, encoding=self.yaml_file_encoding) as yaml_file: - import_yaml() return yaml.safe_load(yaml_file) diff --git a/tests/test_settings.py b/tests/test_settings.py index ba9138b4..badfc5a7 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1925,7 +1925,7 @@ def test_json_file(tmp_path): p = tmp_path / '.env' p.write_text( """ - {"foobar": "Hello", "nested": {"nested_field": "world!"}} + {"foobar": "Hello", "nested": {"nested_field": "world!"}, "null_field": null} """ ) @@ -1936,6 +1936,7 @@ class Settings(BaseSettings): model_config = SettingsConfigDict(json_file=p) foobar: str nested: Nested + null_field: Union[str, None] @classmethod def settings_customise_sources( @@ -1977,6 +1978,7 @@ def test_yaml_file(tmp_path): p.write_text( """ foobar: "Hello" + null_field: nested: nested_field: "world!" """ @@ -1988,6 +1990,7 @@ class Nested(BaseModel): class Settings(BaseSettings): foobar: str nested: Nested + null_field: Union[str, None] model_config = SettingsConfigDict(yaml_file=p) @classmethod From 4c9317bbeff141dfacbb20e9874d4696e9197dc3 Mon Sep 17 00:00:00 2001 From: Sami Tahri Date: Wed, 31 Jan 2024 16:53:14 +0100 Subject: [PATCH 14/18] add tests for non required deps --- .github/workflows/ci.yml | 9 +++++ pydantic_settings/sources.py | 2 +- tests/test_settings.py | 64 ++++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3cc41fd2..fae4e20e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,15 @@ jobs: COVERAGE_FILE: .coverage.${{ runner.os }}-py${{ matrix.python }} CONTEXT: ${{ runner.os }}-py${{ matrix.python }} + - name: uninstall deps + run: pip uninstall -y tomlkit PyYAML python-dotenv + + - name: test without deps + run: make test + env: + COVERAGE_FILE: .coverage.${{ runner.os }}-py${{ matrix.python }}-without-deps + CONTEXT: ${{ runner.os }}-py${{ matrix.python }}-without-deps + - run: coverage combine - run: coverage xml diff --git a/pydantic_settings/sources.py b/pydantic_settings/sources.py index 5c2f552a..ff09bd1e 100644 --- a/pydantic_settings/sources.py +++ b/pydantic_settings/sources.py @@ -41,7 +41,7 @@ def import_yaml() -> None: try: import yaml except ImportError as e: - raise ImportError('pyyaml is not installed, run `pip install pydantic-settings[yaml]`') from e + raise ImportError('PyYAML is not installed, run `pip install pydantic-settings[yaml]`') from e def import_toml() -> None: diff --git a/tests/test_settings.py b/tests/test_settings.py index badfc5a7..3f4f65e9 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -44,6 +44,14 @@ import dotenv except ImportError: dotenv = None +try: + import yaml +except ImportError: + yaml = None +try: + import tomlkit +except ImportError: + tomlkit = None class SimpleSettings(BaseSettings): @@ -1101,6 +1109,62 @@ class Settings(BaseSettings): Settings(_env_file=p) +@pytest.mark.skipif(yaml, reason='PyYAML is installed') +def test_yaml_not_installed(tmp_path): + p = tmp_path / '.env' + p.write_text( + """ + foobar: "Hello" + """ + ) + + class Settings(BaseSettings): + foobar: str + 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),) + + with pytest.raises(ImportError, match=r'^PyYAML is not installed, run `pip install pydantic-settings\[yaml\]`$'): + Settings() + + +@pytest.mark.skipif(sys.version_info >= (3, 11) and tomlkit, reason='tomlkit/tomllib is installed') +def test_toml_not_installed(tmp_path): + p = tmp_path / '.env' + p.write_text( + """ + foobar = "Hello" + """ + ) + + class Settings(BaseSettings): + foobar: str + model_config = SettingsConfigDict(toml_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 (TomlConfigSettingsSource(settings_cls),) + + with pytest.raises(ImportError, match=r'^tomlkit is not installed, run `pip install pydantic-settings\[toml\]`$'): + Settings() + + def test_alias_set(env): class Settings(BaseSettings): foo: str = Field('default foo', validation_alias='foo_env') From c395cb70ff8cd8ae3b456dfb8743fd531e6b25d5 Mon Sep 17 00:00:00 2001 From: Sami Tahri Date: Wed, 31 Jan 2024 16:57:12 +0100 Subject: [PATCH 15/18] fix test on deps --- tests/test_settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_settings.py b/tests/test_settings.py index 3f4f65e9..20846d91 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1137,7 +1137,7 @@ def settings_customise_sources( Settings() -@pytest.mark.skipif(sys.version_info >= (3, 11) and tomlkit, reason='tomlkit/tomllib is installed') +@pytest.mark.skipif(sys.version_info >= (3, 11) or tomlkit, reason='tomlkit/tomllib is installed') def test_toml_not_installed(tmp_path): p = tmp_path / '.env' p.write_text( From 952e80046ed618cb3340e84038cc0726140fd2a6 Mon Sep 17 00:00:00 2001 From: Sami Tahri Date: Wed, 31 Jan 2024 17:04:02 +0100 Subject: [PATCH 16/18] don't make dotenv optionnal in tests --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fae4e20e..0a8b160f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,7 +57,7 @@ jobs: CONTEXT: ${{ runner.os }}-py${{ matrix.python }} - name: uninstall deps - run: pip uninstall -y tomlkit PyYAML python-dotenv + run: pip uninstall -y tomlkit PyYAML - name: test without deps run: make test From da05c319274ae21b07a0719f751e48e6566c1a65 Mon Sep 17 00:00:00 2001 From: Sami Tahri Date: Wed, 31 Jan 2024 17:20:29 +0100 Subject: [PATCH 17/18] dotenv not optionnal, skip test where dep is not fulfilled --- tests/test_settings.py | 86 +++++++++++++++++++++++++++--------------- 1 file changed, 56 insertions(+), 30 deletions(-) diff --git a/tests/test_settings.py b/tests/test_settings.py index 20846d91..f4eec598 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1097,18 +1097,6 @@ def test_read_dotenv_vars_when_env_file_is_none(): ) -@pytest.mark.skipif(dotenv, reason='python-dotenv is installed') -def test_dotenv_not_installed(tmp_path): - p = tmp_path / '.env' - p.write_text('a=b') - - class Settings(BaseSettings): - a: str - - with pytest.raises(ImportError, match=r'^python-dotenv is not installed, run `pip install pydantic\[dotenv\]`$'): - Settings(_env_file=p) - - @pytest.mark.skipif(yaml, reason='PyYAML is installed') def test_yaml_not_installed(tmp_path): p = tmp_path / '.env' @@ -2037,6 +2025,7 @@ def settings_customise_sources( assert s.model_dump() == {} +@pytest.mark.skipif(yaml is None, reason='pyYaml is not installed') def test_yaml_file(tmp_path): p = tmp_path / '.env' p.write_text( @@ -2073,6 +2062,7 @@ def settings_customise_sources( assert s.nested.nested_field == 'world!' +@pytest.mark.skipif(yaml is None, reason='pyYaml is not installed') def test_yaml_no_file(): class Settings(BaseSettings): model_config = SettingsConfigDict(yaml_file=None) @@ -2092,6 +2082,7 @@ def settings_customise_sources( assert s.model_dump() == {} +@pytest.mark.skipif(sys.version_info <= (3, 11) and tomlkit is None, reason='tomlkit/tomllib is not installed') def test_toml_file(tmp_path): p = tmp_path / '.env' p.write_text( @@ -2127,6 +2118,7 @@ def settings_customise_sources( assert s.nested.nested_field == 'world!' +@pytest.mark.skipif(sys.version_info <= (3, 11) and tomlkit is None, reason='tomlkit/tomllib is not installed') def test_toml_no_file(): class Settings(BaseSettings): model_config = SettingsConfigDict(toml_file=None) @@ -2146,13 +2138,10 @@ def settings_customise_sources( assert s.model_dump() == {} -def test_multiple_file(tmp_path): +@pytest.mark.skipif(sys.version_info <= (3, 11) and tomlkit is None, reason='tomlkit/tomllib is not installed') +def test_multiple_file_toml(tmp_path): p1 = tmp_path / '.env.toml1' p2 = tmp_path / '.env.toml2' - p3 = tmp_path / '.env.yaml3' - p4 = tmp_path / '.env.yaml4' - p5 = tmp_path / '.env.json5' - p6 = tmp_path / '.env.json6' p1.write_text( """ toml1=1 @@ -2163,6 +2152,30 @@ def test_multiple_file(tmp_path): toml2=2 """ ) + + class Settings(BaseSettings): + toml1: int + toml2: int + + @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 (TomlConfigSettingsSource(settings_cls, toml_file=[p1, p2]),) + + s = Settings() + assert s.model_dump() == {'toml1': 1, 'toml2': 2} + + +@pytest.mark.skipif(yaml is None, reason='pyYAML is not installed') +def test_multiple_file_yaml(tmp_path): + p3 = tmp_path / '.env.yaml3' + p4 = tmp_path / '.env.yaml4' p3.write_text( """ yaml3: 3 @@ -2173,19 +2186,36 @@ def test_multiple_file(tmp_path): yaml4: 4 """ ) + + class Settings(BaseSettings): + yaml3: int + yaml4: int + + @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, yaml_file=[p3, p4]),) + + s = Settings() + assert s.model_dump() == {'yaml3': 3, 'yaml4': 4} + + +def test_multiple_file_json(tmp_path): + p5 = tmp_path / '.env.json5' + p6 = tmp_path / '.env.json6' + with open(p5, 'w') as f5: json.dump({'json5': 5}, f5) with open(p6, 'w') as f6: json.dump({'json6': 6}, f6) - class Nested(BaseModel): - nested_field: str - class Settings(BaseSettings): - toml1: int - toml2: int - yaml3: int - yaml4: int json5: int json6: int @@ -2198,11 +2228,7 @@ def settings_customise_sources( dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: - return ( - TomlConfigSettingsSource(settings_cls, toml_file=[p1, p2]), - YamlConfigSettingsSource(settings_cls, yaml_file=[p3, p4]), - JsonConfigSettingsSource(settings_cls, json_file=[p5, p6]), - ) + return (JsonConfigSettingsSource(settings_cls, json_file=[p5, p6]),) s = Settings() - assert s.model_dump() == {'toml1': 1, 'toml2': 2, 'yaml3': 3, 'yaml4': 4, 'json5': 5, 'json6': 6} + assert s.model_dump() == {'json5': 5, 'json6': 6} From 90a3b4a684475fe3cbeac288956c850c37654cf8 Mon Sep 17 00:00:00 2001 From: Smixi Date: Fri, 16 Feb 2024 00:05:32 +0100 Subject: [PATCH 18/18] Fix typo in documentation for toml example --- docs/index.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index dde717e5..33acda33 100644 --- a/docs/index.md +++ b/docs/index.md @@ -591,7 +591,6 @@ This will be able to read the following "config.toml" file, located in your work foobar = "Hello" [nested] nested_field = "world!" -""" ``` ## Field value priority