diff --git a/docs/core-concepts/dependency-injection.md b/docs/core-concepts/dependency-injection.md index 56bc3cc..ba4cfd9 100644 --- a/docs/core-concepts/dependency-injection.md +++ b/docs/core-concepts/dependency-injection.md @@ -211,6 +211,48 @@ async def test_get_current_user_no_session(): | `handle_event` | Yes | No | | `@event` handlers | Yes | No | +## LiveComponents + +`Depends()` also works in LiveComponent lifecycle methods: + +| Method | Async Deps | Session Available | +|--------|------------|-------------------| +| `mount` | Yes | No | +| `update` | Yes | No | +| `handle_event` | Yes | No | + +Lifecycle parameters are resolved by name or type. Use `socket`, `assigns`, `event`, and `payload` as parameter names, or annotate with a socket type if you prefer different names. + +Components don't have direct session access—they receive data from their parent via `assigns`. If a component needs session data, pass it from the parent: + +```python +from pyview import Depends +from pyview.components.base import LiveComponent, ComponentSocket + +def get_formatter(): + return Formatter(locale="en-US") + +class PriceDisplay(LiveComponent): + async def mount( + self, + socket: ComponentSocket, + assigns: dict, + formatter=Depends(get_formatter), + ): + # user_id comes from parent via assigns, not session + socket.context = { + "price": formatter.format_currency(assigns["amount"]), + "user_id": assigns.get("user_id"), + } +``` + +In the parent LiveView: + +```python +# Pass session data to component via assigns +live_component(PriceDisplay, id="price", amount=99.99, user_id=session.get("user_id")) +``` + ## Available Injectables ### Type-based injection diff --git a/pyview/binding/context.py b/pyview/binding/context.py index fb4e366..5bba19a 100644 --- a/pyview/binding/context.py +++ b/pyview/binding/context.py @@ -1,10 +1,11 @@ """Binding context for parameter resolution.""" from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, Callable, Generic, Optional, TypeVar +from typing import TYPE_CHECKING, Any, Callable, Generic, Optional, TypeVar, Union from urllib.parse import ParseResult if TYPE_CHECKING: + from pyview.components.base import ComponentSocket from pyview.live_socket import LiveViewSocket from .params import Params @@ -20,7 +21,7 @@ class BindContext(Generic[T]): params: Multi-value parameter container (query/path/form merged) payload: Event payload dict (for handle_event) url: Parsed URL (for handle_params) - socket: LiveView socket instance + socket: Socket instance (LiveViewSocket or ComponentSocket) event: Event name (for handle_event) extra: Additional injectable values cache: Dependency cache for Depends() resolution (per-request) @@ -29,7 +30,7 @@ class BindContext(Generic[T]): params: "Params" payload: Optional[dict[str, Any]] url: Optional[ParseResult] - socket: Optional["LiveViewSocket[T]"] + socket: Union["LiveViewSocket[T]", "ComponentSocket", None] event: Optional[str] extra: dict[str, Any] = field(default_factory=dict) cache: dict[Callable[..., Any], Any] = field(default_factory=dict) diff --git a/pyview/binding/injectables.py b/pyview/binding/injectables.py index d8ccd82..25fd50a 100644 --- a/pyview/binding/injectables.py +++ b/pyview/binding/injectables.py @@ -2,7 +2,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Annotated, Any, Generic, TypeVar, get_args, get_origin +import types +from typing import TYPE_CHECKING, Annotated, Any, Generic, TypeVar, Union, get_args, get_origin from pyview.depends import _SessionInjector @@ -44,6 +45,8 @@ def resolve( # Type-based injection (check annotation first) if self._is_session_annotation(annotation): return ctx.extra.get("session", _NOT_FOUND) + if self._is_socket_annotation(annotation): + return ctx.socket # Name-based injection (backward compatibility) if name == "socket": @@ -56,6 +59,8 @@ def resolve( return ctx.payload if name == "url": return ctx.url + if name == "assigns": + return ctx.extra.get("assigns", _NOT_FOUND) if name == "params": # Only inject if typed as Params, dict, or untyped (Any) # Otherwise treat "params" as a regular URL param name @@ -77,6 +82,40 @@ def _is_session_annotation(self, annotation: Any) -> bool: return isinstance(args[1], _SessionInjector) return False + def _is_socket_annotation(self, annotation: Any) -> bool: + """Check if annotation is a socket type (ComponentSocket or LiveViewSocket).""" + from pyview.components.base import ComponentSocket # noqa: PLC0415 + from pyview.live_socket import ( # noqa: PLC0415 + ConnectedLiveViewSocket, + UnconnectedSocket, + ) + + origin = get_origin(annotation) + + # Unwrap Annotated[T, ...] + if origin is Annotated: + args = get_args(annotation) + if args: + annotation = args[0] + origin = get_origin(annotation) + + # Expand Union/Optional (including PEP 604 `|`) + if origin is Union or origin is types.UnionType: + return any( + self._is_socket_annotation(arg) + for arg in get_args(annotation) + if arg is not type(None) + ) + + # Handle generic types like ComponentSocket[T] or LiveViewSocket[T] + check_type = origin if origin is not None else annotation + + socket_types = (ComponentSocket, ConnectedLiveViewSocket, UnconnectedSocket) + try: + return isinstance(check_type, type) and issubclass(check_type, socket_types) + except TypeError: + return False + def _is_params_annotation(self, annotation: Any) -> bool: """Check if annotation indicates params injection vs URL param named 'params'.""" # Untyped (Any) -> inject for backward compat diff --git a/pyview/components/binding.py b/pyview/components/binding.py new file mode 100644 index 0000000..24a3888 --- /dev/null +++ b/pyview/components/binding.py @@ -0,0 +1,116 @@ +"""Binding helpers for LiveComponent lifecycle methods.""" + +from __future__ import annotations + +import logging +from typing import Any + +from pyview.binding.binder import Binder +from pyview.binding.context import BindContext +from pyview.binding.params import Params + +from .base import ComponentSocket, LiveComponent + +logger = logging.getLogger(__name__) + + +async def call_component_mount( + component: LiveComponent, + socket: ComponentSocket, + assigns: dict[str, Any], +) -> None: + """Bind and call component.mount() with Depends() support. + + Args: + component: The LiveComponent instance + socket: The ComponentSocket instance + assigns: Props from parent template + + Note: + Session is not available to components. Session-dependent data + should be passed from parent LiveView via assigns. + """ + ctx = BindContext( + params=Params({}), + payload=None, + url=None, + socket=socket, + event=None, + extra={"assigns": assigns}, + ) + binder = Binder() + result = await binder.abind(component.mount, ctx) + + if not result.success: + component_name = component.__class__.__name__ + for err in result.errors: + logger.warning(f"Component {component_name} mount binding error: {err}") + raise ValueError(f"Component mount binding failed: {result.errors}") + + await component.mount(**result.bound_args) + + +async def call_component_update( + component: LiveComponent, + socket: ComponentSocket, + assigns: dict[str, Any], +) -> None: + """Bind and call component.update() with Depends() support. + + Args: + component: The LiveComponent instance + socket: The ComponentSocket instance + assigns: Updated props from parent template + """ + ctx = BindContext( + params=Params({}), + payload=None, + url=None, + socket=socket, + event=None, + extra={"assigns": assigns}, + ) + binder = Binder() + result = await binder.abind(component.update, ctx) + + if not result.success: + component_name = component.__class__.__name__ + for err in result.errors: + logger.warning(f"Component {component_name} update binding error: {err}") + raise ValueError(f"Component update binding failed: {result.errors}") + + await component.update(**result.bound_args) + + +async def call_component_handle_event( + component: LiveComponent, + event: str, + payload: dict[str, Any], + socket: ComponentSocket, +) -> None: + """Bind and call component.handle_event() with Depends() support. + + Args: + component: The LiveComponent instance + event: Event name + payload: Event payload dict + socket: The ComponentSocket instance + """ + ctx = BindContext( + params=Params({}), + payload=payload, + url=None, + socket=socket, + event=event, + extra={}, + ) + binder = Binder() + result = await binder.abind(component.handle_event, ctx) + + if not result.success: + component_name = component.__class__.__name__ + for err in result.errors: + logger.warning(f"Component {component_name} event binding error: {err}") + raise ValueError(f"Component event binding failed: {result.errors}") + + await component.handle_event(**result.bound_args) diff --git a/pyview/components/manager.py b/pyview/components/manager.py index e9cc5a4..1e29179 100644 --- a/pyview/components/manager.py +++ b/pyview/components/manager.py @@ -18,6 +18,11 @@ from typing import TYPE_CHECKING, Any, Protocol from .base import ComponentMeta, ComponentSocket, LiveComponent +from .binding import ( + call_component_handle_event, + call_component_mount, + call_component_update, +) from .slots import Slots if TYPE_CHECKING: @@ -176,7 +181,7 @@ async def _run_mount(self, cid: int, assigns: dict[str, Any]) -> None: component_name = component.__class__.__name__ try: # Pass assigns to mount so component can initialize from parent props - await component.mount(socket, assigns) + await call_component_mount(component, socket, assigns) # Persist context changes self._contexts[cid] = socket.context logger.debug(f"Component {component_name} (cid={cid}) mounted successfully") @@ -198,7 +203,7 @@ async def _run_update(self, cid: int, assigns: dict[str, Any]) -> None: component_name = component.__class__.__name__ try: - await component.update(socket, assigns) + await call_component_update(component, socket, assigns) # Persist context changes self._contexts[cid] = socket.context logger.debug( @@ -228,7 +233,7 @@ async def handle_event(self, cid: int, event: str, payload: dict[str, Any]) -> N component_name = component.__class__.__name__ try: - await component.handle_event(event, payload, socket) + await call_component_handle_event(component, event, payload, socket) # Persist context changes self._contexts[cid] = socket.context logger.debug(f"Component {component_name} (cid={cid}) handled event '{event}'") diff --git a/tests/binding/test_injectables.py b/tests/binding/test_injectables.py index 4a88372..a668add 100644 --- a/tests/binding/test_injectables.py +++ b/tests/binding/test_injectables.py @@ -1,12 +1,15 @@ """Tests for InjectableRegistry.""" -from typing import Any +from typing import Any, Optional from unittest.mock import MagicMock import pytest +from pyview import LiveViewSocket from pyview.binding import BindContext, Params from pyview.binding.injectables import _NOT_FOUND, InjectableRegistry +from pyview.components.base import ComponentSocket +from pyview.live_socket import ConnectedLiveViewSocket, UnconnectedSocket class TestInjectableRegistry: @@ -111,3 +114,98 @@ def test_params_with_int_type_returns_not_found( """When 'params' is typed as int, treat it as a URL param name.""" result = registry.resolve("params", int, ctx) assert result is _NOT_FOUND + + # --- Type-based socket injection --- + + def test_inject_component_socket_by_type(self, registry: InjectableRegistry, ctx: BindContext): + """ComponentSocket type annotation injects socket regardless of param name.""" + result = registry.resolve("any_name", ComponentSocket, ctx) + assert result is ctx.socket + + def test_inject_component_socket_generic_by_type( + self, registry: InjectableRegistry, ctx: BindContext + ): + """ComponentSocket[T] generic type annotation injects socket.""" + result = registry.resolve("sock", ComponentSocket[dict], ctx) + assert result is ctx.socket + + def test_inject_connected_socket_by_type(self, registry: InjectableRegistry, ctx: BindContext): + """ConnectedLiveViewSocket type annotation injects socket.""" + result = registry.resolve("s", ConnectedLiveViewSocket, ctx) + assert result is ctx.socket + + def test_inject_connected_socket_generic_by_type( + self, registry: InjectableRegistry, ctx: BindContext + ): + """ConnectedLiveViewSocket[T] generic type annotation injects socket.""" + result = registry.resolve("x", ConnectedLiveViewSocket[dict], ctx) + assert result is ctx.socket + + def test_inject_unconnected_socket_by_type( + self, registry: InjectableRegistry, ctx: BindContext + ): + """UnconnectedSocket type annotation injects socket.""" + result = registry.resolve("whatever", UnconnectedSocket, ctx) + assert result is ctx.socket + + def test_inject_unconnected_socket_generic_by_type( + self, registry: InjectableRegistry, ctx: BindContext + ): + """UnconnectedSocket[T] generic type annotation injects socket.""" + result = registry.resolve("foo", UnconnectedSocket[dict], ctx) + assert result is ctx.socket + + def test_inject_liveviewsocket_alias_by_type( + self, registry: InjectableRegistry, ctx: BindContext + ): + """LiveViewSocket alias (Union) type annotation injects socket.""" + result = registry.resolve("sock", LiveViewSocket, ctx) + assert result is ctx.socket + + def test_inject_liveviewsocket_generic_by_type( + self, registry: InjectableRegistry, ctx: BindContext + ): + """LiveViewSocket[T] alias type annotation injects socket.""" + result = registry.resolve("sock", LiveViewSocket[dict], ctx) + assert result is ctx.socket + + def test_inject_optional_component_socket_by_type( + self, registry: InjectableRegistry, ctx: BindContext + ): + """Optional[ComponentSocket] type annotation injects socket.""" + result = registry.resolve("sock", Optional[ComponentSocket], ctx) + assert result is ctx.socket + + def test_non_socket_name_without_type_not_injected( + self, registry: InjectableRegistry, ctx: BindContext + ): + """Non-socket parameter name without socket type is not injected.""" + result = registry.resolve("sock", Any, ctx) + assert result is _NOT_FOUND + + # --- Assigns injection (for components) --- + + def test_inject_assigns_by_name(self, registry: InjectableRegistry): + """assigns parameter is injected from extra context.""" + ctx = BindContext( + params=Params({}), + payload=None, + url=None, + socket=None, + event=None, + extra={"assigns": {"label": "test", "count": 5}}, + ) + result = registry.resolve("assigns", dict, ctx) + assert result == {"label": "test", "count": 5} + + def test_assigns_missing_returns_not_found(self, registry: InjectableRegistry): + """assigns returns NOT_FOUND when not in extra context.""" + ctx = BindContext( + params=Params({}), + payload=None, + url=None, + socket=None, + event=None, + ) + result = registry.resolve("assigns", dict, ctx) + assert result is _NOT_FOUND diff --git a/tests/components/test_lifecycle.py b/tests/components/test_lifecycle.py index bd2f2b2..8b395f8 100644 --- a/tests/components/test_lifecycle.py +++ b/tests/components/test_lifecycle.py @@ -168,9 +168,9 @@ async def test_component_mount_called(self): mount_called = [] class TrackingComponent(LiveComponent[CounterContext]): - async def mount(self, sock, assigns): + async def mount(self, socket, assigns): mount_called.append(assigns) - sock.context = CounterContext(count=assigns.get("initial", 0)) + socket.context = CounterContext(count=assigns.get("initial", 0)) def template(self, assigns, meta): return t"