Compare commits
5 Commits
d355174f5a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 551c3d1efc | |||
| 8c45097ec5 | |||
| d38f819c49 | |||
| 98fa4260d4 | |||
| e3400120d3 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
38
lib/browser_fetch.py
Normal 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)
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
32
src/static/bundled-demos/demo/aiohttp_fetch_demo.py
Normal file
32
src/static/bundled-demos/demo/aiohttp_fetch_demo.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Async HTTPS GET of Pyodide’s 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 browser’s ``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())
|
||||
27
src/static/bundled-demos/demo/async_fetch_demo.py
Normal file
27
src/static/bundled-demos/demo/async_fetch_demo.py
Normal 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())
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
38
src/static/bundled-lib/browser_fetch.py
Normal file
38
src/static/bundled-lib/browser_fetch.py
Normal 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)
|
||||
@@ -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=36">
|
||||
<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,9 @@
|
||||
<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>
|
||||
@@ -121,6 +126,27 @@
|
||||
</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>
|
||||
</html>
|
||||
|
||||
@@ -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,20 +544,22 @@ class FileSystemBackend {
|
||||
|
||||
async listAllPyFiles() {
|
||||
const out = {};
|
||||
let codeDir;
|
||||
try {
|
||||
codeDir = await this._resolveDir(['code']);
|
||||
} catch (_e) {
|
||||
return out;
|
||||
}
|
||||
const collected = [];
|
||||
await this._readAllRecursive(['code'], codeDir, collected);
|
||||
for (const { path, handle } of collected) {
|
||||
if (!path.toLowerCase().endsWith('.py')) continue;
|
||||
for (const rootSeg of ['code', 'demos']) {
|
||||
let dir;
|
||||
try {
|
||||
out[path] = await (await handle.getFile()).text();
|
||||
dir = await this._resolveDir([rootSeg]);
|
||||
} catch (_e) {
|
||||
// Skip unreadable files.
|
||||
continue;
|
||||
}
|
||||
const collected = [];
|
||||
await this._readAllRecursive([rootSeg], dir, collected);
|
||||
for (const { path, handle } of collected) {
|
||||
if (!path.toLowerCase().endsWith('.py')) continue;
|
||||
try {
|
||||
out[path] = await (await handle.getFile()).text();
|
||||
} catch (_e) {
|
||||
// Skip unreadable files.
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
@@ -564,19 +567,21 @@ class FileSystemBackend {
|
||||
|
||||
async listAllUserFiles() {
|
||||
const out = {};
|
||||
let codeDir;
|
||||
try {
|
||||
codeDir = await this._resolveDir(['code']);
|
||||
} catch (_e) {
|
||||
return out;
|
||||
}
|
||||
const collected = [];
|
||||
await this._readAllRecursive(['code'], codeDir, collected);
|
||||
for (const { path, handle } of collected) {
|
||||
for (const rootSeg of ['code', 'demos']) {
|
||||
let dir;
|
||||
try {
|
||||
out[path] = await (await handle.getFile()).text();
|
||||
dir = await this._resolveDir([rootSeg]);
|
||||
} catch (_e) {
|
||||
// Skip unreadable files (e.g. binaries the editor can't open).
|
||||
continue;
|
||||
}
|
||||
const collected = [];
|
||||
await this._readAllRecursive([rootSeg], dir, collected);
|
||||
for (const { path, handle } of collected) {
|
||||
try {
|
||||
out[path] = await (await handle.getFile()).text();
|
||||
} catch (_e) {
|
||||
// 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) {
|
||||
|
||||
@@ -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(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 });
|
||||
return;
|
||||
}
|
||||
|
||||
1496
src/static/script.js
1496
src/static/script.js
File diff suppressed because it is too large
Load Diff
@@ -138,6 +138,15 @@ 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;
|
||||
}
|
||||
@@ -239,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;
|
||||
@@ -1126,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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user