Coverage for src/configuraptor/cls.py: 100%
87 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-12-04 18:06 +0100
« prev ^ index » next coverage.py v7.2.7, created at 2023-12-04 18:06 +0100
1"""
2Logic for the TypedConfig inheritable class.
3"""
4import copy
5import os
6import typing
7from collections.abc import Mapping, MutableMapping
8from typing import Any, Iterator
10from typing_extensions import Never, Self
12from . import Alias
13from .abs import AbstractTypedConfig
14from .alias import has_aliases
15from .core import check_and_convert_type
16from .errors import ConfigErrorExtraKey, ConfigErrorImmutable
17from .helpers import all_annotations
18from .loaders.loaders_shared import _convert_key
20C = typing.TypeVar("C", bound=Any)
23class TypedConfig(AbstractTypedConfig):
24 """
25 Can be used instead of load_into.
26 """
28 def _update(
29 self,
30 _strict: bool = True,
31 _allow_none: bool = False,
32 _overwrite: bool = True,
33 _ignore_extra: bool = False,
34 _lower_keys: bool = False,
35 _normalize_keys: bool = True,
36 _convert_types: bool = False,
37 _update_aliases: bool = True,
38 **values: Any,
39 ) -> Self:
40 """
41 Underscore version can be used if .update is overwritten with another value in the config.
42 """
43 annotations = all_annotations(self.__class__)
45 for key, value in values.items():
46 if _lower_keys:
47 key = key.lower()
49 if _normalize_keys:
50 # replace - with _
51 key = _convert_key(key)
53 if value is None and not _allow_none:
54 continue
56 existing_value = self.__dict__.get(key)
57 if existing_value is not None and not _overwrite:
58 # fill mode, don't overwrite
59 continue
61 if _strict and key not in annotations:
62 if _ignore_extra:
63 continue
64 else:
65 raise ConfigErrorExtraKey(cls=self.__class__, key=key, value=value)
67 # check_and_convert_type
68 if _strict and not (value is None and _allow_none):
69 value = check_and_convert_type(value, annotations[key], convert_types=_convert_types, key=key)
71 self.__dict__[key] = value
72 # setattr(self, key, value)
74 if _update_aliases:
75 cls = self.__class__
76 prop = cls.__dict__.get(key)
77 if isinstance(prop, Alias):
78 self.__dict__[prop.to] = value
79 else:
80 for alias in has_aliases(cls, key):
81 self.__dict__[alias] = value
83 return self
85 def update(
86 self,
87 _strict: bool = True,
88 _allow_none: bool = False,
89 _overwrite: bool = True,
90 _ignore_extra: bool = False,
91 _lower_keys: bool = False,
92 _normalize_keys: bool = True,
93 _convert_types: bool = False,
94 _update_aliases: bool = True,
95 **values: Any,
96 ) -> Self:
97 """
98 Update values on this config.
100 Args:
101 _strict: allow wrong types?
102 _allow_none: allow None or skip those entries?
103 _overwrite: also update not-None values?
104 _ignore_extra: skip additional keys that aren't in the object.
105 _lower_keys: set the keys to lowercase (useful for env)
106 _normalize_keys: change - to _
107 _convert_types: try to convert variables to the right type if they aren't yet
108 _update_aliases: also update related fields?
110 **values: key: value pairs in the right types to update.
111 """
112 return self._update(
113 _strict=_strict,
114 _allow_none=_allow_none,
115 _overwrite=_overwrite,
116 _ignore_extra=_ignore_extra,
117 _lower_keys=_lower_keys,
118 _normalize_keys=_normalize_keys,
119 _convert_types=_convert_types,
120 _update_aliases=_update_aliases,
121 **values,
122 )
124 def __or__(self, other: dict[str, Any]) -> Self:
125 """
126 Allows config |= {}.
128 Where {} is a dict of new data and optionally settings (starting with _)
130 Returns an updated clone of the original object, so this works too:
131 new_config = config | {...}
132 """
133 to_update = self._clone()
134 return to_update._update(**other)
136 def update_from_env(self) -> Self:
137 """
138 Update (in place) using the current environment variables, lowered etc.
140 Ignores extra env vars.
141 """
142 return self._update(_ignore_extra=True, _lower_keys=True, _convert_types=True, **os.environ)
144 def _fill(self, _strict: bool = True, **values: typing.Any) -> Self:
145 """
146 Alias for update without overwrite.
148 Underscore version can be used if .fill is overwritten with another value in the config.
149 """
150 return self._update(_strict, _allow_none=False, _overwrite=False, **values)
152 def fill(self, _strict: bool = True, **values: typing.Any) -> Self:
153 """
154 Alias for update without overwrite.
155 """
156 return self._update(_strict, _allow_none=False, _overwrite=False, **values)
158 @classmethod
159 def _all_annotations(cls) -> dict[str, type]:
160 """
161 Shortcut to get all annotations.
162 """
163 return all_annotations(cls)
165 def _format(self, string: str) -> str:
166 """
167 Format the config data into a string template.
169 Replacement for string.format(**config), which is only possible for MutableMappings.
170 MutableMapping does not work well with our Singleton Metaclass.
171 """
172 return string.format(**self.__dict__)
174 def __setattr__(self, key: str, value: typing.Any) -> None:
175 """
176 Updates should have the right type.
178 If you want a non-strict option, use _update(strict=False).
179 """
180 if key.startswith("_"):
181 return super().__setattr__(key, value)
182 self._update(**{key: value})
184 def _clone(self) -> Self:
185 return copy.deepcopy(self)
188K = typing.TypeVar("K", bound=str)
189V = typing.TypeVar("V", bound=Any)
192class TypedMappingAbstract(TypedConfig, Mapping[K, V]):
193 """
194 Note: this can't be used as a singleton!
196 Don't use directly, choose either TypedMapping (immutable) or TypedMutableMapping (mutable).
197 """
199 def __getitem__(self, key: K) -> V:
200 """
201 Dict-notation to get attribute.
203 Example:
204 my_config[key]
205 """
206 return typing.cast(V, self.__dict__[key])
208 def __len__(self) -> int:
209 """
210 Required for Mapping.
211 """
212 return len(self.__dict__)
214 def __iter__(self) -> Iterator[K]:
215 """
216 Required for Mapping.
217 """
218 # keys is actually a `dict_keys` but mypy doesn't need to know that
219 keys = typing.cast(list[K], self.__dict__.keys())
220 return iter(keys)
223class TypedMapping(TypedMappingAbstract[K, V]):
224 """
225 Note: this can't be used as a singleton!
226 """
228 def _update(self, *_: Any, **__: Any) -> Never:
229 raise ConfigErrorImmutable(self.__class__)
232class TypedMutableMapping(TypedMappingAbstract[K, V], MutableMapping[K, V]):
233 """
234 Note: this can't be used as a singleton!
235 """
237 def __setitem__(self, key: str, value: V) -> None:
238 """
239 Dict notation to set attribute.
241 Example:
242 my_config[key] = value
243 """
244 self.update(**{key: value})
246 def __delitem__(self, key: K) -> None:
247 """
248 Dict notation to delete attribute.
250 Example:
251 del my_config[key]
252 """
253 del self.__dict__[key]
255 def update(self, *args: Any, **kwargs: V) -> Self: # type: ignore
256 """
257 Ensure TypedConfig.update is used en not MutableMapping.update.
258 """
259 return TypedConfig._update(self, *args, **kwargs)
262T = typing.TypeVar("T", bound=TypedConfig)
265# also expose as separate function:
266def update(self: T, _strict: bool = True, _allow_none: bool = False, **values: Any) -> T:
267 """
268 Update values on a config.
270 Args:
271 self: config instance to update
272 _strict: allow wrong types?
273 _allow_none: allow None or skip those entries?
274 **values: key: value pairs in the right types to update.
275 """
276 return TypedConfig._update(self, _strict, _allow_none, **values)