Coverage for an_website/utils/better_config_parser.py: 82.031%

128 statements  

« prev     ^ index     » next       coverage.py v7.6.4, created at 2024-11-16 19:56 +0000

1# This program is free software: you can redistribute it and/or modify 

2# it under the terms of the GNU Affero General Public License as 

3# published by the Free Software Foundation, either version 3 of the 

4# License, or (at your option) any later version. 

5# 

6# This program is distributed in the hope that it will be useful, 

7# but WITHOUT ANY WARRANTY; without even the implied warranty of 

8# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

9# GNU Affero General Public License for more details. 

10# 

11# You should have received a copy of the GNU Affero General Public License 

12# along with this program. If not, see <https://www.gnu.org/licenses/>. 

13 

14"""A config parser with the ability to parse command-line arguments.""" 

15from __future__ import annotations 

16 

17import argparse 

18import logging 

19import pathlib 

20from collections.abc import Callable, Iterable 

21from configparser import ConfigParser 

22from typing import Any, Final, TypeVar, cast, overload 

23 

24from .utils import bool_to_str, get_arguments_without_help, str_to_set 

25 

26LOGGER: Final = logging.getLogger(__name__) 

27 

28T = TypeVar("T") 

29OptionalBool = TypeVar("OptionalBool", bool, None) 

30OptionalFloat = TypeVar("OptionalFloat", float, None) 

31OptionalInt = TypeVar("OptionalInt", int, None) 

32OptionalStr = TypeVar("OptionalStr", str, None) 

33OptionalSetStr = TypeVar("OptionalSetStr", set[str], None) 

34 

35 

36class BetterConfigParser(ConfigParser): 

37 """A better config parser.""" 

38 

39 _arg_parser: None | argparse.ArgumentParser 

40 _arg_parser_options_added: set[tuple[str, str]] 

41 _all_options_should_be_parsed: bool 

42 

43 def __init__(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def] 

44 """Initialize this config parser.""" 

45 self._arg_parser_options_added = set() 

46 self._arg_parser = None 

47 self._all_options_should_be_parsed = False 

48 kwargs.setdefault("interpolation", None) 

49 kwargs["dict_type"] = dict 

50 super().__init__(*args, **kwargs) 

51 

52 def _add_fallback_to_config( 

53 self, 

54 section: str, 

55 option: str, 

56 fallback: str | Iterable[str] | bool | int | float | None, 

57 ) -> None: 

58 if section in self and option in self[section]: 

59 return 

60 fallback = self._val_to_str(fallback) 

61 if fallback is None: 

62 fallback = "" 

63 option = f"#{option}" 

64 if section not in self.sections(): 

65 self.add_section(section) 

66 self.set(section, option.lower(), fallback) 

67 

68 def _get_conv( # type: ignore[override] 

69 self, section: str, option: str, conv: Callable[[str], T], **kwargs: Any 

70 ) -> T | None: 

71 self._add_fallback_to_config(section, option, kwargs.get("fallback")) 

72 if (val := self._get_from_args(section, option, conv)) is not None: 

73 return val 

74 return cast(T, super()._get_conv(section, option, conv, **kwargs)) 

75 

76 def _get_from_args( 

77 self, section: str, option: str, conv: Callable[[str], T] 

78 ) -> None | T: 

79 """Try to get the value from the command line arguments.""" 

80 if self._arg_parser is None: 

81 return None 

82 option_name = f"{section}-{option}".lower().removeprefix("general-") 

83 if (section, option) not in self._arg_parser_options_added: 

84 if self._all_options_should_be_parsed: 

85 LOGGER.error( 

86 "Option %r in section %r should have been queried before.", 

87 option, 

88 section, 

89 ) 

90 self._arg_parser.add_argument( 

91 f"--{option_name}".replace("_", "-"), 

92 required=False, 

93 type=conv, 

94 help=f"Override {option!r} in the {section!r} section of the config", 

95 ) 

96 self._arg_parser_options_added.add((section, option)) 

97 value = getattr( 

98 self._arg_parser.parse_known_args(get_arguments_without_help())[0], 

99 option_name.replace("-", "_"), 

100 None, 

101 ) 

102 if value is None: 

103 return None 

104 self.set(section, option, self._val_to_str(value)) 

105 return cast(T, value) 

106 

107 def _val_to_str(self, value: object | None) -> str | None: 

108 """Convert a value to a string.""" 

109 if value is None or isinstance(value, str): 

110 return value 

111 if isinstance(value, Iterable): 

112 return ", ".join( 

113 [cast(str, self._val_to_str(val)) for val in value] 

114 ) 

115 if isinstance(value, bool): 

116 return bool_to_str(value) 

117 return str(value) # float, int 

118 

119 def add_override_argument_parser( 

120 self, parser: argparse.ArgumentParser 

121 ) -> None: 

122 """Add an argument parser to override config values.""" 

123 self._arg_parser = parser 

124 

125 @staticmethod 

126 def from_path(*path: pathlib.Path) -> BetterConfigParser: 

127 """Parse the config at the given path.""" 

128 config = BetterConfigParser() 

129 config.read(path, encoding="UTF-8") 

130 return config 

131 

132 @overload # type: ignore[override] 

133 def get( # noqa: D102 # pylint: disable=arguments-differ 

134 self, section: str, option: str, *, fallback: OptionalStr 

135 ) -> str | OptionalStr: ... 

136 

137 @overload 

138 def get( # pylint: disable=arguments-differ 

139 self, section: str, option: str 

140 ) -> str: ... 

141 

142 def get(self, section: str, option: str, **kwargs: Any) -> object: 

143 """Get an option in a section.""" 

144 self._add_fallback_to_config(section, option, kwargs.get("fallback")) 

145 if (val := self._get_from_args(section, option, str)) is not None: 

146 assert isinstance(val, str) 

147 return val 

148 return_value: str | None = super().get(section, option, **kwargs) 

149 if "fallback" in kwargs and kwargs["fallback"] is None: 

150 assert isinstance(return_value, (str, type(None))) 

151 else: 

152 assert isinstance(return_value, str) 

153 return return_value 

154 

155 @overload # type: ignore[override] 

156 def getboolean( # noqa: D102 # pylint: disable=arguments-differ 

157 self, section: str, option: str, *, fallback: OptionalBool 

158 ) -> bool | OptionalBool: ... 

159 

160 @overload 

161 def getboolean( # pylint: disable=arguments-differ 

162 self, section: str, option: str 

163 ) -> bool: ... 

164 

165 def getboolean(self, section: str, option: str, **kwargs: Any) -> object: 

166 """Get a boolean option.""" 

167 return_value: bool | None = super().getboolean( 

168 section, option, **kwargs 

169 ) 

170 if "fallback" in kwargs and kwargs["fallback"] is None: 

171 assert return_value in {False, None, True} 

172 else: 

173 assert return_value in {False, True} 

174 return return_value 

175 

176 @overload # type: ignore[override] 

177 def getfloat( # noqa: D102 # pylint: disable=arguments-differ 

178 self, section: str, option: str, *, fallback: OptionalFloat 

179 ) -> float | OptionalFloat: ... 

180 

181 @overload 

182 def getfloat( # pylint: disable=arguments-differ 

183 self, section: str, option: str 

184 ) -> float: ... 

185 

186 def getfloat(self, section: str, option: str, **kwargs: Any) -> object: 

187 """Get an int option.""" 

188 return_value: float | None = super().getfloat(section, option, **kwargs) 

189 if "fallback" in kwargs and kwargs["fallback"] is None: 

190 assert isinstance(return_value, (float, type(None))) 

191 else: 

192 assert isinstance(return_value, float) 

193 return return_value 

194 

195 @overload # type: ignore[override] 

196 def getint( # noqa: D102 # pylint: disable=arguments-differ 

197 self, section: str, option: str, *, fallback: OptionalInt 

198 ) -> int | OptionalInt: ... 

199 

200 @overload 

201 def getint( # pylint: disable=arguments-differ 

202 self, section: str, option: str 

203 ) -> int: ... 

204 

205 def getint(self, section: str, option: str, **kwargs: Any) -> object: 

206 """Get an int option.""" 

207 return_value: int | None = super().getint(section, option, **kwargs) 

208 if "fallback" in kwargs and kwargs["fallback"] is None: 

209 assert isinstance(return_value, (int, type(None))) 

210 else: 

211 assert isinstance(return_value, int) 

212 return return_value 

213 

214 @overload 

215 def getset( # noqa: D102 

216 self, section: str, option: str, *, fallback: OptionalSetStr 

217 ) -> set[str] | OptionalSetStr: ... 

218 

219 @overload 

220 def getset(self, section: str, option: str) -> set[str]: ... 

221 

222 def getset(self, section: str, option: str, **kwargs: Any) -> object: 

223 """Get an int option.""" 

224 return_value: set[str] | None = self._get_conv( 

225 section, option, str_to_set, **kwargs 

226 ) 

227 if "fallback" in kwargs and kwargs["fallback"] is None: 

228 assert isinstance(return_value, (set, type(None))) 

229 else: 

230 assert isinstance(return_value, set) 

231 if isinstance(return_value, set): 

232 assert all(isinstance(val, str) for val in return_value) 

233 return return_value 

234 

235 def set_all_options_should_be_parsed(self) -> None: 

236 """Set all options should be parsed.""" 

237 self._all_options_should_be_parsed = True