Skip to content

Commit 01a879a

Browse files
committed
4th reorganisation step: Fix parser and make sure tests run
1 parent 528b0fa commit 01a879a

File tree

10 files changed

+256
-112
lines changed

10 files changed

+256
-112
lines changed

src/configupdater/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@
1717

1818
# import everything and rely on __ALL__
1919
from .configupdater import * # noqa
20+
from .parser import * # noqa

src/configupdater/block.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ def __init__(self, container: "Container[T]"):
3333
def __str__(self) -> str:
3434
return "".join(self._lines)
3535

36+
def __repr__(self) -> str:
37+
return f"<{self.__class__.__name__}: {str(self)!r}>"
38+
3639
def __eq__(self, other) -> bool:
3740
if isinstance(other, self.__class__):
3841
return str(self) == str(other)
@@ -113,15 +116,9 @@ class Comment(Block[T]):
113116
def __init__(self, container: "Container[T]"):
114117
super().__init__(container=container)
115118

116-
def __repr__(self) -> str:
117-
return "<Comment>"
118-
119119

120120
class Space(Block[T]):
121121
"""Vertical space block of new lines"""
122122

123123
def __init__(self, container: "Container[T]"):
124124
super().__init__(container=container)
125-
126-
def __repr__(self) -> str:
127-
return "<Space>"

src/configupdater/builder.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ def section(self: T, section) -> T:
5656
# create a new section
5757
section = Section(section, container=container)
5858
elif not isinstance(section, Section):
59-
raise ValueError("Parameter must be a string or Section type!")
59+
msg = "Parameter must be a string or Section type!"
60+
raise ValueError(msg, {"container": section})
6061

6162
if container.has_section(section.name):
6263
raise DuplicateSectionError(section.name)
@@ -98,7 +99,8 @@ def option(self: T, key, value=None, **kwargs) -> T:
9899
from .section import Section
99100

100101
if not isinstance(self._container, Section):
101-
raise ValueError("Options can only be added inside a section!")
102+
msg = "Options can only be added inside a section!"
103+
raise ValueError(msg, {"container": self._container})
102104
option = Option(key, value, container=self._container, **kwargs)
103105
if option.key in self._container.options():
104106
raise DuplicateOptionError(self._container.name, option.key)

src/configupdater/configupdater.py

Lines changed: 56 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,32 @@
1-
import io
2-
import os
31
import sys
42
from configparser import Error
5-
from typing import Optional, TextIO, Tuple, Union, cast
3+
from typing import Optional, TextIO, Tuple, TypeVar
64

75
if sys.version_info[:2] >= (3, 9):
8-
from collections.abc import Iterable, MutableMapping
6+
from collections.abc import Iterable
97

108
List = list
119
Dict = dict
1210
else:
13-
from typing import Iterable, MutableMapping
11+
from typing import Iterable
12+
13+
from .block import Comment, Space
14+
from .document import Document
15+
from .option import Option
16+
from .parser import Parser
17+
from .section import Section
18+
19+
__all__ = [
20+
"ConfigUpdater",
21+
"Section",
22+
"Option",
23+
"Comment",
24+
"Space",
25+
"Parser",
26+
"NoConfigFileReadError",
27+
]
28+
29+
T = TypeVar("T", bound="ConfigUpdater")
1430

1531

1632
class NoConfigFileReadError(Error):
@@ -20,11 +36,8 @@ def __init__(self):
2036
super().__init__("No configuration file was yet read! Use .read(...) first.")
2137

2238

23-
ConfigContent = Union["Section", "Comment", "Space"]
24-
25-
26-
class ConfigUpdater(Container[ConfigContent], MutableMapping[str, Section]):
27-
"""Parser for updating configuration files.
39+
class ConfigUpdater(Document):
40+
"""Tool to parse and modify existing ``cfg`` files.
2841
2942
ConfigUpdater follows the API of ConfigParser with some differences:
3043
* inline comments are treated as part of a key's value,
@@ -52,20 +65,34 @@ def __init__(
5265
empty_lines_in_values: bool = True,
5366
space_around_delimiters: bool = True,
5467
):
55-
pass
56-
57-
def read(self, filename: str, encoding: Optional[str] = None):
68+
self._parser_opts = {
69+
"allow_no_value": allow_no_value,
70+
"delimiters": delimiters,
71+
"comment_prefixes": comment_prefixes,
72+
"inline_comment_prefixes": inline_comment_prefixes,
73+
"strict": strict,
74+
"empty_lines_in_values": empty_lines_in_values,
75+
"space_around_delimiters": space_around_delimiters,
76+
}
77+
self._filename: Optional[str] = None
78+
super().__init__()
79+
80+
def _parser(self, **kwargs):
81+
opts = {"optionxform": self.optionxform, **self._parser_opts, **kwargs}
82+
return Parser(**opts)
83+
84+
def read(self: T, filename: str, encoding: Optional[str] = None) -> T:
5885
"""Read and parse a filename.
5986
6087
Args:
6188
filename (str): path to file
6289
encoding (str): encoding of file, default None
6390
"""
64-
with open(filename, encoding=encoding) as fp:
65-
self._read(fp, filename)
66-
self._filename = os.path.abspath(filename)
91+
self.remove_all()
92+
self._filename = filename
93+
return self._parser().read(filename, encoding, self)
6794

68-
def read_file(self, f: Iterable[str], source: Optional[str] = None):
95+
def read_file(self: T, f: Iterable[str], source: Optional[str] = None) -> T:
6996
"""Like read() but the argument must be a file-like object.
7097
7198
The ``f`` argument must be iterable, returning one line at a time.
@@ -77,24 +104,20 @@ def read_file(self, f: Iterable[str], source: Optional[str] = None):
77104
f: file like object
78105
source (str): reference name for file object, default None
79106
"""
80-
if isinstance(f, str):
81-
raise RuntimeError("f must be a file-like object, not string!")
82-
if source is None:
83-
try:
84-
source = cast(str, cast(io.FileIO, f).name)
85-
except AttributeError:
86-
source = "<???>"
87-
self._read(f, source)
88-
89-
def read_string(self, string: str, source="<string>"):
107+
self.remove_all()
108+
if hasattr(f, "name"):
109+
self._filename = f.name # type: ignore[attr-defined]
110+
return self._parser().read_file(f, source, self)
111+
112+
def read_string(self: T, string: str, source="<string>") -> T:
90113
"""Read configuration from a given string.
91114
92115
Args:
93116
string (str): string containing a configuration
94117
source (str): reference name for file object, default '<string>'
95118
"""
96-
sfile = io.StringIO(string)
97-
self.read_file(sfile, source)
119+
self.remove_all()
120+
return self._parser().read_string(string, source, self)
98121

99122
def optionxform(self, optionstr) -> str:
100123
"""Converts an option key to lower case for unification
@@ -116,10 +139,10 @@ def write(self, fp: TextIO, validate: bool = True):
116139
validate (Boolean): validate format before writing
117140
"""
118141
if validate:
119-
self.validate_format()
142+
self.validate_format(**self._parser_opts)
120143
fp.write(str(self))
121144

122-
def update_file(self, validate: bool = True):
145+
def update_file(self: T, validate: bool = True) -> T:
123146
"""Update the read-in configuration file.
124147
125148
Args:
@@ -128,6 +151,7 @@ def update_file(self, validate: bool = True):
128151
if self._filename is None:
129152
raise NoConfigFileReadError()
130153
if validate: # validate BEFORE opening the file!
131-
self.validate_format()
154+
self.validate_format(**self._parser_opts)
132155
with open(self._filename, "w") as fb:
133156
self.write(fb, validate=False)
157+
return self

src/configupdater/container.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import sys
22
from abc import ABC
3+
from textwrap import indent
34
from typing import Generic, Optional, TypeVar
45

56
if sys.version_info[:2] >= (3, 9):
@@ -19,6 +20,14 @@ class Container(ABC, Generic[T]):
1920
def __init__(self):
2021
self._structure: List[T] = []
2122

23+
def _repr_blocks(self) -> str:
24+
blocks = "\n".join(repr(block) for block in self._structure)
25+
blocks = indent(blocks, " " * 4)
26+
return f"[\n{blocks.rstrip()}\n]" if blocks.strip() else "[]"
27+
28+
def __repr__(self) -> str:
29+
return f"<{self.__class__.__name__} {self._repr_blocks()}>"
30+
2231
@property
2332
def structure(self) -> List[T]:
2433
return self._structure
@@ -53,3 +62,11 @@ def iter_blocks(self) -> Iterator[T]:
5362
def __len__(self) -> int:
5463
"""Number of blocks in container"""
5564
return len(self._structure)
65+
66+
def append(self: C, block: T) -> C:
67+
self._structure.append(block)
68+
return self
69+
70+
def remove_all(self: C) -> C:
71+
self._structure.clear()
72+
return self

src/configupdater/document.py

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -59,17 +59,9 @@ def validate_format(self, **kwargs):
5959
Args:
6060
kwargs: are passed to :class:`configparser.ConfigParser`
6161
"""
62-
args = dict(
63-
dict_type=self._dict,
64-
allow_no_value=self._allow_no_value,
65-
inline_comment_prefixes=self._inline_comment_prefixes,
66-
strict=self._strict,
67-
empty_lines_in_values=self._empty_lines_in_values,
68-
)
69-
args.update(kwargs)
70-
parser = ConfigParser(**args)
71-
updated_cfg = str(self)
72-
parser.read_string(updated_cfg)
62+
kwargs.pop("space_around_delimiters", None)
63+
parser = ConfigParser(**kwargs)
64+
parser.read_string(str(self))
7365

7466
def iter_sections(self) -> Iterator[Section]:
7567
"""Iterate only over section blocks"""

src/configupdater/option.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ def __str__(self) -> str:
7272
return "{}{}{}{}".format(self._key, delim, self._value, "\n")
7373

7474
def __repr__(self) -> str:
75-
return "<Option: {} = {}>".format(self.key, self.value)
75+
return f"<Option: {self._key} = {self.value!r}>"
7676

7777
@property
7878
def key(self) -> str:

0 commit comments

Comments
 (0)