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

87 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-12-04 18:06 +0100

1""" 

2Logic for the TypedConfig inheritable class. 

3""" 

4import copy 

5import os 

6import typing 

7from collections.abc import Mapping, MutableMapping 

8from typing import Any, Iterator 

9 

10from typing_extensions import Never, Self 

11 

12from . import Alias 

13from .abs import AbstractTypedConfig 

14from .alias import has_aliases 

15from .core import check_and_convert_type 

16from .errors import ConfigErrorExtraKey, ConfigErrorImmutable 

17from .helpers import all_annotations 

18from .loaders.loaders_shared import _convert_key 

19 

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

21 

22 

23class TypedConfig(AbstractTypedConfig): 

24 """ 

25 Can be used instead of load_into. 

26 """ 

27 

28 def _update( 

29 self, 

30 _strict: bool = True, 

31 _allow_none: bool = False, 

32 _overwrite: bool = True, 

33 _ignore_extra: bool = False, 

34 _lower_keys: bool = False, 

35 _normalize_keys: bool = True, 

36 _convert_types: bool = False, 

37 _update_aliases: bool = True, 

38 **values: Any, 

39 ) -> Self: 

40 """ 

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

42 """ 

43 annotations = all_annotations(self.__class__) 

44 

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

46 if _lower_keys: 

47 key = key.lower() 

48 

49 if _normalize_keys: 

50 # replace - with _ 

51 key = _convert_key(key) 

52 

53 if value is None and not _allow_none: 

54 continue 

55 

56 existing_value = self.__dict__.get(key) 

57 if existing_value is not None and not _overwrite: 

58 # fill mode, don't overwrite 

59 continue 

60 

61 if _strict and key not in annotations: 

62 if _ignore_extra: 

63 continue 

64 else: 

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

66 

67 # check_and_convert_type 

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

69 value = check_and_convert_type(value, annotations[key], convert_types=_convert_types, key=key) 

70 

71 self.__dict__[key] = value 

72 # setattr(self, key, value) 

73 

74 if _update_aliases: 

75 cls = self.__class__ 

76 prop = cls.__dict__.get(key) 

77 if isinstance(prop, Alias): 

78 self.__dict__[prop.to] = value 

79 else: 

80 for alias in has_aliases(cls, key): 

81 self.__dict__[alias] = value 

82 

83 return self 

84 

85 def update( 

86 self, 

87 _strict: bool = True, 

88 _allow_none: bool = False, 

89 _overwrite: bool = True, 

90 _ignore_extra: bool = False, 

91 _lower_keys: bool = False, 

92 _normalize_keys: bool = True, 

93 _convert_types: bool = False, 

94 _update_aliases: bool = True, 

95 **values: Any, 

96 ) -> Self: 

97 """ 

98 Update values on this config. 

99 

100 Args: 

101 _strict: allow wrong types? 

102 _allow_none: allow None or skip those entries? 

103 _overwrite: also update not-None values? 

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

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

106 _normalize_keys: change - to _ 

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

108 _update_aliases: also update related fields? 

109 

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

111 """ 

112 return self._update( 

113 _strict=_strict, 

114 _allow_none=_allow_none, 

115 _overwrite=_overwrite, 

116 _ignore_extra=_ignore_extra, 

117 _lower_keys=_lower_keys, 

118 _normalize_keys=_normalize_keys, 

119 _convert_types=_convert_types, 

120 _update_aliases=_update_aliases, 

121 **values, 

122 ) 

123 

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

125 """ 

126 Allows config |= {}. 

127 

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

129 

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

131 new_config = config | {...} 

132 """ 

133 to_update = self._clone() 

134 return to_update._update(**other) 

135 

136 def update_from_env(self) -> Self: 

137 """ 

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

139 

140 Ignores extra env vars. 

141 """ 

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

143 

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

145 """ 

146 Alias for update without overwrite. 

147 

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

149 """ 

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

151 

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

153 """ 

154 Alias for update without overwrite. 

155 """ 

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

157 

158 @classmethod 

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

160 """ 

161 Shortcut to get all annotations. 

162 """ 

163 return all_annotations(cls) 

164 

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

166 """ 

167 Format the config data into a string template. 

168 

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

170 MutableMapping does not work well with our Singleton Metaclass. 

171 """ 

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

173 

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

175 """ 

176 Updates should have the right type. 

177 

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

179 """ 

180 if key.startswith("_"): 

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

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

183 

184 def _clone(self) -> Self: 

185 return copy.deepcopy(self) 

186 

187 

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

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

190 

191 

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

193 """ 

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

195 

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

197 """ 

198 

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

200 """ 

201 Dict-notation to get attribute. 

202 

203 Example: 

204 my_config[key] 

205 """ 

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

207 

208 def __len__(self) -> int: 

209 """ 

210 Required for Mapping. 

211 """ 

212 return len(self.__dict__) 

213 

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

215 """ 

216 Required for Mapping. 

217 """ 

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

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

220 return iter(keys) 

221 

222 

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

224 """ 

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

226 """ 

227 

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

229 raise ConfigErrorImmutable(self.__class__) 

230 

231 

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

233 """ 

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

235 """ 

236 

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

238 """ 

239 Dict notation to set attribute. 

240 

241 Example: 

242 my_config[key] = value 

243 """ 

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

245 

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

247 """ 

248 Dict notation to delete attribute. 

249 

250 Example: 

251 del my_config[key] 

252 """ 

253 del self.__dict__[key] 

254 

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

256 """ 

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

258 """ 

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

260 

261 

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

263 

264 

265# also expose as separate function: 

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

267 """ 

268 Update values on a config. 

269 

270 Args: 

271 self: config instance to update 

272 _strict: allow wrong types? 

273 _allow_none: allow None or skip those entries? 

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

275 """ 

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