Compare commits

...

14 Commits

Author SHA1 Message Date
d355174f5a Mobile UX polish: scrollable column, menu workspace section, pin tap fixes
The phone layout had three problems that compounded when running ADC /
Pin / Serial demos: the editor refused to shrink past 42vh so panels
spilled over a clipped console, the workspace badge was crammed full of
buttons, and the IN-pin toggle button was unreliable to tap.

- styles.css: on `<= 768px`, make `.main-content` scroll vertically and
  give the editor a fixed 50vh height (drops to 32vh via `:has()` when a
  simulator panel is open). All panels + console pin to `flex: 0 0 auto`
  so flexbox stops squashing them. Inner panel scroll caps tightened to
  22vh so two stacked panels don't push the console below the fold.
  `.pin-toggle` gets `touch-action: manipulation` + `user-select: none`
  to fix iOS taps inside scrollable parents.

- script.js: pin button now has a `pointerup` backup with a 300 ms
  debounce alongside `click` (Safari sometimes drops `click` on small
  buttons inside scroll containers). The `⋮` workspace menu auto-closes
  on outside `pointerdown` as well as `click`, so the open dropdown
  can't sit on top of the Pin panel and absorb taps.

- script.js / index.html / styles.css: move every Workspace action
  (Export, Import, Reset demos, plus the local-mode-only Folder…,
  Reconnect, IndexedDB swap, Exit) out of the badge and into a new
  "Workspace" section in the `⋮` menu. Badge keeps just the storage
  label. Adds `.menu-separator`, `.menu-section-label`, `.menu-note`,
  and `.menu-action` styles; removes the now-unused
  `.workspace-badge-action` / `-exit` / `-note` rules.

- bundled-demos/pin_demo.py: pin 4 is now driven exclusively by the
  IRQ handler, so it stays steady until the IN button is pressed —
  previously it auto-flashed via on/off in the loop, which made the
  IRQ effect indistinguishable from the existing animation. The IRQ
  handler also no longer prints on every press (the panel indicator
  is the feedback).

Cache busters: styles.css 32 -> 36, script.js 57 -> 59.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 07:16:45 +12:00
7ee15f8eac Stop tracking workspace/; bundled-demos/ is the canonical demo source
`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>
2026-05-10 06:55:59 +12:00
b8d62e01d9 Fix Docker build by relying on committed bundled-demos copies
The previous build step copied `workspace/code/<demo>.py` into
`src/static/bundled-demos/` at image-build time. That failed for some
build contexts where `workspace/` wasn't materialised when the RUN
ran (cp: cannot stat ... No such file or directory).

Since `src/static/bundled-demos/*.py` are version-controlled and ship
with `COPY src ./src`, the runtime image already has them. Replace the
fragile cp loop with a `diff -q` invariant that fails the build if a
canonical demo drifted between `workspace/code/` and
`src/static/bundled-demos/`, catching mismatches at build time instead
of runtime.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 06:44:53 +12:00
76129469a1 Add 'Reset demos' button to refresh canonical demo files
Existing accounts (including admin) seeded before new demos shipped
had no easy way to pull in the latest copies — the registration-time
seeder is intentionally non-destructive. The new badge action fetches
src/static/bundled-demos/manifest.json, confirms the overwrite, and
re-copies each canonical demo into code/. Open tabs of those files are
refreshed in place so the user sees the new content immediately.

src/static/bundled-demos/ ships the six canonical files plus the
manifest so this works in local mode and on a static-only host. The
Dockerfile now mirrors workspace/code/<demo>.py into bundled-demos/
during the image build, keeping the two locations in sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 06:35:03 +12:00
655f8b78fd Seed pin/ADC/serial demos into new accounts
`_CANONICAL_DEMO_FILENAMES` now also lists `pin_demo.py`,
`adc_slider_demo.py`, and `serial_demo.py` so first-time users get
working examples for every simulator. Seeding stays idempotent — the
dst-exists guard keeps re-registration / sign-in from clobbering edits.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 06:21:36 +12:00
ca0ca6fe7e Add local-mode workspace, ZIP import/export, and richer pin/ADC/serial sims
Boot:
- Editor now picks local vs server mode based on URL flag, sign-in
  state, and a stale local-mode flag. Signed-in users are no longer
  bounced to IndexedDB if they had previously clicked "Use locally".

Local mode:
- New LocalWorkspaceClient (src/static/local-workspace.js) with
  pluggable IndexedDB and File System Access backends. Picked folder
  handles persist across reloads with a Reconnect button when the
  permission lapses.
- Static-only host: scripts/serve_static_editor.py serves src/static/
  with COOP/COEP so SharedArrayBuffer-backed sims keep working.
- Bundled MicroPython stubs ship under src/static/bundled-lib/ for
  static hosting; FastAPI also exposes them at /api/public/lib-bundle.

Workspace import / export:
- Zero-dep ZIP encoder + reader (STORE + DEFLATE via
  DecompressionStream). Export/Import buttons in the workspace badge
  work in both local and server modes; imports are confined to code/.

Pin / ADC / Serial simulation:
- machine.py grows ADC, UART, expanded Pin, and PWM mocks, all driven
  by SharedArrayBuffer when cross-origin isolated and falling back to
  postMessage + [pin-out] stdout markers otherwise — pins, ADC slider,
  and serial input now keep working over plain HTTP / LAN-IP origins.
- NeoPixel pins are claimed via a [pin-claim] marker and dropped from
  the Pins panel so the data line doesn't flicker per write().
- New demos: adc_slider_demo.py, pin_demo.py, serial_demo.py.

Lib layout:
- Single source of truth at repo lib/; workspace/lib/ caching layer
  removed and the directory deleted. Filesystem service reads stubs
  directly from PROJECT_ROOT/lib.

UI:
- Home page slimmed to "Sign in" + "Use locally" with optional editor
  / manage-users links. Admin user/invite UI moved to /users.
- Workspace badge gains storage indicator, Folder…/Reconnect, Export,
  Import, and Exit controls.
- Mobile-friendly tweaks: safer-area padding, larger touch targets,
  iOS-zoom-proof serial input, file-tree highlight fix.

Tests:
- test_auth.py patches PROJECT_ROOT for the lib-shared test so the
  repo-root lib refactor stays green. test_api.py asserts the new
  "LED Editor" branding.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 06:16:02 +12:00
9f28eabd2d Rename UI to LED Editor and improve mobile editor layout
Use "LED Editor" in page titles and the home heading. On narrow
viewports, make the file tree an off-canvas drawer with backdrop,
hamburger toggle, Escape to close, and auto-close after opening a
file. Add safe-area and tap-target tweaks, cache-bust static assets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 03:16:35 +12:00
a2318f2244 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>
2026-05-10 02:59:34 +12:00
f7892dd31b Admin user editing, knight-rider demos, self-contained user seeds
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 02:23:53 +12:00
b02a182bf1 Ignore per-user workspaces and dev .reload-token
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 01:49:50 +12:00
687a8347f8 Default per-user main.py; invite-only by default
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 01:45:20 +12:00
6fc651ad72 Add tutorial route; gate lib workspace for superusers; Py worker v4
- Serve /tutorial and add tutorial.html/tutorial.js assets
- Fetch auth role; hide lib from non-superusers in tree and restored tabs
- Cache workspace Python sources briefly for Py worker
- Pyodide worker and home/index links/styling tweaks

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 01:14:51 +12:00
7d682cce8d Add admin invites and user workspace management tools.
Implement invite-token registration with optional email delivery, add admin UI actions for creating invites and opening user workspaces, and support superuser workspace override while preserving per-user code isolation with shared read-only lib.

Made-with: Cursor
2026-05-01 21:13:13 +12:00
e4c811f51d Expand browser editor runtime and LED simulation workflows.
Add Docker deployment support, richer Selenium/LED pattern tests, in-browser diagnostics, responsive UI improvements, and 16x16 panel simulation tooling to speed iteration and hardware-style prototyping.

Made-with: Cursor
2026-05-01 20:24:05 +12:00
62 changed files with 8760 additions and 388 deletions

12
.cursor/hooks.json Normal file
View File

@@ -0,0 +1,12 @@
{
"version": 1,
"hooks": {
"afterFileEdit": [
{
"command": ".cursor/hooks/dev-reload-touch.sh",
"timeout": 5,
"matcher": "Write|TabWrite"
}
]
}
}

View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
# Consume hook JSON input (not needed for this hook).
cat >/dev/null || true
mkdir -p "src/static"
date +%s%3N > "src/static/.reload-token"

14
.dockerignore Normal file
View File

@@ -0,0 +1,14 @@
.git
.gitignore
.venv
__pycache__/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.cursor/
tests/
agent-transcripts/
data/
*.pyc
*.pyo
*.pyd

View File

@@ -8,11 +8,21 @@
# --- User accounts (SQLite) ---
# AUTH_ENABLED=true # require sign-in for /api/* (except /api/auth/*)
# AUTH_REGISTER_OPEN=true # allow POST /api/auth/register
# AUTH_REGISTER_OPEN=false # allow POST /api/auth/register
# AUTH_INVITE_ONLY=true # default: require invite token; set false for open signup
# AUTH_DATABASE_PATH=./data/editor.db
# AUTH_SESSION_DAYS=14
# BOOTSTRAP_ADMIN_USERNAME=admin # first-run only: create superuser if DB has zero users
# BOOTSTRAP_ADMIN_PASSWORD=change-me-in-production
# Optional invite email (used by POST /api/users/invites)
# PUBLIC_BASE_URL=http://127.0.0.1:8080
# SMTP_HOST=smtp.example.com
# SMTP_PORT=587
# SMTP_USER=mailer@example.com
# SMTP_PASSWORD=app-password
# SMTP_FROM=Python Editor <mailer@example.com>
# SMTP_TLS=true
# Base URL for `pipenv run test-selenium` (app must be running separately)
# SELENIUM_BASE_URL=http://127.0.0.1:8080

7
.gitignore vendored
View File

@@ -15,7 +15,6 @@ dist/
downloads/
eggs/
.eggs/
/lib/
/lib64/
parts/
sdist/
@@ -177,3 +176,9 @@ cython_debug/
# PyPI configuration file
.pypirc
# 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/`
# on startup. Nothing under `workspace/` should ever be committed.
/workspace/
src/static/.reload-token

22
Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
RUN pip install --no-cache-dir pipenv
COPY Pipfile Pipfile.lock ./
RUN pipenv install --system --deploy
COPY src ./src
COPY lib ./lib
RUN mkdir -p src/static/bundled-lib && cp -f lib/*.py src/static/bundled-lib/
# `workspace/` is runtime/user state (gitignored) and is created on demand
# at app startup — the image does not need to ship it.
EXPOSE 8080
CMD ["uvicorn", "app:app", "--app-dir", "src", "--host", "0.0.0.0", "--port", "8080"]

75
LED_TUTORIAL.md Normal file
View File

@@ -0,0 +1,75 @@
# Python LED Tutorial (NeoPixel Focus)
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.)
## 1) Basic setup
```python
from machine import Pin
import neopixel
np = neopixel.NeoPixel(Pin(4), 12)
```
- `Pin(4)` means data pin 4 (matching common ESP32 examples).
- `12` is the number of LEDs in the strip/ring.
- `np` is your LED strip object.
## 2) Set one LED color
```python
np[0] = (255, 0, 0) # red
np.write()
```
- Colors are `(red, green, blue)` from `0` to `255`.
- Nothing updates visually until `np.write()`.
## 3) Fill all LEDs
```python
np.fill((0, 0, 255)) # all blue
np.write()
```
## 4) Clear LEDs (turn off)
```python
np.fill((0, 0, 0))
np.write()
```
## 5) Animate over time
```python
import time
for step in range(20):
np.fill((step * 10, 0, 255 - step * 10))
np.write()
time.sleep(0.08)
```
`time.sleep(...)` controls animation speed.
## 6) Moving pixel example
```python
for i in range(len(np)):
np.fill((0, 0, 0))
np[i] = (255, 120, 0)
np.write()
time.sleep(0.06)
```
## 7) Tips
- Keep color values in `0..255`.
- Use helper functions for repeated color logic.
- Start with short loops, then increase frames once behavior looks good.
- If the simulator is closed, run your script again to show updates.

View File

@@ -8,6 +8,7 @@ pytest = "*"
pytest-cov = "*"
httpx = "*"
selenium = "*"
playwright = "*"
[packages]
fastapi = "*"
@@ -19,7 +20,7 @@ bcrypt = "*"
python_version = "3.12"
[scripts]
dev = "uvicorn app:app --app-dir src --reload --port 8080"
dev = "uvicorn app:app --app-dir src --reload --host 0.0.0.0 --port 8080"
test = "bash -lc 'cd src && PYTHONPATH=. pytest ../tests'"
test-integration = "bash -lc 'cd src && PYTHONPATH=. pytest ../tests -m integration'"
test-selenium = "bash -lc 'cd src && PYTHONPATH=. pytest ../tests -m selenium -v'"

90
Pipfile.lock generated
View File

@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "f9de958b9982b3a30c107c2c02746aece031522dc429be84e4f9589c73404282"
"sha256": "ce177325185c5a9e04401b9368766f419921dfc5f9de934820507dae79998082"
},
"pipfile-spec": 6,
"requires": {
@@ -586,6 +586,71 @@
"markers": "python_version >= '3.10'",
"version": "==7.13.5"
},
"greenlet": {
"hashes": [
"sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846",
"sha256:0ed006e4b86c59de7467eb2601cd1b77b5a7d657d1ee55e30fe30d76451edba4",
"sha256:0ff251e9a0279522e62f6176412869395a64ddf2b5c5f782ff609a8216a4e662",
"sha256:1aa4ce8debcd4ea7fb2e150f3036588c41493d1d52c43538924ae1819003f4ce",
"sha256:1bae92a1dd94c5f9d9493c3a212dd874c202442047cf96446412c862feca83a2",
"sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588",
"sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13",
"sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e",
"sha256:29ea813b2e1f45fa9649a17853b2b5465c4072fbcb072e5af6cd3a288216574a",
"sha256:362624e6a8e5bca3b8233e45eef33903a100e9539a2b995c364d595dbc4018b3",
"sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b",
"sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033",
"sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628",
"sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136",
"sha256:47422135b1d308c14b2c6e758beedb1acd33bb91679f5670edf77bf46244722b",
"sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d",
"sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2",
"sha256:4b28037cb07768933c54d81bfe47a85f9f402f57d7d69743b991a713b63954eb",
"sha256:4d0eadc7e4d9ffb2af4247b606cae307be8e448911e5a0d0b16d72fc3d224cfd",
"sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b",
"sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1",
"sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16",
"sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d",
"sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106",
"sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba",
"sha256:5ecd83806b0f4c2f53b1018e0005cd82269ea01d42befc0368730028d850ed1c",
"sha256:64d6ac45f7271f48e45f67c95b54ef73534c52ec041fcda8edf520c6d811f4bc",
"sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7",
"sha256:6c18dfb59c70f5a94acd271c72e90128c3c776e41e5f07767908c8c1b74ad339",
"sha256:6d874e79afd41a96e11ff4c5d0bc90a80973e476fda1c2c64985667397df432b",
"sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae",
"sha256:703cb211b820dbffbbc55a16bfc6e4583a6e6e990f33a119d2cc8b83211119c8",
"sha256:728a73687e39ae9ca34e4694cbf2f049d3fbc7174639468d0f67200a97d8f9e2",
"sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5",
"sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf",
"sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f",
"sha256:804a70b328e706b785c6ef16187051c394a63dd1a906d89be24b6ad77759f13f",
"sha256:83ed9f27f1680b50e89f40f6df348a290ea234b249a4003d366663a12eab94f2",
"sha256:884f649de075b84739713d41dd4dfd41e2b910bfb769c4a3ea02ec1da52cd9bb",
"sha256:8f1cc966c126639cd152fdaa52624d2655f492faa79e013fea161de3e6dda082",
"sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7",
"sha256:9c615f869163e14bb1ced20322d8038fb680b08236521ac3f30cd4c1288785a0",
"sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c",
"sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853",
"sha256:a96fcee45e03fe30a62669fd16ab5c9d3c172660d3085605cb1e2d1280d3c988",
"sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3",
"sha256:ba8f0bdc2fae6ce915dfd0c16d2d00bca7e4247c1eae4416e06430e522137858",
"sha256:bf2d8a80bec89ab46221ae45c5373d5ba0bd36c19aa8508e85c6cd7e5106cd37",
"sha256:cda05425526240807408156b6960a17a79a0c760b813573b67027823be760977",
"sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4",
"sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8",
"sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86",
"sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f",
"sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112",
"sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e",
"sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2",
"sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8",
"sha256:f8c30c2225f40dd76c50790f0eb3b5c7c18431efb299e2782083e1981feed243",
"sha256:fa94cb2288681e3a11645958f1871d48ee9211bd2f66628fdace505927d6e564"
],
"markers": "platform_machine == 'aarch64' or (platform_machine == 'ppc64le' or (platform_machine == 'x86_64' or (platform_machine == 'amd64' or (platform_machine == 'AMD64' or (platform_machine == 'win32' or platform_machine == 'WIN32')))))",
"version": "==3.5.0"
},
"h11": {
"hashes": [
"sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1",
@@ -643,6 +708,21 @@
"markers": "python_version >= '3.8'",
"version": "==26.2"
},
"playwright": {
"hashes": [
"sha256:4a4a2d4842b0e4120de3fa48636e4b69085a05b81d8a35ad4353f530ade72ed6",
"sha256:6989c476be2b9cd3e24a18cc9dcf202e266fb3d91e3e5395cd668c54ea54b119",
"sha256:8c881a19377d2b900af855fb525b5f22a27bf3cfbecba6d1edb36766d56cb100",
"sha256:93581ad515728cadc8af39b288a5633ba6d36e7d72048e79d890ce01ea2156f9",
"sha256:af068143a0c045ec11608b67d6c42e58db7e9cf65a742dd21fddedc1a9802c47",
"sha256:bfc6940100b57423175c819ce2422ec5880d55fa2769987f62ab7a1f5fe6783e",
"sha256:c5792aad9e22b91a09264b9edbc18553cf05ea5a39404d65dc19a012c6b2e51d",
"sha256:d5a5cc064b82ca92996080025710844e417f44df8fda9001102c28f44174171c"
],
"index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==1.59.0"
},
"pluggy": {
"hashes": [
"sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3",
@@ -651,6 +731,14 @@
"markers": "python_version >= '3.9'",
"version": "==1.6.0"
},
"pyee": {
"hashes": [
"sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8",
"sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228"
],
"markers": "python_version >= '3.8'",
"version": "==13.0.1"
},
"pygments": {
"hashes": [
"sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f",

104
README.md
View File

@@ -1,6 +1,6 @@
# python-editor
Browser-based Python editing: **FastAPI** serves static assets, stores workspace files, and optional **API key auth**. **Pyodide** runs your scripts and **Jedi** (inside Pyodide) powers completions — no server-side Python execution or Jedi.
Browser-based Python editing: **FastAPI** serves static assets, stores workspace files, and optional **API key auth**. **Pyodide** runs your scripts and **Jedi** (inside Pyodide) powers completions and syntax diagnostics — no server-side Python execution or LSP process.
## Run
@@ -48,13 +48,111 @@ If nothing is listening, the smoke test **skips** with a short message instead o
Open [http://localhost:8080](http://localhost:8080).
**User accounts** — Set `AUTH_ENABLED=true` in `.env` to require sign-in for workspace APIs. Users live in a SQLite file (`AUTH_DATABASE_PATH`, default `./data/editor.db`). Use `/register` (if `AUTH_REGISTER_OPEN=true`) or `BOOTSTRAP_ADMIN_USERNAME` / `BOOTSTRAP_ADMIN_PASSWORD` for the first superuser. Superusers can **GET/POST/DELETE `/api/users`** to list, create, or remove accounts.
### Editor runtime controls
- `Run Python` runs the active open `.py` tab.
- Enable `Run main.py` to always run `code/main.py` instead.
- Pressing `Run Python` while a script is running will stop and restart with the selected target.
- `LSP` badge in the header shows in-browser Jedi syntax status (`n/a`, `checking...`, `OK`, or issue count).
## Deploy with Docker
Build and run with Docker Compose:
```bash
cp .env.example .env
mkdir -p data
docker compose up --build
```
Then open [http://localhost:8080](http://localhost:8080).
Notes:
- `workspace/` is mounted to `/app/workspace` so your code persists locally.
- `data/` is mounted to `/app/data` for the SQLite auth DB.
- In container mode, `WORKSPACE_ROOT` and `AUTH_DATABASE_PATH` are set by `docker-compose.yml`.
**User accounts** — Set `AUTH_ENABLED=true` in `.env` to require sign-in for workspace APIs. Users live in a SQLite file (`AUTH_DATABASE_PATH`, default `./data/editor.db`). Use `/register` with an invite link (unless you opt into open signup) or `BOOTSTRAP_ADMIN_USERNAME` / `BOOTSTRAP_ADMIN_PASSWORD` for the first superuser. Superusers can **GET `/api/users`**, **PATCH `/api/users/{id}`** (username, password reset, admin flag — renames workspace folder when the username changes), or **DELETE `/api/users/{id}`** to manage accounts. New accounts are added only through **invite links** (**`POST /api/users/invites`**) plus self-service registration (`/register?invite=…`).
Email invite signup:
- By default **`AUTH_INVITE_ONLY=true`**: registrations need a valid invite token. Set **`AUTH_INVITE_ONLY=false`** to allow open signup whenever **`AUTH_REGISTER_OPEN=true`**.
- Superusers can create invites via `POST /api/users/invites` with `{ "email": "...", "expires_days": 7 }`.
- Response includes `invite_url`; if SMTP is configured the invite email is sent automatically.
- Registration page accepts invite links like `/register?invite=<token>`.
When auth is enabled, file APIs use a per-user workspace under `WORKSPACE_ROOT/users/<username-id>/` for **isolated `code/`**. The `lib/` tree is shared and read-only for all users. When auth is disabled, the shared workspace root is used for everything.
Admins can open another user's workspace from the home page user management panel (links to `/editor?workspace_user_id=<id>`). Only superusers may use this override.
**API key** — If `EDITOR_API_KEY` is set, requests may use `Authorization: Bearer …` instead of a session (useful for automation). When `AUTH_ENABLED=true`, a valid session *or* API key is accepted.
The home page can store the API key in `sessionStorage` when you are not using cookie login, or use `?api_key=` on `/editor`.
**Local mode (no login)** — Click *Use locally* on the home page (or open `/editor?local=1`) to run the editor without any FastAPI auth. The boot-time auth probe is skipped when local mode is active, so this works even on a host that has `AUTH_ENABLED=true`. Files default to the browser's **IndexedDB**; inside the editor the workspace badge has a **Folder…** button that opens `window.showDirectoryPicker()` so you can save straight to any folder on disk (Chromium-only — Firefox/Safari stay on IndexedDB). The picked directory handle is persisted across reloads in IndexedDB; if browser permission lapses a *Reconnect* button reappears in the badge. Nothing is sent to the server for file reads/writes. The MicroPython stubs are loaded from **`/static/bundled-lib/*.py`** (files under `src/static/bundled-lib/` in the repo) so a plain static file server is enough; if those requests fail, the app falls back to `GET /api/public/lib-bundle` when FastAPI is available. For static-only hosting, run `python scripts/serve_static_editor.py` from the repo root — it serves `src/static/` with the same `/static/…` URLs the HTML expects (it strips the `/static` prefix when resolving files), rewrites `/editor` → `index.html`, and sends the same COOP/COEP headers as the full app so **ADC sliders, pin toggles, and serial I/O** keep using `SharedArrayBuffer` on mobile Safari and Chrome where supported. An *Exit* button in the editor's workspace badge clears the local-mode flag (your IndexedDB files stay until you wipe browser storage).
## Layout
- `src/` — FastAPI app and static UI (`src/static/`)
- `workspace/` — default tree: `code/` (editable), `lib/` (read-only via API)
- `lib/` — bundled MicroPython stubs, served read-only as `lib/` in the editor and merged into Pyodide at run time (single source of truth)
- `workspace/` — default `WORKSPACE_ROOT`: `code/` samples and per-user folders (editable); the editor surfaces the repo `lib/` alongside it without copying anything to disk
## ESP32 / NeoPixel mock
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` — `value/on/off/toggle/high/low/init/__call__/irq` plus a live "Pins" panel: OUT pins show an indicator, IN pins expose a clickable toggle button (its value is what `Pin.value()` returns), `irq()` fires on rising / falling edges as you click
- `machine.PWM` — `freq()` / `duty()` / `duty_u16()` / `duty_ns()` with a duty-cycle bar in the Pins panel
- `machine.ADC` — backed by a live slider in the editor UI (one slider per pin, `read_u16()` returns 0..65535)
- `machine.UART` — opens a Serial Monitor pane; `write()` text appears there, what you type is delivered via `read()` / `readline()`
- `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 `code/` (your editor workspace, populated on first run from `src/static/bundled-demos/`) like typical ESP32 / MicroPython examples:
```python
from machine import Pin
import neopixel
np = neopixel.NeoPixel(Pin(4), 8)
np[0] = (255, 0, 0)
np.write()
```
`write()` updates the NeoPixel simulator so you can verify behavior visually.
Simulator modes:
- Default: in-app LED strip/panel section under the editor.
- `16x16 panel` checkbox: opens a dedicated popup with 16x16 serpentine mapping:
- first LED at top-right
- first row goes right -> left
- 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):
- `LED_TUTORIAL.md` - step-by-step NeoPixel tutorial
- `led_tutorial.py` - runnable guided LED example
- `led_patterns.py` - shared pattern helpers (used by automated tests); each `pattern_*_demo.py` duplicates what it needs and uses only Python stdlib + `machine` / `neopixel` / `time`
- `pattern_rainbow_demo.py` - rainbow animation (self-contained)
- `pattern_chase_demo.py` - Knight Riderstyle bouncing scanner (self-contained)
- `pattern_twinkle_demo.py` - twinkle animation (self-contained)
- `panel16_utils.py` - helpers for 16x16 serpentine mapping
- `panel16_rainbow_wave.py` - 16x16 rainbow wave
- `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/<file>.py` and re-run "Reset demos" in the editor (or restart the dev server with an empty `workspace/code/`).
## Dev auto-reload hook
Project hook files are included in `.cursor/`:
- `.cursor/hooks.json`
- `.cursor/hooks/dev-reload-touch.sh`
When files are edited through Cursor tools, the hook updates `src/static/.reload-token`.
The editor (on localhost) polls that token and auto-reloads the browser when it changes.

16
docker-compose.yml Normal file
View File

@@ -0,0 +1,16 @@
services:
python-editor:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
env_file:
- .env
environment:
- WORKSPACE_ROOT=/app/workspace
- AUTH_DATABASE_PATH=/app/data/editor.db
volumes:
- ./workspace:/app/workspace
- ./data:/app/data
restart: unless-stopped

445
lib/machine.py Normal file
View File

@@ -0,0 +1,445 @@
"""Minimal MicroPython-style machine module mock for browser simulation."""
import json
_PIN_MODE_OUT = 1
_PIN_MODE_IN = 2
_PIN_MODE_PWM = 4
def _pin_view_get(name):
try:
import js
return getattr(js, name, None)
except Exception:
return None
def _sab_isolated():
"""True when the worker has a real SharedArrayBuffer for pin/ADC views.
Set by `pyodide-worker.js` at init time. When false, output updates
must travel through `print("[pin-out]…")` markers because the main
thread can't read worker-local arrays."""
try:
import js
return bool(getattr(js, "__sab_isolated", False))
except Exception:
return False
def _pin_out_publish(pin_id, ui_mode, value):
"""Pack [ui_mode (high byte) | value (low 24 bits)] for the UI panel."""
view = _pin_view_get("__pin_out_view")
if view is not None:
packed = ((int(ui_mode) & 0xFF) << 24) | (int(value) & 0x00FFFFFF)
try:
import js
js.Atomics.store(view, int(pin_id), packed)
except Exception:
try:
view[int(pin_id)] = packed
except Exception:
pass
if not _sab_isolated():
# Fallback for non-isolated contexts (e.g. mobile over LAN IP):
# the main thread reads `[pin-out]` lines from stdout and updates
# the Pins panel from there. Slower than SAB but works everywhere.
try:
print(
"[pin-out]"
+ json.dumps(
{"pin": int(pin_id), "mode": int(ui_mode), "value": int(value)}
)
)
except Exception:
pass
def _pin_in_read(pin_id):
view = _pin_view_get("__pin_in_view")
if view is None:
return 0
try:
import js
return int(js.Atomics.load(view, int(pin_id))) & 1
except Exception:
try:
return int(view[int(pin_id)]) & 1
except Exception:
return 0
def _pin_register(pin_id, ui_mode, **extra):
payload = {"pin": int(pin_id), "mode": int(ui_mode)}
payload.update({k: v for k, v in extra.items() if v is not None})
try:
print("[pin-register]" + json.dumps(payload))
except Exception:
pass
class Pin:
"""Browser-simulated `machine.Pin`.
A control row appears in the editor UI when a pin is constructed:
* `Pin.OUT` pins show a live indicator that mirrors `value()`.
* `Pin.IN` pins show a toggle button you can click — the next call
to `value()` returns 0 or 1 accordingly.
"""
IN = 0
OUT = 1
PULL_UP = 2
PULL_DOWN = 3
OPEN_DRAIN = 7
IRQ_FALLING = 1
IRQ_RISING = 2
def __init__(self, pin_id, mode=OUT, pull=-1, value=None, **_kwargs):
self.id = int(pin_id)
self.mode = int(mode)
self.pull = int(pull) if pull != -1 else -1
self._value = 1 if value else 0
self._irq_handler = None
self._irq_trigger = 0
self._last_input = 0
self._publish()
def _ui_mode(self):
if self.mode == self.IN:
return _PIN_MODE_IN
return _PIN_MODE_OUT
def _publish(self):
ui_mode = self._ui_mode()
_pin_register(self.id, ui_mode, pull=self.pull if self.pull != -1 else None)
if ui_mode == _PIN_MODE_OUT:
_pin_out_publish(self.id, ui_mode, self._value)
def init(self, mode=-1, pull=-1, value=None):
if mode != -1:
self.mode = int(mode)
if pull != -1:
self.pull = int(pull)
if value is not None:
self._value = 1 if int(value) else 0
self._publish()
def value(self, new_value=None):
if new_value is None:
if self.mode == self.IN:
v = _pin_in_read(self.id)
if self._irq_handler is not None:
if v and not self._last_input and (self._irq_trigger & self.IRQ_RISING):
self._fire_irq()
elif not v and self._last_input and (self._irq_trigger & self.IRQ_FALLING):
self._fire_irq()
self._last_input = v
return v
return self._value
v = 1 if int(new_value) else 0
self._value = v
if self._ui_mode() == _PIN_MODE_OUT:
_pin_out_publish(self.id, _PIN_MODE_OUT, v)
return v
def on(self):
return self.value(1)
def off(self):
return self.value(0)
def high(self):
return self.value(1)
def low(self):
return self.value(0)
def toggle(self):
return self.value(0 if (self._value if self.mode == self.OUT else _pin_in_read(self.id)) else 1)
def irq(self, handler=None, trigger=IRQ_RISING | IRQ_FALLING, **_kwargs):
self._irq_handler = handler
self._irq_trigger = int(trigger)
return self
def _fire_irq(self):
if self._irq_handler is None:
return
try:
self._irq_handler(self)
except Exception as exc:
try:
print(f"[pin] irq handler raised: {exc!r}")
except Exception:
pass
def __call__(self, *args):
return self.value(*args) if args else self.value()
class PWM:
"""Browser-simulated `machine.PWM`.
Visualises the configured duty cycle as a bar in the Pins panel.
`freq()` and `duty()`/`duty_u16()`/`duty_ns()` mirror MicroPython's API.
"""
def __init__(self, pin, freq=1000, duty=None, duty_u16=None, duty_ns=None):
self._pin_id = _adc_pin_id(pin)
self._freq = int(freq) if freq else 1000
self._duty_u16 = 0
if duty_u16 is not None:
self._duty_u16 = max(0, min(65535, int(duty_u16)))
elif duty is not None:
self._duty_u16 = max(0, min(65535, int(duty) * 64))
elif duty_ns is not None:
self._set_duty_ns(int(duty_ns))
_pin_register(self._pin_id, _PIN_MODE_PWM, freq=self._freq)
_pin_out_publish(self._pin_id, _PIN_MODE_PWM, self._duty_u16)
def _set_duty_ns(self, ns):
period_ns = 1_000_000_000 // max(1, self._freq)
if period_ns <= 0:
self._duty_u16 = 0
return
self._duty_u16 = max(0, min(65535, int(ns) * 65535 // period_ns))
def freq(self, value=None):
if value is None:
return self._freq
self._freq = max(1, int(value))
_pin_register(self._pin_id, _PIN_MODE_PWM, freq=self._freq)
def duty(self, value=None):
if value is None:
return self._duty_u16 // 64
self._duty_u16 = max(0, min(65535, int(value) * 64))
_pin_out_publish(self._pin_id, _PIN_MODE_PWM, self._duty_u16)
def duty_u16(self, value=None):
if value is None:
return self._duty_u16
self._duty_u16 = max(0, min(65535, int(value)))
_pin_out_publish(self._pin_id, _PIN_MODE_PWM, self._duty_u16)
def duty_ns(self, value=None):
period_ns = 1_000_000_000 // max(1, self._freq)
if value is None:
return (self._duty_u16 * period_ns) // 65535
self._set_duty_ns(int(value))
_pin_out_publish(self._pin_id, _PIN_MODE_PWM, self._duty_u16)
def deinit(self):
_pin_out_publish(self._pin_id, 0, 0)
def _adc_pin_id(pin):
if isinstance(pin, int):
return pin
return int(getattr(pin, "id", 0))
def _adc_read_raw(pin_id: int) -> int:
"""Read the live slider value (0..65535) shared from the editor UI.
Backed by a SharedArrayBuffer so updates from the browser slider propagate
instantly without the script having to yield. Falls back to 0 when the
runtime is not cross-origin isolated (e.g. older browsers).
"""
try:
import js # only available inside Pyodide
view = getattr(js, "__adc_view", None)
if view is None:
return 0
try:
return int(js.Atomics.load(view, pin_id)) & 0xFFFF
except Exception:
return int(view[pin_id]) & 0xFFFF
except Exception:
return 0
class ADC:
"""Browser-simulated `machine.ADC`.
Exposes a slider in the editor UI for the given pin. `read_u16()` returns
the slider value in the 0..65535 range (matching MicroPython's API);
`read()` scales to the configured `width` (defaults to 12-bit, 0..4095).
"""
WIDTH_9BIT = 9
WIDTH_10BIT = 10
WIDTH_11BIT = 11
WIDTH_12BIT = 12
ATTN_0DB = 0
ATTN_2_5DB = 1
ATTN_6DB = 2
ATTN_11DB = 3
def __init__(self, pin):
self._pin = _adc_pin_id(pin)
self._atten = self.ATTN_11DB
self._width = self.WIDTH_12BIT
try:
print("[adc-register]" + json.dumps({"pin": self._pin}))
except Exception:
pass
def atten(self, attn):
self._atten = int(attn)
def width(self, width):
self._width = int(width)
def read_u16(self):
return _adc_read_raw(self._pin)
def read(self):
bits = self._width if self._width in (9, 10, 11, 12) else 12
max_val = (1 << bits) - 1
return (_adc_read_raw(self._pin) * max_val) // 65535
def read_uv(self):
"""Return microvolts assuming a 0..3.3V range (rough simulation)."""
return (_adc_read_raw(self._pin) * 3_300_000) // 65535
def _serial_drain_pending(buf):
"""Pull any bytes the editor's serial-monitor has pushed into the SAB ring."""
try:
import js
import base64 # noqa: F401 (kept for symmetry with write path)
indices = getattr(js, "__serial_in_indices", None)
data = getattr(js, "__serial_in_data", None)
if indices is None or data is None:
return
cap = int(getattr(js, "__serial_in_capacity", 0))
if cap <= 0:
return
r = int(js.Atomics.load(indices, 0))
w = int(js.Atomics.load(indices, 1))
while r != w:
buf.append(int(data[r]) & 0xFF)
r = (r + 1) % cap
js.Atomics.store(indices, 0, r)
except Exception:
pass
class UART:
"""Browser-simulated `machine.UART`.
A serial monitor pane appears in the editor when this is constructed.
`write()` text shows up there; characters typed into the input box arrive
via `read()` / `readline()` / `any()`.
"""
INV_TX = 1
INV_RX = 2
INV_RTS = 4
INV_CTS = 8
def __init__(self, id=0, baudrate=115200, bits=8, parity=None, stop=1,
tx=None, rx=None, timeout=0, **_kwargs):
self.id = int(id) if id is not None else 0
self.baudrate = int(baudrate)
self.bits = int(bits)
self.parity = parity
self.stop = int(stop)
self.tx = tx
self.rx = rx
self.timeout = int(timeout) if timeout is not None else 0
self._buf = bytearray()
try:
print("[serial-register]" + json.dumps({
"id": self.id,
"baudrate": self.baudrate,
}))
except Exception:
pass
def init(self, *args, **kwargs):
if args:
try:
self.baudrate = int(args[0])
except Exception:
pass
if "baudrate" in kwargs:
self.baudrate = int(kwargs["baudrate"])
def deinit(self):
self._buf = bytearray()
def any(self):
_serial_drain_pending(self._buf)
return len(self._buf)
def read(self, nbytes=None):
_serial_drain_pending(self._buf)
if not self._buf:
return None
if nbytes is None:
out = bytes(self._buf)
self._buf = bytearray()
return out
n = min(int(nbytes), len(self._buf))
if n <= 0:
return None
out = bytes(self._buf[:n])
del self._buf[:n]
return out
def readinto(self, buf, nbytes=None):
n = int(nbytes) if nbytes is not None else len(buf)
data = self.read(n)
if not data:
return None
for i, b in enumerate(data):
buf[i] = b
return len(data)
def readline(self):
_serial_drain_pending(self._buf)
if not self._buf:
return None
nl = self._buf.find(b"\n")
if nl == -1:
return None
out = bytes(self._buf[: nl + 1])
del self._buf[: nl + 1]
return out
def write(self, data):
if isinstance(data, str):
buf = data.encode("utf-8")
elif isinstance(data, (bytes, bytearray, memoryview)):
buf = bytes(data)
else:
buf = bytes([int(data) & 0xFF])
try:
import base64
encoded = base64.b64encode(buf).decode("ascii")
print(f"[serial-out]{encoded}")
except Exception:
pass
return len(buf)
def sendbreak(self):
pass
def flush(self):
pass
def txdone(self):
return True

67
lib/neopixel.py Normal file
View File

@@ -0,0 +1,67 @@
"""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)]
# Tell the editor UI that this pin is the NeoPixel data line so its
# row in the Pins panel goes away (it would just look noisy — every
# `pixels.write()` flips it).
try:
pin_id = getattr(self.pin, "id", self.pin)
print(
"[pin-claim]"
+ json.dumps({"pin": int(pin_id), "by": "neopixel"})
)
except Exception:
pass
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))

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Serve only the contents of `src/static/` (HTML, CSS, JS, bundled stubs).
Use this with **local mode** in the editor (`?local=1`): files live in IndexedDB,
so no FastAPI file API is required. Maps `/` and `/editor` to `home.html` /
`index.html` so links from the home page keep working.
Sends ``Cross-Origin-Opener-Policy: same-origin`` and
``Cross-Origin-Embedder-Policy: credentialless`` on every response so the page
can become cross-origin isolated (SharedArrayBuffer for live ADC / pins /
serial), matching the full FastAPI app.
Example:
python scripts/serve_static_editor.py
# open http://127.0.0.1:8765/ then "Use locally" → /editor?local=1
Note: Pyodide and CodeMirror still load from CDNs; you need network access.
"""
from __future__ import annotations
import argparse
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--host", default="127.0.0.1", help="Bind address (default 127.0.0.1)")
parser.add_argument("--port", type=int, default=8765, help="Port (default 8765)")
args = parser.parse_args()
static_root = (Path(__file__).resolve().parent.parent / "src" / "static").resolve()
if not static_root.is_dir():
raise SystemExit(f"Static directory not found: {static_root}")
class Handler(SimpleHTTPRequestHandler):
def __init__(self, *a, **kw):
super().__init__(*a, directory=str(static_root), **kw)
def end_headers(self):
self.send_header("Cross-Origin-Opener-Policy", "same-origin")
self.send_header("Cross-Origin-Embedder-Policy", "credentialless")
super().end_headers()
def translate_path(self, path: str) -> str:
clean = path.split("?", 1)[0].split("#", 1)[0]
if clean.startswith("/static/"):
clean = clean[len("/static") :] # e.g. /styles.css, /bundled-lib/machine.py
if clean in ("/", ""):
clean = "/home.html"
elif clean == "/editor" or clean.startswith("/editor/"):
clean = "/index.html"
elif clean == "/tutorial" or clean.startswith("/tutorial/"):
clean = "/tutorial.html"
elif clean == "/login" or clean.startswith("/login/"):
clean = "/login.html"
elif clean == "/register" or clean.startswith("/register/"):
clean = "/register.html"
return super().translate_path(clean)
httpd = ThreadingHTTPServer((args.host, args.port), Handler)
print(f"Serving {static_root} at http://{args.host}:{args.port}/")
print("Open / then use “Use locally” for IndexedDB workspace.")
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\nStopped.")
if __name__ == "__main__":
main()

View File

@@ -28,3 +28,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.
BUNDLED_DEMOS_DIR = STATIC_DIR / "bundled-demos"

View File

@@ -38,3 +38,21 @@ class AuthSession(Base):
created_at: Mapped[dt.datetime] = mapped_column(DateTime, default=_utc_naive)
user: Mapped[User] = relationship("User", back_populates="sessions")
class InviteToken(Base):
__tablename__ = "invite_tokens"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
email: Mapped[str] = mapped_column(String(320), index=True)
token: Mapped[str] = mapped_column(String(128), unique=True, index=True)
expires_at: Mapped[dt.datetime] = mapped_column(DateTime)
used_at: Mapped[dt.datetime | None] = mapped_column(DateTime, nullable=True)
invited_by_user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
)
consumed_by_user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
)
grants_superuser: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[dt.datetime] = mapped_column(DateTime, default=_utc_naive)

View File

@@ -1,13 +1,16 @@
from __future__ import annotations
import os
from pathlib import Path
from fastapi import Cookie, Depends, Header, HTTPException
from fastapi import Cookie, Depends, Header, HTTPException, Query
from sqlalchemy.orm import Session
from editor_app.db.session import get_db
from editor_app.db.models import User
from editor_app import config
from editor_app.services import accounts
from editor_app.services import user_workspace
def api_key_valid(authorization: str | None) -> bool:
@@ -61,3 +64,28 @@ async def require_superuser(
if not user.is_superuser:
raise HTTPException(status_code=403, detail="Superuser required")
return user
def _seed_user_workspace(user_root: Path) -> None:
user_workspace.ensure_default_code_main(user_root)
async def get_workspace_root(
user: User | None = Depends(get_current_user_optional),
workspace_user_id: int | None = Query(default=None),
db: Session = Depends(get_db),
) -> Path:
root = config.WORKSPACE_ROOT.resolve()
if not accounts.auth_enabled() or user is None:
return root
target_user = user
if workspace_user_id is not None:
if not user.is_superuser:
raise HTTPException(status_code=403, detail="Superuser required for workspace override")
lookup = accounts.get_user_by_id(db, int(workspace_user_id))
if lookup is None:
raise HTTPException(status_code=404, detail="Workspace user not found")
target_user = lookup
user_root = user_workspace.user_workspace_root(target_user.id, target_user.username, workspace_root=root)
_seed_user_workspace(user_root)
return user_root

View File

@@ -1,8 +1,9 @@
import os
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI
from fastapi import Depends, FastAPI, Request
from fastapi.staticfiles import StaticFiles
from sqlalchemy import text
from sqlalchemy.orm import sessionmaker
from editor_app.config import STATIC_DIR, WORKSPACE_ROOT
@@ -13,14 +14,30 @@ 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)
# `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).
WORKSPACE_ROOT.mkdir(parents=True, exist_ok=True)
user_workspace.seed_all_bundled_demos(WORKSPACE_ROOT)
engine = get_engine()
Base.metadata.create_all(bind=engine)
with engine.begin() as conn:
cols = conn.execute(text("PRAGMA table_info(invite_tokens)")).fetchall()
column_names = {row[1] for row in cols}
if column_names and "grants_superuser" not in column_names:
conn.execute(
text(
"ALTER TABLE invite_tokens ADD COLUMN grants_superuser BOOLEAN NOT NULL DEFAULT 0"
)
)
factory = sessionmaker(autocommit=False, autoflush=False, bind=engine)
db = factory()
try:
@@ -36,6 +53,18 @@ async def lifespan(_app: FastAPI):
def create_app() -> FastAPI:
app = FastAPI(lifespan=lifespan)
@app.middleware("http")
async def cross_origin_isolation(request: Request, call_next):
"""Turn on `crossOriginIsolated` so the editor worker can use SharedArrayBuffer
(powers the live ADC slider). `credentialless` keeps no-credential cross-origin
imports (esm.sh, jsdelivr) loading without requiring CORP everywhere.
"""
response = await call_next(request)
response.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin")
response.headers.setdefault("Cross-Origin-Embedder-Policy", "credentialless")
return response
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
app.include_router(frontend_router)
app.include_router(auth_router)

View File

@@ -33,7 +33,11 @@ def _clear_session_cookie(response: Response, request: Request) -> None:
@router.get("/status", response_model=AuthStatusResponse)
async def auth_status() -> AuthStatusResponse:
return AuthStatusResponse(auth_enabled=accounts.auth_enabled(), register_open=accounts.register_open())
return AuthStatusResponse(
auth_enabled=accounts.auth_enabled(),
register_open=accounts.register_open(),
invite_required=accounts.invite_required(),
)
@router.get("/me")
@@ -57,9 +61,16 @@ async def register(
) -> UserPublic:
if not accounts.auth_enabled():
raise HTTPException(status_code=400, detail="Set AUTH_ENABLED=true to use accounts")
if not accounts.register_open():
if not accounts.register_open() and not body.invite_token:
raise HTTPException(status_code=403, detail="Registration is disabled (AUTH_REGISTER_OPEN=false)")
try:
if accounts.invite_required():
if not body.invite_token:
raise HTTPException(status_code=403, detail="Invite token is required")
user = accounts.register_user_with_invite(db, body.username, body.password, body.invite_token)
elif body.invite_token:
user = accounts.register_user_with_invite(db, body.username, body.password, body.invite_token)
else:
user = accounts.register_user(db, body.username, body.password)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc

View File

@@ -1,5 +1,8 @@
from fastapi import APIRouter
from pathlib import Path
from fastapi import APIRouter, Depends
from editor_app.deps import get_workspace_root
from editor_app.models import FileContent, FolderOperation, MoveFileRequest
from editor_app.services import filesystem
@@ -7,50 +10,51 @@ router = APIRouter(prefix="/api")
@router.get("/files")
async def list_files(path: str = ""):
files = filesystem.list_files(path)
async def list_files(path: str = "", workspace_root: Path = Depends(get_workspace_root)):
files = filesystem.list_files(path, workspace_root=workspace_root)
return {"files": files}
@router.get("/workspace/py-sources")
async def workspace_python_sources():
return {"files": filesystem.collect_python_sources()}
async def workspace_python_sources(workspace_root: Path = Depends(get_workspace_root)):
return {"files": filesystem.collect_python_sources(workspace_root=workspace_root)}
@router.get("/file/{file_path:path}")
async def read_file(file_path: str):
content, filename = filesystem.read_text_file(file_path)
async def read_file(file_path: str, workspace_root: Path = Depends(get_workspace_root)):
content, filename = filesystem.read_text_file(file_path, workspace_root=workspace_root)
return {"content": content, "filename": filename}
@router.post("/file/{file_path:path}")
async def save_file(file_path: str, file_data: FileContent):
filename = filesystem.save_text_file(file_path, file_data.content)
async def save_file(file_path: str, file_data: FileContent, workspace_root: Path = Depends(get_workspace_root)):
filename = filesystem.save_text_file(file_path, file_data.content, workspace_root=workspace_root)
return {"message": "File saved successfully", "filename": filename}
@router.post("/file-move")
async def move_file(move_data: MoveFileRequest):
async def move_file(move_data: MoveFileRequest, workspace_root: Path = Depends(get_workspace_root)):
new_path, moved_type = filesystem.move_path(
source_path=move_data.source_path,
destination_folder=move_data.destination_folder,
workspace_root=workspace_root,
)
return {"message": "Path moved successfully", "new_path": new_path, "moved_type": moved_type}
@router.delete("/file/{file_path:path}")
async def delete_file(file_path: str):
filesystem.delete_file(file_path)
async def delete_file(file_path: str, workspace_root: Path = Depends(get_workspace_root)):
filesystem.delete_file(file_path, workspace_root=workspace_root)
return {"message": "File deleted successfully"}
@router.post("/folder/new/{folder_path:path}")
async def create_folder(folder_path: str, folder_data: FolderOperation):
folder_name = filesystem.create_folder(folder_path)
async def create_folder(folder_path: str, folder_data: FolderOperation, workspace_root: Path = Depends(get_workspace_root)):
folder_name = filesystem.create_folder(folder_path, workspace_root=workspace_root)
return {"message": "Folder created successfully", "folder": folder_name}
@router.delete("/folder/{folder_path:path}")
async def delete_folder(folder_path: str):
filesystem.delete_folder(folder_path)
async def delete_folder(folder_path: str, workspace_root: Path = Depends(get_workspace_root)):
filesystem.delete_folder(folder_path, workspace_root=workspace_root)
return {"message": "Folder deleted successfully"}

View File

@@ -1,11 +1,38 @@
from fastapi import APIRouter
from fastapi.responses import FileResponse
from editor_app.config import STATIC_DIR
from editor_app.config import PROJECT_ROOT, STATIC_DIR
router = APIRouter()
@router.get("/api/public/lib-bundle")
async def serve_lib_bundle():
"""Public, unauthenticated dump of the read-only `lib/` stubs.
Local-mode browsers (no login, files in IndexedDB) need access to the
MicroPython mocks so completion, diagnostics, and `runpy` can resolve
`from machine import …`. Reading these is read-only and contains no
user data, so it is safe to expose without auth."""
bundle_root = (PROJECT_ROOT / "lib").resolve()
files: dict[str, str] = {}
if bundle_root.is_dir():
for path in sorted(bundle_root.rglob("*.py")):
if not path.is_file():
continue
try:
rel = path.relative_to(bundle_root)
except ValueError:
continue
if any(part.startswith(".") for part in rel.parts):
continue
try:
files[str(rel).replace("\\", "/")] = path.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
continue
return {"files": files}
@router.get("/")
async def serve_home():
return FileResponse(STATIC_DIR / "home.html")
@@ -16,6 +43,11 @@ async def serve_frontend():
return FileResponse(STATIC_DIR / "index.html")
@router.get("/tutorial")
async def serve_tutorial():
return FileResponse(STATIC_DIR / "tutorial.html")
@router.get("/login")
async def serve_login():
return FileResponse(STATIC_DIR / "login.html")
@@ -24,3 +56,10 @@ async def serve_login():
@router.get("/register")
async def serve_register():
return FileResponse(STATIC_DIR / "register.html")
@router.get("/users")
async def serve_users():
"""Admin panel for managing accounts and invites. Auth is enforced by
the underlying `/api/users*` endpoints, not the static page itself."""
return FileResponse(STATIC_DIR / "users.html")

View File

@@ -6,7 +6,7 @@ from sqlalchemy.orm import Session
from editor_app.db.session import get_db
from editor_app.db.models import User
from editor_app.deps import require_superuser
from editor_app.schemas.users import UserCreateAdmin, UserPublic
from editor_app.schemas.users import InviteCreateRequest, InviteCreateResponse, UserPublic, UserUpdateAdmin
from editor_app.services import accounts
router = APIRouter(prefix="/api/users", tags=["users"])
@@ -20,21 +20,26 @@ async def list_users(
return [UserPublic.model_validate(u) for u in accounts.list_users(db)]
@router.post("", response_model=UserPublic)
async def create_user_admin(
body: UserCreateAdmin,
admin: User = Depends(require_superuser),
@router.patch("/{user_id}", response_model=UserPublic)
async def patch_user_admin(
user_id: int,
body: UserUpdateAdmin,
_admin: User = Depends(require_superuser),
db: Session = Depends(get_db),
) -> UserPublic:
if accounts.get_user_by_username(db, body.username):
raise HTTPException(status_code=409, detail="Username already taken")
user = accounts.create_user(
try:
updated = accounts.update_user(
db,
body.username,
body.password,
user_id,
username=body.username,
password=body.password,
is_superuser=body.is_superuser,
)
return UserPublic.model_validate(user)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if updated is None:
raise HTTPException(status_code=404, detail="User not found")
return UserPublic.model_validate(updated)
@router.delete("/{user_id}")
@@ -48,3 +53,26 @@ async def delete_user_admin(
if not accounts.delete_user(db, user_id):
raise HTTPException(status_code=404, detail="User not found")
return {"message": "User deleted"}
@router.post("/invites", response_model=InviteCreateResponse)
async def create_invite_admin(
body: InviteCreateRequest,
admin: User = Depends(require_superuser),
db: Session = Depends(get_db),
) -> InviteCreateResponse:
email = (body.email or "").strip().lower()
invite = accounts.create_invite(
db,
email,
invited_by_user_id=admin.id,
expires_days=body.expires_days,
)
invite_url = accounts.build_invite_url(invite.token)
delivered = False
if email:
try:
delivered = accounts.send_invite_email(email, invite_url)
except Exception:
delivered = False
return InviteCreateResponse(email=invite.email, invite_url=invite_url, delivered=delivered)

View File

@@ -1,9 +1,18 @@
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, field_validator, model_validator
class RegisterRequest(BaseModel):
username: str = Field(min_length=3, max_length=64)
password: str = Field(min_length=8, max_length=128)
invite_token: str | None = Field(default=None, min_length=8, max_length=256)
@field_validator("invite_token", mode="before")
@classmethod
def normalize_invite_token(cls, v: object) -> object:
if isinstance(v, str):
s = v.strip()
return s if s else None
return v
@field_validator("username")
@classmethod
@@ -27,20 +36,59 @@ class UserPublic(BaseModel):
model_config = {"from_attributes": True}
class UserCreateAdmin(BaseModel):
username: str = Field(min_length=3, max_length=64)
password: str = Field(min_length=8, max_length=128)
is_superuser: bool = False
class UserUpdateAdmin(BaseModel):
username: str | None = Field(default=None, min_length=3, max_length=64)
password: str | None = Field(default=None, min_length=8, max_length=128)
is_superuser: bool | None = None
@field_validator("username", "password", mode="before")
@classmethod
def empty_str_to_none(cls, v: object) -> object:
if isinstance(v, str) and not v.strip():
return None
return v
@field_validator("username")
@classmethod
def username_chars(cls, v: str) -> str:
def username_chars(cls, v: str | None) -> str | None:
if v is None:
return None
s = v.strip()
if not s.replace("_", "").isalnum():
raise ValueError("Username may only contain letters, numbers, and underscores")
return s
@model_validator(mode="after")
def none_empty_patch(self):
if self.username is None and self.password is None and self.is_superuser is None:
raise ValueError("Provide username, password, and/or superuser changes")
return self
class AuthStatusResponse(BaseModel):
auth_enabled: bool
register_open: bool
invite_required: bool = False
class InviteCreateRequest(BaseModel):
email: str | None = Field(default=None, max_length=320)
expires_days: int = Field(default=7, ge=1, le=30)
@field_validator("email")
@classmethod
def email_sane(cls, v: str | None) -> str | None:
if v is None:
return None
s = v.strip().lower()
if not s:
return None
if "@" not in s or "." not in s.split("@")[-1]:
raise ValueError("Invalid email address")
return s
class InviteCreateResponse(BaseModel):
email: str
invite_url: str
delivered: bool

View File

@@ -3,13 +3,16 @@ from __future__ import annotations
import datetime as dt
import os
import secrets
import smtplib
from email.message import EmailMessage
from typing import TYPE_CHECKING
import bcrypt
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from editor_app.db.models import AuthSession, User
from editor_app.db.models import AuthSession, InviteToken, User
from editor_app.services import user_workspace
if TYPE_CHECKING:
pass
@@ -65,6 +68,10 @@ def create_user(db: Session, username: str, password: str, *, is_superuser: bool
db.add(user)
db.commit()
db.refresh(user)
if auth_enabled():
user_workspace.ensure_default_code_main(
user_workspace.user_workspace_root(user.id, user.username)
)
return user
@@ -75,6 +82,20 @@ def register_user(db: Session, username: str, password: str) -> User:
return create_user(db, username, password, is_superuser=first)
def register_user_with_invite(db: Session, username: str, password: str, invite_token: str) -> User:
invite = get_valid_invite(db, invite_token)
if invite is None:
raise ValueError("Invite is invalid or expired")
if get_user_by_username(db, username):
raise ValueError("Username already taken")
user = create_user(db, username, password, is_superuser=bool(invite.grants_superuser))
invite.used_at = _utc_naive()
invite.consumed_by_user_id = user.id
db.add(invite)
db.commit()
return user
def authenticate(db: Session, username: str, password: str) -> User | None:
user = get_user_by_username(db, username.strip())
if not user or not verify_password(password, user.password_hash):
@@ -126,3 +147,116 @@ def delete_user(db: Session, user_id: int) -> bool:
db.delete(user)
db.commit()
return True
def update_user(
db: Session,
user_id: int,
*,
username: str | None = None,
password: str | None = None,
is_superuser: bool | None = None,
) -> User | None:
user = get_user_by_id(db, user_id)
if user is None:
return None
old_username = user.username
if username is not None:
normalized = username.strip()
if normalized != old_username:
dup = db.scalars(select(User).where(User.username == normalized, User.id != user_id)).first()
if dup is not None:
raise ValueError("Username already taken")
user_workspace.rename_user_workspace_leaf(user_id, old_username, normalized)
user.username = normalized
if password is not None:
user.password_hash = hash_password(password)
if is_superuser is not None:
if user.is_superuser and not is_superuser:
remaining = db.scalar(
select(func.count())
.select_from(User)
.where(User.is_superuser.is_(True), User.id != user_id)
)
if not remaining:
raise ValueError("Cannot demote the last administrator")
user.is_superuser = is_superuser
db.add(user)
db.commit()
db.refresh(user)
return user
def invite_required() -> bool:
return os.environ.get("AUTH_INVITE_ONLY", "true").strip().lower() in ("1", "true", "yes", "on")
def create_invite(
db: Session,
email: str,
invited_by_user_id: int | None = None,
expires_days: int = 7,
) -> InviteToken:
token = secrets.token_urlsafe(36)
expires = _utc_naive() + dt.timedelta(days=max(1, min(30, int(expires_days))))
row = InviteToken(
email=email.strip().lower(),
token=token,
expires_at=expires,
invited_by_user_id=invited_by_user_id,
)
db.add(row)
db.commit()
db.refresh(row)
return row
def get_valid_invite(db: Session, token: str | None) -> InviteToken | None:
if not token:
return None
row = db.scalars(select(InviteToken).where(InviteToken.token == token.strip())).one_or_none()
if row is None:
return None
if row.used_at is not None:
return None
if row.expires_at < _utc_naive():
return None
return row
def build_invite_url(token: str) -> str:
base = (os.environ.get("PUBLIC_BASE_URL") or "http://127.0.0.1:8080").rstrip("/")
return f"{base}/register?invite={token}"
def send_invite_email(email: str, invite_url: str) -> bool:
host = (os.environ.get("SMTP_HOST") or "").strip()
if not host:
return False
port = int((os.environ.get("SMTP_PORT") or "587").strip())
user = (os.environ.get("SMTP_USER") or "").strip()
password = os.environ.get("SMTP_PASSWORD") or ""
sender = (os.environ.get("SMTP_FROM") or user or "noreply@python-editor.local").strip()
use_tls = (os.environ.get("SMTP_TLS", "true").strip().lower() in ("1", "true", "yes", "on"))
msg = EmailMessage()
msg["Subject"] = "Your Python Editor invite"
msg["From"] = sender
msg["To"] = email
msg.set_content(
"You have been invited to Python Editor.\n\n"
f"Use this link to sign up:\n{invite_url}\n\n"
"If you did not expect this invite, you can ignore this message.\n"
)
with smtplib.SMTP(host, port, timeout=10) as smtp:
if use_tls:
smtp.starttls()
if user:
smtp.login(user, password)
smtp.send_message(msg)
return True

View File

@@ -10,6 +10,20 @@ LIB_DIR_NAME = "lib"
WRITABLE_ROOTS = {"code"}
def _workspace_root(workspace_root: Path | None = None) -> Path:
return (workspace_root or config.WORKSPACE_ROOT).resolve()
def _shared_lib_root() -> Path:
"""Shared MicroPython stubs ship in the repo `lib/` directory and are read directly.
Treating the bundle as the single source of truth means there is no on-disk
`workspace/lib/` cache to keep in sync — updates to e.g. `machine.py` flow
straight through to Pyodide and the file tree.
"""
return (config.PROJECT_ROOT / LIB_DIR_NAME).resolve()
def normalize_relative_path(relative_path: str) -> str:
cleaned = (relative_path or "").strip().lstrip("/")
if not cleaned:
@@ -22,33 +36,40 @@ def normalize_relative_path(relative_path: str) -> str:
return "/".join(parts)
def resolve_workspace_path(relative_path: str) -> Path:
def resolve_workspace_path(relative_path: str, workspace_root: Path | None = None) -> Path:
relative_path = normalize_relative_path(relative_path)
target_path = (config.WORKSPACE_ROOT / relative_path).resolve()
root = _workspace_root(workspace_root)
if relative_path == LIB_DIR_NAME or relative_path.startswith(f"{LIB_DIR_NAME}/"):
suffix = relative_path[len(LIB_DIR_NAME) :].lstrip("/")
target_path = (_shared_lib_root() / suffix).resolve()
try:
target_path.relative_to(config.WORKSPACE_ROOT.resolve())
target_path.relative_to(_shared_lib_root())
except ValueError as exc:
raise HTTPException(status_code=400, detail="Path escapes shared lib") from exc
return target_path
target_path = (root / relative_path).resolve()
try:
target_path.relative_to(root)
except ValueError as exc:
raise HTTPException(status_code=400, detail="Path escapes workspace") from exc
return target_path
def _is_path_in_lib(target_path: Path) -> bool:
workspace = config.WORKSPACE_ROOT.resolve()
lib_root = (workspace / LIB_DIR_NAME).resolve()
def _is_path_in_lib(target_path: Path, workspace_root: Path | None = None) -> bool:
try:
target_path.resolve().relative_to(lib_root)
target_path.resolve().relative_to(_shared_lib_root())
return True
except ValueError:
return False
def _ensure_not_lib_path(target_path: Path) -> None:
if _is_path_in_lib(target_path):
def _ensure_not_lib_path(target_path: Path, workspace_root: Path | None = None) -> None:
if _is_path_in_lib(target_path, workspace_root):
raise HTTPException(status_code=403, detail="lib is read-only")
def _is_writable_path(target_path: Path) -> bool:
workspace = config.WORKSPACE_ROOT.resolve()
def _is_writable_path(target_path: Path, workspace_root: Path | None = None) -> bool:
workspace = _workspace_root(workspace_root)
resolved = target_path.resolve()
try:
relative = resolved.relative_to(workspace)
@@ -59,17 +80,18 @@ def _is_writable_path(target_path: Path) -> bool:
return relative.parts[0] in WRITABLE_ROOTS
def _ensure_writable_path(target_path: Path) -> None:
if not _is_writable_path(target_path):
def _ensure_writable_path(target_path: Path, workspace_root: Path | None = 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)",
)
def list_files(path: str = "") -> list[FileInfo]:
def list_files(path: str = "", workspace_root: Path | None = None) -> list[FileInfo]:
path = normalize_relative_path(path)
target_path = config.WORKSPACE_ROOT / path if path else config.WORKSPACE_ROOT
root = _workspace_root(workspace_root)
target_path = resolve_workspace_path(path, root) if path else root
if not target_path.exists() or not target_path.is_dir():
raise HTTPException(status_code=404, detail="Directory not found")
@@ -77,6 +99,8 @@ def list_files(path: str = "") -> list[FileInfo]:
for item in sorted(target_path.iterdir()):
if item.name.startswith("."):
continue
if not path and item.name == "users":
continue
files.append(
FileInfo(
name=item.name,
@@ -84,11 +108,15 @@ def list_files(path: str = "") -> list[FileInfo]:
size=item.stat().st_size if item.is_file() else None,
)
)
if not path:
shared_lib = _shared_lib_root()
if shared_lib.exists() and not any(f.name == LIB_DIR_NAME for f in files):
files.append(FileInfo(name=LIB_DIR_NAME, is_directory=True, size=None))
return files
def read_text_file(file_path: str) -> tuple[str, str]:
target_path = resolve_workspace_path(file_path)
def read_text_file(file_path: str, workspace_root: Path | None = None) -> tuple[str, str]:
target_path = resolve_workspace_path(file_path, workspace_root)
if not target_path.exists():
raise HTTPException(status_code=404, detail="File not found")
if target_path.is_dir():
@@ -100,19 +128,19 @@ def read_text_file(file_path: str) -> tuple[str, str]:
return content, target_path.name
def save_text_file(file_path: str, content: str) -> str:
target_path = resolve_workspace_path(file_path)
_ensure_not_lib_path(target_path)
_ensure_writable_path(target_path)
def save_text_file(file_path: str, content: str, workspace_root: Path | None = None) -> str:
target_path = resolve_workspace_path(file_path, workspace_root)
_ensure_not_lib_path(target_path, workspace_root)
_ensure_writable_path(target_path, workspace_root)
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(content, encoding="utf-8")
return target_path.name
def delete_file(file_path: str) -> None:
target_path = resolve_workspace_path(file_path)
_ensure_not_lib_path(target_path)
_ensure_writable_path(target_path)
def delete_file(file_path: str, workspace_root: Path | None = None) -> None:
target_path = resolve_workspace_path(file_path, workspace_root)
_ensure_not_lib_path(target_path, workspace_root)
_ensure_writable_path(target_path, workspace_root)
if not target_path.exists():
raise HTTPException(status_code=404, detail="File not found")
if target_path.is_dir():
@@ -120,20 +148,21 @@ def delete_file(file_path: str) -> None:
target_path.unlink()
def move_path(source_path: str, destination_folder: str) -> tuple[str, str]:
source = resolve_workspace_path(source_path)
_ensure_not_lib_path(source)
_ensure_writable_path(source)
def move_path(source_path: str, destination_folder: str, workspace_root: Path | None = None) -> tuple[str, str]:
root = _workspace_root(workspace_root)
source = resolve_workspace_path(source_path, root)
_ensure_not_lib_path(source, root)
_ensure_writable_path(source, root)
if not source.exists():
raise HTTPException(status_code=404, detail="Source path not found")
destination_dir = (
resolve_workspace_path(destination_folder)
resolve_workspace_path(destination_folder, root)
if destination_folder
else config.WORKSPACE_ROOT
else root
)
_ensure_not_lib_path(destination_dir)
_ensure_writable_path(destination_dir)
_ensure_not_lib_path(destination_dir, root)
_ensure_writable_path(destination_dir, root)
if not destination_dir.exists() or not destination_dir.is_dir():
raise HTTPException(status_code=404, detail="Destination folder not found")
@@ -157,23 +186,23 @@ def move_path(source_path: str, destination_folder: str) -> tuple[str, str]:
destination.parent.mkdir(parents=True, exist_ok=True)
source.rename(destination)
moved_type = "folder" if destination.is_dir() else "file"
return str(destination.relative_to(config.WORKSPACE_ROOT)), moved_type
return str(destination.relative_to(root)), moved_type
def create_folder(folder_path: str) -> str:
target_path = resolve_workspace_path(folder_path)
_ensure_not_lib_path(target_path)
_ensure_writable_path(target_path)
def create_folder(folder_path: str, workspace_root: Path | None = None) -> str:
target_path = resolve_workspace_path(folder_path, workspace_root)
_ensure_not_lib_path(target_path, workspace_root)
_ensure_writable_path(target_path, workspace_root)
if target_path.exists():
raise HTTPException(status_code=400, detail="Folder already exists")
target_path.mkdir(parents=True, exist_ok=False)
return target_path.name
def delete_folder(folder_path: str) -> None:
target_path = resolve_workspace_path(folder_path)
_ensure_not_lib_path(target_path)
_ensure_writable_path(target_path)
def delete_folder(folder_path: str, workspace_root: Path | None = None) -> None:
target_path = resolve_workspace_path(folder_path, workspace_root)
_ensure_not_lib_path(target_path, workspace_root)
_ensure_writable_path(target_path, workspace_root)
if not target_path.exists():
raise HTTPException(status_code=404, detail="Folder not found")
if not target_path.is_dir():
@@ -181,12 +210,11 @@ def delete_folder(folder_path: str) -> None:
shutil.rmtree(target_path)
def collect_python_sources() -> dict[str, str]:
"""Return all UTF-8 .py files under the workspace for browser-side Pyodide sync."""
def collect_python_sources(workspace_root: Path | None = None) -> dict[str, str]:
"""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 = config.WORKSPACE_ROOT.resolve()
if not workspace.exists():
return result
workspace = _workspace_root(workspace_root)
if workspace.exists():
for path in workspace.rglob("*.py"):
try:
rel = path.relative_to(workspace)
@@ -199,4 +227,18 @@ def collect_python_sources() -> dict[str, str]:
result[key] = path.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
continue
shared_lib = _shared_lib_root()
if shared_lib.is_dir():
for path in shared_lib.rglob("*.py"):
try:
rel = path.relative_to(shared_lib)
except ValueError:
continue
if any(part.startswith(".") for part in rel.parts):
continue
try:
key = str(Path(LIB_DIR_NAME) / rel).replace("\\", "/")
result[key] = path.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
continue
return result

View File

@@ -0,0 +1,112 @@
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)

View File

@@ -0,0 +1,55 @@
"""ADC slider demo — drag the sliders that appear under the editor.
Two simulated ADCs:
* pin 34 — sets the base hue of a rainbow
* pin 35 — sets overall brightness
The strip lights up while the script runs; the values update live (no need to
restart the script when you move the slider).
"""
import time
from machine import ADC, Pin
from neopixel import NeoPixel
NUM_LEDS = 16
strip = NeoPixel(Pin(5, Pin.OUT), NUM_LEDS)
hue_pot = ADC(Pin(34))
bri_pot = ADC(Pin(35))
def hsv_to_rgb(h, s, v):
h = h - int(h)
i = int(h * 6)
f = h * 6 - i
p = v * (1 - s)
q = v * (1 - f * s)
t = v * (1 - (1 - f) * s)
if i == 0:
r, g, b = v, t, p
elif i == 1:
r, g, b = q, v, p
elif i == 2:
r, g, b = p, v, t
elif i == 3:
r, g, b = p, q, v
elif i == 4:
r, g, b = t, p, v
else:
r, g, b = v, p, q
return int(r * 255), int(g * 255), int(b * 255)
print("Move the ADC sliders below the editor while this runs.")
while True:
base_hue = hue_pot.read_u16() / 65535
brightness = bri_pot.read_u16() / 65535
for i in range(NUM_LEDS):
h = (base_hue + i / NUM_LEDS) % 1.0
strip[i] = hsv_to_rgb(h, 1.0, brightness)
strip.write()
time.sleep(0.04)

View File

@@ -0,0 +1,143 @@
"""LED pattern helpers inspired by embedded NeoPixel drivers."""
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:
"""Return a rainbow color for position 0-255."""
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]:
"""Generate one rainbow frame across all LEDs."""
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]:
"""Generate a two-pixel chase pattern."""
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]:
"""Generate deterministic twinkle frames for testing/replay."""
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

View File

@@ -0,0 +1,50 @@
"""LED tutorial script for NeoPixel in the browser editor.
Run this file and watch the in-app NeoPixel simulator panel.
"""
from machine import Pin
import neopixel
import time
LED_COUNT = 12
np = neopixel.NeoPixel(Pin(4), LED_COUNT)
def show_step(title: str):
print(f"\n--- {title} ---")
show_step("Step 1: single colors")
np.fill((0, 0, 0))
np.write()
time.sleep(0.2)
np[0] = (255, 0, 0) # red
np[1] = (0, 255, 0) # green
np[2] = (0, 0, 255) # blue
np.write()
time.sleep(0.8)
show_step("Step 2: fill strip")
np.fill((40, 0, 120))
np.write()
time.sleep(0.6)
show_step("Step 3: moving pixel")
for i in range(len(np)):
np.fill((0, 0, 0))
np[i] = (255, 120, 0)
np.write()
time.sleep(0.06)
show_step("Step 4: simple pulse")
for level in list(range(0, 200, 20)) + list(range(200, -1, -20)):
np.fill((level, 0, level // 3))
np.write()
time.sleep(0.05)
show_step("Done")
np.fill((0, 0, 0))
np.write()
print("Tutorial complete.")

View File

@@ -0,0 +1,10 @@
{
"files": [
"pattern_rainbow_demo.py",
"pattern_twinkle_demo.py",
"pattern_chase_demo.py",
"adc_slider_demo.py",
"pin_demo.py",
"serial_demo.py"
]
}

View File

@@ -0,0 +1,12 @@
"""Example ESP32 NeoPixel script using mock modules in browser."""
from machine import Pin
import neopixel
np = neopixel.NeoPixel(Pin(4), 8)
np.fill((0, 0, 0))
np[0] = (255, 0, 0)
np[1] = (0, 255, 0)
np[2] = (0, 0, 255)
np.write()

View File

@@ -0,0 +1,32 @@
"""NeoPixel time-based animation test for the browser simulator."""
from machine import Pin
import neopixel
import time
np = neopixel.NeoPixel(Pin(4), 50)
def wheel(pos: int) -> tuple[int, int, int]:
"""Generate rainbow colors across 0-255 positions."""
pos = 255 - (pos & 255)
if pos < 85:
return (255 - pos * 3, 0, pos * 3)
if pos < 170:
pos -= 85
return (0, pos * 3, 255 - pos * 3)
pos -= 170
return (pos * 3, 255 - pos * 3, 0)
print("Starting NeoPixel time test...")
for frame in range(60):
for i in range(len(np)):
np[i] = wheel((i * 256 // len(np) + frame * 4) & 255)
np.write()
time.sleep(0.08)
np.fill((0, 0, 0))
np.write()
print("NeoPixel time test complete.")

View File

@@ -0,0 +1,41 @@
"""16x16 bouncing pixel with fading trail."""
from machine import Pin
import neopixel
import time
from panel16_utils import PANEL_H, PANEL_W, clamp8, xy_to_index
np = neopixel.NeoPixel(Pin(4), PANEL_W * PANEL_H)
trail = [[0 for _ in range(PANEL_W)] for _ in range(PANEL_H)]
x = 0
y = 0
vx = 1
vy = 1
for _frame in range(420):
for yy in range(PANEL_H):
for xx in range(PANEL_W):
trail[yy][xx] = max(0, trail[yy][xx] - 14)
trail[y][x] = 255
for yy in range(PANEL_H):
for xx in range(PANEL_W):
v = trail[yy][xx]
np[xy_to_index(xx, yy)] = (clamp8(v), clamp8(v // 2), clamp8(30))
np.write()
time.sleep(0.02)
x += vx
y += vy
if x <= 0 or x >= PANEL_W - 1:
vx *= -1
if y <= 0 or y >= PANEL_H - 1:
vy *= -1
np.fill((0, 0, 0))
np.write()

View File

@@ -0,0 +1,37 @@
"""16x16 matrix-style rain animation."""
from machine import Pin
import neopixel
import random
import time
from panel16_utils import PANEL_H, PANEL_W, clamp8, xy_to_index
np = neopixel.NeoPixel(Pin(4), PANEL_W * PANEL_H)
rng = random.Random(42)
heads = [rng.randrange(-PANEL_H, 0) for _ in range(PANEL_W)]
for _frame in range(320):
for y in range(PANEL_H):
for x in range(PANEL_W):
np[xy_to_index(x, y)] = (0, 0, 0)
for x in range(PANEL_W):
heads[x] += 1
if heads[x] > PANEL_H + 6:
heads[x] = rng.randrange(-PANEL_H, 0)
head_y = heads[x]
for tail in range(8):
y = head_y - tail
if 0 <= y < PANEL_H:
brightness = clamp8(255 - tail * 36)
np[xy_to_index(x, y)] = (0, brightness, 0)
np.write()
time.sleep(0.045)
np.fill((0, 0, 0))
np.write()

View File

@@ -0,0 +1,33 @@
"""16x16 rainbow wave animation."""
from machine import Pin
import neopixel
import time
from panel16_utils import PANEL_H, PANEL_W, clamp8, xy_to_index
np = neopixel.NeoPixel(Pin(4), PANEL_W * PANEL_H)
def wheel(pos: int) -> tuple[int, int, int]:
pos = 255 - (pos & 255)
if pos < 85:
return (clamp8(255 - pos * 3), 0, clamp8(pos * 3))
if pos < 170:
pos -= 85
return (0, clamp8(pos * 3), clamp8(255 - pos * 3))
pos -= 170
return (clamp8(pos * 3), clamp8(255 - pos * 3), 0)
for frame in range(240):
for y in range(PANEL_H):
for x in range(PANEL_W):
color_pos = ((x * 10) + (y * 6) + frame * 5) & 255
np[xy_to_index(x, y)] = wheel(color_pos)
np.write()
time.sleep(0.04)
np.fill((0, 0, 0))
np.write()

View File

@@ -0,0 +1,26 @@
"""Helpers for 16x16 serpentine NeoPixel panel animations.
Mapping matches the simulator's 16x16 mode:
- first LED at top-right
- row 0 goes right -> left
- row 1 goes left -> right
"""
from __future__ import annotations
PANEL_W = 16
PANEL_H = 16
def xy_to_index(x: int, y: int, width: int = PANEL_W) -> int:
"""Map panel coordinate (x, y) to LED index."""
x = int(x)
y = int(y)
if y % 2 == 0:
return y * width + (width - 1 - x)
return y * width + x
def clamp8(v: int) -> int:
return max(0, min(255, int(v)))

View File

@@ -0,0 +1,83 @@
"""Knight Riderstyle bouncing scanner — self-contained (stdlib + simulated hardware only)."""
import time
from machine import Pin
import neopixel
# --- helpers
def _clamp(channel: int) -> int:
return max(0, min(255, int(channel)))
def _bounce_head_index(led_count: int, frame: int) -> int:
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:
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=(220, 0, 28),
tail_len: int = 8,
falloff_gamma: float = 2.6,
):
if led_count <= 0:
return []
out = [(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
# --- demo
NUM_LEDS = 16
np = neopixel.NeoPixel(Pin(4), NUM_LEDS)
for frame in range(200):
frame_colors = knight_rider_scanner_frame(
len(np),
frame,
head_color=(220, 0, 36),
tail_len=10,
falloff_gamma=2.85,
)
for i, color in enumerate(frame_colors):
np[i] = color
np.write()
time.sleep(0.05)
np.fill((0, 0, 0))
np.write()

View File

@@ -0,0 +1,47 @@
"""Rainbow NeoPixel sweep — self-contained (stdlib + simulated hardware only)."""
import time
from machine import Pin
import neopixel
# --- helpers (same logic as bundled led_patterns.py, inlined here)
def _clamp(channel: int) -> int:
return max(0, min(255, int(channel)))
def wheel(pos: int):
"""Return rainbow RGB at position 0255."""
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):
if led_count <= 0:
return []
return [wheel((i * 256 // led_count + frame * step) & 255) for i in range(led_count)]
# --- demo
NUM_LEDS = 16
np = neopixel.NeoPixel(Pin(4), NUM_LEDS)
for frame in range(120):
frame_colors = rainbow_frame(len(np), frame, step=5)
for i, color in enumerate(frame_colors):
np[i] = color
np.write()
time.sleep(0.05)
np.fill((0, 0, 0))
np.write()

View File

@@ -0,0 +1,54 @@
"""Twinkle NeoPixel demo — self-contained (stdlib + simulated hardware only)."""
import random
import time
from machine import Pin
import neopixel
# --- helpers
def _clamp(channel: int) -> int:
return max(0, min(255, int(channel)))
def twinkle_frame(
led_count: int,
frame: int,
base=(0, 0, 8),
sparkle=(255, 255, 180),
sparkles: int = 3,
seed: int = 1337,
):
if led_count <= 0:
return []
out = [tuple(_clamp(v) for v in base) for _ in range(led_count)]
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)
return out
# --- demo
NUM_LEDS = 16
np = neopixel.NeoPixel(Pin(4), NUM_LEDS)
for frame in range(120):
frame_colors = twinkle_frame(
len(np),
frame,
base=(0, 0, 6),
sparkle=(255, 210, 130),
sparkles=3,
)
for i, color in enumerate(frame_colors):
np[i] = color
np.write()
time.sleep(0.08)
np.fill((0, 0, 0))
np.write()

View File

@@ -0,0 +1,56 @@
"""Pin features demo.
A "Pins" panel appears below the editor while this script runs:
* Pin 2 (OUT) — blinks automatically every ~200 ms via ``.value(...)``.
* Pin 4 (OUT) — stays put until you press the button, then flips
(driven from the IRQ handler with ``.toggle()``).
* Pin 0 (IN) — click the toggle button in the panel to drive a 0 → 1
rising edge; the IRQ fires and flips pin 4.
* Pin 13 (PWM) — duty sweeps up and down; the bar shows the live duty.
"""
import time
from machine import Pin, PWM
led_a = Pin(2, Pin.OUT)
led_b = Pin(4, Pin.OUT)
button = Pin(0, Pin.IN, Pin.PULL_UP)
fader = PWM(Pin(13), freq=1000, duty_u16=0)
def on_button(pin):
# Pin 4 is IRQ-driven on purpose — its only source of change is the
# button press, so when you see it flip you know the IRQ fired.
led_b.toggle()
button.irq(handler=on_button, trigger=Pin.IRQ_RISING)
tick = 0
duty = 0
direction = 1024
while True:
# Pin 2: fast on/off via direct .value(...) writes (no IRQ involvement).
led_a.value(tick % 2)
# Pin 13: triangular duty sweep so the PWM bar visibly fills and drains.
duty += direction
if duty >= 65535:
duty = 65535
direction = -1024
elif duty <= 0:
duty = 0
direction = 1024
fader.duty_u16(duty)
# Poll the IN pin so its IRQ actually fires when the panel button changes.
# (`.value()` reads the current state and dispatches any pending edge.)
button.value()
tick += 1
time.sleep(0.1)

View File

@@ -0,0 +1,90 @@
"""Serial in/out demo.
When this script runs, a "Serial monitor" pane appears below the editor.
Try this:
* type hello and press Enter -> Python echoes "echo: hello"
* type color red -> the strip turns red
* try color 0,128,255 -> any (r,g,b) tuple works
* type off -> strip blanks
* type bye -> script exits cleanly
Anything Python `write()`s to the UART shows up in green; what you type back
is shown in white.
"""
import time
from machine import Pin, UART
from neopixel import NeoPixel
NUM_LEDS = 16
strip = NeoPixel(Pin(5, Pin.OUT), NUM_LEDS)
uart = UART(0, baudrate=115200)
PALETTE = {
"red": (255, 0, 0),
"green": (0, 255, 0),
"blue": (0, 0, 255),
"white": (200, 200, 200),
"purple": (160, 0, 200),
"orange": (255, 110, 0),
}
def fill(color):
strip.fill(color)
strip.write()
def parse_color(arg):
arg = arg.strip().lower()
if arg in PALETTE:
return PALETTE[arg]
parts = [p for p in arg.replace(",", " ").split() if p]
if len(parts) == 3:
try:
return tuple(max(0, min(255, int(p))) for p in parts)
except ValueError:
return None
return None
uart.write("ready. commands: color <name|r,g,b> | off | bye\n")
fill((0, 0, 0))
running = True
while running:
line = uart.readline()
if line is None:
time.sleep(0.05)
continue
text = line.decode("utf-8", errors="replace").strip()
if not text:
continue
if text == "bye":
uart.write("goodbye!\n")
running = False
break
if text == "off":
fill((0, 0, 0))
uart.write("strip off\n")
continue
if text.startswith("color"):
rest = text[len("color"):].strip()
color = parse_color(rest) if rest else None
if color is None:
uart.write("usage: color <name> | color r,g,b\n")
else:
fill(color)
uart.write(f"strip = {color}\n")
continue
uart.write(f"echo: {text}\n")
fill((0, 0, 0))

View File

@@ -0,0 +1,419 @@
"""Minimal MicroPython-style machine module mock for browser simulation."""
import json
_PIN_MODE_OUT = 1
_PIN_MODE_IN = 2
_PIN_MODE_PWM = 4
def _pin_view_get(name):
try:
import js
return getattr(js, name, None)
except Exception:
return None
def _pin_out_publish(pin_id, ui_mode, value):
"""Pack [ui_mode (high byte) | value (low 24 bits)] for the UI panel."""
view = _pin_view_get("__pin_out_view")
if view is None:
return
packed = ((int(ui_mode) & 0xFF) << 24) | (int(value) & 0x00FFFFFF)
try:
import js
js.Atomics.store(view, int(pin_id), packed)
except Exception:
try:
view[int(pin_id)] = packed
except Exception:
pass
def _pin_in_read(pin_id):
view = _pin_view_get("__pin_in_view")
if view is None:
return 0
try:
import js
return int(js.Atomics.load(view, int(pin_id))) & 1
except Exception:
try:
return int(view[int(pin_id)]) & 1
except Exception:
return 0
def _pin_register(pin_id, ui_mode, **extra):
payload = {"pin": int(pin_id), "mode": int(ui_mode)}
payload.update({k: v for k, v in extra.items() if v is not None})
try:
print("[pin-register]" + json.dumps(payload))
except Exception:
pass
class Pin:
"""Browser-simulated `machine.Pin`.
A control row appears in the editor UI when a pin is constructed:
* `Pin.OUT` pins show a live indicator that mirrors `value()`.
* `Pin.IN` pins show a toggle button you can click — the next call
to `value()` returns 0 or 1 accordingly.
"""
IN = 0
OUT = 1
PULL_UP = 2
PULL_DOWN = 3
OPEN_DRAIN = 7
IRQ_FALLING = 1
IRQ_RISING = 2
def __init__(self, pin_id, mode=OUT, pull=-1, value=None, **_kwargs):
self.id = int(pin_id)
self.mode = int(mode)
self.pull = int(pull) if pull != -1 else -1
self._value = 1 if value else 0
self._irq_handler = None
self._irq_trigger = 0
self._last_input = 0
self._publish()
def _ui_mode(self):
if self.mode == self.IN:
return _PIN_MODE_IN
return _PIN_MODE_OUT
def _publish(self):
ui_mode = self._ui_mode()
_pin_register(self.id, ui_mode, pull=self.pull if self.pull != -1 else None)
if ui_mode == _PIN_MODE_OUT:
_pin_out_publish(self.id, ui_mode, self._value)
def init(self, mode=-1, pull=-1, value=None):
if mode != -1:
self.mode = int(mode)
if pull != -1:
self.pull = int(pull)
if value is not None:
self._value = 1 if int(value) else 0
self._publish()
def value(self, new_value=None):
if new_value is None:
if self.mode == self.IN:
v = _pin_in_read(self.id)
if self._irq_handler is not None:
if v and not self._last_input and (self._irq_trigger & self.IRQ_RISING):
self._fire_irq()
elif not v and self._last_input and (self._irq_trigger & self.IRQ_FALLING):
self._fire_irq()
self._last_input = v
return v
return self._value
v = 1 if int(new_value) else 0
self._value = v
if self._ui_mode() == _PIN_MODE_OUT:
_pin_out_publish(self.id, _PIN_MODE_OUT, v)
return v
def on(self):
return self.value(1)
def off(self):
return self.value(0)
def high(self):
return self.value(1)
def low(self):
return self.value(0)
def toggle(self):
return self.value(0 if (self._value if self.mode == self.OUT else _pin_in_read(self.id)) else 1)
def irq(self, handler=None, trigger=IRQ_RISING | IRQ_FALLING, **_kwargs):
self._irq_handler = handler
self._irq_trigger = int(trigger)
return self
def _fire_irq(self):
if self._irq_handler is None:
return
try:
self._irq_handler(self)
except Exception as exc:
try:
print(f"[pin] irq handler raised: {exc!r}")
except Exception:
pass
def __call__(self, *args):
return self.value(*args) if args else self.value()
class PWM:
"""Browser-simulated `machine.PWM`.
Visualises the configured duty cycle as a bar in the Pins panel.
`freq()` and `duty()`/`duty_u16()`/`duty_ns()` mirror MicroPython's API.
"""
def __init__(self, pin, freq=1000, duty=None, duty_u16=None, duty_ns=None):
self._pin_id = _adc_pin_id(pin)
self._freq = int(freq) if freq else 1000
self._duty_u16 = 0
if duty_u16 is not None:
self._duty_u16 = max(0, min(65535, int(duty_u16)))
elif duty is not None:
self._duty_u16 = max(0, min(65535, int(duty) * 64))
elif duty_ns is not None:
self._set_duty_ns(int(duty_ns))
_pin_register(self._pin_id, _PIN_MODE_PWM, freq=self._freq)
_pin_out_publish(self._pin_id, _PIN_MODE_PWM, self._duty_u16)
def _set_duty_ns(self, ns):
period_ns = 1_000_000_000 // max(1, self._freq)
if period_ns <= 0:
self._duty_u16 = 0
return
self._duty_u16 = max(0, min(65535, int(ns) * 65535 // period_ns))
def freq(self, value=None):
if value is None:
return self._freq
self._freq = max(1, int(value))
_pin_register(self._pin_id, _PIN_MODE_PWM, freq=self._freq)
def duty(self, value=None):
if value is None:
return self._duty_u16 // 64
self._duty_u16 = max(0, min(65535, int(value) * 64))
_pin_out_publish(self._pin_id, _PIN_MODE_PWM, self._duty_u16)
def duty_u16(self, value=None):
if value is None:
return self._duty_u16
self._duty_u16 = max(0, min(65535, int(value)))
_pin_out_publish(self._pin_id, _PIN_MODE_PWM, self._duty_u16)
def duty_ns(self, value=None):
period_ns = 1_000_000_000 // max(1, self._freq)
if value is None:
return (self._duty_u16 * period_ns) // 65535
self._set_duty_ns(int(value))
_pin_out_publish(self._pin_id, _PIN_MODE_PWM, self._duty_u16)
def deinit(self):
_pin_out_publish(self._pin_id, 0, 0)
def _adc_pin_id(pin):
if isinstance(pin, int):
return pin
return int(getattr(pin, "id", 0))
def _adc_read_raw(pin_id: int) -> int:
"""Read the live slider value (0..65535) shared from the editor UI.
Backed by a SharedArrayBuffer so updates from the browser slider propagate
instantly without the script having to yield. Falls back to 0 when the
runtime is not cross-origin isolated (e.g. older browsers).
"""
try:
import js # only available inside Pyodide
view = getattr(js, "__adc_view", None)
if view is None:
return 0
try:
return int(js.Atomics.load(view, pin_id)) & 0xFFFF
except Exception:
return int(view[pin_id]) & 0xFFFF
except Exception:
return 0
class ADC:
"""Browser-simulated `machine.ADC`.
Exposes a slider in the editor UI for the given pin. `read_u16()` returns
the slider value in the 0..65535 range (matching MicroPython's API);
`read()` scales to the configured `width` (defaults to 12-bit, 0..4095).
"""
WIDTH_9BIT = 9
WIDTH_10BIT = 10
WIDTH_11BIT = 11
WIDTH_12BIT = 12
ATTN_0DB = 0
ATTN_2_5DB = 1
ATTN_6DB = 2
ATTN_11DB = 3
def __init__(self, pin):
self._pin = _adc_pin_id(pin)
self._atten = self.ATTN_11DB
self._width = self.WIDTH_12BIT
try:
print("[adc-register]" + json.dumps({"pin": self._pin}))
except Exception:
pass
def atten(self, attn):
self._atten = int(attn)
def width(self, width):
self._width = int(width)
def read_u16(self):
return _adc_read_raw(self._pin)
def read(self):
bits = self._width if self._width in (9, 10, 11, 12) else 12
max_val = (1 << bits) - 1
return (_adc_read_raw(self._pin) * max_val) // 65535
def read_uv(self):
"""Return microvolts assuming a 0..3.3V range (rough simulation)."""
return (_adc_read_raw(self._pin) * 3_300_000) // 65535
def _serial_drain_pending(buf):
"""Pull any bytes the editor's serial-monitor has pushed into the SAB ring."""
try:
import js
import base64 # noqa: F401 (kept for symmetry with write path)
indices = getattr(js, "__serial_in_indices", None)
data = getattr(js, "__serial_in_data", None)
if indices is None or data is None:
return
cap = int(getattr(js, "__serial_in_capacity", 0))
if cap <= 0:
return
r = int(js.Atomics.load(indices, 0))
w = int(js.Atomics.load(indices, 1))
while r != w:
buf.append(int(data[r]) & 0xFF)
r = (r + 1) % cap
js.Atomics.store(indices, 0, r)
except Exception:
pass
class UART:
"""Browser-simulated `machine.UART`.
A serial monitor pane appears in the editor when this is constructed.
`write()` text shows up there; characters typed into the input box arrive
via `read()` / `readline()` / `any()`.
"""
INV_TX = 1
INV_RX = 2
INV_RTS = 4
INV_CTS = 8
def __init__(self, id=0, baudrate=115200, bits=8, parity=None, stop=1,
tx=None, rx=None, timeout=0, **_kwargs):
self.id = int(id) if id is not None else 0
self.baudrate = int(baudrate)
self.bits = int(bits)
self.parity = parity
self.stop = int(stop)
self.tx = tx
self.rx = rx
self.timeout = int(timeout) if timeout is not None else 0
self._buf = bytearray()
try:
print("[serial-register]" + json.dumps({
"id": self.id,
"baudrate": self.baudrate,
}))
except Exception:
pass
def init(self, *args, **kwargs):
if args:
try:
self.baudrate = int(args[0])
except Exception:
pass
if "baudrate" in kwargs:
self.baudrate = int(kwargs["baudrate"])
def deinit(self):
self._buf = bytearray()
def any(self):
_serial_drain_pending(self._buf)
return len(self._buf)
def read(self, nbytes=None):
_serial_drain_pending(self._buf)
if not self._buf:
return None
if nbytes is None:
out = bytes(self._buf)
self._buf = bytearray()
return out
n = min(int(nbytes), len(self._buf))
if n <= 0:
return None
out = bytes(self._buf[:n])
del self._buf[:n]
return out
def readinto(self, buf, nbytes=None):
n = int(nbytes) if nbytes is not None else len(buf)
data = self.read(n)
if not data:
return None
for i, b in enumerate(data):
buf[i] = b
return len(data)
def readline(self):
_serial_drain_pending(self._buf)
if not self._buf:
return None
nl = self._buf.find(b"\n")
if nl == -1:
return None
out = bytes(self._buf[: nl + 1])
del self._buf[: nl + 1]
return out
def write(self, data):
if isinstance(data, str):
buf = data.encode("utf-8")
elif isinstance(data, (bytes, bytearray, memoryview)):
buf = bytes(data)
else:
buf = bytes([int(data) & 0xFF])
try:
import base64
encoded = base64.b64encode(buf).decode("ascii")
print(f"[serial-out]{encoded}")
except Exception:
pass
return len(buf)
def sendbreak(self):
pass
def flush(self):
pass
def txdone(self):
return True

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

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Python Editor</title>
<title>LED Editor</title>
<link rel="icon" href="data:,">
<style>
* { box-sizing: border-box; }
@@ -15,129 +15,152 @@
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: linear-gradient(135deg, #0f172a, #1e293b);
color: #e2e8f0;
padding: 1rem;
}
.home-card {
width: min(560px, 92vw);
width: min(420px, 100%);
background: rgba(15, 23, 42, 0.78);
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: 14px;
padding: 2rem;
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.35);
text-align: center;
}
h1 {
margin: 0 0 0.5rem 0;
font-size: 1.9rem;
color: #f8fafc;
letter-spacing: 0.02em;
}
p.tagline {
margin: 0 0 1.75rem 0;
color: #94a3b8;
font-size: 0.95rem;
}
.actions {
display: grid;
gap: 0.65rem;
}
h1 { margin: 0 0 0.75rem 0; font-size: 1.8rem; color: #f8fafc; }
p { margin: 0 0 1rem 0; color: #cbd5e1; line-height: 1.5; }
.btn {
display: inline-block;
display: block;
width: 100%;
text-decoration: none;
border-radius: 8px;
padding: 0.65rem 1rem;
border-radius: 10px;
padding: 0.85rem 1rem;
font-weight: 600;
border: 1px solid transparent;
cursor: pointer;
font-size: 1rem;
text-align: center;
}
.btn-primary { background: #3b82f6; color: #ffffff; }
.btn-ghost { background: transparent; border-color: #64748b; color: #e2e8f0; }
label { display: block; font-size: 0.85rem; color: #94a3b8; margin-bottom: 0.35rem; }
input[type="password"] {
width: 100%;
padding: 0.5rem 0.65rem;
border-radius: 8px;
border: 1px solid #64748b;
background: #0f172a;
.btn-primary:hover { background: #2563eb; }
.btn-ghost {
background: transparent;
border-color: #475569;
color: #e2e8f0;
margin-bottom: 0.75rem;
}
.note { font-size: 0.8rem; color: #94a3b8; margin-top: 1rem; }
.nav { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem; align-items: center; }
.nav span { color: #94a3b8; font-size: 0.9rem; }
.btn-ghost:hover { background: rgba(71, 85, 105, 0.25); }
.btn-link {
background: transparent;
border: none;
color: #93c5fd;
font-size: 0.85rem;
cursor: pointer;
padding: 0.4rem;
text-decoration: underline;
}
.signed-in {
font-size: 0.85rem;
color: #94a3b8;
margin-bottom: 0.6rem;
}
.hidden { display: none !important; }
.footnote {
margin-top: 1.25rem;
font-size: 0.78rem;
color: #64748b;
line-height: 1.4;
}
.footnote a { color: #93c5fd; text-decoration: none; }
.footnote a:hover { text-decoration: underline; }
</style>
</head>
<body>
<main class="home-card">
<h1>Python Editor</h1>
<div id="auth-nav" class="nav">
<span id="auth-greeting" class="hidden"></span>
<a class="btn btn-ghost hidden" id="link-login" href="/login">Sign in</a>
<a class="btn btn-ghost hidden" id="link-register" href="/register">Register</a>
<button type="button" class="btn btn-ghost hidden" id="btn-logout">Sign out</button>
<h1>LED Editor</h1>
<p class="tagline">Run MicroPython in your browser. Drive simulated NeoPixels, pins, ADC, and serial.</p>
<div id="signed-in-row" class="signed-in hidden"></div>
<div class="actions">
<a class="btn btn-primary hidden" id="btn-signin" href="/login">Sign in</a>
<a class="btn btn-primary hidden" id="btn-open-editor" href="/editor">Open editor</a>
<a class="btn btn-ghost" id="btn-use-locally" href="/editor?local=1">Use locally (no login)</a>
<a class="btn-link hidden" id="btn-manage-users" href="/users">Manage users</a>
<button type="button" class="btn-link hidden" id="btn-signout">Sign out</button>
</div>
<p>Edit and store files on the server. Python runs in your browser with <a href="https://pyodide.org/" style="color:#93c5fd">Pyodide</a>.</p>
<div id="optional-api-key">
<p>If you use <code style="color:#fcd34d">EDITOR_API_KEY</code> (without user login), store it here for API calls from this browser tab:</p>
<label for="api-key">API key (optional)</label>
<input id="api-key" type="password" autocomplete="off" placeholder="Leave blank if not used" />
<p class="note">The key is kept in <code>sessionStorage</code>. You can also use <code>?api_key=…</code> on the editor URL.</p>
</div>
<a class="btn btn-primary" href="/editor" id="open-editor">Open Editor</a>
<p class="footnote">
Local mode keeps files in this browser's <code>IndexedDB</code>; from the editor you can also pick a folder on disk
(<a href="https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API" target="_blank" rel="noopener">File System Access API</a>, Chromium-only).
</p>
</main>
<script>
const storageKey = 'python-editor.api_key';
const input = document.getElementById('api-key');
const openLink = document.getElementById('open-editor');
async function init() {
const signedInRow = document.getElementById('signed-in-row');
const btnSignIn = document.getElementById('btn-signin');
const btnOpenEditor = document.getElementById('btn-open-editor');
const btnSignOut = document.getElementById('btn-signout');
const btnManageUsers = document.getElementById('btn-manage-users');
async function refreshAuthNav() {
let authEnabled = false;
try {
const st = await fetch('/api/auth/status');
if (st.ok) {
const status = await st.json();
const loginEl = document.getElementById('link-login');
const regEl = document.getElementById('link-register');
const outEl = document.getElementById('btn-logout');
const greet = document.getElementById('auth-greeting');
const optionalKey = document.getElementById('optional-api-key');
if (!status.auth_enabled) {
loginEl.classList.add('hidden');
regEl.classList.add('hidden');
outEl.classList.add('hidden');
greet.classList.add('hidden');
authEnabled = Boolean(status.auth_enabled);
}
} catch (_e) {
// No backend (static-only host) → behave like auth disabled.
}
if (!authEnabled) {
// No login wall, so "Sign in" makes no sense — go straight to editor.
btnOpenEditor.classList.remove('hidden');
return;
}
loginEl.classList.remove('hidden');
if (status.register_open) {
regEl.classList.remove('hidden');
}
const me = await fetch('/api/auth/me', { credentials: 'include' });
if (me.ok) {
const j = await me.json();
greet.textContent = `Signed in as ${j.user.username}`;
greet.classList.remove('hidden');
loginEl.classList.add('hidden');
regEl.classList.add('hidden');
outEl.classList.remove('hidden');
if (optionalKey) optionalKey.classList.add('hidden');
} else {
outEl.classList.add('hidden');
greet.classList.add('hidden');
}
let me = null;
try {
const meRes = await fetch('/api/auth/me', { credentials: 'include' });
if (meRes.ok) me = (await meRes.json()).user;
} catch (_e) {
// ignore
}
document.getElementById('btn-logout').addEventListener('click', async () => {
if (!me) {
btnSignIn.classList.remove('hidden');
return;
}
signedInRow.textContent = `Signed in as ${me.username}${me.is_superuser ? ' (admin)' : ''}`;
signedInRow.classList.remove('hidden');
btnOpenEditor.classList.remove('hidden');
btnSignOut.classList.remove('hidden');
if (me.is_superuser) btnManageUsers.classList.remove('hidden');
}
document.getElementById('btn-signout').addEventListener('click', async () => {
try {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
} catch (_e) {
// ignore
}
window.location.reload();
});
try {
const params = new URLSearchParams(window.location.search);
const fromQuery = params.get('api_key');
if (fromQuery) {
sessionStorage.setItem(storageKey, fromQuery);
}
const existing = sessionStorage.getItem(storageKey);
if (existing) {
input.value = existing;
}
} catch (_e) {}
openLink.addEventListener('click', () => {
try {
const v = input.value.trim();
if (v) {
sessionStorage.setItem(storageKey, v);
} else {
sessionStorage.removeItem(storageKey);
}
} catch (_e) {}
});
refreshAuthNav();
init();
</script>
</body>
</html>

View File

@@ -2,14 +2,16 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Python Editor</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#2d3748">
<title>LED Editor</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="/static/styles.css?v=6">
<link rel="stylesheet" href="/static/styles.css?v=36">
</head>
<body>
<div class="container">
<div class="sidebar">
<div class="sidebar-backdrop" id="sidebar-backdrop" hidden></div>
<div class="sidebar" id="sidebar">
<div class="sidebar-header">
<h3>Files</h3>
<div class="sidebar-actions">
@@ -26,19 +28,31 @@
<div class="main-content">
<div class="editor-header">
<button id="sidebar-toggle" class="sidebar-toggle" type="button" aria-label="Toggle file tree" aria-controls="sidebar" aria-expanded="true" title="Toggle file browser"><span class="sidebar-toggle-icon" aria-hidden="true"></span></button>
<div class="file-info">
<span id="save-status" class="save-status"></span>
<span class="runtime-hint" title="Python runs locally in your browser via Pyodide; completions use Jedi in the same runtime.">Browser · Pyodide</span>
</div>
<div class="mode-toggle">
<a id="home-btn" class="mode-btn active" href="/">Home</a>
<span id="workspace-badge" class="runtime-hint hidden"></span>
</div>
<div class="editor-actions">
<button id="run-btn" disabled>Run Python</button>
<button id="stop-btn" disabled>Stop</button>
<select id="run-file-select" title="Script to run">
<option value="">Run active file</option>
</select>
<button id="run-btn" class="icon-btn" disabled aria-label="Run" title="Run"></button>
<button id="stop-btn" class="icon-btn" disabled aria-label="Stop" title="Stop"></button>
<details class="header-menu" id="header-menu">
<summary class="menu-toggle" aria-label="Editor options" title="Options"></summary>
<div class="menu-content" role="menu">
<a href="/" class="menu-item" id="home-btn" role="menuitem">🏠 Home</a>
<label class="menu-item menu-checkbox">
<input type="checkbox" id="run-main-checkbox" />
Run main.py
</label>
<label class="menu-item menu-checkbox">
<input type="checkbox" id="panel-16x16-checkbox" />
16×16 panel
</label>
<div class="menu-separator" role="separator"></div>
<div class="menu-section-label" id="workspace-menu-label">Workspace</div>
<div id="workspace-menu-actions" role="group"></div>
</div>
</details>
</div>
</div>
@@ -49,8 +63,48 @@
<div id="completion-dropdown" class="completion-dropdown"></div>
</div>
<div class="console-container">
<div class="console-header">Console Output</div>
<section id="led-sim-panel" class="led-sim-panel hidden" aria-label="NeoPixel Simulator">
<div id="led-grid" class="led-grid"></div>
</section>
<section id="pin-panel" class="pin-panel hidden" aria-label="Pins">
<div class="pin-panel-header">
<span class="pin-panel-title">Pins</span>
<span class="pin-panel-hint">OUT shows live state · IN is clickable · PWM shows duty</span>
</div>
<div id="pin-rows" class="pin-rows"></div>
</section>
<section id="adc-panel" class="adc-panel hidden" aria-label="ADC inputs">
<div class="adc-panel-header">
<span class="adc-panel-title">ADC inputs</span>
<span class="adc-panel-hint">drag to set value (065535)</span>
</div>
<div id="adc-sliders" class="adc-sliders"></div>
</section>
<section id="serial-panel" class="serial-panel hidden" aria-label="Serial monitor">
<div class="serial-header">
<span class="serial-title">Serial monitor</span>
<span id="serial-meta" class="serial-meta"></span>
<button type="button" id="serial-clear" class="serial-clear" title="Clear">Clear</button>
</div>
<div id="serial-output" class="serial-output" aria-live="polite"></div>
<form id="serial-form" class="serial-form" autocomplete="off">
<input id="serial-input" type="text" class="serial-input" placeholder="Type and press Enter to send" />
<label class="serial-newline" title="Append \n on send">
<input type="checkbox" id="serial-newline-checkbox" checked />
<span>\n</span>
</label>
<button type="submit" class="serial-send">Send</button>
</form>
</section>
<div class="console-container" id="console-container">
<button type="button" class="console-header" id="console-toggle" aria-expanded="true" aria-controls="console-output">
<span class="chevron" aria-hidden="true"></span>
<span>Console Output</span>
</button>
<pre id="console-output" class="console-output"></pre>
</div>
</div>
@@ -67,6 +121,6 @@
</div>
</div>
<script type="module" src="/static/script.js?v=10"></script>
<script type="module" src="/static/script.js?v=59"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign in — Python Editor</title>
<title>Sign in — LED Editor</title>
<link rel="icon" href="data:,">
<style>
* { box-sizing: border-box; }

View File

@@ -34,7 +34,109 @@ await micropip.install("jedi")
self.onmessage = async (event) => {
const { id, type, payload } = event.data || {};
try {
/* Fast-path: fire-and-forget messages used by the SAB-less fallback
for pin input + ADC slider values. We update the worker-local
Int32Array so Python's next read sees the new value, with no
Pyodide round-trip and no reply expected. */
if (type === 'pinIn') {
const view = self.__pin_in_view;
const pin = payload && Number(payload.pin);
if (view && Number.isFinite(pin) && pin >= 0 && pin < view.length) {
try {
view[pin] = (payload.value | 0) ? 1 : 0;
} catch (_e) {
// ignore
}
}
return;
}
if (type === 'adcSet') {
const view = self.__adc_view;
const pin = payload && Number(payload.pin);
if (view && Number.isFinite(pin) && pin >= 0 && pin < view.length) {
const value = Math.max(0, Math.min(65535, payload.value | 0));
try {
view[pin] = value;
} catch (_e) {
// ignore
}
}
return;
}
if (type === 'init') {
/* The main thread shares an Int32Array-backed SAB so Python ADC.read*()
can pick up live slider values without yielding. */
const adcSab = payload && payload.adcSab;
if (adcSab && typeof adcSab !== 'string') {
try {
self.__adc_view = new Int32Array(adcSab);
} catch (_e) {
self.__adc_view = null;
}
}
/* No SAB? Fall back to a worker-local Int32Array; the main thread
then delivers slider drags via `postMessage({type:'adcSet'})`. */
if (!self.__adc_view) {
try {
self.__adc_view = new Int32Array(64);
} catch (_e) {
self.__adc_view = null;
}
}
/* Pin output state: Int32Array[64] — Python writes packed
[ui_mode << 24 | value (low 24 bits)] entries, the editor UI reads
them every frame to drive the Pins panel indicators. */
const pinOutSab = payload && payload.pinOutSab;
if (pinOutSab && typeof pinOutSab !== 'string') {
try {
self.__pin_out_view = new Int32Array(pinOutSab);
} catch (_e) {
self.__pin_out_view = null;
}
}
if (!self.__pin_out_view) {
try {
self.__pin_out_view = new Int32Array(64);
} catch (_e) {
self.__pin_out_view = null;
}
}
/* Pin input state: Int32Array[64] — UI buttons write 0/1 per pin,
Python's `Pin.value()` for IN mode reads the matching slot. */
const pinInSab = payload && payload.pinInSab;
if (pinInSab && typeof pinInSab !== 'string') {
try {
self.__pin_in_view = new Int32Array(pinInSab);
} catch (_e) {
self.__pin_in_view = null;
}
}
if (!self.__pin_in_view) {
try {
self.__pin_in_view = new Int32Array(64);
} catch (_e) {
self.__pin_in_view = null;
}
}
/* Tell Python whether SAB was actually wired up so machine.py can
emit `[pin-out]` print markers as a fallback for the live UI. */
self.__sab_isolated = Boolean(pinOutSab && typeof pinOutSab !== 'string');
/* Serial-in ring buffer: first 8 bytes are [readIdx, writeIdx] (Int32),
followed by `capacity` bytes of payload data. */
const serialSab = payload && payload.serialSab;
if (serialSab && typeof serialSab !== 'string') {
try {
self.__serial_in_indices = new Int32Array(serialSab, 0, 2);
const capacity = serialSab.byteLength - 8;
self.__serial_in_capacity = capacity;
self.__serial_in_data = new Uint8Array(serialSab, 8, capacity);
} catch (_e) {
self.__serial_in_indices = null;
self.__serial_in_data = null;
self.__serial_in_capacity = 0;
}
}
await ensurePyodide();
self.postMessage({ id, type: 'init', ok: true });
return;
@@ -52,7 +154,7 @@ self.onmessage = async (event) => {
p.globals.set('__cm_max', Math.min(100, Math.max(1, Number(payload.max_results) || 20)));
p.globals.set('__cm_extra_json', JSON.stringify(payload.extra_files || {}));
const raw = p.runPython(`
import json, os
import json, os, sys
import jedi
extra = json.loads(__cm_extra_json)
@@ -66,7 +168,13 @@ for rel_path, body in extra.items():
os.makedirs(os.path.dirname(__cm_path), exist_ok=True)
with open(__cm_path, "w", encoding="utf-8") as fh:
fh.write(__cm_code)
proj = jedi.Project("/workspace")
for entry in ("/workspace/code", "/workspace/lib", "/workspace"):
if entry not in sys.path:
sys.path.insert(0, entry)
proj = jedi.Project(
"/workspace",
added_sys_path=["/workspace/code", "/workspace/lib", "/workspace"],
)
s = jedi.Script(code=__cm_code, path=__cm_path, project=proj)
items = s.complete(line=__cm_line, column=__cm_col)
out = [{"name": i.name, "type": i.type, "complete": i.complete} for i in items[:__cm_max]]
@@ -77,6 +185,44 @@ json.dumps(out)
return;
}
if (type === 'diagnostics') {
const rel = String(payload.path || 'scratch.py').replace(/^\/+/, '');
const vpath = `/workspace/${rel}`;
p.globals.set('__diag_code', String(payload.content ?? ''));
p.globals.set('__diag_path', vpath);
p.globals.set('__diag_extra_json', JSON.stringify(payload.extra_files || {}));
const raw = p.runPython(`
import json, os, sys
import jedi
extra = json.loads(__diag_extra_json)
os.makedirs("/workspace", exist_ok=True)
for rel_path, body in extra.items():
rel_path = str(rel_path).lstrip("/")
full = os.path.join("/workspace", rel_path)
os.makedirs(os.path.dirname(full), exist_ok=True)
with open(full, "w", encoding="utf-8") as fh:
fh.write(str(body))
os.makedirs(os.path.dirname(__diag_path), exist_ok=True)
with open(__diag_path, "w", encoding="utf-8") as fh:
fh.write(__diag_code)
for entry in ("/workspace/code", "/workspace/lib", "/workspace"):
if entry not in sys.path:
sys.path.insert(0, entry)
proj = jedi.Project(
"/workspace",
added_sys_path=["/workspace/code", "/workspace/lib", "/workspace"],
)
s = jedi.Script(code=__diag_code, path=__diag_path, project=proj)
errs = s.get_syntax_errors()
out = [{"line": e.line, "column": e.column, "message": str(e.get_message())} for e in errs]
json.dumps(out)
`);
const diagnostics = JSON.parse(String(raw));
self.postMessage({ id, type: 'diagnostics', ok: true, diagnostics });
return;
}
if (type === 'run') {
const files = payload.files && typeof payload.files === 'object' ? payload.files : {};
const mainRel = String(payload.mainPath || '').replace(/^\/+/, '');
@@ -97,7 +243,7 @@ for rel, body in files.items():
with open(full, "w", encoding="utf-8") as fh:
fh.write(str(body))
for entry in ("/workspace/lib", "/workspace"):
for entry in ("/workspace/code", "/workspace/lib", "/workspace"):
if entry not in sys.path:
sys.path.insert(0, entry)

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Register — Python Editor</title>
<title>Register — LED Editor</title>
<link rel="icon" href="data:,">
<style>
* { box-sizing: border-box; }
@@ -54,6 +54,7 @@
<body>
<main class="card">
<h1>Create account</h1>
<p id="invite-banner" class="hint hidden"></p>
<p class="hint">Username: letters, numbers, underscore (364). Password: at least 8 characters.</p>
<div id="err" class="err"></div>
<form id="form">
@@ -61,11 +62,38 @@
<input id="username" name="username" autocomplete="username" required />
<label for="password">Password</label>
<input id="password" type="password" name="password" autocomplete="new-password" required />
<input id="invite-token" type="hidden" name="invite_token" />
<button type="submit" id="submit">Register</button>
</form>
<p><a href="/login">Sign in</a> · <a href="/">Home</a></p>
</main>
<script>
const inviteTokenRaw = new URLSearchParams(window.location.search).get("invite") || "";
const inviteToken = inviteTokenRaw.trim();
const inviteInput = document.getElementById("invite-token");
if (inviteInput) inviteInput.value = inviteToken;
if (inviteToken) {
const banner = document.getElementById("invite-banner");
if (banner) {
banner.classList.remove("hidden");
banner.textContent =
"You're using an invite link. Choose a username and password — the link works for one signup only.";
}
}
function formatApiDetail(body) {
if (!body || body.detail === undefined || body.detail === null) return "";
const d = body.detail;
if (typeof d === "string") return d;
if (Array.isArray(d))
return d
.map((item) =>
typeof item === "object" && item && item.msg ? String(item.msg) : JSON.stringify(item)
)
.join(" ");
return String(d);
}
(async function checkStatus() {
try {
const r = await fetch("/api/auth/status");
@@ -73,9 +101,12 @@
if (!s.auth_enabled) {
document.getElementById("err").textContent = "Registration is disabled (AUTH_ENABLED is not set).";
document.getElementById("form").style.display = "none";
} else if (!s.register_open) {
} else if (!s.register_open && !inviteToken) {
document.getElementById("err").textContent = "Public registration is closed. Ask an administrator.";
document.getElementById("form").style.display = "none";
} else if (s.invite_required && !inviteToken) {
document.getElementById("err").textContent = "This server requires an invite link to register.";
document.getElementById("form").style.display = "none";
}
} catch (_e) {}
})();
@@ -90,6 +121,7 @@
const body = {
username: document.getElementById("username").value.trim(),
password: document.getElementById("password").value,
invite_token: inviteToken ? inviteToken : null,
};
const res = await fetch("/api/auth/register", {
method: "POST",
@@ -98,7 +130,7 @@
});
if (!res.ok) {
const j = await res.json().catch(() => ({}));
err.textContent = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail) || res.statusText;
err.textContent = formatApiDetail(j) || res.statusText;
return;
}
window.location.href = "/login?next=/editor";

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

250
src/static/tutorial.html Normal file
View File

@@ -0,0 +1,250 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Python Tutorial</title>
<link rel="icon" href="data:,">
<style>
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #0f172a;
color: #e2e8f0;
min-height: 100dvh;
}
.page {
max-width: 1100px;
margin: 0 auto;
padding: 1rem;
display: grid;
gap: 0.9rem;
}
.topbar {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
align-items: center;
}
.btn {
border: 1px solid #334155;
background: #1e293b;
color: #e2e8f0;
border-radius: 8px;
text-decoration: none;
padding: 0.45rem 0.7rem;
cursor: pointer;
font-size: 0.92rem;
}
.btn.primary {
background: #2563eb;
border-color: #2563eb;
}
.layout {
display: grid;
grid-template-columns: 1fr;
gap: 0.9rem;
min-height: 70dvh;
}
.card {
border: 1px solid #334155;
border-radius: 12px;
background: #111827;
padding: 0.8rem;
min-height: 0;
}
.hint { color: #94a3b8; font-size: 0.9rem; margin: 0.4rem 0 0.7rem; white-space: pre-wrap; }
.guided-grid {
display: grid;
grid-template-columns: 1fr 320px;
gap: 0.8rem;
margin-bottom: 0.9rem;
}
.demo-code {
margin: 0.55rem 0 0;
border: 1px solid #334155;
background: #020617;
border-radius: 8px;
padding: 0.6rem;
min-height: 280px;
overflow: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.84rem;
line-height: 1.45;
white-space: pre;
}
.demo-line {
display: block;
padding: 0 0.2rem;
border-radius: 4px;
color: #cbd5e1;
}
.demo-line.active {
background: rgba(59, 130, 246, 0.18);
color: #dbeafe;
outline: 1px solid rgba(59, 130, 246, 0.35);
}
.demo-controls {
display: grid;
gap: 0.65rem;
margin-top: 0.65rem;
padding: 0.7rem;
border: 1px solid #334155;
border-radius: 10px;
background: #0b1220;
}
.demo-controls label {
display: grid;
gap: 0.2rem;
color: #e2e8f0;
font-size: 0.95rem;
font-weight: 600;
}
.demo-controls .control-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.demo-controls .value-badge {
min-width: 52px;
text-align: center;
background: #1d4ed8;
color: white;
border: 1px solid rgba(255,255,255,0.2);
border-radius: 999px;
padding: 0.1rem 0.45rem;
font-size: 0.8rem;
}
.demo-controls input[type="range"] {
width: 100%;
accent-color: #3b82f6;
height: 1.1rem;
}
.demo-controls select {
width: 100%;
border: 1px solid #475569;
border-radius: 8px;
background: #020617;
color: #e2e8f0;
padding: 0.5rem 0.55rem;
font-size: 0.92rem;
}
.guide-explain {
border: 1px solid #334155;
background: #020617;
border-radius: 8px;
padding: 0.65rem;
color: #cbd5e1;
min-height: 120px;
white-space: pre-wrap;
}
.guide-step {
color: #93c5fd;
font-size: 0.9rem;
margin: 0.45rem 0;
}
.hidden { display: none !important; }
.led-demo-panel {
border: 1px solid #334155;
background: #020617;
border-radius: 8px;
padding: 0.65rem;
width: fit-content;
display: inline-block;
}
.led-meta {
color: #9ca3af;
font-size: 0.82rem;
margin-bottom: 0.45rem;
display: none;
}
.led-grid {
display: grid;
grid-template-columns: repeat(16, 24px);
grid-auto-rows: 24px;
gap: 6px;
max-height: none;
overflow: visible;
align-content: start;
justify-content: start;
width: fit-content;
}
.led {
width: 24px;
height: 24px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.45);
}
@media (max-width: 900px) {
.guided-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main class="page">
<div class="topbar">
<a class="btn" href="/">Home</a>
</div>
<div class="layout">
<section class="card">
<h3 style="margin:0">Guided LED Demo</h3>
<div style="display:flex; gap:0.5rem; flex-wrap:wrap;">
<button id="begin-demo-btn" class="btn primary" type="button">Let's Get Started</button>
<button id="toggle-breakdown-btn" class="btn hidden" type="button">Show Breakdown</button>
</div>
<div class="guided-grid">
<div>
<div class="led-demo-panel">
<div id="led-meta" class="led-meta">Press Begin Demo to render the panel animation.</div>
<div id="led-grid" class="led-grid"></div>
</div>
</div>
<div>
<div class="demo-controls">
<label>Pattern
<select id="pattern-select">
<option value="plasma">Plasma Swirl</option>
<option value="rainbow">Rainbow Orbit</option>
</select>
</label>
<label>
<span class="control-head">Speed <span id="speed-value" class="value-badge"></span></span>
<input id="speed-slider" type="range" min="0.8" max="3.2" step="0.1" value="1.6" />
</label>
<label>
<span class="control-head">Wave Width <span id="width-value" class="value-badge"></span></span>
<input id="width-slider" type="range" min="0.2" max="0.8" step="0.02" value="0.45" />
</label>
<label>
<span class="control-head">Brightness <span id="brightness-value" class="value-badge"></span></span>
<input id="brightness-slider" type="range" min="80" max="230" step="5" value="170" />
</label>
</div>
</div>
</div>
<div id="learning-content" class="hidden">
<div id="breakdown-panel" class="hidden">
<div style="display:flex; gap:0.5rem; flex-wrap:wrap; margin-top:0.2rem;">
<button id="prev-part-btn" class="btn" type="button">Prev Part</button>
<button id="next-part-btn" class="btn" type="button">Next Part</button>
</div>
<div class="guided-grid" style="margin-top:0.55rem;">
<div>
<div id="guide-step" class="guide-step"></div>
<div id="demo-code" class="demo-code"></div>
</div>
<div>
<div id="guide-explain" class="guide-explain"></div>
</div>
</div>
</div>
</div>
</section>
</div>
</main>
<script type="module" src="/static/tutorial.js?v=2"></script>
</body>
</html>

435
src/static/tutorial.js Normal file
View File

@@ -0,0 +1,435 @@
const DEMO_PARTS = [
{
title: "Setup objects",
start: 1,
end: 6,
explain:
"We import timing/math and create a 16x16 NeoPixel object.\nThis gives us 256 LEDs to control every frame.",
},
{
title: "Serpentine mapping",
start: 8,
end: 10,
explain:
"The panel wiring zig-zags by row.\nEven rows run right-to-left, odd rows run left-to-right.",
},
{
title: "Frame loop",
start: 12,
end: 12,
explain:
"`for t in range(...)` is time.\nEach pass draws one frame of animation.",
},
{
title: "Pixel color math",
start: 13,
end: 17,
explain:
"For each (x,y), we compute a sine wave value.\nThat value controls brightness and color to create flowing motion.",
},
{
title: "Render + delay",
start: 18,
end: 19,
explain:
"`np.write()` pushes the full frame to LEDs.\nA tiny sleep controls animation speed.",
},
];
class TutorialApp {
constructor() {
this.pyWorker = null;
this.pyWorkerMsgId = 0;
this.pyWorkerHandlers = new Map();
this.pyodideInited = false;
this.outputBuffer = "";
this.introMode = true;
this.previewTimer = null;
this.previewTick = 0;
this.breakdownVisible = false;
this.demoPartIndex = 0;
this.demoVars = {
pattern: "plasma",
speed: 1.6,
width: 0.45,
brightness: 170,
};
this.bind();
this.renderDemoControls();
this.renderDemoCode();
this.renderDemoPart();
this.prewarm();
this.startIdlePreview();
}
bind() {
document.getElementById("toggle-breakdown-btn").addEventListener("click", () => {
this.breakdownVisible = !this.breakdownVisible;
const panel = document.getElementById("breakdown-panel");
const button = document.getElementById("toggle-breakdown-btn");
if (panel) panel.classList.toggle("hidden", !this.breakdownVisible);
if (button) button.textContent = this.breakdownVisible ? "Hide Breakdown" : "Show Breakdown";
});
document.getElementById("begin-demo-btn").addEventListener("click", async () => {
if (this.introMode) {
this.introMode = false;
this.stopIdlePreview();
const learning = document.getElementById("learning-content");
const breakdownToggle = document.getElementById("toggle-breakdown-btn");
if (learning) learning.classList.remove("hidden");
if (breakdownToggle) breakdownToggle.classList.remove("hidden");
}
const src = this.buildDemoCode();
await this.runSource(src, "code/tutorial_demo.py");
this.setStatus("Demo ran. Use Show Breakdown to step through code.", "info");
});
document.getElementById("prev-part-btn").addEventListener("click", () => {
this.demoPartIndex = (this.demoPartIndex - 1 + DEMO_PARTS.length) % DEMO_PARTS.length;
this.renderDemoPart();
});
document.getElementById("next-part-btn").addEventListener("click", () => {
this.demoPartIndex = (this.demoPartIndex + 1) % DEMO_PARTS.length;
this.renderDemoPart();
});
document.getElementById("pattern-select").addEventListener("change", (event) => {
this.demoVars.pattern = String(event.target.value || "plasma");
this.renderDemoCode();
this.renderDemoPart();
});
document.getElementById("speed-slider").addEventListener("input", (event) => {
this.demoVars.speed = Number(event.target.value);
this.renderDemoControls();
this.renderDemoCode();
this.renderDemoPart();
});
document.getElementById("width-slider").addEventListener("input", (event) => {
this.demoVars.width = Number(event.target.value);
this.renderDemoControls();
this.renderDemoCode();
this.renderDemoPart();
});
document.getElementById("brightness-slider").addEventListener("input", (event) => {
this.demoVars.brightness = Number(event.target.value);
this.renderDemoControls();
this.renderDemoCode();
this.renderDemoPart();
});
}
buildDemoCode() {
const pattern = this.demoVars.pattern;
const speed = this.demoVars.speed.toFixed(2);
const width = this.demoVars.width.toFixed(2);
const brightness = Math.round(this.demoVars.brightness);
const bodyLines =
pattern === "rainbow"
? [
" dx = x - 7.5",
" dy = y - 7.5",
" angle = math.atan2(dy, dx)",
" radius = math.sqrt(dx * dx + dy * dy)",
" hue = angle + (radius * WAVE_WIDTH) + (t * SPEED * 0.15)",
" pulse = (math.sin((radius * 0.65) - (t * 0.10)) + 1.0) * 0.5",
" scale = int((0.45 + pulse * 0.55) * BRIGHTNESS)",
" r = int((math.sin(hue + 0.0) * 0.5 + 0.5) * scale)",
" g = int((math.sin(hue + 2.09) * 0.5 + 0.5) * scale)",
" u = int((math.sin(hue + 4.18) * 0.5 + 0.5) * scale)",
" np[xy_to_index(x, y)] = (max(0,min(255,r)), max(0,min(255,g)), max(0,min(255,u)))",
]
: [
" dx = x - 7.5",
" dy = y - 7.5",
" dist = math.sqrt(dx * dx + dy * dy)",
" ring = math.sin(dist * (WAVE_WIDTH * 2.6) - t * (SPEED * 0.9))",
` swirl = math.sin((x * WAVE_WIDTH) + (y * ${Math.max(0.1, width * 0.8).toFixed(2)}) + (t * SPEED * 0.55))`,
" pulse = math.sin(t * SPEED * 0.12)",
" b = int((ring * 0.45 + swirl * 0.35 + pulse * 0.2 + 1.0) * (BRIGHTNESS / 2))",
" b = max(0, min(255, b))",
" r_wave = math.sin((x * 0.38) + (t * 0.12))",
" g_wave = math.sin((y * 0.41) - (t * 0.10))",
" u_wave = math.sin(((x + y) * 0.27) + (t * 0.08))",
" r = max(0, min(255, int(b * 0.45 + (r_wave + 1) * 80)))",
" g = max(0, min(255, int(b * 0.30 + (g_wave + 1) * 95)))",
" u = max(0, min(255, int(b * 0.55 + (u_wave + 1) * 85)))",
" np[xy_to_index(x, y)] = (r, g, u)",
];
return [
"from machine import Pin",
"import neopixel",
"import time",
"import math",
"",
"np = neopixel.NeoPixel(Pin(4), 16 * 16)",
`PATTERN = "${pattern}"`,
`SPEED = ${speed}`,
`WAVE_WIDTH = ${width}`,
`BRIGHTNESS = ${brightness}`,
"",
"def xy_to_index(x, y):",
" return y * 16 + (15 - x if y % 2 == 0 else x)",
"",
"frames = 130",
"for t in range(frames):",
" for y in range(16):",
" for x in range(16):",
...bodyLines,
" np.write()",
" time.sleep(0.03)",
"",
"print('DEMO_DONE')",
].join("\n");
}
escapeHtml(text) {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
renderDemoControls() {
const patternSelect = document.getElementById("pattern-select");
if (patternSelect) patternSelect.value = this.demoVars.pattern;
document.getElementById("speed-value").textContent = this.demoVars.speed.toFixed(1);
document.getElementById("width-value").textContent = this.demoVars.width.toFixed(2);
document.getElementById("brightness-value").textContent = String(Math.round(this.demoVars.brightness));
}
renderDemoCode() {
const code = this.buildDemoCode();
const lines = code.split("\n");
const active = DEMO_PARTS[this.demoPartIndex];
const html = lines
.map((line, idx) => {
const n = idx + 1;
const activeClass = n >= active.start && n <= active.end ? " active" : "";
return `<span class="demo-line${activeClass}">${String(n).padStart(2, " ")} ${this.escapeHtml(line)}</span>`;
})
.join("");
document.getElementById("demo-code").innerHTML = html;
}
renderDemoPart() {
const part = DEMO_PARTS[this.demoPartIndex];
document.getElementById("guide-step").textContent = `Part ${this.demoPartIndex + 1}/${DEMO_PARTS.length}: ${part.title}`;
document.getElementById("guide-explain").textContent = part.explain;
this.renderDemoCode();
}
buildPreviewFrame(tick) {
const pixels = new Array(16 * 16).fill(0).map(() => [0, 0, 0]);
const pattern = this.demoVars.pattern;
const speed = this.demoVars.speed;
const width = this.demoVars.width;
const brightness = this.demoVars.brightness;
for (let y = 0; y < 16; y += 1) {
for (let x = 0; x < 16; x += 1) {
const ledIndex = y % 2 === 0 ? y * 16 + (15 - x) : y * 16 + x;
if (pattern === "rainbow") {
const dx = x - 7.5;
const dy = y - 7.5;
const angle = Math.atan2(dy, dx);
const radius = Math.sqrt(dx * dx + dy * dy);
const hue = angle + (radius * width) + (tick * speed * 0.012);
const pulse = (Math.sin((radius * 0.65) - (tick * 0.012)) + 1) * 0.5;
const scale = (0.45 + pulse * 0.55) * brightness;
const r = Math.max(0, Math.min(255, Math.floor((Math.sin(hue + 0.0) * 0.5 + 0.5) * scale)));
const g = Math.max(0, Math.min(255, Math.floor((Math.sin(hue + 2.09) * 0.5 + 0.5) * scale)));
const u = Math.max(0, Math.min(255, Math.floor((Math.sin(hue + 4.18) * 0.5 + 0.5) * scale)));
pixels[ledIndex] = [r, g, u];
} else {
const dx = x - 7.5;
const dy = y - 7.5;
const dist = Math.sqrt(dx * dx + dy * dy);
const ring = Math.sin(dist * (width * 2.6) - tick * speed * 0.072);
const swirl = Math.sin((x * width) + (y * Math.max(0.1, width * 0.8)) + (tick * speed * 0.044));
const pulse = Math.sin(tick * speed * 0.01);
const b = Math.max(0, Math.min(255, Math.floor((ring * 0.45 + swirl * 0.35 + pulse * 0.2 + 1) * (brightness / 2))));
const rWave = Math.sin((x * 0.38) + (tick * 0.12));
const gWave = Math.sin((y * 0.41) - (tick * 0.10));
const uWave = Math.sin(((x + y) * 0.27) + (tick * 0.08));
const r = Math.max(0, Math.min(255, Math.floor(b * 0.45 + (rWave + 1) * 80)));
const g = Math.max(0, Math.min(255, Math.floor(b * 0.30 + (gWave + 1) * 95)));
const u = Math.max(0, Math.min(255, Math.floor(b * 0.55 + (uWave + 1) * 85)));
pixels[ledIndex] = [r, g, u];
}
}
}
return { type: "neopixel", pin: 4, bpp: 3, pixels };
}
startIdlePreview() {
this.stopIdlePreview();
this.previewTick = 0;
this.previewTimer = setInterval(() => {
this.previewTick += 1;
this.renderLedSimulation(this.buildPreviewFrame(this.previewTick));
}, 50);
}
stopIdlePreview() {
if (this.previewTimer) {
clearInterval(this.previewTimer);
this.previewTimer = null;
}
}
setStatus(msg, kind = "error") {
const status = document.getElementById("status");
if (!status) return;
status.textContent = msg;
if (kind === "success") {
status.style.color = "#86efac";
} else if (kind === "info") {
status.style.color = "#93c5fd";
} else {
status.style.color = "#fca5a5";
}
}
ensurePyWorker() {
if (!this.pyWorker) {
this.pyWorker = new Worker("/static/pyodide-worker.js?v=4");
this.pyWorker.onmessage = (event) => this.onWorkerMessage(event);
}
return this.pyWorker;
}
onWorkerMessage(event) {
const data = event.data;
if (!data || typeof data !== "object") return;
if (data.type === "io") {
const text = data.text || "";
this.outputBuffer += text;
this.processSimulationLines(text.split(/\r?\n/));
return;
}
const pending = this.pyWorkerHandlers.get(data.id);
if (!pending) return;
this.pyWorkerHandlers.delete(data.id);
if (data.ok) pending.resolve(data);
else pending.reject(new Error(data.error || "Worker error"));
}
callWorker(type, payload) {
return new Promise((resolve, reject) => {
const id = ++this.pyWorkerMsgId;
this.pyWorkerHandlers.set(id, { resolve, reject });
this.ensurePyWorker().postMessage({ id, type, payload });
});
}
async ensurePyodideReady() {
if (this.pyodideInited) return;
await this.callWorker("init", {});
this.pyodideInited = true;
}
async prewarm() {
try {
await this.ensurePyodideReady();
} catch (_err) {
// ignore warm-up errors
}
}
renderLedSimulation(frame) {
if (!frame) return;
const meta = document.getElementById("led-meta");
const grid = document.getElementById("led-grid");
if (!meta || !grid) return;
const pixels = Array.isArray(frame.pixels) ? frame.pixels : [];
meta.textContent = `pin=${frame.pin ?? "?"} | leds=${pixels.length} | bpp=${frame.bpp ?? 3} | mode=16x16`;
grid.innerHTML = "";
const panelSize = 16 * 16;
for (let panelIndex = 0; panelIndex < panelSize; panelIndex += 1) {
const row = Math.floor(panelIndex / 16);
const col = panelIndex % 16;
const ledIndex = row % 2 === 0 ? row * 16 + (15 - col) : row * 16 + col;
const px = pixels[ledIndex] || [0, 0, 0];
const r = Number(px?.[0] ?? 0);
const g = Number(px?.[1] ?? 0);
const b = Number(px?.[2] ?? 0);
const div = document.createElement("div");
div.className = "led";
div.title = `panel(${row},${col}) -> #${ledIndex} (${r}, ${g}, ${b})`;
div.style.backgroundColor = `rgb(${r}, ${g}, ${b})`;
div.style.boxShadow = `0 0 10px rgba(${r}, ${g}, ${b}, 0.55), inset 0 0 8px rgba(0,0,0,0.45)`;
grid.appendChild(div);
}
}
processSimulationLines(lines) {
for (const line of lines || []) {
if (typeof line !== "string") continue;
const marker = "[neopixel-json]";
const idx = line.indexOf(marker);
if (idx === -1) continue;
const jsonPart = line.slice(idx + marker.length).trim();
if (!jsonPart) continue;
try {
const payload = JSON.parse(jsonPart);
if (payload && payload.type === "neopixel") {
this.renderLedSimulation(payload);
}
} catch (_err) {
// Ignore malformed simulation payloads.
}
}
}
async runSource(source, mainPath = "code/tutorial_challenge.py") {
this.outputBuffer = "";
const outputEl = document.getElementById("output");
if (outputEl) outputEl.textContent = "Running...\n";
try {
const bundleResp = await fetch("/api/workspace/py-sources", { credentials: "include" });
if (!bundleResp.ok) {
throw new Error("Failed to load workspace sources for tutorial runtime.");
}
const bundle = await bundleResp.json();
const files = { ...(bundle.files || {}) };
files[mainPath] = source;
await this.ensurePyodideReady();
await this.callWorker("run", {
files,
mainPath,
args: [],
});
const visible = this.outputBuffer
.split(/\r?\n/)
.filter((line) => line && !line.includes("[neopixel-json]"))
.join("\n");
if (outputEl) outputEl.textContent = visible || "[no output]";
return { ok: true, output: this.outputBuffer };
} catch (error) {
const out = `${this.outputBuffer}\n${error.message}`.trim();
if (outputEl) outputEl.textContent = out;
return { ok: false, output: out };
}
}
}
async function bootTutorial() {
try {
const st = await fetch("/api/auth/status");
if (st.ok) {
const status = await st.json();
if (status.auth_enabled) {
const me = await fetch("/api/auth/me", { credentials: "include" });
if (!me.ok) {
const next = encodeURIComponent(`${window.location.pathname}${window.location.search}`);
window.location.replace(`/login?next=${next}`);
return;
}
}
}
} catch (_err) {
// Continue even if auth check fails.
}
new TutorialApp();
}
document.addEventListener("DOMContentLoaded", () => {
bootTutorial();
});

484
src/static/users.html Normal file
View File

@@ -0,0 +1,484 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Manage users — LED Editor</title>
<link rel="icon" href="data:,">
<style>
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: linear-gradient(135deg, #0f172a, #1e293b);
color: #e2e8f0;
}
.home-card {
width: min(640px, 92vw);
background: rgba(15, 23, 42, 0.78);
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: 14px;
padding: 2rem;
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.35);
}
h1 { margin: 0 0 0.5rem 0; font-size: 1.6rem; color: #f8fafc; }
p { margin: 0 0 1rem 0; color: #cbd5e1; line-height: 1.5; }
.btn {
display: inline-block;
text-decoration: none;
border-radius: 8px;
padding: 0.55rem 0.9rem;
font-weight: 600;
border: 1px solid transparent;
cursor: pointer;
font-size: 0.95rem;
}
.btn-primary { background: #3b82f6; color: #ffffff; }
.btn-ghost { background: transparent; border-color: #64748b; color: #e2e8f0; }
.btn-danger { background: transparent; border-color: #f87171; color: #fecaca; }
.btn-danger:hover:not(:disabled) { background: rgba(248, 113, 113, 0.15); }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
label { display: block; font-size: 0.85rem; color: #94a3b8; margin-bottom: 0.35rem; }
.note { font-size: 0.85rem; color: #94a3b8; }
.nav { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem; align-items: center; }
.hidden { display: none !important; }
.invite-panel,
.users-panel {
margin: 1rem 0;
padding: 0.9rem;
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: 10px;
background: rgba(30, 41, 59, 0.45);
}
.invite-row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.4rem;
}
.invite-row input[type="email"] {
flex: 1 1 260px;
padding: 0.5rem 0.65rem;
border-radius: 8px;
border: 1px solid #64748b;
background: #0f172a;
color: #e2e8f0;
}
.invite-result {
margin-top: 0.55rem;
font-size: 0.85rem;
color: #cbd5e1;
word-break: break-all;
}
.users-list { margin-top: 0.5rem; display: grid; gap: 0.45rem; }
.user-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
}
.user-row a {
color: #93c5fd;
text-decoration: none;
border: 1px solid rgba(147, 197, 253, 0.35);
border-radius: 6px;
padding: 0.25rem 0.5rem;
}
.user-actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
justify-content: flex-end;
}
#admin-users-feedback:not(:empty) {
padding: 0.5rem 0.65rem;
border-radius: 8px;
font-size: 0.85rem;
margin: 0.5rem 0;
}
#admin-users-feedback.ok {
background: rgba(34, 197, 94, 0.12);
border: 1px solid rgba(74, 222, 128, 0.35);
color: #bbf7d0;
}
#admin-users-feedback.err {
background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.35);
color: #fecaca;
}
.user-edit-form {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid rgba(148, 163, 184, 0.25);
}
.user-edit-form label { display: block; margin-bottom: 0.65rem; }
.user-edit-form input[type='text'],
.user-edit-form input[type='password'] {
width: 100%;
max-width: 320px;
padding: 0.45rem 0.55rem;
border-radius: 8px;
border: 1px solid #64748b;
background: #0f172a;
color: #e2e8f0;
margin-top: 0.25rem;
}
.user-edit-form .edit-actions {
display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.65rem;
}
.user-edit-form .super-line {
display: flex; align-items: center; gap: 0.5rem;
font-size: 0.85rem; color: #cbd5e1; margin-bottom: 0.65rem;
}
.user-edit-form .super-line input { accent-color: #3b82f6; }
</style>
</head>
<body>
<main class="home-card">
<h1>Manage users</h1>
<p class="note">Superuser-only. Add accounts via invite link, edit roles, or remove users.</p>
<div class="nav">
<a class="btn btn-ghost" href="/">← Home</a>
<a class="btn btn-ghost" href="/editor">Open editor</a>
<span id="auth-greeting"></span>
<button type="button" class="btn btn-ghost hidden" id="btn-logout">Sign out</button>
</div>
<div id="not-allowed" class="hidden">
<p style="color:#fca5a5">You need to be signed in as a superuser to manage users.
<a href="/login?next=/users" style="color:#93c5fd">Sign in</a></p>
</div>
<section id="users-panel" class="users-panel hidden">
<strong>Accounts</strong>
<div id="admin-users-feedback" role="status" aria-live="polite"></div>
<div id="users-list" class="users-list"></div>
<div id="user-edit-form" class="user-edit-form hidden">
<strong id="user-edit-heading">Edit account</strong>
<p class="note" style="margin: 0.35rem 0 0.5rem">Change login name or admin role; set a password only when you mean to reset it.</p>
<input type="hidden" id="edit-user-id" autocomplete="off" />
<label for="edit-user-username">Username
<input type="text" id="edit-user-username" name="username" autocomplete="username" minlength="3" maxlength="64" />
</label>
<label for="edit-user-password">New password
<input type="password" id="edit-user-password" name="password" autocomplete="new-password" minlength="8" maxlength="128" placeholder="Leave blank to keep current" />
</label>
<label class="super-line">
<input type="checkbox" id="edit-user-super" />
Superuser (can manage accounts and invites)
</label>
<div class="edit-actions">
<button type="button" class="btn btn-primary" id="edit-user-save">Save changes</button>
<button type="button" class="btn btn-ghost" id="edit-user-cancel">Cancel</button>
</div>
</div>
</section>
<section id="invite-panel" class="invite-panel hidden">
<strong>Add users via invite link</strong>
<p class="note" style="margin-top:0.4rem">
Each link lets <strong>one</strong> person create their own account at <code>/register?invite=…</code> with a password they choose. After it is used, create a new link for the next person.
</p>
<div class="invite-row">
<input id="invite-email" type="email" placeholder="new.user@example.com" autocomplete="email" />
<button type="button" class="btn btn-primary" id="invite-create-btn">Email invite</button>
<button type="button" class="btn btn-ghost" id="invite-link-btn">Invite link only</button>
</div>
<div id="invite-result" class="invite-result"></div>
<button type="button" class="btn btn-ghost hidden" id="invite-copy-btn" aria-label="Copy invite link">Copy invite link</button>
</section>
</main>
<script>
let currentAdminUserId = null;
function formatApiDetail(body) {
if (!body || body.detail === undefined || body.detail === null) return '';
const d = body.detail;
if (typeof d === 'string') return d;
if (Array.isArray(d))
return d.map((item) => (typeof item === 'object' && item && item.msg ? String(item.msg) : JSON.stringify(item))).join(' ');
return String(d);
}
function setAdminUsersFeedback(kind, msg) {
const el = document.getElementById('admin-users-feedback');
if (!el) return;
el.textContent = msg || '';
el.classList.remove('ok', 'err');
if (kind === 'ok') el.classList.add('ok');
if (kind === 'err') el.classList.add('err');
}
async function refreshUsersList(viewerId) {
const usersList = document.getElementById('users-list');
if (!usersList) return;
usersList.textContent = 'Loading users...';
try {
const res = await fetch('/api/users', { credentials: 'include' });
const users = await res.json().catch(() => []);
if (!res.ok) {
usersList.textContent = 'Unable to load users.';
return;
}
usersList.innerHTML = '';
for (const user of users) {
const row = document.createElement('div');
row.className = 'user-row';
const name = document.createElement('span');
name.textContent = `${user.username}${user.is_superuser ? ' (admin)' : ''}`;
const actions = document.createElement('div');
actions.className = 'user-actions';
const link = document.createElement('a');
link.href = `/editor?workspace_user_id=${encodeURIComponent(String(user.id))}`;
link.textContent = 'Open workspace';
const editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'btn btn-ghost';
editBtn.textContent = 'Edit';
editBtn.title = `Edit ${user.username}`;
editBtn.addEventListener('click', () => openUserEdit(user.id, user.username, user.is_superuser));
const delBtn = document.createElement('button');
delBtn.type = 'button';
delBtn.className = 'btn btn-ghost btn-danger';
delBtn.textContent = 'Remove';
const isSelf = Number(user.id) === Number(viewerId);
delBtn.disabled = isSelf;
delBtn.title = isSelf ? 'You cannot delete your own account here' : 'Permanently remove this user';
delBtn.addEventListener('click', async () => {
const ok = confirm(`Remove user “${user.username}”? Their workspace folder stays on disk until you delete it manually.`);
if (!ok) return;
delBtn.disabled = true;
setAdminUsersFeedback('', '');
try {
const dres = await fetch(`/api/users/${encodeURIComponent(String(user.id))}`, {
method: 'DELETE', credentials: 'include',
});
const body = await dres.json().catch(() => ({}));
if (!dres.ok) {
setAdminUsersFeedback('err', formatApiDetail(body) || dres.statusText || 'Could not remove user');
delBtn.disabled = false;
return;
}
setAdminUsersFeedback('ok', `Removed ${user.username}.`);
await refreshUsersList(viewerId);
} catch (err) {
setAdminUsersFeedback('err', String(err.message || err));
delBtn.disabled = false;
}
});
actions.appendChild(editBtn);
actions.appendChild(link);
actions.appendChild(delBtn);
row.appendChild(name);
row.appendChild(actions);
usersList.appendChild(row);
}
} catch (_err) {
usersList.textContent = 'Unable to load users.';
}
}
function closeUserEdit() {
const sheet = document.getElementById('user-edit-form');
if (sheet) sheet.classList.add('hidden');
}
function openUserEdit(userId, username, isSuperuser) {
document.getElementById('edit-user-id').value = String(userId);
document.getElementById('edit-user-username').value = username;
document.getElementById('edit-user-password').value = '';
document.getElementById('edit-user-super').checked = Boolean(isSuperuser);
document.getElementById('user-edit-heading').textContent = `Edit @${username}`;
document.getElementById('user-edit-form').classList.remove('hidden');
}
document.getElementById('edit-user-cancel').addEventListener('click', () => closeUserEdit());
document.getElementById('edit-user-save').addEventListener('click', async () => {
const id = document.getElementById('edit-user-id').value;
const u = document.getElementById('edit-user-username').value.trim();
const pw = document.getElementById('edit-user-password').value;
const superU = document.getElementById('edit-user-super').checked;
const saveBtn = document.getElementById('edit-user-save');
setAdminUsersFeedback('', '');
if (!u || u.length < 3) {
setAdminUsersFeedback('err', 'Username must be at least 3 characters.');
return;
}
const payload = { username: u, is_superuser: superU };
const tpw = pw.trim();
if (tpw.length > 0) {
if (tpw.length < 8) {
setAdminUsersFeedback('err', 'New password must be at least 8 characters (or leave blank).');
return;
}
payload.password = tpw;
}
saveBtn.disabled = true;
try {
const res = await fetch(`/api/users/${encodeURIComponent(String(id))}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
setAdminUsersFeedback('err', formatApiDetail(body) || res.statusText || 'Update failed');
saveBtn.disabled = false;
return;
}
setAdminUsersFeedback('ok', `Updated @${body.username}.`);
closeUserEdit();
await refreshUsersList(currentAdminUserId);
} catch (err) {
setAdminUsersFeedback('err', String(err.message || err));
}
saveBtn.disabled = false;
});
document.getElementById('btn-logout').addEventListener('click', async () => {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
window.location.href = '/';
});
function showInviteOutcome(inviteUrl, headline) {
const result = document.getElementById('invite-result');
const copyBtn = document.getElementById('invite-copy-btn');
result.textContent = '';
const line = document.createElement('p');
line.style.margin = '0 0 0.35rem 0';
line.textContent = headline;
const a = document.createElement('a');
a.href = inviteUrl;
a.textContent = inviteUrl;
a.style.color = '#93c5fd';
a.style.wordBreak = 'break-all';
result.appendChild(line);
result.appendChild(a);
copyBtn.classList.remove('hidden');
copyBtn.dataset.inviteUrl = inviteUrl;
}
function clearInviteOutcome() {
const result = document.getElementById('invite-result');
const copyBtn = document.getElementById('invite-copy-btn');
result.textContent = '';
copyBtn.classList.add('hidden');
copyBtn.textContent = 'Copy invite link';
delete copyBtn.dataset.inviteUrl;
}
document.getElementById('invite-copy-btn').addEventListener('click', async () => {
const copyBtn = document.getElementById('invite-copy-btn');
const url = copyBtn.dataset.inviteUrl;
if (!url) return;
try {
await navigator.clipboard.writeText(url);
copyBtn.textContent = 'Copied!';
setTimeout(() => { copyBtn.textContent = 'Copy invite link'; }, 2000);
} catch (_e) {
copyBtn.textContent = 'Copy failed — select the link above';
setTimeout(() => { copyBtn.textContent = 'Copy invite link'; }, 2500);
}
});
document.getElementById('invite-create-btn').addEventListener('click', async () => {
const emailInput = document.getElementById('invite-email');
const result = document.getElementById('invite-result');
const email = (emailInput.value || '').trim();
clearInviteOutcome();
if (!email) {
result.textContent = 'Enter an email address first.';
return;
}
try {
const res = await fetch('/api/users/invites', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, expires_days: 7 }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
result.textContent = formatApiDetail(body) || res.statusText || 'Failed to create invite';
return;
}
const headline = body.delivered
? 'Email sent. They can also use this link to register:'
: 'Email not sent (SMTP not configured). Share this registration link:';
showInviteOutcome(body.invite_url, headline);
} catch (err) {
result.textContent = String(err.message || err);
}
});
document.getElementById('invite-link-btn').addEventListener('click', async () => {
const result = document.getElementById('invite-result');
clearInviteOutcome();
try {
const res = await fetch('/api/users/invites', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: null, expires_days: 7 }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
result.textContent = formatApiDetail(body) || res.statusText || 'Failed to create invite link';
return;
}
showInviteOutcome(body.invite_url, 'Share this link so they can create an account:');
} catch (err) {
result.textContent = String(err.message || err);
}
});
async function init() {
const greet = document.getElementById('auth-greeting');
const logout = document.getElementById('btn-logout');
const usersPanel = document.getElementById('users-panel');
const invitePanel = document.getElementById('invite-panel');
const blocked = document.getElementById('not-allowed');
const st = await fetch('/api/auth/status').catch(() => null);
if (!st || !st.ok) {
blocked.classList.remove('hidden');
return;
}
const status = await st.json();
if (!status.auth_enabled) {
// Auth disabled — anyone can poke /api/users; show panels regardless.
usersPanel.classList.remove('hidden');
invitePanel.classList.remove('hidden');
await refreshUsersList(null);
return;
}
const me = await fetch('/api/auth/me', { credentials: 'include' });
if (!me.ok) {
blocked.classList.remove('hidden');
return;
}
const j = await me.json();
if (!j.user || !j.user.is_superuser) {
blocked.classList.remove('hidden');
greet.textContent = `Signed in as ${j.user.username}`;
logout.classList.remove('hidden');
return;
}
currentAdminUserId = j.user.id;
greet.textContent = `Signed in as ${j.user.username} (admin)`;
logout.classList.remove('hidden');
usersPanel.classList.remove('hidden');
invitePanel.classList.remove('hidden');
await refreshUsersList(j.user.id);
}
init();
</script>
</body>
</html>

204
src/static/zip-utils.js Normal file
View File

@@ -0,0 +1,204 @@
/**
* Tiny zero-dependency ZIP encoder. STORE-only (no compression) — fine for
* Python source which is small, gzip would only save a few KB. Kept inline
* here so local-mode export works on every browser, including the ones
* (Firefox/Safari/Brave-without-flag) that have no folder picker.
*
* Format reference: APPNOTE.TXT 6.3.x
*/
let _crcTable = null;
function crcTable() {
if (_crcTable) return _crcTable;
const t = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let j = 0; j < 8; j++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
t[i] = c;
}
_crcTable = t;
return t;
}
function crc32(bytes) {
const tbl = crcTable();
let c = 0xffffffff;
for (let i = 0; i < bytes.length; i++) c = tbl[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function dosTime(d) {
return ((d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1)) & 0xffff;
}
function dosDate(d) {
return (((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate()) & 0xffff;
}
/**
* Build an uncompressed ZIP from a list of `{ name, content }` entries.
* `content` may be a string or a Uint8Array.
*
* Returns a Blob (`application/zip`).
*/
export function createZip(entries) {
const enc = new TextEncoder();
const date = new Date();
const time = dosTime(date);
const ddate = dosDate(date);
const parts = [];
const central = [];
let offset = 0;
for (const entry of entries) {
const data = typeof entry.content === 'string' ? enc.encode(entry.content) : entry.content;
const nameBytes = enc.encode(entry.name);
const crc = crc32(data);
const local = new ArrayBuffer(30 + nameBytes.length);
const lv = new DataView(local);
lv.setUint32(0, 0x04034b50, true);
lv.setUint16(4, 20, true);
lv.setUint16(6, 0x0800, true); // bit 11 = UTF-8 filename
lv.setUint16(8, 0, true);
lv.setUint16(10, time, true);
lv.setUint16(12, ddate, true);
lv.setUint32(14, crc, true);
lv.setUint32(18, data.length, true);
lv.setUint32(22, data.length, true);
lv.setUint16(26, nameBytes.length, true);
lv.setUint16(28, 0, true);
new Uint8Array(local, 30).set(nameBytes);
parts.push(local);
parts.push(data);
const cd = new ArrayBuffer(46 + nameBytes.length);
const cv = new DataView(cd);
cv.setUint32(0, 0x02014b50, true);
cv.setUint16(4, 0x031e, true); // made by: unix + zip 3.0
cv.setUint16(6, 20, true);
cv.setUint16(8, 0x0800, true);
cv.setUint16(10, 0, true);
cv.setUint16(12, time, true);
cv.setUint16(14, ddate, true);
cv.setUint32(16, crc, true);
cv.setUint32(20, data.length, true);
cv.setUint32(24, data.length, true);
cv.setUint16(28, nameBytes.length, true);
cv.setUint16(30, 0, true);
cv.setUint16(32, 0, true);
cv.setUint16(34, 0, true);
cv.setUint16(36, 0, true);
cv.setUint32(38, 0, true);
cv.setUint32(42, offset, true);
new Uint8Array(cd, 46).set(nameBytes);
central.push(cd);
offset += local.byteLength + data.length;
}
const cdOffset = offset;
let cdSize = 0;
for (const cd of central) {
parts.push(cd);
cdSize += cd.byteLength;
}
const eocd = new ArrayBuffer(22);
const ev = new DataView(eocd);
ev.setUint32(0, 0x06054b50, true);
ev.setUint16(4, 0, true);
ev.setUint16(6, 0, true);
ev.setUint16(8, entries.length, true);
ev.setUint16(10, entries.length, true);
ev.setUint32(12, cdSize, true);
ev.setUint32(16, cdOffset, true);
ev.setUint16(20, 0, true);
parts.push(eocd);
return new Blob(parts, { type: 'application/zip' });
}
/**
* Parse an uncompressed-or-DEFLATEd ZIP buffer into an array of
* `{ name, content }` entries (text). Skips directory entries (those that
* end with '/'). Throws if the buffer is not a valid ZIP archive.
*
* Compression methods supported:
* - 0 (STORE)
* - 8 (DEFLATE) — decompressed via the browser's `DecompressionStream`
* ('deflate-raw'), available in Chrome/Edge/Brave 113+, Firefox 113+,
* Safari 16.4+. If the browser is too old we surface a clear error.
*/
export async function readZip(arrayBuffer) {
const buf = arrayBuffer instanceof ArrayBuffer ? arrayBuffer : arrayBuffer.buffer;
const view = new DataView(buf);
if (buf.byteLength < 22) throw new Error('Not a ZIP file (too small)');
// EOCD signature scan from the end (max 64 KiB comment).
let eocd = -1;
const minScan = Math.max(0, buf.byteLength - 22 - 65535);
for (let i = buf.byteLength - 22; i >= minScan; i--) {
if (view.getUint32(i, true) === 0x06054b50) {
eocd = i;
break;
}
}
if (eocd === -1) throw new Error('Not a ZIP file (no end-of-central-directory)');
const totalEntries = view.getUint16(eocd + 10, true);
const cdOffset = view.getUint32(eocd + 16, true);
const dec = new TextDecoder();
const out = [];
let p = cdOffset;
for (let i = 0; i < totalEntries; i++) {
if (view.getUint32(p, true) !== 0x02014b50) {
throw new Error('Bad central-directory entry');
}
const method = view.getUint16(p + 10, true);
const compSize = view.getUint32(p + 20, true);
const uncompSize = view.getUint32(p + 24, true);
const nameLen = view.getUint16(p + 28, true);
const extraLen = view.getUint16(p + 30, true);
const commentLen = view.getUint16(p + 32, true);
const localOff = view.getUint32(p + 42, true);
const name = dec.decode(new Uint8Array(buf, p + 46, nameLen));
p += 46 + nameLen + extraLen + commentLen;
// Directory entry — skip; createFolder happens implicitly when we
// write a child file under that path.
if (name.endsWith('/')) continue;
const lview = new DataView(buf, localOff, 30);
if (lview.getUint32(0, true) !== 0x04034b50) {
throw new Error(`Bad local header for "${name}"`);
}
const lNameLen = lview.getUint16(26, true);
const lExtraLen = lview.getUint16(28, true);
const dataOff = localOff + 30 + lNameLen + lExtraLen;
let bytes;
if (method === 0) {
bytes = new Uint8Array(buf, dataOff, uncompSize);
} else if (method === 8) {
if (typeof DecompressionStream === 'undefined') {
throw new Error(
`"${name}" is DEFLATE-compressed but this browser has no DecompressionStream`
);
}
const compressed = new Uint8Array(buf, dataOff, compSize);
const stream = new ReadableStream({
start(controller) {
controller.enqueue(compressed);
controller.close();
},
}).pipeThrough(new DecompressionStream('deflate-raw'));
bytes = new Uint8Array(await new Response(stream).arrayBuffer());
} else {
throw new Error(`Unsupported compression method ${method} on "${name}"`);
}
out.push({ name, content: dec.decode(bytes) });
}
return out;
}

View File

@@ -9,14 +9,23 @@ def test_root_serves_html(client):
response = client.get("/")
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
assert "Python Editor" in response.text
assert "LED Editor" in response.text
def test_editor_route_serves_editor_html(client):
response = client.get("/editor")
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
assert "Python Editor" in response.text
assert "LED Editor" in response.text
def test_lib_served_directly_from_repo_bundle(client, tmp_path):
"""`lib/` reads come straight from repo `lib/`; no workspace cache is created."""
resp = client.get("/api/file/lib/machine.py")
assert resp.status_code == 200
body = resp.json().get("content", "")
assert "class Pin" in body and len(body) > 0
assert not (tmp_path / "lib").exists()
def test_list_files_hides_dotfiles_and_reports_sizes(client, tmp_path):
@@ -57,17 +66,14 @@ def test_save_file_collapses_duplicate_scoped_prefix(client, tmp_path):
def test_lib_folder_is_read_only_for_mutations(client, tmp_path):
lib_dir = tmp_path / "lib"
lib_dir.mkdir(exist_ok=True)
(lib_dir / "helper.py").write_text("x = 1\n", encoding="utf-8")
code_dir = tmp_path / "code"
code_dir.mkdir()
code_dir.mkdir(exist_ok=True)
(code_dir / "main.py").write_text("print('ok')\n", encoding="utf-8")
save_blocked = client.post("/api/file/lib/new.txt", json={"content": "nope"})
assert save_blocked.status_code == 403
delete_blocked = client.delete("/api/file/lib/helper.py")
delete_blocked = client.delete("/api/file/lib/machine.py")
assert delete_blocked.status_code == 403
move_blocked = client.post(
@@ -109,7 +115,7 @@ def test_read_file_non_utf8_returns_400(client, tmp_path):
def test_delete_file_success_and_errors(client, tmp_path):
target = tmp_path / "code" / "delete-me.txt"
target.parent.mkdir()
target.parent.mkdir(exist_ok=True)
target.write_text("x", encoding="utf-8")
ok = client.delete("/api/file/code/delete-me.txt")
@@ -213,7 +219,7 @@ def test_folder_create_and_delete(client, tmp_path):
def test_create_folder_collapses_duplicate_scoped_prefix(client, tmp_path):
(tmp_path / "code").mkdir()
(tmp_path / "code").mkdir(exist_ok=True)
create = client.post("/api/folder/new/code/code/nested", json={"path": "ignored"})
assert create.status_code == 200
assert (tmp_path / "code" / "nested").is_dir()
@@ -224,23 +230,23 @@ def test_folder_delete_errors(client, tmp_path):
missing = client.delete("/api/folder/code/missing")
assert missing.status_code == 404
(tmp_path / "code").mkdir()
(tmp_path / "code").mkdir(exist_ok=True)
(tmp_path / "code" / "file.txt").write_text("x", encoding="utf-8")
not_dir = client.delete("/api/folder/code/file.txt")
assert not_dir.status_code == 400
def test_workspace_py_sources_returns_python_files(client, tmp_path):
(tmp_path / "code").mkdir()
(tmp_path / "code").mkdir(exist_ok=True)
(tmp_path / "code" / "app.py").write_text("x = 1\n", encoding="utf-8")
(tmp_path / "lib").mkdir(exist_ok=True)
(tmp_path / "lib" / "util.py").write_text("def f():\n pass\n", encoding="utf-8")
response = client.get("/api/workspace/py-sources")
assert response.status_code == 200
files = response.json()["files"]
assert files["code/app.py"] == "x = 1\n"
assert "lib/util.py" in files
# Bundled stubs from repo `lib/` are merged in automatically.
assert "lib/machine.py" in files
assert "lib/neopixel.py" in files
def test_api_requires_bearer_when_editor_api_key_set(tmp_path, monkeypatch):
@@ -265,7 +271,9 @@ def test_api_requires_bearer_when_editor_api_key_set(tmp_path, monkeypatch):
assert ok.status_code == 200
def test_create_app_startup_creates_lib(tmp_path, monkeypatch):
def test_create_app_startup_does_not_seed_workspace_lib(tmp_path, monkeypatch):
"""Bundle stubs are read directly from repo `lib/`, so startup must not create
a `WORKSPACE_ROOT/lib` cache."""
import editor_app.config as config
import editor_app.db.session as db_sess
import editor_app.main as main
@@ -283,4 +291,4 @@ def test_create_app_startup_creates_lib(tmp_path, monkeypatch):
assert not (tmp_path / "lib").exists()
with TestClient(main.app) as _client:
_client.get("/api/auth/status")
assert (tmp_path / "lib").is_dir()
assert not (tmp_path / "lib").exists()

View File

@@ -11,12 +11,14 @@ def _reload_app(tmp_path, monkeypatch, **env):
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
monkeypatch.setenv("AUTH_DATABASE_PATH", str(tmp_path / "auth.db"))
monkeypatch.setenv("AUTH_REGISTER_OPEN", "true")
monkeypatch.setenv("AUTH_INVITE_ONLY", "false")
monkeypatch.delenv("EDITOR_API_KEY", raising=False)
monkeypatch.delenv("BOOTSTRAP_ADMIN_USERNAME", raising=False)
monkeypatch.delenv("BOOTSTRAP_ADMIN_PASSWORD", raising=False)
for k, v in env.items():
monkeypatch.setenv(k, v)
config.WORKSPACE_ROOT = tmp_path
monkeypatch.setattr(config, "WORKSPACE_ROOT", tmp_path)
db_sess.reset_engine()
importlib.reload(main)
return main.app
@@ -26,7 +28,33 @@ def test_auth_status_public(tmp_path, monkeypatch):
with TestClient(_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="false")) as client:
r = client.get("/api/auth/status")
assert r.status_code == 200
assert r.json() == {"auth_enabled": False, "register_open": True}
assert r.json() == {"auth_enabled": False, "register_open": True, "invite_required": False}
def test_auth_invite_only_defaults_on(monkeypatch, tmp_path):
"""When AUTH_INVITE_ONLY is unset, require invites (deployment-safe default)."""
import editor_app.config as config
import editor_app.db.session as db_sess
import editor_app.main as main
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
monkeypatch.setenv("AUTH_DATABASE_PATH", str(tmp_path / "auth.db"))
monkeypatch.setenv("AUTH_REGISTER_OPEN", "true")
monkeypatch.setenv("AUTH_ENABLED", "true")
monkeypatch.delenv("AUTH_INVITE_ONLY", raising=False)
monkeypatch.delenv("EDITOR_API_KEY", raising=False)
monkeypatch.delenv("BOOTSTRAP_ADMIN_USERNAME", raising=False)
monkeypatch.delenv("BOOTSTRAP_ADMIN_PASSWORD", raising=False)
config.WORKSPACE_ROOT = tmp_path
db_sess.reset_engine()
importlib.reload(main)
with TestClient(main.app) as client:
st = client.get("/api/auth/status")
assert st.status_code == 200
assert st.json()["invite_required"] is True
denied = client.post("/api/auth/register", json={"username": "noc", "password": "password99"})
assert denied.status_code == 403
def test_register_login_and_api_access(tmp_path, monkeypatch):
@@ -56,6 +84,37 @@ def test_register_login_and_api_access(tmp_path, monkeypatch):
assert client.get("/api/files").status_code == 401
def test_new_user_workspace_has_default_main_py(tmp_path, monkeypatch):
with TestClient(
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
) as client:
reg = client.post("/api/auth/register", json={"username": "alice", "password": "password99"})
assert reg.status_code == 200
assert reg.json()["username"] == "alice"
uid = reg.json()["id"]
code_root = tmp_path / "users" / f"alice-{uid}" / "code"
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)"
text = cp.read_text(encoding="utf-8")
assert len(text.strip()) > 20
assert "from led_patterns" not in text
assert "import led_patterns" not in text
assert client.post("/api/auth/login", json={"username": "alice", "password": "password99"}).status_code == 200
fetched = client.get("/api/file/code/main.py")
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")
assert chase.status_code == 200
assert "knight_rider_scanner_frame" in chase.json()["content"]
def test_second_user_not_superuser(tmp_path, monkeypatch):
with TestClient(
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
@@ -85,7 +144,101 @@ def test_register_closed(tmp_path, monkeypatch):
assert r.status_code == 403
def test_superuser_lists_and_creates_users(tmp_path, monkeypatch):
def test_admin_can_create_invite_and_register_with_token(tmp_path, monkeypatch):
with TestClient(
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
) as client:
client.post("/api/auth/register", json={"username": "admin", "password": "password99", "invite_token": None})
client.post("/api/auth/login", json={"username": "admin", "password": "password99"})
invite = client.post("/api/users/invites", json={"email": "newuser@example.com", "expires_days": 7})
assert invite.status_code == 200
invite_url = invite.json()["invite_url"]
token = invite_url.split("invite=", 1)[1]
client.post("/api/auth/logout")
reg = client.post(
"/api/auth/register",
json={"username": "newuser", "password": "password99", "invite_token": token},
)
assert reg.status_code == 200
assert reg.json()["username"] == "newuser"
def test_admin_can_create_link_only_invite(tmp_path, monkeypatch):
with TestClient(
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
) as client:
client.post("/api/auth/register", json={"username": "admin", "password": "password99", "invite_token": None})
client.post("/api/auth/login", json={"username": "admin", "password": "password99"})
invite = client.post("/api/users/invites", json={"email": None, "expires_days": 7})
assert invite.status_code == 200
body = invite.json()
assert body["invite_url"].startswith("http://")
assert body["delivered"] is False
def test_invite_required_blocks_public_register(tmp_path, monkeypatch):
with TestClient(
_reload_app(
tmp_path,
monkeypatch,
AUTH_ENABLED="true",
AUTH_REGISTER_OPEN="true",
AUTH_INVITE_ONLY="true",
)
) as client:
blocked = client.post("/api/auth/register", json={"username": "plain", "password": "password99"})
assert blocked.status_code == 403
def test_superuser_can_patch_user_account(tmp_path, monkeypatch):
with TestClient(
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
) as client:
client.post("/api/auth/register", json={"username": "admin", "password": "password99"})
client.post("/api/auth/login", json={"username": "admin", "password": "password99"})
invite = client.post("/api/users/invites", json={"email": None, "expires_days": 7})
token = invite.json()["invite_url"].split("invite=", 1)[1]
client.post("/api/auth/logout")
sub = client.post(
"/api/auth/register",
json={"username": "subacc", "password": "original99", "invite_token": token},
).json()
uid = sub["id"]
client.post("/api/auth/login", json={"username": "admin", "password": "password99"})
pat = client.patch(
f"/api/users/{uid}",
json={"username": "renamed", "is_superuser": True, "password": "renewedpw8"},
)
assert pat.status_code == 200
assert pat.json()["username"] == "renamed"
assert pat.json()["is_superuser"] is True
client.post("/api/auth/logout")
assert client.post(
"/api/auth/login",
json={"username": "renamed", "password": "renewedpw8"},
).status_code == 200
def test_superuser_cannot_demote_last_admin(tmp_path, monkeypatch):
with TestClient(
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
) as client:
client.post("/api/auth/register", json={"username": "solo_admin", "password": "password99"})
client.post("/api/auth/login", json={"username": "solo_admin", "password": "password99"})
uid = client.get("/api/auth/me").json()["user"]["id"]
demote = client.patch(
f"/api/users/{uid}",
json={"username": "solo_admin", "is_superuser": False},
)
assert demote.status_code == 400
detail = demote.json().get("detail") or ""
assert "last" in detail.lower() or "administrator" in detail.lower()
def test_superuser_lists_users_after_invite_signup(tmp_path, monkeypatch):
with TestClient(
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
) as client:
@@ -96,14 +249,20 @@ def test_superuser_lists_and_creates_users(tmp_path, monkeypatch):
assert listed.status_code == 200
assert len(listed.json()) == 1
created = client.post(
"/api/users",
json={"username": "sub", "password": "password99", "is_superuser": False},
)
assert created.status_code == 200
assert created.json()["username"] == "sub"
assert created.json()["is_superuser"] is False
invite = client.post("/api/users/invites", json={"email": None, "expires_days": 7})
assert invite.status_code == 200
token = invite.json()["invite_url"].split("invite=", 1)[1]
client.post("/api/auth/logout")
reg = client.post(
"/api/auth/register",
json={"username": "sub", "password": "password99", "invite_token": token},
)
assert reg.status_code == 200
assert reg.json()["username"] == "sub"
assert reg.json()["is_superuser"] is False
client.post("/api/auth/login", json={"username": "admin", "password": "password99"})
names = {u["username"] for u in client.get("/api/users").json()}
assert names == {"admin", "sub"}
@@ -121,6 +280,107 @@ def test_non_superuser_cannot_list_users(tmp_path, monkeypatch):
assert denied.status_code == 403
def test_users_have_isolated_workspaces(tmp_path, monkeypatch):
with TestClient(
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
) as client:
client.post("/api/auth/register", json={"username": "alice", "password": "password99"})
client.post("/api/auth/logout")
client.post("/api/auth/register", json={"username": "bob", "password": "password99"})
client.post("/api/auth/logout")
client.post("/api/auth/login", json={"username": "alice", "password": "password99"})
save = client.post("/api/file/code/only_alice.py", json={"content": "owner = 'alice'\n"})
assert save.status_code == 200
client.post("/api/auth/logout")
client.post("/api/auth/login", json={"username": "bob", "password": "password99"})
missing = client.get("/api/file/code/only_alice.py")
assert missing.status_code == 404
listing = client.get("/api/files", params={"path": "code"})
assert listing.status_code == 200
names = {item["name"] for item in listing.json()["files"]}
assert "only_alice.py" not in names
def test_lib_is_shared_read_only_across_users(tmp_path, monkeypatch):
"""`lib/` lives at the repo root (single source of truth), not under WORKSPACE_ROOT,
so we patch `config.PROJECT_ROOT` to give the test its own isolated lib bundle."""
import editor_app.config as config
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)
with TestClient(
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
) as client:
client.post("/api/auth/register", json={"username": "alice", "password": "password99"})
client.post("/api/auth/logout")
client.post("/api/auth/register", json={"username": "bob", "password": "password99"})
client.post("/api/auth/logout")
client.post("/api/auth/login", json={"username": "alice", "password": "password99"})
lib_read = client.get("/api/file/lib/shared.py")
assert lib_read.status_code == 200
assert "VALUE = 42" in lib_read.json()["content"]
lib_write = client.post("/api/file/lib/shared.py", json={"content": "VALUE = 0\n"})
assert lib_write.status_code == 403
client.post("/api/auth/logout")
client.post("/api/auth/login", json={"username": "bob", "password": "password99"})
lib_read_bob = client.get("/api/file/lib/shared.py")
assert lib_read_bob.status_code == 200
assert "VALUE = 42" in lib_read_bob.json()["content"]
def test_superuser_can_open_other_user_workspace(tmp_path, monkeypatch):
with TestClient(
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
) as client:
client.post("/api/auth/register", json={"username": "admin", "password": "password99"})
client.post("/api/auth/logout")
client.post("/api/auth/register", json={"username": "bob", "password": "password99"})
client.post("/api/auth/logout")
client.post("/api/auth/login", json={"username": "bob", "password": "password99"})
me = client.get("/api/auth/me").json()
bob_id = me["user"]["id"]
client.post("/api/file/code/bob_only.py", json={"content": "owner='bob'\n"})
client.post("/api/auth/logout")
client.post("/api/auth/login", json={"username": "admin", "password": "password99"})
as_bob = client.get("/api/file/code/bob_only.py", params={"workspace_user_id": bob_id})
assert as_bob.status_code == 200
assert "owner='bob'" in as_bob.json()["content"]
def test_non_admin_cannot_override_workspace_user(tmp_path, monkeypatch):
with TestClient(
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
) as client:
client.post("/api/auth/register", json={"username": "admin", "password": "password99"})
client.post("/api/auth/logout")
client.post("/api/auth/register", json={"username": "alice", "password": "password99"})
client.post("/api/auth/logout")
client.post("/api/auth/register", json={"username": "bob", "password": "password99"})
client.post("/api/auth/logout")
client.post("/api/auth/login", json={"username": "alice", "password": "password99"})
denied = client.get("/api/files", params={"workspace_user_id": 1})
assert denied.status_code == 403
def test_login_serves_page(client):
r = client.get("/login")
assert r.status_code == 200

View File

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

View File

@@ -0,0 +1,72 @@
import importlib.util
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"
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
spec.loader.exec_module(module)
return module
def test_rainbow_frame_shape_and_bounds():
patterns = _load_patterns_module()
frame = patterns.rainbow_frame(12, 3)
assert len(frame) == 12
for color in frame:
assert len(color) == 3
assert all(0 <= c <= 255 for c in color)
def test_chase_frame_has_head_and_tail():
patterns = _load_patterns_module()
frame = patterns.chase_frame(8, 5, color=(10, 20, 30), tail=(1, 2, 3))
assert len(frame) == 8
assert frame[5] == (10, 20, 30)
assert frame[4] == (1, 2, 3)
assert sum(1 for c in frame if c != (0, 0, 0)) == 2
def test_bounce_head_pingpongs_off_ends():
patterns = _load_patterns_module()
bounce = patterns._bounce_head_index
assert bounce(5, 0) == 0 and bounce(5, 4) == 4
assert bounce(5, 5) == 3 and bounce(5, 8) == 1 and bounce(5, 16) == bounce(5, 0)
def test_scanner_bounce_has_head_and_fading_tail():
patterns = _load_patterns_module()
frame = patterns.scanner_bounce_frame(
8, 20, head_color=(80, 10, 10), tail_color=(20, 50, 90), tail_len=4
)
assert len(frame) == 8
bright = max(range(8), key=lambda i: sum(frame[i]))
assert sum(frame[bright]) >= 80
def test_knight_rider_tail_falls_off():
patterns = _load_patterns_module()
fr = patterns.knight_rider_scanner_frame(16, 55, tail_len=8, falloff_gamma=3.0)
assert len(fr) == 16
reds_sorted = sorted(fr[i][0] for i in range(16) if sum(fr[i]) > 0)
assert len(reds_sorted) >= 2
assert reds_sorted[-1] >= 200
assert reds_sorted[0] < reds_sorted[-1] * 0.5
def test_twinkle_frame_is_deterministic_for_same_inputs():
patterns = _load_patterns_module()
a = patterns.twinkle_frame(20, frame=9, seed=777, sparkles=4)
b = patterns.twinkle_frame(20, frame=9, seed=777, sparkles=4)
assert a == b
def test_twinkle_frame_varies_between_frames():
patterns = _load_patterns_module()
a = patterns.twinkle_frame(20, frame=1, seed=777, sparkles=4)
b = patterns.twinkle_frame(20, frame=2, seed=777, sparkles=4)
assert a != b

View File

@@ -7,6 +7,9 @@ import urllib.error
import urllib.request
import pytest
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as ec
from selenium.webdriver.support.ui import WebDriverWait
pytest.importorskip("selenium.webdriver")
@@ -46,3 +49,24 @@ def test_home_page_title(driver):
)
driver.get(f"{base}/")
assert "Python Editor" in driver.title
@pytest.mark.selenium
def test_editor_page_loads_core_controls(driver):
base = os.environ.get("SELENIUM_BASE_URL", "http://127.0.0.1:8080").rstrip("/")
if not _server_reachable(base):
pytest.skip(
f"No server at {base}. In another terminal run: "
"pipenv run dev (then re-run this test, or set SELENIUM_BASE_URL)."
)
driver.get(f"{base}/editor")
# If the app is configured with AUTH_ENABLED=true, unauthenticated requests redirect to /login.
if "/login" in (driver.current_url or ""):
pytest.skip("Editor requires login; set AUTH_ENABLED=false for this Selenium smoke test.")
wait = WebDriverWait(driver, 10)
wait.until(ec.presence_of_element_located((By.ID, "run-btn")))
wait.until(ec.presence_of_element_located((By.ID, "stop-btn")))
wait.until(ec.presence_of_element_located((By.ID, "file-tree")))

View File

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