Skip to content

Commit

Permalink
feat: Add Toml loader.
Browse files Browse the repository at this point in the history
  • Loading branch information
DanCardin committed Oct 7, 2024
1 parent 400e14e commit 74ae4b4
Show file tree
Hide file tree
Showing 8 changed files with 104 additions and 12 deletions.
2 changes: 1 addition & 1 deletion docs/source/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

```{eval-rst}
.. autoapimodule:: dataclass_settings.loaders
:members: Env, Secret, Loader
:members: Env, Secret, Loader, Toml
```

## Context
Expand Down
3 changes: 2 additions & 1 deletion docs/source/loaders.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ A "loader" is defined as a class which implements the
[dataclass_settings.Loader](dataclass_settings.loaders.Loader) protocol. i.e. a
type with a method `load(context: Context)`.

Currently the library ships with two loaders:
The library ships with a few loaders:

- `Env`: Loads environment variables
- `Secrets`: Loads file contents (defaults to `/run/secrets/...`, which ensures
compatibility with Docker Secrets)
- `Toml`: Loads values from a toml file

Additional 1st party loaders will be accepted, but any loader which requires
additional dependencies (for example, `AWS Secrets Manager` would imply `boto3`)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "dataclass-settings"
version = "0.4.0"
version = "0.5.0"
description = "Declarative dataclass settings."

repository = "https://github.com/dancardin/dataclass-settings"
Expand Down
3 changes: 2 additions & 1 deletion src/dataclass_settings/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from dataclass_settings.base import load_settings
from dataclass_settings.context import Context
from dataclass_settings.loaders import Env, Loader, Secret
from dataclass_settings.loaders import Env, Loader, Secret, Toml

__all__ = [
"Env",
"load_settings",
"Loader",
"Secret",
"Context",
"Toml",
]
4 changes: 2 additions & 2 deletions src/dataclass_settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from dataclass_settings import class_inspect
from dataclass_settings.context import Context
from dataclass_settings.loaders import Env, Loader, Secret
from dataclass_settings.loaders import Env, Loader, Secret, Toml

log = logging.getLogger("dataclass_settings")

Expand All @@ -16,7 +16,7 @@
def load_settings(
source_cls: type[T],
*,
loaders: Sequence[type[Loader]] = (Env, Secret),
loaders: Sequence[type[Loader]] = (Env, Secret, Toml),
extra_loaders: Sequence[type[Loader]] = (),
nested_delimiter: bool | str = False,
infer_names: bool = False,
Expand Down
53 changes: 47 additions & 6 deletions src/dataclass_settings/loaders.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
import os
from dataclasses import dataclass
from functools import partial
from typing import cast, runtime_checkable
from pathlib import Path, PurePath
from typing import Any, cast, runtime_checkable

from typing_extensions import Protocol, assert_never

Expand All @@ -13,15 +14,15 @@
@runtime_checkable
class Loader(Protocol):
@staticmethod
def init():
def init() -> Any:
"""Return any state necessary to be shared across instances of the loader."""
return

@classmethod
def partial(cls, **kwargs):
return partial(cls, **kwargs) # type: ignore

def load(self, context: Context):
def load(self, context: Context) -> Any:
assert_never() # type: ignore


Expand All @@ -33,10 +34,10 @@ def __init__(self, *env_vars: str):
self.env_vars = env_vars

@staticmethod
def init():
def init() -> Any:
return os.environ

def load(self, context: Context):
def load(self, context: Context) -> Any:
state = context.get_state(self) or {}

field_name = cast(str, context.field_name)
Expand Down Expand Up @@ -69,7 +70,7 @@ def __init__(self, *names: str, dir: str = dir):
self.names = names
self.dir = dir

def load(self, context: Context):
def load(self, context: Context) -> Any:
field_name = cast(str, context.field_name)
if not self.names and not context.infer_names:
field = ".".join([*context.path, field_name])
Expand All @@ -87,3 +88,43 @@ def load(self, context: Context):
return f.read()

return None


@dataclass
class Toml(Loader):
file: str | PurePath
key: str | None = None

@staticmethod
def init():
return {}

def load(self, context: Context) -> Any:
field_name = cast(str, context.field_name)
if not self.key and not context.infer_names:
field = ".".join([*context.path, field_name])
raise ValueError(
f"Toml instance for `{field}` supplies no `key` and `infer_names` is enabled"
)

key = self.key or field_name

import tomllib

state = cast(dict, context.get_state(self))

file = Path(self.file)
if file not in state:
file_content = file.read_text()
state[file] = tomllib.loads(file_content)

file_context = state[file]
context.record_loaded_value(self, str(self.file), file_context)

for segment in key.split("."):
try:
file_context = file_context[segment]
except KeyError:
continue

return file_context
42 changes: 42 additions & 0 deletions tests/loaders/test_toml.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import pytest
from dataclass_settings import Toml, load_settings
from pydantic import BaseModel, ValidationError
from typing_extensions import Annotated

from tests.utils import env_setup, skip_under


@skip_under(3, 11, reason="Requires tomllib")
def test_missing_required():
class Config(BaseModel):
foo: Annotated[int, Toml("pyproject.toml", "tool.poetry.asdf")]

with env_setup({}), pytest.raises(ValidationError):
load_settings(Config)


@skip_under(3, 11, reason="Requires tomllib")
def test_has_required_required():
class Config(BaseModel):
foo: Annotated[str, Toml("pyproject.toml", "tool.poetry.name")]
license: Annotated[str, Toml("pyproject.toml", "tool.poetry.license")]
ignoreme: str = "asdf"

config = load_settings(Config)
assert config == Config(
foo="dataclass-settings", ignoreme="asdf", license="Apache-2.0"
)


@skip_under(3, 11, reason="Requires tomllib")
def test_missing_optional_inferred_name():
class Config(BaseModel):
tool: Annotated[int, Toml("pyproject.toml")]
ignoreme: str = "asdf"

with pytest.raises(ValueError) as e:
load_settings(Config)
assert (
str(e.value)
== "Toml instance for `tool` supplies no `key` and `infer_names` is enabled"
)
7 changes: 7 additions & 0 deletions tests/utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import contextlib
import sys
from unittest.mock import mock_open, patch

import pytest


@contextlib.contextmanager
def env_setup(env={}, files={}):
Expand All @@ -18,3 +21,7 @@ def choose_file(path, *_, **__):

with env_patch, files_patch, exists_patch:
yield


def skip_under(major: int, minor: int, *, reason: str):
return pytest.mark.skipif(sys.version_info < (major, minor), reason=reason)

0 comments on commit 74ae4b4

Please # to comment.