pyview-web 0.0.9a0__py3-none-any.whl → 0.0.20__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.

Potentially problematic release.


This version of pyview-web might be problematic. Click here for more details.

pyview/__init__.py CHANGED
@@ -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
+ ]
pyview/assets/js/app.js CHANGED
@@ -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;
pyview/live_socket.py CHANGED
@@ -1,11 +1,12 @@
1
1
  from __future__ import annotations
2
- from fastapi import WebSocket
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(self.send_info, args=[event], id=id, trigger="interval", seconds=seconds)
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, args=[event], trigger="date", run_date=datetime.datetime.now(), misfire_grace_time=None
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
pyview/live_view.py CHANGED
@@ -3,12 +3,16 @@ from .live_socket import LiveViewSocket, UnconnectedSocket
3
3
  from pyview.template import LiveTemplate, template_file, RenderedContent, LiveRender
4
4
  import inspect
5
5
  from pyview.events import InfoEvent
6
+ from urllib.parse import ParseResult
6
7
 
7
8
  T = TypeVar("T")
8
9
 
9
10
  AnySocket = Union[LiveViewSocket[T], UnconnectedSocket[T]]
10
11
  Session = dict[str, Any]
11
12
 
13
+ # TODO: ideally this would always be a ParseResult, but we need to update push_patch
14
+ URL = Union[ParseResult, str]
15
+
12
16
 
13
17
  class LiveView(Generic[T]):
14
18
  def __init__(self):
@@ -23,7 +27,10 @@ class LiveView(Generic[T]):
23
27
  async def handle_info(self, event: InfoEvent, socket: LiveViewSocket[T]):
24
28
  pass
25
29
 
26
- async def handle_params(self, url, params, socket: AnySocket):
30
+ async def handle_params(self, url: URL, params, socket: AnySocket):
31
+ pass
32
+
33
+ async def disconnect(self, socket: LiveViewSocket[T]):
27
34
  pass
28
35
 
29
36
  async def render(self, assigns: T) -> RenderedContent:
pyview/phx_message.py ADDED
@@ -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
pyview/pyview.py CHANGED
@@ -1,11 +1,11 @@
1
1
  from starlette.applications import Starlette
2
- from fastapi import WebSocket
3
- from fastapi.responses import HTMLResponse
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
@@ -54,7 +54,7 @@ async def liveview_container(
54
54
  session = request.session if "session" in request.scope else {}
55
55
 
56
56
  await lv.mount(s, session)
57
- await lv.handle_params(url, parse_qs(url.query), s)
57
+ await lv.handle_params(urlparse(url._url), parse_qs(url.query), s)
58
58
  r = await lv.render(s.context)
59
59
 
60
60
  id = str(uuid.uuid4())
@@ -5459,7 +5459,8 @@ within:
5459
5459
 
5460
5460
  // assets/js/app.js
5461
5461
  var import_nprogress = __toESM(require_nprogress());
5462
- var Hooks2 = {};
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;
@@ -56,8 +56,8 @@ def _defaultRootTemplate(
56
56
  <meta charset="utf-8">
57
57
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
58
58
  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
59
+ {css}
59
60
  <script defer type="text/javascript" src="/static/assets/app.js"></script>
60
- {css}
61
61
  </head>
62
62
  <body>"""
63
63
  )
pyview/uploads.py ADDED
@@ -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
+ )
pyview/ws_handler.py CHANGED
@@ -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
- data = await socket.websocket.receive_text()
76
- [joinRef, mesageRef, topic, event, payload] = json.loads(data)
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(json.dumps(resp), socket.websocket)
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(json.dumps(resp), socket.websocket)
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(json.dumps(resp), socket.websocket)
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()
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pyview-web
3
- Version: 0.0.9a0
3
+ Version: 0.0.20
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.16,<4.0.0
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 [FastAPI](https://fastapi.tiangolo.com), and of course, [Starlette](https://www.starlette.io/).
128
+ - PyView is built on top of [Starlette](https://www.starlette.io/).
128
129
 
129
130
  # Status
130
131
 
@@ -1,5 +1,5 @@
1
- pyview/__init__.py,sha256=MN3RrkiFdM6bOZV6qJgvwifXV81Qx9k_PN2p8zJvq2I,223
2
- pyview/assets/js/app.js,sha256=n7FiM_VoxZ3ZUkLjtyxbOnSZU88zZkN1A5b8jjhiFoo,2508
1
+ pyview/__init__.py,sha256=Pk-RgSkIG6fkLH3CwrF5tptoq7n72w0O73k2vAW4CAQ,374
2
+ pyview/assets/js/app.js,sha256=XuuSgEMY4hx8v0OuEPwaa7trktu_vppL0tc3Bs9Fw7s,2524
3
3
  pyview/assets/package-lock.json,sha256=kFCrEUJc3G7VD7EsBQf6__EKQhaKAok-I5rrwiAoX0w,2425
4
4
  pyview/assets/package.json,sha256=E6xaX8KMUAektIIedLmI55jGnmlNMSeD2tgKYXWk1vg,151
5
5
  pyview/auth/__init__.py,sha256=vMlirETRhD4va61NOzwg8VY8ep9wVOF96GznJGBmzD0,109
@@ -11,17 +11,19 @@ pyview/csrf.py,sha256=VIURva9EJqXXYGC7engweh3SwDQCnHlhV2zWdcdnFqc,789
11
11
  pyview/events.py,sha256=Zv8G2F1XeXUk1wrnfomeFfxB0OPYmHdjSvxRjQew3No,125
12
12
  pyview/js.py,sha256=4OnPEfBfuvmekeQlm9444As4PLR22zLMIyyzQIIkmls,751
13
13
  pyview/live_routes.py,sha256=tsKFh2gmH2BWsjsZQZErzRp_-KiAZcn4lFKNLRIN5Nc,498
14
- pyview/live_socket.py,sha256=p3eTynzGtvLiooOZyTemmXTrusAJKaoueuZ6Q5gfeYk,3314
15
- pyview/live_view.py,sha256=RRhj89NMianv6dkYd9onOQEJgpRF-pqUb7nmbQr6R6E,1255
16
- pyview/pyview.py,sha256=LdW2irgsd4KRls5TLU22DZJIy5Pmo_2L-ajOwXi9cEs,2302
14
+ pyview/live_socket.py,sha256=6SLEkEBzK-zIUNh_5j_OG5t6IHGTDNCpGXk7D7SMNJ4,4370
15
+ pyview/live_view.py,sha256=SeZ78aA_leqfubPUybRaaCUhqLJJirjPSkf9omvJyTs,1486
16
+ pyview/phx_message.py,sha256=DUdPfl6tlw9K0FNXJ35ehq03JGgynvwA_JItHQ_dxMQ,2007
17
+ pyview/pyview.py,sha256=UfVaYBAzPU_RalijjmXup6oDpIG_w9acjUG1AXm11r4,2342
17
18
  pyview/secret.py,sha256=HbaNpGAkFs4uxMVAmk9HwE3FIehg7dmwEOlED7C9moM,363
18
19
  pyview/session.py,sha256=nC8ExyVwfCgQfx9T-aJGyFhr2C7jsrEY_QFkaXtP28U,432
19
- pyview/static/assets/app.js,sha256=vyD-RACuZxOBWGy7VD-BY5-qkgbFyJM43-M-WGgaAHo,199984
20
+ pyview/static/assets/app.js,sha256=QoXfdcOCYwVYJftvjsIIVwFye7onaOJMxRpalyYqoMU,200029
20
21
  pyview/template/__init__.py,sha256=bDaxDV7QhUwBXfy672Ft0NBDNvhT4kEKDV5VUWhADe8,206
21
22
  pyview/template/live_template.py,sha256=wSKyBw7ejpUY5qXUZdE36Jeeix8Of0CUq8eZdQwxXyg,1864
22
- pyview/template/root_template.py,sha256=0U50QIBhLVshLEog0SCbTDWgRjMpkfNDVcqowKFmGSQ,1876
23
+ pyview/template/root_template.py,sha256=OBTYtaw03yDJIsXDV9ZFqy__U579FGttRjlAXnLaNEo,1882
23
24
  pyview/template/serializer.py,sha256=WDZfqJr2LMlf36fUW2CmWc2aREc63553_y_GRP2-qYc,826
24
25
  pyview/test_csrf.py,sha256=QWTOtfagDMkoYDK_ehYxua34F7-ltPsSeTwQGEOlqHU,684
26
+ pyview/uploads.py,sha256=cFNOlJD5dkA2VccZT_W1bJn_5vYAaphhRJX-RCfEXm8,9598
25
27
  pyview/vendor/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
26
28
  pyview/vendor/flet/pubsub/__init__.py,sha256=JSPCeKB26b5E-IVHNRvVHrlf_CBGDLCulE9ADrostGs,39
27
29
  pyview/vendor/flet/pubsub/pub_sub.py,sha256=gpdruSxKQBqL7_Dtxo4vETm1kM0YH7S299msw2oyUoE,10184
@@ -35,8 +37,8 @@ pyview/vendor/ibis/nodes.py,sha256=jNRmlTCHXC4xCF8nfDRlLWINlaYiFa8NGrzebJFJgEM,2
35
37
  pyview/vendor/ibis/template.py,sha256=IX9z-Ig13yJyRnMqtB52eiRLe002qdIxnfa7fYEXLqM,2314
36
38
  pyview/vendor/ibis/tree.py,sha256=5LAjl3q9iPMZBb6QbKurWj9-QGKLVf11K2_bQotWlUc,2293
37
39
  pyview/vendor/ibis/utils.py,sha256=nLSaxPR9vMphzV9qinlz_Iurv9c49Ps6Knv8vyNlewU,2768
38
- pyview/ws_handler.py,sha256=tokN9gtC_Gn1tlPTxfhYGSFfquiSUvD3sHigzjh1gYM,4738
39
- pyview_web-0.0.9a0.dist-info/LICENSE,sha256=M_bADaBm9_MV9llX3lCicksLhwk3eZUjA2srE0uUWr0,1071
40
- pyview_web-0.0.9a0.dist-info/METADATA,sha256=A5xUGWuuj2y8FvzoqsSynuwkD3osTc2ZO1HWzYzUm2U,5220
41
- pyview_web-0.0.9a0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
42
- pyview_web-0.0.9a0.dist-info/RECORD,,
40
+ pyview/ws_handler.py,sha256=Mkbw6UKEy4HYURSZUdmpF95oWiuEymzS_VGG8WV8mGw,7977
41
+ pyview_web-0.0.20.dist-info/LICENSE,sha256=M_bADaBm9_MV9llX3lCicksLhwk3eZUjA2srE0uUWr0,1071
42
+ pyview_web-0.0.20.dist-info/METADATA,sha256=6EY-yiCteR7fFY029hG2T5jCp8wCMRKpr2FaGhV8tJY,5276
43
+ pyview_web-0.0.20.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
44
+ pyview_web-0.0.20.dist-info/RECORD,,