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

1"""

2 Logic for the TypedConfig inheritable class.

3"""

4 import os

5 import typing

6 from collections.abc import Mapping, MutableMapping

7 from typing import Any, Iterator

8

9 from typing_extensions import Never, Self

10

11 from .abs import AbstractTypedConfig

12 from .errors import ConfigErrorExtraKey, ConfigErrorImmutable, ConfigErrorInvalidType

13 from .helpers import all_annotations, check_type

14

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

16

17

18 class TypedConfig(AbstractTypedConfig):

19 """

20 Can be used instead of load_into.

21 """

22

23 def _update(

24 self,

25 _strict: bool = True,

26 _allow_none: bool = False,

27 _overwrite: bool = True,

28 _ignore_extra: bool = False,

29 **values: Any,

30 ) -> Self:

31 """

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

33 """

34 annotations = all_annotations(self.__class__)

35

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

37 if value is None and not _allow_none:

38 continue

39

40 existing_value = self.__dict__.get(key)

41 if existing_value is not None and not _overwrite:

42 # fill mode, don't overwrite

43 continue

44

45 if _strict and key not in annotations:

46 if _ignore_extra:

47 continue

48 else:

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

50

51 if _strict and not check_type(value, annotations[key]) and not (value is None and _allow_none):

52 raise ConfigErrorInvalidType(expected_type=annotations[key], key=key, value=value)

53

54 self.__dict__[key] = value

55 # setattr(self, key, value)

56

57 return self

58

59 def update(

60 self,

61 _strict: bool = True,

62 _allow_none: bool = False,

63 _overwrite: bool = True,

64 _ignore_extra: bool = False,

65 **values: Any,

66 ) -> Self:

67 """

68 Update values on this config.

69

70 Args:

71 _strict: allow wrong types?

72 _allow_none: allow None or skip those entries?

73 _overwrite: also update not-None values?

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

75

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

77 """

78 return self._update(

79 _strict=_strict, _allow_none=_allow_none, _overwrite=_overwrite, _ignore_extra=_ignore_extra, **values

80 )

81

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

83 """

84 Allows config |= {}.

85

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

87 """

88 return self._update(**other)

89

90 def update_from_env(self) -> Self:

91 return self | {**os.environ, "_ignore_extra": True}

92

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

94 """

95 Alias for update without overwrite.

96

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

98 """

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

100

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

102 """

103 Alias for update without overwrite.

104 """

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

106

107 @classmethod

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

109 """

110 Shortcut to get all annotations.

111 """

112 return all_annotations(cls)

113

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

115 """

116 Format the config data into a string template.

117

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

119 MutableMapping does not work well with our Singleton Metaclass.

120 """

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

122

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

124 """

125 Updates should have the right type.

126

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

128 """

129 if key.startswith("_"):

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

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

132

133

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

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

136

137

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

139 """

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

141

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

143 """

144

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

146 """

147 Dict-notation to get attribute.

148

149 Example:

150 my_config[key]

151 """

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

153

154 def __len__(self) -> int:

155 """

156 Required for Mapping.

157 """

158 return len(self.__dict__)

159

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

161 """

162 Required for Mapping.

163 """

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

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

166 return iter(keys)

167

168

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

170 """

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

172 """

173

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

175 raise ConfigErrorImmutable(self.__class__)

176

177

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

179 """

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

181 """

182

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

184 """

185 Dict notation to set attribute.

186

187 Example:

188 my_config[key] = value

189 """

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

191

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

193 """

194 Dict notation to delete attribute.

195

196 Example:

197 del my_config[key]

198 """

199 del self.__dict__[key]

200

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

202 """

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

204 """

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

206

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

208 return TypedConfig._update(self, **other)

209

210

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

212

213

214# also expose as separate function:

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

216 """

217 Update values on a config.

218

219 Args:

220 self: config instance to update

221 _strict: allow wrong types?

222 _allow_none: allow None or skip those entries?

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

224 """

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