Compare commits
5 Commits
d355174f5a
...
551c3d1efc
| 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
|
# 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
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"
|
_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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
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": [
|
"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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
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">
|
<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>
|
||||||
|
|||||||
@@ -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,20 +544,22 @@ class FileSystemBackend {
|
|||||||
|
|
||||||
async listAllPyFiles() {
|
async listAllPyFiles() {
|
||||||
const out = {};
|
const out = {};
|
||||||
let codeDir;
|
for (const rootSeg of ['code', 'demos']) {
|
||||||
try {
|
let dir;
|
||||||
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;
|
|
||||||
try {
|
try {
|
||||||
out[path] = await (await handle.getFile()).text();
|
dir = await this._resolveDir([rootSeg]);
|
||||||
} catch (_e) {
|
} 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;
|
return out;
|
||||||
@@ -564,19 +567,21 @@ class FileSystemBackend {
|
|||||||
|
|
||||||
async listAllUserFiles() {
|
async listAllUserFiles() {
|
||||||
const out = {};
|
const out = {};
|
||||||
let codeDir;
|
for (const rootSeg of ['code', 'demos']) {
|
||||||
try {
|
let dir;
|
||||||
codeDir = await this._resolveDir(['code']);
|
|
||||||
} catch (_e) {
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
const collected = [];
|
|
||||||
await this._readAllRecursive(['code'], codeDir, collected);
|
|
||||||
for (const { path, handle } of collected) {
|
|
||||||
try {
|
try {
|
||||||
out[path] = await (await handle.getFile()).text();
|
dir = await this._resolveDir([rootSeg]);
|
||||||
} catch (_e) {
|
} 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;
|
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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user