# 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))