Skip to content

Commit e0b250a

Browse files
apiadclaude
andcommitted
feat(examples): canonical 03_interactive — reactive unit converter + RPC
SSR + client-side Python. Single-user reactive UI. Exercises: - @app.local @DataClass for reactive state (meters/feet/inches/mode) - data-bind-value on three live-linked inputs - @app.client.callback for input change handlers (oninput event) - @app.client.on("ready") restoring from violetear.storage.store - @app.server.rpc with float arg + dict return (precise-mode path) - @app.client helper functions (save_state, recompute_from_meters) - @app.local mutation triggering data-bind updates across DOM siblings Smoke test verifies: SSR markup contains data-bind-value for each input + data-bind-text on the mode footer + data-on-{input,change} attrs; served stylesheet route; RPC POST returns expected dict; bundle compiles (using ast.PyCF_ALLOW_TOP_LEVEL_AWAIT — top-level `await` is legal because Pyodide uses runPythonAsync). One new framework gap surfaced (issues/7.5): - App._generate_bundle's inspect.getsource(cls) fails for dynamically loaded modules unless they're registered in sys.modules first. Workaround applied in the test loader; framework fix is to cache source text at @app.local registration time. 40 passed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3af9996 commit e0b250a

3 files changed

Lines changed: 345 additions & 4 deletions

File tree

examples/03_interactive.py

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
"""Tier 3 canonical example — an interactive length converter.
2+
3+
SSR + client-side Python (Pyodide bundle). Demonstrates `@app.local`
4+
reactive state, `data-bind-value` SSR hydration, `@app.client.callback`
5+
on `oninput`, `@app.client.on("ready")` for boot-time restore,
6+
`@app.server.rpc` with float args + dict return, and
7+
`violetear.storage.store` for cross-reload persistence.
8+
9+
Run:
10+
11+
python examples/03_interactive.py
12+
13+
Then open http://localhost:8000 in a browser. Edit any of the three
14+
fields (meters / feet / inches) — the other two update live. Toggle to
15+
"precise" mode and the conversion routes through a server RPC for more
16+
decimals. Refresh the page; the last values restore from localStorage.
17+
"""
18+
19+
from dataclasses import dataclass
20+
21+
from violetear import App, Document, StyleSheet
22+
from violetear.color import Colors
23+
from violetear.dom import Event
24+
from violetear.storage import store
25+
from violetear.units import px, rem
26+
27+
28+
app = App(title="Length Converter")
29+
30+
31+
# ---------------------------------------------------------------------------
32+
# Reactive state
33+
# ---------------------------------------------------------------------------
34+
35+
36+
@app.local
37+
@dataclass
38+
class UiState:
39+
meters: float = 1.0
40+
feet: float = 3.281
41+
inches: float = 39.37
42+
mode: str = "quick"
43+
44+
45+
# Rough client-only constants (quick mode).
46+
QUICK_FT_PER_M = 3.281
47+
QUICK_IN_PER_M = 39.37
48+
49+
50+
# ---------------------------------------------------------------------------
51+
# Server RPC — precise mode
52+
# ---------------------------------------------------------------------------
53+
54+
55+
@app.server.rpc
56+
async def precise_convert(meters: float) -> dict:
57+
return {
58+
"feet": meters * 3.28083989501,
59+
"inches": meters * 39.3700787402,
60+
}
61+
62+
63+
# ---------------------------------------------------------------------------
64+
# Client-side helpers + callbacks
65+
# ---------------------------------------------------------------------------
66+
67+
68+
@app.client
69+
async def save_state():
70+
store.last_state = {
71+
"meters": float(UiState.meters),
72+
"feet": float(UiState.feet),
73+
"inches": float(UiState.inches),
74+
"mode": str(UiState.mode),
75+
}
76+
77+
78+
@app.client
79+
async def recompute_from_meters(m: float):
80+
if str(UiState.mode) == "precise":
81+
result = await precise_convert(meters=m)
82+
UiState.feet = float(result["feet"])
83+
UiState.inches = float(result["inches"])
84+
else:
85+
UiState.feet = m * QUICK_FT_PER_M
86+
UiState.inches = m * QUICK_IN_PER_M
87+
88+
89+
@app.client.callback
90+
async def on_meters_change(event: Event):
91+
try:
92+
v = float(event.target.value)
93+
except (ValueError, TypeError):
94+
return
95+
UiState.meters = v
96+
await recompute_from_meters(v)
97+
await save_state()
98+
99+
100+
@app.client.callback
101+
async def on_feet_change(event: Event):
102+
try:
103+
v = float(event.target.value)
104+
except (ValueError, TypeError):
105+
return
106+
UiState.feet = v
107+
# Anchor on meters so precise/quick paths share one code path.
108+
if str(UiState.mode) == "precise":
109+
m = v / 3.28083989501
110+
result = await precise_convert(meters=m)
111+
UiState.meters = m
112+
UiState.inches = float(result["inches"])
113+
else:
114+
m = v / QUICK_FT_PER_M
115+
UiState.meters = m
116+
UiState.inches = m * QUICK_IN_PER_M
117+
await save_state()
118+
119+
120+
@app.client.callback
121+
async def on_inches_change(event: Event):
122+
try:
123+
v = float(event.target.value)
124+
except (ValueError, TypeError):
125+
return
126+
UiState.inches = v
127+
if str(UiState.mode) == "precise":
128+
m = v / 39.3700787402
129+
result = await precise_convert(meters=m)
130+
UiState.meters = m
131+
UiState.feet = float(result["feet"])
132+
else:
133+
m = v / QUICK_IN_PER_M
134+
UiState.meters = m
135+
UiState.feet = m * QUICK_FT_PER_M
136+
await save_state()
137+
138+
139+
@app.client.callback
140+
async def on_mode_change(event: Event):
141+
UiState.mode = str(event.target.value)
142+
# Re-derive the other two from current meters so the displayed values
143+
# immediately reflect the precision of the chosen mode.
144+
await recompute_from_meters(float(UiState.meters))
145+
await save_state()
146+
147+
148+
@app.client.on("ready")
149+
async def restore():
150+
saved = store.last_state
151+
if saved is None:
152+
return
153+
# Each lookup on a missing key returns None — skip in that case.
154+
if saved.meters is not None:
155+
UiState.meters = float(saved.meters)
156+
if saved.feet is not None:
157+
UiState.feet = float(saved.feet)
158+
if saved.inches is not None:
159+
UiState.inches = float(saved.inches)
160+
if saved.mode is not None:
161+
UiState.mode = str(saved.mode)
162+
163+
164+
# ---------------------------------------------------------------------------
165+
# Stylesheet — flat class selectors only (gap 7.1)
166+
# ---------------------------------------------------------------------------
167+
168+
169+
sheet = StyleSheet(normalize=True)
170+
171+
sheet.select("body").font(
172+
size=rem(1.0), family="system-ui, -apple-system, 'Segoe UI', sans-serif"
173+
).color(Colors.DarkSlateGray).background(Colors.WhiteSmoke).padding(rem(2.5))
174+
175+
sheet.select(".page").rules(max_width="520px").margin("auto").background(
176+
Colors.White
177+
).padding(rem(2.5)).rounded(px(8)).border(px(1), Colors.Gainsboro)
178+
179+
sheet.select(".page-title").font(size=rem(2.0), weight=700).color(Colors.Indigo).margin(
180+
bottom=rem(0.25)
181+
)
182+
183+
sheet.select(".subtitle").color(Colors.SlateGray).font(size=rem(1.0)).margin(
184+
bottom=rem(2.0)
185+
)
186+
187+
sheet.select(".converter").flexbox(direction="column", gap=px(14))
188+
189+
sheet.select(".field").flexbox(direction="column", gap=px(4))
190+
sheet.select(".field-label").font(size=rem(0.875), weight=600).color(
191+
Colors.DarkSlateGray
192+
)
193+
sheet.select(".field-input").rules(
194+
padding="8px 10px",
195+
border=f"1px solid {Colors.Gainsboro}",
196+
font_size="1rem",
197+
font_family="ui-monospace, monospace",
198+
).rounded(px(4))
199+
200+
sheet.select(".mode-row").flexbox(direction="row", gap=px(16), align="center").margin(
201+
top=rem(1.0)
202+
)
203+
sheet.select(".mode-option").flexbox(direction="row", gap=px(6), align="center").font(
204+
size=rem(0.9)
205+
).color(Colors.DarkSlateGray)
206+
sheet.select(".mode-input").rules(cursor="pointer")
207+
208+
sheet.select(".footer").margin(top=rem(1.5)).color(Colors.SlateGray).font(
209+
size=rem(0.8), family="ui-monospace, monospace"
210+
)
211+
sheet.select(".footer-mode").font(weight=700).color(Colors.Indigo)
212+
213+
214+
# ---------------------------------------------------------------------------
215+
# View
216+
# ---------------------------------------------------------------------------
217+
218+
219+
@app.view("/")
220+
def index():
221+
doc = Document(title="Length Converter")
222+
doc.style(href="/style.css", sheet=sheet)
223+
224+
with doc.body as body:
225+
with body.div(classes="page") as page:
226+
page.h1(text="Length Converter").classes("page-title")
227+
page.p(
228+
text="Edit any field — the other two update live. Switch modes for precision."
229+
).classes("subtitle")
230+
231+
with page.div(classes="converter") as form:
232+
# Meters
233+
with form.div(classes="field") as field:
234+
with field.label(text="Meters", classes="field-label") as lbl:
235+
lbl.input(classes="field-input").attrs(
236+
type="number", step="0.01"
237+
).value(UiState.meters).on("input", on_meters_change)
238+
239+
# Feet
240+
with form.div(classes="field") as field:
241+
with field.label(text="Feet", classes="field-label") as lbl:
242+
lbl.input(classes="field-input").attrs(
243+
type="number", step="0.01"
244+
).value(UiState.feet).on("input", on_feet_change)
245+
246+
# Inches
247+
with form.div(classes="field") as field:
248+
with field.label(text="Inches", classes="field-label") as lbl:
249+
lbl.input(classes="field-input").attrs(
250+
type="number", step="0.01"
251+
).value(UiState.inches).on("input", on_inches_change)
252+
253+
# Mode toggle — two radios sharing name="mode".
254+
with form.div(classes="mode-row") as modes:
255+
with modes.label(text="quick", classes="mode-option") as lbl:
256+
lbl.input(classes="mode-input").attrs(
257+
type="radio",
258+
name="mode",
259+
value="quick",
260+
checked="checked",
261+
).on("change", on_mode_change)
262+
with modes.label(text="precise", classes="mode-option") as lbl:
263+
lbl.input(classes="mode-input").attrs(
264+
type="radio", name="mode", value="precise"
265+
).on("change", on_mode_change)
266+
267+
with page.div(classes="footer") as footer:
268+
footer.span(text="mode: ")
269+
footer.span(classes="footer-mode").text(UiState.mode)
270+
271+
return doc
272+
273+
274+
if __name__ == "__main__":
275+
app.run()

issues/7-framework-gaps-from-canonical-examples.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,18 @@ Running log of small framework limitations and ergonomic friction discovered whi
4343

4444
**Impact.** Trips up new users; forces a context-manager-or-`HTML.*` pattern that's slightly more verbose than necessary.
4545

46+
## 7.5 — Bundle generator's `inspect.getsource` breaks for dynamically-loaded modules
47+
48+
**Tier(s):** 03 (smoke-test infrastructure)
49+
50+
**Symptom.** When an example module is loaded via `importlib.util.spec_from_file_location` *without* being inserted into `sys.modules` first, the bundle endpoint (`/_violetear/bundle.py`) raises `TypeError: <class 'mod.UiState'> is a built-in class` from `inspect.getsource(cls)` in `App._generate_bundle`. Tier 2 (`02_ssr`) didn't surface this because it has no `@app.local` state classes. Tier 3 hits it as soon as the test fetches the bundle.
51+
52+
**Workaround.** In the smoke-test loader, register the module in `sys.modules` before `spec.loader.exec_module(module)`. (Already applied in `tests/test_examples_canonical.py::_load`.)
53+
54+
**Where to fix in the framework.** At `@app.local` / `client.register_state` time, snapshot `inspect.getsource(cls)` and cache the source string on the registered class. At bundle-emit time, prefer the cached source over re-calling `inspect.getsource`. Same pattern for `client.code_functions`. Alternative: have `register_state` accept an explicit `source=` override for callers who know they're in a dynamic-load context.
55+
56+
**Impact.** Mostly bites test infrastructure and any future "import an example dynamically" tooling. End-user apps with module-level definitions are unaffected.
57+
4658
## 7.4 — `Element.attrs(**kwargs)` doesn't strip trailing underscores
4759

4860
**Tier(s):** 02

tests/test_examples_canonical.py

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,29 @@
88
See `issues/6-canonical-examples-design.md` for the design.
99
"""
1010

11+
import ast
1112
import importlib.util
13+
import sys
1214
from pathlib import Path
1315

16+
# Top-level `await` is legal in the violetear bundle because Pyodide runs it via
17+
# `pyodide.runPythonAsync(...)`. Standard `compile()` needs this flag to accept it.
18+
COMPILE_ASYNC = ast.PyCF_ALLOW_TOP_LEVEL_AWAIT
19+
1420
EXAMPLES_DIR = Path(__file__).resolve().parent.parent / "examples"
1521

1622

1723
def _load(filename: str):
18-
"""Import an example module by filename without it polluting sys.modules."""
19-
spec = importlib.util.spec_from_file_location(
20-
filename.removesuffix(".py"), EXAMPLES_DIR / filename
21-
)
24+
"""Import an example module by filename and register it in sys.modules.
25+
26+
Registration is REQUIRED because violetear's bundle generator calls
27+
`inspect.getsource(cls)` on `@app.local` state classes; without a sys.modules
28+
entry, that raises `TypeError: <class is a built-in class>` (see gap 7.5).
29+
"""
30+
name = filename.removesuffix(".py")
31+
spec = importlib.util.spec_from_file_location(name, EXAMPLES_DIR / filename)
2232
module = importlib.util.module_from_spec(spec)
33+
sys.modules[name] = module
2334
spec.loader.exec_module(module)
2435
return module
2536

@@ -98,3 +109,46 @@ def test_02_ssr_guestbook_round_trips_an_entry():
98109
# Pydantic-ish enforcement: missing field is rejected by FastAPI Form(...)
99110
r = client.post("/entries", data={"name": "Alex"})
100111
assert r.status_code == 422
112+
113+
114+
# -- 03_interactive ------------------------------------------------------------
115+
116+
117+
def test_03_interactive_ssr_bindings_and_rpc_and_bundle():
118+
"""Tier 3: SSR emits data-bind-value for each input; RPC returns precise
119+
conversion; bundle compiles (the canary for the inspect.getsource issue)."""
120+
from fastapi.testclient import TestClient
121+
122+
mod = _load("03_interactive.py")
123+
client = TestClient(mod.app.api)
124+
125+
# SSR markup — reactive bindings on each input
126+
r = client.get("/")
127+
assert r.status_code == 200
128+
html = r.text
129+
assert 'data-bind-value="UiState.meters"' in html
130+
assert 'data-bind-value="UiState.feet"' in html
131+
assert 'data-bind-value="UiState.inches"' in html
132+
# Mode footer is bound via .text()
133+
assert 'data-bind-text="UiState.mode"' in html
134+
# Event-listener attrs from .on("input", callback)
135+
assert 'data-on-input="on_meters_change"' in html
136+
assert 'data-on-change="on_mode_change"' in html
137+
138+
# Served stylesheet
139+
r = client.get("/style.css")
140+
assert r.status_code == 200
141+
assert ".field-input" in r.text
142+
143+
# RPC endpoint — happy path returns precise conversion
144+
r = client.post("/_violetear/rpc/precise_convert", json={"meters": 2.0})
145+
assert r.status_code == 200
146+
body = r.json()
147+
assert abs(body["feet"] - 2.0 * 3.28083989501) < 1e-9
148+
assert abs(body["inches"] - 2.0 * 39.3700787402) < 1e-9
149+
150+
# Bundle compiles — proves the @app.local state class and all client
151+
# functions got transpiled without inspect.getsource blowing up.
152+
r = client.get("/_violetear/bundle.py")
153+
assert r.status_code == 200
154+
compile(r.text, "<bundle-03>", "exec", flags=COMPILE_ASYNC)

0 commit comments

Comments
 (0)