Skip to content

Commit 93086e5

Browse files
Add INI integration (#402)
* Add INI integration * Restore error-raising behaviour for __delitem__ missing key
1 parent ae24f0f commit 93086e5

10 files changed

Lines changed: 1542 additions & 27 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ dynamic = [
3434
"version",
3535
]
3636
dependencies = [
37+
"configupdater>=3.2",
3738
"mergedeep>=1.3.1",
3839
"packaging>=20.9",
3940
"pydantic>=2.5.0",
@@ -198,7 +199,7 @@ name = "File Integrations Modular Design"
198199
type = "layers"
199200
layers = [
200201
"pyproject_toml",
201-
"toml | yaml",
202+
"ini | toml | yaml",
202203
]
203204
containers = [ "usethis._integrations.file" ]
204205
exhaustive = true

src/usethis/_integrations/file/ini/__init__.py

Whitespace-only changes.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from __future__ import annotations
2+
3+
from usethis.errors import UsethisError
4+
5+
6+
class INIError(UsethisError):
7+
"""Base class for INI-related errors."""
8+
9+
10+
class INIValueAlreadySetError(INIError):
11+
"""Raised when a value is unexpectedly already set in the INI file."""
12+
13+
14+
class INIValueMissingError(KeyError, INIError):
15+
"""Raised when a value is unexpectedly missing from the TOML file."""
16+
17+
18+
class UnexpectedINIOpenError(INIError):
19+
"""Raised when the INI file is unexpectedly opened."""
20+
21+
22+
class ININotFoundError(FileNotFoundError, INIError):
23+
"""Raised when a INI file is unexpectedly not found."""
24+
25+
26+
class INIDecodeError(INIError):
27+
"""Raised when a INI file is unexpectedly not decodable."""
28+
29+
30+
class UnexpectedINIIOError(INIError):
31+
"""Raised when an unexpected attempt is made to read or write the INI file."""
Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
from __future__ import annotations
2+
3+
import configparser
4+
from functools import singledispatch
5+
from typing import TYPE_CHECKING
6+
7+
from configupdater import ConfigUpdater as INIDocument
8+
from configupdater import Section
9+
from pydantic import TypeAdapter
10+
11+
from usethis._integrations.file.ini.errors import (
12+
INIDecodeError,
13+
ININotFoundError,
14+
INIValueAlreadySetError,
15+
INIValueMissingError,
16+
UnexpectedINIIOError,
17+
UnexpectedINIOpenError,
18+
)
19+
from usethis._io import (
20+
KeyValueFileManager,
21+
UnexpectedFileIOError,
22+
UnexpectedFileOpenError,
23+
)
24+
25+
if TYPE_CHECKING:
26+
from pathlib import Path
27+
from typing import Any, ClassVar
28+
29+
from typing_extensions import Self
30+
31+
32+
class INIFileManager(KeyValueFileManager):
33+
_content_by_path: ClassVar[dict[Path, INIDocument | None]] = {}
34+
35+
def __enter__(self) -> Self:
36+
try:
37+
return super().__enter__()
38+
except UnexpectedFileOpenError as err:
39+
raise UnexpectedINIOpenError(err) from None
40+
41+
def read_file(self) -> None:
42+
try:
43+
super().read_file()
44+
except FileNotFoundError as err:
45+
raise ININotFoundError(err) from None
46+
except UnexpectedFileIOError as err:
47+
raise UnexpectedINIIOError(err) from None
48+
except configparser.ParsingError as err:
49+
msg = f"Failed to decode '{self.name}': {err}"
50+
raise INIDecodeError(msg) from None
51+
52+
def _dump_content(self) -> str:
53+
if self._content is None:
54+
msg = "Content is None, cannot dump."
55+
raise ValueError(msg)
56+
57+
return str(self._content)
58+
59+
def _parse_content(self, content: str) -> INIDocument:
60+
updater = INIDocument()
61+
updater.read_string(content)
62+
return updater
63+
64+
def get(self) -> INIDocument:
65+
return super().get()
66+
67+
def commit(self, document: INIDocument) -> None:
68+
return super().commit(document)
69+
70+
@property
71+
def _content(self) -> INIDocument | None:
72+
return super()._content
73+
74+
@_content.setter
75+
def _content(self, value: INIDocument | None) -> None:
76+
self._content_by_path[self.path] = value
77+
78+
def _validate_lock(self) -> None:
79+
try:
80+
super()._validate_lock()
81+
except UnexpectedFileIOError as err:
82+
raise UnexpectedINIIOError(err) from None
83+
84+
def __contains__(self, keys: list[str]) -> bool:
85+
"""Check if the INI file contains a value at the given key.
86+
87+
An non-existent file will return False.
88+
"""
89+
try:
90+
root = self.get()
91+
except FileNotFoundError:
92+
return False
93+
94+
if len(keys) == 0:
95+
# The root level exists if the file exists
96+
return True
97+
elif len(keys) == 1:
98+
(section_key,) = keys
99+
return section_key in root
100+
elif len(keys) == 2:
101+
section_key, option_key = keys
102+
try:
103+
return option_key in root[section_key]
104+
except KeyError:
105+
return False
106+
else:
107+
# Nested keys can't exist in INI files.
108+
return False
109+
110+
def __getitem__(self, item: list[str]) -> Any:
111+
keys = item
112+
113+
root = self.get()
114+
115+
if len(keys) == 0:
116+
return _as_dict(root)
117+
elif len(keys) == 1:
118+
(section_key,) = keys
119+
return _as_dict(root[section_key])
120+
elif len(keys) == 2:
121+
(section_key, option_key) = keys
122+
return root[section_key][option_key].value
123+
else:
124+
msg = (
125+
f"INI files do not support nested config, whereas access to "
126+
f"'{self.name}' was attempted at '{'.'.join(keys)}'"
127+
)
128+
raise KeyError(msg)
129+
130+
def set_value(
131+
self, *, keys: list[str], value: Any, exists_ok: bool = False
132+
) -> None:
133+
"""Set a value in the INI file.
134+
135+
An empty list of keys corresponds to the root of the document.
136+
"""
137+
root = self.get()
138+
139+
if len(keys) == 0:
140+
self._set_value_in_root(root=root, value=value, exists_ok=exists_ok)
141+
elif len(keys) == 1:
142+
(section_key,) = keys
143+
self._set_value_in_section(
144+
root=root, section_key=keys[0], value=value, exists_ok=exists_ok
145+
)
146+
elif len(keys) == 2:
147+
(section_key, option_key) = keys
148+
self._set_value_in_option(
149+
root=root,
150+
section_key=section_key,
151+
option_key=option_key,
152+
value=value,
153+
exists_ok=exists_ok,
154+
)
155+
else:
156+
msg = (
157+
f"INI files do not support nested config, whereas access to "
158+
f"'{self.name}' was attempted at '{'.'.join(keys)}'"
159+
)
160+
raise ValueError(msg)
161+
162+
self.commit(root)
163+
164+
@staticmethod
165+
def _set_value_in_root(
166+
root: INIDocument, value: dict[str, Any], exists_ok: bool
167+
) -> None:
168+
root_dict = value
169+
170+
if any(root) and not exists_ok:
171+
msg = "The INI file already has content at the root level"
172+
raise INIValueAlreadySetError(msg)
173+
174+
# We need to remove section that are not in the new dict
175+
# We don't want to remove existing ones to keep their positions.
176+
for section_key in root.sections():
177+
if section_key not in root_dict:
178+
root.remove_section(name=section_key)
179+
180+
TypeAdapter(dict).validate_python(root_dict)
181+
assert isinstance(root_dict, dict)
182+
183+
for section_key, section_dict in root_dict.items():
184+
TypeAdapter(dict).validate_python(section_dict)
185+
assert isinstance(section_dict, dict)
186+
187+
if section_key in root:
188+
for option_key in root[section_key]:
189+
# We need to remove options that are not in the new dict
190+
# We don't want to remove existing ones to keep their positions.
191+
if option_key not in section_dict:
192+
root.remove_option(section=section_key, option=option_key)
193+
else:
194+
root.add_section(section_key)
195+
196+
for option_key, option in section_dict.items():
197+
INIFileManager._validated_set(
198+
root=root,
199+
section_key=section_key,
200+
option_key=option_key,
201+
value=option,
202+
)
203+
204+
@staticmethod
205+
def _set_value_in_section(
206+
*,
207+
root: INIDocument,
208+
section_key: str,
209+
value: dict[str, Any],
210+
exists_ok: bool,
211+
) -> None:
212+
TypeAdapter(dict).validate_python(value)
213+
assert isinstance(value, dict)
214+
215+
section_dict = value
216+
217+
if section_key in root:
218+
if not exists_ok:
219+
msg = f"The INI file already has content at the section '{section_key}'"
220+
raise INIValueAlreadySetError(msg)
221+
222+
for option_key in root[section_key]:
223+
# We need to remove options that are not in the new dict
224+
# We don't want to remove existing ones to keep their positions.
225+
if option_key not in section_dict:
226+
root.remove_option(section=section_key, option=option_key)
227+
228+
for option_key, option in section_dict.items():
229+
INIFileManager._validated_set(
230+
root=root,
231+
section_key=section_key,
232+
option_key=option_key,
233+
value=option,
234+
)
235+
236+
@staticmethod
237+
def _set_value_in_option(
238+
*,
239+
root: INIDocument,
240+
section_key: str,
241+
option_key: str,
242+
value: str,
243+
exists_ok: bool,
244+
) -> None:
245+
if root.has_option(section=section_key, option=option_key) and not exists_ok:
246+
msg = (
247+
f"The INI file already has content at the section '{section_key}' "
248+
f"and option '{option_key}'"
249+
)
250+
raise INIValueAlreadySetError(msg)
251+
252+
INIFileManager._validated_set(
253+
root=root, section_key=section_key, option_key=option_key, value=value
254+
)
255+
256+
@staticmethod
257+
def _validated_set(
258+
*, root: INIDocument, section_key: str, option_key: str, value: str
259+
) -> None:
260+
if not isinstance(value, str):
261+
msg = f"INI files only support strings, but a {type(value)} was provided."
262+
raise NotImplementedError(msg)
263+
264+
root.set(section=section_key, option=option_key, value=value)
265+
266+
def __delitem__(self, keys: list[str]) -> None:
267+
"""Delete a value in the INI file.
268+
269+
An empty list of keys corresponds to the root of the document.
270+
"""
271+
root = self.get()
272+
273+
if len(keys) == 0:
274+
removed = False
275+
for section_key in root.sections():
276+
removed |= root.remove_section(name=section_key)
277+
elif len(keys) == 1:
278+
(section_key,) = keys
279+
removed = root.remove_section(name=section_key)
280+
elif len(keys) == 2:
281+
section_key, option_key = keys
282+
removed = root.remove_option(section=section_key, option=option_key)
283+
284+
# Cleanup section if empty
285+
if not root[section_key].options():
286+
removed = root.remove_section(name=section_key)
287+
else:
288+
msg = (
289+
f"INI files do not support nested config, whereas access to "
290+
f"'{self.name}' was attempted at '{'.'.join(keys)}'"
291+
)
292+
raise INIValueMissingError(msg)
293+
294+
if not removed:
295+
msg = f"INI file '{self.name}' does not contain the keys '{'.'.join(keys)}'"
296+
raise INIValueMissingError(msg)
297+
298+
self.commit(root)
299+
300+
def extend_list(self, *, keys: list[str], values: list[Any]) -> None:
301+
msg = "INI files do not support lists, so this operation is not applicable."
302+
raise NotImplementedError(msg)
303+
304+
def remove_from_list(self, *, keys: list[str], values: list[Any]) -> None:
305+
msg = "INI files do not support lists, so this operation is not applicable."
306+
raise NotImplementedError(msg)
307+
308+
309+
@singledispatch
310+
def _as_dict(
311+
value: INIDocument | Section,
312+
) -> dict[str, dict[str, Any]] | dict[str, Any]:
313+
raise NotImplementedError
314+
315+
316+
@_as_dict.register(INIDocument)
317+
def _(value: INIDocument) -> dict[str, dict[str, Any]]:
318+
return {k: _as_dict(v) for k, v in value.items()}
319+
320+
321+
@_as_dict.register(Section)
322+
def _(value: Section) -> dict[str, Any]:
323+
return {option.key: option.value for option in value.iter_options()}

src/usethis/_integrations/file/toml/io_.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,6 @@ def __contains__(self, keys: list[str]) -> bool:
104104
def __getitem__(self, item: list[str]) -> Any:
105105
keys = item
106106

107-
if not keys:
108-
msg = "At least one ID key must be provided."
109-
raise ValueError(msg)
110-
111107
d = self.get()
112108
for key in keys:
113109
TypeAdapter(dict).validate_python(d)
@@ -192,6 +188,7 @@ def __delitem__(self, keys: list[str]) -> None:
192188
assert isinstance(d, dict)
193189
d = d[key]
194190
except KeyError:
191+
# N.B. by convention a del call should raise an error if the key is not found.
195192
msg = f"Configuration value '{'.'.join(keys)}' is missing."
196193
raise TOMLValueMissingError(msg) from None
197194

0 commit comments

Comments
 (0)