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