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

1"""

2 Logic for the TypedConfig inheritable class.

3"""

4

5 import typing

6 from collections.abc import Mapping, MutableMapping

7 from typing import Any, Iterator

8

9 from .core import T_data, all_annotations, check_type, load_into

10 from .errors import ConfigErrorExtraKey, ConfigErrorImmutable, ConfigErrorInvalidType

11

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

13

14

15 class TypedConfig:

16 """

17 Can be used instead of load_into.

18 """

19

20 @classmethod

21 def load(

22 cls: typing.Type[C],

23 data: T_data = None,

24 key: str = None,

25 init: dict[str, Any] = None,

26 strict: bool = True,

27 lower_keys: bool = False,

28 convert_types: bool = False,

29 ) -> C:

30 """

31 Load a class' config values from the config file.

32

33 SomeClass.load(data, ...) = load_into(SomeClass, data, ...).

34 """

35 return load_into(

36 cls, data, key=key, init=init, strict=strict, lower_keys=lower_keys, convert_types=convert_types

37 )

38

39 def _update(self, _strict: bool = True, _allow_none: bool = False, _overwrite: bool = True, **values: Any) -> None:

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 value is None and not _allow_none:

47 continue

48

49 existing_value = self.__dict__.get(key)

50 if existing_value is not None and not _overwrite:

51 # fill mode, don't overwrite

52 continue

53

54 if _strict and key not in annotations:

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

56

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

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

59

60 self.__dict__[key] = value

61 # setattr(self, key, value)

62

63 def update(self, _strict: bool = True, _allow_none: bool = False, _overwrite: bool = True, **values: Any) -> None:

64 """

65 Update values on this config.

66

67 Args:

68 _strict: allow wrong types?

69 _allow_none: allow None or skip those entries?

70 _overwrite: also update not-None values?

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

72 """

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

74

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

76 """

77 Alias for update without overwrite.

78

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

80 """

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

82

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

84 """

85 Alias for update without overwrite.

86 """

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

88

89 @classmethod

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

91 """

92 Shortcut to get all annotations.

93 """

94 return all_annotations(cls)

95

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

97 """

98 Format the config data into a string template.

99

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

101 MutableMapping does not work well with our Singleton Metaclass.

102 """

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

104

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

106 """

107 Updates should have the right type.

108

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

110 """

111 if key.startswith("_"):

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

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

114

115

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

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

118

119

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

121 """

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

123

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

125 """

126

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

128 """

129 Dict-notation to get attribute.

130

131 Example:

132 my_config[key]

133 """

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

135

136 def __len__(self) -> int:

137 """

138 Required for Mapping.

139 """

140 return len(self.__dict__)

141

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

143 """

144 Required for Mapping.

145 """

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

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

148 return iter(keys)

149

150

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

152 """

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

154 """

155

156 def _update(self, *_: Any, **__: Any) -> None:

157 raise ConfigErrorImmutable(self.__class__)

158

159

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

161 """

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

163 """

164

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

166 """

167 Dict notation to set attribute.

168

169 Example:

170 my_config[key] = value

171 """

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

173

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

175 """

176 Dict notation to delete attribute.

177

178 Example:

179 del my_config[key]

180 """

181 del self.__dict__[key]

182

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

184 """

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

186 """

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

188

189

190# also expose as separate function:

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

192 """

193 Update values on a config.

194

195 Args:

196 self: config instance to update

197 _strict: allow wrong types?

198 _allow_none: allow None or skip those entries?

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

200 """

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