Source code for genro_treestore.builders.decorators

# Copyright 2025 Softwell S.r.l. - Genropy Team
# SPDX-License-Identifier: Apache-2.0

"""Decorators for builder methods validation rules."""

from __future__ import annotations

import inspect
import re
from functools import wraps
from typing import Callable, Any, Literal, Union, get_origin, get_args

# Pattern for tag with optional cardinality: tag, tag[n], tag[n:], tag[:m], tag[n:m]
_TAG_PATTERN = re.compile(r"^([a-zA-Z_][a-zA-Z0-9_]*)\s*(?:\[(\d*):?(\d*)\])?$")


def _parse_tag_spec(spec: str) -> tuple[str, int, int | None]:
    """Parse a tag specification with optional cardinality.

    Args:
        spec: Tag spec like 'foo', 'foo[1]', 'foo[1:]', 'foo[:2]', 'foo[1:3]'

    Returns:
        Tuple of (tag_name, min_count, max_count)

    Raises:
        ValueError: If spec format is invalid.

    Examples:
        >>> _parse_tag_spec('foo')
        ('foo', 0, None)
        >>> _parse_tag_spec('foo[1]')
        ('foo', 1, 1)
        >>> _parse_tag_spec('foo[2:]')
        ('foo', 2, None)
        >>> _parse_tag_spec('foo[:3]')
        ('foo', 0, 3)
        >>> _parse_tag_spec('foo[1:3]')
        ('foo', 1, 3)
    """
    match = _TAG_PATTERN.match(spec.strip())
    if not match:
        raise ValueError(f"Invalid tag specification: '{spec}'")

    tag = match.group(1)
    min_str = match.group(2)
    max_str = match.group(3)

    # No brackets: unlimited (0..∞)
    if min_str is None and max_str is None:
        return tag, 0, None

    # Check if there was a colon in the original spec
    has_colon = ":" in spec

    if not has_colon:
        # tag[n] - exactly n
        n = int(min_str) if min_str else 0
        return tag, n, n

    # Has colon: slice syntax
    min_count = int(min_str) if min_str else 0
    max_count = int(max_str) if max_str else None

    return tag, min_count, max_count


def _extract_attrs_from_signature(func: Callable) -> dict[str, dict[str, Any]] | None:
    """Extract attribute specs from function signature type hints.

    Extracts typed parameters (excluding self, target, tag, label, value, **kwargs)
    and converts them to attrs spec format for validation.

    Returns None if no typed parameters found.

    Example:
        def foo(self, target, tag, colspan: int = 1, scope: Literal['row', 'col'] = None):
            ...

        Returns:
            {
                'colspan': {'type': 'int', 'required': False, 'default': 1},
                'scope': {'type': 'enum', 'values': ['row', 'col'], 'required': False}
            }
    """
    sig = inspect.signature(func)
    attrs_spec: dict[str, dict[str, Any]] = {}

    # Skip these parameters - they're not user attributes
    skip_params = {"self", "target", "tag", "label", "value"}

    for name, param in sig.parameters.items():
        if name in skip_params:
            continue
        if param.kind == inspect.Parameter.VAR_KEYWORD:
            # **kwargs - skip
            continue
        if param.kind == inspect.Parameter.VAR_POSITIONAL:
            # *args - skip
            continue

        # Get annotation and default
        annotation = param.annotation
        if annotation is inspect.Parameter.empty:
            # No type annotation, skip
            continue

        # Convert annotation to attr spec
        attr_spec = _annotation_to_attr_spec(annotation)

        # Set required/default
        if param.default is inspect.Parameter.empty:
            attr_spec["required"] = True
        else:
            attr_spec["required"] = False
            if param.default is not None:
                attr_spec["default"] = param.default

        attrs_spec[name] = attr_spec

    return attrs_spec if attrs_spec else None


def _annotation_to_attr_spec(annotation: Any) -> dict[str, Any]:
    """Convert a type annotation to attr spec dict.

    Handles:
    - int → {'type': 'int'}
    - str → {'type': 'string'}
    - bool → {'type': 'bool'}
    - Literal['a', 'b'] → {'type': 'enum', 'values': ['a', 'b']}
    - int | None → {'type': 'int'} (optional handled separately)
    - Optional[int] → {'type': 'int'}
    """
    origin = get_origin(annotation)
    args = get_args(annotation)

    # Handle Union types (including Optional which is Union[X, None])
    if origin is Union:
        # Filter out NoneType
        non_none_args = [a for a in args if a is not type(None)]
        if len(non_none_args) == 1:
            # Optional[X] or X | None
            return _annotation_to_attr_spec(non_none_args[0])
        # Multiple types - fall back to string
        return {"type": "string"}

    # Handle Literal
    if origin is Literal:
        return {"type": "enum", "values": list(args)}

    # Handle basic types
    if annotation is int:
        return {"type": "int"}
    elif annotation is bool:
        return {"type": "bool"}
    elif annotation is str:
        return {"type": "string"}

    # Default to string
    return {"type": "string"}


def _parse_tags(tags: str | tuple[str, ...]) -> list[str]:
    """Parse tags parameter into a list of tag names.

    Args:
        tags: Can be:
            - str: 'fridge, oven, sink'
            - tuple[str, ...]: ('fridge', 'oven', 'sink')

    Returns:
        List of tag names.
    """
    if isinstance(tags, str):
        return [t.strip() for t in tags.split(",") if t.strip()]
    elif isinstance(tags, tuple) and tags:
        return list(tags)
    return []


[docs] def element( tags: str | tuple[str, ...] = "", children: str | tuple[str, ...] = "", validate: bool = True, ) -> Callable: """Decorator to define element tags and validation rules for a builder method. The decorator registers the method as handler for the specified tags. If no tags are specified, the method name is used as the tag. Attribute validation is automatically extracted from function signature type hints when validate=True (default). Args: tags: Tag names this method handles. Can be: - A comma-separated string: 'fridge, oven, sink' - A tuple of strings: ('fridge', 'oven', 'sink') If empty, the method name is used as the single tag. children: Valid child tag specs for structure validation. Can be: - A comma-separated string: 'tag1, tag2[:1], tag3[1:]' - A tuple of strings: ('tag1', 'tag2[:1]', 'tag3[1:]') Each spec can be: - 'tag' - allowed, no cardinality constraint (0..∞) - 'tag[n]' - exactly n required - 'tag[n:]' - at least n required - 'tag[:m]' - at most m allowed - 'tag[n:m]' - between n and m (inclusive) Empty string or empty tuple means no children allowed (leaf node). validate: If True (default), extract attribute validation rules from function signature type hints. Set to False to disable validation. Example: >>> class MyBuilder(BuilderBase): ... # Multiple tags pointing to same method ... @element(tags='fridge, oven, sink') ... def appliance(self, target, tag, **attr): ... return self.child(target, tag, value='', **attr) ... ... # Structure validation with children ... @element(children='section, item[1:]') ... def menu(self, target, tag, **attr): ... return self.child(target, tag, **attr) ... ... @element() # No children allowed (leaf) ... def item(self, target, tag, **attr): ... return self.child(target, tag, value='', **attr) ... ... # Attribute validation from signature type hints ... @element() ... def td(self, target, tag, colspan: int = 1, ... scope: Literal['row', 'col'] | None = None, **attr): ... return self.child(target, tag, colspan=colspan, scope=scope, **attr) """ # Parse tags tag_list = _parse_tags(tags) # Check if children spec contains =references (need runtime resolution) children_str = children if isinstance(children, str) else ",".join(children) has_refs = "=" in children_str # Parse children specs - accept both string and tuple # Skip parsing if there are references (will be resolved at runtime) parsed_children: dict[str, tuple[int, int | None]] = {} if not has_refs: if isinstance(children, str): specs = [s.strip() for s in children.split(",") if s.strip()] else: specs = list(children) for spec in specs: tag, min_c, max_c = _parse_tag_spec(spec) parsed_children[tag] = (min_c, max_c) def decorator(func: Callable) -> Callable: # Extract attrs spec from signature if validation enabled attrs_spec: dict[str, dict[str, Any]] | None = None if validate: attrs_spec = _extract_attrs_from_signature(func) @wraps(func) def wrapper(*args: Any, **kwargs: Any) -> Any: # Perform validation if attrs_spec is defined if attrs_spec: _validate_attrs_from_spec(attrs_spec, kwargs) return func(*args, **kwargs) # Store validation rules on the function # _valid_children: set of allowed tag names # _child_cardinality: dict mapping tag -> (min, max) if has_refs: # Contains =references - store raw spec for runtime resolution wrapper._raw_children_spec = children wrapper._valid_children = frozenset() # Will be resolved at runtime wrapper._child_cardinality = {} else: wrapper._valid_children = frozenset(parsed_children.keys()) wrapper._child_cardinality = parsed_children # Store tags this method handles # If no tags specified, will use method name (set in __init_subclass__) wrapper._element_tags = tuple(tag_list) if tag_list else None # Store attrs spec for introspection wrapper._attrs_spec = attrs_spec return wrapper return decorator
def _validate_attrs_from_spec( attrs_spec: dict[str, dict[str, Any]], kwargs: dict[str, Any] ) -> None: """Validate kwargs against attrs spec extracted from signature. Args: attrs_spec: Dict mapping attr name to spec dict with type, required, values, etc. kwargs: The keyword arguments to validate. Raises: ValueError: If validation fails. """ errors = [] for attr_name, attr_spec in attrs_spec.items(): value = kwargs.get(attr_name) required = attr_spec.get("required", False) type_name = attr_spec.get("type", "string") # Check required if required and value is None: errors.append(f"'{attr_name}' is required") continue # Skip validation if value not provided if value is None: continue # Type validation if type_name == "int": if not isinstance(value, int): try: int(value) except (ValueError, TypeError): errors.append(f"'{attr_name}' must be an integer, got {type(value).__name__}") continue elif type_name == "bool": if not isinstance(value, bool): if isinstance(value, str): if value.lower() not in ("true", "false", "1", "0", "yes", "no"): errors.append(f"'{attr_name}' must be a boolean, got '{value}'") else: errors.append(f"'{attr_name}' must be a boolean, got {type(value).__name__}") elif type_name == "enum": values = attr_spec.get("values", []) if values and value not in values: errors.append(f"'{attr_name}' must be one of {values}, got '{value}'") if errors: raise ValueError("Attribute validation failed: " + "; ".join(errors))