mrmd-python 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mrmd_python/__init__.py +16 -0
- mrmd_python/cli.py +119 -0
- mrmd_python/server.py +609 -0
- mrmd_python/types.py +245 -0
- mrmd_python/worker.py +1525 -0
- mrmd_python-0.1.0.dist-info/METADATA +77 -0
- mrmd_python-0.1.0.dist-info/RECORD +9 -0
- mrmd_python-0.1.0.dist-info/WHEEL +4 -0
- mrmd_python-0.1.0.dist-info/entry_points.txt +2 -0
mrmd_python/server.py
ADDED
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MRP HTTP Server
|
|
3
|
+
|
|
4
|
+
Implements the MRMD Runtime Protocol (MRP) over HTTP with SSE streaming.
|
|
5
|
+
|
|
6
|
+
The server exposes endpoints at /mrp/v1/* for:
|
|
7
|
+
- Code execution (sync and streaming)
|
|
8
|
+
- Completions, hover, and inspect
|
|
9
|
+
- Variable inspection
|
|
10
|
+
- Session management
|
|
11
|
+
- Asset serving (for matplotlib figures, HTML output, etc.)
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
from mrmd_python import create_app
|
|
15
|
+
app = create_app(cwd="/path/to/project")
|
|
16
|
+
|
|
17
|
+
Or via CLI:
|
|
18
|
+
mrmd-python --port 8000
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import sys
|
|
23
|
+
import os
|
|
24
|
+
import asyncio
|
|
25
|
+
import uuid
|
|
26
|
+
import threading
|
|
27
|
+
from datetime import datetime, timezone
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from starlette.applications import Starlette
|
|
32
|
+
from starlette.routing import Route
|
|
33
|
+
from starlette.requests import Request
|
|
34
|
+
from starlette.responses import JSONResponse, Response, FileResponse
|
|
35
|
+
from starlette.middleware import Middleware
|
|
36
|
+
from starlette.middleware.cors import CORSMiddleware
|
|
37
|
+
from sse_starlette.sse import EventSourceResponse
|
|
38
|
+
|
|
39
|
+
from .worker import IPythonWorker
|
|
40
|
+
from .types import (
|
|
41
|
+
Capabilities,
|
|
42
|
+
CapabilityFeatures,
|
|
43
|
+
Environment,
|
|
44
|
+
ExecuteResult,
|
|
45
|
+
InputCancelledError,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class SessionManager:
|
|
50
|
+
"""Manages multiple IPython sessions."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, cwd: str | None = None, assets_dir: str | None = None, venv: str | None = None):
|
|
53
|
+
self.cwd = cwd
|
|
54
|
+
self.assets_dir = assets_dir
|
|
55
|
+
self.venv = venv
|
|
56
|
+
self.sessions: dict[str, dict] = {}
|
|
57
|
+
self.workers: dict[str, IPythonWorker] = {}
|
|
58
|
+
self._pending_inputs: dict[str, asyncio.Future] = {}
|
|
59
|
+
self._lock = threading.Lock()
|
|
60
|
+
|
|
61
|
+
def get_or_create_session(self, session_id: str) -> tuple[IPythonWorker, dict]:
|
|
62
|
+
"""Get or create a session."""
|
|
63
|
+
with self._lock:
|
|
64
|
+
if session_id not in self.sessions:
|
|
65
|
+
self.sessions[session_id] = {
|
|
66
|
+
"id": session_id,
|
|
67
|
+
"language": "python",
|
|
68
|
+
"created": datetime.now(timezone.utc).isoformat(),
|
|
69
|
+
"lastActivity": datetime.now(timezone.utc).isoformat(),
|
|
70
|
+
"executionCount": 0,
|
|
71
|
+
"variableCount": 0,
|
|
72
|
+
}
|
|
73
|
+
self.workers[session_id] = IPythonWorker(
|
|
74
|
+
cwd=self.cwd, assets_dir=self.assets_dir, venv=self.venv
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
session = self.sessions[session_id]
|
|
78
|
+
session["lastActivity"] = datetime.now(timezone.utc).isoformat()
|
|
79
|
+
return self.workers[session_id], session
|
|
80
|
+
|
|
81
|
+
def get_session(self, session_id: str) -> dict | None:
|
|
82
|
+
"""Get session info."""
|
|
83
|
+
return self.sessions.get(session_id)
|
|
84
|
+
|
|
85
|
+
def list_sessions(self) -> list[dict]:
|
|
86
|
+
"""List all sessions."""
|
|
87
|
+
return list(self.sessions.values())
|
|
88
|
+
|
|
89
|
+
def destroy_session(self, session_id: str) -> bool:
|
|
90
|
+
"""Destroy a session."""
|
|
91
|
+
with self._lock:
|
|
92
|
+
if session_id in self.sessions:
|
|
93
|
+
del self.sessions[session_id]
|
|
94
|
+
if session_id in self.workers:
|
|
95
|
+
del self.workers[session_id]
|
|
96
|
+
return True
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
def register_pending_input(self, exec_id: str, loop: asyncio.AbstractEventLoop) -> asyncio.Future:
|
|
100
|
+
"""Register that an execution is waiting for input."""
|
|
101
|
+
future = loop.create_future()
|
|
102
|
+
self._pending_inputs[exec_id] = future
|
|
103
|
+
return future
|
|
104
|
+
|
|
105
|
+
def provide_input(self, exec_id: str, text: str) -> bool:
|
|
106
|
+
"""Provide input to a waiting execution."""
|
|
107
|
+
if exec_id in self._pending_inputs:
|
|
108
|
+
future = self._pending_inputs.pop(exec_id)
|
|
109
|
+
if not future.done():
|
|
110
|
+
future.get_loop().call_soon_threadsafe(future.set_result, text)
|
|
111
|
+
return True
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
def cancel_pending_input(self, exec_id: str) -> bool:
|
|
115
|
+
"""Cancel a pending input request.
|
|
116
|
+
|
|
117
|
+
This is called when the user dismisses the input field (e.g., cancels
|
|
118
|
+
execution, navigates away) to unblock the waiting worker thread.
|
|
119
|
+
"""
|
|
120
|
+
if exec_id in self._pending_inputs:
|
|
121
|
+
future = self._pending_inputs.pop(exec_id)
|
|
122
|
+
if not future.done():
|
|
123
|
+
# Set exception to unblock the waiting worker
|
|
124
|
+
future.get_loop().call_soon_threadsafe(
|
|
125
|
+
future.set_exception,
|
|
126
|
+
InputCancelledError("Input cancelled by user")
|
|
127
|
+
)
|
|
128
|
+
return True
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class MRPServer:
|
|
133
|
+
"""MRP HTTP Server."""
|
|
134
|
+
|
|
135
|
+
def __init__(
|
|
136
|
+
self,
|
|
137
|
+
cwd: str | None = None,
|
|
138
|
+
assets_dir: str | None = None,
|
|
139
|
+
venv: str | None = None,
|
|
140
|
+
):
|
|
141
|
+
self.cwd = cwd or os.getcwd()
|
|
142
|
+
self.assets_dir = assets_dir or os.path.join(self.cwd, ".mrmd-assets")
|
|
143
|
+
self.venv = venv
|
|
144
|
+
self.session_manager = SessionManager(
|
|
145
|
+
cwd=self.cwd, assets_dir=self.assets_dir, venv=venv
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def get_capabilities(self) -> Capabilities:
|
|
149
|
+
"""Get server capabilities."""
|
|
150
|
+
return Capabilities(
|
|
151
|
+
runtime="mrmd-python",
|
|
152
|
+
version="0.1.0",
|
|
153
|
+
languages=["python", "py", "python3"],
|
|
154
|
+
features=CapabilityFeatures(
|
|
155
|
+
execute=True,
|
|
156
|
+
executeStream=True,
|
|
157
|
+
interrupt=True,
|
|
158
|
+
complete=True,
|
|
159
|
+
inspect=True,
|
|
160
|
+
hover=True,
|
|
161
|
+
variables=True,
|
|
162
|
+
variableExpand=True,
|
|
163
|
+
reset=True,
|
|
164
|
+
isComplete=True,
|
|
165
|
+
format=True,
|
|
166
|
+
assets=True,
|
|
167
|
+
),
|
|
168
|
+
defaultSession="default",
|
|
169
|
+
maxSessions=10,
|
|
170
|
+
environment=Environment(
|
|
171
|
+
cwd=self.cwd,
|
|
172
|
+
executable=sys.executable,
|
|
173
|
+
virtualenv=self.venv or os.environ.get("VIRTUAL_ENV"),
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# =========================================================================
|
|
178
|
+
# Route Handlers
|
|
179
|
+
# =========================================================================
|
|
180
|
+
|
|
181
|
+
async def handle_capabilities(self, request: Request) -> JSONResponse:
|
|
182
|
+
"""GET /capabilities"""
|
|
183
|
+
caps = self.get_capabilities()
|
|
184
|
+
return JSONResponse(_dataclass_to_dict(caps))
|
|
185
|
+
|
|
186
|
+
async def handle_list_sessions(self, request: Request) -> JSONResponse:
|
|
187
|
+
"""GET /sessions"""
|
|
188
|
+
sessions = self.session_manager.list_sessions()
|
|
189
|
+
return JSONResponse({"sessions": sessions})
|
|
190
|
+
|
|
191
|
+
async def handle_create_session(self, request: Request) -> JSONResponse:
|
|
192
|
+
"""POST /sessions"""
|
|
193
|
+
body = await request.json()
|
|
194
|
+
session_id = body.get("id", str(uuid.uuid4())[:8])
|
|
195
|
+
worker, session = self.session_manager.get_or_create_session(session_id)
|
|
196
|
+
return JSONResponse(session)
|
|
197
|
+
|
|
198
|
+
async def handle_get_session(self, request: Request) -> JSONResponse:
|
|
199
|
+
"""GET /sessions/{id}"""
|
|
200
|
+
session_id = request.path_params["id"]
|
|
201
|
+
session = self.session_manager.get_session(session_id)
|
|
202
|
+
if not session:
|
|
203
|
+
return JSONResponse({"error": "Session not found"}, status_code=404)
|
|
204
|
+
return JSONResponse(session)
|
|
205
|
+
|
|
206
|
+
async def handle_delete_session(self, request: Request) -> JSONResponse:
|
|
207
|
+
"""DELETE /sessions/{id}"""
|
|
208
|
+
session_id = request.path_params["id"]
|
|
209
|
+
if self.session_manager.destroy_session(session_id):
|
|
210
|
+
return JSONResponse({"success": True})
|
|
211
|
+
return JSONResponse({"error": "Session not found"}, status_code=404)
|
|
212
|
+
|
|
213
|
+
async def handle_reset_session(self, request: Request) -> JSONResponse:
|
|
214
|
+
"""POST /sessions/{id}/reset"""
|
|
215
|
+
session_id = request.path_params["id"]
|
|
216
|
+
worker, session = self.session_manager.get_or_create_session(session_id)
|
|
217
|
+
worker.reset()
|
|
218
|
+
session["executionCount"] = 0
|
|
219
|
+
session["variableCount"] = 0
|
|
220
|
+
return JSONResponse({"success": True})
|
|
221
|
+
|
|
222
|
+
async def handle_execute(self, request: Request) -> JSONResponse:
|
|
223
|
+
"""POST /execute"""
|
|
224
|
+
body = await request.json()
|
|
225
|
+
code = body.get("code", "")
|
|
226
|
+
session_id = body.get("session", "default")
|
|
227
|
+
store_history = body.get("storeHistory", True)
|
|
228
|
+
exec_id = body.get("execId", str(uuid.uuid4())[:8])
|
|
229
|
+
|
|
230
|
+
worker, session = self.session_manager.get_or_create_session(session_id)
|
|
231
|
+
|
|
232
|
+
# Run in thread pool to not block
|
|
233
|
+
loop = asyncio.get_event_loop()
|
|
234
|
+
result = await loop.run_in_executor(
|
|
235
|
+
None, lambda: worker.execute(code, store_history, exec_id)
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
session["executionCount"] = result.executionCount
|
|
239
|
+
session["variableCount"] = len(worker.get_variables().variables)
|
|
240
|
+
|
|
241
|
+
return JSONResponse(_dataclass_to_dict(result))
|
|
242
|
+
|
|
243
|
+
async def handle_execute_stream(self, request: Request) -> EventSourceResponse:
|
|
244
|
+
"""POST /execute/stream - SSE streaming execution"""
|
|
245
|
+
body = await request.json()
|
|
246
|
+
code = body.get("code", "")
|
|
247
|
+
session_id = body.get("session", "default")
|
|
248
|
+
store_history = body.get("storeHistory", True)
|
|
249
|
+
exec_id = body.get("execId", str(uuid.uuid4())[:8])
|
|
250
|
+
|
|
251
|
+
worker, session = self.session_manager.get_or_create_session(session_id)
|
|
252
|
+
|
|
253
|
+
async def event_generator():
|
|
254
|
+
# Capture the event loop for use in background threads
|
|
255
|
+
loop = asyncio.get_running_loop()
|
|
256
|
+
|
|
257
|
+
# Send start event
|
|
258
|
+
yield {
|
|
259
|
+
"event": "start",
|
|
260
|
+
"data": json.dumps({
|
|
261
|
+
"execId": exec_id,
|
|
262
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
263
|
+
}),
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
# Queue for output from the worker thread
|
|
267
|
+
output_queue: asyncio.Queue = asyncio.Queue()
|
|
268
|
+
result_holder = [None]
|
|
269
|
+
accumulated = {"stdout": "", "stderr": ""}
|
|
270
|
+
|
|
271
|
+
def on_output(stream: str, content: str, acc: str):
|
|
272
|
+
"""Called from worker thread for each output chunk."""
|
|
273
|
+
accumulated[stream] = acc
|
|
274
|
+
asyncio.run_coroutine_threadsafe(
|
|
275
|
+
output_queue.put({
|
|
276
|
+
"event": stream,
|
|
277
|
+
"data": {"content": content, "accumulated": acc},
|
|
278
|
+
}),
|
|
279
|
+
loop, # Use captured loop
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
def on_stdin_request(request):
|
|
283
|
+
"""Called from worker thread when input() is called.
|
|
284
|
+
|
|
285
|
+
This function blocks until input is provided via POST /input.
|
|
286
|
+
"""
|
|
287
|
+
from .types import StdinRequest
|
|
288
|
+
|
|
289
|
+
# Send stdin_request event to client
|
|
290
|
+
asyncio.run_coroutine_threadsafe(
|
|
291
|
+
output_queue.put({
|
|
292
|
+
"event": "stdin_request",
|
|
293
|
+
"data": {
|
|
294
|
+
"prompt": request.prompt,
|
|
295
|
+
"password": request.password,
|
|
296
|
+
"execId": request.execId,
|
|
297
|
+
},
|
|
298
|
+
}),
|
|
299
|
+
loop,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
# Register that we're waiting for input and get a future
|
|
303
|
+
future = self.session_manager.register_pending_input(exec_id, loop)
|
|
304
|
+
|
|
305
|
+
# Wait for the input (blocking - we're in a worker thread)
|
|
306
|
+
# Use run_coroutine_threadsafe to wait on the future from this thread
|
|
307
|
+
async def wait_for_input():
|
|
308
|
+
return await future
|
|
309
|
+
|
|
310
|
+
concurrent_future = asyncio.run_coroutine_threadsafe(wait_for_input(), loop)
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
# Wait up to 5 minutes for input
|
|
314
|
+
response = concurrent_future.result(timeout=300)
|
|
315
|
+
return response
|
|
316
|
+
except InputCancelledError:
|
|
317
|
+
# Re-raise InputCancelledError so the worker can handle it
|
|
318
|
+
raise
|
|
319
|
+
except Exception as e:
|
|
320
|
+
raise RuntimeError(f"Failed to get input: {e}")
|
|
321
|
+
|
|
322
|
+
def run_execution():
|
|
323
|
+
"""Run execution in thread."""
|
|
324
|
+
try:
|
|
325
|
+
result = worker.execute_streaming(
|
|
326
|
+
code, on_output, store_history, exec_id,
|
|
327
|
+
on_stdin_request=on_stdin_request
|
|
328
|
+
)
|
|
329
|
+
result_holder[0] = result
|
|
330
|
+
except Exception as e:
|
|
331
|
+
result_holder[0] = ExecuteResult(
|
|
332
|
+
success=False,
|
|
333
|
+
error=worker._format_exception(e),
|
|
334
|
+
)
|
|
335
|
+
finally:
|
|
336
|
+
asyncio.run_coroutine_threadsafe(
|
|
337
|
+
output_queue.put(None), # Signal completion
|
|
338
|
+
loop, # Use captured loop
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
# Start execution in background thread
|
|
342
|
+
exec_thread = threading.Thread(target=run_execution, daemon=True)
|
|
343
|
+
exec_thread.start()
|
|
344
|
+
|
|
345
|
+
# Stream output events
|
|
346
|
+
while True:
|
|
347
|
+
try:
|
|
348
|
+
item = await asyncio.wait_for(output_queue.get(), timeout=60.0)
|
|
349
|
+
if item is None:
|
|
350
|
+
break
|
|
351
|
+
yield {
|
|
352
|
+
"event": item["event"],
|
|
353
|
+
"data": json.dumps(item["data"]),
|
|
354
|
+
}
|
|
355
|
+
except asyncio.TimeoutError:
|
|
356
|
+
# Keep connection alive
|
|
357
|
+
yield {"event": "ping", "data": "{}"}
|
|
358
|
+
|
|
359
|
+
# Wait for thread to finish
|
|
360
|
+
exec_thread.join(timeout=5.0)
|
|
361
|
+
|
|
362
|
+
result = result_holder[0]
|
|
363
|
+
if result:
|
|
364
|
+
session["executionCount"] = result.executionCount
|
|
365
|
+
session["variableCount"] = len(worker.get_variables().variables)
|
|
366
|
+
|
|
367
|
+
if result.success:
|
|
368
|
+
yield {
|
|
369
|
+
"event": "result",
|
|
370
|
+
"data": json.dumps(_dataclass_to_dict(result)),
|
|
371
|
+
}
|
|
372
|
+
else:
|
|
373
|
+
yield {
|
|
374
|
+
"event": "error",
|
|
375
|
+
"data": json.dumps(_dataclass_to_dict(result.error) if result.error else {}),
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
yield {"event": "done", "data": "{}"}
|
|
379
|
+
|
|
380
|
+
return EventSourceResponse(event_generator())
|
|
381
|
+
|
|
382
|
+
async def handle_input(self, request: Request) -> JSONResponse:
|
|
383
|
+
"""POST /input - Send user input to waiting execution"""
|
|
384
|
+
body = await request.json()
|
|
385
|
+
exec_id = body.get("exec_id", "")
|
|
386
|
+
text = body.get("text", "")
|
|
387
|
+
|
|
388
|
+
if self.session_manager.provide_input(exec_id, text):
|
|
389
|
+
return JSONResponse({"accepted": True})
|
|
390
|
+
return JSONResponse({"accepted": False, "error": "No pending input request"})
|
|
391
|
+
|
|
392
|
+
async def handle_input_cancel(self, request: Request) -> JSONResponse:
|
|
393
|
+
"""POST /input/cancel - Cancel pending input request
|
|
394
|
+
|
|
395
|
+
Called when the user dismisses the input field without providing input.
|
|
396
|
+
This unblocks the waiting execution and marks it as cancelled.
|
|
397
|
+
"""
|
|
398
|
+
body = await request.json()
|
|
399
|
+
exec_id = body.get("exec_id", "")
|
|
400
|
+
|
|
401
|
+
if self.session_manager.cancel_pending_input(exec_id):
|
|
402
|
+
return JSONResponse({"cancelled": True})
|
|
403
|
+
return JSONResponse({"cancelled": False, "error": "No pending input request"})
|
|
404
|
+
|
|
405
|
+
async def handle_interrupt(self, request: Request) -> JSONResponse:
|
|
406
|
+
"""POST /interrupt"""
|
|
407
|
+
# TODO: Implement actual interrupt via signal
|
|
408
|
+
return JSONResponse({"interrupted": True})
|
|
409
|
+
|
|
410
|
+
async def handle_complete(self, request: Request) -> JSONResponse:
|
|
411
|
+
"""POST /complete"""
|
|
412
|
+
body = await request.json()
|
|
413
|
+
code = body.get("code", "")
|
|
414
|
+
cursor = body.get("cursor", len(code))
|
|
415
|
+
session_id = body.get("session", "default")
|
|
416
|
+
|
|
417
|
+
worker, _ = self.session_manager.get_or_create_session(session_id)
|
|
418
|
+
|
|
419
|
+
loop = asyncio.get_event_loop()
|
|
420
|
+
result = await loop.run_in_executor(
|
|
421
|
+
None, lambda: worker.complete(code, cursor)
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
return JSONResponse(_dataclass_to_dict(result))
|
|
425
|
+
|
|
426
|
+
async def handle_inspect(self, request: Request) -> JSONResponse:
|
|
427
|
+
"""POST /inspect"""
|
|
428
|
+
body = await request.json()
|
|
429
|
+
code = body.get("code", "")
|
|
430
|
+
cursor = body.get("cursor", len(code))
|
|
431
|
+
session_id = body.get("session", "default")
|
|
432
|
+
detail = body.get("detail", 1)
|
|
433
|
+
|
|
434
|
+
worker, _ = self.session_manager.get_or_create_session(session_id)
|
|
435
|
+
|
|
436
|
+
loop = asyncio.get_event_loop()
|
|
437
|
+
result = await loop.run_in_executor(
|
|
438
|
+
None, lambda: worker.inspect(code, cursor, detail)
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
return JSONResponse(_dataclass_to_dict(result))
|
|
442
|
+
|
|
443
|
+
async def handle_hover(self, request: Request) -> JSONResponse:
|
|
444
|
+
"""POST /hover"""
|
|
445
|
+
body = await request.json()
|
|
446
|
+
code = body.get("code", "")
|
|
447
|
+
cursor = body.get("cursor", len(code))
|
|
448
|
+
session_id = body.get("session", "default")
|
|
449
|
+
|
|
450
|
+
worker, _ = self.session_manager.get_or_create_session(session_id)
|
|
451
|
+
|
|
452
|
+
loop = asyncio.get_event_loop()
|
|
453
|
+
result = await loop.run_in_executor(
|
|
454
|
+
None, lambda: worker.hover(code, cursor)
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
return JSONResponse(_dataclass_to_dict(result))
|
|
458
|
+
|
|
459
|
+
async def handle_variables(self, request: Request) -> JSONResponse:
|
|
460
|
+
"""POST /variables"""
|
|
461
|
+
body = await request.json()
|
|
462
|
+
session_id = body.get("session", "default")
|
|
463
|
+
|
|
464
|
+
worker, _ = self.session_manager.get_or_create_session(session_id)
|
|
465
|
+
|
|
466
|
+
loop = asyncio.get_event_loop()
|
|
467
|
+
result = await loop.run_in_executor(None, worker.get_variables)
|
|
468
|
+
|
|
469
|
+
return JSONResponse(_dataclass_to_dict(result))
|
|
470
|
+
|
|
471
|
+
async def handle_variable_detail(self, request: Request) -> JSONResponse:
|
|
472
|
+
"""POST /variables/{name}"""
|
|
473
|
+
name = request.path_params["name"]
|
|
474
|
+
body = await request.json()
|
|
475
|
+
session_id = body.get("session", "default")
|
|
476
|
+
path = body.get("path")
|
|
477
|
+
|
|
478
|
+
worker, _ = self.session_manager.get_or_create_session(session_id)
|
|
479
|
+
|
|
480
|
+
loop = asyncio.get_event_loop()
|
|
481
|
+
result = await loop.run_in_executor(
|
|
482
|
+
None, lambda: worker.get_variable_detail(name, path)
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
return JSONResponse(_dataclass_to_dict(result))
|
|
486
|
+
|
|
487
|
+
async def handle_is_complete(self, request: Request) -> JSONResponse:
|
|
488
|
+
"""POST /is_complete"""
|
|
489
|
+
body = await request.json()
|
|
490
|
+
code = body.get("code", "")
|
|
491
|
+
session_id = body.get("session", "default")
|
|
492
|
+
|
|
493
|
+
worker, _ = self.session_manager.get_or_create_session(session_id)
|
|
494
|
+
|
|
495
|
+
loop = asyncio.get_event_loop()
|
|
496
|
+
result = await loop.run_in_executor(
|
|
497
|
+
None, lambda: worker.is_complete(code)
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
return JSONResponse(_dataclass_to_dict(result))
|
|
501
|
+
|
|
502
|
+
async def handle_format(self, request: Request) -> JSONResponse:
|
|
503
|
+
"""POST /format"""
|
|
504
|
+
body = await request.json()
|
|
505
|
+
code = body.get("code", "")
|
|
506
|
+
session_id = body.get("session", "default")
|
|
507
|
+
|
|
508
|
+
worker, _ = self.session_manager.get_or_create_session(session_id)
|
|
509
|
+
|
|
510
|
+
loop = asyncio.get_event_loop()
|
|
511
|
+
formatted, changed = await loop.run_in_executor(
|
|
512
|
+
None, lambda: worker.format_code(code)
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
return JSONResponse({"formatted": formatted, "changed": changed})
|
|
516
|
+
|
|
517
|
+
async def handle_asset(self, request: Request) -> Response:
|
|
518
|
+
"""GET /assets/{path}"""
|
|
519
|
+
asset_path = request.path_params["path"]
|
|
520
|
+
full_path = Path(self.assets_dir) / asset_path
|
|
521
|
+
|
|
522
|
+
if not full_path.exists():
|
|
523
|
+
return JSONResponse({"error": "Asset not found"}, status_code=404)
|
|
524
|
+
|
|
525
|
+
# Determine content type
|
|
526
|
+
suffix = full_path.suffix.lower()
|
|
527
|
+
content_types = {
|
|
528
|
+
".png": "image/png",
|
|
529
|
+
".jpg": "image/jpeg",
|
|
530
|
+
".jpeg": "image/jpeg",
|
|
531
|
+
".svg": "image/svg+xml",
|
|
532
|
+
".html": "text/html",
|
|
533
|
+
".json": "application/json",
|
|
534
|
+
}
|
|
535
|
+
content_type = content_types.get(suffix, "application/octet-stream")
|
|
536
|
+
|
|
537
|
+
return FileResponse(full_path, media_type=content_type)
|
|
538
|
+
|
|
539
|
+
def create_routes(self) -> list[Route]:
|
|
540
|
+
"""Create all routes."""
|
|
541
|
+
return [
|
|
542
|
+
Route("/mrp/v1/capabilities", self.handle_capabilities, methods=["GET"]),
|
|
543
|
+
Route("/mrp/v1/sessions", self.handle_list_sessions, methods=["GET"]),
|
|
544
|
+
Route("/mrp/v1/sessions", self.handle_create_session, methods=["POST"]),
|
|
545
|
+
Route("/mrp/v1/sessions/{id}", self.handle_get_session, methods=["GET"]),
|
|
546
|
+
Route("/mrp/v1/sessions/{id}", self.handle_delete_session, methods=["DELETE"]),
|
|
547
|
+
Route("/mrp/v1/sessions/{id}/reset", self.handle_reset_session, methods=["POST"]),
|
|
548
|
+
Route("/mrp/v1/execute", self.handle_execute, methods=["POST"]),
|
|
549
|
+
Route("/mrp/v1/execute/stream", self.handle_execute_stream, methods=["POST"]),
|
|
550
|
+
Route("/mrp/v1/input", self.handle_input, methods=["POST"]),
|
|
551
|
+
Route("/mrp/v1/input/cancel", self.handle_input_cancel, methods=["POST"]),
|
|
552
|
+
Route("/mrp/v1/interrupt", self.handle_interrupt, methods=["POST"]),
|
|
553
|
+
Route("/mrp/v1/complete", self.handle_complete, methods=["POST"]),
|
|
554
|
+
Route("/mrp/v1/inspect", self.handle_inspect, methods=["POST"]),
|
|
555
|
+
Route("/mrp/v1/hover", self.handle_hover, methods=["POST"]),
|
|
556
|
+
Route("/mrp/v1/variables", self.handle_variables, methods=["POST"]),
|
|
557
|
+
Route("/mrp/v1/variables/{name}", self.handle_variable_detail, methods=["POST"]),
|
|
558
|
+
Route("/mrp/v1/is_complete", self.handle_is_complete, methods=["POST"]),
|
|
559
|
+
Route("/mrp/v1/format", self.handle_format, methods=["POST"]),
|
|
560
|
+
Route("/mrp/v1/assets/{path:path}", self.handle_asset, methods=["GET"]),
|
|
561
|
+
]
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _dataclass_to_dict(obj: Any) -> dict:
|
|
565
|
+
"""Convert dataclass to dict, handling nested dataclasses."""
|
|
566
|
+
if hasattr(obj, "__dataclass_fields__"):
|
|
567
|
+
result = {}
|
|
568
|
+
for field_name in obj.__dataclass_fields__:
|
|
569
|
+
value = getattr(obj, field_name)
|
|
570
|
+
result[field_name] = _dataclass_to_dict(value)
|
|
571
|
+
return result
|
|
572
|
+
elif isinstance(obj, list):
|
|
573
|
+
return [_dataclass_to_dict(item) for item in obj]
|
|
574
|
+
elif isinstance(obj, dict):
|
|
575
|
+
return {k: _dataclass_to_dict(v) for k, v in obj.items()}
|
|
576
|
+
else:
|
|
577
|
+
return obj
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def create_app(
|
|
581
|
+
cwd: str | None = None,
|
|
582
|
+
assets_dir: str | None = None,
|
|
583
|
+
venv: str | None = None,
|
|
584
|
+
) -> Starlette:
|
|
585
|
+
"""Create the MRP server application.
|
|
586
|
+
|
|
587
|
+
Args:
|
|
588
|
+
cwd: Working directory for code execution
|
|
589
|
+
assets_dir: Directory for saving assets (plots, etc.)
|
|
590
|
+
venv: Path to virtual environment to use for code execution.
|
|
591
|
+
If provided, packages from this venv will be available.
|
|
592
|
+
"""
|
|
593
|
+
server = MRPServer(cwd=cwd, assets_dir=assets_dir, venv=venv)
|
|
594
|
+
|
|
595
|
+
middleware = [
|
|
596
|
+
Middleware(
|
|
597
|
+
CORSMiddleware,
|
|
598
|
+
allow_origins=["*"],
|
|
599
|
+
allow_methods=["*"],
|
|
600
|
+
allow_headers=["*"],
|
|
601
|
+
)
|
|
602
|
+
]
|
|
603
|
+
|
|
604
|
+
app = Starlette(
|
|
605
|
+
routes=server.create_routes(),
|
|
606
|
+
middleware=middleware,
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
return app
|