configaroo 0.1.3__tar.gz → 0.2.1__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.3 → configaroo-0.2.1}/PKG-INFO +1 -1
- {configaroo-0.1.3 → configaroo-0.2.1}/pyproject.toml +2 -2
- configaroo-0.2.1/src/configaroo/__init__.py +17 -0
- {configaroo-0.1.3 → configaroo-0.2.1}/src/configaroo/configuration.py +23 -18
- {configaroo-0.1.3 → configaroo-0.2.1}/src/configaroo.egg-info/PKG-INFO +1 -1
- {configaroo-0.1.3 → configaroo-0.2.1}/src/configaroo.egg-info/SOURCES.txt +1 -0
- {configaroo-0.1.3 → configaroo-0.2.1}/tests/test_configuration.py +7 -48
- configaroo-0.2.1/tests/test_dynamic.py +69 -0
- {configaroo-0.1.3 → configaroo-0.2.1}/tests/test_validation.py +24 -5
- configaroo-0.1.3/src/configaroo/__init__.py +0 -10
- {configaroo-0.1.3 → configaroo-0.2.1}/LICENSE +0 -0
- {configaroo-0.1.3 → configaroo-0.2.1}/README.md +0 -0
- {configaroo-0.1.3 → configaroo-0.2.1}/setup.cfg +0 -0
- {configaroo-0.1.3 → configaroo-0.2.1}/src/configaroo/exceptions.py +0 -0
- {configaroo-0.1.3 → configaroo-0.2.1}/src/configaroo/loaders/__init__.py +0 -0
- {configaroo-0.1.3 → configaroo-0.2.1}/src/configaroo/loaders/json.py +0 -0
- {configaroo-0.1.3 → configaroo-0.2.1}/src/configaroo/loaders/toml.py +0 -0
- {configaroo-0.1.3 → configaroo-0.2.1}/src/configaroo/py.typed +0 -0
- {configaroo-0.1.3 → configaroo-0.2.1}/src/configaroo.egg-info/dependency_links.txt +0 -0
- {configaroo-0.1.3 → configaroo-0.2.1}/src/configaroo.egg-info/requires.txt +0 -0
- {configaroo-0.1.3 → configaroo-0.2.1}/src/configaroo.egg-info/top_level.txt +0 -0
- {configaroo-0.1.3 → configaroo-0.2.1}/tests/test_environment.py +0 -0
- {configaroo-0.1.3 → configaroo-0.2.1}/tests/test_json.py +0 -0
- {configaroo-0.1.3 → configaroo-0.2.1}/tests/test_loaders.py +0 -0
- {configaroo-0.1.3 → configaroo-0.2.1}/tests/test_toml.py +0 -0
@@ -50,13 +50,13 @@ dev = [
|
|
50
50
|
version = { attr = "configaroo.__version__" }
|
51
51
|
|
52
52
|
[tool.bumpver]
|
53
|
-
current_version = "v0.1
|
53
|
+
current_version = "v0.2.1"
|
54
54
|
version_pattern = "vMAJOR.MINOR.PATCH"
|
55
55
|
commit_message = "bump version {old_version} -> {new_version}"
|
56
56
|
tag_message = "{new_version}"
|
57
57
|
commit = true
|
58
58
|
tag = true
|
59
|
-
push =
|
59
|
+
push = true
|
60
60
|
|
61
61
|
[tool.bumpver.file_patterns]
|
62
62
|
"pyproject.toml" = ['current_version = "{version}"']
|
@@ -0,0 +1,17 @@
|
|
1
|
+
"""Bouncy configuration handling"""
|
2
|
+
|
3
|
+
from configaroo.configuration import Configuration
|
4
|
+
from configaroo.exceptions import (
|
5
|
+
ConfigarooException,
|
6
|
+
MissingEnvironmentVariableError,
|
7
|
+
UnsupportedLoaderError,
|
8
|
+
)
|
9
|
+
|
10
|
+
__all__ = [
|
11
|
+
"Configuration",
|
12
|
+
"ConfigarooException",
|
13
|
+
"MissingEnvironmentVariableError",
|
14
|
+
"UnsupportedLoaderError",
|
15
|
+
]
|
16
|
+
|
17
|
+
__version__ = "0.2.1"
|
@@ -5,13 +5,15 @@ import os
|
|
5
5
|
import re
|
6
6
|
from collections import UserDict
|
7
7
|
from pathlib import Path
|
8
|
-
from typing import Any, Self, Type
|
8
|
+
from typing import Any, Self, Type, TypeVar
|
9
9
|
|
10
10
|
from pydantic import BaseModel
|
11
11
|
|
12
12
|
from configaroo import loaders
|
13
13
|
from configaroo.exceptions import MissingEnvironmentVariableError
|
14
14
|
|
15
|
+
ModelT = TypeVar("ModelT", bound=BaseModel)
|
16
|
+
|
15
17
|
|
16
18
|
class Configuration(UserDict):
|
17
19
|
"""A Configuration is a dict-like structure with some conveniences"""
|
@@ -24,12 +26,11 @@ class Configuration(UserDict):
|
|
24
26
|
envs: dict[str, str] | None = None,
|
25
27
|
env_prefix: str = "",
|
26
28
|
extra_dynamic: dict[str, Any] | None = None,
|
27
|
-
model: Type[BaseModel] | None = None,
|
28
29
|
) -> Self:
|
29
30
|
"""Read a Configuration from a file"""
|
30
31
|
config_dict = loaders.from_file(file_path, loader=loader)
|
31
32
|
return cls(config_dict).initialize(
|
32
|
-
envs=envs, env_prefix=env_prefix, extra_dynamic=extra_dynamic
|
33
|
+
envs=envs, env_prefix=env_prefix, extra_dynamic=extra_dynamic
|
33
34
|
)
|
34
35
|
|
35
36
|
def initialize(
|
@@ -37,18 +38,17 @@ class Configuration(UserDict):
|
|
37
38
|
envs: dict[str, str] | None = None,
|
38
39
|
env_prefix: str = "",
|
39
40
|
extra_dynamic: dict[str, Any] | None = None,
|
40
|
-
model: Type[BaseModel] | None = None,
|
41
41
|
) -> Self:
|
42
42
|
"""Initialize a configuration.
|
43
43
|
|
44
|
-
The initialization adds environment variables
|
45
|
-
validates against a Pydantic model, and converts value types using the
|
46
|
-
same model.
|
44
|
+
The initialization adds environment variables and parses dynamic values.
|
47
45
|
"""
|
48
46
|
self = self if envs is None else self.add_envs(envs, prefix=env_prefix)
|
49
|
-
|
50
|
-
|
51
|
-
|
47
|
+
return self.parse_dynamic(extra_dynamic)
|
48
|
+
|
49
|
+
def with_model(self, model: Type[ModelT]) -> ModelT:
|
50
|
+
"""Apply a pydantic model to a configuration."""
|
51
|
+
return self.validate_model(model).convert_model(model)
|
52
52
|
|
53
53
|
def __getitem__(self, key: str) -> Any:
|
54
54
|
"""Make sure nested sections have type Configuration"""
|
@@ -108,18 +108,20 @@ class Configuration(UserDict):
|
|
108
108
|
)
|
109
109
|
return self
|
110
110
|
|
111
|
-
def parse_dynamic(
|
111
|
+
def parse_dynamic(
|
112
|
+
self, extra: dict[str, Any] | None = None, _include_self: bool = True
|
113
|
+
) -> Self:
|
112
114
|
"""Parse dynamic values of the form {section.key}"""
|
113
115
|
cls = type(self)
|
114
116
|
variables = (
|
115
|
-
self.to_flat_dict()
|
117
|
+
(self.to_flat_dict() if _include_self else {})
|
116
118
|
| {"project_path": _find_pyproject_toml()}
|
117
119
|
| ({} if extra is None else extra)
|
118
120
|
)
|
119
|
-
|
121
|
+
parsed = cls(
|
120
122
|
{
|
121
123
|
key: (
|
122
|
-
value.parse_dynamic(extra=variables)
|
124
|
+
value.parse_dynamic(extra=variables, _include_self=False)
|
123
125
|
if isinstance(value, Configuration)
|
124
126
|
else _incomplete_format(value, variables)
|
125
127
|
if isinstance(value, str)
|
@@ -128,16 +130,19 @@ class Configuration(UserDict):
|
|
128
130
|
for key, value in self.items()
|
129
131
|
}
|
130
132
|
)
|
133
|
+
if parsed == self:
|
134
|
+
return parsed
|
135
|
+
# Continue parsing until no more replacements are made.
|
136
|
+
return parsed.parse_dynamic(extra=extra, _include_self=_include_self)
|
131
137
|
|
132
|
-
def
|
138
|
+
def validate_model(self, model: Type[BaseModel]) -> Self:
|
133
139
|
"""Validate the configuration against the given model."""
|
134
140
|
model.model_validate(self.data)
|
135
141
|
return self
|
136
142
|
|
137
|
-
def
|
143
|
+
def convert_model(self, model: Type[ModelT]) -> ModelT:
|
138
144
|
"""Convert data types to match the given model"""
|
139
|
-
|
140
|
-
return cls(model(**self.data).model_dump())
|
145
|
+
return model(**self.data)
|
141
146
|
|
142
147
|
def to_dict(self) -> dict[str, Any]:
|
143
148
|
"""Dump the configuration into a Python dictionary"""
|
@@ -4,6 +4,7 @@ from pathlib import Path
|
|
4
4
|
|
5
5
|
import pytest
|
6
6
|
|
7
|
+
import configaroo
|
7
8
|
from configaroo import Configuration, configuration
|
8
9
|
|
9
10
|
|
@@ -101,54 +102,6 @@ def test_contains_with_dotted_key(config):
|
|
101
102
|
assert "nested.number" not in config
|
102
103
|
|
103
104
|
|
104
|
-
def test_parse_dynamic_default(config, file_path):
|
105
|
-
"""Test parsing of default dynamic variables"""
|
106
|
-
parsed_config = (config | {"diameter": "2 x {nested.pie}"}).parse_dynamic()
|
107
|
-
print("pyproject.toml dir: ", configuration._find_pyproject_toml(file_path))
|
108
|
-
print(f"{parsed_config.paths.dynamic = }")
|
109
|
-
assert parsed_config.paths.dynamic == str(file_path)
|
110
|
-
assert parsed_config.phrase == "The meaning of life is 42"
|
111
|
-
assert parsed_config.diameter == "2 x 3.14"
|
112
|
-
|
113
|
-
|
114
|
-
def test_parse_dynamic_extra(config, file_path):
|
115
|
-
"""Test parsing of extra dynamic variables"""
|
116
|
-
parsed_config = (config | {"animal": "{adjective} kangaroo"}).parse_dynamic(
|
117
|
-
extra={"number": 14, "adjective": "bouncy"}
|
118
|
-
)
|
119
|
-
assert parsed_config.paths.dynamic == str(file_path)
|
120
|
-
assert parsed_config.phrase == "The meaning of life is 14"
|
121
|
-
assert parsed_config.animal == "bouncy kangaroo"
|
122
|
-
|
123
|
-
|
124
|
-
def test_parse_dynamic_formatted(config):
|
125
|
-
"""Test that formatting works for dynamic variables"""
|
126
|
-
parsed_config = (
|
127
|
-
config
|
128
|
-
| {
|
129
|
-
"string": "Hey {word!r}",
|
130
|
-
"three": "->{nested.pie:6.0f}<-",
|
131
|
-
"centered": "|{word:^12}|",
|
132
|
-
}
|
133
|
-
).parse_dynamic()
|
134
|
-
assert parsed_config.centered == "| platypus |"
|
135
|
-
assert parsed_config.three == "-> 3<-"
|
136
|
-
assert parsed_config.string == "Hey 'platypus'"
|
137
|
-
|
138
|
-
|
139
|
-
def test_parse_dynamic_ignore(config):
|
140
|
-
"""Test that parsing of dynamic variables ignores unknown replacements"""
|
141
|
-
parsed_config = (
|
142
|
-
config
|
143
|
-
| {
|
144
|
-
"animal": "{adjective} kangaroo",
|
145
|
-
"phrase": "one {nested.non_existent} dollar",
|
146
|
-
}
|
147
|
-
).parse_dynamic()
|
148
|
-
assert parsed_config.animal == "{adjective} kangaroo"
|
149
|
-
assert parsed_config.phrase == "one {nested.non_existent} dollar"
|
150
|
-
|
151
|
-
|
152
105
|
def test_find_pyproject_toml():
|
153
106
|
"""Test that the pyproject.toml file can be located"""
|
154
107
|
assert configuration._find_pyproject_toml() == Path(__file__).parent.parent
|
@@ -166,3 +119,9 @@ def test_incomplete_formatter():
|
|
166
119
|
{"number": 3.14, "string": "platypus", "name": "Geir Arne"},
|
167
120
|
)
|
168
121
|
assert formatted == " 3.1 {non_existent} 'platypus' Geir Arne"
|
122
|
+
|
123
|
+
|
124
|
+
def test_public_classes_are_exposed():
|
125
|
+
"""Test that the __all__ attribute exposes all public classes"""
|
126
|
+
public_classes = [attr for attr in dir(configaroo) if "A" <= attr[:1] <= "Z"]
|
127
|
+
assert sorted(public_classes) == sorted(configaroo.__all__)
|
@@ -0,0 +1,69 @@
|
|
1
|
+
"""Test handling of dynamic variables"""
|
2
|
+
|
3
|
+
from pathlib import Path
|
4
|
+
|
5
|
+
import pytest
|
6
|
+
|
7
|
+
|
8
|
+
@pytest.fixture
|
9
|
+
def file_path():
|
10
|
+
"""The path to the current file"""
|
11
|
+
return Path(__file__).resolve()
|
12
|
+
|
13
|
+
|
14
|
+
def test_parse_dynamic_default(config, file_path):
|
15
|
+
"""Test parsing of default dynamic variables"""
|
16
|
+
parsed_config = (config | {"diameter": "2 x {nested.pie}"}).parse_dynamic()
|
17
|
+
assert parsed_config.paths.dynamic == str(file_path)
|
18
|
+
assert parsed_config.phrase == "The meaning of life is 42"
|
19
|
+
assert parsed_config.diameter == "2 x 3.14"
|
20
|
+
|
21
|
+
|
22
|
+
def test_parse_dynamic_extra(config, file_path):
|
23
|
+
"""Test parsing of extra dynamic variables"""
|
24
|
+
parsed_config = (config | {"animal": "{adjective} kangaroo"}).parse_dynamic(
|
25
|
+
extra={"number": 14, "adjective": "bouncy"}
|
26
|
+
)
|
27
|
+
assert parsed_config.paths.dynamic == str(file_path)
|
28
|
+
assert parsed_config.phrase == "The meaning of life is 14"
|
29
|
+
assert parsed_config.animal == "bouncy kangaroo"
|
30
|
+
|
31
|
+
|
32
|
+
def test_parse_dynamic_formatted(config):
|
33
|
+
"""Test that formatting works for dynamic variables"""
|
34
|
+
parsed_config = (
|
35
|
+
config
|
36
|
+
| {
|
37
|
+
"string": "Hey {word!r}",
|
38
|
+
"three": "->{nested.pie:6.0f}<-",
|
39
|
+
"centered": "|{word:^12}|",
|
40
|
+
}
|
41
|
+
).parse_dynamic()
|
42
|
+
assert parsed_config.centered == "| platypus |"
|
43
|
+
assert parsed_config.three == "-> 3<-"
|
44
|
+
assert parsed_config.string == "Hey 'platypus'"
|
45
|
+
|
46
|
+
|
47
|
+
def test_parse_dynamic_ignore(config):
|
48
|
+
"""Test that parsing of dynamic variables ignores unknown replacements"""
|
49
|
+
parsed_config = (
|
50
|
+
config
|
51
|
+
| {
|
52
|
+
"animal": "{adjective} kangaroo",
|
53
|
+
"phrase": "one {nested.non_existent} dollar",
|
54
|
+
}
|
55
|
+
).parse_dynamic()
|
56
|
+
assert parsed_config.animal == "{adjective} kangaroo"
|
57
|
+
assert parsed_config.phrase == "one {nested.non_existent} dollar"
|
58
|
+
|
59
|
+
|
60
|
+
def test_parse_dynamic_nested(config, file_path):
|
61
|
+
"""Test that parsing dynamic variables referring to other dynamic variables work"""
|
62
|
+
parsed_config = config.parse_dynamic()
|
63
|
+
assert parsed_config.paths.nested == str(file_path)
|
64
|
+
|
65
|
+
|
66
|
+
def test_parse_dynamic_only_full_name(config):
|
67
|
+
"""Test that parsing dynamic variables only use full dotted name"""
|
68
|
+
parsed_config = config.parse_dynamic()
|
69
|
+
assert parsed_config.log.format == config.log.format
|
@@ -10,7 +10,7 @@ from configaroo import Configuration
|
|
10
10
|
|
11
11
|
def test_can_validate(config, model):
|
12
12
|
"""Test that a configuration can be validated"""
|
13
|
-
assert config.
|
13
|
+
assert config.validate_model(model)
|
14
14
|
|
15
15
|
|
16
16
|
def test_wrong_key_raises(model):
|
@@ -19,24 +19,43 @@ def test_wrong_key_raises(model):
|
|
19
19
|
digit=4, nested={"pie": 3.14, "seven": 7}, path="files/config.toml"
|
20
20
|
)
|
21
21
|
with pytest.raises(pydantic.ValidationError):
|
22
|
-
config.
|
22
|
+
config.validate_model(model)
|
23
23
|
|
24
24
|
|
25
25
|
def test_missing_key_raises(model):
|
26
26
|
"""Test that a missing key raises an error"""
|
27
27
|
config = Configuration(nested={"pie": 3.14, "seven": 7}, path="files/config.toml")
|
28
28
|
with pytest.raises(pydantic.ValidationError):
|
29
|
-
config.
|
29
|
+
config.validate_model(model)
|
30
30
|
|
31
31
|
|
32
32
|
def test_extra_key_ok(config, model):
|
33
33
|
"""Test that an extra key raises when the model is strict"""
|
34
34
|
updated_config = config | {"new_word": "cuckoo-bird"}
|
35
35
|
with pytest.raises(pydantic.ValidationError):
|
36
|
-
updated_config.
|
36
|
+
updated_config.validate_model(model)
|
37
37
|
|
38
38
|
|
39
39
|
def test_type_conversion(config, model):
|
40
|
-
|
40
|
+
"""Test that types can be converted based on the model"""
|
41
|
+
config_w_types = config.convert_model(model)
|
41
42
|
assert isinstance(config.paths.relative, str)
|
42
43
|
assert isinstance(config_w_types.paths.relative, Path)
|
44
|
+
|
45
|
+
|
46
|
+
def test_converted_model_is_pydantic(config, model):
|
47
|
+
"""Test that the converted model is a BaseModel which helps with auto-complete"""
|
48
|
+
config_w_types = config.convert_model(model=model)
|
49
|
+
assert isinstance(config_w_types, pydantic.BaseModel)
|
50
|
+
|
51
|
+
|
52
|
+
def test_validate_and_convert(config, model):
|
53
|
+
config_w_model = config.with_model(model)
|
54
|
+
assert isinstance(config_w_model, pydantic.BaseModel)
|
55
|
+
assert isinstance(config_w_model.paths.relative, Path)
|
56
|
+
|
57
|
+
|
58
|
+
def test_convert_to_path(config, model):
|
59
|
+
paths_cfg = config.parse_dynamic().with_model(model).paths
|
60
|
+
assert isinstance(paths_cfg.relative, Path) and paths_cfg.relative.exists()
|
61
|
+
assert isinstance(paths_cfg.directory, Path) and paths_cfg.directory.is_dir()
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|