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

1342 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-07-23 10:38 +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 textwrap 

15import functools 

16import inspect 

17import io 

18import warnings 

19import abc 

20import typing 

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

22 

23import appdirs 

24 

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

26from .formatters import AbstractFormatter 

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

28 

29if typing.TYPE_CHECKING: 

30 from typing_extensions import Unpack 

31 

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

33T2 = typing.TypeVar('T2') 

34 

35 

36#: If the name or an alias of :class:`~confattr.configfile.ConfigFileCommand` is this value that command is used by :meth:`ConfigFile.parse_split_line() <confattr.configfile.ConfigFile.parse_split_line>` if an undefined command is encountered. 

37DEFAULT_COMMAND = '' 

38 

39 

40 

41# ---------- UI notifier ---------- 

42 

43@functools.total_ordering 

44class NotificationLevel: 

45 

46 ''' 

47 Instances of this class indicate how important a message is. 

48 

49 I am not using an enum anymore in order to allow users to add custom levels. 

50 Like an enum, however, ``NotificationLevel('error')`` returns the existing instance instead of creating a new one. 

51 In order to create a new instance use :meth:`~confattr.configfile.NotificationLevel.new`. 

52 ''' 

53 

54 INFO: 'NotificationLevel' 

55 ERROR: 'NotificationLevel' 

56 

57 _instances: 'list[NotificationLevel]' = [] 

58 

59 def __new__(cls, value: str, *, new: bool = False, more_important_than: 'NotificationLevel|None' = None, less_important_than: 'NotificationLevel|None' = None) -> 'NotificationLevel': 

60 ''' 

61 :return: An existing instance (see :meth:`~confattr.configfile.NotificationLevel.get`) or a new instance if :paramref:`~confattr.configfile.NotificationLevel.__new__.new` is true (see :meth:`~confattr.configfile.NotificationLevel.new`) 

62 :param value: The name of the notification level 

63 :param new: If false: return an existing instance with :meth:`~confattr.configfile.NotificationLevel.get`. If true: create a new instance. 

64 :param more_important_than: If :paramref:`~confattr.configfile.NotificationLevel.__new__.new` is true either this or :paramref:`~confattr.configfile.NotificationLevel.__new__.less_important_than` must be given. 

65 :param less_important_than: If :paramref:`~confattr.configfile.NotificationLevel.__new__.new` is true either this or :paramref:`~confattr.configfile.NotificationLevel.__new__.more_important_than` must be given. 

66 ''' 

67 if new: 

68 if more_important_than and less_important_than: 

69 raise TypeError("more_important_than and less_important_than are mutually exclusive, you can only pass one of them") 

70 elif cls._instances and not (more_important_than or less_important_than): 

71 raise TypeError(f"you must specify how important {value!r} is by passing either more_important_than or less_important_than") 

72 

73 try: 

74 out = cls.get(value) 

75 except ValueError: 

76 pass 

77 else: 

78 if more_important_than and out < more_important_than: 

79 raise ValueError(f"{out} is already defined and it's less important than {more_important_than}") 

80 elif less_important_than and out > less_important_than: 

81 raise ValueError(f"{out} is already defined and it's more important than {less_important_than}") 

82 warnings.warn(f"{out!r} is already defined, ignoring", stacklevel=3) 

83 return out 

84 

85 return super().__new__(cls) 

86 

87 if more_important_than: 

88 raise TypeError('more_important_than must not be passed when new = False') 

89 if less_important_than: 

90 raise TypeError('less_important_than must not be passed when new = False') 

91 

92 return cls.get(value) 

93 

94 def __init__(self, value: str, *, new: bool = False, more_important_than: 'NotificationLevel|None' = None, less_important_than: 'NotificationLevel|None' = None) -> None: 

95 if hasattr(self, '_initialized'): 

96 # __init__ is called every time, even if __new__ has returned an old object 

97 return 

98 

99 assert new 

100 self._initialized = True 

101 self.value = value 

102 

103 if more_important_than: 

104 i = self._instances.index(more_important_than) + 1 

105 elif less_important_than: 

106 i = self._instances.index(less_important_than) 

107 elif not self._instances: 

108 i = 0 

109 else: 

110 assert False 

111 

112 self._instances.insert(i, self) 

113 

114 @classmethod 

115 def new(cls, value: str, *, more_important_than: 'NotificationLevel|None' = None, less_important_than: 'NotificationLevel|None' = None) -> 'NotificationLevel': 

116 ''' 

117 :param value: A name for the new notification level 

118 :param more_important_than: Specify the importance of the new notification level. Either this or :paramref:`~confattr.configfile.NotificationLevel.new.less_important_than` must be given but not both. 

119 :param less_important_than: Specify the importance of the new notification level. Either this or :paramref:`~confattr.configfile.NotificationLevel.new.more_important_than` must be given but not both. 

120 ''' 

121 return cls(value, more_important_than=more_important_than, less_important_than=less_important_than, new=True) 

122 

123 @classmethod 

124 def get(cls, value: str) -> 'NotificationLevel': 

125 ''' 

126 :return: The instance of this class for the given value 

127 :raises ValueError: If there is no instance for the given value 

128 ''' 

129 for lvl in cls._instances: 

130 if lvl.value == value: 

131 return lvl 

132 

133 raise ValueError('') 

134 

135 @classmethod 

136 def get_instances(cls) -> 'Sequence[NotificationLevel]': 

137 ''' 

138 :return: A sequence of all instances of this class 

139 ''' 

140 return cls._instances 

141 

142 def __lt__(self, other: typing.Any) -> bool: 

143 if self.__class__ is other.__class__: 

144 return self._instances.index(self) < self._instances.index(other) 

145 return NotImplemented 

146 

147 def __str__(self) -> str: 

148 return self.value 

149 

150 def __repr__(self) -> str: 

151 return "%s(%r)" % (type(self).__name__, self.value) 

152 

153 

154NotificationLevel.INFO = NotificationLevel.new('info') 

155NotificationLevel.ERROR = NotificationLevel.new('error', more_important_than=NotificationLevel.INFO) 

156 

157 

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

159 

160class Message: 

161 

162 ''' 

163 A message which should be displayed to the user. 

164 This is passed to the callback of the user interface which has been registered with :meth:`ConfigFile.set_ui_callback() <confattr.configfile.ConfigFile.set_ui_callback>`. 

165 

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

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

168 I recommend to use different colors for different values of :attr:`~confattr.configfile.Message.notification_level`. 

169 ''' 

170 

171 #: The value of :attr:`~confattr.configfile.Message.file_name` while loading environment variables. 

172 ENVIRONMENT_VARIABLES = 'environment variables' 

173 

174 

175 __slots__ = ('notification_level', 'message', 'file_name', 'line_number', 'line', 'no_context') 

176 

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

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

179 notification_level: NotificationLevel 

180 

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

182 message: 'str|BaseException' 

183 

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

185 #: If this equals :const:`~confattr.configfile.Message.ENVIRONMENT_VARIABLES` it is not a file but the message has occurred while reading the environment variables. 

186 #: This is None if :meth:`ConfigFile.parse_line() <confattr.configfile.ConfigFile.parse_line>` is called directly, e.g. when parsing the input from a command line. 

187 file_name: 'str|None' 

188 

189 #: The number of the line in the config file. This is None if :attr:`~confattr.configfile.Message.file_name` is not a file name. 

190 line_number: 'int|None' 

191 

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

193 line: str 

194 

195 #: If true: don't show line and line number. 

196 no_context: bool 

197 

198 

199 _last_file_name: 'str|None' = None 

200 

201 @classmethod 

202 def reset(cls) -> None: 

203 ''' 

204 If you are using :meth:`~confattr.configfile.Message.format_file_name_msg_line` or :meth:`~confattr.configfile.Message.__str__` 

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

206 ''' 

207 cls._last_file_name = None 

208 

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

210 self.notification_level = notification_level 

211 self.message = message 

212 self.file_name = file_name 

213 self.line_number = line_number 

214 self.line = line 

215 self.no_context = no_context 

216 

217 @property 

218 def lvl(self) -> NotificationLevel: 

219 ''' 

220 An abbreviation for :attr:`~confattr.configfile.Message.notification_level` 

221 ''' 

222 return self.notification_level 

223 

224 def format_msg_line(self) -> str: 

225 ''' 

226 The return value includes the attributes :attr:`~confattr.configfile.Message.message`, :attr:`~confattr.configfile.Message.line_number` and :attr:`~confattr.configfile.Message.line` if they are set. 

227 ''' 

228 msg = str(self.message) 

229 if self.line and not self.no_context: 

230 if self.line_number is not None: 

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

232 else: 

233 lnref = 'line' 

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

235 

236 return msg 

237 

238 def format_file_name(self) -> str: 

239 ''' 

240 :return: A header including the :attr:`~confattr.configfile.Message.file_name` if the :attr:`~confattr.configfile.Message.file_name` is different from the last time this function has been called or an empty string otherwise 

241 ''' 

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

243 if file_name == self._last_file_name: 

244 return '' 

245 

246 if file_name: 

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

248 else: 

249 out = '' 

250 

251 if self._last_file_name is not None: 

252 out = '\n' + out 

253 

254 type(self)._last_file_name = file_name 

255 

256 return out 

257 

258 

259 def format_file_name_msg_line(self) -> str: 

260 ''' 

261 :return: The concatenation of the return values of :meth:`~confattr.configfile.Message.format_file_name` and :meth:`~confattr.configfile.Message.format_msg_line` 

262 ''' 

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

264 

265 

266 def __str__(self) -> str: 

267 ''' 

268 :return: The return value of :meth:`~confattr.configfile.Message.format_file_name_msg_line` 

269 ''' 

270 return self.format_file_name_msg_line() 

271 

272 def __repr__(self) -> str: 

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

274 

275 @staticmethod 

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

277 return repr(obj) 

278 

279 

280class UiNotifier: 

281 

282 ''' 

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

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

285 This class takes the messages from :class:`~confattr.configfile.ConfigFile` and stores them until the UI is ready. 

286 When you call :meth:`~confattr.configfile.UiNotifier.set_ui_callback` the stored messages will be forwarded and cleared. 

287 

288 This object can also filter the messages. 

289 :class:`~confattr.configfile.ConfigFile` calls :meth:`~confattr.configfile.UiNotifier.show_info` every time a setting is changed. 

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

291 Therefore this object drops all messages of :const:`NotificationLevel.INFO <confattr.configfile.NotificationLevel.INFO>` by default. 

292 Pass :paramref:`~confattr.configfile.UiNotifier.notification_level` to the constructor if you don't want that. 

293 ''' 

294 

295 # ------- public methods ------- 

296 

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

298 ''' 

299 :param config_file: Is used to add context information to messages, to which file and to which line a message belongs. 

300 :param notification_level: Messages which are less important than this notification level will be ignored. I recommend to pass a :class:`~confattr.config.Config` instance so that users can decide themselves what they want to see. 

301 ''' 

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

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

304 self._notification_level = notification_level 

305 self._config_file = config_file 

306 

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

308 ''' 

309 Call :paramref:`~confattr.configfile.UiNotifier.set_ui_callback.callback` for all messages which have been saved by :meth:`~confattr.configfile.UiNotifier.show` and clear all saved messages afterwards. 

310 Save :paramref:`~confattr.configfile.UiNotifier.set_ui_callback.callback` for :meth:`~confattr.configfile.UiNotifier.show` to call. 

311 ''' 

312 self._callback = callback 

313 

314 for msg in self._messages: 

315 callback(msg) 

316 self._messages.clear() 

317 

318 

319 @property 

320 def notification_level(self) -> NotificationLevel: 

321 ''' 

322 Ignore messages that are less important than this level. 

323 ''' 

324 if isinstance(self._notification_level, Config): 

325 return self._notification_level.value 

326 else: 

327 return self._notification_level 

328 

329 @notification_level.setter 

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

331 if isinstance(self._notification_level, Config): 

332 self._notification_level.value = val 

333 else: 

334 self._notification_level = val 

335 

336 

337 # ------- called by ConfigFile ------- 

338 

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

340 ''' 

341 Call :meth:`~confattr.configfile.UiNotifier.show` with :const:`NotificationLevel.INFO <confattr.configfile.NotificationLevel.INFO>`. 

342 ''' 

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

344 

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

346 ''' 

347 Call :meth:`~confattr.configfile.UiNotifier.show` with :const:`NotificationLevel.ERROR <confattr.configfile.NotificationLevel.ERROR>`. 

348 ''' 

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

350 

351 

352 # ------- internal methods ------- 

353 

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

355 ''' 

356 If a callback for the user interface has been registered with :meth:`~confattr.configfile.UiNotifier.set_ui_callback` call that callback. 

357 Otherwise save the message so that :meth:`~confattr.configfile.UiNotifier.set_ui_callback` can forward the message when :meth:`~confattr.configfile.UiNotifier.set_ui_callback` is called. 

358 

359 :param notification_level: The importance of the message 

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

361 :param ignore_filter: If true: Show the message even if :paramref:`~confattr.configfile.UiNotifier.show.notification_level` is smaller then the :paramref:`UiNotifier.notification_level <confattr.configfile.UiNotifier.notification_level>`. 

362 :param no_context: If true: don't show line and line number. 

363 ''' 

364 if notification_level < self.notification_level and not ignore_filter: 

365 return 

366 

367 if self._config_file and not self._config_file.context_line_number and not self._config_file.show_line_always: 

368 no_context = True 

369 

370 message = Message( 

371 notification_level = notification_level, 

372 message = msg, 

373 file_name = self._config_file.context_file_name if self._config_file else None, 

374 line_number = self._config_file.context_line_number if self._config_file else None, 

375 line = self._config_file.context_line if self._config_file else '', 

376 no_context = no_context, 

377 ) 

378 

379 if self._callback: 

380 self._callback(message) 

381 else: 

382 self._messages.append(message) 

383 

384 

385# ---------- format help ---------- 

386 

387class SectionLevel(SortedEnum): 

388 

389 #: Is used to separate different commands in :meth:`ConfigFile.write_help() <confattr.configfile.ConfigFile.write_help>` and :meth:`ConfigFileCommand.save() <confattr.configfile.ConfigFileCommand.save>` 

390 SECTION = 'section' 

391 

392 #: Is used for subsections in :meth:`ConfigFileCommand.save() <confattr.configfile.ConfigFileCommand.save>` such as the "data types" section in the help of the set command 

393 SUB_SECTION = 'sub-section' 

394 

395 

396class FormattedWriter(abc.ABC): 

397 

398 @abc.abstractmethod 

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

400 ''' 

401 Write a single line of documentation. 

402 :paramref:`~confattr.configfile.FormattedWriter.write_line.line` may *not* contain a newline. 

403 If :paramref:`~confattr.configfile.FormattedWriter.write_line.line` is empty it does not need to be prefixed with a comment character. 

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

405 ''' 

406 pass 

407 

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

409 ''' 

410 Write one or more lines of documentation. 

411 ''' 

412 for ln in text.splitlines(): 

413 self.write_line(ln) 

414 

415 @abc.abstractmethod 

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

417 ''' 

418 Write a heading. 

419 

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

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

422 in order to keep the line wrapping consistent. 

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

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

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

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

427 to understand the help generated by argparse 

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

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

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

431 

432 :param lvl: How to format the heading 

433 :param heading: The heading 

434 ''' 

435 pass 

436 

437 @abc.abstractmethod 

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

439 ''' 

440 Write a config file command. 

441 ''' 

442 pass 

443 

444 

445class TextIOWriter(FormattedWriter): 

446 

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

448 self.f = f 

449 self.ignore_empty_lines = True 

450 

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

452 if self.ignore_empty_lines and not line: 

453 return 

454 

455 print(line, file=self.f) 

456 self.ignore_empty_lines = False 

457 

458 

459class ConfigFileWriter(TextIOWriter): 

460 

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

462 super().__init__(f) 

463 self.prefix = prefix 

464 

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

466 self.write_line_raw(cmd) 

467 

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

469 if line: 

470 line = self.prefix + line 

471 

472 self.write_line_raw(line) 

473 

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

475 if lvl is SectionLevel.SECTION: 

476 self.write_line('') 

477 self.write_line('') 

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

479 self.write_line(heading) 

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

481 else: 

482 self.write_line('') 

483 self.write_line(heading) 

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

485 

486class HelpWriter(TextIOWriter): 

487 

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

489 self.write_line_raw(line) 

490 

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

492 self.write_line('') 

493 if lvl is SectionLevel.SECTION: 

494 self.write_line(heading) 

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

496 else: 

497 self.write_line(heading) 

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

499 

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

501 pass # pragma: no cover 

502 

503 

504# ---------- internal exceptions ---------- 

505 

506class ParseException(Exception): 

507 

508 ''' 

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

510 Is caught in :class:`~confattr.configfile.ConfigFile`. 

511 ''' 

512 

513class MultipleParseExceptions(Exception): 

514 

515 ''' 

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

517 Is caught in :class:`~confattr.configfile.ConfigFile`. 

518 ''' 

519 

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

521 super().__init__() 

522 self.exceptions = exceptions 

523 

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

525 return iter(self.exceptions) 

526 

527 

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

529 

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

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

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

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

534 no_multi: bool 

535 comments: bool 

536 commands: 'Sequence[type[ConfigFileCommand]|abc.ABCMeta]' 

537 ignore_commands: 'Sequence[type[ConfigFileCommand]|abc.ABCMeta]' 

538 

539 

540# ---------- ConfigFile class ---------- 

541 

542class ArgPos: 

543 ''' 

544 This is an internal class, the return type of :meth:`ConfigFile.find_arg() <confattr.configfile.ConfigFile.find_arg>` 

545 ''' 

546 

547 #: The index of the argument in :paramref:`~confattr.configfile.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:`~confattr.configfile.ConfigFile.find_arg.ln_split` is long if the line ends on a space or a comment and the cursor is behind/in that space/comment. In that case :attr:`~confattr.configfile.ArgPos.in_between` is true. 

548 argument_pos: int 

549 

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

551 in_between: bool 

552 

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

554 i0: int 

555 

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

557 i1: int 

558 

559 

560class ConfigFile: 

561 

562 ''' 

563 Read or write a config file. 

564 ''' 

565 

566 COMMENT = '#' 

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

568 ENTER_GROUP_PREFIX = '[' 

569 ENTER_GROUP_SUFFIX = ']' 

570 

571 #: How to separete several element in a collection (list, set, dict) 

572 ITEM_SEP = ',' 

573 

574 #: How to separate key and value in a dict 

575 KEY_SEP = ':' 

576 

577 

578 #: The :class:`~confattr.config.Config` instances to load or save 

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

580 

581 #: 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:`~confattr.configfile.ConfigFile.enter_group` and reset in :meth:`~confattr.configfile.ConfigFile.load_file`. 

582 config_id: 'ConfigId|None' 

583 

584 #: Override the config file which is returned by :meth:`~confattr.configfile.ConfigFile.iter_config_paths`. 

585 #: You should set either this attribute or :attr:`~confattr.configfile.ConfigFile.config_directory` in your tests with :meth:`monkeypatch.setattr() <pytest.MonkeyPatch.setattr>`. 

586 #: 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:`~confattr.configfile.ConfigFile.appname` to the constructor but in all upper case letters and hyphens and spaces replaced by underscores.) 

587 config_path: 'str|None' = None 

588 

589 #: Override the config directory which is returned by :meth:`~confattr.configfile.ConfigFile.iter_user_site_config_paths`. 

590 #: You should set either this attribute or :attr:`~confattr.configfile.ConfigFile.config_path` in your tests with :meth:`monkeypatch.setattr() <pytest.MonkeyPatch.setattr>`. 

591 #: 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:`~confattr.configfile.ConfigFile.appname` to the constructor but in all upper case letters and hyphens and spaces replaced by underscores.) 

592 config_directory: 'str|None' = None 

593 

594 #: The name of the config file used by :meth:`~confattr.configfile.ConfigFile.iter_config_paths`. 

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

596 config_name = 'config' 

597 

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

599 env_variables: 'list[str]' 

600 

601 #: A prefix that is prepended to the name of environment variables in :meth:`~confattr.configfile.ConfigFile.get_env_name`. 

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

603 envprefix: str 

604 

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

606 context_file_name: 'str|None' = None 

607 #: The number of the line which is currently parsed. This is :obj:`None` if :attr:`~confattr.configfile.ConfigFile.context_file_name` is not a file name. 

608 context_line_number: 'int|None' = None 

609 #: The line which is currently parsed. 

610 context_line: str = '' 

611 

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

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

614 enable_config_ids: bool 

615 

616 

617 #: A mapping from the name to the object for all commands that are available in this config file. If a command has :attr:`~confattr.configfile.ConfigFileCommand.aliases` every alias appears in this mapping, too. Use :attr:`~confattr.configfile.ConfigFile.commands` instead if you want to iterate over all available commands. This is generated in the constructor based on :paramref:`~confattr.configfile.ConfigFile.commands` if it is given or based on the return value of :meth:`ConfigFileCommand.get_command_types() <confattr.configfile.ConfigFileCommand.get_command_types>` otherwise. Note that you are passing a sequence of *types* as argument but this attribute contains the instantiated *objects*. 

618 command_dict: 'dict[str, ConfigFileCommand]' 

619 

620 #: A list of all commands that are available in this config file. This is generated in the constructor based on :paramref:`~confattr.configfile.ConfigFile.commands` if it is given or based on the return value of :meth:`ConfigFileCommand.get_command_types() <confattr.configfile.ConfigFileCommand.get_command_types>` otherwise. Note that you are passing a sequence of *types* as argument but this attribute contains the instantiated *objects*. In contrast to :attr:`~confattr.configfile.ConfigFile.command_dict` this list contains every command only once. 

621 commands: 'list[ConfigFileCommand]' 

622 

623 

624 #: See :paramref:`~confattr.configfile.ConfigFile.check_config_id` 

625 check_config_id: 'Callable[[ConfigId], None]|None' 

626 

627 #: If this is true :meth:`ui_notifier.show() <confattr.configfile.UiNotifier.show>` concatenates :attr:`~confattr.configfile.ConfigFile.context_line` to the message even if :attr:`~confattr.configfile.ConfigFile.context_line_number` is not set. 

628 show_line_always: bool 

629 

630 

631 def __init__(self, *, 

632 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 

633 appname: str, 

634 authorname: 'str|None' = None, 

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

636 commands: 'Iterable[type[ConfigFileCommand]|abc.ABCMeta]|None' = None, 

637 ignore_commands: 'Sequence[type[ConfigFileCommand]|abc.ABCMeta]|None' = None, 

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

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

640 enable_config_ids: 'bool|None' = None, 

641 show_line_always: bool = True, 

642 ) -> None: 

643 ''' 

644 :param notification_level: A :class:`~confattr.config.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:`~confattr.configfile.Message` with a priority lower than this value is *not* passed to the callback registered with :meth:`~confattr.configfile.ConfigFile.set_ui_callback`. 

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

646 :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:`~confattr.configfile.ConfigFile.load` or :meth:`~confattr.configfile.ConfigFile.save` 

647 :param config_instances: The Config instances to load or save, defaults to :attr:`Config.instances <confattr.config.Config.instances>` 

648 :param commands: The commands (as subclasses of :class:`~confattr.configfile.ConfigFileCommand` or :class:`~confattr.configfile.ConfigFileArgparseCommand`) allowed in this config file, if this is :obj:`None`: use the return value of :meth:`ConfigFileCommand.get_command_types() <confattr.configfile.ConfigFileCommand.get_command_types>`. Abstract classes are expanded to all non-abstract subclasses. 

649 :param ignore_commands: A sequence of commands (as subclasses of :class:`~confattr.configfile.ConfigFileCommand` or :class:`~confattr.configfile.ConfigFileArgparseCommand`) which are *not* allowed in this config file. May contain abstract classes. All commands which are contained in this sequence or which are a subclass of an item in this sequence are not allowed, regardless of whether they are passed to :paramref:`~confattr.configfile.ConfigFile.commands` or not. 

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

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

652 :param enable_config_ids: see :attr:`~confattr.configfile.ConfigFile.enable_config_ids`. If None: Choose True or False automatically based on :paramref:`~confattr.configfile.ConfigFile.check_config_id` and the existence of :class:`~confattr.config.MultiConfig`/:class:`~confattr.config.MultiDictConfig` 

653 :param show_line_always: If false: when calling :meth:`UiNotifier.show() <confattr.configfile.UiNotifier.show>` :attr:`~confattr.configfile.ConfigFile.context_line` and :attr:`~confattr.configfile.ConfigFile.context_line_number` are concatenated to the message if both are set. If :attr:`~confattr.configfile.ConfigFile.context_line_number` is not set it is assumed that the line comes from a command line interface where the user just entered it and it is still visible so there is no need to print it again. If :paramref:`~confattr.configfile.ConfigFile.show_line_always` is true (the default) :attr:`~confattr.configfile.ConfigFile.context_line` is concatenated even if :attr:`~confattr.configfile.ConfigFile.context_line_number` is not set. That is useful when you use :meth:`~confattr.configfile.ConfigFile.parse_line` to parse a command which has been assigned to a keyboard shortcut. 

654 ''' 

655 self.appname = appname 

656 self.authorname = authorname 

657 self.ui_notifier = UiNotifier(self, notification_level) 

658 self.config_instances = config_instances 

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

660 self.formatter_class = formatter_class 

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

662 self.check_config_id = check_config_id 

663 self.show_line_always = show_line_always 

664 

665 if enable_config_ids is None: 

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

667 self.enable_config_ids = enable_config_ids 

668 

669 self.envprefix = '' 

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

671 envname = self.envprefix + 'CONFIG_PATH' 

672 self.env_variables.append(envname) 

673 if envname in os.environ: 

674 self.config_path = os.environ[envname] 

675 envname = self.envprefix + 'CONFIG_DIRECTORY' 

676 self.env_variables.append(envname) 

677 if envname in os.environ: 

678 self.config_directory = os.environ[envname] 

679 envname = self.envprefix + 'CONFIG_NAME' 

680 self.env_variables.append(envname) 

681 if envname in os.environ: 

682 self.config_name = os.environ[envname] 

683 

684 if commands is None: 

685 commands = ConfigFileCommand.get_command_types() 

686 else: 

687 original_commands = commands 

688 def iter_commands() -> 'Iterator[type[ConfigFileCommand]]': 

689 for cmd in original_commands: 

690 cmd = typing.cast('type[ConfigFileCommand]', cmd) 

691 if cmd._abstract: 

692 for c in ConfigFileCommand.get_command_types(): 

693 if issubclass(c, cmd): 

694 yield c 

695 else: 

696 yield cmd 

697 commands = iter_commands() 

698 self.command_dict = {} 

699 self.commands = [] 

700 for cmd_type in commands: 

701 if ignore_commands and any(issubclass(cmd_type, i_c) for i_c in ignore_commands): 

702 continue 

703 cmd = cmd_type(self) 

704 self.commands.append(cmd) 

705 for name in cmd.get_names(): 

706 self.command_dict[name] = cmd 

707 

708 

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

710 ''' 

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

712 

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

714 

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

716 ''' 

717 self.ui_notifier.set_ui_callback(callback) 

718 

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

720 ''' 

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

722 

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

724 The first one installed is used. 

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

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

727 

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

729 ''' 

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

731 try: 

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

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

734 except ImportError: 

735 try: 

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

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

738 except ImportError: 

739 AppDirs = appdirs.AppDirs 

740 

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

742 

743 return self._appdirs 

744 

745 # ------- load ------- 

746 

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

748 ''' 

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

750 

751 The directories are based on :meth:`~confattr.configfile.ConfigFile.get_app_dirs` 

752 unless :attr:`~confattr.configfile.ConfigFile.config_directory` has been set. 

753 If :attr:`~confattr.configfile.ConfigFile.config_directory` has been set 

754 it's value is yielded and nothing else. 

755 ''' 

756 if self.config_directory: 

757 yield self.config_directory 

758 return 

759 

760 appdirs = self.get_app_dirs() 

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

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

763 

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

765 ''' 

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

767 

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

769 The first existing file yielded by this method is used by :meth:`~confattr.configfile.ConfigFile.load`. 

770 

771 The paths are generated by joining the directories yielded by :meth:`~confattr.configfile.ConfigFile.iter_user_site_config_paths` with 

772 :attr:`ConfigFile.config_name <confattr.configfile.ConfigFile.config_name>`. 

773 

774 If :attr:`~confattr.configfile.ConfigFile.config_path` has been set this method yields that path instead and no other paths. 

775 ''' 

776 if self.config_path: 

777 yield self.config_path 

778 return 

779 

780 for path in self.iter_user_site_config_paths(): 

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

782 

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

784 ''' 

785 Load the first existing config file returned by :meth:`~confattr.configfile.ConfigFile.iter_config_paths`. 

786 

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

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

789 :param env: If true: call :meth:`~confattr.configfile.ConfigFile.load_env` after loading the config file. 

790 ''' 

791 for fn in self.iter_config_paths(): 

792 if os.path.isfile(fn): 

793 self.load_file(fn) 

794 break 

795 

796 if env: 

797 self.load_env() 

798 

799 def load_env(self) -> None: 

800 ''' 

801 Load settings from environment variables. 

802 The name of the environment variable belonging to a setting is generated with :meth:`~confattr.configfile.ConfigFile.get_env_name`. 

803 

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

805 

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

807 ''' 

808 old_file_name = self.context_file_name 

809 self.context_file_name = Message.ENVIRONMENT_VARIABLES 

810 

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

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

813 name = self.get_env_name(key) 

814 if name in self.env_variables: 

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

816 elif name in config_instances: 

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

818 else: 

819 config_instances[name] = instance 

820 

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

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

823 continue 

824 if name in self.env_variables: 

825 continue 

826 

827 if name in config_instances: 

828 instance = config_instances[name] 

829 try: 

830 instance.set_value(config_id=None, value=self.parse_value(instance, value, raw=True)) 

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

832 except ValueError as e: 

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

834 else: 

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

836 

837 self.context_file_name = old_file_name 

838 

839 

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

841 ''' 

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

843 

844 :return: An all upper case version of :paramref:`~confattr.configfile.ConfigFile.get_env_name.key` with all hyphens, dots and spaces replaced by underscores and :attr:`~confattr.configfile.ConfigFile.envprefix` prepended to the result. 

845 ''' 

846 out = key 

847 out = out.upper() 

848 for c in ' .-': 

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

850 out = self.envprefix + out 

851 return out 

852 

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

854 ''' 

855 Load a config file and change the :class:`~confattr.config.Config` objects accordingly. 

856 

857 Use :meth:`~confattr.configfile.ConfigFile.set_ui_callback` to get error messages which appeared while loading the config file. 

858 You can call :meth:`~confattr.configfile.ConfigFile.set_ui_callback` after this method without loosing any messages. 

859 

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

861 ''' 

862 self.config_id = None 

863 self.load_without_resetting_config_id(fn) 

864 

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

866 old_file_name = self.context_file_name 

867 self.context_file_name = fn 

868 

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

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

871 self.context_line_number = lnno 

872 self.parse_line(line=ln) 

873 self.context_line_number = None 

874 

875 self.context_file_name = old_file_name 

876 

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

878 ''' 

879 :param line: The line to be parsed 

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

881 

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

883 ''' 

884 ln = line.strip() 

885 if not ln: 

886 return True 

887 if self.is_comment(ln): 

888 return True 

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

890 return True 

891 

892 self.context_line = ln 

893 

894 try: 

895 ln_split = self.split_line(ln) 

896 except Exception as e: 

897 self.parse_error(str(e)) 

898 out = False 

899 else: 

900 out = self.parse_split_line(ln_split) 

901 

902 self.context_line = '' 

903 return out 

904 

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

906 cmd, line = self.split_one_symbol_command(line) 

907 line_split = shlex.split(line, comments=True) 

908 if cmd: 

909 line_split.insert(0, cmd) 

910 return line_split 

911 

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

913 out = [] 

914 cmd, line = self.split_one_symbol_command(line) 

915 if cmd: 

916 out.append(cmd) 

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

918 lex.whitespace_split = True 

919 while True: 

920 try: 

921 t = lex.get_token() 

922 except: 

923 out.append(lex.token) 

924 return out 

925 if t is None: 

926 return out 

927 out.append(t) 

928 

929 def split_one_symbol_command(self, line: str) -> 'tuple[str|None, str]': 

930 if line and not line[0].isalnum() and line[0] in self.command_dict: 

931 return line[0], line[1:] 

932 

933 return None, line 

934 

935 

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

937 ''' 

938 Check if :paramref:`~confattr.configfile.ConfigFile.is_comment.line` is a comment. 

939 

940 :param line: The current line 

941 :return: :obj:`True` if :paramref:`~confattr.configfile.ConfigFile.is_comment.line` is a comment 

942 ''' 

943 for c in self.COMMENT_PREFIXES: 

944 if line.startswith(c): 

945 return True 

946 return False 

947 

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

949 ''' 

950 Check if :paramref:`~confattr.configfile.ConfigFile.enter_group.line` starts a new group and set :attr:`~confattr.configfile.ConfigFile.config_id` if it does. 

951 Call :meth:`~confattr.configfile.ConfigFile.parse_error` if :meth:`~confattr.configfile.ConfigFile.check_config_id` raises a :class:`~confattr.configfile.ParseException`. 

952 

953 :param line: The current line 

954 :return: :obj:`True` if :paramref:`~confattr.configfile.ConfigFile.enter_group.line` starts a new group 

955 ''' 

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

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

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

959 try: 

960 self.check_config_id(config_id) 

961 except ParseException as e: 

962 self.parse_error(str(e)) 

963 self.config_id = config_id 

964 if self.config_id not in MultiConfig.config_ids: 

965 MultiConfig.config_ids.append(self.config_id) 

966 return True 

967 return False 

968 

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

970 ''' 

971 Call the corresponding command in :attr:`~confattr.configfile.ConfigFile.command_dict`. 

972 If any :class:`~confattr.configfile.ParseException` or :class:`~confattr.configfile.MultipleParseExceptions` is raised catch it and call :meth:`~confattr.configfile.ConfigFile.parse_error`. 

973 

974 :return: False if a :class:`~confattr.configfile.ParseException` or :class:`~confattr.configfile.MultipleParseExceptions` has been caught, True if no exception has been caught 

975 ''' 

976 cmd = self.get_command(ln_split) 

977 try: 

978 cmd.run(ln_split) 

979 except ParseException as e: 

980 self.parse_error(str(e)) 

981 return False 

982 except MultipleParseExceptions as exceptions: 

983 for exc in exceptions: 

984 self.parse_error(str(exc)) 

985 return False 

986 

987 return True 

988 

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

990 cmd_name = ln_split[0] 

991 if cmd_name in self.command_dict: 

992 cmd = self.command_dict[cmd_name] 

993 elif DEFAULT_COMMAND in self.command_dict: 

994 cmd = self.command_dict[DEFAULT_COMMAND] 

995 else: 

996 cmd = UnknownCommand(self) 

997 return cmd 

998 

999 

1000 # ------- save ------- 

1001 

1002 def get_save_path(self) -> str: 

1003 ''' 

1004 :return: The first existing and writable file returned by :meth:`~confattr.configfile.ConfigFile.iter_config_paths` or the first path if none of the files are existing and writable. 

1005 ''' 

1006 paths = tuple(self.iter_config_paths()) 

1007 for fn in paths: 

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

1009 return fn 

1010 

1011 return paths[0] 

1012 

1013 def save(self, 

1014 if_not_existing: bool = False, 

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

1016 ) -> str: 

1017 ''' 

1018 Save the current values of all settings to the file returned by :meth:`~confattr.configfile.ConfigFile.get_save_path`. 

1019 Directories are created as necessary. 

1020 

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

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

1023 :param no_multi: Do not write several sections. For :class:`~confattr.config.MultiConfig` instances write the default values only. 

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

1025 :param if_not_existing: Do not overwrite the file if it is already existing. 

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

1027 ''' 

1028 fn = self.get_save_path() 

1029 if if_not_existing and os.path.isfile(fn): 

1030 return fn 

1031 

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

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

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

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

1036 self.save_file(fn, **kw) 

1037 return fn 

1038 

1039 def save_file(self, 

1040 fn: str, 

1041 **kw: 'Unpack[SaveKwargs]' 

1042 ) -> None: 

1043 ''' 

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

1045 

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

1047 :raises FileNotFoundError: if the directory does not exist 

1048 

1049 For an explanation of the other parameters see :meth:`~confattr.configfile.ConfigFile.save`. 

1050 ''' 

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

1052 self.save_to_open_file(f, **kw) 

1053 

1054 

1055 def save_to_open_file(self, 

1056 f: typing.TextIO, 

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

1058 ) -> None: 

1059 ''' 

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

1061 by creating a :class:`~confattr.configfile.ConfigFileWriter` object and calling :meth:`~confattr.configfile.ConfigFile.save_to_writer`. 

1062 

1063 :param f: The file to write to 

1064 

1065 For an explanation of the other parameters see :meth:`~confattr.configfile.ConfigFile.save`. 

1066 ''' 

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

1068 self.save_to_writer(writer, **kw) 

1069 

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

1071 ''' 

1072 Save the current values of all settings. 

1073 

1074 Ensure that all keyword arguments are passed with :meth:`~confattr.configfile.ConfigFile.set_save_default_arguments`. 

1075 Iterate over all :class:`~confattr.configfile.ConfigFileCommand` objects in :attr:`~confattr.configfile.ConfigFile.commands` and do for each of them: 

1076 

1077 - set :attr:`~confattr.configfile.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 

1078 - call :meth:`~confattr.configfile.ConfigFileCommand.save` 

1079 ''' 

1080 self.set_save_default_arguments(kw) 

1081 commands = list(self.commands) 

1082 if 'commands' in kw or 'ignore_commands' in kw: 

1083 command_types = tuple(kw['commands']) if 'commands' in kw else None 

1084 ignore_command_types = tuple(kw['ignore_commands']) if 'ignore_commands' in kw else None 

1085 for cmd in tuple(commands): 

1086 if (ignore_command_types and isinstance(cmd, ignore_command_types)) \ 

1087 or (command_types and not isinstance(cmd, command_types)): 

1088 commands.remove(cmd) 

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

1090 for cmd in commands: 

1091 cmd.should_write_heading = write_headings 

1092 cmd.save(writer, **kw) 

1093 

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

1095 ''' 

1096 Ensure that all arguments are given in :paramref:`~confattr.configfile.ConfigFile.set_save_default_arguments.kw`. 

1097 ''' 

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

1099 kw.setdefault('ignore', None) 

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

1101 kw.setdefault('comments', True) 

1102 

1103 

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

1105 ''' 

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

1107 

1108 The default implementation calls :func:`~confattr.utils.readable_quote`. 

1109 ''' 

1110 return readable_quote(val) 

1111 

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

1113 ''' 

1114 Start a new group in the config file so that all following commands refer to the given :paramref:`~confattr.configfile.ConfigFile.write_config_id.config_id`. 

1115 ''' 

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

1117 

1118 def get_help_config_id(self) -> str: 

1119 ''' 

1120 :return: A help how to use :class:`~confattr.config.MultiConfig`. The return value still needs to be cleaned with :func:`inspect.cleandoc`. 

1121 ''' 

1122 return f''' 

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

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

1125 ''' 

1126 

1127 

1128 # ------- formatting and parsing of values ------- 

1129 

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

1131 ''' 

1132 :param instance: The config value to be saved 

1133 :param config_id: Which value to be written in case of a :class:`~confattr.config.MultiConfig`, should be :obj:`None` for a normal :class:`~confattr.config.Config` instance 

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

1135 

1136 Convert the value of the :class:`~confattr.config.Config` instance into a str with :meth:`~confattr.configfile.ConfigFile.format_any_value`. 

1137 ''' 

1138 return self.format_any_value(instance.type, instance.get_value(config_id)) 

1139 

1140 def format_any_value(self, type: 'AbstractFormatter[T2]', value: 'T2') -> str: 

1141 return type.format_value(self, value) 

1142 

1143 

1144 def parse_value(self, instance: 'Config[T2]', value: str, *, raw: bool) -> 'T2': 

1145 ''' 

1146 :param instance: The config instance for which the value should be parsed, this is important for the data type 

1147 :param value: The string representation of the value to be parsed 

1148 :param raw: if false: expand :paramref:`~confattr.configfile.ConfigFile.parse_value.value` with :meth:`~confattr.configfile.ConfigFile.expand` first, if true: parse :paramref:`~confattr.configfile.ConfigFile.parse_value.value` as it is 

1149 Parse a value to the data type of a given setting by calling :meth:`~confattr.configfile.ConfigFile.parse_value_part` 

1150 ''' 

1151 if not raw: 

1152 value = self.expand(value) 

1153 return self.parse_value_part(instance, instance.type, value) 

1154 

1155 def parse_value_part(self, config: 'Config[typing.Any]', t: 'AbstractFormatter[T2]', value: str) -> 'T2': 

1156 ''' 

1157 Parse a value to the given data type. 

1158 

1159 :param config: Needed for the allowed values and the key for error messages 

1160 :param t: The data type to which :paramref:`~confattr.configfile.ConfigFile.parse_value_part.value` shall be parsed 

1161 :param value: The value to be parsed 

1162 :raises ValueError: if :paramref:`~confattr.configfile.ConfigFile.parse_value_part.value` is invalid 

1163 ''' 

1164 return t.parse_value(self, value) 

1165 

1166 

1167 def expand(self, arg: str) -> str: 

1168 return self.expand_config(self.expand_env(arg)) 

1169 

1170 reo_config = re.compile(r'%([^%]*)%') 

1171 def expand_config(self, arg: str) -> str: 

1172 n = arg.count('%') 

1173 if n % 2 == 1: 

1174 raise ParseException("uneven number of percent characters, use %% for a literal percent sign or --raw if you don't want expansion") 

1175 return self.reo_config.sub(self.expand_config_match, arg) 

1176 

1177 reo_env = re.compile(r'\$\{([^{}]*)\}') 

1178 def expand_env(self, arg: str) -> str: 

1179 return self.reo_env.sub(self.expand_env_match, arg) 

1180 

1181 def expand_config_match(self, m: 're.Match[str]') -> str: 

1182 ''' 

1183 :param m: A match of :attr:`~confattr.configfile.ConfigFile.reo_config`, group 1 is the :attr:`Config.key <confattr.config.Config.key>` possibly including a ``!conversion`` or a ``:format_spec`` 

1184 :return: The expanded form of the setting or ``'%'`` if group 1 is empty 

1185 :raises ParseException: If ``key``, ``!conversion`` or ``:format_spec`` is invalid 

1186 

1187 This is based on the `Python Format String Syntax <https://docs.python.org/3/library/string.html#format-string-syntax>`__. 

1188 

1189 ``field_name`` is the :attr:`~confattr.config.Config.key`. 

1190 

1191 ``!conversion`` is one of: 

1192 

1193 - ``!``: :meth:`ConfigFile.format_value() <confattr.configfile.ConfigFile.format_value>` 

1194 - ``!r``: :func:`repr` 

1195 - ``!s``: :class:`str` 

1196 - ``!a``: :func:`ascii` 

1197 

1198 ``:format_spec`` depends on the :attr:`Config.type <confattr.config.Config.type>`, see the `Python Format Specification Mini-Language <https://docs.python.org/3/library/string.html#formatspec>`__. 

1199 :meth:`List() <confattr.formatters.List.expand_value>`, :meth:`Set() <confattr.formatters.Set.expand_value>` and :meth:`Dict() <confattr.formatters.Dict.expand_value>` implement :meth:`~confattr.formatters.AbstractFormatter.expand_value` so that you can access specific items. 

1200 If :meth:`~confattr.formatters.AbstractFormatter.expand_value` raises an :class:`Exception` it is caught and reraised as a :class:`~confattr.configfile.ParseException`. 

1201 ''' 

1202 key = m.group(1) 

1203 if not key: 

1204 return '%' 

1205 

1206 if ':' in key: 

1207 key, fmt = key.split(':', 1) 

1208 else: 

1209 fmt = None 

1210 if '!' in key: 

1211 key, stringifier = key.split('!', 1) 

1212 else: 

1213 stringifier = None 

1214 

1215 if key not in self.config_instances: 

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

1217 instance = self.config_instances[key] 

1218 

1219 if stringifier is None and fmt is None: 

1220 return self.format_value(instance, config_id=None) 

1221 elif stringifier is None: 

1222 assert fmt is not None 

1223 try: 

1224 return instance.type.expand_value(self, instance.get_value(config_id=None), format_spec=fmt) 

1225 except Exception as e: 

1226 raise ParseException(e) 

1227 

1228 val: object 

1229 if stringifier == '': 

1230 val = self.format_value(instance, config_id=None) 

1231 else: 

1232 val = instance.get_value(config_id=None) 

1233 if stringifier == 'r': 

1234 val = repr(val) 

1235 elif stringifier == 's': 

1236 val = str(val) 

1237 elif stringifier == 'a': 

1238 val = ascii(val) 

1239 else: 

1240 raise ParseException('invalid conversion %r' % stringifier) 

1241 

1242 if fmt is None: 

1243 assert isinstance(val, str) 

1244 return val 

1245 

1246 try: 

1247 return format(val, fmt) 

1248 except ValueError as e: 

1249 raise ParseException(e) 

1250 

1251 def expand_env_match(self, m: 're.Match[str]') -> str: 

1252 ''' 

1253 :param m: A match of :attr:`~confattr.configfile.ConfigFile.reo_env`, group 1 is the name of the environment variable possibly including one of the following expansion features 

1254 :return: The expanded form of the environment variable 

1255 

1256 Supported are the following `parameter expansion features as defined by POSIX <https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_06_02>`__, except that word is not expanded: 

1257 

1258 - ``${parameter:-word}``/``${parameter-word}``: Use Default Values. If parameter is unset (or empty), word shall be substituted; otherwise, the value of parameter shall be substituted. 

1259 - ``${parameter:=word}``/``${parameter=word}``: Assign Default Values. If parameter is unset (or empty), word shall be assigned to parameter. In all cases, the final value of parameter shall be substituted. 

1260 - ``${parameter:?[word]}``/``${parameter?[word]}``: Indicate Error If Unset (or Empty). If parameter is unset (or empty), a :class:`~confattr.configfile.ParseException` shall be raised with word as message or a default error message if word is omitted. Otherwise, the value of parameter shall be substituted. 

1261 - ``${parameter:+word}``/``${parameter+word}``: Use Alternative Value. If parameter is unset (or empty), empty shall be substituted; otherwise, the expansion of word shall be substituted. 

1262 

1263 In the patterns above, if you use a ``:`` it is checked whether parameter is unset or empty. 

1264 If ``:`` is not used the check is only true if parameter is unset, empty is treated as a valid value. 

1265 ''' 

1266 env = m.group(1) 

1267 for op in '-=?+': 

1268 if ':' + op in env: 

1269 env, arg = env.split(':' + op, 1) 

1270 isset = bool(os.environ.get(env)) 

1271 elif op in env: 

1272 env, arg = env.split(op, 1) 

1273 isset = env in os.environ 

1274 else: 

1275 continue 

1276 

1277 val = os.environ.get(env, '') 

1278 if op == '-': 

1279 if isset: 

1280 return val 

1281 else: 

1282 return arg 

1283 elif op == '=': 

1284 if isset: 

1285 return val 

1286 else: 

1287 os.environ[env] = arg 

1288 return arg 

1289 elif op == '?': 

1290 if isset: 

1291 return val 

1292 else: 

1293 if not arg: 

1294 state = 'empty' if env in os.environ else 'unset' 

1295 arg = f'environment variable {env} is {state}' 

1296 raise ParseException(arg) 

1297 elif op == '+': 

1298 if isset: 

1299 return arg 

1300 else: 

1301 return '' 

1302 else: 

1303 assert False 

1304 

1305 return os.environ.get(env, '') 

1306 

1307 

1308 # ------- help ------- 

1309 

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

1311 import platform 

1312 formatter = self.create_formatter() 

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

1314 for path in self.iter_config_paths(): 

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

1316 

1317 writer.write_line('') 

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

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

1320 writer.write_line('- XDG_CONFIG_HOME') 

1321 writer.write_line('- XDG_CONFIG_DIRS') 

1322 for env in self.env_variables: 

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

1324 

1325 writer.write_line('') 

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

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

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

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

1330 

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

1332 

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

1334 for cmd in self.commands: 

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

1336 writer.write_heading(SectionLevel.SECTION, names) 

1337 writer.write_lines(cmd.get_help()) 

1338 

1339 def create_formatter(self) -> HelpFormatterWrapper: 

1340 return HelpFormatterWrapper(self.formatter_class) 

1341 

1342 def get_help(self) -> str: 

1343 ''' 

1344 A convenience wrapper around :meth:`~confattr.configfile.ConfigFile.write_help` 

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

1346 

1347 This uses :class:`~confattr.configfile.HelpWriter`. 

1348 ''' 

1349 doc = io.StringIO() 

1350 self.write_help(HelpWriter(doc)) 

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

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

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

1354 # Therefore I am stripping the trailing \n. 

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

1356 

1357 

1358 # ------- auto complete ------- 

1359 

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

1361 ''' 

1362 Provide an auto completion for commands that can be executed with :meth:`~confattr.configfile.ConfigFile.parse_line`. 

1363 

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

1365 :param cursor_pos: The position of the cursor 

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

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

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

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

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

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

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

1373 *start of line* and *end of line* should be the beginning and end of :paramref:`~confattr.configfile.ConfigFile.get_completions.line` but they may contain minor changes in order to keep quoting feasible. 

1374 ''' 

1375 original_ln = line 

1376 stripped_line = line.lstrip() 

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

1378 cursor_pos -= len(indentation) 

1379 line = stripped_line 

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

1381 out = self.get_completions_enter_group(line, cursor_pos) 

1382 else: 

1383 out = self.get_completions_command(line, cursor_pos) 

1384 

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

1386 return out 

1387 

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

1389 ''' 

1390 For a description of parameters and return type see :meth:`~confattr.configfile.ConfigFile.get_completions`. 

1391 

1392 :meth:`~confattr.configfile.ConfigFile.get_completions` has stripped any indentation from :paramref:`~confattr.configfile.ConfigFile.get_completions_enter_group.line` 

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

1394 ''' 

1395 start = line 

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

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

1398 return '', groups, '' 

1399 

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

1401 ''' 

1402 For a description of parameters and return type see :meth:`~confattr.configfile.ConfigFile.get_completions`. 

1403 

1404 :meth:`~confattr.configfile.ConfigFile.get_completions` has stripped any indentation from :paramref:`~confattr.configfile.ConfigFile.get_completions_command.line` 

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

1406 ''' 

1407 if not line: 

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

1409 

1410 ln_split = self.split_line_ignore_errors(line) 

1411 assert ln_split 

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

1413 

1414 if a.in_between: 

1415 start_of_line = line[:cursor_pos] 

1416 end_of_line = line[cursor_pos:] 

1417 else: 

1418 start_of_line = line[:a.i0] 

1419 end_of_line = line[a.i1:] 

1420 

1421 if a.argument_pos == 0: 

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

1423 else: 

1424 cmd = self.get_command(ln_split) 

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

1426 

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

1428 ''' 

1429 This is an internal method used by :meth:`~confattr.configfile.ConfigFile.get_completions_command` 

1430 ''' 

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

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

1433 out = ArgPos() 

1434 out.in_between = True 

1435 

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

1437 out.argument_pos = 0 

1438 out.i0 = 0 

1439 out.i1 = 0 

1440 

1441 n_ln = len(line) 

1442 i_ln = 0 

1443 n_arg = len(ln_split) 

1444 out.argument_pos = 0 

1445 i_in_arg = 0 

1446 assert out.argument_pos < n_ln 

1447 while True: 

1448 if out.in_between: 

1449 assert i_in_arg == 0 

1450 if i_ln >= n_ln: 

1451 assert out.argument_pos >= n_arg - 1 

1452 out.i0 = i_ln 

1453 return out 

1454 elif line[i_ln].isspace(): 

1455 i_ln += 1 

1456 else: 

1457 out.i0 = i_ln 

1458 if i_ln >= cursor_pos: 

1459 return out 

1460 if out.argument_pos >= n_arg: 

1461 assert line[i_ln] == '#' 

1462 out.i0 = len(line) 

1463 return out 

1464 out.in_between = False 

1465 else: 

1466 if i_ln >= n_ln: 

1467 assert out.argument_pos >= n_arg - 1 

1468 out.i1 = i_ln 

1469 return out 

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

1471 if line[i_ln].isspace(): 

1472 out.i1 = i_ln 

1473 if i_ln >= cursor_pos: 

1474 return out 

1475 out.in_between = True 

1476 i_ln += 1 

1477 out.argument_pos += 1 

1478 i_in_arg = 0 

1479 elif line[i_ln] in CHARS_REMOVED_BY_SHLEX: 

1480 i_ln += 1 

1481 else: 

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

1483 assert line[i_ln] == '#' 

1484 assert out.argument_pos == n_arg - 1 

1485 out.i1 = i_ln 

1486 return out 

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

1488 i_ln += 1 

1489 i_in_arg += 1 

1490 if out.argument_pos == 0 and i_ln == 1 and self.split_one_symbol_command(line)[0]: 

1491 out.in_between = True 

1492 out.argument_pos += 1 

1493 out.i0 = i_ln 

1494 i_in_arg = 0 

1495 else: 

1496 assert line[i_ln] in CHARS_REMOVED_BY_SHLEX 

1497 i_ln += 1 

1498 

1499 

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

1501 start = line[:cursor_pos] 

1502 completions = [cmd for cmd in self.command_dict.keys() if cmd.startswith(start) and len(cmd) > 1] 

1503 return start_of_line, completions, end_of_line 

1504 

1505 

1506 def get_completions_for_file_name(self, start: str, *, relative_to: str, include: 'Callable[[str, str], bool]|None' = None, exclude: 'str|None' = None, match: 'Callable[[str, str, str], bool]' = lambda path, name, start: name.startswith(start), start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]': 

1507 r''' 

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

1509 :param relative_to: If :paramref:`~confattr.configfile.ConfigFile.get_completions_for_file_name.start` is a relative path it's relative to this directory 

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

1511 :param include: A function which takes the path and file name as arguments and returns whether this file/directory is a valid completion. 

1512 :param match: A callable to decide if a completion fits for the given start. It takes three arguments: the parent directory, the file/directory name and the start. If it returns true the file/direcotry is added to the list of possible completions. The default is ``lambda path, name, start: name.startswith(start)``. 

1513 :return: All files and directories that start with :paramref:`~confattr.configfile.ConfigFile.get_completions_for_file_name.start` and do not match :paramref:`~confattr.configfile.ConfigFile.get_completions_for_file_name.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). 

1514 ''' 

1515 if exclude is None: 

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

1517 exclude = '$none' 

1518 else: 

1519 exclude = r'^\.' 

1520 reo = re.compile(exclude) 

1521 

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

1523 if os.path.sep in start: 

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

1525 directory += os.path.sep 

1526 quoted_directory = self.quote_path(directory) 

1527 

1528 start_of_line += quoted_directory 

1529 directory = os.path.expanduser(directory) 

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

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

1532 directory = os.path.normpath(directory) 

1533 else: 

1534 directory = relative_to 

1535 

1536 try: 

1537 names = os.listdir(directory) 

1538 except (FileNotFoundError, NotADirectoryError): 

1539 return start_of_line, [], end_of_line 

1540 

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

1542 for name in names: 

1543 if reo.match(name): 

1544 continue 

1545 if include and not include(directory, name): 

1546 continue 

1547 if not match(directory, name, start): 

1548 continue 

1549 

1550 quoted_name = self.quote(name) 

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

1552 quoted_name += os.path.sep 

1553 

1554 out.append(quoted_name) 

1555 

1556 return start_of_line, out, end_of_line 

1557 

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

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

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

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

1562 if path_split[i]: 

1563 path_split[i] = self.quote(path_split[i]) 

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

1565 

1566 

1567 def get_completions_for_expand(self, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[bool, str, list[str], str]': 

1568 applicable, start_of_line, completions, end_of_line = self.get_completions_for_expand_env(start, start_of_line=start_of_line, end_of_line=end_of_line) 

1569 if applicable: 

1570 return applicable, start_of_line, completions, end_of_line 

1571 

1572 return self.get_completions_for_expand_config(start, start_of_line=start_of_line, end_of_line=end_of_line) 

1573 

1574 def get_completions_for_expand_config(self, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[bool, str, list[str], str]': 

1575 if start.count('%') % 2 == 0: 

1576 return False, start_of_line, [], end_of_line 

1577 

1578 i = start.rindex('%') + 1 

1579 start_of_line = start_of_line + start[:i] 

1580 start = start[i:] 

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

1582 return True, start_of_line, completions, end_of_line 

1583 

1584 def get_completions_for_expand_env(self, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[bool, str, list[str], str]': 

1585 i = start.rfind('${') 

1586 if i < 0: 

1587 return False, start_of_line, [], end_of_line 

1588 i += 2 

1589 

1590 if '}' in start[i:]: 

1591 return False, start_of_line, [], end_of_line 

1592 

1593 start_of_line = start_of_line + start[:i] 

1594 start = start[i:] 

1595 completions = [key for key in sorted(os.environ.keys()) if key.startswith(start)] 

1596 return True, start_of_line, completions, end_of_line 

1597 

1598 

1599 # ------- error handling ------- 

1600 

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

1602 ''' 

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

1604 

1605 This method is called when a :class:`~confattr.configfile.ParseException` or :class:`~confattr.configfile.MultipleParseExceptions` is caught. 

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

1607 

1608 :param msg: The error message 

1609 ''' 

1610 self.ui_notifier.show_error(msg) 

1611 

1612 

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

1614 

1615class ConfigFileCommand(abc.ABC): 

1616 

1617 ''' 

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

1619 

1620 Subclasses must implement the :meth:`~confattr.configfile.ConfigFileCommand.run` method which is called when :class:`~confattr.configfile.ConfigFile` is loading a file. 

1621 Subclasses should contain a doc string so that :meth:`~confattr.configfile.ConfigFileCommand.get_help` can provide a description to the user. 

1622 Subclasses may set the :attr:`~confattr.configfile.ConfigFileCommand.name` and :attr:`~confattr.configfile.ConfigFileCommand.aliases` attributes to change the output of :meth:`~confattr.configfile.ConfigFileCommand.get_name` and :meth:`~confattr.configfile.ConfigFileCommand.get_names`. 

1623 

1624 All subclasses are remembered and can be retrieved with :meth:`~confattr.configfile.ConfigFileCommand.get_command_types`. 

1625 They are instantiated in the constructor of :class:`~confattr.configfile.ConfigFile`. 

1626 ''' 

1627 

1628 #: 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:`~confattr.configfile.ConfigFileCommand.get_name` returns the name of this class in lower case letters and underscores replaced by hyphens. 

1629 name: str 

1630 

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

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

1633 

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

1635 help: str 

1636 

1637 #: 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() <confattr.configfile.ConfigFile.save_to_writer>` if there are several commands which implement the :meth:`~confattr.configfile.ConfigFileCommand.save` method. If you implement :meth:`~confattr.configfile.ConfigFileCommand.save` and this attribute is set then :meth:`~confattr.configfile.ConfigFileCommand.save` should write a section header. If :meth:`~confattr.configfile.ConfigFileCommand.save` writes several sections it should always write the headings regardless of this attribute. 

1638 should_write_heading: bool = False 

1639 

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

1641 config_file: ConfigFile 

1642 

1643 #: The :class:`~confattr.configfile.UiNotifier` of :attr:`~confattr.configfile.ConfigFileCommand.config_file` 

1644 ui_notifier: UiNotifier 

1645 

1646 _abstract: bool 

1647 

1648 

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

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

1651 

1652 @classmethod 

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

1654 ''' 

1655 :return: All subclasses of :class:`~confattr.configfile.ConfigFileCommand` which have not been deleted with :meth:`~confattr.configfile.ConfigFileCommand.delete_command_type` 

1656 ''' 

1657 return tuple(cls._subclasses) 

1658 

1659 @classmethod 

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

1661 ''' 

1662 Delete :paramref:`~confattr.configfile.ConfigFileCommand.delete_command_type.cmd_type` so that it is not returned anymore by :meth:`~confattr.configfile.ConfigFileCommand.get_command_types` and that it's name can be used by another command. 

1663 Do nothing if :paramref:`~confattr.configfile.ConfigFileCommand.delete_command_type.cmd_type` has already been deleted. 

1664 ''' 

1665 if cmd_type in cls._subclasses: 

1666 cls._subclasses.remove(cmd_type) 

1667 for name in cmd_type.get_names(): 

1668 cls._used_names.remove(name) 

1669 

1670 @classmethod 

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

1672 ''' 

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

1674 :param abstract: This class is a base class for the implementation of other commands and shall *not* be returned by :meth:`~confattr.configfile.ConfigFileCommand.get_command_types` 

1675 :raises ValueError: if the name or one of it's aliases is already in use and :paramref:`~confattr.configfile.ConfigFileCommand.__init_subclass__.replace` is not true 

1676 ''' 

1677 cls._abstract = abstract 

1678 if replace: 

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

1680 

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

1682 parent = parent_commands[0] 

1683 if 'name' not in cls.__dict__: 

1684 cls.name = parent.get_name() 

1685 if 'aliases' not in cls.__dict__: 

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

1687 for parent in parent_commands[1:]: 

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

1689 

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

1691 for parent in parent_commands: 

1692 cls.delete_command_type(parent) 

1693 

1694 if not abstract: 

1695 cls._subclasses.append(cls) 

1696 for name in cls.get_names(): 

1697 if name in cls._used_names and not replace: 

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

1699 cls._used_names.add(name) 

1700 

1701 @classmethod 

1702 def get_name(cls) -> str: 

1703 ''' 

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

1705  

1706 If :attr:`~confattr.configfile.ConfigFileCommand.name` is set it is returned as it is. 

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

1708 ''' 

1709 if 'name' in cls.__dict__: 

1710 return cls.name 

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

1712 

1713 @classmethod 

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

1715 ''' 

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

1717  

1718 The first one is always the return value of :meth:`~confattr.configfile.ConfigFileCommand.get_name`. 

1719 If :attr:`~confattr.configfile.ConfigFileCommand.aliases` is set it's items are yielded afterwards. 

1720 

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

1722 and :meth:`~confattr.configfile.ConfigFileCommand.run` will be called if an undefined command is encountered. 

1723 ''' 

1724 yield cls.get_name() 

1725 if 'aliases' in cls.__dict__: 

1726 for name in cls.aliases: 

1727 yield name 

1728 

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

1730 self.config_file = config_file 

1731 self.ui_notifier = config_file.ui_notifier 

1732 

1733 @abc.abstractmethod 

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

1735 ''' 

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

1737 

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

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

1740 ''' 

1741 raise NotImplementedError() 

1742 

1743 

1744 def create_formatter(self) -> HelpFormatterWrapper: 

1745 return self.config_file.create_formatter() 

1746 

1747 def get_help_attr_or_doc_str(self) -> str: 

1748 ''' 

1749 :return: The :attr:`~confattr.configfile.ConfigFileCommand.help` attribute or the doc string if :attr:`~confattr.configfile.ConfigFileCommand.help` has not been set, cleaned up with :func:`inspect.cleandoc`. 

1750 ''' 

1751 if hasattr(self, 'help'): 

1752 doc = self.help 

1753 elif self.__doc__: 

1754 doc = self.__doc__ 

1755 else: 

1756 doc = '' 

1757 

1758 return inspect.cleandoc(doc) 

1759 

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

1761 ''' 

1762 Add the return value of :meth:`~confattr.configfile.ConfigFileCommand.get_help_attr_or_doc_str` to :paramref:`~confattr.configfile.ConfigFileCommand.add_help_to.formatter`. 

1763 ''' 

1764 formatter.add_text(self.get_help_attr_or_doc_str()) 

1765 

1766 def get_help(self) -> str: 

1767 ''' 

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

1769 

1770 This is generated by creating a formatter with :meth:`~confattr.configfile.ConfigFileCommand.create_formatter`, 

1771 adding the help to it with :meth:`~confattr.configfile.ConfigFileCommand.add_help_to` and 

1772 stripping trailing new line characters from the result of :meth:`HelpFormatterWrapper.format_help() <confattr.utils.HelpFormatterWrapper.format_help>`. 

1773 

1774 Most likely you don't want to override this method but :meth:`~confattr.configfile.ConfigFileCommand.add_help_to` instead. 

1775 ''' 

1776 formatter = self.create_formatter() 

1777 self.add_help_to(formatter) 

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

1779 

1780 def get_short_description(self) -> str: 

1781 ''' 

1782 :return: The first paragraph of the doc string/help attribute 

1783 ''' 

1784 out = self.get_help_attr_or_doc_str().split('\n\n') 

1785 if out[0].startswith('usage: '): 

1786 if len(out) > 1: 

1787 return out[1] 

1788 return "" 

1789 return out[0] 

1790 

1791 def save(self, 

1792 writer: FormattedWriter, 

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

1794 ) -> None: 

1795 ''' 

1796 Implement this method if you want calls to this command to be written by :meth:`ConfigFile.save() <confattr.configfile.ConfigFile.save>`. 

1797 

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

1799 If this command writes several sections then write a heading for every section regardless of :attr:`~confattr.configfile.ConfigFileCommand.should_write_heading`. 

1800 

1801 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('...') <confattr.configfile.FormattedWriter.write_command>`. 

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

1803 

1804 There is the :attr:`~confattr.configfile.ConfigFileCommand.config_file` attribute (which was passed to the constructor) which you can use to: 

1805 

1806 - quote arguments with :meth:`ConfigFile.quote() <confattr.configfile.ConfigFile.quote>` 

1807 - call :meth:`ConfigFile.write_config_id() <confattr.configfile.ConfigFile.write_config_id>` 

1808 

1809 You probably don't need the comment character :attr:`ConfigFile.COMMENT <confattr.configfile.ConfigFile.COMMENT>` because :paramref:`~confattr.configfile.ConfigFileCommand.save.writer` automatically comments out everything except for :meth:`FormattedWriter.write_command() <confattr.configfile.FormattedWriter.write_command>`. 

1810 

1811 The default implementation does nothing. 

1812 ''' 

1813 pass 

1814 

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

1816 

1817 

1818 # ------- auto complete ------- 

1819 

1820 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]': 

1821 ''' 

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

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

1824 :param cursor_pos: The index inside of the argument where the cursor is located. This is undefined and should be ignored if :paramref:`~confattr.configfile.ConfigFileCommand.get_completions.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. 

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

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

1827 :param end_of_line: The third return value. 

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

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

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

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

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

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

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

1835 ''' 

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

1837 return start_of_line, completions, end_of_line 

1838 

1839 

1840class ArgumentParser(argparse.ArgumentParser): 

1841 

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

1843 ''' 

1844 Raise a :class:`~confattr.configfile.ParseException`. 

1845 ''' 

1846 raise ParseException(message) 

1847 

1848class ConfigFileArgparseCommand(ConfigFileCommand, abstract=True): 

1849 

1850 ''' 

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

1852 

1853 You must implement the class method :meth:`~confattr.configfile.ConfigFileArgparseCommand.init_parser` to add the arguments to :attr:`~confattr.configfile.ConfigFileArgparseCommand.parser`. 

1854 Instead of :meth:`~confattr.configfile.ConfigFileArgparseCommand.run` you must implement :meth:`~confattr.configfile.ConfigFileArgparseCommand.run_parsed`. 

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

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

1857 

1858 You may specify :attr:`ConfigFileCommand.name <confattr.configfile.ConfigFileCommand.name>`, :attr:`ConfigFileCommand.aliases <confattr.configfile.ConfigFileCommand.aliases>` and :meth:`ConfigFileCommand.save() <confattr.configfile.ConfigFileCommand.save>` like for :class:`~confattr.configfile.ConfigFileCommand`. 

1859 ''' 

1860 

1861 #: The argument parser which is passed to :meth:`~confattr.configfile.ConfigFileArgparseCommand.init_parser` for adding arguments and which is used in :meth:`~confattr.configfile.ConfigFileArgparseCommand.run` 

1862 parser: ArgumentParser 

1863 

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

1865 super().__init__(config_file) 

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

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

1868 self.init_parser(self.parser) 

1869 

1870 @abc.abstractmethod 

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

1872 ''' 

1873 :param parser: The parser to add arguments to. This is the same object like :attr:`~confattr.configfile.ConfigFileArgparseCommand.parser`. 

1874 

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

1876 Use :meth:`ArgumentParser.add_argument() <confattr.configfile.ArgumentParser.add_argument>` to add arguments to :paramref:`~confattr.configfile.ConfigFileArgparseCommand.init_parser.parser`. 

1877 ''' 

1878 pass 

1879 

1880 @staticmethod 

1881 def add_enum_argument(parser: 'argparse.ArgumentParser|argparse._MutuallyExclusiveGroup', *name_or_flags: str, type: 'type[enum.Enum]') -> 'argparse.Action': 

1882 ''' 

1883 This method: 

1884 

1885 - generates a function to convert the user input to an element of the enum 

1886 - gives the function the name of the enum in lower case (argparse uses this in error messages) 

1887 - generates a help string containing the allowed values 

1888 

1889 and adds an argument to the given argparse parser with that. 

1890 ''' 

1891 def parse(name: str) -> enum.Enum: 

1892 for v in type: 

1893 if v.name.lower() == name: 

1894 return v 

1895 raise TypeError() 

1896 parse.__name__ = type.__name__.lower() 

1897 choices = ', '.join(v.name.lower() for v in type) 

1898 return parser.add_argument(*name_or_flags, type=parse, help="one of " + choices) 

1899 

1900 def get_help(self) -> str: 

1901 ''' 

1902 Creates a help text which can be presented to the user by calling :meth:`~confattr.configfile.ArgumentParser.format_help` on :attr:`~confattr.configfile.ConfigFileArgparseCommand.parser`. 

1903 The return value of :meth:`~confattr.configfile.ConfigFileArgparseCommand.get_help_attr_or_doc_str` has been passed as :paramref:`~confattr.configfile.ArgumentParser.description` to the constructor of :class:`~confattr.configfile.ArgumentParser`, therefore :attr:`~confattr.configfile.ConfigFileArgparseCommand.help`/the doc string are included as well. 

1904 ''' 

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

1906 

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

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

1909 if not cmd: 

1910 return # pragma: no cover 

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

1912 if cmd[0] in self._names: 

1913 cmd = cmd[1:] 

1914 args = self.parser.parse_args(cmd) 

1915 self.run_parsed(args) 

1916 

1917 @abc.abstractmethod 

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

1919 ''' 

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

1921 ''' 

1922 pass 

1923 

1924 # ------- auto complete ------- 

1925 

1926 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]': 

1927 if in_between: 

1928 start = '' 

1929 else: 

1930 start = cmd[argument_pos][:cursor_pos] 

1931 

1932 if self.after_positional_argument_marker(cmd, argument_pos): 

1933 pos = self.get_position(cmd, argument_pos) 

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

1935 

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

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

1938 if prevarg: 

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

1940 

1941 if self.is_option_start(start): 

1942 if '=' in start: 

1943 i = start.index('=') 

1944 option_name = start[:i] 

1945 i += 1 

1946 start_of_line += start[:i] 

1947 start = start[i:] 

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

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

1950 

1951 pos = self.get_position(cmd, argument_pos) 

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

1953 

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

1955 ''' 

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

1957 ''' 

1958 pos = 0 

1959 n = len(cmd) 

1960 options_allowed = True 

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

1962 for i in range(1, argument_pos): 

1963 if options_allowed and i < n: 

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

1965 options_allowed = False 

1966 continue 

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

1968 continue 

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

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

1971 continue 

1972 pos += 1 

1973 

1974 return pos 

1975 

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

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

1978 

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

1980 ''' 

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

1982 ''' 

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

1984 

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

1986 if argument_pos >= len(cmd): 

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

1988 

1989 arg = cmd[argument_pos] 

1990 if '=' in arg: 

1991 # argument of option is already given within arg 

1992 return None 

1993 if not self.is_option_start(arg): 

1994 return None 

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

1996 action = self.get_action_for_option(arg) 

1997 if action is None: 

1998 return None 

1999 if action.nargs != 0: 

2000 return arg 

2001 return None 

2002 

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

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

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

2006 if action is None: 

2007 continue 

2008 if action.nargs != 0: 

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

2010 return None 

2011 

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

2013 action = self.get_action_for_option(out) 

2014 if action is None: 

2015 return None 

2016 if action.nargs != 0: 

2017 return out 

2018 return None 

2019 

2020 

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

2022 completions = [] 

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

2024 for opt in a.option_strings: 

2025 if len(opt) <= 2: 

2026 # this is trivial to type but not self explanatory 

2027 # => not helpful for auto completion 

2028 continue 

2029 if opt.startswith(start): 

2030 completions.append(opt) 

2031 return start_of_line, completions, end_of_line 

2032 

2033 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]': 

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

2035 

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

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

2038 

2039 

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

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

2042 if option_name in a.option_strings: 

2043 return a 

2044 return None 

2045 

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

2047 actions = self.parser._get_positional_actions() 

2048 if argument_pos < len(actions): 

2049 return actions[argument_pos] 

2050 return None 

2051 

2052 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]': 

2053 if action is None: 

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

2055 elif not action.choices: 

2056 completions = [] 

2057 else: 

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

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

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

2061 return start_of_line, completions, end_of_line 

2062 

2063 

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

2065 

2066class Set(ConfigFileCommand): 

2067 

2068 r''' 

2069 usage: set [--raw] key1=val1 [key2=val2 ...] \\ 

2070 set [--raw] key [=] val 

2071 

2072 Change the value of a setting. 

2073 

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

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

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

2077 

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

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

2080 

2081 You can use the value of another setting with %other.key% or an environment variable with ${ENV_VAR}. 

2082 If you want to insert a literal percent character use two of them: %%. 

2083 You can disable expansion of settings and environment variables with the --raw flag. 

2084 ''' 

2085 

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

2087 KEY_VAL_SEP = '=' 

2088 

2089 FLAGS_RAW = ('-r', '--raw') 

2090 

2091 raw = False 

2092 

2093 # ------- load ------- 

2094 

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

2096 ''' 

2097 Call :meth:`~confattr.configfile.Set.set_multiple` if the first argument contains :attr:`~confattr.configfile.Set.KEY_VAL_SEP` otherwise :meth:`~confattr.configfile.Set.set_with_spaces`. 

2098 

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

2100 ''' 

2101 if self.is_vim_style(cmd): 

2102 self.set_multiple(cmd) 

2103 else: 

2104 self.set_with_spaces(cmd) 

2105 

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

2107 ''' 

2108 :paramref:`~confattr.configfile.Set.is_vim_style.cmd` has one of two possible styles: 

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

2110 - 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:`~confattr.configfile.Set.set_with_spaces`. 

2111 

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

2113 ''' 

2114 try: 

2115 # cmd[0] is the name of the command, cmd[1] is the first argument 

2116 if cmd[1] in self.FLAGS_RAW: 

2117 i = 2 

2118 else: 

2119 i = 1 

2120 return self.KEY_VAL_SEP in cmd[i] 

2121 except IndexError: 

2122 raise ParseException('no settings given') 

2123 

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

2125 ''' 

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

2127 

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

2129 ''' 

2130 if cmd[1] in self.FLAGS_RAW: 

2131 cmd = cmd[2:] 

2132 self.raw = True 

2133 else: 

2134 cmd = cmd[1:] 

2135 self.raw = False 

2136 

2137 n = len(cmd) 

2138 if n == 2: 

2139 key, value = cmd 

2140 self.parse_key_and_set_value(key, value) 

2141 elif n == 3: 

2142 key, sep, value = cmd 

2143 if sep != self.KEY_VAL_SEP: 

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

2145 self.parse_key_and_set_value(key, value) 

2146 elif n == 1: 

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

2148 else: 

2149 assert n >= 4 

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

2151 

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

2153 ''' 

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

2155 

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

2157 ''' 

2158 self.raw = False 

2159 exceptions = [] 

2160 for arg in cmd[1:]: 

2161 if arg in self.FLAGS_RAW: 

2162 self.raw = True 

2163 continue 

2164 try: 

2165 if not self.KEY_VAL_SEP in arg: 

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

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

2168 self.parse_key_and_set_value(key, value) 

2169 except ParseException as e: 

2170 exceptions.append(e) 

2171 if exceptions: 

2172 raise MultipleParseExceptions(exceptions) 

2173 

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

2175 ''' 

2176 Find the corresponding :class:`~confattr.config.Config` instance for :paramref:`~confattr.configfile.Set.parse_key_and_set_value.key` and call :meth:`~confattr.configfile.Set.set_value` with the return value of :meth:`config_file.parse_value() <confattr.configfile.ConfigFile.parse_value>`. 

2177 

2178 :raises ParseException: if key is invalid or if :meth:`config_file.parse_value() <confattr.configfile.ConfigFile.parse_value>` or :meth:`~confattr.configfile.Set.set_value` raises a :class:`ValueError` 

2179 ''' 

2180 if key not in self.config_file.config_instances: 

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

2182 

2183 instance = self.config_file.config_instances[key] 

2184 try: 

2185 self.set_value(instance, self.config_file.parse_value(instance, value, raw=self.raw)) 

2186 except ValueError as e: 

2187 raise ParseException(str(e)) 

2188 

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

2190 ''' 

2191 Assign :paramref:`~confattr.configfile.Set.set_value.value` to :paramref`instance` by calling :meth:`Config.set_value() <confattr.config.Config.set_value>` with :attr:`ConfigFile.config_id <confattr.configfile.ConfigFile.config_id>` of :attr:`~confattr.configfile.Set.config_file`. 

2192 Afterwards call :meth:`UiNotifier.show_info() <confattr.configfile.UiNotifier.show_info>`. 

2193 ''' 

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

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

2196 

2197 

2198 # ------- save ------- 

2199 

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

2201 ''' 

2202 :param config_instances: The settings to consider 

2203 :param ignore: Skip these settings 

2204 

2205 Iterate over all given :paramref:`~confattr.configfile.Set.iter_config_instances_to_be_saved.config_instances` and expand all :class:`~confattr.config.DictConfig` instances into the :class:`~confattr.config.Config` instances they consist of. 

2206 Sort the resulting list if :paramref:`~confattr.configfile.Set.iter_config_instances_to_be_saved.config_instances` is not a :class:`list` or a :class:`tuple`. 

2207 Yield all :class:`~confattr.config.Config` instances which are not (directly or indirectly) contained in :paramref:`~confattr.configfile.Set.iter_config_instances_to_be_saved.ignore` and where :meth:`Config.wants_to_be_exported() <confattr.config.Config.wants_to_be_exported>` returns true. 

2208 ''' 

2209 config_instances = kw['config_instances'] 

2210 ignore = kw['ignore'] 

2211 

2212 config_keys = [] 

2213 for c in config_instances: 

2214 if isinstance(c, DictConfig): 

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

2216 else: 

2217 config_keys.append(c.key) 

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

2219 config_keys = sorted(config_keys) 

2220 

2221 if ignore is not None: 

2222 tmp = set() 

2223 for c in tuple(ignore): 

2224 if isinstance(c, DictConfig): 

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

2226 else: 

2227 tmp.add(c) 

2228 ignore = tmp 

2229 

2230 for key in config_keys: 

2231 instance = self.config_file.config_instances[key] 

2232 if not instance.wants_to_be_exported(): 

2233 continue 

2234 

2235 if ignore is not None and instance in ignore: 

2236 continue 

2237 

2238 yield instance 

2239 

2240 

2241 #: A temporary variable used in :meth:`~confattr.configfile.Set.write_config_help` to prevent repeating the help of several :class:`~confattr.config.Config` instances belonging to the same :class:`~confattr.config.DictConfig`. It is reset in :meth:`~confattr.configfile.Set.save`. 

2242 last_name: 'str|None' 

2243 

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

2245 ''' 

2246 :param writer: The file to write to 

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

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

2249 

2250 Iterate over all :class:`~confattr.config.Config` instances with :meth:`~confattr.configfile.Set.iter_config_instances_to_be_saved`, 

2251 split them into normal :class:`~confattr.config.Config` and :class:`~confattr.config.MultiConfig` and write them with :meth:`~confattr.configfile.Set.save_config_instance`. 

2252 But before that set :attr:`~confattr.configfile.Set.last_name` to None (which is used by :meth:`~confattr.configfile.Set.write_config_help`) 

2253 and write help for data types based on :meth:`~confattr.configfile.Set.get_help_for_data_types`. 

2254 ''' 

2255 no_multi = kw['no_multi'] 

2256 comments = kw['comments'] 

2257 

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

2259 normal_configs = [] 

2260 multi_configs = [] 

2261 if no_multi: 

2262 normal_configs = config_instances 

2263 else: 

2264 for instance in config_instances: 

2265 if isinstance(instance, MultiConfig): 

2266 multi_configs.append(instance) 

2267 else: 

2268 normal_configs.append(instance) 

2269 

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

2271 

2272 if normal_configs: 

2273 if multi_configs: 

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

2275 elif self.should_write_heading: 

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

2277 

2278 if comments: 

2279 type_help = self.get_help_for_data_types(normal_configs) 

2280 if type_help: 

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

2282 writer.write_lines(type_help) 

2283 

2284 for instance in normal_configs: 

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

2286 

2287 if multi_configs: 

2288 if normal_configs: 

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

2290 elif self.should_write_heading: 

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

2292 

2293 if comments: 

2294 type_help = self.get_help_for_data_types(multi_configs) 

2295 if type_help: 

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

2297 writer.write_lines(type_help) 

2298 

2299 for instance in multi_configs: 

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

2301 

2302 for config_id in MultiConfig.config_ids: 

2303 writer.write_line('') 

2304 self.config_file.write_config_id(writer, config_id) 

2305 for instance in multi_configs: 

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

2307 

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

2309 ''' 

2310 :param writer: The file to write to 

2311 :param instance: The config value to be saved 

2312 :param config_id: Which value to be written in case of a :class:`~confattr.config.MultiConfig`, should be :obj:`None` for a normal :class:`~confattr.config.Config` instance 

2313 :param bool comments: If true: call :meth:`~confattr.configfile.Set.write_config_help` 

2314 

2315 Convert the :class:`~confattr.config.Config` instance into a value str with :meth:`config_file.format_value() <confattr.configfile.ConfigFile.format_value>`, 

2316 wrap it in quotes if necessary with :meth:`config_file.quote() <confattr.configfile.ConfigFile.quote>` and write it to :paramref:`~confattr.configfile.Set.save_config_instance.writer`. 

2317 ''' 

2318 if kw['comments']: 

2319 self.write_config_help(writer, instance) 

2320 value = self.config_file.format_value(instance, config_id) 

2321 value = self.config_file.quote(value) 

2322 if '%' in value or '${' in value: 

2323 raw = ' --raw' 

2324 else: 

2325 raw = '' 

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

2327 writer.write_command(ln) 

2328 

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

2330 ''' 

2331 :param writer: The output to write to 

2332 :param instance: The config value to be saved 

2333 

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

2335 based on :meth:`instance.type.get_description() <confattr.formatters.AbstractFormatter.get_description>` and :attr:`Config.help <confattr.config.Config.help>`. 

2336 

2337 Use :attr:`~confattr.configfile.Set.last_name` to write the help only once for all :class:`~confattr.config.Config` instances belonging to the same :class:`~confattr.config.DictConfig` instance. 

2338 ''' 

2339 if group_dict_configs and instance.parent is not None: 

2340 name = instance.parent.key_changer(instance.parent.key_prefix) 

2341 else: 

2342 name = instance.key 

2343 if name == self.last_name: 

2344 return 

2345 

2346 formatter = HelpFormatterWrapper(self.config_file.formatter_class) 

2347 writer.write_heading(SectionLevel.SUB_SECTION, name) 

2348 writer.write_lines(formatter.format_text(instance.type.get_description(self.config_file)).rstrip()) 

2349 #if instance.unit: 

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

2351 if isinstance(instance.help, dict): 

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

2353 key_name = self.config_file.format_any_value(instance.type.get_primitives()[-1], key) 

2354 val = inspect.cleandoc(val) 

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

2356 elif isinstance(instance.help, str): 

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

2358 

2359 self.last_name = name 

2360 

2361 

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

2363 ''' 

2364 :param config_instances: All config values to be saved 

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

2366 

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

2368 which occur in :paramref:`~confattr.configfile.Set.get_data_type_name_to_help_map.config_instances`. 

2369 The help is gathered from the :attr:`~confattr.configfile.Set.help` attribute of the type 

2370 or :meth:`Primitive.get_help() <confattr.formatters.Primitive.get_help>`. 

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

2372 ''' 

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

2374 for instance in config_instances: 

2375 for t in instance.type.get_primitives(): 

2376 name = t.get_type_name() 

2377 if name in help_text: 

2378 continue 

2379 

2380 h = t.get_help(self.config_file) 

2381 if not h: 

2382 continue 

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

2384 

2385 return help_text 

2386 

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

2388 help_map = self.get_data_type_name_to_help_map(config_instances) 

2389 if not help_map: 

2390 return 

2391 

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

2393 formatter.add_start_section(name) 

2394 formatter.add_text(help_map[name]) 

2395 formatter.add_end_section() 

2396 

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

2398 formatter = self.create_formatter() 

2399 self.add_help_for_data_types(formatter, config_instances) 

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

2401 

2402 # ------- help ------- 

2403 

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

2405 super().add_help_to(formatter) 

2406 

2407 kw: 'SaveKwargs' = {} 

2408 self.config_file.set_save_default_arguments(kw) 

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

2410 self.last_name = None 

2411 

2412 formatter.add_start_section('data types') 

2413 self.add_help_for_data_types(formatter, config_instances) 

2414 formatter.add_end_section() 

2415 

2416 if self.config_file.enable_config_ids: 

2417 normal_configs = [] 

2418 multi_configs = [] 

2419 for instance in config_instances: 

2420 if isinstance(instance, MultiConfig): 

2421 multi_configs.append(instance) 

2422 else: 

2423 normal_configs.append(instance) 

2424 else: 

2425 normal_configs = config_instances 

2426 multi_configs = [] 

2427 

2428 if normal_configs: 

2429 if self.config_file.enable_config_ids: 

2430 formatter.add_start_section('application wide settings') 

2431 else: 

2432 formatter.add_start_section('settings') 

2433 for instance in normal_configs: 

2434 self.add_config_help(formatter, instance) 

2435 formatter.add_end_section() 

2436 

2437 if multi_configs: 

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

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

2440 for instance in multi_configs: 

2441 self.add_config_help(formatter, instance) 

2442 formatter.add_end_section() 

2443 

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

2445 formatter.add_start_section(instance.key) 

2446 formatter.add_text(instance.type.get_description(self.config_file)) 

2447 if isinstance(instance.help, dict): 

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

2449 key_name = self.config_file.format_any_value(instance.type.get_primitives()[-1], key) 

2450 val = inspect.cleandoc(val) 

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

2452 elif isinstance(instance.help, str): 

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

2454 formatter.add_end_section() 

2455 

2456 # ------- auto complete ------- 

2457 

2458 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]': 

2459 if argument_pos >= len(cmd): 

2460 start = '' 

2461 else: 

2462 start = cmd[argument_pos][:cursor_pos] 

2463 

2464 if len(cmd) <= 1: 

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

2466 elif self.is_vim_style(cmd): 

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

2468 else: 

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

2470 

2471 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]': 

2472 if self.KEY_VAL_SEP in start: 

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

2474 start_of_line += key + self.KEY_VAL_SEP 

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

2476 else: 

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

2478 

2479 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]': 

2480 if argument_pos == 1: 

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

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

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

2484 else: 

2485 return start_of_line, [], end_of_line 

2486 

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

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

2489 return start_of_line, completions, end_of_line 

2490 

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

2492 applicable, start_of_line, completions, end_of_line = self.config_file.get_completions_for_expand(start, start_of_line=start_of_line, end_of_line=end_of_line) 

2493 if applicable: 

2494 return start_of_line, completions, end_of_line 

2495 

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

2497 if instance is None: 

2498 return start_of_line, [], end_of_line 

2499 

2500 return instance.type.get_completions(self.config_file, start_of_line, start, end_of_line) 

2501 

2502 

2503class Include(ConfigFileArgparseCommand): 

2504 

2505 ''' 

2506 Load another config file. 

2507 

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

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

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

2511 ''' 

2512 

2513 help_config_id = ''' 

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

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

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

2517 

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

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

2520 ''' 

2521 

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

2523 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.') 

2524 if self.config_file.enable_config_ids: 

2525 assert parser.description is not None 

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

2527 group = parser.add_mutually_exclusive_group() 

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

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

2530 

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

2532 

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

2534 fn_imp = args.path 

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

2536 fn_imp = os.path.expanduser(fn_imp) 

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

2538 fn = self.config_file.context_file_name 

2539 if fn is None: 

2540 fn = self.config_file.get_save_path() 

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

2542 

2543 if fn_imp in self.nested_includes: 

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

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

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

2547 

2548 self.nested_includes.append(fn_imp) 

2549 

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

2551 self.config_file.load_without_resetting_config_id(fn_imp) 

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

2553 config_id = self.config_file.config_id 

2554 self.config_file.load_file(fn_imp) 

2555 self.config_file.config_id = config_id 

2556 else: 

2557 config_id = self.config_file.config_id 

2558 self.config_file.load_without_resetting_config_id(fn_imp) 

2559 self.config_file.config_id = config_id 

2560 

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

2562 del self.nested_includes[-1] 

2563 

2564 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]': 

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

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

2567 return self.config_file.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) 

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

2569 

2570 

2571class Echo(ConfigFileArgparseCommand): 

2572 

2573 ''' 

2574 Display a message. 

2575 

2576 Settings and environment variables are expanded like in the value of a set command. 

2577 ''' 

2578 

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

2580 parser.add_argument('-l', '--level', default=NotificationLevel.INFO, type=NotificationLevel, metavar='{%s}' % ','.join(l.value for l in NotificationLevel.get_instances()), help="The notification level may influence the formatting but messages printed with echo are always displayed regardless of the notification level.") 

2581 parser.add_argument('-r', '--raw', action='store_true', help="Do not expand settings and environment variables.") 

2582 parser.add_argument('msg', nargs=argparse.ONE_OR_MORE, help="The message to display") 

2583 

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

2585 msg = ' '.join(self.config_file.expand(m) for m in args.msg) 

2586 self.ui_notifier.show(args.level, msg, ignore_filter=True) 

2587 

2588 

2589 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]': 

2590 if argument_pos >= len(cmd): 

2591 start = '' 

2592 else: 

2593 start = cmd[argument_pos][:cursor_pos] 

2594 

2595 applicable, start_of_line, completions, end_of_line = self.config_file.get_completions_for_expand(start, start_of_line=start_of_line, end_of_line=end_of_line) 

2596 return start_of_line, completions, end_of_line 

2597 

2598class Help(ConfigFileArgparseCommand): 

2599 

2600 ''' 

2601 Display help. 

2602 ''' 

2603 

2604 max_width = 80 

2605 max_width_name = 18 

2606 min_width_sep = 2 

2607 tab_size = 4 

2608 

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

2610 parser.add_argument('cmd', nargs='?', help="The command for which you want help") 

2611 

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

2613 if args.cmd: 

2614 if args.cmd not in self.config_file.command_dict: 

2615 raise ParseException(f"unknown command {args.cmd!r}") 

2616 cmd = self.config_file.command_dict[args.cmd] 

2617 out = cmd.get_help() 

2618 else: 

2619 out = "The following commands are defined:\n" 

2620 table = [] 

2621 for cmd in self.config_file.commands: 

2622 name = "- %s" % "/".join(cmd.get_names()) 

2623 descr = cmd.get_short_description() 

2624 row = (name, descr) 

2625 table.append(row) 

2626 out += self.format_table(table) 

2627 

2628 out += "\n" 

2629 out += "\nUse `help <cmd>` to get more information about a command." 

2630 

2631 self.ui_notifier.show(NotificationLevel.INFO, out, ignore_filter=True, no_context=True) 

2632 

2633 def format_table(self, table: 'Sequence[tuple[str, str]]') -> str: 

2634 max_name_width = max(len(row[0]) for row in table) 

2635 col_width_name = min(max_name_width, self.max_width_name) 

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

2637 subsequent_indent = ' ' * (col_width_name + self.min_width_sep) 

2638 for name, descr in table: 

2639 if not descr: 

2640 out.append(name) 

2641 continue 

2642 if len(name) > col_width_name: 

2643 out.append(name) 

2644 initial_indent = subsequent_indent 

2645 else: 

2646 initial_indent = name.ljust(col_width_name + self.min_width_sep) 

2647 out.extend(textwrap.wrap(descr, self.max_width, 

2648 initial_indent = initial_indent, 

2649 subsequent_indent = subsequent_indent, 

2650 break_long_words = False, 

2651 tabsize = self.tab_size, 

2652 )) 

2653 return '\n'.join(out) 

2654 

2655 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]': 

2656 if action and action.dest == 'cmd': 

2657 start_of_line, completions, end_of_line = self.config_file.get_completions_command_name(start, cursor_pos=len(start), start_of_line=start_of_line, end_of_line=end_of_line) 

2658 return start_of_line, completions, end_of_line 

2659 

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

2661 

2662 

2663class UnknownCommand(ConfigFileCommand, abstract=True): 

2664 

2665 name = DEFAULT_COMMAND 

2666 

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

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