-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy path_components.py
More file actions
429 lines (340 loc) · 14.3 KB
/
_components.py
File metadata and controls
429 lines (340 loc) · 14.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
"""Custom native-component registration.
Implements the [`@native_component`][pythonnative.sdk.native_component]
decorator and supporting helpers that let third-party packages contribute
new element types to the reconciler.
The registration model is intentionally small. A custom component is a
three-part agreement:
1. A typed, immutable
[`Props`][pythonnative.sdk.Props] dataclass declaring the
component's public surface.
2. One or more
[`ViewHandler`][pythonnative.sdk.ViewHandler] subclasses
(one per platform) implementing the platform-side rendering.
3. A name (string) used by the reconciler to look up the handler.
The decorator stores the (name, props_type, handler_instance) tuple
in a process-wide registry. The
[`NativeViewRegistry`][pythonnative.native_views.NativeViewRegistry]
calls
[`install_into_registry`][pythonnative.sdk.install_into_registry] on
first use; that helper performs entry-point discovery (importing any
modules registered under
[`ENTRY_POINT_GROUP`][pythonnative.sdk.ENTRY_POINT_GROUP]) and copies
every handler matching the active platform into the registry.
Example:
```python
from dataclasses import dataclass
import pythonnative as pn
from pythonnative.sdk import Props, ViewHandler, element_factory, native_component
@dataclass(frozen=True)
class BadgeProps(Props):
text: str = ""
color: str = "#FF3B30"
style: pn.StyleProp = None
@native_component("Badge", props=BadgeProps, platforms=("ios",))
class IOSBadgeHandler(ViewHandler):
def create(self, props):
...
def update(self, view, changed):
...
Badge = element_factory("Badge")
```
"""
from dataclasses import dataclass, fields, is_dataclass
from typing import Any, Callable, Dict, List, Optional, Tuple, Type, TypeVar
from ..element import Element
from ..native_views.base import ViewHandler
ENTRY_POINT_GROUP = "pythonnative.handlers"
"""Entry-point group used by PyPI packages to register native handlers.
Packages declare entries like:
```toml
[project.entry-points."pythonnative.handlers"]
my_blur = "my_pkg.blur:register"
```
PythonNative imports the referenced module the first time the
[`NativeViewRegistry`][pythonnative.native_views.NativeViewRegistry] is
materialized; the decorators inside that module populate the registry
during import.
"""
@dataclass(frozen=True)
class Props:
"""Optional base class for typed prop dataclasses.
Subclassing is not strictly required (any
``@dataclass(frozen=True)`` works), but inheriting from
``Props`` gives third-party components a clear, searchable marker
in their public API and a stable place to add framework-wide
behavior in the future.
Example:
```python
from dataclasses import dataclass
from pythonnative.sdk import Props
@dataclass(frozen=True)
class BadgeProps(Props):
text: str = ""
color: str = "#FF3B30"
```
"""
# ---------------------------------------------------------------------- #
# Internal registry
# ---------------------------------------------------------------------- #
# name -> (props_type or None, {platform_name: handler_instance})
_REGISTRY: Dict[str, Tuple[Optional[type], Dict[str, ViewHandler]]] = {}
# Caches `_install_into_registry` runs to avoid repeated entry-point
# discovery once the registry has been populated for a given platform.
_DISCOVERED: bool = False
H = TypeVar("H", bound=ViewHandler)
def native_component(
name: str,
*,
props: Optional[type] = None,
platforms: Optional[Tuple[str, ...]] = None,
) -> Callable[[Type[H]], Type[H]]:
"""Decorator that registers a [`ViewHandler`][pythonnative.sdk.ViewHandler] under ``name``.
The handler class is instantiated immediately and stored in the
process-wide registry. Decorate the same ``name`` once per platform
when shipping platform-specific implementations; the decorator
accumulates entries in a ``{platform: handler}`` mapping per name.
Args:
name: Element type name (e.g., ``"Badge"``). Must be a valid
identifier-like string. Used by the reconciler at lookup time.
props: Optional dataclass type describing the component's
typed props. When supplied, the
[`element_factory`][pythonnative.sdk.element_factory] helper
uses this type to validate kwargs and produce frozen prop
instances.
platforms: Tuple of platform identifiers
(``"ios"`` / ``"android"``) the handler implements. Defaults
to ``("android", "ios")`` so a single cross-platform handler
registers everywhere.
Returns:
A decorator that, when applied to a
[`ViewHandler`][pythonnative.sdk.ViewHandler] subclass, registers
it and returns the class unchanged.
Raises:
TypeError: If the decorated object is not a class subclassing
``ViewHandler``.
Example:
```python
from dataclasses import dataclass
from pythonnative.sdk import Props, ViewHandler, native_component
@dataclass(frozen=True)
class BadgeProps(Props):
text: str = ""
color: str = "#FF3B30"
@native_component("Badge", props=BadgeProps, platforms=("ios",))
class IOSBadgeHandler(ViewHandler):
def create(self, props):
...
```
"""
plats: Tuple[str, ...] = platforms if platforms is not None else ("android", "ios")
def decorator(handler_cls: Type[H]) -> Type[H]:
if not isinstance(handler_cls, type) or not issubclass(handler_cls, ViewHandler):
raise TypeError(
f"@native_component({name!r}) must decorate a ViewHandler subclass; " f"got {handler_cls!r}"
)
register_component(name=name, props=props, handlers={plat: handler_cls() for plat in plats})
return handler_cls
return decorator
def register_component(
*,
name: str,
props: Optional[type] = None,
handlers: Dict[str, ViewHandler],
) -> None:
"""Register a custom native component imperatively.
Equivalent to applying [`@native_component`][pythonnative.sdk.native_component]
one or more times, but useful when constructing handlers
programmatically (e.g., parameterized handler instances). Subsequent
calls for the same ``name`` merge their ``handlers`` into the
existing entry, replacing any previously-registered handler for the
same platform.
Args:
name: Element type name.
props: Optional dataclass type describing the typed props.
handlers: ``{platform_name: handler_instance}`` mapping. Common
keys are ``"ios"`` and ``"android"``.
Raises:
TypeError: If any handler is not a
[`ViewHandler`][pythonnative.sdk.ViewHandler] instance, or
if ``props`` is not a dataclass type.
"""
if props is not None and not (isinstance(props, type) and is_dataclass(props)):
raise TypeError(f"register_component({name!r}): props must be a @dataclass type, got {props!r}")
for plat, handler in handlers.items():
if not isinstance(handler, ViewHandler):
raise TypeError(f"register_component({name!r}): handler for {plat!r} must be a ViewHandler instance")
existing = _REGISTRY.get(name)
if existing is None:
_REGISTRY[name] = (props, dict(handlers))
return
existing_props, plat_map = existing
new_props = props if props is not None else existing_props
plat_map.update(handlers)
_REGISTRY[name] = (new_props, plat_map)
def unregister_component(name: str) -> None:
"""Remove a previously-registered component (primarily for tests).
Args:
name: The element type name to unregister.
"""
_REGISTRY.pop(name, None)
def list_components() -> List[str]:
"""Return the names of every registered custom component.
Useful for diagnostics and tests.
Returns:
Sorted list of names registered via
[`@native_component`][pythonnative.sdk.native_component] or
[`register_component`][pythonnative.sdk.register_component].
"""
return sorted(_REGISTRY)
def get_props_type(name: str) -> Optional[type]:
"""Return the registered props dataclass for ``name`` (or ``None``)."""
entry = _REGISTRY.get(name)
return entry[0] if entry is not None else None
def install_into_registry(registry: Any, platform_name: str) -> None:
"""Copy registered handlers into a [`NativeViewRegistry`][pythonnative.native_views.NativeViewRegistry].
Called once by the registry on first use. Triggers entry-point
discovery on the first call so PyPI-installed handlers register
themselves before the registry snapshot is taken.
Args:
registry: A
[`NativeViewRegistry`][pythonnative.native_views.NativeViewRegistry]
(or duck-compatible object) with a ``register(name, handler)``
method.
platform_name: The active platform identifier
(``"ios"`` or ``"android"``).
"""
_discover_entry_points()
for name, (_props_type, plat_map) in _REGISTRY.items():
handler = plat_map.get(platform_name)
if handler is not None:
registry.register(name, handler)
def _discover_entry_points() -> None:
"""Import every module registered under ``ENTRY_POINT_GROUP``.
Idempotent and safe to call repeatedly; the actual discovery only
runs once per process. Exceptions raised by individual entry points
are swallowed (with the offending name printed to stderr) so a
single broken plugin never prevents the rest of the process from
rendering.
"""
global _DISCOVERED
if _DISCOVERED:
return
_DISCOVERED = True
try:
from importlib.metadata import entry_points
except ImportError:
return
try:
eps = entry_points()
except Exception:
return
# importlib.metadata's API changed across Python versions; both
# ``select`` and direct ``.get`` are normalized here.
selected: List[Any] = []
if hasattr(eps, "select"):
try:
selected = list(eps.select(group=ENTRY_POINT_GROUP))
except Exception:
selected = []
if not selected:
try:
getter = getattr(eps, "get", None)
if getter is not None:
selected = list(getter(ENTRY_POINT_GROUP, []))
except Exception:
selected = []
for ep in selected:
name = getattr(ep, "name", "?")
try:
ep.load()
except Exception as exc: # pragma: no cover - defensive
import sys
print(
f"[pythonnative.sdk] Failed to load handler entry point {name!r}: {exc!r}",
file=sys.stderr,
flush=True,
)
def _reset_discovery_state_for_tests() -> None:
"""Reset the entry-point discovery flag (for tests only)."""
global _DISCOVERED
_DISCOVERED = False
# ---------------------------------------------------------------------- #
# Element factories
# ---------------------------------------------------------------------- #
def _props_to_dict(value: Any) -> Dict[str, Any]:
"""Convert a typed props dataclass to a flat dict of non-None fields."""
if isinstance(value, dict):
return {k: v for k, v in value.items() if v is not None}
if is_dataclass(value):
out: Dict[str, Any] = {}
for f in fields(value):
field_value = getattr(value, f.name)
if field_value is not None:
out[f.name] = field_value
return out
raise TypeError(f"Expected a dataclass instance or dict, got {type(value).__name__!r}")
def element_factory(name: str) -> Callable[..., Element]:
"""Return a callable that builds [`Element`][pythonnative.Element] instances of type ``name``.
The returned factory accepts:
- Children as positional arguments (any number).
- ``key=`` (optional, keyword-only) for keyed reconciliation.
- Either ``props=`` (a dataclass instance) or per-field keyword
arguments matching the registered props dataclass.
If no ``props`` dataclass was registered for ``name``, kwargs flow
through unmodified — useful when iterating before locking down a
prop schema.
Args:
name: An element type name previously registered via
[`@native_component`][pythonnative.sdk.native_component] or
[`register_component`][pythonnative.sdk.register_component].
Returns:
A callable producing fresh
[`Element`][pythonnative.Element] instances of type ``name``.
Raises:
KeyError: If ``name`` is not registered.
Example:
```python
Badge = element_factory("Badge")
Badge(text="3", color="#0A84FF")
Badge(props=BadgeProps(text="3"))
```
"""
if name not in _REGISTRY:
raise KeyError(
f"No component registered under name {name!r}. " "Use @native_component or register_component first."
)
def factory(*children: Element, key: Optional[str] = None, props: Any = None, **kwargs: Any) -> Element:
props_type = get_props_type(name)
if props is not None:
if kwargs:
raise TypeError("Pass either props=... or keyword props, not both")
props_dict = _props_to_dict(props)
elif props_type is not None:
try:
instance = props_type(**kwargs)
except TypeError as exc:
raise TypeError(f"Invalid props for {name!r}: {exc}") from exc
props_dict = _props_to_dict(instance)
else:
props_dict = dict(kwargs)
# Style props pass through resolve_style at the boundary so list
# forms / None get flattened identically to built-in factories.
from ..style import resolve_style as _resolve
style_value = props_dict.pop("style", None)
style_dict = _resolve(style_value)
merged: Dict[str, Any] = {**style_dict, **props_dict}
return Element(name, merged, list(children), key=key)
factory.__name__ = name
factory.__doc__ = f"Construct an Element of type {name!r}."
return factory
__all__ = [
"ENTRY_POINT_GROUP",
"Props",
"element_factory",
"get_props_type",
"install_into_registry",
"list_components",
"native_component",
"register_component",
"unregister_component",
]