pyview-web 0.0.9a0__tar.gz → 0.0.21__tar.gz
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.
Potentially problematic release.
This version of pyview-web might be problematic. Click here for more details.
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/PKG-INFO +7 -6
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/pyproject.toml +5 -4
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/pyview/__init__.py +10 -0
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/pyview/assets/js/app.js +1 -1
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/pyview/live_socket.py +39 -4
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/pyview/live_view.py +18 -11
- pyview_web-0.0.21/pyview/phx_message.py +55 -0
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/pyview/pyview.py +13 -5
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/pyview/static/assets/app.js +2 -1
- pyview_web-0.0.21/pyview/template/__init__.py +17 -0
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/pyview/template/root_template.py +5 -1
- pyview_web-0.0.21/pyview/template/utils.py +24 -0
- pyview_web-0.0.21/pyview/uploads.py +294 -0
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/pyview/ws_handler.py +109 -9
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/readme.md +2 -1
- pyview_web-0.0.9a0/pyview/template/__init__.py +0 -3
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/LICENSE +0 -0
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/pyview/assets/package-lock.json +0 -0
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/pyview/assets/package.json +0 -0
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/pyview/auth/__init__.py +0 -0
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/pyview/auth/provider.py +0 -0
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/pyview/auth/required.py +0 -0
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/pyview/changesets/__init__.py +0 -0
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/pyview/changesets/changesets.py +0 -0
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/pyview/csrf.py +0 -0
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/pyview/events.py +0 -0
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/pyview/js.py +0 -0
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/pyview/live_routes.py +0 -0
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/pyview/secret.py +0 -0
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/pyview/session.py +0 -0
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/pyview/template/live_template.py +0 -0
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/pyview/template/serializer.py +0 -0
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/pyview/test_csrf.py +0 -0
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/pyview/vendor/__init__.py +0 -0
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/pyview/vendor/flet/pubsub/__init__.py +0 -0
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/pyview/vendor/flet/pubsub/pub_sub.py +0 -0
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/pyview/vendor/ibis/__init__.py +0 -0
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/pyview/vendor/ibis/compiler.py +0 -0
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/pyview/vendor/ibis/context.py +0 -0
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/pyview/vendor/ibis/errors.py +0 -0
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/pyview/vendor/ibis/filters.py +0 -0
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/pyview/vendor/ibis/loaders.py +0 -0
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/pyview/vendor/ibis/nodes.py +0 -0
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/pyview/vendor/ibis/template.py +0 -0
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/pyview/vendor/ibis/tree.py +0 -0
- {pyview_web-0.0.9a0 → pyview_web-0.0.21}/pyview/vendor/ibis/utils.py +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: pyview-web
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.21
|
|
4
4
|
Summary: LiveView in Python
|
|
5
5
|
Home-page: https://pyview.rocks
|
|
6
6
|
License: MIT
|
|
7
7
|
Keywords: web,api,LiveView
|
|
8
8
|
Author: Larry Ogrodnek
|
|
9
9
|
Author-email: ogrodnek@gmail.com
|
|
10
|
-
Requires-Python: >=3.9
|
|
10
|
+
Requires-Python: >=3.9,<3.12
|
|
11
11
|
Classifier: Development Status :: 4 - Beta
|
|
12
12
|
Classifier: Environment :: Web Environment
|
|
13
13
|
Classifier: Framework :: AsyncIO
|
|
@@ -20,11 +20,10 @@ Classifier: License :: OSI Approved :: MIT License
|
|
|
20
20
|
Classifier: Operating System :: OS Independent
|
|
21
21
|
Classifier: Programming Language :: Python
|
|
22
22
|
Classifier: Programming Language :: Python :: 3
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
23
24
|
Classifier: Programming Language :: Python :: 3.10
|
|
24
25
|
Classifier: Programming Language :: Python :: 3.11
|
|
25
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
26
26
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
27
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
28
27
|
Classifier: Topic :: Internet
|
|
29
28
|
Classifier: Topic :: Internet :: WWW/HTTP
|
|
30
29
|
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
@@ -34,10 +33,11 @@ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
|
34
33
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
35
34
|
Classifier: Typing :: Typed
|
|
36
35
|
Requires-Dist: APScheduler (==3.9.1.post1)
|
|
37
|
-
Requires-Dist: fastapi (==0.111.0)
|
|
38
36
|
Requires-Dist: itsdangerous (>=2.1.2,<3.0.0)
|
|
39
37
|
Requires-Dist: markupsafe (>=2.1.2,<3.0.0)
|
|
40
38
|
Requires-Dist: psutil (>=5.9.4,<6.0.0)
|
|
39
|
+
Requires-Dist: pydantic (>=2.7.1,<3.0.0)
|
|
40
|
+
Requires-Dist: starlette (==0.37.2)
|
|
41
41
|
Requires-Dist: uvicorn (==0.20.0)
|
|
42
42
|
Requires-Dist: wsproto (==1.2.0)
|
|
43
43
|
Project-URL: Repository, https://github.com/ogrodnek/pyview
|
|
@@ -72,6 +72,7 @@ cookiecutter gh:ogrodnek/pyview-cookiecutter
|
|
|
72
72
|
## Other Examples
|
|
73
73
|
|
|
74
74
|
- [pyview AI chat](https://github.com/pyview/pyview-example-ai-chat)
|
|
75
|
+
- [pyview auth example](https://github.com/pyview/pyview-example-auth) (using [authlib](https://docs.authlib.org/en/latest/))
|
|
75
76
|
|
|
76
77
|
## Simple Counter
|
|
77
78
|
|
|
@@ -124,7 +125,7 @@ count.html:
|
|
|
124
125
|
## Additional Thanks
|
|
125
126
|
|
|
126
127
|
- We're using the [pubsub implementation from flet](https://github.com/flet-dev/flet)
|
|
127
|
-
- PyView is built on top of [
|
|
128
|
+
- PyView is built on top of [Starlette](https://www.starlette.io/).
|
|
128
129
|
|
|
129
130
|
# Status
|
|
130
131
|
|
|
@@ -5,7 +5,7 @@ packages = [
|
|
|
5
5
|
{ include = "pyview" },
|
|
6
6
|
]
|
|
7
7
|
|
|
8
|
-
version = "0.0.
|
|
8
|
+
version = "0.0.21"
|
|
9
9
|
description = "LiveView in Python"
|
|
10
10
|
authors = ["Larry Ogrodnek <ogrodnek@gmail.com>"]
|
|
11
11
|
license = "MIT"
|
|
@@ -42,17 +42,18 @@ classifiers = [
|
|
|
42
42
|
]
|
|
43
43
|
|
|
44
44
|
[tool.poetry.dependencies]
|
|
45
|
-
python = "
|
|
46
|
-
|
|
45
|
+
python = ">=3.9,<3.12"
|
|
46
|
+
starlette = "0.37.2"
|
|
47
47
|
uvicorn = "0.20.0"
|
|
48
48
|
wsproto = "1.2.0"
|
|
49
49
|
APScheduler = "3.9.1.post1"
|
|
50
50
|
psutil = "^5.9.4"
|
|
51
51
|
markupsafe = "^2.1.2"
|
|
52
52
|
itsdangerous = "^2.1.2"
|
|
53
|
+
pydantic = "^2.7.1"
|
|
53
54
|
|
|
54
55
|
[tool.poetry.group.dev.dependencies]
|
|
55
|
-
pytest = "^
|
|
56
|
+
pytest = "^7.2.0"
|
|
56
57
|
black = "24.3.0"
|
|
57
58
|
pyright = "1.1.292"
|
|
58
59
|
aiohttp = "^3.8.4"
|
|
@@ -3,3 +3,13 @@ from pyview.live_socket import LiveViewSocket
|
|
|
3
3
|
from pyview.pyview import PyView, defaultRootTemplate
|
|
4
4
|
from pyview.js import js
|
|
5
5
|
from pyview.pyview import RootTemplateContext, RootTemplate
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"LiveView",
|
|
9
|
+
"LiveViewSocket",
|
|
10
|
+
"PyView",
|
|
11
|
+
"defaultRootTemplate",
|
|
12
|
+
"js",
|
|
13
|
+
"RootTemplateContext",
|
|
14
|
+
"RootTemplate",
|
|
15
|
+
]
|
|
@@ -26,7 +26,7 @@ import { Socket } from "phoenix";
|
|
|
26
26
|
import { LiveSocket } from "phoenix_live_view";
|
|
27
27
|
import NProgress from "nprogress";
|
|
28
28
|
|
|
29
|
-
let Hooks = {};
|
|
29
|
+
let Hooks = window.Hooks ?? {};
|
|
30
30
|
|
|
31
31
|
let scrollAt = () => {
|
|
32
32
|
let scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
from
|
|
2
|
+
from starlette.websockets import WebSocket
|
|
3
3
|
import json
|
|
4
4
|
from typing import Any, TypeVar, Generic, TYPE_CHECKING, Optional
|
|
5
5
|
from urllib.parse import urlencode
|
|
6
6
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
7
7
|
from pyview.vendor.flet.pubsub import PubSubHub, PubSub
|
|
8
8
|
from pyview.events import InfoEvent
|
|
9
|
+
from pyview.uploads import UploadConstraints, UploadConfig, UploadManager
|
|
9
10
|
import datetime
|
|
10
11
|
|
|
11
12
|
|
|
@@ -25,10 +26,18 @@ class UnconnectedSocket(Generic[T]):
|
|
|
25
26
|
connected: bool = False
|
|
26
27
|
live_title: Optional[str] = None
|
|
27
28
|
|
|
29
|
+
def allow_upload(
|
|
30
|
+
self, upload_name: str, constraints: UploadConstraints
|
|
31
|
+
) -> UploadConfig:
|
|
32
|
+
return UploadConfig(name=upload_name, constraints=constraints)
|
|
33
|
+
|
|
28
34
|
|
|
29
35
|
class LiveViewSocket(Generic[T]):
|
|
30
36
|
context: T
|
|
31
37
|
live_title: Optional[str] = None
|
|
38
|
+
pending_events: list[tuple[str, Any]]
|
|
39
|
+
|
|
40
|
+
upload_manager: UploadManager
|
|
32
41
|
|
|
33
42
|
def __init__(self, websocket: WebSocket, topic: str, liveview: LiveView):
|
|
34
43
|
self.websocket = websocket
|
|
@@ -37,6 +46,8 @@ class LiveViewSocket(Generic[T]):
|
|
|
37
46
|
self.scheduled_jobs = []
|
|
38
47
|
self.connected = True
|
|
39
48
|
self.pub_sub = PubSub(pub_sub_hub, topic)
|
|
49
|
+
self.pending_events = []
|
|
50
|
+
self.upload_manager = UploadManager()
|
|
40
51
|
|
|
41
52
|
async def subscribe(self, topic: str):
|
|
42
53
|
await self.pub_sub.subscribe_topic_async(topic, self._topic_callback_internal)
|
|
@@ -49,12 +60,18 @@ class LiveViewSocket(Generic[T]):
|
|
|
49
60
|
|
|
50
61
|
def schedule_info(self, event, seconds):
|
|
51
62
|
id = f"{self.topic}:{event}"
|
|
52
|
-
scheduler.add_job(
|
|
63
|
+
scheduler.add_job(
|
|
64
|
+
self.send_info, args=[event], id=id, trigger="interval", seconds=seconds
|
|
65
|
+
)
|
|
53
66
|
self.scheduled_jobs.append(id)
|
|
54
67
|
|
|
55
|
-
def schedule_info_once(self, event):
|
|
68
|
+
def schedule_info_once(self, event, seconds=None):
|
|
56
69
|
scheduler.add_job(
|
|
57
|
-
self.send_info,
|
|
70
|
+
self.send_info,
|
|
71
|
+
args=[event],
|
|
72
|
+
trigger="date",
|
|
73
|
+
run_date=datetime.datetime.now() + datetime.timedelta(seconds=seconds or 0),
|
|
74
|
+
misfire_grace_time=None,
|
|
58
75
|
)
|
|
59
76
|
|
|
60
77
|
def diff(self, render: dict[str, Any]) -> dict[str, Any]:
|
|
@@ -103,8 +120,26 @@ class LiveViewSocket(Generic[T]):
|
|
|
103
120
|
except Exception as e:
|
|
104
121
|
print("Error sending message", e)
|
|
105
122
|
|
|
123
|
+
async def push_event(self, event: str, value: dict[str, Any]):
|
|
124
|
+
self.pending_events.append((event, value))
|
|
125
|
+
|
|
126
|
+
def allow_upload(
|
|
127
|
+
self, upload_name: str, constraints: UploadConstraints
|
|
128
|
+
) -> UploadConfig:
|
|
129
|
+
return self.upload_manager.allow_upload(upload_name, constraints)
|
|
130
|
+
|
|
106
131
|
async def close(self):
|
|
107
132
|
self.connected = False
|
|
108
133
|
for id in self.scheduled_jobs:
|
|
109
134
|
scheduler.remove_job(id)
|
|
110
135
|
await self.pub_sub.unsubscribe_all_async()
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
self.upload_manager.close()
|
|
139
|
+
except Exception:
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
await self.liveview.disconnect(self)
|
|
144
|
+
except Exception:
|
|
145
|
+
pass
|
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
from typing import TypeVar, Generic, Optional, Union, Any
|
|
2
2
|
from .live_socket import LiveViewSocket, UnconnectedSocket
|
|
3
|
-
from pyview.template import
|
|
4
|
-
|
|
3
|
+
from pyview.template import (
|
|
4
|
+
LiveTemplate,
|
|
5
|
+
template_file,
|
|
6
|
+
RenderedContent,
|
|
7
|
+
LiveRender,
|
|
8
|
+
find_associated_file,
|
|
9
|
+
)
|
|
5
10
|
from pyview.events import InfoEvent
|
|
11
|
+
from urllib.parse import ParseResult
|
|
6
12
|
|
|
7
13
|
T = TypeVar("T")
|
|
8
14
|
|
|
9
15
|
AnySocket = Union[LiveViewSocket[T], UnconnectedSocket[T]]
|
|
10
16
|
Session = dict[str, Any]
|
|
11
17
|
|
|
18
|
+
# TODO: ideally this would always be a ParseResult, but we need to update push_patch
|
|
19
|
+
URL = Union[ParseResult, str]
|
|
20
|
+
|
|
12
21
|
|
|
13
22
|
class LiveView(Generic[T]):
|
|
14
23
|
def __init__(self):
|
|
@@ -23,7 +32,10 @@ class LiveView(Generic[T]):
|
|
|
23
32
|
async def handle_info(self, event: InfoEvent, socket: LiveViewSocket[T]):
|
|
24
33
|
pass
|
|
25
34
|
|
|
26
|
-
async def handle_params(self, url, params, socket: AnySocket):
|
|
35
|
+
async def handle_params(self, url: URL, params, socket: AnySocket):
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
async def disconnect(self, socket: LiveViewSocket[T]):
|
|
27
39
|
pass
|
|
28
40
|
|
|
29
41
|
async def render(self, assigns: T) -> RenderedContent:
|
|
@@ -36,11 +48,6 @@ class LiveView(Generic[T]):
|
|
|
36
48
|
|
|
37
49
|
|
|
38
50
|
def _find_render(m: LiveView) -> Optional[LiveTemplate]:
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def _find_template(cf: str) -> Optional[LiveTemplate]:
|
|
44
|
-
if cf.endswith(".py"):
|
|
45
|
-
cf = cf[:-3]
|
|
46
|
-
return template_file(cf + ".html")
|
|
51
|
+
html = find_associated_file(m, ".html")
|
|
52
|
+
if html is not None:
|
|
53
|
+
return template_file(html)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from starlette.websockets import WebSocketDisconnect
|
|
2
|
+
from starlette.types import Message
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def parse_message(message: Message) -> tuple[str, str, str, str, dict]:
|
|
7
|
+
if "text" in message:
|
|
8
|
+
data = message["text"]
|
|
9
|
+
[joinRef, mesageRef, topic, event, payload] = json.loads(data)
|
|
10
|
+
return joinRef, mesageRef, topic, event, payload
|
|
11
|
+
|
|
12
|
+
if "bytes" in message:
|
|
13
|
+
data = message["bytes"]
|
|
14
|
+
# TODO: need to handle these message types better
|
|
15
|
+
return BinaryUploadSerDe().deserialize(data) # type: ignore
|
|
16
|
+
|
|
17
|
+
# {'type': 'websocket.disconnect', 'code': <CloseReason.NO_STATUS_RCVD: 1005>}
|
|
18
|
+
# TODO handle: other errors?
|
|
19
|
+
raise WebSocketDisconnect(message["code"])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
import struct
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BinaryUploadSerDe:
|
|
26
|
+
def deserialize(self, encoded_data: bytes) -> tuple[str, str, str, str, bytes]:
|
|
27
|
+
offset = 0
|
|
28
|
+
|
|
29
|
+
# Read the kind (1 byte)
|
|
30
|
+
kind = struct.unpack_from("B", encoded_data, offset)[0]
|
|
31
|
+
offset += 1
|
|
32
|
+
|
|
33
|
+
# Read lengths (4 bytes total, 1 byte each)
|
|
34
|
+
join_ref_length = struct.unpack_from("B", encoded_data, offset)[0]
|
|
35
|
+
offset += 1
|
|
36
|
+
ref_length = struct.unpack_from("B", encoded_data, offset)[0]
|
|
37
|
+
offset += 1
|
|
38
|
+
topic_length = struct.unpack_from("B", encoded_data, offset)[0]
|
|
39
|
+
offset += 1
|
|
40
|
+
event_length = struct.unpack_from("B", encoded_data, offset)[0]
|
|
41
|
+
offset += 1
|
|
42
|
+
|
|
43
|
+
# Read the strings
|
|
44
|
+
join_ref = encoded_data[offset : offset + join_ref_length].decode("utf-8")
|
|
45
|
+
offset += join_ref_length
|
|
46
|
+
msg_ref = encoded_data[offset : offset + ref_length].decode("utf-8")
|
|
47
|
+
offset += ref_length
|
|
48
|
+
topic = encoded_data[offset : offset + topic_length].decode("utf-8")
|
|
49
|
+
offset += topic_length
|
|
50
|
+
event = encoded_data[offset : offset + event_length].decode("utf-8")
|
|
51
|
+
offset += event_length
|
|
52
|
+
|
|
53
|
+
# The remaining bytes are the payload
|
|
54
|
+
payload = encoded_data[offset:]
|
|
55
|
+
return join_ref, msg_ref, topic, event, payload
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
from starlette.applications import Starlette
|
|
2
|
-
from
|
|
3
|
-
from
|
|
2
|
+
from starlette.websockets import WebSocket
|
|
3
|
+
from starlette.responses import HTMLResponse
|
|
4
4
|
from starlette.middleware.gzip import GZipMiddleware
|
|
5
5
|
from starlette.routing import Route
|
|
6
6
|
from starlette.requests import Request
|
|
7
7
|
import uuid
|
|
8
|
-
from urllib.parse import parse_qs
|
|
8
|
+
from urllib.parse import parse_qs, urlparse
|
|
9
9
|
|
|
10
10
|
from pyview.live_socket import UnconnectedSocket
|
|
11
11
|
from pyview.csrf import generate_csrf_token
|
|
@@ -14,7 +14,12 @@ from pyview.auth import AuthProviderFactory
|
|
|
14
14
|
from .ws_handler import LiveSocketHandler
|
|
15
15
|
from .live_view import LiveView
|
|
16
16
|
from .live_routes import LiveViewLookup
|
|
17
|
-
from .template import
|
|
17
|
+
from .template import (
|
|
18
|
+
RootTemplate,
|
|
19
|
+
RootTemplateContext,
|
|
20
|
+
defaultRootTemplate,
|
|
21
|
+
find_associated_css,
|
|
22
|
+
)
|
|
18
23
|
|
|
19
24
|
|
|
20
25
|
class PyView(Starlette):
|
|
@@ -54,9 +59,11 @@ async def liveview_container(
|
|
|
54
59
|
session = request.session if "session" in request.scope else {}
|
|
55
60
|
|
|
56
61
|
await lv.mount(s, session)
|
|
57
|
-
await lv.handle_params(url, parse_qs(url.query), s)
|
|
62
|
+
await lv.handle_params(urlparse(url._url), parse_qs(url.query), s)
|
|
58
63
|
r = await lv.render(s.context)
|
|
59
64
|
|
|
65
|
+
liveview_css = find_associated_css(lv)
|
|
66
|
+
|
|
60
67
|
id = str(uuid.uuid4())
|
|
61
68
|
|
|
62
69
|
context: RootTemplateContext = {
|
|
@@ -65,6 +72,7 @@ async def liveview_container(
|
|
|
65
72
|
"title": s.live_title,
|
|
66
73
|
"csrf_token": generate_csrf_token("lv:phx-" + id),
|
|
67
74
|
"session": serialize_session(session),
|
|
75
|
+
"additional_head_elements": liveview_css,
|
|
68
76
|
}
|
|
69
77
|
|
|
70
78
|
return HTMLResponse(template(context))
|
|
@@ -5459,7 +5459,8 @@ within:
|
|
|
5459
5459
|
|
|
5460
5460
|
// assets/js/app.js
|
|
5461
5461
|
var import_nprogress = __toESM(require_nprogress());
|
|
5462
|
-
var
|
|
5462
|
+
var _a;
|
|
5463
|
+
var Hooks2 = (_a = window.Hooks) != null ? _a : {};
|
|
5463
5464
|
var scrollAt = () => {
|
|
5464
5465
|
let scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
|
|
5465
5466
|
let scrollHeight = document.documentElement.scrollHeight || document.body.scrollHeight;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from pyview.vendor.ibis import Template
|
|
2
|
+
from .live_template import LiveTemplate, template_file, RenderedContent, LiveRender
|
|
3
|
+
from .root_template import RootTemplate, RootTemplateContext, defaultRootTemplate
|
|
4
|
+
from .utils import find_associated_css, find_associated_file
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"Template",
|
|
8
|
+
"LiveTemplate",
|
|
9
|
+
"template_file",
|
|
10
|
+
"RenderedContent",
|
|
11
|
+
"LiveRender",
|
|
12
|
+
"RootTemplate",
|
|
13
|
+
"RootTemplateContext",
|
|
14
|
+
"defaultRootTemplate",
|
|
15
|
+
"find_associated_css",
|
|
16
|
+
"find_associated_file",
|
|
17
|
+
]
|
|
@@ -8,6 +8,7 @@ class RootTemplateContext(TypedDict):
|
|
|
8
8
|
title: Optional[str]
|
|
9
9
|
csrf_token: str
|
|
10
10
|
session: Optional[str]
|
|
11
|
+
additional_head_elements: list[Markup]
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
RootTemplate = Callable[[RootTemplateContext], str]
|
|
@@ -45,6 +46,8 @@ def _defaultRootTemplate(
|
|
|
45
46
|
),
|
|
46
47
|
)
|
|
47
48
|
|
|
49
|
+
additional_head_elements = "\n".join(context["additional_head_elements"])
|
|
50
|
+
|
|
48
51
|
return (
|
|
49
52
|
Markup(
|
|
50
53
|
f"""
|
|
@@ -56,8 +59,9 @@ def _defaultRootTemplate(
|
|
|
56
59
|
<meta charset="utf-8">
|
|
57
60
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
58
61
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
|
59
|
-
<script defer type="text/javascript" src="/static/assets/app.js"></script>
|
|
60
62
|
{css}
|
|
63
|
+
<script defer type="text/javascript" src="/static/assets/app.js"></script>
|
|
64
|
+
{additional_head_elements}
|
|
61
65
|
</head>
|
|
62
66
|
<body>"""
|
|
63
67
|
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
import inspect
|
|
3
|
+
import os
|
|
4
|
+
from markupsafe import Markup
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def find_associated_file(o: object, extension: str) -> Optional[str]:
|
|
8
|
+
object_file = inspect.getfile(o.__class__)
|
|
9
|
+
|
|
10
|
+
if object_file.endswith(".py"):
|
|
11
|
+
object_file = object_file[:-3]
|
|
12
|
+
|
|
13
|
+
associated_file = object_file + extension
|
|
14
|
+
if os.path.isfile(associated_file):
|
|
15
|
+
return associated_file
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def find_associated_css(o: object) -> list[Markup]:
|
|
19
|
+
css_file = find_associated_file(o, ".css")
|
|
20
|
+
if css_file:
|
|
21
|
+
with open(css_file, "r") as css:
|
|
22
|
+
return [Markup(f"<style>{css.read()}</style>")]
|
|
23
|
+
|
|
24
|
+
return []
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import uuid
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
from typing import Optional, Any, Literal, Generator
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class ConstraintViolation:
|
|
13
|
+
ref: str
|
|
14
|
+
code: Literal["too_large", "too_many_files"]
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def message(self) -> str:
|
|
18
|
+
if self.code == "too_large":
|
|
19
|
+
return "File too large"
|
|
20
|
+
return "Too many files"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class UploadEntry(BaseModel):
|
|
24
|
+
path: str
|
|
25
|
+
ref: str
|
|
26
|
+
name: str
|
|
27
|
+
size: int
|
|
28
|
+
type: str
|
|
29
|
+
upload_config: Optional["UploadConfig"] = None
|
|
30
|
+
uuid: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
31
|
+
valid: bool = True
|
|
32
|
+
errors: list[ConstraintViolation] = Field(default_factory=list)
|
|
33
|
+
progress: int = 0
|
|
34
|
+
preflighted: bool = False
|
|
35
|
+
cancelled: bool = False
|
|
36
|
+
done: bool = False
|
|
37
|
+
last_modified: int = Field(
|
|
38
|
+
default_factory=lambda: int(datetime.datetime.now().timestamp())
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_entries(entries: list[dict]) -> list[UploadEntry]:
|
|
43
|
+
return [UploadEntry(**entry) for entry in entries]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class ActiveUpload:
|
|
48
|
+
ref: str
|
|
49
|
+
entry: UploadEntry
|
|
50
|
+
file: tempfile._TemporaryFileWrapper = field(init=False)
|
|
51
|
+
|
|
52
|
+
def __post_init__(self):
|
|
53
|
+
self.file = tempfile.NamedTemporaryFile(delete=False)
|
|
54
|
+
|
|
55
|
+
def close(self):
|
|
56
|
+
self.file.close()
|
|
57
|
+
os.remove(self.file.name)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class ActiveUploads:
|
|
62
|
+
uploads: dict[str, ActiveUpload] = field(default_factory=dict)
|
|
63
|
+
|
|
64
|
+
def add_upload(self, ref: str, entry: UploadEntry):
|
|
65
|
+
self.uploads[ref] = ActiveUpload(ref, entry)
|
|
66
|
+
|
|
67
|
+
def add_chunk(self, ref: str, chunk: bytes):
|
|
68
|
+
self.uploads[ref].file.write(chunk)
|
|
69
|
+
self.uploads[ref].file.flush()
|
|
70
|
+
self.uploads[ref].entry.progress = self.uploads[ref].file.tell()
|
|
71
|
+
|
|
72
|
+
def no_progress(self) -> bool:
|
|
73
|
+
return all(upload.entry.progress == 0 for upload in self.uploads.values())
|
|
74
|
+
|
|
75
|
+
def file_name(self, ref: str) -> str:
|
|
76
|
+
return self.uploads[ref].file.name
|
|
77
|
+
|
|
78
|
+
def join_ref_for_entry(self, ref: str) -> str:
|
|
79
|
+
return [
|
|
80
|
+
join_ref
|
|
81
|
+
for join_ref, upload in self.uploads.items()
|
|
82
|
+
if upload.entry.ref == ref
|
|
83
|
+
][0]
|
|
84
|
+
|
|
85
|
+
def close(self):
|
|
86
|
+
for upload in self.uploads.values():
|
|
87
|
+
upload.close()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class UploadConstraints(BaseModel):
|
|
91
|
+
max_file_size: int = 10 * 1024 * 1024 # 10MB
|
|
92
|
+
max_files: int = 10
|
|
93
|
+
accept: list[str] = Field(default_factory=lambda: ["image/*"])
|
|
94
|
+
chunk_size: int = 64 * 1024 # 64KB
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class UploadConfig(BaseModel):
|
|
98
|
+
name: str
|
|
99
|
+
|
|
100
|
+
entries_by_ref: dict[str, UploadEntry] = Field(default_factory=dict)
|
|
101
|
+
ref: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
102
|
+
errors: list[ConstraintViolation] = Field(default_factory=list)
|
|
103
|
+
autoUpload: bool = False
|
|
104
|
+
constraints: UploadConstraints = Field(default_factory=UploadConstraints)
|
|
105
|
+
|
|
106
|
+
uploads: ActiveUploads = Field(default_factory=ActiveUploads)
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def entries(self) -> list[UploadEntry]:
|
|
110
|
+
return list(self.entries_by_ref.values())
|
|
111
|
+
|
|
112
|
+
def cancel_entry(self, ref: str):
|
|
113
|
+
del self.entries_by_ref[ref]
|
|
114
|
+
|
|
115
|
+
# recheck constraints
|
|
116
|
+
self.errors.clear()
|
|
117
|
+
if len(self.entries_by_ref) > self.constraints.max_files:
|
|
118
|
+
self.errors.append(ConstraintViolation(ref=self.ref, code="too_many_files"))
|
|
119
|
+
|
|
120
|
+
def add_entries(self, entries: list[dict]):
|
|
121
|
+
parsed = parse_entries(entries)
|
|
122
|
+
for entry in parsed:
|
|
123
|
+
entry.upload_config = self
|
|
124
|
+
self.entries_by_ref[entry.ref] = entry
|
|
125
|
+
if entry.size > self.constraints.max_file_size:
|
|
126
|
+
entry.valid = False
|
|
127
|
+
entry.errors.append(
|
|
128
|
+
ConstraintViolation(ref=entry.ref, code="too_large")
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
if len(self.entries_by_ref) > self.constraints.max_files:
|
|
132
|
+
self.errors.append(ConstraintViolation(ref=self.ref, code="too_many_files"))
|
|
133
|
+
|
|
134
|
+
def update_progress(self, ref: str, progress: int):
|
|
135
|
+
self.entries_by_ref[ref].progress = progress
|
|
136
|
+
self.entries_by_ref[ref].done = progress == 100
|
|
137
|
+
|
|
138
|
+
@contextmanager
|
|
139
|
+
def consume_uploads(self) -> Generator[list["ActiveUpload"], None, None]:
|
|
140
|
+
try:
|
|
141
|
+
upload_list = list(self.uploads.uploads.values())
|
|
142
|
+
yield upload_list
|
|
143
|
+
finally:
|
|
144
|
+
try:
|
|
145
|
+
self.uploads.close()
|
|
146
|
+
except Exception as e:
|
|
147
|
+
print("Error closing uploads", e)
|
|
148
|
+
|
|
149
|
+
self.uploads = ActiveUploads()
|
|
150
|
+
self.entries_by_ref = {}
|
|
151
|
+
|
|
152
|
+
def close(self):
|
|
153
|
+
self.uploads.close()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class UploadManager:
|
|
157
|
+
upload_configs: dict[str, UploadConfig]
|
|
158
|
+
upload_config_join_refs: dict[str, UploadConfig]
|
|
159
|
+
|
|
160
|
+
def __init__(self):
|
|
161
|
+
self.upload_configs = {}
|
|
162
|
+
self.upload_config_join_refs = {}
|
|
163
|
+
|
|
164
|
+
def allow_upload(
|
|
165
|
+
self, upload_name: str, constraints: UploadConstraints
|
|
166
|
+
) -> UploadConfig:
|
|
167
|
+
config = UploadConfig(name=upload_name, constraints=constraints)
|
|
168
|
+
self.upload_configs[upload_name] = config
|
|
169
|
+
return config
|
|
170
|
+
|
|
171
|
+
def config_for_name(self, upload_name: str) -> Optional[UploadConfig]:
|
|
172
|
+
return self.upload_configs.get(upload_name)
|
|
173
|
+
|
|
174
|
+
def config_for_ref(self, ref: str) -> Optional[UploadConfig]:
|
|
175
|
+
return [c for c in self.upload_configs.values() if c.ref == ref][0]
|
|
176
|
+
|
|
177
|
+
def maybe_process_uploads(self, qs: dict[str, Any], payload: dict[str, Any]):
|
|
178
|
+
if "uploads" in payload:
|
|
179
|
+
uploads = payload["uploads"]
|
|
180
|
+
config_key = qs["_target"][0]
|
|
181
|
+
|
|
182
|
+
config = self.config_for_name(config_key)
|
|
183
|
+
if config:
|
|
184
|
+
if config.ref in uploads:
|
|
185
|
+
entries = uploads[config.ref]
|
|
186
|
+
config.add_entries(entries)
|
|
187
|
+
else:
|
|
188
|
+
print("can't find ref", config.ref)
|
|
189
|
+
|
|
190
|
+
def process_allow_upload(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
191
|
+
ref = payload["ref"]
|
|
192
|
+
config = self.config_for_ref(ref)
|
|
193
|
+
|
|
194
|
+
if not config:
|
|
195
|
+
print("Can't find config for ref", ref)
|
|
196
|
+
return {"error": [(ref, "not_found")]}
|
|
197
|
+
|
|
198
|
+
proposed_entries = payload["entries"]
|
|
199
|
+
|
|
200
|
+
errors = []
|
|
201
|
+
for entry in proposed_entries:
|
|
202
|
+
if entry["size"] > config.constraints.max_file_size:
|
|
203
|
+
errors.append(ConstraintViolation(ref=entry["ref"], code="too_large"))
|
|
204
|
+
|
|
205
|
+
if len(proposed_entries) > config.constraints.max_files:
|
|
206
|
+
errors.append(ConstraintViolation(ref=ref, code="too_many_files"))
|
|
207
|
+
|
|
208
|
+
if errors:
|
|
209
|
+
return {"error": [(e.ref, e.code) for e in errors]}
|
|
210
|
+
|
|
211
|
+
configJson = config.constraints.model_dump()
|
|
212
|
+
entryJson = {
|
|
213
|
+
e.ref: e.model_dump(exclude={"upload_config"}) for e in config.entries
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return {"config": configJson, "entries": entryJson}
|
|
217
|
+
|
|
218
|
+
def add_upload(self, joinRef: str, payload: dict[str, Any]):
|
|
219
|
+
token = payload["token"]
|
|
220
|
+
|
|
221
|
+
config = self.config_for_name(token["path"])
|
|
222
|
+
if config:
|
|
223
|
+
self.upload_config_join_refs[joinRef] = config
|
|
224
|
+
entry = UploadEntry(**token)
|
|
225
|
+
config.uploads.add_upload(joinRef, entry)
|
|
226
|
+
|
|
227
|
+
def add_chunk(self, joinRef: str, chunk: bytes):
|
|
228
|
+
config = self.upload_config_join_refs[joinRef]
|
|
229
|
+
config.uploads.add_chunk(joinRef, chunk)
|
|
230
|
+
pass
|
|
231
|
+
|
|
232
|
+
def update_progress(self, joinRef: str, payload: dict[str, Any]):
|
|
233
|
+
upload_config_ref = payload["ref"]
|
|
234
|
+
entry_ref = payload["entry_ref"]
|
|
235
|
+
progress = int(payload["progress"])
|
|
236
|
+
|
|
237
|
+
config = self.config_for_ref(upload_config_ref)
|
|
238
|
+
if config:
|
|
239
|
+
config.update_progress(entry_ref, progress)
|
|
240
|
+
|
|
241
|
+
if progress == 100:
|
|
242
|
+
joinRef = config.uploads.join_ref_for_entry(entry_ref)
|
|
243
|
+
del self.upload_config_join_refs[joinRef]
|
|
244
|
+
|
|
245
|
+
def no_progress(self, joinRef) -> bool:
|
|
246
|
+
config = self.upload_config_join_refs[joinRef]
|
|
247
|
+
return config.uploads.no_progress()
|
|
248
|
+
|
|
249
|
+
def close(self):
|
|
250
|
+
for config in self.upload_configs.values():
|
|
251
|
+
config.close()
|
|
252
|
+
self.upload_configs = {}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
from markupsafe import Markup
|
|
256
|
+
from pyview.vendor.ibis import filters
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@filters.register
|
|
260
|
+
def live_file_input(config: Optional[UploadConfig]) -> Markup:
|
|
261
|
+
if not config:
|
|
262
|
+
return Markup("")
|
|
263
|
+
|
|
264
|
+
active_refs = ",".join([entry.ref for entry in config.entries])
|
|
265
|
+
done_refs = ",".join([entry.ref for entry in config.entries if entry.done])
|
|
266
|
+
preflighted_refs = ",".join(
|
|
267
|
+
[entry.ref for entry in config.entries if entry.preflighted]
|
|
268
|
+
)
|
|
269
|
+
accepted = ",".join(config.constraints.accept)
|
|
270
|
+
accept = f'accept="{accepted}"' if accepted else ""
|
|
271
|
+
multiple = "multiple" if config.constraints.max_files > 1 else ""
|
|
272
|
+
|
|
273
|
+
return Markup(
|
|
274
|
+
f"""
|
|
275
|
+
<input type="file" id="{config.ref}" name="{config.name}"
|
|
276
|
+
data-phx-upload-ref="{config.ref}"
|
|
277
|
+
data-phx-active-refs="{active_refs}"
|
|
278
|
+
data-phx-done-refs="{done_refs}"
|
|
279
|
+
data-phx-preflighted-refs="{preflighted_refs}"
|
|
280
|
+
data-phx-update="ignore" phx-hook="Phoenix.LiveFileUpload"
|
|
281
|
+
{accept} {multiple}>
|
|
282
|
+
</input>
|
|
283
|
+
"""
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@filters.register
|
|
288
|
+
def upload_preview_tag(entry: UploadEntry) -> Markup:
|
|
289
|
+
config_ref = entry.upload_config.ref if entry.upload_config else ""
|
|
290
|
+
return Markup(
|
|
291
|
+
f"""<img id="phx-preview-{entry.ref}" data-phx-upload-ref="{config_ref}"
|
|
292
|
+
data-phx-entry-ref="{entry.ref}" data-phx-hook="Phoenix.LiveImgPreview" data-phx-update="ignore" />
|
|
293
|
+
"""
|
|
294
|
+
)
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
from typing import Optional
|
|
2
|
-
from fastapi import WebSocket, WebSocketDisconnect
|
|
3
2
|
import json
|
|
3
|
+
from starlette.websockets import WebSocket, WebSocketDisconnect
|
|
4
4
|
from urllib.parse import urlparse, parse_qs
|
|
5
5
|
from pyview.live_socket import LiveViewSocket
|
|
6
6
|
from pyview.live_routes import LiveViewLookup
|
|
7
7
|
from pyview.csrf import validate_csrf_token
|
|
8
8
|
from pyview.session import deserialize_session
|
|
9
9
|
from pyview.auth import AuthProviderFactory
|
|
10
|
+
from pyview.phx_message import parse_message
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
class AuthException(Exception):
|
|
@@ -37,6 +38,8 @@ class LiveSocketHandler:
|
|
|
37
38
|
if not validate_csrf_token(payload["params"]["_csrf_token"], topic):
|
|
38
39
|
raise AuthException("Invalid CSRF token")
|
|
39
40
|
|
|
41
|
+
self.myJoinId = topic
|
|
42
|
+
|
|
40
43
|
url = urlparse(payload["url"])
|
|
41
44
|
lv = self.routes.get(url.path)
|
|
42
45
|
await self.check_auth(websocket, lv)
|
|
@@ -60,7 +63,7 @@ class LiveSocketHandler:
|
|
|
60
63
|
]
|
|
61
64
|
|
|
62
65
|
await self.manager.send_personal_message(json.dumps(resp), websocket)
|
|
63
|
-
await self.handle_connected(socket)
|
|
66
|
+
await self.handle_connected(topic, socket)
|
|
64
67
|
|
|
65
68
|
except WebSocketDisconnect:
|
|
66
69
|
if socket:
|
|
@@ -70,10 +73,10 @@ class LiveSocketHandler:
|
|
|
70
73
|
await websocket.close()
|
|
71
74
|
self.sessions -= 1
|
|
72
75
|
|
|
73
|
-
async def handle_connected(self, socket: LiveViewSocket):
|
|
76
|
+
async def handle_connected(self, myJoinId, socket: LiveViewSocket):
|
|
74
77
|
while True:
|
|
75
|
-
|
|
76
|
-
[joinRef, mesageRef, topic, event, payload] =
|
|
78
|
+
message = await socket.websocket.receive()
|
|
79
|
+
[joinRef, mesageRef, topic, event, payload] = parse_message(message)
|
|
77
80
|
|
|
78
81
|
if event == "heartbeat":
|
|
79
82
|
resp = [
|
|
@@ -83,25 +86,37 @@ class LiveSocketHandler:
|
|
|
83
86
|
"phx_reply",
|
|
84
87
|
{"response": {}, "status": "ok"},
|
|
85
88
|
]
|
|
86
|
-
await self.manager.send_personal_message(
|
|
89
|
+
await self.manager.send_personal_message(
|
|
90
|
+
json.dumps(resp), socket.websocket
|
|
91
|
+
)
|
|
87
92
|
continue
|
|
88
93
|
|
|
89
94
|
if event == "event":
|
|
90
95
|
value = payload["value"]
|
|
96
|
+
|
|
91
97
|
if payload["type"] == "form":
|
|
92
98
|
value = parse_qs(value)
|
|
99
|
+
socket.upload_manager.maybe_process_uploads(value, payload)
|
|
93
100
|
|
|
94
101
|
await socket.liveview.handle_event(payload["event"], value, socket)
|
|
95
102
|
rendered = await _render(socket)
|
|
96
103
|
|
|
104
|
+
hook_events = (
|
|
105
|
+
{} if not socket.pending_events else {"e": socket.pending_events}
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
socket.pending_events = []
|
|
109
|
+
|
|
97
110
|
resp = [
|
|
98
111
|
joinRef,
|
|
99
112
|
mesageRef,
|
|
100
113
|
topic,
|
|
101
114
|
"phx_reply",
|
|
102
|
-
{"response": {"diff": rendered}, "status": "ok"},
|
|
115
|
+
{"response": {"diff": rendered | hook_events}, "status": "ok"},
|
|
103
116
|
]
|
|
104
|
-
await self.manager.send_personal_message(
|
|
117
|
+
await self.manager.send_personal_message(
|
|
118
|
+
json.dumps(resp), socket.websocket
|
|
119
|
+
)
|
|
105
120
|
continue
|
|
106
121
|
|
|
107
122
|
if event == "live_patch":
|
|
@@ -118,9 +133,94 @@ class LiveSocketHandler:
|
|
|
118
133
|
"phx_reply",
|
|
119
134
|
{"response": {"diff": rendered}, "status": "ok"},
|
|
120
135
|
]
|
|
121
|
-
await self.manager.send_personal_message(
|
|
136
|
+
await self.manager.send_personal_message(
|
|
137
|
+
json.dumps(resp), socket.websocket
|
|
138
|
+
)
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
if event == "allow_upload":
|
|
142
|
+
allow_upload_response = socket.upload_manager.process_allow_upload(
|
|
143
|
+
payload
|
|
144
|
+
)
|
|
145
|
+
rendered = await _render(socket)
|
|
146
|
+
|
|
147
|
+
resp = [
|
|
148
|
+
joinRef,
|
|
149
|
+
mesageRef,
|
|
150
|
+
topic,
|
|
151
|
+
"phx_reply",
|
|
152
|
+
{
|
|
153
|
+
"response": {"diff": rendered} | allow_upload_response,
|
|
154
|
+
"status": "ok",
|
|
155
|
+
},
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
await self.manager.send_personal_message(
|
|
159
|
+
json.dumps(resp), socket.websocket
|
|
160
|
+
)
|
|
122
161
|
continue
|
|
123
162
|
|
|
163
|
+
# file upload
|
|
164
|
+
if event == "phx_join":
|
|
165
|
+
socket.upload_manager.add_upload(joinRef, payload)
|
|
166
|
+
|
|
167
|
+
resp = [
|
|
168
|
+
joinRef,
|
|
169
|
+
mesageRef,
|
|
170
|
+
topic,
|
|
171
|
+
"phx_reply",
|
|
172
|
+
{"response": {}, "status": "ok"},
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
await self.manager.send_personal_message(
|
|
176
|
+
json.dumps(resp), socket.websocket
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
if event == "chunk":
|
|
180
|
+
socket.upload_manager.add_chunk(joinRef, payload) # type: ignore
|
|
181
|
+
|
|
182
|
+
resp = [
|
|
183
|
+
joinRef,
|
|
184
|
+
mesageRef,
|
|
185
|
+
topic,
|
|
186
|
+
"phx_reply",
|
|
187
|
+
{"response": {}, "status": "ok"},
|
|
188
|
+
]
|
|
189
|
+
|
|
190
|
+
if socket.upload_manager.no_progress(joinRef):
|
|
191
|
+
await self.manager.send_personal_message(
|
|
192
|
+
json.dumps(
|
|
193
|
+
[
|
|
194
|
+
joinRef,
|
|
195
|
+
None,
|
|
196
|
+
myJoinId,
|
|
197
|
+
"phx_reply",
|
|
198
|
+
{"response": {"diff": {}}, "status": "ok"},
|
|
199
|
+
]
|
|
200
|
+
),
|
|
201
|
+
socket.websocket,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
await self.manager.send_personal_message(
|
|
205
|
+
json.dumps(resp), socket.websocket
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
if event == "progress":
|
|
209
|
+
socket.upload_manager.update_progress(joinRef, payload)
|
|
210
|
+
rendered = await _render(socket)
|
|
211
|
+
|
|
212
|
+
resp = [
|
|
213
|
+
joinRef,
|
|
214
|
+
mesageRef,
|
|
215
|
+
topic,
|
|
216
|
+
"phx_reply",
|
|
217
|
+
{"response": {"diff": rendered}, "status": "ok"},
|
|
218
|
+
]
|
|
219
|
+
|
|
220
|
+
await self.manager.send_personal_message(
|
|
221
|
+
json.dumps(resp), socket.websocket
|
|
222
|
+
)
|
|
223
|
+
|
|
124
224
|
|
|
125
225
|
async def _render(socket: LiveViewSocket):
|
|
126
226
|
rendered = (await socket.liveview.render(socket.context)).tree()
|
|
@@ -27,6 +27,7 @@ cookiecutter gh:ogrodnek/pyview-cookiecutter
|
|
|
27
27
|
## Other Examples
|
|
28
28
|
|
|
29
29
|
- [pyview AI chat](https://github.com/pyview/pyview-example-ai-chat)
|
|
30
|
+
- [pyview auth example](https://github.com/pyview/pyview-example-auth) (using [authlib](https://docs.authlib.org/en/latest/))
|
|
30
31
|
|
|
31
32
|
## Simple Counter
|
|
32
33
|
|
|
@@ -79,7 +80,7 @@ count.html:
|
|
|
79
80
|
## Additional Thanks
|
|
80
81
|
|
|
81
82
|
- We're using the [pubsub implementation from flet](https://github.com/flet-dev/flet)
|
|
82
|
-
- PyView is built on top of [
|
|
83
|
+
- PyView is built on top of [Starlette](https://www.starlette.io/).
|
|
83
84
|
|
|
84
85
|
# Status
|
|
85
86
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|