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

1037 statements  

« prev     ^ index     » next       coverage.py v7.2.3, created at 2023-04-22 14:03 +0200

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 platform 

11import re 

12import enum 

13import argparse 

14import inspect 

15import io 

16import abc 

17import typing 

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

19 

20import appdirs 

21 

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

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

24 

25if typing.TYPE_CHECKING: 

26 from typing_extensions import Unpack 

27 

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

29 T2 = typing.TypeVar('T2') 

30 

31 

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

33DEFAULT_COMMAND = '' 

34 

35 

36 

37# ---------- UI notifier ---------- 

38 

39@enum.unique 

40class NotificationLevel(SortedEnum): 

41 INFO = 'info' 

42 ERROR = 'error' 

43 

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

45 

46class Message: 

47 

48 ''' 

49 A message which should be displayed to the user. 

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

51 

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

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

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

55 ''' 

56 

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

58 ENVIRONMENT_VARIABLES = 'environment variables' 

59 

60 

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

62 

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

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

65 notification_level: NotificationLevel 

66 

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

68 message: 'str|BaseException' 

69 

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

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

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

73 file_name: 'str|None' 

74 

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

76 line_number: 'int|None' 

77 

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

79 line: str 

80 

81 _last_file_name: 'str|None' = None 

82 

83 @classmethod 

84 def reset(cls) -> None: 

85 ''' 

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

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

88 ''' 

89 cls._last_file_name = None 

90 

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

92 self.notification_level = notification_level 

93 self.message = message 

94 self.file_name = file_name 

95 self.line_number = line_number 

96 self.line = line 

97 

98 @property 

99 def lvl(self) -> NotificationLevel: 

100 ''' 

101 An abbreviation for :attr:`notification_level` 

102 ''' 

103 return self.notification_level 

104 

105 def format_msg_line(self) -> str: 

106 ''' 

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

108 ''' 

109 msg = str(self.message) 

110 if self.line: 

111 if self.line_number is not None: 

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

113 else: 

114 lnref = 'line' 

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

116 

117 return msg 

118 

119 def format_file_name(self) -> str: 

120 ''' 

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

122 ''' 

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

124 if file_name == self._last_file_name: 

125 return '' 

126 

127 if file_name: 

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

129 else: 

130 out = '' 

131 

132 if self._last_file_name is not None: 

133 out = '\n' + out 

134 

135 type(self)._last_file_name = file_name 

136 

137 return out 

138 

139 

140 def format_file_name_msg_line(self) -> str: 

141 ''' 

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

143 ''' 

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

145 

146 

147 def __str__(self) -> str: 

148 ''' 

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

150 ''' 

151 return self.format_file_name_msg_line() 

152 

153 def __repr__(self) -> str: 

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

155 

156 @staticmethod 

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

158 if isinstance(obj, enum.Enum): 

159 return obj.name 

160 return repr(obj) 

161 

162 

163class UiNotifier: 

164 

165 ''' 

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

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

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

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

170 

171 This object can also filter the messages. 

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

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

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

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

176 ''' 

177 

178 # ------- public methods ------- 

179 

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

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

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

183 self._notification_level = notification_level 

184 self._config_file = config_file 

185 

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

187 ''' 

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

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

190 ''' 

191 self._callback = callback 

192 

193 for msg in self._messages: 

194 callback(msg) 

195 self._messages.clear() 

196 

197 

198 @property 

199 def notification_level(self) -> NotificationLevel: 

200 ''' 

201 Ignore messages that are less important than this level. 

202 ''' 

203 if isinstance(self._notification_level, Config): 

204 return self._notification_level.value 

205 else: 

206 return self._notification_level 

207 

208 @notification_level.setter 

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

210 if isinstance(self._notification_level, Config): 

211 self._notification_level.value = val 

212 else: 

213 self._notification_level = val 

214 

215 

216 # ------- called by ConfigFile ------- 

217 

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

219 ''' 

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

221 ''' 

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

223 

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

225 ''' 

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

227 ''' 

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

229 

230 

231 # ------- internal methods ------- 

232 

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

234 ''' 

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

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

237 

238 :param notification_level: The importance of the message 

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

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

241 ''' 

242 if notification_level < self.notification_level and not ignore_filter: 

243 return 

244 

245 message = Message( 

246 notification_level = notification_level, 

247 message = msg, 

248 file_name = self._config_file.context_file_name, 

249 line_number = self._config_file.context_line_number, 

250 line = self._config_file.context_line, 

251 ) 

252 

253 if self._callback: 

254 self._callback(message) 

255 else: 

256 self._messages.append(message) 

257 

258 

259# ---------- format help ---------- 

260 

261class SectionLevel(SortedEnum): 

262 

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

264 SECTION = 'section' 

265 

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

267 SUB_SECTION = 'sub-section' 

268 

269 

270class FormattedWriter(abc.ABC): 

271 

272 @abc.abstractmethod 

273 def write_line(self, line: str) -> None: 

274 ''' 

275 Write a single line of documentation. 

276 :paramref:`line` may *not* contain a newline. 

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

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

279 ''' 

280 pass 

281 

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

283 ''' 

284 Write one or more lines of documentation. 

285 ''' 

286 for ln in text.splitlines(): 

287 self.write_line(ln) 

288 

289 @abc.abstractmethod 

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

291 ''' 

292 Write a heading. 

293 

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

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

296 in order to keep the line wrapping consistent. 

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

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

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

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

301 to understand the help generated by argparse 

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

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

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

305 

306 :param lvl: How to format the heading 

307 :param heading: The heading 

308 ''' 

309 pass 

310 

311 @abc.abstractmethod 

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

313 ''' 

314 Write a config file command. 

315 ''' 

316 pass 

317 

318 

319class TextIOWriter(FormattedWriter): 

320 

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

322 self.f = f 

323 self.ignore_empty_lines = True 

324 

325 def write_line_raw(self, line: str) -> None: 

326 if self.ignore_empty_lines and not line: 

327 return 

328 

329 print(line, file=self.f) 

330 self.ignore_empty_lines = False 

331 

332 

333class ConfigFileWriter(TextIOWriter): 

334 

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

336 super().__init__(f) 

337 self.prefix = prefix 

338 

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

340 self.write_line_raw(cmd) 

341 

342 def write_line(self, line: str) -> None: 

343 if line: 

344 line = self.prefix + line 

345 

346 self.write_line_raw(line) 

347 

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

349 if lvl is SectionLevel.SECTION: 

350 self.write_line('') 

351 self.write_line('') 

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

353 self.write_line(heading) 

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

355 else: 

356 self.write_line('') 

357 self.write_line(heading) 

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

359 

360class HelpWriter(TextIOWriter): 

361 

362 def write_line(self, line: str) -> None: 

363 self.write_line_raw(line) 

364 

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

366 self.write_line('') 

367 if lvl is SectionLevel.SECTION: 

368 self.write_line(heading) 

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

370 else: 

371 self.write_line(heading) 

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

373 

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

375 pass # pragma: no cover 

376 

377 

378# ---------- internal exceptions ---------- 

379 

380class ParseException(Exception): 

381 

382 ''' 

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

384 Is caught in :class:`ConfigFile`. 

385 ''' 

386 

387class MultipleParseExceptions(Exception): 

388 

389 ''' 

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

391 Is caught in :class:`ConfigFile`. 

392 ''' 

393 

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

395 super().__init__() 

396 self.exceptions = exceptions 

397 

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

399 return iter(self.exceptions) 

400 

401 

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

403 

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

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

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

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

408 no_multi: bool 

409 comments: bool 

410 

411 

412# ---------- ConfigFile class ---------- 

413 

414class ArgPos: 

415 ''' 

416 This is an internal class, the return type of :meth:`ConfigFile.find_arg` 

417 ''' 

418 

419 #: The index of the argument in :paramref:`~ConfigFile.find_arg.ln_split` where the cursor is located and which shall be completed. Please note that this can be one bigger than :paramref:`~ConfigFile.find_arg.ln_split` is long if the line ends on a space and the cursor is behind that space. In that case :attr:`in_between` is true. 

420 argument_pos: int 

421 

422 #: If true: The cursor is between two arguments, before the first argument or after the last argument. :attr:`argument_pos` refers to the next argument, :attr:`argument_pos-1 <argument_pos>` to the previous argument. :attr:`i0` is the start of the next argument, :attr:`i1` is the end of the previous argument. 

423 in_between: bool 

424 

425 #: The index in :paramref:`~ConfigFile.find_arg.line` where the argument having the cursor starts (inclusive) or the start of the next argument if :attr:`in_between` is true 

426 i0: int 

427 

428 #: The index in :paramref:`~ConfigFile.find_arg.line` where the current word ends (exclusive) or the end of the previous argument if :attr:`in_between` is true 

429 i1: int 

430 

431 

432class ConfigFile: 

433 

434 ''' 

435 Read or write a config file. 

436 ''' 

437 

438 COMMENT = '#' 

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

440 ENTER_GROUP_PREFIX = '[' 

441 ENTER_GROUP_SUFFIX = ']' 

442 

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

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

445 

446 #: 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`. 

447 config_id: 'ConfigId|None' 

448 

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

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

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

452 config_path: 'str|None' = None 

453 

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

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

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

457 config_directory: 'str|None' = None 

458 

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

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

461 config_name = 'config' 

462 

463 #: 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`. 

464 env_variables: 'list[str]' 

465 

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

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

468 envprefix: str 

469 

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

471 context_file_name: 'str|None' = None 

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

473 context_line_number: 'int|None' = None 

474 #: The line which is currently parsed. 

475 context_line: str = '' 

476 

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

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

479 enable_config_ids: bool 

480 

481 

482 def __init__(self, *, 

483 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 

484 appname: str, 

485 authorname: 'str|None' = None, 

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

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

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

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

490 enable_config_ids: 'bool|None' = None, 

491 ) -> None: 

492 ''' 

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

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

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

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

497 :param commands: The commands (as subclasses of :class:`ConfigFileCommand` or :class:`ConfigFileArgparseCommand`) allowed in this config file, if this is :const:`None`: use the return value of :meth:`ConfigFileCommand.get_command_types` 

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

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

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

501 ''' 

502 self.appname = appname 

503 self.authorname = authorname 

504 self.ui_notifier = UiNotifier(self, notification_level) 

505 self.config_instances = config_instances 

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

507 self.formatter_class = formatter_class 

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

509 self.check_config_id = check_config_id 

510 

511 if enable_config_ids is None: 

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

513 self.enable_config_ids = enable_config_ids 

514 

515 if not appname: 

516 # Avoid an exception if appname is None. 

517 # Although mypy does not allow passing None directly 

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

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

520 appname = '' 

521 self.envprefix = '' 

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

523 envname = self.envprefix + 'CONFIG_PATH' 

524 self.env_variables.append(envname) 

525 if envname in os.environ: 

526 self.config_path = os.environ[envname] 

527 envname = self.envprefix + 'CONFIG_DIRECTORY' 

528 self.env_variables.append(envname) 

529 if envname in os.environ: 

530 self.config_directory = os.environ[envname] 

531 envname = self.envprefix + 'CONFIG_NAME' 

532 self.env_variables.append(envname) 

533 if envname in os.environ: 

534 self.config_name = os.environ[envname] 

535 

536 if commands is None: 

537 commands = ConfigFileCommand.get_command_types() 

538 self.command_dict = {} 

539 self.commands = [] 

540 for cmd_type in commands: 

541 cmd = cmd_type(self) 

542 self.commands.append(cmd) 

543 for name in cmd.get_names(): 

544 self.command_dict[name] = cmd 

545 

546 

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

548 ''' 

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

550 

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

552 

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

554 ''' 

555 self.ui_notifier.set_ui_callback(callback) 

556 

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

558 ''' 

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

560 

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

562 The first one installed is used. 

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

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

565 

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

567 ''' 

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

569 try: 

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

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

572 except ImportError: 

573 try: 

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

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

576 except ImportError: 

577 AppDirs = appdirs.AppDirs 

578 

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

580 

581 return self._appdirs 

582 

583 # ------- load ------- 

584 

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

586 ''' 

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

588 

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

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

591 If :attr:`config_directory` has been set 

592 it's value is yielded and nothing else. 

593 ''' 

594 if self.config_directory: 

595 yield self.config_directory 

596 return 

597 

598 appdirs = self.get_app_dirs() 

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

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

601 

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

603 ''' 

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

605 

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

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

608 

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

610 :attr:`ConfigFile.config_name`. 

611 

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

613 ''' 

614 if self.config_path: 

615 yield self.config_path 

616 return 

617 

618 for path in self.iter_user_site_config_paths(): 

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

620 

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

622 ''' 

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

624 

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

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

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

628 ''' 

629 for fn in self.iter_config_paths(): 

630 if os.path.isfile(fn): 

631 self.load_file(fn) 

632 break 

633 

634 if env: 

635 self.load_env() 

636 

637 def load_env(self) -> None: 

638 ''' 

639 Load settings from environment variables. 

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

641 

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

643 

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

645 ''' 

646 old_file_name = self.context_file_name 

647 self.context_file_name = Message.ENVIRONMENT_VARIABLES 

648 

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

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

651 name = self.get_env_name(key) 

652 if name in self.env_variables: 

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

654 elif name in config_instances: 

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

656 else: 

657 config_instances[name] = instance 

658 

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

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

661 continue 

662 if name in self.env_variables: 

663 continue 

664 

665 if name in config_instances: 

666 instance = config_instances[name] 

667 try: 

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

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

670 except ValueError as e: 

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

672 else: 

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

674 

675 self.context_file_name = old_file_name 

676 

677 

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

679 ''' 

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

681 

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

683 ''' 

684 out = key 

685 out = out.upper() 

686 for c in ' .-': 

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

688 out = self.envprefix + out 

689 return out 

690 

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

692 ''' 

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

694 

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

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

697 

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

699 ''' 

700 self.config_id = None 

701 self.load_without_resetting_config_id(fn) 

702 

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

704 old_file_name = self.context_file_name 

705 self.context_file_name = fn 

706 

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

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

709 self.context_line_number = lnno 

710 self.parse_line(line=ln) 

711 self.context_line_number = None 

712 

713 self.context_file_name = old_file_name 

714 

715 def parse_line(self, line: str) -> bool: 

716 ''' 

717 :param line: The line to be parsed 

718 :return: True if line is valid, False if an error has occurred 

719 

720 :meth:`parse_error` is called if something goes wrong (i.e. if the return value is False), e.g. invalid key or invalid value. 

721 ''' 

722 ln = line.strip() 

723 if not ln: 

724 return True 

725 if self.is_comment(ln): 

726 return True 

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

728 return True 

729 

730 self.context_line = ln 

731 

732 try: 

733 ln_split = self.split_line(ln) 

734 except Exception as e: 

735 self.parse_error(str(e)) 

736 out = False 

737 else: 

738 out = self.parse_split_line(ln_split) 

739 

740 self.context_line = '' 

741 return out 

742 

743 def split_line(self, line: str) -> 'list[str]': 

744 return shlex.split(line, comments=True) 

745 

746 def split_line_ignore_errors(self, line: str) -> 'list[str]': 

747 out = [] 

748 lex = shlex.shlex(line, posix=True) 

749 lex.whitespace_split = True 

750 while True: 

751 try: 

752 t = lex.get_token() 

753 except: 

754 out.append(lex.token) 

755 return out 

756 if t is None: 

757 return out # type: ignore [unreachable] # yes, with posix=True lex.get_token returns None at the end 

758 out.append(t) 

759 

760 def is_comment(self, line: str) -> bool: 

761 ''' 

762 Check if :paramref:`line` is a comment. 

763 

764 :param line: The current line 

765 :return: :const:`True` if :paramref:`line` is a comment 

766 ''' 

767 for c in self.COMMENT_PREFIXES: 

768 if line.startswith(c): 

769 return True 

770 return False 

771 

772 def enter_group(self, line: str) -> bool: 

773 ''' 

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

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

776 

777 :param line: The current line 

778 :return: :const:`True` if :paramref:`line` starts a new group 

779 ''' 

780 if line.startswith(self.ENTER_GROUP_PREFIX) and line.endswith(self.ENTER_GROUP_SUFFIX): 

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

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

783 try: 

784 self.check_config_id(config_id) 

785 except ParseException as e: 

786 self.parse_error(str(e)) 

787 self.config_id = config_id 

788 if self.config_id not in MultiConfig.config_ids: 

789 MultiConfig.config_ids.append(self.config_id) 

790 return True 

791 return False 

792 

793 def parse_split_line(self, ln_split: 'Sequence[str]') -> bool: 

794 ''' 

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

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

797 

798 :return: False if a :class:`ParseException` or :class:`MultipleParseExceptions` has been caught, True if no exception has been caught 

799 ''' 

800 cmd = self.get_command(ln_split) 

801 try: 

802 cmd.run(ln_split) 

803 except ParseException as e: 

804 self.parse_error(str(e)) 

805 return False 

806 except MultipleParseExceptions as exceptions: 

807 for exc in exceptions: 

808 self.parse_error(str(exc)) 

809 return False 

810 

811 return True 

812 

813 def get_command(self, ln_split: 'Sequence[str]') -> 'ConfigFileCommand': 

814 cmd_name = ln_split[0] 

815 if cmd_name in self.command_dict: 

816 cmd = self.command_dict[cmd_name] 

817 elif DEFAULT_COMMAND in self.command_dict: 

818 cmd = self.command_dict[DEFAULT_COMMAND] 

819 else: 

820 cmd = UnknownCommand(self) 

821 return cmd 

822 

823 

824 # ------- save ------- 

825 

826 def get_save_path(self) -> str: 

827 ''' 

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

829 ''' 

830 paths = tuple(self.iter_config_paths()) 

831 for fn in paths: 

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

833 return fn 

834 

835 return paths[0] 

836 

837 def save(self, 

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

839 ) -> str: 

840 ''' 

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

842 Directories are created as necessary. 

843 

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

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

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

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

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

849 ''' 

850 fn = self.get_save_path() 

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

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

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

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

855 self.save_file(fn, **kw) 

856 return fn 

857 

858 def save_file(self, 

859 fn: str, 

860 **kw: 'Unpack[SaveKwargs]' 

861 ) -> None: 

862 ''' 

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

864 

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

866 :raises FileNotFoundError: if the directory does not exist 

867 

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

869 ''' 

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

871 self.save_to_open_file(f, **kw) 

872 

873 

874 def save_to_open_file(self, 

875 f: typing.TextIO, 

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

877 ) -> None: 

878 ''' 

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

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

881 

882 :param f: The file to write to 

883 

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

885 ''' 

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

887 self.save_to_writer(writer, **kw) 

888 

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

890 ''' 

891 Save the current values of all settings. 

892 

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

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

895 

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

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

898 ''' 

899 self.set_save_default_arguments(kw) 

900 commands = self.commands 

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

902 for cmd in commands: 

903 cmd.should_write_heading = write_headings 

904 cmd.save(writer, **kw) 

905 

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

907 ''' 

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

909 ''' 

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

911 kw.setdefault('ignore', None) 

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

913 kw.setdefault('comments', True) 

914 

915 

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

917 ''' 

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

919 

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

921 ''' 

922 return readable_quote(val) 

923 

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

925 ''' 

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

927 ''' 

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

929 

930 def get_help_config_id(self) -> str: 

931 ''' 

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

933 ''' 

934 return f''' 

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

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

937 ''' 

938 

939 

940 # ------- help ------- 

941 

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

943 import platform 

944 formatter = self.create_formatter() 

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

946 for path in self.iter_config_paths(): 

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

948 

949 writer.write_line('') 

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

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

952 writer.write_line('- XDG_CONFIG_HOME') 

953 writer.write_line('- XDG_CONFIG_DIRS') 

954 for env in self.env_variables: 

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

956 

957 writer.write_line('') 

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

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

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

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

962 

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

964 

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

966 for cmd in self.commands: 

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

968 writer.write_heading(SectionLevel.SECTION, names) 

969 writer.write_lines(cmd.get_help()) 

970 

971 def create_formatter(self) -> HelpFormatterWrapper: 

972 return HelpFormatterWrapper(self.formatter_class) 

973 

974 def get_help(self) -> str: 

975 ''' 

976 A convenience wrapper around :meth:`write_help` 

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

978 

979 This uses :class:`HelpWriter`. 

980 ''' 

981 doc = io.StringIO() 

982 self.write_help(HelpWriter(doc)) 

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

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

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

986 # Therefore I am stripping the trailing \n. 

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

988 

989 

990 # ------- auto complete ------- 

991 

992 def get_completions(self, line: str, cursor_pos: int) -> 'tuple[str, list[str], str]': 

993 ''' 

994 Provide an auto completion for commands that can be executed with :meth:`parse_line`. 

995 

996 :param line: The entire line that is currently in the text input field 

997 :param cursor_pos: The position of the cursor 

998 :return: start of line, completions, end of line. 

999 *completions* is a list of possible completions for the word where the cursor is located. 

1000 If *completions* is an empty list there are no completions available and the user input should not be changed. 

1001 If *completions* is not empty it should be displayed by a user interface in a drop down menu. 

1002 The *start of line* is everything on the line before the completions. 

1003 The *end of line* is everything on the line after the completions. 

1004 In the likely case that the cursor is at the end of the line the *end of line* is an empty str. 

1005 *start of line* and *end of line* should be the beginning and end of :paramref:`line` but they may contain minor changes in order to keep quoting feasible. 

1006 ''' 

1007 original_ln = line 

1008 stripped_line = line.lstrip() 

1009 indentation = line[:len(line) - len(stripped_line)] 

1010 cursor_pos -= len(indentation) 

1011 line = stripped_line 

1012 if self.enable_config_ids and line.startswith(self.ENTER_GROUP_PREFIX): 

1013 out = self.get_completions_enter_group(line, cursor_pos) 

1014 else: 

1015 out = self.get_completions_command(line, cursor_pos) 

1016 

1017 out = (indentation + out[0], out[1], out[2]) 

1018 return out 

1019 

1020 def get_completions_enter_group(self, line: str, cursor_pos: int) -> 'tuple[str, list[str], str]': 

1021 ''' 

1022 For a description of parameters and return type see :meth:`get_completions`. 

1023 

1024 :meth:`get_completions` has stripped any indentation from :paramref:`line` 

1025 and will prepend it to the first item of the return value. 

1026 ''' 

1027 start = line 

1028 groups = [self.ENTER_GROUP_PREFIX + str(cid) + self.ENTER_GROUP_SUFFIX for cid in MultiConfig.config_ids] 

1029 groups = [cid for cid in groups if cid.startswith(start)] 

1030 return '', groups, '' 

1031 

1032 def get_completions_command(self, line: str, cursor_pos: int) -> 'tuple[str, list[str], str]': 

1033 ''' 

1034 For a description of parameters and return type see :meth:`get_completions`. 

1035 

1036 :meth:`get_completions` has stripped any indentation from :paramref:`line` 

1037 and will prepend it to the first item of the return value. 

1038 ''' 

1039 if not line: 

1040 return self.get_completions_command_name(line, cursor_pos, start_of_line='', end_of_line='') 

1041 

1042 ln_split = self.split_line_ignore_errors(line) 

1043 assert ln_split 

1044 a = self.find_arg(line, ln_split, cursor_pos) 

1045 

1046 if a.in_between: 

1047 start_of_line = line[:cursor_pos] 

1048 end_of_line = line[cursor_pos:] 

1049 else: 

1050 start_of_line = line[:a.i0] 

1051 end_of_line = line[a.i1:] 

1052 

1053 if a.argument_pos == 0: 

1054 return self.get_completions_command_name(line, cursor_pos, start_of_line=start_of_line, end_of_line=end_of_line) 

1055 else: 

1056 cmd = self.get_command(ln_split) 

1057 return cmd.get_completions(ln_split, a.argument_pos, cursor_pos-a.i0, in_between=a.in_between, start_of_line=start_of_line, end_of_line=end_of_line) 

1058 

1059 def find_arg(self, line: str, ln_split: 'list[str]', cursor_pos: int) -> ArgPos: 

1060 ''' 

1061 This is an internal method used by :meth:`get_completions_command` 

1062 ''' 

1063 CHARS_REMOVED_BY_SHLEX = ('"', "'", '\\') 

1064 assert cursor_pos <= len(line) # yes, cursor_pos can be == len(str) 

1065 out = ArgPos() 

1066 out.in_between = True 

1067 

1068 # init all out attributes just to be save, these should not never be used because line is not empty and not white space only 

1069 out.argument_pos = 0 

1070 out.i0 = 0 

1071 out.i1 = 0 

1072 

1073 n_ln = len(line) 

1074 i_ln = 0 

1075 n_arg = len(ln_split) 

1076 out.argument_pos = 0 

1077 i_in_arg = 0 

1078 assert out.argument_pos < n_ln 

1079 while True: 

1080 if out.in_between: 

1081 assert i_in_arg == 0 

1082 if i_ln >= n_ln: 

1083 assert out.argument_pos >= n_arg - 1 

1084 out.i0 = i_ln 

1085 return out 

1086 elif line[i_ln].isspace(): 

1087 i_ln += 1 

1088 else: 

1089 out.i0 = i_ln 

1090 if i_ln >= cursor_pos: 

1091 return out 

1092 out.in_between = False 

1093 else: 

1094 if i_ln >= n_ln: 

1095 assert out.argument_pos >= n_arg - 1 

1096 out.i1 = i_ln 

1097 return out 

1098 elif out.argument_pos >= n_arg: 

1099 # This is a comment 

1100 out.i1 = n_ln 

1101 return out 

1102 elif i_in_arg >= len(ln_split[out.argument_pos]): 

1103 if line[i_ln].isspace(): 

1104 out.i1 = i_ln 

1105 if i_ln >= cursor_pos: 

1106 return out 

1107 out.in_between = True 

1108 i_ln += 1 

1109 out.argument_pos += 1 

1110 i_in_arg = 0 

1111 elif line[i_ln] in CHARS_REMOVED_BY_SHLEX: 

1112 i_ln += 1 

1113 else: 

1114 # unlike bash shlex treats a comment character inside of an argument as a comment character 

1115 assert line[i_ln] == '#' 

1116 assert out.argument_pos == n_arg - 1 

1117 out.i1 = i_ln 

1118 return out 

1119 elif line[i_ln] == ln_split[out.argument_pos][i_in_arg]: 

1120 i_ln += 1 

1121 i_in_arg += 1 

1122 else: 

1123 assert line[i_ln] in CHARS_REMOVED_BY_SHLEX 

1124 i_ln += 1 

1125 

1126 

1127 def get_completions_command_name(self, line: str, cursor_pos: int, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]': 

1128 start = line[:cursor_pos] 

1129 completions = [cmd for cmd in self.command_dict.keys() if cmd.startswith(start)] 

1130 return start_of_line, completions, end_of_line 

1131 

1132 

1133 # ------- error handling ------- 

1134 

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

1136 ''' 

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

1138 

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

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

1141 

1142 :param msg: The error message 

1143 ''' 

1144 self.ui_notifier.show_error(msg) 

1145 

1146 

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

1148 

1149class ConfigFileCommand(abc.ABC): 

1150 

1151 ''' 

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

1153 

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

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

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

1157 

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

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

1160 ''' 

1161 

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

1163 name: str 

1164 

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

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

1167 

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

1169 help: str 

1170 

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

1172 should_write_heading: bool = False 

1173 

1174 #: The :class:`ConfigFile` that has been passed to the constructor. It determines for example the :paramref:`~ConfigFile.notification_level` and the available :paramref:`~ConfigFile.commands`. 

1175 config_file: ConfigFile 

1176 

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

1178 ui_notifier: UiNotifier 

1179 

1180 

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

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

1183 

1184 @classmethod 

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

1186 ''' 

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

1188 ''' 

1189 return tuple(cls._subclasses) 

1190 

1191 @classmethod 

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

1193 ''' 

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

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

1196 ''' 

1197 if cmd_type in cls._subclasses: 

1198 cls._subclasses.remove(cmd_type) 

1199 for name in cmd_type.get_names(): 

1200 cls._used_names.remove(name) 

1201 

1202 @classmethod 

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

1204 ''' 

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

1206 

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

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

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

1210 ''' 

1211 if replace: 

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

1213 

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

1215 parent = parent_commands[0] 

1216 if 'name' not in cls.__dict__: 

1217 cls.name = parent.get_name() 

1218 if 'aliases' not in cls.__dict__: 

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

1220 for parent in parent_commands[1:]: 

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

1222 

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

1224 for parent in parent_commands: 

1225 cls.delete_command_type(parent) 

1226 

1227 if not abstract: 

1228 cls._subclasses.append(cls) 

1229 for name in cls.get_names(): 

1230 if name in cls._used_names and not replace: 

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

1232 cls._used_names.add(name) 

1233 

1234 @classmethod 

1235 def get_name(cls) -> str: 

1236 ''' 

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

1238  

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

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

1241 ''' 

1242 if 'name' in cls.__dict__: 

1243 return cls.name 

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

1245 

1246 @classmethod 

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

1248 ''' 

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

1250  

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

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

1253 

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

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

1256 ''' 

1257 yield cls.get_name() 

1258 if 'aliases' in cls.__dict__: 

1259 for name in cls.aliases: 

1260 yield name 

1261 

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

1263 self.config_file = config_file 

1264 self.ui_notifier = config_file.ui_notifier 

1265 

1266 @abc.abstractmethod 

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

1268 ''' 

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

1270 

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

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

1273 ''' 

1274 raise NotImplementedError() 

1275 

1276 def create_formatter(self) -> HelpFormatterWrapper: 

1277 return self.config_file.create_formatter() 

1278 

1279 def get_help_attr_or_doc_str(self) -> str: 

1280 ''' 

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

1282 ''' 

1283 if hasattr(self, 'help'): 

1284 doc = self.help 

1285 elif self.__doc__: 

1286 doc = self.__doc__ 

1287 else: 

1288 doc = '' 

1289 

1290 return inspect.cleandoc(doc) 

1291 

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

1293 ''' 

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

1295 ''' 

1296 formatter.add_text(self.get_help_attr_or_doc_str()) 

1297 

1298 def get_help(self) -> str: 

1299 ''' 

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

1301 

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

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

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

1305 

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

1307 ''' 

1308 formatter = self.create_formatter() 

1309 self.add_help_to(formatter) 

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

1311 

1312 def save(self, 

1313 writer: FormattedWriter, 

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

1315 ) -> None: 

1316 ''' 

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

1318 

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

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

1321 

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

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

1324 

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

1326 

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

1328 - call :attr:`ConfigFile.write_config_id` 

1329 

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

1331 

1332 The default implementation does nothing. 

1333 ''' 

1334 pass 

1335 

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

1337 

1338 

1339 # ------- auto complete ------- 

1340 

1341 def get_completions(self, cmd: 'Sequence[str]', argument_pos: int, cursor_pos: int, *, in_between: bool, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]': 

1342 ''' 

1343 :param cmd: The line split into arguments (including the name of this command as cmd[0]) 

1344 :param argument_pos: The index of the argument which shall be completed. Please note that this can be one bigger than :paramref:`cmd` is long if the line ends on a space and the cursor is behind that space. In that case :paramref:`in_between` is true. 

1345 :param cursor_pos: The index inside of the argument where the cursor is located. This is undefined and should be ignored if :paramref:`in_between` is true. The input from the start of the argument to the cursor should be used to filter the completions. The input after the cursor can be ignored. 

1346 :param in_between: If true: The cursor is between two arguments, before the first argument or after the last argument. :paramref:`argument_pos` refers to the next argument, :paramref:`argument_pos-1 <argument_pos>` to the previous argument. :paramref:`cursor_pos` is undefined. 

1347 :param start_of_line: The first return value. If ``cmd[argument_pos]`` has a pattern like ``key=value`` you can append ``key=`` to this value and return only completions of ``value`` as second return value. 

1348 :param end_of_line: The third return value. 

1349 :return: start of line, completions, end of line. 

1350 *completions* is a list of possible completions for the word where the cursor is located. 

1351 If *completions* is an empty list there are no completions available and the user input should not be changed. 

1352 This should be displayed by a user interface in a drop down menu. 

1353 The *start of line* is everything on the line before the completions. 

1354 The *end of line* is everything on the line after the completions. 

1355 In the likely case that the cursor is at the end of the line the *end of line* is an empty str. 

1356 *start of line* and *end of line* should be the beginning and end of :paramref:`line` but they may contain minor changes in order to keep quoting feasible. 

1357 ''' 

1358 completions: 'list[str]' = [] 

1359 return start_of_line, completions, end_of_line 

1360 

1361 def get_completions_for_file_name(self, start: str, *, relative_to: str, exclude: 'str|None' = None, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]': 

1362 r''' 

1363 :param start: The start of the path to be completed 

1364 :param relative_to: If :paramref:`start` is a relative path it's relative to this directory 

1365 :param exclude: A regular expression. The default value :obj:`None` is interpreted differently depending on the :meth:`platform.platform`. For ``Windows`` it's ``$none`` so that nothing is excluded. For others it's ``^\.`` so that hidden files and directories are excluded. 

1366 :return: All files and directories that start with :paramref:`start` and do not match :paramref:`exclude`. Directories are appended with :const:`os.path.sep`. :const:`os.path.sep` is appended after quoting so that it can be easily stripped if undesired (e.g. if the user interface cycles through all possible completions instead of completing the longest common prefix). 

1367 ''' 

1368 if exclude is None: 

1369 if platform.platform() == 'Windows' or os.path.split(start)[1].startswith('.'): 

1370 exclude = '$none' 

1371 else: 

1372 exclude = r'^\.' 

1373 reo = re.compile(exclude) 

1374 

1375 # I cannot use os.path.split because that would ignore the important difference between having a trailing separator or not 

1376 if os.path.sep in start: 

1377 directory, start = start.rsplit(os.path.sep, 1) 

1378 directory += os.path.sep 

1379 quoted_directory = self.quote_path(directory) 

1380 

1381 start_of_line += quoted_directory 

1382 directory = os.path.expanduser(directory) 

1383 if not os.path.isabs(directory): 

1384 directory = os.path.join(relative_to, directory) 

1385 directory = os.path.normpath(directory) 

1386 else: 

1387 directory = relative_to 

1388 

1389 try: 

1390 names = os.listdir(directory) 

1391 except (FileNotFoundError, NotADirectoryError): 

1392 return start_of_line, [], end_of_line 

1393 

1394 out: 'list[str]' = [] 

1395 for name in names: 

1396 if reo.match(name): 

1397 continue 

1398 if not name.startswith(start): 

1399 continue 

1400 

1401 quoted_name = self.config_file.quote(name) 

1402 if os.path.isdir(os.path.join(directory, name)): 

1403 quoted_name += os.path.sep 

1404 

1405 out.append(quoted_name) 

1406 

1407 return start_of_line, out, end_of_line 

1408 

1409 def quote_path(self, path: str) -> str: 

1410 path_split = path.split(os.path.sep) 

1411 i0 = 1 if path_split[0] == '~' else 0 

1412 for i in range(i0, len(path_split)): 

1413 if path_split[i]: 

1414 path_split[i] = self.config_file.quote(path_split[i]) 

1415 return os.path.sep.join(path_split) 

1416 

1417 

1418class ArgumentParser(argparse.ArgumentParser): 

1419 

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

1421 ''' 

1422 Raise a :class:`ParseException`. 

1423 ''' 

1424 raise ParseException(message) 

1425 

1426class ConfigFileArgparseCommand(ConfigFileCommand, abstract=True): 

1427 

1428 ''' 

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

1430 

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

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

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

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

1435 

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

1437 ''' 

1438 

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

1440 super().__init__(config_file) 

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

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

1443 self.init_parser(self.parser) 

1444 

1445 @abc.abstractmethod 

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

1447 ''' 

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

1449 

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

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

1452 ''' 

1453 pass 

1454 

1455 def get_help(self) -> str: 

1456 ''' 

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

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

1459 ''' 

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

1461 

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

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

1464 if not cmd: 

1465 return # pragma: no cover 

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

1467 if cmd[0] in self._names: 

1468 cmd = cmd[1:] 

1469 args = self.parser.parse_args(cmd) 

1470 self.run_parsed(args) 

1471 

1472 @abc.abstractmethod 

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

1474 ''' 

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

1476 ''' 

1477 pass 

1478 

1479 # ------- auto complete ------- 

1480 

1481 def get_completions(self, cmd: 'Sequence[str]', argument_pos: int, cursor_pos: int, *, in_between: bool, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]': 

1482 if in_between: 

1483 start = '' 

1484 else: 

1485 start = cmd[argument_pos][:cursor_pos] 

1486 

1487 if self.after_positional_argument_marker(cmd, argument_pos): 

1488 pos = self.get_position(cmd, argument_pos) 

1489 return self.get_completions_for_positional_argument(pos, start, start_of_line=start_of_line, end_of_line=end_of_line) 

1490 

1491 if argument_pos > 0: # pragma: no branch # if argument_pos was 0 this method would not be called, command names would be completed instead 

1492 prevarg = self.get_option_name_if_it_takes_an_argument(cmd, argument_pos-1) 

1493 if prevarg: 

1494 return self.get_completions_for_option_argument(prevarg, start, start_of_line=start_of_line, end_of_line=end_of_line) 

1495 

1496 if self.is_option_start(start): 

1497 if '=' in start: 

1498 i = start.index('=') 

1499 option_name = start[:i] 

1500 i += 1 

1501 start_of_line += start[:i] 

1502 start = start[i:] 

1503 return self.get_completions_for_option_argument(option_name, start, start_of_line=start_of_line, end_of_line=end_of_line) 

1504 return self.get_completions_for_option_name(start, start_of_line=start_of_line, end_of_line=end_of_line) 

1505 

1506 pos = self.get_position(cmd, argument_pos) 

1507 return self.get_completions_for_positional_argument(pos, start, start_of_line=start_of_line, end_of_line=end_of_line) 

1508 

1509 def get_position(self, cmd: 'Sequence[str]', argument_pos: int) -> int: 

1510 ''' 

1511 :return: the position of a positional argument, not counting options and their arguments 

1512 ''' 

1513 pos = 0 

1514 n = len(cmd) 

1515 options_allowed = True 

1516 # I am starting at 1 because cmd[0] is the name of the command, not an argument 

1517 for i in range(1, argument_pos): 

1518 if options_allowed and i < n: 

1519 if cmd[i] == '--': 

1520 options_allowed = False 

1521 continue 

1522 elif self.is_option_start(cmd[i]): 

1523 continue 

1524 # > 1 because cmd[0] is the name of the command 

1525 elif i > 1 and self.get_option_name_if_it_takes_an_argument(cmd, i-1): 

1526 continue 

1527 pos += 1 

1528 

1529 return pos 

1530 

1531 def is_option_start(self, start: str) -> bool: 

1532 return start.startswith('-') or start.startswith('+') 

1533 

1534 def after_positional_argument_marker(self, cmd: 'Sequence[str]', argument_pos: int) -> bool: 

1535 ''' 

1536 :return: true if this can only be a positional argument. False means it can be both, option or positional argument. 

1537 ''' 

1538 return '--' in cmd and cmd.index('--') < argument_pos 

1539 

1540 def get_option_name_if_it_takes_an_argument(self, cmd: 'Sequence[str]', argument_pos: int) -> 'str|None': 

1541 if argument_pos >= len(cmd): 

1542 return None # pragma: no cover # this does not happen because this method is always called for the previous argument 

1543 

1544 arg = cmd[argument_pos] 

1545 if '=' in arg: 

1546 # argument of option is already given within arg 

1547 return None 

1548 if not self.is_option_start(arg): 

1549 return None 

1550 if arg.startswith('--'): 

1551 action = self.get_action_for_option(arg) 

1552 if action is None: 

1553 return None 

1554 if action.nargs != 0: 

1555 return arg 

1556 return None 

1557 

1558 # arg is a combination of single character flags like in `tar -xzf file` 

1559 for c in arg[1:-1]: 

1560 action = self.get_action_for_option('-' + c) 

1561 if action is None: 

1562 continue 

1563 if action.nargs != 0: 

1564 # c takes an argument but that is already given within arg 

1565 return None 

1566 

1567 out = '-' + arg[-1] 

1568 action = self.get_action_for_option(out) 

1569 if action is None: 

1570 return None 

1571 if action.nargs != 0: 

1572 return out 

1573 return None 

1574 

1575 

1576 def get_completions_for_option_name(self, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]': 

1577 completions = [] 

1578 for a in self.parser._get_optional_actions(): 

1579 for opt in a.option_strings: 

1580 if len(opt) <= 2: 

1581 # this is trivial to type but not self explanatory 

1582 # => not helpful for auto completion 

1583 continue 

1584 if opt.startswith(start): 

1585 completions.append(opt) 

1586 return start_of_line, completions, end_of_line 

1587 

1588 def get_completions_for_option_argument(self, option_name: str, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]': 

1589 return self.get_completions_for_action(self.get_action_for_option(option_name), start, start_of_line=start_of_line, end_of_line=end_of_line) 

1590 

1591 def get_completions_for_positional_argument(self, position: int, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]': 

1592 return self.get_completions_for_action(self.get_action_for_positional_argument(position), start, start_of_line=start_of_line, end_of_line=end_of_line) 

1593 

1594 

1595 def get_action_for_option(self, option_name: str) -> 'argparse.Action|None': 

1596 for a in self.parser._get_optional_actions(): 

1597 if option_name in a.option_strings: 

1598 return a 

1599 return None 

1600 

1601 def get_action_for_positional_argument(self, argument_pos: int) -> 'argparse.Action|None': 

1602 actions = self.parser._get_positional_actions() 

1603 if argument_pos < len(actions): 

1604 return actions[argument_pos] 

1605 return None 

1606 

1607 def get_completions_for_action(self, action: 'argparse.Action|None', start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]': 

1608 if action is None: 

1609 completions: 'list[str]' = [] 

1610 elif not action.choices: 

1611 completions = [] 

1612 else: 

1613 completions = [str(val) for val in action.choices] 

1614 completions = [val for val in completions if val.startswith(start)] 

1615 completions = [self.config_file.quote(val) for val in completions] 

1616 return start_of_line, completions, end_of_line 

1617 

1618 

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

1620 

1621class Set(ConfigFileCommand): 

1622 

1623 r''' 

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

1625 set key [=] val 

1626 

1627 Change the value of a setting. 

1628 

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

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

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

1632 

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

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

1635 ''' 

1636 

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

1638 KEY_VAL_SEP = '=' 

1639 

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

1641 help_for_types = { 

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

1643 int : '''\ 

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

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

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

1647 #bool, 

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

1649 } 

1650 

1651 

1652 # ------- load ------- 

1653 

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

1655 ''' 

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

1657 

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

1659 ''' 

1660 if len(cmd) < 2: 

1661 raise ParseException('no settings given') 

1662 

1663 if self.is_vim_style(cmd): 

1664 self.set_multiple(cmd) 

1665 else: 

1666 self.set_with_spaces(cmd) 

1667 

1668 def is_vim_style(self, cmd: 'Sequence[str]') -> bool: 

1669 ''' 

1670 :paramref:`cmd` has one of two possible styles: 

1671 - vim inspired: set takes an arbitrary number of arguments, each argument sets one setting. Is handled by :meth:`set_multiple`. 

1672 - ranger inspired: set takes two arguments, the key and the value. Optionally a single equals character may be added in between as third argument. Is handled by :meth:`set_with_spaces`. 

1673 

1674 :return: true if cmd has a vim inspired style, false if cmd has a ranger inspired style 

1675 ''' 

1676 return self.KEY_VAL_SEP in cmd[1] # cmd[0] is the name of the command, cmd[1] is the first argument 

1677 

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

1679 ''' 

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

1681 

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

1683 ''' 

1684 n = len(cmd) 

1685 if n == 3: 

1686 cmdname, key, value = cmd 

1687 self.parse_key_and_set_value(key, value) 

1688 elif n == 4: 

1689 cmdname, key, sep, value = cmd 

1690 if sep != self.KEY_VAL_SEP: 

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

1692 self.parse_key_and_set_value(key, value) 

1693 elif n == 2: 

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

1695 else: 

1696 assert n >= 5 

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

1698 

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

1700 ''' 

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

1702 

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

1704 ''' 

1705 exceptions = [] 

1706 for arg in cmd[1:]: 

1707 try: 

1708 if not self.KEY_VAL_SEP in arg: 

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

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

1711 self.parse_key_and_set_value(key, value) 

1712 except ParseException as e: 

1713 exceptions.append(e) 

1714 if exceptions: 

1715 raise MultipleParseExceptions(exceptions) 

1716 

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

1718 ''' 

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

1720 

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

1722 ''' 

1723 if key not in self.config_file.config_instances: 

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

1725 

1726 instance = self.config_file.config_instances[key] 

1727 try: 

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

1729 except ValueError as e: 

1730 raise ParseException(str(e)) 

1731 

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

1733 ''' 

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

1735 ''' 

1736 return instance.parse_value(value) 

1737 

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

1739 ''' 

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

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

1742 ''' 

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

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

1745 

1746 

1747 # ------- save ------- 

1748 

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

1750 ''' 

1751 :param config_instances: The settings to consider 

1752 :param ignore: Skip these settings 

1753 

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

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

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

1757 ''' 

1758 config_instances = kw['config_instances'] 

1759 ignore = kw['ignore'] 

1760 

1761 config_keys = [] 

1762 for c in config_instances: 

1763 if isinstance(c, DictConfig): 

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

1765 else: 

1766 config_keys.append(c.key) 

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

1768 config_keys = sorted(config_keys) 

1769 

1770 if ignore is not None: 

1771 tmp = set() 

1772 for c in tuple(ignore): 

1773 if isinstance(c, DictConfig): 

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

1775 else: 

1776 tmp.add(c) 

1777 ignore = tmp 

1778 

1779 for key in config_keys: 

1780 instance = self.config_file.config_instances[key] 

1781 if not instance.wants_to_be_exported(): 

1782 continue 

1783 

1784 if ignore is not None and instance in ignore: 

1785 continue 

1786 

1787 yield instance 

1788 

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

1790 ''' 

1791 :param writer: The file to write to 

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

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

1794 

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

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

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

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

1799 ''' 

1800 no_multi = kw['no_multi'] 

1801 comments = kw['comments'] 

1802 

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

1804 normal_configs = [] 

1805 multi_configs = [] 

1806 if no_multi: 

1807 normal_configs = config_instances 

1808 else: 

1809 for instance in config_instances: 

1810 if isinstance(instance, MultiConfig): 

1811 multi_configs.append(instance) 

1812 else: 

1813 normal_configs.append(instance) 

1814 

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

1816 

1817 if normal_configs: 

1818 if multi_configs: 

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

1820 elif self.should_write_heading: 

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

1822 

1823 if comments: 

1824 type_help = self.get_help_for_data_types(normal_configs) 

1825 if type_help: 

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

1827 writer.write_lines(type_help) 

1828 

1829 for instance in normal_configs: 

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

1831 

1832 if multi_configs: 

1833 if normal_configs: 

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

1835 elif self.should_write_heading: 

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

1837 

1838 if comments: 

1839 type_help = self.get_help_for_data_types(multi_configs) 

1840 if type_help: 

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

1842 writer.write_lines(type_help) 

1843 

1844 for instance in multi_configs: 

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

1846 

1847 for config_id in MultiConfig.config_ids: 

1848 writer.write_line('') 

1849 self.config_file.write_config_id(writer, config_id) 

1850 for instance in multi_configs: 

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

1852 

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

1854 ''' 

1855 :param writer: The file to write to 

1856 :param instance: The config value to be saved 

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

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

1859 

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

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

1862 ''' 

1863 if kw['comments']: 

1864 self.write_config_help(writer, instance) 

1865 value = self.format_value(instance, config_id) 

1866 value = self.config_file.quote(value) 

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

1868 writer.write_command(ln) 

1869 

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

1871 ''' 

1872 :param instance: The config value to be saved 

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

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

1875 

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

1877 ''' 

1878 return instance.format_value(config_id) 

1879 

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

1881 ''' 

1882 :param writer: The output to write to 

1883 :param instance: The config value to be saved 

1884 

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

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

1887 

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

1889 ''' 

1890 if group_dict_configs and instance.parent is not None: 

1891 name = instance.parent.key_prefix 

1892 else: 

1893 name = instance.key 

1894 if name == self.last_name: 

1895 return 

1896 

1897 formatter = HelpFormatterWrapper(self.config_file.formatter_class) 

1898 writer.write_heading(SectionLevel.SUB_SECTION, name) 

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

1900 #if instance.unit: 

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

1902 if isinstance(instance.help, dict): 

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

1904 key_name = instance.format_any_value(key) 

1905 val = inspect.cleandoc(val) 

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

1907 elif isinstance(instance.help, str): 

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

1909 

1910 self.last_name = name 

1911 

1912 

1913 @classmethod 

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

1915 ''' 

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

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

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

1919 you can set the help with this method. 

1920 

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

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

1923 ''' 

1924 cls.help_for_types[t] = help_text 

1925 

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

1927 ''' 

1928 :param config_instances: All config values to be saved 

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

1930 

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

1932 which occur in :paramref:`config_instances`. 

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

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

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

1936 ''' 

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

1938 for instance in config_instances: 

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

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

1941 if name in help_text: 

1942 continue 

1943 

1944 if t in self.help_for_types: 

1945 h = self.help_for_types[t] 

1946 elif hasattr(t, 'help'): 

1947 h = t.help 

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

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

1950 # bool is treated like an enum 

1951 continue 

1952 else: 

1953 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__)) 

1954 

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

1956 

1957 return help_text 

1958 

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

1960 help_map = self.get_data_type_name_to_help_map(config_instances) 

1961 if not help_map: 

1962 return 

1963 

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

1965 formatter.add_start_section(name) 

1966 formatter.add_text(help_map[name]) 

1967 formatter.add_end_section() 

1968 

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

1970 formatter = self.create_formatter() 

1971 self.add_help_for_data_types(formatter, config_instances) 

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

1973 

1974 # ------- help ------- 

1975 

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

1977 super().add_help_to(formatter) 

1978 

1979 kw: 'SaveKwargs' = {} 

1980 self.config_file.set_save_default_arguments(kw) 

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

1982 self.last_name = None 

1983 

1984 formatter.add_start_section('data types') 

1985 self.add_help_for_data_types(formatter, config_instances) 

1986 formatter.add_end_section() 

1987 

1988 if self.config_file.enable_config_ids: 

1989 normal_configs = [] 

1990 multi_configs = [] 

1991 for instance in config_instances: 

1992 if isinstance(instance, MultiConfig): 

1993 multi_configs.append(instance) 

1994 else: 

1995 normal_configs.append(instance) 

1996 else: 

1997 normal_configs = config_instances 

1998 multi_configs = [] 

1999 

2000 if normal_configs: 

2001 if self.config_file.enable_config_ids: 

2002 formatter.add_start_section('application wide settings') 

2003 else: 

2004 formatter.add_start_section('settings') 

2005 for instance in normal_configs: 

2006 self.add_config_help(formatter, instance) 

2007 formatter.add_end_section() 

2008 

2009 if multi_configs: 

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

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

2012 for instance in multi_configs: 

2013 self.add_config_help(formatter, instance) 

2014 formatter.add_end_section() 

2015 

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

2017 formatter.add_start_section(instance.key) 

2018 formatter.add_text(instance.format_allowed_values_or_type()) 

2019 #if instance.unit: 

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

2021 if isinstance(instance.help, dict): 

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

2023 key_name = instance.format_any_value(key) 

2024 val = inspect.cleandoc(val) 

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

2026 elif isinstance(instance.help, str): 

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

2028 formatter.add_end_section() 

2029 

2030 # ------- auto complete ------- 

2031 

2032 def get_completions(self, cmd: 'Sequence[str]', argument_pos: int, cursor_pos: int, *, in_between: bool, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]': 

2033 if argument_pos >= len(cmd): 

2034 start = '' 

2035 else: 

2036 start = cmd[argument_pos][:cursor_pos] 

2037 

2038 if len(cmd) <= 1: 

2039 return self.get_completions_for_key(start, start_of_line=start_of_line, end_of_line=end_of_line) 

2040 elif self.is_vim_style(cmd): 

2041 return self.get_completions_for_vim_style_arg(cmd, argument_pos, start, start_of_line=start_of_line, end_of_line=end_of_line) 

2042 else: 

2043 return self.get_completions_for_ranger_style_arg(cmd, argument_pos, start, start_of_line=start_of_line, end_of_line=end_of_line) 

2044 

2045 def get_completions_for_vim_style_arg(self, cmd: 'Sequence[str]', argument_pos: int, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]': 

2046 if self.KEY_VAL_SEP in start: 

2047 key, start = start.split(self.KEY_VAL_SEP, 1) 

2048 start_of_line += key + self.KEY_VAL_SEP 

2049 return self.get_completions_for_value(key, start, start_of_line=start_of_line, end_of_line=end_of_line) 

2050 else: 

2051 return self.get_completions_for_key(start, start_of_line=start_of_line, end_of_line=end_of_line) 

2052 

2053 def get_completions_for_ranger_style_arg(self, cmd: 'Sequence[str]', argument_pos: int, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]': 

2054 if argument_pos == 1: 

2055 return self.get_completions_for_key(start, start_of_line=start_of_line, end_of_line=end_of_line) 

2056 elif argument_pos == 2 or (argument_pos == 3 and cmd[2] == self.KEY_VAL_SEP): 

2057 return self.get_completions_for_value(cmd[1], start, start_of_line=start_of_line, end_of_line=end_of_line) 

2058 else: 

2059 return start_of_line, [], end_of_line 

2060 

2061 def get_completions_for_key(self, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]': 

2062 completions = [key for key in self.config_file.config_instances.keys() if key.startswith(start)] 

2063 return start_of_line, completions, end_of_line 

2064 

2065 def get_completions_for_value(self, key: str, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]': 

2066 instance = self.config_file.config_instances.get(key) 

2067 if instance is None: 

2068 return start_of_line, [], end_of_line 

2069 

2070 t: 'None|type[object]' = None 

2071 if instance.type is list: 

2072 first, start = start.rsplit(instance.LIST_SEP) 

2073 start_of_line += first + instance.LIST_SEP 

2074 t = instance.item_type 

2075 

2076 completions = [self.config_file.quote(val) for val in instance.get_stringified_allowed_values(t) if val.startswith(start)] 

2077 return start_of_line, completions, end_of_line 

2078 

2079 

2080class Include(ConfigFileArgparseCommand): 

2081 

2082 ''' 

2083 Load another config file. 

2084 

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

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

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

2088 ''' 

2089 

2090 help_config_id = ''' 

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

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

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

2094 

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

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

2097 ''' 

2098 

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

2100 parser.add_argument('path', help='The config file to load. Slashes are replaced with the directory separator appropriate for the current operating system. If the path contains a space it must be wrapped in single or double quotes.') 

2101 if self.config_file.enable_config_ids: 

2102 assert parser.description is not None 

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

2104 group = parser.add_mutually_exclusive_group() 

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

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

2107 

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

2109 

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

2111 fn_imp = args.path 

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

2113 fn_imp = os.path.expanduser(fn_imp) 

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

2115 fn = self.config_file.context_file_name 

2116 if fn is None: 

2117 fn = self.config_file.get_save_path() 

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

2119 

2120 if fn_imp in self.nested_includes: 

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

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

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

2124 

2125 self.nested_includes.append(fn_imp) 

2126 

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

2128 self.config_file.load_without_resetting_config_id(fn_imp) 

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

2130 config_id = self.config_file.config_id 

2131 self.config_file.load_file(fn_imp) 

2132 self.config_file.config_id = config_id 

2133 else: 

2134 config_id = self.config_file.config_id 

2135 self.config_file.load_without_resetting_config_id(fn_imp) 

2136 self.config_file.config_id = config_id 

2137 

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

2139 del self.nested_includes[-1] 

2140 

2141 def get_completions_for_action(self, action: 'argparse.Action|None', start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]': 

2142 # action does not have a name and metavar is None if not explicitly set, dest is the only way to identify the action 

2143 if action is not None and action.dest == 'path': 

2144 return self.get_completions_for_file_name(start, relative_to=os.path.dirname(self.config_file.get_save_path()), start_of_line=start_of_line, end_of_line=end_of_line) 

2145 return super().get_completions_for_action(action, start, start_of_line=start_of_line, end_of_line=end_of_line) 

2146 

2147 

2148class UnknownCommand(ConfigFileCommand, abstract=True): 

2149 

2150 name = DEFAULT_COMMAND 

2151 

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

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