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 .abs import AbstractTypedConfig

10 from .errors import ConfigErrorExtraKey, ConfigErrorImmutable, ConfigErrorInvalidType

11 from .helpers import all_annotations, check_type

12

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

14

15

16 class TypedConfig(AbstractTypedConfig):

17 """

18 Can be used instead of load_into.

19 """

20

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

22 """

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

24 """

25 annotations = all_annotations(self.__class__)

26

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

28 if value is None and not _allow_none:

29 continue

30

31 existing_value = self.__dict__.get(key)

32 if existing_value is not None and not _overwrite:

33 # fill mode, don't overwrite

34 continue

35

36 if _strict and key not in annotations:

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

38

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

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

41

42 self.__dict__[key] = value

43 # setattr(self, key, value)

44

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

46 """

47 Update values on this config.

48

49 Args:

50 _strict: allow wrong types?

51 _allow_none: allow None or skip those entries?

52 _overwrite: also update not-None values?

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

54 """

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

56

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

58 """

59 Alias for update without overwrite.

60

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

62 """

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

64

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

66 """

67 Alias for update without overwrite.

68 """

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

70

71 @classmethod

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

73 """

74 Shortcut to get all annotations.

75 """

76 return all_annotations(cls)

77

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

79 """

80 Format the config data into a string template.

81

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

83 MutableMapping does not work well with our Singleton Metaclass.

84 """

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

86

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

88 """

89 Updates should have the right type.

90

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

92 """

93 if key.startswith("_"):

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

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

96

97

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

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

100

101

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

103 """

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

105

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

107 """

108

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

110 """

111 Dict-notation to get attribute.

112

113 Example:

114 my_config[key]

115 """

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

117

118 def __len__(self) -> int:

119 """

120 Required for Mapping.

121 """

122 return len(self.__dict__)

123

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

125 """

126 Required for Mapping.

127 """

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

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

130 return iter(keys)

131

132

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

134 """

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

136 """

137

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

139 raise ConfigErrorImmutable(self.__class__)

140

141

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

143 """

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

145 """

146

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

148 """

149 Dict notation to set attribute.

150

151 Example:

152 my_config[key] = value

153 """

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

155

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

157 """

158 Dict notation to delete attribute.

159

160 Example:

161 del my_config[key]

162 """

163 del self.__dict__[key]

164

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

166 """

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

168 """

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

170

171

172# also expose as separate function:

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

174 """

175 Update values on a config.

176

177 Args:

178 self: config instance to update

179 _strict: allow wrong types?

180 _allow_none: allow None or skip those entries?

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

182 """

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