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.
- configupdater/__init__.py +18 -0
- configupdater/block.py +207 -0
- configupdater/builder.py +131 -0
- configupdater/configupdater.py +217 -0
- configupdater/container.py +88 -0
- configupdater/document.py +417 -0
- configupdater/option.py +263 -0
- configupdater/parser.py +576 -0
- configupdater/py.typed +0 -0
- configupdater/section.py +369 -0
- configupdater-4.0.dist-info/METADATA +349 -0
- configupdater-4.0.dist-info/RECORD +14 -0
- configupdater-4.0.dist-info/WHEEL +4 -0
- configupdater-4.0.dist-info/licenses/LICENSE.txt +289 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
# Change here if project is renamed and does not equal the package name
|
|
5
|
+
dist_name = "ConfigUpdater"
|
|
6
|
+
__version__ = version(dist_name)
|
|
7
|
+
except PackageNotFoundError: # pragma: no cover
|
|
8
|
+
__version__ = "unknown"
|
|
9
|
+
finally:
|
|
10
|
+
del version, PackageNotFoundError
|
|
11
|
+
|
|
12
|
+
from . import configupdater, parser
|
|
13
|
+
|
|
14
|
+
# import everything and rely on __ALL__
|
|
15
|
+
from .configupdater import *
|
|
16
|
+
from .parser import *
|
|
17
|
+
|
|
18
|
+
__all__ = list(set(configupdater.__all__ + parser.__all__))
|
configupdater/block.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Together with :mod:`~configupdater.container` this module forms the basis of
|
|
2
|
+
the class hierarchy in **ConfigUpdater**.
|
|
3
|
+
|
|
4
|
+
The :class:`Block` is the parent class of everything that can be nested inside a
|
|
5
|
+
configuration file, e.g. comments, sections, options and even sequences of white space.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from copy import deepcopy
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Self, TypeVar
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from .builder import BlockBuilder
|
|
15
|
+
from .container import Container
|
|
16
|
+
|
|
17
|
+
T = TypeVar("T")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _short_repr(block: object) -> str:
|
|
21
|
+
if isinstance(block, str):
|
|
22
|
+
return block
|
|
23
|
+
name = getattr(block, "raw_key", None) or getattr(block, "name", None)
|
|
24
|
+
name = f" {name!r}" if name else ""
|
|
25
|
+
return f"<{block.__class__.__name__}{name}>"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class NotAttachedError(Exception):
|
|
29
|
+
"""Raised when a block needs a container but is not attached to one.
|
|
30
|
+
|
|
31
|
+
Insert the block first, e.g. with ``section[key] = option`` or one of the
|
|
32
|
+
``add_before``/``add_after`` builders.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, block: str | Block = "The block"):
|
|
36
|
+
super().__init__(
|
|
37
|
+
f"{_short_repr(block)} is not attached to a container yet. "
|
|
38
|
+
"Try to insert it first."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AlreadyAttachedError(Exception):
|
|
43
|
+
"""Raised when a block is inserted while it is attached to another container.
|
|
44
|
+
|
|
45
|
+
A block belongs to exactly one container. Remove it from the old one first with
|
|
46
|
+
``detach`` or insert a copy made with :func:`copy.deepcopy`.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, block: str | Block = "The block"):
|
|
50
|
+
super().__init__(
|
|
51
|
+
f"{_short_repr(block)} has been already attached to a container. "
|
|
52
|
+
"Try to remove it first using ``detach`` or create a copy using "
|
|
53
|
+
"stdlib's ``copy.deepcopy``."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class AssignMultilineValueError(Exception):
|
|
58
|
+
"""Raised when a multi-line value is assigned to an option's ``value``.
|
|
59
|
+
|
|
60
|
+
Use the option's ``set_values`` or ``append`` method for multi-line values.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, block: str | Block = "The block"):
|
|
64
|
+
super().__init__(
|
|
65
|
+
f"Trying to assign a multi-line value to {_short_repr(block)}. "
|
|
66
|
+
"Use the ``set_values`` or ``append`` method to accomplish that."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Block:
|
|
71
|
+
"""Abstract Block type holding lines
|
|
72
|
+
|
|
73
|
+
Block objects hold original lines from the configuration file and hold
|
|
74
|
+
a reference to a container wherein the object resides.
|
|
75
|
+
|
|
76
|
+
The type variable ``T`` is a reference for the type of the sibling blocks
|
|
77
|
+
inside the container.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(self, container: Container[Any] | None = None) -> None:
|
|
81
|
+
self._container = container
|
|
82
|
+
self._lines: list[str] = []
|
|
83
|
+
self._updated = False
|
|
84
|
+
|
|
85
|
+
def __str__(self) -> str:
|
|
86
|
+
return "".join(self._lines)
|
|
87
|
+
|
|
88
|
+
def __repr__(self) -> str:
|
|
89
|
+
return f"<{self.__class__.__name__}: {str(self)!r}>"
|
|
90
|
+
|
|
91
|
+
def __eq__(self, other: object) -> bool:
|
|
92
|
+
if isinstance(other, self.__class__):
|
|
93
|
+
return str(self) == str(other)
|
|
94
|
+
else:
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
def __deepcopy__(self, memo: dict[int, Any]) -> Self:
|
|
98
|
+
clone = self._instantiate_copy()
|
|
99
|
+
clone._lines = deepcopy(self._lines, memo)
|
|
100
|
+
clone._updated = self._updated
|
|
101
|
+
memo[id(self)] = clone
|
|
102
|
+
return clone
|
|
103
|
+
|
|
104
|
+
def _instantiate_copy(self) -> Self:
|
|
105
|
+
"""Auxiliary method that allows subclasses calling ``__deepcopy__``"""
|
|
106
|
+
return self.__class__(container=None) # allow overwrite for different init args
|
|
107
|
+
# ^ A fresh copy should always be made detached from any container
|
|
108
|
+
|
|
109
|
+
def add_line(self, line: str) -> Self:
|
|
110
|
+
"""PRIVATE: this function is not part of the public API of Block.
|
|
111
|
+
It is only used internally by other classes of the package during parsing.
|
|
112
|
+
|
|
113
|
+
Add a line to the current block
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
line (str): one line to add
|
|
117
|
+
"""
|
|
118
|
+
self._lines.append(line)
|
|
119
|
+
return self
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def lines(self) -> list[str]:
|
|
123
|
+
return self._lines
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def container(self) -> Container[Any]:
|
|
127
|
+
"""Container holding the block"""
|
|
128
|
+
if self._container is None:
|
|
129
|
+
raise NotAttachedError(self)
|
|
130
|
+
return self._container
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def container_idx(self) -> int:
|
|
134
|
+
"""Index of the block within its container"""
|
|
135
|
+
for idx, block in enumerate(self.container.structure):
|
|
136
|
+
if block is self:
|
|
137
|
+
return idx
|
|
138
|
+
raise ValueError(f"{self!r} is not in the container")
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def updated(self) -> bool:
|
|
142
|
+
"""True if the option was changed/updated, otherwise False"""
|
|
143
|
+
# if no lines were added, treat it as updated since we added it
|
|
144
|
+
return self._updated or not self._lines
|
|
145
|
+
|
|
146
|
+
def _builder(self, idx: int) -> BlockBuilder:
|
|
147
|
+
from .builder import BlockBuilder
|
|
148
|
+
|
|
149
|
+
return BlockBuilder(self.container, idx)
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def add_before(self) -> BlockBuilder:
|
|
153
|
+
"""Block builder inserting a new block before the current block"""
|
|
154
|
+
return self._builder(self.container_idx)
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def add_after(self) -> BlockBuilder:
|
|
158
|
+
"""Block builder inserting a new block after the current block"""
|
|
159
|
+
return self._builder(self.container_idx + 1)
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def next_block(self) -> Block | None:
|
|
163
|
+
"""Next block in the current container"""
|
|
164
|
+
idx = self.container_idx + 1
|
|
165
|
+
if idx < len(self.container):
|
|
166
|
+
block: Block = self.container.structure[idx]
|
|
167
|
+
return block
|
|
168
|
+
else:
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def previous_block(self) -> Block | None:
|
|
173
|
+
"""Previous block in the current container"""
|
|
174
|
+
idx = self.container_idx - 1
|
|
175
|
+
if idx >= 0:
|
|
176
|
+
block: Block = self.container.structure[idx]
|
|
177
|
+
return block
|
|
178
|
+
else:
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
def detach(self) -> Self:
|
|
182
|
+
"""Remove and return this block from container"""
|
|
183
|
+
self.container._remove_block(self.container_idx)
|
|
184
|
+
self._container = None
|
|
185
|
+
return self
|
|
186
|
+
|
|
187
|
+
def has_container(self) -> bool:
|
|
188
|
+
"""Checks if this block has a container attached"""
|
|
189
|
+
return self._container is not None
|
|
190
|
+
|
|
191
|
+
def attach(self, container: Container[Any]) -> Self:
|
|
192
|
+
"""PRIVATE: Don't use this as a user!
|
|
193
|
+
|
|
194
|
+
Rather use `add_*` or the bracket notation
|
|
195
|
+
"""
|
|
196
|
+
if self._container is not None and self._container is not container:
|
|
197
|
+
raise AlreadyAttachedError(self)
|
|
198
|
+
self._container = container
|
|
199
|
+
return self
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class Comment(Block):
|
|
203
|
+
"""Comment block"""
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class Space(Block):
|
|
207
|
+
"""Vertical space block of new lines"""
|
configupdater/builder.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Core of the fluent API used by **ConfigUpdater** to make editing configuration files
|
|
2
|
+
easier.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from configparser import DuplicateOptionError, DuplicateSectionError
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Self
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .block import Block
|
|
12
|
+
from .container import Container
|
|
13
|
+
from .section import Section
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BlockBuilder:
|
|
17
|
+
"""Builder that injects blocks at a given index position."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, container: Container[Any], idx: int) -> None:
|
|
20
|
+
self._container = container
|
|
21
|
+
self._idx = idx
|
|
22
|
+
|
|
23
|
+
def _insert(self, block: Block) -> Self:
|
|
24
|
+
self._container.structure.insert(self._idx, block)
|
|
25
|
+
self._idx += 1
|
|
26
|
+
return self
|
|
27
|
+
|
|
28
|
+
def comment(self, text: str, comment_prefix: str = "#") -> Self:
|
|
29
|
+
"""Creates a comment block
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
text (str): content of comment without #
|
|
33
|
+
comment_prefix (str): character indicating start of comment
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
self for chaining
|
|
37
|
+
"""
|
|
38
|
+
from .block import Comment
|
|
39
|
+
|
|
40
|
+
comment = Comment(self._container)
|
|
41
|
+
if not text.startswith(comment_prefix):
|
|
42
|
+
text = f"{comment_prefix} {text}"
|
|
43
|
+
if not text.endswith("\n"):
|
|
44
|
+
text = "{}{}".format(text, "\n")
|
|
45
|
+
return self._insert(comment.add_line(text))
|
|
46
|
+
|
|
47
|
+
def section(self, section: str | Section) -> Self:
|
|
48
|
+
"""Creates a section block
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
section (str or :class:`Section`): name of section or object
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
self for chaining
|
|
55
|
+
"""
|
|
56
|
+
from .document import Document
|
|
57
|
+
from .section import Section
|
|
58
|
+
|
|
59
|
+
container = self._container
|
|
60
|
+
if not isinstance(container, Document):
|
|
61
|
+
raise ValueError("Sections can only be added at section level!")
|
|
62
|
+
|
|
63
|
+
if isinstance(section, str):
|
|
64
|
+
# create a new section
|
|
65
|
+
section = Section(section)
|
|
66
|
+
elif not isinstance(section, Section):
|
|
67
|
+
# Runtime check for callers that ignore the type hints
|
|
68
|
+
raise ValueError(
|
|
69
|
+
"Parameter must be a string or Section type!", {"container": section}
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
if container.has_section(section.name):
|
|
73
|
+
raise DuplicateSectionError(section.name)
|
|
74
|
+
|
|
75
|
+
section.attach(container)
|
|
76
|
+
return self._insert(section)
|
|
77
|
+
|
|
78
|
+
def space(self, newlines: int = 1) -> Self:
|
|
79
|
+
"""Creates a vertical space of newlines
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
newlines (int): number of empty lines
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
self for chaining
|
|
86
|
+
"""
|
|
87
|
+
from .block import Space
|
|
88
|
+
|
|
89
|
+
space = Space(container=self._container)
|
|
90
|
+
for _ in range(newlines):
|
|
91
|
+
space.add_line("\n")
|
|
92
|
+
return self._insert(space)
|
|
93
|
+
|
|
94
|
+
def option(
|
|
95
|
+
self,
|
|
96
|
+
key: str,
|
|
97
|
+
value: str | None = None,
|
|
98
|
+
*,
|
|
99
|
+
delimiter: str | None = None,
|
|
100
|
+
space_around_delimiters: bool | None = None,
|
|
101
|
+
) -> Self:
|
|
102
|
+
"""Creates a new option inside a section
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
key (str): key of the option
|
|
106
|
+
value (str or None): value of the option
|
|
107
|
+
delimiter (str or None): delimiter between key and value, by default
|
|
108
|
+
the first of the ``delimiters`` given to
|
|
109
|
+
:class:`~configupdater.ConfigUpdater`
|
|
110
|
+
space_around_delimiters (bool or None): surround the delimiter with
|
|
111
|
+
spaces, by default as given to :class:`~configupdater.ConfigUpdater`.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
self for chaining
|
|
115
|
+
"""
|
|
116
|
+
from .section import Section
|
|
117
|
+
|
|
118
|
+
if not isinstance(self._container, Section):
|
|
119
|
+
msg = "Options can only be added inside a section!"
|
|
120
|
+
raise ValueError(msg, {"container": self._container})
|
|
121
|
+
section = self._container
|
|
122
|
+
option = section.create_option(
|
|
123
|
+
key,
|
|
124
|
+
value,
|
|
125
|
+
delimiter=delimiter,
|
|
126
|
+
space_around_delimiters=space_around_delimiters,
|
|
127
|
+
)
|
|
128
|
+
if option.key in section.options():
|
|
129
|
+
raise DuplicateOptionError(section.name, option.key)
|
|
130
|
+
option.value = value
|
|
131
|
+
return self._insert(option)
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""As the main entry point of the ConfigUpdater library, this module is responsible
|
|
2
|
+
for combining the data layer provided by the :mod:`configupdater.document` module
|
|
3
|
+
and the parsing capabilities of :mod:`configupdater.parser`.
|
|
4
|
+
|
|
5
|
+
To complete the API, this module adds file handling functions, so that you can read a
|
|
6
|
+
configuration file from the disk, change it to your liking and save the updated
|
|
7
|
+
content.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import io
|
|
13
|
+
from collections.abc import Iterable, Mapping
|
|
14
|
+
from configparser import Error
|
|
15
|
+
from types import MappingProxyType as ReadOnlyMapping
|
|
16
|
+
from typing import Any, Self, TextIO
|
|
17
|
+
|
|
18
|
+
from .block import (
|
|
19
|
+
AlreadyAttachedError,
|
|
20
|
+
AssignMultilineValueError,
|
|
21
|
+
Comment,
|
|
22
|
+
NotAttachedError,
|
|
23
|
+
Space,
|
|
24
|
+
)
|
|
25
|
+
from .document import Document
|
|
26
|
+
from .option import NoneValueDisallowed, Option
|
|
27
|
+
from .parser import Parser, PathLike
|
|
28
|
+
from .section import Section
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"AlreadyAttachedError",
|
|
32
|
+
"AssignMultilineValueError",
|
|
33
|
+
"Comment",
|
|
34
|
+
"ConfigUpdater",
|
|
35
|
+
"NoConfigFileReadError",
|
|
36
|
+
"NoneValueDisallowed",
|
|
37
|
+
"NotAttachedError",
|
|
38
|
+
"Option",
|
|
39
|
+
"Parser",
|
|
40
|
+
"Section",
|
|
41
|
+
"Space",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class NoConfigFileReadError(Error):
|
|
46
|
+
"""Raised when no configuration file was read but update requested."""
|
|
47
|
+
|
|
48
|
+
def __init__(self) -> None:
|
|
49
|
+
super().__init__("No configuration file was yet read! Use .read(...) first.")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ConfigUpdater(Document):
|
|
53
|
+
"""Tool to parse and modify existing ``cfg`` files.
|
|
54
|
+
|
|
55
|
+
ConfigUpdater follows the API of ConfigParser with some differences:
|
|
56
|
+
|
|
57
|
+
* inline comments are treated as part of a key's value,
|
|
58
|
+
* only a single config file can be updated at a time,
|
|
59
|
+
* the original case of sections and keys are kept,
|
|
60
|
+
* control over the position of a new section/key.
|
|
61
|
+
|
|
62
|
+
Following features are **deliberately not** implemented:
|
|
63
|
+
|
|
64
|
+
* interpolation of values,
|
|
65
|
+
* propagation of parameters from the default section,
|
|
66
|
+
* conversions of values,
|
|
67
|
+
* passing key/value-pairs with ``default`` argument,
|
|
68
|
+
* non-strict mode allowing duplicate sections and keys.
|
|
69
|
+
|
|
70
|
+
**ConfigUpdater** objects can be created by passing the same kind of arguments
|
|
71
|
+
accepted by the :class:`Parser`. After a ConfigUpdater object is created, you can
|
|
72
|
+
load some content into it by calling any of the ``read*`` methods
|
|
73
|
+
(:meth:`read`, :meth:`read_file` and :meth:`read_string`).
|
|
74
|
+
|
|
75
|
+
Once the content is loaded you can use the ConfigUpdater object more or less in the
|
|
76
|
+
same way you would use a nested dictionary. Please have a look into
|
|
77
|
+
:class:`Document` to understand the main differences.
|
|
78
|
+
|
|
79
|
+
When you are done changing the configuration file, you can call :meth:`write` or
|
|
80
|
+
:meth:`update_file` methods.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
allow_no_value: bool = False,
|
|
86
|
+
*,
|
|
87
|
+
delimiters: tuple[str, ...] = ("=", ":"),
|
|
88
|
+
comment_prefixes: tuple[str, ...] = ("#", ";"),
|
|
89
|
+
inline_comment_prefixes: tuple[str, ...] | None = None,
|
|
90
|
+
strict: bool = True,
|
|
91
|
+
empty_lines_in_values: bool = True,
|
|
92
|
+
space_around_delimiters: bool = True,
|
|
93
|
+
) -> None:
|
|
94
|
+
self._parser_opts: dict[str, Any] = {
|
|
95
|
+
"allow_no_value": allow_no_value,
|
|
96
|
+
"delimiters": delimiters,
|
|
97
|
+
"comment_prefixes": comment_prefixes,
|
|
98
|
+
"inline_comment_prefixes": inline_comment_prefixes,
|
|
99
|
+
"strict": strict,
|
|
100
|
+
"empty_lines_in_values": empty_lines_in_values,
|
|
101
|
+
"space_around_delimiters": space_around_delimiters,
|
|
102
|
+
}
|
|
103
|
+
self._syntax_options = ReadOnlyMapping(self._parser_opts)
|
|
104
|
+
self._filename: PathLike | None = None
|
|
105
|
+
self._encoding: str | None = None
|
|
106
|
+
super().__init__()
|
|
107
|
+
|
|
108
|
+
def _instantiate_copy(self) -> Self:
|
|
109
|
+
"""Will be called by ``Container.__deepcopy__``"""
|
|
110
|
+
clone = self.__class__(**self._parser_opts)
|
|
111
|
+
clone.optionxform = self.optionxform # type: ignore[method-assign]
|
|
112
|
+
clone._filename = self._filename
|
|
113
|
+
clone._encoding = self._encoding
|
|
114
|
+
return clone
|
|
115
|
+
|
|
116
|
+
def _parser(self, **kwargs: Any) -> Parser:
|
|
117
|
+
opts = {"optionxform": self.optionxform, **self._parser_opts, **kwargs}
|
|
118
|
+
return Parser(**opts)
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def syntax_options(self) -> Mapping[str, Any]:
|
|
122
|
+
return self._syntax_options
|
|
123
|
+
|
|
124
|
+
def read(self, filename: PathLike, encoding: str | None = None) -> Self:
|
|
125
|
+
"""Read and parse a filename.
|
|
126
|
+
|
|
127
|
+
``filename`` and ``encoding`` are remembered, so that :meth:`update_file`
|
|
128
|
+
writes the file back with the encoding it was read with.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
filename (str): path to file
|
|
132
|
+
encoding (str): encoding of file, default None, i.e. the locale encoding
|
|
133
|
+
(UTF-8 in UTF-8 mode) like in :func:`open`
|
|
134
|
+
"""
|
|
135
|
+
encoding = io.text_encoding(encoding)
|
|
136
|
+
self.clear()
|
|
137
|
+
self._filename = filename
|
|
138
|
+
self._encoding = encoding
|
|
139
|
+
return self._parser().read(filename, encoding=encoding, into=self)
|
|
140
|
+
|
|
141
|
+
def read_file(self, f: Iterable[str], source: str | None = None) -> Self:
|
|
142
|
+
"""Like read() but the argument must be a file-like object.
|
|
143
|
+
|
|
144
|
+
The ``f`` argument must be iterable, returning one line at a time.
|
|
145
|
+
Optional second argument is the ``source`` specifying the name of the
|
|
146
|
+
file being read. If not given, it is taken from f.name. If ``f`` has no
|
|
147
|
+
``name`` attribute, ``<???>`` is used.
|
|
148
|
+
|
|
149
|
+
If ``f`` has a ``name`` attribute, like a file opened with :func:`open`,
|
|
150
|
+
:meth:`update_file` writes to this file using ``f.encoding`` (the default
|
|
151
|
+
encoding if ``f`` has none). Otherwise, the file and encoding of a previous
|
|
152
|
+
``read*`` call are kept for :meth:`update_file`.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
f: file like object
|
|
156
|
+
source (str): reference name for file object, default None
|
|
157
|
+
"""
|
|
158
|
+
self.clear()
|
|
159
|
+
if hasattr(f, "name"):
|
|
160
|
+
self._filename = f.name
|
|
161
|
+
self._encoding = getattr(f, "encoding", None)
|
|
162
|
+
return self._parser().read_file(f, source, self)
|
|
163
|
+
|
|
164
|
+
def read_string(self, string: str, source: str = "<string>") -> Self:
|
|
165
|
+
"""Read configuration from a given string.
|
|
166
|
+
|
|
167
|
+
The file and encoding of a previous ``read*`` call are kept for
|
|
168
|
+
:meth:`update_file`.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
string (str): string containing a configuration
|
|
172
|
+
source (str): reference name for file object, default '<string>'
|
|
173
|
+
"""
|
|
174
|
+
self.clear()
|
|
175
|
+
return self._parser().read_string(string, source, self)
|
|
176
|
+
|
|
177
|
+
def write(self, fp: TextIO, validate: bool = True) -> None:
|
|
178
|
+
# TODO: For Python>=3.8 instead of TextIO we can define a Writeable protocol
|
|
179
|
+
"""Write an .cfg/.ini-format representation of the configuration state.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
fp (file-like object): open file handle
|
|
183
|
+
validate (Boolean): validate format before writing
|
|
184
|
+
"""
|
|
185
|
+
if validate:
|
|
186
|
+
self.validate_format()
|
|
187
|
+
fp.write(str(self))
|
|
188
|
+
|
|
189
|
+
def update_file(self, validate: bool = True) -> Self:
|
|
190
|
+
"""Update the read-in configuration file.
|
|
191
|
+
|
|
192
|
+
The file is written with the encoding it was read with, see :meth:`read` and
|
|
193
|
+
:meth:`read_file`.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
validate (Boolean): validate format before writing
|
|
197
|
+
"""
|
|
198
|
+
if self._filename is None:
|
|
199
|
+
raise NoConfigFileReadError()
|
|
200
|
+
if validate: # validate BEFORE opening the file!
|
|
201
|
+
self.validate_format()
|
|
202
|
+
encoding = io.text_encoding(self._encoding)
|
|
203
|
+
with open(self._filename, "w", encoding=encoding) as fb:
|
|
204
|
+
self.write(fb, validate=False)
|
|
205
|
+
return self
|
|
206
|
+
|
|
207
|
+
def validate_format(self, **kwargs: Any) -> bool:
|
|
208
|
+
"""Given the current state of the ``ConfigUpdater`` object (e.g. after
|
|
209
|
+
modifications), validate its INI/CFG textual representation by parsing it with
|
|
210
|
+
:class:`~configparser.ConfigParser`.
|
|
211
|
+
|
|
212
|
+
The ConfigParser object is instead with the same arguments as the original
|
|
213
|
+
ConfigUpdater object, but the ``kwargs`` can be used to overwrite them.
|
|
214
|
+
|
|
215
|
+
See :meth:`~configupdater.document.Document.validate_format`.
|
|
216
|
+
"""
|
|
217
|
+
return super().validate_format(**{**self._parser_opts, **kwargs})
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Together with :mod:`~configupdater.block` this module forms the basis of
|
|
2
|
+
the class hierarchy in **ConfigUpdater**.
|
|
3
|
+
|
|
4
|
+
The :class:`Container` is the parent class of everything that can contain configuration
|
|
5
|
+
blocks, e.g. a section or the entire file itself.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
from copy import deepcopy
|
|
12
|
+
from textwrap import indent
|
|
13
|
+
from typing import TYPE_CHECKING, Any, Generic, Self, TypeVar
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from .block import Block
|
|
17
|
+
|
|
18
|
+
T = TypeVar("T", bound="Block")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Container(Generic[T]):
|
|
22
|
+
"""Abstract Mixin Class describing a container that holds blocks of type ``T``"""
|
|
23
|
+
|
|
24
|
+
_structure: list[T]
|
|
25
|
+
|
|
26
|
+
def __init__(self) -> None:
|
|
27
|
+
self._structure = []
|
|
28
|
+
|
|
29
|
+
def _repr_blocks(self) -> str:
|
|
30
|
+
blocks = "\n".join(repr(block) for block in self._structure)
|
|
31
|
+
blocks = indent(blocks, " " * 4)
|
|
32
|
+
return f"[\n{blocks.rstrip()}\n]" if blocks.strip() else "[]"
|
|
33
|
+
|
|
34
|
+
def __repr__(self) -> str:
|
|
35
|
+
return f"<{self.__class__.__name__} {self._repr_blocks()}>"
|
|
36
|
+
|
|
37
|
+
def __deepcopy__(self, memo: dict[int, Any]) -> Self:
|
|
38
|
+
clone = self._instantiate_copy()
|
|
39
|
+
memo[id(self)] = clone
|
|
40
|
+
return clone._copy_structure(self._structure, memo)
|
|
41
|
+
|
|
42
|
+
def _copy_structure(self, structure: list[T], memo: dict[int, Any]) -> Self:
|
|
43
|
+
"""``__deepcopy__`` auxiliary method also useful with multi-inheritance"""
|
|
44
|
+
self._structure = [b.attach(self) for b in deepcopy(structure, memo)]
|
|
45
|
+
return self
|
|
46
|
+
|
|
47
|
+
def _instantiate_copy(self) -> Self:
|
|
48
|
+
"""Auxiliary method that allows subclasses calling ``__deepcopy__``"""
|
|
49
|
+
return self.__class__() # allow overwrite for different init args
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def structure(self) -> list[T]:
|
|
53
|
+
return self._structure
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def first_block(self) -> T | None:
|
|
57
|
+
if self._structure:
|
|
58
|
+
return self._structure[0]
|
|
59
|
+
else:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def last_block(self) -> T | None:
|
|
64
|
+
if self._structure:
|
|
65
|
+
return self._structure[-1]
|
|
66
|
+
else:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
def _remove_block(self, idx: int) -> Self:
|
|
70
|
+
"""Remove block at index idx within container
|
|
71
|
+
|
|
72
|
+
Use `.container_idx` of a block to get the index.
|
|
73
|
+
Not meant for users, rather use block.detach() instead!
|
|
74
|
+
"""
|
|
75
|
+
del self._structure[idx]
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
def iter_blocks(self) -> Iterator[T]:
|
|
79
|
+
"""Iterate over all blocks inside container."""
|
|
80
|
+
return iter(self._structure)
|
|
81
|
+
|
|
82
|
+
def __len__(self) -> int:
|
|
83
|
+
"""Number of blocks in container"""
|
|
84
|
+
return len(self._structure)
|
|
85
|
+
|
|
86
|
+
def append(self, block: T) -> Self:
|
|
87
|
+
self._structure.append(block)
|
|
88
|
+
return self
|