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,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"""
@@ -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