Add initial web editor app, CLI scripts, and test scaffolding.

This introduces the FastAPI editor implementation and related project setup so the app can be run and validated locally.

Made-with: Cursor
This commit is contained in:
2026-04-11 02:14:26 +12:00
parent fb5f55cda7
commit f9bf119af6
33 changed files with 4846 additions and 0 deletions

View File

@@ -0,0 +1 @@
code/demo_prompt_args.py

26
Pipfile Normal file
View File

@@ -0,0 +1,26 @@
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[dev-packages]
pytest = "*"
pytest-cov = "*"
httpx = "*"
pylint = "*"
jedi = "*"
[packages]
fastapi = "*"
uvicorn = "*"
websockets = "*"
pillow = "*"
startspimage = "*"
[requires]
python_version = "3.11"
[scripts]
dev = "uvicorn app:app --app-dir src --reload --port 8080"
test = "bash -lc 'cd src && PYTHONPATH=. pytest ../tests'"
test-cov = "bash -lc 'cd src && PYTHONPATH=. pytest ../tests --cov=editor_app --cov=app --cov-report=term-missing --cov-fail-under=95'"

708
Pipfile.lock generated Normal file
View File

@@ -0,0 +1,708 @@
{
"_meta": {
"hash": {
"sha256": "2eb83d592adec12e8260d182f8391fa19a3983b5bb75be542182cd5036ee5a86"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3.11"
},
"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"
},
"click": {
"hashes": [
"sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5",
"sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d"
],
"markers": "python_version >= '3.10'",
"version": "==8.3.2"
},
"fastapi": {
"hashes": [
"sha256:9b0f590c813acd13d0ab43dd8494138eb58e484bfac405db1f3187cfc5810d98",
"sha256:bd6d7caf1a2bdd8d676843cdcd2287729572a1ef524fc4d65c17ae002a1be654"
],
"index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==0.135.3"
},
"h11": {
"hashes": [
"sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1",
"sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"
],
"markers": "python_version >= '3.8'",
"version": "==0.16.0"
},
"idna": {
"hashes": [
"sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea",
"sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"
],
"markers": "python_version >= '3.8'",
"version": "==3.11"
},
"pillow": {
"hashes": [
"sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9",
"sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5",
"sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987",
"sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9",
"sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b",
"sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f",
"sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd",
"sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e",
"sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e",
"sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe",
"sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795",
"sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601",
"sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1",
"sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed",
"sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea",
"sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5",
"sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97",
"sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453",
"sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98",
"sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa",
"sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b",
"sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d",
"sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705",
"sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8",
"sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024",
"sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0",
"sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286",
"sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150",
"sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2",
"sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3",
"sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b",
"sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f",
"sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463",
"sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940",
"sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166",
"sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed",
"sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f",
"sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795",
"sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780",
"sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7",
"sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1",
"sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5",
"sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295",
"sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b",
"sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354",
"sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60",
"sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65",
"sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005",
"sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c",
"sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be",
"sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5",
"sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06",
"sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae",
"sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c",
"sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c",
"sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612",
"sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e",
"sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab",
"sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808",
"sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f",
"sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e",
"sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909",
"sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec",
"sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe",
"sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50",
"sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4",
"sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f",
"sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff",
"sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5",
"sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb",
"sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414",
"sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1",
"sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032",
"sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76",
"sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136",
"sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e",
"sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c",
"sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3",
"sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea",
"sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f",
"sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104",
"sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176",
"sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24",
"sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3",
"sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4",
"sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed",
"sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43",
"sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421",
"sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7",
"sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06",
"sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5"
],
"index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==12.2.0"
},
"pydantic": {
"hashes": [
"sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49",
"sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"
],
"markers": "python_version >= '3.9'",
"version": "==2.12.5"
},
"pydantic-core": {
"hashes": [
"sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90",
"sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740",
"sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504",
"sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84",
"sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33",
"sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c",
"sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0",
"sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e",
"sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0",
"sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a",
"sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34",
"sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2",
"sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3",
"sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815",
"sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14",
"sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba",
"sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375",
"sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf",
"sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963",
"sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1",
"sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808",
"sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553",
"sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1",
"sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2",
"sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5",
"sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470",
"sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2",
"sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b",
"sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660",
"sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c",
"sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093",
"sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5",
"sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594",
"sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008",
"sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a",
"sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a",
"sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd",
"sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284",
"sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586",
"sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869",
"sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294",
"sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f",
"sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66",
"sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51",
"sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc",
"sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97",
"sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a",
"sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d",
"sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9",
"sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c",
"sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07",
"sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36",
"sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e",
"sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05",
"sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e",
"sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941",
"sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3",
"sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612",
"sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3",
"sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b",
"sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe",
"sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146",
"sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11",
"sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60",
"sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd",
"sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b",
"sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c",
"sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a",
"sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460",
"sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1",
"sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf",
"sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf",
"sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858",
"sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2",
"sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9",
"sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2",
"sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3",
"sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6",
"sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770",
"sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d",
"sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc",
"sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23",
"sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26",
"sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa",
"sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8",
"sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d",
"sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3",
"sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d",
"sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034",
"sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9",
"sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1",
"sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56",
"sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b",
"sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c",
"sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a",
"sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e",
"sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9",
"sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5",
"sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a",
"sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556",
"sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e",
"sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49",
"sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2",
"sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9",
"sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b",
"sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc",
"sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb",
"sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0",
"sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8",
"sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82",
"sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69",
"sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b",
"sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c",
"sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75",
"sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5",
"sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f",
"sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad",
"sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b",
"sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7",
"sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425",
"sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52"
],
"markers": "python_version >= '3.9'",
"version": "==2.41.5"
},
"starlette": {
"hashes": [
"sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149",
"sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b"
],
"markers": "python_version >= '3.10'",
"version": "==1.0.0"
},
"startspimage": {
"hashes": [
"sha256:4f726cc244baad240dcdac4ec387211d46a2d67abe6ce60e366d87b2915acd89"
],
"index": "pypi",
"version": "==0.2.6"
},
"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:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e",
"sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89"
],
"index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==0.44.0"
},
"websockets": {
"hashes": [
"sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c",
"sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a",
"sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe",
"sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e",
"sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec",
"sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1",
"sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64",
"sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3",
"sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8",
"sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206",
"sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3",
"sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156",
"sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d",
"sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9",
"sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad",
"sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2",
"sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03",
"sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8",
"sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230",
"sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8",
"sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea",
"sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641",
"sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957",
"sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6",
"sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6",
"sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5",
"sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f",
"sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00",
"sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e",
"sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b",
"sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72",
"sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39",
"sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9",
"sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79",
"sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0",
"sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac",
"sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35",
"sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0",
"sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5",
"sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c",
"sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8",
"sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1",
"sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244",
"sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3",
"sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767",
"sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a",
"sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d",
"sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd",
"sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e",
"sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944",
"sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82",
"sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d",
"sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4",
"sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5",
"sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904",
"sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde",
"sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f",
"sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c",
"sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89",
"sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da",
"sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4"
],
"index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==16.0"
}
},
"develop": {
"anyio": {
"hashes": [
"sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708",
"sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc"
],
"markers": "python_version >= '3.10'",
"version": "==4.13.0"
},
"astroid": {
"hashes": [
"sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753",
"sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0"
],
"markers": "python_full_version >= '3.10.0'",
"version": "==4.0.4"
},
"certifi": {
"hashes": [
"sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa",
"sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"
],
"markers": "python_version >= '3.7'",
"version": "==2026.2.25"
},
"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"
},
"dill": {
"hashes": [
"sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d",
"sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa"
],
"markers": "python_version >= '3.11'",
"version": "==0.4.1"
},
"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:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea",
"sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"
],
"markers": "python_version >= '3.8'",
"version": "==3.11"
},
"iniconfig": {
"hashes": [
"sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730",
"sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"
],
"markers": "python_version >= '3.10'",
"version": "==2.3.0"
},
"isort": {
"hashes": [
"sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d",
"sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75"
],
"markers": "python_full_version >= '3.10.0'",
"version": "==8.0.1"
},
"jedi": {
"hashes": [
"sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0",
"sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9"
],
"index": "pypi",
"markers": "python_version >= '3.6'",
"version": "==0.19.2"
},
"mccabe": {
"hashes": [
"sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325",
"sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"
],
"markers": "python_version >= '3.6'",
"version": "==0.7.0"
},
"packaging": {
"hashes": [
"sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4",
"sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"
],
"markers": "python_version >= '3.8'",
"version": "==26.0"
},
"parso": {
"hashes": [
"sha256:2b9a0332696df97d454fa67b81618fd69c35a7b90327cbe6ba5c92d2c68a7bfd",
"sha256:2c549f800b70a5c4952197248825584cb00f033b29c692671d3bf08bf380baff"
],
"markers": "python_version >= '3.6'",
"version": "==0.8.6"
},
"platformdirs": {
"hashes": [
"sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a",
"sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917"
],
"markers": "python_version >= '3.10'",
"version": "==4.9.6"
},
"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"
},
"pylint": {
"hashes": [
"sha256:00f51c9b14a3b3ae08cff6b2cdd43f28165c78b165b628692e428fb1f8dc2cf2",
"sha256:8cd6a618df75deb013bd7eb98327a95f02a6fb839205a6bbf5456ef96afb317c"
],
"index": "pypi",
"markers": "python_full_version >= '3.10.0'",
"version": "==4.0.5"
},
"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"
},
"tomlkit": {
"hashes": [
"sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680",
"sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064"
],
"markers": "python_version >= '3.9'",
"version": "==0.14.0"
}
}
}

View File

@@ -1,2 +1,16 @@
# connectionmachine
export NODE_OPTIONS=--max-old-space-size=800
## Web text editor
This repo includes a FastAPI-based web editor in `editor/` for editing text files.
Run it from the project root:
```bash
pipenv run start
```
Then open [http://localhost:8000](http://localhost:8000).

38
code/demo_prompt_args.py Normal file
View File

@@ -0,0 +1,38 @@
from pathlib import Path
import sys
def main() -> int:
workspace_root = Path(__file__).resolve().parents[1]
prompts_root = workspace_root / "prompts"
if len(sys.argv) < 2:
print("Usage: demo_prompt_args.py <prompt-folder>")
print("Example: demo_prompt_args.py test")
return 1
prompt_folder = sys.argv[1].strip().strip("/")
if not prompt_folder:
print("Error: prompt folder arg is empty.")
return 1
target_dir = prompts_root / prompt_folder
if not target_dir.exists() or not target_dir.is_dir():
print(f"Error: prompts folder not found: {target_dir}")
return 1
txt_files = sorted(target_dir.glob("*.txt"))
if not txt_files:
print(f"No .txt files found in prompts/{prompt_folder}")
return 0
print(f"Reading {len(txt_files)} prompt file(s) from prompts/{prompt_folder}")
for txt_file in txt_files:
print(f"\n--- {txt_file.name} ---")
print(txt_file.read_text(encoding="utf-8"))
return 0
if __name__ == "__main__":
raise SystemExit(main())

7
code/main.py Normal file
View File

@@ -0,0 +1,7 @@
from time import sleep
i = 0
while True:
print(f"Hello {i}")
i += 1
sleep(1)

3
code/test.py Normal file
View File

@@ -0,0 +1,3 @@
from printer import printer
printer("hello")

57
lib/helpers.py Normal file
View File

@@ -0,0 +1,57 @@
from __future__ import annotations
from datetime import datetime, timezone
import json
from pathlib import Path
from typing import Any
def utc_now_iso() -> str:
"""Return current UTC time in ISO-8601 format."""
return datetime.now(timezone.utc).isoformat()
def log(message: str) -> None:
"""Print a timestamped log line."""
print(f"[{utc_now_iso()}] {message}")
def get_workspace_root(current_file: str | Path) -> Path:
"""Infer workspace root from a script path inside code/."""
current = Path(current_file).resolve()
return current.parents[1]
def get_prompt_dir(workspace_root: Path, prompt_folder: str) -> Path:
"""Resolve prompts/<prompt_folder> directory safely."""
folder = prompt_folder.strip().strip("/")
if not folder:
raise ValueError("Prompt folder argument is empty")
target = (workspace_root / "prompts" / folder).resolve()
prompts_root = (workspace_root / "prompts").resolve()
if prompts_root not in target.parents and target != prompts_root:
raise ValueError("Prompt folder escapes prompts root")
if not target.exists() or not target.is_dir():
raise FileNotFoundError(f"Prompt folder not found: {target}")
return target
def read_prompt_texts(prompt_dir: Path) -> dict[str, str]:
"""Read all .txt files from prompt directory."""
result: dict[str, str] = {}
for txt_file in sorted(prompt_dir.glob("*.txt")):
result[txt_file.name] = txt_file.read_text(encoding="utf-8")
return result
def read_json(path: Path, default: Any = None) -> Any:
"""Read JSON file; return default when missing."""
if not path.exists():
return default
return json.loads(path.read_text(encoding="utf-8"))
def write_json(path: Path, data: Any) -> None:
"""Write JSON with stable formatting."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, indent=2, sort_keys=True), encoding="utf-8")

2
lib/printer.py Normal file
View File

@@ -0,0 +1,2 @@
def printer(text):
print(text)

1
prompts/test/test.txt Normal file
View File

@@ -0,0 +1 @@
hello

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

@@ -0,0 +1,2 @@
from .main import app

Binary file not shown.

Binary file not shown.

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

@@ -0,0 +1,30 @@
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
STATIC_DIR = BASE_DIR / "static"
def load_env_file(env_path: Path) -> None:
"""Load KEY=VALUE entries from a local .env file."""
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(BASE_DIR / ".env")
WORKSPACE_ROOT = Path(
os.getenv("WORKSPACE_ROOT", "/home/jimmy/projects/connectionmachine")
)

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

@@ -0,0 +1,28 @@
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from editor_app.config import STATIC_DIR, WORKSPACE_ROOT
from editor_app.routers.files import router as files_router
from editor_app.routers.frontend import router as frontend_router
from editor_app.routers.python_exec import router as python_router
def create_app() -> FastAPI:
app = FastAPI()
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
app.include_router(frontend_router)
app.include_router(files_router)
app.include_router(python_router)
@app.on_event("startup")
async def run_configured_startup_script() -> None:
from editor_app.services import python_runner
(WORKSPACE_ROOT / "lib").mkdir(parents=True, exist_ok=True)
python_runner.run_startup_script_if_configured()
return app
app = create_app()

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

@@ -0,0 +1,38 @@
from pydantic import BaseModel
from typing import Optional
class FileContent(BaseModel):
content: str
class FileInfo(BaseModel):
name: str
is_directory: bool
size: Optional[int] = None
class FolderOperation(BaseModel):
path: str
class RunPythonRequest(BaseModel):
file_path: str
args: list[str] = []
class CompletionRequest(BaseModel):
file_path: str
content: str
line: int
column: int
max_results: int = 20
class MoveFileRequest(BaseModel):
source_path: str
destination_folder: str = ""
class StartupScriptRequest(BaseModel):
file_path: str

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,52 @@
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("/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,17 @@
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")

View File

@@ -0,0 +1,120 @@
import asyncio
from fastapi import APIRouter, HTTPException, WebSocket
from editor_app import config
from editor_app.models import CompletionRequest, RunPythonRequest, StartupScriptRequest
from editor_app.services import filesystem, python_runner
router = APIRouter(prefix="/api/python")
@router.post("/run")
async def run_python_file(run_request: RunPythonRequest):
target_path = filesystem.resolve_workspace_path(run_request.file_path)
if not target_path.exists() or not target_path.is_file():
raise HTTPException(status_code=404, detail="Python file not found")
if target_path.suffix.lower() != ".py":
raise HTTPException(status_code=400, detail="Only .py files can be run")
python_runner.run_python_file(target_path, run_request.file_path, run_request.args)
return {"message": "Python process started"}
@router.get("/output")
async def get_python_output(offset: int = 0):
return python_runner.get_output(offset)
@router.post("/stop")
async def stop_python_process():
message = python_runner.stop_python_process()
return {"message": message}
@router.post("/completions")
async def get_python_completions(completion_request: CompletionRequest):
try:
target_path = filesystem.resolve_workspace_path(completion_request.file_path)
max_results = max(1, min(completion_request.max_results, 100))
try:
import jedi
except ImportError as exc:
raise HTTPException(status_code=500, detail="jedi is not installed") from exc
script = jedi.Script(
code=completion_request.content,
path=str(target_path),
project=jedi.Project(path=str(config.WORKSPACE_ROOT)),
)
completions = script.complete(
line=completion_request.line,
column=completion_request.column,
)
return {
"completions": [
{"name": item.name, "type": item.type, "complete": item.complete}
for item in completions[:max_results]
]
}
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Completion failed: {exc}") from exc
@router.websocket("/ws/output")
async def websocket_python_output(websocket: WebSocket):
await websocket.accept()
offset = 0
last_running = None
last_return_code = None
while True:
data = python_runner.get_output(offset)
if data["lines"]:
await websocket.send_json(
{
"lines": data["lines"],
"next_offset": data["next_offset"],
"running": data["running"],
"return_code": data["return_code"],
}
)
offset = data["next_offset"]
last_running = data["running"]
last_return_code = data["return_code"]
# Yield between pushes to avoid overwhelming browser UI threads.
await asyncio.sleep(0.03)
else:
if data["running"] != last_running or data["return_code"] != last_return_code:
await websocket.send_json(
{
"lines": [],
"next_offset": offset,
"running": data["running"],
"return_code": data["return_code"],
}
)
last_running = data["running"]
last_return_code = data["return_code"]
# Keep the websocket active and low-latency.
await asyncio.sleep(0.2)
@router.get("/scripts")
async def list_python_scripts():
return {"scripts": python_runner.list_python_scripts()}
@router.get("/startup-script")
async def get_startup_script():
return {"file_path": python_runner.get_startup_script()}
@router.post("/startup-script")
async def set_startup_script(startup_request: StartupScriptRequest):
file_path = python_runner.set_startup_script(startup_request.file_path)
return {"message": "Startup script saved", "file_path": file_path}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,182 @@
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", "prompts"}
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] in {"code", "prompts"}:
while len(parts) >= 2 and parts[0] == parts[1]:
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 and prompts are 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)

View File

@@ -0,0 +1,172 @@
import os
import subprocess
import sys
import threading
from typing import Optional
from fastapi import HTTPException
from editor_app import config
from editor_app.services import filesystem
class PythonRunnerState:
def __init__(self) -> None:
self.lock = threading.Lock()
self.process: Optional[subprocess.Popen] = None
self.output_lines: list[str] = []
self.output_base_offset = 0
self.return_code: Optional[int] = None
self.running = False
python_runner = PythonRunnerState()
STARTUP_SCRIPT_FILE = config.WORKSPACE_ROOT / ".connectionmachine_startup_script"
MAX_OUTPUT_LINES = 5000
def _append_output_line(line: str) -> None:
python_runner.output_lines.append(line)
if len(python_runner.output_lines) > MAX_OUTPUT_LINES:
overflow = len(python_runner.output_lines) - MAX_OUTPUT_LINES
if overflow > 0:
del python_runner.output_lines[:overflow]
python_runner.output_base_offset += overflow
def stream_process_output(process: subprocess.Popen) -> None:
if process.stdout is None:
return
for line in process.stdout:
with python_runner.lock:
_append_output_line(line)
def wait_for_process(process: subprocess.Popen) -> None:
return_code = process.wait()
with python_runner.lock:
python_runner.return_code = return_code
python_runner.running = False
python_runner.process = None
def run_python_file(target_path, requested_path: str, args: Optional[list[str]] = None) -> None:
run_args = [str(arg) for arg in (args or [])]
with python_runner.lock:
if python_runner.running:
raise HTTPException(status_code=409, detail="A Python process is already running")
try:
run_env = os.environ.copy()
run_env["PYTHONUNBUFFERED"] = "1"
lib_path = str((config.WORKSPACE_ROOT / "lib").resolve())
existing_pythonpath = run_env.get("PYTHONPATH", "")
if existing_pythonpath:
run_env["PYTHONPATH"] = f"{lib_path}:{existing_pythonpath}"
else:
run_env["PYTHONPATH"] = lib_path
process = subprocess.Popen(
[sys.executable, "-u", str(target_path), *run_args],
cwd=str(config.WORKSPACE_ROOT),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
env=run_env,
)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to start process: {exc}") from exc
python_runner.process = process
command_parts = [sys.executable, requested_path, *run_args]
python_runner.output_lines = [f"$ {' '.join(command_parts)}\n"]
python_runner.output_base_offset = 0
python_runner.return_code = None
python_runner.running = True
threading.Thread(target=stream_process_output, args=(process,), daemon=True).start()
threading.Thread(target=wait_for_process, args=(process,), daemon=True).start()
def get_output(offset: int = 0) -> dict:
with python_runner.lock:
safe_offset = max(python_runner.output_base_offset, offset)
relative_index = max(0, safe_offset - python_runner.output_base_offset)
lines = python_runner.output_lines[relative_index:]
return {
"lines": lines,
"next_offset": safe_offset + len(lines),
"running": python_runner.running,
"return_code": python_runner.return_code,
}
def stop_python_process() -> str:
with python_runner.lock:
process = python_runner.process
if not python_runner.running or process is None:
return "No running process"
process.terminate()
try:
process.wait(timeout=2)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=2)
with python_runner.lock:
_append_output_line("\n[Process stopped]\n")
python_runner.running = False
python_runner.return_code = process.returncode
python_runner.process = None
return "Python process stopped"
def list_python_scripts() -> list[str]:
workspace = config.WORKSPACE_ROOT
scripts: list[str] = []
for path in workspace.rglob("*.py"):
rel = path.relative_to(workspace)
# Skip hidden paths.
if any(part.startswith(".") for part in rel.parts):
continue
scripts.append(str(rel))
scripts.sort()
return scripts
def set_startup_script(file_path: str) -> str:
target = filesystem.resolve_workspace_path(file_path)
if not target.exists() or not target.is_file():
raise HTTPException(status_code=404, detail="Python file not found")
if target.suffix.lower() != ".py":
raise HTTPException(status_code=400, detail="Only .py files can be selected")
relative_path = str(target.relative_to(config.WORKSPACE_ROOT))
STARTUP_SCRIPT_FILE.write_text(relative_path, encoding="utf-8")
return relative_path
def get_startup_script() -> Optional[str]:
if not STARTUP_SCRIPT_FILE.exists():
return None
value = STARTUP_SCRIPT_FILE.read_text(encoding="utf-8").strip()
if not value:
return None
return value
def run_startup_script_if_configured() -> None:
startup_script = get_startup_script()
if not startup_script:
return
try:
target_path = filesystem.resolve_workspace_path(startup_script)
if not target_path.exists() or not target_path.is_file():
return
if target_path.suffix.lower() != ".py":
return
run_python_file(target_path, startup_script)
except HTTPException:
return

File diff suppressed because one or more lines are too long

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

@@ -0,0 +1,345 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Connection Machine</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 1.25rem 0;
color: #cbd5e1;
line-height: 1.5;
}
.actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.btn {
display: inline-block;
text-decoration: none;
border-radius: 8px;
padding: 0.65rem 1rem;
font-weight: 600;
border: 1px solid transparent;
}
.btn-primary {
background: #3b82f6;
color: #ffffff;
}
.btn-secondary {
background: transparent;
border-color: #64748b;
color: #e2e8f0;
}
.runner {
border-top: 1px solid rgba(148, 163, 184, 0.25);
padding-top: 1rem;
display: grid;
gap: 0.6rem;
}
.runner h2 {
font-size: 1rem;
margin: 0;
color: #f8fafc;
}
.runner-controls {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
}
.runner select {
flex: 1;
min-width: 220px;
background: #0f172a;
color: #e2e8f0;
border: 1px solid #64748b;
border-radius: 8px;
padding: 0.5rem;
}
.runner-log {
min-height: 140px;
max-height: 220px;
overflow: auto;
background: #020617;
border: 1px solid #334155;
border-radius: 8px;
padding: 0.6rem;
font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
font-size: 12px;
white-space: pre-wrap;
color: #cbd5e1;
margin: 0;
}
.status {
font-size: 0.85rem;
color: #cbd5e1;
}
</style>
</head>
<body>
<main class="home-card">
<h1>Connection Machine</h1>
<p>Open the editor for the <code>code</code> workspace.</p>
<div class="actions">
<a class="btn btn-primary" href="/editor">Open Editor</a>
</div>
<section class="runner">
<h2>Startup Script</h2>
<div class="runner-controls">
<select id="startup-script-select"></select>
<button id="save-startup-btn" class="btn btn-secondary" type="button">Set Startup</button>
</div>
<div class="runner-controls">
<select id="script-args-select"></select>
</div>
<div class="runner-controls">
<button id="start-script-btn" class="btn btn-primary" type="button">Start</button>
<button id="stop-script-btn" class="btn btn-secondary" type="button">Stop</button>
<span id="runner-status" class="status"></span>
</div>
<pre id="runner-log" class="runner-log"></pre>
</section>
</main>
<script>
const selectEl = document.getElementById("startup-script-select");
const saveBtn = document.getElementById("save-startup-btn");
const startBtn = document.getElementById("start-script-btn");
const stopBtn = document.getElementById("stop-script-btn");
const argsSelectEl = document.getElementById("script-args-select");
const logEl = document.getElementById("runner-log");
const statusEl = document.getElementById("runner-status");
const homeStateKey = "connectionmachine.home.state.v1";
let outputOffset = 0;
let logSocket = null;
let pendingLogText = "";
let logFlushTimer = null;
function loadHomeState() {
try {
const raw = localStorage.getItem(homeStateKey);
return raw ? JSON.parse(raw) : {};
} catch (_error) {
return {};
}
}
function saveHomeState() {
try {
const state = {
selectedScript: selectEl.value || "",
selectedArg: argsSelectEl.value || "",
};
localStorage.setItem(homeStateKey, JSON.stringify(state));
} catch (_error) {
// Ignore storage failures.
}
}
function setStatus(message) {
statusEl.textContent = message;
}
function addLog(lines) {
if (!Array.isArray(lines) || lines.length === 0) return;
pendingLogText += lines.join("");
if (logFlushTimer) return;
logFlushTimer = setTimeout(() => {
if (pendingLogText) {
logEl.textContent += pendingLogText;
pendingLogText = "";
}
const maxChars = 200000;
if (logEl.textContent.length > maxChars) {
logEl.textContent = logEl.textContent.slice(-maxChars);
}
logEl.scrollTop = logEl.scrollHeight;
logFlushTimer = null;
}, 50);
}
async function refreshScripts() {
const scriptsResp = await fetch("/api/python/scripts");
const scriptsData = await scriptsResp.json();
const startupResp = await fetch("/api/python/startup-script");
const startupData = await startupResp.json();
const scripts = (scriptsData.scripts || []).filter((path) => path.startsWith("code/"));
const selected = startupData.file_path || "";
selectEl.innerHTML = "";
if (scripts.length === 0) {
const opt = document.createElement("option");
opt.value = "";
opt.textContent = "No Python scripts found in code/";
selectEl.appendChild(opt);
selectEl.disabled = true;
saveBtn.disabled = true;
startBtn.disabled = true;
return;
}
scripts.forEach((scriptPath) => {
const opt = document.createElement("option");
opt.value = scriptPath;
opt.textContent = scriptPath.slice("code/".length);
if (scriptPath === selected) {
opt.selected = true;
}
selectEl.appendChild(opt);
});
const state = loadHomeState();
if (state.selectedScript && scripts.includes(state.selectedScript)) {
selectEl.value = state.selectedScript;
}
selectEl.disabled = false;
saveBtn.disabled = false;
startBtn.disabled = false;
}
async function refreshArgsOptions() {
const resp = await fetch("/api/files?path=prompts");
const data = await resp.json();
const folders = (data.files || []).filter((item) => item.is_directory).map((item) => item.name);
argsSelectEl.innerHTML = "";
const none = document.createElement("option");
none.value = "";
none.textContent = "No script args";
argsSelectEl.appendChild(none);
folders.forEach((folderName) => {
const option = document.createElement("option");
option.value = folderName;
option.textContent = `Prompt folder: ${folderName}`;
argsSelectEl.appendChild(option);
});
const state = loadHomeState();
if (typeof state.selectedArg === "string") {
argsSelectEl.value = state.selectedArg;
}
}
async function saveStartupScript() {
const filePath = selectEl.value;
const resp = await fetch("/api/python/startup-script", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ file_path: filePath }),
});
if (!resp.ok) {
setStatus("Failed to save startup script");
return;
}
setStatus("Startup script saved");
}
async function startScript() {
const filePath = selectEl.value;
const selectedArg = argsSelectEl.value.trim();
const args = selectedArg ? [selectedArg] : [];
const resp = await fetch("/api/python/run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ file_path: filePath, args }),
});
if (!resp.ok) {
setStatus("Failed to start script");
return;
}
outputOffset = 0;
logEl.textContent = "";
setStatus("Script running");
startLogStream();
}
async function stopScript() {
const resp = await fetch("/api/python/stop", { method: "POST" });
if (!resp.ok) {
setStatus("Failed to stop script");
return;
}
setStatus("Script stopped");
stopLogStream();
startLogStream();
}
function startLogStream() {
stopLogStream();
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
logSocket = new WebSocket(`${protocol}://${window.location.host}/api/python/ws/output`);
logSocket.onmessage = (event) => {
const data = JSON.parse(event.data);
addLog(data.lines || []);
outputOffset = data.next_offset ?? outputOffset;
if (!data.running) {
if (data.return_code !== null && data.return_code !== undefined) {
addLog([`\n[Process exited with code ${data.return_code}]\n`]);
}
setStatus("Script idle");
}
};
logSocket.onerror = () => {
setStatus("Log stream connection error");
};
}
function stopLogStream() {
if (logSocket) {
logSocket.close();
logSocket = null;
}
}
saveBtn.addEventListener("click", saveStartupScript);
startBtn.addEventListener("click", startScript);
stopBtn.addEventListener("click", stopScript);
selectEl.addEventListener("change", saveHomeState);
argsSelectEl.addEventListener("change", saveHomeState);
window.addEventListener("beforeunload", saveHomeState);
window.addEventListener("pagehide", saveHomeState);
Promise.all([refreshScripts(), refreshArgsOptions()]).then(() => {
setStatus("Ready");
startLogStream();
});
</script>
</body>
</html>

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

@@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Connection Machine Editor</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="/static/styles.css?v=5">
</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>
</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>
<select id="run-arg-select" title="Prompt folder argument">
<option value="">No prompt arg</option>
</select>
</div>
</div>
<div id="tabs" class="tabs"></div>
<div class="editor-container">
<div id="editor"></div>
<div id="prompt-panel" class="prompt-panel hidden">
<label for="prompt-input">Prompt</label>
<textarea id="prompt-input" placeholder="Write your prompt here..."></textarea>
</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>
<!-- New File Modal -->
<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=7"></script>
</body>
</html>

1664
src/static/script.js Normal file

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,500 @@
* {
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;
}
#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-arg-select {
min-width: 220px;
padding: 0.5rem 0.65rem;
border: 1px solid #cbd5e0;
border-radius: 6px;
font-size: 0.85rem;
background: white;
}
#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;
}
.prompt-panel {
height: 100%;
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
}
.prompt-panel label {
font-size: 0.9rem;
color: #4a5568;
font-weight: 600;
}
.prompt-panel textarea {
flex: 1;
resize: none;
border: 1px solid #cbd5e0;
border-radius: 8px;
padding: 0.75rem;
font-size: 14px;
line-height: 1.4;
font-family: inherit;
}
.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;
}

373
tests/test_api.py Normal file
View File

@@ -0,0 +1,373 @@
import importlib
import time
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def client(tmp_path):
import editor_app.config as config
import editor_app.main as main
import editor_app.services.python_runner as runner
config.WORKSPACE_ROOT = tmp_path
importlib.reload(main)
# Reset runner state to avoid cross-test contamination.
with runner.python_runner.lock:
runner.python_runner.process = None
runner.python_runner.output_lines = []
runner.python_runner.return_code = None
runner.python_runner.running = False
return TestClient(main.app)
def test_root_serves_html(client):
response = client.get("/")
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
assert "Connection Machine" 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 "Connection Machine 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()
(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_and_prompts_are_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
allowed_prompt = client.post("/api/file/prompts/a.txt", json={"content": "ok"})
assert allowed_prompt.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):
create = client.post("/api/folder/new/prompts/prompts/drafts", json={"path": "ignored"})
assert create.status_code == 200
assert (tmp_path / "prompts" / "drafts").is_dir()
assert not (tmp_path / "prompts" / "prompts").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_python_run_output_and_stop(client, tmp_path):
script = tmp_path / "demo.py"
script.write_text(
"import time\nprint('hello')\ntime.sleep(0.3)\nprint('bye')\n",
encoding="utf-8",
)
run = client.post("/api/python/run", json={"file_path": "demo.py"})
assert run.status_code == 200
# Eventually process is running and output starts arriving.
output = {"running": True, "lines": []}
for _ in range(30):
output = client.get("/api/python/output", params={"offset": 0}).json()
if output["lines"]:
break
time.sleep(0.05)
assert output["lines"]
assert output["lines"][0].startswith("$ ")
stop = client.post("/api/python/stop")
assert stop.status_code == 200
final = client.get("/api/python/output", params={"offset": 0}).json()
assert final["running"] is False
def test_python_run_accepts_args_and_logs_command(client, tmp_path):
script = tmp_path / "demo_args.py"
script.write_text("import sys\nprint('args=' + '|'.join(sys.argv[1:]))\n", encoding="utf-8")
run = client.post("/api/python/run", json={"file_path": "demo_args.py", "args": ["child-a"]})
assert run.status_code == 200
output = {"running": True, "lines": []}
for _ in range(30):
output = client.get("/api/python/output", params={"offset": 0}).json()
joined = "".join(output.get("lines", []))
if "args=child-a" in joined:
break
time.sleep(0.05)
joined = "".join(output.get("lines", []))
assert "demo_args.py child-a" in joined
assert "args=child-a" in joined
client.post("/api/python/stop")
def test_python_run_rejects_missing_and_non_python(client, tmp_path):
missing = client.post("/api/python/run", json={"file_path": "missing.py"})
assert missing.status_code == 404
(tmp_path / "note.txt").write_text("hello", encoding="utf-8")
wrong_ext = client.post("/api/python/run", json={"file_path": "note.txt"})
assert wrong_ext.status_code == 400
def test_python_run_rejects_when_already_running(client, tmp_path):
script = tmp_path / "long.py"
script.write_text("import time\ntime.sleep(1.0)\n", encoding="utf-8")
first = client.post("/api/python/run", json={"file_path": "long.py"})
assert first.status_code == 200
second = client.post("/api/python/run", json={"file_path": "long.py"})
assert second.status_code == 409
client.post("/api/python/stop")
def test_python_stop_when_nothing_running(client):
response = client.post("/api/python/stop")
assert response.status_code == 200
assert response.json()["message"] == "No running process"
def test_python_completions_returns_results(client):
response = client.post(
"/api/python/completions",
json={
"file_path": "scratch.py",
"content": "import os\nos.pa",
"line": 2,
"column": 5,
"max_results": 10,
},
)
assert response.status_code == 200
names = {item["name"] for item in response.json()["completions"]}
assert "path" in names
def test_python_completions_bad_position_returns_400(client):
response = client.post(
"/api/python/completions",
json={
"file_path": "scratch.py",
"content": "x = 1",
"line": 99,
"column": 1,
"max_results": 10,
},
)
assert response.status_code == 400
def test_python_scripts_list_and_startup_selection(client, tmp_path):
(tmp_path / "code").mkdir()
(tmp_path / "code" / "job.py").write_text("print('ok')\n", encoding="utf-8")
scripts = client.get("/api/python/scripts")
assert scripts.status_code == 200
assert "code/job.py" in scripts.json()["scripts"]
set_startup = client.post(
"/api/python/startup-script",
json={"file_path": "code/job.py"},
)
assert set_startup.status_code == 200
startup = client.get("/api/python/startup-script")
assert startup.status_code == 200
assert startup.json()["file_path"] == "code/job.py"

91
tests/test_browser.py Normal file
View File

@@ -0,0 +1,91 @@
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))
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 / "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):
import editor_app.config as config
import editor_app.main as main
from fastapi.testclient import TestClient
config.WORKSPACE_ROOT = tmp_path
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()

View File

@@ -0,0 +1,36 @@
import importlib
import os
import sys
from pathlib import Path
from fastapi.testclient import TestClient
def test_python_completion_returns_basic_suggestions(tmp_path):
editor_dir = Path(__file__).resolve().parents[1]
src_dir = editor_dir / "src"
if str(src_dir) not in sys.path:
sys.path.insert(0, str(src_dir))
os.environ["WORKSPACE_ROOT"] = str(tmp_path)
import app as editor_app
editor_app = importlib.reload(editor_app)
editor_app.WORKSPACE_ROOT = tmp_path
client = TestClient(editor_app.app)
response = client.post(
"/api/python/completions",
json={
"file_path": "example.py",
"content": "import os\nos.pa",
"line": 2,
"column": 5,
"max_results": 20,
},
)
assert response.status_code == 200
body = response.json()
names = [item["name"] for item in body["completions"]]
assert "path" in names

237
tests/test_internal.py Normal file
View File

@@ -0,0 +1,237 @@
import builtins
import importlib
import subprocess
from pathlib import Path
import pytest
from fastapi import HTTPException
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")
class _ProcNoStdout:
stdout = None
class _ProcWithLines:
def __init__(self):
self.stdout = iter(["line1\n", "line2\n"])
class _ProcWait:
def __init__(self, code=0):
self.code = code
def wait(self, timeout=None):
return self.code
class _ProcStop:
def __init__(self):
self.returncode = 0
self.terminated = False
self.killed = False
self.wait_calls = 0
def terminate(self):
self.terminated = True
def wait(self, timeout=None):
self.wait_calls += 1
if self.wait_calls == 1:
raise subprocess.TimeoutExpired("cmd", timeout)
return self.returncode
def kill(self):
self.killed = True
def test_python_runner_stream_helpers_and_wait():
import editor_app.services.python_runner as runner
with runner.python_runner.lock:
runner.python_runner.output_lines = []
runner.stream_process_output(_ProcNoStdout())
runner.stream_process_output(_ProcWithLines())
with runner.python_runner.lock:
assert runner.python_runner.output_lines[-2:] == ["line1\n", "line2\n"]
proc = _ProcWait(code=7)
runner.wait_for_process(proc)
with runner.python_runner.lock:
assert runner.python_runner.return_code == 7
assert runner.python_runner.running is False
assert runner.python_runner.process is None
def test_python_runner_run_failure_raises_http(monkeypatch, tmp_path):
import editor_app.config as config
import editor_app.services.python_runner as runner
config.WORKSPACE_ROOT = tmp_path
def _boom(*args, **kwargs):
raise RuntimeError("boom")
monkeypatch.setattr(runner.subprocess, "Popen", _boom)
with pytest.raises(HTTPException) as exc:
runner.run_python_file(tmp_path / "x.py", "x.py")
assert exc.value.status_code == 500
def test_python_runner_stop_kill_path():
import editor_app.services.python_runner as runner
proc = _ProcStop()
with runner.python_runner.lock:
runner.python_runner.process = proc
runner.python_runner.output_lines = []
runner.python_runner.running = True
message = runner.stop_python_process()
assert message == "Python process stopped"
assert proc.terminated is True
assert proc.killed is True
def test_completions_import_error_path(tmp_path, monkeypatch):
import editor_app.config as config
import editor_app.main as main
from fastapi.testclient import TestClient
config.WORKSPACE_ROOT = tmp_path
importlib.reload(main)
client = TestClient(main.app)
real_import = builtins.__import__
def fake_import(name, *args, **kwargs):
if name == "jedi":
raise ImportError("forced")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", fake_import)
response = client.post(
"/api/python/completions",
json={
"file_path": "scratch.py",
"content": "x = 1",
"line": 1,
"column": 1,
"max_results": 10,
},
)
assert response.status_code == 500
def test_websocket_output_status_message(tmp_path):
import editor_app.config as config
import editor_app.main as main
from fastapi.testclient import TestClient
config.WORKSPACE_ROOT = tmp_path
importlib.reload(main)
with TestClient(main.app) as client:
with client.websocket_connect("/api/python/ws/output") as ws:
payload = ws.receive_json()
assert "lines" in payload
assert "running" in payload
assert payload["running"] is False
def test_create_app_startup_creates_lib(tmp_path):
import editor_app.config as config
import editor_app.main as main
from fastapi.testclient import TestClient
config.WORKSPACE_ROOT = tmp_path
importlib.reload(main)
assert not (tmp_path / "lib").exists()
with TestClient(main.app):
pass
assert (tmp_path / "lib").is_dir()
def test_python_runner_helpers_and_startup_paths(tmp_path, monkeypatch):
import editor_app.config as config
import editor_app.services.python_runner as runner
config.WORKSPACE_ROOT = tmp_path
runner.STARTUP_SCRIPT_FILE = tmp_path / ".connectionmachine_startup_script"
(tmp_path / "lib").mkdir(exist_ok=True)
# list_python_scripts should skip hidden paths.
(tmp_path / "code").mkdir(exist_ok=True)
(tmp_path / "code" / "a.py").write_text("print('x')\n", encoding="utf-8")
(tmp_path / ".hidden.py").write_text("print('x')\n", encoding="utf-8")
scripts = runner.list_python_scripts()
assert "code/a.py" in scripts
assert ".hidden.py" not in scripts
# startup script getters/setters.
assert runner.get_startup_script() is None
runner.STARTUP_SCRIPT_FILE.write_text("", encoding="utf-8")
assert runner.get_startup_script() is None
missing = tmp_path / "code" / "missing.py"
with pytest.raises(HTTPException) as missing_exc:
runner.set_startup_script(str(missing.relative_to(tmp_path)))
assert missing_exc.value.status_code == 404
(tmp_path / "code" / "note.txt").write_text("x", encoding="utf-8")
with pytest.raises(HTTPException) as nonpy_exc:
runner.set_startup_script("code/note.txt")
assert nonpy_exc.value.status_code == 400
selected = runner.set_startup_script("code/a.py")
assert selected == "code/a.py"
assert runner.get_startup_script() == "code/a.py"
# run_startup_script_if_configured no-op branches.
runner.STARTUP_SCRIPT_FILE.write_text("code/missing.py", encoding="utf-8")
runner.run_startup_script_if_configured()
runner.STARTUP_SCRIPT_FILE.write_text("code/note.txt", encoding="utf-8")
runner.run_startup_script_if_configured()
# run_startup_script_if_configured should call run_python_file on valid startup script.
called = {"count": 0}
def fake_run(target_path, requested_path):
called["count"] += 1
assert requested_path == "code/a.py"
assert str(target_path).endswith("code/a.py")
monkeypatch.setattr(runner, "run_python_file", fake_run)
runner.STARTUP_SCRIPT_FILE.write_text("code/a.py", encoding="utf-8")
runner.run_startup_script_if_configured()
assert called["count"] == 1