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 CHANGED
@@ -643,7 +643,8 @@ body {
643
643
  }
644
644
 
645
645
  .markdown-rendered code {
646
- background: #f3f4f6;
646
+ background: var(--mc-secondary);
647
+ color: var(--mc-text-color);
647
648
  padding: 2px 4px;
648
649
  border-radius: 3px;
649
650
  font-family: 'Fira', 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
@@ -651,7 +652,7 @@ body {
651
652
  }
652
653
 
653
654
  .markdown-rendered pre {
654
- background: #f9fafb;
655
+ background: var(--mc-secondary);
655
656
  padding: 12px;
656
657
  border-radius: 6px;
657
658
  margin: 12px 0;
@@ -665,7 +666,7 @@ body {
665
666
  }
666
667
 
667
668
  .markdown-rendered blockquote {
668
- border-left: 3px solid #d1d5db;
669
+ border-left: 3px solid var(--mc-border);
669
670
  padding-left: 12px;
670
671
  margin: 12px 0;
671
672
  color: var(--mc-markdown-paragraph-color);
@@ -684,7 +685,7 @@ body {
684
685
  }
685
686
 
686
687
  .markdown-rendered .empty-markdown {
687
- color: #9ca3af;
688
+ color: var(--mc-line-number-color);
688
689
  font-style: italic;
689
690
  }
690
691
 
@@ -697,19 +698,20 @@ body {
697
698
  flex-direction: column;
698
699
  align-items: center;
699
700
  font-size: 11px;
700
- color: #6b7280;
701
+ color: var(--mc-line-number-color);
701
702
  width: 30px;
702
703
  }
703
704
 
704
705
  .execution-count {
705
706
  font-family: 'Fira', 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
706
707
  font-size: 10px;
707
- color: #9ca3af;
708
+ color: var(--mc-line-number-color);
708
709
  }
709
710
 
710
711
  .execution-time {
711
712
  font-size: 9px;
712
- color: #d1d5db;
713
+ color: var(--mc-line-number-color);
714
+ opacity: 0.7;
713
715
  margin-top: 2px;
714
716
  }
715
717
 
frontend/app/layout.tsx CHANGED
@@ -8,6 +8,7 @@ import PackagesPopup from "@/components/popups/PackagesPopup";
8
8
  import ComputePopup from "@/components/popups/ComputePopup";
9
9
  import MetricsPopup from "@/components/popups/MetricsPopup";
10
10
  import SettingsPopup from "@/components/popups/SettingsPopup";
11
+ import ConfirmModal from "@/components/modals/ConfirmModal";
11
12
  import { ConnectionBanner } from "@/components/layout/ConnectionBanner";
12
13
  import {
13
14
  PodWebSocketProvider,
@@ -22,6 +23,7 @@ const POLL_MS = 3000;
22
23
  function AppContent({ children }: { children: React.ReactNode }) {
23
24
  const [appSettings, setAppSettings] = useState<NotebookSettings>(() => loadSettings());
24
25
  const [activePopup, setActivePopup] = useState<string | null>(null);
26
+ const [showRestartModal, setShowRestartModal] = useState(false);
25
27
  const { connectionState, gpuPods, connectingPodId } = usePodWebSocket();
26
28
 
27
29
  // Persistent metrics collection
@@ -84,6 +86,15 @@ function AppContent({ children }: { children: React.ReactNode }) {
84
86
  setActivePopup(null);
85
87
  };
86
88
 
89
+ const handleRestartKernel = () => {
90
+ // Send reset kernel command via WebSocket
91
+ const ws = new WebSocket('ws://127.0.0.1:3141/ws');
92
+ ws.onopen = () => {
93
+ ws.send(JSON.stringify({ type: 'reset_kernel' }));
94
+ setTimeout(() => ws.close(), 100);
95
+ };
96
+ };
97
+
87
98
  const renderPopup = () => {
88
99
  if (!activePopup) return null;
89
100
 
@@ -176,7 +187,28 @@ function AppContent({ children }: { children: React.ReactNode }) {
176
187
  id="kernel-status-dot"
177
188
  className="status-dot connecting"
178
189
  ></span>
179
- <span id="kernel-status-text" className="status-text">
190
+ <span
191
+ id="kernel-status-text"
192
+ className="status-text"
193
+ data-original-text="Connecting..."
194
+ style={{
195
+ cursor: 'pointer',
196
+ transition: 'all 0.15s ease',
197
+ }}
198
+ onMouseEnter={(e) => {
199
+ const originalText = e.currentTarget.textContent || '';
200
+ e.currentTarget.setAttribute('data-original-text', originalText);
201
+ e.currentTarget.textContent = 'Restart Kernel';
202
+ e.currentTarget.style.color = 'var(--mc-primary)';
203
+ }}
204
+ onMouseLeave={(e) => {
205
+ const originalText = e.currentTarget.getAttribute('data-original-text') || 'Connecting...';
206
+ e.currentTarget.textContent = originalText;
207
+ e.currentTarget.style.color = '';
208
+ }}
209
+ onClick={() => setShowRestartModal(true)}
210
+ title="Click to restart kernel"
211
+ >
180
212
  Connecting...
181
213
  </span>
182
214
  </div>
@@ -197,6 +229,16 @@ function AppContent({ children }: { children: React.ReactNode }) {
197
229
  />
198
230
  </div>
199
231
  </div>
232
+ <ConfirmModal
233
+ isOpen={showRestartModal}
234
+ onClose={() => setShowRestartModal(false)}
235
+ onConfirm={handleRestartKernel}
236
+ title="Restart Kernel"
237
+ message="Are you sure you want to restart the kernel? All variables will be lost."
238
+ confirmLabel="Restart"
239
+ cancelLabel="Cancel"
240
+ isDangerous={true}
241
+ />
200
242
  </>
201
243
  );
202
244
  }
@@ -276,10 +276,18 @@ function notebookReducer(
276
276
  case "EXECUTION_COMPLETE": {
277
277
  const payload = action.payload || {};
278
278
  const cell_index = payload.cell_index;
279
+
280
+ console.log(`[EXECUTION_COMPLETE] Processing completion for cell ${cell_index}`, payload);
281
+
279
282
  // Support both shapes: { result: {...} } and flat payload {...}
280
283
  const result = payload && payload.result ? payload.result : payload || {};
284
+
285
+ console.log(`[EXECUTION_COMPLETE] result.status=${result.status}, result.error=`, result.error);
286
+
287
+ // ALWAYS remove cell from executingCells when completion arrives
281
288
  const newExecuting = new Set(state.executingCells);
282
289
  newExecuting.delete(cell_index);
290
+
283
291
  return {
284
292
  ...state,
285
293
  executingCells: newExecuting,
@@ -310,8 +318,13 @@ function notebookReducer(
310
318
 
311
319
  case "EXECUTION_ERROR": {
312
320
  const { cell_index, error } = action.payload;
321
+
322
+ console.log(`[EXECUTION_ERROR] Processing error for cell ${cell_index}`);
323
+
324
+ // ALWAYS remove cell from executingCells when error arrives
313
325
  const newExecuting = new Set(state.executingCells);
314
326
  newExecuting.delete(cell_index);
327
+
315
328
  const normalizedError = normalizeError(error);
316
329
  return {
317
330
  ...state,
@@ -348,6 +361,8 @@ function notebookReducer(
348
361
  ...cell,
349
362
  outputs: [],
350
363
  execution_count: null,
364
+ execution_time: null,
365
+ error: null,
351
366
  })),
352
367
  };
353
368
 
@@ -435,6 +450,10 @@ export const Notebook: React.FC<NotebookProps> = ({
435
450
  dispatch({ type: "NOTEBOOK_UPDATED", payload: data });
436
451
  }, []);
437
452
 
453
+ const handleKernelRestarted = useCallback(() => {
454
+ dispatch({ type: "RESET_KERNEL" });
455
+ }, []);
456
+
438
457
  const handleHeartbeat = useCallback((data: any) => {
439
458
  // Heartbeat received - execution still in progress
440
459
  // Cell spinner already showing via executingCells set
@@ -473,7 +492,7 @@ export const Notebook: React.FC<NotebookProps> = ({
473
492
  wsRef.current = ws;
474
493
  handleKernelStatusUpdate("connecting");
475
494
 
476
- ws.connect("ws://127.0.0.1:8000/ws")
495
+ ws.connect("ws://127.0.0.1:3141/ws")
477
496
  .then(() => {
478
497
  ws.loadNotebook(notebookName || "default");
479
498
  })
@@ -486,6 +505,7 @@ export const Notebook: React.FC<NotebookProps> = ({
486
505
  ws.on("disconnect", () => handleKernelStatusUpdate("disconnected"));
487
506
  ws.on("notebook_loaded", handleNotebookLoaded);
488
507
  ws.on("notebook_updated", handleNotebookUpdate);
508
+ ws.on("kernel_restarted", handleKernelRestarted);
489
509
  ws.on("execution_start", handleExecutionStart);
490
510
  ws.on("stream_output", handleStreamOutput);
491
511
  ws.on("execution_complete", handleExecutionComplete);
@@ -499,6 +519,7 @@ export const Notebook: React.FC<NotebookProps> = ({
499
519
  handleKernelStatusUpdate,
500
520
  handleNotebookLoaded,
501
521
  handleNotebookUpdate,
522
+ handleKernelRestarted,
502
523
  handleExecutionStart,
503
524
  handleStreamOutput,
504
525
  handleExecuteResult,
@@ -22,7 +22,8 @@ export const CellButton: React.FC<CellButtonProps> = ({
22
22
  const [isHovered, setIsHovered] = useState(false);
23
23
 
24
24
  const handleClick = (e: React.MouseEvent) => {
25
- if (onClick && !disabled && !isLoading) {
25
+ if (onClick && !disabled) {
26
+ // Allow clicks even when loading (for stop/interrupt functionality)
26
27
  onClick(e);
27
28
  }
28
29
  };
@@ -34,7 +35,7 @@ export const CellButton: React.FC<CellButtonProps> = ({
34
35
  onClick={handleClick}
35
36
  onMouseEnter={() => setIsHovered(true)}
36
37
  onMouseLeave={() => setIsHovered(false)}
37
- disabled={disabled || isLoading}
38
+ disabled={disabled}
38
39
  title={title}
39
40
  style={{
40
41
  width: '28px',
@@ -45,9 +46,9 @@ export const CellButton: React.FC<CellButtonProps> = ({
45
46
  display: 'flex',
46
47
  alignItems: 'center',
47
48
  justifyContent: 'center',
48
- cursor: disabled || isLoading ? 'not-allowed' : 'pointer',
49
+ cursor: disabled ? 'not-allowed' : 'pointer',
49
50
  transition: 'background-color 0.15s ease',
50
- opacity: disabled ? 0.5 : 1
51
+ opacity: disabled ? 0.5 : isLoading ? 0.8 : 1
51
52
  }}
52
53
  >
53
54
  {icon}
@@ -12,6 +12,7 @@ import {
12
12
  UpdateIcon,
13
13
  LinkBreak2Icon,
14
14
  PlayIcon,
15
+ StopIcon,
15
16
  ChevronUpIcon,
16
17
  ChevronDownIcon,
17
18
  } from "@radix-ui/react-icons";
@@ -269,13 +270,13 @@ export const MonacoCell: React.FC<CellProps> = ({
269
270
  onExecute(indexRef.current);
270
271
  setIsEditing(false);
271
272
  } else {
272
- if (isExecuting) {
273
- onInterrupt(indexRef.current);
274
- } else {
275
- onExecute(indexRef.current);
276
- }
273
+ onExecute(indexRef.current);
277
274
  }
278
- }, [cell.cell_type, isExecuting, onExecute, onInterrupt]);
275
+ }, [cell.cell_type, onExecute]);
276
+
277
+ const handleInterrupt = useCallback(() => {
278
+ onInterrupt(indexRef.current);
279
+ }, [onInterrupt]);
279
280
 
280
281
  const handleCellClick = () => {
281
282
  onSetActive(indexRef.current);
@@ -583,15 +584,26 @@ export const MonacoCell: React.FC<CellProps> = ({
583
584
  <div className="cell-hover-controls">
584
585
  <div className="cell-actions-right">
585
586
  {!isMarkdownWithContent && (
586
- <CellButton
587
- icon={<PlayIcon className="w-6 h-6" />}
588
- onClick={(e) => {
589
- e.stopPropagation();
590
- handleExecute();
591
- }}
592
- title={isExecuting ? "Stop execution" : "Run cell"}
593
- isLoading={isExecuting}
594
- />
587
+ <>
588
+ <CellButton
589
+ icon={<PlayIcon className="w-6 h-6" />}
590
+ onClick={(e) => {
591
+ e.stopPropagation();
592
+ handleExecute();
593
+ }}
594
+ title="Run cell"
595
+ disabled={isExecuting}
596
+ />
597
+ <CellButton
598
+ icon={<StopIcon className="w-6 h-6" />}
599
+ onClick={(e) => {
600
+ e.stopPropagation();
601
+ handleInterrupt();
602
+ }}
603
+ title="Stop execution"
604
+ disabled={!isExecuting}
605
+ />
606
+ </>
595
607
  )}
596
608
  <CellButton
597
609
  icon={<ChevronUpIcon className="w-6 h-6" />}
@@ -599,8 +611,8 @@ export const MonacoCell: React.FC<CellProps> = ({
599
611
  e.stopPropagation();
600
612
  onMoveUp(indexRef.current);
601
613
  }}
602
- title="Move cell up"
603
- disabled={index === 0}
614
+ title={isExecuting ? "Cannot move while executing" : "Move cell up"}
615
+ disabled={isExecuting || index === 0}
604
616
  />
605
617
  <CellButton
606
618
  icon={<ChevronDownIcon className="w-6 h-6" />}
@@ -608,16 +620,25 @@ export const MonacoCell: React.FC<CellProps> = ({
608
620
  e.stopPropagation();
609
621
  onMoveDown(indexRef.current);
610
622
  }}
611
- title="Move cell down"
612
- disabled={index === totalCells - 1}
623
+ title={isExecuting ? "Cannot move while executing" : "Move cell down"}
624
+ disabled={isExecuting || index === totalCells - 1}
613
625
  />
614
626
  <CellButton
615
627
  icon={<LinkBreak2Icon className="w-5 h-5" />}
616
628
  onClick={(e) => {
617
629
  e.stopPropagation();
618
- onDelete(indexRef.current);
630
+ // If cell is executing, interrupt it first
631
+ if (isExecuting) {
632
+ onInterrupt(indexRef.current);
633
+ // Wait a bit for interrupt to complete, then delete
634
+ setTimeout(() => {
635
+ onDelete(indexRef.current);
636
+ }, 500);
637
+ } else {
638
+ onDelete(indexRef.current);
639
+ }
619
640
  }}
620
- title="Delete cell"
641
+ title={isExecuting ? "Stop and delete cell" : "Delete cell"}
621
642
  />
622
643
  </div>
623
644
  </div>
@@ -165,7 +165,20 @@ const TypedErrorDisplay: FC<{ error: ErrorOutput }> = ({ error }) => {
165
165
  margin: 0
166
166
  }}
167
167
  >
168
- {error.traceback?.join('\n') || ''}
168
+ {/* Always show error name and message */}
169
+ {error.ename && (
170
+ <div style={{ fontWeight: 600, marginBottom: '4px' }}>
171
+ {error.ename}: {error.evalue}
172
+ </div>
173
+ )}
174
+ {/* Show traceback if available */}
175
+ {error.traceback && error.traceback.length > 0 && (
176
+ <div>{error.traceback.join('\n')}</div>
177
+ )}
178
+ {/* Fallback if no traceback */}
179
+ {(!error.traceback || error.traceback.length === 0) && !error.ename && (
180
+ <div>An unknown error occurred</div>
181
+ )}
169
182
  </div>
170
183
  </div>
171
184
  </div>
@@ -95,7 +95,7 @@ export const PodWebSocketProvider: React.FC<PodWebSocketProviderProps> = ({ chil
95
95
  wsRef.current = null;
96
96
  }
97
97
 
98
- const wsUrl = 'ws://127.0.0.1:8000/ws';
98
+ const wsUrl = 'ws://127.0.0.1:3141/ws';
99
99
  const ws = new WebSocket(wsUrl);
100
100
 
101
101
  ws.onopen = () => {
frontend/lib/websocket.ts CHANGED
@@ -53,12 +53,12 @@ export class WebSocketService {
53
53
  };
54
54
  }
55
55
 
56
- connect(url: string = 'ws://localhost:8000'): Promise<void> {
56
+ connect(url: string = 'ws://localhost:3141'): Promise<void> {
57
57
  return new Promise((resolve, reject) => {
58
58
  // For development, connect directly to the backend WebSocket
59
59
  const wsUrl = process.env.NODE_ENV === 'production'
60
60
  ? '/ws'
61
- : 'ws://localhost:8000/ws';
61
+ : 'ws://localhost:3141/ws';
62
62
 
63
63
  // Use native WebSocket for FastAPI compatibility
64
64
  const ws = new WebSocket(wsUrl);
frontend/next.config.mjs CHANGED
@@ -4,11 +4,11 @@ const nextConfig = {
4
4
  return [
5
5
  {
6
6
  source: '/ws/:path*',
7
- destination: 'http://localhost:8000/ws/:path*',
7
+ destination: 'http://localhost:3141/ws/:path*',
8
8
  },
9
9
  {
10
10
  source: '/api/:path*',
11
- destination: 'http://localhost:8000/api/:path*',
11
+ destination: 'http://localhost:3141/api/:path*',
12
12
  },
13
13
  ];
14
14
  },
kernel_run.py CHANGED
@@ -33,8 +33,8 @@ class NotebookLauncher:
33
33
  def start_backend(self):
34
34
  """Start the FastAPI backend server"""
35
35
  try:
36
- # Force a stable port (default 8000); if busy, ask to free it
37
- chosen_port = int(os.getenv("MORECOMPUTE_PORT", "8000"))
36
+ # Force a stable port (default 3141); if busy, ask to free it
37
+ chosen_port = int(os.getenv("MORECOMPUTE_PORT", "3141"))
38
38
  self._ensure_port_available(chosen_port)
39
39
  cmd = [
40
40
  sys.executable,
@@ -406,21 +406,40 @@ def build_parser() -> argparse.ArgumentParser:
406
406
 
407
407
  def ensure_notebook_exists(notebook_path: Path):
408
408
  if notebook_path.exists():
409
- if notebook_path.suffix != '.ipynb':
409
+ # File exists, check extension
410
+ if notebook_path.suffix == '.ipynb':
410
411
  raise ValueError(
411
- f"Error: '{notebook_path}' is not a notebook file.\n"
412
- f"Notebook files must have a .ipynb extension.\n"
413
- f"Example: more-compute {notebook_path}.ipynb"
412
+ f"Error: MoreCompute only supports .py notebooks.\n\n"
413
+ f"Convert your notebook with:\n"
414
+ f" more-compute convert {notebook_path.name} -o {notebook_path.stem}.py\n\n"
415
+ f"Then open with:\n"
416
+ f" more-compute {notebook_path.stem}.py"
417
+ )
418
+ elif notebook_path.suffix != '.py':
419
+ raise ValueError(
420
+ f"Error: '{notebook_path}' is not a Python notebook file.\n"
421
+ f"Notebook files must have a .py extension.\n"
422
+ f"Example: more-compute {notebook_path.stem}.py"
414
423
  )
415
424
  return
416
425
 
417
- if notebook_path.suffix != '.ipynb':
426
+ # File doesn't exist, create it
427
+ if notebook_path.suffix == '.ipynb':
428
+ raise ValueError(
429
+ f"Error: MoreCompute only supports .py notebooks.\n\n"
430
+ f"Convert your notebook with:\n"
431
+ f" more-compute convert {notebook_path.name} -o {notebook_path.stem}.py\n\n"
432
+ f"Or create a new notebook:\n"
433
+ f" more-compute new"
434
+ )
435
+
436
+ if notebook_path.suffix != '.py':
418
437
  raise ValueError(
419
- f"Error: '{notebook_path}' does not have the .ipynb extension.\n"
420
- f"Notebook files must end with .ipynb\n\n"
438
+ f"Error: '{notebook_path}' does not have the .py extension.\n"
439
+ f"Notebook files must end with .py\n\n"
421
440
  f"Did you mean?\n"
422
- f" more-compute {notebook_path}.ipynb\n\n"
423
- f"Or to create a new notebook with timestamp:\n"
441
+ f" more-compute {notebook_path}.py\n\n"
442
+ f"Or to create a new notebook:\n"
424
443
  f" more-compute new"
425
444
  )
426
445
 
@@ -431,14 +450,18 @@ def ensure_notebook_exists(notebook_path: Path):
431
450
 
432
451
  def print_help():
433
452
  """Print concise help message"""
434
- print(f"""Usage: more-compute [OPTIONS] [NOTEBOOK]
453
+ print(f"""Usage: more-compute [OPTIONS] [COMMAND] [NOTEBOOK]
435
454
 
436
- MoreCompute - Jupyter notebooks with GPU compute
455
+ MoreCompute - Python notebooks with GPU compute
437
456
 
438
457
  Getting started:
439
458
 
440
459
  * more-compute new create a new notebook with timestamp
441
- * more-compute notebook.ipynb open or create notebook.ipynb
460
+ * more-compute notebook.py open or create notebook.py
461
+
462
+ Commands:
463
+ convert NOTEBOOK -o OUTPUT convert .ipynb to .py format
464
+ Example: more-compute convert notebook.ipynb -o notebook.py
442
465
 
443
466
  Options:
444
467
  --version, -v Show version and exit
@@ -446,12 +469,72 @@ Options:
446
469
  --help, -h Show this message and exit
447
470
 
448
471
  Environment variables:
449
- MORECOMPUTE_PORT Backend port (default: 8000)
472
+ MORECOMPUTE_PORT Backend port (default: 3141)
450
473
  MORECOMPUTE_FRONTEND_PORT Frontend port (default: 2718)
451
474
  MORECOMPUTE_NOTEBOOK_PATH Default notebook path
475
+
476
+ Note: MoreCompute uses .py notebooks (not .ipynb). Convert existing notebooks with:
477
+ more-compute convert notebook.ipynb -o notebook.py
452
478
  """)
453
479
 
454
480
  def main(argv=None):
481
+ # Handle convert command before argparse (to avoid parsing -o flag)
482
+ argv_to_check = argv if argv is not None else sys.argv[1:]
483
+ if len(argv_to_check) > 0 and argv_to_check[0] == "convert":
484
+ # Parse convert arguments
485
+ if len(sys.argv) < 3:
486
+ print("Error: convert command requires input file")
487
+ print("\nUsage:")
488
+ print(" more-compute convert notebook.ipynb # -> notebook.py")
489
+ print(" more-compute convert notebook.py # -> notebook.ipynb")
490
+ print(" more-compute convert notebook.ipynb -o out.py")
491
+ sys.exit(1)
492
+
493
+ input_file = Path(sys.argv[2])
494
+ output_file = None
495
+
496
+ # Parse -o flag
497
+ if len(sys.argv) >= 5 and sys.argv[3] == "-o":
498
+ output_file = Path(sys.argv[4])
499
+ else:
500
+ # Auto-detect output extension based on input
501
+ if input_file.suffix == '.ipynb':
502
+ output_file = input_file.with_suffix('.py')
503
+ elif input_file.suffix == '.py':
504
+ output_file = input_file.with_suffix('.ipynb')
505
+ else:
506
+ print(f"Error: Unsupported file type: {input_file.suffix}")
507
+ print("Supported: .ipynb, .py")
508
+ sys.exit(1)
509
+
510
+ if not input_file.exists():
511
+ print(f"Error: File not found: {input_file}")
512
+ sys.exit(1)
513
+
514
+ # Perform conversion based on input type
515
+ if input_file.suffix == '.ipynb':
516
+ from morecompute.utils.notebook_converter import convert_ipynb_to_py
517
+ try:
518
+ convert_ipynb_to_py(input_file, output_file)
519
+ sys.exit(0)
520
+ except Exception as e:
521
+ print(f"Error converting notebook: {e}")
522
+ sys.exit(1)
523
+ elif input_file.suffix == '.py':
524
+ from morecompute.utils.notebook_converter import convert_py_to_ipynb
525
+ try:
526
+ convert_py_to_ipynb(input_file, output_file)
527
+ sys.exit(0)
528
+ except Exception as e:
529
+ print(f"Error converting notebook: {e}")
530
+ sys.exit(1)
531
+ else:
532
+ print(f"Error: Can only convert .ipynb or .py files")
533
+ print(f"Got: {input_file.suffix}")
534
+ sys.exit(1)
535
+
536
+
537
+ # Parse arguments for non-convert commands
455
538
  parser = build_parser()
456
539
  args = parser.parse_args(argv)
457
540
 
@@ -462,10 +545,11 @@ def main(argv=None):
462
545
 
463
546
  raw_notebook_path = args.notebook_path
464
547
 
548
+ # Handle "new" command
465
549
  if raw_notebook_path == "new":
466
550
  from datetime import datetime
467
551
  timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
468
- raw_notebook_path = f"notebook_{timestamp}.ipynb"
552
+ raw_notebook_path = f"notebook_{timestamp}.py"
469
553
  print(f"Creating new notebook: {raw_notebook_path}")
470
554
 
471
555
  notebook_path_env = os.getenv("MORECOMPUTE_NOTEBOOK_PATH")
@@ -477,7 +561,13 @@ def main(argv=None):
477
561
  sys.exit(0)
478
562
 
479
563
  notebook_path = Path(raw_notebook_path).expanduser().resolve()
480
- ensure_notebook_exists(notebook_path)
564
+
565
+ try:
566
+ ensure_notebook_exists(notebook_path)
567
+ except ValueError as e:
568
+ # Print clean error message without traceback
569
+ print(str(e), file=sys.stderr)
570
+ sys.exit(1)
481
571
 
482
572
  launcher = NotebookLauncher(
483
573
  notebook_path=notebook_path,