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

93 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2025-01-09 20:20 +0100

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 

10 

11from typing_extensions import Never, Self 

12 

13from . import Alias 

14from .abs import AbstractTypedConfig 

15from .alias import has_aliases 

16from .beautify import beautify as apply_beautify 

17from .core import check_and_convert_type 

18from .errors import ConfigErrorExtraKey, ConfigErrorImmutable 

19from .helpers import all_annotations, is_optional 

20from .loaders.loaders_shared import _convert_key 

21 

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

23 

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

25 

26 

27class 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 

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

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

211 

212 

213class 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 

244class 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 

253class 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 

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

284 

285 

286# also expose as separate function: 

287def 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)