Coverage for src/configuraptor/cls.py: 100%

1"""

2 Logic for the TypedConfig inheritable class.

3"""

4 import copy

5 import os

6 import typing

7 from collections.abc import Mapping, MutableMapping

8 from typing import Any, Iterator

9

10 from typing_extensions import Never, Self

11

12 from . import Alias

13 from .abs import AbstractTypedConfig

14 from .core import check_and_convert_type, has_aliases

15 from .errors import ConfigErrorExtraKey, ConfigErrorImmutable

16 from .helpers import all_annotations

17 from .loaders.loaders_shared import _convert_key

18

19 C = typing.TypeVar("C", bound=Any)

20

21

22 class TypedConfig(AbstractTypedConfig):

23 """

24 Can be used instead of load_into.

25 """

26

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__)

43

44 for key, value in values.items():

45 if _lower_keys:

46 key = key.lower()

47

48 if _normalize_keys:

49 # replace - with _

50 key = _convert_key(key)

51

52 if value is None and not _allow_none:

53 continue

54

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

59

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)

65

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)

69

70 self.__dict__[key] = value

71 # setattr(self, key, value)

72

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

81

82 return self

83

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.

98

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?

108

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 )

122

123 def __or__(self, other: dict[str, Any]) -> Self:

124 """

125 Allows config |= {}.

126

127 Where {} is a dict of new data and optionally settings (starting with _)

128

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)

134

135 def update_from_env(self) -> Self:

136 """

137 Update (in place) using the current environment variables, lowered etc.

138

139 Ignores extra env vars.

140 """

141 return self._update(_ignore_extra=True, _lower_keys=True, _convert_types=True, **os.environ)

142

143 def _fill(self, _strict: bool = True, **values: typing.Any) -> Self:

144 """

145 Alias for update without overwrite.

146

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)

150

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)

156

157 @classmethod

158 def _all_annotations(cls) -> dict[str, type]:

159 """

160 Shortcut to get all annotations.

161 """

162 return all_annotations(cls)

163

164 def _format(self, string: str) -> str:

165 """

166 Format the config data into a string template.

167

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__)

172

173 def __setattr__(self, key: str, value: typing.Any) -> None:

174 """

175 Updates should have the right type.

176

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})

182

183 def _clone(self) -> Self:

184 return copy.deepcopy(self)

185

186

187 K = typing.TypeVar("K", bound=str)

188 V = typing.TypeVar("V", bound=Any)

189

190

191 class TypedMappingAbstract(TypedConfig, Mapping[K, V]):

192 """

193 Note: this can't be used as a singleton!

194

195 Don't use directly, choose either TypedMapping (immutable) or TypedMutableMapping (mutable).

196 """

197

198 def __getitem__(self, key: K) -> V:

199 """

200 Dict-notation to get attribute.

201

202 Example:

203 my_config[key]

204 """

205 return typing.cast(V, self.__dict__[key])

206

207 def __len__(self) -> int:

208 """

209 Required for Mapping.

210 """

211 return len(self.__dict__)

212

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)

220

221

222 class TypedMapping(TypedMappingAbstract[K, V]):

223 """

224 Note: this can't be used as a singleton!

225 """

226

227 def _update(self, *_: Any, **__: Any) -> Never:

228 raise ConfigErrorImmutable(self.__class__)

229

230

231 class TypedMutableMapping(TypedMappingAbstract[K, V], MutableMapping[K, V]):

232 """

233 Note: this can't be used as a singleton!

234 """

235

236 def __setitem__(self, key: str, value: V) -> None:

237 """

238 Dict notation to set attribute.

239

240 Example:

241 my_config[key] = value

242 """

243 self.update(**{key: value})

244

245 def __delitem__(self, key: K) -> None:

246 """

247 Dict notation to delete attribute.

248

249 Example:

250 del my_config[key]

251 """

252 del self.__dict__[key]

253

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)

259

260

261 T = typing.TypeVar("T", bound=TypedConfig)

262

263

264# also expose as separate function:

265 def update(self: T, _strict: bool = True, _allow_none: bool = False, **values: Any) -> T:

266 """

267 Update values on a config.

268

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)