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

70 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-11-09 11:17 +0100

1""" 

2Logic for the TypedConfig inheritable class. 

3""" 

4import os 

5import typing 

6from collections.abc import Mapping, MutableMapping 

7from typing import Any, Iterator 

8 

9from typing_extensions import Never, Self 

10 

11from .abs import AbstractTypedConfig 

12from .errors import ConfigErrorExtraKey, ConfigErrorImmutable, ConfigErrorInvalidType 

13from .helpers import all_annotations, check_type 

14 

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

16 

17 

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

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

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

136 

137 

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

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

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

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

212 

213 

214# also expose as separate function: 

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