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