more-compute 0.3.3__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 +9 -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 +107 -17
- {more_compute-0.3.3.dist-info → more_compute-0.4.0.dist-info}/METADATA +30 -28
- {more_compute-0.3.3.dist-info → more_compute-0.4.0.dist-info}/RECORD +25 -24
- morecompute/__version__.py +1 -1
- morecompute/execution/executor.py +113 -44
- morecompute/execution/worker.py +319 -107
- morecompute/notebook.py +65 -6
- morecompute/server.py +72 -40
- morecompute/utils/cell_magics.py +35 -4
- 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.3.dist-info → more_compute-0.4.0.dist-info}/WHEEL +0 -0
- {more_compute-0.3.3.dist-info → more_compute-0.4.0.dist-info}/entry_points.txt +0 -0
- {more_compute-0.3.3.dist-info → more_compute-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {more_compute-0.3.3.dist-info → more_compute-0.4.0.dist-info}/top_level.txt +0 -0
morecompute/notebook.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import json
|
|
2
|
+
from pathlib import Path
|
|
2
3
|
from typing import List, Dict, Any
|
|
3
4
|
from uuid import uuid4
|
|
5
|
+
from .utils.py_percent_parser import parse_py_percent, generate_py_percent
|
|
4
6
|
|
|
5
7
|
class Notebook:
|
|
6
8
|
"""Manages the state of a notebook's cells."""
|
|
@@ -71,6 +73,8 @@ class Notebook:
|
|
|
71
73
|
if cell.get('cell_type') == 'code':
|
|
72
74
|
cell['outputs'] = []
|
|
73
75
|
cell['execution_count'] = None
|
|
76
|
+
cell['execution_time'] = None
|
|
77
|
+
cell['error'] = None
|
|
74
78
|
|
|
75
79
|
def to_json(self) -> str:
|
|
76
80
|
# Basic notebook format
|
|
@@ -84,10 +88,18 @@ class Notebook:
|
|
|
84
88
|
|
|
85
89
|
def load_from_file(self, file_path: str):
|
|
86
90
|
try:
|
|
87
|
-
|
|
88
|
-
|
|
91
|
+
path = Path(file_path)
|
|
92
|
+
|
|
93
|
+
# Check file extension
|
|
94
|
+
if path.suffix == '.py':
|
|
95
|
+
# Load .py file with py:percent format
|
|
96
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
97
|
+
content = f.read()
|
|
98
|
+
|
|
99
|
+
data = parse_py_percent(content)
|
|
89
100
|
loaded_cells = data.get('cells', [])
|
|
90
|
-
|
|
101
|
+
|
|
102
|
+
# Ensure stable IDs for all cells
|
|
91
103
|
self.cells = []
|
|
92
104
|
for cell in loaded_cells:
|
|
93
105
|
if not isinstance(cell, dict):
|
|
@@ -95,9 +107,40 @@ class Notebook:
|
|
|
95
107
|
if 'id' not in cell or not cell['id']:
|
|
96
108
|
cell['id'] = self._generate_cell_id()
|
|
97
109
|
self.cells.append(cell)
|
|
110
|
+
|
|
98
111
|
self.metadata = data.get('metadata', {})
|
|
99
112
|
self.file_path = file_path
|
|
100
|
-
|
|
113
|
+
|
|
114
|
+
elif path.suffix == '.ipynb':
|
|
115
|
+
# Block .ipynb files with helpful error
|
|
116
|
+
raise ValueError(
|
|
117
|
+
f"MoreCompute only supports .py notebooks.\n\n"
|
|
118
|
+
f"Convert your notebook with:\n"
|
|
119
|
+
f" more-compute convert {path.name} -o {path.stem}.py\n\n"
|
|
120
|
+
f"Then open with:\n"
|
|
121
|
+
f" more-compute {path.stem}.py"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
else:
|
|
125
|
+
raise ValueError(f"Unsupported file format: {path.suffix}. Use .py files.")
|
|
126
|
+
|
|
127
|
+
except FileNotFoundError as e:
|
|
128
|
+
print(f"Error: File not found: {e}")
|
|
129
|
+
# Initialize with a default cell if loading fails
|
|
130
|
+
self.cells = [{
|
|
131
|
+
'id': self._generate_cell_id(),
|
|
132
|
+
'cell_type': 'code',
|
|
133
|
+
'source': '',
|
|
134
|
+
'metadata': {},
|
|
135
|
+
'outputs': [],
|
|
136
|
+
'execution_count': None
|
|
137
|
+
}]
|
|
138
|
+
self.metadata = {}
|
|
139
|
+
self.file_path = file_path
|
|
140
|
+
except ValueError as e:
|
|
141
|
+
# Re-raise validation errors (like .ipynb block)
|
|
142
|
+
raise
|
|
143
|
+
except Exception as e:
|
|
101
144
|
print(f"Error loading notebook: {e}")
|
|
102
145
|
# Initialize with a default cell if loading fails
|
|
103
146
|
self.cells = [{
|
|
@@ -116,8 +159,24 @@ class Notebook:
|
|
|
116
159
|
if not path_to_save:
|
|
117
160
|
raise ValueError("No file path specified for saving.")
|
|
118
161
|
|
|
119
|
-
|
|
120
|
-
|
|
162
|
+
path = Path(path_to_save)
|
|
163
|
+
|
|
164
|
+
# Save in appropriate format based on extension
|
|
165
|
+
if path.suffix == '.py':
|
|
166
|
+
# Save as py:percent format
|
|
167
|
+
content = generate_py_percent(self.cells)
|
|
168
|
+
with open(path_to_save, 'w', encoding='utf-8') as f:
|
|
169
|
+
f.write(content)
|
|
170
|
+
elif path.suffix == '.ipynb':
|
|
171
|
+
# Block saving as .ipynb
|
|
172
|
+
raise ValueError("MoreCompute only supports .py notebooks. Use .py extension.")
|
|
173
|
+
else:
|
|
174
|
+
# Default to .py if no extension
|
|
175
|
+
path_to_save = str(path.with_suffix('.py'))
|
|
176
|
+
content = generate_py_percent(self.cells)
|
|
177
|
+
with open(path_to_save, 'w', encoding='utf-8') as f:
|
|
178
|
+
f.write(content)
|
|
179
|
+
|
|
121
180
|
self.file_path = path_to_save
|
|
122
181
|
|
|
123
182
|
def _generate_cell_id(self) -> str:
|
morecompute/server.py
CHANGED
|
@@ -99,7 +99,23 @@ async def startup_event():
|
|
|
99
99
|
@app.on_event("shutdown")
|
|
100
100
|
async def shutdown_event():
|
|
101
101
|
"""Cleanup services on shutdown."""
|
|
102
|
-
global lsp_service
|
|
102
|
+
global lsp_service, executor
|
|
103
|
+
|
|
104
|
+
# Shutdown executor and worker process
|
|
105
|
+
if executor and executor.worker_proc:
|
|
106
|
+
try:
|
|
107
|
+
print("[EXECUTOR] Shutting down worker process...", file=sys.stderr, flush=True)
|
|
108
|
+
executor.worker_proc.terminate()
|
|
109
|
+
executor.worker_proc.wait(timeout=2)
|
|
110
|
+
print("[EXECUTOR] Worker process shutdown complete", file=sys.stderr, flush=True)
|
|
111
|
+
except Exception as e:
|
|
112
|
+
print(f"[EXECUTOR] Error during worker shutdown, forcing kill: {e}", file=sys.stderr, flush=True)
|
|
113
|
+
try:
|
|
114
|
+
executor.worker_proc.kill()
|
|
115
|
+
except Exception:
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
# Shutdown LSP service
|
|
103
119
|
if lsp_service:
|
|
104
120
|
try:
|
|
105
121
|
await lsp_service.shutdown()
|
|
@@ -441,12 +457,34 @@ class WebSocketManager:
|
|
|
441
457
|
|
|
442
458
|
async def handle_message_loop(self, websocket: WebSocket):
|
|
443
459
|
"""Main loop to handle incoming WebSocket messages."""
|
|
460
|
+
tasks = set()
|
|
461
|
+
|
|
462
|
+
def task_done_callback(task):
|
|
463
|
+
tasks.discard(task)
|
|
464
|
+
# Check for exceptions in completed tasks
|
|
465
|
+
try:
|
|
466
|
+
exc = task.exception()
|
|
467
|
+
if exc:
|
|
468
|
+
print(f"[SERVER] Task raised exception: {exc}", file=sys.stderr, flush=True)
|
|
469
|
+
import traceback
|
|
470
|
+
traceback.print_exception(type(exc), exc, exc.__traceback__)
|
|
471
|
+
except asyncio.CancelledError:
|
|
472
|
+
pass
|
|
473
|
+
except Exception as e:
|
|
474
|
+
print(f"[SERVER] Error in task_done_callback: {e}", file=sys.stderr, flush=True)
|
|
475
|
+
|
|
444
476
|
while True:
|
|
445
477
|
try:
|
|
446
478
|
message = await websocket.receive_json()
|
|
447
|
-
|
|
479
|
+
# Process messages concurrently so interrupts can arrive during execution
|
|
480
|
+
task = asyncio.create_task(self._handle_message(websocket, message))
|
|
481
|
+
tasks.add(task)
|
|
482
|
+
task.add_done_callback(task_done_callback)
|
|
448
483
|
except WebSocketDisconnect:
|
|
449
484
|
self.disconnect(websocket)
|
|
485
|
+
# Cancel all pending tasks
|
|
486
|
+
for task in tasks:
|
|
487
|
+
task.cancel()
|
|
450
488
|
break
|
|
451
489
|
except Exception as e:
|
|
452
490
|
await self._send_error(websocket, f"Unhandled error: {e}")
|
|
@@ -534,12 +572,24 @@ class WebSocketManager:
|
|
|
534
572
|
else:
|
|
535
573
|
# Normal add cell
|
|
536
574
|
self.notebook.add_cell(index=index, cell_type=cell_type, source=source)
|
|
575
|
+
|
|
576
|
+
# Save the notebook after adding cell
|
|
577
|
+
try:
|
|
578
|
+
self.notebook.save_to_file()
|
|
579
|
+
except Exception as e:
|
|
580
|
+
print(f"Warning: Failed to save notebook after adding cell: {e}", file=sys.stderr)
|
|
581
|
+
|
|
537
582
|
await self.broadcast_notebook_update()
|
|
538
583
|
|
|
539
584
|
async def _handle_delete_cell(self, websocket: WebSocket, data: dict):
|
|
540
585
|
index = data.get('cell_index')
|
|
541
586
|
if index is not None:
|
|
542
587
|
self.notebook.delete_cell(index)
|
|
588
|
+
# Save the notebook after deleting cell
|
|
589
|
+
try:
|
|
590
|
+
self.notebook.save_to_file()
|
|
591
|
+
except Exception as e:
|
|
592
|
+
print(f"Warning: Failed to save notebook after deleting cell: {e}", file=sys.stderr)
|
|
543
593
|
await self.broadcast_notebook_update()
|
|
544
594
|
|
|
545
595
|
async def _handle_update_cell(self, websocket: WebSocket, data: dict):
|
|
@@ -587,49 +637,30 @@ class WebSocketManager:
|
|
|
587
637
|
print(f"[SERVER] Interrupt request received for cell {cell_index}", file=sys.stderr, flush=True)
|
|
588
638
|
|
|
589
639
|
# Perform the interrupt (this may take up to 1 second)
|
|
640
|
+
# The execution handler will send the appropriate error and completion messages
|
|
590
641
|
await self.executor.interrupt_kernel(cell_index=cell_index)
|
|
591
642
|
|
|
592
|
-
print(f"[SERVER] Interrupt completed,
|
|
643
|
+
print(f"[SERVER] Interrupt completed, execution handler will send completion messages", file=sys.stderr, flush=True)
|
|
593
644
|
|
|
594
|
-
#
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
"data": {
|
|
599
|
-
"cell_index": cell_index,
|
|
600
|
-
"error": {
|
|
601
|
-
"output_type": "error",
|
|
602
|
-
"ename": "KeyboardInterrupt",
|
|
603
|
-
"evalue": "Execution interrupted by user",
|
|
604
|
-
"traceback": ["KeyboardInterrupt: Execution was stopped by user"]
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
})
|
|
608
|
-
await websocket.send_json({
|
|
609
|
-
"type": "execution_complete",
|
|
610
|
-
"data": {
|
|
611
|
-
"cell_index": cell_index,
|
|
612
|
-
"result": {
|
|
613
|
-
"status": "error",
|
|
614
|
-
"execution_count": None,
|
|
615
|
-
"execution_time": "interrupted",
|
|
616
|
-
"outputs": [],
|
|
617
|
-
"error": {
|
|
618
|
-
"output_type": "error",
|
|
619
|
-
"ename": "KeyboardInterrupt",
|
|
620
|
-
"evalue": "Execution interrupted by user",
|
|
621
|
-
"traceback": ["KeyboardInterrupt: Execution was stopped by user"]
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
})
|
|
626
|
-
print(f"[SERVER] Error messages sent for cell {cell_index}", file=sys.stderr, flush=True)
|
|
627
|
-
except Exception as e:
|
|
628
|
-
print(f"[SERVER] Failed to send error messages: {e}", file=sys.stderr, flush=True)
|
|
645
|
+
# Note: We don't send completion messages here anymore because:
|
|
646
|
+
# 1. For shell commands: AsyncSpecialCommandHandler._execute_shell_command sends them
|
|
647
|
+
# 2. For Python code: The worker sends them
|
|
648
|
+
# Sending duplicate messages causes the frontend to get confused
|
|
629
649
|
|
|
630
650
|
async def _handle_reset_kernel(self, websocket: WebSocket, data: dict):
|
|
651
|
+
import sys
|
|
652
|
+
print(f"[SERVER] Resetting kernel", file=sys.stderr, flush=True)
|
|
631
653
|
self.executor.reset_kernel()
|
|
632
654
|
self.notebook.clear_all_outputs()
|
|
655
|
+
|
|
656
|
+
# Note: We don't save the notebook here - this preserves execution times
|
|
657
|
+
# from the last session, which is useful for seeing how long things took
|
|
658
|
+
|
|
659
|
+
# Broadcast kernel restart to all clients
|
|
660
|
+
await self.broadcast_pod_update({
|
|
661
|
+
"type": "kernel_restarted",
|
|
662
|
+
"data": {}
|
|
663
|
+
})
|
|
633
664
|
await self.broadcast_notebook_update()
|
|
634
665
|
|
|
635
666
|
async def _send_error(self, websocket: WebSocket, error_message: str):
|
|
@@ -811,9 +842,10 @@ async def _connect_to_pod_background(pod_id: str):
|
|
|
811
842
|
reconnect_zmq_sockets(
|
|
812
843
|
executor,
|
|
813
844
|
cmd_addr=addresses["cmd_addr"],
|
|
814
|
-
pub_addr=addresses["pub_addr"]
|
|
845
|
+
pub_addr=addresses["pub_addr"],
|
|
846
|
+
is_remote=True # Critical: Tell executor this is a remote worker
|
|
815
847
|
)
|
|
816
|
-
print(f"[CONNECT BACKGROUND] Successfully connected to pod {pod_id}", file=sys.stderr, flush=True)
|
|
848
|
+
print(f"[CONNECT BACKGROUND] Successfully connected to pod {pod_id}, executor.is_remote=True", file=sys.stderr, flush=True)
|
|
817
849
|
else:
|
|
818
850
|
# Connection failed - clean up
|
|
819
851
|
print(f"[CONNECT BACKGROUND] Failed to connect: {result}", file=sys.stderr, flush=True)
|
morecompute/utils/cell_magics.py
CHANGED
|
@@ -248,6 +248,11 @@ class CellMagicHandlers:
|
|
|
248
248
|
env=env
|
|
249
249
|
)
|
|
250
250
|
|
|
251
|
+
# Track process for interrupt handling
|
|
252
|
+
if hasattr(cell_magic_handler, 'special_handler'):
|
|
253
|
+
cell_magic_handler.special_handler.current_process_sync = process
|
|
254
|
+
print(f"[CELL_MAGIC] Tracking sync subprocess PID={process.pid}", file=sys.stderr, flush=True)
|
|
255
|
+
|
|
251
256
|
# Read and print output line by line (real-time streaming)
|
|
252
257
|
def read_stream(stream, output_type):
|
|
253
258
|
"""Read stream line by line and print immediately"""
|
|
@@ -276,12 +281,38 @@ class CellMagicHandlers:
|
|
|
276
281
|
stdout_thread.start()
|
|
277
282
|
stderr_thread.start()
|
|
278
283
|
|
|
279
|
-
# Wait for process to complete
|
|
280
|
-
|
|
284
|
+
# Wait for process to complete, checking if it was killed
|
|
285
|
+
try:
|
|
286
|
+
# Poll with timeout to detect if process was killed externally
|
|
287
|
+
while process.poll() is None:
|
|
288
|
+
try:
|
|
289
|
+
process.wait(timeout=0.1)
|
|
290
|
+
except subprocess.TimeoutExpired:
|
|
291
|
+
# Check if interrupted
|
|
292
|
+
if hasattr(cell_magic_handler, 'special_handler'):
|
|
293
|
+
if cell_magic_handler.special_handler.sync_interrupted:
|
|
294
|
+
# Process was killed by interrupt handler
|
|
295
|
+
print(f"[CELL_MAGIC] Process was interrupted, raising KeyboardInterrupt", file=sys.stderr, flush=True)
|
|
296
|
+
raise KeyboardInterrupt("Execution interrupted by user")
|
|
297
|
+
|
|
298
|
+
return_code = process.returncode
|
|
299
|
+
except KeyboardInterrupt:
|
|
300
|
+
# Kill process if KeyboardInterrupt
|
|
301
|
+
try:
|
|
302
|
+
process.kill()
|
|
303
|
+
process.wait()
|
|
304
|
+
except Exception:
|
|
305
|
+
pass
|
|
306
|
+
raise
|
|
281
307
|
|
|
282
308
|
# Wait for output threads to finish
|
|
283
|
-
stdout_thread.join()
|
|
284
|
-
stderr_thread.join()
|
|
309
|
+
stdout_thread.join(timeout=1)
|
|
310
|
+
stderr_thread.join(timeout=1)
|
|
311
|
+
|
|
312
|
+
# Clear process reference
|
|
313
|
+
if hasattr(cell_magic_handler, 'special_handler'):
|
|
314
|
+
cell_magic_handler.special_handler.current_process_sync = None
|
|
315
|
+
print(f"[CELL_MAGIC] Cleared sync subprocess reference", file=sys.stderr, flush=True)
|
|
285
316
|
|
|
286
317
|
return return_code
|
|
287
318
|
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Converter utilities for notebook formats."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Set
|
|
7
|
+
from .py_percent_parser import generate_py_percent, parse_py_percent
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def extract_pip_dependencies(notebook_data: dict) -> Set[str]:
|
|
11
|
+
"""
|
|
12
|
+
Extract package names from !pip install and %pip install commands.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
notebook_data: Parsed notebook JSON
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
Set of package names
|
|
19
|
+
"""
|
|
20
|
+
packages = set()
|
|
21
|
+
|
|
22
|
+
for cell in notebook_data.get('cells', []):
|
|
23
|
+
if cell.get('cell_type') != 'code':
|
|
24
|
+
continue
|
|
25
|
+
|
|
26
|
+
source = cell.get('source', [])
|
|
27
|
+
if isinstance(source, list):
|
|
28
|
+
source = ''.join(source)
|
|
29
|
+
|
|
30
|
+
# Match: !pip install package1 package2
|
|
31
|
+
# Match: %pip install package1 package2
|
|
32
|
+
pip_pattern = r'[!%]pip\s+install\s+([^\n]+)'
|
|
33
|
+
matches = re.finditer(pip_pattern, source)
|
|
34
|
+
|
|
35
|
+
for match in matches:
|
|
36
|
+
install_line = match.group(1)
|
|
37
|
+
# Remove common flags
|
|
38
|
+
install_line = re.sub(r'--[^\s]+\s*', '', install_line)
|
|
39
|
+
install_line = re.sub(r'-[qU]\s*', '', install_line)
|
|
40
|
+
|
|
41
|
+
# Extract package names (handle package==version format)
|
|
42
|
+
parts = install_line.split()
|
|
43
|
+
for part in parts:
|
|
44
|
+
part = part.strip()
|
|
45
|
+
if part and not part.startswith('-'):
|
|
46
|
+
packages.add(part)
|
|
47
|
+
|
|
48
|
+
return packages
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def convert_ipynb_to_py(ipynb_path: Path, output_path: Path, include_uv_deps: bool = True) -> None:
|
|
52
|
+
"""
|
|
53
|
+
Convert .ipynb notebook to .py format with py:percent cell markers.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
ipynb_path: Path to input .ipynb file
|
|
57
|
+
output_path: Path to output .py file
|
|
58
|
+
include_uv_deps: Whether to add UV inline script dependencies
|
|
59
|
+
"""
|
|
60
|
+
# Read notebook
|
|
61
|
+
with open(ipynb_path, 'r', encoding='utf-8') as f:
|
|
62
|
+
notebook_data = json.load(f)
|
|
63
|
+
|
|
64
|
+
cells = notebook_data.get('cells', [])
|
|
65
|
+
|
|
66
|
+
# Generate UV dependencies header if requested
|
|
67
|
+
header_lines = []
|
|
68
|
+
if include_uv_deps:
|
|
69
|
+
dependencies = extract_pip_dependencies(notebook_data)
|
|
70
|
+
if dependencies:
|
|
71
|
+
header_lines.append('# /// script')
|
|
72
|
+
header_lines.append('# dependencies = [')
|
|
73
|
+
for dep in sorted(dependencies):
|
|
74
|
+
header_lines.append(f'# "{dep}",')
|
|
75
|
+
header_lines.append('# ]')
|
|
76
|
+
header_lines.append('# ///')
|
|
77
|
+
header_lines.append('')
|
|
78
|
+
|
|
79
|
+
# Generate py:percent format
|
|
80
|
+
py_content = generate_py_percent(cells)
|
|
81
|
+
|
|
82
|
+
# Combine header and content
|
|
83
|
+
if header_lines:
|
|
84
|
+
final_content = '\n'.join(header_lines) + '\n' + py_content
|
|
85
|
+
else:
|
|
86
|
+
final_content = py_content
|
|
87
|
+
|
|
88
|
+
# Write output
|
|
89
|
+
with open(output_path, 'w', encoding='utf-8') as f:
|
|
90
|
+
f.write(final_content)
|
|
91
|
+
|
|
92
|
+
print(f"✓ Converted {ipynb_path.name} → {output_path.name}")
|
|
93
|
+
|
|
94
|
+
# Show dependencies if found
|
|
95
|
+
if include_uv_deps and dependencies:
|
|
96
|
+
print(f" Found dependencies: {', '.join(sorted(dependencies))}")
|
|
97
|
+
print(f" Run with: more-compute {output_path.name}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def convert_py_to_ipynb(py_path: Path, output_path: Path) -> None:
|
|
101
|
+
"""
|
|
102
|
+
Convert .py notebook to .ipynb format.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
py_path: Path to input .py file
|
|
106
|
+
output_path: Path to output .ipynb file
|
|
107
|
+
"""
|
|
108
|
+
# Read .py file
|
|
109
|
+
with open(py_path, 'r', encoding='utf-8') as f:
|
|
110
|
+
py_content = f.read()
|
|
111
|
+
|
|
112
|
+
# Parse py:percent format to notebook structure
|
|
113
|
+
notebook_data = parse_py_percent(py_content)
|
|
114
|
+
|
|
115
|
+
# Ensure source is in list format (Jupyter notebook standard)
|
|
116
|
+
for cell in notebook_data.get('cells', []):
|
|
117
|
+
source = cell.get('source', '')
|
|
118
|
+
if isinstance(source, str):
|
|
119
|
+
# Split into lines and keep newlines (Jupyter format)
|
|
120
|
+
lines = source.split('\n')
|
|
121
|
+
# Add \n to each line except the last
|
|
122
|
+
cell['source'] = [line + '\n' for line in lines[:-1]] + ([lines[-1]] if lines[-1] else [])
|
|
123
|
+
|
|
124
|
+
# Write .ipynb file
|
|
125
|
+
with open(output_path, 'w', encoding='utf-8') as f:
|
|
126
|
+
json.dump(notebook_data, f, indent=1, ensure_ascii=False)
|
|
127
|
+
|
|
128
|
+
print(f"Converted {py_path.name} -> {output_path.name}")
|
|
129
|
+
print(f" Upload to Google Colab or open in Jupyter")
|
|
@@ -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)
|