more-compute 0.3.2__py3-none-any.whl → 0.4.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.
- frontend/app/globals.css +10 -7
- frontend/app/layout.tsx +43 -1
- frontend/components/Notebook.tsx +22 -1
- frontend/components/cell/CellButton.tsx +5 -4
- frontend/components/cell/MonacoCell.tsx +42 -21
- frontend/components/output/ErrorDisplay.tsx +14 -1
- frontend/contexts/PodWebSocketContext.tsx +1 -1
- frontend/lib/websocket.ts +2 -2
- frontend/next.config.mjs +2 -2
- kernel_run.py +153 -18
- {more_compute-0.3.2.dist-info → more_compute-0.4.0.dist-info}/METADATA +34 -24
- {more_compute-0.3.2.dist-info → more_compute-0.4.0.dist-info}/RECORD +27 -26
- morecompute/__version__.py +1 -1
- morecompute/execution/executor.py +114 -44
- morecompute/execution/worker.py +319 -107
- morecompute/notebook.py +65 -6
- morecompute/server.py +76 -44
- morecompute/services/pod_manager.py +2 -2
- morecompute/utils/cell_magics.py +35 -4
- morecompute/utils/config_util.py +47 -31
- morecompute/utils/notebook_converter.py +129 -0
- morecompute/utils/py_percent_parser.py +190 -0
- morecompute/utils/special_commands.py +126 -49
- frontend/.DS_Store +0 -0
- {more_compute-0.3.2.dist-info → more_compute-0.4.0.dist-info}/WHEEL +0 -0
- {more_compute-0.3.2.dist-info → more_compute-0.4.0.dist-info}/entry_points.txt +0 -0
- {more_compute-0.3.2.dist-info → more_compute-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {more_compute-0.3.2.dist-info → more_compute-0.4.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Parser for py:percent format (.py files with # %% cell markers)."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import List, Dict, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def parse_py_percent(content: str) -> Dict:
|
|
8
|
+
"""
|
|
9
|
+
Parse py:percent format Python file into notebook structure.
|
|
10
|
+
|
|
11
|
+
Format:
|
|
12
|
+
# %%
|
|
13
|
+
code cell content
|
|
14
|
+
|
|
15
|
+
# %% [markdown]
|
|
16
|
+
# markdown content
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
content: Raw .py file content
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Dict with 'cells' and 'metadata' (compatible with Notebook class)
|
|
23
|
+
"""
|
|
24
|
+
cells = []
|
|
25
|
+
|
|
26
|
+
# Split by cell markers (# %%)
|
|
27
|
+
# Keep the marker in the split to determine cell type
|
|
28
|
+
parts = re.split(r'(# %%.*?\n)', content)
|
|
29
|
+
|
|
30
|
+
# First part before any cell marker (usually imports/metadata)
|
|
31
|
+
if parts[0].strip():
|
|
32
|
+
# Check if it's UV inline script metadata
|
|
33
|
+
first_part = parts[0].strip()
|
|
34
|
+
if not first_part.startswith('# /// script'):
|
|
35
|
+
# It's code, add as first cell
|
|
36
|
+
cells.append({
|
|
37
|
+
'cell_type': 'code',
|
|
38
|
+
'source': parts[0],
|
|
39
|
+
'metadata': {},
|
|
40
|
+
'execution_count': None,
|
|
41
|
+
'outputs': []
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
# Process remaining parts
|
|
45
|
+
i = 1
|
|
46
|
+
while i < len(parts):
|
|
47
|
+
if i >= len(parts):
|
|
48
|
+
break
|
|
49
|
+
|
|
50
|
+
marker = parts[i] if i < len(parts) else ''
|
|
51
|
+
cell_content = parts[i + 1] if i + 1 < len(parts) else ''
|
|
52
|
+
|
|
53
|
+
# Determine cell type from marker
|
|
54
|
+
if '[markdown]' in marker.lower():
|
|
55
|
+
cell_type = 'markdown'
|
|
56
|
+
# Remove leading # from markdown lines
|
|
57
|
+
lines = cell_content.split('\n')
|
|
58
|
+
cleaned_lines = []
|
|
59
|
+
for line in lines:
|
|
60
|
+
if line.strip().startswith('#'):
|
|
61
|
+
# Remove first # and any space after it
|
|
62
|
+
cleaned_lines.append(line.strip()[1:].lstrip())
|
|
63
|
+
else:
|
|
64
|
+
cleaned_lines.append(line)
|
|
65
|
+
cell_content = '\n'.join(cleaned_lines)
|
|
66
|
+
else:
|
|
67
|
+
cell_type = 'code'
|
|
68
|
+
|
|
69
|
+
# Only add non-empty cells
|
|
70
|
+
if cell_content.strip():
|
|
71
|
+
cell = {
|
|
72
|
+
'cell_type': cell_type,
|
|
73
|
+
'source': cell_content.strip(),
|
|
74
|
+
'metadata': {}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if cell_type == 'code':
|
|
78
|
+
cell['execution_count'] = None
|
|
79
|
+
cell['outputs'] = []
|
|
80
|
+
|
|
81
|
+
cells.append(cell)
|
|
82
|
+
|
|
83
|
+
i += 2
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
'cells': cells,
|
|
87
|
+
'metadata': {
|
|
88
|
+
'kernelspec': {
|
|
89
|
+
'display_name': 'Python 3',
|
|
90
|
+
'language': 'python',
|
|
91
|
+
'name': 'python3'
|
|
92
|
+
},
|
|
93
|
+
'language_info': {
|
|
94
|
+
'name': 'python',
|
|
95
|
+
'version': '3.8.0'
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
'nbformat': 4,
|
|
99
|
+
'nbformat_minor': 4
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def extract_uv_dependencies(content: str) -> Optional[List[str]]:
|
|
104
|
+
"""
|
|
105
|
+
Extract UV inline script dependencies from .py file.
|
|
106
|
+
|
|
107
|
+
Format:
|
|
108
|
+
# /// script
|
|
109
|
+
# dependencies = [
|
|
110
|
+
# "package1",
|
|
111
|
+
# "package2>=1.0.0",
|
|
112
|
+
# ]
|
|
113
|
+
# ///
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
content: Raw .py file content
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
List of dependency strings, or None if no dependencies found
|
|
120
|
+
"""
|
|
121
|
+
# Match UV inline script metadata block
|
|
122
|
+
pattern = r'# /// script\n(.*?)# ///\n'
|
|
123
|
+
match = re.search(pattern, content, re.DOTALL)
|
|
124
|
+
|
|
125
|
+
if not match:
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
metadata_block = match.group(1)
|
|
129
|
+
|
|
130
|
+
# Extract dependencies list
|
|
131
|
+
deps_pattern = r'# dependencies = \[(.*?)\]'
|
|
132
|
+
deps_match = re.search(deps_pattern, metadata_block, re.DOTALL)
|
|
133
|
+
|
|
134
|
+
if not deps_match:
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
deps_str = deps_match.group(1)
|
|
138
|
+
|
|
139
|
+
# Parse individual dependencies
|
|
140
|
+
dependencies = []
|
|
141
|
+
for line in deps_str.split('\n'):
|
|
142
|
+
line = line.strip()
|
|
143
|
+
if line.startswith('#'):
|
|
144
|
+
line = line[1:].strip()
|
|
145
|
+
# Remove quotes and trailing comma
|
|
146
|
+
line = line.strip('"\'').strip(',').strip()
|
|
147
|
+
if line:
|
|
148
|
+
dependencies.append(line)
|
|
149
|
+
|
|
150
|
+
return dependencies if dependencies else None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def generate_py_percent(cells: List[Dict]) -> str:
|
|
154
|
+
"""
|
|
155
|
+
Generate py:percent format from cell list.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
cells: List of cell dicts with 'cell_type' and 'source'
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
String in py:percent format
|
|
162
|
+
"""
|
|
163
|
+
lines = []
|
|
164
|
+
|
|
165
|
+
for i, cell in enumerate(cells):
|
|
166
|
+
cell_type = cell.get('cell_type', 'code')
|
|
167
|
+
source = cell.get('source', '')
|
|
168
|
+
|
|
169
|
+
# Ensure source is string
|
|
170
|
+
if isinstance(source, list):
|
|
171
|
+
source = ''.join(source)
|
|
172
|
+
|
|
173
|
+
# Add cell marker
|
|
174
|
+
if cell_type == 'markdown':
|
|
175
|
+
lines.append('# %% [markdown]')
|
|
176
|
+
# Add # prefix to each line of markdown
|
|
177
|
+
for line in source.split('\n'):
|
|
178
|
+
if line.strip():
|
|
179
|
+
lines.append(f'# {line}')
|
|
180
|
+
else:
|
|
181
|
+
lines.append('#')
|
|
182
|
+
else:
|
|
183
|
+
lines.append('# %%')
|
|
184
|
+
lines.append(source)
|
|
185
|
+
|
|
186
|
+
# Add blank line between cells
|
|
187
|
+
if i < len(cells) - 1:
|
|
188
|
+
lines.append('')
|
|
189
|
+
|
|
190
|
+
return '\n'.join(lines)
|
|
@@ -26,6 +26,10 @@ class AsyncSpecialCommandHandler:
|
|
|
26
26
|
self.captured_outputs = {} # Store captured outputs from %%capture
|
|
27
27
|
self.cell_magic_handlers = CellMagicHandlers(globals_dict, self)
|
|
28
28
|
self.line_magic_handlers = LineMagicHandlers(globals_dict)
|
|
29
|
+
self.current_process: Optional[asyncio.subprocess.Process] = None # Track current async subprocess for interrupts
|
|
30
|
+
self.current_process_sync: Optional[subprocess.Popen] = None # Track current sync subprocess for interrupts
|
|
31
|
+
self.sync_interrupted: bool = False # Flag to indicate sync process was interrupted
|
|
32
|
+
self.stream_tasks: list = [] # Track streaming tasks for cancellation
|
|
29
33
|
|
|
30
34
|
def is_special_command(self, source_code: Union[str, list, tuple]) -> bool:
|
|
31
35
|
"""Check if the source code is a special command or contains shell commands"""
|
|
@@ -52,6 +56,9 @@ class AsyncSpecialCommandHandler:
|
|
|
52
56
|
websocket: Optional[WebSocket] = None,
|
|
53
57
|
cell_index: Optional[int] = None) -> Dict[str, Any]:
|
|
54
58
|
"""Execute a special command and return the result"""
|
|
59
|
+
# Reset interrupt flag at start of execution
|
|
60
|
+
self.sync_interrupted = False
|
|
61
|
+
|
|
55
62
|
text = self._coerce_source_to_text(source_code)
|
|
56
63
|
stripped = text.strip()
|
|
57
64
|
|
|
@@ -103,15 +110,8 @@ class AsyncSpecialCommandHandler:
|
|
|
103
110
|
env = prepare_shell_environment(command)
|
|
104
111
|
cmd_parts = prepare_shell_command(command)
|
|
105
112
|
|
|
106
|
-
#
|
|
107
|
-
|
|
108
|
-
await websocket.send_json({
|
|
109
|
-
"type": "execution_start",
|
|
110
|
-
"data": {
|
|
111
|
-
"command": f"!{command}",
|
|
112
|
-
**({"cell_index": cell_index} if cell_index is not None else {})
|
|
113
|
-
}
|
|
114
|
-
})
|
|
113
|
+
# Note: execution_start is sent by the executor, not here
|
|
114
|
+
# Sending it twice confuses the frontend
|
|
115
115
|
|
|
116
116
|
# Create subprocess with streaming
|
|
117
117
|
process = await asyncio.create_subprocess_exec(
|
|
@@ -122,34 +122,78 @@ class AsyncSpecialCommandHandler:
|
|
|
122
122
|
cwd=os.getcwd()
|
|
123
123
|
)
|
|
124
124
|
|
|
125
|
-
#
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
)
|
|
129
|
-
stderr_task = asyncio.create_task(
|
|
130
|
-
self._stream_output(process.stderr, "stderr", result, websocket, cell_index)
|
|
131
|
-
)
|
|
125
|
+
# Track process for interrupt handling
|
|
126
|
+
self.current_process = process
|
|
127
|
+
print(f"[SPECIAL_CMD] Started subprocess PID={process.pid}", file=sys.stderr, flush=True)
|
|
132
128
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
129
|
+
try:
|
|
130
|
+
# Stream output concurrently
|
|
131
|
+
stdout_task = asyncio.create_task(
|
|
132
|
+
self._stream_output(process.stdout, "stdout", result, websocket, cell_index)
|
|
133
|
+
)
|
|
134
|
+
stderr_task = asyncio.create_task(
|
|
135
|
+
self._stream_output(process.stderr, "stderr", result, websocket, cell_index)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Track tasks for interruption
|
|
139
|
+
self.stream_tasks = [stdout_task, stderr_task]
|
|
140
|
+
|
|
141
|
+
print(f"[SPECIAL_CMD] Waiting for stream tasks to complete...", file=sys.stderr, flush=True)
|
|
142
|
+
# Wait for both streams to complete
|
|
143
|
+
await asyncio.gather(stdout_task, stderr_task, return_exceptions=True)
|
|
144
|
+
|
|
145
|
+
print(f"[SPECIAL_CMD] Streams complete, waiting for process to exit...", file=sys.stderr, flush=True)
|
|
146
|
+
# Wait for process completion
|
|
147
|
+
return_code = await process.wait()
|
|
148
|
+
print(f"[SPECIAL_CMD] Process exited with code {return_code}", file=sys.stderr, flush=True)
|
|
149
|
+
except asyncio.CancelledError:
|
|
150
|
+
# Task was cancelled - treat as interrupt
|
|
151
|
+
print(f"[SPECIAL_CMD] Task cancelled, treating as interrupt", file=sys.stderr, flush=True)
|
|
152
|
+
return_code = -15 # SIGTERM
|
|
153
|
+
except Exception as e:
|
|
154
|
+
print(f"[SPECIAL_CMD] Exception during execution: {e}", file=sys.stderr, flush=True)
|
|
155
|
+
import traceback
|
|
156
|
+
traceback.print_exc()
|
|
157
|
+
# Set error result
|
|
158
|
+
result["status"] = "error"
|
|
159
|
+
result["error"] = {
|
|
160
|
+
"output_type": "error",
|
|
161
|
+
"ename": type(e).__name__,
|
|
162
|
+
"evalue": str(e),
|
|
163
|
+
"traceback": traceback.format_exc().split('\n')
|
|
164
|
+
}
|
|
165
|
+
return_code = -1
|
|
166
|
+
finally:
|
|
167
|
+
# Clear process reference when done
|
|
168
|
+
self.current_process = None
|
|
169
|
+
self.stream_tasks = []
|
|
170
|
+
print(f"[SPECIAL_CMD] Cleared process reference", file=sys.stderr, flush=True)
|
|
171
|
+
|
|
172
|
+
# Check if process was interrupted (negative return code means killed by signal)
|
|
173
|
+
if return_code < 0:
|
|
174
|
+
print(f"[SPECIAL_CMD] Process was interrupted (return_code={return_code}), setting KeyboardInterrupt error", file=sys.stderr, flush=True)
|
|
175
|
+
result["status"] = "error"
|
|
176
|
+
result["error"] = {
|
|
177
|
+
"output_type": "error",
|
|
178
|
+
"ename": "KeyboardInterrupt",
|
|
179
|
+
"evalue": "Execution interrupted by user",
|
|
180
|
+
"traceback": [] # No traceback needed for user-initiated interrupt
|
|
181
|
+
}
|
|
182
|
+
elif return_code != 0:
|
|
183
|
+
# Non-zero but positive means command error (not interrupt)
|
|
184
|
+
result["status"] = "error"
|
|
185
|
+
result["error"] = {
|
|
186
|
+
"output_type": "error",
|
|
187
|
+
"ename": "ShellCommandError",
|
|
188
|
+
"evalue": f"Command failed with return code {return_code}",
|
|
189
|
+
"traceback": [f"Shell command '{command}' failed"]
|
|
190
|
+
}
|
|
138
191
|
|
|
139
|
-
|
|
140
|
-
if websocket:
|
|
141
|
-
await websocket.send_json({
|
|
142
|
-
"type": "execution_complete",
|
|
143
|
-
"data": {
|
|
144
|
-
"return_code": return_code,
|
|
145
|
-
"status": "error" if return_code != 0 else "ok",
|
|
146
|
-
**({"cell_index": cell_index} if cell_index is not None else {})
|
|
147
|
-
}
|
|
148
|
-
})
|
|
192
|
+
print(f"[SPECIAL_CMD] Returning result: status={result['status']}, return_code={return_code}", file=sys.stderr, flush=True)
|
|
149
193
|
|
|
150
194
|
# If pip install/uninstall occurred, notify clients to refresh packages
|
|
151
195
|
try:
|
|
152
|
-
if websocket and (command.startswith('pip install') or command.startswith('pip uninstall') or 'pip install' in command or 'pip uninstall' in command):
|
|
196
|
+
if websocket and return_code == 0 and (command.startswith('pip install') or command.startswith('pip uninstall') or 'pip install' in command or 'pip uninstall' in command):
|
|
153
197
|
# Small delay to ensure pip finishes writing metadata to disk
|
|
154
198
|
await asyncio.sleep(0.5)
|
|
155
199
|
await websocket.send_json({
|
|
@@ -159,21 +203,6 @@ class AsyncSpecialCommandHandler:
|
|
|
159
203
|
except Exception:
|
|
160
204
|
pass
|
|
161
205
|
|
|
162
|
-
# Check if command failed
|
|
163
|
-
if return_code != 0:
|
|
164
|
-
result["status"] = "error"
|
|
165
|
-
# Only add generic error if we don't already have detailed stderr output
|
|
166
|
-
# The detailed stderr is already in result["outputs"] from streaming
|
|
167
|
-
has_stderr = any(o.get("name") == "stderr" and o.get("text", "").strip()
|
|
168
|
-
for o in result.get("outputs", []))
|
|
169
|
-
if not has_stderr:
|
|
170
|
-
# No detailed error output, add generic error
|
|
171
|
-
result["error"] = {
|
|
172
|
-
"ename": "ShellCommandError",
|
|
173
|
-
"evalue": f"Command failed with return code {return_code}",
|
|
174
|
-
"traceback": [f"Shell command failed: {command}"]
|
|
175
|
-
}
|
|
176
|
-
|
|
177
206
|
except Exception as e:
|
|
178
207
|
result["status"] = "error"
|
|
179
208
|
result["error"] = {
|
|
@@ -195,8 +224,56 @@ class AsyncSpecialCommandHandler:
|
|
|
195
224
|
return result
|
|
196
225
|
|
|
197
226
|
async def interrupt(self):
|
|
198
|
-
|
|
199
|
-
|
|
227
|
+
"""Interrupt the currently running subprocess"""
|
|
228
|
+
# Cancel stream tasks first
|
|
229
|
+
for task in self.stream_tasks:
|
|
230
|
+
if not task.done():
|
|
231
|
+
print(f"[SPECIAL_CMD] Cancelling stream task", file=sys.stderr, flush=True)
|
|
232
|
+
task.cancel()
|
|
233
|
+
|
|
234
|
+
# Interrupt async subprocess
|
|
235
|
+
if self.current_process:
|
|
236
|
+
try:
|
|
237
|
+
print(f"[SPECIAL_CMD] Interrupting async subprocess PID={self.current_process.pid}", file=sys.stderr, flush=True)
|
|
238
|
+
self.current_process.terminate()
|
|
239
|
+
|
|
240
|
+
# Give it a moment to terminate gracefully
|
|
241
|
+
try:
|
|
242
|
+
await asyncio.wait_for(self.current_process.wait(), timeout=1.0)
|
|
243
|
+
print(f"[SPECIAL_CMD] Async subprocess terminated gracefully", file=sys.stderr, flush=True)
|
|
244
|
+
except asyncio.TimeoutError:
|
|
245
|
+
# Force kill if it doesn't terminate
|
|
246
|
+
print(f"[SPECIAL_CMD] Async subprocess didn't terminate, force killing", file=sys.stderr, flush=True)
|
|
247
|
+
self.current_process.kill()
|
|
248
|
+
await self.current_process.wait()
|
|
249
|
+
print(f"[SPECIAL_CMD] Async subprocess killed", file=sys.stderr, flush=True)
|
|
250
|
+
|
|
251
|
+
except Exception as e:
|
|
252
|
+
print(f"[SPECIAL_CMD] Error interrupting async subprocess: {e}", file=sys.stderr, flush=True)
|
|
253
|
+
|
|
254
|
+
# Interrupt sync subprocess
|
|
255
|
+
if self.current_process_sync:
|
|
256
|
+
try:
|
|
257
|
+
print(f"[SPECIAL_CMD] Interrupting sync subprocess PID={self.current_process_sync.pid}", file=sys.stderr, flush=True)
|
|
258
|
+
self.sync_interrupted = True # Set flag so shell commands know to stop
|
|
259
|
+
self.current_process_sync.terminate()
|
|
260
|
+
|
|
261
|
+
# Give it a moment to terminate gracefully
|
|
262
|
+
try:
|
|
263
|
+
self.current_process_sync.wait(timeout=1.0)
|
|
264
|
+
print(f"[SPECIAL_CMD] Sync subprocess terminated gracefully", file=sys.stderr, flush=True)
|
|
265
|
+
except subprocess.TimeoutExpired:
|
|
266
|
+
# Force kill if it doesn't terminate
|
|
267
|
+
print(f"[SPECIAL_CMD] Sync subprocess didn't terminate, force killing", file=sys.stderr, flush=True)
|
|
268
|
+
self.current_process_sync.kill()
|
|
269
|
+
self.current_process_sync.wait()
|
|
270
|
+
print(f"[SPECIAL_CMD] Sync subprocess killed", file=sys.stderr, flush=True)
|
|
271
|
+
|
|
272
|
+
except Exception as e:
|
|
273
|
+
print(f"[SPECIAL_CMD] Error interrupting sync subprocess: {e}", file=sys.stderr, flush=True)
|
|
274
|
+
|
|
275
|
+
if not self.current_process and not self.current_process_sync:
|
|
276
|
+
print(f"[SPECIAL_CMD] No subprocess to interrupt", file=sys.stderr, flush=True)
|
|
200
277
|
|
|
201
278
|
async def _stream_output(self, stream, stream_type: str, result: Dict[str, Any],
|
|
202
279
|
websocket: Optional[WebSocket] = None,
|
frontend/.DS_Store
DELETED
|
Binary file
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|