Source code for buffalo_core.schema

"""Structured schema metadata shared across Buffalo projects."""

from __future__ import annotations

import re
from dataclasses import dataclass, fields, is_dataclass
from types import NoneType, UnionType
from typing import Any, Union, cast, get_args, get_origin, get_type_hints

__all__ = [
    "SchemaChoice",
    "SchemaClassTree",
    "SchemaCondition",
    "SchemaField",
    "SchemaFieldMetadata",
    "SchemaFieldTree",
    "schema_field_metadata",
    "schema_tree",
]

type SchemaFieldMetadata = dict[str, object]

_PAIR_ARG_COUNT = 2


@dataclass(frozen=True, slots=True)
class SchemaChoice:
    """
    Structured choice metadata for one enum-like schema value.

    Notes
    -----
    ``value`` remains the serialized schema token.
    ``label`` provides the human-readable text that GUIs or editors can
    display without changing the underlying serialized contract.
    """

    value: object
    """Serialized schema value for one choice."""

    label: str
    """Human-readable label for presenting the choice in a UI."""

    def __post_init__(self) -> None:
        """Validate that one display label is present."""
        if not self.label:
            msg = "SchemaChoice label must be a non-empty string."
            raise ValueError(msg)

    @classmethod
    def from_value(
        cls,
        value: object,
        *,
        label: str | None = None,
    ) -> SchemaChoice:
        """
        Build one choice from a serialized value.

        Parameters
        ----------
        value : object
            Serialized schema value to preserve in metadata.
        label : str | None, optional
            Optional explicit display label.
            When omitted, a generic display label is derived from ``value``.

        Returns
        -------
        SchemaChoice
            Structured choice metadata carrying both the serialized value and
            a display-friendly label.
        """
        if label is None:
            label = _default_choice_label(value)
        return cls(value=value, label=label)

    def to_metadata(self) -> SchemaFieldMetadata:
        """Return the plain metadata mapping stored in dataclass fields."""
        return {
            "value": self.value,
            "label": self.label,
        }


@dataclass(frozen=True, slots=True)
class SchemaCondition:
    """
    Structured conditional UI rule attached to one schema field.

    Notes
    -----
    Conditions remain domain-light so downstream projects can interpret them
    for GUI visibility, editability, or other editor workflows.
    """

    path: str
    """Path expression identifying the controlling schema value."""

    operator: str
    """Predicate operator such as ``equals`` or ``non_empty``."""

    value: object | None = None
    """Optional comparison value for the condition."""

    message: str | None = None
    """Optional explanatory message for the condition."""

    def to_metadata(self) -> SchemaFieldMetadata:
        """Return the plain metadata mapping stored in dataclass fields."""
        metadata: SchemaFieldMetadata = {
            "path": self.path,
            "operator": self.operator,
        }
        _store_optional(metadata, key="value", value=self.value)
        _store_optional(metadata, key="message", value=self.message)
        return metadata


@dataclass(frozen=True, slots=True)
class SchemaFieldTree:
    """
    Tree node describing one dataclass field.

    Notes
    -----
    This object keeps the structure domain-light.
    It reports only the field shape, nested dataclass children, optionality,
    and the stored field metadata contract.
    """

    name: str
    """Field name on the parent dataclass."""

    type_name: str
    """Display-friendly type name for the field."""

    optional: bool
    """Whether the field accepts ``None``."""

    docstring: str | None
    """Best available short field documentation text."""

    metadata: SchemaFieldMetadata
    """Structured machine-readable field metadata."""

    object_schema: SchemaClassTree | None = None
    """Nested dataclass schema when the field value is a dataclass object."""

    item_type_name: str | None = None
    """Display-friendly item type name for list, tuple, or mapping values."""

    item_schema: SchemaClassTree | None = None
    """Nested schema for collection items when the item type is a dataclass."""


@dataclass(frozen=True, slots=True)
class SchemaClassTree:
    """Tree node describing one schema dataclass."""

    class_name: str
    """Dataclass name."""

    docstring: str | None
    """Top-level class documentation string."""

    fields: tuple[SchemaFieldTree, ...]
    """Ordered field descriptions."""


@dataclass(frozen=True, slots=True)
class SchemaField:
    """
    Structured machine-readable metadata for one dataclass field.

    Notes
    -----
    This dataclass is a builder for the plain dictionary stored in
    ``dataclasses.field(metadata=...)``.
    Downstream projects keep their dataclasses as the source of truth while
    still attaching stable GUI and editor hints in one shared Core format.
    """

    value_kind: str
    """Machine-readable category such as ``enum``, ``number``, or ``list``."""

    required: bool
    """Whether the field is required by the schema contract."""

    label: str
    """Human-readable field label for UIs and editors."""

    order: int
    """Suggested stable display ordering for sibling fields."""

    short_help: str
    """Short help text describing the field for direct UI display."""

    default: object | None = None
    """Optional default value when one is meaningful for editing."""

    choices: tuple[SchemaChoice | object, ...] | None = None
    """Optional enum-like choices exposed as structured value/label pairs."""

    minimum: float | int | None = None
    """Inclusive numeric lower bound when one is known."""

    maximum: float | int | None = None
    """Inclusive numeric upper bound when one is known."""

    exclusive_minimum: float | int | None = None
    """Exclusive numeric lower bound when one is known."""

    exclusive_maximum: float | int | None = None
    """Exclusive numeric upper bound when one is known."""

    tuple_length: int | None = None
    """Expected tuple length when the field stores a fixed-size tuple."""

    min_items: int | None = None
    """Minimum item count when the field stores a collection."""

    max_items: int | None = None
    """Maximum item count when the field stores a collection."""

    item_kind: str | None = None
    """Machine-readable description of collection item shape or type."""

    format_hint: str | None = None
    """Optional formatting hint for text or tuple entry widgets."""

    reference_target: str | None = None
    """Optional logical target when the field references named objects."""

    collection_add_label: str | None = None
    """Optional add-item button label for collection editors."""

    collection_item_label_field: str | None = None
    """Optional item field used as the row label in collection editors."""

    collection_item_creation_hint: str | None = None
    """Optional help text for creating new collection items."""

    collection_key_label: str | None = None
    """Optional label used for mapping keys in collection editors."""

    collection_key_format_hint: str | None = None
    """Optional format hint used for mapping keys in collection editors."""

    notes: str | None = None
    """Optional extra machine-usable clarification text."""

    advanced: bool = False
    """Whether the field should default to an advanced editing section."""

    shown_when: tuple[SchemaCondition, ...] | None = None
    """Optional conditions controlling whether a field should be shown."""

    enabled_when: tuple[SchemaCondition, ...] | None = None
    """Optional conditions controlling whether a field should be editable."""

    required_when: tuple[SchemaCondition, ...] | None = None
    """Optional conditions controlling whether a field becomes required."""

    def to_metadata(self) -> SchemaFieldMetadata:
        """Return the plain metadata mapping stored in dataclass fields."""
        metadata: SchemaFieldMetadata = {
            "value_kind": self.value_kind,
            "required": self.required,
            "label": self.label,
            "order": self.order,
            "short_help": self.short_help,
            "advanced": self.advanced,
        }
        _store_optional(metadata, key="default", value=self.default)
        _store_optional(metadata, key="minimum", value=self.minimum)
        _store_optional(metadata, key="maximum", value=self.maximum)
        _store_optional(
            metadata,
            key="exclusive_minimum",
            value=self.exclusive_minimum,
        )
        _store_optional(
            metadata,
            key="exclusive_maximum",
            value=self.exclusive_maximum,
        )
        _store_optional(metadata, key="tuple_length", value=self.tuple_length)
        _store_optional(metadata, key="min_items", value=self.min_items)
        _store_optional(metadata, key="max_items", value=self.max_items)
        _store_optional(metadata, key="item_kind", value=self.item_kind)
        _store_optional(metadata, key="format_hint", value=self.format_hint)
        _store_optional(
            metadata,
            key="reference_target",
            value=self.reference_target,
        )
        _store_optional(
            metadata,
            key="collection_add_label",
            value=self.collection_add_label,
        )
        _store_optional(
            metadata,
            key="collection_item_label_field",
            value=self.collection_item_label_field,
        )
        _store_optional(
            metadata,
            key="collection_item_creation_hint",
            value=self.collection_item_creation_hint,
        )
        _store_optional(
            metadata,
            key="collection_key_label",
            value=self.collection_key_label,
        )
        _store_optional(
            metadata,
            key="collection_key_format_hint",
            value=self.collection_key_format_hint,
        )
        _store_optional(metadata, key="notes", value=self.notes)
        _store_conditions(
            metadata,
            key="shown_when",
            conditions=self.shown_when,
        )
        _store_conditions(
            metadata,
            key="enabled_when",
            conditions=self.enabled_when,
        )
        _store_conditions(
            metadata,
            key="required_when",
            conditions=self.required_when,
        )
        if self.choices is not None:
            metadata["choices"] = tuple(
                _normalize_choice(choice).to_metadata()
                for choice in self.choices
            )
        return metadata


TOKENIZED_CHOICE_LABELS: dict[str, str] = {
    "cfd": "CFD",
    "cst": "CST",
    "id": "ID",
    "naca": "NACA",
    "nasa": "NASA",
    "parsec": "PARSEC",
    "vlm": "VLM",
}


def _default_choice_label(value: object) -> str:
    """Return a generic display label for one serialized choice value."""
    if isinstance(value, str):
        words = value.strip().replace("_", " ").split()
        if not words:
            return value
        return " ".join(_label_word(word) for word in words)
    return str(value)


def _label_word(word: str) -> str:
    """Return one display-friendly token inside a derived choice label."""
    normalized = word.lower()
    if normalized in TOKENIZED_CHOICE_LABELS:
        return TOKENIZED_CHOICE_LABELS[normalized]
    if re.fullmatch(r"[A-Za-z]+\d+[A-Za-z]*", word):
        return word.upper()
    return word.title()


def _normalize_choice(choice: SchemaChoice | object) -> SchemaChoice:
    """Return one normalized structured choice entry."""
    if isinstance(choice, SchemaChoice):
        return choice
    return SchemaChoice.from_value(choice)


def _unwrap_optional(target_type: Any) -> tuple[object, bool]:
    """Return the non-optional type plus whether ``None`` is allowed."""
    origin = get_origin(target_type)
    if origin is UnionType or origin is Union:
        args = tuple(
            arg for arg in get_args(target_type) if arg is not NoneType
        )
        if len(args) + 1 == len(get_args(target_type)):
            if len(args) == 1:
                return args[0], True
            return target_type, True
    return target_type, False


def _type_name(target_type: Any) -> str:
    """Return a display-friendly type name."""
    origin = get_origin(target_type)
    if origin is None:
        if isinstance(target_type, type):
            return target_type.__name__
        return str(target_type)
    args = get_args(target_type)
    origin_name = getattr(origin, "__name__", str(origin))
    if args:
        return f"{origin_name}[{', '.join(_type_name(arg) for arg in args)}]"
    return origin_name


def _field_docstring(metadata: SchemaFieldMetadata) -> str | None:
    """Return the best available short doc text for a field."""
    short_help = metadata.get("short_help")
    notes = metadata.get("notes")
    if isinstance(short_help, str) and isinstance(notes, str):
        return f"{short_help} {notes}"
    if isinstance(short_help, str):
        return short_help
    if isinstance(notes, str):
        return notes
    return None


def _item_schema_for_type(
    target_type: Any,
) -> tuple[str | None, SchemaClassTree | None]:
    """Return item type display name and nested schema when available."""
    origin = get_origin(target_type)
    args = get_args(target_type)
    if origin is list and args:
        item_type = args[0]
        return _type_name(item_type), (
            schema_tree(cast(type[object], item_type))
            if isinstance(item_type, type) and is_dataclass(item_type)
            else None
        )
    if origin is tuple and args:
        item_type = (
            args[0]
            if len(args) == _PAIR_ARG_COUNT and args[1] is Ellipsis
            else None
        )
        if item_type is not None:
            return _type_name(item_type), (
                schema_tree(cast(type[object], item_type))
                if isinstance(item_type, type) and is_dataclass(item_type)
                else None
            )
        return "mixed", None
    if origin is dict and len(args) == _PAIR_ARG_COUNT:
        item_type = args[1]
        return _type_name(item_type), (
            schema_tree(cast(type[object], item_type))
            if isinstance(item_type, type) and is_dataclass(item_type)
            else None
        )
    return None, None


def _store_conditions(
    metadata: SchemaFieldMetadata,
    *,
    key: str,
    conditions: tuple[SchemaCondition, ...] | None,
) -> None:
    """Store one condition list when conditions are present."""
    if conditions is None:
        return
    metadata[key] = tuple(condition.to_metadata() for condition in conditions)


def _store_optional(
    metadata: SchemaFieldMetadata,
    *,
    key: str,
    value: object | None,
) -> None:
    """Store one metadata value when it is not ``None``."""
    if value is not None:
        metadata[key] = value


[docs] def schema_field_metadata(cls: type[object]) -> dict[str, SchemaFieldMetadata]: """ Return structured metadata for the dataclass fields on ``cls``. Parameters ---------- cls : type[object] Dataclass type whose field metadata should be inspected. Returns ------- dict[str, SchemaFieldMetadata] Ordered mapping from field name to the structured metadata stored on that dataclass field. Raises ------ TypeError If ``cls`` is not a dataclass type. """ if not is_dataclass(cls): msg = "cls must be a dataclass type." raise TypeError(msg) return { field_info.name: cast(SchemaFieldMetadata, dict(field_info.metadata)) for field_info in fields(cls) if field_info.metadata }
def schema_tree(cls: type[object]) -> SchemaClassTree: """ Return a nested tree description for one schema dataclass. Parameters ---------- cls : type[object] Dataclass type whose nested field structure should be described. Returns ------- SchemaClassTree Nested field tree carrying ordered field descriptions, optionality, nested dataclass children, display-friendly type names, and the stored field metadata contract. Raises ------ TypeError If ``cls`` is not a dataclass type. """ if not is_dataclass(cls): msg = "cls must be a dataclass type." raise TypeError(msg) type_hints = get_type_hints(cls) field_trees: list[SchemaFieldTree] = [] for field_info in fields(cls): annotated_type = type_hints[field_info.name] value_type, optional = _unwrap_optional(annotated_type) resolved_value_type = value_type metadata = cast(SchemaFieldMetadata, dict(field_info.metadata)) object_schema = ( schema_tree(cast(type[object], resolved_value_type)) if isinstance(resolved_value_type, type) and is_dataclass(resolved_value_type) else None ) item_type_name, item_schema = _item_schema_for_type(resolved_value_type) field_trees.append( SchemaFieldTree( name=field_info.name, type_name=_type_name(resolved_value_type), optional=optional, docstring=_field_docstring(metadata), metadata=metadata, object_schema=object_schema, item_type_name=item_type_name, item_schema=item_schema, ) ) return SchemaClassTree( class_name=cls.__name__, docstring=cls.__doc__.strip() if cls.__doc__ is not None else None, fields=tuple(field_trees), )