pulse-framework 0.1.44__py3-none-any.whl → 0.1.47__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 (80) hide show
  1. pulse/__init__.py +10 -24
  2. pulse/app.py +3 -25
  3. pulse/codegen/codegen.py +43 -88
  4. pulse/codegen/js.py +35 -5
  5. pulse/codegen/templates/route.py +341 -254
  6. pulse/form.py +1 -1
  7. pulse/helpers.py +40 -8
  8. pulse/hooks/core.py +2 -2
  9. pulse/hooks/effects.py +1 -1
  10. pulse/hooks/init.py +2 -1
  11. pulse/hooks/setup.py +1 -1
  12. pulse/hooks/stable.py +2 -2
  13. pulse/hooks/states.py +2 -2
  14. pulse/html/props.py +3 -2
  15. pulse/html/tags.py +135 -0
  16. pulse/html/tags.pyi +4 -0
  17. pulse/js/__init__.py +110 -0
  18. pulse/js/__init__.pyi +95 -0
  19. pulse/js/_types.py +297 -0
  20. pulse/js/array.py +253 -0
  21. pulse/js/console.py +47 -0
  22. pulse/js/date.py +113 -0
  23. pulse/js/document.py +138 -0
  24. pulse/js/error.py +139 -0
  25. pulse/js/json.py +62 -0
  26. pulse/js/map.py +84 -0
  27. pulse/js/math.py +66 -0
  28. pulse/js/navigator.py +76 -0
  29. pulse/js/number.py +54 -0
  30. pulse/js/object.py +173 -0
  31. pulse/js/promise.py +150 -0
  32. pulse/js/regexp.py +54 -0
  33. pulse/js/set.py +109 -0
  34. pulse/js/string.py +35 -0
  35. pulse/js/weakmap.py +50 -0
  36. pulse/js/weakset.py +45 -0
  37. pulse/js/window.py +199 -0
  38. pulse/messages.py +22 -3
  39. pulse/queries/client.py +7 -7
  40. pulse/queries/effect.py +16 -0
  41. pulse/queries/infinite_query.py +138 -29
  42. pulse/queries/mutation.py +1 -15
  43. pulse/queries/protocol.py +136 -0
  44. pulse/queries/query.py +610 -174
  45. pulse/queries/store.py +11 -14
  46. pulse/react_component.py +167 -14
  47. pulse/reactive.py +19 -1
  48. pulse/reactive_extensions.py +5 -5
  49. pulse/render_session.py +185 -59
  50. pulse/renderer.py +80 -158
  51. pulse/routing.py +1 -18
  52. pulse/transpiler/__init__.py +131 -0
  53. pulse/transpiler/builtins.py +731 -0
  54. pulse/transpiler/constants.py +110 -0
  55. pulse/transpiler/context.py +26 -0
  56. pulse/transpiler/errors.py +2 -0
  57. pulse/transpiler/function.py +250 -0
  58. pulse/transpiler/ids.py +16 -0
  59. pulse/transpiler/imports.py +409 -0
  60. pulse/transpiler/js_module.py +274 -0
  61. pulse/transpiler/modules/__init__.py +30 -0
  62. pulse/transpiler/modules/asyncio.py +38 -0
  63. pulse/transpiler/modules/json.py +20 -0
  64. pulse/transpiler/modules/math.py +320 -0
  65. pulse/transpiler/modules/re.py +466 -0
  66. pulse/transpiler/modules/tags.py +268 -0
  67. pulse/transpiler/modules/typing.py +59 -0
  68. pulse/transpiler/nodes.py +1216 -0
  69. pulse/transpiler/py_module.py +119 -0
  70. pulse/transpiler/transpiler.py +938 -0
  71. pulse/transpiler/utils.py +4 -0
  72. pulse/types/event_handler.py +3 -2
  73. pulse/vdom.py +212 -13
  74. {pulse_framework-0.1.44.dist-info → pulse_framework-0.1.47.dist-info}/METADATA +1 -1
  75. pulse_framework-0.1.47.dist-info/RECORD +119 -0
  76. pulse/codegen/imports.py +0 -204
  77. pulse/css.py +0 -155
  78. pulse_framework-0.1.44.dist-info/RECORD +0 -79
  79. {pulse_framework-0.1.44.dist-info → pulse_framework-0.1.47.dist-info}/WHEEL +0 -0
  80. {pulse_framework-0.1.44.dist-info → pulse_framework-0.1.47.dist-info}/entry_points.txt +0 -0
pulse/queries/store.py CHANGED
@@ -1,10 +1,11 @@
1
1
  import datetime as dt
2
- from collections.abc import Awaitable, Callable
2
+ from collections.abc import Callable
3
3
  from typing import Any, TypeVar, cast
4
4
 
5
+ from pulse.helpers import MISSING
5
6
  from pulse.queries.common import QueryKey
6
7
  from pulse.queries.infinite_query import InfiniteQuery, Page
7
- from pulse.queries.query import RETRY_DELAY_DEFAULT, Query
8
+ from pulse.queries.query import RETRY_DELAY_DEFAULT, KeyedQuery
8
9
 
9
10
  T = TypeVar("T")
10
11
 
@@ -15,26 +16,25 @@ class QueryStore:
15
16
  """
16
17
 
17
18
  def __init__(self):
18
- self._entries: dict[QueryKey, Query[Any] | InfiniteQuery[Any, Any]] = {}
19
+ self._entries: dict[QueryKey, KeyedQuery[Any] | InfiniteQuery[Any, Any]] = {}
19
20
 
20
21
  def items(self):
21
22
  """Iterate over all (key, query) pairs in the store."""
22
23
  return self._entries.items()
23
24
 
24
- def get_any(self, key: QueryKey) -> Query[Any] | InfiniteQuery[Any, Any] | None:
25
+ def get_any(self, key: QueryKey):
25
26
  """Get any query (regular or infinite) by key, or None if not found."""
26
27
  return self._entries.get(key)
27
28
 
28
29
  def ensure(
29
30
  self,
30
31
  key: QueryKey,
31
- fetch_fn: Callable[[], Awaitable[T]],
32
- initial_data: T | None = None,
32
+ initial_data: T | None = MISSING,
33
33
  initial_data_updated_at: float | dt.datetime | None = None,
34
34
  gc_time: float = 300.0,
35
35
  retries: int = 3,
36
36
  retry_delay: float = RETRY_DELAY_DEFAULT,
37
- ) -> Query[T]:
37
+ ) -> KeyedQuery[T]:
38
38
  # Return existing entry if present
39
39
  existing = self._entries.get(key)
40
40
  if existing:
@@ -42,15 +42,14 @@ class QueryStore:
42
42
  raise TypeError(
43
43
  "Query key is already used for an infinite query; cannot reuse for regular query"
44
44
  )
45
- return cast(Query[T], existing)
45
+ return cast(KeyedQuery[T], existing)
46
46
 
47
- def _on_dispose(e: Query[Any]) -> None:
47
+ def _on_dispose(e: KeyedQuery[Any]) -> None:
48
48
  if e.key in self._entries and self._entries[e.key] is e:
49
49
  del self._entries[e.key]
50
50
 
51
- entry = Query(
51
+ entry = KeyedQuery(
52
52
  key,
53
- fetch_fn,
54
53
  initial_data=initial_data,
55
54
  initial_data_updated_at=initial_data_updated_at,
56
55
  gc_time=gc_time,
@@ -61,7 +60,7 @@ class QueryStore:
61
60
  self._entries[key] = entry
62
61
  return entry
63
62
 
64
- def get(self, key: QueryKey) -> Query[Any] | None:
63
+ def get(self, key: QueryKey) -> KeyedQuery[Any] | None:
65
64
  """
66
65
  Get an existing regular query by key, or None if not found.
67
66
  """
@@ -82,7 +81,6 @@ class QueryStore:
82
81
  def ensure_infinite(
83
82
  self,
84
83
  key: QueryKey,
85
- query_fn: Callable[[Any], Awaitable[Any]],
86
84
  *,
87
85
  initial_page_param: Any,
88
86
  get_next_page_param: Callable[[list[Page[Any, Any]]], Any | None],
@@ -108,7 +106,6 @@ class QueryStore:
108
106
 
109
107
  entry = InfiniteQuery(
110
108
  key,
111
- query_fn,
112
109
  initial_page_param=initial_page_param,
113
110
  get_next_page_param=get_next_page_param,
114
111
  get_previous_page_param=get_previous_page_param,
pulse/react_component.py CHANGED
@@ -13,6 +13,7 @@ from types import UnionType
13
13
  from typing import (
14
14
  Annotated,
15
15
  Any,
16
+ ClassVar,
16
17
  Generic,
17
18
  Literal,
18
19
  ParamSpec,
@@ -24,9 +25,18 @@ from typing import (
24
25
  override,
25
26
  )
26
27
 
27
- from pulse.codegen.imports import Imported, ImportStatement
28
28
  from pulse.helpers import Sentinel
29
29
  from pulse.reactive_extensions import unwrap
30
+ from pulse.transpiler.errors import JSCompilationError
31
+ from pulse.transpiler.imports import Import
32
+ from pulse.transpiler.nodes import (
33
+ JSExpr,
34
+ JSMember,
35
+ JSSpread,
36
+ JSXElement,
37
+ JSXProp,
38
+ JSXSpreadProp,
39
+ )
30
40
  from pulse.vdom import Child, Element, Node
31
41
 
32
42
  T = TypeVar("T")
@@ -295,25 +305,112 @@ def default_fn_signature_without_children(
295
305
  ) -> Element: ...
296
306
 
297
307
 
298
- class ReactComponent(Generic[P], Imported):
308
+ # ----------------------------------------------------------------------------
309
+ # JSX transpilation helpers
310
+ # ----------------------------------------------------------------------------
311
+
312
+
313
+ def _build_jsx_props(kwargs: dict[str, Any]) -> list[JSXProp | JSXSpreadProp]:
314
+ """Build JSX props list from kwargs dict.
315
+
316
+ Kwargs maps:
317
+ - "propName" -> value for named props
318
+ - "__spread_N" -> JSSpread(expr) for spread props
319
+ """
320
+ props: list[JSXProp | JSXSpreadProp] = []
321
+ for key, value in kwargs.items():
322
+ if isinstance(value, JSSpread):
323
+ props.append(JSXSpreadProp(value.expr))
324
+ else:
325
+ props.append(JSXProp(key, JSExpr.of(value)))
326
+ return props
327
+
328
+
329
+ def _flatten_children(items: list[Any], out: list[JSExpr | JSXElement | str]) -> None:
330
+ """Flatten arrays and handle spreads in children list."""
331
+ from pulse.transpiler.nodes import JSArray, JSString
332
+
333
+ for it in items:
334
+ # Convert raw values first
335
+ it = JSExpr.of(it) if not isinstance(it, JSExpr) else it
336
+ if isinstance(it, JSArray):
337
+ _flatten_children(list(it.elements), out)
338
+ elif isinstance(it, JSSpread):
339
+ out.append(it.expr)
340
+ elif isinstance(it, JSString):
341
+ out.append(it.value)
342
+ else:
343
+ out.append(it)
344
+
345
+
346
+ class ReactComponentCallExpr(JSExpr):
347
+ """JSX call expression for a ReactComponent.
348
+
349
+ Created when a ReactComponent is called with props. Supports subscripting
350
+ to add children, producing the final JSXElement.
351
+ """
352
+
353
+ is_jsx: ClassVar[bool] = True
354
+ component: "ReactComponent[...]"
355
+ props: tuple[JSXProp | JSXSpreadProp, ...]
356
+ children: tuple[str | JSExpr | JSXElement, ...]
357
+
358
+ def __init__(
359
+ self,
360
+ component: "ReactComponent[...]",
361
+ props: tuple[JSXProp | JSXSpreadProp, ...],
362
+ children: tuple[str | JSExpr | JSXElement, ...],
363
+ ) -> None:
364
+ self.component = component
365
+ self.props = props
366
+ self.children = children
367
+
368
+ @override
369
+ def emit(self) -> str:
370
+ return JSXElement(self.component, self.props, self.children).emit()
371
+
372
+ @override
373
+ def emit_subscript(self, indices: list[Any]) -> JSExpr:
374
+ """Handle Component(props...)[children] -> JSXElement."""
375
+ extra_children: list[JSExpr | JSXElement | str] = []
376
+ _flatten_children(indices, extra_children)
377
+ all_children = list(self.children) + extra_children
378
+ return JSXElement(self.component, self.props, all_children)
379
+
380
+ @override
381
+ def emit_call(self, args: list[Any], kwargs: dict[str, Any]) -> JSExpr:
382
+ """Calling an already-called component is an error."""
383
+ raise JSCompilationError(
384
+ f"Cannot call <{self.component.name}> - already called. "
385
+ + "Use subscript for children: Component(props...)[children]"
386
+ )
387
+
388
+
389
+ class ReactComponent(JSExpr, Generic[P]):
299
390
  """
300
391
  A React component that can be used within the UI tree.
301
392
  Returns a function that creates mount point UITreeNode instances.
302
393
 
303
394
  Args:
304
- tag: Name of the component (or "default" for default export)
305
- import_path: Module path to import the component from
306
- alias: Optional alias for the component in the registry
395
+ name: Name of the component (or "default" for default export)
396
+ src: Module path to import the component from
307
397
  is_default: True if this is a default export, else named export
308
- import_name: If specified, import this name from import_path and access tag as a property of it
398
+ prop: Optional property name to access the component from the imported object
399
+ lazy: Whether to lazy load the component
400
+ version: Optional npm semver constraint for this component's package
401
+ prop_spec: Optional PropSpec for the component
402
+ fn_signature: Function signature to parse for props
403
+ extra_imports: Additional imports to include (CSS files, etc.)
309
404
 
310
405
  Returns:
311
406
  A function that creates Node instances with mount point tags
312
407
  """
313
408
 
409
+ import_: Import
314
410
  props_spec: PropSpec
315
411
  fn_signature: Callable[P, Element]
316
412
  lazy: bool
413
+ _prop: str | None # Property access like AppShell.Header
317
414
 
318
415
  def __init__(
319
416
  self,
@@ -326,11 +423,14 @@ class ReactComponent(Generic[P], Imported):
326
423
  version: str | None = None,
327
424
  prop_spec: PropSpec | None = None,
328
425
  fn_signature: Callable[P, Element] = default_signature,
329
- extra_imports: tuple[ImportStatement, ...]
330
- | list[ImportStatement]
331
- | None = None,
426
+ extra_imports: tuple[Import, ...] | list[Import] | None = None,
332
427
  ):
333
- super().__init__(name, src, is_default=is_default, prop=prop)
428
+ # Create the Import directly (prop is stored separately on ReactComponent)
429
+ if is_default:
430
+ self.import_ = Import.default(name, src)
431
+ else:
432
+ self.import_ = Import.named(name, src)
433
+ self._prop = prop
334
434
 
335
435
  # Build props_spec from fn_signature if provided and props not provided
336
436
  if prop_spec:
@@ -347,10 +447,62 @@ class ReactComponent(Generic[P], Imported):
347
447
  self.lazy = lazy
348
448
  # Optional npm semver constraint for this component's package
349
449
  self.version: str | None = version
350
- # Additional import statements to include in route where this component is used
351
- self.extra_imports: list[ImportStatement] = list(extra_imports or [])
450
+ # Additional imports to include in route where this component is used
451
+ self.extra_imports: list[Import] = list(extra_imports or [])
352
452
  COMPONENT_REGISTRY.get().add(self)
353
453
 
454
+ @override
455
+ def emit(self) -> str:
456
+ if self.prop:
457
+ return JSMember(self.import_, self.prop).emit()
458
+ return self.import_.emit()
459
+
460
+ @override
461
+ def emit_call(self, args: list[Any], kwargs: dict[str, Any]) -> JSExpr:
462
+ """Handle Component(props...) -> ReactComponentCallExpr."""
463
+ props_list = _build_jsx_props(kwargs)
464
+ children_list: list[JSExpr | JSXElement | str] = []
465
+ _flatten_children(args, children_list)
466
+ return ReactComponentCallExpr(self, tuple(props_list), tuple(children_list))
467
+
468
+ @override
469
+ def emit_subscript(self, indices: list[Any]) -> JSExpr:
470
+ """Direct subscript on ReactComponent is not allowed.
471
+
472
+ Use Component(props...)[children] instead of Component[children].
473
+ """
474
+ raise JSCompilationError(
475
+ f"Cannot subscript ReactComponent '{self.name}' directly. "
476
+ + "Use Component(props...)[children] or Component()[children] instead."
477
+ )
478
+
479
+ @property
480
+ def name(self) -> str:
481
+ return self.import_.name
482
+
483
+ @property
484
+ def src(self) -> str:
485
+ return self.import_.src
486
+
487
+ @property
488
+ def is_default(self) -> bool:
489
+ return self.import_.is_default
490
+
491
+ @property
492
+ def prop(self) -> str | None:
493
+ return self._prop
494
+
495
+ @property
496
+ def expr(self) -> str:
497
+ """Expression for the component in the registry and VDOM tags.
498
+
499
+ Uses the import's js_name (with unique ID suffix) to match the
500
+ unified registry on the client side.
501
+ """
502
+ if self.prop:
503
+ return f"{self.import_.js_name}.{self.prop}"
504
+ return self.import_.js_name
505
+
354
506
  @override
355
507
  def __repr__(self) -> str:
356
508
  default_part = ", default=True" if self.is_default else ""
@@ -359,7 +511,8 @@ class ReactComponent(Generic[P], Imported):
359
511
  props_part = f", props_spec={self.props_spec!r}"
360
512
  return f"ReactComponent(name='{self.name}', src='{self.src}'{prop_part}{default_part}{lazy_part}{props_part})"
361
513
 
362
- def __call__(self, *children: P.args, **props: P.kwargs) -> Node:
514
+ @override
515
+ def __call__(self, *children: P.args, **props: P.kwargs) -> Node: # pyright: ignore[reportIncompatibleMethodOverride]
363
516
  key = props.get("key")
364
517
  if key is not None and not isinstance(key, str):
365
518
  raise ValueError("key must be a string or None")
@@ -806,7 +959,7 @@ def react_component(
806
959
  is_default: bool = False,
807
960
  lazy: bool = False,
808
961
  version: str | None = None,
809
- extra_imports: list[ImportStatement] | None = None,
962
+ extra_imports: list[Import] | None = None,
810
963
  ) -> Callable[[Callable[P, None] | Callable[P, Element]], ReactComponent[P]]:
811
964
  """
812
965
  Decorator to define a React component wrapper. The decorated function is
pulse/reactive.py CHANGED
@@ -191,7 +191,8 @@ class Computed(Generic[T_co]):
191
191
  if len(scope.effects) > 0:
192
192
  raise RuntimeError(
193
193
  "An effect was created within a computed variable's function. "
194
- + "This behavior is not allowed, computed variables should be pure calculations."
194
+ + "This is most likely unintended. If you need to create an effect here, "
195
+ + "wrap the effect creation with Untrack()."
195
196
  )
196
197
  finally:
197
198
  self.on_stack = False
@@ -274,6 +275,7 @@ class Effect(Disposable):
274
275
  _interval_handle: asyncio.TimerHandle | None
275
276
  explicit_deps: bool
276
277
  batch: "Batch | None"
278
+ paused: bool
277
279
 
278
280
  def __init__(
279
281
  self,
@@ -301,6 +303,7 @@ class Effect(Disposable):
301
303
  self._lazy = lazy
302
304
  self._interval = interval
303
305
  self._interval_handle = None
306
+ self.paused = False
304
307
 
305
308
  if immediate and lazy:
306
309
  raise ValueError("An effect cannot be boht immediate and lazy")
@@ -358,7 +361,20 @@ class Effect(Disposable):
358
361
  self._interval_handle.cancel()
359
362
  self._interval_handle = None
360
363
 
364
+ def pause(self):
365
+ """Pause the effect - it won't run when dependencies change."""
366
+ self.paused = True
367
+ self.cancel(cancel_interval=True)
368
+
369
+ def resume(self):
370
+ """Resume a paused effect and schedule it to run."""
371
+ if self.paused:
372
+ self.paused = False
373
+ self.schedule()
374
+
361
375
  def schedule(self):
376
+ if self.paused:
377
+ return
362
378
  # Immediate effects run right away when scheduled and do not enter a batch
363
379
  if self.immediate:
364
380
  self.run()
@@ -383,6 +399,8 @@ class Effect(Disposable):
383
399
  self._cancel_interval()
384
400
 
385
401
  def push_change(self):
402
+ if self.paused:
403
+ return
386
404
  # Short-circuit if already scheduled in a batch.
387
405
  # This avoids redundant schedule() calls and O(n) list checks
388
406
  # when the same effect is reached through multiple dependency paths.
@@ -45,7 +45,7 @@ class SupportsKeysAndGetItem(Protocol[T1, T2_co]):
45
45
 
46
46
  # Return an iterable view that subscribes to per-key signals during iteration
47
47
  class ReactiveDictItems(Generic[T1, T2]):
48
- __slots__: tuple[str, ...] = ("_host",)
48
+ __slots__ = ("_host",) # pyright: ignore[reportUnannotatedClassAttribute]
49
49
  _host: ReactiveDict[T1, T2]
50
50
 
51
51
  def __init__(self, host: ReactiveDict[T1, T2]) -> None:
@@ -60,7 +60,7 @@ class ReactiveDictItems(Generic[T1, T2]):
60
60
 
61
61
 
62
62
  class ReactiveDictValues(Generic[T1, T2]):
63
- __slots__: tuple[str, ...] = ("_host",)
63
+ __slots__ = ("_host",) # pyright: ignore[reportUnannotatedClassAttribute]
64
64
  _host: ReactiveDict[T1, T2]
65
65
 
66
66
  def __init__(self, host: ReactiveDict[T1, T2]) -> None:
@@ -84,7 +84,7 @@ class ReactiveDict(dict[T1, T2]):
84
84
  - Iteration, membership checks, and len are reactive to structural changes
85
85
  """
86
86
 
87
- __slots__: tuple[str, ...] = ("_signals", "_structure")
87
+ __slots__ = ("_signals", "_structure") # pyright: ignore[reportUnannotatedClassAttribute]
88
88
 
89
89
  def __init__(self, initial: Mapping[T1, T2] | None = None) -> None:
90
90
  super().__init__()
@@ -409,7 +409,7 @@ class ReactiveList(list[T1]):
409
409
  - len() subscribes to structural changes
410
410
  """
411
411
 
412
- __slots__: tuple[str, ...] = ("_signals", "_structure")
412
+ __slots__ = ("_signals", "_structure") # pyright: ignore[reportUnannotatedClassAttribute]
413
413
 
414
414
  def __init__(self, initial: Iterable[T1] | None = None) -> None:
415
415
  super().__init__()
@@ -645,7 +645,7 @@ class ReactiveSet(set[T1]):
645
645
  - Iteration subscribes to membership signals for all elements
646
646
  """
647
647
 
648
- __slots__: tuple[str, ...] = ("_signals",)
648
+ __slots__ = ("_signals",) # pyright: ignore[reportUnannotatedClassAttribute]
649
649
 
650
650
  def __init__(self, initial: Iterable[T1] | None = None) -> None:
651
651
  super().__init__()