Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ write_configuration_documentation(Config, output_dir=Path("./docs"))
ApPySetty uses `AppConfigSource` as the interface to define loaders. These sources are loaded and applied in the order they are provided.

```python
cfg = read_configuration(Config, [YamlSource(...), EnvSource(...), DictSource(...)])
cfg = read_configuration(
Config, [YamlSource(...), TomlSource(...), EnvSource(...), DictSource(...)]
)
```

In the example above, YAML values are applied first, then environment variables, and finally dictionary values. Later sources override values from earlier sources.
Expand Down Expand Up @@ -123,6 +125,24 @@ If required is False, a missing file will simply be ignored. If required is True
> [!note]
> Only flat mappings are allowed and the YAML key must match the config key exactly

#### `TomlSource()` - Reading from a .toml file

```python
cfg = read_configuration(Config, TomlSource(path="", required=True))
```

If path is specified, that file is used. Otherwise, the first existing file from the following list is used:

```text
config.toml
config/config.toml
```

If required is False, a missing file will simply be ignored. If required is True an AppConfigError is raised. By default required is set to True.

> [!note]
> Only flat mappings are allowed and the TOML key must match the config key exactly

#### Define your own source

All sources are based on the `AppConfigSource`. To extend the list of sources, you could supply your own implementation:
Expand Down
3 changes: 2 additions & 1 deletion src/appysetty/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from appysetty.model import AppConfigEntry, AppConfigSource
from appysetty.read import read_configuration
from appysetty.source import DictSource, EnvSource, YamlSource
from appysetty.source import DictSource, EnvSource, TomlSource, YamlSource
from appysetty.write import (
write_config_markdown,
write_config_yaml_example,
Expand All @@ -12,6 +12,7 @@
"AppConfigSource",
"DictSource",
"EnvSource",
"TomlSource",
"YamlSource",
"read_configuration",
"write_config_markdown",
Expand Down
2 changes: 1 addition & 1 deletion src/appysetty/read.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ def read_configuration[T](

if unknown:
raise AppConfigError(
f"{type(source).__name__} returned unkown fields that are not part of configuration "
f"{type(source).__name__} returned unknown fields that are not part of configuration "
f"fields: {', '.join(sorted(unknown))}"
)

Expand Down
88 changes: 85 additions & 3 deletions src/appysetty/source.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
import tomllib
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
Expand Down Expand Up @@ -110,10 +111,15 @@ def load(self, config_type_hints):
with yaml_path.open("r", encoding="utf-8") as file:
yaml_values: object = yaml.safe_load(file)
except OSError as e:
raise AppConfigError(
f"Failed to read YAML configuration from {yaml_path}: {e}"
) from e
if self.required:
raise AppConfigError(
f"YAML was used as required source, but failed to read YAML configuration from {yaml_path}: {e}"
)
else:
return {}
except yaml.YAMLError as e:
# this will raise even when required is off
# since the file exists but is invalid - that is a different case from the file not existing
raise AppConfigError(
f"Failed to parse YAML configuration from {yaml_path}: {e}"
) from e
Expand Down Expand Up @@ -147,3 +153,79 @@ def load(self, config_type_hints):
) from e

return values


@dataclass(frozen=True)
class TomlSource(AppConfigSource):
"""Reads input from TOML file, ignoring non-existing files when required is False.

If no path is specified, the first file of the following list is used:
[config.toml, config/config.toml]

If required is True and no file is found, an error is raised.
"""

path: Path | str | None = None
required: bool = True

def load(self, config_type_hints):
toml_path: Path | None = None

if self.path is None:
candidates = [
Path("config.toml"),
Path("config", "config.toml"),
]

for candidate in candidates:
if candidate.is_file():
toml_path = candidate
elif isinstance(self.path, str):
toml_path = Path(self.path)
else:
toml_path = self.path

if toml_path is None:
if self.required:
raise AppConfigError(
"TOML was used as required source, but no toml file was found"
)
else:
return {}

try:
with toml_path.open("rb") as file:
toml_values: object = tomllib.load(file)
except OSError as e:
if self.required:
raise AppConfigError(
f"TOML was used as required source, but failed to read TOML configuration from {toml_path}: {e}"
)
else:
return {}
except tomllib.TOMLDecodeError as e:
raise AppConfigError(
f"Failed to parse TOML configuration from {toml_path}: {e}"
) from e

values: dict[str, object] = {}

for toml_key in toml_values:
toml_value = toml_values[toml_key]

if toml_value is None:
continue

if toml_key not in config_type_hints:
raise AppConfigError(
f"TOML key {toml_key} not found in configuration field names"
)

try:
values[toml_key] = parse_value(toml_value, config_type_hints[toml_key])
except Exception as e:
raise AppConfigError(
f"Failed to parse {toml_key} from TOML: {e}"
) from e

return values
10 changes: 7 additions & 3 deletions tests/test_env_source.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
from dataclasses import dataclass
from typing import Annotated

import pytest

from appysetty import EnvSource, read_configuration
from appysetty import AppConfigEntry, EnvSource, read_configuration
from appysetty.env import get_env_name
from appysetty.model import AppConfigError


@dataclass
class Config:
host: str = "localhost"
port: int = 8080
host: Annotated[
str,
AppConfigEntry(description="The application host"),
] = "localhost"
port: Annotated[int, AppConfigEntry(description="port to run on")] = 8080
debug: bool = False
timeout: float = 5.0

Expand Down
14 changes: 14 additions & 0 deletions tests/test_parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from appysetty import read_configuration
from appysetty.model import AppConfigError, AppConfigWarning
from appysetty.parse import parse_value
from appysetty.source import DictSource


Expand Down Expand Up @@ -85,3 +86,16 @@ def test_config_instance_can_be_passed(self):
config = read_configuration(original, [])

assert config == original

def test_parse_bool_from_int(self):
tt = ["1", "on", "yes", 1, True, "true"]

for input in tt:
result = parse_value(input, bool)
assert result == True

tt = ["0", "off", "no", 0, False, "false"]

for input in tt:
result = parse_value(input, bool)
assert result == False
30 changes: 27 additions & 3 deletions tests/test_read.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import pytest

from appysetty import EnvSource
from appysetty import AppConfigSource, EnvSource
from appysetty.model import AppConfigEntry, AppConfigError, AppConfigWarning
from appysetty.read import (
read_configuration,
Expand All @@ -15,8 +15,11 @@

@dataclass
class Config:
host: str = "localhost"
port: int = 8080
host: Annotated[
str,
AppConfigEntry(description="The application host"),
] = "localhost"
port: Annotated[int, AppConfigEntry(description="port to run on")] = 8080
debug: bool = False
timeout: float = 5.0

Expand All @@ -28,6 +31,11 @@ def test_uses_defaults_when_no_sources_are_provided(self):

assert config == Config()

with pytest.warns(AppConfigWarning, match="No configuration sources"):
config = read_configuration(Config, None)

assert config == Config()

def test_denies_non_dataclass(self):
# not a dataclass
class NonDataclassConfig:
Expand Down Expand Up @@ -65,6 +73,22 @@ class OrderConfig:
assert config.from_yaml == "from-yaml"
assert config.from_dict == "from-dict"

def test_unknown_fields_raise(self):
@dataclass
class OrderConfig:
from_dict: str = "not-set"

@dataclass(frozen=True)
class UnknownSource(AppConfigSource):
def load(self, config_type_hints):
return {"not-in-config": "hello"}

with pytest.raises(AppConfigError):
read_configuration(
OrderConfig,
[DictSource(input={"from_dict": "from-dict"}), UnknownSource()],
)


class TestAnnotatedConfig:
@dataclass
Expand Down
Loading
Loading