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

1"""

2 Logic for the TypedConfig inheritable class.

3"""

4

5 import copy

6 import os

7 import typing

8 from collections.abc import Mapping, MutableMapping

9 from typing import Any, Iterator

10

11 from typing_extensions import Never, Self

12

13 from . import Alias

14 from .abs import AbstractTypedConfig

15 from .alias import has_aliases

16 from .beautify import beautify as apply_beautify

17 from .core import check_and_convert_type

18 from .errors import ConfigErrorExtraKey, ConfigErrorImmutable

19 from .helpers import all_annotations, is_optional

20 from .loaders.loaders_shared import _convert_key

21

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

23

24 NO_ANNOTATION = typing.NewType("NO_ANNOTATION", object) # SentinelObject

25

26

27 class TypedConfig(AbstractTypedConfig):

28 """

29 Can be used instead of load_into.

30 """

31

32 def __init_subclass__(cls, beautify: bool = True, **_: typing.Any) -> None:

33 """

34 When inheriting from TypedConfig, automatically beautify the class.

35

36 To disable this behavior:

37 class MyConfig(TypedConfig, beautify=False):

38 ...

39 """

40 if beautify:

41 apply_beautify(cls)

42

43 def _update(

44 self,

45 _strict: bool = True,

46 _allow_none: bool = False,

47 _skip_none: bool = False,

48 _overwrite: bool = True,

49 _ignore_extra: bool = False,

50 _lower_keys: bool = False,

51 _normalize_keys: bool = True,

52 _convert_types: bool = False,

53 _update_aliases: bool = True,

54 **values: Any,

55 ) -> Self:

56 """

57 Underscore version can be used if .update is overwritten with another value in the config.

58 """

59 annotations = all_annotations(self.__class__)

60

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

62 if _lower_keys:

63 key = key.lower()

64

65 if _normalize_keys:

66 # replace - with _

67 key = _convert_key(key)

68

69 annotation = annotations.get(key, NO_ANNOTATION)

70

71 if value is None and ((not is_optional(annotation) and not _allow_none) or _skip_none):

72 continue

73

74 existing_value = self.__dict__.get(key)

75 if existing_value is not None and not _overwrite:

76 # fill mode, don't overwrite

77 continue

78

79 if _strict and annotation is NO_ANNOTATION:

80 if _ignore_extra:

81 continue

82 else:

83 raise ConfigErrorExtraKey(cls=self.__class__, key=key, value=value)

84

85 # check_and_convert_type

86 if _strict and not (value is None and _allow_none):

87 value = check_and_convert_type(value, annotation, convert_types=_convert_types, key=key)

88

89 self.__dict__[key] = value

90 # setattr(self, key, value)

91

92 if _update_aliases:

93 cls = self.__class__

94 prop = cls.__dict__.get(key)

95 if isinstance(prop, Alias):

96 self.__dict__[prop.to] = value

97 else:

98 for alias in has_aliases(cls, key):

99 self.__dict__[alias] = value

100

101 return self

102

103 def update(

104 self,

105 _strict: bool = True,

106 _allow_none: bool = False,

107 _skip_none: bool = False,

108 _overwrite: bool = True,

109 _ignore_extra: bool = False,

110 _lower_keys: bool = False,

111 _normalize_keys: bool = True,

112 _convert_types: bool = False,

113 _update_aliases: bool = True,

114 **values: Any,

115 ) -> Self:

116 """

117 Update values on this config.

118

119 Args:

120 _strict: allow wrong types?

121 _allow_none: allow None or skip those entries for required items?

122 _skip_none: skip none also for optional items?

123 _overwrite: also update not-None values?

124 _ignore_extra: skip additional keys that aren't in the object.

125 _lower_keys: set the keys to lowercase (useful for env)

126 _normalize_keys: change - to _

127 _convert_types: try to convert variables to the right type if they aren't yet

128 _update_aliases: also update related fields?

129

130 **values: key: value pairs in the right types to update.

131 """

132 return self._update(

133 _strict=_strict,

134 _allow_none=_allow_none,

135 _skip_none=_skip_none,

136 _overwrite=_overwrite,

137 _ignore_extra=_ignore_extra,

138 _lower_keys=_lower_keys,

139 _normalize_keys=_normalize_keys,

140 _convert_types=_convert_types,

141 _update_aliases=_update_aliases,

142 **values,

143 )

144

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

146 """

147 Allows config |= {}.

148

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

150

151 Returns an updated clone of the original object, so this works too:

152 new_config = config | {...}

153 """

154 to_update = self._clone()

155 return to_update._update(**other)

156

157 def update_from_env(self) -> Self:

158 """

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

160

161 Ignores extra env vars.

162 """

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

164

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

166 """

167 Alias for update without overwrite.

168

169 Underscore version can be used if .fill is overwritten with another value in the config.

170 """

171 return self._update(_strict, _allow_none=False, _overwrite=False, **values)

172

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

174 """

175 Alias for update without overwrite.

176 """

177 return self._update(_strict, _allow_none=False, _overwrite=False, **values)

178

179 @classmethod

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

181 """

182 Shortcut to get all annotations.

183 """

184 return all_annotations(cls)

185

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

187 """

188 Format the config data into a string template.

189

190 Replacement for string.format(**config), which is only possible for MutableMappings.

191 MutableMapping does not work well with our Singleton Metaclass.

192 """

193 return string.format(**self.__dict__)

194

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

196 """

197 Updates should have the right type.

198

199 If you want a non-strict option, use _update(strict=False).

200 """

201 if key.startswith("_"):

202 return super().__setattr__(key, value)

203 self._update(**{key: value})

204

205 def _clone(self) -> Self:

206 return copy.deepcopy(self)

207

208

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

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

211

212

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

214 """

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

216

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

218 """

219

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

221 """

222 Dict-notation to get attribute.

223

224 Example:

225 my_config[key]

226 """

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

228

229 def __len__(self) -> int:

230 """

231 Required for Mapping.

232 """

233 return len(self.__dict__)

234

235 def __iter__(self) -> Iterator[K]:

236 """

237 Required for Mapping.

238 """

239 # keys is actually a `dict_keys` but mypy doesn't need to know that

240 keys = typing.cast(list[K], self.__dict__.keys())

241 return iter(keys)

242

243

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

245 """

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

247 """

248

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

250 raise ConfigErrorImmutable(self.__class__)

251

252

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

254 """

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

256 """

257

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

259 """

260 Dict notation to set attribute.

261

262 Example:

263 my_config[key] = value

264 """

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

266

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

268 """

269 Dict notation to delete attribute.

270

271 Example:

272 del my_config[key]

273 """

274 del self.__dict__[key]

275

276 def update(self, *args: Any, **kwargs: V) -> Self: # type: ignore

277 """

278 Ensure TypedConfig.update is used en not MutableMapping.update.

279 """

280 return TypedConfig._update(self, *args, **kwargs)

281

282

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

284

285

286# also expose as separate function:

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

288 """

289 Update values on a config.

290

291 Args:

292 self: config instance to update

293 _strict: allow wrong types?

294 _allow_none: allow None or skip those entries?

295 **values: key: value pairs in the right types to update.

296 """

297 return TypedConfig._update(self, _strict, _allow_none, **values)