Skip to content

Commit 868463e

Browse files
Provide support for ConfigItem to manage a regex pattern of keys (#522)
* Implement regex-based key interface * Test removal of a regex-based ConfigEntry (and null effect of adding) * Remove unnecessary trailing whitespace in test file Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Better document reason for not implementing regex-based keys for TOML config --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent d7851f4 commit 868463e

File tree

5 files changed

+346
-21
lines changed

5 files changed

+346
-21
lines changed

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

Lines changed: 123 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
from __future__ import annotations
22

33
import configparser
4+
import re
45
from functools import singledispatch
56
from typing import TYPE_CHECKING
67

78
from configupdater import ConfigUpdater as INIDocument
89
from configupdater import Option, Section
910
from pydantic import TypeAdapter
11+
from typing_extensions import assert_never
1012

1113
from usethis._integrations.file.ini.errors import (
1214
INIDecodeError,
@@ -27,7 +29,7 @@
2729
)
2830

2931
if TYPE_CHECKING:
30-
from collections.abc import Sequence
32+
from collections.abc import Iterable, Sequence
3133
from pathlib import Path
3234
from typing import Any, ClassVar
3335

@@ -121,9 +123,21 @@ def __getitem__(self, item: Sequence[Key]) -> Any:
121123
return _as_dict(root)
122124
elif len(keys) == 1:
123125
(section_key,) = keys
126+
if not isinstance(section_key, str):
127+
msg = (
128+
f"Only hard-coded strings are supported as keys when "
129+
f"accessing values, but a {type(section_key)} was provided."
130+
)
131+
raise NotImplementedError(msg)
124132
return _as_dict(root[section_key])
125133
elif len(keys) == 2:
126134
(section_key, option_key) = keys
135+
if not isinstance(section_key, str) or not isinstance(option_key, str):
136+
msg = (
137+
f"Only hard-coded strings are supported as keys when "
138+
f"accessing values, but a {type(section_key)} was provided."
139+
)
140+
raise NotImplementedError(msg)
127141
return root[section_key][option_key].value
128142
else:
129143
msg = (
@@ -210,7 +224,7 @@ def _set_value_in_root(
210224
def _set_value_in_section(
211225
*,
212226
root: INIDocument,
213-
section_key: str,
227+
section_key: Key,
214228
value: dict[str, str | list[str]],
215229
exists_ok: bool,
216230
) -> None:
@@ -224,6 +238,13 @@ def _set_value_in_section(
224238
msg = f"The INI file already has content at the section '{section_key}'"
225239
raise INIValueAlreadySetError(msg)
226240

241+
if not isinstance(section_key, str):
242+
msg = (
243+
f"Only hard-coded strings are supported as section keys when "
244+
f"setting values, but a {type(section_key)} was provided."
245+
)
246+
raise NotImplementedError(msg)
247+
227248
for option_key in root[section_key]:
228249
# We need to remove options that are not in the new dict
229250
# We don't want to remove existing ones to keep their positions.
@@ -242,11 +263,18 @@ def _set_value_in_section(
242263
def _set_value_in_option(
243264
*,
244265
root: INIDocument,
245-
section_key: str,
246-
option_key: str,
266+
section_key: Key,
267+
option_key: Key,
247268
value: str,
248269
exists_ok: bool,
249270
) -> None:
271+
if not isinstance(section_key, str) or not isinstance(option_key, str):
272+
msg = (
273+
f"Only hard-coded strings are supported as keys when "
274+
f"setting values, but a {type(section_key)} was provided."
275+
)
276+
raise NotImplementedError(msg)
277+
250278
if root.has_option(section=section_key, option=option_key) and not exists_ok:
251279
msg = (
252280
f"The INI file already has content at the section '{section_key}' "
@@ -260,7 +288,7 @@ def _set_value_in_option(
260288

261289
@staticmethod
262290
def _validated_set(
263-
*, root: INIDocument, section_key: str, option_key: str, value: str | list[str]
291+
*, root: INIDocument, section_key: Key, option_key: Key, value: str | list[str]
264292
) -> None:
265293
if not isinstance(value, str | list):
266294
msg = (
@@ -269,14 +297,21 @@ def _validated_set(
269297
)
270298
raise InvalidINITypeError(msg)
271299

300+
if not isinstance(section_key, str) or not isinstance(option_key, str):
301+
msg = (
302+
f"Only hard-coded strings are supported as keys when "
303+
f"setting values, but a {type(section_key)} was provided."
304+
)
305+
raise NotImplementedError(msg)
306+
272307
if section_key not in root:
273308
root.add_section(section_key)
274309

275310
root.set(section=section_key, option=option_key, value=value)
276311

277312
@staticmethod
278313
def _validated_append(
279-
*, root: INIDocument, section_key: str, option_key: str, value: str
314+
*, root: INIDocument, section_key: Key, option_key: Key, value: str
280315
) -> None:
281316
if not isinstance(value, str):
282317
msg = (
@@ -285,6 +320,13 @@ def _validated_append(
285320
)
286321
raise InvalidINITypeError(msg)
287322

323+
if not isinstance(section_key, str) or not isinstance(option_key, str):
324+
msg = (
325+
f"Only hard-coded strings are supported as keys when "
326+
f"setting values, but a {type(section_key)} was provided."
327+
)
328+
raise NotImplementedError(msg)
329+
288330
if section_key not in root:
289331
root.add_section(section_key)
290332

@@ -299,17 +341,62 @@ def __delitem__(self, keys: Sequence[Key]) -> None:
299341
300342
An empty list of keys corresponds to the root of the document.
301343
"""
302-
root = self.get()
344+
# We will iterate through keys and find all matches in the document
345+
seqs: list[list[str]] = []
303346

304347
if len(keys) == 0:
348+
seqs.append([])
349+
elif len(keys) == 1:
350+
(section_key,) = keys
351+
352+
for seq in _itermatches(self.get().sections(), key=section_key):
353+
seqs.append([seq])
354+
elif len(keys) == 2:
355+
(section_key, option_key) = keys
356+
357+
section_strkeys = []
358+
for section_strkey in _itermatches(self.get().sections(), key=section_key):
359+
section_strkeys.append(section_strkey)
360+
361+
for section_strkey in section_strkeys:
362+
for option_strkey in _itermatches(
363+
self.get()[section_strkey].options(), key=option_key
364+
):
365+
seqs.append([section_strkey, option_strkey])
366+
else:
367+
msg = (
368+
f"INI files do not support nested config, whereas access to "
369+
f"'{self.name}' was attempted at '{print_keys(keys)}'"
370+
)
371+
raise ININestingError(msg)
372+
373+
if not seqs:
374+
msg = (
375+
f"INI file '{self.name}' does not contain the keys '{print_keys(keys)}'"
376+
)
377+
raise INIValueMissingError(msg)
378+
379+
for seq in seqs:
380+
self._delete_strkeys(seq)
381+
382+
def _delete_strkeys(self, strkeys: Sequence[str]) -> None:
383+
"""Delete a specific value in the INI file.
384+
385+
An empty list of strkeys corresponds to the root of the document.
386+
387+
Assumes that the keys exist in the file.
388+
"""
389+
root = self.get()
390+
391+
if len(strkeys) == 0:
305392
removed = False
306393
for section_key in root.sections():
307394
removed |= root.remove_section(name=section_key)
308-
elif len(keys) == 1:
309-
(section_key,) = keys
395+
elif len(strkeys) == 1:
396+
(section_key,) = strkeys
310397
removed = root.remove_section(name=section_key)
311-
elif len(keys) == 2:
312-
section_key, option_key = keys
398+
elif len(strkeys) == 2:
399+
section_key, option_key = strkeys
313400
removed = root.remove_option(section=section_key, option=option_key)
314401

315402
# Cleanup section if empty
@@ -318,14 +405,12 @@ def __delitem__(self, keys: Sequence[Key]) -> None:
318405
else:
319406
msg = (
320407
f"INI files do not support nested config, whereas access to "
321-
f"'{self.name}' was attempted at '{print_keys(keys)}'"
408+
f"'{self.name}' was attempted at '{print_keys(strkeys)}'"
322409
)
323-
raise INIValueMissingError(msg)
410+
raise ININestingError(msg)
324411

325412
if not removed:
326-
msg = (
327-
f"INI file '{self.name}' does not contain the keys '{print_keys(keys)}'"
328-
)
413+
msg = f"INI file '{self.name}' does not contain the keys '{print_keys(strkeys)}'"
329414
raise INIValueMissingError(msg)
330415

331416
self.commit(root)
@@ -365,7 +450,7 @@ def extend_list(self, *, keys: Sequence[Key], values: list[str]) -> None:
365450

366451
@staticmethod
367452
def _extend_list_in_option(
368-
*, root: INIDocument, section_key: str, option_key: str, values: list[str]
453+
*, root: INIDocument, section_key: Key, option_key: Key, values: list[str]
369454
) -> None:
370455
for value in values:
371456
INIFileManager._validated_append(
@@ -374,14 +459,21 @@ def _extend_list_in_option(
374459

375460
@staticmethod
376461
def _remove_from_list_in_option(
377-
*, root: INIDocument, section_key: str, option_key: str, values: list[str]
462+
*, root: INIDocument, section_key: Key, option_key: Key, values: list[str]
378463
) -> None:
379464
if section_key not in root:
380465
return
381466

382467
if option_key not in root[section_key]:
383468
return
384469

470+
if not isinstance(section_key, str) or not isinstance(option_key, str):
471+
msg = (
472+
f"Only hard-coded strings are supported as keys when "
473+
f"modifying values, but a {type(section_key)} was provided."
474+
)
475+
raise NotImplementedError(msg)
476+
385477
original_values = root[section_key][option_key].as_list()
386478
# If already not present, silently pass
387479
new_values = [value for value in original_values if value not in values]
@@ -449,3 +541,16 @@ def _(value: INIDocument) -> dict[str, dict[str, Any]]:
449541
@_as_dict.register(Section)
450542
def _(value: Section) -> dict[str, Any]:
451543
return {option.key: option.value for option in value.iter_options()}
544+
545+
546+
def _itermatches(values: Iterable[str], /, *, key: Key):
547+
"""Iterate through an iterable and find all matches for a key."""
548+
for value in values:
549+
if isinstance(key, str):
550+
if key == value:
551+
yield value
552+
elif isinstance(key, re.Pattern):
553+
if key.fullmatch(value):
554+
yield value
555+
else:
556+
assert_never(key)

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import contextlib
44
import copy
5+
import re
56
from typing import TYPE_CHECKING, Any
67

78
import mergedeep
@@ -10,6 +11,7 @@
1011
from pydantic import TypeAdapter
1112
from tomlkit import TOMLDocument
1213
from tomlkit.exceptions import TOMLKitError
14+
from typing_extensions import assert_never
1315

1416
from usethis._integrations.file.toml.errors import (
1517
TOMLDecodeError,
@@ -34,6 +36,8 @@
3436

3537
from typing_extensions import Self
3638

39+
from usethis._io import Key
40+
3741

3842
class TOMLFileManager(KeyValueFileManager):
3943
"""An abstract class for managing TOML files."""
@@ -92,6 +96,7 @@ def __contains__(self, keys: Sequence[Key]) -> bool:
9296
9397
An non-existent file will return False.
9498
"""
99+
keys = _validate_keys(keys)
95100
try:
96101
try:
97102
container = self.get()
@@ -108,6 +113,7 @@ def __contains__(self, keys: Sequence[Key]) -> bool:
108113

109114
def __getitem__(self, item: Sequence[Key]) -> Any:
110115
keys = item
116+
keys = _validate_keys(keys)
111117

112118
d = self.get()
113119
for key in keys:
@@ -125,6 +131,7 @@ def set_value(
125131
An empty list of keys corresponds to the root of the document.
126132
"""
127133
toml_document = copy.copy(self.get())
134+
keys = _validate_keys(keys)
128135

129136
if not keys:
130137
# Root level config - value must be a mapping.
@@ -196,6 +203,7 @@ def __delitem__(self, keys: Sequence[Key]) -> None:
196203
toml_document = copy.copy(self.get())
197204
except FileNotFoundError:
198205
return
206+
keys = _validate_keys(keys)
199207

200208
# Exit early if the configuration is not present.
201209
try:
@@ -250,6 +258,7 @@ def extend_list(self, *, keys: Sequence[Key], values: list[Any]) -> None:
250258
if not keys:
251259
msg = "At least one ID key must be provided."
252260
raise ValueError(msg)
261+
keys = _validate_keys(keys)
253262

254263
toml_document = copy.copy(self.get())
255264

@@ -283,6 +292,7 @@ def remove_from_list(self, *, keys: Sequence[Key], values: list[Any]) -> None:
283292
if not keys:
284293
msg = "At least one ID key must be provided."
285294
raise ValueError(msg)
295+
keys = _validate_keys(keys)
286296

287297
toml_document = copy.copy(self.get())
288298

@@ -312,7 +322,35 @@ def remove_from_list(self, *, keys: Sequence[Key], values: list[Any]) -> None:
312322
self.commit(toml_document)
313323

314324

325+
def _validate_keys(keys: Sequence[Key]) -> list[str]:
326+
"""Validate the keys.
327+
328+
Args:
329+
keys: The keys to validate.
330+
331+
Raises:
332+
ValueError: If the keys are not valid.
333+
"""
334+
so_far_keys: list[str] = []
335+
for key in keys:
336+
if isinstance(key, str):
337+
so_far_keys.append(key)
338+
elif isinstance(key, re.Pattern):
339+
# Currently no need for this, perhaps we may add it in the future.
340+
msg = (
341+
f"Regex-based keys are not currently supported in TOML files: "
342+
f"{print_keys([*so_far_keys, key])}"
343+
)
344+
raise NotImplementedError(msg)
345+
else:
346+
assert_never(key)
347+
348+
return so_far_keys
349+
350+
315351
def _get_unified_key(keys: Sequence[Key]) -> tomlkit.items.Key:
352+
keys = _validate_keys(keys)
353+
316354
single_keys = [tomlkit.items.SingleKey(key) for key in keys]
317355
if len(single_keys) == 1:
318356
(unified_key,) = single_keys

0 commit comments

Comments
 (0)