diff --git a/.gitignore b/.gitignore index 2c34bd4..a136c70 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/LED_TUTORIAL.md b/LED_TUTORIAL.md index 8643961..3b447a0 100644 --- a/LED_TUTORIAL.md +++ b/LED_TUTORIAL.md @@ -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 diff --git a/README.md b/README.md index 40af383..969d74f 100644 --- a/README.md +++ b/README.md @@ -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/.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/.py` and re-run "Reset demos" in the editor (or restart the dev server with an empty `workspace/demos/`). ## Dev auto-reload hook diff --git a/src/editor_app/config.py b/src/editor_app/config.py index 3584b95..e51491f 100644 --- a/src/editor_app/config.py +++ b/src/editor_app/config.py @@ -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" diff --git a/src/editor_app/main.py b/src/editor_app/main.py index 065ce88..7422187 100644 --- a/src/editor_app/main.py +++ b/src/editor_app/main.py @@ -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) diff --git a/src/editor_app/services/filesystem.py b/src/editor_app/services/filesystem.py index d7bd40c..e53723e 100644 --- a/src/editor_app/services/filesystem.py +++ b/src/editor_app/services/filesystem.py @@ -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)", ) diff --git a/src/editor_app/services/user_workspace.py b/src/editor_app/services/user_workspace.py index 9e459f9..0982757 100644 --- a/src/editor_app/services/user_workspace.py +++ b/src/editor_app/services/user_workspace.py @@ -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 ``/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/.py``, + or old ``code/demos/.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 `/code/`. + """Copy *every* ``.py`` file in `BUNDLED_DEMOS_CODE_DIR` into ``/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") diff --git a/tests/test_auth.py b/tests/test_auth.py index 2b10740..4f87d70 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -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) diff --git a/tests/test_led_patterns.py b/tests/test_led_patterns.py index f3f0624..fb99691 100644 --- a/tests/test_led_patterns.py +++ b/tests/test_led_patterns.py @@ -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