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:
2
src/editor_app/__init__.py
Normal file
2
src/editor_app/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .main import app
|
||||
|
||||
BIN
src/editor_app/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/editor_app/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/editor_app/__pycache__/main.cpython-313.pyc
Normal file
BIN
src/editor_app/__pycache__/main.cpython-313.pyc
Normal file
Binary file not shown.
30
src/editor_app/config.py
Normal file
30
src/editor_app/config.py
Normal 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
28
src/editor_app/main.py
Normal 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
38
src/editor_app/models.py
Normal 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
|
||||
1
src/editor_app/routers/__init__.py
Normal file
1
src/editor_app/routers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
52
src/editor_app/routers/files.py
Normal file
52
src/editor_app/routers/files.py
Normal 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"}
|
||||
|
||||
17
src/editor_app/routers/frontend.py
Normal file
17
src/editor_app/routers/frontend.py
Normal 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")
|
||||
|
||||
120
src/editor_app/routers/python_exec.py
Normal file
120
src/editor_app/routers/python_exec.py
Normal 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}
|
||||
|
||||
1
src/editor_app/services/__init__.py
Normal file
1
src/editor_app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
182
src/editor_app/services/filesystem.py
Normal file
182
src/editor_app/services/filesystem.py
Normal 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)
|
||||
|
||||
172
src/editor_app/services/python_runner.py
Normal file
172
src/editor_app/services/python_runner.py
Normal 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
|
||||
|
||||
Reference in New Issue
Block a user