Compare commits

..

5 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
36 changed files with 1979 additions and 208 deletions

2
.gitignore vendored
View File

@@ -178,7 +178,7 @@ cython_debug/
# Editor runtime state — `workspace/` holds user files (auth-mode per-user # Editor runtime state — `workspace/` holds user files (auth-mode per-user
# folders, no-auth dev's `code/`); the canonical demo source lives under # 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. # on startup. Nothing under `workspace/` should ever be committed.
/workspace/ /workspace/
src/static/.reload-token src/static/.reload-token

View File

@@ -5,7 +5,7 @@ This tutorial is for the browser editor's ESP32-style mocks:
- `machine.Pin` - `machine.Pin`
- `neopixel.NeoPixel` - `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 ## 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` - `utime` — `ticks_ms`, `ticks_diff`, `ticks_add`, `sleep_ms`, `sleep_us`, `sleep`
- `micropython.const` — no-op helper for ported constant declarations - `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 ```python
from machine import Pin from machine import Pin
@@ -132,7 +132,7 @@ Simulator modes:
- rows zig-zag left/right. - rows zig-zag left/right.
- The 16x16 popup closes automatically on **Stop** or when script execution finishes. - 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.md` - step-by-step NeoPixel tutorial
- `led_tutorial.py` - runnable guided LED example - `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_bounce.py` - 16x16 bouncing pixel with trail
- `panel16_matrix_rain.py` - 16x16 matrix rain effect - `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 ## 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" _default_workspace = PROJECT_ROOT / "workspace"
WORKSPACE_ROOT = Path(os.environ.get("WORKSPACE_ROOT", str(_default_workspace))).resolve() 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 # Canonical demo bundle root (`manifest.json` lives here). Sample `.py`
# editor's "Reset demos" button and per-user account seeding. They ship with # sources live under `demo/` (same idea as `bundled-lib/` for shared modules).
# the static bundle (`/static/bundled-demos/...`) so a static-only host # They ship with the static bundle (`/static/bundled-demos/...`) so a
# also exposes them. `workspace/` is intentionally NOT used for canonical # static-only host also exposes them. `workspace/` is intentionally NOT used
# data — it is treated as runtime/user state and is gitignored. # for canonical data — it is treated as runtime/user state and is gitignored.
BUNDLED_DEMOS_DIR = STATIC_DIR / "bundled-demos" 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 @asynccontextmanager
async def lifespan(_app: FastAPI): async def lifespan(_app: FastAPI):
# `workspace/` is gitignored runtime state. On a fresh clone it doesn't # `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 # exist, and in no-auth dev mode the file tree would otherwise be empty
# be empty — seed every bundled demo so `pipenv run dev` after `git # — seed every bundled demo under top-level `demos/` (next to `code/`)
# clone` Just Works without needing user accounts. Files already in # so `pipenv run dev` after `git clone` Just Works without needing user
# `code/` are left alone (user edits are preserved across restarts). # accounts. Existing files are left alone (user edits are preserved).
WORKSPACE_ROOT.mkdir(parents=True, exist_ok=True) WORKSPACE_ROOT.mkdir(parents=True, exist_ok=True)
user_workspace.seed_all_bundled_demos(WORKSPACE_ROOT) 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 from editor_app.models import FileInfo
LIB_DIR_NAME = "lib" LIB_DIR_NAME = "lib"
WRITABLE_ROOTS = {"code"} WRITABLE_ROOTS = {"code", "demos"}
def _workspace_root(workspace_root: Path | None = None) -> Path: 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": if len(parts) >= 2 and parts[0] == "code":
while len(parts) >= 2 and parts[0] == parts[1] == "code": while len(parts) >= 2 and parts[0] == parts[1] == "code":
parts.pop(1) 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) 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): if not _is_writable_path(target_path, workspace_root):
raise HTTPException( raise HTTPException(
status_code=403, 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 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' DEFAULT_MAIN_PY = 'print("Hello, World!")\n'
# Self-contained demos copied from shipped `workspace/code/` (stdlib + machine/neopixel/time only). # Self-contained demos copied from static ``bundled-demos/demo/`` (stdlib + machine/neopixel/time only).
# New accounts get a copy of each one in their own `code/` folder so the # New accounts get a copy under ``<user_root>/demos/`` (same level as ``code/``).
# editor has something to show on first login. They're treated as
# starting points — users can edit/delete freely without affecting the
# shipped originals.
_CANONICAL_DEMO_FILENAMES = ( _CANONICAL_DEMO_FILENAMES = (
"pattern_rainbow_demo.py", "pattern_rainbow_demo.py",
"pattern_twinkle_demo.py", "pattern_twinkle_demo.py",
@@ -20,6 +21,8 @@ _CANONICAL_DEMO_FILENAMES = (
"adc_slider_demo.py", "adc_slider_demo.py",
"pin_demo.py", "pin_demo.py",
"serial_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) return root / "users" / safe_workspace_leaf(username, user_id)
def _seed_canonical_demos_into_code(code_dir: Path) -> None: def _seed_canonical_demos(user_root: Path) -> None:
"""Copy bundled demos into a user's `code/` if missing. """Copy bundled demos into ``demos/`` if missing.
Reads from `BUNDLED_DEMOS_DIR` (single source of truth, ships under Reads from `BUNDLED_DEMOS_CODE_DIR` (``src/static/bundled-demos/demo/``).
`src/static/bundled-demos/`), never from `workspace/`, so this works Skips if the file already exists under ``demos/``, legacy ``code/<name>.py``,
even when `workspace/` is empty (gitignored runtime directory). 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: for filename in _CANONICAL_DEMO_FILENAMES:
dst = code_dir / filename dst = demos_dir / filename
if dst.exists(): legacy_flat = code_dir / filename
legacy_nested = code_dir / "demos" / filename
if dst.exists() or legacy_flat.exists() or legacy_nested.exists():
continue continue
src = src_root / filename src = src_root / filename
if src.is_file(): 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: 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` for sample scripts). Only files listed in `_CANONICAL_DEMO_FILENAMES`
get auto-seeded — the rest are available via the editor's "Reset get auto-seeded — the rest are available via the editor's "Reset
demos" button or a manual copy.""" 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" main_py = code_dir / "main.py"
if not main_py.exists(): if not main_py.exists():
main_py.write_text(DEFAULT_MAIN_PY, encoding="utf-8") 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: 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 Used at app startup to populate a fresh workspace with the full sample
full sample set so a no-auth dev install (`pipenv run dev` after set so a no-auth dev install (`pipenv run dev` after `git clone`) has
`git clone`) has something to play with. Existing files are not something to play with. Existing files are not overwritten — user edits
overwritten — user edits are preserved. are preserved.
""" """
code_dir = user_root / "code" code_dir = user_root / "code"
code_dir.mkdir(parents=True, exist_ok=True) code_dir.mkdir(parents=True, exist_ok=True)
main_py = code_dir / "main.py" main_py = code_dir / "main.py"
if not main_py.exists(): if not main_py.exists():
main_py.write_text(DEFAULT_MAIN_PY, encoding="utf-8") 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(): if not src_root.is_dir():
return return
for src in sorted(src_root.iterdir()): for src in sorted(src_root.iterdir()):
if not src.is_file() or not src.name.endswith(".py"): if not src.is_file() or not src.name.endswith(".py"):
continue continue
dst = code_dir / src.name dst = demos_dir / src.name
if dst.exists(): 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 continue
try: try:
dst.write_text(src.read_text(encoding="utf-8"), encoding="utf-8") 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

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

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"> <meta name="theme-color" content="#2d3748">
<title>LED Editor</title> <title>LED Editor</title>
<link rel="icon" href="data:,"> <link rel="icon" href="data:,">
<link rel="stylesheet" href="/static/styles.css?v=36"> <link rel="stylesheet" href="/static/styles.css?v=41">
</head> </head>
<body> <body>
<div class="container"> <div class="container">
@@ -26,6 +26,8 @@
</div> </div>
</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="main-content">
<div class="editor-header"> <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> <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,9 @@
<input type="checkbox" id="panel-16x16-checkbox" /> <input type="checkbox" id="panel-16x16-checkbox" />
16×16 panel 16×16 panel
</label> </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-separator" role="separator"></div>
<div class="menu-section-label" id="workspace-menu-label">Workspace</div> <div class="menu-section-label" id="workspace-menu-label">Workspace</div>
<div id="workspace-menu-actions" role="group"></div> <div id="workspace-menu-actions" role="group"></div>
@@ -121,6 +126,27 @@
</div> </div>
</div> </div>
<script type="module" src="/static/script.js?v=59"></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> </body>
</html> </html>

View File

@@ -91,7 +91,7 @@ function isLibPath(path) {
function isWritablePath(path) { function isWritablePath(path) {
if (!path) return false; if (!path) return false;
const root = path.split('/')[0]; const root = path.split('/')[0];
return root === 'code'; return root === 'code' || root === 'demos';
} }
function splitParts(path) { function splitParts(path) {
@@ -367,6 +367,7 @@ class FileSystemBackend {
} }
async ensureSeed() { async ensureSeed() {
await this._resolveDir(['demos'], true);
const codeDir = await this._resolveDir(['code'], true); const codeDir = await this._resolveDir(['code'], true);
let mainExists = false; let mainExists = false;
for await (const [name] of codeDir.entries()) { for await (const [name] of codeDir.entries()) {
@@ -543,14 +544,15 @@ class FileSystemBackend {
async listAllPyFiles() { async listAllPyFiles() {
const out = {}; const out = {};
let codeDir; for (const rootSeg of ['code', 'demos']) {
let dir;
try { try {
codeDir = await this._resolveDir(['code']); dir = await this._resolveDir([rootSeg]);
} catch (_e) { } catch (_e) {
return out; continue;
} }
const collected = []; const collected = [];
await this._readAllRecursive(['code'], codeDir, collected); await this._readAllRecursive([rootSeg], dir, collected);
for (const { path, handle } of collected) { for (const { path, handle } of collected) {
if (!path.toLowerCase().endsWith('.py')) continue; if (!path.toLowerCase().endsWith('.py')) continue;
try { try {
@@ -559,19 +561,21 @@ class FileSystemBackend {
// Skip unreadable files. // Skip unreadable files.
} }
} }
}
return out; return out;
} }
async listAllUserFiles() { async listAllUserFiles() {
const out = {}; const out = {};
let codeDir; for (const rootSeg of ['code', 'demos']) {
let dir;
try { try {
codeDir = await this._resolveDir(['code']); dir = await this._resolveDir([rootSeg]);
} catch (_e) { } catch (_e) {
return out; continue;
} }
const collected = []; const collected = [];
await this._readAllRecursive(['code'], codeDir, collected); await this._readAllRecursive([rootSeg], dir, collected);
for (const { path, handle } of collected) { for (const { path, handle } of collected) {
try { try {
out[path] = await (await handle.getFile()).text(); out[path] = await (await handle.getFile()).text();
@@ -579,6 +583,7 @@ class FileSystemBackend {
// Skip unreadable files (e.g. binaries the editor can't open). // Skip unreadable files (e.g. binaries the editor can't open).
} }
} }
}
return out; return out;
} }
} }
@@ -700,7 +705,7 @@ export class LocalWorkspaceClient {
/* --- Lib bundle ------------------------------------------------- */ /* --- Lib bundle ------------------------------------------------- */
async _tryLoadStaticBundledLib() { async _tryLoadStaticBundledLib() {
const names = ['machine.py', 'neopixel.py']; const names = ['machine.py', 'neopixel.py', 'browser_fetch.py'];
const map = {}; const map = {};
for (const name of names) { for (const name of names) {
const r = await fetch(`/static/bundled-lib/${encodeURIComponent(name)}`, { const r = await fetch(`/static/bundled-lib/${encodeURIComponent(name)}`, {
@@ -770,6 +775,9 @@ export class LocalWorkspaceClient {
if (!filtered.some((row) => row.name === 'code')) { if (!filtered.some((row) => row.name === 'code')) {
filtered.push({ name: 'code', is_directory: true, size: null }); 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) { if (this.libFiles && Object.keys(this.libFiles).length) {
filtered.push({ name: 'lib', is_directory: true, size: null }); filtered.push({ name: 'lib', is_directory: true, size: null });
} }
@@ -810,7 +818,7 @@ export class LocalWorkspaceClient {
const path = normalizePath(rawPath); const path = normalizePath(rawPath);
if (!path) return jsonResponse(400, { detail: 'Empty path' }); if (!path) return jsonResponse(400, { detail: 'Empty path' });
if (isLibPath(path)) return jsonResponse(403, { detail: 'lib is read-only' }); 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 : ''; const content = body && typeof body.content === 'string' ? body.content : '';
await this.backend.writeFile(path, content); await this.backend.writeFile(path, content);
return jsonResponse(200, { filename: basename(path) }); return jsonResponse(200, { filename: basename(path) });
@@ -821,7 +829,7 @@ export class LocalWorkspaceClient {
const path = normalizePath(rawPath); const path = normalizePath(rawPath);
if (!path) return jsonResponse(400, { detail: 'Empty path' }); if (!path) return jsonResponse(400, { detail: 'Empty path' });
if (isLibPath(path)) return jsonResponse(403, { detail: 'lib is read-only' }); 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); const r = await this.backend.deleteFile(path);
if (!r.ok) { if (!r.ok) {
if (r.reason === 'missing') return jsonResponse(404, { detail: 'File not found' }); if (r.reason === 'missing') return jsonResponse(404, { detail: 'File not found' });
@@ -836,7 +844,7 @@ export class LocalWorkspaceClient {
const path = normalizePath(rawPath); const path = normalizePath(rawPath);
if (!path) return jsonResponse(400, { detail: 'Empty path' }); if (!path) return jsonResponse(400, { detail: 'Empty path' });
if (isLibPath(path)) return jsonResponse(403, { detail: 'lib is read-only' }); 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); const r = await this.backend.createFolder(path);
if (!r.ok) { if (!r.ok) {
if (r.reason === 'exists') return jsonResponse(400, { detail: 'Folder already exists' }); if (r.reason === 'exists') return jsonResponse(400, { detail: 'Folder already exists' });
@@ -850,7 +858,7 @@ export class LocalWorkspaceClient {
const path = normalizePath(rawPath); const path = normalizePath(rawPath);
if (!path) return jsonResponse(400, { detail: 'Empty path' }); if (!path) return jsonResponse(400, { detail: 'Empty path' });
if (isLibPath(path)) return jsonResponse(403, { detail: 'lib is read-only' }); 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); const r = await this.backend.deleteFolder(path);
if (!r.ok) { if (!r.ok) {
if (r.reason === 'missing') return jsonResponse(404, { detail: 'Folder not found' }); 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 (!source) return jsonResponse(400, { detail: 'Missing source_path' });
if (isLibPath(source) || isLibPath(dest)) return jsonResponse(403, { detail: 'lib is read-only' }); if (isLibPath(source) || isLibPath(dest)) return jsonResponse(403, { detail: 'lib is read-only' });
if (!isWritablePath(source) || (dest && !isWritablePath(dest))) { 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); const r = await this.backend.movePath(source, dest);
if (!r.ok) { 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 pyodide = null;
let loadingPromise = 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() { async function ensurePyodide() {
if (pyodide) { if (pyodide) {
return pyodide; return pyodide;
@@ -20,9 +40,17 @@ async function ensurePyodide() {
batched: (txt) => self.postMessage({ type: 'io', stream: 'stderr', text: txt }), batched: (txt) => self.postMessage({ type: 'io', stream: 'stderr', text: txt }),
}); });
await p.loadPackage('micropip'); 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(` await p.runPythonAsync(`
import micropip import micropip
await micropip.install("jedi") await micropip.install("jedi")
try:
await micropip.install("nest-asyncio")
except Exception:
pass
`); `);
return p; return p;
})(); })();
@@ -137,13 +165,47 @@ self.onmessage = async (event) => {
self.__serial_in_capacity = 0; self.__serial_in_capacity = 0;
} }
} }
await ensurePyodide(); const p = await ensurePyodide();
self.postMessage({ id, type: 'init', ok: true }); 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; return;
} }
const p = await ensurePyodide(); 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') { if (type === 'complete') {
const rel = String(payload.path || 'scratch.py').replace(/^\/+/, ''); const rel = String(payload.path || 'scratch.py').replace(/^\/+/, '');
const vpath = `/workspace/${rel}`; 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) os.makedirs(os.path.dirname(__cm_path), exist_ok=True)
with open(__cm_path, "w", encoding="utf-8") as fh: with open(__cm_path, "w", encoding="utf-8") as fh:
fh.write(__cm_code) 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: if entry not in sys.path:
sys.path.insert(0, entry) sys.path.append(entry)
proj = jedi.Project( proj = jedi.Project(
"/workspace", "/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) s = jedi.Script(code=__cm_code, path=__cm_path, project=proj)
items = s.complete(line=__cm_line, column=__cm_col) 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) os.makedirs(os.path.dirname(__diag_path), exist_ok=True)
with open(__diag_path, "w", encoding="utf-8") as fh: with open(__diag_path, "w", encoding="utf-8") as fh:
fh.write(__diag_code) 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: if entry not in sys.path:
sys.path.insert(0, entry) sys.path.append(entry)
proj = jedi.Project( proj = jedi.Project(
"/workspace", "/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) s = jedi.Script(code=__diag_code, path=__diag_path, project=proj)
errs = s.get_syntax_errors() errs = s.get_syntax_errors()
@@ -243,15 +305,62 @@ for rel, body in files.items():
with open(full, "w", encoding="utf-8") as fh: with open(full, "w", encoding="utf-8") as fh:
fh.write(str(body)) 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: if entry not in sys.path:
sys.path.insert(0, entry) sys.path.append(entry)
os.chdir("/workspace") os.chdir("/workspace")
main = __run_main main = __run_main
sys.argv = [main] + list(__run_args) sys.argv = [main] + list(__run_args)
runpy.run_path(main, run_name="__main__") # 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 }); self.postMessage({ id, type: 'run', ok: true });
return; return;
} }

File diff suppressed because it is too large Load Diff

View File

@@ -138,6 +138,15 @@ button,
background: #f7fafc; 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"] { .menu-checkbox input[type="checkbox"] {
margin: 0; margin: 0;
} }
@@ -239,6 +248,63 @@ button,
padding: 0.5rem; 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 { .file-item {
padding: 0.5rem; padding: 0.5rem;
cursor: pointer; cursor: pointer;
@@ -1126,6 +1192,134 @@ button,
border-color: #2c5aa0; 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. */ /* Desktop: hide the file browser when collapsed via the hamburger toggle. */
@media (min-width: 769px) { @media (min-width: 769px) {
.sidebar.is-collapsed { .sidebar.is-collapsed {

View File

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

View File

@@ -289,7 +289,7 @@ class TutorialApp {
ensurePyWorker() { ensurePyWorker() {
if (!this.pyWorker) { 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); this.pyWorker.onmessage = (event) => this.onWorkerMessage(event);
} }
return this.pyWorker; 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.status_code == 200
assert reg.json()["username"] == "alice" assert reg.json()["username"] == "alice"
uid = reg.json()["id"] 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" on_disk = code_root / "main.py"
assert on_disk.is_file() assert on_disk.is_file()
assert on_disk.read_text(encoding="utf-8") == 'print("Hello, World!")\n' 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") canonical = ("pattern_rainbow_demo.py", "pattern_twinkle_demo.py", "pattern_chase_demo.py")
for fname in canonical: for fname in canonical:
cp = code_root / fname cp = demos_dir / fname
assert cp.is_file(), f"missing bundled copy {fname} (workspace/code must ship with app)" assert cp.is_file(), f"missing bundled copy demos/{fname} (workspace must ship with app)"
text = cp.read_text(encoding="utf-8") text = cp.read_text(encoding="utf-8")
assert len(text.strip()) > 20 assert len(text.strip()) > 20
assert "from led_patterns" not in text 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.status_code == 200
assert fetched.json()["filename"] == "main.py" assert fetched.json()["filename"] == "main.py"
assert 'Hello, World!' in fetched.json()["content"] 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 chase.status_code == 200
assert "knight_rider_scanner_frame" in chase.json()["content"] 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 = tmp_path / "lib"
shared_lib.mkdir(parents=True, exist_ok=True) shared_lib.mkdir(parents=True, exist_ok=True)
(shared_lib / "shared.py").write_text("VALUE = 42\n", encoding="utf-8") (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) monkeypatch.setattr(config, "PROJECT_ROOT", tmp_path)

View File

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