11from __future__ import annotations
22
3+ import inspect
4+ import json
35from datetime import datetime
6+ from pathlib import Path
47from typing import (
58 Annotated ,
9+ Any ,
610 Literal ,
711)
812
13+ from jinja2 import Environment , StrictUndefined , Template
914from pydantic import (
1015 BaseModel ,
1116 ConfigDict ,
1217 Field ,
18+ TypeAdapter ,
1319 model_serializer ,
1420)
1521from typing_extensions import NotRequired , TypedDict
1622
1723from .actions import ActionConfig
1824from .icons import IconName
1925
26+ env = Environment (undefined = StrictUndefined )
27+
2028
2129class ThemeColor (TypedDict ):
2230 """Color values for light and dark themes."""
@@ -1006,32 +1014,23 @@ class LineSeries(BaseModel):
10061014"""Union of all supported chart series types."""
10071015
10081016
1009- class BasicRoot (WidgetComponentBase ):
1010- """Layout root capable of nesting components or other roots."""
1011-
1012- type : Literal ["Basic" ] = Field (default = "Basic" , frozen = True ) # pyright: ignore
1013- children : list [WidgetComponent | WidgetRoot ]
1014- """Children to render inside this root. Can include widget components or nested roots."""
1015- theme : Literal ["light" , "dark" ] | None = None
1016- """Force light or dark theme for this subtree."""
1017- direction : Literal ["row" , "col" ] | None = None
1018- """Flex direction for laying out direct children."""
1019- gap : int | str | None = None
1020- """Gap between direct children; spacing unit or CSS string."""
1021- padding : float | str | Spacing | None = None
1022- """Inner padding; spacing unit, CSS string, or padding object."""
1023- align : Alignment | None = None
1024- """Cross-axis alignment of children."""
1025- justify : Justification | None = None
1026- """Main-axis distribution of children."""
1027-
1028-
10291017WidgetRoot = Annotated [
10301018 Card | ListView ,
10311019 Field (discriminator = "type" ),
10321020]
10331021
1034- WidgetComponent = Annotated [
1022+
1023+ class DynamicWidgetComponent (WidgetComponentBase ):
1024+ """
1025+ A widget component with a statically defined base shape but dynamically
1026+ defined additional fields loaded from a widget template or JSON schema.
1027+ """
1028+
1029+ model_config = ConfigDict (extra = "allow" )
1030+ children : list ["DynamicWidgetComponent" ] | None = None
1031+
1032+
1033+ StrictWidgetComponent = Annotated [
10351034 Text
10361035 | Title
10371036 | Caption
@@ -1058,8 +1057,103 @@ class BasicRoot(WidgetComponentBase):
10581057 | Transition ,
10591058 Field (discriminator = "type" ),
10601059]
1060+
1061+
1062+ StrictWidgetRoot = Annotated [
1063+ Card | ListView ,
1064+ Field (discriminator = "type" ),
1065+ ]
1066+
1067+
1068+ class DynamicWidgetRoot (DynamicWidgetComponent ):
1069+ """Dynamic root widget restricted to root types."""
1070+
1071+ type : Literal ["Card" , "ListView" ] # pyright: ignore
1072+
1073+
1074+ class BasicRoot (WidgetComponentBase ):
1075+ """Layout root capable of nesting components or other roots."""
1076+
1077+ type : Literal ["Basic" ] = Field (default = "Basic" , frozen = True ) # pyright: ignore
1078+ children : list [WidgetComponent | WidgetRoot ]
1079+ """Children to render inside this root. Can include widget components or nested roots."""
1080+ theme : Literal ["light" , "dark" ] | None = None
1081+ """Force light or dark theme for this subtree."""
1082+ direction : Literal ["row" , "col" ] | None = None
1083+ """Flex direction for laying out direct children."""
1084+ gap : int | str | None = None
1085+ """Gap between direct children; spacing unit or CSS string."""
1086+ padding : float | str | Spacing | None = None
1087+ """Inner padding; spacing unit, CSS string, or padding object."""
1088+ align : Alignment | None = None
1089+ """Cross-axis alignment of children."""
1090+ justify : Justification | None = None
1091+ """Main-axis distribution of children."""
1092+
1093+
1094+ WidgetComponent = StrictWidgetComponent | DynamicWidgetComponent
10611095"""Union of all renderable widget components."""
10621096
1097+ WidgetRoot = StrictWidgetRoot | DynamicWidgetRoot
1098+ """Union of all renderable top-level widgets."""
1099+
10631100
10641101WidgetIcon = IconName
10651102"""Icon names accepted by widgets that render icons."""
1103+
1104+
1105+ class WidgetTemplate :
1106+ """
1107+ Utility for loading and building widgets from a .widget file.
1108+
1109+ Example using .widget file on disc:
1110+ ```python
1111+ template = WidgetTemplate.from_file("path/to/my_widget.widget")
1112+ widget = template.build({"name": "Harry Potter"})
1113+ ```
1114+
1115+ Example using already parsed widget definition:
1116+ ```python
1117+ template = WidgetTemplate(definition={"version": "1.0", "name": "...", "template": Template(...), "jsonSchema": {...}})
1118+ widget = template.build({"name": "Harry Potter"})
1119+ ```
1120+ """
1121+
1122+ adapter : TypeAdapter [DynamicWidgetRoot ] = TypeAdapter (DynamicWidgetRoot )
1123+
1124+ def __init__ (self , definition : dict [str , Any ]):
1125+ self .version = definition ["version" ]
1126+ if self .version != "1.0" :
1127+ raise ValueError (f"Unsupported widget spec version: { self .version } " )
1128+
1129+ self .name = definition ["name" ]
1130+ template = definition ["template" ]
1131+ if isinstance (template , Template ):
1132+ self .template = template
1133+ else :
1134+ self .template = env .from_string (template )
1135+ self .data_schema = definition .get ("jsonSchema" , {})
1136+
1137+ @classmethod
1138+ def from_file (cls , file_path : str ) -> "WidgetTemplate" :
1139+ path = Path (file_path )
1140+ if not path .is_absolute ():
1141+ caller_frame = inspect .stack ()[1 ]
1142+ caller_path = Path (caller_frame .filename ).resolve ()
1143+ path = caller_path .parent / path
1144+
1145+ with path .open ("r" , encoding = "utf-8" ) as file :
1146+ payload = json .load (file )
1147+
1148+ return cls (payload )
1149+
1150+ def build (
1151+ self , data : dict [str , Any ] | BaseModel | None = None
1152+ ) -> DynamicWidgetRoot :
1153+ if data is None :
1154+ data = {}
1155+ if isinstance (data , BaseModel ):
1156+ data = data .model_dump ()
1157+ rendered = self .template .render (** data )
1158+ widget_dict = json .loads (rendered )
1159+ return self .adapter .validate_python (widget_dict )
0 commit comments