configaroo 0.4.0__tar.gz → 0.4.2__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.4.0/src/configaroo.egg-info → configaroo-0.4.2}/PKG-INFO +1 -1
- {configaroo-0.4.0 → configaroo-0.4.2}/pyproject.toml +1 -1
- {configaroo-0.4.0 → configaroo-0.4.2}/src/configaroo/__init__.py +7 -2
- {configaroo-0.4.0 → configaroo-0.4.2}/src/configaroo/configuration.py +55 -24
- {configaroo-0.4.0 → configaroo-0.4.2/src/configaroo.egg-info}/PKG-INFO +1 -1
- {configaroo-0.4.0 → configaroo-0.4.2}/tests/test_environment.py +60 -1
- {configaroo-0.4.0 → configaroo-0.4.2}/LICENSE +0 -0
- {configaroo-0.4.0 → configaroo-0.4.2}/README.md +0 -0
- {configaroo-0.4.0 → configaroo-0.4.2}/setup.cfg +0 -0
- {configaroo-0.4.0 → configaroo-0.4.2}/src/configaroo/exceptions.py +0 -0
- {configaroo-0.4.0 → configaroo-0.4.2}/src/configaroo/loaders/__init__.py +0 -0
- {configaroo-0.4.0 → configaroo-0.4.2}/src/configaroo/loaders/json.py +0 -0
- {configaroo-0.4.0 → configaroo-0.4.2}/src/configaroo/loaders/toml.py +0 -0
- {configaroo-0.4.0 → configaroo-0.4.2}/src/configaroo/py.typed +0 -0
- {configaroo-0.4.0 → configaroo-0.4.2}/src/configaroo.egg-info/SOURCES.txt +0 -0
- {configaroo-0.4.0 → configaroo-0.4.2}/src/configaroo.egg-info/dependency_links.txt +0 -0
- {configaroo-0.4.0 → configaroo-0.4.2}/src/configaroo.egg-info/requires.txt +0 -0
- {configaroo-0.4.0 → configaroo-0.4.2}/src/configaroo.egg-info/top_level.txt +0 -0
- {configaroo-0.4.0 → configaroo-0.4.2}/tests/test_configuration.py +0 -0
- {configaroo-0.4.0 → configaroo-0.4.2}/tests/test_dynamic.py +0 -0
- {configaroo-0.4.0 → configaroo-0.4.2}/tests/test_json.py +0 -0
- {configaroo-0.4.0 → configaroo-0.4.2}/tests/test_loaders.py +0 -0
- {configaroo-0.4.0 → configaroo-0.4.2}/tests/test_print.py +0 -0
- {configaroo-0.4.0 → configaroo-0.4.2}/tests/test_toml.py +0 -0
- {configaroo-0.4.0 → configaroo-0.4.2}/tests/test_validation.py +0 -0
@@ -79,7 +79,7 @@ python_version = "3.11"
|
|
79
79
|
strict = true
|
80
80
|
|
81
81
|
[tool.bumpver]
|
82
|
-
current_version = "v0.4.
|
82
|
+
current_version = "v0.4.2"
|
83
83
|
version_pattern = "vMAJOR.MINOR.PATCH"
|
84
84
|
commit_message = "bump version {old_version} -> {new_version}"
|
85
85
|
tag_message = "{new_version}"
|
@@ -1,6 +1,10 @@
|
|
1
1
|
"""Bouncy configuration handling."""
|
2
2
|
|
3
|
-
from configaroo.configuration import
|
3
|
+
from configaroo.configuration import (
|
4
|
+
Configuration,
|
5
|
+
find_pyproject_toml,
|
6
|
+
print_configuration,
|
7
|
+
)
|
4
8
|
from configaroo.exceptions import (
|
5
9
|
ConfigarooError,
|
6
10
|
MissingEnvironmentVariableError,
|
@@ -12,7 +16,8 @@ __all__ = [
|
|
12
16
|
"Configuration",
|
13
17
|
"MissingEnvironmentVariableError",
|
14
18
|
"UnsupportedLoaderError",
|
19
|
+
"find_pyproject_toml",
|
15
20
|
"print_configuration",
|
16
21
|
]
|
17
22
|
|
18
|
-
__version__ = "0.4.
|
23
|
+
__version__ = "0.4.2"
|
@@ -6,6 +6,7 @@ import re
|
|
6
6
|
from collections import UserDict
|
7
7
|
from collections.abc import Callable
|
8
8
|
from pathlib import Path
|
9
|
+
from types import UnionType
|
9
10
|
from typing import Any, Self, TypeVar
|
10
11
|
|
11
12
|
from pydantic import BaseModel
|
@@ -99,30 +100,6 @@ class Configuration(UserDict[str, Any]):
|
|
99
100
|
cls = type(self)
|
100
101
|
return self | {prefix: cls(self.setdefault(prefix, {})).add(rest, value)}
|
101
102
|
|
102
|
-
def add_envs(self, envs: dict[str, str] | None = None, prefix: str = "") -> Self:
|
103
|
-
"""Add environment variables to configuration.
|
104
|
-
|
105
|
-
If you don't specify which environment variables to read, you'll
|
106
|
-
automatically add any that matches a top-level value of the
|
107
|
-
configuration.
|
108
|
-
"""
|
109
|
-
if envs is None:
|
110
|
-
# Automatically add top-level configuration items
|
111
|
-
envs = {
|
112
|
-
re.sub(r"\W", "_", key).upper(): key
|
113
|
-
for key, value in self.data.items()
|
114
|
-
if isinstance(value, str | int | float)
|
115
|
-
}
|
116
|
-
|
117
|
-
# Read environment variables
|
118
|
-
for env, key in envs.items():
|
119
|
-
env_key = f"{prefix}{env}"
|
120
|
-
if env_value := os.getenv(env_key):
|
121
|
-
self = self.add(key, env_value) # noqa: PLW0642
|
122
|
-
elif key not in self:
|
123
|
-
raise MissingEnvironmentVariableError(env_key)
|
124
|
-
return self
|
125
|
-
|
126
103
|
def parse_dynamic(
|
127
104
|
self, extra: dict[str, Any] | None = None, *, _include_self: bool = True
|
128
105
|
) -> Self:
|
@@ -150,6 +127,60 @@ class Configuration(UserDict[str, Any]):
|
|
150
127
|
# Continue parsing until no more replacements are made.
|
151
128
|
return parsed.parse_dynamic(extra=extra, _include_self=_include_self)
|
152
129
|
|
130
|
+
def add_envs(self, envs: dict[str, str] | None = None, prefix: str = "") -> Self:
|
131
|
+
"""Add environment variables to configuration.
|
132
|
+
|
133
|
+
If you don't specify which environment variables to read, you'll
|
134
|
+
automatically add any that matches a simple top-level value of the
|
135
|
+
configuration.
|
136
|
+
"""
|
137
|
+
if envs is None:
|
138
|
+
# Automatically add top-level configuration items
|
139
|
+
envs = {
|
140
|
+
re.sub(r"\W", "_", key).upper(): key
|
141
|
+
for key, value in self.data.items()
|
142
|
+
if isinstance(value, str | int | float)
|
143
|
+
}
|
144
|
+
|
145
|
+
# Read environment variables
|
146
|
+
for env, key in envs.items():
|
147
|
+
env_key = f"{prefix}{env}"
|
148
|
+
if env_value := os.getenv(env_key):
|
149
|
+
self = self.add(key, env_value) # noqa: PLW0642
|
150
|
+
elif key not in self:
|
151
|
+
raise MissingEnvironmentVariableError(env_key)
|
152
|
+
return self
|
153
|
+
|
154
|
+
def add_envs_from_model(
|
155
|
+
self,
|
156
|
+
model: type[BaseModel],
|
157
|
+
prefix: str = "",
|
158
|
+
types: type | UnionType = str | bool | int | float,
|
159
|
+
) -> Self:
|
160
|
+
"""Add environment variables to configuration based on the given model.
|
161
|
+
|
162
|
+
Top level string, bool, integer, and float fields from the model are
|
163
|
+
looked for among environment variables.
|
164
|
+
"""
|
165
|
+
|
166
|
+
def _get_class_from_annotation(annotation: type) -> type:
|
167
|
+
"""Unpack generic annotations and return the underlying class."""
|
168
|
+
return (
|
169
|
+
_get_class_from_annotation(annotation.__origin__)
|
170
|
+
if hasattr(annotation, "__origin__")
|
171
|
+
else annotation
|
172
|
+
)
|
173
|
+
|
174
|
+
envs = {
|
175
|
+
re.sub(r"\W", "_", key).upper(): key
|
176
|
+
for key, field in model.model_fields.items()
|
177
|
+
if (
|
178
|
+
field.annotation is not None
|
179
|
+
and issubclass(_get_class_from_annotation(field.annotation), types)
|
180
|
+
)
|
181
|
+
}
|
182
|
+
return self.add_envs(envs, prefix=prefix)
|
183
|
+
|
153
184
|
def validate_model(self, model: type[BaseModel]) -> Self:
|
154
185
|
"""Validate the configuration against the given model."""
|
155
186
|
model.model_validate(self.data)
|
@@ -1,6 +1,7 @@
|
|
1
1
|
"""Test handling of environment variables."""
|
2
2
|
|
3
3
|
import pytest
|
4
|
+
from pydantic import BaseModel, SecretStr
|
4
5
|
|
5
6
|
from configaroo import Configuration, MissingEnvironmentVariableError
|
6
7
|
|
@@ -25,6 +26,7 @@ def test_several_envs(config: Configuration, monkeypatch: pytest.MonkeyPatch) ->
|
|
25
26
|
"""Test that we can read several environment variables."""
|
26
27
|
monkeypatch.setenv("WORD", "platypus")
|
27
28
|
monkeypatch.setenv("NEW_PATH", "files/config.json")
|
29
|
+
|
28
30
|
config_w_env = config.add_envs({"WORD": "nested.word", "NEW_PATH": "path"})
|
29
31
|
assert config_w_env.nested.word == "platypus"
|
30
32
|
assert config_w_env.path == "files/config.json"
|
@@ -48,6 +50,7 @@ def test_env_prefix(config: Configuration, monkeypatch: pytest.MonkeyPatch) -> N
|
|
48
50
|
"""Test that a common prefix can be used for environment variables."""
|
49
51
|
monkeypatch.setenv("EXAMPLE_NUMBER", "14")
|
50
52
|
monkeypatch.setenv("EXAMPLE_WORD", "platypus")
|
53
|
+
|
51
54
|
config_w_env = config.add_envs(
|
52
55
|
{"NUMBER": "number", "WORD": "nested.word"}, prefix="EXAMPLE_"
|
53
56
|
)
|
@@ -61,9 +64,65 @@ def test_env_automatic(config: Configuration, monkeypatch: pytest.MonkeyPatch) -
|
|
61
64
|
monkeypatch.setenv("WORD", "kangaroo")
|
62
65
|
monkeypatch.setenv("A_B_D_KEY_", "works")
|
63
66
|
monkeypatch.setenv("NESTED", "should not be replaced")
|
64
|
-
config_w_env = (config | {"A b@d-key!": ""}).add_envs()
|
65
67
|
|
68
|
+
config_w_env = (config | {"A b@d-key!": ""}).add_envs()
|
66
69
|
assert config_w_env.number == "28"
|
67
70
|
assert config_w_env.word == "kangaroo"
|
68
71
|
assert config_w_env["A b@d-key!"] == "works"
|
69
72
|
assert config_w_env.nested != "should not be replaced"
|
73
|
+
|
74
|
+
|
75
|
+
def test_env_from_model(monkeypatch: pytest.MonkeyPatch) -> None:
|
76
|
+
"""Test that environment variables can be found and set based on a model."""
|
77
|
+
|
78
|
+
class TestModel(BaseModel):
|
79
|
+
first_name: str
|
80
|
+
age: int
|
81
|
+
check: bool
|
82
|
+
scores: dict[str, int]
|
83
|
+
|
84
|
+
monkeypatch.setenv("TEST_FIRST_NAME", "Michael J.")
|
85
|
+
monkeypatch.setenv("TEST_AGE", "47")
|
86
|
+
monkeypatch.setenv("TEST_CHECK", "true")
|
87
|
+
monkeypatch.setenv("TEST_SCORES", '{"England": 1, "Spain": 0}')
|
88
|
+
monkeypatch.setenv("TEST_UNKNOWN", "Will be ignored")
|
89
|
+
|
90
|
+
config_w_env = Configuration().add_envs_from_model(TestModel, prefix="TEST_")
|
91
|
+
assert config_w_env.first_name == "Michael J."
|
92
|
+
assert config_w_env.age == "47"
|
93
|
+
assert config_w_env.check == "true"
|
94
|
+
assert "scores" not in config_w_env.data # Complex types are ignored
|
95
|
+
assert "unknown" not in config_w_env.data # Unspecified fields are ignored
|
96
|
+
|
97
|
+
|
98
|
+
def test_env_from_model_w_custom_types(monkeypatch: pytest.MonkeyPatch) -> None:
|
99
|
+
"""Test that custom types can be used when discovering env variables from models."""
|
100
|
+
|
101
|
+
class TestModel(BaseModel):
|
102
|
+
first_name: str
|
103
|
+
age: int
|
104
|
+
hush: SecretStr
|
105
|
+
countries: list[str]
|
106
|
+
|
107
|
+
monkeypatch.setenv("TEST_FIRST_NAME", "Michael J.")
|
108
|
+
monkeypatch.setenv("TEST_AGE", "47")
|
109
|
+
monkeypatch.setenv("TEST_HUSH", "hush-hush")
|
110
|
+
monkeypatch.setenv("TEST_COUNTRIES", "England Australia Norway Spain")
|
111
|
+
|
112
|
+
config_w_env = Configuration().add_envs_from_model(
|
113
|
+
TestModel, prefix="TEST_", types=str | SecretStr | list
|
114
|
+
)
|
115
|
+
assert config_w_env.first_name == "Michael J."
|
116
|
+
assert config_w_env.hush == "hush-hush"
|
117
|
+
assert config_w_env.countries.split() == ["England", "Australia", "Norway", "Spain"]
|
118
|
+
assert "age" not in config_w_env # int is not specified as a type to include
|
119
|
+
|
120
|
+
|
121
|
+
def test_env_from_model_raises_if_missing() -> None:
|
122
|
+
"""Test that missing environment variables defined in a model raises an error."""
|
123
|
+
|
124
|
+
class TestModel(BaseModel):
|
125
|
+
nonexistent_env: str
|
126
|
+
|
127
|
+
with pytest.raises(MissingEnvironmentVariableError):
|
128
|
+
Configuration().add_envs_from_model(TestModel)
|
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
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|