pulse-framework 0.1.49__py3-none-any.whl → 0.1.51__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.
pulse/hooks/runtime.py CHANGED
@@ -103,11 +103,13 @@ async def set_cookie(
103
103
  )
104
104
 
105
105
 
106
- def navigate(path: str, *, replace: bool = False) -> None:
106
+ def navigate(path: str, *, replace: bool = False, hard: bool = False) -> None:
107
107
  ctx = PulseContext.get()
108
108
  if ctx.render is None:
109
109
  raise RuntimeError("navigate() must be invoked inside a Pulse callback context")
110
- ctx.render.send({"type": "navigate_to", "path": path, "replace": replace})
110
+ ctx.render.send(
111
+ {"type": "navigate_to", "path": path, "replace": replace, "hard": hard}
112
+ )
111
113
 
112
114
 
113
115
  def redirect(path: str, *, replace: bool = False) -> NoReturn:
pulse/messages.py CHANGED
@@ -48,6 +48,7 @@ class ServerNavigateToMessage(TypedDict):
48
48
  type: Literal["navigate_to"]
49
49
  path: str
50
50
  replace: bool
51
+ hard: bool
51
52
 
52
53
 
53
54
  class ServerApiCallMessage(TypedDict):
pulse/react_component.py CHANGED
@@ -37,7 +37,7 @@ from pulse.transpiler.nodes import (
37
37
  JSXProp,
38
38
  JSXSpreadProp,
39
39
  )
40
- from pulse.vdom import Child, Element, Node
40
+ from pulse.vdom import Child, Element, Node, clean_element_name
41
41
 
42
42
  T = TypeVar("T")
43
43
  P = ParamSpec("P")
@@ -205,7 +205,8 @@ class PropSpec:
205
205
  unknown_keys = props.keys() - known_keys - {"key"}
206
206
  if not self.allow_unspecified and unknown_keys:
207
207
  bad = ", ".join(repr(k) for k in sorted(unknown_keys))
208
- raise ValueError(f"Unexpected prop(s) for component '{comp_tag}': {bad}")
208
+ clean_tag = clean_element_name(comp_tag)
209
+ raise ValueError(f"Unexpected prop(s) for component '{clean_tag}': {bad}")
209
210
  if self.allow_unspecified:
210
211
  for k in unknown_keys:
211
212
  v = props[k]
@@ -290,8 +291,9 @@ class PropSpec:
290
291
  errors.append(
291
292
  f"Multiple props map to '{js_key}': {', '.join(py_keys)}"
292
293
  )
294
+ clean_tag = clean_element_name(comp_tag)
293
295
  raise ValueError(
294
- f"Invalid props for component '{comp_tag}': {'; '.join(errors)}"
296
+ f"Invalid props for component '{clean_tag}': {'; '.join(errors)}"
295
297
  )
296
298
 
297
299
  return result or None
pulse/render_session.py CHANGED
@@ -440,6 +440,7 @@ class RenderSession:
440
440
  type="navigate_to",
441
441
  path=msg["path"],
442
442
  replace=msg["replace"],
443
+ hard=msg.get("hard", False),
443
444
  )
444
445
 
445
446
  prev_sender = self._send_message
@@ -563,14 +564,20 @@ class RenderSession:
563
564
  # Prefer client-side navigation over emitting VDOM operations
564
565
  self.send(
565
566
  ServerNavigateToMessage(
566
- type="navigate_to", path=r.path, replace=r.replace
567
+ type="navigate_to",
568
+ path=r.path,
569
+ replace=r.replace,
570
+ hard=False,
567
571
  )
568
572
  )
569
573
  except NotFoundInterrupt:
570
574
  # Use app-configured not-found path; fallback to '/404'
571
575
  self.send(
572
576
  ServerNavigateToMessage(
573
- type="navigate_to", path=ctx.app.not_found, replace=True
577
+ type="navigate_to",
578
+ path=ctx.app.not_found,
579
+ replace=True,
580
+ hard=False,
574
581
  )
575
582
  )
576
583
 
pulse/serializer.py CHANGED
@@ -29,6 +29,7 @@ containing primitives, lists/tuples, ``dict``/plain objects, ``set`` and
29
29
  from __future__ import annotations
30
30
 
31
31
  import datetime as dt
32
+ import math
32
33
  import types
33
34
  from dataclasses import fields, is_dataclass
34
35
  from typing import Any
@@ -56,7 +57,16 @@ def serialize(data: Any) -> Serialized:
56
57
 
57
58
  def process(value: Any) -> PlainJSON:
58
59
  nonlocal global_index
59
- if value is None or isinstance(value, (bool, int, float, str)):
60
+ if value is None or isinstance(value, (bool, int, str)):
61
+ return value
62
+ if isinstance(value, float):
63
+ if math.isnan(value):
64
+ return None # NaN → None (matches pandas None ↔ NaN semantics)
65
+ if math.isinf(value):
66
+ raise ValueError(
67
+ f"Cannot serialize {value}: Infinity is not valid JSON. "
68
+ + "Replace with None or a sentinel value."
69
+ )
60
70
  return value
61
71
 
62
72
  idx = global_index
pulse/vdom.py CHANGED
@@ -6,7 +6,6 @@ the TypeScript UINode format exactly, eliminating the need for translation.
6
6
  """
7
7
 
8
8
  import functools
9
- import math
10
9
  import re
11
10
  import warnings
12
11
  from collections.abc import Callable, Iterable, Sequence
@@ -30,56 +29,6 @@ from pulse.env import env
30
29
  from pulse.hooks.core import HookContext
31
30
  from pulse.hooks.init import rewrite_init_blocks
32
31
 
33
- # ============================================================================
34
- # Validation helpers (dev mode only)
35
- # ============================================================================
36
-
37
-
38
- def _check_json_safe_float(value: float, context: str) -> None:
39
- """Raise ValueError if a float is NaN or Infinity."""
40
- if math.isnan(value):
41
- raise ValueError(
42
- f"Cannot use nan in {context}. "
43
- + "NaN and Infinity are not supported in Pulse because they cannot be serialized to JSON. "
44
- + "Replace with None or a sentinel value before passing to components."
45
- )
46
- if math.isinf(value):
47
- kind = "inf" if value > 0 else "-inf"
48
- raise ValueError(
49
- f"Cannot use {kind} in {context}. "
50
- + "NaN and Infinity are not supported in Pulse because they cannot be serialized to JSON. "
51
- + "Replace with None or a sentinel value before passing to components."
52
- )
53
-
54
-
55
- def _validate_value(value: Any, context: str) -> None:
56
- """Recursively validate a value for JSON-unsafe floats (NaN, Infinity)."""
57
- if isinstance(value, float):
58
- _check_json_safe_float(value, context)
59
- elif isinstance(value, dict):
60
- for v in value.values():
61
- _validate_value(v, context)
62
- elif isinstance(value, (list, tuple)):
63
- for item in value:
64
- _validate_value(item, context)
65
- # Skip other types - they'll be handled by the serializer
66
-
67
-
68
- def _validate_props(props: dict[str, Any] | None, parent_name: str) -> None:
69
- """Validate all props for JSON-unsafe values."""
70
- if not props:
71
- return
72
- for key, value in props.items():
73
- _validate_value(value, f"{parent_name} prop '{key}'")
74
-
75
-
76
- def _validate_children(children: "Sequence[Element]", parent_name: str) -> None:
77
- """Validate primitive children for JSON-unsafe values."""
78
- for child in children:
79
- if isinstance(child, float):
80
- _check_json_safe_float(child, f"{parent_name} children")
81
-
82
-
83
32
  # ============================================================================
84
33
  # Core VDOM
85
34
  # ============================================================================
@@ -109,7 +58,7 @@ class Node:
109
58
 
110
59
  tag: str
111
60
  props: dict[str, Any] | None
112
- children: "Sequence[Element] | None"
61
+ children: "list[Element] | None"
113
62
  allow_children: bool
114
63
  key: str | None
115
64
 
@@ -122,7 +71,6 @@ class Node:
122
71
  allow_children: bool = True,
123
72
  ):
124
73
  self.tag = tag
125
- # Normalize to None
126
74
  self.props = props or None
127
75
  self.children = (
128
76
  _flatten_children(children, parent_name=f"<{self.tag}>")
@@ -134,13 +82,8 @@ class Node:
134
82
  if key is not None and not isinstance(key, str):
135
83
  raise ValueError("key must be a string or None")
136
84
  if not self.allow_children and children:
137
- raise ValueError(f"{self.tag} cannot have children")
138
- # Dev-only validation for JSON-unsafe values
139
- if env.pulse_env == "dev":
140
- parent_name = f"<{self.tag}>"
141
- _validate_props(self.props, parent_name)
142
- if self.children:
143
- _validate_children(self.children, parent_name)
85
+ clean_tag = clean_element_name(self.tag)
86
+ raise ValueError(f"{clean_tag} cannot have children")
144
87
 
145
88
  # --- Pretty printing helpers -------------------------------------------------
146
89
  @override
@@ -265,7 +208,7 @@ class Component(Generic[P]):
265
208
  if key is not None and not isinstance(key, str):
266
209
  raise ValueError("key must be a string or None")
267
210
 
268
- # Flatten children if component takes children (has *children parameter)
211
+ # Flatten children if component accepts them via `*children` parameter
269
212
  if self._takes_children and args:
270
213
  flattened = _flatten_children(
271
214
  args, # pyright: ignore[reportArgumentType]
@@ -332,17 +275,6 @@ class ComponentNode:
332
275
  # Used for rendering
333
276
  self.contents = None
334
277
  self.hooks = HookContext()
335
- # Dev-only validation for JSON-unsafe values
336
- if env.pulse_env == "dev":
337
- parent_name = f"<{self.name}>"
338
- # Validate kwargs (props)
339
- _validate_props(self.kwargs, parent_name)
340
- # Validate args (children passed positionally)
341
- for arg in self.args:
342
- if isinstance(arg, float):
343
- _check_json_safe_float(arg, f"{parent_name} children")
344
- elif isinstance(arg, (dict, list, tuple)):
345
- _validate_value(arg, f"{parent_name} children")
346
278
 
347
279
  def __getitem__(self, children_arg: "Child | tuple[Child, ...]"):
348
280
  if not self.takes_children:
@@ -356,11 +288,11 @@ class ComponentNode:
356
288
  )
357
289
  if not isinstance(children_arg, tuple):
358
290
  children_arg = (children_arg,)
359
- # Flatten children for ComponentNode as well
291
+ # Flatten children when component accepts them via `*children` parameter
360
292
  flattened_children = _flatten_children(
361
293
  children_arg, parent_name=f"<{self.name}>", warn_stacklevel=4
362
294
  )
363
- result = ComponentNode(
295
+ return ComponentNode(
364
296
  fn=self.fn,
365
297
  args=tuple(flattened_children),
366
298
  kwargs=self.kwargs,
@@ -368,7 +300,6 @@ class ComponentNode:
368
300
  key=self.key,
369
301
  takes_children=self.takes_children,
370
302
  )
371
- return result
372
303
 
373
304
  @override
374
305
  def __repr__(self) -> str:
@@ -486,7 +417,7 @@ VDOMOperation: TypeAlias = (
486
417
  # ----------------------------------------------------------------------------
487
418
 
488
419
 
489
- def _clean_parent_name_for_warning(parent_name: str) -> str:
420
+ def clean_element_name(parent_name: str) -> str:
490
421
  """Strip $$ prefix and hexadecimal suffix from ReactComponent tags in warning messages.
491
422
 
492
423
  ReactComponent tags are in the format <$$ComponentName_1a2b> or <$$ComponentName_1a2b.prop>.
@@ -500,7 +431,7 @@ def _clean_parent_name_for_warning(parent_name: str) -> str:
500
431
 
501
432
  def _flatten_children(
502
433
  children: Children, *, parent_name: str, warn_stacklevel: int = 5
503
- ) -> Sequence[Element]:
434
+ ) -> list[Element]:
504
435
  """Flatten children and emit warnings for unkeyed iterables (dev mode only).
505
436
 
506
437
  Args:
@@ -511,7 +442,6 @@ def _flatten_children(
511
442
  - 4 for ComponentNode.__getitem__ or Component.__call__ (user -> method -> _flatten_children -> visit -> warn)
512
443
  """
513
444
  flat: list[Element] = []
514
- return_tuple = isinstance(children, tuple)
515
445
  is_dev = env.pulse_env == "dev"
516
446
 
517
447
  def visit(item: Child) -> None:
@@ -529,7 +459,7 @@ def _flatten_children(
529
459
  visit(sub)
530
460
  if missing_key:
531
461
  # Warn once per iterable without keys on its elements.
532
- clean_name = _clean_parent_name_for_warning(parent_name)
462
+ clean_name = clean_element_name(parent_name)
533
463
  warnings.warn(
534
464
  (
535
465
  f"[Pulse] Iterable children of {clean_name} contain elements without 'key'. "
@@ -548,13 +478,14 @@ def _flatten_children(
548
478
  for child in flat:
549
479
  if isinstance(child, (Node, ComponentNode)) and child.key is not None:
550
480
  if child.key in seen_keys:
481
+ clean_name = clean_element_name(parent_name)
551
482
  raise ValueError(
552
- f"[Pulse] Duplicate key '{child.key}' found among children of {parent_name}. "
483
+ f"[Pulse] Duplicate key '{child.key}' found among children of {clean_name}. "
553
484
  + "Keys must be unique per sibling set."
554
485
  )
555
486
  seen_keys.add(child.key)
556
487
 
557
- return tuple(flat) if return_tuple else flat
488
+ return flat
558
489
 
559
490
 
560
491
  def _short_args(args: tuple[Any, ...], max_items: int = 4) -> list[str] | str:
@@ -606,19 +537,18 @@ def _callable_qualname(fn: Callable[..., Any]) -> str:
606
537
 
607
538
 
608
539
  def _takes_children(fn: Callable[..., Any]) -> bool:
609
- # Lightweight check: children allowed if function accepts positional
610
- # arguments
540
+ """Return True if function accepts children via `*children` parameter.
541
+
542
+ Convention: A component accepts children if and only if it has a VAR_POSITIONAL
543
+ parameter named "children". This convention should be documented in user-facing docs.
544
+ """
611
545
  try:
612
546
  sig = signature(fn)
613
547
  except (ValueError, TypeError):
614
548
  # Builtins or callables without inspectable signature: assume no children
615
549
  return False
616
550
  for p in sig.parameters.values():
617
- if p.kind in (
618
- Parameter.VAR_POSITIONAL,
619
- Parameter.POSITIONAL_ONLY,
620
- Parameter.POSITIONAL_OR_KEYWORD,
621
- ):
551
+ if p.kind is Parameter.VAR_POSITIONAL and p.name == "children":
622
552
  return True
623
553
  return False
624
554
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pulse-framework
3
- Version: 0.1.49
3
+ Version: 0.1.51
4
4
  Summary: Pulse - Full-stack framework for building real-time React applications in Python
5
5
  Requires-Dist: websockets>=12.0
6
6
  Requires-Dist: fastapi>=0.104.0
@@ -33,7 +33,7 @@ pulse/hooks/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
33
33
  pulse/hooks/core.py,sha256=QfYRz2O8-drNSQx_xnv8mK8ksWcw3LNM1H2hoInT0Rk,7457
34
34
  pulse/hooks/effects.py,sha256=pVq5OndlhFLHLpM9Pn9Bp5rEpnpmJEpbIp2UaHHyJFQ,2428
35
35
  pulse/hooks/init.py,sha256=iTNmEcFgZCXsLImPONbSNwc5asT7NQRz04b1Jopgzxs,11960
36
- pulse/hooks/runtime.py,sha256=k5LZ8hnlNBMKOiEkQcAvs8BKwYxV6gwea2WCfju5K7Y,5106
36
+ pulse/hooks/runtime.py,sha256=61CGZ9B0ScwCx8bwUrXIWYPAJIZtGCp3GcdBqa33dgo,5145
37
37
  pulse/hooks/setup.py,sha256=c_uVi0S0HPioEvjdWUaSdAGT9M3Cxpw8J-llvtmDOGo,4496
38
38
  pulse/hooks/stable.py,sha256=mLNS6WyA4tC-65gNybPOE0DLEz1YlxOCddD9odElArU,1772
39
39
  pulse/hooks/states.py,sha256=fFqN3gf7v7rY6QmieKWN1hVCQRRnL-5H4TeG9LTnKSc,6778
@@ -65,7 +65,7 @@ pulse/js/string.py,sha256=fBd_CKq5nhc300mRa3YgNw0jpTEgGyaXRmGBiJgeu5w,928
65
65
  pulse/js/weakmap.py,sha256=Q7kgPQx6rFqYfhIDyRfhuC12JmlKmO2n-OGSpl3g9ZY,1473
66
66
  pulse/js/weakset.py,sha256=FJoVR0WtaOaHL7AXzJOb29F_sqG1K2mWxvR0RJk3mS0,1333
67
67
  pulse/js/window.py,sha256=ayx3lBl54hTVanlkiC2wCVGNh0IDJqzPO7OlO11YUtI,4081
68
- pulse/messages.py,sha256=PDsb07QDKvkMitAMgLmOk2c4JDb58Cq9WWCwbQ8unvg,3979
68
+ pulse/messages.py,sha256=8SrGVPQ674DPMm-FUNNrEfVonFmcvQdb8B_z6K_dQbw,3991
69
69
  pulse/middleware.py,sha256=9uyAhVUEGMSwqWC3WXqs7x5JMMNEcSTTu3g7DjsR8w8,9812
70
70
  pulse/plugin.py,sha256=RfGl6Vtr7VRHb8bp4Ob4dOX9dVzvc4Riu7HWnStMPpk,580
71
71
  pulse/proxy.py,sha256=jv2IdOEbF-qbtN5hmSqnyhZedOX1597XBye8cerWIyE,7253
@@ -79,14 +79,14 @@ pulse/queries/mutation.py,sha256=px1fprFL-RxNfbRSoRtdsOLkEbjSsMrJxGHKBIPYQTM,495
79
79
  pulse/queries/protocol.py,sha256=R8n238Ex9DbYIAVKB83a8FAPtnCiPNhWar-F01K2fTo,3345
80
80
  pulse/queries/query.py,sha256=G8eXCaT5wuvVcstlqWU8VBxuuUUS7K1R5Y-VtDpMIG0,35065
81
81
  pulse/queries/store.py,sha256=Ct7a-h1-Cq07zEfe9vw-LM85Fm7jIJx7CLAIlsiznlU,3444
82
- pulse/react_component.py,sha256=m2WJwrCvzaHDC_o4PRvZ3pD3nwy9QshxtVn10vMRDQg,30603
82
+ pulse/react_component.py,sha256=7_7gNYiWnLqwta5TBG8GhWN29IH1aH9wRmGCm4pRJzI,30713
83
83
  pulse/reactive.py,sha256=v8a9IttkabeWwYrrHAx33zqzW9WC4WlS4iXbIh2KQkU,24374
84
84
  pulse/reactive_extensions.py,sha256=T1V3AasHtvJkmGO55miC9RVPxDFIj7qrooMsn89x5SI,32076
85
- pulse/render_session.py,sha256=UAI65k28ysZqCp7j6BBdi7MkG2wVKJXj4hIgjK7hsaM,18120
85
+ pulse/render_session.py,sha256=kGskm6NNhQ2u4vBRXOeIeCoXsyP81O2Q4Fr93nA6q-4,18222
86
86
  pulse/renderer.py,sha256=kUwrI5v9XMerVCjBmnYZSyfkDu8B8cW8jFjVnM93TS4,15702
87
87
  pulse/request.py,sha256=sPsSRWi5KvReSPBLIs_kzqomn1wlRk1BTLZ5s0chQr4,4979
88
88
  pulse/routing.py,sha256=XZdq4gjfYeuz1wKtjPza6YA8ya76_cQ58b2l4dBDbr4,13243
89
- pulse/serializer.py,sha256=8RAITNoSNm5-U38elHpWmkBpcM_rxZFMCluJSfldfk4,5420
89
+ pulse/serializer.py,sha256=7YF8ZjDZYgA5Wmxd9CT-lPvakSb4zjUI1YbTd9m4jQM,5733
90
90
  pulse/state.py,sha256=ikQbK4R8PieV96qd4uWREUvs0jXo9sCapawY7i6oCYo,10776
91
91
  pulse/transpiler/__init__.py,sha256=sgfHxLwZEPj3rBBMtzD4997qx7GTr5Wt22-296e-uC8,6492
92
92
  pulse/transpiler/builtins.py,sha256=V_H3bpgU22Yb_GzM6YvOutZ65O36xHLzRANl7uHRqUI,22401
@@ -111,9 +111,9 @@ pulse/transpiler/utils.py,sha256=W5PAOWvmYdJCEw1eY7QEJRQFmNLVjFTdlCyWzmnTrCc,94
111
111
  pulse/types/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
112
112
  pulse/types/event_handler.py,sha256=psQCydj-WEtBcFU5JU4mDwvyzkW8V2O0g_VFRU2EOHI,1618
113
113
  pulse/user_session.py,sha256=FITxLSEl3JU-jod6UWuUYC6EpnPG2rbaLCnIOdkQPtg,7803
114
- pulse/vdom.py,sha256=yHMOYiXcaP3IVnYOAZrd_7cXWULKQ2fRQAiPDPSsOyU,18494
114
+ pulse/vdom.py,sha256=BQov3TqjrEoLwgMTYHHiB4hhbAxeNHxeOFZPv6eKB1o,16075
115
115
  pulse/version.py,sha256=711vaM1jVIQPgkisGgKZqwmw019qZIsc_QTae75K2pg,1895
116
- pulse_framework-0.1.49.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
117
- pulse_framework-0.1.49.dist-info/entry_points.txt,sha256=i7aohd3QaPu5IcuGKKvsQQEiMYMe5HcF56QEsaLVO64,46
118
- pulse_framework-0.1.49.dist-info/METADATA,sha256=HKBRKLM8CJdL2a7oguyCDzayWPqIBQirMMN40jFrSTw,580
119
- pulse_framework-0.1.49.dist-info/RECORD,,
116
+ pulse_framework-0.1.51.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
117
+ pulse_framework-0.1.51.dist-info/entry_points.txt,sha256=i7aohd3QaPu5IcuGKKvsQQEiMYMe5HcF56QEsaLVO64,46
118
+ pulse_framework-0.1.51.dist-info/METADATA,sha256=eHobxGSJSHVYV0jn6LtVABYkSnhxxcBHiFvi3N2HTSI,580
119
+ pulse_framework-0.1.51.dist-info/RECORD,,