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

1"""

2 Logic for the TypedConfig inheritable class.

3"""

4 import copy

5 import os

6 import typing

7 from collections.abc import Mapping, MutableMapping

8 from typing import Any, Iterator

9

10 from typing_extensions import Never, Self

11

12 from . import Alias

13 from .abs import AbstractTypedConfig

14 from .alias import has_aliases

15 from .core import check_and_convert_type

16 from .errors import ConfigErrorExtraKey, ConfigErrorImmutable

17 from .helpers import all_annotations

18 from .loaders.loaders_shared import _convert_key

19

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

21

22

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

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

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

190

191

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

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

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

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

263

264

265# also expose as separate function:

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