diff --git a/.gitignore b/.gitignore index e12b5d9..9a5a693 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,6 @@ dist/ downloads/ eggs/ .eggs/ -/lib/ /lib64/ parts/ sdist/ diff --git a/Dockerfile b/Dockerfile index 4034e25..acafa03 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,7 @@ COPY Pipfile Pipfile.lock ./ RUN pipenv install --system --deploy COPY src ./src +COPY lib ./lib COPY workspace ./workspace EXPOSE 8080 diff --git a/README.md b/README.md index d693349..abfd6d7 100644 --- a/README.md +++ b/README.md @@ -93,16 +93,19 @@ The home page can store the API key in `sessionStorage` when you are not using c ## Layout - `src/` — FastAPI app and static UI (`src/static/`) -- `workspace/` — default tree: `code/` (editable), `lib/` (read-only via API) +- `lib/` — bundled MicroPython stubs (copied into `WORKSPACE_ROOT/lib` when missing; read-only via API) +- `workspace/` — default `WORKSPACE_ROOT`: `code/` samples (editable); runtime `lib/` is filled from `lib/` above ## ESP32 / NeoPixel mock -The browser runtime now includes MicroPython-style mocks in `workspace/lib`: +The browser runtime ships MicroPython-style stubs in repo `lib/` (they appear as `lib/` in the editor and are read-only via the APIs): -- `machine.Pin` +- `machine.Pin`, `machine.freq()`, `machine.unique_id()`, `machine.reset()` (no-op here) - `neopixel.NeoPixel` +- `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 `workspace/code` exactly like ESP32 examples: +Use them from scripts in `workspace/code` like typical ESP32 / MicroPython examples: ```python from machine import Pin diff --git a/lib/machine.py b/lib/machine.py new file mode 100644 index 0000000..eb0a242 --- /dev/null +++ b/lib/machine.py @@ -0,0 +1,19 @@ +"""Minimal MicroPython-style machine module mock for browser simulation.""" + + +class Pin: + IN = 0 + OUT = 1 + PULL_UP = 2 + PULL_DOWN = 3 + + def __init__(self, pin_id: int, mode: int = OUT, value: int = 0): + self.id = int(pin_id) + self.mode = int(mode) + self._value = 1 if value else 0 + + def value(self, new_value=None): + if new_value is None: + return self._value + self._value = 1 if int(new_value) else 0 + return self._value diff --git a/lib/neopixel.py b/lib/neopixel.py new file mode 100644 index 0000000..9446b38 --- /dev/null +++ b/lib/neopixel.py @@ -0,0 +1,56 @@ +"""NeoPixel mock for Pyodide/browser execution. + +Supports a useful subset of MicroPython's neopixel.NeoPixel API: +- NeoPixel(pin, n, bpp=3, timing=1) +- __setitem__, __getitem__, __len__ +- fill(color) +- write() # prints current pixel buffer snapshot +""" + +import json + + +def _normalize_color(value, bpp: int): + if not hasattr(value, "__iter__"): + raise TypeError("Color must be an iterable, e.g. (r, g, b)") + parts = [int(v) for v in value] + if len(parts) != bpp: + raise ValueError(f"Expected {bpp} color channels, got {len(parts)}") + out = [] + for channel in parts: + out.append(max(0, min(255, channel))) + return tuple(out) + + +class NeoPixel: + def __init__(self, pin, n: int, bpp: int = 3, timing: int = 1): + self.pin = pin + self.n = int(n) + self.bpp = int(bpp) + self.timing = int(timing) + self._buf = [tuple([0] * self.bpp) for _ in range(self.n)] + + def __len__(self): + return self.n + + def __getitem__(self, index): + return self._buf[int(index)] + + def __setitem__(self, index, color): + idx = int(index) + self._buf[idx] = _normalize_color(color, self.bpp) + + def fill(self, color): + c = _normalize_color(color, self.bpp) + for i in range(self.n): + self._buf[i] = c + + def write(self): + pin_id = getattr(self.pin, "id", self.pin) + payload = { + "type": "neopixel", + "pin": pin_id, + "pixels": [list(pixel) for pixel in self._buf], + "bpp": self.bpp, + } + print("[neopixel-json]" + json.dumps(payload)) diff --git a/src/editor_app/main.py b/src/editor_app/main.py index 16bae78..8d00246 100644 --- a/src/editor_app/main.py +++ b/src/editor_app/main.py @@ -14,12 +14,13 @@ from editor_app.routers.auth_routes import router as auth_router from editor_app.routers.files import router as files_router from editor_app.routers.frontend import router as frontend_router from editor_app.routers.users_admin import router as users_admin_router -from editor_app.services import accounts +from editor_app.services import accounts, user_workspace @asynccontextmanager async def lifespan(_app: FastAPI): (WORKSPACE_ROOT / "lib").mkdir(parents=True, exist_ok=True) + user_workspace.ensure_workspace_lib() engine = get_engine() Base.metadata.create_all(bind=engine) with engine.begin() as conn: diff --git a/src/editor_app/services/filesystem.py b/src/editor_app/services/filesystem.py index 1727382..183f7cb 100644 --- a/src/editor_app/services/filesystem.py +++ b/src/editor_app/services/filesystem.py @@ -15,7 +15,12 @@ def _workspace_root(workspace_root: Path | None = None) -> Path: def _shared_lib_root() -> Path: - return (config.WORKSPACE_ROOT.resolve() / LIB_DIR_NAME).resolve() + """Shared MicroPython stubs live under WORKSPACE_ROOT/lib; seed from bundle if missing (e.g. volume wiped).""" + lib = (config.WORKSPACE_ROOT.resolve() / LIB_DIR_NAME).resolve() + from editor_app.services.user_workspace import ensure_workspace_lib + + ensure_workspace_lib() + return lib def normalize_relative_path(relative_path: str) -> str: @@ -213,25 +218,24 @@ def delete_folder(folder_path: str, workspace_root: Path | None = None) -> None: def collect_python_sources(workspace_root: Path | None = None) -> dict[str, str]: - """Return all UTF-8 .py files under the workspace for browser-side Pyodide sync.""" + """Return UTF-8 `.py` under the scoped workspace plus shared stubs from `WORKSPACE_ROOT/lib/` (bundled Micropython mocks for Jedi/completion and Pyodide).""" result: dict[str, str] = {} workspace = _workspace_root(workspace_root) - if not workspace.exists(): - return result - for path in workspace.rglob("*.py"): - try: - rel = path.relative_to(workspace) - except ValueError: - continue - if any(part.startswith(".") for part in rel.parts): - continue - try: - key = str(rel).replace("\\", "/") - result[key] = path.read_text(encoding="utf-8") - except (UnicodeDecodeError, OSError): - continue + if workspace.exists(): + for path in workspace.rglob("*.py"): + try: + rel = path.relative_to(workspace) + except ValueError: + continue + if any(part.startswith(".") for part in rel.parts): + continue + try: + key = str(rel).replace("\\", "/") + result[key] = path.read_text(encoding="utf-8") + except (UnicodeDecodeError, OSError): + continue shared_lib = _shared_lib_root() - if shared_lib.exists() and shared_lib.is_dir() and shared_lib != (workspace / LIB_DIR_NAME).resolve(): + if shared_lib.is_dir(): for path in shared_lib.rglob("*.py"): try: rel = path.relative_to(shared_lib) diff --git a/src/editor_app/services/user_workspace.py b/src/editor_app/services/user_workspace.py index 3f3b6f7..fe83c8f 100644 --- a/src/editor_app/services/user_workspace.py +++ b/src/editor_app/services/user_workspace.py @@ -16,6 +16,22 @@ _CANONICAL_DEMO_FILENAMES = ( ) +def ensure_workspace_lib(workspace_root: Path | None = None) -> None: + """Copy shipped MicroPython stubs from the repo into WORKSPACE_ROOT/lib when each file is absent.""" + dst_root = (workspace_root or config.WORKSPACE_ROOT).resolve() / "lib" + dst_root.mkdir(parents=True, exist_ok=True) + src_root = config.PROJECT_ROOT.resolve() / "lib" + if not src_root.is_dir(): + return + for src in sorted(src_root.glob("*.py")): + if not src.is_file(): + continue + dst = dst_root / src.name + if dst.exists(): + continue + shutil.copy2(src, dst) + + 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}" diff --git a/src/static/index.html b/src/static/index.html index 4a5dceb..000b63a 100644 --- a/src/static/index.html +++ b/src/static/index.html @@ -87,6 +87,6 @@ - + diff --git a/src/static/script.js b/src/static/script.js index 835512c..11feca0 100644 --- a/src/static/script.js +++ b/src/static/script.js @@ -293,9 +293,6 @@ class TextEditor { return; } for (const path of session.openTabPaths) { - if (!this.isSuperuser && typeof path === 'string' && path.startsWith('lib/')) { - continue; - } await this.openFile(path); } if (session.activeTabPath && this.findTab(session.activeTabPath)) { @@ -338,7 +335,8 @@ class TextEditor { } getVisibleTopLevelNames() { - return this.isSuperuser ? new Set(['code', 'lib']) : new Set(['code']); + /* lib is shared read-only for everyone; browsing is allowed, saves are blocked in API/UI. */ + return new Set(['code', 'lib']); } getDefaultEditableRoot() { @@ -352,10 +350,7 @@ class TextEditor { this.selectedIsDirectory = true; this.expandedDirs.add('code'); await this.loadDirectory('code', { suppressError: true }); - if (this.isSuperuser) { - await this.ensureFolderExists('lib'); - await this.loadDirectory('lib', { suppressError: true }); - } + await this.loadDirectory('lib', { suppressError: true }); this.renderFileTree(); } diff --git a/tests/test_api.py b/tests/test_api.py index b3f9a0a..030d6a6 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,4 +1,5 @@ import importlib +import shutil from pathlib import Path import pytest @@ -19,6 +20,24 @@ def test_editor_route_serves_editor_html(client): assert "Python Editor" in response.text +def test_workspace_lib_seeded_from_bundle_on_startup(client, tmp_path): + """Populate WORKSPACE_ROOT/lib with shipped stubs when absent (empty volume / fresh tmp).""" + lib_dir = tmp_path / "lib" + assert (lib_dir / "machine.py").is_file() + assert (lib_dir / "neopixel.py").is_file() + assert len((lib_dir / "machine.py").read_text(encoding="utf-8")) > 0 + + +def test_workspace_lib_repops_when_removed_after_startup(client, tmp_path): + """Touching lib via the API restores bundled stubs after the workspace lib dir is wiped.""" + lib_dir = tmp_path / "lib" + assert (lib_dir / "machine.py").is_file() + shutil.rmtree(lib_dir) + + resp = client.get("/api/file/lib/machine.py") + assert resp.status_code == 200 + assert (lib_dir / "machine.py").is_file() + def test_list_files_hides_dotfiles_and_reports_sizes(client, tmp_path): (tmp_path / ".hidden.txt").write_text("secret", encoding="utf-8") (tmp_path / "visible.txt").write_text("hello", encoding="utf-8") diff --git a/tests/test_internal.py b/tests/test_internal.py index 71eabe1..6299d00 100644 --- a/tests/test_internal.py +++ b/tests/test_internal.py @@ -43,3 +43,4 @@ def test_collect_python_sources_skips_hidden_and_binary(tmp_path): out = filesystem.collect_python_sources() assert out["code/ok.py"] == "a = 1\n" assert "bad.py" not in out + assert "lib/machine.py" in out, "shared lib stubs should always be merged for Jedi / Pyodide" diff --git a/workspace/lib/helpers.py b/workspace/lib/helpers.py deleted file mode 100644 index 1fd557f..0000000 --- a/workspace/lib/helpers.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Shared helpers (read-only on server; copied into Pyodide when you run).""" - - -def greet(name: str) -> str: - return f"Hello, {name}!" diff --git a/workspace/lib/led_patterns.py b/workspace/lib/led_patterns.py deleted file mode 100644 index b677841..0000000 --- a/workspace/lib/led_patterns.py +++ /dev/null @@ -1,144 +0,0 @@ -"""Compatibility pattern helpers for NeoPixel demos. - -This file mirrors `workspace/code/led_patterns.py` so imports like -`from led_patterns import ...` work even in older worker sessions that only -include `/workspace/lib` in `sys.path`. -""" - -from __future__ import annotations - -import random - - -Color = tuple[int, int, int] - - -def _clamp(channel: int) -> int: - return max(0, min(255, int(channel))) - - -def wheel(pos: int) -> Color: - pos = 255 - (pos & 255) - if pos < 85: - return (_clamp(255 - pos * 3), 0, _clamp(pos * 3)) - if pos < 170: - pos -= 85 - return (0, _clamp(pos * 3), _clamp(255 - pos * 3)) - pos -= 170 - return (_clamp(pos * 3), _clamp(255 - pos * 3), 0) - - -def rainbow_frame(led_count: int, frame: int, step: int = 4) -> list[Color]: - if led_count <= 0: - return [] - return [wheel((i * 256 // led_count + frame * step) & 255) for i in range(led_count)] - - -def chase_frame( - led_count: int, - frame: int, - color: Color = (255, 120, 0), - tail: Color = (16, 0, 0), -) -> list[Color]: - if led_count <= 0: - return [] - out: list[Color] = [(0, 0, 0) for _ in range(led_count)] - head = frame % led_count - trail = (head - 1) % led_count - out[trail] = tuple(_clamp(v) for v in tail) # type: ignore[assignment] - out[head] = tuple(_clamp(v) for v in color) # type: ignore[assignment] - return out - - -def _bounce_head_index(led_count: int, frame: int) -> int: - """Map frame to a triangular index sweep 0..N-1..0 (Ping-Pong position).""" - if led_count <= 1: - return 0 - span = led_count - 1 - cycle = span * 2 - if cycle <= 0: - return 0 - t = frame % cycle - return t if t <= span else 2 * span - t - - -def _bounce_phase_tail_direction(led_count: int, frame: int) -> int: - """Extend tail opposite motion: -1 fades toward lower indices, +1 toward higher.""" - if led_count <= 1: - return -1 - span = led_count - 1 - cycle = span * 2 - if cycle <= 0: - return -1 - t = frame % cycle - if t <= span: - return -1 - return 1 - - -def knight_rider_scanner_frame( - led_count: int, - frame: int, - head_color: Color = (220, 0, 28), - tail_len: int = 8, - falloff_gamma: float = 2.6, -) -> list[Color]: - """KITT-style bouncing scanner: saturated head with exponential tail fading to off.""" - if led_count <= 0: - return [] - out: list[Color] = [(0, 0, 0) for _ in range(led_count)] - tl = max(1, tail_len) - head = _bounce_head_index(led_count, frame) - direc = _bounce_phase_tail_direction(led_count, frame) - gamma = max(1.05, falloff_gamma) - for rk in reversed(range(tl)): - idx = head + direc * rk - if idx < 0 or idx >= led_count: - continue - w = max(0.0, float(tl - rk) / float(tl)) - strength = w**gamma - out[idx] = tuple(_clamp(int(head_color[ch] * strength)) for ch in range(3)) - return out - - -def scanner_bounce_frame( - led_count: int, - frame: int, - head_color: Color = (0, 220, 255), - tail_color: Color = (0, 40, 90), - tail_len: int = 5, -) -> list[Color]: - """Ping-pong scanner: head reverses at both ends with a directional fading tail.""" - if led_count <= 0: - return [] - out: list[Color] = [(0, 0, 0) for _ in range(led_count)] - tl = max(1, tail_len) - for rk in reversed(range(tl)): - past = frame - rk - if past < 0: - continue - idx = _bounce_head_index(led_count, past) - strength = max(0.0, float(tl - rk) / float(tl)) - if rk == 0: - out[idx] = tuple(_clamp(int(c)) for c in head_color) - else: - out[idx] = tuple(_clamp(int(tail_color[i] * strength)) for i in range(3)) - return out - - -def twinkle_frame( - led_count: int, - frame: int, - base: Color = (0, 0, 8), - sparkle: Color = (255, 255, 180), - sparkles: int = 3, - seed: int = 1337, -) -> list[Color]: - if led_count <= 0: - return [] - out: list[Color] = [tuple(_clamp(v) for v in base) for _ in range(led_count)] # type: ignore[list-item] - rng = random.Random(seed + frame) - for _ in range(min(max(0, sparkles), led_count)): - idx = rng.randrange(led_count) - out[idx] = tuple(_clamp(v) for v in sparkle) # type: ignore[assignment] - return out