Coverage for src/configuraptor/cls.py: 100%
86 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-11-20 11:43 +0100
« prev ^ index » next coverage.py v7.2.7, created at 2023-11-20 11:43 +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 .core import check_and_convert_type, has_aliases
15from .errors import ConfigErrorExtraKey, ConfigErrorImmutable
16from .helpers import all_annotations
17from .loaders.loaders_shared import _convert_key
19C = typing.TypeVar("C", bound=Any)
22class TypedConfig(AbstractTypedConfig):
23 """
24 Can be used instead of load_into.
25 """
27 def _update(
28 self,
29 _strict: bool = True,
30 _allow_none: bool = False,
31 _overwrite: bool = True,
32 _ignore_extra: bool = False,
33 _lower_keys: bool = False,
34 _normalize_keys: bool = True,
35 _convert_types: bool = False,
36 _update_aliases: bool = True,
37 **values: Any,
38 ) -> Self:
39 """
40 Underscore version can be used if .update is overwritten with another value in the config.
41 """
42 annotations = all_annotations(self.__class__)
44 for key, value in values.items():
45 if _lower_keys:
46 key = key.lower()
48 if _normalize_keys:
49 # replace - with _
50 key = _convert_key(key)
52 if value is None and not _allow_none:
53 continue
55 existing_value = self.__dict__.get(key)
56 if existing_value is not None and not _overwrite:
57 # fill mode, don't overwrite
58 continue
60 if _strict and key not in annotations:
61 if _ignore_extra:
62 continue
63 else:
64 raise ConfigErrorExtraKey(cls=self.__class__, key=key, value=value)
66 # check_and_convert_type
67 if _strict and not (value is None and _allow_none):
68 value = check_and_convert_type(value, annotations[key], convert_types=_convert_types, key=key)
70 self.__dict__[key] = value
71 # setattr(self, key, value)
73 if _update_aliases:
74 cls = self.__class__
75 prop = cls.__dict__.get(key)
76 if isinstance(prop, Alias):
77 self.__dict__[prop.to] = value
78 else:
79 for alias in has_aliases(cls, key):
80 self.__dict__[alias] = value
82 return self
84 def update(
85 self,
86 _strict: bool = True,
87 _allow_none: bool = False,
88 _overwrite: bool = True,
89 _ignore_extra: bool = False,
90 _lower_keys: bool = False,
91 _normalize_keys: bool = True,
92 _convert_types: bool = False,
93 _update_aliases: bool = True,
94 **values: Any,
95 ) -> Self:
96 """
97 Update values on this config.
99 Args:
100 _strict: allow wrong types?
101 _allow_none: allow None or skip those entries?
102 _overwrite: also update not-None values?
103 _ignore_extra: skip additional keys that aren't in the object.
104 _lower_keys: set the keys to lowercase (useful for env)
105 _normalize_keys: change - to _
106 _convert_types: try to convert variables to the right type if they aren't yet
107 _update_aliases: also update related fields?
109 **values: key: value pairs in the right types to update.
110 """
111 return self._update(
112 _strict=_strict,
113 _allow_none=_allow_none,
114 _overwrite=_overwrite,
115 _ignore_extra=_ignore_extra,
116 _lower_keys=_lower_keys,
117 _normalize_keys=_normalize_keys,
118 _convert_types=_convert_types,
119 _update_aliases=_update_aliases,
120 **values,
121 )
123 def __or__(self, other: dict[str, Any]) -> Self:
124 """
125 Allows config |= {}.
127 Where {} is a dict of new data and optionally settings (starting with _)
129 Returns an updated clone of the original object, so this works too:
130 new_config = config | {...}
131 """
132 to_update = self._clone()
133 return to_update._update(**other)
135 def update_from_env(self) -> Self:
136 """
137 Update (in place) using the current environment variables, lowered etc.
139 Ignores extra env vars.
140 """
141 return self._update(_ignore_extra=True, _lower_keys=True, _convert_types=True, **os.environ)
143 def _fill(self, _strict: bool = True, **values: typing.Any) -> Self:
144 """
145 Alias for update without overwrite.
147 Underscore version can be used if .fill is overwritten with another value in the config.
148 """
149 return self._update(_strict, _allow_none=False, _overwrite=False, **values)
151 def fill(self, _strict: bool = True, **values: typing.Any) -> Self:
152 """
153 Alias for update without overwrite.
154 """
155 return self._update(_strict, _allow_none=False, _overwrite=False, **values)
157 @classmethod
158 def _all_annotations(cls) -> dict[str, type]:
159 """
160 Shortcut to get all annotations.
161 """
162 return all_annotations(cls)
164 def _format(self, string: str) -> str:
165 """
166 Format the config data into a string template.
168 Replacement for string.format(**config), which is only possible for MutableMappings.
169 MutableMapping does not work well with our Singleton Metaclass.
170 """
171 return string.format(**self.__dict__)
173 def __setattr__(self, key: str, value: typing.Any) -> None:
174 """
175 Updates should have the right type.
177 If you want a non-strict option, use _update(strict=False).
178 """
179 if key.startswith("_"):
180 return super().__setattr__(key, value)
181 self._update(**{key: value})
183 def _clone(self) -> Self:
184 return copy.deepcopy(self)
187K = typing.TypeVar("K", bound=str)
188V = typing.TypeVar("V", bound=Any)
191class TypedMappingAbstract(TypedConfig, Mapping[K, V]):
192 """
193 Note: this can't be used as a singleton!
195 Don't use directly, choose either TypedMapping (immutable) or TypedMutableMapping (mutable).
196 """
198 def __getitem__(self, key: K) -> V:
199 """
200 Dict-notation to get attribute.
202 Example:
203 my_config[key]
204 """
205 return typing.cast(V, self.__dict__[key])
207 def __len__(self) -> int:
208 """
209 Required for Mapping.
210 """
211 return len(self.__dict__)
213 def __iter__(self) -> Iterator[K]:
214 """
215 Required for Mapping.
216 """
217 # keys is actually a `dict_keys` but mypy doesn't need to know that
218 keys = typing.cast(list[K], self.__dict__.keys())
219 return iter(keys)
222class TypedMapping(TypedMappingAbstract[K, V]):
223 """
224 Note: this can't be used as a singleton!
225 """
227 def _update(self, *_: Any, **__: Any) -> Never:
228 raise ConfigErrorImmutable(self.__class__)
231class TypedMutableMapping(TypedMappingAbstract[K, V], MutableMapping[K, V]):
232 """
233 Note: this can't be used as a singleton!
234 """
236 def __setitem__(self, key: str, value: V) -> None:
237 """
238 Dict notation to set attribute.
240 Example:
241 my_config[key] = value
242 """
243 self.update(**{key: value})
245 def __delitem__(self, key: K) -> None:
246 """
247 Dict notation to delete attribute.
249 Example:
250 del my_config[key]
251 """
252 del self.__dict__[key]
254 def update(self, *args: Any, **kwargs: V) -> Self: # type: ignore
255 """
256 Ensure TypedConfig.update is used en not MutableMapping.update.
257 """
258 return TypedConfig._update(self, *args, **kwargs)
261T = typing.TypeVar("T", bound=TypedConfig)
264# also expose as separate function:
265def update(self: T, _strict: bool = True, _allow_none: bool = False, **values: Any) -> T:
266 """
267 Update values on a config.
269 Args:
270 self: config instance to update
271 _strict: allow wrong types?
272 _allow_none: allow None or skip those entries?
273 **values: key: value pairs in the right types to update.
274 """
275 return TypedConfig._update(self, _strict, _allow_none, **values)