Source code for glotaran.model.item

"""This module contains the items."""
from __future__ import annotations

import contextlib
from inspect import getmro
from inspect import isclass
from textwrap import indent
from types import NoneType
from types import UnionType
from typing import TYPE_CHECKING
from typing import Any
from typing import Callable
from typing import ClassVar
from typing import Generator
from typing import Iterator
from typing import Type
from typing import TypeAlias
from typing import TypeVar
from typing import Union
from typing import get_args
from typing import get_origin

from attrs import NOTHING
from attrs import Attribute
from attrs import define
from attrs import evolve
from attrs import field
from attrs import fields
from attrs import resolve_types

from glotaran.parameter import Parameter
from glotaran.parameter import Parameters
from glotaran.utils.ipython import MarkdownStr

if TYPE_CHECKING:
    from glotaran.model.model import Model


META_ALIAS = "__glotaran_alias__"
META_VALIDATOR = "__glotaran_validator__"


class ItemIssue:
    """Baseclass for item issues."""

    def to_string(self) -> str:
        """Get the issue as string.

        Returns
        -------
        str

        .. # noqa: DAR202
        .. # noqa: DAR401
        """
        raise NotImplementedError

    def __rep__(self) -> str:
        """Get the representation."""
        return self.to_string()


class ModelItemIssue(ItemIssue):
    """Issue for missing model items."""

    def __init__(self, item_name: str, label: str):
        """Create a model issue.

        Parameters
        ----------
        item_name : str
            The name of the item.
        label : str
            The item label.
        """
        self._item_name = item_name
        self._label = label

    def to_string(self) -> str:
        """Get the issue as string.

        Returns
        -------
        str
        """
        return f"Missing model item '{self._item_name}' with label '{self._label}'."


class ParameterIssue(ItemIssue):
    """Issue for missing parameters."""

    def __init__(self, label: str):
        """Create a parameter issue.

        Parameters
        ----------
        label : str
            The parameter label.
        """
        self._label = label

    def to_string(self) -> str:
        """Get the issue as string.

        Returns
        -------
        str
        """
        return f"Missing parameter with label '{self._label}'."


@define(kw_only=True, slots=False)
class Item:
    """A baseclass for items."""


@define(kw_only=True, slots=False)
class ModelItem(Item):
    """An item with a label."""

    label: str


@define(kw_only=True, slots=False)
class TypedItem(Item):
    """An item with a type."""

    type: str
    __item_types__: ClassVar[dict[str, Type]]

    @classmethod
    def _register_item_class(cls):
        """Register a class as type."""
        item_type = cls.get_item_type()
        if item_type is not NOTHING:
            cls.__item_types__[item_type] = cls

    @classmethod
    def get_item_type(cls) -> str:
        """Get the type string.

        Returns
        -------
        str
        """
        return fields(cls).type.default

    @classmethod
    def get_item_types(cls) -> list[str]:
        """Get all type strings.

        Returns
        -------
        list[str]
        """
        return list(cls.__item_types__.keys())

    @classmethod
    def get_item_type_class(cls, item_type: str) -> Type:
        """Get the type for a type string.

        Parameters
        ----------
        item_type: str
            The type string.
        Returns
        -------
        Type
        """
        return cls.__item_types__[item_type]


@define(kw_only=True, slots=False)
class ModelItemTyped(TypedItem, ModelItem):
    """A model item with a type."""


ItemT = TypeVar("ItemT", bound="Item")
ModelItemT = TypeVar("ModelItemT", bound="ModelItem")

ParameterType: TypeAlias = Parameter | str
ModelItemType: TypeAlias = ModelItemT | str


def item_to_markdown(
    item: Item, parameters: Parameters | None = None, initial_parameters: Parameters | None = None
) -> MarkdownStr:
    """Get the item as markdown string.

    Parameters
    ----------
    item: Item
        The item.
    parameters: Parameters | None
        The parameters.
    initial_parameters: Parameters | None
        The initial parameters.

    Returns
    -------
    MarkdownStr
    """
    md = "\n"
    for attr in fields(item.__class__):
        name = attr.name
        value = getattr(item, name)
        if value is None:
            continue

        structure, item_type = strip_type_and_structure_from_attribute(attr)
        if item_type is Parameter and parameters is not None:
            if structure is dict:
                value = {
                    k: parameters.get(v.label if isinstance(v, Parameter) else v).markdown(
                        parameters, initial_parameters
                    )
                    for k, v in value.items()
                }
            elif structure is list:
                value = [
                    parameters.get(v.label if isinstance(v, Parameter) else v).markdown(
                        parameters, initial_parameters
                    )
                    for v in value
                ]
            else:
                value = parameters.get(
                    value.label if isinstance(value, Parameter) else value
                ).markdown(parameters, initial_parameters)

        property_md = indent(f"- _{name.replace('_', ' ').title()}_: {value}\n", "  ")

        md += property_md

    return MarkdownStr(md)


def iterate_attributes_of_type(
    item: type[Item], attr_type: type
) -> Generator[Attribute, None, None]:
    """Get attributes of type from an item type.

    Parameters
    ----------
    item: type[Item]
        The item type.
    attr_type: type
        The attribute type.

    Yields
    ------
    Attribute
        The attributes.
    """
    for attr in fields(item):
        _, item_type = strip_type_and_structure_from_attribute(attr)
        with contextlib.suppress(TypeError):
            # issubclass does for some reason not work with e.g. tuple as item_type
            # and Parameter as attr_type
            if isclass(item_type) and issubclass(item_type, attr_type):
                yield attr


def model_attributes(
    item: type[Item], with_alias: bool = True
) -> Generator[Attribute, None, None]:
    """Get model attributes from an item type.

    Parameters
    ----------
    item: type[Item]
        The item type.
    with_alias: bool
        Whether to return aliased attributes.

    Yields
    ------
    Attribute
        The model attributes.
    """
    for attr in iterate_attributes_of_type(item, ModelItem):
        if with_alias or META_ALIAS not in attr.metadata:
            yield attr


def parameter_attributes(item: type[Item]) -> Generator[Attribute, None, None]:
    """Get parameter attributes from an item type.

    Parameters
    ----------
    item: type[Item]
        The item type.

    Yields
    ------
    Attribute
        The parameter attributes.
    """
    yield from iterate_attributes_of_type(item, Parameter)


def iterate_names_and_labels(
    item: Item, attributes: Generator[Attribute, None, None]
) -> Generator[tuple[str, str], None, None]:
    """Get attribute names and labels.

    Parameters
    ----------
    item: Item
        The item.
    attributes: Generator[Attribute, None, None]
        The attributes.

    Yields
    ------
    tuple[str, str]
        The name and the label.
    """
    for attr in attributes:
        structure, _ = strip_type_and_structure_from_attribute(attr)
        value = getattr(item, attr.name)
        name: str = attr.metadata.get(META_ALIAS, attr.name)

        if not value:
            continue

        if structure is dict:
            for v in value.values():
                yield name, v if isinstance(v, str) else (name, v.label)  # type:ignore[misc]

        elif structure is list:
            for v in value:
                yield name, v if isinstance(v, str) else (name, v.label)  # type:ignore[misc]

        else:
            yield name, value if isinstance(value, str) else (
                name,
                value.label,  # type:ignore[misc]
            )


def iterate_model_item_names_and_labels(item: Item) -> Generator[tuple[str, str], None, None]:
    """Get model item names and labels.

    Parameters
    ----------
    item: Item
        The item.

    Yields
    ------
    tuple[str, str]
        The name and the label.
    """
    yield from iterate_names_and_labels(item, model_attributes(item.__class__))


def iterate_parameter_names_and_labels(item: Item) -> Generator[tuple[str, str], None, None]:
    """Get parameter item names and labels.

    Parameters
    ----------
    item: Item
        The item.

    Yields
    ------
    tuple[str, str]
        The name and the label.
    """
    yield from iterate_names_and_labels(item, parameter_attributes(item.__class__))


def fill_item_attributes(
    item: Item,
    iterator: Iterator[Attribute],
    fill_function: Callable[[str, str], Parameter | ModelItem],
):
    """Fill item attributes.

    Parameters
    ----------
    item: Item
        The item.
    iterator: Iterator[Attribute]
        An iterator over attributes.
    fill_function: Callable[[str, str], Parameter | ModelItem]
        The function to fill the values.
    """
    for attr in iterator:
        value = getattr(item, attr.name)
        if not value:
            continue

        structure, _ = strip_type_and_structure_from_attribute(attr)
        name = attr.metadata.get(META_ALIAS, attr.name)
        if structure is dict:
            value = {
                k: fill_function(name, v) if isinstance(v, str) else fill_function(name, v.label)
                for k, v in value.items()
            }
        elif structure is list:
            value = [
                fill_function(name, v) if isinstance(v, str) else fill_function(name, v.label)
                for v in value
            ]
        else:
            value = (
                fill_function(name, value)
                if isinstance(value, str)
                else fill_function(name, value.label)
            )

        setattr(item, attr.name, value)


def fill_item(item: ItemT, model: Model, parameters: Parameters) -> ItemT:
    """Fill an item.

    Parameters
    ----------
    item: ItemT
        The item.
    model: Model
        The model.
    parameters: Parameters
        The parameters.

    Returns
    -------
    ItemT
        The filled item.
    """
    item = evolve(item)
    fill_item_model_attributes(item, model, parameters)
    fill_item_parameter_attributes(item, parameters)
    return item


def fill_item_model_attributes(item: Item, model: Model, parameters: Parameters):
    """Fill item model attributes.

    Parameters
    ----------
    item: Item
        The item.
    model: Model
        The model.
    parameters: Parameters
        The parameters.
    """
    fill_item_attributes(
        item,
        model_attributes(item.__class__),
        lambda name, label: fill_item(getattr(model, name)[label], model, parameters),
    )


def fill_item_parameter_attributes(item: Item, parameters: Parameters):
    """Fill item parameter attributes.

    Parameters
    ----------
    item: Item
        The item.
    parameters: Parameters
        The parameters.
    """
    fill_item_attributes(
        item, parameter_attributes(item.__class__), lambda _, label: parameters.get(label)
    )


def get_item_model_issues(item: Item, model: Model) -> list[ItemIssue]:
    """Get model item issues for an item.

    Parameters
    ----------
    item: Item
        The item.
    model: Model
        The model.

    Returns
    -------
    list[ItemIssue]
    """
    return [
        ModelItemIssue(name, label)
        for name, label in iterate_model_item_names_and_labels(item)
        if label not in getattr(model, name)
    ]


def get_item_parameter_issues(item: Item, parameters: Parameters) -> list[ItemIssue]:
    """Get model item issues for an item.

    Parameters
    ----------
    item: Item
        The item.
    parameters: Parameters
        The parameters.

    Returns
    -------
    list[ItemIssue]
    """
    return [
        ParameterIssue(label)
        for name, label in iterate_parameter_names_and_labels(item)
        if not parameters.has(label)
    ]


def get_item_validator_issues(
    item: Item, model: Model, parameters: Parameters | None = None
) -> list[ItemIssue]:
    """Get validator issues for an item.

    Parameters
    ----------
    item: Item
        The item.
    model: Model
        The model.
    parameters: Parameters | None
        The parameters.

    Returns
    -------
    list[ItemIssue]
    """
    issues = []
    for name, validator in [
        (attr.name, attr.metadata[META_VALIDATOR])
        for attr in fields(item.__class__)
        if META_VALIDATOR in attr.metadata
    ]:
        issues += validator(getattr(item, name), item, model, parameters)

    return issues


def get_item_issues(
    *, item: Item, model: Model, parameters: Parameters | None = None
) -> list[ItemIssue]:
    """Get issues for an item.

    Parameters
    ----------
    item: Item
        The item.
    model: Model
        The model.
    parameters: Parameters | None
        The parameters.

    Returns
    -------
    list[ItemIssue]
    """
    issues = get_item_model_issues(item, model)
    issues += get_item_validator_issues(item, model, parameters)
    if parameters is not None:
        issues += get_item_parameter_issues(item, parameters)
    return issues


def strip_type_and_structure_from_attribute(attr: Attribute) -> tuple[None | list | dict, type]:
    """Strip the type and the structure from an attribute.

    Parameters
    ----------
    attr: Attribute
        The attribute.

    Returns
    -------
    tuple[None | list | dict, type]:
        The structure and the type.
    """
    definition = attr.type
    definition = strip_option_type(definition)
    structure, definition = strip_structure_type(definition)
    definition = strip_option_type(definition, strip_type=str)
    return structure, definition


def strip_option_type(definition: type, strip_type: type = NoneType) -> type:
    """Strip the type if the definition is an option.

    Parameters
    ----------
    definition: type
        The definition.
    strip_type: type
        The type which should be removed from the option.

    Returns
    -------
    type
    """
    args = list(get_args(definition))
    if get_origin(definition) in [Union, UnionType] and strip_type in args:
        args.remove(strip_type)
        definition = args[0]
    return definition


def strip_structure_type(definition: type) -> tuple[None | list | dict, type]:
    """Strip the structure from a definition.

    Parameters
    ----------
    definition: type
        The definition.

    Returns
    -------
    tuple[None | list | dict, type]:
        The structure and the type.
    """
    structure = get_origin(definition)
    if structure is list:
        definition = get_args(definition)[0]
    elif structure is dict:
        definition = get_args(definition)[1]
    else:
        structure = None

    return structure, definition


[docs] def item(cls: type[ItemT]) -> type[ItemT]: """Create an item from a class. Parameters ---------- cls: type[ItemT] The class. Returns ------- type[ItemT] """ parent = getmro(cls)[1] cls = define(kw_only=True, slots=False)(cls) if parent in (TypedItem, ModelItemTyped): assert issubclass(cls, TypedItem) cls.__item_types__ = {} elif issubclass(cls, TypedItem): cls._register_item_class() resolve_types(cls) return cls
def attribute( *, alias: str | None = None, default: Any = NOTHING, factory: Callable[[], Any] | None = None, validator: Callable[[Any, Item, Model, Parameters | None], list[ItemIssue]] | None = None, ) -> Attribute: """Create an attribute for an item. Parameters ---------- alias: str | None The alias of the attribute (only useful for model items). default: Any The default value of the attribute. factory: Callable[[], Any] | None A factory function for the attribute. validator: Callable[[Any, Item, Model, Parameters | None], list[ItemIssue]] | None A validator function for the attribute. Returns ------- Attribute """ metadata: dict[str, Any] = {} if alias is not None: metadata[META_ALIAS] = alias if validator is not None: metadata[META_VALIDATOR] = validator return field(default=default, factory=factory, metadata=metadata)