configaroo 0.1.0__py3-none-any.whl
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/__init__.py +8 -0
- configaroo/configuration.py +168 -0
- configaroo/exceptions.py +13 -0
- configaroo/loaders/__init__.py +24 -0
- configaroo/loaders/json.py +13 -0
- configaroo/loaders/toml.py +13 -0
- configaroo/py.typed +0 -0
- configaroo-0.1.0.dist-info/METADATA +21 -0
- configaroo-0.1.0.dist-info/RECORD +11 -0
- configaroo-0.1.0.dist-info/WHEEL +5 -0
- configaroo-0.1.0.dist-info/top_level.txt +1 -0
configaroo/__init__.py
ADDED
@@ -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
|
+
}
|
configaroo/exceptions.py
ADDED
@@ -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"))
|
configaroo/py.typed
ADDED
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,11 @@
|
|
1
|
+
configaroo/__init__.py,sha256=10BHeYLHiIz-oAGM1IdfaqBKyHbn2hiNnjS_zrFOtC8,232
|
2
|
+
configaroo/configuration.py,sha256=V1Dx9LdkRlUSq46Djdq12sWb9JfNi_8N4VBnkXdB11s,5821
|
3
|
+
configaroo/exceptions.py,sha256=1h-6hV7VqqKaRFnu539pjsO0ESSNDibt2kUeF7jAM2M,374
|
4
|
+
configaroo/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
5
|
+
configaroo/loaders/__init__.py,sha256=-6GAR4g7vYW7Zagq9Ev479kpkfY7kbvDl4JPXd4wQQ0,756
|
6
|
+
configaroo/loaders/json.py,sha256=6gs_ZegUEe70vb6uSJfKBos7UjfuJnyWgPE_kjDQISw,258
|
7
|
+
configaroo/loaders/toml.py,sha256=ZVbHutZ7V-Z_jf2aHjV18jwaqCXumlr06j48rojHcBM,264
|
8
|
+
configaroo-0.1.0.dist-info/METADATA,sha256=Oy67b4ut26KSC43oh5g50fpjR_Mz5chJ0iqvo4nrWIo,827
|
9
|
+
configaroo-0.1.0.dist-info/WHEEL,sha256=zaaOINJESkSfm_4HQVc5ssNzHCPXhJm0kEUakpsEHaU,91
|
10
|
+
configaroo-0.1.0.dist-info/top_level.txt,sha256=JVYICl1cWSjvSOZuZMYm976z9lnZaWtHVRSt373QCxg,11
|
11
|
+
configaroo-0.1.0.dist-info/RECORD,,
|
@@ -0,0 +1 @@
|
|
1
|
+
configaroo
|