pulse-framework 0.1.62__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 (126) hide show
  1. pulse/__init__.py +1493 -0
  2. pulse/_examples.py +29 -0
  3. pulse/app.py +1086 -0
  4. pulse/channel.py +607 -0
  5. pulse/cli/__init__.py +0 -0
  6. pulse/cli/cmd.py +575 -0
  7. pulse/cli/dependencies.py +181 -0
  8. pulse/cli/folder_lock.py +134 -0
  9. pulse/cli/helpers.py +271 -0
  10. pulse/cli/logging.py +102 -0
  11. pulse/cli/models.py +35 -0
  12. pulse/cli/packages.py +262 -0
  13. pulse/cli/processes.py +292 -0
  14. pulse/cli/secrets.py +39 -0
  15. pulse/cli/uvicorn_log_config.py +87 -0
  16. pulse/code_analysis.py +38 -0
  17. pulse/codegen/__init__.py +0 -0
  18. pulse/codegen/codegen.py +359 -0
  19. pulse/codegen/templates/__init__.py +0 -0
  20. pulse/codegen/templates/layout.py +106 -0
  21. pulse/codegen/templates/route.py +345 -0
  22. pulse/codegen/templates/routes_ts.py +42 -0
  23. pulse/codegen/utils.py +20 -0
  24. pulse/component.py +237 -0
  25. pulse/components/__init__.py +0 -0
  26. pulse/components/for_.py +83 -0
  27. pulse/components/if_.py +86 -0
  28. pulse/components/react_router.py +94 -0
  29. pulse/context.py +108 -0
  30. pulse/cookies.py +322 -0
  31. pulse/decorators.py +344 -0
  32. pulse/dom/__init__.py +0 -0
  33. pulse/dom/elements.py +1024 -0
  34. pulse/dom/events.py +445 -0
  35. pulse/dom/props.py +1250 -0
  36. pulse/dom/svg.py +0 -0
  37. pulse/dom/tags.py +328 -0
  38. pulse/dom/tags.pyi +480 -0
  39. pulse/env.py +178 -0
  40. pulse/form.py +538 -0
  41. pulse/helpers.py +541 -0
  42. pulse/hooks/__init__.py +0 -0
  43. pulse/hooks/core.py +452 -0
  44. pulse/hooks/effects.py +88 -0
  45. pulse/hooks/init.py +668 -0
  46. pulse/hooks/runtime.py +464 -0
  47. pulse/hooks/setup.py +254 -0
  48. pulse/hooks/stable.py +138 -0
  49. pulse/hooks/state.py +192 -0
  50. pulse/js/__init__.py +125 -0
  51. pulse/js/__init__.pyi +115 -0
  52. pulse/js/_types.py +299 -0
  53. pulse/js/array.py +339 -0
  54. pulse/js/console.py +50 -0
  55. pulse/js/date.py +119 -0
  56. pulse/js/document.py +145 -0
  57. pulse/js/error.py +140 -0
  58. pulse/js/json.py +66 -0
  59. pulse/js/map.py +97 -0
  60. pulse/js/math.py +69 -0
  61. pulse/js/navigator.py +79 -0
  62. pulse/js/number.py +57 -0
  63. pulse/js/obj.py +81 -0
  64. pulse/js/object.py +172 -0
  65. pulse/js/promise.py +172 -0
  66. pulse/js/pulse.py +115 -0
  67. pulse/js/react.py +495 -0
  68. pulse/js/regexp.py +57 -0
  69. pulse/js/set.py +124 -0
  70. pulse/js/string.py +38 -0
  71. pulse/js/weakmap.py +53 -0
  72. pulse/js/weakset.py +48 -0
  73. pulse/js/window.py +205 -0
  74. pulse/messages.py +202 -0
  75. pulse/middleware.py +471 -0
  76. pulse/plugin.py +96 -0
  77. pulse/proxy.py +242 -0
  78. pulse/py.typed +0 -0
  79. pulse/queries/__init__.py +0 -0
  80. pulse/queries/client.py +609 -0
  81. pulse/queries/common.py +101 -0
  82. pulse/queries/effect.py +55 -0
  83. pulse/queries/infinite_query.py +1418 -0
  84. pulse/queries/mutation.py +295 -0
  85. pulse/queries/protocol.py +136 -0
  86. pulse/queries/query.py +1314 -0
  87. pulse/queries/store.py +120 -0
  88. pulse/react_component.py +88 -0
  89. pulse/reactive.py +1208 -0
  90. pulse/reactive_extensions.py +1172 -0
  91. pulse/render_session.py +768 -0
  92. pulse/renderer.py +584 -0
  93. pulse/request.py +205 -0
  94. pulse/routing.py +598 -0
  95. pulse/serializer.py +279 -0
  96. pulse/state.py +556 -0
  97. pulse/test_helpers.py +15 -0
  98. pulse/transpiler/__init__.py +111 -0
  99. pulse/transpiler/assets.py +81 -0
  100. pulse/transpiler/builtins.py +1029 -0
  101. pulse/transpiler/dynamic_import.py +130 -0
  102. pulse/transpiler/emit_context.py +49 -0
  103. pulse/transpiler/errors.py +96 -0
  104. pulse/transpiler/function.py +611 -0
  105. pulse/transpiler/id.py +18 -0
  106. pulse/transpiler/imports.py +341 -0
  107. pulse/transpiler/js_module.py +336 -0
  108. pulse/transpiler/modules/__init__.py +33 -0
  109. pulse/transpiler/modules/asyncio.py +57 -0
  110. pulse/transpiler/modules/json.py +24 -0
  111. pulse/transpiler/modules/math.py +265 -0
  112. pulse/transpiler/modules/pulse/__init__.py +5 -0
  113. pulse/transpiler/modules/pulse/tags.py +250 -0
  114. pulse/transpiler/modules/typing.py +63 -0
  115. pulse/transpiler/nodes.py +1987 -0
  116. pulse/transpiler/py_module.py +135 -0
  117. pulse/transpiler/transpiler.py +1100 -0
  118. pulse/transpiler/vdom.py +256 -0
  119. pulse/types/__init__.py +0 -0
  120. pulse/types/event_handler.py +50 -0
  121. pulse/user_session.py +386 -0
  122. pulse/version.py +69 -0
  123. pulse_framework-0.1.62.dist-info/METADATA +198 -0
  124. pulse_framework-0.1.62.dist-info/RECORD +126 -0
  125. pulse_framework-0.1.62.dist-info/WHEEL +4 -0
  126. pulse_framework-0.1.62.dist-info/entry_points.txt +3 -0
pulse/form.py ADDED
@@ -0,0 +1,538 @@
1
+ import json
2
+ import uuid
3
+ from collections.abc import Awaitable, Callable
4
+ from dataclasses import dataclass
5
+ from typing import (
6
+ TYPE_CHECKING,
7
+ Never,
8
+ TypedDict,
9
+ Unpack,
10
+ override,
11
+ )
12
+
13
+ from fastapi import HTTPException, Request, Response
14
+ from starlette.datastructures import FormData as StarletteFormData
15
+ from starlette.datastructures import UploadFile
16
+
17
+ from pulse.context import PulseContext
18
+ from pulse.dom.props import HTMLFormProps
19
+ from pulse.helpers import Disposable, call_flexible, maybe_await
20
+ from pulse.hooks.core import HOOK_CONTEXT, HookMetadata, HookState, hooks
21
+ from pulse.hooks.runtime import server_address
22
+ from pulse.hooks.stable import stable
23
+ from pulse.react_component import react_component
24
+ from pulse.reactive import Signal
25
+ from pulse.serializer import deserialize
26
+ from pulse.transpiler.imports import Import
27
+ from pulse.transpiler.nodes import Node
28
+ from pulse.types.event_handler import EventHandler1
29
+
30
+ if TYPE_CHECKING:
31
+ from pulse.render_session import RenderSession
32
+ from pulse.user_session import UserSession
33
+
34
+
35
+ __all__ = [
36
+ "Form",
37
+ "ManualForm",
38
+ "FormData",
39
+ "FormValue",
40
+ "UploadFile",
41
+ "FormRegistry",
42
+ "FormStorage",
43
+ "internal_forms_hook",
44
+ ]
45
+
46
+ FormValue = str | UploadFile
47
+ """Individual form field value: ``str | UploadFile``."""
48
+
49
+ FormData = dict[str, FormValue | list[FormValue]]
50
+ """Parsed form submission data.
51
+
52
+ Values are either single or multiple (for repeated field names).
53
+ Type alias for ``dict[str, FormValue | list[FormValue]]``.
54
+ """
55
+
56
+
57
+ @react_component(Import("PulseForm", "pulse-ui-client"))
58
+ def client_form_component(
59
+ *children: Node,
60
+ key: str | None = None,
61
+ **props: Unpack[HTMLFormProps],
62
+ ): ...
63
+
64
+
65
+ @dataclass
66
+ class FormRegistration:
67
+ """Internal registration info for a form.
68
+
69
+ Attributes:
70
+ id: Unique form identifier.
71
+ render_id: Associated render session ID.
72
+ route_path: Route path this form is bound to.
73
+ session_id: Associated user session ID.
74
+ on_submit: Async callback for form submission.
75
+ """
76
+
77
+ id: str
78
+ render_id: str
79
+ route_path: str
80
+ session_id: str
81
+ on_submit: Callable[[FormData], Awaitable[None]]
82
+
83
+
84
+ class FormRegistry(Disposable):
85
+ """Internal class managing form registrations.
86
+
87
+ Not typically used directly. Forms are registered automatically via
88
+ ``ps.Form`` or ``ManualForm``.
89
+ """
90
+
91
+ def __init__(self, render: "RenderSession") -> None:
92
+ self._render: "RenderSession" = render
93
+ self._handlers: dict[str, FormRegistration] = {}
94
+
95
+ def register(
96
+ self,
97
+ render_id: str,
98
+ route_id: str,
99
+ session_id: str,
100
+ on_submit: Callable[[FormData], Awaitable[None]],
101
+ ) -> FormRegistration:
102
+ """Register a form handler.
103
+
104
+ Args:
105
+ render_id: Render session ID.
106
+ route_id: Route path.
107
+ session_id: User session ID.
108
+ on_submit: Async callback for form submission.
109
+
110
+ Returns:
111
+ FormRegistration with generated form ID.
112
+ """
113
+ registration = FormRegistration(
114
+ uuid.uuid4().hex,
115
+ render_id=render_id,
116
+ route_path=route_id,
117
+ session_id=session_id,
118
+ on_submit=on_submit,
119
+ )
120
+ self._handlers[registration.id] = registration
121
+ return registration
122
+
123
+ def unregister(self, form_id: str) -> None:
124
+ """Unregister a form handler.
125
+
126
+ Args:
127
+ form_id: The form ID to unregister.
128
+ """
129
+ self._handlers.pop(form_id, None)
130
+
131
+ @override
132
+ def dispose(self) -> None:
133
+ """Clean up all registered forms."""
134
+ self._handlers.clear()
135
+
136
+ async def handle_submit(
137
+ self,
138
+ form_id: str,
139
+ request: Request,
140
+ session: "UserSession",
141
+ ) -> Response:
142
+ """Handle incoming form submission.
143
+
144
+ Args:
145
+ form_id: The form ID being submitted.
146
+ request: The HTTP request.
147
+ session: The user session.
148
+
149
+ Returns:
150
+ HTTP response (204 on success).
151
+
152
+ Raises:
153
+ HTTPException: If form not found (404), session mismatch (403),
154
+ or route unmounted (410).
155
+ """
156
+ registration = self._handlers.get(form_id)
157
+ if registration is None:
158
+ raise HTTPException(status_code=404, detail="Unknown form submission")
159
+
160
+ if registration.session_id != session.sid:
161
+ raise HTTPException(
162
+ status_code=403, detail="Form does not belong to this session"
163
+ )
164
+
165
+ raw_form = await request.form()
166
+ data = normalize_form_data(raw_form)
167
+
168
+ # Deserialize complex data from __data__ field if present
169
+ if "__data__" in data:
170
+ data_value = data["__data__"]
171
+ if isinstance(data_value, str):
172
+ serialized_data = json.loads(data_value)
173
+ deserialized_data = deserialize(serialized_data)
174
+ # Merge deserialized data into form data, excluding __data__
175
+ for key, value in deserialized_data.items():
176
+ if key != "__data__":
177
+ data[key] = value
178
+ # Remove the __data__ field
179
+ del data["__data__"]
180
+
181
+ try:
182
+ mount = self._render.get_route_mount(registration.route_path)
183
+ except ValueError as exc:
184
+ self.unregister(form_id)
185
+ raise HTTPException(
186
+ status_code=410,
187
+ detail="Form route is no longer mounted",
188
+ ) from exc
189
+
190
+ with PulseContext.update(render=self._render, route=mount.route):
191
+ await call_flexible(registration.on_submit, data)
192
+
193
+ return Response(status_code=204)
194
+
195
+
196
+ def normalize_form_data(raw: StarletteFormData) -> FormData:
197
+ """Convert Starlette FormData to normalized FormData dict.
198
+
199
+ Handles multiple values for the same key and filters out empty file uploads.
200
+
201
+ Args:
202
+ raw: Starlette FormData from request.form().
203
+
204
+ Returns:
205
+ Normalized FormData dictionary.
206
+ """
207
+ normalized: FormData = {}
208
+ for key, value in raw.multi_items():
209
+ item: FormValue
210
+ if isinstance(value, UploadFile):
211
+ # Form submission tends to produce empty UploadFile objects for
212
+ # empty file inputs
213
+ if not value.filename and not value.size:
214
+ continue
215
+ item = value
216
+ else:
217
+ item = str(value)
218
+
219
+ existing = normalized.get(key)
220
+ if existing is None:
221
+ normalized[key] = item
222
+ elif isinstance(existing, list):
223
+ existing.append(item)
224
+ else:
225
+ normalized[key] = [existing, item]
226
+
227
+ return normalized
228
+
229
+
230
+ class PulseFormProps(HTMLFormProps, total=False):
231
+ """Form props that exclude action, method, encType, and onSubmit.
232
+
233
+ These props are auto-generated by Pulse for form handling.
234
+ """
235
+
236
+ action: Never # pyright: ignore[reportIncompatibleVariableOverride]
237
+ method: Never # pyright: ignore[reportIncompatibleVariableOverride]
238
+ encType: Never # pyright: ignore[reportIncompatibleVariableOverride]
239
+ onSubmit: Never # pyright: ignore[reportIncompatibleVariableOverride]
240
+
241
+
242
+ def Form(
243
+ *children: Node,
244
+ key: str,
245
+ onSubmit: EventHandler1[FormData] | None = None,
246
+ **props: Unpack[PulseFormProps], # pyright: ignore[reportGeneralTypeIssues]
247
+ ) -> Node:
248
+ """Server-registered HTML form component.
249
+
250
+ Automatically wires up form submission to a Python handler. Uses
251
+ ``multipart/form-data`` encoding to support file uploads.
252
+
253
+ Args:
254
+ *children: Form content (inputs, buttons, etc.).
255
+ key: Unique form identifier (required, non-empty string).
256
+ onSubmit: Submit handler receiving parsed FormData.
257
+ **props: Standard HTML form props (except action, method, encType, onSubmit).
258
+
259
+ Returns:
260
+ Form node.
261
+
262
+ Raises:
263
+ ValueError: If key is empty or onSubmit is not callable.
264
+ RuntimeError: If called outside a component render.
265
+
266
+ Example:
267
+
268
+ ```python
269
+ async def handle_submit(data: ps.FormData):
270
+ name = data.get("name") # str
271
+ file = data.get("avatar") # UploadFile
272
+ await save_user(name, file)
273
+
274
+ def my_form():
275
+ return ps.Form(
276
+ m.TextInput(name="name", label="Name"),
277
+ m.FileInput(name="avatar", label="Avatar"),
278
+ m.Button("Submit", type="submit"),
279
+ key="user-form",
280
+ onSubmit=handle_submit,
281
+ )
282
+ ```
283
+
284
+ Note:
285
+ - ``key`` must be unique within the render.
286
+ - Cannot override ``action``, ``method``, ``encType``, or ``onSubmit`` via props.
287
+ - Handler receives parsed form data as a ``FormData`` dict.
288
+ """
289
+ if not isinstance(key, str) or not key:
290
+ raise ValueError("ps.Form requires a non-empty string key")
291
+ if not callable(onSubmit):
292
+ raise ValueError("ps.Form requires an onSubmit callable")
293
+ if "action" in props:
294
+ raise ValueError("ps.Form does not allow overriding the form action")
295
+
296
+ hook_state = HOOK_CONTEXT.get()
297
+ if hook_state is None:
298
+ raise RuntimeError("ps.Form can only be used within a component render")
299
+
300
+ handler = stable(f"form:{key}", onSubmit)
301
+ storage = internal_forms_hook()
302
+ manual = storage.register(
303
+ key,
304
+ lambda: ManualForm(handler),
305
+ )
306
+
307
+ return manual(*children, key=key, **props)
308
+
309
+
310
+ class GeneratedFormProps(TypedDict):
311
+ """Form props generated by ``ManualForm.props()``.
312
+
313
+ Attributes:
314
+ action: Form submission URL.
315
+ method: HTTP method ("POST").
316
+ encType: Encoding type ("multipart/form-data").
317
+ onSubmit: Submission trigger callback.
318
+ """
319
+
320
+ action: str
321
+ method: str
322
+ encType: str
323
+ onSubmit: Callable[[], None]
324
+
325
+
326
+ class ManualForm(Disposable):
327
+ """Low-level form handler for custom form implementations.
328
+
329
+ Use when you need more control over form rendering than ``ps.Form`` provides.
330
+
331
+ Attributes:
332
+ is_submitting: Whether the form is currently submitting.
333
+ registration: Form registration info (raises if disposed).
334
+
335
+ Example:
336
+
337
+ ```python
338
+ def custom_form():
339
+ manual = ManualForm(on_submit=handle_data)
340
+
341
+ # Option 1: Render directly
342
+ return manual(
343
+ m.TextInput(name="field"),
344
+ m.Button("Submit", type="submit"),
345
+ key="my-form",
346
+ )
347
+
348
+ # Option 2: Use props manually
349
+ form_props = manual.props()
350
+ return m.form(
351
+ m.TextInput(name="field"),
352
+ m.Button("Submit", type="submit"),
353
+ **form_props,
354
+ )
355
+ ```
356
+ """
357
+
358
+ _submit_signal: Signal[bool]
359
+ _render: "RenderSession"
360
+ _registration: FormRegistration | None
361
+
362
+ def __init__(self, on_submit: EventHandler1[FormData] | None = None) -> None:
363
+ """Initialize a manual form handler.
364
+
365
+ Args:
366
+ on_submit: Optional submit handler receiving parsed FormData.
367
+
368
+ Raises:
369
+ RuntimeError: If called outside a render pass, route context,
370
+ or user session.
371
+ """
372
+ ctx = PulseContext.get()
373
+ render = ctx.render
374
+ route = ctx.route
375
+ session = ctx.session
376
+ if render is None:
377
+ raise RuntimeError("ManualForm must be created during a render pass")
378
+ if route is None:
379
+ raise RuntimeError("ManualForm requires an active route context")
380
+ if session is None:
381
+ raise RuntimeError("ManualForm requires an active user session")
382
+
383
+ self._submit_signal = Signal(False)
384
+ self._render = render
385
+ self._registration = render.forms.register(
386
+ session_id=session.sid,
387
+ render_id=render.id,
388
+ route_id=route.pulse_route.unique_path(),
389
+ on_submit=self.wrap_on_submit(on_submit),
390
+ )
391
+
392
+ def wrap_on_submit(self, on_submit: EventHandler1[FormData] | None):
393
+ async def on_submit_handler(data: FormData):
394
+ if on_submit:
395
+ await maybe_await(call_flexible(on_submit, data))
396
+ self._submit_signal.write(False)
397
+
398
+ return on_submit_handler
399
+
400
+ @property
401
+ def is_submitting(self) -> bool:
402
+ """Whether the form is currently submitting."""
403
+ return self._submit_signal.read()
404
+
405
+ @property
406
+ def registration(self) -> FormRegistration:
407
+ """Form registration info.
408
+
409
+ Raises:
410
+ ValueError: If the form has been disposed.
411
+ """
412
+ if self._registration is None:
413
+ raise ValueError("This form has been disposed")
414
+ return self._registration
415
+
416
+ def _start_submit(self) -> None:
417
+ self._submit_signal.write(True)
418
+
419
+ def props(self) -> GeneratedFormProps:
420
+ """Get form props for manual binding to a form element.
421
+
422
+ Returns:
423
+ Dict with action, method, encType, and onSubmit props.
424
+ """
425
+ prefix = PulseContext.get().app.api_prefix
426
+ return {
427
+ "action": f"{server_address()}{prefix}/forms/{self._render.id}/{self.registration.id}",
428
+ "method": "POST",
429
+ "encType": "multipart/form-data",
430
+ "onSubmit": self._start_submit,
431
+ }
432
+
433
+ def __call__(
434
+ self,
435
+ *children: Node,
436
+ key: str | None = None,
437
+ **props: Unpack[PulseFormProps],
438
+ ) -> Node:
439
+ """Render as a form element with children.
440
+
441
+ Args:
442
+ *children: Form content.
443
+ key: Optional element key.
444
+ **props: Additional form props.
445
+
446
+ Returns:
447
+ Form node with auto-generated submission props.
448
+ """
449
+ props.update(self.props()) # pyright: ignore[reportCallIssue, reportArgumentType]
450
+ return client_form_component(*children, key=key, **props)
451
+
452
+ @override
453
+ def dispose(self) -> None:
454
+ """Unregister the form and clean up."""
455
+ if self._registration is None:
456
+ return
457
+ self._render.forms.unregister(self._registration.id)
458
+ self._registration = None
459
+
460
+ def update(self, on_submit: EventHandler1[FormData] | None) -> None:
461
+ """Update the submit handler.
462
+
463
+ Args:
464
+ on_submit: New submit handler.
465
+ """
466
+ self.registration.on_submit = self.wrap_on_submit(on_submit)
467
+
468
+
469
+ class FormStorage(HookState):
470
+ """Internal hook state for managing form lifecycle within renders.
471
+
472
+ Not typically used directly. Manages form persistence and cleanup across
473
+ render cycles.
474
+ """
475
+
476
+ __slots__ = ("forms", "prev_forms", "render_mark") # pyright: ignore[reportUnannotatedClassAttribute]
477
+ render_mark: int
478
+
479
+ def __init__(self) -> None:
480
+ super().__init__()
481
+ self.forms: dict[str, ManualForm] = {}
482
+ self.prev_forms: dict[str, ManualForm] = {}
483
+ self.render_mark = 0
484
+
485
+ @override
486
+ def on_render_start(self, render_cycle: int) -> None:
487
+ super().on_render_start(render_cycle)
488
+ if self.render_mark == render_cycle:
489
+ return
490
+ self.prev_forms = self.forms
491
+ self.forms = {}
492
+ self.render_mark = render_cycle
493
+
494
+ @override
495
+ def on_render_end(self, render_cycle: int) -> None:
496
+ if not self.prev_forms:
497
+ return
498
+ for form in self.prev_forms.values():
499
+ form.dispose()
500
+ self.prev_forms.clear()
501
+
502
+ def register(
503
+ self,
504
+ key: str,
505
+ factory: Callable[[], ManualForm],
506
+ ) -> ManualForm:
507
+ if key in self.forms:
508
+ raise RuntimeError(
509
+ f"Duplicate ps.Form id '{key}' detected within the same render"
510
+ )
511
+ form = self.prev_forms.pop(key, None)
512
+ if form is None:
513
+ form = factory()
514
+ self.forms[key] = form
515
+ return form
516
+
517
+ @override
518
+ def dispose(self) -> None:
519
+ for form in self.forms.values():
520
+ form.dispose()
521
+ for form in self.prev_forms.values():
522
+ form.dispose()
523
+ self.forms.clear()
524
+ self.prev_forms.clear()
525
+
526
+
527
+ def _forms_factory():
528
+ return FormStorage()
529
+
530
+
531
+ internal_forms_hook = hooks.create(
532
+ "pulse:core.forms",
533
+ _forms_factory,
534
+ metadata=HookMetadata(
535
+ owner="pulse.core",
536
+ description="Internal storage for ps.Form manual forms",
537
+ ),
538
+ )