Compare commits

...

2 Commits

Author SHA1 Message Date
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
42 changed files with 2159 additions and 129 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

@@ -9,10 +9,20 @@
# --- User accounts (SQLite) --- # --- User accounts (SQLite) ---
# AUTH_ENABLED=true # require sign-in for /api/* (except /api/auth/*) # AUTH_ENABLED=true # require sign-in for /api/* (except /api/auth/*)
# AUTH_REGISTER_OPEN=true # allow POST /api/auth/register # AUTH_REGISTER_OPEN=true # allow POST /api/auth/register
# AUTH_INVITE_ONLY=false # require invite token for registration
# AUTH_DATABASE_PATH=./data/editor.db # AUTH_DATABASE_PATH=./data/editor.db
# AUTH_SESSION_DAYS=14 # AUTH_SESSION_DAYS=14
# BOOTSTRAP_ADMIN_USERNAME=admin # first-run only: create superuser if DB has zero users # BOOTSTRAP_ADMIN_USERNAME=admin # first-run only: create superuser if DB has zero users
# BOOTSTRAP_ADMIN_PASSWORD=change-me-in-production # 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) # Base URL for `pipenv run test-selenium` (app must be running separately)
# SELENIUM_BASE_URL=http://127.0.0.1:8080 # SELENIUM_BASE_URL=http://127.0.0.1:8080

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
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 workspace ./workspace
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`
Use `workspace/code/led_tutorial.py` while reading this guide.
## 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 = "*" pytest-cov = "*"
httpx = "*" httpx = "*"
selenium = "*" selenium = "*"
playwright = "*"
[packages] [packages]
fastapi = "*" fastapi = "*"

90
Pipfile.lock generated
View File

@@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "f9de958b9982b3a30c107c2c02746aece031522dc429be84e4f9589c73404282" "sha256": "ce177325185c5a9e04401b9368766f419921dfc5f9de934820507dae79998082"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@@ -586,6 +586,71 @@
"markers": "python_version >= '3.10'", "markers": "python_version >= '3.10'",
"version": "==7.13.5" "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": { "h11": {
"hashes": [ "hashes": [
"sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1",
@@ -643,6 +708,21 @@
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==26.2" "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": { "pluggy": {
"hashes": [ "hashes": [
"sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3",
@@ -651,6 +731,14 @@
"markers": "python_version >= '3.9'", "markers": "python_version >= '3.9'",
"version": "==1.6.0" "version": "==1.6.0"
}, },
"pyee": {
"hashes": [
"sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8",
"sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228"
],
"markers": "python_version >= '3.8'",
"version": "==13.0.1"
},
"pygments": { "pygments": {
"hashes": [ "hashes": [
"sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f",

View File

@@ -1,6 +1,6 @@
# python-editor # 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 ## Run
@@ -48,8 +48,44 @@ If nothing is listening, the smoke test **skips** with a short message instead o
Open [http://localhost:8080](http://localhost:8080). Open [http://localhost:8080](http://localhost:8080).
### 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` (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. **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.
Email invite signup:
- 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.
- Set `AUTH_INVITE_ONLY=true` to require invite tokens for all registrations.
- 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. **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`. The home page can store the API key in `sessionStorage` when you are not using cookie login, or use `?api_key=` on `/editor`.
@@ -58,3 +94,55 @@ The home page can store the API key in `sessionStorage` when you are not using c
- `src/` — FastAPI app and static UI (`src/static/`) - `src/` — FastAPI app and static UI (`src/static/`)
- `workspace/` — default tree: `code/` (editable), `lib/` (read-only via API) - `workspace/` — default tree: `code/` (editable), `lib/` (read-only via API)
## ESP32 / NeoPixel mock
The browser runtime now includes MicroPython-style mocks in `workspace/lib`:
- `machine.Pin`
- `neopixel.NeoPixel`
Use them from scripts in `workspace/code` exactly like ESP32 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:
- `LED_TUTORIAL.md` - step-by-step NeoPixel tutorial
- `workspace/code/led_tutorial.py` - runnable guided LED example
- `workspace/code/led_patterns.py` - reusable pattern helpers (`rainbow_frame`, `chase_frame`, `twinkle_frame`)
- `workspace/code/pattern_rainbow_demo.py` - rainbow animation demo
- `workspace/code/pattern_chase_demo.py` - chase animation demo
- `workspace/code/pattern_twinkle_demo.py` - twinkle animation demo
- `workspace/code/panel16_utils.py` - helpers for 16x16 serpentine mapping
- `workspace/code/panel16_rainbow_wave.py` - 16x16 rainbow wave
- `workspace/code/panel16_bounce.py` - 16x16 bouncing pixel with trail
- `workspace/code/panel16_matrix_rain.py` - 16x16 matrix rain effect
## 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

View File

@@ -38,3 +38,20 @@ class AuthSession(Base):
created_at: Mapped[dt.datetime] = mapped_column(DateTime, default=_utc_naive) created_at: Mapped[dt.datetime] = mapped_column(DateTime, default=_utc_naive)
user: Mapped[User] = relationship("User", back_populates="sessions") 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
)
created_at: Mapped[dt.datetime] = mapped_column(DateTime, default=_utc_naive)

View File

@@ -1,12 +1,15 @@
from __future__ import annotations from __future__ import annotations
import os import os
import re
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 sqlalchemy.orm import Session
from editor_app.db.session import get_db from editor_app.db.session import get_db
from editor_app.db.models import User from editor_app.db.models import User
from editor_app import config
from editor_app.services import accounts from editor_app.services import accounts
@@ -61,3 +64,33 @@ async def require_superuser(
if not user.is_superuser: if not user.is_superuser:
raise HTTPException(status_code=403, detail="Superuser required") raise HTTPException(status_code=403, detail="Superuser required")
return user return user
def _safe_workspace_leaf(user: User) -> str:
base = re.sub(r"[^a-zA-Z0-9._-]+", "-", user.username).strip("-").lower() or "user"
return f"{base}-{user.id}"
def _seed_user_workspace(user_root: Path) -> None:
(user_root / "code").mkdir(parents=True, exist_ok=True)
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 = root / "users" / _safe_workspace_leaf(target_user)
_seed_user_workspace(user_root)
return user_root

View File

@@ -33,7 +33,11 @@ def _clear_session_cookie(response: Response, request: Request) -> None:
@router.get("/status", response_model=AuthStatusResponse) @router.get("/status", response_model=AuthStatusResponse)
async def auth_status() -> 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") @router.get("/me")
@@ -57,10 +61,17 @@ async def register(
) -> UserPublic: ) -> UserPublic:
if not accounts.auth_enabled(): if not accounts.auth_enabled():
raise HTTPException(status_code=400, detail="Set AUTH_ENABLED=true to use accounts") 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)") raise HTTPException(status_code=403, detail="Registration is disabled (AUTH_REGISTER_OPEN=false)")
try: try:
user = accounts.register_user(db, body.username, body.password) 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: except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc raise HTTPException(status_code=409, detail=str(exc)) from exc
return UserPublic.model_validate(user) return UserPublic.model_validate(user)

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

View File

@@ -6,7 +6,7 @@ from sqlalchemy.orm import Session
from editor_app.db.session import get_db from editor_app.db.session import get_db
from editor_app.db.models import User from editor_app.db.models import User
from editor_app.deps import require_superuser from editor_app.deps import require_superuser
from editor_app.schemas.users import UserCreateAdmin, UserPublic from editor_app.schemas.users import InviteCreateRequest, InviteCreateResponse, UserCreateAdmin, UserPublic
from editor_app.services import accounts from editor_app.services import accounts
router = APIRouter(prefix="/api/users", tags=["users"]) router = APIRouter(prefix="/api/users", tags=["users"])
@@ -48,3 +48,26 @@ async def delete_user_admin(
if not accounts.delete_user(db, user_id): if not accounts.delete_user(db, user_id):
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
return {"message": "User deleted"} 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

@@ -4,6 +4,7 @@ from pydantic import BaseModel, Field, field_validator
class RegisterRequest(BaseModel): class RegisterRequest(BaseModel):
username: str = Field(min_length=3, max_length=64) username: str = Field(min_length=3, max_length=64)
password: str = Field(min_length=8, max_length=128) password: str = Field(min_length=8, max_length=128)
invite_token: str | None = Field(default=None, min_length=8, max_length=256)
@field_validator("username") @field_validator("username")
@classmethod @classmethod
@@ -44,3 +45,27 @@ class UserCreateAdmin(BaseModel):
class AuthStatusResponse(BaseModel): class AuthStatusResponse(BaseModel):
auth_enabled: bool auth_enabled: bool
register_open: 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,15 @@ from __future__ import annotations
import datetime as dt import datetime as dt
import os import os
import secrets import secrets
import smtplib
from email.message import EmailMessage
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import bcrypt import bcrypt
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from editor_app.db.models import AuthSession, User from editor_app.db.models import AuthSession, InviteToken, User
if TYPE_CHECKING: if TYPE_CHECKING:
pass pass
@@ -75,6 +77,20 @@ def register_user(db: Session, username: str, password: str) -> User:
return create_user(db, username, password, is_superuser=first) 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=False)
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: def authenticate(db: Session, username: str, password: str) -> User | None:
user = get_user_by_username(db, username.strip()) user = get_user_by_username(db, username.strip())
if not user or not verify_password(password, user.password_hash): if not user or not verify_password(password, user.password_hash):
@@ -126,3 +142,68 @@ def delete_user(db: Session, user_id: int) -> bool:
db.delete(user) db.delete(user)
db.commit() db.commit()
return True return True
def invite_required() -> bool:
return os.environ.get("AUTH_INVITE_ONLY", "false").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,14 @@ LIB_DIR_NAME = "lib"
WRITABLE_ROOTS = {"code"} 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:
return (config.WORKSPACE_ROOT.resolve() / LIB_DIR_NAME).resolve()
def normalize_relative_path(relative_path: str) -> str: def normalize_relative_path(relative_path: str) -> str:
cleaned = (relative_path or "").strip().lstrip("/") cleaned = (relative_path or "").strip().lstrip("/")
if not cleaned: if not cleaned:
@@ -22,33 +30,48 @@ def normalize_relative_path(relative_path: str) -> str:
return "/".join(parts) 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) 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(_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: try:
target_path.relative_to(config.WORKSPACE_ROOT.resolve()) target_path.relative_to(root)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail="Path escapes workspace") from exc raise HTTPException(status_code=400, detail="Path escapes workspace") from exc
return target_path return target_path
def _is_path_in_lib(target_path: Path) -> bool: def _is_path_in_lib(target_path: Path, workspace_root: Path | None = None) -> bool:
workspace = config.WORKSPACE_ROOT.resolve() workspace = _workspace_root(workspace_root)
lib_root = (workspace / LIB_DIR_NAME).resolve() lib_root = (workspace / LIB_DIR_NAME).resolve()
shared_lib_root = _shared_lib_root()
try: try:
target_path.resolve().relative_to(lib_root) target_path.resolve().relative_to(lib_root)
return True return True
except ValueError:
pass
try:
target_path.resolve().relative_to(shared_lib_root)
return True
except ValueError: except ValueError:
return False return False
def _ensure_not_lib_path(target_path: Path) -> None: def _ensure_not_lib_path(target_path: Path, workspace_root: Path | None = None) -> None:
if _is_path_in_lib(target_path): if _is_path_in_lib(target_path, workspace_root):
raise HTTPException(status_code=403, detail="lib is read-only") raise HTTPException(status_code=403, detail="lib is read-only")
def _is_writable_path(target_path: Path) -> bool: def _is_writable_path(target_path: Path, workspace_root: Path | None = None) -> bool:
workspace = config.WORKSPACE_ROOT.resolve() workspace = _workspace_root(workspace_root)
resolved = target_path.resolve() resolved = target_path.resolve()
try: try:
relative = resolved.relative_to(workspace) relative = resolved.relative_to(workspace)
@@ -59,17 +82,18 @@ def _is_writable_path(target_path: Path) -> bool:
return relative.parts[0] in WRITABLE_ROOTS return relative.parts[0] in WRITABLE_ROOTS
def _ensure_writable_path(target_path: Path) -> None: def _ensure_writable_path(target_path: Path, workspace_root: Path | None = None) -> None:
if not _is_writable_path(target_path): if not _is_writable_path(target_path, workspace_root):
raise HTTPException( raise HTTPException(
status_code=403, status_code=403,
detail="Only code/ is writable (lib is read-only)", 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) 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(): if not target_path.exists() or not target_path.is_dir():
raise HTTPException(status_code=404, detail="Directory not found") raise HTTPException(status_code=404, detail="Directory not found")
@@ -77,6 +101,8 @@ def list_files(path: str = "") -> list[FileInfo]:
for item in sorted(target_path.iterdir()): for item in sorted(target_path.iterdir()):
if item.name.startswith("."): if item.name.startswith("."):
continue continue
if not path and item.name == "users":
continue
files.append( files.append(
FileInfo( FileInfo(
name=item.name, name=item.name,
@@ -84,11 +110,15 @@ def list_files(path: str = "") -> list[FileInfo]:
size=item.stat().st_size if item.is_file() else None, 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 return files
def read_text_file(file_path: str) -> tuple[str, str]: def read_text_file(file_path: str, workspace_root: Path | None = None) -> tuple[str, str]:
target_path = resolve_workspace_path(file_path) target_path = resolve_workspace_path(file_path, workspace_root)
if not target_path.exists(): if not target_path.exists():
raise HTTPException(status_code=404, detail="File not found") raise HTTPException(status_code=404, detail="File not found")
if target_path.is_dir(): if target_path.is_dir():
@@ -100,19 +130,19 @@ def read_text_file(file_path: str) -> tuple[str, str]:
return content, target_path.name return content, target_path.name
def save_text_file(file_path: str, content: str) -> str: def save_text_file(file_path: str, content: str, workspace_root: Path | None = None) -> str:
target_path = resolve_workspace_path(file_path) target_path = resolve_workspace_path(file_path, workspace_root)
_ensure_not_lib_path(target_path) _ensure_not_lib_path(target_path, workspace_root)
_ensure_writable_path(target_path) _ensure_writable_path(target_path, workspace_root)
target_path.parent.mkdir(parents=True, exist_ok=True) target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(content, encoding="utf-8") target_path.write_text(content, encoding="utf-8")
return target_path.name return target_path.name
def delete_file(file_path: str) -> None: def delete_file(file_path: str, workspace_root: Path | None = None) -> None:
target_path = resolve_workspace_path(file_path) target_path = resolve_workspace_path(file_path, workspace_root)
_ensure_not_lib_path(target_path) _ensure_not_lib_path(target_path, workspace_root)
_ensure_writable_path(target_path) _ensure_writable_path(target_path, workspace_root)
if not target_path.exists(): if not target_path.exists():
raise HTTPException(status_code=404, detail="File not found") raise HTTPException(status_code=404, detail="File not found")
if target_path.is_dir(): if target_path.is_dir():
@@ -120,20 +150,21 @@ def delete_file(file_path: str) -> None:
target_path.unlink() target_path.unlink()
def move_path(source_path: str, destination_folder: str) -> tuple[str, str]: def move_path(source_path: str, destination_folder: str, workspace_root: Path | None = None) -> tuple[str, str]:
source = resolve_workspace_path(source_path) root = _workspace_root(workspace_root)
_ensure_not_lib_path(source) source = resolve_workspace_path(source_path, root)
_ensure_writable_path(source) _ensure_not_lib_path(source, root)
_ensure_writable_path(source, root)
if not source.exists(): if not source.exists():
raise HTTPException(status_code=404, detail="Source path not found") raise HTTPException(status_code=404, detail="Source path not found")
destination_dir = ( destination_dir = (
resolve_workspace_path(destination_folder) resolve_workspace_path(destination_folder, root)
if destination_folder if destination_folder
else config.WORKSPACE_ROOT else root
) )
_ensure_not_lib_path(destination_dir) _ensure_not_lib_path(destination_dir, root)
_ensure_writable_path(destination_dir) _ensure_writable_path(destination_dir, root)
if not destination_dir.exists() or not destination_dir.is_dir(): if not destination_dir.exists() or not destination_dir.is_dir():
raise HTTPException(status_code=404, detail="Destination folder not found") raise HTTPException(status_code=404, detail="Destination folder not found")
@@ -157,23 +188,23 @@ def move_path(source_path: str, destination_folder: str) -> tuple[str, str]:
destination.parent.mkdir(parents=True, exist_ok=True) destination.parent.mkdir(parents=True, exist_ok=True)
source.rename(destination) source.rename(destination)
moved_type = "folder" if destination.is_dir() else "file" 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: def create_folder(folder_path: str, workspace_root: Path | None = None) -> str:
target_path = resolve_workspace_path(folder_path) target_path = resolve_workspace_path(folder_path, workspace_root)
_ensure_not_lib_path(target_path) _ensure_not_lib_path(target_path, workspace_root)
_ensure_writable_path(target_path) _ensure_writable_path(target_path, workspace_root)
if target_path.exists(): if target_path.exists():
raise HTTPException(status_code=400, detail="Folder already exists") raise HTTPException(status_code=400, detail="Folder already exists")
target_path.mkdir(parents=True, exist_ok=False) target_path.mkdir(parents=True, exist_ok=False)
return target_path.name return target_path.name
def delete_folder(folder_path: str) -> None: def delete_folder(folder_path: str, workspace_root: Path | None = None) -> None:
target_path = resolve_workspace_path(folder_path) target_path = resolve_workspace_path(folder_path, workspace_root)
_ensure_not_lib_path(target_path) _ensure_not_lib_path(target_path, workspace_root)
_ensure_writable_path(target_path) _ensure_writable_path(target_path, workspace_root)
if not target_path.exists(): if not target_path.exists():
raise HTTPException(status_code=404, detail="Folder not found") raise HTTPException(status_code=404, detail="Folder not found")
if not target_path.is_dir(): if not target_path.is_dir():
@@ -181,10 +212,10 @@ def delete_folder(folder_path: str) -> None:
shutil.rmtree(target_path) shutil.rmtree(target_path)
def collect_python_sources() -> dict[str, str]: def collect_python_sources(workspace_root: Path | None = None) -> dict[str, str]:
"""Return all UTF-8 .py files under the workspace for browser-side Pyodide sync.""" """Return all UTF-8 .py files under the workspace for browser-side Pyodide sync."""
result: dict[str, str] = {} result: dict[str, str] = {}
workspace = config.WORKSPACE_ROOT.resolve() workspace = _workspace_root(workspace_root)
if not workspace.exists(): if not workspace.exists():
return result return result
for path in workspace.rglob("*.py"): for path in workspace.rglob("*.py"):
@@ -199,4 +230,18 @@ def collect_python_sources() -> dict[str, str]:
result[key] = path.read_text(encoding="utf-8") result[key] = path.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError): except (UnicodeDecodeError, OSError):
continue continue
shared_lib = _shared_lib_root()
if shared_lib.exists() and shared_lib.is_dir() and shared_lib != (workspace / LIB_DIR_NAME).resolve():
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 return result

1
src/static/.reload-token Normal file
View File

@@ -0,0 +1 @@
1777623664358

View File

@@ -52,6 +52,59 @@
.nav { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem; align-items: center; } .nav { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem; align-items: center; }
.nav span { color: #94a3b8; font-size: 0.9rem; } .nav span { color: #94a3b8; font-size: 0.9rem; }
.hidden { display: none !important; } .hidden { display: none !important; }
.invite-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;
width: auto;
margin: 0;
}
.invite-row button {
margin: 0;
}
.invite-result {
margin-top: 0.55rem;
font-size: 0.85rem;
color: #cbd5e1;
word-break: break-all;
}
.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);
}
.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;
}
</style> </style>
</head> </head>
<body> <body>
@@ -70,6 +123,20 @@
<input id="api-key" type="password" autocomplete="off" placeholder="Leave blank if not used" /> <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> <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> </div>
<section id="invite-panel" class="invite-panel hidden">
<strong>Admin invites</strong>
<p class="note" style="margin-top:0.4rem">Create an email invite link for signup.</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">Create invite</button>
<button type="button" class="btn btn-ghost" id="invite-link-btn">Create link only</button>
</div>
<div id="invite-result" class="invite-result"></div>
</section>
<section id="users-panel" class="users-panel hidden">
<strong>User management</strong>
<div id="users-list" class="users-list"></div>
</section>
<a class="btn btn-primary" href="/editor" id="open-editor">Open Editor</a> <a class="btn btn-primary" href="/editor" id="open-editor">Open Editor</a>
</main> </main>
<script> <script>
@@ -85,11 +152,15 @@
const outEl = document.getElementById('btn-logout'); const outEl = document.getElementById('btn-logout');
const greet = document.getElementById('auth-greeting'); const greet = document.getElementById('auth-greeting');
const optionalKey = document.getElementById('optional-api-key'); const optionalKey = document.getElementById('optional-api-key');
const invitePanel = document.getElementById('invite-panel');
const usersPanel = document.getElementById('users-panel');
if (!status.auth_enabled) { if (!status.auth_enabled) {
loginEl.classList.add('hidden'); loginEl.classList.add('hidden');
regEl.classList.add('hidden'); regEl.classList.add('hidden');
outEl.classList.add('hidden'); outEl.classList.add('hidden');
greet.classList.add('hidden'); greet.classList.add('hidden');
if (invitePanel) invitePanel.classList.add('hidden');
if (usersPanel) usersPanel.classList.add('hidden');
return; return;
} }
loginEl.classList.remove('hidden'); loginEl.classList.remove('hidden');
@@ -105,9 +176,50 @@
regEl.classList.add('hidden'); regEl.classList.add('hidden');
outEl.classList.remove('hidden'); outEl.classList.remove('hidden');
if (optionalKey) optionalKey.classList.add('hidden'); if (optionalKey) optionalKey.classList.add('hidden');
if (invitePanel) {
if (j.user && j.user.is_superuser) {
invitePanel.classList.remove('hidden');
if (usersPanel) usersPanel.classList.remove('hidden');
await refreshUsersList();
} else {
invitePanel.classList.add('hidden');
if (usersPanel) usersPanel.classList.add('hidden');
}
}
} else { } else {
outEl.classList.add('hidden'); outEl.classList.add('hidden');
greet.classList.add('hidden'); greet.classList.add('hidden');
if (invitePanel) invitePanel.classList.add('hidden');
if (usersPanel) usersPanel.classList.add('hidden');
}
}
async function refreshUsersList() {
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 link = document.createElement('a');
link.href = `/editor?workspace_user_id=${encodeURIComponent(String(user.id))}`;
link.textContent = 'Open workspace';
row.appendChild(name);
row.appendChild(link);
usersList.appendChild(row);
}
} catch (_err) {
usersList.textContent = 'Unable to load users.';
} }
} }
@@ -137,6 +249,54 @@
} }
} catch (_e) {} } catch (_e) {}
}); });
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();
result.textContent = '';
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 = body.detail || res.statusText || 'Failed to create invite';
return;
}
const delivered = body.delivered ? 'Email sent.' : 'Email not sent (SMTP not configured), copy link manually.';
result.innerHTML = `${delivered}<br><a href="${body.invite_url}" style="color:#93c5fd">${body.invite_url}</a>`;
} catch (err) {
result.textContent = String(err.message || err);
}
});
document.getElementById('invite-link-btn').addEventListener('click', async () => {
const result = document.getElementById('invite-result');
result.textContent = '';
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 = body.detail || res.statusText || 'Failed to create invite link';
return;
}
result.innerHTML = `Invite link created.<br><a href="${body.invite_url}" style="color:#93c5fd">${body.invite_url}</a>`;
} catch (err) {
result.textContent = String(err.message || err);
}
});
refreshAuthNav(); refreshAuthNav();
</script> </script>
</body> </body>

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Python Editor</title> <title>Python Editor</title>
<link rel="icon" href="data:,"> <link rel="icon" href="data:,">
<link rel="stylesheet" href="/static/styles.css?v=6"> <link rel="stylesheet" href="/static/styles.css?v=10">
</head> </head>
<body> <body>
<div class="container"> <div class="container">
@@ -29,6 +29,8 @@
<div class="file-info"> <div class="file-info">
<span id="save-status" class="save-status"></span> <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> <span class="runtime-hint" title="Python runs locally in your browser via Pyodide; completions use Jedi in the same runtime.">Browser · Pyodide</span>
<span id="lsp-status" class="runtime-hint" title="Jedi in-browser diagnostics">LSP: n/a</span>
<span id="workspace-badge" class="runtime-hint hidden"></span>
</div> </div>
<div class="mode-toggle"> <div class="mode-toggle">
<a id="home-btn" class="mode-btn active" href="/">Home</a> <a id="home-btn" class="mode-btn active" href="/">Home</a>
@@ -36,9 +38,14 @@
<div class="editor-actions"> <div class="editor-actions">
<button id="run-btn" disabled>Run Python</button> <button id="run-btn" disabled>Run Python</button>
<button id="stop-btn" disabled>Stop</button> <button id="stop-btn" disabled>Stop</button>
<select id="run-file-select" title="Script to run"> <label for="run-main-checkbox" class="run-main-toggle">
<option value="">Run active file</option> <input type="checkbox" id="run-main-checkbox" />
</select> Run `main.py`
</label>
<label for="panel-16x16-checkbox" class="run-main-toggle">
<input type="checkbox" id="panel-16x16-checkbox" />
16x16 panel
</label>
</div> </div>
</div> </div>
@@ -49,6 +56,19 @@
<div id="completion-dropdown" class="completion-dropdown"></div> <div id="completion-dropdown" class="completion-dropdown"></div>
</div> </div>
<section id="led-sim-panel" class="led-sim-panel hidden" aria-label="NeoPixel Simulator">
<div class="led-sim-header">
<h3>NeoPixel Simulator</h3>
<div class="led-sim-actions">
<button id="led-run-btn" type="button">Run</button>
<button id="led-stop-btn" type="button">Stop</button>
<button id="led-close-btn" type="button" aria-label="Close simulator">Close</button>
</div>
</div>
<div id="led-meta" class="led-meta">Waiting for neopixel.write()...</div>
<div id="led-grid" class="led-grid"></div>
</section>
<div class="console-container"> <div class="console-container">
<div class="console-header">Console Output</div> <div class="console-header">Console Output</div>
<pre id="console-output" class="console-output"></pre> <pre id="console-output" class="console-output"></pre>
@@ -67,6 +87,6 @@
</div> </div>
</div> </div>
<script type="module" src="/static/script.js?v=10"></script> <script type="module" src="/static/script.js?v=23"></script>
</body> </body>
</html> </html>

View File

@@ -77,6 +77,38 @@ json.dumps(out)
return; 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
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)
proj = jedi.Project("/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') { if (type === 'run') {
const files = payload.files && typeof payload.files === 'object' ? payload.files : {}; const files = payload.files && typeof payload.files === 'object' ? payload.files : {};
const mainRel = String(payload.mainPath || '').replace(/^\/+/, ''); const mainRel = String(payload.mainPath || '').replace(/^\/+/, '');
@@ -97,7 +129,7 @@ for rel, body in files.items():
with open(full, "w", encoding="utf-8") as fh: with open(full, "w", encoding="utf-8") as fh:
fh.write(str(body)) fh.write(str(body))
for entry in ("/workspace/lib", "/workspace"): for entry in ("/workspace/code", "/workspace/lib", "/workspace"):
if entry not in sys.path: if entry not in sys.path:
sys.path.insert(0, entry) sys.path.insert(0, entry)

View File

@@ -61,11 +61,16 @@
<input id="username" name="username" autocomplete="username" required /> <input id="username" name="username" autocomplete="username" required />
<label for="password">Password</label> <label for="password">Password</label>
<input id="password" type="password" name="password" autocomplete="new-password" required /> <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> <button type="submit" id="submit">Register</button>
</form> </form>
<p><a href="/login">Sign in</a> · <a href="/">Home</a></p> <p><a href="/login">Sign in</a> · <a href="/">Home</a></p>
</main> </main>
<script> <script>
const inviteToken = new URLSearchParams(window.location.search).get("invite") || "";
const inviteInput = document.getElementById("invite-token");
if (inviteInput) inviteInput.value = inviteToken;
(async function checkStatus() { (async function checkStatus() {
try { try {
const r = await fetch("/api/auth/status"); const r = await fetch("/api/auth/status");
@@ -73,9 +78,12 @@
if (!s.auth_enabled) { if (!s.auth_enabled) {
document.getElementById("err").textContent = "Registration is disabled (AUTH_ENABLED is not set)."; document.getElementById("err").textContent = "Registration is disabled (AUTH_ENABLED is not set).";
document.getElementById("form").style.display = "none"; 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("err").textContent = "Public registration is closed. Ask an administrator.";
document.getElementById("form").style.display = "none"; 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) {} } catch (_e) {}
})(); })();
@@ -90,6 +98,7 @@
const body = { const body = {
username: document.getElementById("username").value.trim(), username: document.getElementById("username").value.trim(),
password: document.getElementById("password").value, password: document.getElementById("password").value,
invite_token: inviteToken || null,
}; };
const res = await fetch("/api/auth/register", { const res = await fetch("/api/auth/register", {
method: "POST", method: "POST",

View File

@@ -1,4 +1,6 @@
import { EditorView, basicSetup } from "/static/codemirror.bundle.mjs"; import { EditorView, basicSetup } from "https://esm.sh/codemirror";
import { Compartment } from "https://esm.sh/@codemirror/state";
import { python } from "https://esm.sh/@codemirror/lang-python";
class TextEditor { class TextEditor {
constructor() { constructor() {
@@ -7,6 +9,7 @@ class TextEditor {
this.pyWorkerMsgId = 0; this.pyWorkerMsgId = 0;
this.pyWorkerHandlers = new Map(); this.pyWorkerHandlers = new Map();
this.pyodideInited = false; this.pyodideInited = false;
this.workerWarmupPromise = null;
this.pyRunGeneration = 0; this.pyRunGeneration = 0;
this.editor = null; this.editor = null;
this.currentFilePath = null; this.currentFilePath = null;
@@ -26,31 +29,91 @@ class TextEditor {
this.completionIndex = 0; this.completionIndex = 0;
this.completionOpen = false; this.completionOpen = false;
this.completionRequestId = 0; this.completionRequestId = 0;
this.diagnosticsRequestId = 0;
this.diagnosticsTimer = null;
this.draggedItemPath = null; this.draggedItemPath = null;
this.draggedItemIsDirectory = false; this.draggedItemIsDirectory = false;
this.dragHoverExpandTimer = null; this.dragHoverExpandTimer = null;
this.dragHoverTargetPath = null; this.dragHoverTargetPath = null;
this.savedSession = null; this.savedSession = null;
this.languageCompartment = new Compartment();
this.autoCompleteTimer = null;
this.ledSimWindow = null;
this.ledPanelDismissed = false;
this.lastLedFrame = null;
this.ledPanelWindow = null;
this.workspaceUserId = null;
this.init(); this.init();
} }
init() { init() {
try { try {
const fromQuery = new URLSearchParams(window.location.search).get('api_key'); const params = new URLSearchParams(window.location.search);
const fromQuery = params.get('api_key');
const workspaceUserId = params.get('workspace_user_id');
if (fromQuery) { if (fromQuery) {
sessionStorage.setItem('python-editor.api_key', fromQuery); sessionStorage.setItem('python-editor.api_key', fromQuery);
} }
if (workspaceUserId && /^\d+$/.test(workspaceUserId)) {
this.workspaceUserId = workspaceUserId;
}
} catch (_error) { } catch (_error) {
// Ignore query / storage failures. // Ignore query / storage failures.
} }
this.loadSessionState(); this.loadSessionState();
this.setupEditor(); this.setupEditor();
this.setupEventListeners(); this.setupEventListeners();
this.setupDevAutoReload();
this.updateRunButtonState(); this.updateRunButtonState();
this.setLspStatus('LSP: n/a', 'Open a Python file for diagnostics');
this.updateWorkspaceBanner();
this.prewarmPyWorker();
this.loadInitialDirectoryState().then(() => this.restoreSessionTabs()); this.loadInitialDirectoryState().then(() => this.restoreSessionTabs());
} }
updateWorkspaceBanner() {
const badge = document.getElementById('workspace-badge');
if (!badge) return;
if (this.workspaceUserId) {
badge.textContent = `Workspace: user ${this.workspaceUserId}`;
badge.classList.remove('hidden');
} else {
badge.classList.add('hidden');
}
}
setupDevAutoReload() {
const isLocalhost =
window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1';
if (!isLocalhost) return;
let lastToken = null;
const poll = async () => {
try {
const resp = await fetch(`/static/.reload-token?ts=${Date.now()}`, { cache: 'no-store' });
if (!resp.ok) {
return;
}
const token = (await resp.text()).trim();
if (lastToken === null) {
lastToken = token;
return;
}
if (token && token !== lastToken) {
window.location.reload();
return;
}
} catch (_err) {
// Dev hook file may not exist yet.
}
};
setInterval(poll, 1000);
poll();
}
apiFetch(url, init = {}) { apiFetch(url, init = {}) {
const next = { ...init }; const next = { ...init };
const headers = new Headers(init.headers || {}); const headers = new Headers(init.headers || {});
@@ -60,7 +123,19 @@ class TextEditor {
} }
next.headers = headers; next.headers = headers;
next.credentials = 'include'; next.credentials = 'include';
return fetch(url, next); let finalUrl = url;
if (this.workspaceUserId && typeof url === 'string' && url.startsWith('/api/')) {
try {
const parsed = new URL(url, window.location.origin);
if (!parsed.searchParams.has('workspace_user_id')) {
parsed.searchParams.set('workspace_user_id', this.workspaceUserId);
}
finalUrl = parsed.pathname + parsed.search;
} catch (_error) {
// ignore URL parse failure and use original
}
}
return fetch(finalUrl, next);
} }
disposePyWorker() { disposePyWorker() {
@@ -77,7 +152,7 @@ class TextEditor {
ensurePyWorker() { ensurePyWorker() {
if (!this.pyWorker) { if (!this.pyWorker) {
const worker = new Worker('/static/pyodide-worker.js'); const worker = new Worker('/static/pyodide-worker.js?v=3');
this.pyWorker = worker; this.pyWorker = worker;
worker.onmessage = (event) => this.handlePyWorkerMessage(event); worker.onmessage = (event) => this.handlePyWorkerMessage(event);
} }
@@ -126,6 +201,19 @@ class TextEditor {
this.pyodideInited = true; this.pyodideInited = true;
} }
prewarmPyWorker() {
if (this.pyodideInited || this.workerWarmupPromise) {
return;
}
this.workerWarmupPromise = this.ensurePyodideReady()
.catch(() => {
// Ignore warm-up failures; next foreground run will retry.
})
.finally(() => {
this.workerWarmupPromise = null;
});
}
loadSessionState() { loadSessionState() {
try { try {
const raw = localStorage.getItem(this.sessionStorageKey); const raw = localStorage.getItem(this.sessionStorageKey);
@@ -139,11 +227,13 @@ class TextEditor {
saveSessionState() { saveSessionState() {
try { try {
const runFileSelect = document.getElementById('run-file-select'); const runMainCheckbox = document.getElementById('run-main-checkbox');
const panelModeCheckbox = document.getElementById('panel-16x16-checkbox');
const session = { const session = {
openTabPaths: this.openTabs.map((tab) => tab.path), openTabPaths: this.openTabs.map((tab) => tab.path),
activeTabPath: this.activeTabPath, activeTabPath: this.activeTabPath,
selectedRunFile: runFileSelect ? runFileSelect.value : '', runMainChecked: Boolean(runMainCheckbox && runMainCheckbox.checked),
panel16x16Checked: Boolean(panelModeCheckbox && panelModeCheckbox.checked),
expandedDirs: Array.from(this.expandedDirs || []), expandedDirs: Array.from(this.expandedDirs || []),
selectedPath: this.selectedPath || '', selectedPath: this.selectedPath || '',
selectedIsDirectory: Boolean(this.selectedIsDirectory) selectedIsDirectory: Boolean(this.selectedIsDirectory)
@@ -170,9 +260,13 @@ class TextEditor {
if (session.activeTabPath && this.findTab(session.activeTabPath)) { if (session.activeTabPath && this.findTab(session.activeTabPath)) {
this.switchToTab(session.activeTabPath); this.switchToTab(session.activeTabPath);
} }
const runFileSelect = document.getElementById('run-file-select'); const runMainCheckbox = document.getElementById('run-main-checkbox');
if (runFileSelect && typeof session.selectedRunFile === 'string') { if (runMainCheckbox && typeof session.runMainChecked === 'boolean') {
runFileSelect.value = session.selectedRunFile; runMainCheckbox.checked = session.runMainChecked;
}
const panelModeCheckbox = document.getElementById('panel-16x16-checkbox');
if (panelModeCheckbox && typeof session.panel16x16Checked === 'boolean') {
panelModeCheckbox.checked = session.panel16x16Checked;
} }
this.saveSessionState(); this.saveSessionState();
} }
@@ -256,7 +350,7 @@ class TextEditor {
setupEditor() { setupEditor() {
this.editor = new EditorView({ this.editor = new EditorView({
doc: '', doc: '',
extensions: [basicSetup], extensions: [basicSetup, this.languageCompartment.of([])],
parent: document.getElementById('editor') parent: document.getElementById('editor')
}); });
@@ -323,10 +417,88 @@ class TextEditor {
this.hideCompletionDropdown(); this.hideCompletionDropdown();
this.updateActiveTabContent(); this.updateActiveTabContent();
this.markAsModified(); this.markAsModified();
this.scheduleAutoCompletion();
this.scheduleDiagnostics();
} }
})(this.editor.dispatch); })(this.editor.dispatch);
} }
setLspStatus(text, title = '') {
const status = document.getElementById('lsp-status');
if (!status) return;
status.textContent = text;
status.title = title || text;
}
scheduleDiagnostics() {
if (this.diagnosticsTimer) {
clearTimeout(this.diagnosticsTimer);
}
this.diagnosticsTimer = setTimeout(() => {
this.runDiagnostics();
}, 220);
}
async runDiagnostics() {
if (!this.currentFilePath || !this.currentFilePath.toLowerCase().endsWith('.py')) {
this.setLspStatus('LSP: n/a', 'Open a Python file for diagnostics');
return;
}
const requestId = ++this.diagnosticsRequestId;
this.setLspStatus('LSP: checking...', 'Running Jedi syntax diagnostics');
try {
await this.ensurePyodideReady();
const extraFiles = {};
for (const tab of this.openTabs) {
if (tab.path && tab.path.toLowerCase().endsWith('.py')) {
extraFiles[tab.path] = tab.content;
}
}
const data = await this.callPyWorker('diagnostics', {
path: this.currentFilePath,
content: this.editor.state.doc.toString(),
extra_files: extraFiles
});
if (requestId !== this.diagnosticsRequestId) return;
const diagnostics = Array.isArray(data.diagnostics) ? data.diagnostics : [];
if (diagnostics.length === 0) {
this.setLspStatus('LSP: OK', 'No syntax errors');
} else {
const first = diagnostics[0];
const msg = `${first.message || 'Syntax error'} (line ${first.line ?? '?'})`;
this.setLspStatus(`LSP: ${diagnostics.length} issue(s)`, msg);
}
} catch (_error) {
this.setLspStatus('LSP: unavailable', 'Diagnostics failed');
}
}
scheduleAutoCompletion() {
if (!this.currentFilePath || !this.currentFilePath.toLowerCase().endsWith('.py')) {
return;
}
const cursor = this.editor.state.selection.main.head;
const doc = this.editor.state.doc.toString();
const prev = cursor > 0 ? doc[cursor - 1] : '';
if (!(prev === '.' || /[A-Za-z0-9_]/.test(prev))) {
return;
}
if (this.autoCompleteTimer) {
clearTimeout(this.autoCompleteTimer);
}
this.autoCompleteTimer = setTimeout(() => {
this.showCompletionDropdown();
}, 140);
}
setLanguageForPath(path) {
const ext = (path || '').toLowerCase();
const language = ext.endsWith('.py') ? python() : [];
this.editor.dispatch({
effects: this.languageCompartment.reconfigure(language),
});
}
getCursorLineAndColumn() { getCursorLineAndColumn() {
const cursor = this.editor.state.selection.main.head; const cursor = this.editor.state.selection.main.head;
const lineInfo = this.editor.state.doc.lineAt(cursor); const lineInfo = this.editor.state.doc.lineAt(cursor);
@@ -566,6 +738,27 @@ class TextEditor {
this.stopPython(); this.stopPython();
}); });
const ledRunBtn = document.getElementById('led-run-btn');
if (ledRunBtn) {
ledRunBtn.addEventListener('click', () => {
this.runPython();
});
}
const ledStopBtn = document.getElementById('led-stop-btn');
if (ledStopBtn) {
ledStopBtn.addEventListener('click', () => {
this.stopPython();
});
}
const ledCloseBtn = document.getElementById('led-close-btn');
if (ledCloseBtn) {
ledCloseBtn.addEventListener('click', () => {
this.ledPanelDismissed = true;
const panel = document.getElementById('led-sim-panel');
if (panel) panel.classList.add('hidden');
});
}
document.getElementById('create-file-btn').addEventListener('click', () => { document.getElementById('create-file-btn').addEventListener('click', () => {
this.createNewFile(); this.createNewFile();
}); });
@@ -590,15 +783,26 @@ class TextEditor {
window.location.href = '/'; window.location.href = '/';
}); });
document.getElementById('run-file-select').addEventListener('change', (event) => { const runMainCheckbox = document.getElementById('run-main-checkbox');
const selectedPath = event.target.value; if (runMainCheckbox) {
if (!selectedPath) return; runMainCheckbox.addEventListener('change', () => {
const tab = this.findTab(selectedPath); this.saveSessionState();
if (tab) { this.updateRunButtonState();
this.switchToTab(selectedPath); });
} }
this.saveSessionState(); const panelModeCheckbox = document.getElementById('panel-16x16-checkbox');
}); if (panelModeCheckbox) {
panelModeCheckbox.addEventListener('change', () => {
this.saveSessionState();
if (!panelModeCheckbox.checked && this.ledPanelWindow && !this.ledPanelWindow.closed) {
this.ledPanelWindow.close();
this.ledPanelWindow = null;
}
if (this.lastLedFrame) {
this.renderLedSimulation(this.lastLedFrame);
}
});
}
} }
@@ -644,29 +848,9 @@ class TextEditor {
this.closeTab(closeButton.dataset.path); this.closeTab(closeButton.dataset.path);
}); });
}); });
this.renderRunFileSelect();
this.saveSessionState(); this.saveSessionState();
} }
renderRunFileSelect() {
const select = document.getElementById('run-file-select');
if (!select) return;
const pythonTabs = this.openTabs
.map((tab) => tab.path)
.filter((path) => typeof path === 'string' && path.toLowerCase().endsWith('.py'));
const currentValue = select.value;
select.innerHTML = '<option value="">Run active file</option>';
pythonTabs.forEach((path) => {
const option = document.createElement('option');
option.value = path;
option.textContent = path.startsWith('code/') ? path.slice('code/'.length) : path;
if (path === currentValue || path === this.currentFilePath) {
option.selected = true;
}
select.appendChild(option);
});
}
findTab(path) { findTab(path) {
return this.openTabs.find((tab) => tab.path === path); return this.openTabs.find((tab) => tab.path === path);
} }
@@ -705,10 +889,12 @@ class TextEditor {
} }
}); });
this.ignoreNextChange = false; this.ignoreNextChange = false;
this.setLanguageForPath(path);
const currentFileEl = document.getElementById('current-file'); const currentFileEl = document.getElementById('current-file');
if (currentFileEl) currentFileEl.textContent = path; if (currentFileEl) currentFileEl.textContent = path;
this.setEditorReadOnly(this.isReadOnlyPath(path)); this.setEditorReadOnly(this.isReadOnlyPath(path));
this.updateRunButtonState(); this.updateRunButtonState();
this.scheduleDiagnostics();
if (tab.isModified) { if (tab.isModified) {
this.markAsModified(); this.markAsModified();
} else { } else {
@@ -737,6 +923,7 @@ class TextEditor {
this.activeTabPath = null; this.activeTabPath = null;
this.currentFilePath = null; this.currentFilePath = null;
this.setEditorReadOnly(false); this.setEditorReadOnly(false);
this.setLanguageForPath('');
this.ignoreNextChange = true; this.ignoreNextChange = true;
this.editor.dispatch({ this.editor.dispatch({
changes: { changes: {
@@ -749,6 +936,7 @@ class TextEditor {
const currentFileEl = document.getElementById('current-file'); const currentFileEl = document.getElementById('current-file');
if (currentFileEl) currentFileEl.textContent = 'No file selected'; if (currentFileEl) currentFileEl.textContent = 'No file selected';
this.updateRunButtonState(); this.updateRunButtonState();
this.setLspStatus('LSP: n/a', 'Open a Python file for diagnostics');
this.markAsSaved(); this.markAsSaved();
} }
} }
@@ -1021,12 +1209,14 @@ class TextEditor {
} }
}); });
this.ignoreNextChange = false; this.ignoreNextChange = false;
this.setLanguageForPath(filePath);
this.markAsSaved(); this.markAsSaved();
this.setEditorReadOnly(this.isReadOnlyPath(filePath)); this.setEditorReadOnly(this.isReadOnlyPath(filePath));
const currentFileEl = document.getElementById('current-file'); const currentFileEl = document.getElementById('current-file');
if (currentFileEl) currentFileEl.textContent = filePath; if (currentFileEl) currentFileEl.textContent = filePath;
this.updateRunButtonState(); this.updateRunButtonState();
this.scheduleDiagnostics();
this.renderTabs(); this.renderTabs();
this.saveSessionState(); this.saveSessionState();
} catch (error) { } catch (error) {
@@ -1107,6 +1297,7 @@ class TextEditor {
const currentFileEl = document.getElementById('current-file'); const currentFileEl = document.getElementById('current-file');
if (currentFileEl) currentFileEl.textContent = 'No file selected'; if (currentFileEl) currentFileEl.textContent = 'No file selected';
this.updateRunButtonState(); this.updateRunButtonState();
this.setLspStatus('LSP: n/a', 'Open a Python file for diagnostics');
} }
this.directoryCache.clear(); this.directoryCache.clear();
@@ -1129,6 +1320,7 @@ class TextEditor {
} else { } else {
this.activeTabPath = null; this.activeTabPath = null;
this.currentFilePath = null; this.currentFilePath = null;
this.setLanguageForPath('');
this.ignoreNextChange = true; this.ignoreNextChange = true;
this.editor.dispatch({ this.editor.dispatch({
changes: { changes: {
@@ -1150,9 +1342,13 @@ class TextEditor {
updateRunButtonState() { updateRunButtonState() {
const runButton = document.getElementById('run-btn'); const runButton = document.getElementById('run-btn');
const stopButton = document.getElementById('stop-btn'); const stopButton = document.getElementById('stop-btn');
const runMainCheckbox = document.getElementById('run-main-checkbox');
const runMainSelected = Boolean(runMainCheckbox && runMainCheckbox.checked);
const hasPythonFile = Boolean(this.currentFilePath && this.currentFilePath.toLowerCase().endsWith('.py')); const hasPythonFile = Boolean(this.currentFilePath && this.currentFilePath.toLowerCase().endsWith('.py'));
runButton.disabled = !hasPythonFile || this.isPythonRunning; const canRun = runMainSelected || hasPythonFile;
runButton.disabled = !canRun;
stopButton.disabled = !this.isPythonRunning; stopButton.disabled = !this.isPythonRunning;
this.updateLedWindowControls();
} }
clearConsole() { clearConsole() {
@@ -1165,9 +1361,147 @@ class TextEditor {
} }
} }
maybePrepareLedWindow(files) {
const importsNeoPixel = Object.values(files || {}).some((content) =>
typeof content === 'string' &&
/\bimport\s+neopixel\b|\bfrom\s+neopixel\s+import\b/.test(content)
);
if (!importsNeoPixel) return;
this.ledPanelDismissed = false;
this.ensureLedWindow();
}
ensureLedWindow() {
const panel = document.getElementById('led-sim-panel');
if (!panel) return null;
if (!this.ledPanelDismissed) {
panel.classList.remove('hidden');
}
this.updateLedWindowControls();
return panel;
}
ensureLedPanelWindow() {
if (this.ledPanelWindow && !this.ledPanelWindow.closed) {
return this.ledPanelWindow;
}
const win = window.open('', 'neopixel-16x16', 'width=760,height=760');
if (!win) return null;
win.document.title = 'NeoPixel 16x16 Panel';
win.document.body.innerHTML = `
<style>
body { margin: 0; background: #111827; color: #e5e7eb; font-family: system-ui, sans-serif; padding: 12px; }
.meta { margin-bottom: 10px; color: #9ca3af; font-size: 13px; }
.grid { display: grid; grid-template-columns: repeat(16, 24px); grid-auto-rows: 24px; gap: 6px; }
.led { width: 24px; height: 24px; border-radius: 50%; border: 1px solid rgba(255,255,255,0.2); box-shadow: inset 0 0 8px rgba(0,0,0,0.45); }
</style>
<div id="meta" class="meta">Waiting for frame...</div>
<div id="grid" class="grid"></div>
`;
this.ledPanelWindow = win;
return win;
}
closeLedPanelWindow() {
if (this.ledPanelWindow && !this.ledPanelWindow.closed) {
this.ledPanelWindow.close();
}
this.ledPanelWindow = null;
}
updateLedWindowControls() {
const runBtn = document.getElementById('led-run-btn');
const stopBtn = document.getElementById('led-stop-btn');
if (!runBtn || !stopBtn) return;
const runMainCheckbox = document.getElementById('run-main-checkbox');
const runMainSelected = Boolean(runMainCheckbox && runMainCheckbox.checked);
const hasPythonFile = Boolean(this.currentFilePath && this.currentFilePath.toLowerCase().endsWith('.py'));
runBtn.disabled = !(runMainSelected || hasPythonFile);
stopBtn.disabled = !this.isPythonRunning;
}
renderLedSimulation(frame) {
this.lastLedFrame = frame;
this.ledPanelDismissed = false;
if (!frame) return;
const panelModeCheckbox = document.getElementById('panel-16x16-checkbox');
const panelMode = Boolean(panelModeCheckbox && panelModeCheckbox.checked);
const pixels = Array.isArray(frame.pixels) ? frame.pixels : [];
if (panelMode) {
const panelWindow = this.ensureLedPanelWindow();
if (!panelWindow || panelWindow.closed) return;
const meta = panelWindow.document.getElementById('meta');
const grid = panelWindow.document.getElementById('grid');
if (!meta || !grid) return;
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 = panelWindow.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);
}
} else {
const panel = this.ensureLedWindow();
if (!panel) return;
const meta = document.getElementById('led-meta');
const grid = document.getElementById('led-grid');
if (!meta || !grid) return;
meta.textContent = `pin=${frame.pin ?? '?'} | leds=${pixels.length} | bpp=${frame.bpp ?? 3}`;
grid.innerHTML = '';
grid.classList.remove('panel-mode');
pixels.forEach((px, i) => {
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 = `#${i} (${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.
}
}
}
appendConsoleOutput(lines) { appendConsoleOutput(lines) {
if (!Array.isArray(lines) || lines.length === 0) return; if (!Array.isArray(lines) || lines.length === 0) return;
this.consolePendingText += lines.join(''); this.processSimulationLines(lines);
const visibleLines = lines.filter((line) => {
if (typeof line !== 'string') return false;
return !line.includes('[neopixel-json]');
});
if (visibleLines.length === 0) return;
this.consolePendingText += visibleLines.join('');
if (this.consoleFlushTimer) return; if (this.consoleFlushTimer) return;
this.consoleFlushTimer = setTimeout(() => { this.consoleFlushTimer = setTimeout(() => {
const consoleOutput = document.getElementById('console-output'); const consoleOutput = document.getElementById('console-output');
@@ -1185,8 +1519,8 @@ class TextEditor {
} }
async runPython() { async runPython() {
const runFileSelect = document.getElementById('run-file-select'); const runMainCheckbox = document.getElementById('run-main-checkbox');
const selectedRunFile = runFileSelect && runFileSelect.value ? runFileSelect.value : this.currentFilePath; const selectedRunFile = runMainCheckbox && runMainCheckbox.checked ? 'code/main.py' : this.currentFilePath;
if (selectedRunFile && selectedRunFile !== this.currentFilePath && this.findTab(selectedRunFile)) { if (selectedRunFile && selectedRunFile !== this.currentFilePath && this.findTab(selectedRunFile)) {
this.switchToTab(selectedRunFile); this.switchToTab(selectedRunFile);
} }
@@ -1214,6 +1548,7 @@ class TextEditor {
files[tab.path] = tab.content; files[tab.path] = tab.content;
} }
} }
this.maybePrepareLedWindow(files);
this.clearConsole(); this.clearConsole();
const args = []; const args = [];
this.appendConsoleOutput([`$ pyodide ${selectedRunFile}\n`]); this.appendConsoleOutput([`$ pyodide ${selectedRunFile}\n`]);
@@ -1228,6 +1563,7 @@ class TextEditor {
}); });
if (generation === this.pyRunGeneration) { if (generation === this.pyRunGeneration) {
this.appendConsoleOutput(['\n[Finished]\n']); this.appendConsoleOutput(['\n[Finished]\n']);
this.closeLedPanelWindow();
} }
} catch (error) { } catch (error) {
this.appendConsoleOutput([`\n${error.message}\n`]); this.appendConsoleOutput([`\n${error.message}\n`]);
@@ -1244,8 +1580,10 @@ class TextEditor {
this.pyRunGeneration += 1; this.pyRunGeneration += 1;
this.disposePyWorker(); this.disposePyWorker();
this.isPythonRunning = false; this.isPythonRunning = false;
this.closeLedPanelWindow();
this.appendConsoleOutput(['\n[Execution stopped — Pyodide worker was reset]\n']); this.appendConsoleOutput(['\n[Execution stopped — Pyodide worker was reset]\n']);
this.updateRunButtonState(); this.updateRunButtonState();
this.prewarmPyWorker();
} }
async deleteSelected() { async deleteSelected() {

View File

@@ -7,13 +7,14 @@
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f5f5f5; background-color: #f5f5f5;
height: 100vh; height: 100dvh;
overflow: hidden; overflow: hidden;
} }
.container { .container {
display: flex; display: flex;
height: 100vh; height: 100dvh;
overflow: hidden;
} }
/* Sidebar */ /* Sidebar */
@@ -23,6 +24,7 @@ body {
color: white; color: white;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0;
} }
.sidebar-header { .sidebar-header {
@@ -117,6 +119,9 @@ body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background-color: white; background-color: white;
min-width: 0;
min-height: 0;
overflow: hidden;
} }
.editor-header { .editor-header {
@@ -191,15 +196,6 @@ body {
gap: 0.5rem; gap: 0.5rem;
} }
#run-file-select {
min-width: 220px;
padding: 0.5rem 0.65rem;
border: 1px solid #cbd5e0;
border-radius: 6px;
font-size: 0.85rem;
background: white;
}
.editor-actions button { .editor-actions button {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
@@ -225,10 +221,28 @@ body {
background-color: #edf2f7; background-color: #edf2f7;
} }
.run-main-toggle {
display: inline-flex;
align-items: center;
gap: 0.45rem;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 0.4rem 0.6rem;
font-size: 0.85rem;
color: #374151;
background: white;
white-space: nowrap;
}
.run-main-toggle input[type="checkbox"] {
margin: 0;
}
.editor-container { .editor-container {
flex: 1; flex: 1;
position: relative; position: relative;
min-height: 0; min-height: 0;
overflow: hidden;
} }
.hidden { .hidden {
@@ -350,6 +364,89 @@ body {
background: #0f172a; background: #0f172a;
} }
.led-sim-panel {
border-top: 1px solid #e2e8f0;
background: #111827;
color: #e5e7eb;
padding: 0.75rem;
}
.led-sim-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
}
.led-sim-header h3 {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
}
.led-sim-actions {
display: flex;
align-items: center;
gap: 0.4rem;
}
.led-sim-actions button {
padding: 0.3rem 0.65rem;
border: 1px solid #374151;
background: #1f2937;
color: #e5e7eb;
border-radius: 6px;
cursor: pointer;
font-size: 0.8rem;
}
.led-sim-actions button:hover:not(:disabled) {
background: #243244;
}
.led-sim-actions button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.led-meta {
margin-top: 0.5rem;
color: #9ca3af;
font-size: 0.82rem;
}
.led-grid {
margin-top: 0.6rem;
display: flex;
flex-wrap: wrap;
overflow-x: hidden;
overflow-y: auto;
gap: 8px;
width: 100%;
max-height: 132px;
padding: 0.1rem 0 0.2rem;
}
.led-grid.panel-mode {
display: grid;
grid-template-columns: repeat(16, minmax(0, 24px));
grid-auto-rows: 24px;
justify-content: start;
align-content: start;
gap: 6px;
max-height: 470px;
overflow: auto;
}
.led {
width: 24px;
height: 24px;
min-width: 24px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.45);
}
.console-header { .console-header {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
font-size: 0.85rem; font-size: 0.85rem;
@@ -445,14 +542,106 @@ body {
/* Responsive design */ /* Responsive design */
@media (max-width: 768px) { @media (max-width: 768px) {
body {
height: 100dvh;
overflow: hidden;
}
.container {
flex-direction: column;
height: 100dvh;
overflow: hidden;
}
.sidebar { .sidebar {
width: 250px; width: 100%;
max-height: 30vh;
min-height: 170px;
}
.sidebar-header {
padding: 0.7rem 0.8rem;
}
.file-tree {
min-height: 100px;
}
.main-content {
min-height: 0;
}
.editor-header {
padding: 0.65rem;
flex-wrap: wrap;
gap: 0.55rem;
align-items: stretch;
}
.file-info {
width: 100%;
justify-content: space-between;
}
.mode-toggle {
order: 3;
}
.editor-actions {
width: 100%;
order: 4;
flex-wrap: wrap;
}
.editor-actions button {
flex: 1 1 100px;
}
.run-main-toggle {
width: 100%;
justify-content: flex-start;
flex: 1 1 100%;
}
.tabs {
padding: 0.3rem 0.35rem;
}
.tab-title {
max-width: 150px;
}
.editor-container {
min-height: 46vh;
}
.cm-editor {
font-size: 13px;
} }
.modal-content { .modal-content {
width: 90%; width: 90%;
margin: 20% auto; margin: 20% auto;
} }
.led-grid {
max-height: 120px;
}
.led-sim-header {
flex-wrap: wrap;
align-items: center;
}
.led-sim-actions {
width: 100%;
justify-content: flex-start;
flex-wrap: wrap;
}
.console-container {
height: 180px;
}
} }
/* Scrollbar styling */ /* Scrollbar styling */

View File

@@ -11,6 +11,8 @@ def _reload_app(tmp_path, monkeypatch, **env):
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path)) monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
monkeypatch.setenv("AUTH_DATABASE_PATH", str(tmp_path / "auth.db")) 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("EDITOR_API_KEY", raising=False)
monkeypatch.delenv("BOOTSTRAP_ADMIN_USERNAME", raising=False) monkeypatch.delenv("BOOTSTRAP_ADMIN_USERNAME", raising=False)
monkeypatch.delenv("BOOTSTRAP_ADMIN_PASSWORD", raising=False) monkeypatch.delenv("BOOTSTRAP_ADMIN_PASSWORD", raising=False)
@@ -26,7 +28,7 @@ def test_auth_status_public(tmp_path, monkeypatch):
with TestClient(_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="false")) as client: with TestClient(_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="false")) as client:
r = client.get("/api/auth/status") r = client.get("/api/auth/status")
assert r.status_code == 200 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_register_login_and_api_access(tmp_path, monkeypatch): def test_register_login_and_api_access(tmp_path, monkeypatch):
@@ -85,6 +87,53 @@ def test_register_closed(tmp_path, monkeypatch):
assert r.status_code == 403 assert r.status_code == 403
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_lists_and_creates_users(tmp_path, monkeypatch): def test_superuser_lists_and_creates_users(tmp_path, monkeypatch):
with TestClient( with TestClient(
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true") _reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
@@ -121,6 +170,93 @@ def test_non_superuser_cannot_list_users(tmp_path, monkeypatch):
assert denied.status_code == 403 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):
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")
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): def test_login_serves_page(client):
r = client.get("/login") r = client.get("/login")
assert r.status_code == 200 assert r.status_code == 200

View File

@@ -0,0 +1,44 @@
import importlib.util
from pathlib import Path
def _load_patterns_module():
repo_root = Path(__file__).resolve().parents[1]
module_path = repo_root / "workspace" / "code" / "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_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 urllib.request
import pytest 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") pytest.importorskip("selenium.webdriver")
@@ -46,3 +49,24 @@ def test_home_page_title(driver):
) )
driver.get(f"{base}/") driver.get(f"{base}/")
assert "Python Editor" in driver.title 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

@@ -0,0 +1,67 @@
"""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 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,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,20 @@
"""Chase pattern demo using led_patterns helpers."""
from machine import Pin
import neopixel
import time
from led_patterns import chase_frame
np = neopixel.NeoPixel(Pin(4), 24)
for frame in range(120):
frame_colors = chase_frame(len(np), frame, color=(0, 220, 255), tail=(0, 40, 55))
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,20 @@
"""Rainbow pattern demo using led_patterns helpers."""
from machine import Pin
import neopixel
import time
from led_patterns import rainbow_frame
np = neopixel.NeoPixel(Pin(4), 256)
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,26 @@
"""Twinkle pattern demo using led_patterns helpers."""
from machine import Pin
import neopixel
import time
from led_patterns import twinkle_frame
np = neopixel.NeoPixel(Pin(4), 36)
for frame in range(120):
frame_colors = twinkle_frame(
len(np),
frame,
base=(0, 0, 6),
sparkle=(255, 210, 130),
sparkles=5,
)
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,68 @@
"""Compatibility pattern helpers for NeoPixel demos.
This file mirrors `workspace/code/led_patterns.py` so imports like
`from led_patterns import ...` work even in older worker sessions that only
include `/workspace/lib` in `sys.path`.
"""
from __future__ import annotations
import random
Color = tuple[int, int, int]
def _clamp(channel: int) -> int:
return max(0, min(255, int(channel)))
def wheel(pos: int) -> Color:
pos = 255 - (pos & 255)
if pos < 85:
return (_clamp(255 - pos * 3), 0, _clamp(pos * 3))
if pos < 170:
pos -= 85
return (0, _clamp(pos * 3), _clamp(255 - pos * 3))
pos -= 170
return (_clamp(pos * 3), _clamp(255 - pos * 3), 0)
def rainbow_frame(led_count: int, frame: int, step: int = 4) -> list[Color]:
if led_count <= 0:
return []
return [wheel((i * 256 // led_count + frame * step) & 255) for i in range(led_count)]
def chase_frame(
led_count: int,
frame: int,
color: Color = (255, 120, 0),
tail: Color = (16, 0, 0),
) -> list[Color]:
if led_count <= 0:
return []
out: list[Color] = [(0, 0, 0) for _ in range(led_count)]
head = frame % led_count
trail = (head - 1) % led_count
out[trail] = tuple(_clamp(v) for v in tail) # type: ignore[assignment]
out[head] = tuple(_clamp(v) for v in color) # type: ignore[assignment]
return out
def twinkle_frame(
led_count: int,
frame: int,
base: Color = (0, 0, 8),
sparkle: Color = (255, 255, 180),
sparkles: int = 3,
seed: int = 1337,
) -> list[Color]:
if led_count <= 0:
return []
out: list[Color] = [tuple(_clamp(v) for v in base) for _ in range(led_count)] # type: ignore[list-item]
rng = random.Random(seed + frame)
for _ in range(min(max(0, sparkles), led_count)):
idx = rng.randrange(led_count)
out[idx] = tuple(_clamp(v) for v in sparkle) # type: ignore[assignment]
return out

19
workspace/lib/machine.py Normal file
View File

@@ -0,0 +1,19 @@
"""Minimal MicroPython-style machine module mock for browser simulation."""
class Pin:
IN = 0
OUT = 1
PULL_UP = 2
PULL_DOWN = 3
def __init__(self, pin_id: int, mode: int = OUT, value: int = 0):
self.id = int(pin_id)
self.mode = int(mode)
self._value = 1 if value else 0
def value(self, new_value=None):
if new_value is None:
return self._value
self._value = 1 if int(new_value) else 0
return self._value

56
workspace/lib/neopixel.py Normal file
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))