configaroo 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- configaroo-0.1.0/PKG-INFO +21 -0
- configaroo-0.1.0/README.md +10 -0
- configaroo-0.1.0/pyproject.toml +15 -0
- configaroo-0.1.0/setup.cfg +4 -0
- configaroo-0.1.0/src/configaroo/__init__.py +8 -0
- configaroo-0.1.0/src/configaroo/configuration.py +168 -0
- configaroo-0.1.0/src/configaroo/exceptions.py +13 -0
- configaroo-0.1.0/src/configaroo/loaders/__init__.py +24 -0
- configaroo-0.1.0/src/configaroo/loaders/json.py +13 -0
- configaroo-0.1.0/src/configaroo/loaders/toml.py +13 -0
- configaroo-0.1.0/src/configaroo/py.typed +0 -0
- configaroo-0.1.0/src/configaroo.egg-info/PKG-INFO +21 -0
- configaroo-0.1.0/src/configaroo.egg-info/SOURCES.txt +20 -0
- configaroo-0.1.0/src/configaroo.egg-info/dependency_links.txt +1 -0
- configaroo-0.1.0/src/configaroo.egg-info/requires.txt +3 -0
- configaroo-0.1.0/src/configaroo.egg-info/top_level.txt +1 -0
- configaroo-0.1.0/tests/test_configuration.py +110 -0
- configaroo-0.1.0/tests/test_environment.py +53 -0
- configaroo-0.1.0/tests/test_json.py +44 -0
- configaroo-0.1.0/tests/test_loaders.py +38 -0
- configaroo-0.1.0/tests/test_toml.py +44 -0
- configaroo-0.1.0/tests/test_validation.py +42 -0
@@ -0,0 +1,21 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: configaroo
|
3
|
+
Version: 0.1.0
|
4
|
+
Summary: Bouncy handling of configuration files
|
5
|
+
Author-email: Geir Arne Hjelle <geirarne@gmail.com>
|
6
|
+
Requires-Python: >=3.11
|
7
|
+
Description-Content-Type: text/markdown
|
8
|
+
Requires-Dist: pydantic>=2.0
|
9
|
+
Requires-Dist: pyplugs>=0.4.0
|
10
|
+
Requires-Dist: python-dotenv>=1.1.0
|
11
|
+
|
12
|
+
# Configaroo - Bouncy Configuration Handling
|
13
|
+
|
14
|
+
Configaroo is a light configuration package for Python that offers the following features:
|
15
|
+
|
16
|
+
- Access configuration settings with dotted keys: `config.nested.key`
|
17
|
+
- Use different configuration file formats, including TOML and JSON
|
18
|
+
- Override key configuration settings with environment variables
|
19
|
+
- Validate a configuration based on a Pydantic model
|
20
|
+
- Convert the type of configuration values based on a Pydantic model
|
21
|
+
- Dynamically format certain configuration values
|
@@ -0,0 +1,10 @@
|
|
1
|
+
# Configaroo - Bouncy Configuration Handling
|
2
|
+
|
3
|
+
Configaroo is a light configuration package for Python that offers the following features:
|
4
|
+
|
5
|
+
- Access configuration settings with dotted keys: `config.nested.key`
|
6
|
+
- Use different configuration file formats, including TOML and JSON
|
7
|
+
- Override key configuration settings with environment variables
|
8
|
+
- Validate a configuration based on a Pydantic model
|
9
|
+
- Convert the type of configuration values based on a Pydantic model
|
10
|
+
- Dynamically format certain configuration values
|
@@ -0,0 +1,15 @@
|
|
1
|
+
[project]
|
2
|
+
name = "configaroo"
|
3
|
+
version = "0.1.0"
|
4
|
+
description = "Bouncy handling of configuration files"
|
5
|
+
readme = "README.md"
|
6
|
+
authors = [{ name = "Geir Arne Hjelle", email = "geirarne@gmail.com" }]
|
7
|
+
requires-python = ">=3.11"
|
8
|
+
dependencies = ["pydantic>=2.0", "pyplugs>=0.4.0", "python-dotenv>=1.1.0"]
|
9
|
+
|
10
|
+
[build-system]
|
11
|
+
requires = ["setuptools>=61"]
|
12
|
+
build-backend = "setuptools.build_meta"
|
13
|
+
|
14
|
+
[dependency-groups]
|
15
|
+
dev = ["ipython>=8.36.0", "pytest>=8.3.5", "ruff>=0.11.11", "tomli-w>=1.2.0"]
|
@@ -0,0 +1,168 @@
|
|
1
|
+
"""A dict-like configuration with support for envvars, validation and type conversion"""
|
2
|
+
|
3
|
+
import os
|
4
|
+
from collections import UserDict
|
5
|
+
from pathlib import Path
|
6
|
+
from typing import Any, Self, Type
|
7
|
+
|
8
|
+
import dotenv
|
9
|
+
from pydantic import BaseModel
|
10
|
+
|
11
|
+
from configaroo import loaders
|
12
|
+
from configaroo.exceptions import MissingEnvironmentVariableError
|
13
|
+
|
14
|
+
|
15
|
+
class Configuration(UserDict):
|
16
|
+
"""A Configuration is a dict-like structure with some conveniences"""
|
17
|
+
|
18
|
+
@classmethod
|
19
|
+
def from_file(
|
20
|
+
cls,
|
21
|
+
file_path: str | Path,
|
22
|
+
loader: str | None = None,
|
23
|
+
envs: dict[str, str] | None = None,
|
24
|
+
env_prefix: str = "",
|
25
|
+
extra_dynamic: dict[str, Any] | None = None,
|
26
|
+
model: Type[BaseModel] | None = None,
|
27
|
+
) -> Self:
|
28
|
+
"""Read a Configuration from a file"""
|
29
|
+
config_dict = loaders.from_file(file_path, loader=loader)
|
30
|
+
return cls(**config_dict).initialize(envs=envs, model=model)
|
31
|
+
|
32
|
+
def initialize(
|
33
|
+
self,
|
34
|
+
envs: dict[str, str] | None = None,
|
35
|
+
env_prefix: str = "",
|
36
|
+
extra_dynamic: dict[str, Any] | None = None,
|
37
|
+
model: Type[BaseModel] | None = None,
|
38
|
+
) -> Self:
|
39
|
+
"""Initialize a configuration.
|
40
|
+
|
41
|
+
The initialization adds environment variables, parses dynamic values,
|
42
|
+
validates against a Pydantic model, and converts value types using the
|
43
|
+
same model.
|
44
|
+
"""
|
45
|
+
self = self if envs is None else self.add_envs(envs, prefix=env_prefix)
|
46
|
+
self = self.parse_dynamic(extra_dynamic)
|
47
|
+
self = self if model is None else self.validate(model).convert(model)
|
48
|
+
return self
|
49
|
+
|
50
|
+
def __getitem__(self, key: str) -> Any:
|
51
|
+
"""Make sure nested sections have type Configuration"""
|
52
|
+
value = self.data[key]
|
53
|
+
if isinstance(value, dict | UserDict | Configuration):
|
54
|
+
return Configuration(**value)
|
55
|
+
else:
|
56
|
+
return value
|
57
|
+
|
58
|
+
def __getattr__(self, key: str) -> Any:
|
59
|
+
"""Create attribute access for config keys for convenience"""
|
60
|
+
try:
|
61
|
+
return self[key]
|
62
|
+
except KeyError:
|
63
|
+
raise AttributeError(
|
64
|
+
f"'{type(self).__name__}' has no attribute or key '{key}'"
|
65
|
+
)
|
66
|
+
|
67
|
+
def __contains__(self, key: str) -> bool:
|
68
|
+
"""Add support for dotted keys"""
|
69
|
+
if key in self.data:
|
70
|
+
return True
|
71
|
+
prefix, _, rest = key.partition(".")
|
72
|
+
try:
|
73
|
+
return rest in self[prefix]
|
74
|
+
except KeyError:
|
75
|
+
return False
|
76
|
+
|
77
|
+
def get(self, key: str, default: Any = None) -> Any:
|
78
|
+
"""Allow dotted keys when using .get()"""
|
79
|
+
if key not in self.data:
|
80
|
+
prefix, _, rest = key.partition(".")
|
81
|
+
try:
|
82
|
+
return self[prefix].get(rest, default=default)
|
83
|
+
except KeyError:
|
84
|
+
return default
|
85
|
+
else:
|
86
|
+
return self[key]
|
87
|
+
|
88
|
+
def add(self, key: str, value: Any) -> Self:
|
89
|
+
"""Add a value, allow dotted keys"""
|
90
|
+
prefix, _, rest = key.partition(".")
|
91
|
+
if rest:
|
92
|
+
cls = type(self)
|
93
|
+
return self | {prefix: cls(**self.setdefault(prefix, {})).add(rest, value)}
|
94
|
+
else:
|
95
|
+
return self | {key: value}
|
96
|
+
|
97
|
+
def add_envs(self, envs: dict[str, str], prefix: str = "", use_dotenv=True) -> Self:
|
98
|
+
"""Add environment variables to configuration"""
|
99
|
+
if use_dotenv:
|
100
|
+
dotenv.load_dotenv()
|
101
|
+
|
102
|
+
for env, key in envs.items():
|
103
|
+
env_key = f"{prefix}{env}"
|
104
|
+
env_value = os.getenv(env_key)
|
105
|
+
if env_value:
|
106
|
+
self = self.add(key, env_value)
|
107
|
+
else:
|
108
|
+
if key not in self:
|
109
|
+
raise MissingEnvironmentVariableError(
|
110
|
+
f"required environment variable '{env_key}' not found"
|
111
|
+
)
|
112
|
+
return self
|
113
|
+
|
114
|
+
def parse_dynamic(self, extra: dict[str, Any] | None = None) -> Self:
|
115
|
+
"""Parse dynamic values of the form {section.key}"""
|
116
|
+
cls = type(self)
|
117
|
+
variables = (
|
118
|
+
self.to_flat_dict()
|
119
|
+
| {"project_path": Path(__file__).parent.parent.parent}
|
120
|
+
| ({} if extra is None else extra)
|
121
|
+
)
|
122
|
+
return cls(
|
123
|
+
**{
|
124
|
+
key: (
|
125
|
+
value.parse_dynamic(extra=variables)
|
126
|
+
if isinstance(value, Configuration)
|
127
|
+
else value.format(**variables)
|
128
|
+
if isinstance(value, str)
|
129
|
+
else value
|
130
|
+
)
|
131
|
+
for key, value in self.items()
|
132
|
+
}
|
133
|
+
)
|
134
|
+
|
135
|
+
def validate(self, model: Type[BaseModel]) -> Self:
|
136
|
+
"""Validate the configuration against the given model."""
|
137
|
+
model.model_validate(self.data)
|
138
|
+
return self
|
139
|
+
|
140
|
+
def convert(self, model: Type[BaseModel]) -> Self:
|
141
|
+
"""Convert data types to match the given model"""
|
142
|
+
cls = type(self)
|
143
|
+
return cls(**model(**self.data).model_dump())
|
144
|
+
|
145
|
+
def to_dict(self) -> dict[str, Any]:
|
146
|
+
"""Dump the configuration into a Python dictionary"""
|
147
|
+
return {
|
148
|
+
key: value.to_dict() if isinstance(value, Configuration) else value
|
149
|
+
for key, value in self.items()
|
150
|
+
}
|
151
|
+
|
152
|
+
def to_flat_dict(self, _prefix: str = "") -> dict[str, Any]:
|
153
|
+
"""Dump the configuration into a flat dictionary.
|
154
|
+
|
155
|
+
Nested configurations are converted into dotted keys.
|
156
|
+
"""
|
157
|
+
return {
|
158
|
+
f"{_prefix}{key}": value
|
159
|
+
for key, value in self.items()
|
160
|
+
if not isinstance(value, Configuration)
|
161
|
+
} | {
|
162
|
+
key: value
|
163
|
+
for nested_key, nested_value in self.items()
|
164
|
+
if isinstance(nested_value, Configuration)
|
165
|
+
for key, value in (
|
166
|
+
self[nested_key].to_flat_dict(_prefix=f"{_prefix}{nested_key}.").items()
|
167
|
+
)
|
168
|
+
}
|
@@ -0,0 +1,13 @@
|
|
1
|
+
"""Configaroo specific exceptions"""
|
2
|
+
|
3
|
+
|
4
|
+
class ConfigarooException(Exception):
|
5
|
+
"""Base exception for more specific Configaroo exceptions"""
|
6
|
+
|
7
|
+
|
8
|
+
class MissingEnvironmentVariableError(ConfigarooException, KeyError):
|
9
|
+
"""A required environment variable is missing"""
|
10
|
+
|
11
|
+
|
12
|
+
class UnsupportedLoaderError(ConfigarooException, ValueError):
|
13
|
+
"""An unsupported loader is called"""
|
@@ -0,0 +1,24 @@
|
|
1
|
+
"""Loaders that read configuration files."""
|
2
|
+
|
3
|
+
from pathlib import Path
|
4
|
+
from typing import Any
|
5
|
+
|
6
|
+
import pyplugs
|
7
|
+
|
8
|
+
from configaroo.exceptions import UnsupportedLoaderError
|
9
|
+
|
10
|
+
load = pyplugs.call_factory(__package__)
|
11
|
+
loader_names = pyplugs.names_factory(__package__)
|
12
|
+
|
13
|
+
|
14
|
+
def from_file(path: str | Path, loader: str | None = None) -> dict[str, Any]:
|
15
|
+
"""Load a file using a loader defined by the suffix if necessary."""
|
16
|
+
path = Path(path)
|
17
|
+
loader = path.suffix.lstrip(".") if loader is None else loader
|
18
|
+
try:
|
19
|
+
return load(loader, path=path)
|
20
|
+
except pyplugs.UnknownPluginError:
|
21
|
+
raise UnsupportedLoaderError(
|
22
|
+
f"file type '{loader}' isn't supported. "
|
23
|
+
f"Use one of: {', '.join(loader_names())}"
|
24
|
+
) from None
|
@@ -0,0 +1,13 @@
|
|
1
|
+
"""Loader for JSON-files"""
|
2
|
+
|
3
|
+
import json
|
4
|
+
from pathlib import Path
|
5
|
+
from typing import Any
|
6
|
+
|
7
|
+
import pyplugs
|
8
|
+
|
9
|
+
|
10
|
+
@pyplugs.register
|
11
|
+
def load_json_file(path: Path) -> dict[str, Any]:
|
12
|
+
"""Read a JSON-file"""
|
13
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
@@ -0,0 +1,13 @@
|
|
1
|
+
"""Loader for TOML-files"""
|
2
|
+
|
3
|
+
import tomllib
|
4
|
+
from pathlib import Path
|
5
|
+
from typing import Any
|
6
|
+
|
7
|
+
import pyplugs
|
8
|
+
|
9
|
+
|
10
|
+
@pyplugs.register
|
11
|
+
def load_toml_file(path: Path) -> dict[str, Any]:
|
12
|
+
"""Read a TOML-file"""
|
13
|
+
return tomllib.loads(path.read_text(encoding="utf-8"))
|
File without changes
|
@@ -0,0 +1,21 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: configaroo
|
3
|
+
Version: 0.1.0
|
4
|
+
Summary: Bouncy handling of configuration files
|
5
|
+
Author-email: Geir Arne Hjelle <geirarne@gmail.com>
|
6
|
+
Requires-Python: >=3.11
|
7
|
+
Description-Content-Type: text/markdown
|
8
|
+
Requires-Dist: pydantic>=2.0
|
9
|
+
Requires-Dist: pyplugs>=0.4.0
|
10
|
+
Requires-Dist: python-dotenv>=1.1.0
|
11
|
+
|
12
|
+
# Configaroo - Bouncy Configuration Handling
|
13
|
+
|
14
|
+
Configaroo is a light configuration package for Python that offers the following features:
|
15
|
+
|
16
|
+
- Access configuration settings with dotted keys: `config.nested.key`
|
17
|
+
- Use different configuration file formats, including TOML and JSON
|
18
|
+
- Override key configuration settings with environment variables
|
19
|
+
- Validate a configuration based on a Pydantic model
|
20
|
+
- Convert the type of configuration values based on a Pydantic model
|
21
|
+
- Dynamically format certain configuration values
|
@@ -0,0 +1,20 @@
|
|
1
|
+
README.md
|
2
|
+
pyproject.toml
|
3
|
+
src/configaroo/__init__.py
|
4
|
+
src/configaroo/configuration.py
|
5
|
+
src/configaroo/exceptions.py
|
6
|
+
src/configaroo/py.typed
|
7
|
+
src/configaroo.egg-info/PKG-INFO
|
8
|
+
src/configaroo.egg-info/SOURCES.txt
|
9
|
+
src/configaroo.egg-info/dependency_links.txt
|
10
|
+
src/configaroo.egg-info/requires.txt
|
11
|
+
src/configaroo.egg-info/top_level.txt
|
12
|
+
src/configaroo/loaders/__init__.py
|
13
|
+
src/configaroo/loaders/json.py
|
14
|
+
src/configaroo/loaders/toml.py
|
15
|
+
tests/test_configuration.py
|
16
|
+
tests/test_environment.py
|
17
|
+
tests/test_json.py
|
18
|
+
tests/test_loaders.py
|
19
|
+
tests/test_toml.py
|
20
|
+
tests/test_validation.py
|
@@ -0,0 +1 @@
|
|
1
|
+
|
@@ -0,0 +1 @@
|
|
1
|
+
configaroo
|
@@ -0,0 +1,110 @@
|
|
1
|
+
"""Test base Configuration functionality"""
|
2
|
+
|
3
|
+
import pytest
|
4
|
+
|
5
|
+
from configaroo import Configuration
|
6
|
+
|
7
|
+
|
8
|
+
def test_read_simple_values_as_attributes(config):
|
9
|
+
"""Test attribute access for simple values."""
|
10
|
+
assert config.number == 42
|
11
|
+
assert config.word == "platypus"
|
12
|
+
assert config.things == ["house", "car", "kayak"]
|
13
|
+
|
14
|
+
|
15
|
+
def test_read_simple_values_as_items(config):
|
16
|
+
"""Test dictionary access for simple values."""
|
17
|
+
assert config["number"] == 42
|
18
|
+
assert config["word"] == "platypus"
|
19
|
+
assert config["things"] == ["house", "car", "kayak"]
|
20
|
+
|
21
|
+
|
22
|
+
def test_missing_attributes_raise_attribute_error(config):
|
23
|
+
"""Test that accessing missing attributes raise the appropriate error"""
|
24
|
+
with pytest.raises(AttributeError):
|
25
|
+
config.non_existent
|
26
|
+
|
27
|
+
|
28
|
+
def test_nested_values_are_configurations(config):
|
29
|
+
"""Test that a nested configuration has type Configuration"""
|
30
|
+
assert isinstance(config["nested"], Configuration)
|
31
|
+
|
32
|
+
|
33
|
+
def test_read_nested_values_as_attributes(config):
|
34
|
+
"""Test attribute access for nested values."""
|
35
|
+
assert config.nested.pie == 3.14
|
36
|
+
assert config.nested.seven == 7
|
37
|
+
|
38
|
+
|
39
|
+
def test_read_nested_values_as_items(config):
|
40
|
+
"""Test dictionary access for nested values."""
|
41
|
+
assert config["nested"]["pie"] == 3.14
|
42
|
+
assert config["nested"]["seven"] == 7
|
43
|
+
assert config["with_dot"]["org.num"] == 1234
|
44
|
+
|
45
|
+
|
46
|
+
def test_read_nested_values_as_attributes_and_items(config):
|
47
|
+
"""Test mixed access for nested values."""
|
48
|
+
assert config["nested"].pie == 3.14
|
49
|
+
assert config.nested["seven"] == 7
|
50
|
+
|
51
|
+
|
52
|
+
def test_get_nested_values(config):
|
53
|
+
"""Test that .get() can use dotted keys"""
|
54
|
+
assert config.get("nested.seven") == 7
|
55
|
+
assert config.get("with_dot.org.num") == 1234
|
56
|
+
|
57
|
+
|
58
|
+
def test_update_preserves_type(config):
|
59
|
+
"""Test that an update operation gives a Configuration"""
|
60
|
+
assert isinstance(config | {"new": 1}, Configuration)
|
61
|
+
|
62
|
+
config.update(new=1)
|
63
|
+
assert isinstance(config, Configuration)
|
64
|
+
|
65
|
+
|
66
|
+
def test_dump_to_dict(config):
|
67
|
+
"""Test that dumping to a dictionary unwraps nested sections"""
|
68
|
+
config_dict = config.to_dict()
|
69
|
+
assert isinstance(config_dict, dict)
|
70
|
+
assert isinstance(config_dict["paths"], dict)
|
71
|
+
|
72
|
+
|
73
|
+
def test_dump_to_flat_dict(config):
|
74
|
+
"""Test that a configuration can be converted to a flat dictionary"""
|
75
|
+
flat_config_dict = config.to_flat_dict()
|
76
|
+
assert isinstance(flat_config_dict, dict)
|
77
|
+
assert flat_config_dict["number"] == 42
|
78
|
+
assert flat_config_dict["nested.seven"] == 7
|
79
|
+
assert flat_config_dict["nested.deep.sea"] == "Marianer"
|
80
|
+
assert flat_config_dict["with_dot.org.num"] == 1234
|
81
|
+
|
82
|
+
|
83
|
+
def test_contains_with_simple_key(config):
|
84
|
+
"""Test that "key" in config works for simple keys"""
|
85
|
+
assert "number" in config
|
86
|
+
assert "not_there" not in config
|
87
|
+
|
88
|
+
|
89
|
+
def test_contains_with_dotted_key(config):
|
90
|
+
"""Test that "key" in config works for dotted keys"""
|
91
|
+
assert "nested.seven" in config
|
92
|
+
assert "with_dot.org.num" in config
|
93
|
+
assert "nested.number" not in config
|
94
|
+
|
95
|
+
|
96
|
+
def test_parse_dynamic_default(config):
|
97
|
+
"""Test parsing of default dynamic variables"""
|
98
|
+
parsed_config = config.parse_dynamic()
|
99
|
+
assert parsed_config.paths.dynamic == __file__
|
100
|
+
assert parsed_config.phrase == "The meaning of life is 42"
|
101
|
+
|
102
|
+
|
103
|
+
def test_parse_dynamic_extra(config):
|
104
|
+
"""Test parsing of extra dynamic variables"""
|
105
|
+
parsed_config = (config | {"animal": "{adjective} platypus"}).parse_dynamic(
|
106
|
+
extra={"number": 14, "adjective": "tall"}
|
107
|
+
)
|
108
|
+
assert parsed_config.paths.dynamic == __file__
|
109
|
+
assert parsed_config.phrase == "The meaning of life is 14"
|
110
|
+
assert parsed_config.animal == "tall platypus"
|
@@ -0,0 +1,53 @@
|
|
1
|
+
"""Test handling of environment variables"""
|
2
|
+
|
3
|
+
import pytest
|
4
|
+
|
5
|
+
from configaroo import MissingEnvironmentVariableError
|
6
|
+
|
7
|
+
|
8
|
+
def test_add_one_env(config, monkeypatch):
|
9
|
+
"""Test that we can add one environment variable in a new field"""
|
10
|
+
monkeypatch.setenv("WORD", "platypus")
|
11
|
+
config_w_env = config.add_envs({"WORD": "nested.word"})
|
12
|
+
assert config_w_env.nested.word == "platypus"
|
13
|
+
|
14
|
+
|
15
|
+
def test_overwrite_one_env(config, monkeypatch):
|
16
|
+
"""Test that we can overwrite a value with an environment value"""
|
17
|
+
monkeypatch.setenv("NEW_PATH", "files/config.json")
|
18
|
+
config_w_env = config.add_envs({"NEW_PATH": "path"})
|
19
|
+
assert config_w_env.path == "files/config.json"
|
20
|
+
|
21
|
+
|
22
|
+
def test_several_envs(config, monkeypatch):
|
23
|
+
"""Test that we can read several environment variables"""
|
24
|
+
monkeypatch.setenv("WORD", "platypus")
|
25
|
+
monkeypatch.setenv("NEW_PATH", "files/config.json")
|
26
|
+
config_w_env = config.add_envs({"WORD": "nested.word", "NEW_PATH": "path"})
|
27
|
+
assert config_w_env.nested.word == "platypus"
|
28
|
+
assert config_w_env.path == "files/config.json"
|
29
|
+
|
30
|
+
|
31
|
+
def test_error_on_missing_env(config):
|
32
|
+
"""Test that a missing environment variable raises an error if the value is not set already"""
|
33
|
+
with pytest.raises(KeyError):
|
34
|
+
config.add_envs({"NON_EXISTENT": "non_existent"})
|
35
|
+
with pytest.raises(MissingEnvironmentVariableError):
|
36
|
+
config.add_envs({"NON_EXISTENT": "non_existent"})
|
37
|
+
|
38
|
+
|
39
|
+
def test_missing_env_ok_if_optional(config):
|
40
|
+
"""Test that a missing environment variable is ok if the value is already set"""
|
41
|
+
config_w_env = config.add_envs({"NON_EXISTENT": "number"})
|
42
|
+
assert config_w_env.number == 42
|
43
|
+
|
44
|
+
|
45
|
+
def test_env_prefix(config, monkeypatch):
|
46
|
+
"""Test that a common prefix can be used for environment_variables"""
|
47
|
+
monkeypatch.setenv("EXAMPLE_NUMBER", "14")
|
48
|
+
monkeypatch.setenv("EXAMPLE_WORD", "platypus")
|
49
|
+
config_w_env = config.add_envs(
|
50
|
+
{"NUMBER": "number", "WORD": "nested.word"}, prefix="EXAMPLE_"
|
51
|
+
)
|
52
|
+
assert config_w_env.number == "14"
|
53
|
+
assert config_w_env.nested.word == "platypus"
|
@@ -0,0 +1,44 @@
|
|
1
|
+
"""Test handling of JSON files"""
|
2
|
+
|
3
|
+
import json
|
4
|
+
|
5
|
+
import pytest
|
6
|
+
|
7
|
+
from configaroo import Configuration
|
8
|
+
|
9
|
+
|
10
|
+
def test_can_load_config_from_toml(json_path):
|
11
|
+
"""Test that the TOML file can be loaded"""
|
12
|
+
config = Configuration.from_file(json_path)
|
13
|
+
assert config
|
14
|
+
|
15
|
+
|
16
|
+
def test_can_load_config_with_path_as_str(json_path):
|
17
|
+
"""Test that the path can be specified in a string"""
|
18
|
+
config = Configuration.from_file(str(json_path))
|
19
|
+
assert config
|
20
|
+
|
21
|
+
|
22
|
+
def test_can_specify_loader(other_json_path):
|
23
|
+
"""Test that we can specify the "json" loader"""
|
24
|
+
config = Configuration.from_file(other_json_path, loader="json")
|
25
|
+
assert config
|
26
|
+
|
27
|
+
|
28
|
+
def test_error_on_nonexisting_file():
|
29
|
+
"""Test that a FileNotFoundError is raised if the file doesn't exist"""
|
30
|
+
with pytest.raises(FileNotFoundError):
|
31
|
+
Configuration.from_file("non-existent.json")
|
32
|
+
|
33
|
+
|
34
|
+
def test_error_on_wrong_format(toml_path):
|
35
|
+
"""Test that a JSONDecodeError is raised if the file is not a valid JSON-file"""
|
36
|
+
with pytest.raises(json.JSONDecodeError):
|
37
|
+
Configuration.from_file(toml_path, loader="json")
|
38
|
+
|
39
|
+
|
40
|
+
def test_can_read_json_values(json_path):
|
41
|
+
"""Test that values can be accessed"""
|
42
|
+
config = Configuration.from_file(json_path)
|
43
|
+
assert config.word == "platypus"
|
44
|
+
assert config.nested.seven == 7
|
@@ -0,0 +1,38 @@
|
|
1
|
+
"""Test file loader framework"""
|
2
|
+
|
3
|
+
import pytest
|
4
|
+
|
5
|
+
from configaroo import UnsupportedLoaderError, loaders
|
6
|
+
|
7
|
+
|
8
|
+
def test_unsupported_loader(toml_path):
|
9
|
+
"""Test that calling an unsupported loader fails"""
|
10
|
+
with pytest.raises(UnsupportedLoaderError):
|
11
|
+
loaders.from_file(toml_path, loader="non_existent")
|
12
|
+
|
13
|
+
|
14
|
+
def test_unsupported_suffix(toml_path):
|
15
|
+
"""Test that loading a file with an unsupported suffix fails"""
|
16
|
+
with pytest.raises(UnsupportedLoaderError):
|
17
|
+
loaders.from_file(toml_path.with_suffix(".non_existent"))
|
18
|
+
|
19
|
+
|
20
|
+
def test_error_lists_supported_loaders(toml_path):
|
21
|
+
"""Test that the names of supported loaders are listed when failing"""
|
22
|
+
try:
|
23
|
+
loaders.from_file(toml_path.with_suffix(".non_existent"))
|
24
|
+
except UnsupportedLoaderError as err:
|
25
|
+
for loader in ["json", "toml"]:
|
26
|
+
assert loader in str(err)
|
27
|
+
|
28
|
+
|
29
|
+
def test_toml_returns_dict(toml_path):
|
30
|
+
"""Test that the TOML loader returns a nonempty dictionary"""
|
31
|
+
config_dict = loaders.from_file(toml_path, loader="toml")
|
32
|
+
assert config_dict and isinstance(config_dict, dict)
|
33
|
+
|
34
|
+
|
35
|
+
def test_json_returns_dict(json_path):
|
36
|
+
"""Test that the JSON loader returns a nonempty dictionary"""
|
37
|
+
config_dict = loaders.from_file(json_path, loader="json")
|
38
|
+
assert config_dict and isinstance(config_dict, dict)
|
@@ -0,0 +1,44 @@
|
|
1
|
+
"""Test handling of TOML files"""
|
2
|
+
|
3
|
+
import tomllib
|
4
|
+
|
5
|
+
import pytest
|
6
|
+
|
7
|
+
from configaroo import Configuration
|
8
|
+
|
9
|
+
|
10
|
+
def test_can_load_config_from_toml(toml_path):
|
11
|
+
"""Test that the TOML file can be loaded"""
|
12
|
+
config = Configuration.from_file(toml_path)
|
13
|
+
assert config
|
14
|
+
|
15
|
+
|
16
|
+
def test_can_load_config_with_path_as_str(toml_path):
|
17
|
+
"""Test that the path can be specified in a string"""
|
18
|
+
config = Configuration.from_file(str(toml_path))
|
19
|
+
assert config
|
20
|
+
|
21
|
+
|
22
|
+
def test_can_specify_loader(other_toml_path):
|
23
|
+
"""Test that we can specify the "toml" loader"""
|
24
|
+
config = Configuration.from_file(other_toml_path, loader="toml")
|
25
|
+
assert config
|
26
|
+
|
27
|
+
|
28
|
+
def test_error_on_nonexisting_file():
|
29
|
+
"""Test that a FileNotFoundError is raised if the file doesn't exist"""
|
30
|
+
with pytest.raises(FileNotFoundError):
|
31
|
+
Configuration.from_file("non-existent.toml")
|
32
|
+
|
33
|
+
|
34
|
+
def test_error_on_wrong_format(json_path):
|
35
|
+
"""Test that a TOMLDecodeError is raised if the file is not a valid TOML-file"""
|
36
|
+
with pytest.raises(tomllib.TOMLDecodeError):
|
37
|
+
Configuration.from_file(json_path, loader="toml")
|
38
|
+
|
39
|
+
|
40
|
+
def test_can_read_toml_values(toml_path):
|
41
|
+
"""Test that values can be accessed"""
|
42
|
+
config = Configuration.from_file(toml_path)
|
43
|
+
assert config.word == "platypus"
|
44
|
+
assert config.nested.seven == 7
|
@@ -0,0 +1,42 @@
|
|
1
|
+
"""Test validation and type conversion with Pydantic"""
|
2
|
+
|
3
|
+
from pathlib import Path
|
4
|
+
|
5
|
+
import pydantic
|
6
|
+
import pytest
|
7
|
+
|
8
|
+
from configaroo import Configuration
|
9
|
+
|
10
|
+
|
11
|
+
def test_can_validate(config, model):
|
12
|
+
"""Test that a configuration can be validated"""
|
13
|
+
assert config.validate(model)
|
14
|
+
|
15
|
+
|
16
|
+
def test_wrong_key_raises(model):
|
17
|
+
"""Test that a wrong key raises an error"""
|
18
|
+
config = Configuration(
|
19
|
+
digit=4, nested={"pie": 3.14, "seven": 7}, path="files/config.toml"
|
20
|
+
)
|
21
|
+
with pytest.raises(pydantic.ValidationError):
|
22
|
+
config.validate(model)
|
23
|
+
|
24
|
+
|
25
|
+
def test_missing_key_raises(model):
|
26
|
+
"""Test that a missing key raises an error"""
|
27
|
+
config = Configuration(nested={"pie": 3.14, "seven": 7}, path="files/config.toml")
|
28
|
+
with pytest.raises(pydantic.ValidationError):
|
29
|
+
config.validate(model)
|
30
|
+
|
31
|
+
|
32
|
+
def test_extra_key_ok(config, model):
|
33
|
+
"""Test that an extra key raises when the model is strict"""
|
34
|
+
updated_config = config | {"new_word": "cuckoo-bird"}
|
35
|
+
with pytest.raises(pydantic.ValidationError):
|
36
|
+
updated_config.validate(model)
|
37
|
+
|
38
|
+
|
39
|
+
def test_type_conversion(config, model):
|
40
|
+
config_w_types = config.convert(model)
|
41
|
+
assert isinstance(config.paths.relative, str)
|
42
|
+
assert isinstance(config_w_types.paths.relative, Path)
|