flyrail 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- flyrail-0.1.0/PKG-INFO +192 -0
- flyrail-0.1.0/README.md +174 -0
- flyrail-0.1.0/flyrail/__init__.py +13 -0
- flyrail-0.1.0/flyrail/asgi.py +77 -0
- flyrail-0.1.0/flyrail/core.py +107 -0
- flyrail-0.1.0/flyrail/driver.py +55 -0
- flyrail-0.1.0/flyrail/hooks.py +175 -0
- flyrail-0.1.0/flyrail/layout.py +578 -0
- flyrail-0.1.0/flyrail/transport.py +26 -0
- flyrail-0.1.0/flyrail.egg-info/PKG-INFO +192 -0
- flyrail-0.1.0/flyrail.egg-info/SOURCES.txt +30 -0
- flyrail-0.1.0/flyrail.egg-info/dependency_links.txt +1 -0
- flyrail-0.1.0/flyrail.egg-info/top_level.txt +1 -0
- flyrail-0.1.0/pyproject.toml +30 -0
- flyrail-0.1.0/setup.cfg +4 -0
- flyrail-0.1.0/tests/test_asgi.py +110 -0
- flyrail-0.1.0/tests/test_async_handlers.py +130 -0
- flyrail-0.1.0/tests/test_core.py +60 -0
- flyrail-0.1.0/tests/test_docs.py +35 -0
- flyrail-0.1.0/tests/test_driver.py +86 -0
- flyrail-0.1.0/tests/test_effects.py +180 -0
- flyrail-0.1.0/tests/test_event_options.py +51 -0
- flyrail-0.1.0/tests/test_hooks.py +376 -0
- flyrail-0.1.0/tests/test_identity.py +253 -0
- flyrail-0.1.0/tests/test_integration.py +93 -0
- flyrail-0.1.0/tests/test_layout_diff.py +258 -0
- flyrail-0.1.0/tests/test_layout_dispatch_slots.py +66 -0
- flyrail-0.1.0/tests/test_layout_serialize.py +160 -0
- flyrail-0.1.0/tests/test_layout_snapshot.py +40 -0
- flyrail-0.1.0/tests/test_pure.py +114 -0
- flyrail-0.1.0/tests/test_transport.py +23 -0
flyrail-0.1.0/LICENSE
ADDED
|
@@ -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.
|
flyrail-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
flyrail-0.1.0/README.md
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# flyrail
|
|
2
|
+
|
|
3
|
+
Bring-your-own-frontend server-driven UI with a reactpy-style Python API.
|
|
4
|
+
Python declares the UI, your React+MUI app renders it, your socket carries
|
|
5
|
+
it, your tick drives it.
|
|
6
|
+
|
|
7
|
+
## Why this exists
|
|
8
|
+
|
|
9
|
+
`reactpy` owns the whole React tree and its own socket protocol. `rjsf`
|
|
10
|
+
owns form rendering but not arbitrary layouts. flyrail splits the problem:
|
|
11
|
+
|
|
12
|
+
- **Python** (`flyrail/`): declarative element helpers, per-session handler
|
|
13
|
+
registry, per-component memoisation, gated keyed-list diffing, slot
|
|
14
|
+
fast-path. Zero dependencies.
|
|
15
|
+
- **JS** (`js/`, `flyrail-renderer`): namespace component registry
|
|
16
|
+
(`import * as MUI`), patch applier, client store with resync, debounced
|
|
17
|
+
inputs. Zero dependencies (React is a peer).
|
|
18
|
+
|
|
19
|
+
See [docs/features.md](docs/features.md) for what each capability buys you,
|
|
20
|
+
with a runnable example each.
|
|
21
|
+
|
|
22
|
+
## Message flow
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
tick: render(state) -> tree -> diff -> [] | patch ops -> {chan:ui,type:patch,seq,ops}
|
|
26
|
+
click: {chan:ui,type:action,handlerId,event?} -> dispatch -> mutate state -> next tick emits
|
|
27
|
+
hot: set_slot(name, value) -> {chan:ui,type:slot} (bypasses diff)
|
|
28
|
+
gap: seq skip -> resync-request -> snapshot(state, seq) -> {chan:ui,type:snapshot,tree}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Python quickstart
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from flyrail import Layout, Stack, Text, Button
|
|
35
|
+
from flyrail.transport import ui_envelope_patch
|
|
36
|
+
|
|
37
|
+
class Panel:
|
|
38
|
+
def render(self, s):
|
|
39
|
+
return Stack(
|
|
40
|
+
Text(f"speed: {s.speed}"),
|
|
41
|
+
Button("Stop", on_click=self.stop), # like reactpy
|
|
42
|
+
)
|
|
43
|
+
def stop(self, state, event):
|
|
44
|
+
state.speed = 0
|
|
45
|
+
|
|
46
|
+
layout = Layout(Panel().render, allowed_types={"Stack", "Text", "Button"})
|
|
47
|
+
seq = 0
|
|
48
|
+
def on_tick(state):
|
|
49
|
+
global seq
|
|
50
|
+
if ops := layout.tick(state): # [] on idle ticks: send nothing
|
|
51
|
+
seq += 1
|
|
52
|
+
broadcast(ui_envelope_patch(ops, seq))
|
|
53
|
+
|
|
54
|
+
def on_ws(msg, state):
|
|
55
|
+
if msg.get("chan") == "ui" and msg.get("type") == "action":
|
|
56
|
+
layout.dispatch(msg["handlerId"], state, msg.get("event"))
|
|
57
|
+
elif msg.get("type") == "resync-request":
|
|
58
|
+
broadcast(layout.snapshot(state, seq + 1))
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## JS quickstart
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
64
|
+
import * as MUI from '@mui/material';
|
|
65
|
+
import { createRenderer } from 'flyrail-renderer';
|
|
66
|
+
import { createStore } from 'flyrail-renderer/store';
|
|
67
|
+
|
|
68
|
+
const { ServerNode } = createRenderer({ ...MUI }); // auto-registered, no switch
|
|
69
|
+
const store = createStore({ onResync: (req) => ws.send(JSON.stringify(req)) });
|
|
70
|
+
ws.onmessage = (e) => store.ingest(JSON.parse(e.data));
|
|
71
|
+
// render store.getTree() via <ServerNode node={tree} send={...} />
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Hosting (pick one)
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
# 1. Fixed tick you own: Driver replaces the hand-rolled seq counter.
|
|
78
|
+
from flyrail import Driver
|
|
79
|
+
driver = Driver(layout)
|
|
80
|
+
def on_tick(state, version):
|
|
81
|
+
if env := driver.flush(state, version):
|
|
82
|
+
broadcast(env)
|
|
83
|
+
|
|
84
|
+
# 2. Async host: raw ASGI app, one session per connection. FastAPI:
|
|
85
|
+
# from fastapi import WebSocket; await create_ws_app(Panel().render)(scope, receive, send)
|
|
86
|
+
# (or mount in any ASGI framework; no fastapi dependency in this package)
|
|
87
|
+
from flyrail import create_ws_app
|
|
88
|
+
app = create_ws_app(Panel().render, state_factory=Sim,
|
|
89
|
+
on_message=lambda data, state: handle_telemetry(data))
|
|
90
|
+
|
|
91
|
+
# 3. Naive host (no loop at all): invalidate + flush around every message.
|
|
92
|
+
driver.invalidate()
|
|
93
|
+
if env := driver.flush(state):
|
|
94
|
+
send(env)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Best practices
|
|
98
|
+
|
|
99
|
+
1. **Stable keys, never indexes.** `key=item.id` on every list child, both
|
|
100
|
+
sides: Python handler ids embed the key, React reconciles by it. Reorder
|
|
101
|
+
without keys = full remount + lost focus. Keys must be unique among
|
|
102
|
+
siblings: duplicates share one handler id (last registration wins) and
|
|
103
|
+
components reject them, plain nodes do not.
|
|
104
|
+
2. **Slots for tick-rate values.** Table rows, labels, progress: `Slot("rows")`
|
|
105
|
+
+ `set_slot()` bypass the tree diff. Structure goes through patches (rare),
|
|
106
|
+
values through slots (every tick).
|
|
107
|
+
3. **Allowlist both ends.** Python `allowed_types={...}` rejects unknown node
|
|
108
|
+
types; JS `isKnownComponent` renders only functions (filters MUI's
|
|
109
|
+
`colors`, `createTheme`, etc.). Never render `registry[arbitraryString]`.
|
|
110
|
+
4. **One render per tick, send only on change.** `tick()` then `if ops:`.
|
|
111
|
+
Hash-gating makes idle ticks free.
|
|
112
|
+
5. **Inputs debounce client-side** (150ms default). Server never sees
|
|
113
|
+
keystroke storms; use `flush()` on submit.
|
|
114
|
+
6. **Caller-owned seq, snapshot on gaps.** The tick loop numbers patches; the
|
|
115
|
+
store drops stale/duplicates and answers gaps with one snapshot round-trip.
|
|
116
|
+
7. **Keep `render(state)` pure and cheap.** No DB, no IO: derive from the
|
|
117
|
+
already-computed tick state. One `Layout` per session.
|
|
118
|
+
8. **Safe event defaults, names like the DOM.** `preventDefault` is true
|
|
119
|
+
unless opted out (a reload is session death); `stopPropagation` stays
|
|
120
|
+
opt-in; `throttleMs` rate-caps sliders and guards double-submit. Same
|
|
121
|
+
names as the browser, defaults chosen for the socket.
|
|
122
|
+
9. **Mark renders `@pure`, version host state.** Pure renders skip render
|
|
123
|
+
CPU when the version is unchanged (unmarked always re-render: correct by
|
|
124
|
+
default). `strict=True` double-renders in dev to catch nondeterminism.
|
|
125
|
+
10. **Component-local UI state via hooks.** `use_state`/`use_memo` inside
|
|
126
|
+
`@component` bodies keeps collapsed flags and drafts out of tick state;
|
|
127
|
+
setters schedule through `invalidate()`. Distinct `key=` per instance,
|
|
128
|
+
hooks unconditional and order-stable, or it raises loudly. Effect
|
|
129
|
+
cleanups that raise abort the render instead of being swallowed.
|
|
130
|
+
11. **Async handlers via `adispatch`.** Handlers may be `async def` (e.g.
|
|
131
|
+
`await db.save()` on click); sync `dispatch` refuses them loudly instead
|
|
132
|
+
of silently dropping the coroutine. Pattern: `await adispatch(...)`,
|
|
133
|
+
then `invalidate()`.
|
|
134
|
+
|
|
135
|
+
## Docs
|
|
136
|
+
|
|
137
|
+
- [How it works](docs/how-it-works.md) — architecture, tick/action/resync
|
|
138
|
+
sequences, render pipeline, hook slots, scheduling, message catalog.
|
|
139
|
+
|
|
140
|
+
## Layout
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
flyrail/ Python core (stdlib only)
|
|
144
|
+
core.py Stack/Text/Button/TextField/Slot, @component, @pure
|
|
145
|
+
hooks.py use_state/use_memo keyed slots
|
|
146
|
+
layout.py registry + diff + dispatch + slots + snapshot
|
|
147
|
+
driver.py dirty-flag scheduling for tick/async/naive hosts
|
|
148
|
+
asgi.py framework-free websocket sessions
|
|
149
|
+
transport.py multiplex envelope + tick sketch
|
|
150
|
+
js/ flyrail-renderer (zero-dep ESM + tsx)
|
|
151
|
+
protocol.mjs envelopes, applyOps, debounce (node-tested)
|
|
152
|
+
store.mjs seq tracking, gap->resync, slots (node-tested)
|
|
153
|
+
ServerNode.tsx MUI-bound renderer (imports protocol.mjs)
|
|
154
|
+
example/ hmi_demo.py runnable narrative of the wire
|
|
155
|
+
tests/ focused unittest suites + public-API loopback
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Tests
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
PYTHONPATH=. python3 -m unittest discover -s tests -v # 67 tests
|
|
162
|
+
node --test js/protocol.test.mjs js/store.test.mjs # 24 tests
|
|
163
|
+
PYTHONPATH=. python3 example/hmi_demo.py # narrated wire demo
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Non-goals / roadmap
|
|
167
|
+
|
|
168
|
+
- Not a form validator (use your backend validation + error slots), not a
|
|
169
|
+
JSON-Schema renderer (see rjsf), not a full reactpy replacement (sync
|
|
170
|
+
core with no async effects; bring your own frontend instead of an
|
|
171
|
+
owned tree).
|
|
172
|
+
- Roadmap: vitest + React Testing Library for `ServerNode`, `byId/order`
|
|
173
|
+
maps for huge reorderable lists, keystroke `ackSeq` if loss-less input sync
|
|
174
|
+
is ever needed, registry packaging.
|
|
@@ -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"]
|
|
@@ -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
|
|
@@ -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}}
|
|
@@ -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)
|