Seed bundled demos under top-level demos/ instead of code/
Move canonical sample scripts to a sibling folder of code/ and lib/ so user projects stay separate from shipped examples. Backend seeding, writable paths, and docs follow the new layout. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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