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
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ dynamic = [
"version",
]
dependencies = [
"configupdater>=3.2",
"mergedeep>=1.3.1",
"packaging>=20.9",
"pydantic>=2.5.0",
Expand Down Expand Up @@ -198,7 +199,7 @@ name = "File Integrations Modular Design"
type = "layers"
layers = [
"pyproject_toml",
"toml | yaml",
"ini | toml | yaml",
]
containers = [ "usethis._integrations.file" ]
exhaustive = true
Expand Down
Empty file.
31 changes: 31 additions & 0 deletions src/usethis/_integrations/file/ini/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from __future__ import annotations

from usethis.errors import UsethisError


class INIError(UsethisError):
"""Base class for INI-related errors."""


class INIValueAlreadySetError(INIError):
"""Raised when a value is unexpectedly already set in the INI file."""


class INIValueMissingError(KeyError, INIError):
"""Raised when a value is unexpectedly missing from the TOML file."""


class UnexpectedINIOpenError(INIError):
"""Raised when the INI file is unexpectedly opened."""


class ININotFoundError(FileNotFoundError, INIError):
"""Raised when a INI file is unexpectedly not found."""


class INIDecodeError(INIError):
"""Raised when a INI file is unexpectedly not decodable."""


class UnexpectedINIIOError(INIError):
"""Raised when an unexpected attempt is made to read or write the INI file."""
323 changes: 323 additions & 0 deletions src/usethis/_integrations/file/ini/io_.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,323 @@
from __future__ import annotations

import configparser
from functools import singledispatch
from typing import TYPE_CHECKING

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

from usethis._integrations.file.ini.errors import (
INIDecodeError,
ININotFoundError,
INIValueAlreadySetError,
INIValueMissingError,
UnexpectedINIIOError,
UnexpectedINIOpenError,
)
from usethis._io import (
KeyValueFileManager,
UnexpectedFileIOError,
UnexpectedFileOpenError,
)

if TYPE_CHECKING:
from pathlib import Path
from typing import Any, ClassVar

from typing_extensions import Self


class INIFileManager(KeyValueFileManager):
_content_by_path: ClassVar[dict[Path, INIDocument | None]] = {}

def __enter__(self) -> Self:
try:
return super().__enter__()
except UnexpectedFileOpenError as err:
raise UnexpectedINIOpenError(err) from None

def read_file(self) -> None:
try:
super().read_file()
except FileNotFoundError as err:
raise ININotFoundError(err) from None
except UnexpectedFileIOError as err:
raise UnexpectedINIIOError(err) from None
except configparser.ParsingError as err:
msg = f"Failed to decode '{self.name}': {err}"
raise INIDecodeError(msg) from None

def _dump_content(self) -> str:
if self._content is None:
msg = "Content is None, cannot dump."
raise ValueError(msg)

return str(self._content)

def _parse_content(self, content: str) -> INIDocument:
updater = INIDocument()
updater.read_string(content)
return updater

def get(self) -> INIDocument:
return super().get()

def commit(self, document: INIDocument) -> None:
return super().commit(document)

@property
def _content(self) -> INIDocument | None:
return super()._content

@_content.setter
def _content(self, value: INIDocument | None) -> None:
self._content_by_path[self.path] = value

def _validate_lock(self) -> None:
try:
super()._validate_lock()
except UnexpectedFileIOError as err:
raise UnexpectedINIIOError(err) from None

def __contains__(self, keys: list[str]) -> bool:
"""Check if the INI file contains a value at the given key.

An non-existent file will return False.
"""
try:
root = self.get()
except FileNotFoundError:
return False

if len(keys) == 0:
# The root level exists if the file exists
return True
elif len(keys) == 1:
(section_key,) = keys
return section_key in root
elif len(keys) == 2:
section_key, option_key = keys
try:
return option_key in root[section_key]
except KeyError:
return False
else:
# Nested keys can't exist in INI files.
return False

def __getitem__(self, item: list[str]) -> Any:
keys = item

root = self.get()

if len(keys) == 0:
return _as_dict(root)
elif len(keys) == 1:
(section_key,) = keys
return _as_dict(root[section_key])
elif len(keys) == 2:
(section_key, option_key) = keys
return root[section_key][option_key].value
else:
msg = (
f"INI files do not support nested config, whereas access to "
f"'{self.name}' was attempted at '{'.'.join(keys)}'"
)
raise KeyError(msg)

def set_value(
self, *, keys: list[str], value: Any, exists_ok: bool = False
) -> None:
"""Set a value in the INI file.

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

if len(keys) == 0:
self._set_value_in_root(root=root, value=value, exists_ok=exists_ok)
elif len(keys) == 1:
(section_key,) = keys
self._set_value_in_section(
root=root, section_key=keys[0], value=value, exists_ok=exists_ok
)
elif len(keys) == 2:
(section_key, option_key) = keys
self._set_value_in_option(
root=root,
section_key=section_key,
option_key=option_key,
value=value,
exists_ok=exists_ok,
)
else:
msg = (
f"INI files do not support nested config, whereas access to "
f"'{self.name}' was attempted at '{'.'.join(keys)}'"
)
raise ValueError(msg)

self.commit(root)

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

if any(root) and not exists_ok:
msg = "The INI file already has content at the root level"
raise INIValueAlreadySetError(msg)

# We need to remove section that are not in the new dict
# We don't want to remove existing ones to keep their positions.
for section_key in root.sections():
if section_key not in root_dict:
root.remove_section(name=section_key)

TypeAdapter(dict).validate_python(root_dict)
assert isinstance(root_dict, dict)

for section_key, section_dict in root_dict.items():
TypeAdapter(dict).validate_python(section_dict)
assert isinstance(section_dict, dict)

if section_key in root:
for option_key in root[section_key]:
# We need to remove options that are not in the new dict
# We don't want to remove existing ones to keep their positions.
if option_key not in section_dict:
root.remove_option(section=section_key, option=option_key)
else:
root.add_section(section_key)

for option_key, option in section_dict.items():
INIFileManager._validated_set(
root=root,
section_key=section_key,
option_key=option_key,
value=option,
)

@staticmethod
def _set_value_in_section(
*,
root: INIDocument,
section_key: str,
value: dict[str, Any],
exists_ok: bool,
) -> None:
TypeAdapter(dict).validate_python(value)
assert isinstance(value, dict)

section_dict = value

if section_key in root:
if not exists_ok:
msg = f"The INI file already has content at the section '{section_key}'"
raise INIValueAlreadySetError(msg)

for option_key in root[section_key]:
# We need to remove options that are not in the new dict
# We don't want to remove existing ones to keep their positions.
if option_key not in section_dict:
root.remove_option(section=section_key, option=option_key)

for option_key, option in section_dict.items():
INIFileManager._validated_set(
root=root,
section_key=section_key,
option_key=option_key,
value=option,
)

@staticmethod
def _set_value_in_option(
*,
root: INIDocument,
section_key: str,
option_key: str,
value: str,
exists_ok: bool,
) -> None:
if root.has_option(section=section_key, option=option_key) and not exists_ok:
msg = (
f"The INI file already has content at the section '{section_key}' "
f"and option '{option_key}'"
)
raise INIValueAlreadySetError(msg)

INIFileManager._validated_set(
root=root, section_key=section_key, option_key=option_key, value=value
)

@staticmethod
def _validated_set(
*, 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)

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

def __delitem__(self, keys: list[str]) -> None:
"""Delete a value in the INI file.

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

if len(keys) == 0:
removed = False
for section_key in root.sections():
removed |= root.remove_section(name=section_key)
elif len(keys) == 1:
(section_key,) = keys
removed = root.remove_section(name=section_key)
elif len(keys) == 2:
section_key, option_key = keys
removed = root.remove_option(section=section_key, option=option_key)

# Cleanup section if empty
if not root[section_key].options():
removed = root.remove_section(name=section_key)
else:
msg = (
f"INI files do not support nested config, whereas access to "
f"'{self.name}' was attempted at '{'.'.join(keys)}'"
)
raise INIValueMissingError(msg)

if not removed:
msg = f"INI file '{self.name}' does not contain the keys '{'.'.join(keys)}'"
raise INIValueMissingError(msg)

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 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)


@singledispatch
def _as_dict(
value: INIDocument | Section,
) -> dict[str, dict[str, Any]] | dict[str, Any]:
raise NotImplementedError


@_as_dict.register(INIDocument)
def _(value: INIDocument) -> dict[str, dict[str, Any]]:
return {k: _as_dict(v) for k, v in value.items()}


@_as_dict.register(Section)
def _(value: Section) -> dict[str, Any]:
return {option.key: option.value for option in value.iter_options()}
5 changes: 1 addition & 4 deletions src/usethis/_integrations/file/toml/io_.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,6 @@ def __contains__(self, keys: list[str]) -> bool:
def __getitem__(self, item: list[str]) -> Any:
keys = item

if not keys:
msg = "At least one ID key must be provided."
raise ValueError(msg)

d = self.get()
for key in keys:
TypeAdapter(dict).validate_python(d)
Expand Down Expand Up @@ -192,6 +188,7 @@ def __delitem__(self, keys: list[str]) -> None:
assert isinstance(d, dict)
d = d[key]
except KeyError:
# N.B. by convention a del call should raise an error if the key is not found.
msg = f"Configuration value '{'.'.join(keys)}' is missing."
raise TOMLValueMissingError(msg) from None

Expand Down
Loading