pulse-framework 0.1.51__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 (84) 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 -1001
  47. pulse/render_session.py +74 -66
  48. pulse/renderer.py +311 -238
  49. pulse/routing.py +1 -10
  50. pulse/transpiler/__init__.py +84 -114
  51. pulse/transpiler/builtins.py +661 -343
  52. pulse/transpiler/errors.py +78 -2
  53. pulse/transpiler/function.py +463 -133
  54. pulse/transpiler/id.py +18 -0
  55. pulse/transpiler/imports.py +230 -325
  56. pulse/transpiler/js_module.py +218 -209
  57. pulse/transpiler/modules/__init__.py +16 -13
  58. pulse/transpiler/modules/asyncio.py +45 -26
  59. pulse/transpiler/modules/json.py +12 -8
  60. pulse/transpiler/modules/math.py +161 -216
  61. pulse/transpiler/modules/pulse/__init__.py +5 -0
  62. pulse/transpiler/modules/pulse/tags.py +231 -0
  63. pulse/transpiler/modules/typing.py +33 -28
  64. pulse/transpiler/nodes.py +1607 -923
  65. pulse/transpiler/py_module.py +118 -95
  66. pulse/transpiler/react_component.py +51 -0
  67. pulse/transpiler/transpiler.py +593 -437
  68. pulse/transpiler/vdom.py +255 -0
  69. {pulse_framework-0.1.51.dist-info → pulse_framework-0.1.52.dist-info}/METADATA +1 -1
  70. pulse_framework-0.1.52.dist-info/RECORD +120 -0
  71. pulse/html/tags.pyi +0 -470
  72. pulse/transpiler/constants.py +0 -110
  73. pulse/transpiler/context.py +0 -26
  74. pulse/transpiler/ids.py +0 -16
  75. pulse/transpiler/modules/re.py +0 -466
  76. pulse/transpiler/modules/tags.py +0 -268
  77. pulse/transpiler/utils.py +0 -4
  78. pulse/vdom.py +0 -599
  79. pulse_framework-0.1.51.dist-info/RECORD +0 -119
  80. /pulse/{html → dom}/__init__.py +0 -0
  81. /pulse/{html → dom}/elements.py +0 -0
  82. /pulse/{html → dom}/svg.py +0 -0
  83. {pulse_framework-0.1.51.dist-info → pulse_framework-0.1.52.dist-info}/WHEEL +0 -0
  84. {pulse_framework-0.1.51.dist-info → pulse_framework-0.1.52.dist-info}/entry_points.txt +0 -0
pulse/react_component.py CHANGED
@@ -1,1003 +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, clean_element_name
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
- clean_tag = clean_element_name(comp_tag)
209
- raise ValueError(f"Unexpected prop(s) for component '{clean_tag}': {bad}")
210
- if self.allow_unspecified:
211
- for k in unknown_keys:
212
- v = props[k]
213
- if v is not DEFAULT:
214
- result[k] = v
215
-
216
- missing_props: list[str] = []
217
- overlaps: dict[str, list[str]] = defaultdict(list)
218
-
219
- # 1) Apply required props (including their defaults if provided)
220
- for py_key, prop in self.required.items():
221
- p = prop if isinstance(prop, Prop) else Prop(typ_=prop)
222
- if py_key in props:
223
- value = props[py_key]
224
- elif p.default_factory is not None:
225
- value = p.default_factory()
226
- else:
227
- value = p.default
228
-
229
- if value is DEFAULT:
230
- missing_props.append(py_key)
231
- continue
232
-
233
- if p.serialize is not None:
234
- value = p.serialize(value)
235
-
236
- js_key = p.map_to or py_key
237
- if js_key in result:
238
- overlaps[js_key].append(py_key)
239
- continue
240
- result[js_key] = value
241
-
242
- # 2) Apply provided optional props (only those present)
243
- provided_known_optional = props.keys() & self.optional.keys()
244
- for py_key in provided_known_optional:
245
- prop = self.optional[py_key]
246
- p = prop if isinstance(prop, Prop) else Prop(typ_=prop)
247
- value = props[py_key]
248
-
249
- if value is DEFAULT:
250
- # Omit optional prop when DEFAULT sentinel is used
251
- continue
252
-
253
- if p.serialize is not None:
254
- value = p.serialize(value)
255
-
256
- js_key = p.map_to or py_key
257
- if js_key in result:
258
- overlaps[js_key].append(py_key)
259
- continue
260
- result[js_key] = value
261
-
262
- # 3) Apply optional props that have defaults and were not provided
263
- for py_key in self._optional_with_defaults:
264
- if py_key in props:
265
- continue
266
- prop = self.optional[py_key]
267
- p = prop if isinstance(prop, Prop) else Prop(typ_=prop)
268
- if p.default_factory is not None:
269
- value = p.default_factory()
270
- else:
271
- value = p.default
272
-
273
- if value is DEFAULT:
274
- continue
275
-
276
- if p.serialize is not None:
277
- value = p.serialize(value)
278
-
279
- js_key = p.map_to or py_key
280
- if js_key in result:
281
- overlaps[js_key].append(py_key)
282
- continue
283
- result[js_key] = value
284
-
285
- if missing_props or overlaps:
286
- errors: list[str] = []
287
- if missing_props:
288
- errors.append(f"Missing required props: {', '.join(missing_props)}")
289
- if overlaps:
290
- for js_key, py_keys in overlaps.items():
291
- errors.append(
292
- f"Multiple props map to '{js_key}': {', '.join(py_keys)}"
293
- )
294
- clean_tag = clean_element_name(comp_tag)
295
- raise ValueError(
296
- f"Invalid props for component '{clean_tag}': {'; '.join(errors)}"
297
- )
298
-
299
- return result or None
300
-
301
-
302
- def default_signature(
303
- *children: Child, key: str | None = None, **props: Any
304
- ) -> Element: ...
305
- def default_fn_signature_without_children(
306
- key: str | None = None, **props: Any
307
- ) -> Element: ...
308
-
309
-
310
- # ----------------------------------------------------------------------------
311
- # JSX transpilation helpers
312
- # ----------------------------------------------------------------------------
313
-
314
-
315
- def _build_jsx_props(kwargs: dict[str, Any]) -> list[JSXProp | JSXSpreadProp]:
316
- """Build JSX props list from kwargs dict.
317
-
318
- Kwargs maps:
319
- - "propName" -> value for named props
320
- - "__spread_N" -> JSSpread(expr) for spread props
321
- """
322
- props: list[JSXProp | JSXSpreadProp] = []
323
- for key, value in kwargs.items():
324
- if isinstance(value, JSSpread):
325
- props.append(JSXSpreadProp(value.expr))
326
- else:
327
- props.append(JSXProp(key, JSExpr.of(value)))
328
- return props
329
-
330
-
331
- def _flatten_children(items: list[Any], out: list[JSExpr | JSXElement | str]) -> None:
332
- """Flatten arrays and handle spreads in children list."""
333
- from pulse.transpiler.nodes import JSArray, JSString
334
-
335
- for it in items:
336
- # Convert raw values first
337
- it = JSExpr.of(it) if not isinstance(it, JSExpr) else it
338
- if isinstance(it, JSArray):
339
- _flatten_children(list(it.elements), out)
340
- elif isinstance(it, JSSpread):
341
- out.append(it.expr)
342
- elif isinstance(it, JSString):
343
- out.append(it.value)
344
- else:
345
- out.append(it)
346
-
347
-
348
- class ReactComponentCallExpr(JSExpr):
349
- """JSX call expression for a ReactComponent.
350
-
351
- Created when a ReactComponent is called with props. Supports subscripting
352
- to add children, producing the final JSXElement.
353
- """
354
-
355
- is_jsx: ClassVar[bool] = True
356
- component: "ReactComponent[...]"
357
- props: tuple[JSXProp | JSXSpreadProp, ...]
358
- children: tuple[str | JSExpr | JSXElement, ...]
359
-
360
- def __init__(
361
- self,
362
- component: "ReactComponent[...]",
363
- props: tuple[JSXProp | JSXSpreadProp, ...],
364
- children: tuple[str | JSExpr | JSXElement, ...],
365
- ) -> None:
366
- self.component = component
367
- self.props = props
368
- self.children = children
369
-
370
- @override
371
- def emit(self) -> str:
372
- return JSXElement(self.component, self.props, self.children).emit()
373
-
374
- @override
375
- def emit_subscript(self, indices: list[Any]) -> JSExpr:
376
- """Handle Component(props...)[children] -> JSXElement."""
377
- extra_children: list[JSExpr | JSXElement | str] = []
378
- _flatten_children(indices, extra_children)
379
- all_children = list(self.children) + extra_children
380
- return JSXElement(self.component, self.props, all_children)
381
-
382
- @override
383
- def emit_call(self, args: list[Any], kwargs: dict[str, Any]) -> JSExpr:
384
- """Calling an already-called component is an error."""
385
- raise JSCompilationError(
386
- f"Cannot call <{self.component.name}> - already called. "
387
- + "Use subscript for children: Component(props...)[children]"
388
- )
389
-
390
-
391
- class ReactComponent(JSExpr, Generic[P]):
392
- """
393
- A React component that can be used within the UI tree.
394
- Returns a function that creates mount point UITreeNode instances.
395
-
396
- Args:
397
- name: Name of the component (or "default" for default export)
398
- src: Module path to import the component from
399
- is_default: True if this is a default export, else named export
400
- prop: Optional property name to access the component from the imported object
401
- lazy: Whether to lazy load the component
402
- version: Optional npm semver constraint for this component's package
403
- prop_spec: Optional PropSpec for the component
404
- fn_signature: Function signature to parse for props
405
- extra_imports: Additional imports to include (CSS files, etc.)
406
-
407
- Returns:
408
- A function that creates Node instances with mount point tags
409
- """
410
-
411
- import_: Import
412
- props_spec: PropSpec
413
- fn_signature: Callable[P, Element]
414
- lazy: bool
415
- _prop: str | None # Property access like AppShell.Header
416
-
417
- def __init__(
418
- self,
419
- name: str,
420
- src: str,
421
- *,
422
- is_default: bool = False,
423
- prop: str | None = None,
424
- lazy: bool = False,
425
- version: str | None = None,
426
- prop_spec: PropSpec | None = None,
427
- fn_signature: Callable[P, Element] = default_signature,
428
- extra_imports: tuple[Import, ...] | list[Import] | None = None,
429
- ):
430
- # Create the Import directly (prop is stored separately on ReactComponent)
431
- if is_default:
432
- self.import_ = Import.default(name, src)
433
- else:
434
- self.import_ = Import.named(name, src)
435
- self._prop = prop
436
-
437
- # Build props_spec from fn_signature if provided and props not provided
438
- if prop_spec:
439
- self.props_spec = prop_spec
440
- elif fn_signature not in (
441
- default_signature,
442
- default_fn_signature_without_children,
443
- ):
444
- self.props_spec = parse_fn_signature(fn_signature)
445
- else:
446
- self.props_spec = PropSpec({}, {}, allow_unspecified=True)
447
-
448
- self.fn_signature = fn_signature
449
- self.lazy = lazy
450
- # Optional npm semver constraint for this component's package
451
- self.version: str | None = version
452
- # Additional imports to include in route where this component is used
453
- self.extra_imports: list[Import] = list(extra_imports or [])
454
- COMPONENT_REGISTRY.get().add(self)
455
-
456
- @override
457
- def emit(self) -> str:
458
- if self.prop:
459
- return JSMember(self.import_, self.prop).emit()
460
- return self.import_.emit()
461
-
462
- @override
463
- def emit_call(self, args: list[Any], kwargs: dict[str, Any]) -> JSExpr:
464
- """Handle Component(props...) -> ReactComponentCallExpr."""
465
- props_list = _build_jsx_props(kwargs)
466
- children_list: list[JSExpr | JSXElement | str] = []
467
- _flatten_children(args, children_list)
468
- return ReactComponentCallExpr(self, tuple(props_list), tuple(children_list))
469
-
470
- @override
471
- def emit_subscript(self, indices: list[Any]) -> JSExpr:
472
- """Direct subscript on ReactComponent is not allowed.
473
-
474
- Use Component(props...)[children] instead of Component[children].
475
- """
476
- raise JSCompilationError(
477
- f"Cannot subscript ReactComponent '{self.name}' directly. "
478
- + "Use Component(props...)[children] or Component()[children] instead."
479
- )
480
-
481
- @property
482
- def name(self) -> str:
483
- return self.import_.name
484
-
485
- @property
486
- def src(self) -> str:
487
- return self.import_.src
488
-
489
- @property
490
- def is_default(self) -> bool:
491
- return self.import_.is_default
492
-
493
- @property
494
- def prop(self) -> str | None:
495
- return self._prop
496
-
497
- @property
498
- def expr(self) -> str:
499
- """Expression for the component in the registry and VDOM tags.
500
-
501
- Uses the import's js_name (with unique ID suffix) to match the
502
- unified registry on the client side.
503
- """
504
- if self.prop:
505
- return f"{self.import_.js_name}.{self.prop}"
506
- return self.import_.js_name
507
-
508
- @override
509
- def __repr__(self) -> str:
510
- default_part = ", default=True" if self.is_default else ""
511
- prop_part = f", prop='{self.prop}'" if self.prop else ""
512
- lazy_part = ", lazy=True" if self.lazy else ""
513
- props_part = f", props_spec={self.props_spec!r}"
514
- return f"ReactComponent(name='{self.name}', src='{self.src}'{prop_part}{default_part}{lazy_part}{props_part})"
515
-
516
- @override
517
- def __call__(self, *children: P.args, **props: P.kwargs) -> Node: # pyright: ignore[reportIncompatibleMethodOverride]
518
- key = props.get("key")
519
- if key is not None and not isinstance(key, str):
520
- raise ValueError("key must be a string or None")
521
- # Apply optional props specification: fill defaults, enforce required,
522
- # run serializers, and remap keys.
523
- real_props = self.props_spec.apply(self.name, props)
524
- if real_props:
525
- real_props = {key: unwrap(value) for key, value in real_props.items()}
526
-
527
- return Node(
528
- tag=f"$${self.expr}",
529
- key=key,
530
- props=real_props,
531
- children=cast(tuple[Child], children),
532
- )
533
-
534
-
535
- def parse_fn_signature(fn: Callable[..., Any]) -> PropSpec:
536
- """Parse a function signature into a Props spec using a single pass.
537
-
538
- Rules:
539
- - May accept var-positional children `*children` (if annotated, must be Child)
540
- - Must define `key: Optional[str] = None` (keyword-accepting)
541
- - Other props may be explicit keyword params and/or via **props: Unpack[TypedDict]
542
- - A prop may not be specified both explicitly and in the Unpack
543
- - Annotated[..., Prop(...)] on parameters is disallowed (use default Prop instead)
544
- """
545
-
546
- # If annotations are stringified, skip building and allow unspecified props.
547
- if _function_has_string_annotations(fn):
548
- return PropSpec({}, {}, allow_unspecified=True)
549
-
550
- sig = inspect.signature(fn)
551
- params = list(sig.parameters.values())
552
-
553
- explicit_required: dict[str, Prop[Any]] = {}
554
- explicit_optional: dict[str, Prop[Any]] = {}
555
- explicit_order: list[str] = []
556
- explicit_spec: PropSpec
557
- unpack_spec: PropSpec
558
-
559
- var_positional: Parameter | None = None
560
- var_kw: Parameter | None = None
561
- key: Parameter | None = None
562
-
563
- # One pass: collect structure and build explicit spec as we go
564
- for p in params:
565
- # Disallow positional-only parameters
566
- if p.kind is Parameter.POSITIONAL_ONLY:
567
- raise ValueError(
568
- "Function must not declare positional-only parameters besides *children",
569
- )
570
-
571
- if p.kind is Parameter.VAR_POSITIONAL:
572
- var_positional = p
573
- continue
574
-
575
- if p.kind is Parameter.VAR_KEYWORD:
576
- var_kw = p
577
- continue
578
-
579
- if p.name == "key":
580
- key = p
581
- continue
582
-
583
- # For regular params, forbid additional required positionals
584
- if p.kind is Parameter.POSITIONAL_OR_KEYWORD and p.default is Parameter.empty:
585
- raise ValueError(
586
- "Function signature must not declare additional required positional parameters; only *children is allowed for positionals",
587
- )
588
-
589
- if p.kind not in (
590
- Parameter.POSITIONAL_OR_KEYWORD,
591
- Parameter.KEYWORD_ONLY,
592
- ):
593
- continue
594
-
595
- # Build explicit spec (skip 'key' handled above)
596
- annotation = p.annotation if p.annotation is not Parameter.empty else Any
597
- origin = get_origin(annotation)
598
- annotation_args = get_args(annotation)
599
-
600
- # Disallow Annotated[..., Prop(...)] on parameters
601
- if (
602
- origin is Annotated
603
- and annotation_args
604
- and any(isinstance(m, Prop) for m in annotation_args[1:])
605
- ):
606
- raise TypeError(
607
- "Annotated[..., ps.prop(...)] is not allowed on function parameters; use a default `= ps.prop(...)` or a TypedDict",
608
- )
609
-
610
- runtime_type = _annotation_to_runtime_type(
611
- annotation_args[0]
612
- if origin is Annotated and annotation_args
613
- else annotation
614
- )
615
-
616
- if isinstance(p.default, Prop):
617
- prop = p.default
618
- if prop.typ_ is None:
619
- prop.typ_ = runtime_type
620
- # Has default via Prop -> optional
621
- explicit_optional[p.name] = prop
622
- explicit_order.append(p.name)
623
- elif p.default is not Parameter.empty:
624
- prop = Prop(default=p.default, required=False, typ_=runtime_type)
625
- explicit_optional[p.name] = prop
626
- explicit_order.append(p.name)
627
- else:
628
- prop = Prop(typ_=runtime_type)
629
- # No default -> required
630
- prop.required = True
631
- explicit_required[p.name] = prop
632
- explicit_order.append(p.name)
633
-
634
- explicit_spec = PropSpec(
635
- explicit_required,
636
- explicit_optional,
637
- )
638
-
639
- # Validate *children annotation if present
640
- if var_positional is not None:
641
- annotation = var_positional.annotation
642
- if (
643
- annotation is not Parameter.empty
644
- and annotation is not Child
645
- and not _is_any_annotation(annotation)
646
- ):
647
- raise TypeError(
648
- f"*{var_positional.name} must be annotated as `*{var_positional.name}: ps.Child`"
649
- )
650
-
651
- # Validate `key`` argument
652
- if key is None:
653
- raise ValueError("Function must define a `key: str | None = None` parameter")
654
- if key.default is not None:
655
- raise ValueError("'key' parameter must default to None")
656
- if key.kind not in (
657
- Parameter.KEYWORD_ONLY,
658
- Parameter.POSITIONAL_OR_KEYWORD,
659
- ):
660
- raise ValueError("'key' parameter must be a keyword argument")
661
-
662
- # Parse **props as Unpack[TypedDict]
663
- unpack_spec = parse_typed_dict_props(var_kw)
664
-
665
- return unpack_spec.merge(explicit_spec)
666
-
667
-
668
- class ComponentRegistry:
669
- """A registry for React components that can be used as a context manager."""
670
-
671
- _token: Any
672
-
673
- def __init__(self):
674
- self.components: list[ReactComponent[...]] = []
675
- self._token = None
676
-
677
- def add(self, component: ReactComponent[...]):
678
- """Adds a component to the registry."""
679
- self.components.append(component)
680
-
681
- def clear(self):
682
- self.components.clear()
683
-
684
- def __enter__(self) -> "ComponentRegistry":
685
- self._token = COMPONENT_REGISTRY.set(self)
686
- return self
687
-
688
- def __exit__(
689
- self,
690
- exc_type: type[BaseException] | None,
691
- exc_val: BaseException | None,
692
- exc_tb: Any,
693
- ) -> Literal[False]:
694
- if self._token:
695
- COMPONENT_REGISTRY.reset(self._token)
696
- self._token = None
697
- return False
698
-
699
-
700
- COMPONENT_REGISTRY: ContextVar[ComponentRegistry] = ContextVar(
701
- "component_registry",
702
- default=ComponentRegistry(), # noqa: B039
703
- )
704
-
705
-
706
- def registered_react_components():
707
- """Get all registered React components."""
708
- return COMPONENT_REGISTRY.get().components
709
-
710
-
711
- # ----------------------------------------------------------------------------
712
- # Utilities: Build Props specs from TypedDict definitions
713
- # ----------------------------------------------------------------------------
714
-
715
-
716
- def _is_typeddict_type(cls: type) -> bool:
717
- """Best-effort detection for TypedDict types across Python versions."""
718
- return isinstance(getattr(cls, "__annotations__", None), dict) and getattr(
719
- cls, "__total__", None
720
- ) in (True, False)
721
-
722
-
723
- def _unwrap_required_notrequired(annotation: Any) -> tuple[Any, bool | None]:
724
- """
725
- If annotation is typing.Required[T] or typing.NotRequired[T], return (T, required?).
726
- Otherwise return (annotation, None).
727
- """
728
-
729
- origin = get_origin(annotation)
730
- if origin is typing.Required:
731
- args = get_args(annotation)
732
- inner = args[0] if args else Any
733
- return inner, True
734
- if origin is typing.NotRequired:
735
- args = get_args(annotation)
736
- inner = args[0] if args else Any
737
- return inner, False
738
- return annotation, None
739
-
740
-
741
- @cache
742
- def _annotation_to_runtime_type(annotation: Any) -> type | tuple[type, ...]:
743
- """
744
- Convert a typing annotation into a runtime-checkable class or tuple of classes
745
- suitable for isinstance(). This is intentionally lossy but practical.
746
- """
747
- # Unwrap Required/NotRequired
748
- annotation, _ = _unwrap_required_notrequired(annotation)
749
-
750
- origin = get_origin(annotation)
751
- args = get_args(annotation)
752
-
753
- # Any -> accept anything
754
- if annotation is Any:
755
- return object
756
-
757
- # Annotated[T, ...] -> T
758
- if origin is Annotated and args:
759
- return _annotation_to_runtime_type(args[0])
760
-
761
- # Optional[T] / Union[...] / X | Y
762
- if origin is UnionType:
763
- # Fallback for some Python versions where get_origin may be odd
764
- union_args = args or getattr(annotation, "__args__", ())
765
- runtime_types: list[type] = []
766
- for a in union_args:
767
- rt = _annotation_to_runtime_type(a)
768
- if isinstance(rt, tuple):
769
- runtime_types.extend(rt)
770
- elif isinstance(rt, type):
771
- runtime_types.append(rt)
772
- # Deduplicate while preserving order
773
- out: list[type] = []
774
- for t in runtime_types:
775
- if t not in out:
776
- out.append(t)
777
- return tuple(out) if len(out) > 1 else (out[0] if out else object)
778
-
779
- # Literal[...] -> base types of provided literals
780
- if origin is Literal:
781
- literal_types: set[type] = {type(v) for v in args}
782
- # None appears as NoneType
783
- if len(literal_types) == 0:
784
- return object
785
- if len(literal_types) == 1:
786
- return next(iter(literal_types))
787
- return tuple(literal_types)
788
-
789
- # Parametrized containers -> use their builtin origins for isinstance
790
- if origin in (list, dict, set, tuple):
791
- return cast(type | tuple[type, ...], origin)
792
-
793
- # TypedDict nested -> treat as dict
794
- if isinstance(annotation, type) and _is_typeddict_type(annotation):
795
- return dict
796
-
797
- # Direct classes
798
- if isinstance(annotation, type):
799
- return annotation
800
-
801
- # Fallback: accept anything
802
- return object
803
-
804
-
805
- def _extract_prop_from_annotated(annotation: Any) -> tuple[Any, Prop[Any] | None]:
806
- """
807
- If annotation is Annotated[T, ...] and any metadata item is a Prop, return (T, Prop).
808
- Otherwise return (annotation, None).
809
- """
810
- origin = get_origin(annotation)
811
- args = get_args(annotation)
812
- if origin is Annotated and args:
813
- base = args[0]
814
- meta = args[1:]
815
- for m in meta:
816
- if isinstance(m, Prop):
817
- return base, m
818
- return annotation, None
819
-
820
-
821
- def _clone_prop(p: Prop[Any]) -> Prop[Any]:
822
- """Shallow clone a Prop to avoid sharing instances across cached specs."""
823
- return Prop(
824
- default=p.default,
825
- required=p.required,
826
- default_factory=p.default_factory,
827
- serialize=p.serialize,
828
- map_to=p.map_to,
829
- typ_=p.typ_,
830
- )
831
-
832
-
833
- def _typed_dict_bases(typed_dict_cls: type) -> list[type]:
834
- return [
835
- b for b in getattr(typed_dict_cls, "__bases__", ()) if _is_typeddict_type(b)
836
- ]
837
-
838
-
839
- @cache
840
- def prop_spec_from_typeddict(typed_dict_cls: type) -> PropSpec:
841
- """Build and cache a Props spec from a TypedDict class tree.
842
-
843
- Caches by the TypedDict class object (stable and hashable). This speeds up
844
- repeated reuse of common props like HTMLProps and HTMLAnchorProps across many
845
- component definitions.
846
- """
847
- annotations: dict[str, Any] = getattr(typed_dict_cls, "__annotations__", {})
848
- # If TypedDict annotations are stringified, skip building and allow unspecified.
849
- if annotations and any(isinstance(v, str) for v in annotations.values()):
850
- return PropSpec({}, {}, allow_unspecified=True)
851
- required_keys: set[str] | None = getattr(typed_dict_cls, "__required_keys__", None)
852
- is_total: bool = bool(getattr(typed_dict_cls, "__total__", True))
853
-
854
- # 1) Merge cached specs from TypedDict base classes (preserve insertion order)
855
- merged: dict[str, Prop[Any]] = {}
856
- for base in _typed_dict_bases(typed_dict_cls):
857
- base_spec = prop_spec_from_typeddict(base)
858
- for k, p in base_spec.as_dict().items():
859
- merged[k] = _clone_prop(p)
860
-
861
- # 2) Add/override keys declared locally on this class in definition order.
862
- # If a key exists in a base, this intentionally shadows the base entry.
863
- for key in annotations.keys():
864
- annotation = annotations[key]
865
- # First see if runtime provides explicit required/optional wrappers
866
- annotation, _annotation_required = _unwrap_required_notrequired(annotation)
867
- # Extract Prop metadata from Annotated if present
868
- annotation, annotation_prop = _extract_prop_from_annotated(annotation)
869
-
870
- runtime_type = _annotation_to_runtime_type(annotation)
871
- prop = annotation_prop or Prop()
872
- if prop.required is not DEFAULT:
873
- raise TypeError(
874
- "Use total=True + NotRequired[T] or total=False + Required[T] to define required and optional props within a TypedDict"
875
- )
876
- prop.typ_ = runtime_type
877
- merged[key] = prop
878
-
879
- # 3) Finalize required flags per this class's semantics
880
- if required_keys is not None:
881
- for k, p in merged.items():
882
- p.required = k in required_keys
883
- else:
884
- # Fallback: infer via wrappers if available; otherwise default to class total
885
- for k, p in merged.items():
886
- ann = annotations.get(k, None)
887
- if ann is not None:
888
- _, req = _unwrap_required_notrequired(ann)
889
- if req is not None:
890
- p.required = req
891
- continue
892
- p.required = is_total
893
-
894
- # Split into required/optional
895
- required: dict[str, Prop[Any]] = {}
896
- optional: dict[str, Prop[Any]] = {}
897
- for k, p in merged.items():
898
- if p.required is True:
899
- required[k] = p
900
- else:
901
- optional[k] = p
902
-
903
- return PropSpec(required, optional)
904
-
905
-
906
- def parse_typed_dict_props(var_kw: Parameter | None) -> PropSpec:
907
- """
908
- Build a Props spec from a TypedDict class.
909
-
910
- - Required vs optional is inferred from __required_keys__/__optional_keys__ when
911
- available, otherwise from Required/NotRequired wrappers or the class __total__.
912
- - Types are converted to runtime-checkable types for isinstance checks.
913
- """
914
- # No **props -> no keyword arguments defined here
915
- if not var_kw:
916
- return PropSpec({}, {})
917
-
918
- # Untyped **props -> allow all
919
- annot = var_kw.annotation
920
- if annot in (None, Parameter.empty):
921
- return PropSpec({}, {}, allow_unspecified=True)
922
- if _is_any_annotation(annot):
923
- return PropSpec({}, {}, allow_unspecified=True)
924
- # Stringified annotations like "Unpack[Props]" are too fragile to parse.
925
- if isinstance(annot, str):
926
- return PropSpec({}, {}, allow_unspecified=True)
927
-
928
- # From here, we should have **props: Unpack[MyProps] where MyProps is a TypedDict
929
- origin = get_origin(annot)
930
- if origin is not Unpack:
931
- raise TypeError(
932
- "**props must be annotated as typing.Unpack[Props] where Props is a TypedDict"
933
- )
934
- unpack_args = get_args(annot)
935
- if not unpack_args:
936
- raise TypeError("Unpack must wrap a TypedDict class, e.g., Unpack[MyProps]")
937
- typed_arg = unpack_args[0]
938
-
939
- # Handle parameterized TypedDicts like MyProps[T] or MyProps[int]
940
- # typing.get_origin returns the underlying class for parameterized generics
941
- origin_td = get_origin(typed_arg) or typed_arg
942
-
943
- if not isinstance(origin_td, type) or not _is_typeddict_type(origin_td):
944
- raise TypeError("Unpack must wrap a TypedDict class, e.g., Unpack[MyProps]")
945
-
946
- # NOTE: For TypedDicts, the annotations contain the fields of all classes in
947
- # the hierarchy, we don't need to walk the MRO. Use cached builder.
948
- return prop_spec_from_typeddict(origin_td)
949
-
950
-
951
- # ----------------------------------------------------------------------------
952
- # Public decorator: define a wrapped React component from a Python function
953
- # ----------------------------------------------------------------------------
954
-
955
-
956
- def react_component(
957
- name: str | Literal["default"],
958
- src: str,
959
- *,
960
- prop: str | None = None,
961
- is_default: bool = False,
962
- lazy: bool = False,
963
- version: str | None = None,
964
- extra_imports: list[Import] | None = None,
965
- ) -> Callable[[Callable[P, None] | Callable[P, Element]], ReactComponent[P]]:
966
- """
967
- Decorator to define a React component wrapper. The decorated function is
968
- passed to `ReactComponent`, which parses and validates its signature.
969
-
970
- Args:
971
- tag: Name of the component (or "default" for default export)
972
- import_: Module path to import the component from
973
- property: Optional property name to access the component from the imported object
974
- is_default: True if this is a default export, else named export
975
- lazy: Whether to lazy load the component
976
- """
977
-
978
- def decorator(fn: Callable[P, None] | Callable[P, Element]) -> ReactComponent[P]:
979
- return ReactComponent(
980
- name=name,
981
- src=src,
982
- prop=prop,
983
- is_default=is_default,
984
- lazy=lazy,
985
- version=version,
986
- fn_signature=fn,
987
- extra_imports=extra_imports,
988
- )
989
-
990
- return decorator
991
-
992
-
993
- # ----------------------------------------------------------------------------
994
- # Helpers for display of runtime types
995
- # ----------------------------------------------------------------------------
996
-
997
-
998
- def _format_runtime_type(t: type | tuple[type, ...]) -> str:
999
- if isinstance(t, tuple):
1000
- return "(" + ", ".join(_format_runtime_type(x) for x in t) + ")"
1001
- if isinstance(t, type):
1002
- return getattr(t, "__name__", repr(t))
1003
- return repr(t)
5
+ __all__ = ["react_component", "default_signature"]