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
15 changes: 15 additions & 0 deletions remotestate-py/CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
## 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()` and the built-in `Service.get()` query now default to the root
state value when no path is passed.
- 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]`.


## Version 0.2.0

Expand Down
38 changes: 25 additions & 13 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 @@ -106,9 +111,12 @@ def defaults(path: rs.path.Path):
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 @@ -141,7 +149,7 @@ class Counter(rs.Service):

`Service` also provides built-in methods that power the generic TypeScript bridge:

- `get(path)` reads a store value by path
- `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
Expand Down Expand Up @@ -189,19 +197,23 @@ advanced integrations:
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 |
| 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 `parse_path()` and `format_path()` when you need to inspect, validate, or construct paths.

Expand Down
54 changes: 39 additions & 15 deletions remotestate-py/src/remotestate/path.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ def parse_path(path: str) -> Path:
RemoteState paths use a strict subset of JSONPath without the ``"$."``
prefix:

- the root segment must be an identifier
- an empty path 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
- identifiers must match ``[a-zA-Z_][a-zA-Z0-9_]*``
Expand All @@ -55,8 +57,11 @@ def parse_path(path: str) -> Path:

Examples:

- ``""``
- ``"user"``
- ``"[0].label"``
- ``"items[0].label"``
- ``"[\"display name\"]"``
- ``"user[\"display name\"]"``

Args:
Expand All @@ -69,13 +74,22 @@ def parse_path(path: str) -> Path:
Raises:
ValueError: If ``path`` is not a valid RemoteState path.
"""
if path == "":
return ()

first = _read_identifier(path, 0)
if first is None:
if first is not None:
segments: list[PathSegment] = [Property(first[0])]
pos = first[1]
elif path[0] == "[":
bracket = _read_bracket_segment(path, 0)
if bracket is None:
raise _invalid_path(path)
segments = [bracket[0]]
pos = bracket[1]
else:
raise _invalid_path(path)

segments: list[PathSegment] = [Property(first[0])]
pos = first[1]

while pos < len(path):
match path[pos]:
case ".":
Expand All @@ -98,13 +112,14 @@ def parse_path(path: str) -> Path:


def prefixes(path: Path) -> list[Path]:
"""Return all non-empty prefixes of a parsed path.
"""Return all non-root prefixes of a parsed path.

Args:
path: Parsed path.

Returns:
Prefix paths ordered from shortest to longest.
Prefix paths ordered from shortest to longest. The root path ``()`` has
no non-root prefixes.
"""
return [path[:i] for i in range(1, len(path) + 1)]

Expand All @@ -119,12 +134,14 @@ def format_path(path: Path) -> str:
String representation of ``path``.
"""
_validate_path(path)
if len(path) == 0:
return ""

parts: list[str] = []
for index, seg in enumerate(path):
match seg:
case Property(key):
if index == 0:
if index == 0 and _IDENTIFIER_RE.fullmatch(key):
parts.append(key)
elif _IDENTIFIER_RE.fullmatch(key):
parts.append(f".{key}")
Expand All @@ -144,39 +161,46 @@ def to_jsonpath(path: str) -> str:
Returns:
JSONPath string for the same location.
"""
if path == "":
return "$"
if path.startswith("["):
return f"${path}"
return f"$.{path}"


def from_jsonpath(path: str) -> str:
"""Convert a simple JSONPath string to a RemoteState path.

Args:
path: JSONPath string that starts with ``"$."``.
path: JSONPath string that is ``"$"``, starts with ``"$."``, or starts
with ``"$["``.

Returns:
RemoteState path string.

Raises:
ValueError: If ``path`` does not start with ``"$."``.
ValueError: If ``path`` is not a supported simple JSONPath string.
"""
if path == "$":
return ""
if path.startswith("$["):
return path[1:]
if not path.startswith("$."):
raise ValueError(f"Not a JSONPath: {path!r}")
return path[2:]


def _validate_path(path: Path) -> None:
if len(path) == 0:
raise ValueError(_INVALID_PATH_MESSAGE)
if not isinstance(path[0], Property) or not _IDENTIFIER_RE.fullmatch(path[0].key):
raise ValueError(_INVALID_PATH_MESSAGE)
for segment in path[1:]:
for segment in path:
match segment:
case Property(key):
if not isinstance(key, str):
raise ValueError(_INVALID_PATH_MESSAGE)
case Index(i):
if not isinstance(i, int) or i < 0:
raise ValueError(_INVALID_PATH_MESSAGE)
case _:
raise ValueError(_INVALID_PATH_MESSAGE)


def _read_identifier(path: str, start: int) -> tuple[str, int] | None:
Expand Down
5 changes: 3 additions & 2 deletions remotestate-py/src/remotestate/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,14 +154,15 @@ def store(self) -> Store:
return self._store

@query
def get(self, path: str) -> Any:
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.
path: RemoteState path to read. If omitted, reads the root state
value.

Returns:
The value at ``path``, or ``None`` when the path is missing.
Expand Down
Loading