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

96 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2026-05-01 16:57 +0200

1""" 

2Logic for the TypedConfig inheritable class. 

3""" 

4 

5import copy 

6import os 

7import typing 

8from collections.abc import Mapping, MutableMapping 

9from typing import Any, Iterator, Never, Self 

10 

11from . import Alias 

12from .abs import AbstractTypedConfig 

13from .alias import has_aliases 

14from .beautify import beautify as apply_beautify 

15from .core import check_and_convert_type 

16from .errors import ConfigErrorExtraKey, ConfigErrorImmutable 

17from .helpers import all_annotations, is_optional 

18from .loaders.loaders_shared import _convert_key 

19 

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

21 

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

23 

24 

25class TypedConfig(AbstractTypedConfig): 

26 """ 

27 Can be used instead of load_into. 

28 """ 

29 

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

31 """ 

32 When inheriting from TypedConfig, automatically beautify the class. 

33 

34 To disable this behavior: 

35 class MyConfig(TypedConfig, beautify=False): 

36 ... 

37 """ 

38 if beautify: 

39 apply_beautify(cls) 

40 

41 def _update( 

42 self, 

43 _strict: bool = True, 

44 _allow_none: bool = False, 

45 _skip_none: bool = False, 

46 _overwrite: bool = True, 

47 _ignore_extra: bool = False, 

48 _lower_keys: bool = False, 

49 _normalize_keys: bool = True, 

50 _convert_types: bool = False, 

51 _update_aliases: bool = True, 

52 **values: Any, 

53 ) -> Self: 

54 """ 

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

56 """ 

57 annotations = all_annotations(self.__class__) 

58 

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

60 if _lower_keys: 

61 key = key.lower() 

62 

63 if _normalize_keys: 

64 # replace - with _ 

65 key = _convert_key(key) 

66 

67 annotation = annotations.get(key, NO_ANNOTATION) 

68 

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

70 continue 

71 

72 existing_value = self.__dict__.get(key) 

73 if existing_value is not None and not _overwrite: 

74 # fill mode, don't overwrite 

75 continue 

76 

77 if _strict and annotation is NO_ANNOTATION: 

78 if _ignore_extra: 

79 continue 

80 else: 

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

82 

83 # check_and_convert_type 

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

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

86 

87 self.__dict__[key] = value 

88 # setattr(self, key, value) 

89 

90 if _update_aliases: 

91 cls = self.__class__ 

92 prop = cls.__dict__.get(key) 

93 if isinstance(prop, Alias): 

94 self.__dict__[prop.to] = value 

95 else: 

96 for alias in has_aliases(cls, key): 

97 self.__dict__[alias] = value 

98 

99 return self 

100 

101 def update( 

102 self, 

103 _strict: bool = True, 

104 _allow_none: bool = False, 

105 _skip_none: bool = False, 

106 _overwrite: bool = True, 

107 _ignore_extra: bool = False, 

108 _lower_keys: bool = False, 

109 _normalize_keys: bool = True, 

110 _convert_types: bool = False, 

111 _update_aliases: bool = True, 

112 **values: Any, 

113 ) -> Self: 

114 """ 

115 Update values on this config. 

116 

117 Args: 

118 _strict: allow wrong types? 

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

120 _skip_none: skip none also for optional items? 

121 _overwrite: also update not-None values? 

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

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

124 _normalize_keys: change - to _ 

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

126 _update_aliases: also update related fields? 

127 

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

129 """ 

130 return self._update( 

131 _strict=_strict, 

132 _allow_none=_allow_none, 

133 _skip_none=_skip_none, 

134 _overwrite=_overwrite, 

135 _ignore_extra=_ignore_extra, 

136 _lower_keys=_lower_keys, 

137 _normalize_keys=_normalize_keys, 

138 _convert_types=_convert_types, 

139 _update_aliases=_update_aliases, 

140 **values, 

141 ) 

142 

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

144 """ 

145 Allows config |= {}. 

146 

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

148 

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

150 new_config = config | {...} 

151 """ 

152 to_update = self._clone() 

153 return to_update._update(**other) 

154 

155 def update_from_env(self) -> Self: 

156 """ 

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

158 

159 Ignores extra env vars. 

160 """ 

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

162 

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

164 """ 

165 Alias for update without overwrite. 

166 

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

168 """ 

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

170 

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

172 """ 

173 Alias for update without overwrite. 

174 """ 

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

176 

177 @classmethod 

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

179 """ 

180 Shortcut to get all annotations. 

181 """ 

182 return all_annotations(cls) 

183 

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

185 """ 

186 Format the config data into a string template. 

187 

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

189 MutableMapping does not work well with our Singleton Metaclass. 

190 """ 

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

192 

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

194 """ 

195 Updates should have the right type. 

196 

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

198 """ 

199 if key.startswith("_"): 

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

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

202 

203 def _clone(self) -> Self: 

204 return copy.deepcopy(self) 

205 

206 def __eq__(self, other: typing.Any) -> bool: 

207 """ 

208 Two instances are equal if they are of the same type and have equal internal data. 

209 """ 

210 if type(self) is not type(other): 

211 # only comparisons between the same classes are allowed 

212 return False 

213 

214 # == should always return bool already but otherwise mypy gets confused: 

215 return bool(self.__dict__ == other.__dict__) 

216 

217 

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

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

220 

221 

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

223 """ 

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

225 

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

227 """ 

228 

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

230 """ 

231 Dict-notation to get attribute. 

232 

233 Example: 

234 my_config[key] 

235 """ 

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

237 

238 def __len__(self) -> int: 

239 """ 

240 Required for Mapping. 

241 """ 

242 return len(self.__dict__) 

243 

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

245 """ 

246 Required for Mapping. 

247 """ 

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

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

250 return iter(keys) 

251 

252 

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

254 """ 

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

256 """ 

257 

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

259 raise ConfigErrorImmutable(self.__class__) 

260 

261 

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

263 """ 

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

265 """ 

266 

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

268 """ 

269 Dict notation to set attribute. 

270 

271 Example: 

272 my_config[key] = value 

273 """ 

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

275 

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

277 """ 

278 Dict notation to delete attribute. 

279 

280 Example: 

281 del my_config[key] 

282 """ 

283 del self.__dict__[key] 

284 

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

286 """ 

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

288 """ 

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

290 

291 

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

293 

294 

295# also expose as separate function: 

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

297 """ 

298 Update values on a config. 

299 

300 Args: 

301 self: config instance to update 

302 _strict: allow wrong types? 

303 _allow_none: allow None or skip those entries? 

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

305 """ 

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