Add browser Python editor with Pyodide, user auth, and workspace API

- FastAPI serves static UI, file CRUD under code/ and read-only lib/
- Pyodide worker runs Python and Jedi completions in the browser
- SQLite accounts: login/register, session cookies, superuser user management
- Optional EDITOR_API_KEY, AUTH_* env vars, .env.example
- Pipenv, pytest, Selenium smoke test, README

Made-with: Cursor
This commit is contained in:
2026-05-01 14:33:26 +12:00
parent d245ecd353
commit f204109a84
40 changed files with 4950 additions and 2 deletions

18
.env.example Normal file
View File

@@ -0,0 +1,18 @@
# Copy to `.env` in the repo root (same directory as Pipfile). `.env` is gitignored.
# Workspace tree: code/ (writable), lib/ (read-only); defaults to ./workspace under the repo root
# WORKSPACE_ROOT=/home/you/projects/python-editor/workspace
# If set, `/api/*` accepts this Bearer token (and still accepts session cookies if AUTH_ENABLED=true)
# EDITOR_API_KEY=
# --- User accounts (SQLite) ---
# AUTH_ENABLED=true # require sign-in for /api/* (except /api/auth/*)
# AUTH_REGISTER_OPEN=true # allow POST /api/auth/register
# AUTH_DATABASE_PATH=./data/editor.db
# AUTH_SESSION_DAYS=14
# BOOTSTRAP_ADMIN_USERNAME=admin # first-run only: create superuser if DB has zero users
# BOOTSTRAP_ADMIN_PASSWORD=change-me-in-production
# Base URL for `pipenv run test-selenium` (app must be running separately)
# SELENIUM_BASE_URL=http://127.0.0.1:8080

7
.gitignore vendored
View File

@@ -15,8 +15,8 @@ dist/
downloads/
eggs/
.eggs/
lib/
lib64/
/lib/
/lib64/
parts/
sdist/
var/
@@ -128,6 +128,9 @@ celerybeat.pid
# SageMath parsed files
*.sage.py
# Local auth database (default path)
/data/
# Environments
.env
.venv

25
Pipfile Normal file
View File

@@ -0,0 +1,25 @@
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[dev-packages]
pytest = "*"
pytest-cov = "*"
httpx = "*"
selenium = "*"
[packages]
fastapi = "*"
uvicorn = "*"
sqlalchemy = "*"
bcrypt = "*"
[requires]
python_version = "3.12"
[scripts]
dev = "uvicorn app:app --app-dir src --reload --port 8080"
test = "bash -lc 'cd src && PYTHONPATH=. pytest ../tests'"
test-integration = "bash -lc 'cd src && PYTHONPATH=. pytest ../tests -m integration'"
test-selenium = "bash -lc 'cd src && PYTHONPATH=. pytest ../tests -m selenium -v'"

764
Pipfile.lock generated Normal file
View File

@@ -0,0 +1,764 @@
{
"_meta": {
"hash": {
"sha256": "f9de958b9982b3a30c107c2c02746aece031522dc429be84e4f9589c73404282"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3.12"
},
"sources": [
{
"name": "pypi",
"url": "https://pypi.org/simple",
"verify_ssl": true
}
]
},
"default": {
"annotated-doc": {
"hashes": [
"sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320",
"sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"
],
"markers": "python_version >= '3.8'",
"version": "==0.0.4"
},
"annotated-types": {
"hashes": [
"sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53",
"sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"
],
"markers": "python_version >= '3.8'",
"version": "==0.7.0"
},
"anyio": {
"hashes": [
"sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708",
"sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc"
],
"markers": "python_version >= '3.10'",
"version": "==4.13.0"
},
"bcrypt": {
"hashes": [
"sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4",
"sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a",
"sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464",
"sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4",
"sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746",
"sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2",
"sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41",
"sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd",
"sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9",
"sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e",
"sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538",
"sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10",
"sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb",
"sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef",
"sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4",
"sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23",
"sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef",
"sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75",
"sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42",
"sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a",
"sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172",
"sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683",
"sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2",
"sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4",
"sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba",
"sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da",
"sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493",
"sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254",
"sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534",
"sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f",
"sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c",
"sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c",
"sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83",
"sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff",
"sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d",
"sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861",
"sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5",
"sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9",
"sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b",
"sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac",
"sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e",
"sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f",
"sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb",
"sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86",
"sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980",
"sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd",
"sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d",
"sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1",
"sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911",
"sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993",
"sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191",
"sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4",
"sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2",
"sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8",
"sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db",
"sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927",
"sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be",
"sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb",
"sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e",
"sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf",
"sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd",
"sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822",
"sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b"
],
"index": "pypi",
"markers": "python_version >= '3.8'",
"version": "==5.0.0"
},
"click": {
"hashes": [
"sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2",
"sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613"
],
"markers": "python_version >= '3.10'",
"version": "==8.3.3"
},
"fastapi": {
"hashes": [
"sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f",
"sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f"
],
"index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==0.136.1"
},
"greenlet": {
"hashes": [
"sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846",
"sha256:0ed006e4b86c59de7467eb2601cd1b77b5a7d657d1ee55e30fe30d76451edba4",
"sha256:0ff251e9a0279522e62f6176412869395a64ddf2b5c5f782ff609a8216a4e662",
"sha256:1aa4ce8debcd4ea7fb2e150f3036588c41493d1d52c43538924ae1819003f4ce",
"sha256:1bae92a1dd94c5f9d9493c3a212dd874c202442047cf96446412c862feca83a2",
"sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588",
"sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13",
"sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e",
"sha256:29ea813b2e1f45fa9649a17853b2b5465c4072fbcb072e5af6cd3a288216574a",
"sha256:362624e6a8e5bca3b8233e45eef33903a100e9539a2b995c364d595dbc4018b3",
"sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b",
"sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033",
"sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628",
"sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136",
"sha256:47422135b1d308c14b2c6e758beedb1acd33bb91679f5670edf77bf46244722b",
"sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d",
"sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2",
"sha256:4b28037cb07768933c54d81bfe47a85f9f402f57d7d69743b991a713b63954eb",
"sha256:4d0eadc7e4d9ffb2af4247b606cae307be8e448911e5a0d0b16d72fc3d224cfd",
"sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b",
"sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1",
"sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16",
"sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d",
"sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106",
"sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba",
"sha256:5ecd83806b0f4c2f53b1018e0005cd82269ea01d42befc0368730028d850ed1c",
"sha256:64d6ac45f7271f48e45f67c95b54ef73534c52ec041fcda8edf520c6d811f4bc",
"sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7",
"sha256:6c18dfb59c70f5a94acd271c72e90128c3c776e41e5f07767908c8c1b74ad339",
"sha256:6d874e79afd41a96e11ff4c5d0bc90a80973e476fda1c2c64985667397df432b",
"sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae",
"sha256:703cb211b820dbffbbc55a16bfc6e4583a6e6e990f33a119d2cc8b83211119c8",
"sha256:728a73687e39ae9ca34e4694cbf2f049d3fbc7174639468d0f67200a97d8f9e2",
"sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5",
"sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf",
"sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f",
"sha256:804a70b328e706b785c6ef16187051c394a63dd1a906d89be24b6ad77759f13f",
"sha256:83ed9f27f1680b50e89f40f6df348a290ea234b249a4003d366663a12eab94f2",
"sha256:884f649de075b84739713d41dd4dfd41e2b910bfb769c4a3ea02ec1da52cd9bb",
"sha256:8f1cc966c126639cd152fdaa52624d2655f492faa79e013fea161de3e6dda082",
"sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7",
"sha256:9c615f869163e14bb1ced20322d8038fb680b08236521ac3f30cd4c1288785a0",
"sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c",
"sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853",
"sha256:a96fcee45e03fe30a62669fd16ab5c9d3c172660d3085605cb1e2d1280d3c988",
"sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3",
"sha256:ba8f0bdc2fae6ce915dfd0c16d2d00bca7e4247c1eae4416e06430e522137858",
"sha256:bf2d8a80bec89ab46221ae45c5373d5ba0bd36c19aa8508e85c6cd7e5106cd37",
"sha256:cda05425526240807408156b6960a17a79a0c760b813573b67027823be760977",
"sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4",
"sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8",
"sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86",
"sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f",
"sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112",
"sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e",
"sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2",
"sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8",
"sha256:f8c30c2225f40dd76c50790f0eb3b5c7c18431efb299e2782083e1981feed243",
"sha256:fa94cb2288681e3a11645958f1871d48ee9211bd2f66628fdace505927d6e564"
],
"markers": "platform_machine == 'aarch64' or (platform_machine == 'ppc64le' or (platform_machine == 'x86_64' or (platform_machine == 'amd64' or (platform_machine == 'AMD64' or (platform_machine == 'win32' or platform_machine == 'WIN32')))))",
"version": "==3.5.0"
},
"h11": {
"hashes": [
"sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1",
"sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"
],
"markers": "python_version >= '3.8'",
"version": "==0.16.0"
},
"idna": {
"hashes": [
"sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242",
"sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3"
],
"markers": "python_version >= '3.8'",
"version": "==3.13"
},
"pydantic": {
"hashes": [
"sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927",
"sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d"
],
"markers": "python_version >= '3.9'",
"version": "==2.13.3"
},
"pydantic-core": {
"hashes": [
"sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba",
"sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35",
"sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4",
"sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505",
"sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7",
"sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8",
"sha256:1108da631e602e5b3c38d6d04fe5bb3bfa54349e6918e3ca6cf570b2e2b2f9d4",
"sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37",
"sha256:13afdd885f3d71280cf286b13b310ee0f7ccfefd1dbbb661514a474b726e2f25",
"sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022",
"sha256:1da3786b8018e60349680720158cc19161cc3b4bdd815beb0a321cd5ce1ad5b1",
"sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7",
"sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c",
"sha256:27f9067c3bfadd04c55484b89c0d267981b2f3512850f6f66e1e74204a4e4ce3",
"sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb",
"sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3",
"sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976",
"sha256:2b8e4f2bbdf71415c544b4b1138b8060db7b6611bc927e8064c769f64bed651c",
"sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab",
"sha256:3481bd1341dc85779ee506bc8e1196a277ace359d89d28588a9468c3ecbe63fa",
"sha256:3612edf65c8ea67ac13616c4d23af12faef1ae435a8a93e5934c2a0cbbdd1fd6",
"sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396",
"sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c",
"sha256:3d08782c4045f90724b44c95d35ebec0d67edb8a957a2ac81d5a8e4b8a200495",
"sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c",
"sha256:4335e87c7afa436a0dfa899e138d57a72f8aad542e2cf19c36fb428461caabd0",
"sha256:4b068543bdb707f5d935dab765d99227aa2545ef2820935f2e5dd801795c7dbd",
"sha256:4de88889d7e88d50d40ee5b39d5dac0bcaef9ba91f7e536ac064e6b2834ecccf",
"sha256:4e9d76736da5f362fabfeea6a69b13b7f2be405c6d6966f06b2f6bfff7e64531",
"sha256:57697d7c056aca4bbb680200f96563e841a6386ac1129370a0102592f4dddff5",
"sha256:57a973eae4665352a47cf1a99b4ee864620f2fe663a217d7a8da68a1f3a5bfda",
"sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d",
"sha256:5c024e08c0ba23e6fd68c771a521e9d6a792f2ebb0fa734296b36394dc30390e",
"sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df",
"sha256:5dfd51cf457482f04ec49491811a2b8fd5b843b64b11eecd2d7a1ee596ea78a6",
"sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c",
"sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13",
"sha256:6529d1d128321a58d30afcc97b49e98836542f68dd41b33c2e972bb9e5290536",
"sha256:6645ce7eec4928e29a1e3b3d5c946621d105d3e79f0c9cddf07c2a9770949287",
"sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0",
"sha256:68ef2f623dda6d5a9067ac014e406c020c780b2a358930a7e5c1b73702900720",
"sha256:69a868ef3ff206343579021c40faf3b1edc64b1cc508ff243a28b0a514ccb050",
"sha256:6dff8cc884679df229ebc6d8eb2321ea6f8e091bc7d4886d4dc2e0e71452843c",
"sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1",
"sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8",
"sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0",
"sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374",
"sha256:79f561438481f28681584b89e2effb22855e2179880314bcddbf5968e935e807",
"sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6",
"sha256:831eb19aa789a97356979e94c981e5667759301fb708d1c0d5adf1bc0098b873",
"sha256:83d002b97072a53ea150d63e0a3adfae5670cef5aa8a6e490240e482d3b22e57",
"sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f",
"sha256:8690eba565c6d68ffd3a8655525cbdd5246510b44a637ee2c6c03a7ebfe64d3c",
"sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad",
"sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e",
"sha256:91249bcb7c165c2fb2a2f852dbc5c91636e2e218e75d96dfdd517e4078e173dd",
"sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23",
"sha256:9715525891ed524a0a1eb6d053c74d4d4ad5017677fb00af0b7c2644a31bae46",
"sha256:975c267cff4f7e7272eacbe50f6cc03ca9a3da4c4fbd66fffd89c94c1e311aa1",
"sha256:99421e7684a60f7f3550a1d159ade5fdff1954baedb6bdd407cba6a307c9f27d",
"sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1",
"sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee",
"sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c",
"sha256:9d2f400712a99a013aff420ef1eb9be077f8189a36c1e3ef87660b4e1088a874",
"sha256:9f247596366f4221af52beddd65af1218797771d6989bc891a0b86ccaa019168",
"sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a",
"sha256:a35cc284c8dd7edae8a31533713b4d2467dfe7c4f1b5587dd4031f28f90d1d13",
"sha256:a3b11c812f61b3129c4905781a2601dfdfdea5fe1e6c1cfb696b55d14e9c054f",
"sha256:a642ac886ecf6402d9882d10c405dcf4b902abeb2972cd5fb4a48c83cd59279a",
"sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789",
"sha256:a712c7118e6c5ea96562f7b488435172abb94a3c53c22c9efc1412264a45cbbe",
"sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f",
"sha256:ab124d49d0459b2373ecf54118a45c28a1e6d4192a533fbc915e70f556feb8e5",
"sha256:ac5ec7fb9b87f04ee839af2d53bcadea57ded7d229719f56c0ed895bff987943",
"sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b",
"sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089",
"sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b",
"sha256:b00b76f7142fc60c762ce579bd29c8fa44aaa56592dd3c54fab3928d0d4ca6ff",
"sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67",
"sha256:b12dd51f1187c2eb489af8e20f880362db98e954b54ab792fa5d92e8bcc6b803",
"sha256:b40ddd51e7c44b28cfaef746c9d3c506d658885e0a46f9eeef2ee815cbf8e045",
"sha256:b504bda01bafc69b6d3c7a0c7f039dcf60f47fab70e06fe23f57b5c75bdc82b8",
"sha256:b5b9c6cf08a8a5e502698f5e153056d12c34b8fb30317e0c5fd06f45162a6346",
"sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2",
"sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f",
"sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687",
"sha256:bd2aab0e2e9dc2daf36bd2686c982535d5e7b1d930a1344a7bb6e82baab42a76",
"sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1",
"sha256:cc0988cb29d21bf4a9d5cf2ef970b5c0e38d8d8e107a493278c05dc6c1dda69f",
"sha256:cc7e8c32db809aa0f6ea1d6869ebc8518a65d5150fdfad8bcae6a49ae32a22e2",
"sha256:cca67d52a5c7a16aed2b3999e719c4bcf644074eac304a5d3d62dd70ae7d4b2c",
"sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018",
"sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba",
"sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c",
"sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf",
"sha256:d11058e3201527d41bc6b545c79187c9e4bf85e15a236a6007f0e991518882b7",
"sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47",
"sha256:d56bdb4af1767cc15b0386b3c581fdfe659bb9ee4a4f776e92c1cd9d074000d6",
"sha256:dcda6583921c05a40533f982321532f2d8db29326c7b95c4026941fa5074bd79",
"sha256:dd81f6907932ebac3abbe41378dac64b2380db1287e2aa64d8d88f78d170f51a",
"sha256:de3a5c376f8cd94da9a1b8fd3dd1c16c7a7b216ed31dc8ce9fd7a22bf13b836e",
"sha256:de885175515bcfa98ae618c1df7a072f13d179f81376c8007112af20567fd08a",
"sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34",
"sha256:e480080975c1ef7f780b8f99ed72337e7cc5efea2e518a20a692e8e7b278eb8b",
"sha256:e61ea8e9fff9606d09178f577ff8ccdd7206ff73d6552bcec18e1033c4254b85",
"sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca",
"sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f",
"sha256:f00a0961b125f1a47af7bcc17f00782e12f4cd056f83416006b30111d941dfa3",
"sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64",
"sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22",
"sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72",
"sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec",
"sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d",
"sha256:f91c0aff3e3ee0928edd1232c57f643a7a003e6edf1860bc3afcdc749cb513f3",
"sha256:fa3eb7c2995aa443687a825bc30395c8521b7c6ec201966e55debfd1128bcceb",
"sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395",
"sha256:fc331a5314ffddd5385b9ee9d0d2fee0b13c27e0e02dad71b1ae5d6561f51eeb",
"sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4",
"sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127",
"sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56"
],
"markers": "python_version >= '3.9'",
"version": "==2.46.3"
},
"sqlalchemy": {
"hashes": [
"sha256:01146546d84185f12721a1d2ce0c6673451a7894d1460b592d378ca4871a0c72",
"sha256:059d7151fff513c53a4638da8778be7fce81a0c4854c7348ebd0c4078ddf28fe",
"sha256:0c98c59075b890df8abfcc6ad632879540f5791c68baebacb4f833713b510e75",
"sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5",
"sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148",
"sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7",
"sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e",
"sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518",
"sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7",
"sha256:32fe6a41ad97302db2931f05bb91abbcc65b5ce4c675cd44b972428dd2947700",
"sha256:334edbcff10514ad1d66e3a70b339c0a29886394892490119dbb669627b17717",
"sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672",
"sha256:3ddcb27fb39171de36e207600116ac9dfd4ae46f86c82a9bf3934043e80ebb88",
"sha256:42e8804962f9e6f4be2cbaedc0c3718f08f60a16910fa3d86da5a1e3b1bfe60f",
"sha256:43d044780732d9e0381ac8d5316f95d7f02ef04d6e4ef6dc82379f09795d993f",
"sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08",
"sha256:46d51518d53edfbe0563662c96954dc8fcace9832332b914375f45a99b77cc9a",
"sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3",
"sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b",
"sha256:4d4e5a0ceba319942fa6b585cf82539288a61e314ef006c1209f734551ab9536",
"sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0",
"sha256:566df36fd0e901625523a5a1835032f1ebdd7f7886c54584143fa6c668b4df3b",
"sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a",
"sha256:5e61abbec255be7b122aa461021daa7c3f310f3e743411a67079f9b3cc91ece3",
"sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4",
"sha256:62557958002b69699bdb7f5137c6714ca1133f045f97b3903964f47db97ea339",
"sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158",
"sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066",
"sha256:69469ce8ce7a8df4d37620e3163b71238719e1e2e5048d114a1b6ce0fbf8c662",
"sha256:6eb188b84269f357669b62cb576b5b918de10fb7c728a005fa0ebb0b758adce1",
"sha256:74ab4ee7794d7ed1b0c37e7333640e0f0a626fc7b398c07a7aef52f484fddde3",
"sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5",
"sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01",
"sha256:7d6be30b2a75362325176c036d7fb8d19e8846c77e87683ffaa8177b35135613",
"sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a",
"sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0",
"sha256:88690f4e1f0fbf5339bedbb127e240fec1fd3070e9934c0b7bef83432f779d2f",
"sha256:8a97ac839c2c6672c4865e48f3cbad7152cee85f4233fb4ca6291d775b9b954a",
"sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e",
"sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2",
"sha256:951d4a210744813be63019f3df343bf233b7432aadf0db54c75802247330d3af",
"sha256:9ac7a3e245fd0310fd31495eb61af772e637bdf7d88ee81e7f10a3f271bff014",
"sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33",
"sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61",
"sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d",
"sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187",
"sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401",
"sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b",
"sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d",
"sha256:b95b2f470c1b2683febd2e7eab1d3f0e078c91dbdd0b00e9c645d07a413bb99f",
"sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba",
"sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977",
"sha256:c338ec6ec01c0bc8e735c58b9f5d51e75bacb6ff23296658826d7cfdfdb8678a",
"sha256:c5070135e1b7409c4161133aa525419b0062088ed77c92b1da95366ec5cbebbe",
"sha256:cc992c6ed024c8c3c592c5fc9846a03dd68a425674900c70122c77ea16c5fb0b",
"sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f",
"sha256:d898cc2c76c135ef65517f4ddd7a3512fb41f23087b0650efb3418b8389a3cd1",
"sha256:d99945830a6f3e9638d89a28ed130b1eb24c91255e4f24366fbe699b983f29e4",
"sha256:da9b91bca419dc9b9267ffadde24eae9b1a6bffcd09d0a207e5e3af99a03ce0d",
"sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120",
"sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750",
"sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0",
"sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982"
],
"index": "pypi",
"markers": "python_version >= '3.7'",
"version": "==2.0.49"
},
"starlette": {
"hashes": [
"sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149",
"sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b"
],
"markers": "python_version >= '3.10'",
"version": "==1.0.0"
},
"typing-extensions": {
"hashes": [
"sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466",
"sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"
],
"markers": "python_version >= '3.9'",
"version": "==4.15.0"
},
"typing-inspection": {
"hashes": [
"sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7",
"sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"
],
"markers": "python_version >= '3.9'",
"version": "==0.4.2"
},
"uvicorn": {
"hashes": [
"sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048",
"sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d"
],
"index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==0.46.0"
}
},
"develop": {
"anyio": {
"hashes": [
"sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708",
"sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc"
],
"markers": "python_version >= '3.10'",
"version": "==4.13.0"
},
"attrs": {
"hashes": [
"sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309",
"sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"
],
"markers": "python_version >= '3.9'",
"version": "==26.1.0"
},
"certifi": {
"hashes": [
"sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a",
"sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580"
],
"markers": "python_version >= '3.7'",
"version": "==2026.4.22"
},
"coverage": {
"extras": [
"toml"
],
"hashes": [
"sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256",
"sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b",
"sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5",
"sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d",
"sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a",
"sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969",
"sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642",
"sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87",
"sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740",
"sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215",
"sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d",
"sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422",
"sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8",
"sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911",
"sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b",
"sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587",
"sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8",
"sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606",
"sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9",
"sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf",
"sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633",
"sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6",
"sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43",
"sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2",
"sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61",
"sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930",
"sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc",
"sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247",
"sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75",
"sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e",
"sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376",
"sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01",
"sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1",
"sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3",
"sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743",
"sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9",
"sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf",
"sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e",
"sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1",
"sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd",
"sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b",
"sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab",
"sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d",
"sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a",
"sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0",
"sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510",
"sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f",
"sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0",
"sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8",
"sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf",
"sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209",
"sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9",
"sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3",
"sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3",
"sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d",
"sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd",
"sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2",
"sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882",
"sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09",
"sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea",
"sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c",
"sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562",
"sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3",
"sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806",
"sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e",
"sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878",
"sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e",
"sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9",
"sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45",
"sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29",
"sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4",
"sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c",
"sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479",
"sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400",
"sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c",
"sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a",
"sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf",
"sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686",
"sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de",
"sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028",
"sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0",
"sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179",
"sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16",
"sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85",
"sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a",
"sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0",
"sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810",
"sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161",
"sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607",
"sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26",
"sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819",
"sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40",
"sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5",
"sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15",
"sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0",
"sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90",
"sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0",
"sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6",
"sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a",
"sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58",
"sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b",
"sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17",
"sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5",
"sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664",
"sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0",
"sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f"
],
"markers": "python_version >= '3.10'",
"version": "==7.13.5"
},
"h11": {
"hashes": [
"sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1",
"sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"
],
"markers": "python_version >= '3.8'",
"version": "==0.16.0"
},
"httpcore": {
"hashes": [
"sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55",
"sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"
],
"markers": "python_version >= '3.8'",
"version": "==1.0.9"
},
"httpx": {
"hashes": [
"sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc",
"sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"
],
"index": "pypi",
"markers": "python_version >= '3.8'",
"version": "==0.28.1"
},
"idna": {
"hashes": [
"sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242",
"sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3"
],
"markers": "python_version >= '3.8'",
"version": "==3.13"
},
"iniconfig": {
"hashes": [
"sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730",
"sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"
],
"markers": "python_version >= '3.10'",
"version": "==2.3.0"
},
"outcome": {
"hashes": [
"sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8",
"sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b"
],
"markers": "python_version >= '3.7'",
"version": "==1.3.0.post0"
},
"packaging": {
"hashes": [
"sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e",
"sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"
],
"markers": "python_version >= '3.8'",
"version": "==26.2"
},
"pluggy": {
"hashes": [
"sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3",
"sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"
],
"markers": "python_version >= '3.9'",
"version": "==1.6.0"
},
"pygments": {
"hashes": [
"sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f",
"sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"
],
"markers": "python_version >= '3.9'",
"version": "==2.20.0"
},
"pysocks": {
"hashes": [
"sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299",
"sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5",
"sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"
],
"version": "==1.7.1"
},
"pytest": {
"hashes": [
"sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9",
"sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"
],
"index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==9.0.3"
},
"pytest-cov": {
"hashes": [
"sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2",
"sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678"
],
"index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==7.1.0"
},
"selenium": {
"hashes": [
"sha256:4f97639055dcfa9eadf8ccf549ba7b0e49c655d4e2bde19b9a44e916b754e769",
"sha256:bada5c08a989f812728a4b5bea884d8e91894e939a441cc3a025201ce718581e"
],
"index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==4.43.0"
},
"sniffio": {
"hashes": [
"sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2",
"sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"
],
"markers": "python_version >= '3.7'",
"version": "==1.3.1"
},
"sortedcontainers": {
"hashes": [
"sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88",
"sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"
],
"version": "==2.4.0"
},
"trio": {
"hashes": [
"sha256:3bd5d87f781d9b0192d592aef28691f8951d6c2e41b7e1da4c25cde6c180ae9b",
"sha256:a29b92b73f09d4b48ed249acd91073281a7f1063f09caba5dc70465b5c7aa970"
],
"markers": "python_version >= '3.10'",
"version": "==0.33.0"
},
"trio-websocket": {
"hashes": [
"sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae",
"sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6"
],
"markers": "python_version >= '3.8'",
"version": "==0.12.2"
},
"typing-extensions": {
"hashes": [
"sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466",
"sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"
],
"markers": "python_version >= '3.9'",
"version": "==4.15.0"
},
"urllib3": {
"extras": [
"socks"
],
"hashes": [
"sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed",
"sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"
],
"markers": "python_version >= '3.9'",
"version": "==2.6.3"
},
"websocket-client": {
"hashes": [
"sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98",
"sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef"
],
"markers": "python_version >= '3.9'",
"version": "==1.9.0"
},
"wsproto": {
"hashes": [
"sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584",
"sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294"
],
"markers": "python_version >= '3.10'",
"version": "==1.3.2"
}
}
}

View File

@@ -1,2 +1,60 @@
# 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.
## Run
```bash
cp .env.example .env # optional: set WORKSPACE_ROOT, EDITOR_API_KEY, etc.
pipenv install
pipenv run dev
```
Configuration is read from **`.env`** at the repo root (see `.env.example`). Values there are applied when the app loads unless the variable is already set in your shell. [Pipenv](https://pipenv.pypa.io/) also loads `.env` for `pipenv run` commands.
Tests (includes **pytest** and **selenium** in dev dependencies):
```bash
pipenv run test
pipenv run test-integration # Playwright; optional
```
### Selenium
Selenium talks to a **real browser** against a **running server** (not the in-process `TestClient`).
1. Install **Google Chrome** or Chromium on the machine (Selenium 4 uses [Selenium Manager](https://www.selenium.dev/documentation/selenium_manager/) to resolve a matching driver).
2. In one terminal, start the app (default `http://127.0.0.1:8080`):
```bash
pipenv run dev
```
3. In another terminal:
```bash
pipenv run test-selenium
```
If the app listens elsewhere, set **`SELENIUM_BASE_URL`** (e.g. `http://127.0.0.1:9000`) before running.
Or run only Selenium-marked tests:
```bash
cd src && PYTHONPATH=. pipenv run pytest ../tests -m selenium -v
```
If nothing is listening, the smoke test **skips** with a short message instead of failing.
Open [http://localhost:8080](http://localhost:8080).
**User accounts** — Set `AUTH_ENABLED=true` in `.env` to require sign-in for workspace APIs. Users live in a SQLite file (`AUTH_DATABASE_PATH`, default `./data/editor.db`). Use `/register` (if `AUTH_REGISTER_OPEN=true`) or `BOOTSTRAP_ADMIN_USERNAME` / `BOOTSTRAP_ADMIN_PASSWORD` for the first superuser. Superusers can **GET/POST/DELETE `/api/users`** to list, create, or remove accounts.
**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`.
## Layout
- `src/` — FastAPI app and static UI (`src/static/`)
- `workspace/` — default tree: `code/` (editable), `lib/` (read-only via API)

4
pytest.ini Normal file
View File

@@ -0,0 +1,4 @@
[pytest]
markers =
integration: browser / network integration tests
selenium: Selenium UI tests (need running server; see README)

7
src/app.py Normal file
View File

@@ -0,0 +1,7 @@
from editor_app.main import app
if __name__ == "__main__": # pragma: no cover
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8080)

View File

30
src/editor_app/config.py Normal file
View File

@@ -0,0 +1,30 @@
import os
from pathlib import Path
# Application package lives under `src/`; repo root is one level up (for `.env`, default workspace).
BASE_DIR = Path(__file__).resolve().parent.parent
PROJECT_ROOT = BASE_DIR.parent
STATIC_DIR = BASE_DIR / "static"
def load_env_file(env_path: Path) -> None:
"""Load KEY=VALUE entries from a local .env file (does not override existing os.environ)."""
if not env_path.exists():
return
for line in env_path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
continue
key, value = stripped.split("=", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = value
load_env_file(PROJECT_ROOT / ".env")
_default_workspace = PROJECT_ROOT / "workspace"
WORKSPACE_ROOT = Path(os.environ.get("WORKSPACE_ROOT", str(_default_workspace))).resolve()

View File

View File

@@ -0,0 +1,40 @@
from __future__ import annotations
import datetime as dt
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
def _utc_naive() -> dt.datetime:
return dt.datetime.now(dt.UTC).replace(tzinfo=None)
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(256))
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[dt.datetime] = mapped_column(DateTime, default=_utc_naive)
sessions: Mapped[list[AuthSession]] = relationship(
"AuthSession", back_populates="user", cascade="all, delete-orphan"
)
class AuthSession(Base):
__tablename__ = "auth_sessions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
token: Mapped[str] = mapped_column(String(128), unique=True, index=True)
expires_at: Mapped[dt.datetime] = mapped_column(DateTime)
created_at: Mapped[dt.datetime] = mapped_column(DateTime, default=_utc_naive)
user: Mapped[User] = relationship("User", back_populates="sessions")

View File

@@ -0,0 +1,55 @@
from __future__ import annotations
import os
from collections.abc import Generator
from pathlib import Path
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from editor_app.config import PROJECT_ROOT
_engine = None
_SessionLocal: sessionmaker[Session] | None = None
def _database_url() -> str:
path = os.environ.get("AUTH_DATABASE_PATH", str(PROJECT_ROOT / "data" / "editor.db"))
Path(path).parent.mkdir(parents=True, exist_ok=True)
return f"sqlite:///{path}"
def reset_engine() -> None:
"""Test helper: clear cached engine after env change."""
global _engine, _SessionLocal
if _engine is not None:
_engine.dispose()
_engine = None
_SessionLocal = None
def get_engine():
global _engine, _SessionLocal
if _engine is None:
_engine = create_engine(
_database_url(),
connect_args={"check_same_thread": False},
pool_pre_ping=True,
)
_SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=_engine)
return _engine
def get_session_factory() -> sessionmaker[Session]:
get_engine()
assert _SessionLocal is not None
return _SessionLocal
def get_db() -> Generator[Session, None, None]:
factory = get_session_factory()
db = factory()
try:
yield db
finally:
db.close()

63
src/editor_app/deps.py Normal file
View File

@@ -0,0 +1,63 @@
from __future__ import annotations
import os
from fastapi import Cookie, Depends, Header, HTTPException
from sqlalchemy.orm import Session
from editor_app.db.session import get_db
from editor_app.db.models import User
from editor_app.services import accounts
def api_key_valid(authorization: str | None) -> bool:
expected = (os.environ.get("EDITOR_API_KEY") or "").strip()
if not expected:
return False
return (authorization or "").strip() == f"Bearer {expected}"
async def require_api_access(
db: Session = Depends(get_db),
authorization: str | None = Header(None),
editor_session: str | None = Cookie(None),
) -> None:
"""API key, or (when auth off) open access, or (when auth on) valid session — see README."""
if api_key_valid(authorization):
return
key_configured = bool((os.environ.get("EDITOR_API_KEY") or "").strip())
if key_configured:
if accounts.auth_enabled():
user = accounts.get_session_user(db, editor_session)
if user:
return
raise HTTPException(status_code=401, detail="Invalid or missing API key")
if not accounts.auth_enabled():
return
user = accounts.get_session_user(db, editor_session)
if user is None:
raise HTTPException(status_code=401, detail="Not authenticated")
async def get_current_user_optional(
db: Session = Depends(get_db),
authorization: str | None = Header(None),
editor_session: str | None = Cookie(None),
) -> User | None:
if api_key_valid(authorization):
return None
if not accounts.auth_enabled():
return None
return accounts.get_session_user(db, editor_session)
async def require_superuser(
user: User | None = Depends(get_current_user_optional),
) -> User:
if not accounts.auth_enabled():
raise HTTPException(status_code=400, detail="User management requires AUTH_ENABLED=true")
if user is None:
raise HTTPException(status_code=401, detail="Not authenticated")
if not user.is_superuser:
raise HTTPException(status_code=403, detail="Superuser required")
return user

47
src/editor_app/main.py Normal file
View File

@@ -0,0 +1,47 @@
import os
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI
from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import sessionmaker
from editor_app.config import STATIC_DIR, WORKSPACE_ROOT
from editor_app.db.models import Base
from editor_app.db.session import get_engine
from editor_app.deps import require_api_access
from editor_app.routers.auth_routes import router as auth_router
from editor_app.routers.files import router as files_router
from editor_app.routers.frontend import router as frontend_router
from editor_app.routers.users_admin import router as users_admin_router
from editor_app.services import accounts
@asynccontextmanager
async def lifespan(_app: FastAPI):
(WORKSPACE_ROOT / "lib").mkdir(parents=True, exist_ok=True)
engine = get_engine()
Base.metadata.create_all(bind=engine)
factory = sessionmaker(autocommit=False, autoflush=False, bind=engine)
db = factory()
try:
if accounts.auth_enabled():
bu = os.environ.get("BOOTSTRAP_ADMIN_USERNAME", "").strip()
bp = os.environ.get("BOOTSTRAP_ADMIN_PASSWORD", "").strip()
if bu and bp and accounts.count_users(db) == 0:
accounts.create_user(db, bu, bp, is_superuser=True)
finally:
db.close()
yield
def create_app() -> FastAPI:
app = FastAPI(lifespan=lifespan)
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
app.include_router(frontend_router)
app.include_router(auth_router)
app.include_router(users_admin_router)
app.include_router(files_router, dependencies=[Depends(require_api_access)])
return app
app = create_app()

22
src/editor_app/models.py Normal file
View File

@@ -0,0 +1,22 @@
from typing import Optional
from pydantic import BaseModel
class FileContent(BaseModel):
content: str
class FileInfo(BaseModel):
name: str
is_directory: bool
size: Optional[int] = None
class FolderOperation(BaseModel):
path: str
class MoveFileRequest(BaseModel):
source_path: str
destination_folder: str = ""

View File

View File

@@ -0,0 +1,95 @@
from __future__ import annotations
import os
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy.orm import Session
from editor_app.db.session import get_db
from editor_app.schemas.users import AuthStatusResponse, LoginRequest, RegisterRequest, UserPublic
from editor_app.services import accounts
router = APIRouter(prefix="/api/auth", tags=["auth"])
def _set_session_cookie(response: Response, request: Request, token: str) -> None:
max_age = accounts.session_ttl_days() * 86400
secure = request.url.scheme == "https"
response.set_cookie(
key=accounts.SESSION_COOKIE_NAME,
value=token,
max_age=max_age,
httponly=True,
samesite="lax",
secure=secure,
path="/",
)
def _clear_session_cookie(response: Response, request: Request) -> None:
secure = request.url.scheme == "https"
response.delete_cookie(accounts.SESSION_COOKIE_NAME, path="/", samesite="lax", secure=secure)
@router.get("/status", response_model=AuthStatusResponse)
async def auth_status() -> AuthStatusResponse:
return AuthStatusResponse(auth_enabled=accounts.auth_enabled(), register_open=accounts.register_open())
@router.get("/me")
async def auth_me(
request: Request,
db: Session = Depends(get_db),
) -> dict:
if not accounts.auth_enabled():
return {"auth_enabled": False, "user": None}
token = request.cookies.get(accounts.SESSION_COOKIE_NAME)
user = accounts.get_session_user(db, token)
if user is None:
raise HTTPException(status_code=401, detail="Not authenticated")
return {"auth_enabled": True, "user": UserPublic.model_validate(user).model_dump()}
@router.post("/register", response_model=UserPublic)
async def register(
body: RegisterRequest,
db: Session = Depends(get_db),
) -> UserPublic:
if not accounts.auth_enabled():
raise HTTPException(status_code=400, detail="Set AUTH_ENABLED=true to use accounts")
if not accounts.register_open():
raise HTTPException(status_code=403, detail="Registration is disabled (AUTH_REGISTER_OPEN=false)")
try:
user = accounts.register_user(db, body.username, body.password)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return UserPublic.model_validate(user)
@router.post("/login")
async def login(
request: Request,
response: Response,
body: LoginRequest,
db: Session = Depends(get_db),
) -> UserPublic:
if not accounts.auth_enabled():
raise HTTPException(status_code=400, detail="Set AUTH_ENABLED=true to use accounts")
user = accounts.authenticate(db, body.username, body.password)
if not user:
raise HTTPException(status_code=401, detail="Invalid username or password")
sess = accounts.create_session(db, user)
_set_session_cookie(response, request, sess.token)
return UserPublic.model_validate(user)
@router.post("/logout")
async def logout(
request: Request,
response: Response,
db: Session = Depends(get_db),
) -> dict:
token = request.cookies.get(accounts.SESSION_COOKIE_NAME)
accounts.delete_session(db, token)
_clear_session_cookie(response, request)
return {"message": "Logged out"}

View File

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

View File

@@ -0,0 +1,26 @@
from fastapi import APIRouter
from fastapi.responses import FileResponse
from editor_app.config import STATIC_DIR
router = APIRouter()
@router.get("/")
async def serve_home():
return FileResponse(STATIC_DIR / "home.html")
@router.get("/editor")
async def serve_frontend():
return FileResponse(STATIC_DIR / "index.html")
@router.get("/login")
async def serve_login():
return FileResponse(STATIC_DIR / "login.html")
@router.get("/register")
async def serve_register():
return FileResponse(STATIC_DIR / "register.html")

View File

@@ -0,0 +1,50 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from editor_app.db.session import get_db
from editor_app.db.models import User
from editor_app.deps import require_superuser
from editor_app.schemas.users import UserCreateAdmin, UserPublic
from editor_app.services import accounts
router = APIRouter(prefix="/api/users", tags=["users"])
@router.get("", response_model=list[UserPublic])
async def list_users(
_admin: User = Depends(require_superuser),
db: Session = Depends(get_db),
) -> list[UserPublic]:
return [UserPublic.model_validate(u) for u in accounts.list_users(db)]
@router.post("", response_model=UserPublic)
async def create_user_admin(
body: UserCreateAdmin,
admin: User = Depends(require_superuser),
db: Session = Depends(get_db),
) -> UserPublic:
if accounts.get_user_by_username(db, body.username):
raise HTTPException(status_code=409, detail="Username already taken")
user = accounts.create_user(
db,
body.username,
body.password,
is_superuser=body.is_superuser,
)
return UserPublic.model_validate(user)
@router.delete("/{user_id}")
async def delete_user_admin(
user_id: int,
admin: User = Depends(require_superuser),
db: Session = Depends(get_db),
) -> dict:
if user_id == admin.id:
raise HTTPException(status_code=400, detail="Cannot delete your own account")
if not accounts.delete_user(db, user_id):
raise HTTPException(status_code=404, detail="User not found")
return {"message": "User deleted"}

View File

@@ -0,0 +1,46 @@
from pydantic import BaseModel, Field, field_validator
class RegisterRequest(BaseModel):
username: str = Field(min_length=3, max_length=64)
password: str = Field(min_length=8, max_length=128)
@field_validator("username")
@classmethod
def username_chars(cls, v: str) -> str:
s = v.strip()
if not s.replace("_", "").isalnum():
raise ValueError("Username may only contain letters, numbers, and underscores")
return s
class LoginRequest(BaseModel):
username: str
password: str
class UserPublic(BaseModel):
id: int
username: str
is_superuser: bool
model_config = {"from_attributes": True}
class UserCreateAdmin(BaseModel):
username: str = Field(min_length=3, max_length=64)
password: str = Field(min_length=8, max_length=128)
is_superuser: bool = False
@field_validator("username")
@classmethod
def username_chars(cls, v: str) -> str:
s = v.strip()
if not s.replace("_", "").isalnum():
raise ValueError("Username may only contain letters, numbers, and underscores")
return s
class AuthStatusResponse(BaseModel):
auth_enabled: bool
register_open: bool

View File

View File

@@ -0,0 +1,128 @@
from __future__ import annotations
import datetime as dt
import os
import secrets
from typing import TYPE_CHECKING
import bcrypt
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from editor_app.db.models import AuthSession, User
if TYPE_CHECKING:
pass
SESSION_COOKIE_NAME = "editor_session"
SESSION_DAYS_DEFAULT = 14
def auth_enabled() -> bool:
return os.environ.get("AUTH_ENABLED", "false").strip().lower() in ("1", "true", "yes", "on")
def register_open() -> bool:
return os.environ.get("AUTH_REGISTER_OPEN", "true").strip().lower() in ("1", "true", "yes", "on")
def session_ttl_days() -> int:
try:
return max(1, min(365, int(os.environ.get("AUTH_SESSION_DAYS", str(SESSION_DAYS_DEFAULT)))))
except ValueError:
return SESSION_DAYS_DEFAULT
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
def verify_password(plain: str, hashed: str) -> bool:
try:
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("ascii"))
except (ValueError, TypeError):
return False
def get_user_by_username(db: Session, username: str) -> User | None:
return db.scalars(select(User).where(User.username == username)).one_or_none()
def get_user_by_id(db: Session, user_id: int) -> User | None:
return db.scalars(select(User).where(User.id == user_id)).one_or_none()
def count_users(db: Session) -> int:
return int(db.scalar(select(func.count()).select_from(User)) or 0)
def create_user(db: Session, username: str, password: str, *, is_superuser: bool = False) -> User:
user = User(
username=username,
password_hash=hash_password(password),
is_superuser=is_superuser,
)
db.add(user)
db.commit()
db.refresh(user)
return user
def register_user(db: Session, username: str, password: str) -> User:
if get_user_by_username(db, username):
raise ValueError("Username already taken")
first = count_users(db) == 0
return create_user(db, username, password, is_superuser=first)
def authenticate(db: Session, username: str, password: str) -> User | None:
user = get_user_by_username(db, username.strip())
if not user or not verify_password(password, user.password_hash):
return None
return user
def _utc_naive() -> dt.datetime:
return dt.datetime.now(dt.UTC).replace(tzinfo=None)
def create_session(db: Session, user: User) -> AuthSession:
token = secrets.token_urlsafe(48)
expires = _utc_naive() + dt.timedelta(days=session_ttl_days())
row = AuthSession(user_id=user.id, token=token, expires_at=expires)
db.add(row)
db.commit()
db.refresh(row)
return row
def get_session_user(db: Session, token: str | None) -> User | None:
if not token:
return None
now = _utc_naive()
row = db.scalars(select(AuthSession).where(AuthSession.token == token)).one_or_none()
if not row or row.expires_at < now:
return None
return get_user_by_id(db, row.user_id)
def delete_session(db: Session, token: str | None) -> None:
if not token:
return
row = db.scalars(select(AuthSession).where(AuthSession.token == token)).one_or_none()
if row:
db.delete(row)
db.commit()
def list_users(db: Session) -> list[User]:
return list(db.scalars(select(User).order_by(User.username)).all())
def delete_user(db: Session, user_id: int) -> bool:
user = get_user_by_id(db, user_id)
if not user:
return False
db.delete(user)
db.commit()
return True

View File

@@ -0,0 +1,202 @@
import shutil
from pathlib import Path
from fastapi import HTTPException
from editor_app import config
from editor_app.models import FileInfo
LIB_DIR_NAME = "lib"
WRITABLE_ROOTS = {"code"}
def normalize_relative_path(relative_path: str) -> str:
cleaned = (relative_path or "").strip().lstrip("/")
if not cleaned:
return ""
parts = [segment for segment in cleaned.split("/") if segment]
if len(parts) >= 2 and parts[0] == "code":
while len(parts) >= 2 and parts[0] == parts[1] == "code":
parts.pop(1)
return "/".join(parts)
def resolve_workspace_path(relative_path: str) -> Path:
relative_path = normalize_relative_path(relative_path)
target_path = (config.WORKSPACE_ROOT / relative_path).resolve()
try:
target_path.relative_to(config.WORKSPACE_ROOT.resolve())
except ValueError as exc:
raise HTTPException(status_code=400, detail="Path escapes workspace") from exc
return target_path
def _is_path_in_lib(target_path: Path) -> bool:
workspace = config.WORKSPACE_ROOT.resolve()
lib_root = (workspace / LIB_DIR_NAME).resolve()
try:
target_path.resolve().relative_to(lib_root)
return True
except ValueError:
return False
def _ensure_not_lib_path(target_path: Path) -> None:
if _is_path_in_lib(target_path):
raise HTTPException(status_code=403, detail="lib is read-only")
def _is_writable_path(target_path: Path) -> bool:
workspace = config.WORKSPACE_ROOT.resolve()
resolved = target_path.resolve()
try:
relative = resolved.relative_to(workspace)
except ValueError:
return False
if not relative.parts:
return False
return relative.parts[0] in WRITABLE_ROOTS
def _ensure_writable_path(target_path: Path) -> None:
if not _is_writable_path(target_path):
raise HTTPException(
status_code=403,
detail="Only code/ is writable (lib is read-only)",
)
def list_files(path: str = "") -> list[FileInfo]:
path = normalize_relative_path(path)
target_path = config.WORKSPACE_ROOT / path if path else config.WORKSPACE_ROOT
if not target_path.exists() or not target_path.is_dir():
raise HTTPException(status_code=404, detail="Directory not found")
files = []
for item in sorted(target_path.iterdir()):
if item.name.startswith("."):
continue
files.append(
FileInfo(
name=item.name,
is_directory=item.is_dir(),
size=item.stat().st_size if item.is_file() else None,
)
)
return files
def read_text_file(file_path: str) -> tuple[str, str]:
target_path = resolve_workspace_path(file_path)
if not target_path.exists():
raise HTTPException(status_code=404, detail="File not found")
if target_path.is_dir():
raise HTTPException(status_code=400, detail="Path is a directory")
try:
content = target_path.read_text(encoding="utf-8")
except UnicodeDecodeError as exc:
raise HTTPException(status_code=400, detail="File is not a text file") from exc
return content, target_path.name
def save_text_file(file_path: str, content: str) -> str:
target_path = resolve_workspace_path(file_path)
_ensure_not_lib_path(target_path)
_ensure_writable_path(target_path)
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(content, encoding="utf-8")
return target_path.name
def delete_file(file_path: str) -> None:
target_path = resolve_workspace_path(file_path)
_ensure_not_lib_path(target_path)
_ensure_writable_path(target_path)
if not target_path.exists():
raise HTTPException(status_code=404, detail="File not found")
if target_path.is_dir():
raise HTTPException(status_code=400, detail="Cannot delete directories")
target_path.unlink()
def move_path(source_path: str, destination_folder: str) -> tuple[str, str]:
source = resolve_workspace_path(source_path)
_ensure_not_lib_path(source)
_ensure_writable_path(source)
if not source.exists():
raise HTTPException(status_code=404, detail="Source path not found")
destination_dir = (
resolve_workspace_path(destination_folder)
if destination_folder
else config.WORKSPACE_ROOT
)
_ensure_not_lib_path(destination_dir)
_ensure_writable_path(destination_dir)
if not destination_dir.exists() or not destination_dir.is_dir():
raise HTTPException(status_code=404, detail="Destination folder not found")
destination = destination_dir / source.name
source_resolved = source.resolve()
destination_resolved = destination.resolve()
if destination_resolved == source_resolved:
raise HTTPException(status_code=400, detail="Path is already in that folder")
if source.is_dir():
source_prefix = str(source_resolved) + "/"
if str(destination_dir.resolve()).startswith(source_prefix):
raise HTTPException(
status_code=400, detail="Cannot move a folder into itself or its child"
)
if destination.exists():
raise HTTPException(
status_code=409,
detail="A path with that name already exists in destination",
)
destination.parent.mkdir(parents=True, exist_ok=True)
source.rename(destination)
moved_type = "folder" if destination.is_dir() else "file"
return str(destination.relative_to(config.WORKSPACE_ROOT)), moved_type
def create_folder(folder_path: str) -> str:
target_path = resolve_workspace_path(folder_path)
_ensure_not_lib_path(target_path)
_ensure_writable_path(target_path)
if target_path.exists():
raise HTTPException(status_code=400, detail="Folder already exists")
target_path.mkdir(parents=True, exist_ok=False)
return target_path.name
def delete_folder(folder_path: str) -> None:
target_path = resolve_workspace_path(folder_path)
_ensure_not_lib_path(target_path)
_ensure_writable_path(target_path)
if not target_path.exists():
raise HTTPException(status_code=404, detail="Folder not found")
if not target_path.is_dir():
raise HTTPException(status_code=400, detail="Path is not a directory")
shutil.rmtree(target_path)
def collect_python_sources() -> dict[str, str]:
"""Return all UTF-8 .py files under the workspace for browser-side Pyodide sync."""
result: dict[str, str] = {}
workspace = config.WORKSPACE_ROOT.resolve()
if not workspace.exists():
return result
for path in workspace.rglob("*.py"):
try:
rel = path.relative_to(workspace)
except ValueError:
continue
if any(part.startswith(".") for part in rel.parts):
continue
try:
key = str(rel).replace("\\", "/")
result[key] = path.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
continue
return result

File diff suppressed because one or more lines are too long

143
src/static/home.html Normal file
View File

@@ -0,0 +1,143 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Python Editor</title>
<link rel="icon" href="data:,">
<style>
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: linear-gradient(135deg, #0f172a, #1e293b);
color: #e2e8f0;
}
.home-card {
width: min(560px, 92vw);
background: rgba(15, 23, 42, 0.78);
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: 14px;
padding: 2rem;
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.35);
}
h1 { margin: 0 0 0.75rem 0; font-size: 1.8rem; color: #f8fafc; }
p { margin: 0 0 1rem 0; color: #cbd5e1; line-height: 1.5; }
.btn {
display: inline-block;
text-decoration: none;
border-radius: 8px;
padding: 0.65rem 1rem;
font-weight: 600;
border: 1px solid transparent;
cursor: pointer;
font-size: 1rem;
}
.btn-primary { background: #3b82f6; color: #ffffff; }
.btn-ghost { background: transparent; border-color: #64748b; color: #e2e8f0; }
label { display: block; font-size: 0.85rem; color: #94a3b8; margin-bottom: 0.35rem; }
input[type="password"] {
width: 100%;
padding: 0.5rem 0.65rem;
border-radius: 8px;
border: 1px solid #64748b;
background: #0f172a;
color: #e2e8f0;
margin-bottom: 0.75rem;
}
.note { font-size: 0.8rem; color: #94a3b8; margin-top: 1rem; }
.nav { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem; align-items: center; }
.nav span { color: #94a3b8; font-size: 0.9rem; }
.hidden { display: none !important; }
</style>
</head>
<body>
<main class="home-card">
<h1>Python Editor</h1>
<div id="auth-nav" class="nav">
<span id="auth-greeting" class="hidden"></span>
<a class="btn btn-ghost hidden" id="link-login" href="/login">Sign in</a>
<a class="btn btn-ghost hidden" id="link-register" href="/register">Register</a>
<button type="button" class="btn btn-ghost hidden" id="btn-logout">Sign out</button>
</div>
<p>Edit and store files on the server. Python runs in your browser with <a href="https://pyodide.org/" style="color:#93c5fd">Pyodide</a>.</p>
<div id="optional-api-key">
<p>If you use <code style="color:#fcd34d">EDITOR_API_KEY</code> (without user login), store it here for API calls from this browser tab:</p>
<label for="api-key">API key (optional)</label>
<input id="api-key" type="password" autocomplete="off" placeholder="Leave blank if not used" />
<p class="note">The key is kept in <code>sessionStorage</code>. You can also use <code>?api_key=…</code> on the editor URL.</p>
</div>
<a class="btn btn-primary" href="/editor" id="open-editor">Open Editor</a>
</main>
<script>
const storageKey = 'python-editor.api_key';
const input = document.getElementById('api-key');
const openLink = document.getElementById('open-editor');
async function refreshAuthNav() {
const st = await fetch('/api/auth/status');
const status = await st.json();
const loginEl = document.getElementById('link-login');
const regEl = document.getElementById('link-register');
const outEl = document.getElementById('btn-logout');
const greet = document.getElementById('auth-greeting');
const optionalKey = document.getElementById('optional-api-key');
if (!status.auth_enabled) {
loginEl.classList.add('hidden');
regEl.classList.add('hidden');
outEl.classList.add('hidden');
greet.classList.add('hidden');
return;
}
loginEl.classList.remove('hidden');
if (status.register_open) {
regEl.classList.remove('hidden');
}
const me = await fetch('/api/auth/me', { credentials: 'include' });
if (me.ok) {
const j = await me.json();
greet.textContent = `Signed in as ${j.user.username}`;
greet.classList.remove('hidden');
loginEl.classList.add('hidden');
regEl.classList.add('hidden');
outEl.classList.remove('hidden');
if (optionalKey) optionalKey.classList.add('hidden');
} else {
outEl.classList.add('hidden');
greet.classList.add('hidden');
}
}
document.getElementById('btn-logout').addEventListener('click', async () => {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
window.location.reload();
});
try {
const params = new URLSearchParams(window.location.search);
const fromQuery = params.get('api_key');
if (fromQuery) {
sessionStorage.setItem(storageKey, fromQuery);
}
const existing = sessionStorage.getItem(storageKey);
if (existing) {
input.value = existing;
}
} catch (_e) {}
openLink.addEventListener('click', () => {
try {
const v = input.value.trim();
if (v) {
sessionStorage.setItem(storageKey, v);
} else {
sessionStorage.removeItem(storageKey);
}
} catch (_e) {}
});
refreshAuthNav();
</script>
</body>
</html>

72
src/static/index.html Normal file
View File

@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Python Editor</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="/static/styles.css?v=6">
</head>
<body>
<div class="container">
<div class="sidebar">
<div class="sidebar-header">
<h3>Files</h3>
<div class="sidebar-actions">
<button id="new-file-btn" title="New File">📄</button>
<button id="new-folder-btn" title="New Folder">📁+</button>
<button id="delete-selected-btn" title="Delete Selected">🗑️</button>
<button id="refresh-btn" title="Refresh">🔄</button>
</div>
</div>
<div class="file-tree" id="file-tree">
<div class="loading">Loading files...</div>
</div>
</div>
<div class="main-content">
<div class="editor-header">
<div class="file-info">
<span id="save-status" class="save-status"></span>
<span class="runtime-hint" title="Python runs locally in your browser via Pyodide; completions use Jedi in the same runtime.">Browser · Pyodide</span>
</div>
<div class="mode-toggle">
<a id="home-btn" class="mode-btn active" href="/">Home</a>
</div>
<div class="editor-actions">
<button id="run-btn" disabled>Run Python</button>
<button id="stop-btn" disabled>Stop</button>
<select id="run-file-select" title="Script to run">
<option value="">Run active file</option>
</select>
</div>
</div>
<div id="tabs" class="tabs"></div>
<div class="editor-container">
<div id="editor"></div>
<div id="completion-dropdown" class="completion-dropdown"></div>
</div>
<div class="console-container">
<div class="console-header">Console Output</div>
<pre id="console-output" class="console-output"></pre>
</div>
</div>
</div>
<div id="new-file-modal" class="modal">
<div class="modal-content">
<h3>Create New File</h3>
<input type="text" id="new-filename" placeholder="Enter filename (e.g., example.txt)" />
<div class="modal-actions">
<button id="create-file-btn">Create</button>
<button id="cancel-create-btn">Cancel</button>
</div>
</div>
</div>
<script type="module" src="/static/script.js?v=10"></script>
</body>
</html>

120
src/static/login.html Normal file
View File

@@ -0,0 +1,120 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign in — Python Editor</title>
<link rel="icon" href="data:,">
<style>
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: linear-gradient(135deg, #0f172a, #1e293b);
color: #e2e8f0;
}
.card {
width: min(400px, 92vw);
background: rgba(15, 23, 42, 0.85);
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: 14px;
padding: 2rem;
}
h1 { margin: 0 0 1rem 0; font-size: 1.4rem; }
label { display: block; font-size: 0.85rem; color: #94a3b8; margin-bottom: 0.35rem; }
input {
width: 100%;
padding: 0.55rem 0.65rem;
border-radius: 8px;
border: 1px solid #64748b;
background: #0f172a;
color: #e2e8f0;
margin-bottom: 1rem;
}
button {
width: 100%;
padding: 0.65rem;
border: none;
border-radius: 8px;
background: #3b82f6;
color: #fff;
font-weight: 600;
cursor: pointer;
margin-bottom: 0.75rem;
}
button:disabled { opacity: 0.6; cursor: not-allowed; }
.err { color: #fca5a5; font-size: 0.9rem; margin-bottom: 0.75rem; min-height: 1.2em; }
a { color: #93c5fd; }
.links { font-size: 0.9rem; margin-top: 1rem; }
</style>
</head>
<body>
<main class="card">
<h1>Sign in</h1>
<div id="err" class="err"></div>
<form id="form">
<label for="username">Username</label>
<input id="username" name="username" autocomplete="username" required />
<label for="password">Password</label>
<input id="password" type="password" name="password" autocomplete="current-password" required />
<button type="submit" id="submit">Sign in</button>
</form>
<div class="links">
<a href="/">Home</a>
· <a id="register-link" href="/register">Create account</a>
</div>
</main>
<script>
const params = new URLSearchParams(window.location.search);
const next = params.get("next") || "/editor";
(async function checkStatus() {
try {
const r = await fetch("/api/auth/status");
const s = await r.json();
if (!s.auth_enabled) {
document.getElementById("err").textContent = "Sign-in is disabled (AUTH_ENABLED is not set).";
document.getElementById("form").style.display = "none";
}
if (!s.register_open) {
const link = document.getElementById("register-link");
link.style.display = "none";
}
} catch (_e) {}
})();
document.getElementById("form").addEventListener("submit", async (e) => {
e.preventDefault();
const err = document.getElementById("err");
const btn = document.getElementById("submit");
err.textContent = "";
btn.disabled = true;
try {
const body = {
username: document.getElementById("username").value.trim(),
password: document.getElementById("password").value,
};
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body),
});
if (!res.ok) {
const j = await res.json().catch(() => ({}));
err.textContent = j.detail || res.statusText || "Login failed";
return;
}
window.location.href = next.startsWith("/") ? next : "/editor";
} catch (ex) {
err.textContent = String(ex.message || ex);
} finally {
btn.disabled = false;
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,122 @@
/* global importScripts, loadPyodide, self */
importScripts('https://cdn.jsdelivr.net/pyodide/v0.26.4/full/pyodide.js');
const PYODIDE_INDEX_URL = 'https://cdn.jsdelivr.net/pyodide/v0.26.4/full/';
let pyodide = null;
let loadingPromise = null;
async function ensurePyodide() {
if (pyodide) {
return pyodide;
}
if (!loadingPromise) {
loadingPromise = (async () => {
const p = await loadPyodide({ indexURL: PYODIDE_INDEX_URL });
p.setStdout({
batched: (txt) => self.postMessage({ type: 'io', stream: 'stdout', text: txt }),
});
p.setStderr({
batched: (txt) => self.postMessage({ type: 'io', stream: 'stderr', text: txt }),
});
await p.loadPackage('micropip');
await p.runPythonAsync(`
import micropip
await micropip.install("jedi")
`);
return p;
})();
}
pyodide = await loadingPromise;
return pyodide;
}
self.onmessage = async (event) => {
const { id, type, payload } = event.data || {};
try {
if (type === 'init') {
await ensurePyodide();
self.postMessage({ id, type: 'init', ok: true });
return;
}
const p = await ensurePyodide();
if (type === 'complete') {
const rel = String(payload.path || 'scratch.py').replace(/^\/+/, '');
const vpath = `/workspace/${rel}`;
p.globals.set('__cm_code', String(payload.content ?? ''));
p.globals.set('__cm_path', vpath);
p.globals.set('__cm_line', Number(payload.line) || 1);
p.globals.set('__cm_col', Number(payload.column) || 0);
p.globals.set('__cm_max', Math.min(100, Math.max(1, Number(payload.max_results) || 20)));
p.globals.set('__cm_extra_json', JSON.stringify(payload.extra_files || {}));
const raw = p.runPython(`
import json, os
import jedi
extra = json.loads(__cm_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(__cm_path), exist_ok=True)
with open(__cm_path, "w", encoding="utf-8") as fh:
fh.write(__cm_code)
proj = jedi.Project("/workspace")
s = jedi.Script(code=__cm_code, path=__cm_path, project=proj)
items = s.complete(line=__cm_line, column=__cm_col)
out = [{"name": i.name, "type": i.type, "complete": i.complete} for i in items[:__cm_max]]
json.dumps(out)
`);
const completions = JSON.parse(String(raw));
self.postMessage({ id, type: 'complete', ok: true, completions });
return;
}
if (type === 'run') {
const files = payload.files && typeof payload.files === 'object' ? payload.files : {};
const mainRel = String(payload.mainPath || '').replace(/^\/+/, '');
const argsList = Array.isArray(payload.args) ? payload.args.map(String) : [];
p.globals.set('__run_files_json', JSON.stringify(files));
p.globals.set('__run_main', `/workspace/${mainRel}`);
p.globals.set('__run_args', p.toPy(argsList));
await p.runPythonAsync(`
import json, os, shutil, sys, runpy
files = json.loads(__run_files_json)
shutil.rmtree('/workspace', ignore_errors=True)
os.makedirs('/workspace', exist_ok=True)
for rel, body in files.items():
rel = str(rel).lstrip("/")
full = os.path.join("/workspace", rel)
os.makedirs(os.path.dirname(full), exist_ok=True)
with open(full, "w", encoding="utf-8") as fh:
fh.write(str(body))
for entry in ("/workspace/lib", "/workspace"):
if entry not in sys.path:
sys.path.insert(0, entry)
os.chdir("/workspace")
main = __run_main
sys.argv = [main] + list(__run_args)
runpy.run_path(main, run_name="__main__")
`);
self.postMessage({ id, type: 'run', ok: true });
return;
}
self.postMessage({ id, type, ok: false, error: `Unknown message type: ${type}` });
} catch (err) {
self.postMessage({
id,
type,
ok: false,
error: err && err.message ? String(err.message) : String(err),
});
}
};

113
src/static/register.html Normal file
View File

@@ -0,0 +1,113 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Register — Python Editor</title>
<link rel="icon" href="data:,">
<style>
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: linear-gradient(135deg, #0f172a, #1e293b);
color: #e2e8f0;
}
.card {
width: min(400px, 92vw);
background: rgba(15, 23, 42, 0.85);
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: 14px;
padding: 2rem;
}
h1 { margin: 0 0 1rem 0; font-size: 1.4rem; }
label { display: block; font-size: 0.85rem; color: #94a3b8; margin-bottom: 0.35rem; }
input {
width: 100%;
padding: 0.55rem 0.65rem;
border-radius: 8px;
border: 1px solid #64748b;
background: #0f172a;
color: #e2e8f0;
margin-bottom: 1rem;
}
button {
width: 100%;
padding: 0.65rem;
border: none;
border-radius: 8px;
background: #3b82f6;
color: #fff;
font-weight: 600;
cursor: pointer;
margin-bottom: 0.75rem;
}
button:disabled { opacity: 0.6; cursor: not-allowed; }
.err { color: #fca5a5; font-size: 0.9rem; margin-bottom: 0.75rem; min-height: 1.2em; }
.hint { font-size: 0.8rem; color: #94a3b8; margin-bottom: 1rem; }
a { color: #93c5fd; }
</style>
</head>
<body>
<main class="card">
<h1>Create account</h1>
<p class="hint">Username: letters, numbers, underscore (364). Password: at least 8 characters.</p>
<div id="err" class="err"></div>
<form id="form">
<label for="username">Username</label>
<input id="username" name="username" autocomplete="username" required />
<label for="password">Password</label>
<input id="password" type="password" name="password" autocomplete="new-password" required />
<button type="submit" id="submit">Register</button>
</form>
<p><a href="/login">Sign in</a> · <a href="/">Home</a></p>
</main>
<script>
(async function checkStatus() {
try {
const r = await fetch("/api/auth/status");
const s = await r.json();
if (!s.auth_enabled) {
document.getElementById("err").textContent = "Registration is disabled (AUTH_ENABLED is not set).";
document.getElementById("form").style.display = "none";
} else if (!s.register_open) {
document.getElementById("err").textContent = "Public registration is closed. Ask an administrator.";
document.getElementById("form").style.display = "none";
}
} catch (_e) {}
})();
document.getElementById("form").addEventListener("submit", async (e) => {
e.preventDefault();
const err = document.getElementById("err");
const btn = document.getElementById("submit");
err.textContent = "";
btn.disabled = true;
try {
const body = {
username: document.getElementById("username").value.trim(),
password: document.getElementById("password").value,
};
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const j = await res.json().catch(() => ({}));
err.textContent = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail) || res.statusText;
return;
}
window.location.href = "/login?next=/editor";
} catch (ex) {
err.textContent = String(ex.message || ex);
} finally {
btn.disabled = false;
}
});
</script>
</body>
</html>

1505
src/static/script.js Normal file

File diff suppressed because it is too large Load Diff

475
src/static/styles.css Normal file
View File

@@ -0,0 +1,475 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f5f5f5;
height: 100vh;
overflow: hidden;
}
.container {
display: flex;
height: 100vh;
}
/* Sidebar */
.sidebar {
width: 300px;
background-color: #2d3748;
color: white;
display: flex;
flex-direction: column;
}
.sidebar-header {
padding: 1rem;
border-bottom: 1px solid #4a5568;
display: flex;
justify-content: space-between;
align-items: center;
}
.sidebar-header h3 {
font-size: 1.1rem;
font-weight: 600;
}
.sidebar-actions {
display: flex;
gap: 0.5rem;
}
.sidebar-actions button {
background: none;
border: none;
color: white;
cursor: pointer;
padding: 0.25rem;
border-radius: 4px;
font-size: 1rem;
transition: background-color 0.2s;
}
.sidebar-actions button:hover {
background-color: #4a5568;
}
.file-tree {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.file-item {
padding: 0.5rem;
cursor: pointer;
border-radius: 4px;
margin-bottom: 2px;
display: flex;
align-items: center;
gap: 0.5rem;
transition: background-color 0.2s;
}
.file-item:hover {
background-color: #4a5568;
}
.file-item.selected {
background-color: #3182ce;
}
.file-item.drag-target {
background-color: #2b6cb0;
outline: 1px dashed #90cdf4;
}
.file-item.dragging-file {
opacity: 0.55;
}
.file-item.directory {
font-weight: 600;
}
.file-item.root-drop {
border: 1px dashed #4a5568;
margin-bottom: 0.5rem;
}
.file-icon {
font-size: 1rem;
}
.loading {
padding: 1rem;
text-align: center;
color: #a0aec0;
}
/* Main Content */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
background-color: white;
}
.editor-header {
padding: 1rem;
border-bottom: 1px solid #e2e8f0;
display: flex;
justify-content: space-between;
align-items: center;
background-color: #f7fafc;
}
.file-info {
display: flex;
align-items: center;
gap: 1rem;
}
.runtime-hint {
font-size: 0.75rem;
color: #718096;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 0.2rem 0.45rem;
white-space: nowrap;
}
#current-file {
font-weight: 600;
color: #2d3748;
}
.save-status {
font-size: 0.875rem;
color: #718096;
}
.save-status.saved {
color: #38a169;
}
.save-status.unsaved {
color: #e53e3e;
}
.mode-toggle {
display: inline-flex;
border: 1px solid #cbd5e0;
border-radius: 8px;
overflow: hidden;
}
.mode-btn {
border: none;
background: #edf2f7;
color: #4a5568;
padding: 0.45rem 0.8rem;
font-size: 0.85rem;
cursor: pointer;
}
.mode-btn + .mode-btn {
border-left: 1px solid #cbd5e0;
}
.mode-btn.active {
background: #3182ce;
color: white;
}
.editor-actions {
display: flex;
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 {
padding: 0.5rem 1rem;
border: 1px solid #e2e8f0;
background-color: white;
color: #4a5568;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
}
.editor-actions button:hover:not(:disabled) {
background-color: #f7fafc;
border-color: #cbd5e0;
}
.editor-actions button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.editor-actions button:not(:disabled):active {
background-color: #edf2f7;
}
.editor-container {
flex: 1;
position: relative;
min-height: 0;
}
.hidden {
display: none !important;
}
.completion-dropdown {
position: absolute;
z-index: 30;
display: none;
min-width: 220px;
max-width: 420px;
max-height: 220px;
overflow-y: auto;
background: #ffffff;
border: 1px solid #cbd5e0;
border-radius: 6px;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.18);
}
.completion-item {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 0.4rem 0.6rem;
cursor: pointer;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 13px;
}
.completion-item:hover,
.completion-item.active {
background: #ebf8ff;
}
.completion-name {
color: #1a202c;
}
.completion-type {
color: #718096;
font-size: 12px;
}
.tabs {
display: flex;
gap: 4px;
padding: 0.4rem 0.5rem;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
overflow-x: auto;
}
.tab {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.6rem;
border: 1px solid #cbd5e0;
border-radius: 6px;
background: white;
color: #2d3748;
cursor: pointer;
white-space: nowrap;
}
.tab.active {
border-color: #3182ce;
background: #ebf8ff;
}
.tab-title {
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
}
.tab-close {
border: none;
background: transparent;
color: #718096;
cursor: pointer;
font-size: 14px;
line-height: 1;
}
.tab-close:hover {
color: #e53e3e;
}
#editor {
width: 100%;
height: 100%;
border: none;
outline: none;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 14px;
line-height: 1.5;
padding: 1rem;
resize: none;
}
/* CodeMirror 6 styling */
.cm-editor {
height: 100%;
font-size: 14px;
line-height: 1.5;
}
.cm-focused {
outline: none;
}
.console-container {
height: 220px;
border-top: 1px solid #e2e8f0;
display: flex;
flex-direction: column;
background: #0f172a;
}
.console-header {
padding: 0.5rem 0.75rem;
font-size: 0.85rem;
color: #cbd5e1;
border-bottom: 1px solid #1e293b;
}
.console-output {
flex: 1;
margin: 0;
padding: 0.75rem;
overflow: auto;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 13px;
line-height: 1.45;
color: #e2e8f0;
white-space: pre-wrap;
}
/* Modal */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
}
.modal-content {
background-color: white;
margin: 15% auto;
padding: 2rem;
border-radius: 8px;
width: 400px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
}
.modal-content h3 {
margin-bottom: 1rem;
color: #2d3748;
}
.modal-content input {
width: 100%;
padding: 0.75rem;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 1rem;
margin-bottom: 1rem;
}
.modal-content input:focus {
outline: none;
border-color: #3182ce;
box-shadow: 0 0 0 3px rgba(49, 130, 206, 0.1);
}
.modal-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.modal-actions button {
padding: 0.5rem 1rem;
border: 1px solid #e2e8f0;
background-color: white;
color: #4a5568;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
}
.modal-actions button:hover {
background-color: #f7fafc;
border-color: #cbd5e0;
}
.modal-actions button:first-child {
background-color: #3182ce;
color: white;
border-color: #3182ce;
}
.modal-actions button:first-child:hover {
background-color: #2c5aa0;
border-color: #2c5aa0;
}
/* Responsive design */
@media (max-width: 768px) {
.sidebar {
width: 250px;
}
.modal-content {
width: 90%;
margin: 20% auto;
}
}
/* Scrollbar styling */
.file-tree::-webkit-scrollbar {
width: 6px;
}
.file-tree::-webkit-scrollbar-track {
background: #2d3748;
}
.file-tree::-webkit-scrollbar-thumb {
background: #4a5568;
border-radius: 3px;
}
.file-tree::-webkit-scrollbar-thumb:hover {
background: #718096;
}

24
tests/conftest.py Normal file
View File

@@ -0,0 +1,24 @@
import importlib
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def client(tmp_path, monkeypatch):
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
monkeypatch.setenv("AUTH_ENABLED", "false")
monkeypatch.setenv("AUTH_DATABASE_PATH", str(tmp_path / "auth.db"))
monkeypatch.delenv("EDITOR_API_KEY", raising=False)
monkeypatch.delenv("BOOTSTRAP_ADMIN_USERNAME", raising=False)
monkeypatch.delenv("BOOTSTRAP_ADMIN_PASSWORD", raising=False)
import editor_app.config as config
import editor_app.db.session as db_sess
import editor_app.main as main
config.WORKSPACE_ROOT = tmp_path
db_sess.reset_engine()
importlib.reload(main)
with TestClient(main.app) as test_client:
yield test_client

286
tests/test_api.py Normal file
View File

@@ -0,0 +1,286 @@
import importlib
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
def test_root_serves_html(client):
response = client.get("/")
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
assert "Python Editor" in response.text
def test_editor_route_serves_editor_html(client):
response = client.get("/editor")
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
assert "Python Editor" in response.text
def test_list_files_hides_dotfiles_and_reports_sizes(client, tmp_path):
(tmp_path / ".hidden.txt").write_text("secret", encoding="utf-8")
(tmp_path / "visible.txt").write_text("hello", encoding="utf-8")
(tmp_path / "folder").mkdir()
response = client.get("/api/files")
assert response.status_code == 200
files = response.json()["files"]
names = {item["name"] for item in files}
assert ".hidden.txt" not in names
assert "visible.txt" in names
assert "folder" in names
def test_list_files_missing_directory_returns_404(client):
response = client.get("/api/files", params={"path": "does-not-exist"})
assert response.status_code == 404
def test_save_and_read_file_roundtrip(client, tmp_path):
response = client.post("/api/file/code/docs/readme.txt", json={"content": "doc body"})
assert response.status_code == 200
assert (tmp_path / "code" / "docs" / "readme.txt").read_text(encoding="utf-8") == "doc body"
read_response = client.get("/api/file/code/docs/readme.txt")
assert read_response.status_code == 200
assert read_response.json()["content"] == "doc body"
def test_save_file_collapses_duplicate_scoped_prefix(client, tmp_path):
response = client.post("/api/file/code/code/main.py", json={"content": "print('ok')"})
assert response.status_code == 200
assert (tmp_path / "code" / "main.py").read_text(encoding="utf-8") == "print('ok')"
assert not (tmp_path / "code" / "code" / "main.py").exists()
def test_lib_folder_is_read_only_for_mutations(client, tmp_path):
lib_dir = tmp_path / "lib"
lib_dir.mkdir(exist_ok=True)
(lib_dir / "helper.py").write_text("x = 1\n", encoding="utf-8")
code_dir = tmp_path / "code"
code_dir.mkdir()
(code_dir / "main.py").write_text("print('ok')\n", encoding="utf-8")
save_blocked = client.post("/api/file/lib/new.txt", json={"content": "nope"})
assert save_blocked.status_code == 403
delete_blocked = client.delete("/api/file/lib/helper.py")
assert delete_blocked.status_code == 403
move_blocked = client.post(
"/api/file-move",
json={"source_path": "code/main.py", "destination_folder": "lib"},
)
assert move_blocked.status_code == 403
def test_only_code_is_writable(client, tmp_path):
blocked_file = client.post("/api/file/notes.txt", json={"content": "nope"})
assert blocked_file.status_code == 403
blocked_folder = client.post("/api/folder/new/archive", json={"path": "ignored"})
assert blocked_folder.status_code == 403
blocked_prompt = client.post("/api/file/prompts/a.txt", json={"content": "nope"})
assert blocked_prompt.status_code == 403
allowed_code = client.post("/api/file/code/a.txt", json={"content": "ok"})
assert allowed_code.status_code == 200
def test_read_file_errors_for_directory_and_missing(client, tmp_path):
(tmp_path / "docs").mkdir()
dir_response = client.get("/api/file/docs")
assert dir_response.status_code == 400
missing_response = client.get("/api/file/missing.txt")
assert missing_response.status_code == 404
def test_read_file_non_utf8_returns_400(client, tmp_path):
(tmp_path / "bin.dat").write_bytes(b"\xff\xfe\x00")
response = client.get("/api/file/bin.dat")
assert response.status_code == 400
def test_delete_file_success_and_errors(client, tmp_path):
target = tmp_path / "code" / "delete-me.txt"
target.parent.mkdir()
target.write_text("x", encoding="utf-8")
ok = client.delete("/api/file/code/delete-me.txt")
assert ok.status_code == 200
assert not target.exists()
missing = client.delete("/api/file/code/delete-me.txt")
assert missing.status_code == 404
(tmp_path / "code" / "dir").mkdir(parents=True)
directory = client.delete("/api/file/code/dir")
assert directory.status_code == 400
def test_move_file_to_another_folder(client, tmp_path):
source = tmp_path / "code" / "docs" / "note.txt"
source.parent.mkdir(parents=True)
source.write_text("hello", encoding="utf-8")
(tmp_path / "code" / "archive").mkdir(parents=True)
response = client.post(
"/api/file-move",
json={"source_path": "code/docs/note.txt", "destination_folder": "code/archive"},
)
assert response.status_code == 200
assert response.json()["new_path"] == "code/archive/note.txt"
assert not source.exists()
assert (tmp_path / "code" / "archive" / "note.txt").exists()
def test_move_file_errors(client, tmp_path):
(tmp_path / "code" / "docs").mkdir(parents=True)
(tmp_path / "code" / "docs" / "note.txt").write_text("x", encoding="utf-8")
(tmp_path / "code" / "archive").mkdir(parents=True)
(tmp_path / "code" / "archive" / "note.txt").write_text("x", encoding="utf-8")
conflict = client.post(
"/api/file-move",
json={"source_path": "code/docs/note.txt", "destination_folder": "code/archive"},
)
assert conflict.status_code == 409
missing = client.post(
"/api/file-move",
json={"source_path": "code/missing.txt", "destination_folder": "code/archive"},
)
assert missing.status_code == 404
def test_move_folder_to_another_folder(client, tmp_path):
(tmp_path / "code" / "docs").mkdir(parents=True)
(tmp_path / "code" / "docs" / "note.txt").write_text("x", encoding="utf-8")
(tmp_path / "code" / "archive").mkdir(parents=True)
response = client.post(
"/api/file-move",
json={"source_path": "code/docs", "destination_folder": "code/archive"},
)
assert response.status_code == 200
assert response.json()["new_path"] == "code/archive/docs"
assert response.json()["moved_type"] == "folder"
assert (tmp_path / "code" / "archive" / "docs" / "note.txt").exists()
assert not (tmp_path / "code" / "docs").exists()
def test_move_folder_errors(client, tmp_path):
(tmp_path / "code" / "docs").mkdir(parents=True)
(tmp_path / "code" / "docs" / "nested").mkdir()
(tmp_path / "code" / "archive").mkdir(parents=True)
(tmp_path / "code" / "archive" / "docs").mkdir()
into_child = client.post(
"/api/file-move",
json={"source_path": "code/docs", "destination_folder": "code/docs/nested"},
)
assert into_child.status_code == 400
name_conflict = client.post(
"/api/file-move",
json={"source_path": "code/docs", "destination_folder": "code/archive"},
)
assert name_conflict.status_code == 409
def test_path_escape_is_blocked(client):
response = client.post("/api/file/%2E%2E/evil.txt", json={"content": "nope"})
assert response.status_code == 400
def test_folder_create_and_delete(client, tmp_path):
create = client.post("/api/folder/new/code/new-folder", json={"path": "ignored"})
assert create.status_code == 200
assert (tmp_path / "code" / "new-folder").is_dir()
exists = client.post("/api/folder/new/code/new-folder", json={"path": "ignored"})
assert exists.status_code == 400
delete = client.delete("/api/folder/code/new-folder")
assert delete.status_code == 200
assert not (tmp_path / "code" / "new-folder").exists()
def test_create_folder_collapses_duplicate_scoped_prefix(client, tmp_path):
(tmp_path / "code").mkdir()
create = client.post("/api/folder/new/code/code/nested", json={"path": "ignored"})
assert create.status_code == 200
assert (tmp_path / "code" / "nested").is_dir()
assert not (tmp_path / "code" / "code").exists()
def test_folder_delete_errors(client, tmp_path):
missing = client.delete("/api/folder/code/missing")
assert missing.status_code == 404
(tmp_path / "code").mkdir()
(tmp_path / "code" / "file.txt").write_text("x", encoding="utf-8")
not_dir = client.delete("/api/folder/code/file.txt")
assert not_dir.status_code == 400
def test_workspace_py_sources_returns_python_files(client, tmp_path):
(tmp_path / "code").mkdir()
(tmp_path / "code" / "app.py").write_text("x = 1\n", encoding="utf-8")
(tmp_path / "lib").mkdir(exist_ok=True)
(tmp_path / "lib" / "util.py").write_text("def f():\n pass\n", encoding="utf-8")
response = client.get("/api/workspace/py-sources")
assert response.status_code == 200
files = response.json()["files"]
assert files["code/app.py"] == "x = 1\n"
assert "lib/util.py" in files
def test_api_requires_bearer_when_editor_api_key_set(tmp_path, monkeypatch):
import editor_app.config as config
import editor_app.db.session as db_sess
import editor_app.main as main
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
monkeypatch.setenv("AUTH_ENABLED", "false")
monkeypatch.setenv("AUTH_DATABASE_PATH", str(tmp_path / "auth.db"))
monkeypatch.setenv("EDITOR_API_KEY", "secret-token")
monkeypatch.delenv("BOOTSTRAP_ADMIN_USERNAME", raising=False)
monkeypatch.delenv("BOOTSTRAP_ADMIN_PASSWORD", raising=False)
config.WORKSPACE_ROOT = tmp_path
db_sess.reset_engine()
importlib.reload(main)
with TestClient(main.app) as app_client:
blocked = app_client.get("/api/files")
assert blocked.status_code == 401
ok = app_client.get("/api/files", headers={"Authorization": "Bearer secret-token"})
assert ok.status_code == 200
def test_create_app_startup_creates_lib(tmp_path, monkeypatch):
import editor_app.config as config
import editor_app.db.session as db_sess
import editor_app.main as main
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
monkeypatch.setenv("AUTH_ENABLED", "false")
monkeypatch.setenv("AUTH_DATABASE_PATH", str(tmp_path / "auth.db"))
monkeypatch.delenv("EDITOR_API_KEY", raising=False)
monkeypatch.delenv("BOOTSTRAP_ADMIN_USERNAME", raising=False)
monkeypatch.delenv("BOOTSTRAP_ADMIN_PASSWORD", raising=False)
config.WORKSPACE_ROOT = tmp_path
db_sess.reset_engine()
importlib.reload(main)
assert not (tmp_path / "lib").exists()
with TestClient(main.app) as _client:
_client.get("/api/auth/status")
assert (tmp_path / "lib").is_dir()

133
tests/test_auth.py Normal file
View File

@@ -0,0 +1,133 @@
import importlib
import pytest
from fastapi.testclient import TestClient
def _reload_app(tmp_path, monkeypatch, **env):
import editor_app.config as config
import editor_app.db.session as db_sess
import editor_app.main as main
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
monkeypatch.setenv("AUTH_DATABASE_PATH", str(tmp_path / "auth.db"))
monkeypatch.delenv("EDITOR_API_KEY", raising=False)
monkeypatch.delenv("BOOTSTRAP_ADMIN_USERNAME", raising=False)
monkeypatch.delenv("BOOTSTRAP_ADMIN_PASSWORD", raising=False)
for k, v in env.items():
monkeypatch.setenv(k, v)
config.WORKSPACE_ROOT = tmp_path
db_sess.reset_engine()
importlib.reload(main)
return main.app
def test_auth_status_public(tmp_path, monkeypatch):
with TestClient(_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="false")) as client:
r = client.get("/api/auth/status")
assert r.status_code == 200
assert r.json() == {"auth_enabled": False, "register_open": True}
def test_register_login_and_api_access(tmp_path, monkeypatch):
with TestClient(
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
) as client:
assert client.get("/api/files").status_code == 401
reg = client.post("/api/auth/register", json={"username": "alice", "password": "hunter2000"})
assert reg.status_code == 200
body = reg.json()
assert body["username"] == "alice"
assert body["is_superuser"] is True
login = client.post("/api/auth/login", json={"username": "alice", "password": "hunter2000"})
assert login.status_code == 200
ok = client.get("/api/files")
assert ok.status_code == 200
me = client.get("/api/auth/me")
assert me.status_code == 200
assert me.json()["user"]["username"] == "alice"
out = client.post("/api/auth/logout")
assert out.status_code == 200
assert client.get("/api/files").status_code == 401
def test_second_user_not_superuser(tmp_path, monkeypatch):
with TestClient(
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
) as client:
r1 = client.post("/api/auth/register", json={"username": "usr1", "password": "password99"})
assert r1.status_code == 200
assert r1.json()["is_superuser"] is True
r2 = client.post("/api/auth/register", json={"username": "usr2", "password": "password99"})
assert r2.status_code == 200
assert r2.json()["is_superuser"] is False
def test_register_duplicate_username(tmp_path, monkeypatch):
with TestClient(
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
) as client:
assert client.post("/api/auth/register", json={"username": "dupuser", "password": "password99"}).status_code == 200
dup = client.post("/api/auth/register", json={"username": "dupuser", "password": "otherpass1"})
assert dup.status_code == 409
def test_register_closed(tmp_path, monkeypatch):
with TestClient(
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="false")
) as client:
r = client.post("/api/auth/register", json={"username": "bob", "password": "password99"})
assert r.status_code == 403
def test_superuser_lists_and_creates_users(tmp_path, monkeypatch):
with TestClient(
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
) as client:
client.post("/api/auth/register", json={"username": "admin", "password": "password99"})
client.post("/api/auth/login", json={"username": "admin", "password": "password99"})
listed = client.get("/api/users")
assert listed.status_code == 200
assert len(listed.json()) == 1
created = client.post(
"/api/users",
json={"username": "sub", "password": "password99", "is_superuser": False},
)
assert created.status_code == 200
assert created.json()["username"] == "sub"
assert created.json()["is_superuser"] is False
names = {u["username"] for u in client.get("/api/users").json()}
assert names == {"admin", "sub"}
def test_non_superuser_cannot_list_users(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": "boss", "password": "password99"})
client.post("/api/auth/login", json={"username": "boss", "password": "password99"})
client.post("/api/auth/logout")
client.post("/api/auth/register", json={"username": "peon", "password": "password99"})
client.post("/api/auth/login", json={"username": "peon", "password": "password99"})
denied = client.get("/api/users")
assert denied.status_code == 403
def test_login_serves_page(client):
r = client.get("/login")
assert r.status_code == 200
assert "Sign in" in r.text
def test_register_serves_page(client):
r = client.get("/register")
assert r.status_code == 200
assert "Create account" in r.text

101
tests/test_browser.py Normal file
View File

@@ -0,0 +1,101 @@
import importlib
import socket
import subprocess
import sys
import time
from pathlib import Path
import pytest
def _is_port_open(port: int) -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(0.3)
return sock.connect_ex(("127.0.0.1", port)) == 0
@pytest.mark.integration
def test_browser_create_file_not_forced_into_new(tmp_path):
playwright = pytest.importorskip("playwright.sync_api")
sync_playwright = playwright.sync_playwright
editor_dir = Path(__file__).resolve().parents[1]
port = 8123
env = dict(
**__import__("os").environ,
WORKSPACE_ROOT=str(tmp_path),
AUTH_ENABLED="false",
AUTH_DATABASE_PATH=str(tmp_path / "playwright_auth.db"),
)
server = subprocess.Popen(
[
sys.executable,
"-m",
"uvicorn",
"app:app",
"--app-dir",
"src",
"--host",
"127.0.0.1",
"--port",
str(port),
],
cwd=str(editor_dir),
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
try:
for _ in range(50):
if _is_port_open(port):
break
time.sleep(0.1)
else:
pytest.fail("Server did not start in time")
with sync_playwright() as p:
try:
browser = p.chromium.launch()
except Exception as exc: # pragma: no cover
pytest.skip(f"Playwright browser not installed: {exc}")
page = browser.new_page()
page.goto(f"http://127.0.0.1:{port}/editor", wait_until="networkidle")
page.click("#new-file-btn")
page.fill("#new-filename", "browser-test.txt")
page.click("#create-file-btn")
page.wait_for_timeout(500)
browser.close()
assert (tmp_path / "code" / "browser-test.txt").exists()
assert not (tmp_path / "new" / "browser-test.txt").exists()
finally:
server.terminate()
try:
server.wait(timeout=3)
except subprocess.TimeoutExpired:
server.kill()
def test_new_file_uses_api_file_route(tmp_path, monkeypatch):
import editor_app.config as config
import editor_app.db.session as db_sess
import editor_app.main as main
from fastapi.testclient import TestClient
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
monkeypatch.setenv("AUTH_ENABLED", "false")
monkeypatch.setenv("AUTH_DATABASE_PATH", str(tmp_path / "auth_route.db"))
config.WORKSPACE_ROOT = tmp_path
db_sess.reset_engine()
importlib.reload(main)
client = TestClient(main.app)
response = client.post(
"/api/file/code/routing-check.txt",
json={"content": "ok"},
)
assert response.status_code == 200
assert (tmp_path / "code" / "routing-check.txt").exists()
assert not (tmp_path / "new" / "routing-check.txt").exists()

45
tests/test_internal.py Normal file
View File

@@ -0,0 +1,45 @@
import importlib
from pathlib import Path
import pytest
def test_load_env_file_sets_missing_keys_only(tmp_path, monkeypatch):
import editor_app.config as config
env_file = tmp_path / ".env"
env_file.write_text(
"# comment\nFOO=bar\nBAZ='quoted'\nEXISTING=from_file\n", encoding="utf-8"
)
monkeypatch.setenv("EXISTING", "kept")
monkeypatch.delenv("FOO", raising=False)
monkeypatch.delenv("BAZ", raising=False)
config.load_env_file(env_file)
assert __import__("os").environ["FOO"] == "bar"
assert __import__("os").environ["BAZ"] == "quoted"
assert __import__("os").environ["EXISTING"] == "kept"
def test_load_env_file_ignores_missing_file(tmp_path):
import editor_app.config as config
config.load_env_file(tmp_path / "missing.env")
def test_collect_python_sources_skips_hidden_and_binary(tmp_path):
import editor_app.config as config
import editor_app.services.filesystem as filesystem
(tmp_path / "code").mkdir()
(tmp_path / "code" / "ok.py").write_text("a = 1\n", encoding="utf-8")
(tmp_path / "code" / ".venv").mkdir()
(tmp_path / "code" / ".venv" / "skip.py").write_text("x\n", encoding="utf-8")
(tmp_path / "bad.py").write_bytes(b"\xff\xff")
config.WORKSPACE_ROOT = tmp_path
out = filesystem.collect_python_sources()
assert out["code/ok.py"] == "a = 1\n"
assert "bad.py" not in out

View File

@@ -0,0 +1,48 @@
"""Selenium smoke tests — need a running app (see README)."""
from __future__ import annotations
import os
import urllib.error
import urllib.request
import pytest
pytest.importorskip("selenium.webdriver")
def _server_reachable(base_url: str, timeout: float = 2.0) -> bool:
try:
urllib.request.urlopen(f"{base_url.rstrip('/')}/", timeout=timeout)
return True
except (urllib.error.URLError, OSError):
return False
@pytest.fixture
def driver():
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
opts = Options()
opts.add_argument("--headless=new")
opts.add_argument("--no-sandbox")
opts.add_argument("--disable-dev-shm-usage")
try:
browser = webdriver.Chrome(options=opts)
except Exception as exc: # pragma: no cover
pytest.skip(f"Chrome / ChromeDriver not available: {exc}")
yield browser
browser.quit()
@pytest.mark.selenium
def test_home_page_title(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}/")
assert "Python Editor" in driver.title

3
workspace/code/main.py Normal file
View File

@@ -0,0 +1,3 @@
"""Sample script — runs in the browser under Pyodide."""
print("Hello from Pyodide")

5
workspace/lib/helpers.py Normal file
View File

@@ -0,0 +1,5 @@
"""Shared helpers (read-only on server; copied into Pyodide when you run)."""
def greet(name: str) -> str:
return f"Hello, {name}!"