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

727 statements  

« prev     ^ index     » next       coverage.py v7.2.1, created at 2023-03-06 19:52 +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 

27#: 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. 

28DEFAULT_COMMAND = '' 

29 

30 

31 

32# ---------- UI notifier ---------- 

33 

34@enum.unique 

35class NotificationLevel(SortedEnum): 

36 INFO = 'info' 

37 ERROR = 'error' 

38 

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

40 

41class Message: 

42 

43 ''' 

44 A message which should be displayed to the user. 

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

46 

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

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

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

50 ''' 

51 

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

53 ENVIRONMENT_VARIABLES = 'environment variables' 

54 

55 

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

57 

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

59 #: :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. 

60 notification_level: NotificationLevel 

61 

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

63 message: 'str|BaseException' 

64 

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

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

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

68 file_name: 'str|None' 

69 

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

71 line_number: 'int|None' 

72 

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

74 line: str 

75 

76 _last_file_name: 'str|None' = None 

77 

78 @classmethod 

79 def reset(cls) -> None: 

80 ''' 

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

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

83 ''' 

84 cls._last_file_name = None 

85 

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

87 self.notification_level = notification_level 

88 self.message = message 

89 self.file_name = file_name 

90 self.line_number = line_number 

91 self.line = line 

92 

93 @property 

94 def lvl(self) -> NotificationLevel: 

95 ''' 

96 An abbreviation for :attr:`notification_level` 

97 ''' 

98 return self.notification_level 

99 

100 def format_msg_line(self) -> str: 

101 ''' 

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

103 ''' 

104 msg = str(self.message) 

105 if self.line: 

106 if self.line_number is not None: 

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

108 else: 

109 lnref = 'line' 

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

111 

112 return msg 

113 

114 def format_file_name(self) -> str: 

115 ''' 

116 :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 

117 ''' 

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

119 if file_name == self._last_file_name: 

120 return '' 

121 

122 if file_name: 

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

124 else: 

125 out = '' 

126 

127 if self._last_file_name is not None: 

128 out = '\n' + out 

129 

130 type(self)._last_file_name = file_name 

131 

132 return out 

133 

134 

135 def format_file_name_msg_line(self) -> str: 

136 ''' 

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

138 ''' 

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

140 

141 

142 def __str__(self) -> str: 

143 ''' 

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

145 ''' 

146 return self.format_file_name_msg_line() 

147 

148 def __repr__(self) -> str: 

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

150 

151 

152class UiNotifier: 

153 

154 ''' 

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

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

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

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

159 

160 This object can also filter the messages. 

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

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

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

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

165 ''' 

166 

167 # ------- public methods ------- 

168 

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

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

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

172 self._notification_level = notification_level 

173 self._config_file = config_file 

174 

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

176 ''' 

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

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

179 ''' 

180 self._callback = callback 

181 

182 for msg in self._messages: 

183 callback(msg) 

184 self._messages.clear() 

185 

186 

187 @property 

188 def notification_level(self) -> NotificationLevel: 

189 ''' 

190 Ignore messages that are less important than this level. 

191 ''' 

192 if isinstance(self._notification_level, Config): 

193 return self._notification_level.value 

194 else: 

195 return self._notification_level 

196 

197 @notification_level.setter 

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

199 if isinstance(self._notification_level, Config): 

200 self._notification_level.value = val 

201 else: 

202 self._notification_level = val 

203 

204 

205 # ------- called by ConfigFile ------- 

206 

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

208 ''' 

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

210 ''' 

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

212 

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

214 ''' 

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

216 ''' 

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

218 

219 

220 # ------- internal methods ------- 

221 

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

223 ''' 

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

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

226 

227 :param notification_level: The importance of the message 

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

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

230 ''' 

231 if notification_level < self.notification_level and not ignore_filter: 

232 return 

233 

234 message = Message( 

235 notification_level = notification_level, 

236 message = msg, 

237 file_name = self._config_file.context_file_name, 

238 line_number = self._config_file.context_line_number, 

239 line = self._config_file.context_line, 

240 ) 

241 

242 if self._callback: 

243 self._callback(message) 

244 else: 

245 self._messages.append(message) 

246 

247 

248# ---------- format help ---------- 

249 

250class SectionLevel(SortedEnum): 

251 

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

253 SECTION = 'section' 

254 

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

256 SUB_SECTION = 'sub-section' 

257 

258 

259class FormattedWriter(abc.ABC): 

260 

261 @abc.abstractmethod 

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

263 ''' 

264 Write a single line of documentation. 

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

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

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

268 ''' 

269 pass 

270 

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

272 ''' 

273 Write one or more lines of documentation. 

274 ''' 

275 for ln in text.splitlines(): 

276 self.write_line(ln) 

277 

278 @abc.abstractmethod 

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

280 ''' 

281 Write a heading. 

282 

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

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

285 in order to keep the line wrapping consistent. 

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

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

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

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

290 to understand the help generated by argparse 

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

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

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

294 

295 :param lvl: How to format the heading 

296 :param heading: The heading 

297 ''' 

298 pass 

299 

300 @abc.abstractmethod 

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

302 ''' 

303 Write a config file command. 

304 ''' 

305 pass 

306 

307 

308class TextIOWriter(FormattedWriter): 

309 

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

311 self.f = f 

312 self.ignore_empty_lines = True 

313 

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

315 if self.ignore_empty_lines and not ln: 

316 return 

317 

318 print(ln, file=self.f) 

319 self.ignore_empty_lines = False 

320 

321 

322class ConfigFileWriter(TextIOWriter): 

323 

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

325 super().__init__(f) 

326 self.prefix = prefix 

327 

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

329 self.write_line_raw(cmd) 

330 

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

332 if ln: 

333 ln = self.prefix + ln 

334 

335 self.write_line_raw(ln) 

336 

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

338 if lvl is SectionLevel.SECTION: 

339 self.write_line('') 

340 self.write_line('') 

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

342 self.write_line(heading) 

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

344 else: 

345 self.write_line('') 

346 self.write_line(heading) 

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

348 

349class HelpWriter(TextIOWriter): 

350 

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

352 self.write_line_raw(ln) 

353 

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

355 self.write_line('') 

356 if lvl is SectionLevel.SECTION: 

357 self.write_line(heading) 

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

359 else: 

360 self.write_line(heading) 

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

362 

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

364 pass 

365 

366 

367# ---------- internal exceptions ---------- 

368 

369class ParseException(Exception): 

370 

371 ''' 

372 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. 

373 Is caught in :class:`ConfigFile`. 

374 ''' 

375 

376class MultipleParseExceptions(Exception): 

377 

378 ''' 

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

380 Is caught in :class:`ConfigFile`. 

381 ''' 

382 

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

384 super().__init__() 

385 self.exceptions = exceptions 

386 

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

388 return iter(self.exceptions) 

389 

390 

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

392 

393if hasattr(typing, 'TypedDict'): # python >= 3.8 

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

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

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

397 no_multi: bool 

398 comments: bool 

399 

400 

401# ---------- ConfigFile class ---------- 

402 

403class ConfigFile: 

404 

405 ''' 

406 Read or write a config file. 

407 ''' 

408 

409 COMMENT = '#' 

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

411 ENTER_GROUP_PREFIX = '[' 

412 ENTER_GROUP_SUFFIX = ']' 

413 

414 

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

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

417 #: 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.) 

418 config_path: 'str|None' = None 

419 

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

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

422 #: 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.) 

423 config_directory: 'str|None' = None 

424 

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

426 #: 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.). 

427 config_name = 'config' 

428 

429 

430 #: 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). 

431 context_file_name: 'str|None' = None 

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

433 context_line_number: 'int|None' = None 

434 #: The line which is currently parsed. 

435 context_line: str = '' 

436 

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

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

439 enable_config_ids: bool 

440 

441 

442 def __init__(self, *, 

443 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 

444 appname: str, 

445 authorname: 'str|None' = None, 

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

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

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

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

450 enable_config_ids: 'bool|None' = None, 

451 ) -> None: 

452 ''' 

453 :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`. 

454 :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 

455 :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` 

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

457 :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` 

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

459 :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. 

460 :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` 

461 ''' 

462 self.appname = appname 

463 self.authorname = authorname 

464 self.ui_notifier = UiNotifier(self, notification_level) 

465 self.config_instances = config_instances 

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

467 self.formatter_class = formatter_class 

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

469 self.check_config_id = check_config_id 

470 

471 if enable_config_ids is None: 

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

473 self.enable_config_ids = enable_config_ids 

474 

475 if not appname: 

476 # Avoid an exception if appname is None. 

477 # Although mypy does not allow passing None directly 

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

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

480 appname = '' 

481 self.envprefix = '' 

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

483 envname = self.envprefix + 'CONFIG_PATH' 

484 self.env_variables.append(envname) 

485 if envname in os.environ: 

486 self.config_path = os.environ[envname] 

487 envname = self.envprefix + 'CONFIG_DIRECTORY' 

488 self.env_variables.append(envname) 

489 if envname in os.environ: 

490 self.config_directory = os.environ[envname] 

491 envname = self.envprefix + 'CONFIG_NAME' 

492 self.env_variables.append(envname) 

493 if envname in os.environ: 

494 self.config_name = os.environ[envname] 

495 

496 if commands is None: 

497 commands = ConfigFileCommand.get_command_types() 

498 self.command_dict = {} 

499 self.commands = [] 

500 for cmd_type in commands: 

501 cmd = cmd_type(self) 

502 self.commands.append(cmd) 

503 for name in cmd.get_names(): 

504 self.command_dict[name] = cmd 

505 

506 

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

508 ''' 

509 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. 

510 

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

512 

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

514 ''' 

515 self.ui_notifier.set_ui_callback(callback) 

516 

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

518 ''' 

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

520 

521 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. 

522 The first one installed is used. 

523 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. 

524 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``. 

525 

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

527 ''' 

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

529 try: 

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

531 AppDirs = typing.cast('type[appdirs.AppDirs]', platformdirs.PlatformDirs) 

532 except ImportError: 

533 try: 

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

535 AppDirs = typing.cast('type[appdirs.AppDirs]', xdgappdirs.AppDirs) 

536 except ImportError: 

537 AppDirs = appdirs.AppDirs 

538 

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

540 

541 return self._appdirs 

542 

543 # ------- load ------- 

544 

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

546 ''' 

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

548 

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

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

551 If :attr:`config_directory` has been set 

552 it's value is yielded and nothing else. 

553 ''' 

554 if self.config_directory: 

555 yield self.config_directory 

556 return 

557 

558 appdirs = self.get_app_dirs() 

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

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

561 

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

563 ''' 

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

565 

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

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

568 

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

570 :attr:`ConfigFile.config_name`. 

571 

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

573 ''' 

574 if self.config_path: 

575 yield self.config_path 

576 return 

577 

578 for path in self.iter_user_site_config_paths(): 

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

580 

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

582 ''' 

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

584 

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

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

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

588 ''' 

589 for fn in self.iter_config_paths(): 

590 if os.path.isfile(fn): 

591 self.load_file(fn) 

592 break 

593 

594 if env: 

595 self.load_env() 

596 

597 def load_env(self) -> None: 

598 ''' 

599 Load settings from environment variables. 

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

601 

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

603 ''' 

604 old_file_name = self.context_file_name 

605 self.context_file_name = Message.ENVIRONMENT_VARIABLES 

606 

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

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

609 name = self.get_env_name(key) 

610 if name in self.env_variables: 

611 self.ui_notifier.show_error(f'{instance.key} conflicts with environment variable {name}') 

612 elif name in config_instances: 

613 self.ui_notifier.show_error(f'{instance.key} and {config_instances[name].key} result in the same environment variable name: {name}') 

614 else: 

615 config_instances[name] = instance 

616 

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

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

619 continue 

620 if name in self.env_variables: 

621 continue 

622 

623 if name in config_instances: 

624 instance = config_instances[name] 

625 try: 

626 instance.parse_and_set_value(config_id=None, value=value) 

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

628 except ValueError as e: 

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

630 else: 

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

632 

633 self.context_file_name = old_file_name 

634 

635 

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

637 ''' 

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

639 

640 :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. (:attr:`envprefix` is set in the constructor by first setting it to an empty str and then passing the value of :paramref:`appname <ConfigFile.appname>` to this method and appending an underscore.) 

641 ''' 

642 out = key 

643 out = out.upper() 

644 for c in ' .-': 

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

646 out = self.envprefix + out 

647 return out 

648 

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

650 ''' 

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

652 

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

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

655 

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

657 ''' 

658 self.config_id = None 

659 self.load_without_resetting_config_id(fn) 

660 

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

662 old_file_name = self.context_file_name 

663 self.context_file_name = fn 

664 

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

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

667 self.context_line_number = lnno 

668 self.parse_line(line=ln) 

669 self.context_line_number = None 

670 

671 self.context_file_name = old_file_name 

672 

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

674 ''' 

675 :param line: The line to be parsed 

676 

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

678 ''' 

679 ln = line.strip() 

680 if not ln: 

681 return 

682 if self.is_comment(ln): 

683 return 

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

685 return 

686 

687 self.context_line = ln 

688 

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

690 self.parse_splitted_line(ln_splitted) 

691 

692 self.context_line = '' 

693 

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

695 ''' 

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

697 

698 :param ln: The current line 

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

700 ''' 

701 for c in self.COMMENT_PREFIXES: 

702 if ln.startswith(c): 

703 return True 

704 return False 

705 

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

707 ''' 

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

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

710 

711 :param ln: The current line 

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

713 ''' 

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

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

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

717 try: 

718 self.check_config_id(config_id) 

719 except ParseException as e: 

720 self.parse_error(str(e)) 

721 self.config_id = config_id 

722 if self.config_id not in MultiConfig.config_ids: 

723 MultiConfig.config_ids.append(self.config_id) 

724 return True 

725 return False 

726 

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

728 ''' 

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

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

731 ''' 

732 cmd_name = ln_splitted[0] 

733 

734 try: 

735 if cmd_name in self.command_dict: 

736 cmd = self.command_dict[cmd_name] 

737 elif DEFAULT_COMMAND in self.command_dict: 

738 cmd = self.command_dict[DEFAULT_COMMAND] 

739 else: 

740 cmd = UnknownCommand(self) 

741 cmd.run(ln_splitted) 

742 except ParseException as e: 

743 self.parse_error(str(e)) 

744 except MultipleParseExceptions as exceptions: 

745 for exc in exceptions: 

746 self.parse_error(str(exc)) 

747 

748 

749 # ------- save ------- 

750 

751 def get_save_path(self) -> str: 

752 ''' 

753 :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. 

754 ''' 

755 paths = tuple(self.iter_config_paths()) 

756 for fn in paths: 

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

758 return fn 

759 

760 return paths[0] 

761 

762 def save(self, 

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

764 ) -> str: 

765 ''' 

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

767 Directories are created as necessary. 

768 

769 :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. 

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

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

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

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

774 ''' 

775 fn = self.get_save_path() 

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

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

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

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

780 self.save_file(fn, **kw) 

781 return fn 

782 

783 def save_file(self, 

784 fn: str, 

785 **kw: 'Unpack[SaveKwargs]' 

786 ) -> None: 

787 ''' 

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

789 

790 :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. 

791 :raises FileNotFoundError: if the directory does not exist 

792 

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

794 ''' 

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

796 self.save_to_open_file(f, **kw) 

797 

798 

799 def save_to_open_file(self, 

800 f: typing.TextIO, 

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

802 ) -> None: 

803 ''' 

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

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

806 

807 :param f: The file to write to 

808 

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

810 ''' 

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

812 self.save_to_writer(writer, **kw) 

813 

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

815 ''' 

816 Save the current values of all settings. 

817 

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

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

820 

821 - 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 

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

823 ''' 

824 self.set_save_default_arguments(kw) 

825 commands = self.commands 

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

827 for cmd in commands: 

828 cmd.should_write_heading = write_headings 

829 cmd.save(writer, **kw) 

830 

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

832 ''' 

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

834 ''' 

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

836 kw.setdefault('ignore', None) 

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

838 kw.setdefault('comments', True) 

839 

840 

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

842 ''' 

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

844 

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

846 ''' 

847 return readable_quote(val) 

848 

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

850 ''' 

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

852 ''' 

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

854 

855 def get_help_config_id(self) -> str: 

856 ''' 

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

858 ''' 

859 return f''' 

860 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. 

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

862 ''' 

863 

864 

865 # ------- help ------- 

866 

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

868 import platform 

869 formatter = self.create_formatter() 

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

871 for path in self.iter_config_paths(): 

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

873 

874 writer.write_line('') 

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

876 if platform.system() == 'Linux': 

877 writer.write_line('- XDG_CONFIG_HOME') 

878 writer.write_line('- XDG_CONFIG_DIRS') 

879 for env in self.env_variables: 

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

881 

882 writer.write_line('') 

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

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

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

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

887 

888 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))) 

889 

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

891 for cmd in self.commands: 

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

893 writer.write_heading(SectionLevel.SECTION, names) 

894 writer.write_lines(cmd.get_help()) 

895 

896 def create_formatter(self) -> HelpFormatterWrapper: 

897 return HelpFormatterWrapper(self.formatter_class) 

898 

899 def get_help(self) -> str: 

900 ''' 

901 A convenience wrapper around :meth:`write_help` 

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

903 

904 This uses :class:`HelpWriter`. 

905 ''' 

906 doc = io.StringIO() 

907 self.write_help(HelpWriter(doc)) 

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

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

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

911 # Therefore I am stripping the trailing \n. 

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

913 

914 

915 # ------- error handling ------- 

916 

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

918 ''' 

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

920 

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

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

923 

924 :param msg: The error message 

925 ''' 

926 self.ui_notifier.show_error(msg) 

927 

928 

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

930 

931class ConfigFileCommand(abc.ABC): 

932 

933 ''' 

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

935 

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

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

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

939 

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

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

942 ''' 

943 

944 #: 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. 

945 name: str 

946 

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

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

949 

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

951 help: str 

952 

953 #: 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. 

954 should_write_heading: bool = False 

955 

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

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

958 

959 @classmethod 

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

961 ''' 

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

963 ''' 

964 return tuple(cls._subclasses) 

965 

966 @classmethod 

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

968 ''' 

969 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. 

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

971 ''' 

972 if cmd_type in cls._subclasses: 

973 cls._subclasses.remove(cmd_type) 

974 for name in cmd_type.get_names(): 

975 cls._used_names.remove(name) 

976 

977 @classmethod 

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

979 ''' 

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

981 

982 :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 

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

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

985 ''' 

986 if replace: 

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

988 

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

990 parent = parent_commands[0] 

991 if 'name' not in cls.__dict__: 

992 cls.name = parent.get_name() 

993 if 'aliases' not in cls.__dict__: 

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

995 for parent in parent_commands[1:]: 

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

997 

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

999 for parent in parent_commands: 

1000 if issubclass(parent, ConfigFileCommand): 

1001 cls.delete_command_type(parent) 

1002 

1003 if not abstract: 

1004 cls._subclasses.append(cls) 

1005 for name in cls.get_names(): 

1006 if name in cls._used_names and not replace: 

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

1008 cls._used_names.add(name) 

1009 

1010 @classmethod 

1011 def get_name(cls) -> str: 

1012 ''' 

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

1014  

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

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

1017 ''' 

1018 if 'name' in cls.__dict__: 

1019 return cls.name 

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

1021 

1022 @classmethod 

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

1024 ''' 

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

1026  

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

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

1029 

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

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

1032 ''' 

1033 yield cls.get_name() 

1034 if 'aliases' in cls.__dict__: 

1035 for name in cls.aliases: 

1036 yield name 

1037 

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

1039 self.config_file = config_file 

1040 self.ui_notifier = config_file.ui_notifier 

1041 

1042 @abc.abstractmethod 

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

1044 ''' 

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

1046 

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

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

1049 ''' 

1050 raise NotImplementedError() 

1051 

1052 def create_formatter(self) -> HelpFormatterWrapper: 

1053 return self.config_file.create_formatter() 

1054 

1055 def get_help_attr_or_doc_str(self) -> str: 

1056 ''' 

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

1058 ''' 

1059 if hasattr(self, 'help'): 

1060 doc = self.help 

1061 elif self.__doc__: 

1062 doc = self.__doc__ 

1063 else: 

1064 doc = '' 

1065 

1066 return inspect.cleandoc(doc) 

1067 

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

1069 ''' 

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

1071 ''' 

1072 formatter.add_text(self.get_help_attr_or_doc_str()) 

1073 

1074 def get_help(self) -> str: 

1075 ''' 

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

1077 

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

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

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

1081 

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

1083 ''' 

1084 formatter = self.create_formatter() 

1085 self.add_help_to(formatter) 

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

1087 

1088 def save(self, 

1089 writer: FormattedWriter, 

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

1091 ) -> None: 

1092 ''' 

1093 Write as many calls to this command as necessary to the config file in order to create the current state. 

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

1095 

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

1097 - get the comment character :attr:`ConfigFile.COMMENT` 

1098 - call :attr:`ConfigFile.write_config_id` 

1099 

1100 The default implementation does nothing. 

1101 ''' 

1102 pass 

1103 

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

1105 

1106 

1107class ArgumentParser(argparse.ArgumentParser): 

1108 

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

1110 ''' 

1111 Raise a :class:`ParseException`. 

1112 ''' 

1113 raise ParseException(message) 

1114 

1115class ConfigFileArgparseCommand(ConfigFileCommand, abstract=True): 

1116 

1117 ''' 

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

1119 

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

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

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

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

1124 

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

1126 ''' 

1127 

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

1129 super().__init__(config_file) 

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

1131 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) 

1132 self.init_parser(self.parser) 

1133 

1134 @abc.abstractmethod 

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

1136 ''' 

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

1138 

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

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

1141 ''' 

1142 pass 

1143 

1144 def get_help(self) -> str: 

1145 ''' 

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

1147 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. 

1148 ''' 

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

1150 

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

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

1153 if not cmd: 

1154 return 

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

1156 if cmd[0] in self._names: 

1157 cmd = cmd[1:] 

1158 args = self.parser.parse_args(cmd) 

1159 self.run_parsed(args) 

1160 

1161 @abc.abstractmethod 

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

1163 ''' 

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

1165 ''' 

1166 pass 

1167 

1168 

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

1170 

1171class Set(ConfigFileCommand): 

1172 

1173 r''' 

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

1175 set key [=] val 

1176 

1177 Change the value of a setting. 

1178 

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

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

1181 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. 

1182 

1183 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. 

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

1185 ''' 

1186 

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

1188 KEY_VAL_SEP = '=' 

1189 

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

1191 help_for_types = { 

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

1193 int : '''\ 

1194 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). 

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

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

1197 #bool, 

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

1199 } 

1200 

1201 

1202 # ------- load ------- 

1203 

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

1205 ''' 

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

1207 

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

1209 ''' 

1210 if len(cmd) < 2: 

1211 raise ParseException('no settings given') 

1212 

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

1214 self.set_multiple(cmd) 

1215 else: 

1216 self.set_with_spaces(cmd) 

1217 

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

1219 ''' 

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

1221 

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

1223 ''' 

1224 n = len(cmd) 

1225 if n == 3: 

1226 cmdname, key, value = cmd 

1227 self.parse_key_and_set_value(key, value) 

1228 elif n == 4: 

1229 cmdname, key, sep, value = cmd 

1230 if sep != self.KEY_VAL_SEP: 

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

1232 self.parse_key_and_set_value(key, value) 

1233 elif n == 2: 

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

1235 else: 

1236 assert n >= 5 

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

1238 

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

1240 ''' 

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

1242 

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

1244 ''' 

1245 exceptions = [] 

1246 for arg in cmd[1:]: 

1247 if not self.KEY_VAL_SEP in arg: 

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

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

1250 try: 

1251 self.parse_key_and_set_value(key, value) 

1252 except ParseException as e: 

1253 exceptions.append(e) 

1254 if exceptions: 

1255 raise MultipleParseExceptions(exceptions) 

1256 

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

1258 ''' 

1259 Find the corresponding :class:`Config` instance for :paramref:`key` and pass it to :meth:`parse_and_set_value`. 

1260 

1261 :raises ParseException: if key is invalid or :meth:`parse_and_set_value` raises a :class:`ValueError` 

1262 ''' 

1263 if key not in self.config_file.config_instances: 

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

1265 

1266 instance = self.config_file.config_instances[key] 

1267 try: 

1268 self.parse_and_set_value(instance, value) 

1269 except ValueError as e: 

1270 raise ParseException(str(e)) 

1271 

1272 def parse_and_set_value(self, instance: Config[typing.Any], value: str) -> None: 

1273 ''' 

1274 Parse the given value str and assign it to the given instance by calling :meth:`Config.parse_and_set_value` with :attr:`ConfigFile.config_id` of :attr:`config_file`. 

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

1276 ''' 

1277 instance.parse_and_set_value(self.config_file.config_id, value) 

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

1279 

1280 

1281 # ------- save ------- 

1282 

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

1284 ''' 

1285 :param config_instances: The settings to consider 

1286 :param ignore: Skip these settings 

1287 

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

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

1290 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. 

1291 ''' 

1292 config_instances = kw['config_instances'] 

1293 ignore = kw['ignore'] 

1294 

1295 config_keys = [] 

1296 for c in config_instances: 

1297 if isinstance(c, DictConfig): 

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

1299 else: 

1300 config_keys.append(c.key) 

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

1302 config_keys = sorted(config_keys) 

1303 

1304 if ignore is not None: 

1305 tmp = set() 

1306 for c in tuple(ignore): 

1307 if isinstance(c, DictConfig): 

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

1309 else: 

1310 tmp.add(c) 

1311 ignore = tmp 

1312 

1313 for key in config_keys: 

1314 instance = self.config_file.config_instances[key] 

1315 if not instance.wants_to_be_exported(): 

1316 continue 

1317 

1318 if ignore is not None and instance in ignore: 

1319 continue 

1320 

1321 yield instance 

1322 

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

1324 ''' 

1325 :param writer: The file to write to 

1326 :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`. 

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

1328 

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

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

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

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

1333 ''' 

1334 no_multi = kw['no_multi'] 

1335 comments = kw['comments'] 

1336 

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

1338 normal_configs = [] 

1339 multi_configs = [] 

1340 if no_multi: 

1341 normal_configs = config_instances 

1342 else: 

1343 for instance in config_instances: 

1344 if isinstance(instance, MultiConfig): 

1345 multi_configs.append(instance) 

1346 else: 

1347 normal_configs.append(instance) 

1348 

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

1350 

1351 if normal_configs: 

1352 if multi_configs: 

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

1354 elif self.should_write_heading: 

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

1356 

1357 if comments: 

1358 type_help = self.get_help_for_data_types(normal_configs) 

1359 if type_help: 

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

1361 writer.write_lines(type_help) 

1362 

1363 for instance in normal_configs: 

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

1365 

1366 if multi_configs: 

1367 if normal_configs: 

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

1369 

1370 if comments: 

1371 type_help = self.get_help_for_data_types(multi_configs) 

1372 if type_help: 

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

1374 writer.write_lines(type_help) 

1375 

1376 for instance in multi_configs: 

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

1378 

1379 for config_id in MultiConfig.config_ids: 

1380 writer.write_line('') 

1381 self.config_file.write_config_id(writer, config_id) 

1382 for instance in multi_configs: 

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

1384 

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

1386 ''' 

1387 :param writer: The file to write to 

1388 :param instance: The config value to be saved 

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

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

1391 

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

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

1394 ''' 

1395 if kw['comments']: 

1396 self.write_config_help(writer, instance) 

1397 value = self.format_value(instance, config_id) 

1398 value = self.config_file.quote(value) 

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

1400 writer.write_command(ln) 

1401 

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

1403 ''' 

1404 :param instance: The config value to be saved 

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

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

1407 

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

1409 ''' 

1410 return instance.format_value(config_id) 

1411 

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

1413 ''' 

1414 :param writer: The output to write to 

1415 :param instance: The config value to be saved 

1416 

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

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

1419 

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

1421 ''' 

1422 if group_dict_configs and instance.parent is not None: 

1423 name = instance.parent.key_prefix 

1424 else: 

1425 name = instance.key 

1426 if name == self.last_name: 

1427 return 

1428 

1429 formatter = HelpFormatterWrapper(self.config_file.formatter_class) 

1430 writer.write_heading(SectionLevel.SUB_SECTION, name) 

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

1432 #if instance.unit: 

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

1434 if isinstance(instance.help, dict): 

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

1436 key_name = instance.format_any_value(key) 

1437 val = inspect.cleandoc(val) 

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

1439 elif isinstance(instance.help, str): 

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

1441 

1442 self.last_name = name 

1443 

1444 

1445 @classmethod 

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

1447 ''' 

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

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

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

1451 you can set the help with this method. 

1452 

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

1454 :param help_text: The help for :paramref:`t`. It is cleaned up in :meth:`get_help_for_data_types` with :meth:`HelpFormatterWrapper.format_text` depending on :attr:`self.config_file.formatter_class`. 

1455 ''' 

1456 cls.help_for_types[t] = help_text 

1457 

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

1459 ''' 

1460 :param config_instances: All config values to be saved 

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

1462 

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

1464 which occur in :paramref:`config_instances`. 

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

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

1467 ''' 

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

1469 for instance in config_instances: 

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

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

1472 

1473 if t in self.help_for_types: 

1474 h = self.help_for_types[t] 

1475 elif hasattr(t, 'help'): 

1476 h = t.help 

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

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

1479 # bool is treated like an enum 

1480 continue 

1481 else: 

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

1483 

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

1485 

1486 return help_text 

1487 

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

1489 help_map = self.get_data_type_name_to_help_map(config_instances) 

1490 if not help_map: 

1491 return 

1492 

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

1494 formatter.add_start_section(name) 

1495 formatter.add_text(help_map[name]) 

1496 formatter.add_end_section() 

1497 

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

1499 formatter = self.create_formatter() 

1500 self.add_help_for_data_types(formatter, config_instances) 

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

1502 

1503 # ------- help ------- 

1504 

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

1506 super().add_help_to(formatter) 

1507 

1508 kw: 'SaveKwargs' = {} 

1509 self.config_file.set_save_default_arguments(kw) 

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

1511 self.last_name = None 

1512 

1513 formatter.add_start_section('data types') 

1514 self.add_help_for_data_types(formatter, config_instances) 

1515 formatter.add_end_section() 

1516 

1517 if self.config_file.enable_config_ids: 

1518 normal_configs = [] 

1519 multi_configs = [] 

1520 for instance in config_instances: 

1521 if isinstance(instance, MultiConfig): 

1522 multi_configs.append(instance) 

1523 else: 

1524 normal_configs.append(instance) 

1525 else: 

1526 normal_configs = config_instances 

1527 multi_configs = [] 

1528 

1529 if normal_configs: 

1530 if self.config_file.enable_config_ids: 

1531 formatter.add_start_section('application wide settings') 

1532 else: 

1533 formatter.add_start_section('settings') 

1534 for instance in normal_configs: 

1535 self.add_config_help(formatter, instance) 

1536 formatter.add_end_section() 

1537 

1538 if multi_configs: 

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

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

1541 for instance in multi_configs: 

1542 self.add_config_help(formatter, instance) 

1543 formatter.add_end_section() 

1544 

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

1546 formatter.add_start_section(instance.key) 

1547 formatter.add_text(instance.format_allowed_values_or_type()) 

1548 #if instance.unit: 

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

1550 if isinstance(instance.help, dict): 

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

1552 key_name = instance.format_any_value(key) 

1553 val = inspect.cleandoc(val) 

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

1555 elif isinstance(instance.help, str): 

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

1557 formatter.add_end_section() 

1558 

1559 

1560class Include(ConfigFileArgparseCommand): 

1561 

1562 ''' 

1563 Load another config file. 

1564 

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

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

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

1568 ''' 

1569 

1570 help_config_id = ''' 

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

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

1573 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. 

1574 

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

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

1577 ''' 

1578 

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

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

1581 if self.config_file.enable_config_ids: 

1582 assert parser.description is not None 

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

1584 group = parser.add_mutually_exclusive_group() 

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

1586 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') 

1587 

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

1589 

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

1591 fn_imp = args.path 

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

1593 fn_imp = os.path.expanduser(fn_imp) 

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

1595 fn = self.config_file.context_file_name 

1596 if fn is None: 

1597 fn = self.config_file.get_save_path() 

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

1599 

1600 if fn_imp in self.nested_includes: 

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

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

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

1604 

1605 self.nested_includes.append(fn_imp) 

1606 

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

1608 self.config_file.load_without_resetting_config_id(fn_imp) 

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

1610 config_id = self.config_file.config_id 

1611 self.config_file.load_file(fn_imp) 

1612 self.config_file.config_id = config_id 

1613 else: 

1614 config_id = self.config_file.config_id 

1615 self.config_file.load_without_resetting_config_id(fn_imp) 

1616 self.config_file.config_id = config_id 

1617 

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

1619 del self.nested_includes[-1] 

1620 

1621 

1622class UnknownCommand(ConfigFileCommand, abstract=True): 

1623 

1624 name = DEFAULT_COMMAND 

1625 

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

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