Coverage for src/configuraptor/cls.py: 100%
97 statements
« prev ^ index » next coverage.py v7.2.7, created at 2026-05-01 14:18 +0200
« prev ^ index » next coverage.py v7.2.7, created at 2026-05-01 14:18 +0200
1"""
2Logic for the TypedConfig inheritable class.
3"""
5import copy
6import os
7import typing
8from collections.abc import Mapping, MutableMapping
9from typing import Any, Iterator
11from typing import Never, Self
13from . import Alias
14from .abs import AbstractTypedConfig
15from .alias import has_aliases
16from .beautify import beautify as apply_beautify
17from .core import check_and_convert_type
18from .errors import ConfigErrorExtraKey, ConfigErrorImmutable
19from .helpers import all_annotations, is_optional
20from .loaders.loaders_shared import _convert_key
22C = typing.TypeVar("C", bound=Any)
24NO_ANNOTATION = typing.NewType("NO_ANNOTATION", object) # SentinelObject
27class TypedConfig(AbstractTypedConfig):
28 """
29 Can be used instead of load_into.
30 """
32 def __init_subclass__(cls, beautify: bool = True, **_: typing.Any) -> None:
33 """
34 When inheriting from TypedConfig, automatically beautify the class.
36 To disable this behavior:
37 class MyConfig(TypedConfig, beautify=False):
38 ...
39 """
40 if beautify:
41 apply_beautify(cls)
43 def _update(
44 self,
45 _strict: bool = True,
46 _allow_none: bool = False,
47 _skip_none: bool = False,
48 _overwrite: bool = True,
49 _ignore_extra: bool = False,
50 _lower_keys: bool = False,
51 _normalize_keys: bool = True,
52 _convert_types: bool = False,
53 _update_aliases: bool = True,
54 **values: Any,
55 ) -> Self:
56 """
57 Underscore version can be used if .update is overwritten with another value in the config.
58 """
59 annotations = all_annotations(self.__class__)
61 for key, value in values.items():
62 if _lower_keys:
63 key = key.lower()
65 if _normalize_keys:
66 # replace - with _
67 key = _convert_key(key)
69 annotation = annotations.get(key, NO_ANNOTATION)
71 if value is None and ((not is_optional(annotation) and not _allow_none) or _skip_none):
72 continue
74 existing_value = self.__dict__.get(key)
75 if existing_value is not None and not _overwrite:
76 # fill mode, don't overwrite
77 continue
79 if _strict and annotation is NO_ANNOTATION:
80 if _ignore_extra:
81 continue
82 else:
83 raise ConfigErrorExtraKey(cls=self.__class__, key=key, value=value)
85 # check_and_convert_type
86 if _strict and not (value is None and _allow_none):
87 value = check_and_convert_type(value, annotation, convert_types=_convert_types, key=key)
89 self.__dict__[key] = value
90 # setattr(self, key, value)
92 if _update_aliases:
93 cls = self.__class__
94 prop = cls.__dict__.get(key)
95 if isinstance(prop, Alias):
96 self.__dict__[prop.to] = value
97 else:
98 for alias in has_aliases(cls, key):
99 self.__dict__[alias] = value
101 return self
103 def update(
104 self,
105 _strict: bool = True,
106 _allow_none: bool = False,
107 _skip_none: bool = False,
108 _overwrite: bool = True,
109 _ignore_extra: bool = False,
110 _lower_keys: bool = False,
111 _normalize_keys: bool = True,
112 _convert_types: bool = False,
113 _update_aliases: bool = True,
114 **values: Any,
115 ) -> Self:
116 """
117 Update values on this config.
119 Args:
120 _strict: allow wrong types?
121 _allow_none: allow None or skip those entries for required items?
122 _skip_none: skip none also for optional items?
123 _overwrite: also update not-None values?
124 _ignore_extra: skip additional keys that aren't in the object.
125 _lower_keys: set the keys to lowercase (useful for env)
126 _normalize_keys: change - to _
127 _convert_types: try to convert variables to the right type if they aren't yet
128 _update_aliases: also update related fields?
130 **values: key: value pairs in the right types to update.
131 """
132 return self._update(
133 _strict=_strict,
134 _allow_none=_allow_none,
135 _skip_none=_skip_none,
136 _overwrite=_overwrite,
137 _ignore_extra=_ignore_extra,
138 _lower_keys=_lower_keys,
139 _normalize_keys=_normalize_keys,
140 _convert_types=_convert_types,
141 _update_aliases=_update_aliases,
142 **values,
143 )
145 def __or__(self, other: dict[str, Any]) -> Self:
146 """
147 Allows config |= {}.
149 Where {} is a dict of new data and optionally settings (starting with _)
151 Returns an updated clone of the original object, so this works too:
152 new_config = config | {...}
153 """
154 to_update = self._clone()
155 return to_update._update(**other)
157 def update_from_env(self) -> Self:
158 """
159 Update (in place) using the current environment variables, lowered etc.
161 Ignores extra env vars.
162 """
163 return self._update(_ignore_extra=True, _lower_keys=True, _convert_types=True, **os.environ)
165 def _fill(self, _strict: bool = True, **values: typing.Any) -> Self:
166 """
167 Alias for update without overwrite.
169 Underscore version can be used if .fill is overwritten with another value in the config.
170 """
171 return self._update(_strict, _allow_none=False, _overwrite=False, **values)
173 def fill(self, _strict: bool = True, **values: typing.Any) -> Self:
174 """
175 Alias for update without overwrite.
176 """
177 return self._update(_strict, _allow_none=False, _overwrite=False, **values)
179 @classmethod
180 def _all_annotations(cls) -> dict[str, type]:
181 """
182 Shortcut to get all annotations.
183 """
184 return all_annotations(cls)
186 def _format(self, string: str) -> str:
187 """
188 Format the config data into a string template.
190 Replacement for string.format(**config), which is only possible for MutableMappings.
191 MutableMapping does not work well with our Singleton Metaclass.
192 """
193 return string.format(**self.__dict__)
195 def __setattr__(self, key: str, value: typing.Any) -> None:
196 """
197 Updates should have the right type.
199 If you want a non-strict option, use _update(strict=False).
200 """
201 if key.startswith("_"):
202 return super().__setattr__(key, value)
203 self._update(**{key: value})
205 def _clone(self) -> Self:
206 return copy.deepcopy(self)
208 def __eq__(self, other: typing.Any) -> bool:
209 """
210 Two instances are equal if they are of the same type and have equal internal data.
211 """
212 if type(self) is not type(other):
213 # only comparisons between the same classes are allowed
214 return False
216 # == should always return bool already but otherwise mypy gets confused:
217 return bool(self.__dict__ == other.__dict__)
220K = typing.TypeVar("K", bound=str)
221V = typing.TypeVar("V", bound=Any)
224class TypedMappingAbstract(TypedConfig, Mapping[K, V]):
225 """
226 Note: this can't be used as a singleton!
228 Don't use directly, choose either TypedMapping (immutable) or TypedMutableMapping (mutable).
229 """
231 def __getitem__(self, key: K) -> V:
232 """
233 Dict-notation to get attribute.
235 Example:
236 my_config[key]
237 """
238 return typing.cast(V, self.__dict__[key])
240 def __len__(self) -> int:
241 """
242 Required for Mapping.
243 """
244 return len(self.__dict__)
246 def __iter__(self) -> Iterator[K]:
247 """
248 Required for Mapping.
249 """
250 # keys is actually a `dict_keys` but mypy doesn't need to know that
251 keys = typing.cast(list[K], self.__dict__.keys())
252 return iter(keys)
255class TypedMapping(TypedMappingAbstract[K, V]):
256 """
257 Note: this can't be used as a singleton!
258 """
260 def _update(self, *_: Any, **__: Any) -> Never:
261 raise ConfigErrorImmutable(self.__class__)
264class TypedMutableMapping(TypedMappingAbstract[K, V], MutableMapping[K, V]):
265 """
266 Note: this can't be used as a singleton!
267 """
269 def __setitem__(self, key: str, value: V) -> None:
270 """
271 Dict notation to set attribute.
273 Example:
274 my_config[key] = value
275 """
276 self.update(**{key: value})
278 def __delitem__(self, key: K) -> None:
279 """
280 Dict notation to delete attribute.
282 Example:
283 del my_config[key]
284 """
285 del self.__dict__[key]
287 def update(self, *args: Any, **kwargs: V) -> Self: # type: ignore
288 """
289 Ensure TypedConfig.update is used en not MutableMapping.update.
290 """
291 return TypedConfig._update(self, *args, **kwargs)
294T = typing.TypeVar("T", bound=TypedConfig)
297# also expose as separate function:
298def update(self: T, _strict: bool = True, _allow_none: bool = False, **values: Any) -> T:
299 """
300 Update values on a config.
302 Args:
303 self: config instance to update
304 _strict: allow wrong types?
305 _allow_none: allow None or skip those entries?
306 **values: key: value pairs in the right types to update.
307 """
308 return TypedConfig._update(self, _strict, _allow_none, **values)