diff --git a/README.md b/README.md index bcf993d..2484d33 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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: diff --git a/src/appysetty/__init__.py b/src/appysetty/__init__.py index 5072ded..4fcfaa0 100644 --- a/src/appysetty/__init__.py +++ b/src/appysetty/__init__.py @@ -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, @@ -12,6 +12,7 @@ "AppConfigSource", "DictSource", "EnvSource", + "TomlSource", "YamlSource", "read_configuration", "write_config_markdown", diff --git a/src/appysetty/read.py b/src/appysetty/read.py index bfa3f3b..900310e 100644 --- a/src/appysetty/read.py +++ b/src/appysetty/read.py @@ -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))}" ) diff --git a/src/appysetty/source.py b/src/appysetty/source.py index bea9d26..f8d1be3 100644 --- a/src/appysetty/source.py +++ b/src/appysetty/source.py @@ -1,4 +1,5 @@ import os +import tomllib from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path @@ -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 @@ -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 diff --git a/tests/test_env_source.py b/tests/test_env_source.py index 57df053..b507984 100644 --- a/tests/test_env_source.py +++ b/tests/test_env_source.py @@ -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 diff --git a/tests/test_parse.py b/tests/test_parse.py index c289007..191f435 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -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 @@ -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 diff --git a/tests/test_read.py b/tests/test_read.py index 1116ed3..8c1012f 100644 --- a/tests/test_read.py +++ b/tests/test_read.py @@ -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, @@ -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 @@ -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: @@ -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 diff --git a/tests/test_toml_source.py b/tests/test_toml_source.py new file mode 100644 index 0000000..68ac69a --- /dev/null +++ b/tests/test_toml_source.py @@ -0,0 +1,158 @@ +from dataclasses import dataclass +from os import mkdir +from typing import Annotated + +import pytest + +from appysetty import AppConfigEntry, TomlSource, read_configuration +from appysetty.model import AppConfigError + + +@dataclass +class Config: + 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 + + +class TestReadConfigurationFromTOML: + def test_toml_source(self, tmp_path): + config_file = tmp_path / "config.toml" + config_file.write_text( + """ + host = "example.com" + port = 9000 + debug = true + timeout = 2.5 + """, + encoding="utf-8", + ) + + config = read_configuration(Config, TomlSource(config_file)) + + assert config.host == "example.com" + assert config.port == 9000 + assert config.debug is True + assert config.timeout == 2.5 + + def test_toml_uses_defaults_for_missing_values(self, tmp_path): + config_file = tmp_path / "config.toml" + config_file.write_text( + 'host = "example.com"', + encoding="utf-8", + ) + + config = read_configuration(Config, TomlSource(config_file)) + + assert config.host == "example.com" + assert config.port == 8080 + assert config.debug is False + assert config.timeout == 5.0 + + def test_toml_path_can_be_string(self, tmp_path): + config_file = tmp_path / "config_test.toml" + config_file.write_text( + "port = 9000", + encoding="utf-8", + ) + + config = read_configuration(Config, TomlSource(config_file)) + + assert config.port == 9000 + + def test_toml_path_is_none(self): + toml_path = None + + with pytest.raises(AppConfigError): + read_configuration(Config, TomlSource(toml_path)) + + read_configuration(Config, TomlSource(toml_path, required=False)) + + def test_toml_path_defaults(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + variants = [ + "config.toml", + "config/config.toml", + ] + + mkdir(tmp_path / "config") + + for variant in variants: + (tmp_path / variant).write_text("port = 9000\n") + + config = read_configuration(Config, TomlSource()) + assert config.port == 9000 + + def test_toml_raises_for_invalid_type(self, tmp_path): + config_file = tmp_path / "config.toml" + config_file.write_text( + """ + host = "example.com" + port = [] + """, + encoding="utf-8", + ) + + with pytest.raises(AppConfigError): + read_configuration(Config, TomlSource(config_file)) + + def test_toml_raises_for_invalid_key(self, tmp_path): + config_file = tmp_path / "config.toml" + config_file.write_text( + """ + non_existing = "example.com" + port = [] + """, + encoding="utf-8", + ) + + with pytest.raises(AppConfigError): + read_configuration(Config, TomlSource(config_file)) + + def test_toml_missing_path_raises(self): + toml_path = "/does/not/exist/config.toml" + + with pytest.raises(AppConfigError): + read_configuration( + Config, + TomlSource(toml_path, required=True), + ) + + def test_toml_invalid_syntax_raises(self, tmp_path): + config_file = tmp_path / "config.toml" + config_file.write_text( + """ + host = "example.com + port = 9000 + """, + encoding="utf-8", + ) + + with pytest.raises(AppConfigError): + read_configuration(Config, TomlSource(config_file)) + + def test_toml_requires_mapping(self, tmp_path): + config_file = tmp_path / "config.toml" + config_file.write_text( + """ + one + two + """, + encoding="utf-8", + ) + + with pytest.raises(AppConfigError): + read_configuration(Config, TomlSource(config_file)) + + def test_empty_toml_uses_defaults(self, tmp_path): + config_file = tmp_path / "config.toml" + config_file.write_text("", encoding="utf-8") + + config = read_configuration(Config, TomlSource(config_file)) + + assert config == Config() diff --git a/tests/test_yaml_source.py b/tests/test_yaml_source.py index 5dde193..002d894 100644 --- a/tests/test_yaml_source.py +++ b/tests/test_yaml_source.py @@ -1,16 +1,20 @@ from dataclasses import dataclass from os import mkdir +from typing import Annotated import pytest -from appysetty import YamlSource, read_configuration +from appysetty import AppConfigEntry, YamlSource, read_configuration 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 @@ -66,6 +70,9 @@ def test_yaml_path_is_none(self): with pytest.raises(AppConfigError): read_configuration(Config, YamlSource(yaml_path)) + # this should not raise + read_configuration(Config, YamlSource(yaml_path, required=False)) + def test_yaml_path_defaults(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path)