From 3777dcdb16af9378792727f23e38c7a8608a307e Mon Sep 17 00:00:00 2001 From: Norman Fomferra Date: Sat, 20 Jun 2026 16:45:07 +0200 Subject: [PATCH 1/3] Consistently using `PathInput` and `Path` in both APIs --- remotestate-py/CHANGES.md | 3 ++ remotestate-py/README.md | 6 ++- remotestate-py/src/remotestate/path.py | 65 +++++++++++++++++++++++ remotestate-py/src/remotestate/store.py | 49 +++-------------- remotestate-py/tests/test_path.py | 35 ++++++++++++ remotestate-ts/CHANGES.md | 2 + remotestate-ts/README.md | 2 +- remotestate-ts/src/lib/index.ts | 2 +- remotestate-ts/src/lib/path.ts | 17 +++--- remotestate-ts/src/lib/react/hooks.ts | 12 ++--- remotestate-ts/src/test/path.test.ts | 13 +++-- remotestate-ts/src/test/provider.test.tsx | 4 +- 12 files changed, 149 insertions(+), 61 deletions(-) diff --git a/remotestate-py/CHANGES.md b/remotestate-py/CHANGES.md index 7c7c31d..f04126f 100644 --- a/remotestate-py/CHANGES.md +++ b/remotestate-py/CHANGES.md @@ -18,6 +18,9 @@ `[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`. ## Version 0.2.0 diff --git a/remotestate-py/README.md b/remotestate-py/README.md index cc9106c..ee9ad05 100644 --- a/remotestate-py/README.md +++ b/remotestate-py/README.md @@ -190,6 +190,9 @@ print("UI Base URL: ", result.ui_base_url) advanced integrations: - `Path` +- `PathSegment` +- `PathInput` +- `PathSegmentInput` - `Property` - `Index` @@ -214,7 +217,8 @@ subset without the `"$."` prefix: | `$.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. +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 diff --git a/remotestate-py/src/remotestate/path.py b/remotestate-py/src/remotestate/path.py index 0faf76b..1f6a20a 100644 --- a/remotestate-py/src/remotestate/path.py +++ b/remotestate-py/src/remotestate/path.py @@ -1,6 +1,7 @@ import functools import json import re +from collections.abc import Sequence from dataclasses import dataclass @@ -32,6 +33,12 @@ class Index: # A parsed RemoteState path. type Path = tuple[PathSegment, ...] +# A raw value accepted as one path segment. +type PathSegmentInput = str | int | PathSegment + +# A raw value accepted anywhere a RemoteState path is needed. +type PathInput = str | int | Sequence[PathSegmentInput] | Path + _IDENTIFIER_RE = re.compile(r"[a-zA-Z_][a-zA-Z0-9_]*") _INTEGER_RE = re.compile(r"0|[1-9][0-9]*") _INVALID_PATH_MESSAGE = "RemoteState paths must be valid simplified JSONPath paths" @@ -111,6 +118,58 @@ def parse_path(path: str) -> Path: return tuple(segments) +def normalize_path(path: PathInput) -> Path: + """Normalize a path input value into a parsed RemoteState path. + + Args: + path: RemoteState path string, root array index, or a sequence of path + segment inputs such as ``("items", 0, "label")``. + + Returns: + Parsed path. + + Raises: + TypeError: If ``path`` is not a supported path input value. + ValueError: If ``path`` contains an invalid segment. + """ + if isinstance(path, str): + return parse_path(path) + if isinstance(path, int): + return (normalize_path_segment(path),) + if isinstance(path, Sequence): + return tuple(normalize_path_segment(segment) for segment in path) + raise TypeError( + "RemoteState path must be a string, integer, or sequence of path segments" + ) + + +def normalize_path_segment(segment: PathSegmentInput) -> PathSegment: + """Normalize one path segment input value into a parsed path segment. + + Args: + segment: A string property name, integer index, ``Property``, or + ``Index``. + + Returns: + Parsed path segment. + + Raises: + TypeError: If ``segment`` is not a supported path segment input value. + ValueError: If an integer index is negative. + """ + if isinstance(segment, Property): + return segment + if isinstance(segment, Index): + return _normalize_index(segment.i) + if isinstance(segment, bool): + raise ValueError("RemoteState path indices must be non-negative integers") + if isinstance(segment, int): + return _normalize_index(segment) + if isinstance(segment, str): + return Property(segment) + raise TypeError("RemoteState path segments must be strings or integers") + + def prefixes(path: Path) -> list[Path]: """Return all non-root prefixes of a parsed path. @@ -203,6 +262,12 @@ def _validate_path(path: Path) -> None: raise ValueError(_INVALID_PATH_MESSAGE) +def _normalize_index(index: int) -> Index: + if isinstance(index, bool) or index < 0: + raise ValueError("RemoteState path indices must be non-negative integers") + return Index(index) + + def _read_identifier(path: str, start: int) -> tuple[str, int] | None: if start >= len(path): return None diff --git a/remotestate-py/src/remotestate/store.py b/remotestate-py/src/remotestate/store.py index 7450458..87a280d 100644 --- a/remotestate-py/src/remotestate/store.py +++ b/remotestate-py/src/remotestate/store.py @@ -9,16 +9,15 @@ from .path import ( Index, Path, + PathInput, PathSegment, Property, format_path, - parse_path, + normalize_path, ) type PendingUpdates = dict[str, Any] type DefaultFactory = Callable[[Path], Any] -type PathKeySegment = str | int | PathSegment -type PathKey = str | int | tuple[PathKeySegment, ...] T = TypeVar("T") @@ -56,7 +55,7 @@ def state(self) -> T: """The current root state value.""" return self._state - def __getitem__(self, path: PathKey) -> Any: + def __getitem__(self, path: PathInput) -> Any: """Return the value at ``path``. ``path`` may be a RemoteState path string, an integer root index, or a @@ -65,14 +64,14 @@ def __getitem__(self, path: PathKey) -> Any: """ return self.get(path) - def __setitem__(self, path: PathKey, value: Any) -> None: + def __setitem__(self, path: PathInput, value: Any) -> None: """Set ``value`` at ``path``. ``path`` follows the same rules as ``__getitem__``. """ self.set(path, value) - def get(self, path: PathKey = (), *, require: bool = False) -> Any: + def get(self, path: PathInput = (), *, require: bool = False) -> Any: """Return the value at ``path``. Missing values return ``None`` by default. Pass ``require=True`` to @@ -96,10 +95,10 @@ def get(self, path: PathKey = (), *, require: bool = False) -> Any: IndexError: If a required list index is missing. AttributeError: If a required object attribute is missing. """ - parsed = _normalize_path(path) + parsed = normalize_path(path) return _get_at(self._state, parsed, require) - def set(self, path: PathKey, value: Any) -> None: + def set(self, path: PathInput, value: Any) -> None: """Set ``value`` at ``path`` and notify subscribers. If this store has a default factory, missing intermediate path @@ -124,7 +123,7 @@ def set(self, path: PathKey, value: Any) -> None: if ctx is not None and ctx.readonly: raise PermissionError("query methods cannot mutate store") - parsed = _normalize_path(path) + parsed = normalize_path(path) self._state = cast( T, _set_at( @@ -296,35 +295,3 @@ def __exit__(self, *_: Any) -> None: _batch_context: ContextVar[PendingUpdates | None] = ContextVar( "_batch_context", default=None ) - - -def _normalize_path(path: PathKey) -> Path: - if isinstance(path, str): - return parse_path(path) - if isinstance(path, int): - return (_normalize_index(path),) - if isinstance(path, tuple): - return tuple(_normalize_path_segment(segment) for segment in path) - raise TypeError( - "RemoteState path must be a string, integer, or tuple of path segments" - ) - - -def _normalize_path_segment(segment: PathKeySegment) -> PathSegment: - if isinstance(segment, Property): - return segment - if isinstance(segment, Index): - return _normalize_index(segment.i) - if isinstance(segment, bool): - raise ValueError("RemoteState path indices must be non-negative integers") - if isinstance(segment, int): - return _normalize_index(segment) - if isinstance(segment, str): - return Property(segment) - raise TypeError("RemoteState path tuple segments must be strings or integers") - - -def _normalize_index(index: int) -> Index: - if isinstance(index, bool) or index < 0: - raise ValueError("RemoteState path indices must be non-negative integers") - return Index(index) diff --git a/remotestate-py/tests/test_path.py b/remotestate-py/tests/test_path.py index 7695d2e..1df2105 100644 --- a/remotestate-py/tests/test_path.py +++ b/remotestate-py/tests/test_path.py @@ -6,6 +6,8 @@ Property, from_jsonpath, format_path, + normalize_path, + normalize_path_segment, parse_path, prefixes, to_jsonpath, @@ -128,6 +130,39 @@ def test_invalid_jsonpath_wildcard(): parse_path("items[*]") +# --- normalize_path --- + + +def test_normalize_path_accepts_strings(): + assert normalize_path("items[0].label") == ( + Property("items"), + Index(0), + Property("label"), + ) + + +def test_normalize_path_accepts_segment_sequences(): + assert normalize_path(("items", 0, "label")) == ( + Property("items"), + Index(0), + Property("label"), + ) + + +def test_normalize_path_accepts_bare_root_index(): + assert normalize_path(0) == (Index(0),) + + +def test_normalize_path_segment_accepts_raw_segments(): + assert normalize_path_segment("items") == Property("items") + assert normalize_path_segment(0) == Index(0) + + +def test_normalize_path_rejects_invalid_segments(): + with pytest.raises(ValueError): + normalize_path(("items", -1)) + + # --- prefixes --- diff --git a/remotestate-ts/CHANGES.md b/remotestate-ts/CHANGES.md index 546e6d7..bcee07f 100644 --- a/remotestate-ts/CHANGES.md +++ b/remotestate-ts/CHANGES.md @@ -14,6 +14,8 @@ - Updated the transport-backed store cache so root subscriptions overlap all descendant updates, root updates materialize cached descendants, and leaf updates can patch cached root snapshots. +- Renamed the public path input alias from `PathLike` to `PathInput`, and added + `PathSegmentInput` for raw segment values. ## Version 0.2.0 diff --git a/remotestate-ts/README.md b/remotestate-ts/README.md index ae91890..1f0491e 100644 --- a/remotestate-ts/README.md +++ b/remotestate-ts/README.md @@ -198,7 +198,7 @@ always uses double quotes. | `$.user` | no | `$.` prefix is not part of the syntax | | `items[01]` | no | indices are canonical integers without leading zeroes | -- `normalizePath(path)` validates a path-like value and returns a `Path` +- `normalizePath(path)` validates a `PathInput` value and returns a `Path` - `parsePath(path)` turns a strict string path into a `Path` and throws `SyntaxError` on malformed input - `formatPath(path)` turns parsed segments back into canonical path syntax - `getPathAt(value, path)` reads a nested value diff --git a/remotestate-ts/src/lib/index.ts b/remotestate-ts/src/lib/index.ts index d79539d..e1e30d3 100644 --- a/remotestate-ts/src/lib/index.ts +++ b/remotestate-ts/src/lib/index.ts @@ -14,7 +14,7 @@ export type { LocalQueryHandlers, LocalStateClientOptions, } from "./local"; -export type { Path, PathLike, PathSegment } from "./path"; +export type { Path, PathInput, PathSegment, PathSegmentInput } from "./path"; export type { IncomingMessage, OutgoingMessage, diff --git a/remotestate-ts/src/lib/path.ts b/remotestate-ts/src/lib/path.ts index f931021..ec67f39 100644 --- a/remotestate-ts/src/lib/path.ts +++ b/remotestate-ts/src/lib/path.ts @@ -14,9 +14,14 @@ export type PathSegment = string | number; export type Path = readonly PathSegment[]; /** - * A value of type ``PathLike`` can be normalized into a value of type `Path`. + * A raw value accepted as one path segment. */ -export type PathLike = string | Path; +export type PathSegmentInput = string | number | PathSegment; + +/** + * A raw value accepted anywhere a RemoteState path is needed. + */ +export type PathInput = string | readonly PathSegmentInput[] | Path; const PATH_SEGMENT_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; const INVALID_PATH_MESSAGE = @@ -34,18 +39,18 @@ const STRING_ESCAPES: Readonly>> = { }; /** - * Normalizes a path-like value into a validated RemoteState path. + * Normalizes a path input value into a validated RemoteState path. * * A valid path may be empty to address the root value. Otherwise it starts * with an identifier or bracketed segment and may continue with dotted * identifiers, bracketed integer indices, or bracketed JSON string keys. * - * @param path A path-like value. + * @param path A path input value. * @returns The validated RemoteState path. * @throws A `SyntaxError` if the path is malformed. */ -export function normalizePath(path: PathLike): Path { - let rawPath: readonly PathSegment[]; +export function normalizePath(path: PathInput): Path { + let rawPath: readonly PathSegmentInput[]; if (typeof path === "string") { rawPath = parsePath(path); } else if (Array.isArray(path)) { diff --git a/remotestate-ts/src/lib/react/hooks.ts b/remotestate-ts/src/lib/react/hooks.ts index a870206..324b77c 100644 --- a/remotestate-ts/src/lib/react/hooks.ts +++ b/remotestate-ts/src/lib/react/hooks.ts @@ -7,7 +7,7 @@ import { useSyncExternalStore, } from "react"; import { type RemoteStateClient } from "../client"; -import { normalizePath, type Path, PathLike } from "../path"; +import { normalizePath, type Path, type PathInput } from "../path"; import { RemoteStateContext } from "./context"; import type { Store } from "../types"; import type { TaskState, TaskStore } from "../tasks"; @@ -66,7 +66,7 @@ export function useRemoteTaskStore(): TaskStore { */ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters export function useRemoteStateValue( - path: PathLike = ROOT_PATH, + path: PathInput = ROOT_PATH, ): T | undefined { const parsedPath = useNormalizedPath(path); const store = useRemoteStore(); @@ -99,14 +99,14 @@ export function useRemoteStateValue( * @returns A tuple containing the current value and an async setter. */ export function useRemoteState( - path: PathLike, + path: PathInput, ): [T | undefined, (next: SetStateValue) => Promise]; export function useRemoteState( - path: PathLike, + path: PathInput, initialValue: T, ): [T, (next: SetStateValue) => Promise]; export function useRemoteState( - path: PathLike, + path: PathInput, initialValue?: T, ): [T | undefined, (next: SetStateValue) => Promise] { const parsedPath = useNormalizedPath(path); @@ -188,6 +188,6 @@ export function useRemoteTasks(): readonly TaskState[] { // --- Helper hooks -export function useNormalizedPath(path: PathLike = ROOT_PATH): Path { +export function useNormalizedPath(path: PathInput = ROOT_PATH): Path { return useMemo(() => normalizePath(path), [path]); } diff --git a/remotestate-ts/src/test/path.test.ts b/remotestate-ts/src/test/path.test.ts index 6a6ba1e..a7ce737 100644 --- a/remotestate-ts/src/test/path.test.ts +++ b/remotestate-ts/src/test/path.test.ts @@ -6,7 +6,8 @@ import { parsePath, setPathAt, type Path, - type PathLike, + type PathInput, + type PathSegmentInput, } from "../lib"; import { pathsOverlap } from "../lib/path"; @@ -98,8 +99,8 @@ describe("normalizePath", () => { expectTypeOf(normalized).toEqualTypeOf(); }); - it("accepts an already parsed PathLike value without cloning", () => { - const path = ["items", 1, "label"] as const satisfies PathLike; + it("accepts an already parsed PathInput value without cloning", () => { + const path = ["items", 1, "label"] as const satisfies PathInput; expect(normalizePath(path)).toBe(path); }); @@ -128,6 +129,12 @@ describe("normalizePath", () => { expect(normalizePath(["", "label"])).toEqual(["", "label"]); }); + it("exports a PathSegmentInput type for raw segment values", () => { + const segment = 1 satisfies PathSegmentInput; + + expect(segment).toBe(1); + }); + it("rejects invalid array-form path segments", () => { expect(() => normalizePath(["items", 1.5])).toThrow(SyntaxError); expect(() => normalizePath(["items", -1, "label"])).toThrow(SyntaxError); diff --git a/remotestate-ts/src/test/provider.test.tsx b/remotestate-ts/src/test/provider.test.tsx index e119650..0344a8f 100644 --- a/remotestate-ts/src/test/provider.test.tsx +++ b/remotestate-ts/src/test/provider.test.tsx @@ -5,7 +5,7 @@ import { RemoteStateProvider, useRemoteStateClient, useRemoteStateValue, - type PathLike, + type PathInput, type RemoteStateClient, type Store, } from "../lib"; @@ -59,7 +59,7 @@ function RequiredClientStatus() { describe("RemoteStateProvider", () => { it("allows useRemoteStateValue to omit the path", () => { - const readRoot: (path?: PathLike) => unknown = useRemoteStateValue; + const readRoot: (path?: PathInput) => unknown = useRemoteStateValue; expect(readRoot).toBe(useRemoteStateValue); }); From ca88167ad5c643c5680b361802c0d08ee451eb6b Mon Sep 17 00:00:00 2001 From: Norman Fomferra Date: Sat, 20 Jun 2026 18:02:14 +0200 Subject: [PATCH 2/3] format --- remotestate-demo/README.md | 22 +++++++++++----------- remotestate-demo/eslint.config.js | 18 +++++++++--------- remotestate-demo/package-lock.json | 20 +------------------- remotestate-demo/src/App.css | 2 +- remotestate-demo/src/index.css | 4 ++-- remotestate-demo/vite.config.ts | 10 +++++----- remotestate-py/src/remotestate/__init__.py | 2 +- remotestate-py/src/remotestate/log.py | 2 +- remotestate-py/src/remotestate/protocol.py | 2 +- remotestate-py/src/remotestate/serve.py | 7 +++---- remotestate-py/src/remotestate/server.py | 13 ++++++------- remotestate-ts/CHANGES.md | 1 - remotestate-ts/TODO.md | 4 ++-- remotestate-ts/src/lib/path.ts | 10 ++-------- remotestate-ts/src/test/store.test.ts | 12 ++++++------ 15 files changed, 51 insertions(+), 78 deletions(-) diff --git a/remotestate-demo/README.md b/remotestate-demo/README.md index 7dbf7eb..db579d2 100644 --- a/remotestate-demo/README.md +++ b/remotestate-demo/README.md @@ -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... @@ -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... }, }, -]) +]); ``` diff --git a/remotestate-demo/eslint.config.js b/remotestate-demo/eslint.config.js index ef614d2..da6f026 100644 --- a/remotestate-demo/eslint.config.js +++ b/remotestate-demo/eslint.config.js @@ -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, @@ -19,4 +19,4 @@ export default defineConfig([ globals: globals.browser, }, }, -]) +]); diff --git a/remotestate-demo/package-lock.json b/remotestate-demo/package-lock.json index 70bb4cf..cf39ccb 100644 --- a/remotestate-demo/package-lock.json +++ b/remotestate-demo/package-lock.json @@ -30,7 +30,7 @@ }, "../remotestate-ts": { "name": "remotestate", - "version": "0.2.0", + "version": "0.3.0-dev.0", "license": "MIT", "devDependencies": { "@eslint/js": "^9.0.0", @@ -698,9 +698,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -718,9 +715,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -738,9 +732,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -758,9 +749,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -778,9 +766,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -798,9 +783,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/remotestate-demo/src/App.css b/remotestate-demo/src/App.css index f90339d..f460279 100644 --- a/remotestate-demo/src/App.css +++ b/remotestate-demo/src/App.css @@ -167,7 +167,7 @@ &::before, &::after { - content: ''; + content: ""; position: absolute; top: -4.5px; border: 5px solid transparent; diff --git a/remotestate-demo/src/index.css b/remotestate-demo/src/index.css index 5fb3313..aad5cb1 100644 --- a/remotestate-demo/src/index.css +++ b/remotestate-demo/src/index.css @@ -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); diff --git a/remotestate-demo/vite.config.ts b/remotestate-demo/vite.config.ts index 7174aea..44dcdb1 100644 --- a/remotestate-demo/vite.config.ts +++ b/remotestate-demo/vite.config.ts @@ -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({ @@ -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"], + }, +}); diff --git a/remotestate-py/src/remotestate/__init__.py b/remotestate-py/src/remotestate/__init__.py index 6914bf2..9af3ce0 100644 --- a/remotestate-py/src/remotestate/__init__.py +++ b/remotestate-py/src/remotestate/__init__.py @@ -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") diff --git a/remotestate-py/src/remotestate/log.py b/remotestate-py/src/remotestate/log.py index 5c8a1ff..625f29e 100644 --- a/remotestate-py/src/remotestate/log.py +++ b/remotestate-py/src/remotestate/log.py @@ -1,4 +1,4 @@ -from typing import Final import logging +from typing import Final LOG: Final = logging.getLogger("remotestate") diff --git a/remotestate-py/src/remotestate/protocol.py b/remotestate-py/src/remotestate/protocol.py index 9031d56..f58d27e 100644 --- a/remotestate-py/src/remotestate/protocol.py +++ b/remotestate-py/src/remotestate/protocol.py @@ -1,4 +1,4 @@ -from typing import Any, Literal, Annotated +from typing import Annotated, Any, Literal from pydantic import BaseModel, Field diff --git a/remotestate-py/src/remotestate/serve.py b/remotestate-py/src/remotestate/serve.py index d409148..7203cf0 100644 --- a/remotestate-py/src/remotestate/serve.py +++ b/remotestate-py/src/remotestate/serve.py @@ -11,14 +11,12 @@ import uvicorn from fastapi import FastAPI - from fastapi.staticfiles import StaticFiles from starlette.staticfiles import PathLike +from .log import LOG from .server import Server from .service import Service -from .log import LOG - # Imported at module level so tests can patch remotestate.serve._get_ipython. try: @@ -206,7 +204,8 @@ def _display_result( display_mode = _get_display_mode(display) if display_mode == "notebook": - from IPython.display import IFrame, display as ipython_display + from IPython.display import IFrame + from IPython.display import display as ipython_display ipython_display(IFrame(src=result.ui_url, width=width, height=height)) elif display_mode == "browser": diff --git a/remotestate-py/src/remotestate/server.py b/remotestate-py/src/remotestate/server.py index 6188e05..906ddae 100644 --- a/remotestate-py/src/remotestate/server.py +++ b/remotestate-py/src/remotestate/server.py @@ -6,29 +6,28 @@ from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi.staticfiles import StaticFiles -from starlette.staticfiles import PathLike from pydantic import TypeAdapter +from starlette.staticfiles import PathLike +from .context import _suppress_store_broadcast +from .log import LOG from .protocol import ( ActionMessage, + ActionResultMessage, ErrorMessage, GetMessage, GetResultMessage, IncomingMessage, - SetMessage, - SetResultMessage, - ActionResultMessage, OutgoingMessage, QueryMessage, QueryResultMessage, + SetMessage, + SetResultMessage, TaskUpdateMessage, ) -from .context import _suppress_store_broadcast from .service import Service from .store import PendingUpdates, _batch_pending_updates from .transport import Transport -from .log import LOG - _IncomingAdapter: TypeAdapter[IncomingMessage] = TypeAdapter(IncomingMessage) diff --git a/remotestate-ts/CHANGES.md b/remotestate-ts/CHANGES.md index bcee07f..425f9f6 100644 --- a/remotestate-ts/CHANGES.md +++ b/remotestate-ts/CHANGES.md @@ -17,7 +17,6 @@ - Renamed the public path input alias from `PathLike` to `PathInput`, and added `PathSegmentInput` for raw segment values. - ## Version 0.2.0 - Tightened the shared path grammar to a strict JSONPath subset without the diff --git a/remotestate-ts/TODO.md b/remotestate-ts/TODO.md index bc42876..864f3f3 100644 --- a/remotestate-ts/TODO.md +++ b/remotestate-ts/TODO.md @@ -3,9 +3,9 @@ ## New Features - [ ] Ease implementing the `RemoteStateClient` interface for zustand-users. - Create a new subpackage `zustand` for this and make zustand a peer dependency. + Create a new subpackage `zustand` for this and make zustand a peer dependency. - [ ] Make `react` a truly optional submodule. - No longer export it from maon module. + No longer export it from maon module. - [x] Ease implementing the `RemoteStateClient` interface in general. See example in `README.md`, which is quite complex. - [x] Require an explicit WebSocket URL or local fallback client. diff --git a/remotestate-ts/src/lib/path.ts b/remotestate-ts/src/lib/path.ts index ec67f39..cd18991 100644 --- a/remotestate-ts/src/lib/path.ts +++ b/remotestate-ts/src/lib/path.ts @@ -227,10 +227,7 @@ export function setPathAt( * @param path The full path to compare against. * @returns Whether `prefix` is the same path or an ancestor of `path`. */ -export function isPathPrefixSegments( - prefix: Path, - path: Path, -): boolean { +export function isPathPrefixSegments(prefix: Path, path: Path): boolean { if (prefix.length > path.length) { return false; } @@ -255,10 +252,7 @@ export function pathsOverlap(left: string, right: string): boolean { * @param path The full parsed path. * @returns The remaining path segments after the prefix. */ -export function pathSegmentsAfter( - prefix: Path, - path: Path, -): Path { +export function pathSegmentsAfter(prefix: Path, path: Path): Path { return path.slice(prefix.length); } diff --git a/remotestate-ts/src/test/store.test.ts b/remotestate-ts/src/test/store.test.ts index a2c6661..b730364 100644 --- a/remotestate-ts/src/test/store.test.ts +++ b/remotestate-ts/src/test/store.test.ts @@ -280,9 +280,9 @@ describe("StoreImpl", () => { }); expect(listener).toHaveBeenCalledOnce(); - expect( - store.get(["processRequests", "sleep_a_while", "inputs"]), - ).toEqual({ duration: 123 }); + expect(store.get(["processRequests", "sleep_a_while", "inputs"])).toEqual({ + duration: 123, + }); }); it("materializes a subscribed child snapshot from a root action update", () => { @@ -317,9 +317,9 @@ describe("StoreImpl", () => { }); expect(listener).toHaveBeenCalledOnce(); - expect( - store.get(["processRequests", "sleep_a_while", "inputs"]), - ).toEqual({ duration: 123 }); + expect(store.get(["processRequests", "sleep_a_while", "inputs"])).toEqual({ + duration: 123, + }); }); it("refreshes cached child values from a parent action update", () => { From 2b2419698569999c91d7d0ba707c938d3a9a8ffc Mon Sep 17 00:00:00 2001 From: Norman Fomferra Date: Sun, 21 Jun 2026 08:49:19 +0200 Subject: [PATCH 3/3] Implemented the path alignment between Python and TS --- remotestate-py/src/remotestate/path.py | 113 +++++++++++-------------- remotestate-py/tests/test_path.py | 23 +++++ remotestate-ts/TODO.md | 2 +- remotestate-ts/src/lib/path.ts | 65 +++++++++----- remotestate-ts/src/test/path.test.ts | 21 +++++ 5 files changed, 140 insertions(+), 84 deletions(-) diff --git a/remotestate-py/src/remotestate/path.py b/remotestate-py/src/remotestate/path.py index 1f6a20a..1856405 100644 --- a/remotestate-py/src/remotestate/path.py +++ b/remotestate-py/src/remotestate/path.py @@ -40,8 +40,18 @@ class Index: type PathInput = str | int | Sequence[PathSegmentInput] | Path _IDENTIFIER_RE = re.compile(r"[a-zA-Z_][a-zA-Z0-9_]*") -_INTEGER_RE = re.compile(r"0|[1-9][0-9]*") _INVALID_PATH_MESSAGE = "RemoteState paths must be valid simplified JSONPath paths" +_STRING_ESCAPES = { + '"': '"', + "'": "'", + "\\": "\\", + "/": "/", + "b": "\b", + "f": "\f", + "n": "\n", + "r": "\r", + "t": "\t", +} @functools.cache @@ -287,7 +297,7 @@ def _read_bracket_segment(path: str, start: int) -> tuple[PathSegment, int] | No next_char = path[start + 1] if next_char in {'"', "'"}: - parsed = _read_quoted_string(path, start + 1) + parsed = _read_quoted_string_literal(path, start + 1) if parsed is None: return None key, pos = parsed @@ -295,23 +305,21 @@ def _read_bracket_segment(path: str, start: int) -> tuple[PathSegment, int] | No return None return Property(key), pos + 1 - if not next_char.isdigit(): + if not _is_digit(next_char): return None pos = start + 1 - while pos < len(path) and path[pos].isdigit(): + while pos < len(path) and _is_digit(path[pos]): pos += 1 digits = path[start + 1 : pos] if len(digits) > 1 and digits.startswith("0"): return None - if not _INTEGER_RE.fullmatch(digits): - return None if pos >= len(path) or path[pos] != "]": return None return Index(int(digits)), pos + 1 -def _read_quoted_string(path: str, start: int) -> tuple[str, int] | None: +def _read_quoted_string_literal(path: str, start: int) -> tuple[str, int] | None: if start >= len(path) or path[start] not in {'"', "'"}: return None @@ -332,64 +340,35 @@ def _read_quoted_string(path: str, start: int) -> tuple[str, int] | None: if pos >= len(path): return None escape = path[pos] - if escape == quote: - value.append(quote) + escaped = _STRING_ESCAPES.get(escape) + if escaped is not None: + value.append(escaped) pos += 1 continue - match escape: - case "\\": - value.append("\\") - pos += 1 - continue - case "/": - value.append("/") - pos += 1 - continue - case "b": - value.append("\b") - pos += 1 - continue - case "f": - value.append("\f") - pos += 1 - continue - case "n": - value.append("\n") - pos += 1 - continue - case "r": - value.append("\r") - pos += 1 - continue - case "t": - value.append("\t") - pos += 1 - continue - case "u": - parsed = _read_unicode_code_unit(path, pos + 1) - if parsed is None: + if escape == "u": + parsed = _read_unicode_code_unit(path, pos + 1) + if parsed is None: + return None + code_unit, pos = parsed + if 0xD800 <= code_unit <= 0xDBFF: + if pos + 6 > len(path) or path[pos] != "\\" or path[pos + 1] != "u": + return None + low = _read_unicode_code_unit(path, pos + 2) + if low is None: return None - code_unit, pos = parsed - if 0xD800 <= code_unit <= 0xDBFF: - if pos + 6 > len(path) or path[pos] != "\\" or path[pos + 1] != "u": - return None - low = _read_unicode_code_unit(path, pos + 2) - if low is None: - return None - low_unit, pos = low - if not 0xDC00 <= low_unit <= 0xDFFF: - return None - code_point = ( - 0x10000 + ((code_unit - 0xD800) << 10) + (low_unit - 0xDC00) - ) - value.append(chr(code_point)) - continue - if 0xDC00 <= code_unit <= 0xDFFF: + low_unit, pos = low + if not 0xDC00 <= low_unit <= 0xDFFF: return None - value.append(chr(code_unit)) + code_point = ( + 0x10000 + ((code_unit - 0xD800) << 10) + (low_unit - 0xDC00) + ) + value.append(chr(code_point)) continue - case _: + if 0xDC00 <= code_unit <= 0xDFFF: return None + value.append(chr(code_unit)) + continue + return None return None @@ -397,17 +376,27 @@ def _read_unicode_code_unit(path: str, start: int) -> tuple[int, int] | None: if start + 4 > len(path): return None hex_digits = path[start : start + 4] - if not re.fullmatch(r"[0-9a-fA-F]{4}", hex_digits): + if not all(_is_hex_digit(char) for char in hex_digits): return None return int(hex_digits, 16), start + 4 def _is_identifier_start(char: str) -> bool: - return bool(re.fullmatch(r"[a-zA-Z_]", char)) + code = ord(char) + return char == "_" or 0x41 <= code <= 0x5A or 0x61 <= code <= 0x7A def _is_identifier_part(char: str) -> bool: - return bool(re.fullmatch(r"[a-zA-Z0-9_]", char)) + return _is_identifier_start(char) or _is_digit(char) + + +def _is_digit(char: str) -> bool: + return "0" <= char <= "9" + + +def _is_hex_digit(char: str) -> bool: + code = ord(char) + return 0x30 <= code <= 0x39 or 0x41 <= code <= 0x46 or 0x61 <= code <= 0x66 def _invalid_path(path: str, pos: int | None = None) -> ValueError: diff --git a/remotestate-py/tests/test_path.py b/remotestate-py/tests/test_path.py index 1df2105..0875677 100644 --- a/remotestate-py/tests/test_path.py +++ b/remotestate-py/tests/test_path.py @@ -54,6 +54,29 @@ def test_string_key(): ) +def test_string_key_escapes(): + assert parse_path('user["line\\nbreak"]') == ( + Property("user"), + Property("line\nbreak"), + ) + assert parse_path('user["tab\\tseparated"]') == ( + Property("user"), + Property("tab\tseparated"), + ) + assert parse_path('user["quote\\"slash\\\\"]') == ( + Property("user"), + Property('quote"slash\\'), + ) + assert parse_path("user['double\\\"quote']") == ( + Property("user"), + Property('double"quote'), + ) + assert parse_path('user["emoji \\uD83D\\uDE00"]') == ( + Property("user"), + Property("emoji " + chr(0x1F600)), + ) + + def test_root_string_key(): assert parse_path('["root"]') == (Property("root"),) assert parse_path('["display name"].value') == ( diff --git a/remotestate-ts/TODO.md b/remotestate-ts/TODO.md index 864f3f3..42141f2 100644 --- a/remotestate-ts/TODO.md +++ b/remotestate-ts/TODO.md @@ -5,7 +5,7 @@ - [ ] Ease implementing the `RemoteStateClient` interface for zustand-users. Create a new subpackage `zustand` for this and make zustand a peer dependency. - [ ] Make `react` a truly optional submodule. - No longer export it from maon module. + No longer export it from main module. - [x] Ease implementing the `RemoteStateClient` interface in general. See example in `README.md`, which is quite complex. - [x] Require an explicit WebSocket URL or local fallback client. diff --git a/remotestate-ts/src/lib/path.ts b/remotestate-ts/src/lib/path.ts index cd18991..0843ccc 100644 --- a/remotestate-ts/src/lib/path.ts +++ b/remotestate-ts/src/lib/path.ts @@ -6,7 +6,7 @@ export type PathSegment = string | number; /** * A parsed RemoteState path. * - * An empty path addresses the root state value. Otherwise segments may be + * An empty path addresses the root state value. Otherwise, segments may be * strings or numeric array indices. This is the form used by store * implementations and other low-level helpers that already operate on * segmented paths. @@ -23,7 +23,7 @@ export type PathSegmentInput = string | number | PathSegment; */ export type PathInput = string | readonly PathSegmentInput[] | Path; -const PATH_SEGMENT_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; +const IDENTIFIER_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/; const INVALID_PATH_MESSAGE = "RemoteState paths must be valid simplified JSONPath paths"; const STRING_ESCAPES: Readonly>> = { @@ -41,7 +41,7 @@ const STRING_ESCAPES: Readonly>> = { /** * Normalizes a path input value into a validated RemoteState path. * - * A valid path may be empty to address the root value. Otherwise it starts + * A valid path may be empty to address the root value. Otherwise, it starts * with an identifier or bracketed segment and may continue with dotted * identifiers, bracketed integer indices, or bracketed JSON string keys. * @@ -97,8 +97,23 @@ export function parsePath(path: string): Path { return []; } - const segments: PathSegment[] = []; - let position = 0; + const first = readIdentifier(path, 0); + let segments: PathSegment[]; + let position: number; + + if (first) { + segments = [first.value]; + position = first.nextIndex; + } else if (path[0] === "[") { + const bracketSegment = readBracketSegment(path, 0); + if (!bracketSegment) { + throw new SyntaxError(INVALID_PATH_MESSAGE); + } + segments = [bracketSegment.value]; + position = bracketSegment.nextIndex; + } else { + throw new SyntaxError(INVALID_PATH_MESSAGE); + } while (position < path.length) { const next = path[position]; @@ -121,14 +136,6 @@ export function parsePath(path: string): Path { position = bracketSegment.nextIndex; continue; } - if (position === 0) { - const identifier = readIdentifier(path, position); - if (identifier) { - segments.push(identifier.value); - position = identifier.nextIndex; - continue; - } - } throw new SyntaxError(INVALID_PATH_MESSAGE); } @@ -149,9 +156,9 @@ export function formatPath(path: Path): string { const segment = path[index]; if (typeof segment === "number") { result += "[" + String(segment) + "]"; - } else if (index === 0 && PATH_SEGMENT_PATTERN.test(segment)) { + } else if (index === 0 && IDENTIFIER_RE.test(segment)) { result += segment; - } else if (PATH_SEGMENT_PATTERN.test(segment)) { + } else if (IDENTIFIER_RE.test(segment)) { result += "." + segment; } else { result += "[" + JSON.stringify(segment) + "]"; @@ -345,11 +352,16 @@ function readIdentifier( } function isIdentifierStart(char: string): boolean { - return /[a-zA-Z_]/.test(char); + const code = char.charCodeAt(0); + return ( + char === "_" || + (code >= 0x41 && code <= 0x5a) || + (code >= 0x61 && code <= 0x7a) + ); } function isIdentifierPart(char: string): boolean { - return /[a-zA-Z0-9_]/.test(char); + return isIdentifierStart(char) || isDigit(char); } function readBracketSegment( @@ -473,12 +485,23 @@ function readUnicodeCodeUnit( return null; } const hex = path.slice(start, start + 4); - if (!/^[0-9a-fA-F]{4}$/.test(hex)) { - return null; + for (let index = 0; index < hex.length; index += 1) { + if (!isHexDigit(hex[index])) { + return null; + } } return { codeUnit: Number.parseInt(hex, 16), nextIndex: start + 4 }; } -function isDigit(char: string): boolean { - return char >= "0" && char <= "9"; +function isDigit(char: string | undefined): boolean { + return char !== undefined && char >= "0" && char <= "9"; +} + +function isHexDigit(char: string): boolean { + const code = char.charCodeAt(0); + return ( + (code >= 0x30 && code <= 0x39) || + (code >= 0x41 && code <= 0x46) || + (code >= 0x61 && code <= 0x66) + ); } diff --git a/remotestate-ts/src/test/path.test.ts b/remotestate-ts/src/test/path.test.ts index a7ce737..c7048b0 100644 --- a/remotestate-ts/src/test/path.test.ts +++ b/remotestate-ts/src/test/path.test.ts @@ -31,6 +31,26 @@ describe("parsePath", () => { expect(parsePath('user[""]')).toEqual(["user", ""]); }); + it("parses bracketed string key escapes", () => { + expect(parsePath('user["line\\nbreak"]')).toEqual(["user", "line\nbreak"]); + expect(parsePath('user["tab\\tseparated"]')).toEqual([ + "user", + "tab\tseparated", + ]); + expect(parsePath('user["quote\\"slash\\\\"]')).toEqual([ + "user", + 'quote"slash\\', + ]); + expect(parsePath("user['double\\\"quote']")).toEqual([ + "user", + 'double"quote', + ]); + expect(parsePath('user["emoji \\uD83D\\uDE00"]')).toEqual([ + "user", + "emoji " + String.fromCodePoint(0x1f600), + ]); + }); + it("parses a single root segment", () => { expect(parsePath("count")).toEqual(["count"]); }); @@ -53,6 +73,7 @@ describe("parsePath", () => { it("throws on invalid path starts", () => { expect(() => parsePath("1items")).toThrow(SyntaxError); + expect(() => parsePath(".items")).toThrow(SyntaxError); }); it("throws on non-canonical integer syntax", () => {