pyview-web 0.3.0__py3-none-any.whl → 0.8.0a2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. pyview/__init__.py +16 -6
  2. pyview/assets/js/app.js +1 -0
  3. pyview/assets/js/uploaders.js +221 -0
  4. pyview/assets/package-lock.json +16 -14
  5. pyview/assets/package.json +2 -2
  6. pyview/async_stream_runner.py +2 -1
  7. pyview/auth/__init__.py +3 -1
  8. pyview/auth/provider.py +6 -6
  9. pyview/auth/required.py +7 -10
  10. pyview/binding/__init__.py +47 -0
  11. pyview/binding/binder.py +134 -0
  12. pyview/binding/context.py +33 -0
  13. pyview/binding/converters.py +191 -0
  14. pyview/binding/helpers.py +78 -0
  15. pyview/binding/injectables.py +119 -0
  16. pyview/binding/params.py +105 -0
  17. pyview/binding/result.py +32 -0
  18. pyview/changesets/__init__.py +2 -0
  19. pyview/changesets/changesets.py +8 -3
  20. pyview/cli/commands/create_view.py +4 -3
  21. pyview/cli/main.py +1 -1
  22. pyview/components/__init__.py +72 -0
  23. pyview/components/base.py +212 -0
  24. pyview/components/lifecycle.py +85 -0
  25. pyview/components/manager.py +366 -0
  26. pyview/components/renderer.py +14 -0
  27. pyview/components/slots.py +73 -0
  28. pyview/csrf.py +4 -2
  29. pyview/events/AutoEventDispatch.py +98 -0
  30. pyview/events/BaseEventHandler.py +51 -8
  31. pyview/events/__init__.py +2 -1
  32. pyview/instrumentation/__init__.py +3 -3
  33. pyview/instrumentation/interfaces.py +57 -33
  34. pyview/instrumentation/noop.py +21 -18
  35. pyview/js.py +20 -23
  36. pyview/live_routes.py +5 -3
  37. pyview/live_socket.py +167 -44
  38. pyview/live_view.py +24 -12
  39. pyview/meta.py +14 -2
  40. pyview/phx_message.py +7 -8
  41. pyview/playground/__init__.py +10 -0
  42. pyview/playground/builder.py +118 -0
  43. pyview/playground/favicon.py +39 -0
  44. pyview/pyview.py +54 -20
  45. pyview/session.py +2 -0
  46. pyview/static/assets/app.js +2088 -806
  47. pyview/static/assets/uploaders.js +221 -0
  48. pyview/stream.py +308 -0
  49. pyview/template/__init__.py +11 -1
  50. pyview/template/live_template.py +12 -8
  51. pyview/template/live_view_template.py +338 -0
  52. pyview/template/render_diff.py +33 -7
  53. pyview/template/root_template.py +21 -9
  54. pyview/template/serializer.py +2 -5
  55. pyview/template/template_view.py +170 -0
  56. pyview/template/utils.py +3 -2
  57. pyview/uploads.py +344 -55
  58. pyview/vendor/flet/pubsub/__init__.py +3 -1
  59. pyview/vendor/flet/pubsub/pub_sub.py +10 -18
  60. pyview/vendor/ibis/__init__.py +3 -7
  61. pyview/vendor/ibis/compiler.py +25 -32
  62. pyview/vendor/ibis/context.py +13 -15
  63. pyview/vendor/ibis/errors.py +0 -6
  64. pyview/vendor/ibis/filters.py +70 -76
  65. pyview/vendor/ibis/loaders.py +6 -7
  66. pyview/vendor/ibis/nodes.py +40 -42
  67. pyview/vendor/ibis/template.py +4 -5
  68. pyview/vendor/ibis/tree.py +62 -3
  69. pyview/vendor/ibis/utils.py +14 -15
  70. pyview/ws_handler.py +116 -86
  71. {pyview_web-0.3.0.dist-info → pyview_web-0.8.0a2.dist-info}/METADATA +39 -33
  72. pyview_web-0.8.0a2.dist-info/RECORD +80 -0
  73. pyview_web-0.8.0a2.dist-info/WHEEL +4 -0
  74. pyview_web-0.8.0a2.dist-info/entry_points.txt +3 -0
  75. pyview_web-0.3.0.dist-info/LICENSE +0 -21
  76. pyview_web-0.3.0.dist-info/RECORD +0 -58
  77. pyview_web-0.3.0.dist-info/WHEEL +0 -4
  78. pyview_web-0.3.0.dist-info/entry_points.txt +0 -3
pyview/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
- return "Too many files"
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
- default_factory=lambda: int(datetime.datetime.now().timestamp())
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[ref].progress = progress
139
- self.entries_by_ref[ref].done = progress == 100
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, upload_name: str, constraints: UploadConstraints
317
+ self,
318
+ upload_name: str,
319
+ constraints: UploadConstraints,
320
+ auto_upload: bool = False,
321
+ progress: Optional[Callable] = None,
322
+ external: Optional[Callable] = None,
323
+ entry_complete: Optional[Callable] = None,
169
324
  ) -> UploadConfig:
170
- config = UploadConfig(name=upload_name, constraints=constraints)
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 process_allow_upload(self, payload: dict[str, Any]) -> dict[str, Any]:
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
- errors = []
204
- for entry in proposed_entries:
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
- configJson = config.constraints.model_dump()
215
- entryJson = {
216
- e.ref: e.model_dump(exclude={"upload_config"}) for e in config.entries
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
- progress = int(payload["progress"])
464
+ progress_data = payload["progress"]
239
465
 
240
466
  config = self.config_for_ref(upload_config_ref)
241
- if config:
242
- config.update_progress(entry_ref, progress)
243
-
244
- if progress == 100:
245
- joinRef = config.uploads.join_ref_for_entry(entry_ref)
246
- del self.upload_config_join_refs[joinRef]
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
  )
@@ -1 +1,3 @@
1
- from .pub_sub import PubSubHub, PubSub
1
+ from .pub_sub import PubSub, PubSubHub
2
+
3
+ __all__ = ["PubSub", "PubSubHub"]
@@ -3,19 +3,19 @@
3
3
  import asyncio
4
4
  import logging
5
5
  import threading
6
- from typing import Any, Callable, Dict, Iterable
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: Dict[str, Callable] = {} # key: session_id, value: handler
14
- self.__topic_subscribers: Dict[
15
- str, Dict[str, Callable]
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: Dict[
18
- str, Dict[str, Callable]
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
- self, except_session_id: str, topic: str, message: Any
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)
@@ -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