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/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