"""
Prepared patterns for deferred rendering.
This module provides PreparedPattern for deferred card rendering,
using the card_builder package utilities for metadata and rendering.
"""
import logging
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from adapters.module_wrapper.types import (
ComponentName,
ComponentPaths,
JsonDict,
Payload,
Serializable,
)
from gchat.card_builder.constants import COMPONENT_PARAMS
from gchat.card_builder.metadata import (
get_children_field,
is_empty_component,
)
logger = logging.getLogger(__name__)
# =============================================================================
# INPUT MAPPING REPORT - Tracks how inputs are consumed during card building
# =============================================================================
@dataclass
class InputMappingReport:
"""Tracks how inputs are consumed during card building.
Implements Serializable protocol for consistent JSON serialization.
"""
consumptions: List[JsonDict] = field(default_factory=list)
def record(
self,
input_type: str,
index: int,
value: str,
component: ComponentName,
field_name: str,
) -> None:
"""Record a consumption event."""
preview = value[:50] + "..." if len(value) > 50 else value
self.consumptions.append(
{
"input": f"{input_type}[{index}]",
"value_preview": preview,
"component": component,
"field": field_name,
}
)
def to_dict(self, context: Optional[JsonDict] = None) -> JsonDict:
"""Convert to dictionary for API response.
Args:
context: Optional build context for unconsumed counts
Returns:
JSON-serializable dictionary with mappings and unconsumed counts
"""
result: JsonDict = {"mappings": self.consumptions}
if context:
buttons_total = len(context.get("buttons", []))
texts_total = len(context.get("content_texts", []))
result["unconsumed"] = {
"buttons": buttons_total - context.get("_button_index", 0),
"texts": texts_total - context.get("_text_index", 0),
}
return result
# =============================================================================
# PREPARED PATTERN - Deferred Rendering Support
# =============================================================================
class PreparedPattern:
"""
A pattern prepared for rendering with deferred parameter population.
This class allows you to:
1. Load component classes from the wrapper once
2. Set/update parameters before rendering
3. Render to Google Chat JSON when ready
Usage:
# Get a prepared pattern
prepared = PreparedPattern.from_pattern(pattern, wrapper)
# Set params (chainable)
prepared.set_params(text="Hello", title="My Card")
# Or set individual params
prepared.set_param("buttons", [{"text": "Click", "url": "..."}])
# Render when ready
card_json = prepared.render()
# Or get instances without rendering (for inspection/modification)
instances = prepared.get_instances()
"""
def __init__(self, component_paths: ComponentPaths, wrapper):
"""
Initialize with component paths and wrapper.
Args:
component_paths: List of component names (e.g., ["Section", "DecoratedText", "ButtonList"])
wrapper: ModuleWrapper instance for loading component classes
"""
self.component_paths: ComponentPaths = component_paths
self.wrapper = wrapper
self.params: JsonDict = {}
self._component_classes: Dict[ComponentName, Any] = {}
self._load_component_classes()
def _load_component_classes(self):
"""Load component classes from wrapper using cached class retrieval."""
if not self.wrapper:
return
for comp_name in set(self.component_paths):
if comp_name in ("Section",): # Skip containers
continue
# Use cached class retrieval for fast L1 memory access
comp_class = self.wrapper.get_cached_class(comp_name)
# Fallback to path-based lookup if not in cache
if not comp_class:
paths_to_try = [
f"card_framework.v2.widgets.{comp_name.lower()}.{comp_name}",
f"card_framework.v2.{comp_name.lower()}.{comp_name}",
]
for path in paths_to_try:
comp_class = self.wrapper.get_component_by_path(path)
if comp_class:
break
if comp_class:
self._component_classes[comp_name] = comp_class
def set_params(self, **params) -> "PreparedPattern":
"""
Set multiple parameters at once. Chainable.
Args:
**params: Parameter key-value pairs
Returns:
Self for chaining
"""
self.params.update(params)
return self
def set_param(self, key: str, value: Any) -> "PreparedPattern":
"""
Set a single parameter. Chainable.
Args:
key: Parameter name
value: Parameter value
Returns:
Self for chaining
"""
self.params[key] = value
return self
def get_instances(self) -> List[JsonDict]:
"""
Get component instances without rendering.
Useful for inspection or manual modification before rendering.
Returns:
List of dicts with 'name', 'class', 'instance' (or None if instantiation failed)
"""
instances: List[JsonDict] = []
for comp_name in self.component_paths:
if comp_name in ("Section",):
continue
comp_class = self._component_classes.get(comp_name)
instance = None
if comp_class:
# Get params relevant to this component
comp_params = self._get_params_for_component(comp_name)
instance = self.wrapper.create_card_component(comp_class, comp_params)
instances.append(
{
"name": comp_name,
"class": comp_class,
"instance": instance,
}
)
return instances
def _get_params_for_component(self, comp_name: ComponentName) -> JsonDict:
"""Get relevant params for a specific component type.
Uses COMPONENT_PARAMS from card_builder.constants for dynamic mapping.
"""
# Get expected params from COMPONENT_PARAMS registry
expected_params = COMPONENT_PARAMS.get(comp_name, {})
comp_params: JsonDict = {}
# Map input params to component params based on COMPONENT_PARAMS keys
for param_key in expected_params.keys():
# Check both exact match and common aliases
if param_key in self.params:
comp_params[param_key] = self.params[param_key]
# Handle common aliases
elif param_key == "text" and "description" in self.params:
comp_params["text"] = self.params["description"]
elif param_key == "image_url" and "imageUrl" in self.params:
comp_params["image_url"] = self.params["imageUrl"]
# Also pass through any params that match directly
for key, value in self.params.items():
if key not in comp_params and key in expected_params:
comp_params[key] = value
return comp_params
def render(self, include_header: bool = True) -> JsonDict:
"""
Render the pattern to Google Chat card JSON.
Args:
include_header: Whether to include card header if title is set
Returns:
Card dict in Google Chat format
"""
widgets: List[JsonDict] = []
text_consumed = False
buttons_consumed = False
image_consumed = False
for comp_name in self.component_paths:
if comp_name in ("Section",):
continue
# Check if this is an empty component (like Divider)
if is_empty_component(comp_name, self.wrapper):
widgets.append(self._build_empty_widget(comp_name))
continue
comp_class = self._component_classes.get(comp_name)
if comp_class:
# Try to use wrapper's create_card_component + render
comp_params = self._get_params_for_component(comp_name)
instance = self.wrapper.create_card_component(comp_class, comp_params)
if instance and hasattr(instance, "render"):
widget = instance.render()
if widget:
widgets.append(widget)
# Track consumption based on component type
if comp_name in ("DecoratedText", "TextParagraph"):
text_consumed = True
elif comp_name == "ButtonList":
buttons_consumed = True
elif comp_name == "Image":
image_consumed = True
continue
# Fallback: manual widget construction for components that failed
widget = self._build_fallback_widget(
comp_name, text_consumed, buttons_consumed, image_consumed
)
if widget:
widgets.append(widget)
if comp_name in ("DecoratedText", "TextParagraph"):
text_consumed = True
elif comp_name == "ButtonList":
buttons_consumed = True
elif comp_name == "Image":
image_consumed = True
# Build card structure
card = {"sections": [{"widgets": widgets}]} if widgets else {"sections": []}
# Add header if requested and title exists
if include_header and self.params.get("title"):
card["header"] = {"title": self.params["title"]}
if self.params.get("subtitle"):
card["header"]["subtitle"] = self.params["subtitle"]
return card
def _build_empty_widget(self, comp_name: ComponentName) -> JsonDict:
"""Build an empty widget (like Divider)."""
json_key = comp_name[0].lower() + comp_name[1:] # camelCase
return {json_key: {}}
def _build_fallback_widget(
self,
comp_name: ComponentName,
text_consumed: bool,
buttons_consumed: bool,
image_consumed: bool,
) -> Optional[JsonDict]:
"""Build widget manually when component class rendering fails.
Uses COMPONENT_PARAMS to understand expected fields.
"""
if comp_name == "DecoratedText" and not text_consumed:
text = self.params.get("text") or self.params.get("description", "")
if text:
return {"decoratedText": {"text": text, "wrapText": True}}
elif comp_name == "TextParagraph" and not text_consumed:
text = self.params.get("text") or self.params.get("description", "")
if text:
return {"textParagraph": {"text": text}}
elif comp_name == "ButtonList" and not buttons_consumed:
buttons = self.params.get("buttons", [])
if buttons:
btn_list = []
for btn in buttons:
if isinstance(btn, dict):
btn_obj = {"text": btn.get("text", "Button")}
if btn.get("url"):
btn_obj["onClick"] = {"openLink": {"url": btn["url"]}}
btn_list.append(btn_obj)
if btn_list:
return {"buttonList": {"buttons": btn_list}}
elif comp_name == "Image" and not image_consumed:
image_url = self.params.get("image_url") or self.params.get("imageUrl")
if image_url:
return {"image": {"imageUrl": image_url}}
elif comp_name == "Divider":
return {"divider": {}}
return None
@classmethod
def from_pattern(cls, pattern: Payload, wrapper) -> "PreparedPattern":
"""
Create a PreparedPattern from a pattern dict.
Args:
pattern: Dict with 'component_paths' and optionally 'instance_params'
wrapper: ModuleWrapper instance
Returns:
PreparedPattern ready for param setting and rendering
"""
component_paths: ComponentPaths = pattern.get("component_paths", [])
instance_params: JsonDict = pattern.get("instance_params", {})
prepared = cls(component_paths, wrapper)
if instance_params:
prepared.set_params(**instance_params)
return prepared
@classmethod
def from_dsl(cls, dsl_string: str, wrapper) -> "PreparedPattern":
"""
Create a PreparedPattern from DSL notation.
Args:
dsl_string: DSL notation (symbols from wrapper.symbol_mapping)
wrapper: ModuleWrapper instance
Returns:
PreparedPattern ready for param setting and rendering
"""
from gchat.card_framework_wrapper import parse_dsl
result = parse_dsl(dsl_string)
if not result.is_valid:
logger.warning(f"Invalid DSL: {result.issues}")
return cls(["Section", "TextParagraph"], wrapper)
return cls(result.component_paths, wrapper)
def prepare_pattern(
pattern: Dict[str, Any],
wrapper=None,
) -> PreparedPattern:
"""
Convenience function to create a PreparedPattern.
Args:
pattern: Dict with 'component_paths' and optionally 'instance_params'
wrapper: ModuleWrapper instance (uses singleton if not provided)
Returns:
PreparedPattern ready for param setting and rendering
Example:
from gchat.card_builder import prepare_pattern
pattern = {"component_paths": ["Section", "DecoratedText", "ButtonList"]}
card = (
prepare_pattern(pattern)
.set_params(text="Hello World", buttons=[{"text": "Click"}])
.render()
)
"""
if wrapper is None:
from gchat.card_framework_wrapper import get_card_framework_wrapper
wrapper = get_card_framework_wrapper()
return PreparedPattern.from_pattern(pattern, wrapper)
def prepare_pattern_from_dsl(
dsl_string: str,
wrapper=None,
) -> PreparedPattern:
"""
Convenience function to create a PreparedPattern from DSL.
Args:
dsl_string: DSL notation (symbols from ModuleWrapper.symbol_mapping)
wrapper: ModuleWrapper instance (uses singleton if not provided)
Returns:
PreparedPattern ready for param setting and rendering
Example:
from gchat.card_builder import prepare_pattern_from_dsl
# Get symbols from wrapper.symbol_mapping for current mappings
card = (
prepare_pattern_from_dsl("§[δ, Ƀ[ᵬ×2]]")
.set_params(text="Status: OK", buttons=[{"text": "Refresh"}, {"text": "Close"}])
.render()
)
"""
if wrapper is None:
from gchat.card_framework_wrapper import get_card_framework_wrapper
wrapper = get_card_framework_wrapper()
return PreparedPattern.from_dsl(dsl_string, wrapper)
__all__ = [
"InputMappingReport",
"PreparedPattern",
"prepare_pattern",
"prepare_pattern_from_dsl",
]