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
pyview/js.py CHANGED
@@ -1,62 +1,65 @@
1
1
  import json
2
+ from dataclasses import dataclass
2
3
  from typing import Any, Optional
3
- from pyview.vendor.ibis import filters
4
+
4
5
  from pyview.template.context_processor import context_processor
5
- from dataclasses import dataclass
6
+ from pyview.vendor.ibis import filters
6
7
 
7
8
 
8
9
  @context_processor
9
- def add_js(meta):
10
+ def add_js(meta) -> dict[str, "JsCommands"]:
10
11
  return {"js": JsCommands([])}
11
12
 
12
13
 
13
14
  @filters.register("js.add_class")
14
- def js_add_class(js: "JsCommands", selector: str, *classes):
15
+ def js_add_class(js: "JsCommands", selector: str, *classes) -> "JsCommands":
15
16
  return js.add_class(selector, *classes)
16
17
 
17
18
 
18
19
  @filters.register("js.remove_class")
19
- def js_remove_class(js: "JsCommands", selector: str, *classes):
20
+ def js_remove_class(js: "JsCommands", selector: str, *classes) -> "JsCommands":
20
21
  return js.remove_class(selector, *classes)
21
22
 
22
23
 
23
24
  @filters.register("js.show")
24
- def js_show(js: "JsCommands", selector: str):
25
+ def js_show(js: "JsCommands", selector: str) -> "JsCommands":
25
26
  return js.show(selector)
26
27
 
27
28
 
28
29
  @filters.register("js.hide")
29
- def js_hide(js: "JsCommands", selector: str):
30
+ def js_hide(js: "JsCommands", selector: str) -> "JsCommands":
30
31
  return js.hide(selector)
31
32
 
32
33
 
33
34
  @filters.register("js.toggle")
34
- def js_toggle(js: "JsCommands", selector: str):
35
+ def js_toggle(js: "JsCommands", selector: str) -> "JsCommands":
35
36
  return js.toggle(selector)
36
37
 
37
38
 
38
39
  @filters.register("js.dispatch")
39
- def js_dispatch(js: "JsCommands", event: str, selector: str):
40
+ def js_dispatch(js: "JsCommands", event: str, selector: str) -> "JsCommands":
40
41
  return js.dispatch(event, selector)
41
42
 
42
43
 
43
44
  @filters.register("js.push")
44
- def js_push(js: "JsCommands", event: str, payload: Optional[dict[str, Any]] = None):
45
+ def js_push(js: "JsCommands", event: str, payload: Optional[dict[str, Any]] = None) -> "JsCommands":
45
46
  return js.push(event, payload)
46
47
 
47
48
 
48
49
  @filters.register("js.focus")
49
- def js_focus(js: "JsCommands", selector: str):
50
+ def js_focus(js: "JsCommands", selector: str) -> "JsCommands":
50
51
  return js.focus(selector)
51
52
 
52
53
 
53
54
  @filters.register("js.focus_first")
54
- def js_focus_first(js: "JsCommands", selector: str):
55
+ def js_focus_first(js: "JsCommands", selector: str) -> "JsCommands":
55
56
  return js.focus_first(selector)
56
57
 
57
58
 
58
59
  @filters.register("js.transition")
59
- def js_transition(js: "JsCommands", selector: str, transition: str, time: int = 200):
60
+ def js_transition(
61
+ js: "JsCommands", selector: str, transition: str, time: int = 200
62
+ ) -> "JsCommands":
60
63
  return js.transition(selector, transition, time)
61
64
 
62
65
 
@@ -91,13 +94,9 @@ class JsCommands:
91
94
  def dispatch(self, event: str, selector: str) -> "JsCommands":
92
95
  return self.add(JsCommand("dispatch", {"to": selector, "event": event}))
93
96
 
94
- def push(
95
- self, event: str, payload: Optional[dict[str, Any]] = None
96
- ) -> "JsCommands":
97
+ def push(self, event: str, payload: Optional[dict[str, Any]] = None) -> "JsCommands":
97
98
  return self.add(
98
- JsCommand(
99
- "push", {"event": event} | ({"value": payload} if payload else {})
100
- )
99
+ JsCommand("push", {"event": event} | ({"value": payload} if payload else {}))
101
100
  )
102
101
 
103
102
  def focus(self, selector: str) -> "JsCommands":
@@ -106,9 +105,7 @@ class JsCommands:
106
105
  def focus_first(self, selector: str) -> "JsCommands":
107
106
  return self.add(JsCommand("focus_first", {"to": selector}))
108
107
 
109
- def transition(
110
- self, selector: str, transition: str, time: int = 200
111
- ) -> "JsCommands":
108
+ def transition(self, selector: str, transition: str, time: int = 200) -> "JsCommands":
112
109
  return self.add(
113
110
  JsCommand(
114
111
  "transition",
@@ -116,5 +113,5 @@ class JsCommands:
116
113
  )
117
114
  )
118
115
 
119
- def __str__(self):
116
+ def __str__(self) -> str:
120
117
  return json.dumps([(c.cmd, c.opts) for c in self.commands])
pyview/live_routes.py CHANGED
@@ -1,7 +1,9 @@
1
- from pyview.live_view import LiveView
2
- from typing import Callable, Any
1
+ from typing import Any, Callable
2
+
3
3
  from starlette.routing import compile_path
4
4
 
5
+ from pyview.live_view import LiveView
6
+
5
7
 
6
8
  class LiveViewLookup:
7
9
  def __init__(self):
@@ -15,7 +17,7 @@ class LiveViewLookup:
15
17
  # Find all matching routes
16
18
  matches = []
17
19
 
18
- for path_format, path_regex, param_convertors, lv in self.routes:
20
+ for _path_format, path_regex, param_convertors, lv in self.routes:
19
21
  match_obj = path_regex.match(path)
20
22
  if match_obj is not None:
21
23
  params = match_obj.groupdict()
pyview/live_socket.py CHANGED
@@ -1,34 +1,42 @@
1
1
  from __future__ import annotations
2
- from starlette.websockets import WebSocket
2
+
3
+ import datetime
3
4
  import json
4
5
  import logging
6
+ import uuid
7
+ from contextlib import suppress
5
8
  from typing import (
9
+ TYPE_CHECKING,
6
10
  Any,
7
- TypeVar,
11
+ Callable,
8
12
  Generic,
9
- TYPE_CHECKING,
10
13
  Optional,
11
- Union,
12
14
  TypeAlias,
13
15
  TypeGuard,
16
+ TypeVar,
17
+ Union,
14
18
  )
15
- from urllib.parse import urlencode
16
- from apscheduler.schedulers.asyncio import AsyncIOScheduler
19
+ from urllib.parse import urlencode, urlparse
20
+
17
21
  from apscheduler.jobstores.base import JobLookupError
18
- from pyview.vendor.flet.pubsub import PubSubHub, PubSub
22
+ from apscheduler.schedulers.asyncio import AsyncIOScheduler
23
+ from starlette.websockets import WebSocket
24
+
25
+ from pyview.async_stream_runner import AsyncStreamRunner
26
+ from pyview.binding.helpers import call_handle_params
27
+ from pyview.components.manager import ComponentsManager
19
28
  from pyview.events import InfoEvent
20
- from pyview.uploads import UploadConstraints, UploadConfig, UploadManager
21
29
  from pyview.meta import PyViewMeta
22
30
  from pyview.template.render_diff import calc_diff
23
- import datetime
24
- from pyview.async_stream_runner import AsyncStreamRunner
31
+ from pyview.uploads import UploadConfig, UploadConstraints, UploadManager
32
+ from pyview.vendor.flet.pubsub import PubSub, PubSubHub
25
33
 
26
34
  logger = logging.getLogger(__name__)
27
35
 
28
36
 
29
37
  if TYPE_CHECKING:
30
- from .live_view import LiveView
31
38
  from .instrumentation import InstrumentationProvider
39
+ from .live_view import LiveView
32
40
 
33
41
 
34
42
  pub_sub_hub = PubSubHub()
@@ -36,19 +44,52 @@ pub_sub_hub = PubSubHub()
36
44
  T = TypeVar("T")
37
45
 
38
46
 
39
- def is_connected(socket: LiveViewSocket[T]) -> TypeGuard["ConnectedLiveViewSocket[T]"]:
47
+ def is_connected(socket: LiveViewSocket[T]) -> TypeGuard[ConnectedLiveViewSocket[T]]:
40
48
  return socket.connected
41
49
 
42
50
 
51
+ class UnconnectedLiveView:
52
+ """Stub liveview that raises if send_parent() is called in unconnected phase."""
53
+
54
+ async def handle_event(self, event: str, payload: dict[str, Any], socket: Any) -> None:
55
+ raise RuntimeError(
56
+ "send_parent() is not available during initial HTTP render. "
57
+ "Component events only work after WebSocket connection."
58
+ )
59
+
60
+
43
61
  class UnconnectedSocket(Generic[T]):
44
62
  context: T
45
63
  live_title: Optional[str] = None
46
64
  connected: bool = False
65
+ _liveview: UnconnectedLiveView
66
+ components: ComponentsManager
67
+
68
+ def __init__(self) -> None:
69
+ self._liveview = UnconnectedLiveView()
70
+ self.components = ComponentsManager(self)
71
+
72
+ @property
73
+ def liveview(self) -> UnconnectedLiveView:
74
+ return self._liveview
47
75
 
48
76
  def allow_upload(
49
- self, upload_name: str, constraints: UploadConstraints
77
+ self,
78
+ upload_name: str,
79
+ constraints: UploadConstraints,
80
+ auto_upload: bool = False,
81
+ progress: Optional[Callable] = None,
82
+ external: Optional[Callable] = None,
83
+ entry_complete: Optional[Callable] = None,
50
84
  ) -> UploadConfig:
51
- return UploadConfig(name=upload_name, constraints=constraints)
85
+ return UploadConfig(
86
+ name=upload_name,
87
+ constraints=constraints,
88
+ autoUpload=auto_upload,
89
+ progress_callback=progress,
90
+ external_callback=external,
91
+ entry_complete_callback=entry_complete,
92
+ )
52
93
 
53
94
 
54
95
  class ConnectedLiveViewSocket(Generic[T]):
@@ -64,23 +105,63 @@ class ConnectedLiveViewSocket(Generic[T]):
64
105
  topic: str,
65
106
  liveview: LiveView,
66
107
  scheduler: AsyncIOScheduler,
67
- instrumentation: "InstrumentationProvider",
108
+ instrumentation: InstrumentationProvider,
68
109
  ):
69
110
  self.websocket = websocket
70
111
  self.topic = topic
71
112
  self.liveview = liveview
72
113
  self.instrumentation = instrumentation
73
- self.scheduled_jobs = []
114
+ self.scheduled_jobs = set()
74
115
  self.connected = True
75
116
  self.pub_sub = PubSub(pub_sub_hub, topic)
76
117
  self.pending_events = []
77
118
  self.upload_manager = UploadManager()
78
119
  self.stream_runner = AsyncStreamRunner(self)
79
120
  self.scheduler = scheduler
121
+ self.components = ComponentsManager(self)
80
122
 
81
123
  @property
82
124
  def meta(self) -> PyViewMeta:
83
- return PyViewMeta()
125
+ return PyViewMeta(socket=self)
126
+
127
+ async def render_with_components(self) -> dict[str, Any]:
128
+ """
129
+ Render the LiveView and all its components.
130
+
131
+ Handles the full component lifecycle:
132
+ 1. Begin render cycle (track seen components)
133
+ 2. Render parent LiveView template
134
+ 3. Run pending component lifecycle (mount/update)
135
+ 4. Prune stale components not in this render
136
+ 5. Render all component templates with ROOT flag
137
+
138
+ Returns:
139
+ Rendered tree in Phoenix wire format
140
+ """
141
+ import sys
142
+
143
+ # Start new render cycle - track which components are seen during parent render
144
+ self.components.begin_render()
145
+
146
+ rendered = (await self.liveview.render(self.context, self.meta)).tree()
147
+
148
+ # Component rendering requires Python 3.14+ (t-string support)
149
+ if sys.version_info < (3, 14):
150
+ return rendered
151
+
152
+ from pyview.components.lifecycle import run_nested_component_lifecycle
153
+
154
+ # Run component lifecycle and get rendered trees in one pass
155
+ rendered_trees = await run_nested_component_lifecycle(self, self.meta)
156
+
157
+ # Clean up components that were removed from the DOM
158
+ self.components.prune_stale_components()
159
+
160
+ # Include rendered component trees in response
161
+ if rendered_trees:
162
+ rendered["c"] = {str(cid): tree for cid, tree in rendered_trees.items()}
163
+
164
+ return rendered
84
165
 
85
166
  async def subscribe(self, topic: str):
86
167
  await self.pub_sub.subscribe_topic_async(topic, self._topic_callback_internal)
@@ -96,16 +177,19 @@ class ConnectedLiveViewSocket(Generic[T]):
96
177
  self.scheduler.add_job(
97
178
  self.send_info, args=[event], id=id, trigger="interval", seconds=seconds
98
179
  )
99
- self.scheduled_jobs.append(id)
180
+ self.scheduled_jobs.add(id)
100
181
 
101
182
  def schedule_info_once(self, event, seconds=None):
183
+ job_id = f"{self.topic}:once:{uuid.uuid4().hex}"
102
184
  self.scheduler.add_job(
103
- self.send_info,
104
- args=[event],
185
+ self._send_info_once,
186
+ args=[job_id, event],
187
+ id=job_id,
105
188
  trigger="date",
106
189
  run_date=datetime.datetime.now() + datetime.timedelta(seconds=seconds or 0),
107
190
  misfire_grace_time=None,
108
191
  )
192
+ self.scheduled_jobs.add(job_id)
109
193
 
110
194
  def diff(self, render: dict[str, Any]) -> dict[str, Any]:
111
195
  if self.prev_rendered:
@@ -116,24 +200,31 @@ class ConnectedLiveViewSocket(Generic[T]):
116
200
  self.prev_rendered = render
117
201
  return diff
118
202
 
203
+ async def _send_info_once(self, job_id: str, event: InfoEvent):
204
+ """Wrapper for one-time info sends that cleans up the job ID after execution"""
205
+ await self.send_info(event)
206
+ self.scheduled_jobs.discard(job_id)
207
+
119
208
  async def send_info(self, event: InfoEvent):
120
209
  await self.liveview.handle_info(event, self)
121
- r = await self.liveview.render(self.context, self.meta)
122
- resp = [None, None, self.topic, "diff", self.diff(r.tree())]
210
+
211
+ rendered = await self.render_with_components()
212
+ resp = [None, None, self.topic, "diff", self.diff(rendered)]
123
213
 
124
214
  try:
125
215
  await self.websocket.send_text(json.dumps(resp))
126
216
  except Exception:
127
- for id in self.scheduled_jobs:
217
+ for id in list(self.scheduled_jobs):
128
218
  logger.debug("Removing scheduled job %s", id)
129
219
  try:
130
220
  self.scheduler.remove_job(id)
131
221
  except Exception:
132
- logger.warning(
133
- "Failed to remove scheduled job %s", id, exc_info=True
134
- )
222
+ logger.warning("Failed to remove scheduled job %s", id, exc_info=True)
223
+
224
+ async def push_patch(self, path: str, params: Optional[dict[str, Any]] = None):
225
+ if params is None:
226
+ params = {}
135
227
 
136
- async def push_patch(self, path: str, params: dict[str, Any] = {}):
137
228
  # or "replace"
138
229
  kind = "push"
139
230
 
@@ -153,21 +244,27 @@ class ConnectedLiveViewSocket(Generic[T]):
153
244
  ]
154
245
 
155
246
  # TODO another way to marshall this
156
- for k in params:
157
- params[k] = [params[k]]
247
+ # Create a copy to avoid mutating the caller's dict
248
+ params_for_handler = {k: [v] for k, v in params.items()}
158
249
 
159
- await self.liveview.handle_params(to, params, self)
250
+ # Parse string to ParseResult for type consistency
251
+ parsed_url = urlparse(to)
252
+ await call_handle_params(self.liveview, parsed_url, params_for_handler, self)
160
253
  try:
161
254
  await self.websocket.send_text(json.dumps(message))
162
255
  except Exception:
163
256
  logger.warning("Error sending patch message", exc_info=True)
164
257
 
165
- async def push_navigate(self, path: str, params: dict[str, Any] = {}):
258
+ async def push_navigate(self, path: str, params: Optional[dict[str, Any]] = None):
166
259
  """Navigate to a different LiveView without full page reload"""
260
+ if params is None:
261
+ params = {}
167
262
  await self._navigate(path, params, kind="push")
168
263
 
169
- async def replace_navigate(self, path: str, params: dict[str, Any] = {}):
264
+ async def replace_navigate(self, path: str, params: Optional[dict[str, Any]] = None):
170
265
  """Navigate to a different LiveView, replacing current history entry"""
266
+ if params is None:
267
+ params = {}
171
268
  await self._navigate(path, params, kind="replace")
172
269
 
173
270
  async def _navigate(self, path: str, params: dict[str, Any], kind: str):
@@ -192,32 +289,58 @@ class ConnectedLiveViewSocket(Generic[T]):
192
289
  except Exception:
193
290
  logger.warning("Error sending navigation message", exc_info=True)
194
291
 
292
+ async def redirect(self, path: str, params: Optional[dict[str, Any]] = None):
293
+ """Redirect to a new location with full page reload"""
294
+ if params is None:
295
+ params = {}
296
+ to = path
297
+ if params:
298
+ to = to + "?" + urlencode(params)
299
+
300
+ message = [
301
+ None,
302
+ None,
303
+ self.topic,
304
+ "redirect",
305
+ {"to": to},
306
+ ]
307
+
308
+ try:
309
+ await self.websocket.send_text(json.dumps(message))
310
+ except Exception:
311
+ logger.warning("Error sending redirect message", exc_info=True)
312
+
195
313
  async def push_event(self, event: str, value: dict[str, Any]):
196
314
  self.pending_events.append((event, value))
197
315
 
198
316
  def allow_upload(
199
- self, upload_name: str, constraints: UploadConstraints
317
+ self,
318
+ upload_name: str,
319
+ constraints: UploadConstraints,
320
+ auto_upload: bool = False,
321
+ progress: Optional[Callable] = None,
322
+ external: Optional[Callable] = None,
323
+ entry_complete: Optional[Callable] = None,
200
324
  ) -> UploadConfig:
201
- return self.upload_manager.allow_upload(upload_name, constraints)
325
+ return self.upload_manager.allow_upload(
326
+ upload_name, constraints, auto_upload, progress, external, entry_complete
327
+ )
202
328
 
203
329
  async def close(self):
204
330
  self.connected = False
205
- for id in self.scheduled_jobs:
206
- try:
331
+ for id in list(self.scheduled_jobs):
332
+ with suppress(JobLookupError):
207
333
  self.scheduler.remove_job(id)
208
- except JobLookupError:
209
- pass
210
334
  await self.pub_sub.unsubscribe_all_async()
211
335
 
212
- try:
336
+ with suppress(Exception):
213
337
  self.upload_manager.close()
214
- except Exception:
215
- pass
216
338
 
217
- try:
339
+ with suppress(Exception):
340
+ self.components.clear()
341
+
342
+ with suppress(Exception):
218
343
  await self.liveview.disconnect(self)
219
- except Exception:
220
- pass
221
344
 
222
345
 
223
346
  LiveViewSocket: TypeAlias = Union[ConnectedLiveViewSocket[T], UnconnectedSocket[T]]
pyview/live_view.py CHANGED
@@ -1,22 +1,22 @@
1
- from typing import TypeVar, Generic, Optional, Union, Any
2
- from .live_socket import LiveViewSocket, ConnectedLiveViewSocket
1
+ from typing import Any, Generic, Optional, TypeVar
2
+ from urllib.parse import ParseResult
3
+
4
+ from pyview.events import InfoEvent
5
+ from pyview.meta import PyViewMeta
3
6
  from pyview.template import (
7
+ LiveRender,
4
8
  LiveTemplate,
5
- template_file,
6
9
  RenderedContent,
7
- LiveRender,
8
10
  find_associated_file,
11
+ template_file,
9
12
  )
10
- from pyview.events import InfoEvent
11
- from pyview.meta import PyViewMeta
12
- from urllib.parse import ParseResult
13
+
14
+ from .live_socket import ConnectedLiveViewSocket, LiveViewSocket
13
15
 
14
16
  T = TypeVar("T")
15
17
 
16
18
  Session = dict[str, Any]
17
-
18
- # TODO: ideally this would always be a ParseResult, but we need to update push_patch
19
- URL = Union[ParseResult, str]
19
+ URL = ParseResult
20
20
 
21
21
 
22
22
  class LiveView(Generic[T]):
@@ -26,13 +26,25 @@ class LiveView(Generic[T]):
26
26
  async def mount(self, socket: LiveViewSocket[T], session: Session):
27
27
  pass
28
28
 
29
- async def handle_event(self, event, payload, socket: ConnectedLiveViewSocket[T]):
29
+ async def handle_event(self, *args, **kwargs) -> None:
30
+ """Handle client events (clicks, form submissions).
31
+
32
+ Common signatures:
33
+ handle_event(self, socket, amount: int) # new style
34
+ handle_event(self, event, payload, socket) # legacy style
35
+ """
30
36
  pass
31
37
 
32
38
  async def handle_info(self, event: InfoEvent, socket: ConnectedLiveViewSocket[T]):
33
39
  pass
34
40
 
35
- async def handle_params(self, url: URL, params, socket: LiveViewSocket[T]):
41
+ async def handle_params(self, *args, **kwargs) -> None:
42
+ """Called when URL params change.
43
+
44
+ Common signatures:
45
+ handle_params(self, socket, page: int = 1) # new style
46
+ handle_params(self, url, params, socket) # legacy style
47
+ """
36
48
  pass
37
49
 
38
50
  async def disconnect(self, socket: ConnectedLiveViewSocket[T]):
pyview/meta.py CHANGED
@@ -1,6 +1,18 @@
1
- from dataclasses import dataclass
1
+ from dataclasses import dataclass, field
2
+ from typing import TYPE_CHECKING, Optional
3
+
4
+ if TYPE_CHECKING:
5
+ from pyview.components import SocketWithComponents
2
6
 
3
7
 
4
8
  @dataclass
5
9
  class PyViewMeta:
6
- pass
10
+ """
11
+ Metadata passed to LiveView render and template methods.
12
+
13
+ Attributes:
14
+ socket: Optional reference to the socket (for component registration).
15
+ Can be either ConnectedLiveViewSocket or UnconnectedSocket.
16
+ """
17
+
18
+ socket: Optional["SocketWithComponents"] = field(default=None, repr=False)
pyview/phx_message.py CHANGED
@@ -1,13 +1,15 @@
1
- from starlette.websockets import WebSocketDisconnect
2
- from starlette.types import Message
3
1
  import json
2
+ import struct
3
+
4
+ from starlette.types import Message
5
+ from starlette.websockets import WebSocketDisconnect
4
6
 
5
7
 
6
8
  def parse_message(message: Message) -> tuple[str, str, str, str, dict]:
7
9
  if "text" in message:
8
10
  data = message["text"]
9
- [joinRef, mesageRef, topic, event, payload] = json.loads(data)
10
- return joinRef, mesageRef, topic, event, payload
11
+ [joinRef, messageRef, topic, event, payload] = json.loads(data)
12
+ return joinRef, messageRef, topic, event, payload
11
13
 
12
14
  if "bytes" in message:
13
15
  data = message["bytes"]
@@ -19,15 +21,12 @@ def parse_message(message: Message) -> tuple[str, str, str, str, dict]:
19
21
  raise WebSocketDisconnect(message["code"])
20
22
 
21
23
 
22
- import struct
23
-
24
-
25
24
  class BinaryUploadSerDe:
26
25
  def deserialize(self, encoded_data: bytes) -> tuple[str, str, str, str, bytes]:
27
26
  offset = 0
28
27
 
29
28
  # Read the kind (1 byte)
30
- kind = struct.unpack_from("B", encoded_data, offset)[0]
29
+ _kind = struct.unpack_from("B", encoded_data, offset)[0]
31
30
  offset += 1
32
31
 
33
32
  # Read lengths (4 bytes total, 1 byte each)
@@ -0,0 +1,10 @@
1
+ """Playground module for creating single-file PyView examples."""
2
+
3
+ from pyview.playground.builder import PlaygroundBuilder, playground
4
+ from pyview.playground.favicon import Favicon
5
+
6
+ __all__ = [
7
+ "Favicon",
8
+ "PlaygroundBuilder",
9
+ "playground",
10
+ ]