diff --git a/remotestate-py/CHANGES.md b/remotestate-py/CHANGES.md index 114dd7d..10b56ec 100644 --- a/remotestate-py/CHANGES.md +++ b/remotestate-py/CHANGES.md @@ -1,6 +1,17 @@ +## Version 0.3.3 (in development) + +- Improved usability of the Python `Store` class: + - Introduced public protocol `StoreAt` now returned + from `Store.at` instead of internal `_StoreAt` type. + - Added `StoreAt.value` read-only property to access the value + of a node returned by `Store.at`. + +- The Python `ServeResult` class now renders as a HTML table in notebooks. + + ## Version 0.3.2 -- Fixed issue in TypeScript library. (#42) +- Fixed issue in remotestate TypeScript library. (#42) ## Version 0.3.1 diff --git a/remotestate-py/README.md b/remotestate-py/README.md index 8347f38..cf940da 100644 --- a/remotestate-py/README.md +++ b/remotestate-py/README.md @@ -64,15 +64,13 @@ rs.serve(CounterService(), ui_dist="my-ui/dist") The public Python API is exported from `remotestate`: -- `Store` -- `Service` -- `ServeResult` -- `action` -- `query` +- `Store` and `StoreAt` +- `Service` and `ServeResult` +- `action` and `query` - `serve` - `path` -## Store +## Store and StoreAt `Store(initial, *, default_factory=None)` holds the Python-side application state. @@ -87,8 +85,10 @@ The public Python API is exported from `remotestate`: - `set(path, value)` writes a value and notifies subscribers - `store[path]` and `store[path] = value` are notebook-friendly aliases for `get()` and `set()` -- `store.at.some.path = value` is notebook-friendly sugar for nested - `set()` calls; use item syntax for keys that are not valid identifiers +- `store.at` returns a notebook-friendly accessor of type `StoreAt` + for nested state variables using item or attribute syntax +- `store.at.some.path = value` for example executes nested + `set()` calls; use item syntax for arrays or keys that are not valid identifiers - `subscribe(callback)` receives batched path-to-value updates after changes flush - `default_factory` can materialize missing parents while setting nested values diff --git a/remotestate-py/notebooks/demo.ipynb b/remotestate-py/notebooks/demo.ipynb index e4b1402..95b8abe 100644 --- a/remotestate-py/notebooks/demo.ipynb +++ b/remotestate-py/notebooks/demo.ipynb @@ -50,6 +50,14 @@ "import remotestate as rs" ] }, + { + "cell_type": "markdown", + "id": "07f01128-58ac-4f12-a2b7-27aa1f4e0dad", + "metadata": {}, + "source": [ + "Define a store that hold's our UI's data state" + ] + }, { "cell_type": "code", "execution_count": 2, @@ -60,6 +68,14 @@ "store = rs.Store({\"count\": 42})" ] }, + { + "cell_type": "markdown", + "id": "8e1ac855-9ecf-4638-8325-ec34fee9d6ff", + "metadata": {}, + "source": [ + "Define a service that provides actions and/or queries upon the store" + ] + }, { "cell_type": "code", "execution_count": 3, @@ -70,12 +86,23 @@ "class MyService(rs.Service):\n", " @rs.action\n", " async def increment(self):\n", - " self.store.set(\"count\", self.store.get(\"count\") + 1)\n" + " # Using 'at':\n", + " self.store.at.count = self.store.at.count.value + 1\n", + " # Using 'set()' / 'get()':\n", + " # self.store.set(\"count\", self.store.get(\"count\") + 1)\n" + ] + }, + { + "cell_type": "markdown", + "id": "faa078d2-a579-4a44-8dd6-53280dec5b17", + "metadata": {}, + "source": [ + "Serve the app and display the UI as a side-effect" ] }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 4, "id": "979719a8-99c0-484e-ba5a-7f41a9f9c0a4", "metadata": {}, "outputs": [ @@ -86,7 +113,7 @@ " " + "" ] }, "metadata": {}, @@ -103,16 +130,26 @@ ], "source": [ "serve_result = rs.serve(\n", + " # If we wouldn't need actions/queries, we'd have passed rs.Service(store) here\n", " MyService(store), \n", " # The app's build directory or its (dev) URL, e.g., \"http://localhost:5173/\"\n", " ui_dist=\"../../remotestate-demo/dist\",\n", + " # The height of the generated UI in CSS pixels\n", " height=300,\n", ")" ] }, + { + "cell_type": "markdown", + "id": "a22e5777-2bf7-48c5-aaec-ddc67234360c", + "metadata": {}, + "source": [ + "We can now change state in the store" + ] + }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 5, "id": "5a8d1487-8f9f-492b-bc77-69ac1320cd18", "metadata": {}, "outputs": [], @@ -120,16 +157,42 @@ "store.set(\"count\", 100)" ] }, + { + "cell_type": "markdown", + "id": "2172ac31-6123-4b70-a89b-7c2a4d2cecaa", + "metadata": {}, + "source": [ + "or via 'at'" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "407879ae-4f90-4c89-ac72-029395e3ce9d", + "metadata": {}, + "outputs": [], + "source": [ + "store.at.count = 200" + ] + }, + { + "cell_type": "markdown", + "id": "c83498b4-6107-46fb-8b6f-13bbcdf0a023", + "metadata": {}, + "source": [ + "And get state values from the store" + ] + }, { "cell_type": "code", "execution_count": 7, - "id": "59bfc45c-450a-4f64-b073-50381b5b986d", + "id": "a8914148-4262-4785-93bb-66445c05730c", "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "ServeResult(host='localhost', port=49692, server_url='http://localhost:49692', ws_url='ws://localhost:49692/ws', ui_base_url='http://localhost:49692', ui_url='http://localhost:49692?t=1782044457&ws=ws%3A%2F%2Flocalhost%3A49692%2Fws', app=, server=, thread=)" + "200" ] }, "execution_count": 7, @@ -137,6 +200,70 @@ "output_type": "execute_result" } ], + "source": [ + "store.get(\"count\")" + ] + }, + { + "cell_type": "markdown", + "id": "a5cf7aab-a314-4829-86b4-a1eea49cfb5b", + "metadata": {}, + "source": [ + "or via 'at' " + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "cff59797-15fa-42c0-9738-0250d798ea1d", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
200
" + ], + "text/plain": [ + "200" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "store.at.count" + ] + }, + { + "cell_type": "markdown", + "id": "905e4677-eb1d-46e1-9dd1-0968f7fc9d2d", + "metadata": {}, + "source": [ + "The serve result carries important info for clients using remotestore" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "59bfc45c-450a-4f64-b073-50381b5b986d", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
FieldValue
Hostlocalhost
Port61303
Server URLhttp://localhost:61303
WebSocket URLws://localhost:61303/ws
UI Base URLhttp://localhost:61303
UI URLhttp://localhost:61303?t=1783061078&ws=ws%3A%2F%2Flocalhost%3A61303%2Fws
" + ], + "text/plain": [ + "ServeResult(host='localhost', port=61303, server_url='http://localhost:61303', ws_url='ws://localhost:61303/ws', ui_base_url='http://localhost:61303', ui_url='http://localhost:61303?t=1783061078&ws=ws%3A%2F%2Flocalhost%3A61303%2Fws', app=, server=, thread=)" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "serve_result" ] @@ -144,7 +271,7 @@ { "cell_type": "code", "execution_count": null, - "id": "407879ae-4f90-4c89-ac72-029395e3ce9d", + "id": "29f7f7ef-0294-48cc-a5fe-36e864d5ced0", "metadata": {}, "outputs": [], "source": [] @@ -166,7 +293,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.14.5" + "version": "3.14.6" } }, "nbformat": 4, diff --git a/remotestate-py/pyproject.toml b/remotestate-py/pyproject.toml index fd113ce..27e5373 100644 --- a/remotestate-py/pyproject.toml +++ b/remotestate-py/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "remotestate" -version = "0.3.2" +version = "0.3.3.dev0" authors = [{name = "forman"}] description = "Python state, React UI." readme = "README.md" diff --git a/remotestate-py/src/remotestate/__init__.py b/remotestate-py/src/remotestate/__init__.py index 9af3ce0..43d9876 100644 --- a/remotestate-py/src/remotestate/__init__.py +++ b/remotestate-py/src/remotestate/__init__.py @@ -5,12 +5,13 @@ from . import path from .serve import ServeResult, serve from .service import Service, action, query -from .store import Store +from .store import Store, StoreAt __version__ = version("remotestate") __all__ = [ "Store", + "StoreAt", "Service", "ServeResult", "action", diff --git a/remotestate-py/src/remotestate/serve.py b/remotestate-py/src/remotestate/serve.py index 7203cf0..d431d84 100644 --- a/remotestate-py/src/remotestate/serve.py +++ b/remotestate-py/src/remotestate/serve.py @@ -6,6 +6,7 @@ import webbrowser from collections.abc import Callable from dataclasses import dataclass, field +from html import escape from typing import Any, Literal, TypeGuard from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit @@ -56,6 +57,33 @@ def stop(self, *, timeout: float = 5.0) -> None: _wait_for_port_free(self.host, self.port, timeout=timeout) _servers.pop(self.registry_key, None) + def _repr_html_(self) -> str: + rows = [ + ("Host", self.host, False), + ("Port", self.port, False), + ("Server URL", self.server_url, True), + ("WebSocket URL", self.ws_url, True), + ("UI Base URL", self.ui_base_url, True), + ("UI URL", self.ui_url, True), + ] + + def format_value(value: object, is_url: bool) -> str: + escaped = escape(str(value)) + if is_url: + return f'{escaped}' + return escaped + + body = "".join( + f"{escape(label)}{format_value(value, is_url)}" + for label, value, is_url in rows + ) + return ( + "" + "" + f"{body}" + "
FieldValue
" + ) + Display = DisplayMode | Callable[[ServeResult], None] diff --git a/remotestate-py/src/remotestate/store.py b/remotestate-py/src/remotestate/store.py index 21ce187..72dd09e 100644 --- a/remotestate-py/src/remotestate/store.py +++ b/remotestate-py/src/remotestate/store.py @@ -1,16 +1,16 @@ -# remotestate/store.py from __future__ import annotations from collections.abc import Callable from contextvars import ContextVar from html import escape -from typing import Any, Generic, TypeVar, cast +from typing import Any, Generic, Protocol, TypeVar, cast from .context import _call_context from .path import Path, PathInput, PathSegment, normalize_path type PendingUpdates = dict[Path, Any] type DefaultFactory = Callable[[Path], Any] + T = TypeVar("T") @@ -49,7 +49,7 @@ def state(self) -> T: return self._state @property - def at(self) -> _StoreAt: + def at(self) -> StoreAt: """Notebook-friendly path accessor for setting nested values. The accessor builds paths through attribute and item access, then writes @@ -176,8 +176,24 @@ def _flush(self, pending: PendingUpdates) -> None: self._notify(pending) -class _StoreAt: - """Path-building proxy returned by ``Store.at``.""" +class StoreAt(Protocol): + """Path-building proxy protocol as returned by ``Store.at``.""" + + @property + def value(self) -> Any: ... + + def __getattr__(self, name: str) -> StoreAt: ... + def __setattr__(self, name: str, value: Any) -> None: ... + + def __getitem__(self, segment: PathSegment) -> StoreAt: ... + def __setitem__(self, segment: PathSegment, value: Any) -> None: ... + + def __str__(self) -> str: ... + def __repr__(self) -> str: ... + + +class _StoreAt(StoreAt): + """Path-building proxy implementation returned by ``Store.at``.""" _path: Path _store: Store[Any] @@ -190,15 +206,19 @@ def __init__(self, store: Store[Any], path: Path = ()) -> None: object.__setattr__(self, "_store", store) object.__setattr__(self, "_path", path) - def __getattr__(self, segment: str) -> _StoreAt: - if segment.startswith("_"): - raise AttributeError(segment) - return _StoreAt(self._store, (*self._path, segment)) + @property + def value(self) -> Any: + return self._store.get(self._path) - def __setattr__(self, segment: str, value: Any) -> None: - if segment.startswith("_"): - raise AttributeError(segment) - self._store.set((*self._path, segment), value) + def __getattr__(self, name: str) -> _StoreAt: + if name.startswith("_"): + raise AttributeError(name) + return _StoreAt(self._store, (*self._path, name)) + + def __setattr__(self, name: str, value: Any) -> None: + if name.startswith("_"): + raise AttributeError(name) + self._store.set((*self._path, name), value) def __getitem__(self, segment: PathSegment) -> _StoreAt: return _StoreAt(self._store, (*self._path, segment)) @@ -206,20 +226,20 @@ def __getitem__(self, segment: PathSegment) -> _StoreAt: def __setitem__(self, segment: PathSegment, value: Any) -> None: self._store.set((*self._path, segment), value) + def __str__(self) -> str: + return str(self.value) + def __repr__(self) -> str: - return repr(self._value()) + return repr(self.value) def _repr_html_(self) -> str: - return f"
{escape(repr(self._value()))}
" + return f"
{escape(repr(self.value))}
" def _repr_pretty_(self, printer: Any, cycle: bool) -> None: if cycle: printer.text("...") - return - printer.pretty(self._value()) - - def _value(self) -> Any: - return self._store.get(self._path) + else: + printer.pretty(self.value) def _set_or_append_segment( diff --git a/remotestate-py/tests/test_serve.py b/remotestate-py/tests/test_serve.py index 8119c06..03fe5af 100644 --- a/remotestate-py/tests/test_serve.py +++ b/remotestate-py/tests/test_serve.py @@ -9,6 +9,7 @@ # noinspection PyProtectedMember from remotestate.serve import ( + ServeResult, _add_ui_url_params, _get_cell_id, _get_display_mode, @@ -16,6 +17,45 @@ _wait_for_port_free, ) + +# --- ServeResult --- + + +def test_serve_result_html_repr_shows_relevant_fields(): + result = ServeResult( + host="local", + port=50733, + server_url="http://local:50733", + ws_url="ws://local:50733/ws", + ui_base_url="http://localhost:5173/app?mode=", + ui_url="http://localhost:5173/app?mode=&ws=ws://local:50733/ws", + app=MagicMock(), + server=MagicMock(), + thread=MagicMock(), + registry_key="cell-1", + ) + + assert result._repr_html_() == ( + "" + "" + "" + "" + "" + '" + '" + '" + "" + '" + "" + "
FieldValue
Hostlocal<host>
Port50733
Server URL' + "http://local<host>:50733
WebSocket URL' + "ws://local<host>:50733/ws
UI Base URL' + "http://localhost:5173/app?mode=<dev>
UI URL' + "http://localhost:5173/app?mode=<dev>&ws=ws://local<host>:50733/ws" + "
" + ) + + # --- _in_jupyter --- diff --git a/remotestate-py/tests/test_store.py b/remotestate-py/tests/test_store.py index 6ede6a8..962a551 100644 --- a/remotestate-py/tests/test_store.py +++ b/remotestate-py/tests/test_store.py @@ -97,6 +97,12 @@ def test_get_index_out_of_bounds_require(simple_store): simple_store.get("items[99]", require=True) +def test_get_with_at_and_value(simple_store): + assert simple_store.at.value == simple_store.state + assert simple_store.at.user.value == {"name": "Norman", "age": 42} + assert simple_store.at.user.age.value == 42 + + def test_get_pydantic(pydantic_store): assert pydantic_store.get("user.name") == "Norman" @@ -161,6 +167,12 @@ def test_set_with_at_accessor(simple_store): assert simple_store.get("items[0].label") == "x" +def test_set_with_at_and_value(simple_store): + with pytest.raises(AttributeError, match="value"): + # noinspection PyPropertyAccess + simple_store.at.user.age.value = 137 + + def test_set_with_at_accessor_item_keys(): store = Store({"items.with.dot": {"get": "old"}}) @@ -179,6 +191,10 @@ def test_set_with_at_accessor_notifies_exact_path(simple_store): assert updates == {("items", 0, "label"): "new"} +def test_at_accessor_str_shows_value(simple_store): + assert str(simple_store.at.items[0].label) == "foo" + + def test_at_accessor_repr_shows_value(simple_store): assert repr(simple_store.at.items[0].label) == "'foo'" @@ -186,15 +202,17 @@ def test_at_accessor_repr_shows_value(simple_store): def test_at_accessor_pretty_repr_shows_value(simple_store): printer = MagicMock() + # noinspection PyCallingNonCallable simple_store.at.items[0].label._repr_pretty_(printer, cycle=False) printer.pretty.assert_called_once_with("foo") def test_at_accessor_html_repr_shows_escaped_value(): - store = Store({"value": ""}) + store = Store({"x": ""}) - assert store.at.value._repr_html_() == "
'<tag>'
" + # noinspection PyCallingNonCallable + assert store.at.x._repr_html_() == "
'<tag>'
" def test_set_pydantic(pydantic_store): diff --git a/remotestate-ts/CHANGES.md b/remotestate-ts/CHANGES.md index 64caa9f..5abce40 100644 --- a/remotestate-ts/CHANGES.md +++ b/remotestate-ts/CHANGES.md @@ -1,3 +1,7 @@ +## Version 0.3.3 (in development) + +- Improved usability of the Python `Store` class. + ## Version 0.3.2 - Fixed WebSocket serialization so store `set` messages with `undefined`