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
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
|
-
|
|
4
|
+
|
|
4
5
|
from pyview.template.context_processor import context_processor
|
|
5
|
-
from
|
|
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(
|
|
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
|
|
2
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
19
|
+
from urllib.parse import urlencode, urlparse
|
|
20
|
+
|
|
17
21
|
from apscheduler.jobstores.base import JobLookupError
|
|
18
|
-
from
|
|
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
|
|
24
|
-
from pyview.
|
|
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[
|
|
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,
|
|
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(
|
|
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:
|
|
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.
|
|
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.
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
336
|
+
with suppress(Exception):
|
|
213
337
|
self.upload_manager.close()
|
|
214
|
-
except Exception:
|
|
215
|
-
pass
|
|
216
338
|
|
|
217
|
-
|
|
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
|
|
2
|
-
from .
|
|
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
|
-
|
|
11
|
-
from
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
10
|
-
return joinRef,
|
|
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
|
-
|
|
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)
|