configaroo 0.4.0__tar.gz → 0.4.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.
Files changed (25) hide show
  1. {configaroo-0.4.0/src/configaroo.egg-info → configaroo-0.4.1}/PKG-INFO +1 -1
  2. {configaroo-0.4.0 → configaroo-0.4.1}/pyproject.toml +1 -1
  3. {configaroo-0.4.0 → configaroo-0.4.1}/src/configaroo/__init__.py +1 -1
  4. {configaroo-0.4.0 → configaroo-0.4.1}/src/configaroo/configuration.py +55 -24
  5. {configaroo-0.4.0 → configaroo-0.4.1/src/configaroo.egg-info}/PKG-INFO +1 -1
  6. {configaroo-0.4.0 → configaroo-0.4.1}/tests/test_environment.py +60 -1
  7. {configaroo-0.4.0 → configaroo-0.4.1}/LICENSE +0 -0
  8. {configaroo-0.4.0 → configaroo-0.4.1}/README.md +0 -0
  9. {configaroo-0.4.0 → configaroo-0.4.1}/setup.cfg +0 -0
  10. {configaroo-0.4.0 → configaroo-0.4.1}/src/configaroo/exceptions.py +0 -0
  11. {configaroo-0.4.0 → configaroo-0.4.1}/src/configaroo/loaders/__init__.py +0 -0
  12. {configaroo-0.4.0 → configaroo-0.4.1}/src/configaroo/loaders/json.py +0 -0
  13. {configaroo-0.4.0 → configaroo-0.4.1}/src/configaroo/loaders/toml.py +0 -0
  14. {configaroo-0.4.0 → configaroo-0.4.1}/src/configaroo/py.typed +0 -0
  15. {configaroo-0.4.0 → configaroo-0.4.1}/src/configaroo.egg-info/SOURCES.txt +0 -0
  16. {configaroo-0.4.0 → configaroo-0.4.1}/src/configaroo.egg-info/dependency_links.txt +0 -0
  17. {configaroo-0.4.0 → configaroo-0.4.1}/src/configaroo.egg-info/requires.txt +0 -0
  18. {configaroo-0.4.0 → configaroo-0.4.1}/src/configaroo.egg-info/top_level.txt +0 -0
  19. {configaroo-0.4.0 → configaroo-0.4.1}/tests/test_configuration.py +0 -0
  20. {configaroo-0.4.0 → configaroo-0.4.1}/tests/test_dynamic.py +0 -0
  21. {configaroo-0.4.0 → configaroo-0.4.1}/tests/test_json.py +0 -0
  22. {configaroo-0.4.0 → configaroo-0.4.1}/tests/test_loaders.py +0 -0
  23. {configaroo-0.4.0 → configaroo-0.4.1}/tests/test_print.py +0 -0
  24. {configaroo-0.4.0 → configaroo-0.4.1}/tests/test_toml.py +0 -0
  25. {configaroo-0.4.0 → configaroo-0.4.1}/tests/test_validation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: configaroo
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: Bouncy handling of configuration files
5
5
  Author-email: Geir Arne Hjelle <geirarne@gmail.com>
6
6
  Maintainer-email: Geir Arne Hjelle <geirarne@gmail.com>
@@ -79,7 +79,7 @@ python_version = "3.11"
79
79
  strict = true
80
80
 
81
81
  [tool.bumpver]
82
- current_version = "v0.4.0"
82
+ current_version = "v0.4.1"
83
83
  version_pattern = "vMAJOR.MINOR.PATCH"
84
84
  commit_message = "bump version {old_version} -> {new_version}"
85
85
  tag_message = "{new_version}"
@@ -15,4 +15,4 @@ __all__ = [
15
15
  "print_configuration",
16
16
  ]
17
17
 
18
- __version__ = "0.4.0"
18
+ __version__ = "0.4.1"
@@ -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,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: configaroo
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: Bouncy handling of configuration files
5
5
  Author-email: Geir Arne Hjelle <geirarne@gmail.com>
6
6
  Maintainer-email: Geir Arne Hjelle <geirarne@gmail.com>
@@ -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