more-compute 0.2.6__py3-none-any.whl → 0.3.1__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.
@@ -0,0 +1,713 @@
1
+ import os
2
+ import io
3
+ import sys
4
+ import time
5
+ import asyncio
6
+ import shlex
7
+ import subprocess
8
+ import platform
9
+ from contextlib import redirect_stdout, redirect_stderr
10
+ from typing import Dict, Any, Optional
11
+ from fastapi import WebSocket
12
+
13
+ from .shell_utils import prepare_shell_command, prepare_shell_environment
14
+
15
+
16
+ class CellMagicHandlers:
17
+ """Handlers for IPython cell magic commands (%%magic)"""
18
+
19
+ def __init__(self, globals_dict: dict, special_handler):
20
+ self.globals_dict = globals_dict
21
+ self.special_handler = special_handler # Reference to parent AsyncSpecialCommandHandler
22
+ self.captured_outputs = {}
23
+
24
+ async def execute_cell_content(self, cell_content: str, result: Dict[str, Any],
25
+ execution_count: int, websocket: Optional[WebSocket] = None,
26
+ capture_stdout: bool = False, capture_stderr: bool = False) -> tuple:
27
+ """
28
+ Execute cell content that may contain shell commands (!cmd) mixed with Python code.
29
+
30
+ Uses preprocessing to transform shell commands into Python function calls.
31
+ This approach properly handles shell commands inside control structures.
32
+
33
+ Supported:
34
+ - Shell commands on their own lines (can be indented): `!pip install pandas`
35
+ - Shell commands in if/else blocks: `if x:\n !ls`
36
+ - Mixed Python and shell code in same cell
37
+ - Async execution (non-blocking)
38
+ - Cross-platform (Windows/macOS/Linux)
39
+
40
+ Returns:
41
+ (stdout_output, stderr_output) if capturing, otherwise (None, None)
42
+ """
43
+ stdout_capture = io.StringIO() if capture_stdout else None
44
+ stderr_capture = io.StringIO() if capture_stderr else None
45
+
46
+ # Store websocket reference for shell command execution
47
+ self._current_websocket = websocket
48
+ self._capture_mode = (capture_stdout or capture_stderr)
49
+
50
+ # Use preprocessing approach for all cases
51
+ # This transforms !cmd into function calls, preserving Python syntax
52
+ transformed_code = await self._preprocess_cell_content(cell_content, capture_mode=(capture_stdout or capture_stderr))
53
+
54
+ try:
55
+ # Compile the code
56
+ compiled_code = compile(transformed_code, '<cell>', 'exec')
57
+
58
+ if capture_stdout or capture_stderr:
59
+ # Execute with output capture in a thread pool to avoid blocking
60
+ # This allows us to send heartbeats while long-running commands execute
61
+ loop = asyncio.get_event_loop()
62
+
63
+ def _exec_with_capture():
64
+ with redirect_stdout(stdout_capture or sys.stdout), \
65
+ redirect_stderr(stderr_capture or sys.stderr):
66
+ exec(compiled_code, self.globals_dict)
67
+
68
+ # Run in thread pool
69
+ exec_future = loop.run_in_executor(None, _exec_with_capture)
70
+
71
+ # Send heartbeats while waiting
72
+ heartbeat_count = 0
73
+ while not exec_future.done():
74
+ heartbeat_count += 1
75
+ if websocket and heartbeat_count % 2 == 0: # Every 2 seconds
76
+ try:
77
+ await websocket.send_json({
78
+ "type": "heartbeat",
79
+ "data": {"status": "executing", "message": "Executing cell..."}
80
+ })
81
+ except:
82
+ pass # WebSocket might be closed
83
+ await asyncio.sleep(1)
84
+
85
+ # Wait for completion
86
+ await exec_future
87
+ else:
88
+ # Execute normally
89
+ exec(compiled_code, self.globals_dict)
90
+ except Exception as e:
91
+ raise e
92
+ finally:
93
+ # Clean up websocket reference
94
+ self._current_websocket = None
95
+ self._capture_mode = False
96
+
97
+ # Return captured outputs
98
+ return (
99
+ stdout_capture.getvalue() if stdout_capture else None,
100
+ stderr_capture.getvalue() if stderr_capture else None
101
+ )
102
+
103
+ async def _execute_cell_with_async_shell(self, cell_content: str,
104
+ stdout_capture: Optional[io.StringIO],
105
+ stderr_capture: Optional[io.StringIO],
106
+ websocket: Optional[WebSocket]) -> None:
107
+ """
108
+ Execute cell content line-by-line with async shell command execution.
109
+ Used for %%capture mode to avoid blocking on long pip installs.
110
+ """
111
+ import re
112
+
113
+ lines = cell_content.split('\n')
114
+ python_lines = []
115
+
116
+ for line in lines:
117
+ # Check if this line is a shell command
118
+ shell_match = re.match(r'^(\s*)!(.+)$', line)
119
+
120
+ if shell_match:
121
+ # Execute accumulated Python code first
122
+ if python_lines:
123
+ python_code = '\n'.join(python_lines)
124
+ if stdout_capture or stderr_capture:
125
+ with redirect_stdout(stdout_capture or sys.stdout), \
126
+ redirect_stderr(stderr_capture or sys.stderr):
127
+ exec(compile(python_code, '<cell>', 'exec'), self.globals_dict)
128
+ else:
129
+ exec(compile(python_code, '<cell>', 'exec'), self.globals_dict)
130
+ python_lines = []
131
+
132
+ # Execute shell command asynchronously
133
+ shell_cmd = shell_match.group(2).strip()
134
+ await self._run_shell_non_blocking(
135
+ shell_cmd, stdout_capture, stderr_capture, websocket
136
+ )
137
+ else:
138
+ # Accumulate Python line
139
+ python_lines.append(line)
140
+
141
+ # Execute remaining Python code
142
+ if python_lines:
143
+ python_code = '\n'.join(python_lines)
144
+ if stdout_capture or stderr_capture:
145
+ with redirect_stdout(stdout_capture or sys.stdout), \
146
+ redirect_stderr(stderr_capture or sys.stderr):
147
+ exec(compile(python_code, '<cell>', 'exec'), self.globals_dict)
148
+ else:
149
+ exec(compile(python_code, '<cell>', 'exec'), self.globals_dict)
150
+
151
+ async def _run_shell_non_blocking(self, cmd: str,
152
+ stdout_capture: Optional[io.StringIO],
153
+ stderr_capture: Optional[io.StringIO],
154
+ websocket: Optional[WebSocket]) -> int:
155
+ """
156
+ Run shell command in thread pool executor (non-blocking).
157
+ Sends periodic heartbeats to keep frontend alive during long operations.
158
+
159
+ Returns:
160
+ Return code from subprocess
161
+ """
162
+ loop = asyncio.get_event_loop()
163
+
164
+ # Prepare platform-specific shell command
165
+ system = platform.system()
166
+ if system == 'Windows':
167
+ shell_cmd = ['cmd', '/c', cmd]
168
+ else:
169
+ shell_cmd = ['/bin/bash', '-c', cmd]
170
+
171
+ # Run blocking subprocess in thread pool
172
+ shell_future = loop.run_in_executor(
173
+ None, # Uses default ThreadPoolExecutor
174
+ lambda: subprocess.run(
175
+ shell_cmd,
176
+ stdout=subprocess.PIPE,
177
+ stderr=subprocess.PIPE,
178
+ text=True,
179
+ env=os.environ.copy(),
180
+ timeout=1800 # 30 minute timeout
181
+ )
182
+ )
183
+
184
+ # Send heartbeats while waiting
185
+ heartbeat_count = 0
186
+ while not shell_future.done():
187
+ heartbeat_count += 1
188
+ if websocket and heartbeat_count % 2 == 0: # Every 2 seconds
189
+ try:
190
+ await websocket.send_json({
191
+ "type": "heartbeat",
192
+ "data": {"status": "executing", "message": "Running shell command..."}
193
+ })
194
+ except:
195
+ pass # WebSocket might be closed, continue anyway
196
+ await asyncio.sleep(1)
197
+
198
+ # Get result
199
+ proc = await shell_future
200
+
201
+ # Write captured output
202
+ if stdout_capture and proc.stdout:
203
+ stdout_capture.write(proc.stdout)
204
+ if stderr_capture and proc.stderr:
205
+ stderr_capture.write(proc.stderr)
206
+
207
+ return proc.returncode
208
+
209
+ async def _preprocess_cell_content(self, cell_content: str, capture_mode: bool = False) -> str:
210
+ """
211
+ Preprocess cell content to transform shell commands into Python code.
212
+ This is how IPython handles !commands - it transforms them before execution.
213
+
214
+ Args:
215
+ cell_content: The cell content to preprocess
216
+ capture_mode: Whether this is being called from %%capture (unused for now, but available for future use)
217
+
218
+ Example:
219
+ !pip install pandas -> _run_shell_command('pip install pandas')
220
+ """
221
+ import re
222
+
223
+ lines = cell_content.split('\n')
224
+ transformed_lines = []
225
+
226
+ # Create a shell command executor function in globals if not exists
227
+ # Use a closure to capture self reference for capture mode detection
228
+ cell_magic_handler = self # Capture self in closure
229
+
230
+ if '_run_shell_command' not in self.globals_dict:
231
+ def _run_shell_command(cmd: str):
232
+ """Execute a shell command synchronously with streaming output (injected by cell magic handler)"""
233
+ import subprocess
234
+ import threading
235
+
236
+ # Prepare command and environment (using shared utilities)
237
+ shell_cmd = prepare_shell_command(cmd)
238
+ env = prepare_shell_environment(cmd)
239
+
240
+ # Stream output in real-time (like regular !commands)
241
+ # Use Popen instead of run to get real-time output
242
+ process = subprocess.Popen(
243
+ shell_cmd,
244
+ stdout=subprocess.PIPE,
245
+ stderr=subprocess.PIPE,
246
+ text=True,
247
+ bufsize=1, # Line buffered
248
+ env=env
249
+ )
250
+
251
+ # Read and print output line by line (real-time streaming)
252
+ def read_stream(stream, output_type):
253
+ """Read stream line by line and print immediately"""
254
+ try:
255
+ for line in iter(stream.readline, ''):
256
+ if not line:
257
+ break
258
+ # Print immediately (unless in capture mode)
259
+ if not getattr(cell_magic_handler, '_capture_mode', False):
260
+ if output_type == 'stdout':
261
+ print(line, end='')
262
+ sys.stdout.flush() # Force immediate flush for streaming
263
+ else:
264
+ print(line, end='', file=sys.stderr)
265
+ sys.stderr.flush() # Force immediate flush for streaming
266
+ except Exception:
267
+ pass
268
+ finally:
269
+ stream.close()
270
+
271
+ # Start threads to read stdout and stderr concurrently
272
+ stdout_thread = threading.Thread(target=read_stream, args=(process.stdout, 'stdout'))
273
+ stderr_thread = threading.Thread(target=read_stream, args=(process.stderr, 'stderr'))
274
+ stdout_thread.daemon = True
275
+ stderr_thread.daemon = True
276
+ stdout_thread.start()
277
+ stderr_thread.start()
278
+
279
+ # Wait for process to complete
280
+ return_code = process.wait()
281
+
282
+ # Wait for output threads to finish
283
+ stdout_thread.join()
284
+ stderr_thread.join()
285
+
286
+ return return_code
287
+
288
+ self.globals_dict['_run_shell_command'] = _run_shell_command
289
+
290
+ # Transform each line
291
+ for line in lines:
292
+ # Match shell commands: " !pip install pandas"
293
+ shell_match = re.match(r'^(\s*)!(.+)$', line)
294
+
295
+ if shell_match:
296
+ indent = shell_match.group(1)
297
+ shell_cmd = shell_match.group(2).strip()
298
+
299
+ # Use repr() for proper escaping of quotes and backslashes
300
+ # This handles all edge cases correctly
301
+ shell_cmd_repr = repr(shell_cmd)
302
+
303
+ # Transform to synchronous function call
304
+ # This preserves Python syntax (shell command becomes valid Python)
305
+ transformed = f"{indent}_run_shell_command({shell_cmd_repr})"
306
+ transformed_lines.append(transformed)
307
+ else:
308
+ # Regular Python line
309
+ transformed_lines.append(line)
310
+
311
+ return '\n'.join(transformed_lines)
312
+
313
+ async def handle_capture(self, args: list, cell_content: str, result: Dict[str, Any],
314
+ start_time: float, execution_count: int,
315
+ websocket: Optional[WebSocket] = None) -> Dict[str, Any]:
316
+ """
317
+ Handle %%capture magic - capture stdout/stderr without displaying.
318
+ Usage: %%capture [output_var] [--no-stdout] [--no-stderr] [--no-display]
319
+ """
320
+ output_var = None
321
+ no_stdout = "--no-stdout" in args
322
+ no_stderr = "--no-stderr" in args
323
+
324
+ # First non-flag argument is the output variable name
325
+ for arg in args:
326
+ if not arg.startswith('--'):
327
+ output_var = arg
328
+ break
329
+
330
+ capture_stdout = not no_stdout
331
+ capture_stderr = not no_stderr
332
+
333
+ try:
334
+ stdout_text, stderr_text = await self.execute_cell_content(
335
+ cell_content, result, execution_count, websocket,
336
+ capture_stdout, capture_stderr
337
+ )
338
+
339
+ # Store captured output in a variable if specified
340
+ if output_var:
341
+ captured_data = {
342
+ 'stdout': stdout_text or '',
343
+ 'stderr': stderr_text or ''
344
+ }
345
+ self.globals_dict[output_var] = captured_data
346
+ self.captured_outputs[output_var] = captured_data
347
+
348
+ # Check if there were any errors in stderr (pip failures, etc.)
349
+ if stderr_text and stderr_text.strip():
350
+ # Check for common error indicators
351
+ stderr_lower = stderr_text.lower()
352
+ if any(err in stderr_lower for err in ['error:', 'failed', 'could not', 'unable to']):
353
+ # Add a warning output (not a full error, just FYI)
354
+ result["outputs"].append({
355
+ "output_type": "stream",
356
+ "name": "stderr",
357
+ "text": f" Captured stderr (may contain errors):\n{stderr_text[:500]}{'...' if len(stderr_text) > 500 else ''}\n"
358
+ })
359
+
360
+ # Don't add outputs to result (they're captured, not displayed)
361
+ result["status"] = "ok"
362
+
363
+ # Check if pip install/uninstall occurred and notify
364
+ if websocket and ('pip install' in cell_content or 'pip uninstall' in cell_content):
365
+ try:
366
+ await websocket.send_json({
367
+ "type": "packages_updated",
368
+ "data": {"action": "pip"}
369
+ })
370
+ except Exception:
371
+ pass # Ignore websocket errors
372
+
373
+ if websocket:
374
+ await websocket.send_json({
375
+ "type": "execution_complete",
376
+ "data": {
377
+ "status": "ok",
378
+ "message": "Output captured" + (f" in variable '{output_var}'" if output_var else "")
379
+ }
380
+ })
381
+
382
+ except Exception as e:
383
+ result["status"] = "error"
384
+ result["error"] = {
385
+ "ename": type(e).__name__,
386
+ "evalue": str(e),
387
+ "traceback": [f"Capture magic error: {str(e)}"]
388
+ }
389
+
390
+ if websocket:
391
+ await websocket.send_json({
392
+ "type": "execution_error",
393
+ "data": {"error": result["error"]}
394
+ })
395
+
396
+ return result
397
+
398
+ async def handle_time(self, cell_content: str, result: Dict[str, Any],
399
+ start_time: float, execution_count: int,
400
+ websocket: Optional[WebSocket] = None) -> Dict[str, Any]:
401
+ """
402
+ Handle %%time magic - time cell execution and display results.
403
+ """
404
+ try:
405
+ # Record start time
406
+ cell_start = time.time()
407
+ cpu_start = time.process_time()
408
+
409
+ # Execute cell content
410
+ await self.execute_cell_content(
411
+ cell_content, result, execution_count, websocket
412
+ )
413
+
414
+ # Calculate timing
415
+ wall_time = time.time() - cell_start
416
+ cpu_time = time.process_time() - cpu_start
417
+
418
+ # Format timing output
419
+ timing_output = f"CPU times: user {cpu_time:.2f} s, sys: 0 s, total: {cpu_time:.2f} s\n"
420
+ timing_output += f"Wall time: {wall_time:.2f} s"
421
+
422
+ # Add timing as stream output
423
+ result["outputs"].append({
424
+ "output_type": "stream",
425
+ "name": "stdout",
426
+ "text": timing_output + "\n"
427
+ })
428
+
429
+ if websocket:
430
+ await websocket.send_json({
431
+ "type": "stream_output",
432
+ "data": {
433
+ "stream": "stdout",
434
+ "text": timing_output + "\n"
435
+ }
436
+ })
437
+
438
+ except Exception as e:
439
+ result["status"] = "error"
440
+ result["error"] = {
441
+ "ename": type(e).__name__,
442
+ "evalue": str(e),
443
+ "traceback": [f"Time magic error: {str(e)}"]
444
+ }
445
+
446
+ if websocket:
447
+ await websocket.send_json({
448
+ "type": "execution_error",
449
+ "data": {"error": result["error"]}
450
+ })
451
+
452
+ return result
453
+
454
+ async def handle_timeit(self, args: list, cell_content: str, result: Dict[str, Any],
455
+ start_time: float, execution_count: int,
456
+ websocket: Optional[WebSocket] = None) -> Dict[str, Any]:
457
+ """
458
+ Handle %%timeit magic - time cell execution using timeit module.
459
+ Options: -n<N> (iterations), -r<R> (repeats), -q (quiet)
460
+ """
461
+ import timeit
462
+
463
+ # Parse arguments
464
+ number = None
465
+ repeat = 7
466
+ quiet = '-q' in args
467
+
468
+ for arg in args:
469
+ if arg.startswith('-n'):
470
+ number = int(arg[2:])
471
+ elif arg.startswith('-r'):
472
+ repeat = int(arg[2:])
473
+
474
+ try:
475
+ # Create a timer
476
+ timer = timeit.Timer(
477
+ stmt=cell_content,
478
+ globals=self.globals_dict
479
+ )
480
+
481
+ # Auto-determine number of iterations if not specified
482
+ if number is None:
483
+ # Determine number of loops automatically
484
+ for i in range(1, 10):
485
+ n = 10 ** i
486
+ try:
487
+ t = timer.timeit(n)
488
+ if t >= 0.2:
489
+ number = n
490
+ break
491
+ except:
492
+ number = 1
493
+ break
494
+ if number is None:
495
+ number = 10 ** 7
496
+
497
+ # Run the timing
498
+ all_runs = timer.repeat(repeat=repeat, number=number)
499
+ best = min(all_runs) / number
500
+
501
+ # Format output
502
+ if best < 1e-6:
503
+ timing_str = f"{best * 1e9:.0f} ns"
504
+ elif best < 1e-3:
505
+ timing_str = f"{best * 1e6:.0f} µs"
506
+ elif best < 1:
507
+ timing_str = f"{best * 1e3:.0f} ms"
508
+ else:
509
+ timing_str = f"{best:.2f} s"
510
+
511
+ output_text = f"{timing_str} ± {(max(all_runs) - min(all_runs)) / number * 1e6:.0f} µs per loop (mean ± std. dev. of {repeat} runs, {number} loops each)\n"
512
+
513
+ if not quiet:
514
+ result["outputs"].append({
515
+ "output_type": "stream",
516
+ "name": "stdout",
517
+ "text": output_text
518
+ })
519
+
520
+ if websocket:
521
+ await websocket.send_json({
522
+ "type": "stream_output",
523
+ "data": {"stream": "stdout", "text": output_text}
524
+ })
525
+
526
+ except Exception as e:
527
+ result["status"] = "error"
528
+ result["error"] = {
529
+ "ename": type(e).__name__,
530
+ "evalue": str(e),
531
+ "traceback": [f"Timeit magic error: {str(e)}"]
532
+ }
533
+
534
+ if websocket:
535
+ await websocket.send_json({
536
+ "type": "execution_error",
537
+ "data": {"error": result["error"]}
538
+ })
539
+
540
+ return result
541
+
542
+ async def handle_writefile(self, args: list, cell_content: str, result: Dict[str, Any],
543
+ start_time: float, websocket: Optional[WebSocket] = None) -> Dict[str, Any]:
544
+ """
545
+ Handle %%writefile magic - write cell contents to file.
546
+ Usage: %%writefile [-a/--append] filename
547
+ """
548
+ if not args:
549
+ result["status"] = "error"
550
+ result["error"] = {
551
+ "ename": "UsageError",
552
+ "evalue": "%%writefile requires a filename",
553
+ "traceback": ["Usage: %%writefile [-a/--append] filename"]
554
+ }
555
+ return result
556
+
557
+ append_mode = '-a' in args or '--append' in args
558
+ filename = args[-1] # Last argument is the filename
559
+
560
+ try:
561
+ mode = 'a' if append_mode else 'w'
562
+ with open(filename, mode) as f:
563
+ f.write(cell_content)
564
+ if not cell_content.endswith('\n'):
565
+ f.write('\n')
566
+
567
+ action = "Appending to" if append_mode else "Writing"
568
+ output_text = f"{action} {filename}\n"
569
+
570
+ result["outputs"].append({
571
+ "output_type": "stream",
572
+ "name": "stdout",
573
+ "text": output_text
574
+ })
575
+
576
+ if websocket:
577
+ await websocket.send_json({
578
+ "type": "stream_output",
579
+ "data": {"stream": "stdout", "text": output_text}
580
+ })
581
+
582
+ except Exception as e:
583
+ result["status"] = "error"
584
+ result["error"] = {
585
+ "ename": type(e).__name__,
586
+ "evalue": str(e),
587
+ "traceback": [f"Writefile magic error: {str(e)}"]
588
+ }
589
+
590
+ if websocket:
591
+ await websocket.send_json({
592
+ "type": "execution_error",
593
+ "data": {"error": result["error"]}
594
+ })
595
+
596
+ return result
597
+
598
+ async def handle_bash(self, cell_content: str, result: Dict[str, Any],
599
+ start_time: float, websocket: Optional[WebSocket] = None) -> Dict[str, Any]:
600
+ """
601
+ Handle %%bash / %%sh magic - run cell as bash script.
602
+ """
603
+ try:
604
+ # Execute as shell script
605
+ shell_result = {
606
+ "outputs": [],
607
+ "status": "ok"
608
+ }
609
+
610
+ # Write cell content to temporary script and execute
611
+ import tempfile
612
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.sh', delete=False) as f:
613
+ f.write(cell_content)
614
+ script_path = f.name
615
+
616
+ try:
617
+ await self.special_handler._execute_shell_command(
618
+ f"bash {script_path}", shell_result, start_time, websocket
619
+ )
620
+
621
+ # Merge results
622
+ result["outputs"].extend(shell_result.get("outputs", []))
623
+ if shell_result.get("status") == "error":
624
+ result["status"] = "error"
625
+ if "error" in shell_result:
626
+ result["error"] = shell_result["error"]
627
+ finally:
628
+ # Clean up temp file
629
+ os.unlink(script_path)
630
+
631
+ except Exception as e:
632
+ result["status"] = "error"
633
+ result["error"] = {
634
+ "ename": type(e).__name__,
635
+ "evalue": str(e),
636
+ "traceback": [f"Bash magic error: {str(e)}"]
637
+ }
638
+
639
+ if websocket:
640
+ await websocket.send_json({
641
+ "type": "execution_error",
642
+ "data": {"error": result["error"]}
643
+ })
644
+
645
+ return result
646
+
647
+ async def handle_html(self, cell_content: str, result: Dict[str, Any],
648
+ start_time: float, websocket: Optional[WebSocket] = None) -> Dict[str, Any]:
649
+ """
650
+ Handle %%html magic - render cell as HTML.
651
+ """
652
+ try:
653
+ result["outputs"].append({
654
+ "output_type": "display_data",
655
+ "data": {
656
+ "text/html": cell_content,
657
+ "text/plain": f"<IPython.core.display.HTML object>"
658
+ }
659
+ })
660
+
661
+ if websocket:
662
+ await websocket.send_json({
663
+ "type": "display_data",
664
+ "data": {
665
+ "data": {
666
+ "text/html": cell_content
667
+ }
668
+ }
669
+ })
670
+
671
+ except Exception as e:
672
+ result["status"] = "error"
673
+ result["error"] = {
674
+ "ename": type(e).__name__,
675
+ "evalue": str(e),
676
+ "traceback": [f"HTML magic error: {str(e)}"]
677
+ }
678
+
679
+ return result
680
+
681
+ async def handle_markdown(self, cell_content: str, result: Dict[str, Any],
682
+ start_time: float, websocket: Optional[WebSocket] = None) -> Dict[str, Any]:
683
+ """
684
+ Handle %%markdown magic - render cell as Markdown.
685
+ """
686
+ try:
687
+ result["outputs"].append({
688
+ "output_type": "display_data",
689
+ "data": {
690
+ "text/markdown": cell_content,
691
+ "text/plain": f"<IPython.core.display.Markdown object>"
692
+ }
693
+ })
694
+
695
+ if websocket:
696
+ await websocket.send_json({
697
+ "type": "display_data",
698
+ "data": {
699
+ "data": {
700
+ "text/markdown": cell_content
701
+ }
702
+ }
703
+ })
704
+
705
+ except Exception as e:
706
+ result["status"] = "error"
707
+ result["error"] = {
708
+ "ename": type(e).__name__,
709
+ "evalue": str(e),
710
+ "traceback": [f"Markdown magic error: {str(e)}"]
711
+ }
712
+
713
+ return result