Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions src/usethis/_config_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,31 @@ def files_manager() -> Iterator[None]:
with (
PyprojectTOMLManager(),
SetupCFGManager(),
CodespellRCManager(),
CoverageRCManager(),
DotRuffTOMLManager(),
RuffTOMLManager(),
CodespellRCManager(),
ToxINIManager(),
):
yield


class CodespellRCManager(INIFileManager):
"""Class to manage the .codespellrc file."""

@property
def relative_path(self) -> Path:
return Path(".codespellrc")


class CoverageRCManager(INIFileManager):
"""Class to manage the .coveragerc file."""

@property
def relative_path(self) -> Path:
return Path(".coveragerc")


class DotRuffTOMLManager(TOMLFileManager):
"""Class to manage the .ruff.toml file."""

Expand All @@ -41,9 +59,9 @@ def relative_path(self) -> Path:
return Path("ruff.toml")


class CodespellRCManager(INIFileManager):
"""Class to manage the .codespellrc file."""
class ToxINIManager(INIFileManager):
"""Class to manage the tox.ini file."""

@property
def relative_path(self) -> Path:
return Path(".codespellrc")
return Path("tox.ini")
12 changes: 12 additions & 0 deletions src/usethis/_integrations/file/ini/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,15 @@ class INIDecodeError(INIError):

class UnexpectedINIIOError(INIError):
"""Raised when an unexpected attempt is made to read or write the INI file."""


class INIStructureError(INIError):
"""Raised when the INI file has an unexpected structure."""


class InvalidINITypeError(TypeError, INIStructureError):
"""Raised when an invalid type is encountered in the INI file."""


class ININestingError(ValueError, INIStructureError):
"""Raised when there is an unexpected nesting of INI sections."""
149 changes: 136 additions & 13 deletions src/usethis/_integrations/file/ini/io_.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@
from typing import TYPE_CHECKING

from configupdater import ConfigUpdater as INIDocument
from configupdater import Section
from configupdater import Option, Section
from pydantic import TypeAdapter

from usethis._integrations.file.ini.errors import (
INIDecodeError,
ININestingError,
ININotFoundError,
INIValueAlreadySetError,
INIValueMissingError,
InvalidINITypeError,
UnexpectedINIIOError,
UnexpectedINIOpenError,
)
Expand Down Expand Up @@ -157,13 +159,13 @@ def set_value(
f"INI files do not support nested config, whereas access to "
f"'{self.name}' was attempted at '{'.'.join(keys)}'"
)
raise ValueError(msg)
raise ININestingError(msg)

self.commit(root)

@staticmethod
def _set_value_in_root(
root: INIDocument, value: dict[str, Any], exists_ok: bool
root: INIDocument, value: dict[str, dict[str, str | list[str]]], exists_ok: bool
) -> None:
root_dict = value

Expand Down Expand Up @@ -206,7 +208,7 @@ def _set_value_in_section(
*,
root: INIDocument,
section_key: str,
value: dict[str, Any],
value: dict[str, str | list[str]],
exists_ok: bool,
) -> None:
TypeAdapter(dict).validate_python(value)
Expand Down Expand Up @@ -255,13 +257,39 @@ def _set_value_in_option(

@staticmethod
def _validated_set(
*, root: INIDocument, section_key: str, option_key: str, value: str | list[str]
) -> None:
if not isinstance(value, str | list):
msg = (
f"INI files only support strings (or lists of strings), but a "
f"{type(value)} was provided."
)
raise InvalidINITypeError(msg)

if section_key not in root:
root.add_section(section_key)

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

@staticmethod
def _validated_append(
*, root: INIDocument, section_key: str, option_key: str, value: str
) -> None:
if not isinstance(value, str):
msg = f"INI files only support strings, but a {type(value)} was provided."
raise NotImplementedError(msg)
msg = (
f"INI files only support strings (or lists of strings), but a "
f"{type(value)} was provided."
)
raise InvalidINITypeError(msg)

root.set(section=section_key, option=option_key, value=value)
if section_key not in root:
root.add_section(section_key)

if option_key not in root[section_key]:
option = Option(key=option_key, value=value)
root[section_key].add_option(option)
else:
root[section_key][option_key].append(value)

def __delitem__(self, keys: list[str]) -> None:
"""Delete a value in the INI file.
Expand Down Expand Up @@ -297,13 +325,108 @@ def __delitem__(self, keys: list[str]) -> None:

self.commit(root)

def extend_list(self, *, keys: list[str], values: list[Any]) -> None:
msg = "INI files do not support lists, so this operation is not applicable."
raise NotImplementedError(msg)
def extend_list(self, *, keys: list[str], values: list[str]) -> None:
"""Extend a list in the INI file.

An empty list of keys corresponds to the root of the document.
"""
root = self.get()

if len(keys) == 0:
msg = (
f"INI files do not support lists at the root level, whereas access to "
f"'{self.name}' was attempted at '{'.'.join(keys)}'"
)
raise InvalidINITypeError(msg)
elif len(keys) == 1:
msg = (
f"INI files do not support lists at the section level, whereas access "
f"to '{self.name}' was attempted at '{'.'.join(keys)}'"
)
raise InvalidINITypeError(msg)
elif len(keys) == 2:
section_key, option_key = keys
self._extend_list_in_option(
root=root, section_key=section_key, option_key=option_key, values=values
)
else:
msg = (
f"INI files do not support nested config, whereas access to "
f"'{self.name}' was attempted at '{'.'.join(keys)}'"
)
raise ININestingError(msg)

self.commit(root)

@staticmethod
def _extend_list_in_option(
*, root: INIDocument, section_key: str, option_key: str, values: list[str]
) -> None:
for value in values:
INIFileManager._validated_append(
root=root, section_key=section_key, option_key=option_key, value=value
)

@staticmethod
def _remove_from_list_in_option(
*, root: INIDocument, section_key: str, option_key: str, values: list[str]
) -> None:
if section_key not in root:
return

if option_key not in root[section_key]:
return

original_values = root[section_key][option_key].as_list()
# If already not present, silently pass
new_values = [value for value in original_values if value not in values]

if len(new_values) == 0:
# Remove the option if empty
root.remove_option(section=section_key, option=option_key)

# Remove the section if empty
if not root[section_key].options():
root.remove_section(name=section_key)

elif len(new_values) == 1:
# If only one value left, set it directly
root[section_key][option_key] = new_values[0]
elif len(new_values) > 1:
root[section_key][option_key].set_values(new_values)

def remove_from_list(self, *, keys: list[str], values: list[str]) -> None:
"""Remove values from a list in the INI file.

An empty list of keys corresponds to the root of the document.
"""
root = self.get()

if len(keys) == 0:
msg = (
f"INI files do not support lists at the root level, whereas access to "
f"'{self.name}' was attempted at '{'.'.join(keys)}'"
)
raise InvalidINITypeError(msg)
elif len(keys) == 1:
msg = (
f"INI files do not support lists at the section level, whereas access "
f"to '{self.name}' was attempted at '{'.'.join(keys)}'"
)
raise InvalidINITypeError(msg)
elif len(keys) == 2:
section_key, option_key = keys
self._remove_from_list_in_option(
root=root, section_key=section_key, option_key=option_key, values=values
)
else:
msg = (
f"INI files do not support nested config, whereas access to "
f"'{self.name}' was attempted at '{'.'.join(keys)}'"
)
raise ININestingError(msg)

def remove_from_list(self, *, keys: list[str], values: list[Any]) -> None:
msg = "INI files do not support lists, so this operation is not applicable."
raise NotImplementedError(msg)
self.commit(root)


@singledispatch
Expand Down
Loading