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.
Files changed (78) hide show
  1. pyview/__init__.py +16 -6
  2. pyview/assets/js/app.js +1 -0
  3. pyview/assets/js/uploaders.js +221 -0
  4. pyview/assets/package-lock.json +16 -14
  5. pyview/assets/package.json +2 -2
  6. pyview/async_stream_runner.py +2 -1
  7. pyview/auth/__init__.py +3 -1
  8. pyview/auth/provider.py +6 -6
  9. pyview/auth/required.py +7 -10
  10. pyview/binding/__init__.py +47 -0
  11. pyview/binding/binder.py +134 -0
  12. pyview/binding/context.py +33 -0
  13. pyview/binding/converters.py +191 -0
  14. pyview/binding/helpers.py +78 -0
  15. pyview/binding/injectables.py +119 -0
  16. pyview/binding/params.py +105 -0
  17. pyview/binding/result.py +32 -0
  18. pyview/changesets/__init__.py +2 -0
  19. pyview/changesets/changesets.py +8 -3
  20. pyview/cli/commands/create_view.py +4 -3
  21. pyview/cli/main.py +1 -1
  22. pyview/components/__init__.py +72 -0
  23. pyview/components/base.py +212 -0
  24. pyview/components/lifecycle.py +85 -0
  25. pyview/components/manager.py +366 -0
  26. pyview/components/renderer.py +14 -0
  27. pyview/components/slots.py +73 -0
  28. pyview/csrf.py +4 -2
  29. pyview/events/AutoEventDispatch.py +98 -0
  30. pyview/events/BaseEventHandler.py +51 -8
  31. pyview/events/__init__.py +2 -1
  32. pyview/instrumentation/__init__.py +3 -3
  33. pyview/instrumentation/interfaces.py +57 -33
  34. pyview/instrumentation/noop.py +21 -18
  35. pyview/js.py +20 -23
  36. pyview/live_routes.py +5 -3
  37. pyview/live_socket.py +167 -44
  38. pyview/live_view.py +24 -12
  39. pyview/meta.py +14 -2
  40. pyview/phx_message.py +7 -8
  41. pyview/playground/__init__.py +10 -0
  42. pyview/playground/builder.py +118 -0
  43. pyview/playground/favicon.py +39 -0
  44. pyview/pyview.py +54 -20
  45. pyview/session.py +2 -0
  46. pyview/static/assets/app.js +2088 -806
  47. pyview/static/assets/uploaders.js +221 -0
  48. pyview/stream.py +308 -0
  49. pyview/template/__init__.py +11 -1
  50. pyview/template/live_template.py +12 -8
  51. pyview/template/live_view_template.py +338 -0
  52. pyview/template/render_diff.py +33 -7
  53. pyview/template/root_template.py +21 -9
  54. pyview/template/serializer.py +2 -5
  55. pyview/template/template_view.py +170 -0
  56. pyview/template/utils.py +3 -2
  57. pyview/uploads.py +344 -55
  58. pyview/vendor/flet/pubsub/__init__.py +3 -1
  59. pyview/vendor/flet/pubsub/pub_sub.py +10 -18
  60. pyview/vendor/ibis/__init__.py +3 -7
  61. pyview/vendor/ibis/compiler.py +25 -32
  62. pyview/vendor/ibis/context.py +13 -15
  63. pyview/vendor/ibis/errors.py +0 -6
  64. pyview/vendor/ibis/filters.py +70 -76
  65. pyview/vendor/ibis/loaders.py +6 -7
  66. pyview/vendor/ibis/nodes.py +40 -42
  67. pyview/vendor/ibis/template.py +4 -5
  68. pyview/vendor/ibis/tree.py +62 -3
  69. pyview/vendor/ibis/utils.py +14 -15
  70. pyview/ws_handler.py +116 -86
  71. {pyview_web-0.3.0.dist-info → pyview_web-0.8.0a2.dist-info}/METADATA +39 -33
  72. pyview_web-0.8.0a2.dist-info/RECORD +80 -0
  73. pyview_web-0.8.0a2.dist-info/WHEEL +4 -0
  74. pyview_web-0.8.0a2.dist-info/entry_points.txt +3 -0
  75. pyview_web-0.3.0.dist-info/LICENSE +0 -21
  76. pyview_web-0.3.0.dist-info/RECORD +0 -58
  77. pyview_web-0.3.0.dist-info/WHEEL +0 -4
  78. 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("&", "&amp;")
319
+ .replace("<", "&lt;")
320
+ .replace(">", "&gt;")
321
+ .replace('"', "&quot;")
322
+ .replace("'", "&#x27;")
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
+ )
@@ -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]["d"]
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:
@@ -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, content_wrapper: Optional[ContentWrapper] = 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(context, css or Markup(""), content_wrapper)
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, css: Markup, contentWrapper: ContentWrapper
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 = " | LiveView"
33
- render_title = (context["title"] + suffix) if context.get("title", None) is not None else "LiveView" # type: ignore
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['session']}"
52
+ data-phx-session="{context["session"]}"
41
53
  data-phx-static=""
42
- id="phx-{context['id']}"
54
+ id="phx-{context["id"]}"
43
55
  >
44
- {context['content']}
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['csrf_token']}" />
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">
@@ -1,8 +1,5 @@
1
- from typing import Any, Union, Protocol, Optional
2
1
  from dataclasses import fields, is_dataclass
3
- from pydantic import BaseModel
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, "r") as css:
22
+ with open(css_file) as css:
22
23
  return [Markup(f"<style>{css.read()}</style>")]
23
24
 
24
25
  return []