Ship MicroPython stubs from repo lib/ and seed workspace lib on startup
Move machine.py and neopixel.py into a tracked /lib/ at the repo root and auto-copy them into WORKSPACE_ROOT/lib whenever files are missing, so empty volumes and fresh per-user workspaces always have the read-only stubs available to Jedi and Pyodide. Allow all users to browse lib/ in the UI (writes still gated by the API), and add tests covering initial seeding and re-population after the dir is wiped. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -15,7 +15,6 @@ dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
/lib/
|
||||
/lib64/
|
||||
parts/
|
||||
sdist/
|
||||
|
||||
@@ -12,6 +12,7 @@ COPY Pipfile Pipfile.lock ./
|
||||
RUN pipenv install --system --deploy
|
||||
|
||||
COPY src ./src
|
||||
COPY lib ./lib
|
||||
COPY workspace ./workspace
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
11
README.md
11
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
|
||||
|
||||
19
lib/machine.py
Normal file
19
lib/machine.py
Normal file
@@ -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
|
||||
56
lib/neopixel.py
Normal file
56
lib/neopixel.py
Normal file
@@ -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))
|
||||
@@ -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:
|
||||
|
||||
@@ -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,11 +218,10 @@ 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
|
||||
if workspace.exists():
|
||||
for path in workspace.rglob("*.py"):
|
||||
try:
|
||||
rel = path.relative_to(workspace)
|
||||
@@ -231,7 +235,7 @@ def collect_python_sources(workspace_root: Path | None = None) -> dict[str, str]
|
||||
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)
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -87,6 +87,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/static/script.js?v=25"></script>
|
||||
<script type="module" src="/static/script.js?v=26"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
this.renderFileTree();
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}!"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user