Skip to content
Draft
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
22 changes: 11 additions & 11 deletions remotestate-demo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ If you are developing a production application, we recommend updating the config

```js
export default defineConfig([
globalIgnores(['dist']),
globalIgnores(["dist"]),
{
files: ['**/*.{ts,tsx}'],
files: ["**/*.{ts,tsx}"],
extends: [
// Other configs...

Expand All @@ -34,40 +34,40 @@ export default defineConfig([
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
project: ["./tsconfig.node.json", "./tsconfig.app.json"],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
]);
```

You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:

```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
import reactX from "eslint-plugin-react-x";
import reactDom from "eslint-plugin-react-dom";

export default defineConfig([
globalIgnores(['dist']),
globalIgnores(["dist"]),
{
files: ['**/*.{ts,tsx}'],
files: ["**/*.{ts,tsx}"],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
reactX.configs["recommended-typescript"],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
project: ["./tsconfig.node.json", "./tsconfig.app.json"],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
]);
```
18 changes: 9 additions & 9 deletions remotestate-demo/eslint.config.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
import { defineConfig, globalIgnores } from "eslint/config";

export default defineConfig([
globalIgnores(['dist']),
globalIgnores(["dist"]),
{
files: ['**/*.{ts,tsx}'],
files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
Expand All @@ -19,4 +19,4 @@ export default defineConfig([
globals: globals.browser,
},
},
])
]);
20 changes: 1 addition & 19 deletions remotestate-demo/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion remotestate-demo/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@

&::before,
&::after {
content: '';
content: "";
position: absolute;
top: -4.5px;
border: 5px solid transparent;
Expand Down
4 changes: 2 additions & 2 deletions remotestate-demo/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;

--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--sans: system-ui, "Segoe UI", Roboto, sans-serif;
--heading: system-ui, "Segoe UI", Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;

font: 18px/145% var(--sans);
Expand Down
10 changes: 5 additions & 5 deletions remotestate-demo/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

// https://vite.dev/config/
export default defineConfig({
Expand All @@ -8,6 +8,6 @@ export default defineConfig({
// linked via `file:` paths (which create symlinks with separate node_modules).
// Can be removed once all dependencies are installed from npm.
resolve: {
dedupe: ['react', 'react-dom']
}
})
dedupe: ["react", "react-dom"],
},
});
30 changes: 30 additions & 0 deletions remotestate-py/CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,33 @@
## Version 0.3.0 (in development)

- `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()` 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.
- `store["items", 0, "label"]` uses tuple path segments.
- `store[()]` addresses the root state value.
- Relaxed the path grammar so the empty string addresses the root value and
paths may start with a bracketed array index or string key, such as
`[0].label` or `["display name"].value`.
- Updated JSONPath conversion helpers so `""` maps to `$` and `[0]` maps to
`$[0]`.
- Added public `PathInput` and `PathSegmentInput` aliases plus
`normalize_path()` and `normalize_path_segment()` helpers under
`remotestate.path`.
- Removed the Python-only `Property` and `Index` parsed path segment classes.
Parsed paths now use primitive tuple segments, such as `("items", 0,
"label")`, matching the TypeScript path model.
- Aligned `PathInput` with TypeScript: pass a string path or a sequence of
path segments. Root array entries can be addressed as `"[0]"` or `(0,)`.


## Version 0.2.0

- Tightened the shared path grammar to a strict JSONPath subset without the
Expand Down
62 changes: 38 additions & 24 deletions remotestate-py/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,17 @@ The public Python API is exported from `remotestate`:

`Store(initial, *, default_factory=None)` holds the Python-side application state.

- the root state is a mapping
- the root state may be any JSON-serializable value, including a mapping, list,
scalar, Pydantic model, or dataclass
- nested dicts, lists, Pydantic models, and dataclasses are all supported
- `default_factory` receives the missing prefix as a `rs.path.Path` tuple

- `get(path, require=False)` reads a value from a path such as `user.name` or `items[0].label`
- `state` returns the current root state value
- `get(path=(), require=False)` reads a value from a path such as ``, `user.name`,
`[0].label`, or `items[0].label`; omit `path` to read the root state value
- `set(path, value)` writes a value and notifies subscribers
- `store[path]` and `store[path] = value` are notebook-friendly aliases for
`get()` and `set()`
- `subscribe(callback)` receives batched path-to-value updates after changes flush
- `default_factory` can materialize missing parents while setting nested values

Expand All @@ -96,19 +101,22 @@ class User:


def defaults(path: rs.path.Path):
if path == (rs.path.Property("user"),):
if path == ("user",):
return User()
if path == (rs.path.Property("items"),):
if path == ("items",):
return []
return {}


store = rs.Store({}, default_factory=defaults)
store.set("user.city", "Hamburg")
store.set("items[0].label", "foo")
store["items", 0, "label"] = "bar"

assert store.get("user.city") == "Hamburg"
assert store.get("items") == [{"label": "foo"}]
assert store.get() is store.state
assert store["items"] == [{"label": "bar"}]
assert store[()] is store.state
```

`get()` never calls the default factory. Reads stay side-effect free, and missing
Expand Down Expand Up @@ -139,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 Expand Up @@ -180,30 +187,37 @@ print("UI Base URL: ", result.ui_base_url)
## Paths

`remotestate.path` exposes the parsed path types used by `Store.default_factory` and other
advanced integrations:
advanced integrations. Parsed paths are tuples of string property names and integer array
indices, matching the TypeScript package's `string | number` path segments:

- `Path`
- `Property`
- `Index`
- `PathSegment`
- `PathInput`
- `PathSegmentInput`

RemoteState paths use a simplified [JSONPath](https://www.rfc-editor.org/info/rfc9535/)
subset without the `"$."` prefix:

- the root segment is an identifier
- the empty string addresses the root state value
- the first segment may be an identifier, bracketed integer index, or bracketed
JSON string key
- later segments may be dotted identifiers, bracketed integer indices, or bracketed JSON string keys
- bracketed string keys may use either single or double quotes; canonical output uses double quotes
- the whole string must match the grammar; prefix parsing is not allowed

| Example | Valid? | Notes |
|------------------------|--------|-------------------------------------------------------|
| `user` | yes | root identifier only |
| `items[0].label` | yes | dotted identifier plus integer index |
| `user["display name"]` | yes | bracketed string key |
| `$.user` | no | `"$."` prefix is not part of the syntax |
| `["root"]` | no | root must be an identifier |
| `items[01]` | no | indices are canonical integers without leading zeroes |

Use `parse_path()` and `format_path()` when you need to inspect, validate, or construct paths.
| Example | Valid? | Notes |
|---------------------------|--------|-------------------------------------------------------|
| empty string | yes | root state value |
| `user` | yes | root property shorthand |
| `[0].label` | yes | array root plus child property |
| `items[0].label` | yes | dotted identifier plus integer index |
| `["display name"].value` | yes | bracketed string key at the root |
| `user["display name"]` | yes | bracketed string key |
| `$.user` | no | `"$."` prefix is not part of the syntax |
| `items[01]` | no | indices are canonical integers without leading zeroes |

Use `normalize_path()` when accepting raw path inputs, and use `parse_path()` and
`format_path()` when you need to inspect, validate, or construct string paths.

## More Docs

Expand Down
2 changes: 1 addition & 1 deletion remotestate-py/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "remotestate"
version = "0.2.0"
version = "0.3.0.dev0"
authors = [{name = "forman"}]
description = "Python state, React UI."
readme = "README.md"
Expand Down
2 changes: 1 addition & 1 deletion remotestate-py/src/remotestate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
from importlib.metadata import version

from . import path
from .service import Service, action, query
from .serve import ServeResult, serve
from .service import Service, action, query
from .store import Store

__version__ = version("remotestate")
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."""
2 changes: 1 addition & 1 deletion remotestate-py/src/remotestate/log.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Final
import logging
from typing import Final

LOG: Final = logging.getLogger("remotestate")
Loading