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

86 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-11-20 11:43 +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 .core import check_and_convert_type, has_aliases 

15from .errors import ConfigErrorExtraKey, ConfigErrorImmutable 

16from .helpers import all_annotations 

17from .loaders.loaders_shared import _convert_key 

18 

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

20 

21 

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

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

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

189 

190 

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

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

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

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

262 

263 

264# also expose as separate function: 

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