[ Web Proxy ]
URL:
Viewing: https://raw.githubusercontent.com/andevsr/pythonnative/main/src/pythonnative/sdk/_components.py [Back]  [Original]

"""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, tag, 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, tag, 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",
]

Web Proxy Viewer  |  New URL  |  Original Page