Coverage for src/configuraptor/cls.py: 99%
70 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-11-09 11:17 +0100
« prev ^ index » next coverage.py v7.2.7, created at 2023-11-09 11:17 +0100
1"""
2Logic for the TypedConfig inheritable class.
3"""
4import os
5import typing
6from collections.abc import Mapping, MutableMapping
7from typing import Any, Iterator
9from typing_extensions import Never, Self
11from .abs import AbstractTypedConfig
12from .errors import ConfigErrorExtraKey, ConfigErrorImmutable, ConfigErrorInvalidType
13from .helpers import all_annotations, check_type
15C = typing.TypeVar("C", bound=Any)
18class TypedConfig(AbstractTypedConfig):
19 """
20 Can be used instead of load_into.
21 """
23 def _update(
24 self,
25 _strict: bool = True,
26 _allow_none: bool = False,
27 _overwrite: bool = True,
28 _ignore_extra: bool = False,
29 **values: Any,
30 ) -> Self:
31 """
32 Underscore version can be used if .update is overwritten with another value in the config.
33 """
34 annotations = all_annotations(self.__class__)
36 for key, value in values.items():
37 if value is None and not _allow_none:
38 continue
40 existing_value = self.__dict__.get(key)
41 if existing_value is not None and not _overwrite:
42 # fill mode, don't overwrite
43 continue
45 if _strict and key not in annotations:
46 if _ignore_extra:
47 continue
48 else:
49 raise ConfigErrorExtraKey(cls=self.__class__, key=key, value=value)
51 if _strict and not check_type(value, annotations[key]) and not (value is None and _allow_none):
52 raise ConfigErrorInvalidType(expected_type=annotations[key], key=key, value=value)
54 self.__dict__[key] = value
55 # setattr(self, key, value)
57 return self
59 def update(
60 self,
61 _strict: bool = True,
62 _allow_none: bool = False,
63 _overwrite: bool = True,
64 _ignore_extra: bool = False,
65 **values: Any,
66 ) -> Self:
67 """
68 Update values on this config.
70 Args:
71 _strict: allow wrong types?
72 _allow_none: allow None or skip those entries?
73 _overwrite: also update not-None values?
74 _ignore_extra: skip additional keys that aren't in the object.
76 **values: key: value pairs in the right types to update.
77 """
78 return self._update(
79 _strict=_strict, _allow_none=_allow_none, _overwrite=_overwrite, _ignore_extra=_ignore_extra, **values
80 )
82 def __or__(self, other: dict[str, Any]) -> Self:
83 """
84 Allows config |= {}.
86 Where {} is a dict of new data and optionally settings (starting with _)
87 """
88 return self._update(**other)
90 def update_from_env(self) -> Self:
91 return self | {**os.environ, "_ignore_extra": True}
93 def _fill(self, _strict: bool = True, **values: typing.Any) -> Self:
94 """
95 Alias for update without overwrite.
97 Underscore version can be used if .fill is overwritten with another value in the config.
98 """
99 return self._update(_strict, _allow_none=False, _overwrite=False, **values)
101 def fill(self, _strict: bool = True, **values: typing.Any) -> Self:
102 """
103 Alias for update without overwrite.
104 """
105 return self._update(_strict, _allow_none=False, _overwrite=False, **values)
107 @classmethod
108 def _all_annotations(cls) -> dict[str, type]:
109 """
110 Shortcut to get all annotations.
111 """
112 return all_annotations(cls)
114 def _format(self, string: str) -> str:
115 """
116 Format the config data into a string template.
118 Replacement for string.format(**config), which is only possible for MutableMappings.
119 MutableMapping does not work well with our Singleton Metaclass.
120 """
121 return string.format(**self.__dict__)
123 def __setattr__(self, key: str, value: typing.Any) -> None:
124 """
125 Updates should have the right type.
127 If you want a non-strict option, use _update(strict=False).
128 """
129 if key.startswith("_"):
130 return super().__setattr__(key, value)
131 self._update(**{key: value})
134K = typing.TypeVar("K", bound=str)
135V = typing.TypeVar("V", bound=Any)
138class TypedMappingAbstract(TypedConfig, Mapping[K, V]):
139 """
140 Note: this can't be used as a singleton!
142 Don't use directly, choose either TypedMapping (immutable) or TypedMutableMapping (mutable).
143 """
145 def __getitem__(self, key: K) -> V:
146 """
147 Dict-notation to get attribute.
149 Example:
150 my_config[key]
151 """
152 return typing.cast(V, self.__dict__[key])
154 def __len__(self) -> int:
155 """
156 Required for Mapping.
157 """
158 return len(self.__dict__)
160 def __iter__(self) -> Iterator[K]:
161 """
162 Required for Mapping.
163 """
164 # keys is actually a `dict_keys` but mypy doesn't need to know that
165 keys = typing.cast(list[K], self.__dict__.keys())
166 return iter(keys)
169class TypedMapping(TypedMappingAbstract[K, V]):
170 """
171 Note: this can't be used as a singleton!
172 """
174 def _update(self, *_: Any, **__: Any) -> Never:
175 raise ConfigErrorImmutable(self.__class__)
178class TypedMutableMapping(TypedMappingAbstract[K, V], MutableMapping[K, V]):
179 """
180 Note: this can't be used as a singleton!
181 """
183 def __setitem__(self, key: str, value: V) -> None:
184 """
185 Dict notation to set attribute.
187 Example:
188 my_config[key] = value
189 """
190 self.update(**{key: value})
192 def __delitem__(self, key: K) -> None:
193 """
194 Dict notation to delete attribute.
196 Example:
197 del my_config[key]
198 """
199 del self.__dict__[key]
201 def update(self, *args: Any, **kwargs: V) -> Self: # type: ignore
202 """
203 Ensure TypedConfig.update is used en not MutableMapping.update.
204 """
205 return TypedConfig._update(self, *args, **kwargs)
207 def __or__(self, other: dict[str, Any]) -> Self:
208 return TypedConfig._update(self, **other)
211T = typing.TypeVar("T", bound=TypedConfig)
214# also expose as separate function:
215def update(self: T, _strict: bool = True, _allow_none: bool = False, **values: Any) -> T:
216 """
217 Update values on a config.
219 Args:
220 self: config instance to update
221 _strict: allow wrong types?
222 _allow_none: allow None or skip those entries?
223 **values: key: value pairs in the right types to update.
224 """
225 return TypedConfig._update(self, _strict, _allow_none, **values)