Skip to content

Commit 8b92f61

Browse files
authored
feat: adding json, yaml and toml sources (#211)
1 parent 0a00678 commit 8b92f61

File tree

10 files changed

+565
-14
lines changed

10 files changed

+565
-14
lines changed

.github/workflows/ci.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,15 @@ jobs:
5656
COVERAGE_FILE: .coverage.${{ runner.os }}-py${{ matrix.python }}
5757
CONTEXT: ${{ runner.os }}-py${{ matrix.python }}
5858

59+
- name: uninstall deps
60+
run: pip uninstall -y tomlkit PyYAML
61+
62+
- name: test without deps
63+
run: make test
64+
env:
65+
COVERAGE_FILE: .coverage.${{ runner.os }}-py${{ matrix.python }}-without-deps
66+
CONTEXT: ${{ runner.os }}-py${{ matrix.python }}-without-deps
67+
5968
- run: coverage combine
6069
- run: coverage xml
6170

docs/index.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,65 @@ Last, run your application inside a Docker container and supply your newly creat
535535
docker service create --name pydantic-with-secrets --secret my_secret_data pydantic-app:latest
536536
```
537537

538+
## Other settings source
539+
540+
Other settings sources are available for common configuration files:
541+
542+
- `TomlConfigSettingsSource` using `toml_file` and toml_file_encoding arguments
543+
- `YamlConfigSettingsSource` using `yaml_file` and yaml_file_encoding arguments
544+
- `JsonConfigSettingsSource` using `json_file` and `json_file_encoding` arguments
545+
546+
You can also provide multiple files by providing a list of path:
547+
```py
548+
toml_file = ['config.default.toml', 'config.custom.toml']
549+
```
550+
To use them, you can use the same mechanism described [here](#customise-settings-sources)
551+
552+
553+
```py
554+
from typing import Tuple, Type
555+
556+
from pydantic import BaseModel
557+
558+
from pydantic_settings import (
559+
BaseSettings,
560+
PydanticBaseSettingsSource,
561+
SettingsConfigDict,
562+
TomlConfigSettingsSource,
563+
)
564+
565+
566+
class Nested(BaseModel):
567+
nested_field: str
568+
569+
570+
class Settings(BaseSettings):
571+
foobar: str
572+
nested: Nested
573+
model_config = SettingsConfigDict(
574+
toml_file='config.toml', toml_file_encoding='utf-8'
575+
)
576+
577+
@classmethod
578+
def settings_customise_sources(
579+
cls,
580+
settings_cls: Type[BaseSettings],
581+
init_settings: PydanticBaseSettingsSource,
582+
env_settings: PydanticBaseSettingsSource,
583+
dotenv_settings: PydanticBaseSettingsSource,
584+
file_secret_settings: PydanticBaseSettingsSource,
585+
) -> Tuple[PydanticBaseSettingsSource, ...]:
586+
return (TomlConfigSettingsSource(settings_cls),)
587+
```
588+
589+
This will be able to read the following "config.toml" file, located in your working directory:
590+
591+
```toml
592+
foobar = "Hello"
593+
[nested]
594+
nested_field = "world!"
595+
```
596+
538597
## Field value priority
539598

540599
In the case where a value is specified for the same `Settings` field in multiple ways,

pydantic_settings/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@
33
DotEnvSettingsSource,
44
EnvSettingsSource,
55
InitSettingsSource,
6+
JsonConfigSettingsSource,
67
PydanticBaseSettingsSource,
78
SecretsSettingsSource,
9+
TomlConfigSettingsSource,
10+
YamlConfigSettingsSource,
811
)
912
from .version import VERSION
1013

@@ -13,9 +16,12 @@
1316
'DotEnvSettingsSource',
1417
'EnvSettingsSource',
1518
'InitSettingsSource',
19+
'JsonConfigSettingsSource',
1620
'PydanticBaseSettingsSource',
1721
'SecretsSettingsSource',
1822
'SettingsConfigDict',
23+
'TomlConfigSettingsSource',
24+
'YamlConfigSettingsSource',
1925
'__version__',
2026
)
2127

pydantic_settings/main.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
DotenvType,
1515
EnvSettingsSource,
1616
InitSettingsSource,
17+
PathType,
1718
PydanticBaseSettingsSource,
1819
SecretsSettingsSource,
1920
)
@@ -28,6 +29,12 @@ class SettingsConfigDict(ConfigDict, total=False):
2829
env_nested_delimiter: str | None
2930
env_parse_none_str: str | None
3031
secrets_dir: str | Path | None
32+
json_file: PathType | None
33+
json_file_encoding: str | None
34+
yaml_file: PathType | None
35+
yaml_file_encoding: str | None
36+
toml_file: PathType | None
37+
toml_file_encoding: str | None
3138

3239

3340
# Extend `config_keys` by pydantic settings config keys to
@@ -195,6 +202,12 @@ def _settings_build_values(
195202
env_ignore_empty=False,
196203
env_nested_delimiter=None,
197204
env_parse_none_str=None,
205+
json_file=None,
206+
json_file_encoding=None,
207+
yaml_file=None,
208+
yaml_file_encoding=None,
209+
toml_file=None,
210+
toml_file_encoding=None,
198211
secrets_dir=None,
199212
protected_namespaces=('model_', 'settings_'),
200213
)

pydantic_settings/sources.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import json
44
import os
5+
import sys
56
import warnings
67
from abc import ABC, abstractmethod
78
from collections import deque
@@ -19,10 +20,49 @@
1920
from pydantic_settings.utils import path_type_label
2021

2122
if TYPE_CHECKING:
23+
if sys.version_info >= (3, 11):
24+
import tomllib
25+
else:
26+
tomllib = None
27+
import tomlkit
28+
import yaml
29+
2230
from pydantic_settings.main import BaseSettings
31+
else:
32+
yaml = None
33+
tomllib = None
34+
tomlkit = None
35+
36+
37+
def import_yaml() -> None:
38+
global yaml
39+
if yaml is not None:
40+
return
41+
try:
42+
import yaml
43+
except ImportError as e:
44+
raise ImportError('PyYAML is not installed, run `pip install pydantic-settings[yaml]`') from e
45+
46+
47+
def import_toml() -> None:
48+
global tomlkit
49+
global tomllib
50+
if sys.version_info < (3, 11):
51+
if tomlkit is not None:
52+
return
53+
try:
54+
import tomlkit
55+
except ImportError as e:
56+
raise ImportError('tomlkit is not installed, run `pip install pydantic-settings[toml]`') from e
57+
else:
58+
if tomllib is not None:
59+
return
60+
import tomllib
2361

2462

2563
DotenvType = Union[Path, str, List[Union[Path, str]], Tuple[Union[Path, str], ...]]
64+
PathType = Union[Path, str, List[Union[Path, str]], Tuple[Union[Path, str], ...]]
65+
DEFAULT_PATH: PathType = Path('')
2666

2767
# This is used as default value for `_env_file` in the `BaseSettings` class and
2868
# `env_file` in `DotEnvSettingsSource` so the default can be distinguished from `None`.
@@ -674,6 +714,103 @@ def __repr__(self) -> str:
674714
)
675715

676716

717+
class ConfigFileSourceMixin(ABC):
718+
def _read_files(self, files: PathType | None) -> dict[str, Any]:
719+
if files is None:
720+
return {}
721+
if isinstance(files, (str, os.PathLike)):
722+
files = [files]
723+
vars: dict[str, Any] = {}
724+
for file in files:
725+
file_path = Path(file).expanduser()
726+
if file_path.is_file():
727+
vars.update(self._read_file(file_path))
728+
return vars
729+
730+
@abstractmethod
731+
def _read_file(self, path: Path) -> dict[str, Any]:
732+
pass
733+
734+
735+
class JsonConfigSettingsSource(InitSettingsSource, ConfigFileSourceMixin):
736+
"""
737+
A source class that loads variables from a JSON file
738+
"""
739+
740+
def __init__(
741+
self,
742+
settings_cls: type[BaseSettings],
743+
json_file: PathType | None = DEFAULT_PATH,
744+
json_file_encoding: str | None = None,
745+
):
746+
self.json_file_path = json_file if json_file != DEFAULT_PATH else settings_cls.model_config.get('json_file')
747+
self.json_file_encoding = (
748+
json_file_encoding
749+
if json_file_encoding is not None
750+
else settings_cls.model_config.get('json_file_encoding')
751+
)
752+
self.json_data = self._read_files(self.json_file_path)
753+
super().__init__(settings_cls, self.json_data)
754+
755+
def _read_file(self, file_path: Path) -> dict[str, Any]:
756+
with open(file_path, encoding=self.json_file_encoding) as json_file:
757+
return json.load(json_file)
758+
759+
760+
class TomlConfigSettingsSource(InitSettingsSource, ConfigFileSourceMixin):
761+
"""
762+
A source class that loads variables from a JSON file
763+
"""
764+
765+
def __init__(
766+
self,
767+
settings_cls: type[BaseSettings],
768+
toml_file: PathType | None = DEFAULT_PATH,
769+
toml_file_encoding: str | None = None,
770+
):
771+
self.toml_file_path = toml_file if toml_file != DEFAULT_PATH else settings_cls.model_config.get('toml_file')
772+
self.toml_file_encoding = (
773+
toml_file_encoding
774+
if toml_file_encoding is not None
775+
else settings_cls.model_config.get('toml_file_encoding')
776+
)
777+
self.toml_data = self._read_files(self.toml_file_path)
778+
super().__init__(settings_cls, self.toml_data)
779+
780+
def _read_file(self, file_path: Path) -> dict[str, Any]:
781+
import_toml()
782+
with open(file_path, mode='rb', encoding=self.toml_file_encoding) as toml_file:
783+
if sys.version_info < (3, 11):
784+
return tomlkit.load(toml_file)
785+
return tomllib.load(toml_file)
786+
787+
788+
class YamlConfigSettingsSource(InitSettingsSource, ConfigFileSourceMixin):
789+
"""
790+
A source class that loads variables from a yaml file
791+
"""
792+
793+
def __init__(
794+
self,
795+
settings_cls: type[BaseSettings],
796+
yaml_file: PathType | None = DEFAULT_PATH,
797+
yaml_file_encoding: str | None = None,
798+
):
799+
self.yaml_file_path = yaml_file if yaml_file != DEFAULT_PATH else settings_cls.model_config.get('yaml_file')
800+
self.yaml_file_encoding = (
801+
yaml_file_encoding
802+
if yaml_file_encoding is not None
803+
else settings_cls.model_config.get('yaml_file_encoding')
804+
)
805+
self.yaml_data = self._read_files(self.yaml_file_path)
806+
super().__init__(settings_cls, self.yaml_data)
807+
808+
def _read_file(self, file_path: Path) -> dict[str, Any]:
809+
import_yaml()
810+
with open(file_path, encoding=self.yaml_file_encoding) as yaml_file:
811+
return yaml.safe_load(yaml_file)
812+
813+
677814
def _get_env_var_key(key: str, case_sensitive: bool = False) -> str:
678815
return key if case_sensitive else key.lower()
679816

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ dependencies = [
4545
]
4646
dynamic = ['version']
4747

48+
[project.optional-dependencies]
49+
yaml = ["pyyaml>=6.0.1"]
50+
toml = ["tomlkit>=0.12"]
51+
4852
[project.urls]
4953
Homepage = 'https://github.com/pydantic/pydantic-settings'
5054
Funding = 'https://github.com/sponsors/samuelcolvin'

requirements/linting.in

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,6 @@ black
22
ruff
33
pyupgrade
44
mypy
5+
types-PyYAML
6+
pyyaml==6.0.1
57
pre-commit

requirements/linting.txt

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#
2-
# This file is autogenerated by pip-compile with Python 3.10
2+
# This file is autogenerated by pip-compile with Python 3.11
33
# by the following command:
44
#
55
# pip-compile --output-file=requirements/linting.txt requirements/linting.in
@@ -36,16 +36,16 @@ pre-commit==2.21.0
3636
# via -r requirements/linting.in
3737
pyupgrade==3.3.2
3838
# via -r requirements/linting.in
39-
pyyaml==6.0
40-
# via pre-commit
39+
pyyaml==6.0.1
40+
# via
41+
# -r requirements/linting.in
42+
# pre-commit
4143
ruff==0.0.270
4244
# via -r requirements/linting.in
4345
tokenize-rt==5.0.0
4446
# via pyupgrade
45-
tomli==2.0.1
46-
# via
47-
# black
48-
# mypy
47+
types-pyyaml==6.0.12.12
48+
# via -r requirements/linting.in
4949
typing-extensions==4.6.2
5050
# via mypy
5151
virtualenv==20.23.0

requirements/pyproject.txt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# This file is autogenerated by pip-compile with Python 3.11
33
# by the following command:
44
#
5-
# pip-compile --output-file=requirements/pyproject.txt pyproject.toml
5+
# pip-compile --extra=toml --extra=yaml --output-file=requirements/pyproject.txt pyproject.toml
66
#
77
annotated-types==0.4.0
88
# via pydantic
@@ -12,6 +12,10 @@ pydantic-core==2.14.5
1212
# via pydantic
1313
python-dotenv==0.21.1
1414
# via pydantic-settings (pyproject.toml)
15+
pyyaml==6.0.1
16+
# via pydantic-settings (pyproject.toml)
17+
tomlkit==0.12.3
18+
# via pydantic-settings (pyproject.toml)
1519
typing-extensions==4.6.2
1620
# via
1721
# pydantic

0 commit comments

Comments
 (0)