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 +13 -0
- flyrail/asgi.py +77 -0
- flyrail/core.py +107 -0
- flyrail/driver.py +55 -0
- flyrail/hooks.py +175 -0
- flyrail/layout.py +578 -0
- flyrail/transport.py +26 -0
- flyrail-0.1.0.dist-info/METADATA +192 -0
- flyrail-0.1.0.dist-info/RECORD +12 -0
- flyrail-0.1.0.dist-info/WHEEL +5 -0
- flyrail-0.1.0.dist-info/licenses/LICENSE +21 -0
- flyrail-0.1.0.dist-info/top_level.txt +1 -0
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,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
|