pulse-framework 0.1.50__py3-none-any.whl → 0.1.52__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 (85) hide show
  1. pulse/__init__.py +542 -562
  2. pulse/_examples.py +29 -0
  3. pulse/app.py +0 -14
  4. pulse/cli/cmd.py +96 -80
  5. pulse/cli/dependencies.py +10 -41
  6. pulse/cli/folder_lock.py +3 -3
  7. pulse/cli/helpers.py +40 -67
  8. pulse/cli/logging.py +102 -0
  9. pulse/cli/packages.py +16 -0
  10. pulse/cli/processes.py +40 -23
  11. pulse/codegen/codegen.py +70 -35
  12. pulse/codegen/js.py +2 -4
  13. pulse/codegen/templates/route.py +94 -146
  14. pulse/component.py +115 -0
  15. pulse/components/for_.py +1 -1
  16. pulse/components/if_.py +1 -1
  17. pulse/components/react_router.py +16 -22
  18. pulse/{html → dom}/events.py +1 -1
  19. pulse/{html → dom}/props.py +6 -6
  20. pulse/{html → dom}/tags.py +11 -11
  21. pulse/dom/tags.pyi +480 -0
  22. pulse/form.py +7 -6
  23. pulse/hooks/init.py +1 -13
  24. pulse/js/__init__.py +37 -41
  25. pulse/js/__init__.pyi +22 -2
  26. pulse/js/_types.py +5 -3
  27. pulse/js/array.py +121 -38
  28. pulse/js/console.py +9 -9
  29. pulse/js/date.py +22 -19
  30. pulse/js/document.py +8 -4
  31. pulse/js/error.py +12 -14
  32. pulse/js/json.py +4 -3
  33. pulse/js/map.py +17 -7
  34. pulse/js/math.py +2 -2
  35. pulse/js/navigator.py +4 -4
  36. pulse/js/number.py +8 -8
  37. pulse/js/object.py +9 -13
  38. pulse/js/promise.py +25 -9
  39. pulse/js/regexp.py +6 -6
  40. pulse/js/set.py +20 -8
  41. pulse/js/string.py +7 -7
  42. pulse/js/weakmap.py +6 -6
  43. pulse/js/weakset.py +6 -6
  44. pulse/js/window.py +17 -14
  45. pulse/messages.py +1 -4
  46. pulse/react_component.py +3 -999
  47. pulse/render_session.py +74 -66
  48. pulse/renderer.py +311 -238
  49. pulse/routing.py +1 -10
  50. pulse/serializer.py +11 -1
  51. pulse/transpiler/__init__.py +84 -114
  52. pulse/transpiler/builtins.py +661 -343
  53. pulse/transpiler/errors.py +78 -2
  54. pulse/transpiler/function.py +463 -133
  55. pulse/transpiler/id.py +18 -0
  56. pulse/transpiler/imports.py +230 -325
  57. pulse/transpiler/js_module.py +218 -209
  58. pulse/transpiler/modules/__init__.py +16 -13
  59. pulse/transpiler/modules/asyncio.py +45 -26
  60. pulse/transpiler/modules/json.py +12 -8
  61. pulse/transpiler/modules/math.py +161 -216
  62. pulse/transpiler/modules/pulse/__init__.py +5 -0
  63. pulse/transpiler/modules/pulse/tags.py +231 -0
  64. pulse/transpiler/modules/typing.py +33 -28
  65. pulse/transpiler/nodes.py +1607 -923
  66. pulse/transpiler/py_module.py +118 -95
  67. pulse/transpiler/react_component.py +51 -0
  68. pulse/transpiler/transpiler.py +593 -437
  69. pulse/transpiler/vdom.py +255 -0
  70. {pulse_framework-0.1.50.dist-info → pulse_framework-0.1.52.dist-info}/METADATA +1 -1
  71. pulse_framework-0.1.52.dist-info/RECORD +120 -0
  72. pulse/html/tags.pyi +0 -470
  73. pulse/transpiler/constants.py +0 -110
  74. pulse/transpiler/context.py +0 -26
  75. pulse/transpiler/ids.py +0 -16
  76. pulse/transpiler/modules/re.py +0 -466
  77. pulse/transpiler/modules/tags.py +0 -268
  78. pulse/transpiler/utils.py +0 -4
  79. pulse/vdom.py +0 -667
  80. pulse_framework-0.1.50.dist-info/RECORD +0 -119
  81. /pulse/{html → dom}/__init__.py +0 -0
  82. /pulse/{html → dom}/elements.py +0 -0
  83. /pulse/{html → dom}/svg.py +0 -0
  84. {pulse_framework-0.1.50.dist-info → pulse_framework-0.1.52.dist-info}/WHEEL +0 -0
  85. {pulse_framework-0.1.50.dist-info → pulse_framework-0.1.52.dist-info}/entry_points.txt +0 -0
pulse/react_component.py CHANGED
@@ -1,1001 +1,5 @@
1
- # ============================================================================
2
- # React Component Integration
3
- # ============================================================================
1
+ """Thin wrappers for transpiler react_component integration."""
4
2
 
5
- import inspect
6
- import typing
7
- from collections import defaultdict
8
- from collections.abc import Callable
9
- from contextvars import ContextVar
10
- from functools import cache
11
- from inspect import Parameter
12
- from types import UnionType
13
- from typing import (
14
- Annotated,
15
- Any,
16
- ClassVar,
17
- Generic,
18
- Literal,
19
- ParamSpec,
20
- TypeVar,
21
- Unpack,
22
- cast,
23
- get_args,
24
- get_origin,
25
- override,
26
- )
3
+ from pulse.transpiler.react_component import default_signature, react_component
27
4
 
28
- from pulse.helpers import Sentinel
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
- )
40
- from pulse.vdom import Child, Element, Node
41
-
42
- T = TypeVar("T")
43
- P = ParamSpec("P")
44
- DEFAULT: Any = Sentinel("DEFAULT")
45
-
46
-
47
- # ----------------------------------------------------------------------------
48
- # Detection for stringified annotations (from __future__ import annotations)
49
- # ----------------------------------------------------------------------------
50
-
51
-
52
- def _function_has_string_annotations(fn: Callable[..., Any]) -> bool:
53
- """Return True if any function annotations are strings.
54
-
55
- This happens when the defining module uses `from __future__ import annotations`.
56
- In that case, resolving types at runtime is fragile; we skip PropSpec building.
57
- """
58
- try:
59
- anns = getattr(fn, "__annotations__", {}) or {}
60
- return any(isinstance(v, str) for v in anns.values())
61
- except Exception:
62
- return False
63
-
64
-
65
- def _is_any_annotation(annotation: object) -> bool:
66
- """Return True when an annotation is effectively `typing.Any`."""
67
- try:
68
- return annotation is Any or annotation == Any
69
- except TypeError:
70
- return False
71
-
72
-
73
- class Prop(Generic[T]):
74
- default: T | None
75
- required: bool
76
- default_factory: Callable[[], T] | None
77
- serialize: Callable[[T], Any] | None
78
- map_to: str | None
79
- typ_: type | tuple[type, ...] | None
80
-
81
- def __init__(
82
- self,
83
- default: T | None = DEFAULT,
84
- # Will be set by all the conventional ways of defining PropSpec
85
- required: bool = DEFAULT, # type: ignore
86
- default_factory: Callable[[], T] | None = None,
87
- serialize: Callable[[T], Any] | None = None,
88
- map_to: str | None = None,
89
- typ_: type | tuple[type, ...] | None = None,
90
- ) -> None:
91
- self.default = default
92
- self.required = required
93
- self.default_factory = default_factory
94
- self.serialize = serialize
95
- self.map_to = map_to
96
- self.typ_ = typ_
97
-
98
- @override
99
- def __repr__(self) -> str:
100
- def _callable_name(fn: Callable[..., Any] | None) -> str:
101
- if fn is None:
102
- return "None"
103
- return getattr(fn, "__name__", fn.__class__.__name__)
104
-
105
- parts: list[str] = []
106
- if self.typ_:
107
- parts.append(f"type={_format_runtime_type(self.typ_)}")
108
- if self.required is not DEFAULT:
109
- parts.append(f"required={self.required}")
110
- if self.default is not None:
111
- parts.append(f"default={self.default!r}")
112
- if self.default_factory is not None:
113
- parts.append(f"default_factory={_callable_name(self.default_factory)}")
114
- if self.serialize is not None:
115
- parts.append(f"serialize={_callable_name(self.serialize)}")
116
- if self.map_to is not None:
117
- parts.append(f"map_to={self.map_to!r}")
118
- return f"Prop({', '.join(parts)})"
119
-
120
-
121
- def prop(
122
- default: T = DEFAULT,
123
- *,
124
- default_factory: Callable[[], T] | None = None,
125
- serialize: Callable[[T], Any] | None = None,
126
- map_to: str | None = None,
127
- ) -> T:
128
- """
129
- Convenience constructor for Prop to be used inside TypedDict defaults.
130
- """
131
- return Prop( # pyright: ignore[reportReturnType]
132
- default=default,
133
- default_factory=default_factory,
134
- serialize=serialize,
135
- map_to=map_to,
136
- )
137
-
138
-
139
- class PropSpec:
140
- allow_unspecified: bool
141
-
142
- def __init__(
143
- self,
144
- required: dict[str, Prop[Any]],
145
- optional: dict[str, Prop[Any]],
146
- allow_unspecified: bool = False,
147
- ) -> None:
148
- if "key" in required or "key" in optional:
149
- raise ValueError(
150
- "'key' is a reserved prop, please use another name (like 'id', 'label', or even 'key_')"
151
- )
152
- self.required: dict[str, Prop[Any]] = required
153
- self.optional: dict[str, Prop[Any]] = optional
154
- self.allow_unspecified = allow_unspecified
155
- # Precompute optional keys that provide defaults so we can apply them
156
- # without scanning all optional props on every apply call.
157
- self._optional_with_defaults: tuple[str, ...] = tuple(
158
- k
159
- for k, p in optional.items()
160
- if (p.default is not DEFAULT) or (p.default_factory is not None)
161
- )
162
-
163
- @override
164
- def __repr__(self) -> str:
165
- keys_list = list(self.keys())
166
- keys_preview = ", ".join(keys_list[:5])
167
- if len(keys_list) > 5:
168
- keys_preview += ", ..."
169
- return f"Props(keys=[{keys_preview}])"
170
-
171
- def as_dict(self) -> dict[str, Prop[Any]]:
172
- """Return a merged dict of required and optional props."""
173
- return self.required | self.optional
174
-
175
- def __getitem__(self, key: str) -> Prop[Any]:
176
- if key in self.required:
177
- return self.required[key]
178
- if key in self.optional:
179
- return self.optional[key]
180
- raise KeyError(key)
181
-
182
- def keys(self):
183
- return self.required.keys() | self.optional.keys()
184
-
185
- def merge(self, other: "PropSpec"):
186
- conflicts = self.keys() & other.keys()
187
- if conflicts:
188
- conflict_list = ", ".join(sorted(conflicts))
189
- raise ValueError(
190
- f"Conflicting prop definitions for: {conflict_list}. Define each prop only once across explicit params and Unpack[TypedDict]",
191
- )
192
- merged_required = self.required | other.required
193
- merged_optional = self.optional | other.optional
194
- return PropSpec(
195
- required=merged_required,
196
- optional=merged_optional,
197
- allow_unspecified=self.allow_unspecified or other.allow_unspecified,
198
- )
199
-
200
- def apply(self, comp_tag: str, props: dict[str, Any]):
201
- result: dict[str, Any] = {}
202
- known_keys = self.keys()
203
-
204
- # Unknown keys handling (exclude 'key' as it's special)
205
- unknown_keys = props.keys() - known_keys - {"key"}
206
- if not self.allow_unspecified and unknown_keys:
207
- bad = ", ".join(repr(k) for k in sorted(unknown_keys))
208
- raise ValueError(f"Unexpected prop(s) for component '{comp_tag}': {bad}")
209
- if self.allow_unspecified:
210
- for k in unknown_keys:
211
- v = props[k]
212
- if v is not DEFAULT:
213
- result[k] = v
214
-
215
- missing_props: list[str] = []
216
- overlaps: dict[str, list[str]] = defaultdict(list)
217
-
218
- # 1) Apply required props (including their defaults if provided)
219
- for py_key, prop in self.required.items():
220
- p = prop if isinstance(prop, Prop) else Prop(typ_=prop)
221
- if py_key in props:
222
- value = props[py_key]
223
- elif p.default_factory is not None:
224
- value = p.default_factory()
225
- else:
226
- value = p.default
227
-
228
- if value is DEFAULT:
229
- missing_props.append(py_key)
230
- continue
231
-
232
- if p.serialize is not None:
233
- value = p.serialize(value)
234
-
235
- js_key = p.map_to or py_key
236
- if js_key in result:
237
- overlaps[js_key].append(py_key)
238
- continue
239
- result[js_key] = value
240
-
241
- # 2) Apply provided optional props (only those present)
242
- provided_known_optional = props.keys() & self.optional.keys()
243
- for py_key in provided_known_optional:
244
- prop = self.optional[py_key]
245
- p = prop if isinstance(prop, Prop) else Prop(typ_=prop)
246
- value = props[py_key]
247
-
248
- if value is DEFAULT:
249
- # Omit optional prop when DEFAULT sentinel is used
250
- continue
251
-
252
- if p.serialize is not None:
253
- value = p.serialize(value)
254
-
255
- js_key = p.map_to or py_key
256
- if js_key in result:
257
- overlaps[js_key].append(py_key)
258
- continue
259
- result[js_key] = value
260
-
261
- # 3) Apply optional props that have defaults and were not provided
262
- for py_key in self._optional_with_defaults:
263
- if py_key in props:
264
- continue
265
- prop = self.optional[py_key]
266
- p = prop if isinstance(prop, Prop) else Prop(typ_=prop)
267
- if p.default_factory is not None:
268
- value = p.default_factory()
269
- else:
270
- value = p.default
271
-
272
- if value is DEFAULT:
273
- continue
274
-
275
- if p.serialize is not None:
276
- value = p.serialize(value)
277
-
278
- js_key = p.map_to or py_key
279
- if js_key in result:
280
- overlaps[js_key].append(py_key)
281
- continue
282
- result[js_key] = value
283
-
284
- if missing_props or overlaps:
285
- errors: list[str] = []
286
- if missing_props:
287
- errors.append(f"Missing required props: {', '.join(missing_props)}")
288
- if overlaps:
289
- for js_key, py_keys in overlaps.items():
290
- errors.append(
291
- f"Multiple props map to '{js_key}': {', '.join(py_keys)}"
292
- )
293
- raise ValueError(
294
- f"Invalid props for component '{comp_tag}': {'; '.join(errors)}"
295
- )
296
-
297
- return result or None
298
-
299
-
300
- def default_signature(
301
- *children: Child, key: str | None = None, **props: Any
302
- ) -> Element: ...
303
- def default_fn_signature_without_children(
304
- key: str | None = None, **props: Any
305
- ) -> Element: ...
306
-
307
-
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]):
390
- """
391
- A React component that can be used within the UI tree.
392
- Returns a function that creates mount point UITreeNode instances.
393
-
394
- Args:
395
- name: Name of the component (or "default" for default export)
396
- src: Module path to import the component from
397
- is_default: True if this is a default export, else named export
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.)
404
-
405
- Returns:
406
- A function that creates Node instances with mount point tags
407
- """
408
-
409
- import_: Import
410
- props_spec: PropSpec
411
- fn_signature: Callable[P, Element]
412
- lazy: bool
413
- _prop: str | None # Property access like AppShell.Header
414
-
415
- def __init__(
416
- self,
417
- name: str,
418
- src: str,
419
- *,
420
- is_default: bool = False,
421
- prop: str | None = None,
422
- lazy: bool = False,
423
- version: str | None = None,
424
- prop_spec: PropSpec | None = None,
425
- fn_signature: Callable[P, Element] = default_signature,
426
- extra_imports: tuple[Import, ...] | list[Import] | None = None,
427
- ):
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
434
-
435
- # Build props_spec from fn_signature if provided and props not provided
436
- if prop_spec:
437
- self.props_spec = prop_spec
438
- elif fn_signature not in (
439
- default_signature,
440
- default_fn_signature_without_children,
441
- ):
442
- self.props_spec = parse_fn_signature(fn_signature)
443
- else:
444
- self.props_spec = PropSpec({}, {}, allow_unspecified=True)
445
-
446
- self.fn_signature = fn_signature
447
- self.lazy = lazy
448
- # Optional npm semver constraint for this component's package
449
- self.version: str | None = version
450
- # Additional imports to include in route where this component is used
451
- self.extra_imports: list[Import] = list(extra_imports or [])
452
- COMPONENT_REGISTRY.get().add(self)
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
-
506
- @override
507
- def __repr__(self) -> str:
508
- default_part = ", default=True" if self.is_default else ""
509
- prop_part = f", prop='{self.prop}'" if self.prop else ""
510
- lazy_part = ", lazy=True" if self.lazy else ""
511
- props_part = f", props_spec={self.props_spec!r}"
512
- return f"ReactComponent(name='{self.name}', src='{self.src}'{prop_part}{default_part}{lazy_part}{props_part})"
513
-
514
- @override
515
- def __call__(self, *children: P.args, **props: P.kwargs) -> Node: # pyright: ignore[reportIncompatibleMethodOverride]
516
- key = props.get("key")
517
- if key is not None and not isinstance(key, str):
518
- raise ValueError("key must be a string or None")
519
- # Apply optional props specification: fill defaults, enforce required,
520
- # run serializers, and remap keys.
521
- real_props = self.props_spec.apply(self.name, props)
522
- if real_props:
523
- real_props = {key: unwrap(value) for key, value in real_props.items()}
524
-
525
- return Node(
526
- tag=f"$${self.expr}",
527
- key=key,
528
- props=real_props,
529
- children=cast(tuple[Child], children),
530
- )
531
-
532
-
533
- def parse_fn_signature(fn: Callable[..., Any]) -> PropSpec:
534
- """Parse a function signature into a Props spec using a single pass.
535
-
536
- Rules:
537
- - May accept var-positional children `*children` (if annotated, must be Child)
538
- - Must define `key: Optional[str] = None` (keyword-accepting)
539
- - Other props may be explicit keyword params and/or via **props: Unpack[TypedDict]
540
- - A prop may not be specified both explicitly and in the Unpack
541
- - Annotated[..., Prop(...)] on parameters is disallowed (use default Prop instead)
542
- """
543
-
544
- # If annotations are stringified, skip building and allow unspecified props.
545
- if _function_has_string_annotations(fn):
546
- return PropSpec({}, {}, allow_unspecified=True)
547
-
548
- sig = inspect.signature(fn)
549
- params = list(sig.parameters.values())
550
-
551
- explicit_required: dict[str, Prop[Any]] = {}
552
- explicit_optional: dict[str, Prop[Any]] = {}
553
- explicit_order: list[str] = []
554
- explicit_spec: PropSpec
555
- unpack_spec: PropSpec
556
-
557
- var_positional: Parameter | None = None
558
- var_kw: Parameter | None = None
559
- key: Parameter | None = None
560
-
561
- # One pass: collect structure and build explicit spec as we go
562
- for p in params:
563
- # Disallow positional-only parameters
564
- if p.kind is Parameter.POSITIONAL_ONLY:
565
- raise ValueError(
566
- "Function must not declare positional-only parameters besides *children",
567
- )
568
-
569
- if p.kind is Parameter.VAR_POSITIONAL:
570
- var_positional = p
571
- continue
572
-
573
- if p.kind is Parameter.VAR_KEYWORD:
574
- var_kw = p
575
- continue
576
-
577
- if p.name == "key":
578
- key = p
579
- continue
580
-
581
- # For regular params, forbid additional required positionals
582
- if p.kind is Parameter.POSITIONAL_OR_KEYWORD and p.default is Parameter.empty:
583
- raise ValueError(
584
- "Function signature must not declare additional required positional parameters; only *children is allowed for positionals",
585
- )
586
-
587
- if p.kind not in (
588
- Parameter.POSITIONAL_OR_KEYWORD,
589
- Parameter.KEYWORD_ONLY,
590
- ):
591
- continue
592
-
593
- # Build explicit spec (skip 'key' handled above)
594
- annotation = p.annotation if p.annotation is not Parameter.empty else Any
595
- origin = get_origin(annotation)
596
- annotation_args = get_args(annotation)
597
-
598
- # Disallow Annotated[..., Prop(...)] on parameters
599
- if (
600
- origin is Annotated
601
- and annotation_args
602
- and any(isinstance(m, Prop) for m in annotation_args[1:])
603
- ):
604
- raise TypeError(
605
- "Annotated[..., ps.prop(...)] is not allowed on function parameters; use a default `= ps.prop(...)` or a TypedDict",
606
- )
607
-
608
- runtime_type = _annotation_to_runtime_type(
609
- annotation_args[0]
610
- if origin is Annotated and annotation_args
611
- else annotation
612
- )
613
-
614
- if isinstance(p.default, Prop):
615
- prop = p.default
616
- if prop.typ_ is None:
617
- prop.typ_ = runtime_type
618
- # Has default via Prop -> optional
619
- explicit_optional[p.name] = prop
620
- explicit_order.append(p.name)
621
- elif p.default is not Parameter.empty:
622
- prop = Prop(default=p.default, required=False, typ_=runtime_type)
623
- explicit_optional[p.name] = prop
624
- explicit_order.append(p.name)
625
- else:
626
- prop = Prop(typ_=runtime_type)
627
- # No default -> required
628
- prop.required = True
629
- explicit_required[p.name] = prop
630
- explicit_order.append(p.name)
631
-
632
- explicit_spec = PropSpec(
633
- explicit_required,
634
- explicit_optional,
635
- )
636
-
637
- # Validate *children annotation if present
638
- if var_positional is not None:
639
- annotation = var_positional.annotation
640
- if (
641
- annotation is not Parameter.empty
642
- and annotation is not Child
643
- and not _is_any_annotation(annotation)
644
- ):
645
- raise TypeError(
646
- f"*{var_positional.name} must be annotated as `*{var_positional.name}: ps.Child`"
647
- )
648
-
649
- # Validate `key`` argument
650
- if key is None:
651
- raise ValueError("Function must define a `key: str | None = None` parameter")
652
- if key.default is not None:
653
- raise ValueError("'key' parameter must default to None")
654
- if key.kind not in (
655
- Parameter.KEYWORD_ONLY,
656
- Parameter.POSITIONAL_OR_KEYWORD,
657
- ):
658
- raise ValueError("'key' parameter must be a keyword argument")
659
-
660
- # Parse **props as Unpack[TypedDict]
661
- unpack_spec = parse_typed_dict_props(var_kw)
662
-
663
- return unpack_spec.merge(explicit_spec)
664
-
665
-
666
- class ComponentRegistry:
667
- """A registry for React components that can be used as a context manager."""
668
-
669
- _token: Any
670
-
671
- def __init__(self):
672
- self.components: list[ReactComponent[...]] = []
673
- self._token = None
674
-
675
- def add(self, component: ReactComponent[...]):
676
- """Adds a component to the registry."""
677
- self.components.append(component)
678
-
679
- def clear(self):
680
- self.components.clear()
681
-
682
- def __enter__(self) -> "ComponentRegistry":
683
- self._token = COMPONENT_REGISTRY.set(self)
684
- return self
685
-
686
- def __exit__(
687
- self,
688
- exc_type: type[BaseException] | None,
689
- exc_val: BaseException | None,
690
- exc_tb: Any,
691
- ) -> Literal[False]:
692
- if self._token:
693
- COMPONENT_REGISTRY.reset(self._token)
694
- self._token = None
695
- return False
696
-
697
-
698
- COMPONENT_REGISTRY: ContextVar[ComponentRegistry] = ContextVar(
699
- "component_registry",
700
- default=ComponentRegistry(), # noqa: B039
701
- )
702
-
703
-
704
- def registered_react_components():
705
- """Get all registered React components."""
706
- return COMPONENT_REGISTRY.get().components
707
-
708
-
709
- # ----------------------------------------------------------------------------
710
- # Utilities: Build Props specs from TypedDict definitions
711
- # ----------------------------------------------------------------------------
712
-
713
-
714
- def _is_typeddict_type(cls: type) -> bool:
715
- """Best-effort detection for TypedDict types across Python versions."""
716
- return isinstance(getattr(cls, "__annotations__", None), dict) and getattr(
717
- cls, "__total__", None
718
- ) in (True, False)
719
-
720
-
721
- def _unwrap_required_notrequired(annotation: Any) -> tuple[Any, bool | None]:
722
- """
723
- If annotation is typing.Required[T] or typing.NotRequired[T], return (T, required?).
724
- Otherwise return (annotation, None).
725
- """
726
-
727
- origin = get_origin(annotation)
728
- if origin is typing.Required:
729
- args = get_args(annotation)
730
- inner = args[0] if args else Any
731
- return inner, True
732
- if origin is typing.NotRequired:
733
- args = get_args(annotation)
734
- inner = args[0] if args else Any
735
- return inner, False
736
- return annotation, None
737
-
738
-
739
- @cache
740
- def _annotation_to_runtime_type(annotation: Any) -> type | tuple[type, ...]:
741
- """
742
- Convert a typing annotation into a runtime-checkable class or tuple of classes
743
- suitable for isinstance(). This is intentionally lossy but practical.
744
- """
745
- # Unwrap Required/NotRequired
746
- annotation, _ = _unwrap_required_notrequired(annotation)
747
-
748
- origin = get_origin(annotation)
749
- args = get_args(annotation)
750
-
751
- # Any -> accept anything
752
- if annotation is Any:
753
- return object
754
-
755
- # Annotated[T, ...] -> T
756
- if origin is Annotated and args:
757
- return _annotation_to_runtime_type(args[0])
758
-
759
- # Optional[T] / Union[...] / X | Y
760
- if origin is UnionType:
761
- # Fallback for some Python versions where get_origin may be odd
762
- union_args = args or getattr(annotation, "__args__", ())
763
- runtime_types: list[type] = []
764
- for a in union_args:
765
- rt = _annotation_to_runtime_type(a)
766
- if isinstance(rt, tuple):
767
- runtime_types.extend(rt)
768
- elif isinstance(rt, type):
769
- runtime_types.append(rt)
770
- # Deduplicate while preserving order
771
- out: list[type] = []
772
- for t in runtime_types:
773
- if t not in out:
774
- out.append(t)
775
- return tuple(out) if len(out) > 1 else (out[0] if out else object)
776
-
777
- # Literal[...] -> base types of provided literals
778
- if origin is Literal:
779
- literal_types: set[type] = {type(v) for v in args}
780
- # None appears as NoneType
781
- if len(literal_types) == 0:
782
- return object
783
- if len(literal_types) == 1:
784
- return next(iter(literal_types))
785
- return tuple(literal_types)
786
-
787
- # Parametrized containers -> use their builtin origins for isinstance
788
- if origin in (list, dict, set, tuple):
789
- return cast(type | tuple[type, ...], origin)
790
-
791
- # TypedDict nested -> treat as dict
792
- if isinstance(annotation, type) and _is_typeddict_type(annotation):
793
- return dict
794
-
795
- # Direct classes
796
- if isinstance(annotation, type):
797
- return annotation
798
-
799
- # Fallback: accept anything
800
- return object
801
-
802
-
803
- def _extract_prop_from_annotated(annotation: Any) -> tuple[Any, Prop[Any] | None]:
804
- """
805
- If annotation is Annotated[T, ...] and any metadata item is a Prop, return (T, Prop).
806
- Otherwise return (annotation, None).
807
- """
808
- origin = get_origin(annotation)
809
- args = get_args(annotation)
810
- if origin is Annotated and args:
811
- base = args[0]
812
- meta = args[1:]
813
- for m in meta:
814
- if isinstance(m, Prop):
815
- return base, m
816
- return annotation, None
817
-
818
-
819
- def _clone_prop(p: Prop[Any]) -> Prop[Any]:
820
- """Shallow clone a Prop to avoid sharing instances across cached specs."""
821
- return Prop(
822
- default=p.default,
823
- required=p.required,
824
- default_factory=p.default_factory,
825
- serialize=p.serialize,
826
- map_to=p.map_to,
827
- typ_=p.typ_,
828
- )
829
-
830
-
831
- def _typed_dict_bases(typed_dict_cls: type) -> list[type]:
832
- return [
833
- b for b in getattr(typed_dict_cls, "__bases__", ()) if _is_typeddict_type(b)
834
- ]
835
-
836
-
837
- @cache
838
- def prop_spec_from_typeddict(typed_dict_cls: type) -> PropSpec:
839
- """Build and cache a Props spec from a TypedDict class tree.
840
-
841
- Caches by the TypedDict class object (stable and hashable). This speeds up
842
- repeated reuse of common props like HTMLProps and HTMLAnchorProps across many
843
- component definitions.
844
- """
845
- annotations: dict[str, Any] = getattr(typed_dict_cls, "__annotations__", {})
846
- # If TypedDict annotations are stringified, skip building and allow unspecified.
847
- if annotations and any(isinstance(v, str) for v in annotations.values()):
848
- return PropSpec({}, {}, allow_unspecified=True)
849
- required_keys: set[str] | None = getattr(typed_dict_cls, "__required_keys__", None)
850
- is_total: bool = bool(getattr(typed_dict_cls, "__total__", True))
851
-
852
- # 1) Merge cached specs from TypedDict base classes (preserve insertion order)
853
- merged: dict[str, Prop[Any]] = {}
854
- for base in _typed_dict_bases(typed_dict_cls):
855
- base_spec = prop_spec_from_typeddict(base)
856
- for k, p in base_spec.as_dict().items():
857
- merged[k] = _clone_prop(p)
858
-
859
- # 2) Add/override keys declared locally on this class in definition order.
860
- # If a key exists in a base, this intentionally shadows the base entry.
861
- for key in annotations.keys():
862
- annotation = annotations[key]
863
- # First see if runtime provides explicit required/optional wrappers
864
- annotation, _annotation_required = _unwrap_required_notrequired(annotation)
865
- # Extract Prop metadata from Annotated if present
866
- annotation, annotation_prop = _extract_prop_from_annotated(annotation)
867
-
868
- runtime_type = _annotation_to_runtime_type(annotation)
869
- prop = annotation_prop or Prop()
870
- if prop.required is not DEFAULT:
871
- raise TypeError(
872
- "Use total=True + NotRequired[T] or total=False + Required[T] to define required and optional props within a TypedDict"
873
- )
874
- prop.typ_ = runtime_type
875
- merged[key] = prop
876
-
877
- # 3) Finalize required flags per this class's semantics
878
- if required_keys is not None:
879
- for k, p in merged.items():
880
- p.required = k in required_keys
881
- else:
882
- # Fallback: infer via wrappers if available; otherwise default to class total
883
- for k, p in merged.items():
884
- ann = annotations.get(k, None)
885
- if ann is not None:
886
- _, req = _unwrap_required_notrequired(ann)
887
- if req is not None:
888
- p.required = req
889
- continue
890
- p.required = is_total
891
-
892
- # Split into required/optional
893
- required: dict[str, Prop[Any]] = {}
894
- optional: dict[str, Prop[Any]] = {}
895
- for k, p in merged.items():
896
- if p.required is True:
897
- required[k] = p
898
- else:
899
- optional[k] = p
900
-
901
- return PropSpec(required, optional)
902
-
903
-
904
- def parse_typed_dict_props(var_kw: Parameter | None) -> PropSpec:
905
- """
906
- Build a Props spec from a TypedDict class.
907
-
908
- - Required vs optional is inferred from __required_keys__/__optional_keys__ when
909
- available, otherwise from Required/NotRequired wrappers or the class __total__.
910
- - Types are converted to runtime-checkable types for isinstance checks.
911
- """
912
- # No **props -> no keyword arguments defined here
913
- if not var_kw:
914
- return PropSpec({}, {})
915
-
916
- # Untyped **props -> allow all
917
- annot = var_kw.annotation
918
- if annot in (None, Parameter.empty):
919
- return PropSpec({}, {}, allow_unspecified=True)
920
- if _is_any_annotation(annot):
921
- return PropSpec({}, {}, allow_unspecified=True)
922
- # Stringified annotations like "Unpack[Props]" are too fragile to parse.
923
- if isinstance(annot, str):
924
- return PropSpec({}, {}, allow_unspecified=True)
925
-
926
- # From here, we should have **props: Unpack[MyProps] where MyProps is a TypedDict
927
- origin = get_origin(annot)
928
- if origin is not Unpack:
929
- raise TypeError(
930
- "**props must be annotated as typing.Unpack[Props] where Props is a TypedDict"
931
- )
932
- unpack_args = get_args(annot)
933
- if not unpack_args:
934
- raise TypeError("Unpack must wrap a TypedDict class, e.g., Unpack[MyProps]")
935
- typed_arg = unpack_args[0]
936
-
937
- # Handle parameterized TypedDicts like MyProps[T] or MyProps[int]
938
- # typing.get_origin returns the underlying class for parameterized generics
939
- origin_td = get_origin(typed_arg) or typed_arg
940
-
941
- if not isinstance(origin_td, type) or not _is_typeddict_type(origin_td):
942
- raise TypeError("Unpack must wrap a TypedDict class, e.g., Unpack[MyProps]")
943
-
944
- # NOTE: For TypedDicts, the annotations contain the fields of all classes in
945
- # the hierarchy, we don't need to walk the MRO. Use cached builder.
946
- return prop_spec_from_typeddict(origin_td)
947
-
948
-
949
- # ----------------------------------------------------------------------------
950
- # Public decorator: define a wrapped React component from a Python function
951
- # ----------------------------------------------------------------------------
952
-
953
-
954
- def react_component(
955
- name: str | Literal["default"],
956
- src: str,
957
- *,
958
- prop: str | None = None,
959
- is_default: bool = False,
960
- lazy: bool = False,
961
- version: str | None = None,
962
- extra_imports: list[Import] | None = None,
963
- ) -> Callable[[Callable[P, None] | Callable[P, Element]], ReactComponent[P]]:
964
- """
965
- Decorator to define a React component wrapper. The decorated function is
966
- passed to `ReactComponent`, which parses and validates its signature.
967
-
968
- Args:
969
- tag: Name of the component (or "default" for default export)
970
- import_: Module path to import the component from
971
- property: Optional property name to access the component from the imported object
972
- is_default: True if this is a default export, else named export
973
- lazy: Whether to lazy load the component
974
- """
975
-
976
- def decorator(fn: Callable[P, None] | Callable[P, Element]) -> ReactComponent[P]:
977
- return ReactComponent(
978
- name=name,
979
- src=src,
980
- prop=prop,
981
- is_default=is_default,
982
- lazy=lazy,
983
- version=version,
984
- fn_signature=fn,
985
- extra_imports=extra_imports,
986
- )
987
-
988
- return decorator
989
-
990
-
991
- # ----------------------------------------------------------------------------
992
- # Helpers for display of runtime types
993
- # ----------------------------------------------------------------------------
994
-
995
-
996
- def _format_runtime_type(t: type | tuple[type, ...]) -> str:
997
- if isinstance(t, tuple):
998
- return "(" + ", ".join(_format_runtime_type(x) for x in t) + ")"
999
- if isinstance(t, type):
1000
- return getattr(t, "__name__", repr(t))
1001
- return repr(t)
5
+ __all__ = ["react_component", "default_signature"]