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

124 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-24 18:51 +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.""" 

15 

16import argparse 

17import logging 

18import pathlib 

19from collections.abc import Callable, Iterable 

20from configparser import ConfigParser 

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

22 

23from .utils import bool_to_str, get_arguments_without_help, str_to_set 

24 

25LOGGER: Final = logging.getLogger(__name__) 

26 

27T = TypeVar("T") 

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

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

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

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

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

33 

34 

35class BetterConfigParser(ConfigParser): 

36 """A better config parser.""" 

37 

38 _arg_parser: None | argparse.ArgumentParser 

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

40 _all_options_should_be_parsed: bool 

41 

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

43 """Initialize this config parser.""" 

44 self._arg_parser_options_added = set() 

45 self._arg_parser = None 

46 self._all_options_should_be_parsed = False 

47 kwargs.setdefault("interpolation", None) 

48 kwargs["dict_type"] = dict 

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

50 

51 def _add_fallback_to_config( 

52 self, 

53 section: str, 

54 option: str, 

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

56 ) -> None: 

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

58 return 

59 fallback = self._val_to_str(fallback) 

60 if fallback is None: 

61 fallback = "" 

62 option = f"#{option}" 

63 if section not in self.sections(): 

64 self.add_section(section) 

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

66 

67 def _get_conv( # type: ignore[override] 

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

69 ) -> T | None: 

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

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

72 return val 

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

74 

75 def _get_from_args( 

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

77 ) -> None | T: 

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

79 if self._arg_parser is None: 

80 return None 

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

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

83 if self._all_options_should_be_parsed: 

84 LOGGER.error( 

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

86 option, 

87 section, 

88 ) 

89 self._arg_parser.add_argument( 

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

91 required=False, 

92 type=conv, 

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

94 ) 

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

96 value = getattr( 

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

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

99 None, 

100 ) 

101 if value is None: 

102 return None 

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

104 return cast(T, value) 

105 

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

107 """Convert a value to a string.""" 

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

109 return value 

110 if isinstance(value, Iterable): 

111 return ", ".join( 

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

113 ) 

114 if isinstance(value, bool): 

115 return bool_to_str(value) 

116 return str(value) # float, int 

117 

118 def add_override_argument_parser( 

119 self, parser: argparse.ArgumentParser 

120 ) -> None: 

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

122 self._arg_parser = parser 

123 

124 @staticmethod 

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

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

127 config = BetterConfigParser() 

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

129 return config 

130 

131 @overload # type: ignore[override] 

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

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

134 ) -> str | OptionalStr: ... 

135 

136 @overload 

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

138 self, section: str, option: str 

139 ) -> str: ... 

140 

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

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

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

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

145 assert isinstance(val, str) 

146 return val 

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

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

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

150 else: 

151 assert isinstance(return_value, str) 

152 return return_value 

153 

154 @overload # type: ignore[override] 

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

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

157 ) -> bool | OptionalBool: ... 

158 

159 @overload 

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

161 self, section: str, option: str 

162 ) -> bool: ... 

163 

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

165 """Get a boolean option.""" 

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

167 section, option, **kwargs 

168 ) 

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

170 assert return_value in {False, None, True} 

171 else: 

172 assert return_value in {False, True} 

173 return return_value 

174 

175 @overload # type: ignore[override] 

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

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

178 ) -> float | OptionalFloat: ... 

179 

180 @overload 

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

182 self, section: str, option: str 

183 ) -> float: ... 

184 

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

186 """Get an int option.""" 

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

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

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

190 else: 

191 assert isinstance(return_value, float) 

192 return return_value 

193 

194 @overload # type: ignore[override] 

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

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

197 ) -> int | OptionalInt: ... 

198 

199 @overload 

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

201 self, section: str, option: str 

202 ) -> int: ... 

203 

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

205 """Get an int option.""" 

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

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

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

209 else: 

210 assert isinstance(return_value, int) 

211 return return_value 

212 

213 @overload 

214 def getset( # noqa: D102 

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

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

217 

218 @overload 

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

220 

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

222 """Get an int option.""" 

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

224 section, option, str_to_set, **kwargs 

225 ) 

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

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

228 else: 

229 assert isinstance(return_value, set) 

230 if isinstance(return_value, set): 

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

232 return return_value 

233 

234 def set_all_options_should_be_parsed(self) -> None: 

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

236 self._all_options_should_be_parsed = True