"""
Rendering utilities for card components.
This module provides helpers for building and rendering card components,
including proper handling of array items and nested structures, as well as
key conversion utilities for the Google Chat API.
"""
from typing import Any, Dict, List, Optional
from adapters.module_wrapper.types import ComponentName, JsonDict
# =============================================================================
# KEY CONVERSION UTILITIES
# =============================================================================
def get_json_key(component_name: ComponentName) -> str:
"""Get camelCase JSON key from component name.
Derived directly from component name - no hardcoded mapping needed.
Args:
component_name: PascalCase component name (e.g., "DecoratedText")
Returns:
camelCase JSON key (e.g., "decoratedText")
Examples:
>>> get_json_key("DecoratedText")
"decoratedText"
>>> get_json_key("ButtonList")
"buttonList"
"""
if not component_name:
return ""
return component_name[0].lower() + component_name[1:]
# Mapping from JSON keys to component names for reverse lookup
_JSON_KEY_TO_COMPONENT = {
"decoratedText": "DecoratedText",
"textParagraph": "TextParagraph",
"buttonList": "ButtonList",
"chipList": "ChipList",
"image": "Image",
"divider": "Divider",
"grid": "Grid",
"columns": "Columns",
"carousel": "Carousel",
"carouselCard": "CarouselCard",
"textInput": "TextInput",
"selectionInput": "SelectionInput",
"dateTimePicker": "DateTimePicker",
"gridItem": "GridItem",
"column": "Column",
"button": "Button",
"chip": "Chip",
}
def json_key_to_component_name(json_key: str) -> ComponentName:
"""Convert JSON key to component name.
Args:
json_key: camelCase JSON key (e.g., "decoratedText")
Returns:
PascalCase component name (e.g., "DecoratedText")
Examples:
>>> json_key_to_component_name("decoratedText")
"DecoratedText"
>>> json_key_to_component_name("buttonList")
"ButtonList"
"""
return _JSON_KEY_TO_COMPONENT.get(json_key, json_key.title().replace("_", ""))
def convert_to_camel_case(data: Any) -> Any:
"""Convert snake_case keys to camelCase for Google Chat API.
The wrapper renders snake_case (e.g., start_icon, on_click) but
Google Chat API expects camelCase (e.g., startIcon, onClick).
Keys starting with underscore (e.g., _card_id, _feedback_assembly) are
internal metadata and are stripped from the output.
Args:
data: Dict, list, or primitive value
Returns:
Same structure with snake_case keys converted to camelCase
Examples:
>>> convert_to_camel_case({"start_icon": {"known_icon": "STAR"}})
{"startIcon": {"knownIcon": "STAR"}}
>>> convert_to_camel_case({"_internal": "skip", "text": "keep"})
{"text": "keep"}
"""
if isinstance(data, dict):
result = {}
for key, value in data.items():
# Skip internal metadata keys (start with underscore)
if key.startswith("_"):
continue
# Convert snake_case to camelCase
parts = key.split("_")
camel_key = parts[0] + "".join(word.capitalize() for word in parts[1:])
result[camel_key] = convert_to_camel_case(value)
return result
elif isinstance(data, list):
return [convert_to_camel_case(item) for item in data]
else:
return data
# JSON keys for components that are array items (should NOT have wrapper key)
# When these components appear as children of a container, they should be unwrapped
_ARRAY_ITEM_JSON_KEYS = {
"carouselCard", # Items in Carousel.carouselCards
"gridItem", # Items in Grid.items
"column", # Items in Columns.columnItems
"button", # Items in ButtonList.buttons
"chip", # Items in ChipList.chips
}
def unwrap_array_item(item: JsonDict) -> JsonDict:
"""
Unwrap an array item that was built with wrap_with_key=True.
When building component trees, child components are built with wrapper keys
like {"carouselCard": {"widgets": [...]}}, but when placed in array fields
(like carouselCards), they should be just {"widgets": [...]}.
Args:
item: Widget dict possibly wrapped with JSON key
Returns:
Unwrapped item if it was wrapped with an array item key, otherwise unchanged
Example:
>>> unwrap_array_item({"carouselCard": {"widgets": [...]}})
{"widgets": [...]}
>>> unwrap_array_item({"decoratedText": {"text": "..."}})
{"decoratedText": {"text": "..."}} # Not an array item, unchanged
"""
if not isinstance(item, dict):
return item
# Check if item has exactly one key that's an array item key
if len(item) == 1:
key = next(iter(item.keys()))
if key in _ARRAY_ITEM_JSON_KEYS:
return item[key]
return item
def unwrap_array_items(items: List[JsonDict]) -> List[JsonDict]:
"""
Unwrap multiple array items.
Args:
items: List of widget dicts
Returns:
List with array items unwrapped
"""
return [unwrap_array_item(item) for item in items]
def should_unwrap_children(container: ComponentName, children_field: str) -> bool:
"""
Determine if children should be unwrapped for a given container.
Array fields contain items that should NOT have wrapper keys.
Args:
container: Container component name (e.g., "Carousel")
children_field: The field name for children (e.g., "carouselCards")
Returns:
True if children should be unwrapped
"""
# Fields that contain array items (not widgets)
array_fields = {
"carouselCards", # Carousel -> CarouselCard[]
"items", # Grid -> GridItem[]
"columnItems", # Columns -> Column[]
"buttons", # ButtonList -> Button[]
"chips", # ChipList -> Chip[]
}
return children_field in array_fields
def prepare_children_for_container(
container: ComponentName,
children: List[JsonDict],
children_field: str,
) -> List[JsonDict]:
"""
Prepare children for insertion into a container's children field.
This handles unwrapping array items so they don't have redundant
wrapper keys when placed in array fields.
Args:
container: Container component name
children: List of built child widgets
children_field: The field name where children will be placed
Returns:
Children list ready for the container
Example:
>>> children = [{"carouselCard": {"widgets": [...]}}, ...]
>>> prepare_children_for_container("Carousel", children, "carouselCards")
[{"widgets": [...]}, ...] # Wrapper keys stripped
"""
if should_unwrap_children(container, children_field):
return unwrap_array_items(children)
return children
# =============================================================================
# ICON BUILDING
# =============================================================================
import logging
from adapters.module_wrapper.strict import warn_strict
logger = logging.getLogger(__name__)
def build_material_icon(
icon_name: str,
fill: bool = None,
weight: int = None,
) -> JsonDict:
"""Build a materialIcon dict using the wrapper's Icon.MaterialIcon class.
This uses the card_framework wrapper components to ensure consistency
and proper rendering of Material Design icons (2,209+ available icons).
Icon names are resolved through resolve_icon_name() which handles:
- Case normalization: "FOLDER" → "folder"
- Semantic resolution: "success" → "check_circle"
- Validation with helpful suggestions for typos
Args:
icon_name: Material icon name (e.g., "thumb_up", "check_circle")
fill: Optional fill style (True for filled, False for outlined)
weight: Optional weight (100-700, default varies by icon)
Returns:
Dict in format: {"materialIcon": {"name": "icon_name", ...}}
Example:
>>> build_material_icon("thumb_up")
{"materialIcon": {"name": "thumb_up"}}
>>> build_material_icon("check_circle", fill=True, weight=400)
{"materialIcon": {"name": "check_circle", "fill": True, "weight": 400}}
"""
from gchat.material_icons import resolve_icon_name
icon_name = resolve_icon_name(icon_name)
try:
from card_framework.v2.widgets.icon import Icon
# Build MaterialIcon using wrapper component
mi = Icon.MaterialIcon(name=icon_name, fill=fill, weight=weight)
icon = Icon(material_icon=mi)
# Use to_dict() for proper rendering
icon_dict = icon.to_dict()
# Convert to camelCase for Google Chat API
if "material_icon" in icon_dict:
icon_dict["materialIcon"] = icon_dict.pop("material_icon")
return icon_dict
except Exception as e:
logger.debug(f"Wrapper MaterialIcon failed, using fallback: {e}")
# Fallback to manual dict construction
result: Dict[str, Any] = {"materialIcon": {"name": icon_name}}
if fill is not None:
result["materialIcon"]["fill"] = fill
if weight is not None:
result["materialIcon"]["weight"] = weight
return result
def build_start_icon(icon_name: str) -> JsonDict:
"""Build a startIcon dict for decoratedText using materialIcon.
Args:
icon_name: Material icon name (e.g., "feedback", "rate_review")
Returns:
Dict in format: {"materialIcon": {"name": "icon_name"}}
Example:
>>> build_start_icon("feedback")
{"materialIcon": {"name": "feedback"}}
"""
return build_material_icon(icon_name)
# =============================================================================
# SPECIALIZED COMPONENT BUILDERS (via wrapper)
# =============================================================================
# These builders handle components with special requirements (enums, nested objects)
# They take wrapper as parameter for proper component class access.
def build_button_via_wrapper(wrapper, params: JsonDict) -> Optional[JsonDict]:
"""Build a Button using wrapper component classes.
Args:
wrapper: ModuleWrapper instance for component access
params: Button parameters (text, url)
Returns:
Button dict in Google Chat format, or None if build fails
"""
# Get component classes via cache for fast retrieval
Button = wrapper.get_cached_class("Button")
OnClick = wrapper.get_cached_class("OnClick")
OpenLink = wrapper.get_cached_class("OpenLink")
# Fallback to direct imports if cache misses (OnClick path resolution can fail)
if not OnClick:
try:
from card_framework.v2.widgets.on_click import OnClick
except ImportError:
pass
if not OpenLink:
try:
from card_framework.v2.widgets.open_link import OpenLink
except ImportError:
pass
if not Button:
try:
from card_framework.v2.widgets.button import Button
except ImportError:
pass
if not all([Button, OnClick, OpenLink]):
missing = [
n
for n, c in [
("Button", Button),
("OnClick", OnClick),
("OpenLink", OpenLink),
]
if not c
]
warn_strict(
f"build_button_via_wrapper(): missing classes {missing}. "
f"Button will not render."
)
logger.debug(
"Could not get Button/OnClick/OpenLink classes from cache or import"
)
return None
try:
# Build OnClick with OpenLink
url = params.get("url", "https://example.com")
open_link = OpenLink(url=url)
on_click = OnClick(open_link=open_link)
# Build Button
button = Button(
text=params.get("text", "Button"),
on_click=on_click,
)
# Render and convert to camelCase
if hasattr(button, "to_dict"):
rendered = button.to_dict()
return convert_to_camel_case(rendered)
except Exception as e:
logger.debug(f"build_button_via_wrapper failed: {e}")
return None
def build_icon_via_wrapper(wrapper, params: JsonDict) -> Optional[JsonDict]:
"""Build an Icon using wrapper component classes.
Args:
wrapper: ModuleWrapper instance for component access
params: Icon parameters (known_icon or icon_url)
Returns:
Icon dict in Google Chat format, or None if build fails
"""
# Use cached class for fast retrieval
Icon = wrapper.get_cached_class("Icon")
if not Icon:
logger.debug("Could not get Icon class from cache")
return None
try:
# Get the KnownIcon enum
if hasattr(Icon, "KnownIcon"):
known_icon_name = params.get("known_icon", "STAR")
# Try to get the enum value
known_icon_enum = getattr(
Icon.KnownIcon, known_icon_name, Icon.KnownIcon.STAR
)
icon = Icon(known_icon=known_icon_enum)
if hasattr(icon, "to_dict"):
rendered = icon.to_dict()
return convert_to_camel_case(rendered)
except Exception as e:
logger.debug(f"Failed to create Icon with enum: {e}")
# Fallback: try with icon_url if provided
if params.get("icon_url"):
try:
icon = Icon(icon_url=params["icon_url"])
if hasattr(icon, "to_dict"):
rendered = icon.to_dict()
return convert_to_camel_case(rendered)
except Exception as e:
logger.debug(f"Failed to create Icon with URL: {e}")
return None
def build_switch_via_wrapper(wrapper, params: JsonDict) -> Optional[JsonDict]:
"""Build a SwitchControl using wrapper component classes.
Args:
wrapper: ModuleWrapper instance for component access
params: SwitchControl parameters (name, selected)
Returns:
SwitchControl dict in Google Chat format, or None if build fails
"""
# Use cached class for fast retrieval
SwitchControl = wrapper.get_cached_class("SwitchControl")
if not SwitchControl:
logger.debug("Could not get SwitchControl class")
return None
try:
switch = SwitchControl(
name=params.get("name", "switch"),
selected=params.get("selected", False),
)
if hasattr(switch, "to_dict"):
rendered = switch.to_dict()
return convert_to_camel_case(rendered)
except Exception as e:
logger.debug(f"Failed to create SwitchControl: {e}")
return None
def build_onclick_via_wrapper(wrapper, params: JsonDict) -> Optional[JsonDict]:
"""Build an OnClick using wrapper component classes.
Args:
wrapper: ModuleWrapper instance for component access
params: OnClick parameters (url)
Returns:
OnClick dict in Google Chat format, or None if build fails
"""
# Use cached classes for fast retrieval
OnClick = wrapper.get_cached_class("OnClick")
OpenLink = wrapper.get_cached_class("OpenLink")
if not all([OnClick, OpenLink]):
missing = [
n for n, c in [("OnClick", OnClick), ("OpenLink", OpenLink)] if not c
]
warn_strict(
f"build_onclick_via_wrapper(): missing classes {missing}. "
f"OnClick will not render."
)
logger.debug("Could not get OnClick/OpenLink classes from cache")
return None
try:
if params.get("url"):
open_link = OpenLink(url=params["url"])
on_click = OnClick(open_link=open_link)
if hasattr(on_click, "to_dict"):
rendered = on_click.to_dict()
return convert_to_camel_case(rendered)
except Exception as e:
logger.debug(f"Failed to create OnClick: {e}")
return None
__all__ = [
# Key conversion utilities
"get_json_key",
"json_key_to_component_name",
"convert_to_camel_case",
# Array item handling
"unwrap_array_item",
"unwrap_array_items",
"should_unwrap_children",
"prepare_children_for_container",
# Icon building
"build_material_icon",
"build_start_icon",
# Specialized component builders
"build_button_via_wrapper",
"build_icon_via_wrapper",
"build_switch_via_wrapper",
"build_onclick_via_wrapper",
]