Coverage for .tox/cov/lib/python3.10/site-packages/confattr/configfile.py: 100%

733 statements  

« prev     ^ index     » next       coverage.py v7.2.1, created at 2023-03-14 13:30 +0100

1#!./runmodule.sh 

2 

3''' 

4This module defines the ConfigFile class 

5which can be used to load and save config files. 

6''' 

7 

8import os 

9import shlex 

10import enum 

11import argparse 

12import inspect 

13import io 

14import abc 

15import typing 

16from collections.abc import Iterable, Iterator, Sequence, Callable 

17 

18import appdirs 

19 

20from .config import Config, DictConfig, MultiConfig, ConfigId 

21from .utils import HelpFormatter, HelpFormatterWrapper, SortedEnum, readable_quote 

22 

23if typing.TYPE_CHECKING: 

24 from typing_extensions import Unpack 

25 

26 # T is already used in config.py and I cannot use the same name because both are imported with * 

27 T2 = typing.TypeVar('T2') 

28 

29 

30#: If the name or an alias of :class:`ConfigFileCommand` is this value that command is used by :meth:`ConfigFile.parse_splitted_line` if an undefined command is encountered. 

31DEFAULT_COMMAND = '' 

32 

33 

34 

35# ---------- UI notifier ---------- 

36 

37@enum.unique 

38class NotificationLevel(SortedEnum): 

39 INFO = 'info' 

40 ERROR = 'error' 

41 

42UiCallback: 'typing.TypeAlias' = 'Callable[[Message], None]' 

43 

44class Message: 

45 

46 ''' 

47 A message which should be displayed to the user. 

48 This is passed to the callback of the user interface which has been registered with :meth:`ConfigFile.set_ui_callback`. 

49 

50 If you want full control how to display messages to the user you can access the attributes directly. 

51 Otherwise you can simply convert this object to a str, e.g. with ``str(msg)``. 

52 I recommend to use different colors for different values of :attr:`notification_level`. 

53 ''' 

54 

55 #: The value of :attr:`file_name` while loading environment variables. 

56 ENVIRONMENT_VARIABLES = 'environment variables' 

57 

58 

59 __slots__ = ('notification_level', 'message', 'file_name', 'line_number', 'line') 

60 

61 #: The importance of this message. I recommend to display messages of different importance levels in different colors. 

62 #: :class:`ConfigFile` does not output messages which are less important than the :paramref:`~ConfigFile.notification_level` setting which has been passed to it's constructor. 

63 notification_level: NotificationLevel 

64 

65 #: The string or exception which should be displayed to the user 

66 message: 'str|BaseException' 

67 

68 #: The name of the config file which has caused this message. 

69 #: If this equals :const:`ENVIRONMENT_VARIABLES` it is not a file but the message has occurred while reading the environment variables. 

70 #: This is None if :meth:`ConfigFile.parse_line` is called directly, e.g. when parsing the input from a command line. 

71 file_name: 'str|None' 

72 

73 #: The number of the line in the config file. This is None if :attr:`file_name` is not a file name. 

74 line_number: 'int|None' 

75 

76 #: The line where the message occurred. This is an empty str if there is no line, e.g. when loading environment variables. 

77 line: str 

78 

79 _last_file_name: 'str|None' = None 

80 

81 @classmethod 

82 def reset(cls) -> None: 

83 ''' 

84 If you are using :meth:`format_file_name_msg_line` or :meth:`__str__` 

85 you must call this method when the widget showing the error messages is cleared. 

86 ''' 

87 cls._last_file_name = None 

88 

89 def __init__(self, notification_level: NotificationLevel, message: 'str|BaseException', file_name: 'str|None' = None, line_number: 'int|None' = None, line: 'str' = '') -> None: 

90 self.notification_level = notification_level 

91 self.message = message 

92 self.file_name = file_name 

93 self.line_number = line_number 

94 self.line = line 

95 

96 @property 

97 def lvl(self) -> NotificationLevel: 

98 ''' 

99 An abbreviation for :attr:`notification_level` 

100 ''' 

101 return self.notification_level 

102 

103 def format_msg_line(self) -> str: 

104 ''' 

105 The return value includes the attributes :attr:`message`, :attr:`line_number` and :attr:`line` if they are set. 

106 ''' 

107 msg = str(self.message) 

108 if self.line: 

109 if self.line_number is not None: 

110 lnref = 'line %s' % self.line_number 

111 else: 

112 lnref = 'line' 

113 return f'{msg} in {lnref} {self.line!r}' 

114 

115 return msg 

116 

117 def format_file_name(self) -> str: 

118 ''' 

119 :return: A header including the :attr:`file_name` if the :attr:`file_name` is different from the last time this function has been called or an empty string otherwise 

120 ''' 

121 file_name = '' if self.file_name is None else self.file_name 

122 if file_name == self._last_file_name: 

123 return '' 

124 

125 if file_name: 

126 out = f'While loading {file_name}:\n' 

127 else: 

128 out = '' 

129 

130 if self._last_file_name is not None: 

131 out = '\n' + out 

132 

133 type(self)._last_file_name = file_name 

134 

135 return out 

136 

137 

138 def format_file_name_msg_line(self) -> str: 

139 ''' 

140 :return: The concatenation of the return values of :meth:`format_file_name` and :meth:`format_msg_line` 

141 ''' 

142 return self.format_file_name() + self.format_msg_line() 

143 

144 

145 def __str__(self) -> str: 

146 ''' 

147 :return: The return value of :meth:`format_file_name_msg_line` 

148 ''' 

149 return self.format_file_name_msg_line() 

150 

151 def __repr__(self) -> str: 

152 return f'{type(self).__name__}(%s)' % ', '.join(f'{a}={self._format_attribute(getattr(self, a))}' for a in self.__slots__) 

153 

154 @staticmethod 

155 def _format_attribute(obj: object) -> str: 

156 if isinstance(obj, enum.Enum): 

157 return obj.name 

158 return repr(obj) 

159 

160 

161class UiNotifier: 

162 

163 ''' 

164 Most likely you will want to load the config file before creating the UI (user interface). 

165 But if there are errors in the config file the user will want to know about them. 

166 This class takes the messages from :class:`ConfigFile` and stores them until the UI is ready. 

167 When you call :meth:`set_ui_callback` the stored messages will be forwarded and cleared. 

168 

169 This object can also filter the messages. 

170 :class:`ConfigFile` calls :meth:`show_info` every time a setting is changed. 

171 If you load an entire config file this can be many messages and the user probably does not want to see them all. 

172 Therefore this object drops all messages of :const:`NotificationLevel.INFO` by default. 

173 Pass :obj:`notification_level` to the constructor if you don't want that. 

174 ''' 

175 

176 # ------- public methods ------- 

177 

178 def __init__(self, config_file: 'ConfigFile', notification_level: 'Config[NotificationLevel]|NotificationLevel' = NotificationLevel.ERROR) -> None: 

179 self._messages: 'list[Message]' = [] 

180 self._callback: 'UiCallback|None' = None 

181 self._notification_level = notification_level 

182 self._config_file = config_file 

183 

184 def set_ui_callback(self, callback: UiCallback) -> None: 

185 ''' 

186 Call :paramref:`callback` for all messages which have been saved by :meth:`show` and clear all saved messages afterwards. 

187 Save :paramref:`callback` for :meth:`show` to call. 

188 ''' 

189 self._callback = callback 

190 

191 for msg in self._messages: 

192 callback(msg) 

193 self._messages.clear() 

194 

195 

196 @property 

197 def notification_level(self) -> NotificationLevel: 

198 ''' 

199 Ignore messages that are less important than this level. 

200 ''' 

201 if isinstance(self._notification_level, Config): 

202 return self._notification_level.value 

203 else: 

204 return self._notification_level 

205 

206 @notification_level.setter 

207 def notification_level(self, val: NotificationLevel) -> None: 

208 if isinstance(self._notification_level, Config): 

209 self._notification_level.value = val 

210 else: 

211 self._notification_level = val 

212 

213 

214 # ------- called by ConfigFile ------- 

215 

216 def show_info(self, msg: str, *, ignore_filter: bool = False) -> None: 

217 ''' 

218 Call :meth:`show` with :obj:`NotificationLevel.INFO`. 

219 ''' 

220 self.show(NotificationLevel.INFO, msg, ignore_filter=ignore_filter) 

221 

222 def show_error(self, msg: 'str|BaseException', *, ignore_filter: bool = False) -> None: 

223 ''' 

224 Call :meth:`show` with :obj:`NotificationLevel.ERROR`. 

225 ''' 

226 self.show(NotificationLevel.ERROR, msg, ignore_filter=ignore_filter) 

227 

228 

229 # ------- internal methods ------- 

230 

231 def show(self, notification_level: NotificationLevel, msg: 'str|BaseException', *, ignore_filter: bool = False) -> None: 

232 ''' 

233 If a callback for the user interface has been registered with :meth:`set_ui_callback` call that callback. 

234 Otherwise save the message so that :meth:`set_ui_callback` can forward the message when :meth:`set_ui_callback` is called. 

235 

236 :param notification_level: The importance of the message 

237 :param msg: The message to be displayed on the user interface 

238 :param ignore_filter: If true: Show the message even if :paramref:`notification_level` is smaller then the :paramref:`UiNotifier.notification_level`. 

239 ''' 

240 if notification_level < self.notification_level and not ignore_filter: 

241 return 

242 

243 message = Message( 

244 notification_level = notification_level, 

245 message = msg, 

246 file_name = self._config_file.context_file_name, 

247 line_number = self._config_file.context_line_number, 

248 line = self._config_file.context_line, 

249 ) 

250 

251 if self._callback: 

252 self._callback(message) 

253 else: 

254 self._messages.append(message) 

255 

256 

257# ---------- format help ---------- 

258 

259class SectionLevel(SortedEnum): 

260 

261 #: Is used to separate different commands in :meth:`ConfigFile.write_help` and :meth:`ConfigFileCommand.save` 

262 SECTION = 'section' 

263 

264 #: Is used for subsections in :meth:`ConfigFileCommand.save` such as the "data types" section in the help of the set command 

265 SUB_SECTION = 'sub-section' 

266 

267 

268class FormattedWriter(abc.ABC): 

269 

270 @abc.abstractmethod 

271 def write_line(self, ln: str) -> None: 

272 ''' 

273 Write a single line of documentation. 

274 :paramref:`ln` may *not* contain a newline. 

275 If :paramref:`ln` is empty it does not need to be prefixed with a comment character. 

276 Empty lines should be dropped if no other lines have been written before. 

277 ''' 

278 pass 

279 

280 def write_lines(self, text: str) -> None: 

281 ''' 

282 Write one or more lines of documentation. 

283 ''' 

284 for ln in text.splitlines(): 

285 self.write_line(ln) 

286 

287 @abc.abstractmethod 

288 def write_heading(self, lvl: SectionLevel, heading: str) -> None: 

289 ''' 

290 Write a heading. 

291 

292 This object should *not* add an indentation depending on the section 

293 because if the indentation is increased the line width should be decreased 

294 in order to keep the line wrapping consistent. 

295 Wrapping lines is handled by :class:`confattr.utils.HelpFormatter`, 

296 i.e. before the text is passed to this object. 

297 It would be possible to use :class:`argparse.RawTextHelpFormatter` instead 

298 and handle line wrapping on a higher level but that would require 

299 to understand the help generated by argparse 

300 in order to know how far to indent a broken line. 

301 One of the trickiest parts would probably be to get the indentation of the usage right. 

302 Keep in mind that the term "usage" can differ depending on the language settings of the user. 

303 

304 :param lvl: How to format the heading 

305 :param heading: The heading 

306 ''' 

307 pass 

308 

309 @abc.abstractmethod 

310 def write_command(self, cmd: str) -> None: 

311 ''' 

312 Write a config file command. 

313 ''' 

314 pass 

315 

316 

317class TextIOWriter(FormattedWriter): 

318 

319 def __init__(self, f: 'typing.TextIO|None') -> None: 

320 self.f = f 

321 self.ignore_empty_lines = True 

322 

323 def write_line_raw(self, ln: str) -> None: 

324 if self.ignore_empty_lines and not ln: 

325 return 

326 

327 print(ln, file=self.f) 

328 self.ignore_empty_lines = False 

329 

330 

331class ConfigFileWriter(TextIOWriter): 

332 

333 def __init__(self, f: 'typing.TextIO|None', prefix: str) -> None: 

334 super().__init__(f) 

335 self.prefix = prefix 

336 

337 def write_command(self, cmd: str) -> None: 

338 self.write_line_raw(cmd) 

339 

340 def write_line(self, ln: str) -> None: 

341 if ln: 

342 ln = self.prefix + ln 

343 

344 self.write_line_raw(ln) 

345 

346 def write_heading(self, lvl: SectionLevel, heading: str) -> None: 

347 if lvl is SectionLevel.SECTION: 

348 self.write_line('') 

349 self.write_line('') 

350 self.write_line('=' * len(heading)) 

351 self.write_line(heading) 

352 self.write_line('=' * len(heading)) 

353 else: 

354 self.write_line('') 

355 self.write_line(heading) 

356 self.write_line('-' * len(heading)) 

357 

358class HelpWriter(TextIOWriter): 

359 

360 def write_line(self, ln: str) -> None: 

361 self.write_line_raw(ln) 

362 

363 def write_heading(self, lvl: SectionLevel, heading: str) -> None: 

364 self.write_line('') 

365 if lvl is SectionLevel.SECTION: 

366 self.write_line(heading) 

367 self.write_line('=' * len(heading)) 

368 else: 

369 self.write_line(heading) 

370 self.write_line('-' * len(heading)) 

371 

372 def write_command(self, cmd: str) -> None: 

373 pass # pragma: no cover 

374 

375 

376# ---------- internal exceptions ---------- 

377 

378class ParseException(Exception): 

379 

380 ''' 

381 This is raised by :class:`ConfigFileCommand` implementations and functions passed to :paramref:`~ConfigFile.check_config_id` in order to communicate an error in the config file like invalid syntax or an invalid value. 

382 Is caught in :class:`ConfigFile`. 

383 ''' 

384 

385class MultipleParseExceptions(Exception): 

386 

387 ''' 

388 This is raised by :class:`ConfigFileCommand` implementations in order to communicate that multiple errors have occured on the same line. 

389 Is caught in :class:`ConfigFile`. 

390 ''' 

391 

392 def __init__(self, exceptions: 'Sequence[ParseException]') -> None: 

393 super().__init__() 

394 self.exceptions = exceptions 

395 

396 def __iter__(self) -> 'Iterator[ParseException]': 

397 return iter(self.exceptions) 

398 

399 

400# ---------- data types for **kw args ---------- 

401 

402if hasattr(typing, 'TypedDict'): # python >= 3.8 # pragma: no cover. This is tested but in a different environment which is not known to coverage. 

403 class SaveKwargs(typing.TypedDict, total=False): 

404 config_instances: 'Iterable[Config[typing.Any] | DictConfig[typing.Any, typing.Any]]' 

405 ignore: 'Iterable[Config[typing.Any] | DictConfig[typing.Any, typing.Any]] | None' 

406 no_multi: bool 

407 comments: bool 

408 

409 

410# ---------- ConfigFile class ---------- 

411 

412class ConfigFile: 

413 

414 ''' 

415 Read or write a config file. 

416 ''' 

417 

418 COMMENT = '#' 

419 COMMENT_PREFIXES = ('"', '#') 

420 ENTER_GROUP_PREFIX = '[' 

421 ENTER_GROUP_SUFFIX = ']' 

422 

423 #: The :class:`Config` instances to load or save 

424 config_instances: 'dict[str, Config[typing.Any]]' 

425 

426 #: While loading a config file: The group that is currently being parsed, i.e. an identifier for which object(s) the values shall be set. This is set in :meth:`enter_group` and reset in :meth:`load_file`. 

427 config_id: 'ConfigId|None' 

428 

429 #: Override the config file which is returned by :meth:`iter_config_paths`. 

430 #: You should set either this attribute or :attr:`config_directory` in your tests with :meth:`monkeypatch.setattr <pytest.MonkeyPatch.setattr>`. 

431 #: If the environment variable ``APPNAME_CONFIG_PATH`` is set this attribute is set to it's value in the constructor (where ``APPNAME`` is the value which is passed as :paramref:`appname <ConfigFile.appname>` to the constructor but in all upper case letters and hyphens and spaces replaced by underscores.) 

432 config_path: 'str|None' = None 

433 

434 #: Override the config directory which is returned by :meth:`iter_user_site_config_paths`. 

435 #: You should set either this attribute or :attr:`config_path` in your tests with :meth:`monkeypatch.setattr <pytest.MonkeyPatch.setattr>`. 

436 #: If the environment variable ``APPNAME_CONFIG_DIRECTORY`` is set this attribute is set to it's value in the constructor (where ``APPNAME`` is the value which is passed as :paramref:`appname <ConfigFile.appname>` to the constructor but in all upper case letters and hyphens and spaces replaced by underscores.) 

437 config_directory: 'str|None' = None 

438 

439 #: The name of the config file used by :meth:`iter_config_paths`. 

440 #: Can be changed with the environment variable ``APPNAME_CONFIG_NAME`` (where ``APPNAME`` is the value which is passed as :paramref:`appname <ConfigFile.appname>` to the constructor but in all upper case letters and hyphens and spaces replaced by underscores.). 

441 config_name = 'config' 

442 

443 #: Contains the names of the environment variables for :attr:`config_path`, :attr:`config_directory` and :attr:`config_name`—in capital letters and prefixed with :attr:`envprefix`. 

444 env_variables: 'list[str]' 

445 

446 #: A prefix that is prepended to the name of environment variables in :meth:`get_env_name`. 

447 #: It is set in the constructor by first setting it to an empty str and then passing the value of :paramref:`appname <ConfigFile.appname>` to :meth:`get_env_name` and appending an underscore. 

448 envprefix: str 

449 

450 #: The name of the file which is currently loaded. If this equals :attr:`Message.ENVIRONMENT_VARIABLES` it is no file name but an indicator that environment variables are loaded. This is :obj:`None` if :meth:`parse_line` is called directly (e.g. the input from a command line is parsed). 

451 context_file_name: 'str|None' = None 

452 #: The number of the line which is currently parsed. This is :obj:`None` if :attr:`context_file_name` is not a file name. 

453 context_line_number: 'int|None' = None 

454 #: The line which is currently parsed. 

455 context_line: str = '' 

456 

457 #: If true: ``[config-id]`` syntax is allowed in config file, config ids are included in help, config id related options are available for include. 

458 #: If false: It is not possible to set different values for different objects (but default values for :class:`MultiConfig` instances can be set) 

459 enable_config_ids: bool 

460 

461 

462 def __init__(self, *, 

463 notification_level: 'Config[NotificationLevel]' = NotificationLevel.ERROR, # type: ignore [assignment] # yes, passing a NotificationLevel directly is possible but I don't want users to do that in order to give the users of their applications the freedom to set this the way they need it 

464 appname: str, 

465 authorname: 'str|None' = None, 

466 config_instances: 'dict[str, Config[typing.Any]]' = Config.instances, 

467 commands: 'Sequence[type[ConfigFileCommand]]|None' = None, 

468 formatter_class: 'type[argparse.HelpFormatter]' = HelpFormatter, 

469 check_config_id: 'Callable[[ConfigId], None]|None' = None, 

470 enable_config_ids: 'bool|None' = None, 

471 ) -> None: 

472 ''' 

473 :param notification_level: A :class:`Config` which the users of your application can set to choose whether they want to see information which might be interesting for debugging a config file. A :class:`Message` with a priority lower than this value is *not* passed to the callback registered with :meth:`set_ui_callback`. 

474 :param appname: The name of the application, required for generating the path of the config file if you use :meth:`load` or :meth:`save` and as prefix of environment variable names 

475 :param authorname: The name of the developer of the application, on MS Windows useful for generating the path of the config file if you use :meth:`load` or :meth:`save` 

476 :param config_instances: The Config instances to load or save, defaults to :attr:`Config.instances` 

477 :param commands: The :class:`ConfigFileCommand`s allowed in this config file, if this is :const:`None`: use the return value of :meth:`ConfigFileCommand.get_command_types` 

478 :param formatter_class: Is used to clean up doc strings and wrap lines in the help 

479 :param check_config_id: Is called every time a configuration group is opened (except for :attr:`Config.default_config_id`—that is always allowed). The callback should raise a :class:`ParseException` if the config id is invalid. 

480 :param enable_config_ids: see :attr:`enable_config_ids`. If None: Choose True or False automatically based on :paramref:`check_config_id` and the existence of :class:`MultiConfig`/:class:`MultiDictConfig` 

481 ''' 

482 self.appname = appname 

483 self.authorname = authorname 

484 self.ui_notifier = UiNotifier(self, notification_level) 

485 self.config_instances = config_instances 

486 self.config_id: 'ConfigId|None' = None 

487 self.formatter_class = formatter_class 

488 self.env_variables: 'list[str]' = [] 

489 self.check_config_id = check_config_id 

490 

491 if enable_config_ids is None: 

492 enable_config_ids = self.check_config_id is not None or any(isinstance(cfg, MultiConfig) for cfg in self.config_instances.values()) 

493 self.enable_config_ids = enable_config_ids 

494 

495 if not appname: 

496 # Avoid an exception if appname is None. 

497 # Although mypy does not allow passing None directly 

498 # passing __package__ is (and should be) allowed. 

499 # And __package__ is None if the module is not part of a package. 

500 appname = '' 

501 self.envprefix = '' 

502 self.envprefix = self.get_env_name(appname + '_') 

503 envname = self.envprefix + 'CONFIG_PATH' 

504 self.env_variables.append(envname) 

505 if envname in os.environ: 

506 self.config_path = os.environ[envname] 

507 envname = self.envprefix + 'CONFIG_DIRECTORY' 

508 self.env_variables.append(envname) 

509 if envname in os.environ: 

510 self.config_directory = os.environ[envname] 

511 envname = self.envprefix + 'CONFIG_NAME' 

512 self.env_variables.append(envname) 

513 if envname in os.environ: 

514 self.config_name = os.environ[envname] 

515 

516 if commands is None: 

517 commands = ConfigFileCommand.get_command_types() 

518 self.command_dict = {} 

519 self.commands = [] 

520 for cmd_type in commands: 

521 cmd = cmd_type(self) 

522 self.commands.append(cmd) 

523 for name in cmd.get_names(): 

524 self.command_dict[name] = cmd 

525 

526 

527 def set_ui_callback(self, callback: UiCallback) -> None: 

528 ''' 

529 Register a callback to a user interface in order to show messages to the user like syntax errors or invalid values in the config file. 

530 

531 Messages which occur before this method is called are stored and forwarded as soon as the callback is registered. 

532 

533 :param ui_callback: A function to display messages to the user 

534 ''' 

535 self.ui_notifier.set_ui_callback(callback) 

536 

537 def get_app_dirs(self) -> 'appdirs.AppDirs': 

538 ''' 

539 Create or get a cached `AppDirs <https://github.com/ActiveState/appdirs/blob/master/README.rst#appdirs-for-convenience>`_ instance with multipath support enabled. 

540 

541 When creating a new instance, `platformdirs <https://pypi.org/project/platformdirs/>`_, `xdgappdirs <https://pypi.org/project/xdgappdirs/>`_ and `appdirs <https://pypi.org/project/appdirs/>`_ are tried, in that order. 

542 The first one installed is used. 

543 appdirs, the original of the two forks and the only one of the three with type stubs, is specified in pyproject.toml as a hard dependency so that at least one of the three should always be available. 

544 I am not very familiar with the differences but if a user finds that appdirs does not work for them they can choose to use an alternative with ``pipx inject appname xdgappdirs|platformdirs``. 

545 

546 These libraries should respect the environment variables ``XDG_CONFIG_HOME`` and ``XDG_CONFIG_DIRS``. 

547 ''' 

548 if not hasattr(self, '_appdirs'): 

549 try: 

550 import platformdirs # type: ignore [import] # this library is not typed and not necessarily installed, I am relying on it's compatibility with appdirs 

551 AppDirs = typing.cast('type[appdirs.AppDirs]', platformdirs.PlatformDirs) # pragma: no cover # This is tested but in a different tox environment 

552 except ImportError: 

553 try: 

554 import xdgappdirs # type: ignore [import] # this library is not typed and not necessarily installed, I am relying on it's compatibility with appdirs 

555 AppDirs = typing.cast('type[appdirs.AppDirs]', xdgappdirs.AppDirs) # pragma: no cover # This is tested but in a different tox environment 

556 except ImportError: 

557 AppDirs = appdirs.AppDirs 

558 

559 self._appdirs = AppDirs(self.appname, self.authorname, multipath=True) 

560 

561 return self._appdirs 

562 

563 # ------- load ------- 

564 

565 def iter_user_site_config_paths(self) -> 'Iterator[str]': 

566 ''' 

567 Iterate over all directories which are searched for config files, user specific first. 

568 

569 The directories are based on :meth:`get_app_dirs` 

570 unless :attr:`config_directory` has been set. 

571 If :attr:`config_directory` has been set 

572 it's value is yielded and nothing else. 

573 ''' 

574 if self.config_directory: 

575 yield self.config_directory 

576 return 

577 

578 appdirs = self.get_app_dirs() 

579 yield from appdirs.user_config_dir.split(os.path.pathsep) 

580 yield from appdirs.site_config_dir.split(os.path.pathsep) 

581 

582 def iter_config_paths(self) -> 'Iterator[str]': 

583 ''' 

584 Iterate over all paths which are checked for config files, user specific first. 

585 

586 Use this method if you want to tell the user where the application is looking for it's config file. 

587 The first existing file yielded by this method is used by :meth:`load`. 

588 

589 The paths are generated by joining the directories yielded by :meth:`iter_user_site_config_paths` with 

590 :attr:`ConfigFile.config_name`. 

591 

592 If :attr:`config_path` has been set this method yields that path instead and no other paths. 

593 ''' 

594 if self.config_path: 

595 yield self.config_path 

596 return 

597 

598 for path in self.iter_user_site_config_paths(): 

599 yield os.path.join(path, self.config_name) 

600 

601 def load(self, *, env: bool = True) -> None: 

602 ''' 

603 Load the first existing config file returned by :meth:`iter_config_paths`. 

604 

605 If there are several config files a user specific config file is preferred. 

606 If a user wants a system wide config file to be loaded, too, they can explicitly include it in their config file. 

607 :param env: If true: call :meth:`load_env` after loading the config file. 

608 ''' 

609 for fn in self.iter_config_paths(): 

610 if os.path.isfile(fn): 

611 self.load_file(fn) 

612 break 

613 

614 if env: 

615 self.load_env() 

616 

617 def load_env(self) -> None: 

618 ''' 

619 Load settings from environment variables. 

620 The name of the environment variable belonging to a setting is generated with :meth:`get_env_name`. 

621 

622 Environment variables not matching a setting or having an invalid value are reported with :meth:`self.ui_notifier.show_error() <UiNotifier.show_error>`. 

623 

624 :raises ValueError: if two settings have the same environment variable name (see :meth:`get_env_name`) or the environment variable name for a setting collides with one of the standard environment variables listed in :attr:`env_variables` 

625 ''' 

626 old_file_name = self.context_file_name 

627 self.context_file_name = Message.ENVIRONMENT_VARIABLES 

628 

629 config_instances: 'dict[str, Config[object]]' = {} 

630 for key, instance in self.config_instances.items(): 

631 name = self.get_env_name(key) 

632 if name in self.env_variables: 

633 raise ValueError(f'setting {instance.key!r} conflicts with environment variable {name!r}') 

634 elif name in config_instances: 

635 raise ValueError(f'settings {instance.key!r} and {config_instances[name].key!r} result in the same environment variable {name!r}') 

636 else: 

637 config_instances[name] = instance 

638 

639 for name, value in os.environ.items(): 

640 if not name.startswith(self.envprefix): 

641 continue 

642 if name in self.env_variables: 

643 continue 

644 

645 if name in config_instances: 

646 instance = config_instances[name] 

647 try: 

648 instance.set_value(config_id=None, value=instance.parse_value(value)) 

649 self.ui_notifier.show_info(f'set {instance.key} to {instance.format_value(config_id=None)}') 

650 except ValueError as e: 

651 self.ui_notifier.show_error(f"{e} while trying to parse environment variable {name}='{value}'") 

652 else: 

653 self.ui_notifier.show_error(f"unknown environment variable {name}='{value}'") 

654 

655 self.context_file_name = old_file_name 

656 

657 

658 def get_env_name(self, key: str) -> str: 

659 ''' 

660 Convert the key of a setting to the name of the corresponding environment variable. 

661 

662 :return: An all upper case version of :paramref:`key` with all hyphens, dots and spaces replaced by underscores and :attr:`envprefix` prepended to the result. 

663 ''' 

664 out = key 

665 out = out.upper() 

666 for c in ' .-': 

667 out = out.replace(c, '_') 

668 out = self.envprefix + out 

669 return out 

670 

671 def load_file(self, fn: str) -> None: 

672 ''' 

673 Load a config file and change the :class:`Config` objects accordingly. 

674 

675 Use :meth:`set_ui_callback` to get error messages which appeared while loading the config file. 

676 You can call :meth:`set_ui_callback` after this method without loosing any messages. 

677 

678 :param fn: The file name of the config file (absolute or relative path) 

679 ''' 

680 self.config_id = None 

681 self.load_without_resetting_config_id(fn) 

682 

683 def load_without_resetting_config_id(self, fn: str) -> None: 

684 old_file_name = self.context_file_name 

685 self.context_file_name = fn 

686 

687 with open(fn, 'rt') as f: 

688 for lnno, ln in enumerate(f, 1): 

689 self.context_line_number = lnno 

690 self.parse_line(line=ln) 

691 self.context_line_number = None 

692 

693 self.context_file_name = old_file_name 

694 

695 def parse_line(self, line: str) -> None: 

696 ''' 

697 :param line: The line to be parsed 

698 

699 :meth:`parse_error` is called if something goes wrong, e.g. invalid key or invalid value. 

700 ''' 

701 ln = line.strip() 

702 if not ln: 

703 return 

704 if self.is_comment(ln): 

705 return 

706 if self.enable_config_ids and self.enter_group(ln): 

707 return 

708 

709 self.context_line = ln 

710 

711 ln_splitted = shlex.split(ln, comments=True) 

712 self.parse_splitted_line(ln_splitted) 

713 

714 self.context_line = '' 

715 

716 def is_comment(self, ln: str) -> bool: 

717 ''' 

718 Check if :paramref:`ln` is a comment. 

719 

720 :param ln: The current line 

721 :return: :const:`True` if :paramref:`ln` is a comment 

722 ''' 

723 for c in self.COMMENT_PREFIXES: 

724 if ln.startswith(c): 

725 return True 

726 return False 

727 

728 def enter_group(self, ln: str) -> bool: 

729 ''' 

730 Check if :paramref:`ln` starts a new group and set :attr:`config_id` if it does. 

731 Call :meth:`parse_error` if :attr:`check_config_id` raises a :class:`ParseException`. 

732 

733 :param ln: The current line 

734 :return: :const:`True` if :paramref:`ln` starts a new group 

735 ''' 

736 if ln.startswith(self.ENTER_GROUP_PREFIX) and ln.endswith(self.ENTER_GROUP_SUFFIX): 

737 config_id = typing.cast(ConfigId, ln[len(self.ENTER_GROUP_PREFIX):-len(self.ENTER_GROUP_SUFFIX)]) 

738 if self.check_config_id and config_id != Config.default_config_id: 

739 try: 

740 self.check_config_id(config_id) 

741 except ParseException as e: 

742 self.parse_error(str(e)) 

743 self.config_id = config_id 

744 if self.config_id not in MultiConfig.config_ids: 

745 MultiConfig.config_ids.append(self.config_id) 

746 return True 

747 return False 

748 

749 def parse_splitted_line(self, ln_splitted: 'Sequence[str]') -> None: 

750 ''' 

751 Call the corresponding command in :attr:`command_dict`. 

752 If any :class:`ParseException` or :class:`MultipleParseExceptions` is raised catch it and call :meth:`parse_error`. 

753 ''' 

754 cmd_name = ln_splitted[0] 

755 

756 try: 

757 if cmd_name in self.command_dict: 

758 cmd = self.command_dict[cmd_name] 

759 elif DEFAULT_COMMAND in self.command_dict: 

760 cmd = self.command_dict[DEFAULT_COMMAND] 

761 else: 

762 cmd = UnknownCommand(self) 

763 cmd.run(ln_splitted) 

764 except ParseException as e: 

765 self.parse_error(str(e)) 

766 except MultipleParseExceptions as exceptions: 

767 for exc in exceptions: 

768 self.parse_error(str(exc)) 

769 

770 

771 # ------- save ------- 

772 

773 def get_save_path(self) -> str: 

774 ''' 

775 :return: The first existing and writable file returned by :meth:`iter_config_paths` or the first path if none of the files are existing and writable. 

776 ''' 

777 paths = tuple(self.iter_config_paths()) 

778 for fn in paths: 

779 if os.path.isfile(fn) and os.access(fn, os.W_OK): 

780 return fn 

781 

782 return paths[0] 

783 

784 def save(self, 

785 **kw: 'Unpack[SaveKwargs]', 

786 ) -> str: 

787 ''' 

788 Save the current values of all settings to the file returned by :meth:`get_save_path`. 

789 Directories are created as necessary. 

790 

791 :param config_instances: Do not save all settings but only those given. If this is a :class:`list` they are written in the given order. If this is a :class:`set` they are sorted by their keys. 

792 :param ignore: Do not write these settings to the file. 

793 :param no_multi: Do not write several sections. For :class:`MultiConfig` instances write the default values only. 

794 :param comments: Write comments with allowed values and help. 

795 :return: The path to the file which has been written 

796 ''' 

797 fn = self.get_save_path() 

798 # "If, when attempting to write a file, the destination directory is non-existent an attempt should be made to create it with permission 0700. 

799 # If the destination directory exists already the permissions should not be changed." 

800 # https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html 

801 os.makedirs(os.path.dirname(fn), exist_ok=True, mode=0o0700) 

802 self.save_file(fn, **kw) 

803 return fn 

804 

805 def save_file(self, 

806 fn: str, 

807 **kw: 'Unpack[SaveKwargs]' 

808 ) -> None: 

809 ''' 

810 Save the current values of all settings to a specific file. 

811 

812 :param fn: The name of the file to write to. If this is not an absolute path it is relative to the current working directory. 

813 :raises FileNotFoundError: if the directory does not exist 

814 

815 For an explanation of the other parameters see :meth:`save`. 

816 ''' 

817 with open(fn, 'wt') as f: 

818 self.save_to_open_file(f, **kw) 

819 

820 

821 def save_to_open_file(self, 

822 f: typing.TextIO, 

823 **kw: 'Unpack[SaveKwargs]', 

824 ) -> None: 

825 ''' 

826 Save the current values of all settings to a file-like object 

827 by creating a :class:`ConfigFileWriter` object and calling :meth:`save_to_writer`. 

828 

829 :param f: The file to write to 

830 

831 For an explanation of the other parameters see :meth:`save`. 

832 ''' 

833 writer = ConfigFileWriter(f, prefix=self.COMMENT + ' ') 

834 self.save_to_writer(writer, **kw) 

835 

836 def save_to_writer(self, writer: FormattedWriter, **kw: 'Unpack[SaveKwargs]') -> None: 

837 ''' 

838 Save the current values of all settings. 

839 

840 Ensure that all keyword arguments are passed with :meth:`set_save_default_arguments`. 

841 Iterate over all :class:`ConfigFileCommand` objects in :attr:`self.commands` and do for each of them: 

842 

843 - set :attr:`~ConfigFileCommand.should_write_heading` to :obj:`True` if :python:`getattr(cmd.save, 'implemented', True)` is true for two or more of those commands or to :obj:`False` otherwise 

844 - call :meth:`~ConfigFileCommand.save` 

845 ''' 

846 self.set_save_default_arguments(kw) 

847 commands = self.commands 

848 write_headings = len(tuple(cmd for cmd in commands if getattr(cmd.save, 'implemented', True))) >= 2 

849 for cmd in commands: 

850 cmd.should_write_heading = write_headings 

851 cmd.save(writer, **kw) 

852 

853 def set_save_default_arguments(self, kw: 'SaveKwargs') -> None: 

854 ''' 

855 Ensure that all arguments are given in :paramref:`kw`. 

856 ''' 

857 kw.setdefault('config_instances', set(self.config_instances.values())) 

858 kw.setdefault('ignore', None) 

859 kw.setdefault('no_multi', not self.enable_config_ids) 

860 kw.setdefault('comments', True) 

861 

862 

863 def quote(self, val: str) -> str: 

864 ''' 

865 Quote a value if necessary so that it will be interpreted as one argument. 

866 

867 The default implementation calls :func:`readable_quote`. 

868 ''' 

869 return readable_quote(val) 

870 

871 def write_config_id(self, writer: FormattedWriter, config_id: ConfigId) -> None: 

872 ''' 

873 Start a new group in the config file so that all following commands refer to the given :paramref:`config_id`. 

874 ''' 

875 writer.write_command(self.ENTER_GROUP_PREFIX + config_id + self.ENTER_GROUP_SUFFIX) 

876 

877 def get_help_config_id(self) -> str: 

878 ''' 

879 :return: A help how to use :class:`MultiConfig`. The return value still needs to be cleaned with :meth:`inspect.cleandoc`. 

880 ''' 

881 return f''' 

882 You can specify the object that a value shall refer to by inserting the line `{self.ENTER_GROUP_PREFIX}config-id{self.ENTER_GROUP_SUFFIX}` above. 

883 `config-id` must be replaced by the corresponding identifier for the object. 

884 ''' 

885 

886 

887 # ------- help ------- 

888 

889 def write_help(self, writer: FormattedWriter) -> None: 

890 import platform 

891 formatter = self.create_formatter() 

892 writer.write_lines('The first existing file of the following paths is loaded:') 

893 for path in self.iter_config_paths(): 

894 writer.write_line('- %s' % path) 

895 

896 writer.write_line('') 

897 writer.write_line('This can be influenced with the following environment variables:') 

898 if platform.system() == 'Linux': # pragma: no branch 

899 writer.write_line('- XDG_CONFIG_HOME') 

900 writer.write_line('- XDG_CONFIG_DIRS') 

901 for env in self.env_variables: 

902 writer.write_line(f'- {env}') 

903 

904 writer.write_line('') 

905 writer.write_lines(formatter.format_text(f'''\ 

906You can also use environment variables to change the values of the settings listed under `set` command. 

907The corresponding environment variable name is the name of the setting in all upper case letters 

908with dots, hypens and spaces replaced by underscores and prefixed with "{self.envprefix}".''')) 

909 

910 writer.write_lines(formatter.format_text('Lines in the config file which start with a %s are ignored.' % ' or '.join('`%s`' % c for c in self.COMMENT_PREFIXES))) 

911 

912 writer.write_lines('The config file may contain the following commands:') 

913 for cmd in self.commands: 

914 names = '|'.join(cmd.get_names()) 

915 writer.write_heading(SectionLevel.SECTION, names) 

916 writer.write_lines(cmd.get_help()) 

917 

918 def create_formatter(self) -> HelpFormatterWrapper: 

919 return HelpFormatterWrapper(self.formatter_class) 

920 

921 def get_help(self) -> str: 

922 ''' 

923 A convenience wrapper around :meth:`write_help` 

924 to return the help as a str instead of writing it to a file. 

925 

926 This uses :class:`HelpWriter`. 

927 ''' 

928 doc = io.StringIO() 

929 self.write_help(HelpWriter(doc)) 

930 # The generated help ends with a \n which is implicitly added by print. 

931 # If I was writing to stdout or a file that would be desired. 

932 # But if I return it as a string and then print it, the print adds another \n which would be too much. 

933 # Therefore I am stripping the trailing \n. 

934 return doc.getvalue().rstrip('\n') 

935 

936 

937 # ------- error handling ------- 

938 

939 def parse_error(self, msg: str) -> None: 

940 ''' 

941 Is called if something went wrong while trying to load a config file. 

942 

943 This method is called when a :class:`ParseException` or :class:`MultipleParseExceptions` is caught. 

944 This method compiles the given information into an error message and calls :meth:`self.ui_notifier.show_error() <UiNotifier.show_error>`. 

945 

946 :param msg: The error message 

947 ''' 

948 self.ui_notifier.show_error(msg) 

949 

950 

951# ---------- base classes for commands which can be used in config files ---------- 

952 

953class ConfigFileCommand(abc.ABC): 

954 

955 ''' 

956 An abstract base class for commands which can be used in a config file. 

957 

958 Subclasses must implement the :meth:`run` method which is called when :class:`ConfigFile` is loading a file. 

959 Subclasses should contain a doc string so that :meth:`get_help` can provide a description to the user. 

960 Subclasses may set the :attr:`name` and :attr:`aliases` attributes to change the output of :meth:`get_name` and :meth:`get_names`. 

961 

962 All subclasses are remembered and can be retrieved with :meth:`get_command_types`. 

963 They are instantiated in the constructor of :class:`ConfigFile`. 

964 ''' 

965 

966 #: The name which is used in the config file to call this command. Use an empty string to define a default command which is used if an undefined command is encountered. If this is not set :meth:`get_name` returns the name of this class in lower case letters and underscores replaced by hyphens. 

967 name: str 

968 

969 #: Alternative names which can be used in the config file. 

970 aliases: 'tuple[str, ...]|list[str]' 

971 

972 #: A description which may be used by an in-app help. If this is not set :meth:`get_help` uses the doc string instead. 

973 help: str 

974 

975 #: If a config file contains only a single section it makes no sense to write a heading for it. This attribute is set by :meth:`ConfigFile.save_to_writer` if there are several commands which implement the :meth:`save` method. If you implement :meth:`save` and this attribute is set then :meth:`save` should write a section header. If :meth:`save` writes several sections it should always write the headings regardless of this attribute. 

976 should_write_heading: bool = False 

977 

978 #: The :class:`ConfigFile` that is passed to the constructor 

979 config_file: ConfigFile 

980 

981 #: The :class:`UiNotifier` of :attr:`config_file` 

982 ui_notifier: UiNotifier 

983 

984 

985 _subclasses: 'list[type[ConfigFileCommand]]' = [] 

986 _used_names: 'set[str]' = set() 

987 

988 @classmethod 

989 def get_command_types(cls) -> 'tuple[type[ConfigFileCommand], ...]': 

990 ''' 

991 :return: All subclasses of :class:`ConfigFileCommand` which have not been deleted with :meth:`delete_command_type` 

992 ''' 

993 return tuple(cls._subclasses) 

994 

995 @classmethod 

996 def delete_command_type(cls, cmd_type: 'type[ConfigFileCommand]') -> None: 

997 ''' 

998 Delete :paramref:`cmd_type` so that it is not returned anymore by :meth:`get_command_types` and that it's name can be used by another command. 

999 Do nothing if :paramref:`cmd_type` has already been deleted. 

1000 ''' 

1001 if cmd_type in cls._subclasses: 

1002 cls._subclasses.remove(cmd_type) 

1003 for name in cmd_type.get_names(): 

1004 cls._used_names.remove(name) 

1005 

1006 @classmethod 

1007 def __init_subclass__(cls, replace: bool = False, abstract: bool = False) -> None: 

1008 ''' 

1009 Add the new subclass to :attr:`subclass`. 

1010 

1011 :param replace: Set :attr:`name` and :attr:`aliases` to the values of the parent class if they are not set explicitly, delete the parent class with :meth:`delete_command_type` and replace any commands with the same name 

1012 :param abstract: This class is a base class for the implementation of other commands and shall *not* be returned by :meth:`get_command_types` 

1013 :raises ValueError: if the name or one of it's aliases is already in use and :paramref:`replace` is not true 

1014 ''' 

1015 if replace: 

1016 parent_commands = [parent for parent in cls.__bases__ if issubclass(parent, ConfigFileCommand)] 

1017 

1018 # set names of this class to that of the parent class(es) 

1019 parent = parent_commands[0] 

1020 if 'name' not in cls.__dict__: 

1021 cls.name = parent.get_name() 

1022 if 'aliases' not in cls.__dict__: 

1023 cls.aliases = list(parent.get_names())[1:] 

1024 for parent in parent_commands[1:]: 

1025 cls.aliases.extend(parent.get_names()) 

1026 

1027 # remove parent class from the list of commands to be loaded or saved 

1028 for parent in parent_commands: 

1029 cls.delete_command_type(parent) 

1030 

1031 if not abstract: 

1032 cls._subclasses.append(cls) 

1033 for name in cls.get_names(): 

1034 if name in cls._used_names and not replace: 

1035 raise ValueError('duplicate command name %r' % name) 

1036 cls._used_names.add(name) 

1037 

1038 @classmethod 

1039 def get_name(cls) -> str: 

1040 ''' 

1041 :return: The name which is used in config file to call this command. 

1042  

1043 If :attr:`name` is set it is returned as it is. 

1044 Otherwise a name is generated based on the class name. 

1045 ''' 

1046 if 'name' in cls.__dict__: 

1047 return cls.name 

1048 return cls.__name__.lower().replace("_", "-") 

1049 

1050 @classmethod 

1051 def get_names(cls) -> 'Iterator[str]': 

1052 ''' 

1053 :return: Several alternative names which can be used in a config file to call this command. 

1054  

1055 The first one is always the return value of :meth:`get_name`. 

1056 If :attr:`aliases` is set it's items are yielded afterwards. 

1057 

1058 If one of the returned items is the empty string this class is the default command 

1059 and :meth:`run` will be called if an undefined command is encountered. 

1060 ''' 

1061 yield cls.get_name() 

1062 if 'aliases' in cls.__dict__: 

1063 for name in cls.aliases: 

1064 yield name 

1065 

1066 def __init__(self, config_file: ConfigFile) -> None: 

1067 self.config_file = config_file 

1068 self.ui_notifier = config_file.ui_notifier 

1069 

1070 @abc.abstractmethod 

1071 def run(self, cmd: 'Sequence[str]') -> None: 

1072 ''' 

1073 Process one line which has been read from a config file 

1074 

1075 :raises ParseException: if there is an error in the line (e.g. invalid syntax) 

1076 :raises MultipleParseExceptions: if there are several errors in the same line 

1077 ''' 

1078 raise NotImplementedError() 

1079 

1080 def create_formatter(self) -> HelpFormatterWrapper: 

1081 return self.config_file.create_formatter() 

1082 

1083 def get_help_attr_or_doc_str(self) -> str: 

1084 ''' 

1085 :return: The :attr:`help` attribute or the doc string if :attr:`help` has not been set, cleaned up with :meth:`inspect.cleandoc`. 

1086 ''' 

1087 if hasattr(self, 'help'): 

1088 doc = self.help 

1089 elif self.__doc__: 

1090 doc = self.__doc__ 

1091 else: 

1092 doc = '' 

1093 

1094 return inspect.cleandoc(doc) 

1095 

1096 def add_help_to(self, formatter: HelpFormatterWrapper) -> None: 

1097 ''' 

1098 Add the return value of :meth:`get_help_attr_or_doc_str` to :paramref:`formatter`. 

1099 ''' 

1100 formatter.add_text(self.get_help_attr_or_doc_str()) 

1101 

1102 def get_help(self) -> str: 

1103 ''' 

1104 :return: A help text which can be presented to the user. 

1105 

1106 This is generated by creating a formatter with :meth:`create_formatter`, 

1107 adding the help to it with :meth:`add_help_to` and 

1108 stripping trailing new line characters from the result of :meth:`HelpFormatterWrapper.format_help`. 

1109 

1110 Most likely you don't want to override this method but :meth:`add_help_to` instead. 

1111 ''' 

1112 formatter = self.create_formatter() 

1113 self.add_help_to(formatter) 

1114 return formatter.format_help().rstrip('\n') 

1115 

1116 def save(self, 

1117 writer: FormattedWriter, 

1118 **kw: 'Unpack[SaveKwargs]', 

1119 ) -> None: 

1120 ''' 

1121 Implement this method if you want calls to this command to be written by :meth:`ConfigFile.save`. 

1122 

1123 If you implement this method write a section heading with :meth:`writer.write_heading('Heading') <FormattedWriter.write_heading>` if :attr:`should_write_heading` is true. 

1124 If this command writes several sections then write a heading for every section regardless of :attr:`should_write_heading`. 

1125 

1126 Write as many calls to this command as necessary to the config file in order to create the current state with :meth:`writer.write_command('...') <FormattedWriter.write_command>`. 

1127 Write comments or help with :meth:`writer.write_lines('...') <FormattedWriter.write_lines>`. 

1128 

1129 There is the :attr:`config_file` attribute (which was passed to the constructor) which you can use to: 

1130 

1131 - quote arguments with :meth:`ConfigFile.quote` 

1132 - call :attr:`ConfigFile.write_config_id` 

1133 

1134 You probably don't need the comment character :attr:`ConfigFile.COMMENT` because :paramref:`writer` automatically comments out everything except for :meth:`FormattedWriter.write_command`. 

1135 

1136 The default implementation does nothing. 

1137 ''' 

1138 pass 

1139 

1140 save.implemented = False # type: ignore [attr-defined] 

1141 

1142 

1143class ArgumentParser(argparse.ArgumentParser): 

1144 

1145 def error(self, message: str) -> 'typing.NoReturn': 

1146 ''' 

1147 Raise a :class:`ParseException`. 

1148 ''' 

1149 raise ParseException(message) 

1150 

1151class ConfigFileArgparseCommand(ConfigFileCommand, abstract=True): 

1152 

1153 ''' 

1154 An abstract subclass of :class:`ConfigFileCommand` which uses :mod:`argparse` to make parsing and providing help easier. 

1155 

1156 You must implement the class method :meth:`init_parser` to add the arguments to :attr:`parser`. 

1157 Instead of :meth:`run` you must implement :meth:`run_parsed`. 

1158 You don't need to add a usage or the possible arguments to the doc string as :mod:`argparse` will do that for you. 

1159 You should, however, still give a description what this command does in the doc string. 

1160 

1161 You may specify :attr:`ConfigFileCommand.name`, :attr:`ConfigFileCommand.aliases` and :meth:`ConfigFileCommand.save` like for :class:`ConfigFileCommand`. 

1162 ''' 

1163 

1164 def __init__(self, config_file: ConfigFile) -> None: 

1165 super().__init__(config_file) 

1166 self._names = set(self.get_names()) 

1167 self.parser = ArgumentParser(prog=self.get_name(), description=self.get_help_attr_or_doc_str(), add_help=False, formatter_class=self.config_file.formatter_class) 

1168 self.init_parser(self.parser) 

1169 

1170 @abc.abstractmethod 

1171 def init_parser(self, parser: ArgumentParser) -> None: 

1172 ''' 

1173 :param parser: The parser to add arguments to. This is the same object like :attr:`parser`. 

1174 

1175 This is an abstract method which must be implemented by subclasses. 

1176 Use :meth:`ArgumentParser.add_argument` to add arguments to :paramref:`parser`. 

1177 ''' 

1178 pass 

1179 

1180 def get_help(self) -> str: 

1181 ''' 

1182 Creates a help text which can be presented to the user by calling :meth:`parser.format_help`. 

1183 The return value of :meth:`ConfigFileCommand.write_help` has been passed as :paramref:`description` to the constructor of :class:`ArgumentParser`, therefore :attr:`help`/the doc string are included as well. 

1184 ''' 

1185 return self.parser.format_help().rstrip('\n') 

1186 

1187 def run(self, cmd: 'Sequence[str]') -> None: 

1188 # if the line was empty this method should not be called but an empty line should be ignored either way 

1189 if not cmd: 

1190 return # pragma: no cover 

1191 # cmd[0] does not need to be in self._names if this is the default command, i.e. if '' in self._names 

1192 if cmd[0] in self._names: 

1193 cmd = cmd[1:] 

1194 args = self.parser.parse_args(cmd) 

1195 self.run_parsed(args) 

1196 

1197 @abc.abstractmethod 

1198 def run_parsed(self, args: argparse.Namespace) -> None: 

1199 ''' 

1200 This is an abstract method which must be implemented by subclasses. 

1201 ''' 

1202 pass 

1203 

1204 

1205# ---------- implementations of commands which can be used in config files ---------- 

1206 

1207class Set(ConfigFileCommand): 

1208 

1209 r''' 

1210 usage: set key1=val1 [key2=val2 ...] \\ 

1211 set key [=] val 

1212 

1213 Change the value of a setting. 

1214 

1215 In the first form set takes an arbitrary number of arguments, each argument sets one setting. 

1216 This has the advantage that several settings can be changed at once. 

1217 That is useful if you want to bind a set command to a key and process that command with ConfigFile.parse_line() if the key is pressed. 

1218 

1219 In the second form set takes two arguments, the key and the value. Optionally a single equals character may be added in between as third argument. 

1220 This has the advantage that key and value are separated by one or more spaces which can improve the readability of a config file. 

1221 ''' 

1222 

1223 #: The separator which is used between a key and it's value 

1224 KEY_VAL_SEP = '=' 

1225 

1226 #: Help for data types. This is used by :meth:`get_help_for_data_types`. Change this with :meth:`set_help_for_type`. 

1227 help_for_types = { 

1228 str : 'A text. If it contains spaces it must be wrapped in single or double quotes.', 

1229 int : '''\ 

1230 An integer number in python 3 syntax, as decimal (e.g. 42), hexadecimal (e.g. 0x2a), octal (e.g. 0o52) or binary (e.g. 0b101010). 

1231 Leading zeroes are not permitted to avoid confusion with python 2's syntax for octal numbers. 

1232 It is permissible to group digits with underscores for better readability, e.g. 1_000_000.''', 

1233 #bool, 

1234 float : 'A floating point number in python syntax, e.g. 23, 1.414, -1e3, 3.14_15_93.', 

1235 } 

1236 

1237 

1238 # ------- load ------- 

1239 

1240 def run(self, cmd: 'Sequence[str]') -> None: 

1241 ''' 

1242 Call :meth:`set_multiple` if the first argument contains :attr:`KEY_VAL_SEP` otherwise :meth:`set_with_spaces`. 

1243 

1244 :raises ParseException: if something is wrong (no arguments given, invalid syntax, invalid key, invalid value) 

1245 ''' 

1246 if len(cmd) < 2: 

1247 raise ParseException('no settings given') 

1248 

1249 if self.KEY_VAL_SEP in cmd[1]: # cmd[0] is the name of the command, cmd[1] is the first argument 

1250 self.set_multiple(cmd) 

1251 else: 

1252 self.set_with_spaces(cmd) 

1253 

1254 def set_with_spaces(self, cmd: 'Sequence[str]') -> None: 

1255 ''' 

1256 Process one line of the format ``set key [=] value`` 

1257 

1258 :raises ParseException: if something is wrong (invalid syntax, invalid key, invalid value) 

1259 ''' 

1260 n = len(cmd) 

1261 if n == 3: 

1262 cmdname, key, value = cmd 

1263 self.parse_key_and_set_value(key, value) 

1264 elif n == 4: 

1265 cmdname, key, sep, value = cmd 

1266 if sep != self.KEY_VAL_SEP: 

1267 raise ParseException(f'separator between key and value should be {self.KEY_VAL_SEP}, not {sep!r}') 

1268 self.parse_key_and_set_value(key, value) 

1269 elif n == 2: 

1270 raise ParseException(f'missing value or missing {self.KEY_VAL_SEP}') 

1271 else: 

1272 assert n >= 5 

1273 raise ParseException(f'too many arguments given or missing {self.KEY_VAL_SEP} in first argument') 

1274 

1275 def set_multiple(self, cmd: 'Sequence[str]') -> None: 

1276 ''' 

1277 Process one line of the format ``set key=value [key2=value2 ...]`` 

1278 

1279 :raises MultipleParseExceptions: if something is wrong (invalid syntax, invalid key, invalid value) 

1280 ''' 

1281 exceptions = [] 

1282 for arg in cmd[1:]: 

1283 try: 

1284 if not self.KEY_VAL_SEP in arg: 

1285 raise ParseException(f'missing {self.KEY_VAL_SEP} in {arg!r}') 

1286 key, value = arg.split(self.KEY_VAL_SEP, 1) 

1287 self.parse_key_and_set_value(key, value) 

1288 except ParseException as e: 

1289 exceptions.append(e) 

1290 if exceptions: 

1291 raise MultipleParseExceptions(exceptions) 

1292 

1293 def parse_key_and_set_value(self, key: str, value: str) -> None: 

1294 ''' 

1295 Find the corresponding :class:`Config` instance for :paramref:`key` and call :meth:`set_value` with the return value of :meth:`parse_value`. 

1296 

1297 :raises ParseException: if key is invalid or if :meth:`parse_value` or :meth:`set_value` raises a :class:`ValueError` 

1298 ''' 

1299 if key not in self.config_file.config_instances: 

1300 raise ParseException(f'invalid key {key!r}') 

1301 

1302 instance = self.config_file.config_instances[key] 

1303 try: 

1304 self.set_value(instance, self.parse_value(instance, value)) 

1305 except ValueError as e: 

1306 raise ParseException(str(e)) 

1307 

1308 def parse_value(self, instance: 'Config[T2]', value: str) -> 'T2': 

1309 ''' 

1310 Parse a value to the data type of a given setting by calling :meth:`instance.parse_value(value) <Config.parse_value>` 

1311 ''' 

1312 return instance.parse_value(value) 

1313 

1314 def set_value(self, instance: 'Config[T2]', value: 'T2') -> None: 

1315 ''' 

1316 Assign :paramref:`value` to :paramref`instance` by calling :meth:`Config.set_value` with :attr:`ConfigFile.config_id` of :attr:`config_file`. 

1317 Afterwards call :meth:`UiNotifier.show_info`. 

1318 ''' 

1319 instance.set_value(self.config_file.config_id, value) 

1320 self.ui_notifier.show_info(f'set {instance.key} to {instance.format_value(self.config_file.config_id)}') 

1321 

1322 

1323 # ------- save ------- 

1324 

1325 def iter_config_instances_to_be_saved(self, **kw: 'Unpack[SaveKwargs]') -> 'Iterator[Config[object]]': 

1326 ''' 

1327 :param config_instances: The settings to consider 

1328 :param ignore: Skip these settings 

1329 

1330 Iterate over all given :paramref:`config_instances` and expand all :class:`DictConfig` instances into the :class:`Config` instances they consist of. 

1331 Sort the resulting list if :paramref:`config_instances` is not a :class:`list` or a :class:`tuple`. 

1332 Yield all :class:`Config` instances which are not (directly or indirectly) contained in :paramref:`ignore` and where :meth:`Config.wants_to_be_exported` returns true. 

1333 ''' 

1334 config_instances = kw['config_instances'] 

1335 ignore = kw['ignore'] 

1336 

1337 config_keys = [] 

1338 for c in config_instances: 

1339 if isinstance(c, DictConfig): 

1340 config_keys.extend(sorted(c.iter_keys())) 

1341 else: 

1342 config_keys.append(c.key) 

1343 if not isinstance(config_instances, (list, tuple)): 

1344 config_keys = sorted(config_keys) 

1345 

1346 if ignore is not None: 

1347 tmp = set() 

1348 for c in tuple(ignore): 

1349 if isinstance(c, DictConfig): 

1350 tmp |= set(c._values.values()) 

1351 else: 

1352 tmp.add(c) 

1353 ignore = tmp 

1354 

1355 for key in config_keys: 

1356 instance = self.config_file.config_instances[key] 

1357 if not instance.wants_to_be_exported(): 

1358 continue 

1359 

1360 if ignore is not None and instance in ignore: 

1361 continue 

1362 

1363 yield instance 

1364 

1365 def save(self, writer: FormattedWriter, **kw: 'Unpack[SaveKwargs]') -> None: 

1366 ''' 

1367 :param writer: The file to write to 

1368 :param bool no_multi: If true: treat :class:`MultiConfig` instances like normal :class:`Config` instances and only write their default value. If false: Separate :class:`MultiConfig` instances and print them once for every :attr:`MultiConfig.config_ids`. 

1369 :param bool comments: If false: don't write help for data types 

1370 

1371 Iterate over all :class:`Config` instances with :meth:`iter_config_instances_to_be_saved`, 

1372 split them into normal :class:`Config` and :class:`MultiConfig` and write them with :meth:`save_config_instance`. 

1373 But before that set :attr:`last_name` to None (which is used by :meth:`write_config_help`) 

1374 and write help for data types based on :meth:`get_help_for_data_types`. 

1375 ''' 

1376 no_multi = kw['no_multi'] 

1377 comments = kw['comments'] 

1378 

1379 config_instances = list(self.iter_config_instances_to_be_saved(**kw)) 

1380 normal_configs = [] 

1381 multi_configs = [] 

1382 if no_multi: 

1383 normal_configs = config_instances 

1384 else: 

1385 for instance in config_instances: 

1386 if isinstance(instance, MultiConfig): 

1387 multi_configs.append(instance) 

1388 else: 

1389 normal_configs.append(instance) 

1390 

1391 self.last_name: 'str|None' = None 

1392 

1393 if normal_configs: 

1394 if multi_configs: 

1395 writer.write_heading(SectionLevel.SECTION, 'Application wide settings') 

1396 elif self.should_write_heading: 

1397 writer.write_heading(SectionLevel.SECTION, 'Settings') 

1398 

1399 if comments: 

1400 type_help = self.get_help_for_data_types(normal_configs) 

1401 if type_help: 

1402 writer.write_heading(SectionLevel.SUB_SECTION, 'Data types') 

1403 writer.write_lines(type_help) 

1404 

1405 for instance in normal_configs: 

1406 self.save_config_instance(writer, instance, config_id=None, **kw) 

1407 

1408 if multi_configs: 

1409 if normal_configs: 

1410 writer.write_heading(SectionLevel.SECTION, 'Settings which can have different values for different objects') 

1411 elif self.should_write_heading: 

1412 writer.write_heading(SectionLevel.SECTION, 'Settings') 

1413 

1414 if comments: 

1415 type_help = self.get_help_for_data_types(multi_configs) 

1416 if type_help: 

1417 writer.write_heading(SectionLevel.SUB_SECTION, 'Data types') 

1418 writer.write_lines(type_help) 

1419 

1420 for instance in multi_configs: 

1421 self.save_config_instance(writer, instance, config_id=instance.default_config_id, **kw) 

1422 

1423 for config_id in MultiConfig.config_ids: 

1424 writer.write_line('') 

1425 self.config_file.write_config_id(writer, config_id) 

1426 for instance in multi_configs: 

1427 self.save_config_instance(writer, instance, config_id, **kw) 

1428 

1429 def save_config_instance(self, writer: FormattedWriter, instance: 'Config[object]', config_id: 'ConfigId|None', **kw: 'Unpack[SaveKwargs]') -> None: 

1430 ''' 

1431 :param writer: The file to write to 

1432 :param instance: The config value to be saved 

1433 :param config_id: Which value to be written in case of a :class:`MultiConfig`, should be :const:`None` for a normal :class:`Config` instance 

1434 :param bool comments: If true: call :meth:`write_config_help` 

1435 

1436 Convert the :class:`Config` instance into a value str with :meth:`format_value`, 

1437 wrap it in quotes if necessary with :meth:`config_file.quote` and write it to :paramref:`writer`. 

1438 ''' 

1439 if kw['comments']: 

1440 self.write_config_help(writer, instance) 

1441 value = self.format_value(instance, config_id) 

1442 value = self.config_file.quote(value) 

1443 ln = f'{self.get_name()} {instance.key} = {value}' 

1444 writer.write_command(ln) 

1445 

1446 def format_value(self, instance: Config[typing.Any], config_id: 'ConfigId|None') -> str: 

1447 ''' 

1448 :param instance: The config value to be saved 

1449 :param config_id: Which value to be written in case of a :class:`MultiConfig`, should be :const:`None` for a normal :class:`Config` instance 

1450 :return: A str representation to be written to the config file 

1451 

1452 Convert the value of the :class:`Config` instance into a str with :meth:`Config.format_value`. 

1453 ''' 

1454 return instance.format_value(config_id) 

1455 

1456 def write_config_help(self, writer: FormattedWriter, instance: Config[typing.Any], *, group_dict_configs: bool = True) -> None: 

1457 ''' 

1458 :param writer: The output to write to 

1459 :param instance: The config value to be saved 

1460 

1461 Write a comment which explains the meaning and usage of this setting 

1462 based on :meth:`Config.format_allowed_values_or_type` and :attr:`Config.help`. 

1463 

1464 Use :attr:`last_name` to write the help only once for all :class:`Config` instances belonging to the same :class:`DictConfig` instance. 

1465 ''' 

1466 if group_dict_configs and instance.parent is not None: 

1467 name = instance.parent.key_prefix 

1468 else: 

1469 name = instance.key 

1470 if name == self.last_name: 

1471 return 

1472 

1473 formatter = HelpFormatterWrapper(self.config_file.formatter_class) 

1474 writer.write_heading(SectionLevel.SUB_SECTION, name) 

1475 writer.write_lines(formatter.format_text(instance.format_allowed_values_or_type()).rstrip()) 

1476 #if instance.unit: 

1477 # writer.write_line('unit: %s' % instance.unit) 

1478 if isinstance(instance.help, dict): 

1479 for key, val in instance.help.items(): 

1480 key_name = instance.format_any_value(key) 

1481 val = inspect.cleandoc(val) 

1482 writer.write_lines(formatter.format_item(bullet=key_name+': ', text=val).rstrip()) 

1483 elif isinstance(instance.help, str): 

1484 writer.write_lines(formatter.format_text(inspect.cleandoc(instance.help)).rstrip()) 

1485 

1486 self.last_name = name 

1487 

1488 

1489 @classmethod 

1490 def set_help_for_type(cls, t: 'type[object]', help_text: str) -> None: 

1491 ''' 

1492 :meth:`get_help_for_data_types` is used by :meth:`save` and :meth:`get_help`. 

1493 Usually it uses the :attr:`help` attribute of the class. 

1494 But if the class does not have a :attr:`help` attribute or if you want a different help text 

1495 you can set the help with this method. 

1496 

1497 :param t: The type for which you want to specify a help 

1498 :param help_text: The help for :paramref:`t`. It is cleaned up in :meth:`get_data_type_name_to_help_map` with :func:`inspect.cleandoc`. 

1499 ''' 

1500 cls.help_for_types[t] = help_text 

1501 

1502 def get_data_type_name_to_help_map(self, config_instances: 'Iterable[Config[object]]') -> 'dict[str, str]': 

1503 ''' 

1504 :param config_instances: All config values to be saved 

1505 :return: A dictionary containing the type names as keys and the help as values 

1506 

1507 The returned dictionary contains the help for all data types except enumerations 

1508 which occur in :paramref:`config_instances`. 

1509 The help is gathered from the :attr:`help` attribute of the type 

1510 or the str registered with :meth:`set_help_for_type`. 

1511 The help is cleaned up with :func:`inspect.cleandoc`. 

1512 ''' 

1513 help_text: 'dict[str, str]' = {} 

1514 for instance in config_instances: 

1515 t = instance.type if instance.type != list else instance.item_type 

1516 name = getattr(t, 'type_name', t.__name__) 

1517 if name in help_text: 

1518 continue 

1519 

1520 if t in self.help_for_types: 

1521 h = self.help_for_types[t] 

1522 elif hasattr(t, 'help'): 

1523 h = t.help 

1524 elif issubclass(t, enum.Enum) or t is bool: 

1525 # an enum does not need a help if the values have self explanatory names 

1526 # bool is treated like an enum 

1527 continue 

1528 else: 

1529 raise AttributeError('No help given for {typename} ({classname}). Please specify it as help attribute or with set_help_for_type.'.format(typename=name, classname=t.__name__)) 

1530 

1531 help_text[name] = inspect.cleandoc(h) 

1532 

1533 return help_text 

1534 

1535 def add_help_for_data_types(self, formatter: HelpFormatterWrapper, config_instances: 'Iterable[Config[object]]') -> None: 

1536 help_map = self.get_data_type_name_to_help_map(config_instances) 

1537 if not help_map: 

1538 return 

1539 

1540 for name in sorted(help_map.keys()): 

1541 formatter.add_start_section(name) 

1542 formatter.add_text(help_map[name]) 

1543 formatter.add_end_section() 

1544 

1545 def get_help_for_data_types(self, config_instances: 'Iterable[Config[object]]') -> str: 

1546 formatter = self.create_formatter() 

1547 self.add_help_for_data_types(formatter, config_instances) 

1548 return formatter.format_help().rstrip('\n') 

1549 

1550 # ------- help ------- 

1551 

1552 def add_help_to(self, formatter: HelpFormatterWrapper) -> None: 

1553 super().add_help_to(formatter) 

1554 

1555 kw: 'SaveKwargs' = {} 

1556 self.config_file.set_save_default_arguments(kw) 

1557 config_instances = list(self.iter_config_instances_to_be_saved(**kw)) 

1558 self.last_name = None 

1559 

1560 formatter.add_start_section('data types') 

1561 self.add_help_for_data_types(formatter, config_instances) 

1562 formatter.add_end_section() 

1563 

1564 if self.config_file.enable_config_ids: 

1565 normal_configs = [] 

1566 multi_configs = [] 

1567 for instance in config_instances: 

1568 if isinstance(instance, MultiConfig): 

1569 multi_configs.append(instance) 

1570 else: 

1571 normal_configs.append(instance) 

1572 else: 

1573 normal_configs = config_instances 

1574 multi_configs = [] 

1575 

1576 if normal_configs: 

1577 if self.config_file.enable_config_ids: 

1578 formatter.add_start_section('application wide settings') 

1579 else: 

1580 formatter.add_start_section('settings') 

1581 for instance in normal_configs: 

1582 self.add_config_help(formatter, instance) 

1583 formatter.add_end_section() 

1584 

1585 if multi_configs: 

1586 formatter.add_start_section('settings which can have different values for different objects') 

1587 formatter.add_text(inspect.cleandoc(self.config_file.get_help_config_id())) 

1588 for instance in multi_configs: 

1589 self.add_config_help(formatter, instance) 

1590 formatter.add_end_section() 

1591 

1592 def add_config_help(self, formatter: HelpFormatterWrapper, instance: Config[typing.Any]) -> None: 

1593 formatter.add_start_section(instance.key) 

1594 formatter.add_text(instance.format_allowed_values_or_type()) 

1595 #if instance.unit: 

1596 # formatter.add_item(bullet='unit: ', text=instance.unit) 

1597 if isinstance(instance.help, dict): 

1598 for key, val in instance.help.items(): 

1599 key_name = instance.format_any_value(key) 

1600 val = inspect.cleandoc(val) 

1601 formatter.add_item(bullet=key_name+': ', text=val) 

1602 elif isinstance(instance.help, str): 

1603 formatter.add_text(inspect.cleandoc(instance.help)) 

1604 formatter.add_end_section() 

1605 

1606 

1607class Include(ConfigFileArgparseCommand): 

1608 

1609 ''' 

1610 Load another config file. 

1611 

1612 This is useful if a config file is getting so big that you want to split it up 

1613 or if you want to have different config files for different use cases which all include the same standard config file to avoid redundancy 

1614 or if you want to bind several commands to one key which executes one command with ConfigFile.parse_line(). 

1615 ''' 

1616 

1617 help_config_id = ''' 

1618 By default the loaded config file starts with which ever config id is currently active. 

1619 This is useful if you want to use the same values for several config ids: 

1620 Write the set commands without a config id to a separate config file and include this file for every config id where these settings shall apply. 

1621 

1622 After the include the config id is reset to the config id which was active at the beginning of the include 

1623 because otherwise it might lead to confusion if the config id is changed in the included config file. 

1624 ''' 

1625 

1626 def init_parser(self, parser: ArgumentParser) -> None: 

1627 parser.add_argument('path', help='The config file to load. Slashes are replaced with the directory separator appropriate for the current operating system.') 

1628 if self.config_file.enable_config_ids: 

1629 assert parser.description is not None 

1630 parser.description += '\n\n' + inspect.cleandoc(self.help_config_id) 

1631 group = parser.add_mutually_exclusive_group() 

1632 group.add_argument('--reset-config-id-before', action='store_true', help='Ignore any config id which might be active when starting the include') 

1633 group.add_argument('--no-reset-config-id-after', action='store_true', help='Treat the included lines as if they were written in the same config file instead of the include command') 

1634 

1635 self.nested_includes: 'list[str]' = [] 

1636 

1637 def run_parsed(self, args: argparse.Namespace) -> None: 

1638 fn_imp = args.path 

1639 fn_imp = fn_imp.replace('/', os.path.sep) 

1640 fn_imp = os.path.expanduser(fn_imp) 

1641 if not os.path.isabs(fn_imp): 

1642 fn = self.config_file.context_file_name 

1643 if fn is None: 

1644 fn = self.config_file.get_save_path() 

1645 fn_imp = os.path.join(os.path.dirname(os.path.abspath(fn)), fn_imp) 

1646 

1647 if fn_imp in self.nested_includes: 

1648 raise ParseException(f'circular include of file {fn_imp!r}') 

1649 if not os.path.isfile(fn_imp): 

1650 raise ParseException(f'no such file {fn_imp!r}') 

1651 

1652 self.nested_includes.append(fn_imp) 

1653 

1654 if self.config_file.enable_config_ids and args.no_reset_config_id_after: 

1655 self.config_file.load_without_resetting_config_id(fn_imp) 

1656 elif self.config_file.enable_config_ids and args.reset_config_id_before: 

1657 config_id = self.config_file.config_id 

1658 self.config_file.load_file(fn_imp) 

1659 self.config_file.config_id = config_id 

1660 else: 

1661 config_id = self.config_file.config_id 

1662 self.config_file.load_without_resetting_config_id(fn_imp) 

1663 self.config_file.config_id = config_id 

1664 

1665 assert self.nested_includes[-1] == fn_imp 

1666 del self.nested_includes[-1] 

1667 

1668 

1669class UnknownCommand(ConfigFileCommand, abstract=True): 

1670 

1671 name = DEFAULT_COMMAND 

1672 

1673 def run(self, cmd: 'Sequence[str]') -> None: 

1674 raise ParseException('unknown command %r' % cmd[0])