Compare commits

..

6 Commits

Author SHA1 Message Date
551c3d1efc Add PyPI package installer, file-tree context menu, and demos UI
Expose micropip installs from the menu, right-click actions on the file
tree, local folder/file open shortcuts, and browse demos/ alongside code/.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 22:34:55 +12:00
8c45097ec5 Support demos/ in the local-mode workspace client
Treat demos as a writable top-level folder alongside code/, seed it on
first open, and ship browser_fetch.py with bundled lib stubs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 22:34:45 +12:00
d38f819c49 Add browser_fetch helpers and async HTTP demos for Pyodide
Ship pyfetch-based fetch utilities in lib/, run asyncio scripts via
nest-asyncio in the worker, and add sample demos for HTTPS in the browser.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 22:34:34 +12:00
98fa4260d4 Relocate bundled demo sources into bundled-demos/demo/
Mirror bundled-lib/ by keeping manifest.json at the bundle root and
shipping sample .py files from a demo/ subdirectory.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 22:34:17 +12:00
e3400120d3 Seed bundled demos under top-level demos/ instead of code/
Move canonical sample scripts to a sibling folder of code/ and lib/ so
user projects stay separate from shipped examples. Backend seeding,
writable paths, and docs follow the new layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 22:34:12 +12:00
d355174f5a Mobile UX polish: scrollable column, menu workspace section, pin tap fixes
The phone layout had three problems that compounded when running ADC /
Pin / Serial demos: the editor refused to shrink past 42vh so panels
spilled over a clipped console, the workspace badge was crammed full of
buttons, and the IN-pin toggle button was unreliable to tap.

- styles.css: on `<= 768px`, make `.main-content` scroll vertically and
  give the editor a fixed 50vh height (drops to 32vh via `:has()` when a
  simulator panel is open). All panels + console pin to `flex: 0 0 auto`
  so flexbox stops squashing them. Inner panel scroll caps tightened to
  22vh so two stacked panels don't push the console below the fold.
  `.pin-toggle` gets `touch-action: manipulation` + `user-select: none`
  to fix iOS taps inside scrollable parents.

- script.js: pin button now has a `pointerup` backup with a 300 ms
  debounce alongside `click` (Safari sometimes drops `click` on small
  buttons inside scroll containers). The `⋮` workspace menu auto-closes
  on outside `pointerdown` as well as `click`, so the open dropdown
  can't sit on top of the Pin panel and absorb taps.

- script.js / index.html / styles.css: move every Workspace action
  (Export, Import, Reset demos, plus the local-mode-only Folder…,
  Reconnect, IndexedDB swap, Exit) out of the badge and into a new
  "Workspace" section in the `⋮` menu. Badge keeps just the storage
  label. Adds `.menu-separator`, `.menu-section-label`, `.menu-note`,
  and `.menu-action` styles; removes the now-unused
  `.workspace-badge-action` / `-exit` / `-note` rules.

- bundled-demos/pin_demo.py: pin 4 is now driven exclusively by the
  IRQ handler, so it stays steady until the IN button is pressed —
  previously it auto-flashed via on/off in the loop, which made the
  IRQ effect indistinguishable from the existing animation. The IRQ
  handler also no longer prints on every press (the panel indicator
  is the feedback).

Cache busters: styles.css 32 -> 36, script.js 57 -> 59.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 07:16:45 +12:00
37 changed files with 2296 additions and 407 deletions

2
.gitignore vendored
View File

@@ -178,7 +178,7 @@ cython_debug/
# Editor runtime state — `workspace/` holds user files (auth-mode per-user
# folders, no-auth dev's `code/`); the canonical demo source lives under
# `src/static/bundled-demos/` and is what gets seeded into `workspace/`
# `src/static/bundled-demos/demo/` seeds into `workspace/demos/` (and per-user `users/.../demos/`)
# on startup. Nothing under `workspace/` should ever be committed.
/workspace/
src/static/.reload-token

View File

@@ -5,7 +5,7 @@ This tutorial is for the browser editor's ESP32-style mocks:
- `machine.Pin`
- `neopixel.NeoPixel`
Open `code/led_tutorial.py` in the editor while reading this guide. (Source of truth: `src/static/bundled-demos/led_tutorial.py` — the editor's `code/` folder is seeded from there on first run.)
Open `demos/led_tutorial.py` in the editor while reading this guide. (Source of truth: `src/static/bundled-demos/demo/led_tutorial.py` — the top-level `demos/` folder is seeded from there on first run.)
## 1) Basic setup

View File

@@ -110,7 +110,7 @@ The browser runtime ships MicroPython-style stubs in repo `lib/` (they appear as
- `utime` — `ticks_ms`, `ticks_diff`, `ticks_add`, `sleep_ms`, `sleep_us`, `sleep`
- `micropython.const` — no-op helper for ported constant declarations
Use them from scripts in `code/` (your editor workspace, populated on first run from `src/static/bundled-demos/`) like typical ESP32 / MicroPython examples:
Use them from the top-level `demos/` folder (sibling of `code/` and `lib/`; first run seeds from `src/static/bundled-demos/demo/`) like typical ESP32 / MicroPython examples:
```python
from machine import Pin
@@ -132,7 +132,7 @@ Simulator modes:
- rows zig-zag left/right.
- The 16x16 popup closes automatically on **Stop** or when script execution finishes.
Tutorial files (canonical source — committed under `src/static/bundled-demos/`; copies appear in your editor's `code/` folder on first run):
Tutorial files (canonical source — committed under `src/static/bundled-demos/demo/`; copies appear under top-level `demos/` on first run):
- `LED_TUTORIAL.md` - step-by-step NeoPixel tutorial
- `led_tutorial.py` - runnable guided LED example
@@ -145,7 +145,7 @@ Tutorial files (canonical source — committed under `src/static/bundled-demos/`
- `panel16_bounce.py` - 16x16 bouncing pixel with trail
- `panel16_matrix_rain.py` - 16x16 matrix rain effect
> `workspace/` is gitignored runtime state. To edit the **shipped** demo source, edit `src/static/bundled-demos/<file>.py` and re-run "Reset demos" in the editor (or restart the dev server with an empty `workspace/code/`).
> `workspace/` is gitignored runtime state. To edit the **shipped** demo source, edit `src/static/bundled-demos/demo/<file>.py` and re-run "Reset demos" in the editor (or restart the dev server with an empty `workspace/demos/`).
## Dev auto-reload hook

38
lib/browser_fetch.py Normal file
View File

@@ -0,0 +1,38 @@
"""Browser-friendly async HTTP for the Pyodide worker.
These helpers use ``pyodide.http.pyfetch``, which maps to the browser
``fetch`` API (no Python TLS stack). Prefer them for ``https://`` in
Pyodide: ``aiohttp`` in the worker often raises
``RuntimeError('SSL is not supported.')`` for HTTPS even though the wheel
exists, because user-level SSL is not wired the same as on CPython.
The browser's normal rules apply: the page is served over HTTPS so
``http://`` URLs are blocked as mixed content, and the response host must
send permissive CORS headers (e.g. ``Access-Control-Allow-Origin``) or the
browser hides the body even if the request succeeded.
"""
from __future__ import annotations
from typing import Any
async def fetch_text(url: str) -> str:
from pyodide.http import pyfetch
r = await pyfetch(url)
return await r.text()
async def fetch_bytes(url: str) -> bytes:
from pyodide.http import pyfetch
r = await pyfetch(url)
return await r.bytes()
async def fetch_json(url: str, **kwargs: Any) -> Any:
from pyodide.http import pyfetch
r = await pyfetch(url)
return await r.json(**kwargs)

View File

@@ -29,9 +29,10 @@ load_env_file(PROJECT_ROOT / ".env")
_default_workspace = PROJECT_ROOT / "workspace"
WORKSPACE_ROOT = Path(os.environ.get("WORKSPACE_ROOT", str(_default_workspace))).resolve()
# Canonical demo source. Files here are the single source of truth for the
# editor's "Reset demos" button and per-user account seeding. They ship with
# the static bundle (`/static/bundled-demos/...`) so a static-only host
# also exposes them. `workspace/` is intentionally NOT used for canonical
# data — it is treated as runtime/user state and is gitignored.
# Canonical demo bundle root (`manifest.json` lives here). Sample `.py`
# sources live under `demo/` (same idea as `bundled-lib/` for shared modules).
# They ship with the static bundle (`/static/bundled-demos/...`) so a
# static-only host also exposes them. `workspace/` is intentionally NOT used
# for canonical data — it is treated as runtime/user state and is gitignored.
BUNDLED_DEMOS_DIR = STATIC_DIR / "bundled-demos"
BUNDLED_DEMOS_CODE_DIR = BUNDLED_DEMOS_DIR / "demo"

View File

@@ -20,10 +20,10 @@ from editor_app.services import accounts, user_workspace
@asynccontextmanager
async def lifespan(_app: FastAPI):
# `workspace/` is gitignored runtime state. On a fresh clone it doesn't
# exist, and in no-auth dev mode the file tree's `code/` would otherwise
# be empty — seed every bundled demo so `pipenv run dev` after `git
# clone` Just Works without needing user accounts. Files already in
# `code/` are left alone (user edits are preserved across restarts).
# exist, and in no-auth dev mode the file tree would otherwise be empty
# — seed every bundled demo under top-level `demos/` (next to `code/`)
# so `pipenv run dev` after `git clone` Just Works without needing user
# accounts. Existing files are left alone (user edits are preserved).
WORKSPACE_ROOT.mkdir(parents=True, exist_ok=True)
user_workspace.seed_all_bundled_demos(WORKSPACE_ROOT)

View File

@@ -7,7 +7,7 @@ from editor_app import config
from editor_app.models import FileInfo
LIB_DIR_NAME = "lib"
WRITABLE_ROOTS = {"code"}
WRITABLE_ROOTS = {"code", "demos"}
def _workspace_root(workspace_root: Path | None = None) -> Path:
@@ -33,6 +33,9 @@ def normalize_relative_path(relative_path: str) -> str:
if len(parts) >= 2 and parts[0] == "code":
while len(parts) >= 2 and parts[0] == parts[1] == "code":
parts.pop(1)
if len(parts) >= 2 and parts[0] == "demos":
while len(parts) >= 2 and parts[0] == parts[1] == "demos":
parts.pop(1)
return "/".join(parts)
@@ -84,7 +87,7 @@ def _ensure_writable_path(target_path: Path, workspace_root: Path | None = None)
if not _is_writable_path(target_path, workspace_root):
raise HTTPException(
status_code=403,
detail="Only code/ is writable (lib is read-only)",
detail="Only code/ and demos/ are writable (lib is read-only)",
)

View File

@@ -6,13 +6,14 @@ from pathlib import Path
from editor_app import config
_BUNDLED_DEMO_PY_DIR = config.BUNDLED_DEMOS_CODE_DIR
# Top-level workspace folder for shipped samples (sibling of ``code/``, like ``lib/``).
_EDITOR_DEMOS_ROOT = "demos"
DEFAULT_MAIN_PY = 'print("Hello, World!")\n'
# Self-contained demos copied from shipped `workspace/code/` (stdlib + machine/neopixel/time only).
# New accounts get a copy of each one in their own `code/` folder so the
# editor has something to show on first login. They're treated as
# starting points — users can edit/delete freely without affecting the
# shipped originals.
# Self-contained demos copied from static ``bundled-demos/demo/`` (stdlib + machine/neopixel/time only).
# New accounts get a copy under ``<user_root>/demos/`` (same level as ``code/``).
_CANONICAL_DEMO_FILENAMES = (
"pattern_rainbow_demo.py",
"pattern_twinkle_demo.py",
@@ -20,6 +21,8 @@ _CANONICAL_DEMO_FILENAMES = (
"adc_slider_demo.py",
"pin_demo.py",
"serial_demo.py",
"async_fetch_demo.py",
"aiohttp_fetch_demo.py",
)
@@ -33,17 +36,22 @@ def user_workspace_root(user_id: int, username: str, workspace_root: Path | None
return root / "users" / safe_workspace_leaf(username, user_id)
def _seed_canonical_demos_into_code(code_dir: Path) -> None:
"""Copy bundled demos into a user's `code/` if missing.
def _seed_canonical_demos(user_root: Path) -> None:
"""Copy bundled demos into ``demos/`` if missing.
Reads from `BUNDLED_DEMOS_DIR` (single source of truth, ships under
`src/static/bundled-demos/`), never from `workspace/`, so this works
even when `workspace/` is empty (gitignored runtime directory).
Reads from `BUNDLED_DEMOS_CODE_DIR` (``src/static/bundled-demos/demo/``).
Skips if the file already exists under ``demos/``, legacy ``code/<name>.py``,
or old ``code/demos/<name>.py``.
"""
src_root = config.BUNDLED_DEMOS_DIR.resolve()
src_root = _BUNDLED_DEMO_PY_DIR.resolve()
demos_dir = user_root / _EDITOR_DEMOS_ROOT
code_dir = user_root / "code"
demos_dir.mkdir(parents=True, exist_ok=True)
for filename in _CANONICAL_DEMO_FILENAMES:
dst = code_dir / filename
if dst.exists():
dst = demos_dir / filename
legacy_flat = code_dir / filename
legacy_nested = code_dir / "demos" / filename
if dst.exists() or legacy_flat.exists() or legacy_nested.exists():
continue
src = src_root / filename
if src.is_file():
@@ -51,9 +59,9 @@ def _seed_canonical_demos_into_code(code_dir: Path) -> None:
def ensure_default_code_main(user_root: Path) -> None:
"""Ensure code/ has main.py and the canonical NeoPixel demos.
"""Ensure ``code/main.py`` and canonical demos under top-level ``demos/``.
Demos are sourced from `BUNDLED_DEMOS_DIR` (the single committed home
Demos are sourced from `BUNDLED_DEMOS_CODE_DIR` (the single committed home
for sample scripts). Only files listed in `_CANONICAL_DEMO_FILENAMES`
get auto-seeded — the rest are available via the editor's "Reset
demos" button or a manual copy."""
@@ -62,30 +70,34 @@ def ensure_default_code_main(user_root: Path) -> None:
main_py = code_dir / "main.py"
if not main_py.exists():
main_py.write_text(DEFAULT_MAIN_PY, encoding="utf-8")
_seed_canonical_demos_into_code(code_dir)
_seed_canonical_demos(user_root)
def seed_all_bundled_demos(user_root: Path) -> None:
"""Copy *every* file in `BUNDLED_DEMOS_DIR` into `<user_root>/code/`.
"""Copy *every* ``.py`` file in `BUNDLED_DEMOS_CODE_DIR` into ``<user_root>/demos/``.
Used at app startup to populate a fresh `workspace/code/` with the
full sample set so a no-auth dev install (`pipenv run dev` after
`git clone`) has something to play with. Existing files are not
overwritten — user edits are preserved.
Used at app startup to populate a fresh workspace with the full sample
set so a no-auth dev install (`pipenv run dev` after `git clone`) has
something to play with. Existing files are not overwritten — user edits
are preserved.
"""
code_dir = user_root / "code"
code_dir.mkdir(parents=True, exist_ok=True)
main_py = code_dir / "main.py"
if not main_py.exists():
main_py.write_text(DEFAULT_MAIN_PY, encoding="utf-8")
src_root = config.BUNDLED_DEMOS_DIR.resolve()
demos_dir = user_root / _EDITOR_DEMOS_ROOT
demos_dir.mkdir(parents=True, exist_ok=True)
src_root = _BUNDLED_DEMO_PY_DIR.resolve()
if not src_root.is_dir():
return
for src in sorted(src_root.iterdir()):
if not src.is_file() or not src.name.endswith(".py"):
continue
dst = code_dir / src.name
if dst.exists():
dst = demos_dir / src.name
legacy_flat = code_dir / src.name
legacy_nested = code_dir / "demos" / src.name
if dst.exists() or legacy_flat.exists() or legacy_nested.exists():
continue
try:
dst.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")

View File

@@ -0,0 +1,32 @@
"""Async HTTPS GET of Pyodides lock file (browser-safe).
Older Pyodide + ``aiohttp`` examples used ``ClientSession`` here, but the
Pyodide ``aiohttp`` wheel often raises ``RuntimeError('SSL is not supported.')``
for ``https://`` — there is no real Python TLS stack in the worker; traffic must
go through the browsers ``fetch``.
This demo uses the shared ``browser_fetch`` helpers (``pyodide.http.pyfetch``
under the hood), same idea as ``async_fetch_demo.py``.
CORS still applies — jsDelivr allows cross-origin GETs. ``print(..., flush=True)``
helps batched worker stdout appear before the run finishes.
"""
import asyncio
from browser_fetch import fetch_json
async def main():
url = "https://cdn.jsdelivr.net/pyodide/v0.26.4/full/pyodide-lock.json"
print("Fetching", url, "via browser_fetch.fetch_json ...", flush=True)
data = await fetch_json(url)
info = data.get("info") or {}
packages = data.get("packages") or {}
print("pyodide-lock version:", info.get("version"), flush=True)
print("python runtime :", info.get("python"), flush=True)
print("indexed packages :", len(packages), flush=True)
print("aiohttp wheel in lock:", "aiohttp" in packages, flush=True)
asyncio.run(main())

View File

@@ -0,0 +1,27 @@
"""Async HTTP in the browser (Pyodide).
``aiohttp`` and similar clients use OS sockets, which Wasm does not provide, so
a ``session.get(...)`` can hang after ``print("Running")`` with no body.
Use ``pyodide.http.pyfetch`` or the shared ``browser_fetch`` helpers. From an
HTTPS editor page, use ``https://`` URLs (mixed content blocks ``http://``).
Many sites do not send CORS headers, so the browser blocks the response even
when the URL is valid. This demo uses jsDelivr JSON that allows cross-origin
GET (same host Pyodide loads from).
"""
import asyncio
from browser_fetch import fetch_json
async def main():
print("Running")
url = "https://cdn.jsdelivr.net/pyodide/v0.26.4/full/repodata.json"
data = await fetch_json(url)
info = data.get("info") or {}
print("Fetched repodata; pyodide lock says:", info.get("version"), info.get("python"))
asyncio.run(main())

View File

@@ -0,0 +1,56 @@
"""Pin features demo.
A "Pins" panel appears below the editor while this script runs:
* Pin 2 (OUT) — blinks automatically every ~200 ms via ``.value(...)``.
* Pin 4 (OUT) — stays put until you press the button, then flips
(driven from the IRQ handler with ``.toggle()``).
* Pin 0 (IN) — click the toggle button in the panel to drive a 0 → 1
rising edge; the IRQ fires and flips pin 4.
* Pin 13 (PWM) — duty sweeps up and down; the bar shows the live duty.
"""
import time
from machine import Pin, PWM
led_a = Pin(2, Pin.OUT)
led_b = Pin(4, Pin.OUT)
button = Pin(0, Pin.IN, Pin.PULL_UP)
fader = PWM(Pin(13), freq=1000, duty_u16=0)
def on_button(pin):
# Pin 4 is IRQ-driven on purpose — its only source of change is the
# button press, so when you see it flip you know the IRQ fired.
led_b.toggle()
button.irq(handler=on_button, trigger=Pin.IRQ_RISING)
tick = 0
duty = 0
direction = 1024
while True:
# Pin 2: fast on/off via direct .value(...) writes (no IRQ involvement).
led_a.value(tick % 2)
# Pin 13: triangular duty sweep so the PWM bar visibly fills and drains.
duty += direction
if duty >= 65535:
duty = 65535
direction = -1024
elif duty <= 0:
duty = 0
direction = 1024
fader.duty_u16(duty)
# Poll the IN pin so its IRQ actually fires when the panel button changes.
# (`.value()` reads the current state and dispatches any pending edge.)
button.value()
tick += 1
time.sleep(0.1)

View File

@@ -1,10 +1,11 @@
{
"files": [
"pattern_rainbow_demo.py",
"pattern_twinkle_demo.py",
"pattern_chase_demo.py",
"adc_slider_demo.py",
"pin_demo.py",
"serial_demo.py"
"demo/pattern_rainbow_demo.py",
"demo/pattern_twinkle_demo.py",
"demo/pattern_chase_demo.py",
"demo/adc_slider_demo.py",
"demo/pin_demo.py",
"demo/serial_demo.py",
"demo/aiohttp_fetch_demo.py"
]
}

View File

@@ -1,54 +0,0 @@
"""Pin features demo.
A "Pins" panel appears below the editor while this script runs:
* Pin 2 (OUT) — blinks every 200 ms; the indicator follows along.
* Pin 4 (OUT) — chases through .on() / .off() / .toggle().
* Pin 0 (IN) — click the toggle button in the panel to flip its value.
When it goes 0 -> 1 we register an IRQ that toggles pin 2.
* Pin 13 (PWM) — duty sweeps up and down; the bar shows the live duty cycle.
"""
import time
from machine import Pin, PWM
led_a = Pin(2, Pin.OUT)
led_b = Pin(4, Pin.OUT)
button = Pin(0, Pin.IN, Pin.PULL_UP)
fader = PWM(Pin(13), freq=1000, duty_u16=0)
def on_button(pin):
print("[irq] button rising edge -> toggling pin 2")
led_a.toggle()
button.irq(handler=on_button, trigger=Pin.IRQ_RISING)
tick = 0
duty = 0
direction = 1024
while True:
led_a.value(tick % 2)
if tick % 4 == 0:
led_b.on()
elif tick % 4 == 2:
led_b.off()
duty += direction
if duty >= 65535:
duty = 65535
direction = -1024
elif duty <= 0:
duty = 0
direction = 1024
fader.duty_u16(duty)
button.value()
tick += 1
time.sleep(0.1)

View File

@@ -0,0 +1,38 @@
"""Browser-friendly async HTTP for the Pyodide worker.
These helpers use ``pyodide.http.pyfetch``, which maps to the browser
``fetch`` API (no Python TLS stack). Prefer them for ``https://`` in
Pyodide: ``aiohttp`` in the worker often raises
``RuntimeError('SSL is not supported.')`` for HTTPS even though the wheel
exists, because user-level SSL is not wired the same as on CPython.
The browser's normal rules apply: the page is served over HTTPS so
``http://`` URLs are blocked as mixed content, and the response host must
send permissive CORS headers (e.g. ``Access-Control-Allow-Origin``) or the
browser hides the body even if the request succeeded.
"""
from __future__ import annotations
from typing import Any
async def fetch_text(url: str) -> str:
from pyodide.http import pyfetch
r = await pyfetch(url)
return await r.text()
async def fetch_bytes(url: str) -> bytes:
from pyodide.http import pyfetch
r = await pyfetch(url)
return await r.bytes()
async def fetch_json(url: str, **kwargs: Any) -> Any:
from pyodide.http import pyfetch
r = await pyfetch(url)
return await r.json(**kwargs)

View File

@@ -6,7 +6,7 @@
<meta name="theme-color" content="#2d3748">
<title>LED Editor</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="/static/styles.css?v=32">
<link rel="stylesheet" href="/static/styles.css?v=41">
</head>
<body>
<div class="container">
@@ -26,6 +26,8 @@
</div>
</div>
<div id="file-tree-context-menu" class="file-tree-context-menu" role="menu" hidden aria-hidden="true"></div>
<div class="main-content">
<div class="editor-header">
<button id="sidebar-toggle" class="sidebar-toggle" type="button" aria-label="Toggle file tree" aria-controls="sidebar" aria-expanded="true" title="Toggle file browser"><span class="sidebar-toggle-icon" aria-hidden="true"></span></button>
@@ -48,6 +50,12 @@
<input type="checkbox" id="panel-16x16-checkbox" />
16×16 panel
</label>
<button type="button" class="menu-item menu-button" id="packages-menu-btn" role="menuitem">📦 Python packages…</button>
<button type="button" class="menu-item menu-button" id="menu-open-local-folder-btn" role="menuitem">📂 Open local folder…</button>
<button type="button" class="menu-item menu-button" id="menu-open-local-file-btn" role="menuitem">📄 Open local file…</button>
<div class="menu-separator" role="separator"></div>
<div class="menu-section-label" id="workspace-menu-label">Workspace</div>
<div id="workspace-menu-actions" role="group"></div>
</div>
</details>
</div>
@@ -118,6 +126,27 @@
</div>
</div>
<script type="module" src="/static/script.js?v=57"></script>
<div id="packages-modal" class="modal">
<div class="modal-content packages-modal-content">
<h3>Python packages (Pyodide)</h3>
<p class="packages-modal-hint">Install from PyPI using micropip inside the browser. Pure-Python wheels (or packages built for WebAssembly) usually work; manylinux-only C extensions often do not.</p>
<label class="packages-field-label" for="packages-install-input">Package names or PEP 508 specs</label>
<input type="text" id="packages-install-input" placeholder="e.g. httpx, phonenumbers" autocomplete="off" />
<div id="packages-pypi-info" class="packages-pypi-info" aria-live="polite"></div>
<div class="packages-modal-actions-row">
<button type="button" id="packages-lookup-btn" class="packages-secondary-btn">Look up on PyPI</button>
</div>
<div id="packages-modal-status" class="packages-modal-status" aria-live="polite"></div>
<pre id="packages-install-log" class="packages-install-log hidden" aria-label="Micropip installer output"></pre>
<p class="packages-saved-label">Saved in this browser (restored when the editor loads Pyodide):</p>
<ul id="packages-saved-list" class="packages-saved-list"></ul>
<div class="modal-actions">
<button type="button" id="packages-install-btn">Install</button>
<button type="button" id="packages-close-btn">Close</button>
</div>
</div>
</div>
<script type="module" src="/static/script.js?v=83"></script>
</body>
</html>

View File

@@ -91,7 +91,7 @@ function isLibPath(path) {
function isWritablePath(path) {
if (!path) return false;
const root = path.split('/')[0];
return root === 'code';
return root === 'code' || root === 'demos';
}
function splitParts(path) {
@@ -367,6 +367,7 @@ class FileSystemBackend {
}
async ensureSeed() {
await this._resolveDir(['demos'], true);
const codeDir = await this._resolveDir(['code'], true);
let mainExists = false;
for await (const [name] of codeDir.entries()) {
@@ -543,14 +544,15 @@ class FileSystemBackend {
async listAllPyFiles() {
const out = {};
let codeDir;
for (const rootSeg of ['code', 'demos']) {
let dir;
try {
codeDir = await this._resolveDir(['code']);
dir = await this._resolveDir([rootSeg]);
} catch (_e) {
return out;
continue;
}
const collected = [];
await this._readAllRecursive(['code'], codeDir, collected);
await this._readAllRecursive([rootSeg], dir, collected);
for (const { path, handle } of collected) {
if (!path.toLowerCase().endsWith('.py')) continue;
try {
@@ -559,19 +561,21 @@ class FileSystemBackend {
// Skip unreadable files.
}
}
}
return out;
}
async listAllUserFiles() {
const out = {};
let codeDir;
for (const rootSeg of ['code', 'demos']) {
let dir;
try {
codeDir = await this._resolveDir(['code']);
dir = await this._resolveDir([rootSeg]);
} catch (_e) {
return out;
continue;
}
const collected = [];
await this._readAllRecursive(['code'], codeDir, collected);
await this._readAllRecursive([rootSeg], dir, collected);
for (const { path, handle } of collected) {
try {
out[path] = await (await handle.getFile()).text();
@@ -579,6 +583,7 @@ class FileSystemBackend {
// Skip unreadable files (e.g. binaries the editor can't open).
}
}
}
return out;
}
}
@@ -700,7 +705,7 @@ export class LocalWorkspaceClient {
/* --- Lib bundle ------------------------------------------------- */
async _tryLoadStaticBundledLib() {
const names = ['machine.py', 'neopixel.py'];
const names = ['machine.py', 'neopixel.py', 'browser_fetch.py'];
const map = {};
for (const name of names) {
const r = await fetch(`/static/bundled-lib/${encodeURIComponent(name)}`, {
@@ -770,6 +775,9 @@ export class LocalWorkspaceClient {
if (!filtered.some((row) => row.name === 'code')) {
filtered.push({ name: 'code', is_directory: true, size: null });
}
if (!filtered.some((row) => row.name === 'demos')) {
filtered.push({ name: 'demos', is_directory: true, size: null });
}
if (this.libFiles && Object.keys(this.libFiles).length) {
filtered.push({ name: 'lib', is_directory: true, size: null });
}
@@ -810,7 +818,7 @@ export class LocalWorkspaceClient {
const path = normalizePath(rawPath);
if (!path) return jsonResponse(400, { detail: 'Empty path' });
if (isLibPath(path)) return jsonResponse(403, { detail: 'lib is read-only' });
if (!isWritablePath(path)) return jsonResponse(403, { detail: 'Only code/ is writable (lib is read-only)' });
if (!isWritablePath(path)) return jsonResponse(403, { detail: 'Only code/ and demos/ are writable (lib is read-only)' });
const content = body && typeof body.content === 'string' ? body.content : '';
await this.backend.writeFile(path, content);
return jsonResponse(200, { filename: basename(path) });
@@ -821,7 +829,7 @@ export class LocalWorkspaceClient {
const path = normalizePath(rawPath);
if (!path) return jsonResponse(400, { detail: 'Empty path' });
if (isLibPath(path)) return jsonResponse(403, { detail: 'lib is read-only' });
if (!isWritablePath(path)) return jsonResponse(403, { detail: 'Only code/ is writable' });
if (!isWritablePath(path)) return jsonResponse(403, { detail: 'Only code/ and demos/ are writable' });
const r = await this.backend.deleteFile(path);
if (!r.ok) {
if (r.reason === 'missing') return jsonResponse(404, { detail: 'File not found' });
@@ -836,7 +844,7 @@ export class LocalWorkspaceClient {
const path = normalizePath(rawPath);
if (!path) return jsonResponse(400, { detail: 'Empty path' });
if (isLibPath(path)) return jsonResponse(403, { detail: 'lib is read-only' });
if (!isWritablePath(path)) return jsonResponse(403, { detail: 'Only code/ is writable' });
if (!isWritablePath(path)) return jsonResponse(403, { detail: 'Only code/ and demos/ are writable' });
const r = await this.backend.createFolder(path);
if (!r.ok) {
if (r.reason === 'exists') return jsonResponse(400, { detail: 'Folder already exists' });
@@ -850,7 +858,7 @@ export class LocalWorkspaceClient {
const path = normalizePath(rawPath);
if (!path) return jsonResponse(400, { detail: 'Empty path' });
if (isLibPath(path)) return jsonResponse(403, { detail: 'lib is read-only' });
if (!isWritablePath(path)) return jsonResponse(403, { detail: 'Only code/ is writable' });
if (!isWritablePath(path)) return jsonResponse(403, { detail: 'Only code/ and demos/ are writable' });
const r = await this.backend.deleteFolder(path);
if (!r.ok) {
if (r.reason === 'missing') return jsonResponse(404, { detail: 'Folder not found' });
@@ -867,7 +875,7 @@ export class LocalWorkspaceClient {
if (!source) return jsonResponse(400, { detail: 'Missing source_path' });
if (isLibPath(source) || isLibPath(dest)) return jsonResponse(403, { detail: 'lib is read-only' });
if (!isWritablePath(source) || (dest && !isWritablePath(dest))) {
return jsonResponse(403, { detail: 'Only code/ is writable' });
return jsonResponse(403, { detail: 'Only code/ and demos/ are writable' });
}
const r = await this.backend.movePath(source, dest);
if (!r.ok) {

View File

@@ -6,6 +6,26 @@ const PYODIDE_INDEX_URL = 'https://cdn.jsdelivr.net/pyodide/v0.26.4/full/';
let pyodide = null;
let loadingPromise = null;
/**
* Install PyPI wheels into this Pyodide interpreter via micropip.
* Pure-Python wheels (or Pyodide-built packages) work; manylinux
* binary wheels that are not built for Emscripten will fail.
*/
async function micropipInstallSpecs(p, specs) {
const list = specs
.map((s) => String(s || '').trim())
.filter(Boolean);
if (!list.length) {
return;
}
p.globals.set('__micropip_specs', p.toPy(list));
await p.runPythonAsync(`
import micropip
specs = list(__micropip_specs)
await micropip.install(specs)
`);
}
async function ensurePyodide() {
if (pyodide) {
return pyodide;
@@ -20,9 +40,17 @@ async function ensurePyodide() {
batched: (txt) => self.postMessage({ type: 'io', stream: 'stderr', text: txt }),
});
await p.loadPackage('micropip');
/* Optional wheels (numpy, scipy, pandas, scikit-learn, …) are not preloaded —
first worker init stays small. Install from user code with
`await micropip.install("…", index_urls=[…])` (same host as `loadPyodide`).
HTTPS: use `pyodide.http.pyfetch` / `browser_fetch`, not `aiohttp`. */
await p.runPythonAsync(`
import micropip
await micropip.install("jedi")
try:
await micropip.install("nest-asyncio")
except Exception:
pass
`);
return p;
})();
@@ -137,13 +165,47 @@ self.onmessage = async (event) => {
self.__serial_in_capacity = 0;
}
}
await ensurePyodide();
self.postMessage({ id, type: 'init', ok: true });
const p = await ensurePyodide();
const rawExtra =
payload && Array.isArray(payload.persistedMicropipPackages)
? payload.persistedMicropipPackages
: [];
const extra = rawExtra.map((s) => String(s || '').trim()).filter(Boolean);
let micropipRestoreError = null;
if (extra.length) {
try {
await micropipInstallSpecs(p, extra);
} catch (err) {
micropipRestoreError = err && err.message ? String(err.message) : String(err);
}
}
const initReply = { id, type: 'init', ok: true };
if (micropipRestoreError) {
initReply.micropipRestoreError = micropipRestoreError;
}
self.postMessage(initReply);
return;
}
const p = await ensurePyodide();
if (type === 'micropipInstall') {
const raw =
payload && payload.specs != null
? payload.specs
: payload && payload.spec != null
? [payload.spec]
: [];
const specs = (Array.isArray(raw) ? raw : [raw]).map((s) => String(s || '').trim()).filter(Boolean);
if (!specs.length) {
self.postMessage({ id, type: 'micropipInstall', ok: false, error: 'No package names given' });
return;
}
await micropipInstallSpecs(p, specs);
self.postMessage({ id, type: 'micropipInstall', ok: true });
return;
}
if (type === 'complete') {
const rel = String(payload.path || 'scratch.py').replace(/^\/+/, '');
const vpath = `/workspace/${rel}`;
@@ -168,12 +230,12 @@ for rel_path, body in extra.items():
os.makedirs(os.path.dirname(__cm_path), exist_ok=True)
with open(__cm_path, "w", encoding="utf-8") as fh:
fh.write(__cm_code)
for entry in ("/workspace/code", "/workspace/lib", "/workspace"):
for entry in ("/workspace/code", "/workspace/demos", "/workspace/lib", "/workspace"):
if entry not in sys.path:
sys.path.insert(0, entry)
sys.path.append(entry)
proj = jedi.Project(
"/workspace",
added_sys_path=["/workspace/code", "/workspace/lib", "/workspace"],
added_sys_path=["/workspace/code", "/workspace/demos", "/workspace/lib", "/workspace"],
)
s = jedi.Script(code=__cm_code, path=__cm_path, project=proj)
items = s.complete(line=__cm_line, column=__cm_col)
@@ -206,12 +268,12 @@ for rel_path, body in extra.items():
os.makedirs(os.path.dirname(__diag_path), exist_ok=True)
with open(__diag_path, "w", encoding="utf-8") as fh:
fh.write(__diag_code)
for entry in ("/workspace/code", "/workspace/lib", "/workspace"):
for entry in ("/workspace/code", "/workspace/demos", "/workspace/lib", "/workspace"):
if entry not in sys.path:
sys.path.insert(0, entry)
sys.path.append(entry)
proj = jedi.Project(
"/workspace",
added_sys_path=["/workspace/code", "/workspace/lib", "/workspace"],
added_sys_path=["/workspace/code", "/workspace/demos", "/workspace/lib", "/workspace"],
)
s = jedi.Script(code=__diag_code, path=__diag_path, project=proj)
errs = s.get_syntax_errors()
@@ -243,15 +305,62 @@ for rel, body in files.items():
with open(full, "w", encoding="utf-8") as fh:
fh.write(str(body))
for entry in ("/workspace/code", "/workspace/lib", "/workspace"):
for entry in ("/workspace/code", "/workspace/demos", "/workspace/lib", "/workspace"):
if entry not in sys.path:
sys.path.insert(0, entry)
sys.path.append(entry)
os.chdir("/workspace")
main = __run_main
sys.argv = [main] + list(__run_args)
# runpy.run_path is synchronous; user asyncio.run() needs an *active* loop (nest_asyncio).
# Await an async wrapper so get_running_loop() works during the script.
import asyncio
import asyncio.events
import asyncio.base_events
def _patch_set_debug(cls):
_orig = cls.set_debug
def set_debug(self, enabled):
try:
return _orig(self, enabled)
except NotImplementedError:
pass
cls.set_debug = set_debug
_patch_set_debug(asyncio.events.AbstractEventLoop)
_patch_set_debug(asyncio.base_events.BaseEventLoop)
async def __pyodide_run_user_script():
loop = asyncio.get_running_loop()
try:
import nest_asyncio
nest_asyncio.apply(loop)
except Exception:
try:
import nest_asyncio
nest_asyncio.apply()
except Exception:
pass
def _pyodide_asyncio_run(coro, *, debug=False):
task = asyncio.ensure_future(coro, loop=loop)
return loop.run_until_complete(task)
asyncio.run = _pyodide_asyncio_run
import runpy
runpy.run_path(main, run_name="__main__")
await __pyodide_run_user_script()
`);
try {
await p.runPythonAsync(`
import sys
for _s in (sys.stdout, sys.stderr):
try:
_s.flush()
except Exception:
pass
`);
} catch (_e) {
// ignore
}
self.postMessage({ id, type: 'run', ok: true });
return;
}

File diff suppressed because it is too large Load Diff

View File

@@ -113,7 +113,8 @@ button,
border-radius: 8px;
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.18);
z-index: 60;
min-width: 220px;
min-width: 240px;
max-width: min(85vw, 320px);
padding: 0.35rem;
display: flex;
flex-direction: column;
@@ -137,10 +138,67 @@ button,
background: #f7fafc;
}
button.menu-item.menu-button {
border: none;
background: transparent;
width: 100%;
text-align: left;
font: inherit;
font-family: inherit;
}
.menu-checkbox input[type="checkbox"] {
margin: 0;
}
.menu-separator {
height: 1px;
background: #e2e8f0;
margin: 0.35rem 0.2rem;
}
.menu-section-label {
padding: 0.35rem 0.7rem 0.2rem;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #94a3b8;
}
.menu-note {
padding: 0.4rem 0.7rem;
font-size: 0.78rem;
color: #94a3b8;
font-style: italic;
}
/* Buttons styled as menu items (Workspace actions, etc.). */
.menu-action {
appearance: none;
background: transparent;
border: none;
width: 100%;
text-align: left;
font: inherit;
color: #2d3748;
}
.menu-action:hover,
.menu-action:focus-visible {
background: #f1f5f9;
outline: none;
}
.menu-action-danger {
color: #b91c1c;
}
.menu-action-danger:hover,
.menu-action-danger:focus-visible {
background: #fee2e2;
}
/* Sidebar */
.sidebar {
width: 300px;
@@ -190,6 +248,63 @@ button,
padding: 0.5rem;
}
.file-tree-context-menu {
position: fixed;
z-index: 250;
min-width: 210px;
max-width: min(92vw, 300px);
background: #fff;
border: 1px solid #cbd5e0;
border-radius: 8px;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.16);
padding: 0.35rem 0;
font-size: 0.875rem;
color: #2d3748;
}
.file-tree-context-menu[hidden] {
display: none !important;
}
.file-tree-context-menu .file-tree-cm-note {
padding: 0.35rem 0.85rem 0.5rem;
font-size: 0.75rem;
color: #718096;
line-height: 1.35;
}
.file-tree-context-menu button {
display: block;
width: 100%;
text-align: left;
padding: 0.45rem 0.85rem;
border: none;
background: transparent;
cursor: pointer;
font: inherit;
color: inherit;
}
.file-tree-context-menu button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.file-tree-context-menu button:hover:not(:disabled) {
background: #edf2f7;
}
.file-tree-context-menu button.file-tree-cm-danger {
color: #c53030;
}
.file-tree-context-menu hr.file-tree-cm-sep {
height: 1px;
margin: 0.3rem 0.5rem;
background: #e2e8f0;
border: none;
}
.file-item {
padding: 0.5rem;
cursor: pointer;
@@ -277,40 +392,7 @@ button,
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
max-width: 220px;
}
.workspace-badge-exit,
.workspace-badge-action {
appearance: none;
border: 1px solid #cbd5e1;
background: transparent;
color: #475569;
border-radius: 4px;
padding: 0.05rem 0.4rem;
font-size: 0.7rem;
cursor: pointer;
line-height: 1.4;
}
.workspace-badge-exit:hover,
.workspace-badge-action:hover {
background: #e2e8f0;
}
.workspace-badge-action {
border-color: #93c5fd;
color: #1d4ed8;
}
.workspace-badge-action:hover {
background: #dbeafe;
}
.workspace-badge-note {
font-size: 0.7rem;
color: #94a3b8;
font-style: italic;
max-width: 240px;
}
#current-file {
@@ -685,6 +767,12 @@ button,
font-size: 0.85rem;
cursor: pointer;
text-align: center;
/* iOS Safari swallows taps on small buttons inside scrollable parents
* unless `touch-action: manipulation` is set (kills the 300ms double-tap
* zoom delay and the "is this a scroll start?" hesitation). */
touch-action: manipulation;
-webkit-tap-highlight-color: rgba(56, 189, 248, 0.25);
user-select: none;
}
.pin-toggle.on {
@@ -1104,6 +1192,134 @@ button,
border-color: #2c5aa0;
}
.packages-modal-content {
width: min(440px, 92vw);
max-height: 85vh;
overflow-y: auto;
margin: 6% auto;
}
.packages-modal-hint {
font-size: 0.8rem;
color: #718096;
line-height: 1.45;
margin: 0 0 0.75rem;
}
.packages-field-label {
display: block;
font-size: 0.75rem;
font-weight: 600;
color: #64748b;
margin: 0.25rem 0 0.35rem;
}
.packages-pypi-info {
min-height: 1.2rem;
font-size: 0.85rem;
color: #334155;
margin: 0.15rem 0 0.5rem;
}
.packages-modal-actions-row {
margin-bottom: 0.65rem;
}
.packages-secondary-btn {
padding: 0.4rem 0.75rem;
border: 1px solid #cbd5e0;
background: #f8fafc;
color: #334155;
border-radius: 6px;
font-size: 0.8rem;
cursor: pointer;
}
.packages-secondary-btn:hover {
background: #edf2f7;
}
.packages-modal-status {
min-height: 1.25rem;
font-size: 0.85rem;
color: #2b6cb0;
margin-bottom: 0.5rem;
}
.packages-install-log {
max-height: 12rem;
overflow: auto;
margin: 0 0 0.65rem;
padding: 0.45rem 0.5rem;
font-size: 0.72rem;
line-height: 1.35;
color: #1e293b;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
white-space: pre-wrap;
word-break: break-word;
}
.packages-saved-label {
font-size: 0.75rem;
font-weight: 600;
color: #64748b;
margin: 0 0 0.35rem;
}
.packages-saved-list {
list-style: none;
margin: 0 0 1rem;
padding: 0;
border: 1px solid #e2e8f0;
border-radius: 6px;
max-height: 10rem;
overflow-y: auto;
}
.packages-saved-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.4rem 0.55rem;
border-bottom: 1px solid #f1f5f9;
font-size: 0.85rem;
color: #2d3748;
}
.packages-saved-item:last-child {
border-bottom: none;
}
.packages-saved-item span {
word-break: break-all;
}
.packages-saved-empty {
padding: 0.55rem 0.65rem;
color: #94a3b8;
font-size: 0.85rem;
}
.packages-remove-btn {
flex-shrink: 0;
padding: 0.2rem 0.45rem;
font-size: 0.75rem;
border: 1px solid #e2e8f0;
background: white;
border-radius: 4px;
cursor: pointer;
color: #4a5568;
}
.packages-remove-btn:hover {
background: #fff5f5;
border-color: #feb2b2;
color: #c53030;
}
/* Desktop: hide the file browser when collapsed via the hamburger toggle. */
@media (min-width: 769px) {
.sidebar.is-collapsed {
@@ -1172,6 +1388,12 @@ button,
.main-content {
width: 100%;
min-height: 0;
/* Let the right column scroll as a whole on phones. With ADC + Pins +
* Serial all open at once, their combined intrinsic heights exceed the
* viewport; without this the console (last child) gets clipped. */
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
.editor-header {
@@ -1244,9 +1466,31 @@ button,
}
.editor-container {
/* Pin the editor at a usable height; the rest of the column scrolls. */
flex: 0 0 auto;
height: 50vh;
min-height: 42vh;
}
/* When any of the live simulator panels (Pins / ADC / Serial / LED grid)
* is visible, give the editor less screen real estate so the panels and
* console don't get pushed below the fold. The whole column is still
* scrollable, this just biases space toward the panels. */
.main-content:has(.pin-panel:not(.hidden), .adc-panel:not(.hidden), .serial-panel:not(.hidden), .led-sim-panel:not(.hidden)) .editor-container {
height: 32vh;
min-height: 26vh;
}
/* Stop the editor / panels / console from being squashed by flexbox —
* each keeps its natural (or capped) height and the column scrolls. */
.led-sim-panel,
.pin-panel,
.adc-panel,
.serial-panel,
.console-container {
flex: 0 0 auto;
}
.cm-editor {
font-size: 14px; /* >=16px would prevent iOS zoom but feels too large here; CM is contenteditable so no zoom anyway. */
}
@@ -1306,6 +1550,21 @@ button,
-webkit-tap-highlight-color: transparent;
}
/* Cap each panel's scroll region tighter on mobile so two or three of them
* stacked don't push the console far below the fold. The whole column is
* already scrollable; this just keeps individual panels compact. */
.pin-rows {
max-height: 22vh;
}
.adc-sliders {
max-height: 22vh;
}
.serial-output {
max-height: 22vh;
}
.adc-slider {
min-height: 2.75rem;
padding: 0.75rem 0;

View File

@@ -245,6 +245,6 @@
</section>
</div>
</main>
<script type="module" src="/static/tutorial.js?v=2"></script>
<script type="module" src="/static/tutorial.js?v=4"></script>
</body>
</html>

View File

@@ -289,7 +289,7 @@ class TutorialApp {
ensurePyWorker() {
if (!this.pyWorker) {
this.pyWorker = new Worker("/static/pyodide-worker.js?v=4");
this.pyWorker = new Worker("/static/pyodide-worker.js?v=24");
this.pyWorker.onmessage = (event) => this.onWorkerMessage(event);
}
return this.pyWorker;

View File

@@ -92,14 +92,16 @@ def test_new_user_workspace_has_default_main_py(tmp_path, monkeypatch):
assert reg.status_code == 200
assert reg.json()["username"] == "alice"
uid = reg.json()["id"]
code_root = tmp_path / "users" / f"alice-{uid}" / "code"
user_root = tmp_path / "users" / f"alice-{uid}"
code_root = user_root / "code"
demos_dir = user_root / "demos"
on_disk = code_root / "main.py"
assert on_disk.is_file()
assert on_disk.read_text(encoding="utf-8") == 'print("Hello, World!")\n'
canonical = ("pattern_rainbow_demo.py", "pattern_twinkle_demo.py", "pattern_chase_demo.py")
for fname in canonical:
cp = code_root / fname
assert cp.is_file(), f"missing bundled copy {fname} (workspace/code must ship with app)"
cp = demos_dir / fname
assert cp.is_file(), f"missing bundled copy demos/{fname} (workspace must ship with app)"
text = cp.read_text(encoding="utf-8")
assert len(text.strip()) > 20
assert "from led_patterns" not in text
@@ -110,7 +112,7 @@ def test_new_user_workspace_has_default_main_py(tmp_path, monkeypatch):
assert fetched.status_code == 200
assert fetched.json()["filename"] == "main.py"
assert 'Hello, World!' in fetched.json()["content"]
chase = client.get("/api/file/code/pattern_chase_demo.py")
chase = client.get("/api/file/demos/pattern_chase_demo.py")
assert chase.status_code == 200
assert "knight_rider_scanner_frame" in chase.json()["content"]
@@ -311,14 +313,6 @@ def test_lib_is_shared_read_only_across_users(tmp_path, monkeypatch):
shared_lib = tmp_path / "lib"
shared_lib.mkdir(parents=True, exist_ok=True)
(shared_lib / "shared.py").write_text("VALUE = 42\n", encoding="utf-8")
# Mirror canonical demo files so `_seed_canonical_demos_into_code` still works.
real_demos = config.PROJECT_ROOT / "workspace" / "code"
fake_demos = tmp_path / "workspace" / "code"
fake_demos.mkdir(parents=True, exist_ok=True)
for fname in ("pattern_rainbow_demo.py", "pattern_twinkle_demo.py", "pattern_chase_demo.py"):
src = real_demos / fname
if src.is_file():
(fake_demos / fname).write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
monkeypatch.setattr(config, "PROJECT_ROOT", tmp_path)

View File

@@ -5,7 +5,7 @@ from pathlib import Path
def _load_patterns_module():
repo_root = Path(__file__).resolve().parents[1]
# Canonical home for shipped demos — `workspace/` is gitignored.
module_path = repo_root / "src" / "static" / "bundled-demos" / "led_patterns.py"
module_path = repo_root / "src" / "static" / "bundled-demos" / "demo" / "led_patterns.py"
spec = importlib.util.spec_from_file_location("led_patterns", module_path)
module = importlib.util.module_from_spec(spec)
assert spec is not None and spec.loader is not None