ConfigUpdater 4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,576 @@
1
+ """Parser for configuration files (normally ``*.cfg/*.ini``)
2
+
3
+ A configuration file consists of sections, lead by a "[section]" header,
4
+ and followed by "name: value" entries, with continuations and such in
5
+ the style of RFC 822.
6
+
7
+ The basic idea of **ConfigUpdater** is that a configuration file consists of
8
+ three kinds of building blocks: sections, comments and spaces for separation.
9
+ A section itself consists of three kinds of blocks: options, comments and
10
+ spaces. This gives us the corresponding data structures to describe a
11
+ configuration file.
12
+
13
+ A general block object contains the lines which were parsed and make up
14
+ the block. If a block object was not changed then during writing the same
15
+ lines that were parsed will be used to express the block. In case a block,
16
+ e.g. an option, was changed, it is marked as `updated` and its values will
17
+ be transformed into a corresponding string during an update of a
18
+ configuration file.
19
+
20
+
21
+ .. note::
22
+
23
+ ConfigUpdater is based on Python's ConfigParser source code, specially regarding the
24
+ ``parser`` module.
25
+ The main parsing rules and algorithm are preserved, however ConfigUpdater implements
26
+ its own modified version of the abstract syntax tree to support retaining comments
27
+ and whitespace in an attempt to provide format-preserving document manipulation.
28
+ The copyright and license of the original ConfigParser code is included as an
29
+ attachment to ConfigUpdater's own license, at the root of the source code repository;
30
+ see the file LICENSE for details.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import io
36
+ import os
37
+ import re
38
+ import sys
39
+ from collections.abc import Callable, Iterable, Mapping
40
+ from configparser import (
41
+ DuplicateOptionError,
42
+ DuplicateSectionError,
43
+ MissingSectionHeaderError,
44
+ NoOptionError,
45
+ NoSectionError,
46
+ ParsingError,
47
+ )
48
+ from types import MappingProxyType as ReadOnlyMapping
49
+ from typing import Any, TypeVar, cast, overload
50
+
51
+ from .block import Comment, Space
52
+ from .document import ConfigContent, Document
53
+ from .option import Option
54
+ from .section import Section
55
+
56
+ __all__ = [
57
+ "DuplicateOptionError",
58
+ "DuplicateSectionError",
59
+ "InconsistentStateError",
60
+ "MissingSectionHeaderError",
61
+ "NoOptionError",
62
+ "NoSectionError",
63
+ "Parser",
64
+ "ParsingError",
65
+ ]
66
+
67
+ T = TypeVar("T")
68
+ E = TypeVar("E", bound=Exception)
69
+ D = TypeVar("D", bound=Document)
70
+
71
+ PathLike = str | bytes | os.PathLike[str] | os.PathLike[bytes]
72
+
73
+
74
+ class InconsistentStateError(Exception): # pragma: no cover (not expected to happen)
75
+ """Internal parser error, some of the parsing algorithm assumptions was violated,
76
+ and the internal state machine ended up in an unpredicted state.
77
+ """
78
+
79
+ def __init__(
80
+ self, msg: str, fpname: str = "<???>", lineno: int = -1, line: str = "???"
81
+ ) -> None:
82
+ super().__init__(msg)
83
+ self.args = (msg, fpname, lineno, line)
84
+
85
+ def __str__(self) -> str:
86
+ (msg, fpname, lineno, line) = self.args
87
+ return f"{msg}\n{fpname}({lineno}): {line!r}"
88
+
89
+
90
+ class Parser:
91
+ """Parser for updating configuration files.
92
+
93
+ ConfigUpdater's parser follows ConfigParser with some differences:
94
+
95
+ * inline comments are treated as part of a key's value,
96
+ * only a single config file can be updated at a time,
97
+ * the original case of sections and keys are kept,
98
+ * control over the position of a new section/key.
99
+
100
+ Following features are **deliberately not** implemented:
101
+
102
+ * interpolation of values,
103
+ * propagation of parameters from the default section,
104
+ * conversions of values,
105
+ * passing key/value-pairs with ``default`` argument,
106
+ * non-strict mode allowing duplicate sections and keys.
107
+ """
108
+
109
+ # Regular expressions for parsing section headers and options
110
+ _SECT_TMPL: str = r"""
111
+ \[ # [
112
+ (?P<header>.+) # very permissive!
113
+ \] # ]
114
+ (?P<raw_comment>.*) # match any suffix
115
+ """
116
+ # The option templates are taken from Python 3.15's configparser, whose negative
117
+ # lookaheads avoid quadratic backtracking on long lines (CPython gh-146333)
118
+ _OPT_TMPL: str = r"""
119
+ (?P<option> # very permissive!
120
+ (?:(?!{delim})\S)* # non-delimiter non-whitespace
121
+ (?:(?:(?!{delim})\s)+ # optionally more
122
+ (?:(?!{delim})\S)+)*) # space-separated words
123
+ \s*(?P<vi>{delim})\s* # any number of space/tab,
124
+ # followed by any of the
125
+ # allowed delimiters,
126
+ # followed by any space/tab
127
+ (?P<value>.*)$ # everything up to eol
128
+ """
129
+ _OPT_NV_TMPL: str = r"""
130
+ (?P<option> # very permissive!
131
+ (?:(?!{delim})\S)* # non-delimiter non-whitespace
132
+ (?:(?:(?!{delim})\s)+ # optionally more
133
+ (?:(?!{delim})\S)+)*) # space-separated words
134
+ \s*(?: # any number of space/tab,
135
+ (?P<vi>{delim})\s* # optionally followed by
136
+ # any of the allowed
137
+ # delimiters, followed by any
138
+ # space/tab
139
+ (?P<value>.*))?$ # everything up to eol
140
+ """
141
+ # Compiled regular expression for matching sections
142
+ SECTCRE = re.compile(_SECT_TMPL, re.VERBOSE)
143
+ # Compiled regular expression for matching options with typical separators
144
+ OPTCRE = re.compile(_OPT_TMPL.format(delim="=|:"), re.VERBOSE)
145
+ # Compiled regular expression for matching options with optional values
146
+ # delimited using typical separators
147
+ OPTCRE_NV = re.compile(_OPT_NV_TMPL.format(delim="=|:"), re.VERBOSE)
148
+ # Compiled regular expression for matching leading whitespace in a line
149
+ NONSPACECRE = re.compile(r"\S")
150
+
151
+ def __init__(
152
+ self,
153
+ allow_no_value: bool = False,
154
+ *,
155
+ delimiters: tuple[str, ...] = ("=", ":"),
156
+ comment_prefixes: tuple[str, ...] = ("#", ";"),
157
+ inline_comment_prefixes: tuple[str, ...] | None = None,
158
+ strict: bool = True,
159
+ empty_lines_in_values: bool = True,
160
+ space_around_delimiters: bool = True,
161
+ optionxform: Callable[[str], str] = str,
162
+ ) -> None:
163
+ """Constructor of the Parser
164
+
165
+ Args:
166
+ allow_no_value (bool): allow keys without a value, default False
167
+ delimiters (tuple): delimiters for key/value pairs, default =, :
168
+ comment_prefixes (tuple): prefix of comments, default # and ;
169
+ inline_comment_prefixes (tuple): prefix of inline comment,
170
+ default None
171
+ strict (bool): each section must be unique as well as every key
172
+ within a section, default True
173
+ empty_lines_in_values (bool): each empty line marks the end of an option.
174
+ Otherwise, internal empty lines of a multiline option are kept as part
175
+ of the value, default: True.
176
+ space_around_delimiters (bool): add a space before and after the
177
+ delimiter, default True
178
+ """
179
+ self._document: Document # bind later
180
+ self._optionxform_fn = optionxform
181
+ self._lineno = -1
182
+ self._fpname = "<???>"
183
+
184
+ self._filename: str | bytes | None = None
185
+ self._space_around_delimiters: bool = space_around_delimiters
186
+
187
+ self._dict = dict # no reason to let the user change this
188
+ # keeping _sections to keep code aligned with ConfigParser but
189
+ # _document takes the actual role instead. Only use self._document!
190
+ self._sections: dict[str, dict[str, list[str]]] = self._dict()
191
+ self._delimiters: tuple[str, ...] = tuple(delimiters)
192
+ if delimiters == ("=", ":"):
193
+ self._optcre = self.OPTCRE_NV if allow_no_value else self.OPTCRE
194
+ else:
195
+ d = "|".join(re.escape(d) for d in delimiters)
196
+ if allow_no_value:
197
+ self._optcre = re.compile(self._OPT_NV_TMPL.format(delim=d), re.VERBOSE)
198
+ else:
199
+ self._optcre = re.compile(self._OPT_TMPL.format(delim=d), re.VERBOSE)
200
+ self._comment_prefixes: tuple[str, ...] = tuple(comment_prefixes or ())
201
+ self._inline_comment_prefixes: tuple[str, ...] = tuple(
202
+ inline_comment_prefixes or ()
203
+ )
204
+ self._strict = strict
205
+ self._allow_no_value = allow_no_value
206
+ self._empty_lines_in_values = empty_lines_in_values
207
+
208
+ def _get_args(self) -> dict[str, Any]:
209
+ args = (
210
+ "allow_no_value",
211
+ "delimiters",
212
+ "comment_prefixes",
213
+ "inline_comment_prefixes",
214
+ "strict",
215
+ "empty_lines_in_values",
216
+ "space_around_delimiters",
217
+ )
218
+ return {attr: getattr(self, f"_{attr}") for attr in args}
219
+
220
+ def __repr__(self) -> str:
221
+ return f"<{self.__class__.__name__}: {self._get_args()!r}>"
222
+
223
+ @property
224
+ def syntax_options(self) -> Mapping[str, Any]:
225
+ return ReadOnlyMapping(self._get_args())
226
+
227
+ @overload
228
+ def read(self, filename: PathLike, encoding: str | None = None) -> Document: ...
229
+
230
+ @overload
231
+ def read(self, filename: PathLike, encoding: str, into: D) -> D: ...
232
+
233
+ @overload
234
+ def read(
235
+ self, filename: PathLike, *, into: D, encoding: str | None = None
236
+ ) -> D: ...
237
+
238
+ def read(
239
+ self,
240
+ filename: PathLike,
241
+ encoding: str | None = None,
242
+ into: Document | None = None,
243
+ ) -> Document:
244
+ """Read and parse a filename.
245
+
246
+ Args:
247
+ filename (str): path to file
248
+ encoding (Optional[str]): encoding of file, default None, i.e. the locale
249
+ encoding (UTF-8 in UTF-8 mode) like in :func:`open`
250
+ into (Optional[Document]): object to be populated with the parsed config
251
+ """
252
+ document = Document() if into is None else into
253
+ encoding = io.text_encoding(encoding)
254
+ with open(filename, encoding=encoding) as fp:
255
+ self._read(fp, str(filename), document)
256
+ self._filename = os.path.abspath(filename)
257
+ return document
258
+
259
+ @overload
260
+ def read_file(self, f: Iterable[str], source: str | None) -> Document: ...
261
+
262
+ @overload
263
+ def read_file(self, f: Iterable[str], source: str | None, into: D) -> D: ...
264
+
265
+ @overload
266
+ def read_file(
267
+ self, f: Iterable[str], *, into: D, source: str | None = None
268
+ ) -> D: ...
269
+
270
+ def read_file(
271
+ self,
272
+ f: Iterable[str],
273
+ source: str | None = None,
274
+ into: Document | None = None,
275
+ ) -> Document:
276
+ """Like read() but the argument must be a file-like object.
277
+
278
+ The ``f`` argument must be iterable, returning one line at a time.
279
+ Optional second argument is the ``source`` specifying the name of the
280
+ file being read. If not given, it is taken from f.name. If ``f`` has no
281
+ ``name`` attribute, ``<???>`` is used.
282
+
283
+ Args:
284
+ f: file like object
285
+ source (Optional[str]): reference name for file object, default None
286
+ into (Optional[Document]): object to be populated with the parsed config
287
+ """
288
+ if isinstance(f, str):
289
+ raise RuntimeError("f must be a file-like object, not string!")
290
+ document = Document() if into is None else into
291
+ if source is None:
292
+ try:
293
+ source = cast(str, cast(io.FileIO, f).name)
294
+ except AttributeError:
295
+ source = "<???>"
296
+ self._read(f, source, document)
297
+ return document
298
+
299
+ @overload
300
+ def read_string(self, string: str, source: str = "<string>") -> Document: ...
301
+
302
+ @overload
303
+ def read_string(self, string: str, source: str, into: D) -> D: ...
304
+
305
+ @overload
306
+ def read_string(self, string: str, *, into: D, source: str = "<string>") -> D: ...
307
+
308
+ def read_string(
309
+ self, string: str, source: str = "<string>", into: Document | None = None
310
+ ) -> Document:
311
+ """Read configuration from a given string.
312
+
313
+ Args:
314
+ string (str): string containing a configuration
315
+ source (str): reference name for file object, default '<string>'
316
+ into (Optional[Document]): object to be populated with the parsed config
317
+ """
318
+ sfile = io.StringIO(string)
319
+ if into is None:
320
+ return self.read_file(sfile, source)
321
+ return self.read_file(sfile, source, into)
322
+
323
+ def optionxform(self, string: str) -> str:
324
+ fn = self._optionxform_fn
325
+ return fn(string)
326
+
327
+ @property
328
+ def _last_block(self) -> ConfigContent | None:
329
+ return self._document.last_block
330
+
331
+ def _update_curr_block(self, block_type: type[Comment | Space]) -> Comment | Space:
332
+ if isinstance(self._last_block, block_type):
333
+ return self._last_block
334
+ else:
335
+ new_block = block_type(container=self._document)
336
+ self._document.append(new_block)
337
+ return new_block
338
+
339
+ def _add_comment(self, line: str) -> None:
340
+ if isinstance(self._last_block, Section):
341
+ self._last_block.add_comment(line)
342
+ else:
343
+ self._update_curr_block(Comment).add_line(line)
344
+
345
+ def _add_section(self, sectname: str, raw_comment: str, line: str) -> None:
346
+ new_section = Section(
347
+ sectname, container=self._document, raw_comment=raw_comment
348
+ )
349
+ new_section.add_line(line)
350
+ self._document.append(new_section)
351
+
352
+ def _add_option(
353
+ self, key: str, vi: str | None, value: str | None, line: str
354
+ ) -> None:
355
+ if not isinstance(self._last_block, Section): # pragma: no cover
356
+ msg = f"{self._last_block!r} should be Section"
357
+ raise InconsistentStateError(msg, self._fpname, self._lineno, line)
358
+ # A no-value option needs a delimiter if a value is assigned later.
359
+ if vi is None and self._delimiters:
360
+ vi = self._delimiters[0]
361
+ entry = Option(
362
+ key,
363
+ value=None,
364
+ delimiter=vi or "", # vi is only None if there are no delimiters
365
+ container=self._last_block,
366
+ space_around_delimiters=self._space_around_delimiters,
367
+ line=line,
368
+ )
369
+ # Initially add the value as further lines might follow
370
+ entry.add_value(value)
371
+ self._last_block.add_option(entry)
372
+
373
+ def _add_option_line(self, line: str) -> None:
374
+ last_section = self._last_block
375
+ if not isinstance(last_section, Section): # pragma: no cover
376
+ msg = f"{last_section!r} should be Section"
377
+ raise InconsistentStateError(msg, self._fpname, self._lineno, line)
378
+ # if empty_lines_in_values is true, we later will merge options and whitespace
379
+ # (in the _check_values_with_blank_lines function called at the end).
380
+ # This allows option values to have empty new lines inside them
381
+ # So for now we can add parts of option values to Space nodes, than we check if
382
+ # that is an error or not.
383
+ last_option = last_section.last_block
384
+ # handle special case of unindented comment in multi-line value
385
+ if isinstance(last_option, Comment):
386
+ last_option, comment = (
387
+ cast(Option, last_option.previous_block),
388
+ last_option.detach(),
389
+ )
390
+ # move lines from comment to last option to keep it.
391
+ for comment_line in comment.lines:
392
+ last_option.add_line(comment_line)
393
+ if not isinstance(last_option, (Option, Space)): # pragma: no cover
394
+ msg = f"{last_option!r} should be Option or Space"
395
+ raise InconsistentStateError(msg, self._fpname, self._lineno, line)
396
+ last_option.add_line(line)
397
+
398
+ def _add_space(self, line: str) -> None:
399
+ if isinstance(self._last_block, Section):
400
+ self._last_block.add_space(line)
401
+ else:
402
+ self._update_curr_block(Space).add_line(line)
403
+
404
+ def _read(self, fp: Iterable[str], fpname: str, into: Document) -> None:
405
+ """Parse a sectioned configuration file.
406
+
407
+ Each section in a configuration file contains a header, indicated by
408
+ a name in square brackets (`[]`), plus key/value options, indicated by
409
+ `name` and `value` delimited with a specific substring (`=` or `:` by
410
+ default).
411
+
412
+ Values can span multiple lines, as long as they are indented deeper
413
+ than the first line of the value. Depending on the parser's mode, blank
414
+ lines may be treated as parts of multiline values or ignored.
415
+
416
+ Configuration files may include comments, prefixed by specific
417
+ characters (`#` and `;` by default). Comments may appear on their own
418
+ in an otherwise empty line or may be entered in lines holding values or
419
+ section names.
420
+
421
+ Note: This method was borrowed from ConfigParser and we keep this
422
+ mess here as close as possible to the original messod (pardon
423
+ this german pun) for consistency reasons and later upgrades.
424
+ """
425
+ self._document = into
426
+ elements_added: set[str | tuple[str, str]] = set()
427
+ cursect: dict[str, list[str]] | None = None # None or dict
428
+ sectname: str | None = None
429
+ optname: str | None = None
430
+ lineno = 0
431
+ indent_level = 0
432
+ e: Exception | None = None # None, or an exception
433
+ self._fpname = fpname
434
+ for lineno, line in enumerate(fp, start=1):
435
+ self._lineno = lineno
436
+ comment_start: int | None = sys.maxsize
437
+ # strip inline comments
438
+ inline_prefixes = {p: -1 for p in self._inline_comment_prefixes} # noqa: C420
439
+ while comment_start == sys.maxsize and inline_prefixes:
440
+ next_prefixes = {}
441
+ for prefix, index in inline_prefixes.items():
442
+ index = line.find(prefix, index + 1)
443
+ if index == -1:
444
+ continue
445
+ next_prefixes[prefix] = index
446
+ if index == 0 or (index > 0 and line[index - 1].isspace()):
447
+ comment_start = min(comment_start, index)
448
+ inline_prefixes = next_prefixes
449
+ # strip full line comments
450
+ for prefix in self._comment_prefixes:
451
+ # configparser would do line.strip() here,
452
+ # we do rstrip() to allow comments in multi-line options
453
+ if line.rstrip().startswith(prefix):
454
+ comment_start = 0
455
+ self._add_comment(line) # HOOK
456
+ break
457
+ if comment_start == sys.maxsize:
458
+ comment_start = None
459
+ value = line[:comment_start].strip()
460
+ if not value:
461
+ if self._empty_lines_in_values:
462
+ # add empty line to the value, but only if there was no
463
+ # comment on the line
464
+ if (
465
+ comment_start is None
466
+ and cursect is not None
467
+ and optname
468
+ and cursect[optname] is not None
469
+ ):
470
+ cursect[optname].append("") # newlines added at join
471
+ if line.strip():
472
+ self._add_option_line(line) # HOOK
473
+ else:
474
+ # empty line marks end of value
475
+ indent_level = sys.maxsize
476
+ if comment_start is None:
477
+ self._add_space(line)
478
+ continue
479
+ # continuation line?
480
+ first_nonspace = self.NONSPACECRE.search(line)
481
+ cur_indent_level = first_nonspace.start() if first_nonspace else 0
482
+ if cursect is not None and optname and cur_indent_level > indent_level:
483
+ cursect[optname].append(value)
484
+ self._add_option_line(line) # HOOK
485
+ # a section header or option header?
486
+ else:
487
+ indent_level = cur_indent_level
488
+ # is it a section header?
489
+ mo = self.SECTCRE.match(value)
490
+ if mo:
491
+ sectname = mo.group("header")
492
+ if sectname in self._sections:
493
+ if self._strict and sectname in elements_added:
494
+ raise DuplicateSectionError(sectname, fpname, lineno)
495
+ cursect = self._sections[sectname]
496
+ elements_added.add(sectname)
497
+ else:
498
+ cursect = self._dict()
499
+ self._sections[sectname] = cursect
500
+ elements_added.add(sectname)
501
+ # So sections can't start with a continuation line
502
+ optname = None
503
+ self._add_section(sectname, mo.group("raw_comment"), line) # HOOK
504
+ # no section header in the file?
505
+ elif cursect is None:
506
+ raise MissingSectionHeaderError(fpname, lineno, line)
507
+ # an option line?
508
+ else:
509
+ mo = self._optcre.match(value)
510
+ if mo:
511
+ optname, vi, optval = mo.group("option", "vi", "value")
512
+ if not optname:
513
+ e = self._handle_error(e, fpname, lineno, line)
514
+ # optname = self.optionxform(optname.rstrip())
515
+ # keep original case of key
516
+ optname = optname.rstrip()
517
+ if sectname is None: # pragma: no cover
518
+ msg = f"Could not find the section name for {optname}"
519
+ raise InconsistentStateError(msg, fpname, lineno, line)
520
+ if self._strict and (sectname, optname) in elements_added:
521
+ args = (sectname, optname, fpname, lineno)
522
+ raise DuplicateOptionError(*args)
523
+ elements_added.add((sectname, optname))
524
+ # This check is fine because the OPTCRE cannot
525
+ # match if it would set optval to None
526
+ if optval is not None:
527
+ optval = optval.strip()
528
+ cursect[optname] = [optval]
529
+ else:
530
+ # valueless option handling
531
+ cursect[optname] = [] # None in Configparser
532
+ self._add_option(optname, vi, optval, line) # HOOK
533
+ # handle indented comment
534
+ elif (
535
+ first_nonspace is not None
536
+ and first_nonspace.group(0) in self._comment_prefixes
537
+ ):
538
+ self._add_comment(line) # HOOK
539
+ else:
540
+ # a non-fatal parsing error occurred. set up the
541
+ # exception but keep going. the exception will be
542
+ # raised at the end of the file and will contain a
543
+ # list of all bogus lines
544
+ e = self._handle_error(e, fpname, lineno, line)
545
+ # if any parsing errors occurred, raise an exception
546
+ if e:
547
+ raise e
548
+ # if empty_lines_in_values is true, we have to eliminate spurious newlines
549
+ if self._empty_lines_in_values:
550
+ self._check_values_with_blank_lines()
551
+
552
+ def _handle_error(
553
+ self, exc: E | None, fpname: str, lineno: int, line: str
554
+ ) -> ParsingError | E:
555
+ e = exc or ParsingError(fpname)
556
+ if hasattr(e, "append"):
557
+ e.append(lineno, repr(line))
558
+ # ^ the typechecker cannot handle hasattr
559
+ return e
560
+
561
+ def _check_values_with_blank_lines(self) -> None:
562
+ for section in self._document.section_blocks():
563
+ for option in section.option_blocks():
564
+ next_block = option.next_block
565
+ # check if space is part of a multi-line value with blank lines
566
+ if isinstance(next_block, Space) and "".join(next_block.lines).strip():
567
+ self._merge_option_with_space(option, next_block)
568
+
569
+ def _merge_option_with_space(self, option: Option, space: Space) -> None:
570
+ last_val_idx = max(i for i, line in enumerate(space.lines) if line.strip())
571
+ value_lines = space.lines[: last_val_idx + 1]
572
+ merge_vals = "".join(line.lstrip(" ") for line in value_lines)
573
+ option._values.append(merge_vals)
574
+ option._multiline_value_joined = False
575
+ option.lines.extend(space.lines[: last_val_idx + 1])
576
+ del space.lines[: last_val_idx + 1]
configupdater/py.typed ADDED
File without changes