# SPDX-License-Identifier: GPLv3-or-later
# Copyright © 2025 pygaindalf Rui Pinheiro
import importlib
from abc import ABCMeta
from typing import TYPE_CHECKING, Any
from pydantic import Field, ModelWrapValidatorHandler, model_validator
from ...util.config import BaseConfigModel
from ...util.helpers import classproperty
if TYPE_CHECKING:
from .component_meta import ComponentMeta
# MARK: Base Component Configuration
[docs]
class ComponentConfig(BaseConfigModel, metaclass=ABCMeta):
package: str = Field(description="Package name of the component to load")
@model_validator(mode="before")
@classmethod
def _validate_title(cls, data: Any) -> Any:
if not isinstance(data, dict):
return data
if (title := data.get("title", None)) is not None:
if not isinstance(title, str):
msg = f"Expected a string for 'title', got {type(title).__name__} instead."
raise TypeError(msg)
data["instance_name"] = title
del data["title"]
return data
@property
def title(self) -> str:
return self.final_instance_name
@model_validator(mode="wrap")
@classmethod
def _coerce_to_concrete_class[Child: ComponentConfig](cls: type[Child], data: Any, handler: ModelWrapValidatorHandler) -> Child:
# Already instantiated
if isinstance(data, cls):
return data
# Must be a dictionary
if not isinstance(data, dict):
msg = f"Expected a dictionary for {cls.__name__} configuration, got {type(data).__name__}."
raise TypeError(msg)
package = data.get("package", None)
if package is None:
msg = f"Missing 'package' key in {cls.__name__} configuration."
raise ValueError(msg)
# Get the concrete configuration class for this package
component_cls: type = cls.get_component_class_for_package(package)
concrete_cls = component_cls.config_class
if concrete_cls is None:
msg = f"Configuration class for {package} does not define 'config_class'."
raise ImportError(msg)
if cls is concrete_cls:
return handler(data)
if not issubclass(concrete_cls, cls):
msg = f"Expected configuration class {cls.__name__}, got {concrete_cls.__name__} instead."
raise TypeError(msg)
return concrete_cls.model_validate(data)
[docs]
@classmethod
def get_component_class_for_package(cls, package: str) -> type[ComponentMeta]:
# Import the package
root_path = cls.package_root
rel_path = f".{package}"
path = f"{root_path}{rel_path}"
mod = importlib.import_module(f".{package}", root_path)
# Get the component class
component_cls = getattr(mod, "COMPONENT", None)
if component_cls is None:
msg = f"Configuration class for {package} not found in '{path}'."
raise ImportError(msg)
if not isinstance(component_cls, type):
msg = f"Expected a class for {package} component, got {type(component_cls).__name__} instead."
raise TypeError(msg)
# Sanity check the configuration class
config_cls = component_cls.config_class
if config_cls is None:
msg = f"Configuration class for {package} does not define 'config_class'."
raise ImportError(msg)
if not issubclass(config_cls, ComponentConfig):
msg = f"Expected configuration class {cls.__name__}, got {config_cls.__name__} instead."
raise TypeError(msg)
# Done
return component_cls
@property
def component_class(self) -> type[ComponentMeta]:
return self.get_component_class_for_package(self.package)
[docs]
@classproperty
def package_root(cls) -> str:
return "app.components"
[docs]
def create_component(self, *args, **kwargs) -> ComponentMeta:
component_cls = self.component_class
return component_cls(self, *args, **kwargs)