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.
- pythonnative/__init__.py +77 -5
- pythonnative/animated.py +2 -2
- pythonnative/components.py +41 -33
- pythonnative/native_views/__init__.py +62 -14
- pythonnative/sdk/__init__.py +132 -0
- pythonnative/sdk/_components.py +429 -0
- pythonnative/style.py +413 -61
- {pythonnative-0.14.0.dist-info → pythonnative-0.15.0.dist-info}/METADATA +5 -4
- {pythonnative-0.14.0.dist-info → pythonnative-0.15.0.dist-info}/RECORD +13 -11
- {pythonnative-0.14.0.dist-info → pythonnative-0.15.0.dist-info}/WHEEL +0 -0
- {pythonnative-0.14.0.dist-info → pythonnative-0.15.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.14.0.dist-info → pythonnative-0.15.0.dist-info}/licenses/LICENSE +0 -0
- {pythonnative-0.14.0.dist-info → pythonnative-0.15.0.dist-info}/top_level.txt +0 -0
|
@@ -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
|
+
]
|