#!/usr/bin/env python3
#
# __init__.py
"""
Nested configuration parsed from a TOML file.
.. extras-require:: config
:pyproject:
.. versionadded:: 2.2.0
"""
#
# Copyright © 2026 Dominic Davis-Foster <dominic@davis-foster.co.uk>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
# OR OTHER DEALINGS IN THE SOFTWARE.
#
# stdlib
import json
import re
from typing import Any, Callable, Dict, Mapping, Type, TypeVar, Union
# 3rd party
import attrs # nodep
import tomli_w # nodep
from typing_extensions import Self # nodep
# this package
import dom_toml
__all__ = ("Config", "subtable_field", "to_kebab_case", "table_name")
_C = TypeVar("_C", bound="Config")
[docs]def subtable_field(submethod_type: Type[_C]) -> _C:
"""
Attrs field for a nested table.
:param submethod_type: The :class:`~.Config` for the table.
"""
def on_setattr(inst: object, attr: attrs.Attribute, value: Any) -> _C:
return submethod_type._coerce(value)
# Actually returns attr.Attribute, but mypy doesn't like it
return attrs.field(factory=submethod_type, converter=submethod_type._coerce, on_setattr=on_setattr)
_case_boundary_re = re.compile(r"([a-z])([A-Z])")
_single_letters_re = re.compile(r"([A-Z]|\d)([A-Z])([a-z])")
_underscore_boundary_re = re.compile(r'(\S)(_)(\S)')
[docs]def to_kebab_case(value: Union[str, Type["Config"], "Config"]) -> str:
"""
Convert the given string into ``kebab-case``.
:param value:
:rtype:
.. versionadded:: 2.3.0
"""
if isinstance(value, str):
pass
elif isinstance(value, Config):
value = getattr(value, "table_name", value.__class__.__name__)
elif value is Config or (isinstance(value, type) and issubclass(value, Config)):
value = getattr(value, "table_name", value.__name__)
# Matches VSCode behaviour
case_boundary = _case_boundary_re.findall(value)
single_letters = _single_letters_re.findall(value)
underscore_boundary = _underscore_boundary_re.findall(value)
if not case_boundary and not single_letters and not underscore_boundary:
return value.lower()
value = _case_boundary_re.sub(r"\1-\2", value)
value = _single_letters_re.sub(r"\1-\2\3", value)
value = _underscore_boundary_re.sub(r"\1-\3", value)
return value.lower()
[docs]def table_name(name: str) -> Callable[[Type[_C]], Type[_C]]:
"""
Decorator to override the table name on a :class:`~.Config` class.
:param name: The new table name.
:rtype:
.. versionadded:: 2.3.0
"""
def deco(config_class: Type[_C]) -> Type[_C]:
config_class.table_name = name # type: ignore[attr-defined]
return config_class
return deco
[docs]@attrs.define
class Config:
"""
Configuration parsed from a TOML file.
"""
[docs] @classmethod
def from_dict(cls: Type[Self], config: Mapping[str, Any]) -> Self:
"""
Construct a :class:`~.Config` from a dictionary or TOML table.
:param config:
"""
return cls(**config)
[docs] def to_dict(self) -> Dict[str, Any]:
"""
Convert a :class:`~.Config` to a dictionary.
"""
return attrs.asdict(self, recurse=True)
[docs] @classmethod
def from_toml(cls: Type[Self], toml_string: str) -> Self:
"""
Parse a :class:`~.Config` from a TOML string.
:param toml_string:
"""
parsed_toml = dom_toml.loads(toml_string)
return cls(**parsed_toml[to_kebab_case(cls)])
[docs] @classmethod
def from_json(cls: Type[Self], json_string: str) -> Self:
"""
Parse a :class:`~.Config` from a JSON string.
:param json_string:
"""
parsed_json = json.loads(json_string)
return cls(**parsed_json[to_kebab_case(cls)])
[docs] def to_toml(self) -> str:
"""
Convert a :class:`~.Config` to a TOML string.
"""
return tomli_w.dumps({to_kebab_case(self): self.to_dict()})
@classmethod
def _coerce(cls: Type[Self], config: Any) -> Self:
if isinstance(config, cls):
return config
elif isinstance(config, Mapping):
return cls(**config)
else:
class_name = cls.__name__
if class_name[0] in "aeiouAEIOU":
raise TypeError(f"Cannot convert {type(config).__name__} to an {class_name}")
# TODO: edge cases
else:
raise TypeError(f"Cannot convert {type(config).__name__} to a {class_name}")