Coverage for mcp_chatkit_widget / widget_loader.py: 100%
71 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-11 06:37 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-11 06:37 +0000
1"""Widget discovery and loading utilities.
3This module provides functionality to load curated .widget definitions and
4expose them in a controlled way so the tooling always targets the same
5directory.
6"""
8import json
9import logging
10from dataclasses import dataclass
11from pathlib import Path
12from typing import Any
15LOGGER = logging.getLogger(__name__)
18@dataclass
19class WidgetDefinition:
20 """Represents a loaded widget definition.
22 Attributes:
23 name: The widget name
24 version: Widget format version
25 json_schema: JSON Schema defining the widget's input structure
26 output_json_preview: Preview of the rendered widget output
27 template: Jinja2 template string for rendering the widget
28 encoded_widget: Base64 encoded widget data (optional)
29 file_path: Path to the .widget file
30 """
32 name: str
33 version: str
34 json_schema: dict[str, Any]
35 output_json_preview: dict[str, Any]
36 template: str
37 encoded_widget: str | None
38 file_path: Path
41def _validate_widgets_dir(widgets_dir: Path | None) -> Path:
42 """Ensure the provided widgets directory exists and is a directory."""
43 if widgets_dir is None:
44 raise ValueError("The widgets_dir argument is required to load widgets.")
45 resolved_dir = widgets_dir.resolve()
46 if not resolved_dir.exists():
47 raise ValueError(f"Widgets directory does not exist: {resolved_dir}")
48 if not resolved_dir.is_dir():
49 raise ValueError(f"Widgets directory is not a directory: {resolved_dir}")
50 return resolved_dir
53def _collect_widget_files(base_dir: Path) -> list[Path]:
54 """Return .widget files that physically live inside the base directory."""
55 base_resolved = base_dir.resolve()
56 widget_files: list[Path] = []
57 seen_files: set[Path] = set()
58 for widget_file in sorted(base_dir.rglob("*.widget")):
59 if not widget_file.is_file():
60 continue
61 try:
62 resolved_path = widget_file.resolve()
63 except OSError as exc:
64 LOGGER.warning(
65 "Skipping widget %s because it could not be resolved (%s)",
66 widget_file,
67 exc,
68 )
69 continue
70 try:
71 resolved_path.relative_to(base_resolved)
72 except ValueError:
73 LOGGER.warning(
74 "Skipping widget %s because it is outside the widgets directory",
75 widget_file,
76 )
77 continue
78 if resolved_path in seen_files:
79 continue
80 seen_files.add(resolved_path)
81 widget_files.append(widget_file)
82 return widget_files
85def load_widgets(widgets_dir: Path | None) -> list[WidgetDefinition]:
86 """Load all .widget files from the curated directory.
88 Args:
89 widgets_dir: Path to the curated widgets directory. Must exist.
91 Returns:
92 List of loaded WidgetDefinition instances.
93 """
94 base_dir = _validate_widgets_dir(widgets_dir)
95 widget_files = _collect_widget_files(base_dir)
97 if not widget_files:
98 LOGGER.warning("No widget definitions found in %s", base_dir)
100 return [load_widget(widget_file) for widget_file in widget_files]
103def discover_widgets(widgets_dir: Path | None = None) -> list[WidgetDefinition]:
104 """Discover widgets from a curated directory."""
105 return load_widgets(widgets_dir)
108def load_widget(widget_path: Path) -> WidgetDefinition:
109 """Load a widget definition from a .widget file.
111 Args:
112 widget_path: Path to the .widget file
114 Returns:
115 WidgetDefinition object containing the parsed widget data
117 Raises:
118 ValueError: If the widget file is invalid or missing required fields
119 json.JSONDecodeError: If the widget file contains invalid JSON
120 """
121 with open(widget_path) as f:
122 data = json.load(f)
124 required_fields = ["name", "version", "jsonSchema", "outputJsonPreview", "template"]
125 missing_fields = [field for field in required_fields if field not in data]
126 if missing_fields:
127 raise ValueError(
128 f"Widget file {widget_path} missing required fields: "
129 f"{', '.join(missing_fields)}"
130 )
132 template_value = data["template"]
133 if not isinstance(template_value, str):
134 raise ValueError(f"Widget template must be a string: {widget_path}")
136 return WidgetDefinition(
137 name=data["name"],
138 version=data["version"],
139 json_schema=data["jsonSchema"],
140 output_json_preview=data["outputJsonPreview"],
141 template=template_value,
142 encoded_widget=data.get("encodedWidget"),
143 file_path=widget_path,
144 )
147def get_widget_by_name(name: str, widgets_dir: Path) -> WidgetDefinition | None:
148 """Get a specific widget by name.
150 Args:
151 name: The widget name to search for
152 widgets_dir: Directory to search for .widget files.
154 Returns:
155 WidgetDefinition if found, None otherwise
156 """
157 widgets = load_widgets(widgets_dir)
158 for widget in widgets:
159 if widget.name == name:
160 return widget
161 return None