Skip to content
Merged
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
8 changes: 6 additions & 2 deletions remotestate-py/CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@

- `Store` now accepts any root state value, exposes it through the typed
`state` property, and supports root reads/writes with the empty path.
- `Store.get()` and the built-in `Service.get()` query now default to the root
state value when no path is passed.
- `Store.get()` now defaults to the root state value when no path is passed.
- Removed the built-in `Service.get()` query and `Service.set()` action.
Store reads and writes now use dedicated `get`/`set` protocol messages, while
service actions and queries are reserved for domain methods.
- `Service` is now generic over the root state type, so `service.store`
preserves the `Store[T]` type.
- Added notebook-friendly `Store.__getitem__()` and `Store.__setitem__()`
aliases:
- `store["items[0].label"]` uses RemoteState string path syntax.
Expand Down
9 changes: 4 additions & 5 deletions remotestate-py/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,15 +147,14 @@ class Counter(rs.Service):

## Service Helpers

`Service` also provides built-in methods that power the generic TypeScript bridge:
`Service` exposes the store and progress helper used by actions and queries:

- `get(path="")` reads a store value by path
- `set(path, value)` writes a store value by path
- `notify(name=None, detail=None, progress=None)` emits `update_task` progress messages
for tracked calls

The reserved service method names are `get`, `set`, and `notify`. Do not reuse those names
for custom actions or queries.
The reserved service method name is `notify`. Store reads and writes use
`service.store.get(...)` and `service.store.set(...)`; `get` and `set` remain
available for custom actions or queries.

`Service._init_app(app)` can be overridden to customize the FastAPI app when `serve()`
creates one.
Expand Down
5 changes: 5 additions & 0 deletions remotestate-py/src/remotestate/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,8 @@ class _CallContext:
"_call_context", default=None
)
"""Task-local context for the currently executing action or query."""

_suppress_store_broadcast: ContextVar[bool] = ContextVar(
"_suppress_store_broadcast", default=False
)
"""Whether store subscribers should skip external transport broadcasts."""
40 changes: 35 additions & 5 deletions remotestate-py/src/remotestate/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,23 @@ class GetMessage(BaseModel):
"""An internal get-ID."""

path: str
"""The modification path using a simplified JSON-Path format."""
"""Path into store's state using a simplified JSON-Path format."""


class SetMessage(BaseModel):
"""Set one store value by path."""

type: Literal["set"] = "set"
"""Message type."""

call_id: str
"""An internal set-ID."""

path: str
"""Path into store's state using a simplified JSON-Path format."""

value: Any
"""New value to assign at ``path``."""


class ActionMessage(BaseModel):
Expand Down Expand Up @@ -99,12 +115,25 @@ class ActionResultMessage(BaseModel):
"""Message type."""

call_id: str
"""An internal action- or query-ID."""
"""An internal action-ID."""

updates: dict[str, Any]
"""Mapping from state paths to changed state values. May be empty."""


class SetResultMessage(BaseModel):
"""Return the batched store updates produced by a previous ``SetMessage``."""

type: Literal["set_result"] = "set_result"
"""Message type."""

call_id: str
"""An internal set-ID."""

updates: dict[str, Any]
"""Mapping from state paths to changed state values."""


class QueryResultMessage(BaseModel):
"""Return the computed result for a previous ``QueryMessage``."""

Expand Down Expand Up @@ -153,13 +182,13 @@ class TaskUpdateMessage(BaseModel):


class ErrorMessage(BaseModel):
"""Return an error for a previous action, query, or parse failure."""
"""Return an error for a previous request or parse failure."""

type: Literal["error"] = "error"
"""Message type."""

call_id: str
"""An internal action- or query-ID."""
"""An internal request ID."""

message: str
"""Error message text."""
Expand All @@ -170,13 +199,14 @@ class ErrorMessage(BaseModel):
# ----------------------------------------------------

IncomingMessage = Annotated[
GetMessage | ActionMessage | QueryMessage,
GetMessage | SetMessage | ActionMessage | QueryMessage,
Field(discriminator="type"),
]
"""Any message that can be sent from JavaScript to Python."""

OutgoingMessage = Annotated[
GetResultMessage
| SetResultMessage
| QueryResultMessage
| TaskUpdateMessage
| ActionResultMessage
Expand Down
28 changes: 21 additions & 7 deletions remotestate-py/src/remotestate/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,17 @@
GetMessage,
GetResultMessage,
IncomingMessage,
SetMessage,
SetResultMessage,
ActionResultMessage,
OutgoingMessage,
QueryMessage,
QueryResultMessage,
TaskUpdateMessage,
)
from .context import _call_context
from .context import _suppress_store_broadcast
from .service import Service
from .store import PendingUpdates
from .store import PendingUpdates, _batch_pending_updates
from .transport import Transport
from .log import LOG

Expand Down Expand Up @@ -83,13 +85,13 @@ async def sender(msg: TaskUpdateMessage) -> None:
return sender

def _broadcast_store_update(self, updates: PendingUpdates) -> None:
# Action dispatch already returns the batched updates as an
# ActionResultMessage. This subscription is for Python-side store.set()
# calls that happen outside a dispatched service action.
if _call_context.get() is not None:
# Dispatched actions and store set messages return their updates in the
# matching result message. This subscription is for Python-side
# store.set() calls that happen outside a dispatched request.
if _suppress_store_broadcast.get():
return
self._transport.send_nowait(
ActionResultMessage(call_id="store_update", updates=updates)
SetResultMessage(call_id="store_update", updates=updates)
)

# noinspection PyProtectedMember
Expand All @@ -112,6 +114,18 @@ async def __dispatch(self, msg: IncomingMessage) -> None:
GetResultMessage(call_id=call_id, path=path, value=value)
)

case SetMessage(call_id=call_id, path=path, value=value):
token = _suppress_store_broadcast.set(True)
try:
with _batch_pending_updates() as pending:
self._store.set(path, value)
self._store._flush(pending)
await self._transport.send(
SetResultMessage(call_id=call_id, updates=pending)
)
finally:
_suppress_store_broadcast.reset(token)

case ActionMessage(
call_id=call_id,
task_id=task_id,
Expand Down
60 changes: 13 additions & 47 deletions remotestate-py/src/remotestate/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@
import functools
import inspect
from collections.abc import Callable, Coroutine
from typing import Any
from typing import Any, Generic, TypeVar

from fastapi import FastAPI

from .context import _call_context, _CallContext
from .context import _call_context, _CallContext, _suppress_store_broadcast
from .protocol import TaskUpdateMessage
from .store import PendingUpdates, Store, _batch_pending_updates

T = TypeVar("T")


class _ActionMarker:
"""Marker object used while collecting ``@action`` methods."""
Expand Down Expand Up @@ -66,19 +68,16 @@ def query(fn: Callable) -> _QueryMarker:
return _QueryMarker(_ensure_async(fn))


_BUILTIN_SERVICE_METHODS = {"get", "set", "notify"}
_RESERVED_SERVICE_METHODS = {"notify"}


class Service:
class Service(Generic[T]):
"""Implements the Python queries and actions exposed over the websocket bridge.

Subclasses define ``@action`` and ``@query`` methods. Dispatch helpers take
care of call scoping, read-only enforcement for queries, and batched store
invalidation after actions complete.

The base class also provides the built-in ``get`` query and ``set``
action used by the generic React bridge.

``Service`` may serve as a base class for store-specific queries and actions,
but it can also be instantiated as-is, if no queries and actions are required
for a given store.
Expand All @@ -88,15 +87,13 @@ class Service:

- ``_init_app`` - FastAPI instance initialization
- ``store`` - property that provides reactive state container
- ``get`` - built-in query to get a state value
- ``set`` - built-in action to set a state value
- ``notify`` - report task updates

Args:
store: The reactive state container.
"""

_store: Store
_store: Store[T]
_actions: dict[str, Callable]
_queries: dict[str, Callable]

Expand All @@ -110,9 +107,9 @@ def __init_subclass__(cls, **kwargs: Any) -> None:
cls._queries.update(getattr(base, "_queries", {}))

for name, value in list(cls.__dict__.items()):
if name in _BUILTIN_SERVICE_METHODS:
if name in _RESERVED_SERVICE_METHODS:
raise TypeError(
f"{cls.__name__}.{name} conflicts with a built-in "
f"{cls.__name__}.{name} conflicts with a reserved "
"RemoteState service method"
)
if isinstance(value, _ActionMarker):
Expand All @@ -122,7 +119,7 @@ def __init_subclass__(cls, **kwargs: Any) -> None:
cls._queries[name] = value.fn
setattr(cls, name, value.fn)

def __init__(self, store: Store) -> None:
def __init__(self, store: Store[T]) -> None:
"""Create a service bound to a reactive store.

Args:
Expand All @@ -149,43 +146,10 @@ def _init_app(self, app: FastAPI):
"""

@property
def store(self) -> Store:
def store(self) -> Store[T]:
"""Store: The reactive state container."""
return self._store

@query
def get(self, path: str = "") -> Any:
"""Built-in query that returns a store value by path.

This is the read side of the generic bridge used by the TypeScript
``useRemoteState()`` hook and related helpers.

Args:
path: RemoteState path to read. If omitted, reads the root state
value.

Returns:
The value at ``path``, or ``None`` when the path is missing.
"""
return self.store.get(path)

@action
def set(self, path: str, value: Any) -> None:
"""Built-in action that sets a store value by path.

This is the write-side of the generic bridge used by the TypeScript
``useRemoteState()`` hook and related helpers, so a simple UI state does
not require a custom action on every user service.

Args:
path: RemoteState path to write.
value: New value to assign at ``path``.

Returns:
None.
"""
self.store.set(path, value)

# noinspection PyMethodMayBeStatic
def notify(
self,
Expand Down Expand Up @@ -264,13 +228,15 @@ async def _rs_invoke_action(
readonly=False,
)
)
broadcast_token = _suppress_store_broadcast.set(True)
try:
with _batch_pending_updates() as pending:
await fn(self, *args, **kwargs)
# noinspection PyProtectedMember
self.store._flush(pending)
return pending
finally:
_suppress_store_broadcast.reset(broadcast_token)
_call_context.reset(token)

async def _rs_invoke_query(
Expand Down
14 changes: 14 additions & 0 deletions remotestate-py/tests/test_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from remotestate.protocol import (
IncomingMessage,
GetMessage,
SetMessage,
SetResultMessage,
OutgoingMessage,
ActionMessage,
)
Expand All @@ -23,6 +25,18 @@ def test_get():
) == GetMessage(call_id="x", path="y")


def test_set():
assert _incoming_adapter.validate_json(
to_json(type="set", call_id="x", path="count", value=7)
) == SetMessage(call_id="x", path="count", value=7)


def test_set_result():
assert _outgoing_adapter.validate_python(
{"type": "set_result", "call_id": "x", "updates": {"count": 7}}
) == SetResultMessage(call_id="x", updates={"count": 7})


def test_action():
assert _incoming_adapter.validate_json(
to_json(
Expand Down
Loading