# Copyright 2025 Softwell S.r.l. - Genropy Team
# SPDX-License-Identifier: Apache-2.0
"""BuilderBase - Abstract base class for TreeStore builders."""
from __future__ import annotations
from abc import ABC
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from ..store import TreeStore
from ..store import TreeStoreNode
[docs]
class BuilderBase(ABC):
"""Abstract base class for TreeStore builders.
A builder provides domain-specific methods for creating nodes
in a TreeStore. There are two ways to define elements:
1. Using @element decorator on methods:
@element(children='item')
def menu(self, target, tag, **attr):
return self.child(target, tag, **attr)
@element(tags='fridge, oven, sink')
def appliance(self, target, tag, **attr):
return self.child(target, tag, value='', **attr)
2. Using _schema dict for external/dynamic definitions:
class HtmlBuilder(BuilderBase):
_schema = {
'div': {'children': '=flow'},
'br': {'leaf': True},
'td': {
'children': '=flow',
'attrs': {
'colspan': {'type': 'int', 'min': 1, 'default': 1},
'rowspan': {'type': 'int', 'min': 0, 'default': 1},
'scope': {'type': 'enum', 'values': ['row', 'col']},
}
},
}
Schema keys:
- children: str or set of allowed child tags (supports =ref)
- leaf: True if element has no children (value='')
- attrs: dict of attribute specs for validation
- type: 'int', 'string', 'uri', 'bool', 'enum', 'idrefs'
- required: True/False (default: False)
- min/max: numeric constraints for int type
- default: default value
- values: list of valid values for enum type
The lookup order is: decorated methods first, then _schema.
Attribute validation is performed with pure Python (no dependencies).
Usage:
>>> store = TreeStore(builder=MyBuilder())
>>> store.fridge() # calls appliance() with tag='fridge'
"""
# Class-level dict mapping tag -> method name (from @element decorator)
_element_tags: dict[str, str]
# Schema dict for external element definitions (optional)
_schema: dict[str, dict] = {}
def _validate_attrs(
self, tag: str, attrs: dict[str, Any], raise_on_error: bool = True
) -> list[str]:
"""Validate attributes against schema specification (pure Python).
Args:
tag: The tag name to get attrs spec for.
attrs: Dict of attribute values to validate.
raise_on_error: If True, raises ValueError on validation failure.
If False, returns list of error messages.
Returns:
List of error messages (empty if valid).
Raises:
ValueError: If validation fails and raise_on_error is True.
"""
schema = getattr(self, "_schema", {})
spec = schema.get(tag, {})
attrs_spec = spec.get("attrs")
if not attrs_spec:
return []
errors = []
for attr_name, attr_spec in attrs_spec.items():
value = attrs.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 for '{tag}'")
continue
# Skip validation if value not provided
if value is None:
continue
# Type validation
if type_name == "int":
if not isinstance(value, int):
try:
value = int(value)
except (ValueError, TypeError):
errors.append(
f"'{attr_name}' must be an integer, got {type(value).__name__}"
)
continue
# Range constraints
min_val = attr_spec.get("min")
max_val = attr_spec.get("max")
if min_val is not None and value < min_val:
errors.append(f"'{attr_name}' must be >= {min_val}, got {value}")
if max_val is not None and value > max_val:
errors.append(f"'{attr_name}' must be <= {max_val}, got {value}")
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}'")
# string, uri, idrefs, idref, color - accept any string
elif type_name in ("string", "uri", "idrefs", "idref", "color"):
if not isinstance(value, str):
# Allow conversion to string
pass
if errors and raise_on_error:
raise ValueError(f"Attribute validation failed for '{tag}': " + "; ".join(errors))
return errors
def _resolve_ref(self, value: Any) -> Any:
"""Resolve =ref references by looking up _ref_<name> properties.
References use the = prefix convention (static pointer in Genropy):
- '=flow' → looks up self._ref_flow property
- '=phrasing' → looks up self._ref_phrasing property
Handles comma-separated strings with mixed refs and literals:
- '=appliances, sink' → split, resolve '=appliances', keep 'sink', rejoin
This allows:
- Override in subclasses (properties can be overridden)
- Computed/lazy values (property getter is called each time)
- Use in both _schema dict and @element decorator
Args:
value: The value to resolve. Can be:
- '=ref' → single reference
- '=ref, tag, =other' → mixed refs and literals
- set/frozenset containing references
Returns:
The resolved value, or the original value if not a reference.
Raises:
ValueError: If reference property not found on builder.
Example:
>>> class MyBuilder(BuilderBase):
... @property
... def _ref_flow(self):
... return 'div, p, span, a'
...
... _schema = {
... 'section': {'children': '=flow'},
... 'kitchen': {'children': '=appliances, sink'},
... }
"""
# Handle sets/frozensets containing references
if isinstance(value, (set, frozenset)):
resolved = set()
for item in value:
resolved_item = self._resolve_ref(item)
if isinstance(resolved_item, (set, frozenset)):
resolved.update(resolved_item)
elif isinstance(resolved_item, str):
# Could be comma-separated string
resolved.update(t.strip() for t in resolved_item.split(",") if t.strip())
else:
resolved.add(resolved_item)
return frozenset(resolved) if isinstance(value, frozenset) else resolved
if not isinstance(value, str):
return value
# If string contains comma, split and resolve each part recursively
if "," in value:
parts = [p.strip() for p in value.split(",") if p.strip()]
resolved_parts = []
for part in parts:
resolved_part = self._resolve_ref(part)
if isinstance(resolved_part, (set, frozenset)):
# Convert set to comma-separated string
resolved_parts.extend(resolved_part)
elif isinstance(resolved_part, str):
resolved_parts.append(resolved_part)
else:
resolved_parts.append(str(resolved_part))
return ", ".join(resolved_parts)
# Single value - check if it's a reference
if value.startswith("="):
ref_name = value[1:] # '=flow' → 'flow'
prop_name = f"_ref_{ref_name}"
# Check if property exists on this instance
if hasattr(self, prop_name):
resolved = getattr(self, prop_name)
# Recursively resolve in case the property returns another ref
return self._resolve_ref(resolved)
raise ValueError(
f"Reference '{value}' not found: no '{prop_name}' property on {type(self).__name__}"
)
return value
[docs]
def __init_subclass__(cls, **kwargs: Any) -> None:
"""Build the _element_tags dict from @element decorated methods."""
super().__init_subclass__(**kwargs)
# Start with parent's tags if any
cls._element_tags = {}
for base in cls.__mro__[1:]:
if hasattr(base, "_element_tags"):
cls._element_tags.update(base._element_tags)
break
# Scan class methods for @element decorated ones
for name, method in cls.__dict__.items():
if name.startswith("_"):
continue
if not callable(method):
continue
element_tags = getattr(method, "_element_tags", None)
if element_tags is None and hasattr(method, "_valid_children"):
# No explicit tags, use method name
cls._element_tags[name] = name
elif element_tags:
# Explicit tags specified
for tag in element_tags:
cls._element_tags[tag] = name
[docs]
def __getattr__(self, name: str) -> Any:
"""Look up tag in _element_tags or _schema and return handler."""
if name.startswith("_"):
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
# First, check decorated methods
element_tags = getattr(type(self), "_element_tags", {})
if name in element_tags:
method_name = element_tags[name]
return getattr(self, method_name)
# Then, check _schema
schema = getattr(self, "_schema", {})
if name in schema:
return self._make_schema_handler(name, schema[name])
raise AttributeError(f"'{type(self).__name__}' has no element '{name}'")
def _make_schema_handler(self, tag: str, spec: dict):
"""Create a handler function for a schema-defined element.
Args:
tag: The tag name.
spec: Schema spec dict with keys:
- children: str or set of allowed child tags
- leaf: True if element has no children
- attrs: dict for attribute validation
Returns:
A callable that creates the element.
"""
is_leaf = spec.get("leaf", False)
# Capture self for closure
builder = self
def handler(target, tag: str = tag, label: str | None = None, value=None, **attr):
# Validation is handled by ValidationSubscriber after node creation
# Determine value: user-provided > leaf default > branch (None)
if value is None and is_leaf:
value = ""
return builder.child(target, tag, label=label, value=value, **attr)
# Store validation rules on the handler for check() to find
# Note: children_spec is resolved at validation time, not here
children_spec = spec.get("children")
if children_spec is not None:
# Store raw spec - will be resolved in _parse_children_spec
handler._raw_children_spec = children_spec
handler._valid_children, handler._child_cardinality = self._parse_children_spec(
children_spec
)
else:
# No children spec = leaf element (no children allowed)
handler._valid_children = frozenset()
handler._child_cardinality = {}
return handler
def _parse_children_spec(
self, spec: str | set | frozenset
) -> tuple[frozenset[str], dict[str, tuple[int, int | None]]]:
"""Parse a children spec into validation rules.
Args:
spec: Can be:
- str: 'tag1, tag2[:1], tag3[1:]' or '=ref' or '=ref, tag'
- set/frozenset: {'tag1', 'tag2', '=ref'}
Returns:
Tuple of (valid_children frozenset, cardinality dict).
"""
from .decorators import _parse_tag_spec
# First, resolve any =references (handles split and recursion)
resolved_spec = self._resolve_ref(spec)
if isinstance(resolved_spec, (set, frozenset)):
# Simple set of tags, no cardinality
return frozenset(resolved_spec), {}
# Parse string spec with cardinality
parsed: dict[str, tuple[int, int | None]] = {}
specs = [s.strip() for s in resolved_spec.split(",") if s.strip()]
for tag_spec in specs:
tag, min_c, max_c = _parse_tag_spec(tag_spec)
parsed[tag] = (min_c, max_c)
return frozenset(parsed.keys()), parsed
[docs]
def child(
self,
target: TreeStore,
tag: str,
label: str | None = None,
value: Any = None,
_position: str | None = None,
_builder: BuilderBase | None = None,
**attr: Any,
) -> TreeStore | TreeStoreNode:
"""Create a child node in the target TreeStore.
Args:
target: The TreeStore to add the child to.
tag: The node's type (stored in node.tag).
label: Explicit label. If None, auto-generated as tag_N.
value: If provided, creates a leaf node; otherwise creates a branch.
_position: Position specifier (see TreeStore.set_item for syntax).
_builder: Override builder for this branch and its descendants.
If None, inherits from target.
**attr: Node attributes.
Returns:
TreeStore if branch (for adding children), TreeStoreNode if leaf.
Example:
>>> builder.child(store, 'div', id='main')
>>> builder.child(store, 'meta', value='', charset='utf-8') # void
>>> builder.child(store, 'svg', _builder=SvgBuilder())
"""
# Import here to avoid circular dependency
from ..store import TreeStore
from ..store import TreeStoreNode
# Auto-generate label if not provided
if label is None:
n = 0
while f"{tag}_{n}" in target._nodes:
n += 1
label = f"{tag}_{n}"
# Determine builder for child
child_builder = _builder if _builder is not None else target._builder
if value is not None:
# Leaf node
node = TreeStoreNode(label, attr, value, parent=target, tag=tag)
target._insert_node(node, _position)
return node
else:
# Branch node
child_store = TreeStore(builder=child_builder)
node = TreeStoreNode(label, attr, value=child_store, parent=target, tag=tag)
child_store.parent = node
target._insert_node(node, _position)
return child_store
def _get_validation_rules(
self, tag: str | None
) -> tuple[frozenset[str] | None, dict[str, tuple[int, int | None]]]:
"""Get validation rules for a tag from decorated methods or schema.
Args:
tag: The tag name to look up. None means root level.
Returns:
Tuple of (valid_children, child_cardinality).
- valid_children: frozenset of allowed child tag names, or None if no rules
- child_cardinality: dict mapping tag -> (min, max) for each child type
Returns (None, {}) if no rules defined or tag is None.
"""
if tag is None:
return None, {}
# First, check decorated methods
element_tags = getattr(type(self), "_element_tags", {})
if tag in element_tags:
method_name = element_tags[tag]
method = getattr(self, method_name, None)
if method is not None:
# Check for raw children spec (needs dynamic resolution)
raw_spec = getattr(method, "_raw_children_spec", None)
if raw_spec is not None:
# Re-parse with current instance for =ref resolution
return self._parse_children_spec(raw_spec)
# Otherwise use pre-computed values
valid = getattr(method, "_valid_children", None)
cardinality = getattr(method, "_child_cardinality", {})
return valid, cardinality
# Then, check _schema
schema = getattr(self, "_schema", {})
if tag in schema:
spec = schema[tag]
children_spec = spec.get("children")
if children_spec is not None:
return self._parse_children_spec(children_spec)
else:
# No children spec = leaf element
return frozenset(), {}
return None, {}
[docs]
def check(self, store: TreeStore, parent_tag: str | None = None, path: str = "") -> list[str]:
"""Check the TreeStore structure against this builder's rules.
Checks structure rules defined via @element(children=...) decorator:
- valid_children: which tags can be children of this tag
- cardinality: per-tag min/max constraints using slice syntax
Args:
store: The TreeStore to check.
parent_tag: The tag of the parent node (for context).
path: Current path in the tree (for error messages).
Returns:
List of error messages (empty if valid).
"""
errors = []
# Get rules for parent tag
valid_children, cardinality = self._get_validation_rules(parent_tag)
# Count children by tag
child_counts: dict[str, int] = {}
for node in store.nodes():
child_tag = node.tag or node.label
child_counts[child_tag] = child_counts.get(child_tag, 0) + 1
# Check each child
for node in store.nodes():
child_tag = node.tag or node.label
node_path = f"{path}.{node.label}" if path else node.label
# Check if child tag is valid for parent
if valid_children is not None and child_tag not in valid_children:
if valid_children:
errors.append(
f"'{child_tag}' is not a valid child of '{parent_tag}'. "
f"Valid children: {', '.join(sorted(valid_children))}"
)
else:
errors.append(
f"'{child_tag}' is not a valid child of '{parent_tag}'. "
f"'{parent_tag}' cannot have children"
)
# Recursively check branch children
if not node.is_leaf:
child_errors = self.check(node.value, parent_tag=child_tag, path=node_path)
errors.extend(child_errors)
# Check per-tag cardinality constraints
for tag, (min_count, max_count) in cardinality.items():
actual = child_counts.get(tag, 0)
if min_count > 0 and actual < min_count:
errors.append(
f"'{parent_tag}' requires at least {min_count} '{tag}', but has {actual}"
)
if max_count is not None and actual > max_count:
errors.append(
f"'{parent_tag}' allows at most {max_count} '{tag}', but has {actual}"
)
return errors