Files
Launcher/manager/settings.py
T

272 lines
9.6 KiB
Python

from __future__ import annotations
from collections.abc import Mapping
from enum import Enum
from pathlib import Path
from types import UnionType
from typing import Annotated, Any, Literal, TypeVar, Union, cast, get_args, get_origin
from pydantic import BaseModel, ValidationError
import tomli_w
from core_lib.common.exception import ModelRegistrationError, ConfigValidationError, SettingsError
try:
import tomllib
except ModuleNotFoundError: # Python 3.10
import tomli as tomllib
ModelT = TypeVar("ModelT", bound=BaseModel)
class SettingsManager:
"""
A settings manager (Based-on pydantic)
Vibed!
"""
def __init__(self, path: str | Path | None = None) -> None:
self.path = Path(path) if path is not None else None
self._models: dict[str, type[BaseModel]] = {}
self._settings: dict[str, BaseModel] = {}
@property
def registered_sections(self) -> tuple[str, ...]:
return tuple(self._models)
def register(self, section: str, model: type[ModelT], *, replace: bool = False) -> type[ModelT]:
"""
Register a new model
:param section:
:param model:
:param replace:
:return:
model: type[ModelT]
"""
if not section:
raise ModelRegistrationError(
"Section must be a non-empty"
)
elif "." in section:
raise ModelRegistrationError(
"Top level section cannot contain dots"
)
if not isinstance(model, type) or not issubclass(model, BaseModel):
raise ModelRegistrationError("Model must be a Pydantic BaseModel class")
if section in self._models and not replace:
raise ModelRegistrationError(f"Section {section!r} is already registered")
self._models[section] = model
self._settings.pop(section, None)
return model
def model(self, section: str):
def decorator(model: type[ModelT]) -> type[ModelT]:
return self.register(section, model)
return decorator
def load(self, path: str | Path | None = None, *, reject_unknown: bool = True) -> SettingsManager:
"""
Load a toml settings file
:param reject_unknown: Raise exception if settings file contains unknown section
:param path: If not set, use default path instead
"""
config_path = self._resolve_path(path)
# Check file exist
if not config_path.exists() or not config_path.is_file():
raise FileNotFoundError(f"Settings file {config_path} does not exist")
with config_path.open("rb") as file:
raw = tomllib.load(file)
unknown = set(raw).difference(self._models)
if reject_unknown and unknown:
names = ", ".join(sorted(unknown))
raise SettingsError(f"unregistered TOML section(s): {names}")
loaded: dict[str, BaseModel] = {}
errors: dict[str, ValidationError] = {}
for section, model in self._models.items():
value: Any = raw.get(section, {})
try:
# Validate and save section data
loaded[section] = model.model_validate(value)
except ValidationError as error:
errors[section] = error
if errors:
raise ConfigValidationError(errors)
self.path = config_path
self._settings = loaded
return self
def get(self, section: str, model: type[ModelT] | None = None) -> ModelT:
"""
Get target section model
:param section:
:param model:
:return:
model: type[ModelT]
"""
if section not in self._models:
raise KeyError(f"settings section {section!r} is not registered")
if section not in self._settings:
raise SettingsError(f"settings have not been loaded for {section!r}")
value = self._settings[section]
if model is not None and not isinstance(value, model):
raise TypeError(
f"section {section!r} contains {type(value).__name__}, "
f"not {model.__name__}"
)
return cast(ModelT, value)
def set(self, section: str, value: BaseModel | Mapping[str, Any]) -> BaseModel:
"""Validate and replace one loaded settings section."""
try:
model = self._models[section]
except KeyError as error:
raise KeyError(f"settings section {section!r} is not registered") from error
validated = model.model_validate(value)
self._settings[section] = validated
return validated
def load_defaults(self) -> SettingsManager:
"""Load every registered model using its declared default values."""
loaded: dict[str, BaseModel] = {}
errors: dict[str, ValidationError] = {}
for section, model in self._models.items():
try:
loaded[section] = model.model_validate({})
except ValidationError as error:
errors[section] = error
if errors:
raise ConfigValidationError(errors)
self._settings = loaded
return self
def save(self, path: str | Path | None = None) -> Path:
"""Validate all loaded sections and save them as UTF-8 TOML."""
config_path = self._resolve_path(path)
missing = set(self._models).difference(self._settings)
if missing:
names = ", ".join(sorted(missing))
raise SettingsError(f"settings section(s) have not been loaded: {names}")
data = {
section: self._models[section]
.model_validate(self._settings[section])
.model_dump(mode="json")
for section in self._models
}
config_path.parent.mkdir(parents=True, exist_ok=True)
temporary_path = config_path.with_suffix(config_path.suffix + ".tmp")
temporary_path.write_bytes(tomli_w.dumps(data).encode("utf-8"))
temporary_path.replace(config_path)
self.path = config_path
return config_path
def choices(self, section: str, field: str) -> tuple[Any, ...]:
"""
Return choices for a field, accepting paths such as ``window.mode``.
This can be used by a settings page to populate a combo box. Pydantic
uses the same annotation to validate values read from TOML.
:param section:
:param field:
"""
try:
model = self._models[section]
except KeyError as error:
raise KeyError(f"settings section {section!r} is not registered") from error
annotation: Any = model
for part in field.split("."):
nested_model = _model_type(annotation)
if nested_model is None:
raise KeyError(
f"{part!r} cannot be resolved in field path {section}.{field}"
)
try:
annotation = nested_model.model_fields[part].annotation
except KeyError as error:
raise KeyError(f"field path {section}.{field} does not exist") from error
values = _choice_values(annotation)
if values is None:
raise SettingsError(
f"field {section}.{field} is not declared with Literal or Enum"
)
return values
def validate(self, data: Mapping[str, Any], *, reject_unknown: bool = True) -> None:
"""
Validate an in-memory mapping without changing loaded settings.
:param data:
:param reject_unknown:
:return:
"""
unknown = set(data).difference(self._models)
if reject_unknown and unknown:
names = ", ".join(sorted(unknown))
raise SettingsError(f"unregistered settings section(s): {names}")
errors: dict[str, ValidationError] = {}
for section, model in self._models.items():
try:
model.model_validate(data.get(section, {}))
except ValidationError as error:
errors[section] = error
if errors:
raise ConfigValidationError(errors)
def _resolve_path(self, path: str | Path | None) -> Path:
resolved = Path(path) if path is not None else self.path
if resolved is None:
raise SettingsError("no TOML configuration path was provided")
return resolved
def _choice_values(annotation: Any) -> tuple[Any, ...] | None:
"""Extract choices from a field annotation, including Annotated/Optional."""
origin = get_origin(annotation)
if origin is Literal:
return get_args(annotation)
if isinstance(annotation, type) and issubclass(annotation, Enum):
return tuple(member.value for member in annotation)
if origin is Annotated:
return _choice_values(get_args(annotation)[0])
if origin in (Union, UnionType):
choices: list[Any] = []
found = False
for member in get_args(annotation):
values = _choice_values(member)
if values is not None:
choices.extend(values)
found = True
return tuple(choices) if found else None
return None
def _model_type(annotation: Any) -> type[BaseModel] | None:
"""Find a nested BaseModel type inside Annotated or Optional."""
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
return annotation
origin = get_origin(annotation)
if origin is Annotated:
return _model_type(get_args(annotation)[0])
if origin in (Union, UnionType):
for member in get_args(annotation):
model = _model_type(member)
if model is not None:
return model
return None