# SPDX-FileCopyrightText: 2023 Carmen Bianca BAKKER <carmen@carmenbianca.eu>
#
# SPDX-License-Identifier: EUPL-1.2+
"""The global configuration of Protokolo."""
import tomllib
from collections.abc import Sequence
from copy import deepcopy
from pathlib import Path
from types import UnionType
from typing import IO, Any, Self, cast
import attrs
from ._util import nested_itemgetter, type_in_expected_type
from .exceptions import (
AttributeNotPositiveError,
DictTypeError,
DictTypeListError,
)
from .i18n import _
from .types import StrPath, TOMLValue, TOMLValueType
[docs]
def parse_toml(
toml: str | IO[bytes],
section: Sequence[str] | None = None,
) -> dict[str, Any]:
"""Parse a string representing a TOML file, and return a dictionary
representing the defined section.
Args:
toml: A TOML string or binary file object.
sections: A list of nested sections, for example
``["protokolo", "section"]`` to return the values of
``[protokolo.section]``
Raises:
TypeError: *toml* is not a valid type.
tomllib.TOMLDecodeError: not valid TOML.
"""
if isinstance(toml, str):
values = tomllib.loads(toml)
else:
try:
values = tomllib.load(toml)
except tomllib.TOMLDecodeError:
raise
except Exception as error:
# TRANSLATORS: do not translate TOML, str, or IO[bytes]
raise TypeError(_("TOML must be a str or IO[bytes]")) from error
if not section:
return values
try:
return nested_itemgetter(*section)(values)
except KeyError:
return {}
[docs]
@attrs.define
class TOMLConfig:
"""A utility class to hold data parsed from a TOML file.
Immediately after object instantiation, :meth:`validate` is called.
"""
_values: dict[str, TOMLValue] = attrs.field(factory=dict)
source: str | None = attrs.field(converter=str, default=None)
def __attrs_post_init__(self) -> None:
self._values = deepcopy(self._values)
# Can't use validator on the attribute itself because the validation
# depends on `self`. So we do the validation here.
self.validate()
[docs]
@classmethod
def from_dict(
cls, values: dict[str, Any], source: StrPath | None = None
) -> Self:
"""Generate :class:`TOMLConfig` from a dictionary containing the keys
and values. This is useless for the :class:`TOMLConfig` base class, but
potentially useful for subclasses that change the ``__init__``
signature.
Raises:
DictTypeError: value isn't an expected/supported type.
DictTypeListError: if a list contains elements other than a dict.
"""
return cls(values=values, source=str(source))
def __getitem__(self, key: str | Sequence[str]) -> TOMLValue:
if isinstance(key, str):
keys = [key]
else:
keys = list(key)
return nested_itemgetter(*keys)(self._values)
def __setitem__(self, key: str | Sequence[str], value: TOMLValue) -> None:
if isinstance(key, str):
final_key = key
keys = []
else:
copied = list(key)
final_key = copied.pop()
keys = copied
nested_itemgetter(*keys)(self._values)[final_key] = value
[docs]
def as_dict(self) -> dict[str, TOMLValue]:
"""Return a mapping of the :class:`TOMLConfig`."""
return deepcopy(self._values)
[docs]
def validate(self) -> None:
"""Verify that all keys contain valid TOML types. This is automatically
run on object instantiation.
Raises:
DictTypeError: value isn't an expected/supported type.
DictTypeListError: if a list contains elements that aren't
supported.
"""
self._validate(cast(dict[str, Any], self._values))
def _validate(self, values: dict[str, Any]) -> None:
for name, value in values.items():
# Use typed annotations to expect a very specific type. If not,
# allow any valid TOML type.
expected_type = self.__annotations__.get(f"_{name}", TOMLValueType)
self._validate_item(value, name, expected_type=expected_type)
if isinstance(value, dict):
self._validate(value)
elif isinstance(value, list):
self._validate_list(value, name)
def _validate_item(
self,
item: Any,
name: str,
expected_type: type | UnionType = cast(UnionType, TOMLValueType),
) -> None:
# Because `isinstance(False, int)` is True, but we want it to be False,
# we do some custom magic here to achieve that effect.
bool_err = False
if isinstance(item, bool) and not type_in_expected_type(
bool, expected_type
):
bool_err = True
if bool_err or not isinstance(item, expected_type):
raise DictTypeError(name, expected_type, item, str(self.source))
def _validate_list(
self,
values: list[Any],
name: str,
) -> None:
for item in values:
if isinstance(item, dict):
self._validate(item)
elif isinstance(item, list):
self._validate_list(item, name)
else:
try:
self._validate_item(item, name)
except DictTypeError as error:
raise DictTypeListError(
name, TOMLValueType, item, self.source
) from error
[docs]
@attrs.define
class SectionAttributes(TOMLConfig):
"""A data container to hold some metadata for a :class:`.compile.Section`
object.
"""
_title: str = attrs.field(
default="TODO: No section title defined",
repr=False,
eq=False,
order=False,
)
_level: int = attrs.field(default=1, repr=False, eq=False, order=False)
_order: int | None = attrs.field(
default=None, repr=False, eq=False, order=False
)
def __attrs_post_init__(self) -> None:
self._values = deepcopy(self._values)
# The private variables are no longer used after they are written to
# _values.
self._values.setdefault("title", self._title)
self._values.setdefault("level", self._level)
self._values.setdefault("order", self._order)
super().__attrs_post_init__()
[docs]
@classmethod
def from_dict(
cls, values: dict[str, Any], source: StrPath | None = None
) -> Self:
"""Generate :class:`SectionAttributes` from a dictionary containing the
keys and values.
Raises:
AttributeNotPositiveError: one of the values should have been
positive.
DictTypeError: value isn't an expected/supported type.
DictTypeListError: if a list contains elements other than a dict.
"""
return super().from_dict(values, source=source)
[docs]
def validate(self) -> None:
"""
Raises:
AttributeNotPositiveError: one of the values should have been
positive.
DictTypeError: value isn't an expected/supported type.
DictTypeListError: if a list contains elements other than a dict.
"""
super().validate()
if self.level <= 0:
raise AttributeNotPositiveError(
# TRANSLATORS: do not translate level.
_("level must be a positive integer, got {level}").format(
level=repr(self.level)
)
)
if self.order is not None and self.order <= 0:
raise AttributeNotPositiveError(
# TRANSLATORS: do not translate order.
_(
"order must be None or a positive integer, got {order}"
).format(order=repr(self.order))
)
@property
def title(self) -> str:
"""The title of a section. If no value is provided, it defaults to
'TODO: No section title defined'.
"""
return cast(str, self["title"])
@title.setter
def title(self, value: str) -> None:
self["title"] = value
@property
def level(self) -> int:
"""The level of the section heading, which must not be zero or lower."""
return cast(int, self["level"])
@level.setter
def level(self, value: int) -> None:
self["level"] = value
@property
def order(self) -> int | None:
"""The order of the section in relation to others. It must not be zero
or lower, and may be :const:`None`, in which case it is alphabetically
sorted after all sections that do have an order.
"""
return cast(int | None, self["order"])
@order.setter
def order(self, value: int | None) -> None:
self["order"] = value
[docs]
@attrs.define
class GlobalConfig(TOMLConfig):
"""A container object for config values of the global ``.protokolo.toml``
file.
"""
_changelog: str | None = attrs.field(
default=None, repr=False, eq=False, order=False
)
_markup: str | None = attrs.field(
default=None, repr=False, eq=False, order=False
)
_directory: str | None = attrs.field(
default=None, repr=False, eq=False, order=False
)
_FILE_SECTION = { # pylint: disable=invalid-name
".protokolo.toml": ["protokolo"],
"pyproject.toml": ["tool", "protokolo"],
}
def __attrs_post_init__(self) -> None:
self._values = deepcopy(self._values)
self._values.setdefault("changelog", self._changelog)
self._values.setdefault("markup", self._markup)
self._values.setdefault("directory", self._directory)
super().__attrs_post_init__()
[docs]
@classmethod
def from_file(cls, path: StrPath) -> Self:
"""Factory method to create a :class:`GlobalConfig` from a path. The
exact table that is loaded from the file depends on the file name. In
``pyproject.toml``, the table ``[tool.protokolo]`` is loaded, whereas
``[protokolo]`` is loaded everywhere else.
Raises:
OSError: if the file could not be opened.
tomllib.TOMLDecodeError: if the file could not be decoded.
DictTypeError: value isn't an expected/supported type.
DictTypeListError: if a list contains elements other than a dict.
"""
path = Path(path)
section = cls._FILE_SECTION.get(path.name, ["protokolo"])
with path.open("rb") as fp:
try:
values = parse_toml(fp, section=section)
except tomllib.TOMLDecodeError as error:
raise tomllib.TOMLDecodeError(
_("Invalid TOML in {file_name}: {error}").format(
file_name=repr(fp.name), error=error
)
) from error
return cls.from_dict(values, source=path)
[docs]
@classmethod
def find_config(cls, directory: StrPath) -> Path | None:
"""In *directory*, find the config file.
The order of precedence (highest to lowest) is:
- ``.protokolo.toml``
- ``pyproject.toml``
"""
directory = Path(directory)
for name in cls._FILE_SECTION:
target = directory / name
if target.exists() and target.is_file():
return target
return None
@property
def changelog(self) -> str | None:
"""The path to the change log file."""
return cast(str | None, self["changelog"])
@changelog.setter
def changelog(self, value: str | None) -> None:
self["changelog"] = value
@property
def markup(self) -> str | None:
"""The markup language used by the project."""
return cast(str | None, self["markup"])
@markup.setter
def markup(self, value: str | None) -> None:
self["markup"] = value
@property
def directory(self) -> str | None:
"""The directory where the change log fragments are stored."""
return cast(str | None, self["directory"])
@directory.setter
def directory(self, value: str | None) -> None:
self["directory"] = value