mrmd-python 0.3.8__tar.gz → 0.4.0__tar.gz
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-0.4.0/LICENSE +21 -0
- {mrmd_python-0.3.8 → mrmd_python-0.4.0}/PKG-INFO +2 -1
- {mrmd_python-0.3.8 → mrmd_python-0.4.0}/pyproject.toml +1 -1
- {mrmd_python-0.3.8 → mrmd_python-0.4.0}/src/mrmd_python/cli.py +6 -1
- {mrmd_python-0.3.8 → mrmd_python-0.4.0}/src/mrmd_python/runtime_client.py +3 -12
- {mrmd_python-0.3.8 → mrmd_python-0.4.0}/src/mrmd_python/runtime_daemon.py +2 -1
- {mrmd_python-0.3.8 → mrmd_python-0.4.0}/src/mrmd_python/server.py +87 -209
- {mrmd_python-0.3.8 → mrmd_python-0.4.0}/src/mrmd_python/types.py +0 -17
- {mrmd_python-0.3.8 → mrmd_python-0.4.0}/uv.lock +2 -2
- {mrmd_python-0.3.8 → mrmd_python-0.4.0}/.gitignore +0 -0
- {mrmd_python-0.3.8 → mrmd_python-0.4.0}/README.md +0 -0
- {mrmd_python-0.3.8 → mrmd_python-0.4.0}/src/mrmd_python/__init__.py +0 -0
- {mrmd_python-0.3.8 → mrmd_python-0.4.0}/src/mrmd_python/__main__.py +0 -0
- {mrmd_python-0.3.8 → mrmd_python-0.4.0}/src/mrmd_python/subprocess_manager.py +0 -0
- {mrmd_python-0.3.8 → mrmd_python-0.4.0}/src/mrmd_python/subprocess_worker.py +0 -0
- {mrmd_python-0.3.8 → mrmd_python-0.4.0}/src/mrmd_python/worker.py +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Maxime Rivest
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mrmd-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Python runtime server implementing the MRMD Runtime Protocol (MRP)
|
|
5
5
|
Author: mrmd contributors
|
|
6
6
|
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
7
8
|
Keywords: ipython,mrmd,mrp,notebook,runtime
|
|
8
9
|
Classifier: Development Status :: 3 - Alpha
|
|
9
10
|
Classifier: Intended Audience :: Developers
|
|
@@ -66,6 +66,11 @@ Using with uvx (no install needed):
|
|
|
66
66
|
default=0,
|
|
67
67
|
help="Port to bind to (0 = auto-assign)",
|
|
68
68
|
)
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"--host",
|
|
71
|
+
default="localhost",
|
|
72
|
+
help="Host to bind to (default: localhost, use 0.0.0.0 for containers)",
|
|
73
|
+
)
|
|
69
74
|
parser.add_argument(
|
|
70
75
|
"--venv",
|
|
71
76
|
default=None,
|
|
@@ -196,7 +201,7 @@ Using with uvx (no install needed):
|
|
|
196
201
|
print(f" cwd: {cwd}")
|
|
197
202
|
print(f" port: {args.port or 'auto'}")
|
|
198
203
|
print(f"\nPress Ctrl+C to stop\n")
|
|
199
|
-
run_daemon(args.id, args.port, venv, cwd, args.assets_dir)
|
|
204
|
+
run_daemon(args.id, args.port, venv, cwd, args.assets_dir, host=args.host)
|
|
200
205
|
else:
|
|
201
206
|
# Spawn daemon (non-blocking)
|
|
202
207
|
print(f"Starting mrmd-python daemon...")
|
|
@@ -144,7 +144,6 @@ class DaemonRuntimeClient:
|
|
|
144
144
|
data = {
|
|
145
145
|
"code": code,
|
|
146
146
|
"storeHistory": store_history,
|
|
147
|
-
"session": "default", # Daemon uses default session
|
|
148
147
|
}
|
|
149
148
|
if exec_id:
|
|
150
149
|
data["execId"] = exec_id
|
|
@@ -170,7 +169,6 @@ class DaemonRuntimeClient:
|
|
|
170
169
|
data = {
|
|
171
170
|
"code": code,
|
|
172
171
|
"storeHistory": store_history,
|
|
173
|
-
"session": "default",
|
|
174
172
|
}
|
|
175
173
|
if exec_id:
|
|
176
174
|
data["execId"] = exec_id
|
|
@@ -225,7 +223,6 @@ class DaemonRuntimeClient:
|
|
|
225
223
|
result = self._post("/complete", {
|
|
226
224
|
"code": code,
|
|
227
225
|
"cursor": cursor_pos,
|
|
228
|
-
"session": "default",
|
|
229
226
|
})
|
|
230
227
|
|
|
231
228
|
matches = []
|
|
@@ -252,7 +249,6 @@ class DaemonRuntimeClient:
|
|
|
252
249
|
"code": code,
|
|
253
250
|
"cursor": cursor_pos,
|
|
254
251
|
"detail": detail,
|
|
255
|
-
"session": "default",
|
|
256
252
|
})
|
|
257
253
|
|
|
258
254
|
return InspectResult(
|
|
@@ -273,7 +269,6 @@ class DaemonRuntimeClient:
|
|
|
273
269
|
result = self._post("/hover", {
|
|
274
270
|
"code": code,
|
|
275
271
|
"cursor": cursor_pos,
|
|
276
|
-
"session": "default",
|
|
277
272
|
})
|
|
278
273
|
|
|
279
274
|
return HoverResult(
|
|
@@ -286,7 +281,7 @@ class DaemonRuntimeClient:
|
|
|
286
281
|
|
|
287
282
|
def get_variables(self) -> VariablesResult:
|
|
288
283
|
"""Get user variables."""
|
|
289
|
-
result = self._post("/variables", {
|
|
284
|
+
result = self._post("/variables", {})
|
|
290
285
|
|
|
291
286
|
variables = []
|
|
292
287
|
for v in result.get("variables", []):
|
|
@@ -310,7 +305,6 @@ class DaemonRuntimeClient:
|
|
|
310
305
|
def get_variable_detail(self, name: str, path: Optional[list[str]] = None) -> VariableDetail:
|
|
311
306
|
"""Get detailed info about a variable."""
|
|
312
307
|
result = self._post(f"/variables/{name}", {
|
|
313
|
-
"session": "default",
|
|
314
308
|
"path": path,
|
|
315
309
|
})
|
|
316
310
|
|
|
@@ -343,7 +337,6 @@ class DaemonRuntimeClient:
|
|
|
343
337
|
"""Check if code is a complete statement."""
|
|
344
338
|
result = self._post("/is_complete", {
|
|
345
339
|
"code": code,
|
|
346
|
-
"session": "default",
|
|
347
340
|
})
|
|
348
341
|
|
|
349
342
|
return IsCompleteResult(
|
|
@@ -355,7 +348,6 @@ class DaemonRuntimeClient:
|
|
|
355
348
|
"""Format code using black."""
|
|
356
349
|
result = self._post("/format", {
|
|
357
350
|
"code": code,
|
|
358
|
-
"session": "default",
|
|
359
351
|
})
|
|
360
352
|
|
|
361
353
|
return result.get("formatted", code), result.get("changed", False)
|
|
@@ -363,8 +355,7 @@ class DaemonRuntimeClient:
|
|
|
363
355
|
def reset(self):
|
|
364
356
|
"""Reset the runtime namespace."""
|
|
365
357
|
self._ensure_connected()
|
|
366
|
-
|
|
367
|
-
self._client.post("/sessions/default/reset")
|
|
358
|
+
self._client.post("/reset")
|
|
368
359
|
|
|
369
360
|
def get_info(self) -> dict:
|
|
370
361
|
"""Get info about the daemon runtime."""
|
|
@@ -408,7 +399,7 @@ class DaemonRuntimeClient:
|
|
|
408
399
|
Returns True if request was sent successfully.
|
|
409
400
|
"""
|
|
410
401
|
try:
|
|
411
|
-
result = self._post("/
|
|
402
|
+
result = self._post("/interrupt", {})
|
|
412
403
|
return result.get("interrupted", False)
|
|
413
404
|
except Exception:
|
|
414
405
|
return False
|
|
@@ -226,6 +226,7 @@ def run_daemon(
|
|
|
226
226
|
venv: str,
|
|
227
227
|
cwd: Optional[str] = None,
|
|
228
228
|
assets_dir: Optional[str] = None,
|
|
229
|
+
host: str = "localhost",
|
|
229
230
|
):
|
|
230
231
|
"""
|
|
231
232
|
Run the daemon runtime server.
|
|
@@ -299,7 +300,7 @@ def run_daemon(
|
|
|
299
300
|
# Run uvicorn (this blocks)
|
|
300
301
|
uvicorn.run(
|
|
301
302
|
app,
|
|
302
|
-
host=
|
|
303
|
+
host=host,
|
|
303
304
|
port=port,
|
|
304
305
|
log_level="info",
|
|
305
306
|
access_log=False,
|
|
@@ -7,7 +7,7 @@ The server exposes endpoints at /mrp/v1/* for:
|
|
|
7
7
|
- Code execution (sync and streaming)
|
|
8
8
|
- Completions, hover, and inspect
|
|
9
9
|
- Variable inspection
|
|
10
|
-
-
|
|
10
|
+
- Runtime reset
|
|
11
11
|
- Asset serving (for matplotlib figures, HTML output, etc.)
|
|
12
12
|
|
|
13
13
|
Usage:
|
|
@@ -37,15 +37,12 @@ from starlette.middleware.cors import CORSMiddleware
|
|
|
37
37
|
from sse_starlette.sse import EventSourceResponse
|
|
38
38
|
|
|
39
39
|
from .runtime_client import DaemonRuntimeClient
|
|
40
|
-
from .runtime_daemon import list_runtimes, is_runtime_alive, kill_runtime
|
|
41
40
|
from .worker import IPythonWorker
|
|
42
|
-
#
|
|
43
|
-
# -
|
|
44
|
-
# -
|
|
45
|
-
# - Registry in ~/.mrmd/runtimes/ for discovery
|
|
41
|
+
# Runtime workers can run either:
|
|
42
|
+
# - locally in-process (daemon_mode=True), or
|
|
43
|
+
# - in an external daemon process via DaemonRuntimeClient.
|
|
46
44
|
#
|
|
47
|
-
#
|
|
48
|
-
# IPythonWorker to avoid recursive daemon spawning.
|
|
45
|
+
# Runtime model is single-namespace: one server process == one REPL runtime.
|
|
49
46
|
from .types import (
|
|
50
47
|
Capabilities,
|
|
51
48
|
CapabilityFeatures,
|
|
@@ -68,17 +65,12 @@ def _get_current_venv() -> str | None:
|
|
|
68
65
|
return os.environ.get("VIRTUAL_ENV")
|
|
69
66
|
|
|
70
67
|
|
|
71
|
-
class
|
|
72
|
-
"""Manages
|
|
68
|
+
class RuntimeManager:
|
|
69
|
+
"""Manages a single Python runtime process.
|
|
73
70
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
This is a single, independent Python runtime.
|
|
78
|
-
|
|
79
|
-
2. daemon_mode=False (default): Used by orchestrators.
|
|
80
|
-
Each session spawns an independent daemon process via DaemonRuntimeClient.
|
|
81
|
-
Daemons survive if orchestrator dies, variables persist, GPU memory isolated.
|
|
71
|
+
Runtime model:
|
|
72
|
+
- one server process = one REPL namespace
|
|
73
|
+
- no per-request or per-document MRP sessions
|
|
82
74
|
"""
|
|
83
75
|
|
|
84
76
|
def __init__(
|
|
@@ -92,75 +84,53 @@ class SessionManager:
|
|
|
92
84
|
self.assets_dir = assets_dir
|
|
93
85
|
self.default_venv = venv
|
|
94
86
|
self.daemon_mode = daemon_mode
|
|
95
|
-
self.
|
|
96
|
-
|
|
97
|
-
# In orchestrator mode: workers dict holds DaemonRuntimeClient instances
|
|
98
|
-
self.workers: dict[str, IPythonWorker | DaemonRuntimeClient] = {}
|
|
87
|
+
self.runtime: dict | None = None
|
|
88
|
+
self.worker: IPythonWorker | DaemonRuntimeClient | None = None
|
|
99
89
|
self._pending_inputs: dict[str, asyncio.Future] = {}
|
|
100
90
|
self._lock = threading.Lock()
|
|
101
91
|
|
|
102
|
-
def
|
|
92
|
+
def get_or_create_runtime(
|
|
103
93
|
self,
|
|
104
|
-
session_id: str,
|
|
105
94
|
venv: str | None = None,
|
|
106
95
|
cwd: str | None = None,
|
|
107
96
|
) -> tuple[IPythonWorker | DaemonRuntimeClient, dict]:
|
|
108
|
-
"""
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
- daemon_mode=False: Creates DaemonRuntimeClient (spawns independent daemon)
|
|
114
|
-
|
|
115
|
-
Args:
|
|
116
|
-
session_id: Unique session identifier
|
|
117
|
-
venv: Path to virtual environment. If not provided, auto-detects.
|
|
118
|
-
cwd: Working directory override for this session.
|
|
119
|
-
|
|
120
|
-
Returns:
|
|
121
|
-
Tuple of (worker, session_info)
|
|
122
|
-
"""
|
|
123
|
-
print(f"[MRPServer.get_or_create_session] session_id={session_id}", flush=True)
|
|
124
|
-
print(f"[MRPServer.get_or_create_session] venv param={venv}", flush=True)
|
|
125
|
-
print(f"[MRPServer.get_or_create_session] self.default_venv={self.default_venv}", flush=True)
|
|
126
|
-
print(f"[MRPServer.get_or_create_session] _get_current_venv()={_get_current_venv()}", flush=True)
|
|
127
|
-
print(f"[MRPServer.get_or_create_session] sys.prefix={sys.prefix}", flush=True)
|
|
97
|
+
"""Get or create the single runtime worker."""
|
|
98
|
+
print(f"[MRPServer.get_or_create_runtime] venv param={venv}", flush=True)
|
|
99
|
+
print(f"[MRPServer.get_or_create_runtime] self.default_venv={self.default_venv}", flush=True)
|
|
100
|
+
print(f"[MRPServer.get_or_create_runtime] _get_current_venv()={_get_current_venv()}", flush=True)
|
|
101
|
+
print(f"[MRPServer.get_or_create_runtime] sys.prefix={sys.prefix}", flush=True)
|
|
128
102
|
|
|
129
103
|
effective_venv = venv or self.default_venv or _get_current_venv() or sys.prefix
|
|
130
104
|
effective_cwd = cwd or self.cwd
|
|
131
105
|
|
|
132
|
-
print(f"[MRPServer.
|
|
133
|
-
print(f"[MRPServer.
|
|
106
|
+
print(f"[MRPServer.get_or_create_runtime] effective_venv={effective_venv}", flush=True)
|
|
107
|
+
print(f"[MRPServer.get_or_create_runtime] daemon_mode={self.daemon_mode}", flush=True)
|
|
134
108
|
|
|
135
109
|
with self._lock:
|
|
136
|
-
if
|
|
137
|
-
print(
|
|
110
|
+
if self.runtime is None or self.worker is None:
|
|
111
|
+
print("[MRPServer.get_or_create_runtime] Creating runtime", flush=True)
|
|
138
112
|
if self.daemon_mode:
|
|
139
|
-
# DAEMON MODE: Use local IPythonWorker
|
|
140
|
-
# This is the actual execution engine inside the daemon
|
|
141
|
-
print(f"[MRPServer.get_or_create_session] Creating IPythonWorker with venv={effective_venv}", flush=True)
|
|
142
113
|
worker = IPythonWorker(
|
|
143
114
|
cwd=effective_cwd,
|
|
144
115
|
assets_dir=self.assets_dir,
|
|
145
116
|
venv=effective_venv,
|
|
146
117
|
)
|
|
147
|
-
|
|
148
|
-
"id":
|
|
118
|
+
runtime = {
|
|
119
|
+
"id": "runtime",
|
|
149
120
|
"language": "python",
|
|
150
121
|
"created": datetime.now(timezone.utc).isoformat(),
|
|
151
122
|
"lastActivity": datetime.now(timezone.utc).isoformat(),
|
|
152
123
|
"executionCount": 0,
|
|
153
124
|
"variableCount": 0,
|
|
154
|
-
"workerType": "local",
|
|
125
|
+
"workerType": "local",
|
|
155
126
|
"environment": {
|
|
156
127
|
"cwd": effective_cwd,
|
|
157
128
|
"virtualenv": effective_venv,
|
|
158
129
|
},
|
|
159
130
|
}
|
|
160
131
|
else:
|
|
161
|
-
# ORCHESTRATOR MODE: Spawn independent daemon via HTTP client
|
|
162
132
|
client = DaemonRuntimeClient(
|
|
163
|
-
runtime_id=
|
|
133
|
+
runtime_id="runtime",
|
|
164
134
|
venv=effective_venv,
|
|
165
135
|
cwd=effective_cwd,
|
|
166
136
|
assets_dir=self.assets_dir,
|
|
@@ -168,14 +138,14 @@ class SessionManager:
|
|
|
168
138
|
)
|
|
169
139
|
daemon_info = client.get_info()
|
|
170
140
|
worker = client
|
|
171
|
-
|
|
172
|
-
"id":
|
|
141
|
+
runtime = {
|
|
142
|
+
"id": "runtime",
|
|
173
143
|
"language": "python",
|
|
174
144
|
"created": datetime.now(timezone.utc).isoformat(),
|
|
175
145
|
"lastActivity": datetime.now(timezone.utc).isoformat(),
|
|
176
146
|
"executionCount": 0,
|
|
177
147
|
"variableCount": 0,
|
|
178
|
-
"workerType": "daemon",
|
|
148
|
+
"workerType": "daemon",
|
|
179
149
|
"daemon": {
|
|
180
150
|
"pid": daemon_info.get("pid"),
|
|
181
151
|
"port": daemon_info.get("port"),
|
|
@@ -187,51 +157,40 @@ class SessionManager:
|
|
|
187
157
|
},
|
|
188
158
|
}
|
|
189
159
|
|
|
190
|
-
self.
|
|
191
|
-
|
|
192
|
-
print(f"[MRPServer.get_or_create_session] Using EXISTING session", flush=True)
|
|
193
|
-
existing_worker = self.workers.get(session_id)
|
|
194
|
-
if existing_worker and hasattr(existing_worker, 'venv'):
|
|
195
|
-
print(f"[MRPServer.get_or_create_session] Existing worker venv={existing_worker.venv}", flush=True)
|
|
196
|
-
|
|
197
|
-
session = self.sessions[session_id]
|
|
198
|
-
session["lastActivity"] = datetime.now(timezone.utc).isoformat()
|
|
199
|
-
return self.workers[session_id], session
|
|
160
|
+
self.worker = worker
|
|
161
|
+
self.runtime = runtime
|
|
200
162
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
return self.sessions.get(session_id)
|
|
163
|
+
self.runtime["lastActivity"] = datetime.now(timezone.utc).isoformat()
|
|
164
|
+
return self.worker, self.runtime
|
|
204
165
|
|
|
205
|
-
def
|
|
206
|
-
"""
|
|
207
|
-
return
|
|
166
|
+
def get_runtime(self) -> dict | None:
|
|
167
|
+
"""Get runtime info if initialized."""
|
|
168
|
+
return self.runtime
|
|
208
169
|
|
|
209
|
-
def
|
|
210
|
-
"""
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
In daemon mode: Resets the local IPythonWorker
|
|
214
|
-
In orchestrator mode: Kills the daemon process, releasing GPU/VRAM
|
|
215
|
-
"""
|
|
216
|
-
with self._lock:
|
|
217
|
-
if session_id in self.sessions:
|
|
218
|
-
if session_id in self.workers:
|
|
219
|
-
worker = self.workers[session_id]
|
|
220
|
-
if hasattr(worker, 'shutdown'):
|
|
221
|
-
worker.shutdown() # DaemonRuntimeClient - kills daemon
|
|
222
|
-
elif hasattr(worker, 'reset'):
|
|
223
|
-
worker.reset() # IPythonWorker - just reset
|
|
224
|
-
del self.workers[session_id]
|
|
225
|
-
|
|
226
|
-
del self.sessions[session_id]
|
|
227
|
-
return True
|
|
170
|
+
def reset_runtime(self) -> bool:
|
|
171
|
+
"""Reset runtime state (clear namespace)."""
|
|
172
|
+
if not self.worker or not self.runtime:
|
|
228
173
|
return False
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
""
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
return
|
|
174
|
+
if hasattr(self.worker, 'reset'):
|
|
175
|
+
self.worker.reset()
|
|
176
|
+
self.runtime["executionCount"] = 0
|
|
177
|
+
self.runtime["variableCount"] = 0
|
|
178
|
+
self.runtime["lastActivity"] = datetime.now(timezone.utc).isoformat()
|
|
179
|
+
return True
|
|
180
|
+
|
|
181
|
+
def shutdown(self) -> bool:
|
|
182
|
+
"""Shutdown runtime worker and clear metadata."""
|
|
183
|
+
with self._lock:
|
|
184
|
+
if self.worker is None:
|
|
185
|
+
return False
|
|
186
|
+
worker = self.worker
|
|
187
|
+
if hasattr(worker, 'shutdown'):
|
|
188
|
+
worker.shutdown()
|
|
189
|
+
elif hasattr(worker, 'reset'):
|
|
190
|
+
worker.reset()
|
|
191
|
+
self.worker = None
|
|
192
|
+
self.runtime = None
|
|
193
|
+
return True
|
|
235
194
|
|
|
236
195
|
def register_pending_input(self, exec_id: str, loop: asyncio.AbstractEventLoop) -> asyncio.Future:
|
|
237
196
|
"""Register that an execution is waiting for input."""
|
|
@@ -249,15 +208,10 @@ class SessionManager:
|
|
|
249
208
|
return False
|
|
250
209
|
|
|
251
210
|
def cancel_pending_input(self, exec_id: str) -> bool:
|
|
252
|
-
"""Cancel a pending input request.
|
|
253
|
-
|
|
254
|
-
This is called when the user dismisses the input field (e.g., cancels
|
|
255
|
-
execution, navigates away) to unblock the waiting worker thread.
|
|
256
|
-
"""
|
|
211
|
+
"""Cancel a pending input request."""
|
|
257
212
|
if exec_id in self._pending_inputs:
|
|
258
213
|
future = self._pending_inputs.pop(exec_id)
|
|
259
214
|
if not future.done():
|
|
260
|
-
# Set exception to unblock the waiting worker
|
|
261
215
|
future.get_loop().call_soon_threadsafe(
|
|
262
216
|
future.set_exception,
|
|
263
217
|
InputCancelledError("Input cancelled by user")
|
|
@@ -280,7 +234,7 @@ class MRPServer:
|
|
|
280
234
|
self.assets_dir = assets_dir or os.path.join(self.cwd, ".mrmd-assets")
|
|
281
235
|
self.venv = venv
|
|
282
236
|
self.daemon_mode = daemon_mode
|
|
283
|
-
self.
|
|
237
|
+
self.runtime_manager = RuntimeManager(
|
|
284
238
|
cwd=self.cwd,
|
|
285
239
|
assets_dir=self.assets_dir,
|
|
286
240
|
venv=venv,
|
|
@@ -307,8 +261,6 @@ class MRPServer:
|
|
|
307
261
|
format=True,
|
|
308
262
|
assets=True,
|
|
309
263
|
),
|
|
310
|
-
defaultSession="default",
|
|
311
|
-
maxSessions=10,
|
|
312
264
|
environment=Environment(
|
|
313
265
|
cwd=self.cwd,
|
|
314
266
|
executable=sys.executable,
|
|
@@ -325,74 +277,22 @@ class MRPServer:
|
|
|
325
277
|
caps = self.get_capabilities()
|
|
326
278
|
return JSONResponse(_dataclass_to_dict(caps))
|
|
327
279
|
|
|
328
|
-
async def
|
|
329
|
-
"""
|
|
330
|
-
|
|
331
|
-
return JSONResponse({"sessions": sessions})
|
|
332
|
-
|
|
333
|
-
async def handle_create_session(self, request: Request) -> JSONResponse:
|
|
334
|
-
"""POST /sessions
|
|
335
|
-
|
|
336
|
-
Create a new session with optional environment configuration.
|
|
337
|
-
|
|
338
|
-
Request body:
|
|
339
|
-
id: Optional session ID (generated if not provided)
|
|
340
|
-
language: Language (default: python)
|
|
341
|
-
environment:
|
|
342
|
-
cwd: Working directory
|
|
343
|
-
virtualenv: Path to venv (enables subprocess isolation)
|
|
344
|
-
executable: Python executable (ignored, derived from venv)
|
|
345
|
-
env: Environment variables (not yet implemented)
|
|
346
|
-
dependencies: Package dependencies (not yet implemented)
|
|
347
|
-
"""
|
|
348
|
-
body = await request.json()
|
|
349
|
-
session_id = body.get("id", str(uuid.uuid4())[:8])
|
|
350
|
-
|
|
351
|
-
# Extract environment config
|
|
352
|
-
env_config = body.get("environment", {})
|
|
353
|
-
venv = env_config.get("virtualenv")
|
|
354
|
-
cwd = env_config.get("cwd")
|
|
355
|
-
|
|
356
|
-
worker, session = self.session_manager.get_or_create_session(
|
|
357
|
-
session_id,
|
|
358
|
-
venv=venv,
|
|
359
|
-
cwd=cwd,
|
|
360
|
-
)
|
|
361
|
-
return JSONResponse(session)
|
|
362
|
-
|
|
363
|
-
async def handle_get_session(self, request: Request) -> JSONResponse:
|
|
364
|
-
"""GET /sessions/{id}"""
|
|
365
|
-
session_id = request.path_params["id"]
|
|
366
|
-
session = self.session_manager.get_session(session_id)
|
|
367
|
-
if not session:
|
|
368
|
-
return JSONResponse({"error": "Session not found"}, status_code=404)
|
|
369
|
-
return JSONResponse(session)
|
|
370
|
-
|
|
371
|
-
async def handle_delete_session(self, request: Request) -> JSONResponse:
|
|
372
|
-
"""DELETE /sessions/{id}"""
|
|
373
|
-
session_id = request.path_params["id"]
|
|
374
|
-
if self.session_manager.destroy_session(session_id):
|
|
375
|
-
return JSONResponse({"success": True})
|
|
376
|
-
return JSONResponse({"error": "Session not found"}, status_code=404)
|
|
377
|
-
|
|
378
|
-
async def handle_reset_session(self, request: Request) -> JSONResponse:
|
|
379
|
-
"""POST /sessions/{id}/reset"""
|
|
380
|
-
session_id = request.path_params["id"]
|
|
381
|
-
worker, session = self.session_manager.get_or_create_session(session_id)
|
|
280
|
+
async def handle_reset_runtime(self, request: Request) -> JSONResponse:
|
|
281
|
+
"""POST /reset"""
|
|
282
|
+
worker, runtime = self.runtime_manager.get_or_create_runtime()
|
|
382
283
|
worker.reset()
|
|
383
|
-
|
|
384
|
-
|
|
284
|
+
runtime["executionCount"] = 0
|
|
285
|
+
runtime["variableCount"] = 0
|
|
385
286
|
return JSONResponse({"success": True})
|
|
386
287
|
|
|
387
288
|
async def handle_execute(self, request: Request) -> JSONResponse:
|
|
388
289
|
"""POST /execute"""
|
|
389
290
|
body = await request.json()
|
|
390
291
|
code = body.get("code", "")
|
|
391
|
-
session_id = body.get("session", "default")
|
|
392
292
|
store_history = body.get("storeHistory", True)
|
|
393
293
|
exec_id = body.get("execId", str(uuid.uuid4())[:8])
|
|
394
294
|
|
|
395
|
-
worker,
|
|
295
|
+
worker, runtime = self.runtime_manager.get_or_create_runtime()
|
|
396
296
|
|
|
397
297
|
# Run in thread pool to not block
|
|
398
298
|
loop = asyncio.get_event_loop()
|
|
@@ -400,8 +300,8 @@ class MRPServer:
|
|
|
400
300
|
None, lambda: worker.execute(code, store_history, exec_id)
|
|
401
301
|
)
|
|
402
302
|
|
|
403
|
-
|
|
404
|
-
|
|
303
|
+
runtime["executionCount"] = result.executionCount
|
|
304
|
+
runtime["variableCount"] = len(worker.get_variables().variables)
|
|
405
305
|
|
|
406
306
|
return JSONResponse(_dataclass_to_dict(result))
|
|
407
307
|
|
|
@@ -410,12 +310,11 @@ class MRPServer:
|
|
|
410
310
|
print(f"[DEBUG] handle_execute_stream called", flush=True)
|
|
411
311
|
body = await request.json()
|
|
412
312
|
code = body.get("code", "")
|
|
413
|
-
session_id = body.get("session", "default")
|
|
414
313
|
store_history = body.get("storeHistory", True)
|
|
415
314
|
exec_id = body.get("execId", str(uuid.uuid4())[:8])
|
|
416
|
-
print(f"[DEBUG]
|
|
315
|
+
print(f"[DEBUG] code length={len(code)}, exec_id={exec_id}", flush=True)
|
|
417
316
|
|
|
418
|
-
worker,
|
|
317
|
+
worker, runtime = self.runtime_manager.get_or_create_runtime()
|
|
419
318
|
print(f"[DEBUG] Got worker, starting event generator", flush=True)
|
|
420
319
|
|
|
421
320
|
async def event_generator():
|
|
@@ -468,7 +367,7 @@ class MRPServer:
|
|
|
468
367
|
)
|
|
469
368
|
|
|
470
369
|
# Register that we're waiting for input and get a future
|
|
471
|
-
future = self.
|
|
370
|
+
future = self.runtime_manager.register_pending_input(exec_id, loop)
|
|
472
371
|
|
|
473
372
|
# Wait for the input (blocking - we're in a worker thread)
|
|
474
373
|
# Use run_coroutine_threadsafe to wait on the future from this thread
|
|
@@ -529,8 +428,8 @@ class MRPServer:
|
|
|
529
428
|
|
|
530
429
|
result = result_holder[0]
|
|
531
430
|
if result:
|
|
532
|
-
|
|
533
|
-
|
|
431
|
+
runtime["executionCount"] = result.executionCount
|
|
432
|
+
runtime["variableCount"] = len(worker.get_variables().variables)
|
|
534
433
|
|
|
535
434
|
if result.success:
|
|
536
435
|
yield {
|
|
@@ -553,7 +452,7 @@ class MRPServer:
|
|
|
553
452
|
exec_id = body.get("exec_id", "")
|
|
554
453
|
text = body.get("text", "")
|
|
555
454
|
|
|
556
|
-
if self.
|
|
455
|
+
if self.runtime_manager.provide_input(exec_id, text):
|
|
557
456
|
return JSONResponse({"accepted": True})
|
|
558
457
|
return JSONResponse({"accepted": False, "error": "No pending input request"})
|
|
559
458
|
|
|
@@ -566,27 +465,17 @@ class MRPServer:
|
|
|
566
465
|
body = await request.json()
|
|
567
466
|
exec_id = body.get("exec_id", "")
|
|
568
467
|
|
|
569
|
-
if self.
|
|
468
|
+
if self.runtime_manager.cancel_pending_input(exec_id):
|
|
570
469
|
return JSONResponse({"cancelled": True})
|
|
571
470
|
return JSONResponse({"cancelled": False, "error": "No pending input request"})
|
|
572
471
|
|
|
573
472
|
async def handle_interrupt(self, request: Request) -> JSONResponse:
|
|
574
473
|
"""POST /interrupt
|
|
575
474
|
|
|
576
|
-
Interrupt currently running code in
|
|
475
|
+
Interrupt currently running code in the runtime.
|
|
577
476
|
Sends SIGINT to subprocess workers or sets interrupt flag for local workers.
|
|
578
477
|
"""
|
|
579
|
-
|
|
580
|
-
session_id = body.get("session", "default")
|
|
581
|
-
|
|
582
|
-
# Get the worker for this session (don't create if doesn't exist)
|
|
583
|
-
session = self.session_manager.get_session(session_id)
|
|
584
|
-
if not session:
|
|
585
|
-
return JSONResponse({"interrupted": False, "error": "Session not found"})
|
|
586
|
-
|
|
587
|
-
worker = self.session_manager.workers.get(session_id)
|
|
588
|
-
if not worker:
|
|
589
|
-
return JSONResponse({"interrupted": False, "error": "Worker not found"})
|
|
478
|
+
worker, _ = self.runtime_manager.get_or_create_runtime()
|
|
590
479
|
|
|
591
480
|
# Call interrupt on the worker
|
|
592
481
|
try:
|
|
@@ -600,9 +489,8 @@ class MRPServer:
|
|
|
600
489
|
body = await request.json()
|
|
601
490
|
code = body.get("code", "")
|
|
602
491
|
cursor = body.get("cursor", len(code))
|
|
603
|
-
session_id = body.get("session", "default")
|
|
604
492
|
|
|
605
|
-
worker, _ = self.
|
|
493
|
+
worker, _ = self.runtime_manager.get_or_create_runtime()
|
|
606
494
|
|
|
607
495
|
loop = asyncio.get_event_loop()
|
|
608
496
|
result = await loop.run_in_executor(
|
|
@@ -616,10 +504,9 @@ class MRPServer:
|
|
|
616
504
|
body = await request.json()
|
|
617
505
|
code = body.get("code", "")
|
|
618
506
|
cursor = body.get("cursor", len(code))
|
|
619
|
-
session_id = body.get("session", "default")
|
|
620
507
|
detail = body.get("detail", 1)
|
|
621
508
|
|
|
622
|
-
worker, _ = self.
|
|
509
|
+
worker, _ = self.runtime_manager.get_or_create_runtime()
|
|
623
510
|
|
|
624
511
|
loop = asyncio.get_event_loop()
|
|
625
512
|
result = await loop.run_in_executor(
|
|
@@ -633,9 +520,8 @@ class MRPServer:
|
|
|
633
520
|
body = await request.json()
|
|
634
521
|
code = body.get("code", "")
|
|
635
522
|
cursor = body.get("cursor", len(code))
|
|
636
|
-
session_id = body.get("session", "default")
|
|
637
523
|
|
|
638
|
-
worker, _ = self.
|
|
524
|
+
worker, _ = self.runtime_manager.get_or_create_runtime()
|
|
639
525
|
|
|
640
526
|
loop = asyncio.get_event_loop()
|
|
641
527
|
result = await loop.run_in_executor(
|
|
@@ -647,9 +533,8 @@ class MRPServer:
|
|
|
647
533
|
async def handle_variables(self, request: Request) -> JSONResponse:
|
|
648
534
|
"""POST /variables"""
|
|
649
535
|
body = await request.json()
|
|
650
|
-
session_id = body.get("session", "default")
|
|
651
536
|
|
|
652
|
-
worker, _ = self.
|
|
537
|
+
worker, _ = self.runtime_manager.get_or_create_runtime()
|
|
653
538
|
|
|
654
539
|
loop = asyncio.get_event_loop()
|
|
655
540
|
result = await loop.run_in_executor(None, worker.get_variables)
|
|
@@ -660,10 +545,9 @@ class MRPServer:
|
|
|
660
545
|
"""POST /variables/{name}"""
|
|
661
546
|
name = request.path_params["name"]
|
|
662
547
|
body = await request.json()
|
|
663
|
-
session_id = body.get("session", "default")
|
|
664
548
|
path = body.get("path")
|
|
665
549
|
|
|
666
|
-
worker, _ = self.
|
|
550
|
+
worker, _ = self.runtime_manager.get_or_create_runtime()
|
|
667
551
|
|
|
668
552
|
loop = asyncio.get_event_loop()
|
|
669
553
|
result = await loop.run_in_executor(
|
|
@@ -676,9 +560,8 @@ class MRPServer:
|
|
|
676
560
|
"""POST /is_complete"""
|
|
677
561
|
body = await request.json()
|
|
678
562
|
code = body.get("code", "")
|
|
679
|
-
session_id = body.get("session", "default")
|
|
680
563
|
|
|
681
|
-
worker, _ = self.
|
|
564
|
+
worker, _ = self.runtime_manager.get_or_create_runtime()
|
|
682
565
|
|
|
683
566
|
loop = asyncio.get_event_loop()
|
|
684
567
|
result = await loop.run_in_executor(
|
|
@@ -691,9 +574,8 @@ class MRPServer:
|
|
|
691
574
|
"""POST /format"""
|
|
692
575
|
body = await request.json()
|
|
693
576
|
code = body.get("code", "")
|
|
694
|
-
session_id = body.get("session", "default")
|
|
695
577
|
|
|
696
|
-
worker, _ = self.
|
|
578
|
+
worker, _ = self.runtime_manager.get_or_create_runtime()
|
|
697
579
|
|
|
698
580
|
loop = asyncio.get_event_loop()
|
|
699
581
|
formatted, changed = await loop.run_in_executor(
|
|
@@ -728,11 +610,7 @@ class MRPServer:
|
|
|
728
610
|
"""Create all routes."""
|
|
729
611
|
return [
|
|
730
612
|
Route("/mrp/v1/capabilities", self.handle_capabilities, methods=["GET"]),
|
|
731
|
-
Route("/mrp/v1/
|
|
732
|
-
Route("/mrp/v1/sessions", self.handle_create_session, methods=["POST"]),
|
|
733
|
-
Route("/mrp/v1/sessions/{id}", self.handle_get_session, methods=["GET"]),
|
|
734
|
-
Route("/mrp/v1/sessions/{id}", self.handle_delete_session, methods=["DELETE"]),
|
|
735
|
-
Route("/mrp/v1/sessions/{id}/reset", self.handle_reset_session, methods=["POST"]),
|
|
613
|
+
Route("/mrp/v1/reset", self.handle_reset_runtime, methods=["POST"]),
|
|
736
614
|
Route("/mrp/v1/execute", self.handle_execute, methods=["POST"]),
|
|
737
615
|
Route("/mrp/v1/execute/stream", self.handle_execute_stream, methods=["POST"]),
|
|
738
616
|
Route("/mrp/v1/input", self.handle_input, methods=["POST"]),
|
|
@@ -778,7 +656,7 @@ def create_app(
|
|
|
778
656
|
assets_dir: Directory for saving assets (plots, etc.)
|
|
779
657
|
venv: Path to virtual environment to use for code execution.
|
|
780
658
|
daemon_mode: If True, use local IPythonWorker (for daemon process).
|
|
781
|
-
If False,
|
|
659
|
+
If False, use an external daemon-backed runtime worker.
|
|
782
660
|
"""
|
|
783
661
|
server = MRPServer(cwd=cwd, assets_dir=assets_dir, venv=venv, daemon_mode=daemon_mode)
|
|
784
662
|
|
|
@@ -42,27 +42,10 @@ class Capabilities:
|
|
|
42
42
|
version: str = ""
|
|
43
43
|
languages: list[str] = field(default_factory=lambda: ["python", "py", "python3"])
|
|
44
44
|
features: CapabilityFeatures = field(default_factory=CapabilityFeatures)
|
|
45
|
-
defaultSession: str = "default"
|
|
46
|
-
maxSessions: int = 10
|
|
47
45
|
environment: Environment = field(default_factory=Environment)
|
|
48
46
|
lspFallback: str | None = None
|
|
49
47
|
|
|
50
48
|
|
|
51
|
-
# =============================================================================
|
|
52
|
-
# Sessions
|
|
53
|
-
# =============================================================================
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
@dataclass
|
|
57
|
-
class Session:
|
|
58
|
-
id: str
|
|
59
|
-
language: str = "python"
|
|
60
|
-
created: str = ""
|
|
61
|
-
lastActivity: str = ""
|
|
62
|
-
executionCount: int = 0
|
|
63
|
-
variableCount: int = 0
|
|
64
|
-
|
|
65
|
-
|
|
66
49
|
# =============================================================================
|
|
67
50
|
# Execution
|
|
68
51
|
# =============================================================================
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
version = 1
|
|
2
|
-
revision =
|
|
2
|
+
revision = 3
|
|
3
3
|
requires-python = ">=3.10"
|
|
4
4
|
resolution-markers = [
|
|
5
5
|
"python_full_version >= '3.11'",
|
|
@@ -285,7 +285,7 @@ wheels = [
|
|
|
285
285
|
|
|
286
286
|
[[package]]
|
|
287
287
|
name = "mrmd-python"
|
|
288
|
-
version = "0.3.
|
|
288
|
+
version = "0.3.8"
|
|
289
289
|
source = { editable = "." }
|
|
290
290
|
dependencies = [
|
|
291
291
|
{ name = "black" },
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|