flyrail 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
flyrail/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """flyrail - BYOF server-driven UI core.
2
+
3
+ Python API is reactpy-style but transport/V DOM agnostic:
4
+ - you build dict trees with helpers (Stack, Text, Button, ...)
5
+ - Layout.render(state) replaces callables with {"handlerId": ...} descriptors
6
+ """
7
+ from .core import component, pure, Slot, Stack, Text, Button, TextField
8
+ from .layout import Layout
9
+ from .driver import Driver
10
+ from .hooks import use_state, use_memo, use_effect
11
+ from .asgi import create_ws_app
12
+
13
+ __all__ = ["component", "pure", "Slot", "Stack", "Text", "Button", "TextField", "Layout", "Driver", "use_state", "use_memo", "use_effect", "create_ws_app"]
flyrail/asgi.py ADDED
@@ -0,0 +1,77 @@
1
+ """Framework-free ASGI websocket adapter: the async-host recipe.
2
+
3
+ One state + Driver + Layout per connection; no dependency beyond stdlib.
4
+ FastAPI/Starlette/Django-Channels users mount this where they handle
5
+ websockets (see README Hosting); everyone else copies the 30-line shape.
6
+ """
7
+ from __future__ import annotations
8
+ import asyncio
9
+ import inspect
10
+ import json
11
+ from typing import Any, Callable
12
+
13
+ from .driver import Driver
14
+ from .layout import Layout
15
+
16
+
17
+ def create_ws_app(
18
+ render_fn: Callable[[Any], dict],
19
+ *,
20
+ state_factory: Callable[[], Any] = dict,
21
+ version_fn: Callable[[], Any] | None = None,
22
+ allowed_types: set[str] | None = None,
23
+ strict: bool = False,
24
+ on_message: Callable[[dict, Any], Any] | None = None,
25
+ ):
26
+ """Return an ASGI websocket app serving one flyrail session per connection."""
27
+ _version_of = version_fn or (lambda: None)
28
+
29
+ async def app(scope, receive, send):
30
+ if scope["type"] != "websocket":
31
+ raise RuntimeError("flyrail ASGI app handles websocket scope only")
32
+ layout = Layout(render_fn, allowed_types=allowed_types, strict=strict)
33
+ driver = Driver(layout)
34
+ state = state_factory()
35
+
36
+ async def send_json(env: dict) -> None:
37
+ await send({"type": "websocket.send", "text": json.dumps(env)})
38
+
39
+ await send({"type": "websocket.accept"})
40
+ snap = layout.snapshot(state, seq=1)
41
+ driver.seq = 1
42
+ await send_json(snap)
43
+
44
+ run_task = asyncio.create_task(
45
+ driver.run(lambda: state, send_json, _version_of)
46
+ )
47
+ try:
48
+ while True:
49
+ msg = await receive()
50
+ if msg["type"] == "websocket.disconnect":
51
+ break
52
+ if msg["type"] != "websocket.receive":
53
+ continue
54
+ data = json.loads(msg.get("text") or msg.get("bytes") or "{}")
55
+ if data.get("chan") != "ui":
56
+ if on_message is not None:
57
+ res = on_message(data, state)
58
+ if inspect.isawaitable(res):
59
+ await res
60
+ continue
61
+ kind = data.get("type")
62
+ if kind == "action":
63
+ await layout.adispatch(
64
+ data["handlerId"], state, data.get("event"))
65
+ driver.invalidate()
66
+ elif kind == "resync-request":
67
+ driver.seq += 1
68
+ await send_json(layout.snapshot(state, driver.seq))
69
+ # Unknown ui subtypes ignored (forward-compat, mirrors store).
70
+ finally:
71
+ run_task.cancel()
72
+ try:
73
+ await run_task
74
+ except asyncio.CancelledError:
75
+ pass
76
+
77
+ return app
flyrail/core.py ADDED
@@ -0,0 +1,107 @@
1
+ """Declarative element constructors. No VDOM dependency."""
2
+ from __future__ import annotations
3
+ import functools
4
+ from typing import Any, Callable
5
+
6
+
7
+ def component(fn=None, *, key_arg: str | None = None):
8
+ """Declare a component. Calling it returns a lazy node that Layout
9
+ expands with per-instance hook state (keyed by key=, else position).
10
+ """
11
+ def wrap(f):
12
+ @functools.wraps(f)
13
+ def invoke(*args, **kwargs):
14
+ key = kwargs.pop("key", None)
15
+ return {"type": "__Component__", "fn": f, "key": key,
16
+ "args": args, "kwargs": kwargs}
17
+ invoke._is_flyrail_component = True
18
+ invoke._key_arg = key_arg
19
+ invoke._component_fn = f
20
+ return invoke
21
+ return wrap(fn) if fn else wrap
22
+
23
+
24
+ def pure(fn=None):
25
+ """Opt-in contract: output is a pure function of (arguments, hook slots).
26
+
27
+ Declares, not verifies: Layout may skip re-renders when the host version
28
+ is unchanged or when a component's arguments are unchanged, and strict mode
29
+ double-renders to spot-check. Unmarked renders always re-render. If you lie,
30
+ the stale UI is your bug.
31
+
32
+ Marks both sides of @component so either decorator order works: Layout
33
+ expands the inner function, while a host reading the flag off the name it
34
+ was given sees the wrapper.
35
+ """
36
+ def wrap(f):
37
+ f._flyrail_pure = True
38
+ inner = getattr(f, "_component_fn", None)
39
+ if inner is not None:
40
+ inner._flyrail_pure = True
41
+ return f
42
+ return wrap(fn) if fn else wrap
43
+
44
+
45
+ def _opts(event: str, prevent_default: bool, stop_propagation: bool,
46
+ throttle_ms: int | None) -> dict:
47
+ """Per-event wire options. throttleMs is omitted when unset (minimal wire)."""
48
+ opts: dict[str, Any] = {"preventDefault": prevent_default,
49
+ "stopPropagation": stop_propagation}
50
+ if throttle_ms is not None:
51
+ opts["throttleMs"] = throttle_ms
52
+ return {event: opts}
53
+
54
+
55
+ def _el(type_: str, *children: Any, key: Any = None, on_click: Callable | None = None,
56
+ event_options: dict | None = None, **props: Any) -> dict:
57
+ node: dict[str, Any] = {"type": type_, "props": props}
58
+ if key is not None:
59
+ node["key"] = key
60
+ if children:
61
+ # flatten one level of lists (for [... for ...] splats)
62
+ flat: list[Any] = []
63
+ for c in children:
64
+ if isinstance(c, list):
65
+ flat.extend(c)
66
+ else:
67
+ flat.append(c)
68
+ node["children"] = flat
69
+ if on_click is not None:
70
+ node["on_click"] = on_click
71
+ if event_options:
72
+ node["event_options"] = event_options
73
+ return node
74
+
75
+
76
+ def Stack(*children: Any, key: Any = None, **props: Any) -> dict:
77
+ return _el("Stack", *children, key=key, **props)
78
+
79
+
80
+ def Text(value: str, key: Any = None, **props: Any) -> dict:
81
+ return _el("Text", key=key, value=value, **props)
82
+
83
+
84
+ def Button(label: str, on_click: Callable | None = None, key: Any = None, *,
85
+ prevent_default: bool = True, stop_propagation: bool = False,
86
+ throttle_ms: int | None = None, **props: Any) -> dict:
87
+ return _el("Button", key=key, on_click=on_click,
88
+ event_options=_opts("on_click", prevent_default,
89
+ stop_propagation, throttle_ms) if on_click else None,
90
+ label=label, **props)
91
+
92
+
93
+ def TextField(key: Any = None, on_change: Callable | None = None, *,
94
+ prevent_default: bool = True, stop_propagation: bool = False,
95
+ throttle_ms: int | None = None, **props: Any) -> dict:
96
+ node = _el("TextField", key=key, **props)
97
+ if on_change is not None:
98
+ node["on_change"] = on_change
99
+ node["event_options"] = _opts("on_change", prevent_default,
100
+ stop_propagation, throttle_ms)
101
+ return node
102
+
103
+
104
+ def Slot(name: str, default: Any = None) -> dict:
105
+ """Hot-path placeholder. Renderer subscribes by name; tick sends slot values
106
+ directly instead of diffing the whole tree."""
107
+ return {"type": "__Slot__", "props": {"name": name, "default": default}}
flyrail/driver.py ADDED
@@ -0,0 +1,55 @@
1
+ """Host scheduling seam: one primitive for tick, async, and naive hosts."""
2
+ from __future__ import annotations
3
+ import asyncio
4
+ from typing import Any, Callable
5
+
6
+ from .layout import Layout
7
+
8
+
9
+ class Driver:
10
+ """Wraps Layout with dirty-flag scheduling and caller-owned seq.
11
+
12
+ Tick hosts: invalidate() per tick (or on sim change); flush(state, version).
13
+ Async hosts: invalidate() from event handlers; await run(state_fn, send).
14
+ Naive hosts: invalidate() + flush() around every message.
15
+ Thread-safe invalidate: safe to call from any thread; the run loop
16
+ (single asyncio loop assumption) wakes via call_soon_threadsafe.
17
+ """
18
+
19
+ def __init__(self, layout: Layout):
20
+ self.layout = layout
21
+ self.seq = 0
22
+ self._event = asyncio.Event()
23
+
24
+ def invalidate(self) -> None:
25
+ self.layout.invalidate()
26
+ try:
27
+ loop = asyncio.get_running_loop()
28
+ except RuntimeError:
29
+ loop = None
30
+ if loop is None:
31
+ self._event.set()
32
+ else:
33
+ loop.call_soon_threadsafe(self._event.set)
34
+
35
+ def flush(self, state: Any, version: Any = None) -> dict | None:
36
+ """Render+diff if dirty/version-changed; seq-numbered envelope or None."""
37
+ ops = self.layout.tick(state, version)
38
+ if not ops:
39
+ return None
40
+ self.seq += 1
41
+ return {"chan": "ui", "type": "patch", "seq": self.seq, "ops": ops}
42
+
43
+ async def run(
44
+ self,
45
+ state_fn: Callable[[], Any],
46
+ send: Callable[[dict], Any],
47
+ version_fn: Callable[[], Any] = lambda: None,
48
+ ) -> None:
49
+ """Never returns; cancel to stop. Bursts coalesce into one flush."""
50
+ while True:
51
+ await self._event.wait()
52
+ self._event.clear()
53
+ env = self.flush(state_fn(), version_fn())
54
+ if env is not None:
55
+ await send(env)
flyrail/hooks.py ADDED
@@ -0,0 +1,175 @@
1
+ """Minimal React-style hooks for component-local UI state. Sync only.
2
+
3
+ Rules (React's, enforced by convention + loud errors):
4
+ - call hooks unconditionally at the top of a @component body, same order
5
+ every render;
6
+ - never call hooks outside a @component body;
7
+ - give repeated components distinct key= values.
8
+
9
+ Renders are serial per Layout. Two Layouts on two threads are fine: the frame
10
+ stack is thread-local, so neither can see the other's hook slots.
11
+ """
12
+ from __future__ import annotations
13
+ import threading
14
+ from typing import Any, Callable
15
+
16
+
17
+ class _Stack(threading.local):
18
+ """Frame stack, innermost last, private to the thread that renders.
19
+
20
+ One Layout renders serially, but a host with more than one Layout may well
21
+ drive them from different threads -- a tick loop per session is an ordinary
22
+ shape. A module-global stack lets those renders interleave and hands a
23
+ component another component's slots, which surfaces later as impossible
24
+ state rather than as an error.
25
+ """
26
+
27
+ def __init__(self):
28
+ self.frames: list = []
29
+
30
+
31
+ _stack = _Stack()
32
+
33
+
34
+ class _Frame:
35
+ __slots__ = ("slots", "index", "schedule")
36
+
37
+ def __init__(self, slots: list, schedule: Callable[[], None]):
38
+ self.slots = slots
39
+ self.index = 0
40
+ self.schedule = schedule
41
+
42
+
43
+ def enter(slots: list, schedule: Callable[[], None]) -> _Frame:
44
+ frame = _Frame(slots, schedule)
45
+ _stack.frames.append(frame)
46
+ return frame
47
+
48
+
49
+ def exit() -> None:
50
+ if not _stack.frames:
51
+ raise RuntimeError("hook frames unbalanced (concurrent renders?)")
52
+ _stack.frames.pop()
53
+
54
+
55
+ def _frame() -> _Frame:
56
+ if not _stack.frames:
57
+ raise RuntimeError("hooks may only be called inside a @component body")
58
+ return _stack.frames[-1]
59
+
60
+
61
+ def use_state(initial: Any = None):
62
+ """Component-local state. Returns (value, setter).
63
+
64
+ Setter accepts a value or an updater fn. Re-renders are scheduled via
65
+ Layout.invalidate; identical values skip the schedule. Initializer
66
+ callables run once (to store a function itself, wrap it in lambda).
67
+ """
68
+ frame = _frame()
69
+ i = frame.index
70
+ frame.index += 1
71
+ if i >= len(frame.slots):
72
+ frame.slots.append(["state", initial() if callable(initial) else initial])
73
+ cell = frame.slots[i]
74
+ if cell[0] != "state":
75
+ raise RuntimeError(
76
+ "hook order changed between renders: call hooks unconditionally "
77
+ "in the same order every render")
78
+ def set_state(new: Any) -> None:
79
+ old = cell[1]
80
+ nxt = new(old) if callable(new) else new
81
+ try:
82
+ changed = bool(nxt != old)
83
+ except Exception:
84
+ changed = True
85
+ if changed:
86
+ cell[1] = nxt
87
+ frame.schedule()
88
+ return cell[1], set_state
89
+
90
+
91
+ def use_memo(fn: Callable[[], Any], deps: list | tuple):
92
+ """Cached derived value; recomputed only when deps change."""
93
+ frame = _frame()
94
+ i = frame.index
95
+ frame.index += 1
96
+ if i >= len(frame.slots):
97
+ value = fn()
98
+ frame.slots.append(["memo", list(deps), value])
99
+ return value
100
+ cell = frame.slots[i]
101
+ if cell[0] != "memo":
102
+ raise RuntimeError(
103
+ "hook order changed between renders: call hooks unconditionally "
104
+ "in the same order every render")
105
+ if _deps_changed(cell[1], deps):
106
+ cell[1] = list(deps)
107
+ cell[2] = fn()
108
+ return cell[2]
109
+
110
+
111
+ def use_effect(fn: Callable[[], Any], deps: list | tuple) -> None:
112
+ """Run fn after the render commits; re-run it when deps change.
113
+
114
+ fn may return a cleanup, which runs before the next run of that effect and
115
+ once more when the component leaves the tree -- which is what makes an
116
+ effect the right place for anything that has to be undone: a highlight, a
117
+ subscription, a lock.
118
+
119
+ Synchronous, like the rest of these hooks. A tick host has nowhere to await
120
+ and an async effect would need a loop and a task per effect; running on the
121
+ commit keeps the ordering obvious (cleanup, then run) and keeps flyrail
122
+ free of asyncio in the render path.
123
+
124
+ Fail-loud: a cleanup that raises aborts the render (and the tick driving
125
+ it) instead of being swallowed. Keep cleanups total.
126
+ """
127
+ frame = _frame()
128
+ i = frame.index
129
+ frame.index += 1
130
+ if i >= len(frame.slots):
131
+ frame.slots.append(["effect", list(deps), None, fn])
132
+ return
133
+ cell = frame.slots[i]
134
+ if cell[0] != "effect":
135
+ raise RuntimeError(
136
+ "hook order changed between renders: call hooks unconditionally "
137
+ "in the same order every render")
138
+ if _deps_changed(cell[1], deps):
139
+ cell[1] = list(deps)
140
+ cell[3] = fn
141
+
142
+
143
+ def run_effects(slots: list) -> None:
144
+ """Run whatever this component's render scheduled, cleaning up first."""
145
+ for cell in slots:
146
+ if cell[0] != "effect" or cell[3] is None:
147
+ continue
148
+ pending, cell[3] = cell[3], None
149
+ cleanup, cell[2] = cell[2], None
150
+ if cleanup is not None:
151
+ cleanup()
152
+ cell[2] = pending()
153
+
154
+
155
+ def run_cleanups(slots: list) -> None:
156
+ """Unwind this component's effects, for a component leaving the tree."""
157
+ for cell in slots:
158
+ if cell[0] == "effect" and cell[2] is not None:
159
+ cleanup, cell[2] = cell[2], None
160
+ cleanup()
161
+
162
+
163
+ def _deps_changed(old: list, new: list | tuple) -> bool:
164
+ new = list(new)
165
+ if len(old) != len(new):
166
+ return True
167
+ for a, b in zip(old, new):
168
+ if a is b:
169
+ continue
170
+ try:
171
+ if a != b:
172
+ return True
173
+ except Exception:
174
+ return True # exotic values (ndarray): recompute rather than lie
175
+ return False
flyrail/layout.py ADDED
@@ -0,0 +1,578 @@
1
+ """Transport-agnostic layout: handler registry, wire serialization, diffing."""
2
+ from __future__ import annotations
3
+ import copy
4
+ import functools
5
+ import inspect
6
+ from typing import Any, Callable
7
+
8
+ from . import hooks as _hooks
9
+
10
+
11
+ def _unchanged(old: Any, new: Any) -> bool:
12
+ """Whether new says the same as old, erring towards "changed".
13
+
14
+ Exotic values (ndarray and friends) return something that is not a bool
15
+ from ==; those are reported as changed rather than guessed at, the same way
16
+ _deps_changed treats them.
17
+ """
18
+ try:
19
+ return bool(old == new)
20
+ except Exception:
21
+ return False
22
+
23
+
24
+ def _is_async(fn: Any) -> bool:
25
+ """Whether calling fn starts a coroutine, without calling it.
26
+
27
+ functools.partial and callable objects wrap the real function, so unwrap
28
+ before asking; otherwise a partial around an async def reads as sync.
29
+ """
30
+ target = fn
31
+ while isinstance(target, functools.partial):
32
+ target = target.func
33
+ if inspect.iscoroutinefunction(target):
34
+ return True
35
+ call = getattr(target, "__call__", None)
36
+ return call is not None and inspect.iscoroutinefunction(call)
37
+
38
+
39
+ def _segment(child: Any, index: int) -> Any:
40
+ """What names a child within its parent: its key, or its position.
41
+
42
+ Position alone made a handler id move when its siblings did, so a click on
43
+ a row that had shifted up reached whatever now sat where it used to. A key
44
+ is exactly the promise that this element is the same element, so use it.
45
+
46
+ Keys must be unique among siblings: duplicates serialize the same id and
47
+ the registry keeps the last registration. Components reject duplicates;
48
+ plain nodes do not, so this is a documented must-not rather than a check.
49
+ """
50
+ if isinstance(child, dict):
51
+ key = child.get("key")
52
+ if key is not None:
53
+ return key
54
+ return index
55
+
56
+
57
+ def _escape(path: str) -> str:
58
+ return path.replace("~", "~0").replace("/", "~1")
59
+
60
+
61
+ #: Wire defaults for event descriptors. preventDefault is True because a
62
+ #: socket-driven control must never trigger browser navigation (reload =
63
+ #: dead session); opt out explicitly with prevent_default=False. Shape
64
+ #: mirrors reactpy's eventHandlers entries for cross-compat.
65
+ EVENT_DEFAULTS = {"preventDefault": True, "stopPropagation": False}
66
+
67
+
68
+ def _keys_of(items: list) -> list | None:
69
+ """The list's element keys, or None if it is not keyed throughout.
70
+
71
+ Reconciling by key needs every element to be a keyed dict with keys unique
72
+ within the list; anything else and position is all there is to go on.
73
+ Unhashable keys (a list as a key) cannot be reconciled either -- they
74
+ fall back to replacing, rather than raising mid-diff.
75
+ """
76
+ keys = []
77
+ for item in items:
78
+ if not isinstance(item, dict):
79
+ return None
80
+ key = item.get("key")
81
+ if key is None:
82
+ return None
83
+ keys.append(key)
84
+ try:
85
+ unique = len(set(keys)) == len(keys)
86
+ except TypeError:
87
+ return None
88
+ return keys if unique else None
89
+
90
+
91
+ def _diff_keyed_list(old: list, new: list, path: str, keyed_lists: bool) -> list[dict] | None:
92
+ """Per-element ops for two keyed lists, or None to fall back to a replace.
93
+
94
+ Emitted in apply order against a running copy, because RFC6902 array
95
+ pointers are indices: a remove shifts everything after it, so the ops only
96
+ mean anything applied in sequence from the same baseline -- which is what
97
+ the committed tree guarantees.
98
+ """
99
+ old_keys, new_keys = _keys_of(old), _keys_of(new)
100
+ if old_keys is None or new_keys is None:
101
+ return None
102
+
103
+ ops: list[dict] = []
104
+ working = list(old)
105
+ keys = list(old_keys)
106
+
107
+ wanted = set(new_keys)
108
+ for index in range(len(working) - 1, -1, -1):
109
+ if keys[index] not in wanted:
110
+ ops.append({"op": "remove", "path": f"{path}/{index}"})
111
+ del working[index]
112
+ del keys[index]
113
+
114
+ present = set(keys)
115
+ for index, key in enumerate(new_keys):
116
+ if key not in present:
117
+ ops.append({"op": "add", "path": f"{path}/{index}", "value": new[index]})
118
+ working.insert(index, new[index])
119
+ keys.insert(index, key)
120
+ present.add(key)
121
+
122
+ if keys != new_keys:
123
+ # Same elements, different order. RFC6902 has `move`, but the ops this
124
+ # emits are add/replace/remove, so a reorder is not expressible here;
125
+ # let the caller replace the list outright rather than emit something
126
+ # a client cannot apply.
127
+ return None
128
+
129
+ for index, (before, after) in enumerate(zip(working, new)):
130
+ ops.extend(_diff(before, after, f"{path}/{index}", keyed_lists))
131
+ return ops
132
+
133
+
134
+ def _diff(old: Any, new: Any, path: str = "", keyed_lists: bool = True) -> list[dict]:
135
+ """Minimal RFC6902 diff. Dicts recurse; keyed lists reconcile by key.
136
+
137
+ Lists used to replace wholesale, on the reasoning that a client reconciles
138
+ arrays by `key` anyway. That is true of the DOM and irrelevant to the wire:
139
+ the whole list has already crossed the socket by the time the client
140
+ reconciles it, so one changed label re-sent every sibling it had.
141
+ """
142
+ ops: list[dict] = []
143
+ if _unchanged(old, new):
144
+ return ops
145
+ if isinstance(old, dict) and isinstance(new, dict):
146
+ for k in old:
147
+ if k not in new:
148
+ ops.append({"op": "remove", "path": f"{path}/{_escape(k)}" or "/"})
149
+ for k, v in new.items():
150
+ p = f"{path}/{_escape(k)}"
151
+ if k not in old:
152
+ ops.append({"op": "add", "path": p, "value": v})
153
+ else:
154
+ ops.extend(_diff(old[k], v, p, keyed_lists))
155
+ return ops
156
+ if keyed_lists and isinstance(old, list) and isinstance(new, list):
157
+ keyed = _diff_keyed_list(old, new, path, keyed_lists)
158
+ if keyed is not None:
159
+ return keyed
160
+ return [{"op": "replace", "path": path or "/", "value": new}]
161
+
162
+
163
+ class Layout:
164
+ """One per session. Bring your own socket/tick.
165
+
166
+ ``render(state)`` serializes callables to ``{"handlerId": ...}``;
167
+ ``diff_and_commit(tree)`` returns RFC6902-ish ops, or ``[]`` when nothing
168
+ changed so the tick loop sends nothing; ``tick(state)`` does both.
169
+ ``dispatch(handlerId, state, event)`` routes client actions back to the
170
+ registered Python callable; ``set_slot(name, value)`` pushes hot per-tick
171
+ values past the diff entirely.
172
+ """
173
+
174
+ def __init__(self, render_fn: Callable[[Any], dict], allowed_types: set[str] | None = None,
175
+ strict: bool = False, keyed_lists: bool = True,
176
+ memo: bool = True):
177
+ #: Reconcile keyed lists element by element instead of replacing them
178
+ #: whole. The ops stay within add/replace/remove, and the bundled
179
+ #: client applies array pointers already, so this is on by default.
180
+ #: Turn it off for a client that only understands object pointers.
181
+ self.keyed_lists = keyed_lists
182
+ #: Skip re-running a @pure component whose arguments are unchanged and
183
+ #: nothing under which went stale. Only @pure components are eligible,
184
+ #: so an unmarked component still re-renders every time. Off turns the
185
+ #: whole thing back into an unconditional render, which is what you
186
+ #: want when a stale panel has you suspecting a lying @pure.
187
+ self.memo = memo
188
+ self.render_fn = render_fn
189
+ self.allowed_types = allowed_types
190
+ self.strict = strict
191
+ self.registry: dict[str, Callable] = {}
192
+ self._last_tree: Any = None
193
+ #: The committed tree as rendered, not the defensive copy, so an
194
+ #: unchanged render can be recognised by identity before anything is
195
+ #: compared element by element.
196
+ self._last_source: Any = None
197
+ self._slots: dict[str, Any] = {}
198
+ self._hooks: dict = {}
199
+ self._hooks_seen: dict = {}
200
+ self._hooks_visited: set = set()
201
+ self._hooks_arity: dict = {}
202
+ #: Per-slot memo: slot_id -> (args, kwargs, expanded subtree).
203
+ self._memo: dict = {}
204
+ #: Slots expanded beneath each slot last render, so a component can ask
205
+ #: whether anything under it went stale, not just itself.
206
+ self._subtree: dict = {}
207
+ #: Per-slot serialized form: slot_id -> (expanded, path, tree, handlers).
208
+ #: Keyed on the expanded object and the path it was serialized at, both
209
+ #: of which have to still hold for the cached tree to be the right
210
+ #: answer -- handler ids are built from the path.
211
+ self._serial: dict = {}
212
+ #: id(expanded subtree) -> slot_id, for the slots currently memoised.
213
+ #: Rebuilt each render; the objects are held alive by _memo, so their
214
+ #: ids cannot be recycled underneath it.
215
+ self._memo_root_by_id: dict = {}
216
+ self._expanding: list = []
217
+ self._dirty_slots: set = set()
218
+ #: Slots in the order they finished expanding, which is children before
219
+ #: parents, so an effect sees its children already committed.
220
+ self._effect_order: list = []
221
+ self._version: Any = None
222
+ self._dirty = True
223
+
224
+ def invalidate(self) -> None:
225
+ """Mark dirty: the next tick() re-renders regardless of version.
226
+ Scheduling seam for hook dispatch and the Driver.
227
+
228
+ Deliberately does not drop memoised subtrees. Driver documents
229
+ invalidate() per tick for tick hosts, so clearing here would mean the
230
+ memo never survives a tick and buys nothing for the main use case.
231
+ Host state reaches a component through its arguments, which the memo
232
+ compares, so anything the host actually changed re-renders on that
233
+ basis. A @pure component that reads host state it was not passed is
234
+ lying, and this is the stale UI the decorator warns about; reach for
235
+ reset() or memo=False when chasing one.
236
+ """
237
+ self._dirty = True
238
+
239
+ def reset(self) -> None:
240
+ """Drop every memoised subtree, forcing the next render to run all of
241
+ them. For a host that mutates state components read without being
242
+ handed it, and for bisecting a suspected @pure that is not."""
243
+ self._memo.clear()
244
+ self._serial.clear()
245
+
246
+ def _invalidate_slot(self, slot_id: Any) -> None:
247
+ """A hook in this component set new state: only it needs re-running."""
248
+ self._dirty = True
249
+ self._dirty_slots.add(slot_id)
250
+
251
+ def render(self, state: Any) -> dict:
252
+ self.registry.clear()
253
+ tree = self._render_once(state)
254
+ if self.strict and getattr(self.render_fn, "_flyrail_pure", False):
255
+ again = self._render_once(state)
256
+ if again != tree:
257
+ raise AssertionError(
258
+ "render_fn marked @pure produced different trees across "
259
+ "two immediate renders; remove @pure or eliminate the "
260
+ "nondeterminism (time, random, counters, unversioned reads)")
261
+ self._run_effects()
262
+ self._dirty = False
263
+ return tree
264
+
265
+ def _render_once(self, state: Any) -> dict:
266
+ self._hooks_seen = {}
267
+ self._hooks_visited = set()
268
+ self._effect_order = []
269
+ self._memo_root_by_id = {id(entry[2]): slot_id
270
+ for slot_id, entry in self._memo.items()}
271
+ expanded = self._expand(self.render_fn(state))
272
+ for k in list(self._hooks):
273
+ if k not in self._hooks_visited:
274
+ # Leaving the tree: unwind its effects before its state goes.
275
+ _hooks.run_cleanups(self._hooks[k])
276
+ del self._hooks[k]
277
+ self._hooks_arity.pop(k, None)
278
+ # An unmounted component must not leave a memo behind: the same
279
+ # slot_id can be handed to a later instance, which would then
280
+ # start from the old instance's subtree.
281
+ stale = self._memo.pop(k, None)
282
+ if stale is not None:
283
+ # Dropping the last reference to that subtree frees it, and
284
+ # a later allocation can be handed the same id. Forget the
285
+ # mapping rather than let an unrelated node match it.
286
+ self._memo_root_by_id.pop(id(stale[2]), None)
287
+ self._serial.pop(k, None)
288
+ self._subtree.pop(k, None)
289
+ self._dirty_slots.discard(k)
290
+ collected: dict[str, Callable] = {}
291
+ tree = self._serialize(expanded, path="0", collected=collected)
292
+ self.registry.update(collected)
293
+ if self.allowed_types:
294
+ self._check_allowlist(tree)
295
+ return tree
296
+
297
+ def _run_effects(self) -> None:
298
+ """Commit point: effects run once the tree they describe is built.
299
+
300
+ A reused subtree scheduled nothing, so nothing of it runs -- which is
301
+ the behaviour you want and falls out for free.
302
+ """
303
+ for slot_id in self._effect_order:
304
+ slots = self._hooks.get(slot_id)
305
+ if slots is not None:
306
+ _hooks.run_effects(slots)
307
+
308
+ def _reusable(self, slot_id: Any, fn: Any, node: dict) -> Any:
309
+ """The cached subtree for this component, if reusing it is safe.
310
+
311
+ Safe means: it was marked @pure, its arguments still compare equal, its
312
+ own hooks have not been written to, and nothing expanded beneath it has
313
+ either. Strict mode never reuses -- its whole job is to render twice and
314
+ compare, which a cache would quietly turn into one render.
315
+ """
316
+ if not self.memo or self.strict or not getattr(fn, "_flyrail_pure", False):
317
+ return None
318
+ remembered = self._memo.get(slot_id)
319
+ if remembered is None:
320
+ return None
321
+ if slot_id in self._dirty_slots:
322
+ return None
323
+ if self._dirty_slots & self._subtree.get(slot_id, frozenset()):
324
+ return None
325
+ args, kwargs, result = remembered
326
+ if not _unchanged(args, node.get("args", ())):
327
+ return None
328
+ if not _unchanged(kwargs, node.get("kwargs", {})):
329
+ return None
330
+ return result
331
+
332
+ def _mark_reused(self, slot_id: Any) -> None:
333
+ """Report a skipped subtree as still mounted.
334
+
335
+ The prune at the end of a render deletes hook state for any slot it did
336
+ not see. A reused subtree is never walked, so without this its hooks --
337
+ and every hook under it -- would be collected and the component would
338
+ silently restart from its initial state on the next real render.
339
+ """
340
+ self._hooks_visited.add(slot_id)
341
+ beneath = self._subtree.get(slot_id, frozenset())
342
+ self._hooks_visited.update(beneath)
343
+ for ancestor in self._expanding:
344
+ self._subtree.setdefault(ancestor, set()).update(beneath)
345
+
346
+ def _expand(self, node: Any, path: str = "0") -> Any:
347
+ if isinstance(node, dict) and node.get("type") == "__Component__":
348
+ fn = node["fn"]
349
+ key = node.get("key")
350
+ slot_id = (id(fn), key if key is not None else path)
351
+ count = self._hooks_seen.get(slot_id, 0) + 1
352
+ self._hooks_seen[slot_id] = count
353
+ if count > 1:
354
+ raise ValueError(
355
+ f"duplicate component key {key!r} for "
356
+ f"{getattr(fn, '__name__', fn)}; keys must be unique "
357
+ "per component within one render")
358
+ for ancestor in self._expanding:
359
+ self._subtree.setdefault(ancestor, set()).add(slot_id)
360
+
361
+ cached = self._reusable(slot_id, fn, node)
362
+ if cached is not None:
363
+ self._mark_reused(slot_id)
364
+ return cached
365
+
366
+ slots = self._hooks.setdefault(slot_id, [])
367
+ self._subtree[slot_id] = set()
368
+ self._expanding.append(slot_id)
369
+ frame = _hooks.enter(slots, lambda sid=slot_id: self._invalidate_slot(sid))
370
+ try:
371
+ try:
372
+ expanded = fn(*node.get("args", ()), **node.get("kwargs", {}))
373
+ finally:
374
+ arity_ok = (frame.index == len(slots)
375
+ and frame.index == self._hooks_arity.setdefault(slot_id, frame.index))
376
+ _hooks.exit()
377
+ if not arity_ok:
378
+ raise RuntimeError(
379
+ "hook count changed between renders: call hooks "
380
+ "unconditionally in the same order every render")
381
+ self._hooks_visited.add(slot_id)
382
+ self._dirty_slots.discard(slot_id)
383
+ # Stays on the expanding stack across this call: children are
384
+ # expanded here, and each has to be recorded against every
385
+ # component above it or an ancestor cannot tell that something
386
+ # beneath it went stale.
387
+ result = self._expand(expanded, path)
388
+ finally:
389
+ self._expanding.pop()
390
+ self._effect_order.append(slot_id)
391
+ if self.memo and getattr(fn, "_flyrail_pure", False):
392
+ self._memo[slot_id] = (node.get("args", ()),
393
+ node.get("kwargs", {}), result)
394
+ self._memo_root_by_id[id(result)] = slot_id
395
+ return result
396
+ if isinstance(node, dict):
397
+ out = dict(node)
398
+ children = out.get("children")
399
+ if isinstance(children, list):
400
+ out["children"] = [self._expand(c, f"{path}.{i}")
401
+ for i, c in enumerate(children)]
402
+ return out
403
+ if isinstance(node, list):
404
+ return [self._expand(c, f"{path}.{i}") for i, c in enumerate(node)]
405
+ return node
406
+
407
+ def _serialize(self, node: Any, path: str, collected: dict) -> Any:
408
+ """Serialize a subtree, reusing the last result at a memoised root.
409
+
410
+ Serialization rebuilds every node carrying a handler, so a subtree that
411
+ _expand handed back untouched still came out as fresh objects and the
412
+ tree was never identical twice. At a memoised root the answer is
413
+ already known: same expanded input, same path, same output. The path
414
+ has to match because handler ids are built from it.
415
+
416
+ The registry is still rebuilt from scratch every render -- the ids have
417
+ to stay resolvable -- so a cache hit replays the subtree's
418
+ registrations rather than skipping them.
419
+ """
420
+ slot_id = self._memo_root_by_id.get(id(node)) if isinstance(node, dict) else None
421
+ if slot_id is None:
422
+ return self._serialize_node(node, path, collected)
423
+
424
+ cached = self._serial.get(slot_id)
425
+ if cached is not None and cached[0] is node and cached[1] == path:
426
+ collected.update(cached[3])
427
+ return cached[2]
428
+
429
+ own: dict[str, Callable] = {}
430
+ out = self._serialize_node(node, path, own)
431
+ self._serial[slot_id] = (node, path, out, own)
432
+ collected.update(own)
433
+ return out
434
+
435
+ def _serialize_node(self, node: Any, path: str, collected: dict) -> Any:
436
+ """Swap callables for {"handlerId": ...}, returning a new node only
437
+ where something actually changed.
438
+
439
+ This used to mutate a deep copy of the whole tree. Copying every node
440
+ to rewrite the few that carry handlers cost more than the diff did, and
441
+ a subtree that is reused across renders must not be written through at
442
+ all. Rebuilding only the nodes that change keeps untouched subtrees
443
+ identical, which is what lets a caller skip work on them.
444
+ """
445
+ if not isinstance(node, dict):
446
+ return node
447
+
448
+ key = node.get("key", "")
449
+ replaced: dict[str, Any] = {}
450
+ for evt in ("on_click", "on_change"):
451
+ fn = node.get(evt)
452
+ if callable(fn):
453
+ # The path is built from keys where children have them, so a
454
+ # widget keeps its id when its siblings move around it.
455
+ hid = f"{path}:{evt}:{key}"
456
+ collected[hid] = fn
457
+ replaced[evt] = {"handlerId": hid,
458
+ **{**EVENT_DEFAULTS,
459
+ **node.get("event_options", {}).get(evt, {})}}
460
+
461
+ children = node.get("children")
462
+ serialized_children = None
463
+ if isinstance(children, list):
464
+ walked = [self._serialize(child, f"{path}.{_segment(child, i)}", collected)
465
+ for i, child in enumerate(children)]
466
+ if any(a is not b for a, b in zip(walked, children)):
467
+ serialized_children = walked
468
+
469
+ if not replaced and serialized_children is None:
470
+ return node
471
+
472
+ out = dict(node)
473
+ out.update(replaced)
474
+ if serialized_children is not None:
475
+ out["children"] = serialized_children
476
+ return out
477
+
478
+ def _check_allowlist(self, node: Any) -> None:
479
+ if isinstance(node, dict):
480
+ t = node.get("type")
481
+ if t not in ("__Slot__",) and t not in (self.allowed_types or set()):
482
+ raise ValueError(f"node type {t!r} not in allowlist")
483
+ for c in node.get("children", []) or []:
484
+ self._check_allowlist(c)
485
+
486
+ def diff_and_commit(self, tree: dict) -> list[dict]:
487
+ """Ops for what changed, or [] when nothing did.
488
+
489
+ Three gates, cheapest first. A memoised render hands back the very
490
+ same tree object, so identity settles it outright. Otherwise the
491
+ previous tree has to be kept anyway to diff against, so comparing it is
492
+ cheaper than serialising and hashing it -- and cannot collide, which a
493
+ digest over json.dumps(default=str) can: two values that stringify
494
+ alike hashed alike and the update was silently dropped.
495
+ """
496
+ if tree is self._last_source:
497
+ return []
498
+ if self._last_tree is not None and _unchanged(self._last_tree, tree):
499
+ self._last_source = tree
500
+ return []
501
+ old = self._last_tree if self._last_tree is not None else {}
502
+ ops = _diff(old, tree, path="", keyed_lists=self.keyed_lists)
503
+ self._last_tree = copy.deepcopy(tree)
504
+ self._last_source = tree
505
+ return ops
506
+
507
+ def tick(self, state: Any, version: Any = None) -> list[dict]:
508
+ """Render + diff. Pure renders skip render CPU when the host version
509
+ matches the last rendered version and nothing invalidated since.
510
+ Unmarked renders always re-render (correct by default)."""
511
+ if (version is not None
512
+ and version == self._version
513
+ and not self._dirty
514
+ and getattr(self.render_fn, "_flyrail_pure", False)):
515
+ return []
516
+ tree = self.render(state)
517
+ self._version = version
518
+ return self.diff_and_commit(tree)
519
+
520
+ def snapshot(self, state: Any, seq: int) -> dict:
521
+ """Full-tree recovery message answering a client resync-request.
522
+
523
+ Re-renders, re-registers handlers, and resets the diff baseline so
524
+ subsequent ticks stay incremental from the snapshot point.
525
+ """
526
+ tree = self.render(state)
527
+ self._last_tree = copy.deepcopy(tree)
528
+ self._last_source = tree
529
+ return {"chan": "ui", "type": "snapshot", "seq": seq, "tree": tree}
530
+
531
+ def dispatch(self, handler_id: str, state: Any, event: Any = None) -> None:
532
+ """Run a sync handler; refuse an async one.
533
+
534
+ Two different author mistakes used to give the same message. A handler
535
+ that *is* async is caught on the function, before a throwaway coroutine
536
+ is built; a sync handler that *returns* an awaitable can only be caught
537
+ after calling it, and its synchronous part has necessarily already run.
538
+ Saying which happened is the difference between "use adispatch" and
539
+ "you have a half-applied handler".
540
+ """
541
+ fn = self.registry.get(handler_id)
542
+ if fn is None:
543
+ raise KeyError(f"unknown handler {handler_id!r}")
544
+ if _is_async(fn):
545
+ raise RuntimeError(
546
+ f"handler {handler_id!r} is async; use await adispatch() "
547
+ "instead of dispatch()")
548
+ res = fn(state, event)
549
+ if inspect.isawaitable(res):
550
+ # A sync function that returns an awaitable: nothing of the
551
+ # awaitable has run, so closing it really does cancel it.
552
+ if inspect.iscoroutine(res):
553
+ res.close()
554
+ raise RuntimeError(
555
+ f"handler {handler_id!r} returned an awaitable; use await "
556
+ "adispatch() instead of dispatch()")
557
+
558
+ async def adispatch(self, handler_id: str, state: Any, event: Any = None) -> Any:
559
+ """Dispatch both sync and async handlers; awaits awaitables.
560
+ Async-host pattern: await layout.adispatch(...) then invalidate()."""
561
+ fn = self.registry.get(handler_id)
562
+ if fn is None:
563
+ raise KeyError(f"unknown handler {handler_id!r}")
564
+ res = fn(state, event)
565
+ if inspect.isawaitable(res):
566
+ res = await res
567
+ return res
568
+
569
+ def set_slot(self, name: str, value: Any) -> dict | None:
570
+ """Hot path bypassing the diff: unchanged values return None.
571
+
572
+ Compared, not hashed, for the same reason as the tree: the last value
573
+ is kept regardless, so the digest bought nothing and could collide.
574
+ """
575
+ if name in self._slots and _unchanged(self._slots[name], value):
576
+ return None
577
+ self._slots[name] = value
578
+ return {"chan": "ui", "type": "slot", "name": name, "value": value}
flyrail/transport.py ADDED
@@ -0,0 +1,26 @@
1
+ """Optional FastAPI helper. Skip it if you have your own socket/tick."""
2
+ from __future__ import annotations
3
+ from typing import Any
4
+
5
+
6
+ def ui_envelope_patch(ops: list, seq: int) -> dict:
7
+ return {"chan": "ui", "type": "patch", "seq": seq, "ops": ops}
8
+
9
+
10
+ FASTAPI_EXAMPLE = '''
11
+ # tick integration sketch (your loop owns timing):
12
+ from flyrail import Layout
13
+ layout = Layout(MyPanel().render, allowed_types={"Stack","Text","Button","TextField"})
14
+ seq = 0
15
+ def on_tick(state):
16
+ global seq
17
+ ops = layout.tick(state) # [] when unchanged
18
+ if ops:
19
+ seq += 1
20
+ broadcast(ui_envelope_patch(ops, seq))
21
+
22
+ # your existing ws handler:
23
+ async def on_ws_msg(msg, state):
24
+ if msg.get("chan") == "ui" and msg.get("type") == "action":
25
+ layout.dispatch(msg["handlerId"], state, msg.get("event"))
26
+ '''
@@ -0,0 +1,192 @@
1
+ Metadata-Version: 2.4
2
+ Name: flyrail
3
+ Version: 0.1.0
4
+ Summary: Bring-your-own-frontend server-driven UI core (reactpy-style API, transport agnostic)
5
+ Author-email: Aaron Lipinski <kris.lipinski@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/krisl/flyrail
8
+ Project-URL: Repository, https://github.com/krisl/flyrail
9
+ Keywords: ui,server-driven-ui,websocket,asgi
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Framework :: AsyncIO
12
+ Classifier: Topic :: Internet :: WWW/HTTP
13
+ Classifier: Typing :: Typed
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Dynamic: license-file
18
+
19
+ # flyrail
20
+
21
+ Bring-your-own-frontend server-driven UI with a reactpy-style Python API.
22
+ Python declares the UI, your React+MUI app renders it, your socket carries
23
+ it, your tick drives it.
24
+
25
+ ## Why this exists
26
+
27
+ `reactpy` owns the whole React tree and its own socket protocol. `rjsf`
28
+ owns form rendering but not arbitrary layouts. flyrail splits the problem:
29
+
30
+ - **Python** (`flyrail/`): declarative element helpers, per-session handler
31
+ registry, per-component memoisation, gated keyed-list diffing, slot
32
+ fast-path. Zero dependencies.
33
+ - **JS** (`js/`, `flyrail-renderer`): namespace component registry
34
+ (`import * as MUI`), patch applier, client store with resync, debounced
35
+ inputs. Zero dependencies (React is a peer).
36
+
37
+ See [docs/features.md](docs/features.md) for what each capability buys you,
38
+ with a runnable example each.
39
+
40
+ ## Message flow
41
+
42
+ ```
43
+ tick: render(state) -> tree -> diff -> [] | patch ops -> {chan:ui,type:patch,seq,ops}
44
+ click: {chan:ui,type:action,handlerId,event?} -> dispatch -> mutate state -> next tick emits
45
+ hot: set_slot(name, value) -> {chan:ui,type:slot} (bypasses diff)
46
+ gap: seq skip -> resync-request -> snapshot(state, seq) -> {chan:ui,type:snapshot,tree}
47
+ ```
48
+
49
+ ## Python quickstart
50
+
51
+ ```python
52
+ from flyrail import Layout, Stack, Text, Button
53
+ from flyrail.transport import ui_envelope_patch
54
+
55
+ class Panel:
56
+ def render(self, s):
57
+ return Stack(
58
+ Text(f"speed: {s.speed}"),
59
+ Button("Stop", on_click=self.stop), # like reactpy
60
+ )
61
+ def stop(self, state, event):
62
+ state.speed = 0
63
+
64
+ layout = Layout(Panel().render, allowed_types={"Stack", "Text", "Button"})
65
+ seq = 0
66
+ def on_tick(state):
67
+ global seq
68
+ if ops := layout.tick(state): # [] on idle ticks: send nothing
69
+ seq += 1
70
+ broadcast(ui_envelope_patch(ops, seq))
71
+
72
+ def on_ws(msg, state):
73
+ if msg.get("chan") == "ui" and msg.get("type") == "action":
74
+ layout.dispatch(msg["handlerId"], state, msg.get("event"))
75
+ elif msg.get("type") == "resync-request":
76
+ broadcast(layout.snapshot(state, seq + 1))
77
+ ```
78
+
79
+ ## JS quickstart
80
+
81
+ ```tsx
82
+ import * as MUI from '@mui/material';
83
+ import { createRenderer } from 'flyrail-renderer';
84
+ import { createStore } from 'flyrail-renderer/store';
85
+
86
+ const { ServerNode } = createRenderer({ ...MUI }); // auto-registered, no switch
87
+ const store = createStore({ onResync: (req) => ws.send(JSON.stringify(req)) });
88
+ ws.onmessage = (e) => store.ingest(JSON.parse(e.data));
89
+ // render store.getTree() via <ServerNode node={tree} send={...} />
90
+ ```
91
+
92
+ ## Hosting (pick one)
93
+
94
+ ```python
95
+ # 1. Fixed tick you own: Driver replaces the hand-rolled seq counter.
96
+ from flyrail import Driver
97
+ driver = Driver(layout)
98
+ def on_tick(state, version):
99
+ if env := driver.flush(state, version):
100
+ broadcast(env)
101
+
102
+ # 2. Async host: raw ASGI app, one session per connection. FastAPI:
103
+ # from fastapi import WebSocket; await create_ws_app(Panel().render)(scope, receive, send)
104
+ # (or mount in any ASGI framework; no fastapi dependency in this package)
105
+ from flyrail import create_ws_app
106
+ app = create_ws_app(Panel().render, state_factory=Sim,
107
+ on_message=lambda data, state: handle_telemetry(data))
108
+
109
+ # 3. Naive host (no loop at all): invalidate + flush around every message.
110
+ driver.invalidate()
111
+ if env := driver.flush(state):
112
+ send(env)
113
+ ```
114
+
115
+ ## Best practices
116
+
117
+ 1. **Stable keys, never indexes.** `key=item.id` on every list child, both
118
+ sides: Python handler ids embed the key, React reconciles by it. Reorder
119
+ without keys = full remount + lost focus. Keys must be unique among
120
+ siblings: duplicates share one handler id (last registration wins) and
121
+ components reject them, plain nodes do not.
122
+ 2. **Slots for tick-rate values.** Table rows, labels, progress: `Slot("rows")`
123
+ + `set_slot()` bypass the tree diff. Structure goes through patches (rare),
124
+ values through slots (every tick).
125
+ 3. **Allowlist both ends.** Python `allowed_types={...}` rejects unknown node
126
+ types; JS `isKnownComponent` renders only functions (filters MUI's
127
+ `colors`, `createTheme`, etc.). Never render `registry[arbitraryString]`.
128
+ 4. **One render per tick, send only on change.** `tick()` then `if ops:`.
129
+ Hash-gating makes idle ticks free.
130
+ 5. **Inputs debounce client-side** (150ms default). Server never sees
131
+ keystroke storms; use `flush()` on submit.
132
+ 6. **Caller-owned seq, snapshot on gaps.** The tick loop numbers patches; the
133
+ store drops stale/duplicates and answers gaps with one snapshot round-trip.
134
+ 7. **Keep `render(state)` pure and cheap.** No DB, no IO: derive from the
135
+ already-computed tick state. One `Layout` per session.
136
+ 8. **Safe event defaults, names like the DOM.** `preventDefault` is true
137
+ unless opted out (a reload is session death); `stopPropagation` stays
138
+ opt-in; `throttleMs` rate-caps sliders and guards double-submit. Same
139
+ names as the browser, defaults chosen for the socket.
140
+ 9. **Mark renders `@pure`, version host state.** Pure renders skip render
141
+ CPU when the version is unchanged (unmarked always re-render: correct by
142
+ default). `strict=True` double-renders in dev to catch nondeterminism.
143
+ 10. **Component-local UI state via hooks.** `use_state`/`use_memo` inside
144
+ `@component` bodies keeps collapsed flags and drafts out of tick state;
145
+ setters schedule through `invalidate()`. Distinct `key=` per instance,
146
+ hooks unconditional and order-stable, or it raises loudly. Effect
147
+ cleanups that raise abort the render instead of being swallowed.
148
+ 11. **Async handlers via `adispatch`.** Handlers may be `async def` (e.g.
149
+ `await db.save()` on click); sync `dispatch` refuses them loudly instead
150
+ of silently dropping the coroutine. Pattern: `await adispatch(...)`,
151
+ then `invalidate()`.
152
+
153
+ ## Docs
154
+
155
+ - [How it works](docs/how-it-works.md) — architecture, tick/action/resync
156
+ sequences, render pipeline, hook slots, scheduling, message catalog.
157
+
158
+ ## Layout
159
+
160
+ ```
161
+ flyrail/ Python core (stdlib only)
162
+ core.py Stack/Text/Button/TextField/Slot, @component, @pure
163
+ hooks.py use_state/use_memo keyed slots
164
+ layout.py registry + diff + dispatch + slots + snapshot
165
+ driver.py dirty-flag scheduling for tick/async/naive hosts
166
+ asgi.py framework-free websocket sessions
167
+ transport.py multiplex envelope + tick sketch
168
+ js/ flyrail-renderer (zero-dep ESM + tsx)
169
+ protocol.mjs envelopes, applyOps, debounce (node-tested)
170
+ store.mjs seq tracking, gap->resync, slots (node-tested)
171
+ ServerNode.tsx MUI-bound renderer (imports protocol.mjs)
172
+ example/ hmi_demo.py runnable narrative of the wire
173
+ tests/ focused unittest suites + public-API loopback
174
+ ```
175
+
176
+ ## Tests
177
+
178
+ ```
179
+ PYTHONPATH=. python3 -m unittest discover -s tests -v # 67 tests
180
+ node --test js/protocol.test.mjs js/store.test.mjs # 24 tests
181
+ PYTHONPATH=. python3 example/hmi_demo.py # narrated wire demo
182
+ ```
183
+
184
+ ## Non-goals / roadmap
185
+
186
+ - Not a form validator (use your backend validation + error slots), not a
187
+ JSON-Schema renderer (see rjsf), not a full reactpy replacement (sync
188
+ core with no async effects; bring your own frontend instead of an
189
+ owned tree).
190
+ - Roadmap: vitest + React Testing Library for `ServerNode`, `byId/order`
191
+ maps for huge reorderable lists, keystroke `ackSeq` if loss-less input sync
192
+ is ever needed, registry packaging.
@@ -0,0 +1,12 @@
1
+ flyrail/__init__.py,sha256=9iftclITtzY_F9dlbFveQ6aNrG2zO1r483jMy5xBHAo,615
2
+ flyrail/asgi.py,sha256=SmNXYRZoi8sQQ5gduSreDemmjJtv-o5fZSiE6xk8te8,2789
3
+ flyrail/core.py,sha256=b9h6hiJVwf8SnMjOwK7Z9FzGFs2_EhaUBN8OfJtCvUM,4112
4
+ flyrail/driver.py,sha256=jSP76d82Y4V3QC1eKTh3JkqqfcAZGWAbtykpbMAxkMs,1883
5
+ flyrail/hooks.py,sha256=j44bF7VcF3O4-64DkH7Amh2gQQAdWj3Q94LcTEcJyiY,5697
6
+ flyrail/layout.py,sha256=ke0fn4ac7ZMprmzJoAf_EgSEDZJH67woyEy6MekbuF0,25704
7
+ flyrail/transport.py,sha256=4r4b5zlnG02URR6CwnMfdrKJajjo5vMgFw5JWRlI7E0,807
8
+ flyrail-0.1.0.dist-info/licenses/LICENSE,sha256=DXMteL9cssAvP5kfNKN3eo0P0g0tB5qzopMPZBNz6EY,1071
9
+ flyrail-0.1.0.dist-info/METADATA,sha256=rQickIxA3aaMr6R9Ui2LYXZD3E5x2SCBXePDYzpDO8w,7987
10
+ flyrail-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
11
+ flyrail-0.1.0.dist-info/top_level.txt,sha256=64sQe8W-UPujoM0JwigoePakZqIUiwDtoKynpGvq--A,8
12
+ flyrail-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aaron Lipinski
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ flyrail