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/uploads.py
CHANGED
|
@@ -1,34 +1,77 @@
|
|
|
1
1
|
import datetime
|
|
2
|
-
import uuid
|
|
3
2
|
import logging
|
|
4
|
-
from pydantic import BaseModel, Field
|
|
5
|
-
from typing import Optional, Any, Literal, Generator
|
|
6
|
-
from dataclasses import dataclass, field
|
|
7
|
-
from contextlib import contextmanager
|
|
8
3
|
import os
|
|
9
4
|
import tempfile
|
|
5
|
+
import uuid
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, Awaitable, Callable, Generator, Literal, Optional
|
|
9
|
+
|
|
10
|
+
from markupsafe import Markup
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
|
|
13
|
+
from pyview.vendor.ibis import filters
|
|
10
14
|
|
|
11
15
|
logger = logging.getLogger(__name__)
|
|
12
16
|
|
|
13
17
|
|
|
18
|
+
@dataclass
|
|
19
|
+
class UploadSuccess:
|
|
20
|
+
"""Upload completed successfully (no additional data needed)."""
|
|
21
|
+
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class UploadSuccessWithData:
|
|
27
|
+
"""Upload completed successfully with completion data.
|
|
28
|
+
|
|
29
|
+
Used for multipart uploads where the client sends additional data like:
|
|
30
|
+
- upload_id: S3 multipart upload ID
|
|
31
|
+
- parts: List of {PartNumber, ETag} dicts
|
|
32
|
+
- key: S3 object key
|
|
33
|
+
- Any other provider-specific fields
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
data: dict
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class UploadFailure:
|
|
41
|
+
"""Upload failed with an error.
|
|
42
|
+
|
|
43
|
+
Used when the client reports an upload error.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
error: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Type alias for upload completion results
|
|
50
|
+
UploadResult = UploadSuccess | UploadSuccessWithData | UploadFailure
|
|
51
|
+
|
|
52
|
+
|
|
14
53
|
@dataclass
|
|
15
54
|
class ConstraintViolation:
|
|
16
55
|
ref: str
|
|
17
|
-
code: Literal["too_large", "too_many_files"]
|
|
56
|
+
code: Literal["too_large", "too_many_files", "upload_failed"]
|
|
18
57
|
|
|
19
58
|
@property
|
|
20
59
|
def message(self) -> str:
|
|
21
60
|
if self.code == "too_large":
|
|
22
61
|
return "File too large"
|
|
23
|
-
|
|
62
|
+
if self.code == "too_many_files":
|
|
63
|
+
return "Too many files"
|
|
64
|
+
if self.code == "upload_failed":
|
|
65
|
+
return "Upload failed"
|
|
66
|
+
return self.code
|
|
24
67
|
|
|
25
68
|
|
|
26
69
|
class UploadEntry(BaseModel):
|
|
27
|
-
path: str
|
|
28
70
|
ref: str
|
|
29
71
|
name: str
|
|
30
72
|
size: int
|
|
31
73
|
type: str
|
|
74
|
+
path: Optional[str] = None # None for external uploads, set for internal uploads
|
|
32
75
|
upload_config: Optional["UploadConfig"] = None
|
|
33
76
|
uuid: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
34
77
|
valid: bool = True
|
|
@@ -37,9 +80,8 @@ class UploadEntry(BaseModel):
|
|
|
37
80
|
preflighted: bool = False
|
|
38
81
|
cancelled: bool = False
|
|
39
82
|
done: bool = False
|
|
40
|
-
last_modified: int = Field(
|
|
41
|
-
|
|
42
|
-
)
|
|
83
|
+
last_modified: int = Field(default_factory=lambda: int(datetime.datetime.now().timestamp()))
|
|
84
|
+
meta: Optional["ExternalUploadMeta"] = None # Metadata from external uploads
|
|
43
85
|
|
|
44
86
|
|
|
45
87
|
def parse_entries(entries: list[dict]) -> list[UploadEntry]:
|
|
@@ -53,7 +95,7 @@ class ActiveUpload:
|
|
|
53
95
|
file: tempfile._TemporaryFileWrapper = field(init=False)
|
|
54
96
|
|
|
55
97
|
def __post_init__(self):
|
|
56
|
-
self.file = tempfile.NamedTemporaryFile(delete=False)
|
|
98
|
+
self.file = tempfile.NamedTemporaryFile(delete=False) # noqa: SIM115
|
|
57
99
|
|
|
58
100
|
def close(self):
|
|
59
101
|
self.file.close()
|
|
@@ -79,17 +121,28 @@ class ActiveUploads:
|
|
|
79
121
|
return self.uploads[ref].file.name
|
|
80
122
|
|
|
81
123
|
def join_ref_for_entry(self, ref: str) -> str:
|
|
82
|
-
return [
|
|
83
|
-
join_ref
|
|
84
|
-
for join_ref, upload in self.uploads.items()
|
|
85
|
-
if upload.entry.ref == ref
|
|
86
|
-
][0]
|
|
124
|
+
return [join_ref for join_ref, upload in self.uploads.items() if upload.entry.ref == ref][0]
|
|
87
125
|
|
|
88
126
|
def close(self):
|
|
89
127
|
for upload in self.uploads.values():
|
|
90
128
|
upload.close()
|
|
91
129
|
|
|
92
130
|
|
|
131
|
+
class ExternalUploadMeta(BaseModel):
|
|
132
|
+
"""Metadata returned by external upload presign functions.
|
|
133
|
+
|
|
134
|
+
The 'uploader' field is required and specifies the name of the client-side
|
|
135
|
+
JavaScript uploader (e.g., "S3", "GCS", "Azure").
|
|
136
|
+
|
|
137
|
+
Additional provider-specific fields (url, fields, etc.) can be added as needed.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
uploader: str # Required - name of client-side JS uploader
|
|
141
|
+
|
|
142
|
+
# Allow extra fields for provider-specific data (url, fields, etc.)
|
|
143
|
+
model_config = {"extra": "allow"}
|
|
144
|
+
|
|
145
|
+
|
|
93
146
|
class UploadConstraints(BaseModel):
|
|
94
147
|
max_file_size: int = 10 * 1024 * 1024 # 10MB
|
|
95
148
|
max_files: int = 10
|
|
@@ -105,6 +158,11 @@ class UploadConfig(BaseModel):
|
|
|
105
158
|
errors: list[ConstraintViolation] = Field(default_factory=list)
|
|
106
159
|
autoUpload: bool = False
|
|
107
160
|
constraints: UploadConstraints = Field(default_factory=UploadConstraints)
|
|
161
|
+
progress_callback: Optional[Callable[[UploadEntry, Any], Awaitable[None]]] = None
|
|
162
|
+
external_callback: Optional[Callable[[UploadEntry, Any], Awaitable[ExternalUploadMeta]]] = None
|
|
163
|
+
entry_complete_callback: Optional[
|
|
164
|
+
Callable[[UploadEntry, UploadResult, Any], Awaitable[None]]
|
|
165
|
+
] = None
|
|
108
166
|
|
|
109
167
|
uploads: ActiveUploads = Field(default_factory=ActiveUploads)
|
|
110
168
|
|
|
@@ -112,6 +170,11 @@ class UploadConfig(BaseModel):
|
|
|
112
170
|
def entries(self) -> list[UploadEntry]:
|
|
113
171
|
return list(self.entries_by_ref.values())
|
|
114
172
|
|
|
173
|
+
@property
|
|
174
|
+
def is_external(self) -> bool:
|
|
175
|
+
"""Returns True if this upload config uses external (direct-to-cloud) uploads"""
|
|
176
|
+
return self.external_callback is not None
|
|
177
|
+
|
|
115
178
|
def cancel_entry(self, ref: str):
|
|
116
179
|
del self.entries_by_ref[ref]
|
|
117
180
|
|
|
@@ -127,16 +190,15 @@ class UploadConfig(BaseModel):
|
|
|
127
190
|
self.entries_by_ref[entry.ref] = entry
|
|
128
191
|
if entry.size > self.constraints.max_file_size:
|
|
129
192
|
entry.valid = False
|
|
130
|
-
entry.errors.append(
|
|
131
|
-
ConstraintViolation(ref=entry.ref, code="too_large")
|
|
132
|
-
)
|
|
193
|
+
entry.errors.append(ConstraintViolation(ref=entry.ref, code="too_large"))
|
|
133
194
|
|
|
134
195
|
if len(self.entries_by_ref) > self.constraints.max_files:
|
|
135
196
|
self.errors.append(ConstraintViolation(ref=self.ref, code="too_many_files"))
|
|
136
197
|
|
|
137
198
|
def update_progress(self, ref: str, progress: int):
|
|
138
|
-
self.entries_by_ref
|
|
139
|
-
|
|
199
|
+
if ref in self.entries_by_ref:
|
|
200
|
+
self.entries_by_ref[ref].progress = progress
|
|
201
|
+
self.entries_by_ref[ref].done = progress == 100
|
|
140
202
|
|
|
141
203
|
@contextmanager
|
|
142
204
|
def consume_uploads(self) -> Generator[list["ActiveUpload"], None, None]:
|
|
@@ -152,6 +214,93 @@ class UploadConfig(BaseModel):
|
|
|
152
214
|
self.uploads = ActiveUploads()
|
|
153
215
|
self.entries_by_ref = {}
|
|
154
216
|
|
|
217
|
+
@contextmanager
|
|
218
|
+
def consume_upload_entry(
|
|
219
|
+
self, entry_ref: str
|
|
220
|
+
) -> Generator[Optional["ActiveUpload"], None, None]:
|
|
221
|
+
"""Consume a single upload entry by its ref"""
|
|
222
|
+
upload = None
|
|
223
|
+
join_ref = None
|
|
224
|
+
|
|
225
|
+
# Find the join_ref for this entry
|
|
226
|
+
for jr, active_upload in self.uploads.uploads.items():
|
|
227
|
+
if active_upload.entry.ref == entry_ref:
|
|
228
|
+
upload = active_upload
|
|
229
|
+
join_ref = jr
|
|
230
|
+
break
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
yield upload
|
|
234
|
+
finally:
|
|
235
|
+
if upload and join_ref:
|
|
236
|
+
try:
|
|
237
|
+
upload.close()
|
|
238
|
+
except Exception:
|
|
239
|
+
logger.warning("Error closing upload entry", exc_info=True)
|
|
240
|
+
|
|
241
|
+
# Remove only this specific upload
|
|
242
|
+
if join_ref in self.uploads.uploads:
|
|
243
|
+
del self.uploads.uploads[join_ref]
|
|
244
|
+
if entry_ref in self.entries_by_ref:
|
|
245
|
+
del self.entries_by_ref[entry_ref]
|
|
246
|
+
|
|
247
|
+
@contextmanager
|
|
248
|
+
def consume_external_upload(
|
|
249
|
+
self, entry_ref: str
|
|
250
|
+
) -> Generator[Optional["UploadEntry"], None, None]:
|
|
251
|
+
"""Consume a single external upload entry by its ref.
|
|
252
|
+
|
|
253
|
+
For external uploads (direct-to-cloud), this returns the UploadEntry containing
|
|
254
|
+
metadata about the uploaded file. The entry is automatically removed after the
|
|
255
|
+
context manager exits.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
entry_ref: The ref of the entry to consume
|
|
259
|
+
|
|
260
|
+
Yields:
|
|
261
|
+
UploadEntry if found, None otherwise
|
|
262
|
+
|
|
263
|
+
Raises:
|
|
264
|
+
ValueError: If called on a non-external upload config
|
|
265
|
+
"""
|
|
266
|
+
if not self.is_external:
|
|
267
|
+
raise ValueError(
|
|
268
|
+
"consume_external_upload() can only be called on external upload configs"
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
entry = self.entries_by_ref.get(entry_ref)
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
yield entry
|
|
275
|
+
finally:
|
|
276
|
+
if entry_ref in self.entries_by_ref:
|
|
277
|
+
del self.entries_by_ref[entry_ref]
|
|
278
|
+
|
|
279
|
+
@contextmanager
|
|
280
|
+
def consume_external_uploads(self) -> Generator[list["UploadEntry"], None, None]:
|
|
281
|
+
"""Consume all external upload entries and clean up.
|
|
282
|
+
|
|
283
|
+
For external uploads (direct-to-cloud), this returns the UploadEntry objects
|
|
284
|
+
containing metadata about the uploaded files. The entries are automatically
|
|
285
|
+
cleared after the context manager exits.
|
|
286
|
+
|
|
287
|
+
Yields:
|
|
288
|
+
List of UploadEntry objects
|
|
289
|
+
|
|
290
|
+
Raises:
|
|
291
|
+
ValueError: If called on a non-external upload config
|
|
292
|
+
"""
|
|
293
|
+
if not self.is_external:
|
|
294
|
+
raise ValueError(
|
|
295
|
+
"consume_external_uploads() can only be called on external upload configs"
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
upload_list = list(self.entries_by_ref.values())
|
|
300
|
+
yield upload_list
|
|
301
|
+
finally:
|
|
302
|
+
self.entries_by_ref = {}
|
|
303
|
+
|
|
155
304
|
def close(self):
|
|
156
305
|
self.uploads.close()
|
|
157
306
|
|
|
@@ -165,9 +314,22 @@ class UploadManager:
|
|
|
165
314
|
self.upload_config_join_refs = {}
|
|
166
315
|
|
|
167
316
|
def allow_upload(
|
|
168
|
-
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,
|
|
169
324
|
) -> UploadConfig:
|
|
170
|
-
config = UploadConfig(
|
|
325
|
+
config = UploadConfig(
|
|
326
|
+
name=upload_name,
|
|
327
|
+
constraints=constraints,
|
|
328
|
+
autoUpload=auto_upload,
|
|
329
|
+
progress_callback=progress,
|
|
330
|
+
external_callback=external,
|
|
331
|
+
entry_complete_callback=entry_complete,
|
|
332
|
+
)
|
|
171
333
|
self.upload_configs[upload_name] = config
|
|
172
334
|
return config
|
|
173
335
|
|
|
@@ -190,7 +352,78 @@ class UploadManager:
|
|
|
190
352
|
else:
|
|
191
353
|
logger.warning("Upload config not found for ref: %s", config.ref)
|
|
192
354
|
|
|
193
|
-
def
|
|
355
|
+
def _validate_constraints(
|
|
356
|
+
self, config: UploadConfig, proposed_entries: list[dict[str, Any]]
|
|
357
|
+
) -> list[ConstraintViolation]:
|
|
358
|
+
"""Validate proposed entries against upload constraints."""
|
|
359
|
+
errors = []
|
|
360
|
+
for entry in proposed_entries:
|
|
361
|
+
if entry["size"] > config.constraints.max_file_size:
|
|
362
|
+
errors.append(ConstraintViolation(ref=entry["ref"], code="too_large"))
|
|
363
|
+
|
|
364
|
+
if len(proposed_entries) > config.constraints.max_files:
|
|
365
|
+
errors.append(ConstraintViolation(ref=config.ref, code="too_many_files"))
|
|
366
|
+
|
|
367
|
+
return errors
|
|
368
|
+
|
|
369
|
+
async def _process_external_upload(
|
|
370
|
+
self, config: UploadConfig, proposed_entries: list[dict[str, Any]], context: Any
|
|
371
|
+
) -> dict[str, Any]:
|
|
372
|
+
"""Process external (direct-to-cloud) upload by calling presign function for each entry."""
|
|
373
|
+
entries_with_meta = {}
|
|
374
|
+
successfully_preflighted = [] # Track entries added to config for atomic cleanup
|
|
375
|
+
|
|
376
|
+
if not config.external_callback:
|
|
377
|
+
logger.error("external_callback is required for external uploads")
|
|
378
|
+
return {"error": [("config", "external_callback_missing")]}
|
|
379
|
+
|
|
380
|
+
for entry_data in proposed_entries:
|
|
381
|
+
# Create UploadEntry to pass to presign function
|
|
382
|
+
entry = UploadEntry(**entry_data)
|
|
383
|
+
entry.upload_config = config
|
|
384
|
+
|
|
385
|
+
try:
|
|
386
|
+
# Call user's presign function
|
|
387
|
+
meta: ExternalUploadMeta = await config.external_callback(entry, context)
|
|
388
|
+
|
|
389
|
+
# Store metadata and mark entry as preflighted
|
|
390
|
+
entry.meta = meta
|
|
391
|
+
entry.preflighted = True
|
|
392
|
+
config.entries_by_ref[entry.ref] = entry
|
|
393
|
+
successfully_preflighted.append(entry.ref) # Track for cleanup
|
|
394
|
+
|
|
395
|
+
# Build entry JSON with metadata merged at top level
|
|
396
|
+
entry_dict = entry.model_dump(exclude={"upload_config", "meta"})
|
|
397
|
+
entry_dict.update(meta.model_dump()) # Merge meta fields into entry
|
|
398
|
+
entries_with_meta[entry.ref] = entry_dict
|
|
399
|
+
|
|
400
|
+
except Exception as e:
|
|
401
|
+
logger.error(
|
|
402
|
+
f"Error calling presign function for entry {entry.ref}: {e}", exc_info=True
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
# Atomic cleanup: remove all entries added before this failure
|
|
406
|
+
for ref in successfully_preflighted:
|
|
407
|
+
config.entries_by_ref.pop(ref, None)
|
|
408
|
+
|
|
409
|
+
return {"error": [(entry.ref, "presign_error")]}
|
|
410
|
+
|
|
411
|
+
configJson = config.constraints.model_dump()
|
|
412
|
+
return {"config": configJson, "entries": entries_with_meta}
|
|
413
|
+
|
|
414
|
+
def _process_internal_upload(self, config: UploadConfig) -> dict[str, Any]:
|
|
415
|
+
"""Process internal (direct-to-server) upload."""
|
|
416
|
+
configJson = config.constraints.model_dump()
|
|
417
|
+
entryJson = {e.ref: e.model_dump(exclude={"upload_config"}) for e in config.entries}
|
|
418
|
+
return {"config": configJson, "entries": entryJson}
|
|
419
|
+
|
|
420
|
+
async def process_allow_upload(self, payload: dict[str, Any], context: Any) -> dict[str, Any]:
|
|
421
|
+
"""Process allow_upload request from client.
|
|
422
|
+
|
|
423
|
+
Validates constraints and either:
|
|
424
|
+
- For external uploads: calls presign function to generate upload metadata
|
|
425
|
+
- For internal uploads: returns standard config/entries response
|
|
426
|
+
"""
|
|
194
427
|
ref = payload["ref"]
|
|
195
428
|
config = self.config_for_ref(ref)
|
|
196
429
|
|
|
@@ -200,23 +433,16 @@ class UploadManager:
|
|
|
200
433
|
|
|
201
434
|
proposed_entries = payload["entries"]
|
|
202
435
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if entry["size"] > config.constraints.max_file_size:
|
|
206
|
-
errors.append(ConstraintViolation(ref=entry["ref"], code="too_large"))
|
|
207
|
-
|
|
208
|
-
if len(proposed_entries) > config.constraints.max_files:
|
|
209
|
-
errors.append(ConstraintViolation(ref=ref, code="too_many_files"))
|
|
210
|
-
|
|
436
|
+
# Validate constraints
|
|
437
|
+
errors = self._validate_constraints(config, proposed_entries)
|
|
211
438
|
if errors:
|
|
212
439
|
return {"error": [(e.ref, e.code) for e in errors]}
|
|
213
440
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
return {"config": configJson, "entries": entryJson}
|
|
441
|
+
# Handle external vs internal uploads
|
|
442
|
+
if config.is_external:
|
|
443
|
+
return await self._process_external_upload(config, proposed_entries, context)
|
|
444
|
+
else:
|
|
445
|
+
return self._process_internal_upload(config)
|
|
220
446
|
|
|
221
447
|
def add_upload(self, joinRef: str, payload: dict[str, Any]):
|
|
222
448
|
token = payload["token"]
|
|
@@ -232,33 +458,97 @@ class UploadManager:
|
|
|
232
458
|
config.uploads.add_chunk(joinRef, chunk)
|
|
233
459
|
pass
|
|
234
460
|
|
|
235
|
-
def update_progress(self, joinRef: str, payload: dict[str, Any]):
|
|
461
|
+
async def update_progress(self, joinRef: str, payload: dict[str, Any], socket):
|
|
236
462
|
upload_config_ref = payload["ref"]
|
|
237
463
|
entry_ref = payload["entry_ref"]
|
|
238
|
-
|
|
464
|
+
progress_data = payload["progress"]
|
|
239
465
|
|
|
240
466
|
config = self.config_for_ref(upload_config_ref)
|
|
241
|
-
if config:
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
467
|
+
if not config:
|
|
468
|
+
logger.warning(f"[update_progress] No config found for ref: {upload_config_ref}")
|
|
469
|
+
return
|
|
470
|
+
|
|
471
|
+
# Handle dict (error or completion)
|
|
472
|
+
if isinstance(progress_data, dict):
|
|
473
|
+
if progress_data.get("complete"):
|
|
474
|
+
entry = config.entries_by_ref.get(entry_ref)
|
|
475
|
+
if entry:
|
|
476
|
+
entry.progress = 100
|
|
477
|
+
entry.done = True
|
|
478
|
+
|
|
479
|
+
# Call entry_complete callback with success result
|
|
480
|
+
if config.entry_complete_callback:
|
|
481
|
+
result = UploadSuccessWithData(data=progress_data)
|
|
482
|
+
await config.entry_complete_callback(entry, result, socket)
|
|
483
|
+
return
|
|
484
|
+
|
|
485
|
+
# Handle error case: {error: "reason"}
|
|
486
|
+
error_msg = progress_data.get("error", "Upload failed")
|
|
487
|
+
logger.warning(f"Upload error for entry {entry_ref}: {error_msg}")
|
|
488
|
+
|
|
489
|
+
if entry_ref in config.entries_by_ref:
|
|
490
|
+
entry = config.entries_by_ref[entry_ref]
|
|
491
|
+
entry.valid = False
|
|
492
|
+
entry.done = True
|
|
493
|
+
entry.errors.append(ConstraintViolation(ref=entry_ref, code="upload_failed"))
|
|
494
|
+
|
|
495
|
+
# Call entry_complete callback with failure result
|
|
496
|
+
if config.entry_complete_callback:
|
|
497
|
+
result = UploadFailure(error=error_msg)
|
|
498
|
+
await config.entry_complete_callback(entry, result, socket)
|
|
499
|
+
return
|
|
500
|
+
|
|
501
|
+
# Handle progress number
|
|
502
|
+
progress = int(progress_data)
|
|
503
|
+
config.update_progress(entry_ref, progress)
|
|
504
|
+
|
|
505
|
+
# Fire entry_complete callback on 100
|
|
506
|
+
if progress == 100:
|
|
507
|
+
entry = config.entries_by_ref.get(entry_ref)
|
|
508
|
+
if entry and config.entry_complete_callback:
|
|
509
|
+
result = UploadSuccess()
|
|
510
|
+
await config.entry_complete_callback(entry, result, socket)
|
|
511
|
+
|
|
512
|
+
# Cleanup for internal uploads only (external uploads never populate upload_config_join_refs)
|
|
513
|
+
if not config.is_external:
|
|
514
|
+
try:
|
|
515
|
+
joinRef_to_remove = config.uploads.join_ref_for_entry(entry_ref)
|
|
516
|
+
if joinRef_to_remove in self.upload_config_join_refs:
|
|
517
|
+
del self.upload_config_join_refs[joinRef_to_remove]
|
|
518
|
+
except (IndexError, KeyError):
|
|
519
|
+
# Entry might have already been consumed and removed
|
|
520
|
+
pass
|
|
247
521
|
|
|
248
522
|
def no_progress(self, joinRef) -> bool:
|
|
249
523
|
config = self.upload_config_join_refs[joinRef]
|
|
250
524
|
return config.uploads.no_progress()
|
|
251
525
|
|
|
526
|
+
async def trigger_progress_callback_if_exists(self, payload: dict[str, Any], socket):
|
|
527
|
+
"""Trigger progress callback if one exists for this upload config"""
|
|
528
|
+
upload_config_ref = payload["ref"]
|
|
529
|
+
config = self.config_for_ref(upload_config_ref)
|
|
530
|
+
|
|
531
|
+
if config and config.progress_callback:
|
|
532
|
+
entry_ref = payload["entry_ref"]
|
|
533
|
+
if entry_ref in config.entries_by_ref:
|
|
534
|
+
entry = config.entries_by_ref[entry_ref]
|
|
535
|
+
progress_data = payload["progress"]
|
|
536
|
+
|
|
537
|
+
# Update entry progress before calling callback
|
|
538
|
+
if isinstance(progress_data, int):
|
|
539
|
+
entry.progress = progress_data
|
|
540
|
+
entry.done = progress_data == 100
|
|
541
|
+
# For dict (error or completion), don't update entry.progress here
|
|
542
|
+
# (will be handled in update_progress or completion handler)
|
|
543
|
+
|
|
544
|
+
await config.progress_callback(entry, socket)
|
|
545
|
+
|
|
252
546
|
def close(self):
|
|
253
547
|
for config in self.upload_configs.values():
|
|
254
548
|
config.close()
|
|
255
549
|
self.upload_configs = {}
|
|
256
550
|
|
|
257
551
|
|
|
258
|
-
from markupsafe import Markup
|
|
259
|
-
from pyview.vendor.ibis import filters
|
|
260
|
-
|
|
261
|
-
|
|
262
552
|
@filters.register
|
|
263
553
|
def live_file_input(config: Optional[UploadConfig]) -> Markup:
|
|
264
554
|
if not config:
|
|
@@ -266,12 +556,11 @@ def live_file_input(config: Optional[UploadConfig]) -> Markup:
|
|
|
266
556
|
|
|
267
557
|
active_refs = ",".join([entry.ref for entry in config.entries])
|
|
268
558
|
done_refs = ",".join([entry.ref for entry in config.entries if entry.done])
|
|
269
|
-
preflighted_refs = ",".join(
|
|
270
|
-
[entry.ref for entry in config.entries if entry.preflighted]
|
|
271
|
-
)
|
|
559
|
+
preflighted_refs = ",".join([entry.ref for entry in config.entries if entry.preflighted])
|
|
272
560
|
accepted = ",".join(config.constraints.accept)
|
|
273
561
|
accept = f'accept="{accepted}"' if accepted else ""
|
|
274
562
|
multiple = "multiple" if config.constraints.max_files > 1 else ""
|
|
563
|
+
auto_upload = "data-phx-auto-upload" if config.autoUpload else ""
|
|
275
564
|
|
|
276
565
|
return Markup(
|
|
277
566
|
f"""
|
|
@@ -281,7 +570,7 @@ def live_file_input(config: Optional[UploadConfig]) -> Markup:
|
|
|
281
570
|
data-phx-done-refs="{done_refs}"
|
|
282
571
|
data-phx-preflighted-refs="{preflighted_refs}"
|
|
283
572
|
data-phx-update="ignore" phx-hook="Phoenix.LiveFileUpload"
|
|
284
|
-
{accept} {multiple}>
|
|
573
|
+
{accept} {multiple} {auto_upload}>
|
|
285
574
|
</input>
|
|
286
575
|
"""
|
|
287
576
|
)
|
|
@@ -3,19 +3,19 @@
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import logging
|
|
5
5
|
import threading
|
|
6
|
-
from typing import Any, Callable,
|
|
6
|
+
from typing import Any, Callable, Iterable
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
class PubSubHub:
|
|
10
10
|
def __init__(self):
|
|
11
11
|
self.__lock = threading.Lock()
|
|
12
12
|
self.__async_lock = asyncio.Lock()
|
|
13
|
-
self.__subscribers:
|
|
14
|
-
self.__topic_subscribers:
|
|
15
|
-
str,
|
|
13
|
+
self.__subscribers: dict[str, Callable] = {} # key: session_id, value: handler
|
|
14
|
+
self.__topic_subscribers: dict[
|
|
15
|
+
str, dict[str, Callable]
|
|
16
16
|
] = {} # key: topic, value: dict[session_id, handler]
|
|
17
|
-
self.__subscriber_topics:
|
|
18
|
-
str,
|
|
17
|
+
self.__subscriber_topics: dict[
|
|
18
|
+
str, dict[str, Callable]
|
|
19
19
|
] = {} # key: session_id, value: dict[topic, handler]
|
|
20
20
|
|
|
21
21
|
def send_all(self, message: Any):
|
|
@@ -59,21 +59,15 @@ class PubSubHub:
|
|
|
59
59
|
await self.__send_async(handler, [message])
|
|
60
60
|
|
|
61
61
|
def send_others_on_topic(self, except_session_id: str, topic: str, message: Any):
|
|
62
|
-
logging.debug(
|
|
63
|
-
f"pubsub.send_others_on_topic({except_session_id}, {topic}, {message})"
|
|
64
|
-
)
|
|
62
|
+
logging.debug(f"pubsub.send_others_on_topic({except_session_id}, {topic}, {message})")
|
|
65
63
|
with self.__lock:
|
|
66
64
|
if topic in self.__topic_subscribers:
|
|
67
65
|
for session_id, handler in self.__topic_subscribers[topic].items():
|
|
68
66
|
if except_session_id != session_id:
|
|
69
67
|
self.__send(handler, [topic, message])
|
|
70
68
|
|
|
71
|
-
async def send_others_on_topic_async(
|
|
72
|
-
|
|
73
|
-
):
|
|
74
|
-
logging.debug(
|
|
75
|
-
f"pubsub.send_others_on_topic_async({except_session_id}, {topic}, {message})"
|
|
76
|
-
)
|
|
69
|
+
async def send_others_on_topic_async(self, except_session_id: str, topic: str, message: Any):
|
|
70
|
+
logging.debug(f"pubsub.send_others_on_topic_async({except_session_id}, {topic}, {message})")
|
|
77
71
|
async with self.__async_lock:
|
|
78
72
|
if topic in self.__topic_subscribers:
|
|
79
73
|
for session_id, handler in self.__topic_subscribers[topic].items():
|
|
@@ -205,9 +199,7 @@ class PubSub:
|
|
|
205
199
|
self.__pubsub.send_others_on_topic(self.__session_id, topic, message)
|
|
206
200
|
|
|
207
201
|
async def send_others_on_topic_async(self, topic: str, message: Any):
|
|
208
|
-
await self.__pubsub.send_others_on_topic_async(
|
|
209
|
-
self.__session_id, topic, message
|
|
210
|
-
)
|
|
202
|
+
await self.__pubsub.send_others_on_topic_async(self.__session_id, topic, message)
|
|
211
203
|
|
|
212
204
|
def subscribe(self, handler: Callable):
|
|
213
205
|
self.__pubsub.subscribe(self.__session_id, handler)
|
pyview/vendor/ibis/__init__.py
CHANGED
|
@@ -1,15 +1,11 @@
|
|
|
1
|
-
from . import filters
|
|
2
|
-
from . import nodes
|
|
3
|
-
from . import loaders
|
|
4
|
-
from . import errors
|
|
5
|
-
from . import compiler
|
|
6
|
-
|
|
1
|
+
from . import compiler, errors, filters, loaders, nodes
|
|
7
2
|
from .template import Template
|
|
8
3
|
|
|
9
|
-
|
|
10
4
|
# Library version.
|
|
11
5
|
__version__ = "3.2.1"
|
|
12
6
|
|
|
7
|
+
__all__ = ["compiler", "errors", "filters", "loaders", "nodes", "Template"]
|
|
8
|
+
|
|
13
9
|
|
|
14
10
|
# Assign a template-loading callable here to enable the {% include %} and {% extends %} tags.
|
|
15
11
|
# The callable should accept a single string argument and either return an instance of the
|