`workspace/` is runtime state (per-user folders, no-auth dev's `code/`) and shouldn't be in git. The same files were previously committed under both `workspace/code/` and `src/static/bundled-demos/`, which forced a Docker `diff -q` sync check and leaked user-scoped paths into version control. - /workspace/ added to .gitignore; all previously tracked files removed via `git rm --cached`. - src/static/bundled-demos/ becomes the single source of truth: panel16 demos, led_tutorial, led_patterns, neopixel demos, and main.py move here alongside the existing canonical demos. - New BUNDLED_DEMOS_DIR config; user_workspace seeders read from it. - main.py lifespan seeds WORKSPACE_ROOT/code/ on startup so a fresh clone running `pipenv run dev` still gets the full sample set (existing files never overwritten — user edits survive restarts). - Dockerfile drops `COPY workspace` and the diff sanity check. - README/LED_TUTORIAL repointed at the new canonical paths. - test_led_patterns loads led_patterns.py from bundled-demos. - test_api uses mkdir(exist_ok=True) for `code/` (startup pre-creates). Co-authored-by: Cursor <cursoragent@cursor.com>
113 lines
4.1 KiB
Python
113 lines
4.1 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
from editor_app import config
|
|
|
|
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.
|
|
_CANONICAL_DEMO_FILENAMES = (
|
|
"pattern_rainbow_demo.py",
|
|
"pattern_twinkle_demo.py",
|
|
"pattern_chase_demo.py",
|
|
"adc_slider_demo.py",
|
|
"pin_demo.py",
|
|
"serial_demo.py",
|
|
)
|
|
|
|
|
|
def safe_workspace_leaf(username: str, user_id: int) -> str:
|
|
base = re.sub(r"[^a-zA-Z0-9._-]+", "-", username.strip()).strip("-").lower() or "user"
|
|
return f"{base}-{user_id}"
|
|
|
|
|
|
def user_workspace_root(user_id: int, username: str, workspace_root: Path | None = None) -> Path:
|
|
root = (workspace_root or config.WORKSPACE_ROOT).resolve()
|
|
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.
|
|
|
|
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).
|
|
"""
|
|
src_root = config.BUNDLED_DEMOS_DIR.resolve()
|
|
for filename in _CANONICAL_DEMO_FILENAMES:
|
|
dst = code_dir / filename
|
|
if dst.exists():
|
|
continue
|
|
src = src_root / filename
|
|
if src.is_file():
|
|
dst.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
|
|
|
|
|
|
def ensure_default_code_main(user_root: Path) -> None:
|
|
"""Ensure code/ has main.py and the canonical NeoPixel demos.
|
|
|
|
Demos are sourced from `BUNDLED_DEMOS_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."""
|
|
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")
|
|
_seed_canonical_demos_into_code(code_dir)
|
|
|
|
|
|
def seed_all_bundled_demos(user_root: Path) -> None:
|
|
"""Copy *every* file in `BUNDLED_DEMOS_DIR` into `<user_root>/code/`.
|
|
|
|
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.
|
|
"""
|
|
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()
|
|
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():
|
|
continue
|
|
try:
|
|
dst.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
|
|
except (UnicodeDecodeError, OSError):
|
|
continue
|
|
|
|
|
|
def rename_user_workspace_leaf(
|
|
user_id: int, old_username: str, new_username: str, workspace_root: Path | None = None
|
|
) -> None:
|
|
"""Rename per-user workspace directory when login name changes."""
|
|
root = (workspace_root or config.WORKSPACE_ROOT).resolve()
|
|
users_dir = root / "users"
|
|
src = users_dir / safe_workspace_leaf(old_username, user_id)
|
|
dst = users_dir / safe_workspace_leaf(new_username, user_id)
|
|
if src.resolve() == dst.resolve():
|
|
return
|
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
if dst.exists():
|
|
raise ValueError("Workspace folder for new username already exists; pick another username.")
|
|
if src.exists():
|
|
shutil.move(str(src), str(dst))
|
|
else:
|
|
ensure_default_code_main(dst)
|