pyview-web 0.3.0__py3-none-any.whl → 0.8.0a2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyview/__init__.py +16 -6
- pyview/assets/js/app.js +1 -0
- pyview/assets/js/uploaders.js +221 -0
- pyview/assets/package-lock.json +16 -14
- pyview/assets/package.json +2 -2
- pyview/async_stream_runner.py +2 -1
- pyview/auth/__init__.py +3 -1
- pyview/auth/provider.py +6 -6
- pyview/auth/required.py +7 -10
- pyview/binding/__init__.py +47 -0
- pyview/binding/binder.py +134 -0
- pyview/binding/context.py +33 -0
- pyview/binding/converters.py +191 -0
- pyview/binding/helpers.py +78 -0
- pyview/binding/injectables.py +119 -0
- pyview/binding/params.py +105 -0
- pyview/binding/result.py +32 -0
- pyview/changesets/__init__.py +2 -0
- pyview/changesets/changesets.py +8 -3
- pyview/cli/commands/create_view.py +4 -3
- pyview/cli/main.py +1 -1
- pyview/components/__init__.py +72 -0
- pyview/components/base.py +212 -0
- pyview/components/lifecycle.py +85 -0
- pyview/components/manager.py +366 -0
- pyview/components/renderer.py +14 -0
- pyview/components/slots.py +73 -0
- pyview/csrf.py +4 -2
- pyview/events/AutoEventDispatch.py +98 -0
- pyview/events/BaseEventHandler.py +51 -8
- pyview/events/__init__.py +2 -1
- pyview/instrumentation/__init__.py +3 -3
- pyview/instrumentation/interfaces.py +57 -33
- pyview/instrumentation/noop.py +21 -18
- pyview/js.py +20 -23
- pyview/live_routes.py +5 -3
- pyview/live_socket.py +167 -44
- pyview/live_view.py +24 -12
- pyview/meta.py +14 -2
- pyview/phx_message.py +7 -8
- pyview/playground/__init__.py +10 -0
- pyview/playground/builder.py +118 -0
- pyview/playground/favicon.py +39 -0
- pyview/pyview.py +54 -20
- pyview/session.py +2 -0
- pyview/static/assets/app.js +2088 -806
- pyview/static/assets/uploaders.js +221 -0
- pyview/stream.py +308 -0
- pyview/template/__init__.py +11 -1
- pyview/template/live_template.py +12 -8
- pyview/template/live_view_template.py +338 -0
- pyview/template/render_diff.py +33 -7
- pyview/template/root_template.py +21 -9
- pyview/template/serializer.py +2 -5
- pyview/template/template_view.py +170 -0
- pyview/template/utils.py +3 -2
- pyview/uploads.py +344 -55
- pyview/vendor/flet/pubsub/__init__.py +3 -1
- pyview/vendor/flet/pubsub/pub_sub.py +10 -18
- pyview/vendor/ibis/__init__.py +3 -7
- pyview/vendor/ibis/compiler.py +25 -32
- pyview/vendor/ibis/context.py +13 -15
- pyview/vendor/ibis/errors.py +0 -6
- pyview/vendor/ibis/filters.py +70 -76
- pyview/vendor/ibis/loaders.py +6 -7
- pyview/vendor/ibis/nodes.py +40 -42
- pyview/vendor/ibis/template.py +4 -5
- pyview/vendor/ibis/tree.py +62 -3
- pyview/vendor/ibis/utils.py +14 -15
- pyview/ws_handler.py +116 -86
- {pyview_web-0.3.0.dist-info → pyview_web-0.8.0a2.dist-info}/METADATA +39 -33
- pyview_web-0.8.0a2.dist-info/RECORD +80 -0
- pyview_web-0.8.0a2.dist-info/WHEEL +4 -0
- pyview_web-0.8.0a2.dist-info/entry_points.txt +3 -0
- pyview_web-0.3.0.dist-info/LICENSE +0 -21
- pyview_web-0.3.0.dist-info/RECORD +0 -58
- pyview_web-0.3.0.dist-info/WHEEL +0 -4
- pyview_web-0.3.0.dist-info/entry_points.txt +0 -3
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LiveViewTemplate processor for Python t-strings.
|
|
3
|
+
Converts Template objects into LiveView's diff tree structure.
|
|
4
|
+
|
|
5
|
+
This module requires Python 3.14+ for t-string support.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
from typing import TYPE_CHECKING, Any, Callable, TypeVar, Union
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
# T-string support requires Python 3.14+
|
|
13
|
+
if sys.version_info < (3, 14):
|
|
14
|
+
raise ImportError(
|
|
15
|
+
"T-string template support requires Python 3.14 or later. "
|
|
16
|
+
f"Current version: {sys.version_info.major}.{sys.version_info.minor}"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from string.templatelib import Template
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from pyview.components.base import LiveComponent
|
|
23
|
+
from pyview.stream import Stream
|
|
24
|
+
|
|
25
|
+
T = TypeVar("T")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class StreamList:
|
|
30
|
+
"""
|
|
31
|
+
A list wrapper that carries stream metadata for T-string templates.
|
|
32
|
+
|
|
33
|
+
This is returned by stream_for() and detected by LiveViewTemplate._process_list()
|
|
34
|
+
to include the "stream" key in the wire format.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
items: list[Any]
|
|
38
|
+
stream: "Stream"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def stream_for(
|
|
42
|
+
stream: "Stream[T]",
|
|
43
|
+
render_fn: Callable[[str, T], "Template"],
|
|
44
|
+
) -> StreamList:
|
|
45
|
+
"""
|
|
46
|
+
Render a stream in a T-string template.
|
|
47
|
+
|
|
48
|
+
This function iterates over the stream and applies the render function to each item,
|
|
49
|
+
returning a StreamList that LiveViewTemplate will process to include stream metadata.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
stream: The Stream to render
|
|
53
|
+
render_fn: A function that takes (dom_id, item) and returns a Template
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
StreamList containing rendered items and stream reference
|
|
57
|
+
|
|
58
|
+
Example:
|
|
59
|
+
def template(self, assigns, meta):
|
|
60
|
+
return t'''
|
|
61
|
+
<div id="messages" phx-update="stream">
|
|
62
|
+
{stream_for(assigns.messages, lambda dom_id, msg:
|
|
63
|
+
t'<div id="{dom_id}">{msg.text}</div>'
|
|
64
|
+
)}
|
|
65
|
+
</div>
|
|
66
|
+
'''
|
|
67
|
+
"""
|
|
68
|
+
items = [render_fn(dom_id, item) for dom_id, item in stream]
|
|
69
|
+
return StreamList(items=items, stream=stream)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class LiveComponentPlaceholder:
|
|
74
|
+
"""Placeholder for live components in templates."""
|
|
75
|
+
|
|
76
|
+
component_class: "type[LiveComponent]"
|
|
77
|
+
component_id: str
|
|
78
|
+
assigns: dict[str, Any]
|
|
79
|
+
|
|
80
|
+
def __str__(self):
|
|
81
|
+
# Return a placeholder that gets replaced during rendering
|
|
82
|
+
return f"<pyview-component cid='{self.component_id}'/>"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class ComponentMarker:
|
|
87
|
+
"""Marker for component that will be resolved lazily in .text().
|
|
88
|
+
|
|
89
|
+
Used during unconnected (HTTP) phase to defer component rendering
|
|
90
|
+
until after lifecycle methods have run.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
cid: int
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class LiveViewTemplate:
|
|
97
|
+
"""Processes Python t-string Templates into LiveView diff tree format."""
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def process(template: Template, socket: Any = None) -> dict[str, Any]:
|
|
101
|
+
"""
|
|
102
|
+
Convert a Python Template to LiveView diff tree format.
|
|
103
|
+
|
|
104
|
+
The LiveView tree format:
|
|
105
|
+
{
|
|
106
|
+
"s": ["static", "parts", "here"], # Static strings
|
|
107
|
+
"0": "dynamic value", # Dynamic values indexed by position
|
|
108
|
+
"1": { "s": [...], "0": ... }, # Nested structures
|
|
109
|
+
"d": [[...], [...]] # For comprehensions (loops)
|
|
110
|
+
}
|
|
111
|
+
"""
|
|
112
|
+
# Use the template.strings directly for the static parts
|
|
113
|
+
parts: dict[str, Any] = {"s": list(template.strings)}
|
|
114
|
+
|
|
115
|
+
# Process only the interpolations
|
|
116
|
+
interp_index = 0
|
|
117
|
+
for item in template:
|
|
118
|
+
if not isinstance(item, str):
|
|
119
|
+
# This is an Interpolation object
|
|
120
|
+
key = str(interp_index)
|
|
121
|
+
|
|
122
|
+
# Get the actual value from the interpolation
|
|
123
|
+
interp_value = item.value
|
|
124
|
+
|
|
125
|
+
# Apply format specifier if present
|
|
126
|
+
if hasattr(item, "format_spec") and item.format_spec:
|
|
127
|
+
try:
|
|
128
|
+
formatted_value = format(interp_value, item.format_spec)
|
|
129
|
+
except (ValueError, TypeError):
|
|
130
|
+
# If formatting fails, use the value as-is
|
|
131
|
+
formatted_value = interp_value
|
|
132
|
+
else:
|
|
133
|
+
formatted_value = interp_value
|
|
134
|
+
|
|
135
|
+
# Handle different interpolation types
|
|
136
|
+
if isinstance(formatted_value, LiveComponentPlaceholder):
|
|
137
|
+
# Handle live component - Phoenix.js expects CID as a number
|
|
138
|
+
# which it looks up in output.components[cid]
|
|
139
|
+
if socket and hasattr(socket, "components"):
|
|
140
|
+
cid = socket.components.register(
|
|
141
|
+
formatted_value.component_class,
|
|
142
|
+
formatted_value.component_id,
|
|
143
|
+
formatted_value.assigns,
|
|
144
|
+
)
|
|
145
|
+
if getattr(socket, "connected", True):
|
|
146
|
+
# Connected: return CID for wire format
|
|
147
|
+
parts[key] = cid
|
|
148
|
+
else:
|
|
149
|
+
# Unconnected: store marker for lazy resolution in .text()
|
|
150
|
+
parts[key] = ComponentMarker(cid=cid)
|
|
151
|
+
else:
|
|
152
|
+
# Fallback if no socket available
|
|
153
|
+
parts[key] = str(formatted_value)
|
|
154
|
+
|
|
155
|
+
elif isinstance(formatted_value, Template):
|
|
156
|
+
# Handle nested templates
|
|
157
|
+
parts[key] = LiveViewTemplate.process(formatted_value, socket)
|
|
158
|
+
|
|
159
|
+
elif isinstance(formatted_value, str):
|
|
160
|
+
# Simple string interpolation (HTML escaped)
|
|
161
|
+
parts[key] = LiveViewTemplate.escape_html(formatted_value)
|
|
162
|
+
|
|
163
|
+
elif isinstance(formatted_value, (int, float, bool)):
|
|
164
|
+
# Primitive types
|
|
165
|
+
parts[key] = str(formatted_value)
|
|
166
|
+
|
|
167
|
+
elif isinstance(formatted_value, StreamList):
|
|
168
|
+
# Handle stream_for() results
|
|
169
|
+
parts[key] = LiveViewTemplate._process_stream_list(formatted_value, socket)
|
|
170
|
+
|
|
171
|
+
elif isinstance(formatted_value, list):
|
|
172
|
+
# Handle list comprehensions
|
|
173
|
+
parts[key] = LiveViewTemplate._process_list(formatted_value, socket)
|
|
174
|
+
|
|
175
|
+
elif hasattr(formatted_value, "__html__"):
|
|
176
|
+
# Handle objects that can render as HTML (like Markup)
|
|
177
|
+
parts[key] = str(formatted_value.__html__())
|
|
178
|
+
|
|
179
|
+
else:
|
|
180
|
+
# Default: convert to string and escape
|
|
181
|
+
parts[key] = LiveViewTemplate.escape_html(str(formatted_value))
|
|
182
|
+
|
|
183
|
+
interp_index += 1
|
|
184
|
+
|
|
185
|
+
return parts
|
|
186
|
+
|
|
187
|
+
@staticmethod
|
|
188
|
+
def _process_list(items: list, socket: Any = None) -> Union[dict[str, Any], str]:
|
|
189
|
+
"""Process a list of items for the 'd' (dynamics) format."""
|
|
190
|
+
if not items:
|
|
191
|
+
return ""
|
|
192
|
+
|
|
193
|
+
# Process each item based on its type
|
|
194
|
+
processed_items = []
|
|
195
|
+
for item in items:
|
|
196
|
+
if isinstance(item, Template):
|
|
197
|
+
# Process template items - produces {"s": [...], "0": ..., ...}
|
|
198
|
+
processed_items.append(LiveViewTemplate.process(item, socket))
|
|
199
|
+
elif isinstance(item, LiveComponentPlaceholder):
|
|
200
|
+
# Handle component placeholders in lists
|
|
201
|
+
# Phoenix.js expects CID as a number for component lookup
|
|
202
|
+
if socket and hasattr(socket, "components"):
|
|
203
|
+
cid = socket.components.register(
|
|
204
|
+
item.component_class,
|
|
205
|
+
item.component_id,
|
|
206
|
+
item.assigns,
|
|
207
|
+
)
|
|
208
|
+
if getattr(socket, "connected", True):
|
|
209
|
+
# Connected: return CID for wire format
|
|
210
|
+
processed_items.append(cid)
|
|
211
|
+
else:
|
|
212
|
+
# Unconnected: store marker for lazy resolution in .text()
|
|
213
|
+
processed_items.append(ComponentMarker(cid=cid))
|
|
214
|
+
else:
|
|
215
|
+
# Fallback if no socket available - just escaped string
|
|
216
|
+
processed_items.append(LiveViewTemplate.escape_html(str(item)))
|
|
217
|
+
else:
|
|
218
|
+
# Plain strings - just escape, will be wrapped once in fallback
|
|
219
|
+
processed_items.append(LiveViewTemplate.escape_html(str(item)))
|
|
220
|
+
|
|
221
|
+
# Phoenix.js comprehension format ALWAYS requires:
|
|
222
|
+
# - "s": array of static strings (shared across all items)
|
|
223
|
+
# - "d": array where each item is an array of dynamic values
|
|
224
|
+
#
|
|
225
|
+
# processed_items contains either:
|
|
226
|
+
# - dicts with {"s": [...], "0": val, "1": val, ...} for Template items
|
|
227
|
+
# - integer CIDs for component references
|
|
228
|
+
# - escaped strings for non-Template items
|
|
229
|
+
|
|
230
|
+
# Check if all items are dicts with the same statics (true comprehension)
|
|
231
|
+
if processed_items and isinstance(processed_items[0], dict) and "s" in processed_items[0]:
|
|
232
|
+
first_statics = processed_items[0]["s"]
|
|
233
|
+
all_same_statics = all(
|
|
234
|
+
isinstance(item, dict) and item.get("s") == first_statics
|
|
235
|
+
for item in processed_items
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
if all_same_statics:
|
|
239
|
+
# True comprehension: all items share same statics
|
|
240
|
+
# Extract statics to top level, keep only dynamics in "d"
|
|
241
|
+
# Note: We rely on Python 3.7+ dict insertion order here.
|
|
242
|
+
# Keys are inserted as "0", "1", "2", ... in process(), so
|
|
243
|
+
# item.items() yields them in correct order without sorting.
|
|
244
|
+
return {
|
|
245
|
+
"s": first_statics,
|
|
246
|
+
"d": [
|
|
247
|
+
[v for k, v in item.items() if k != "s"]
|
|
248
|
+
for item in processed_items
|
|
249
|
+
],
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
# For all other cases (mixed types, different statics, components, etc.):
|
|
253
|
+
# Use empty statics and wrap each item as a single dynamic
|
|
254
|
+
# This ensures Phoenix.js comprehensionToBuffer always has valid statics
|
|
255
|
+
return {
|
|
256
|
+
"s": ["", ""],
|
|
257
|
+
"d": [[item] for item in processed_items],
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
@staticmethod
|
|
261
|
+
def _process_stream_list(
|
|
262
|
+
stream_list: StreamList, socket: Any = None
|
|
263
|
+
) -> Union[dict[str, Any], str]:
|
|
264
|
+
"""Process a StreamList (from stream_for) including stream metadata."""
|
|
265
|
+
from pyview.stream import Stream
|
|
266
|
+
|
|
267
|
+
stream = stream_list.stream
|
|
268
|
+
items = stream_list.items
|
|
269
|
+
|
|
270
|
+
# Handle empty stream
|
|
271
|
+
if not items:
|
|
272
|
+
# Still check for delete/reset operations
|
|
273
|
+
ops = stream._get_pending_ops()
|
|
274
|
+
if ops is None:
|
|
275
|
+
return ""
|
|
276
|
+
return {"stream": stream._to_wire_format(ops)}
|
|
277
|
+
|
|
278
|
+
# Process each item
|
|
279
|
+
processed_items = []
|
|
280
|
+
for item in items:
|
|
281
|
+
if isinstance(item, Template):
|
|
282
|
+
processed_items.append(LiveViewTemplate.process(item, socket))
|
|
283
|
+
else:
|
|
284
|
+
processed_items.append([LiveViewTemplate.escape_html(str(item))])
|
|
285
|
+
|
|
286
|
+
result: dict[str, Any] = {"d": processed_items}
|
|
287
|
+
|
|
288
|
+
# Extract statics from first item if it has them.
|
|
289
|
+
# processed_items contains either:
|
|
290
|
+
# - dicts with {"s": [...], "0": val, "1": val, ...} for Template items
|
|
291
|
+
# - lists of escaped strings for non-Template items
|
|
292
|
+
if processed_items and isinstance(processed_items[0], dict) and "s" in processed_items[0]:
|
|
293
|
+
# All Template items share the same statics (the template's static strings),
|
|
294
|
+
# so we extract "s" from the first item and use it for the entire result.
|
|
295
|
+
result["s"] = processed_items[0]["s"]
|
|
296
|
+
# Convert each item to just its dynamic values (excluding "s").
|
|
297
|
+
# We rely on Python 3.7+ dict insertion order - keys are inserted as
|
|
298
|
+
# "0", "1", "2", ... in process(), so item.items() yields correct order.
|
|
299
|
+
# Non-dict items (lists): pass through as-is.
|
|
300
|
+
result["d"] = [
|
|
301
|
+
[v for k, v in item.items() if k != "s"]
|
|
302
|
+
if isinstance(item, dict)
|
|
303
|
+
else item
|
|
304
|
+
for item in processed_items
|
|
305
|
+
]
|
|
306
|
+
|
|
307
|
+
# Add stream metadata
|
|
308
|
+
ops = stream._get_pending_ops()
|
|
309
|
+
if ops is not None:
|
|
310
|
+
result["stream"] = stream._to_wire_format(ops)
|
|
311
|
+
|
|
312
|
+
return result
|
|
313
|
+
|
|
314
|
+
@staticmethod
|
|
315
|
+
def escape_html(text: str) -> str:
|
|
316
|
+
"""Escape HTML special characters."""
|
|
317
|
+
return (
|
|
318
|
+
text.replace("&", "&")
|
|
319
|
+
.replace("<", "<")
|
|
320
|
+
.replace(">", ">")
|
|
321
|
+
.replace('"', """)
|
|
322
|
+
.replace("'", "'")
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def live_component(
|
|
327
|
+
component_class: "type[LiveComponent]", id: str, **assigns
|
|
328
|
+
) -> LiveComponentPlaceholder:
|
|
329
|
+
"""
|
|
330
|
+
Insert a live component into a template.
|
|
331
|
+
|
|
332
|
+
Usage:
|
|
333
|
+
comp = live_component(MyComponent, id="comp-1", foo="bar")
|
|
334
|
+
template = t'<div>{comp}</div>'
|
|
335
|
+
"""
|
|
336
|
+
return LiveComponentPlaceholder(
|
|
337
|
+
component_class=component_class, component_id=id, assigns=assigns
|
|
338
|
+
)
|
pyview/template/render_diff.py
CHANGED
|
@@ -6,22 +6,38 @@ def calc_diff(old_tree: dict[str, Any], new_tree: dict[str, Any]) -> dict[str, A
|
|
|
6
6
|
for key in new_tree:
|
|
7
7
|
if key not in old_tree:
|
|
8
8
|
diff[key] = new_tree[key]
|
|
9
|
-
elif (
|
|
10
|
-
isinstance(new_tree[key], dict)
|
|
11
|
-
and "s" in new_tree[key]
|
|
12
|
-
and "d" in new_tree[key]
|
|
13
|
-
):
|
|
9
|
+
elif isinstance(new_tree[key], dict) and "s" in new_tree[key] and "d" in new_tree[key]:
|
|
14
10
|
if isinstance(old_tree[key], str):
|
|
15
11
|
diff[key] = new_tree[key]
|
|
16
12
|
continue
|
|
17
13
|
|
|
18
|
-
# Handle special case of for loop
|
|
14
|
+
# Handle special case of for loop (comprehension)
|
|
19
15
|
old_static = old_tree[key].get("s", [])
|
|
20
16
|
new_static = new_tree[key]["s"]
|
|
21
17
|
|
|
22
|
-
old_dynamic = old_tree[key]
|
|
18
|
+
old_dynamic = old_tree[key].get("d", [])
|
|
23
19
|
new_dynamic = new_tree[key]["d"]
|
|
24
20
|
|
|
21
|
+
# Check for stream metadata - always include if present
|
|
22
|
+
has_stream = "stream" in new_tree[key]
|
|
23
|
+
|
|
24
|
+
if has_stream:
|
|
25
|
+
# For streams, always include the stream operations
|
|
26
|
+
# The stream metadata contains insert/delete operations since last render
|
|
27
|
+
comp_diff: dict[str, Any] = {"stream": new_tree[key]["stream"]}
|
|
28
|
+
|
|
29
|
+
# Include dynamics if there are items being inserted
|
|
30
|
+
if new_dynamic:
|
|
31
|
+
comp_diff["d"] = new_dynamic
|
|
32
|
+
|
|
33
|
+
# Include statics on first render or if changed
|
|
34
|
+
if old_static != new_static:
|
|
35
|
+
comp_diff["s"] = new_static
|
|
36
|
+
|
|
37
|
+
diff[key] = comp_diff
|
|
38
|
+
continue
|
|
39
|
+
|
|
40
|
+
# Regular comprehension (non-stream)
|
|
25
41
|
if old_static != new_static:
|
|
26
42
|
diff[key] = {"s": new_static, "d": new_dynamic}
|
|
27
43
|
continue
|
|
@@ -29,6 +45,16 @@ def calc_diff(old_tree: dict[str, Any], new_tree: dict[str, Any]) -> dict[str, A
|
|
|
29
45
|
if old_dynamic != new_dynamic:
|
|
30
46
|
diff[key] = {"d": new_dynamic}
|
|
31
47
|
|
|
48
|
+
elif isinstance(new_tree[key], dict) and "stream" in new_tree[key]:
|
|
49
|
+
# Handle stream-only diff (no "s" or "d", just stream operations like delete-only)
|
|
50
|
+
diff[key] = new_tree[key]
|
|
51
|
+
|
|
52
|
+
elif new_tree[key] == "" and isinstance(old_tree[key], dict) and "stream" in old_tree[key]:
|
|
53
|
+
# Stream went from having items to no pending operations
|
|
54
|
+
# Don't report this as a change - client already has the content
|
|
55
|
+
# This is Phoenix LiveView semantics: stream items persist on client
|
|
56
|
+
pass
|
|
57
|
+
|
|
32
58
|
elif isinstance(new_tree[key], dict) and isinstance(old_tree[key], dict):
|
|
33
59
|
nested_diff = calc_diff(old_tree[key], new_tree[key])
|
|
34
60
|
if nested_diff:
|
pyview/template/root_template.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from typing import Callable, Optional, TypedDict
|
|
2
|
+
|
|
2
3
|
from markupsafe import Markup
|
|
3
4
|
|
|
4
5
|
|
|
@@ -16,32 +17,43 @@ ContentWrapper = Callable[[RootTemplateContext, Markup], Markup]
|
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
def defaultRootTemplate(
|
|
19
|
-
css: Optional[Markup] = None,
|
|
20
|
+
css: Optional[Markup] = None,
|
|
21
|
+
content_wrapper: Optional[ContentWrapper] = None,
|
|
22
|
+
title: Optional[str] = None,
|
|
23
|
+
title_suffix: Optional[str] = " | LiveView",
|
|
20
24
|
) -> RootTemplate:
|
|
21
25
|
content_wrapper = content_wrapper or (lambda c, m: m)
|
|
22
26
|
|
|
23
27
|
def template(context: RootTemplateContext) -> str:
|
|
24
|
-
return _defaultRootTemplate(
|
|
28
|
+
return _defaultRootTemplate(
|
|
29
|
+
context, css or Markup(""), content_wrapper, title, title_suffix
|
|
30
|
+
)
|
|
25
31
|
|
|
26
32
|
return template
|
|
27
33
|
|
|
28
34
|
|
|
29
35
|
def _defaultRootTemplate(
|
|
30
|
-
context: RootTemplateContext,
|
|
36
|
+
context: RootTemplateContext,
|
|
37
|
+
css: Markup,
|
|
38
|
+
contentWrapper: ContentWrapper,
|
|
39
|
+
default_title: Optional[str] = None,
|
|
40
|
+
title_suffix: Optional[str] = " | LiveView",
|
|
31
41
|
) -> str:
|
|
32
|
-
suffix =
|
|
33
|
-
|
|
42
|
+
suffix = title_suffix or ""
|
|
43
|
+
# Use context title if provided, otherwise use default_title, otherwise "LiveView"
|
|
44
|
+
title = context.get("title") or default_title
|
|
45
|
+
render_title = (title + suffix) if title is not None else "LiveView"
|
|
34
46
|
main_content = contentWrapper(
|
|
35
47
|
context,
|
|
36
48
|
Markup(
|
|
37
49
|
f"""
|
|
38
50
|
<div
|
|
39
51
|
data-phx-main="true"
|
|
40
|
-
data-phx-session="{context[
|
|
52
|
+
data-phx-session="{context["session"]}"
|
|
41
53
|
data-phx-static=""
|
|
42
|
-
id="phx-{context[
|
|
54
|
+
id="phx-{context["id"]}"
|
|
43
55
|
>
|
|
44
|
-
{context[
|
|
56
|
+
{context["content"]}
|
|
45
57
|
</div>"""
|
|
46
58
|
),
|
|
47
59
|
)
|
|
@@ -55,7 +67,7 @@ def _defaultRootTemplate(
|
|
|
55
67
|
<html lang="en">
|
|
56
68
|
<head>
|
|
57
69
|
<title data-suffix="{suffix}">{render_title}</title>
|
|
58
|
-
<meta name="csrf-token" content="{context[
|
|
70
|
+
<meta name="csrf-token" content="{context["csrf_token"]}" />
|
|
59
71
|
<meta charset="utf-8">
|
|
60
72
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
61
73
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
pyview/template/serializer.py
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
from typing import Any, Union, Protocol, Optional
|
|
2
1
|
from dataclasses import fields, is_dataclass
|
|
3
|
-
from
|
|
4
|
-
|
|
5
|
-
import inspect
|
|
2
|
+
from typing import Any
|
|
6
3
|
|
|
7
4
|
|
|
8
5
|
def serialize(assigns: Any) -> dict[str, Any]:
|
|
@@ -18,7 +15,7 @@ def serialize(assigns: Any) -> dict[str, Any]:
|
|
|
18
15
|
raise TypeError("Assigns must be a dict or have an asdict() method")
|
|
19
16
|
|
|
20
17
|
|
|
21
|
-
def isprop(v):
|
|
18
|
+
def isprop(v) -> bool:
|
|
22
19
|
return isinstance(v, property)
|
|
23
20
|
|
|
24
21
|
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LiveView support for t-string templates.
|
|
3
|
+
|
|
4
|
+
This module requires Python 3.14+ for t-string support.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Any, TypeVar, Generic, Optional
|
|
9
|
+
|
|
10
|
+
# T-string support requires Python 3.14+
|
|
11
|
+
if sys.version_info < (3, 14):
|
|
12
|
+
raise ImportError(
|
|
13
|
+
"T-string template support requires Python 3.14 or later. "
|
|
14
|
+
f"Current version: {sys.version_info.major}.{sys.version_info.minor}"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from pyview.components import SocketWithComponents
|
|
18
|
+
from .live_view_template import LiveViewTemplate, ComponentMarker
|
|
19
|
+
from string.templatelib import Template
|
|
20
|
+
from pyview.meta import PyViewMeta
|
|
21
|
+
|
|
22
|
+
T = TypeVar("T")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TStringRenderedContent:
|
|
26
|
+
"""RenderedContent implementation for t-string templates."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, tree_data: dict[str, Any]):
|
|
29
|
+
self._tree_data = tree_data
|
|
30
|
+
|
|
31
|
+
def tree(self) -> dict[str, Any]:
|
|
32
|
+
"""Return the LiveView diff tree."""
|
|
33
|
+
return self._tree_data
|
|
34
|
+
|
|
35
|
+
def text(self, socket: Optional[SocketWithComponents] = None) -> str:
|
|
36
|
+
"""Convert tree back to HTML string, resolving any component markers.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
socket: Optional socket with components manager for resolving
|
|
40
|
+
ComponentMarkers during unconnected phase.
|
|
41
|
+
"""
|
|
42
|
+
return self._tree_to_html(self._tree_data, socket)
|
|
43
|
+
|
|
44
|
+
def _tree_to_html(self, tree: dict[str, Any] | list[Any], socket: Optional[SocketWithComponents] = None) -> str:
|
|
45
|
+
"""Convert tree back to HTML, resolving ComponentMarkers."""
|
|
46
|
+
if isinstance(tree, str):
|
|
47
|
+
return tree
|
|
48
|
+
|
|
49
|
+
if isinstance(tree, ComponentMarker):
|
|
50
|
+
return self._resolve_component_marker(tree, socket)
|
|
51
|
+
|
|
52
|
+
if not isinstance(tree, dict):
|
|
53
|
+
return str(tree)
|
|
54
|
+
|
|
55
|
+
# Handle comprehension format with "s" and "d" keys
|
|
56
|
+
# This is the format for loops: {"s": ["<div>", "</div>"], "d": [["value1"], ["value2"]]}
|
|
57
|
+
if "d" in tree and "s" in tree:
|
|
58
|
+
statics = tree["s"]
|
|
59
|
+
dynamics_list = tree["d"]
|
|
60
|
+
html_items = []
|
|
61
|
+
|
|
62
|
+
for dynamics in dynamics_list:
|
|
63
|
+
# Each dynamics is a list of values to interleave with statics
|
|
64
|
+
parts = []
|
|
65
|
+
for i, static in enumerate(statics):
|
|
66
|
+
parts.append(static)
|
|
67
|
+
if i < len(dynamics):
|
|
68
|
+
dyn = dynamics[i]
|
|
69
|
+
parts.append(self._value_to_html(dyn, socket))
|
|
70
|
+
html_items.append("".join(parts))
|
|
71
|
+
|
|
72
|
+
return "".join(html_items)
|
|
73
|
+
|
|
74
|
+
# Handle "d" without "s" (just a list of items)
|
|
75
|
+
if "d" in tree:
|
|
76
|
+
items = tree["d"]
|
|
77
|
+
html_items = []
|
|
78
|
+
for item in items:
|
|
79
|
+
if isinstance(item, list) and len(item) == 1:
|
|
80
|
+
html_items.append(self._value_to_html(item[0], socket))
|
|
81
|
+
else:
|
|
82
|
+
html_items.append(self._tree_to_html(item, socket))
|
|
83
|
+
return "".join(html_items)
|
|
84
|
+
|
|
85
|
+
html_parts = []
|
|
86
|
+
statics = tree.get("s", [])
|
|
87
|
+
|
|
88
|
+
for i, static in enumerate(statics):
|
|
89
|
+
html_parts.append(static)
|
|
90
|
+
|
|
91
|
+
# Look for dynamic content
|
|
92
|
+
key = str(i)
|
|
93
|
+
if key in tree:
|
|
94
|
+
dynamic = tree[key]
|
|
95
|
+
html_parts.append(self._value_to_html(dynamic, socket))
|
|
96
|
+
|
|
97
|
+
return "".join(html_parts)
|
|
98
|
+
|
|
99
|
+
def _value_to_html(self, value: Any, socket: Optional[SocketWithComponents]) -> str:
|
|
100
|
+
"""Convert a tree value to HTML string."""
|
|
101
|
+
if isinstance(value, ComponentMarker):
|
|
102
|
+
return self._resolve_component_marker(value, socket)
|
|
103
|
+
elif isinstance(value, dict):
|
|
104
|
+
return self._tree_to_html(value, socket)
|
|
105
|
+
elif isinstance(value, list):
|
|
106
|
+
return "".join(self._value_to_html(item, socket) for item in value)
|
|
107
|
+
else:
|
|
108
|
+
return str(value)
|
|
109
|
+
|
|
110
|
+
def _resolve_component_marker(self, marker: ComponentMarker, socket: Optional[SocketWithComponents]) -> str:
|
|
111
|
+
"""Resolve a ComponentMarker to HTML by rendering the component."""
|
|
112
|
+
if not socket or not hasattr(socket, "components"):
|
|
113
|
+
return "" # Component not available
|
|
114
|
+
|
|
115
|
+
meta = PyViewMeta(socket=socket)
|
|
116
|
+
template = socket.components.render_component(marker.cid, meta)
|
|
117
|
+
if template is None:
|
|
118
|
+
return ""
|
|
119
|
+
|
|
120
|
+
# Process the component template and recursively convert to HTML
|
|
121
|
+
component_tree = LiveViewTemplate.process(template, socket)
|
|
122
|
+
return self._tree_to_html(component_tree, socket)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class TemplateView(Generic[T]):
|
|
126
|
+
"""
|
|
127
|
+
Mixin for LiveView classes to support t-string templates.
|
|
128
|
+
|
|
129
|
+
Usage:
|
|
130
|
+
class MyView(TemplateView, LiveView[MyContext]):
|
|
131
|
+
def template(self, assigns: MyContext, meta: PyViewMeta):
|
|
132
|
+
return t'<div>{assigns.name}</div>'
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
async def render(self, assigns: T, meta: PyViewMeta):
|
|
136
|
+
"""Override render to check for t-string template method."""
|
|
137
|
+
|
|
138
|
+
# Check if this class has a template method
|
|
139
|
+
if hasattr(self, "template") and callable(self.template):
|
|
140
|
+
# Call template method with both assigns and meta
|
|
141
|
+
template = self.template(assigns, meta)
|
|
142
|
+
|
|
143
|
+
# Ensure it returns a Template
|
|
144
|
+
if not isinstance(template, Template):
|
|
145
|
+
raise ValueError(
|
|
146
|
+
f"template() method must return a Template, got {type(template)}"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# Process the template into LiveView tree format
|
|
150
|
+
# Pass socket for component registration if available
|
|
151
|
+
socket = meta.socket if meta else None
|
|
152
|
+
tree = LiveViewTemplate.process(template, socket=socket)
|
|
153
|
+
|
|
154
|
+
return TStringRenderedContent(tree)
|
|
155
|
+
|
|
156
|
+
# Fall back to parent implementation (Ibis templates)
|
|
157
|
+
return await super().render(assigns, meta) # type: ignore
|
|
158
|
+
|
|
159
|
+
def template(self, assigns: T, meta: PyViewMeta) -> Template:
|
|
160
|
+
"""
|
|
161
|
+
Override this method to provide a t-string template.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
assigns: The typed context object (dataclass)
|
|
165
|
+
meta: PyViewMeta object with request info
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Template: A t-string Template object
|
|
169
|
+
"""
|
|
170
|
+
raise NotImplementedError("Subclasses must implement the template() method")
|
pyview/template/utils.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
from typing import Optional
|
|
2
1
|
import inspect
|
|
3
2
|
import os
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
4
5
|
from markupsafe import Markup
|
|
5
6
|
|
|
6
7
|
|
|
@@ -18,7 +19,7 @@ def find_associated_file(o: object, extension: str) -> Optional[str]:
|
|
|
18
19
|
def find_associated_css(o: object) -> list[Markup]:
|
|
19
20
|
css_file = find_associated_file(o, ".css")
|
|
20
21
|
if css_file:
|
|
21
|
-
with open(css_file
|
|
22
|
+
with open(css_file) as css:
|
|
22
23
|
return [Markup(f"<style>{css.read()}</style>")]
|
|
23
24
|
|
|
24
25
|
return []
|