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