Skip to content

Commit c2fd92f

Browse files
authored
add PyprojectTomlConfigSettingsSource (#255)
1 parent a853a13 commit c2fd92f

File tree

7 files changed

+547
-2
lines changed

7 files changed

+547
-2
lines changed

docs/index.md

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -539,9 +539,10 @@ docker service create --name pydantic-with-secrets --secret my_secret_data pydan
539539

540540
Other settings sources are available for common configuration files:
541541

542+
- `JsonConfigSettingsSource` using `json_file` and `json_file_encoding` arguments
543+
- `PyprojectTomlConfigSettingsSource` using *(optional)* `pyproject_toml_depth` and *(optional)* `pyproject_toml_table_header` arguments
542544
- `TomlConfigSettingsSource` using `toml_file` argument
543545
- `YamlConfigSettingsSource` using `yaml_file` and yaml_file_encoding arguments
544-
- `JsonConfigSettingsSource` using `json_file` and `json_file_encoding` arguments
545546

546547
You can also provide multiple files by providing a list of path:
547548
```py
@@ -592,6 +593,127 @@ foobar = "Hello"
592593
nested_field = "world!"
593594
```
594595

596+
### pyproject.toml
597+
598+
"pyproject.toml" is a standardized file for providing configuration values in Python projects.
599+
[PEP 518](https://peps.python.org/pep-0518/#tool-table) defines a `[tool]` table that can be used to provide arbitrary tool configuration.
600+
While encouraged to use the `[tool]` table, `PyprojectTomlConfigSettingsSource` can be used to load variables from any location with in "pyproject.toml" file.
601+
602+
This is controlled by providing `SettingsConfigDict(pyproject_toml_table_header=tuple[str, ...])` where the value is a tuple of header parts.
603+
By default, `pyproject_toml_table_header=('tool', 'pydantic-settings')` which will load variables from the `[tool.pydantic-settings]` table.
604+
605+
```python
606+
from typing import Tuple, Type
607+
608+
from pydantic_settings import (
609+
BaseSettings,
610+
PydanticBaseSettingsSource,
611+
PyprojectTomlConfigSettingsSource,
612+
SettingsConfigDict,
613+
)
614+
615+
616+
class Settings(BaseSettings):
617+
"""Example loading values from the table used by default."""
618+
619+
field: str
620+
621+
@classmethod
622+
def settings_customise_sources(
623+
cls,
624+
settings_cls: Type[BaseSettings],
625+
init_settings: PydanticBaseSettingsSource,
626+
env_settings: PydanticBaseSettingsSource,
627+
dotenv_settings: PydanticBaseSettingsSource,
628+
file_secret_settings: PydanticBaseSettingsSource,
629+
) -> Tuple[PydanticBaseSettingsSource, ...]:
630+
return (PyprojectTomlConfigSettingsSource(settings_cls),)
631+
632+
633+
class SomeTableSettings(Settings):
634+
"""Example loading values from a user defined table."""
635+
636+
model_config = SettingsConfigDict(
637+
pyproject_toml_table_header=('tool', 'some-table')
638+
)
639+
640+
641+
class RootSettings(Settings):
642+
"""Example loading values from the root of a pyproject.toml file."""
643+
644+
model_config = SettingsConfigDict(extra='ignore', pyproject_toml_table_header=())
645+
```
646+
647+
This will be able to read the following "pyproject.toml" file, located in your working directory, resulting in `Settings(field='default-table')`, `SomeTableSettings(field='some-table')`, & `RootSettings(field='root')`:
648+
649+
```toml
650+
field = "root"
651+
652+
[tool.pydantic-settings]
653+
field = "default-table"
654+
655+
[tool.some-table]
656+
field = "some-table"
657+
```
658+
659+
By default, `PyprojectTomlConfigSettingsSource` will only look for a "pyproject.toml" in the your current working directory.
660+
However, there are two options to change this behavior.
661+
662+
* `SettingsConfigDict(pyproject_toml_depth=<int>)` can be provided to check `<int>` number of directories **up** in the directory tree for a "pyproject.toml" if one is not found in the current working directory.
663+
By default, no parent directories are checked.
664+
* An explicit file path can be provided to the source when it is instantiated (e.g. `PyprojectTomlConfigSettingsSource(settings_cls, Path('~/.config').resolve() / 'pyproject.toml')`).
665+
If a file path is provided this way, it will be treated as absolute (no other locations are checked).
666+
667+
```python
668+
from pathlib import Path
669+
from typing import Tuple, Type
670+
671+
from pydantic_settings import (
672+
BaseSettings,
673+
PydanticBaseSettingsSource,
674+
PyprojectTomlConfigSettingsSource,
675+
SettingsConfigDict,
676+
)
677+
678+
679+
class DiscoverSettings(BaseSettings):
680+
"""Example of discovering a pyproject.toml in parent directories in not in `Path.cwd()`."""
681+
682+
model_config = SettingsConfigDict(pyproject_toml_depth=2)
683+
684+
@classmethod
685+
def settings_customise_sources(
686+
cls,
687+
settings_cls: Type[BaseSettings],
688+
init_settings: PydanticBaseSettingsSource,
689+
env_settings: PydanticBaseSettingsSource,
690+
dotenv_settings: PydanticBaseSettingsSource,
691+
file_secret_settings: PydanticBaseSettingsSource,
692+
) -> Tuple[PydanticBaseSettingsSource, ...]:
693+
return (PyprojectTomlConfigSettingsSource(settings_cls),)
694+
695+
696+
class ExplicitFilePathSettings(BaseSettings):
697+
"""Example of explicitly providing the path to the file to load."""
698+
699+
field: str
700+
701+
@classmethod
702+
def settings_customise_sources(
703+
cls,
704+
settings_cls: Type[BaseSettings],
705+
init_settings: PydanticBaseSettingsSource,
706+
env_settings: PydanticBaseSettingsSource,
707+
dotenv_settings: PydanticBaseSettingsSource,
708+
file_secret_settings: PydanticBaseSettingsSource,
709+
) -> Tuple[PydanticBaseSettingsSource, ...]:
710+
return (
711+
PyprojectTomlConfigSettingsSource(
712+
settings_cls, Path('~/.config').resolve() / 'pyproject.toml'
713+
),
714+
)
715+
```
716+
595717
## Field value priority
596718

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

pydantic_settings/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
InitSettingsSource,
66
JsonConfigSettingsSource,
77
PydanticBaseSettingsSource,
8+
PyprojectTomlConfigSettingsSource,
89
SecretsSettingsSource,
910
TomlConfigSettingsSource,
1011
YamlConfigSettingsSource,
@@ -17,6 +18,7 @@
1718
'EnvSettingsSource',
1819
'InitSettingsSource',
1920
'JsonConfigSettingsSource',
21+
'PyprojectTomlConfigSettingsSource',
2022
'PydanticBaseSettingsSource',
2123
'SecretsSettingsSource',
2224
'SettingsConfigDict',

pydantic_settings/main.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,26 @@ class SettingsConfigDict(ConfigDict, total=False):
3434
json_file_encoding: str | None
3535
yaml_file: PathType | None
3636
yaml_file_encoding: str | None
37+
pyproject_toml_depth: int
38+
"""
39+
Number of levels **up** from the current working directory to attempt to find a pyproject.toml
40+
file.
41+
42+
This is only used when a pyproject.toml file is not found in the current working directory.
43+
"""
44+
45+
pyproject_toml_table_header: tuple[str, ...]
46+
"""
47+
Header of the TOML table within a pyproject.toml file to use when filling variables.
48+
This is supplied as a `tuple[str, ...]` instead of a `str` to accommodate for headers
49+
containing a `.`.
50+
51+
For example, `toml_table_header = ("tool", "my.tool", "foo")` can be used to fill variable
52+
values from a table with header `[tool."my.tool".foo]`.
53+
54+
To use the root table, exclude this config setting or provide an empty tuple.
55+
"""
56+
3757
toml_file: PathType | None
3858

3959

pydantic_settings/sources.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -783,7 +783,7 @@ def _read_file(self, file_path: Path) -> dict[str, Any]:
783783

784784
class TomlConfigSettingsSource(InitSettingsSource, ConfigFileSourceMixin):
785785
"""
786-
A source class that loads variables from a JSON file
786+
A source class that loads variables from a TOML file
787787
"""
788788

789789
def __init__(
@@ -803,6 +803,52 @@ def _read_file(self, file_path: Path) -> dict[str, Any]:
803803
return tomllib.load(toml_file)
804804

805805

806+
class PyprojectTomlConfigSettingsSource(TomlConfigSettingsSource):
807+
"""
808+
A source class that loads variables from a `pyproject.toml` file.
809+
"""
810+
811+
def __init__(
812+
self,
813+
settings_cls: type[BaseSettings],
814+
toml_file: Path | None = None,
815+
) -> None:
816+
self.toml_file_path = self._pick_pyproject_toml_file(
817+
toml_file, settings_cls.model_config.get('pyproject_toml_depth', 0)
818+
)
819+
self.toml_table_header: tuple[str, ...] = settings_cls.model_config.get(
820+
'pyproject_toml_table_header', ('tool', 'pydantic-settings')
821+
)
822+
self.toml_data = self._read_files(self.toml_file_path)
823+
for key in self.toml_table_header:
824+
self.toml_data = self.toml_data.get(key, {})
825+
super(TomlConfigSettingsSource, self).__init__(settings_cls, self.toml_data)
826+
827+
@staticmethod
828+
def _pick_pyproject_toml_file(provided: Path | None, depth: int) -> Path:
829+
"""Pick a `pyproject.toml` file path to use.
830+
831+
Args:
832+
provided: Explicit path provided when instantiating this class.
833+
depth: Number of directories up the tree to check of a pyproject.toml.
834+
835+
"""
836+
if provided:
837+
return provided.resolve()
838+
rv = Path.cwd() / 'pyproject.toml'
839+
count = 0
840+
if not rv.is_file():
841+
child = rv.parent.parent / 'pyproject.toml'
842+
while count < depth:
843+
if child.is_file():
844+
return child
845+
if str(child.parent) == rv.root:
846+
break # end discovery after checking system root once
847+
child = child.parent.parent / 'pyproject.toml'
848+
count += 1
849+
return rv
850+
851+
806852
class YamlConfigSettingsSource(InitSettingsSource, ConfigFileSourceMixin):
807853
"""
808854
A source class that loads variables from a yaml file

tests/conftest.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
1+
from __future__ import annotations
2+
13
import os
4+
from pathlib import Path
5+
from typing import TYPE_CHECKING
26

37
import pytest
48

9+
if TYPE_CHECKING:
10+
from collections.abc import Iterator
11+
512

613
class SetEnv:
714
def __init__(self):
@@ -20,6 +27,34 @@ def clear(self):
2027
os.environ.pop(n)
2128

2229

30+
@pytest.fixture
31+
def cd_tmp_path(tmp_path: Path) -> Iterator[Path]:
32+
"""Change directory into the value of the ``tmp_path`` fixture.
33+
34+
.. rubric:: Example
35+
.. code-block:: python
36+
37+
from typing import TYPE_CHECKING
38+
39+
if TYPE_CHECKING:
40+
from pathlib import Path
41+
42+
43+
def test_something(cd_tmp_path: Path) -> None:
44+
...
45+
46+
Returns:
47+
Value of the :fixture:`tmp_path` fixture (a :class:`~pathlib.Path` object).
48+
49+
"""
50+
prev_dir = Path.cwd()
51+
os.chdir(tmp_path)
52+
try:
53+
yield tmp_path
54+
finally:
55+
os.chdir(prev_dir)
56+
57+
2358
@pytest.fixture
2459
def env():
2560
setenv = SetEnv()

0 commit comments

Comments
 (0)