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:
2026-05-10 02:59:34 +12:00
parent f7892dd31b
commit a2318f2244
14 changed files with 146 additions and 181 deletions

View File

@@ -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:

View File

@@ -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)

View File

@@ -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}"

View File

@@ -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>

View File

@@ -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();
}