Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions docs/core-concepts/dependency-injection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Comment on lines +214 to +223
Copy link

Copilot AI Feb 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doc update introduces LiveComponents DI support, but the “Type-based injection” section below still only lists Session. With this PR, socket types can also be injected by type annotation (e.g., ComponentSocket, ConnectedLiveViewSocket, UnconnectedSocket).

Please update the “Available Injectables” docs to include the new type-based socket injections (and clarify whether LiveViewSocket aliases are supported).

Copilot uses AI. Check for mistakes.
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):
Comment on lines +230 to +235
Copy link

Copilot AI Feb 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new example uses Formatter(...) but doesn’t import or define Formatter, so the snippet won’t run as-written.

Suggestion: add an explicit import (or replace with a minimal stub/placeholder) so readers can copy/paste the example without hitting a NameError.

Copilot uses AI. Check for mistakes.
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
Expand Down
7 changes: 4 additions & 3 deletions pyview/binding/context.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Expand All @@ -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)
41 changes: 40 additions & 1 deletion pyview/binding/injectables.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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":
Expand All @@ -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
Expand All @@ -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
Expand Down
116 changes: 116 additions & 0 deletions pyview/components/binding.py
Original file line number Diff line number Diff line change
@@ -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)

Comment on lines +17 to +43
Copy link

Copilot AI Feb 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

call_component_mount/update/handle_event introduce new behavior (Depends() + binder-driven argument binding) for component lifecycle methods, but there are no tests asserting that async Depends() resolution and payload/assigns binding work end-to-end for LiveComponents.

Add component-focused integration tests (similar to tests/binding/test_depends.py) that exercise: (1) async Depends() in component mount and update, (2) typed payload params in handle_event, and (3) caching behavior within a single lifecycle call.

Copilot uses AI. Check for mistakes.
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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve positional binding for legacy component params

This switches component lifecycle invocation to component.mount(**result.bound_args) after name/type-based binding. Previously the manager called component.mount(socket, assigns) (and similar for update/handle_event), so parameter names didn’t matter. With the new kwargs-only call, any existing components that use nonstandard parameter names without socket/assigns/event/payload annotations (e.g., def mount(self, sock, props):) will now fail binding as “missing required parameter” because those names are not injectable. This is a backward‑compatibility regression for unannotated components unless they rename parameters or add type hints.

Useful? React with 👍 / 👎.



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)
11 changes: 8 additions & 3 deletions pyview/components/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Expand All @@ -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(
Expand Down Expand Up @@ -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}'")
Expand Down
Loading