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
« 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/>.
14"""A config parser with the ability to parse command-line arguments."""
16import argparse
17import logging
18import pathlib
19from collections.abc import Callable, Iterable
20from configparser import ConfigParser
21from typing import Any, Final, TypeVar, cast, overload
23from .utils import bool_to_str, get_arguments_without_help, str_to_set
25LOGGER: Final = logging.getLogger(__name__)
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)
35class BetterConfigParser(ConfigParser):
36 """A better config parser."""
38 _arg_parser: None | argparse.ArgumentParser
39 _arg_parser_options_added: set[tuple[str, str]]
40 _all_options_should_be_parsed: bool
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)
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)
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))
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)
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
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
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
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: ...
136 @overload
137 def get( # pylint: disable=arguments-differ
138 self, section: str, option: str
139 ) -> str: ...
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
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: ...
159 @overload
160 def getboolean( # pylint: disable=arguments-differ
161 self, section: str, option: str
162 ) -> bool: ...
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
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: ...
180 @overload
181 def getfloat( # pylint: disable=arguments-differ
182 self, section: str, option: str
183 ) -> float: ...
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
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: ...
199 @overload
200 def getint( # pylint: disable=arguments-differ
201 self, section: str, option: str
202 ) -> int: ...
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
213 @overload
214 def getset( # noqa: D102
215 self, section: str, option: str, *, fallback: OptionalSetStr
216 ) -> set[str] | OptionalSetStr: ...
218 @overload
219 def getset(self, section: str, option: str) -> set[str]: ...
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
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