8
8
from contextlib import redirect_stdout , redirect_stderr
9
9
import io
10
10
from pathlib import Path
11
+ import plotly .graph_objects as go
11
12
import sys
12
13
import traceback
13
14
14
15
15
- def parse_markdown (content ):
16
- """Parse markdown content and extract Python code blocks."""
17
- lines = content .split ("\n " )
18
- blocks = []
19
- current_block = None
20
- in_code_block = False
21
-
22
- for i , line in enumerate (lines ):
23
- # Start of Python code block
24
- if line .strip ().startswith ("```python" ):
25
- in_code_block = True
26
- current_block = {
27
- "start_line" : i ,
28
- "end_line" : None ,
29
- "code" : [],
30
- "type" : "python" ,
31
- }
32
-
33
- # End of code block
34
- elif line .strip () == "```" and in_code_block :
35
- in_code_block = False
36
- current_block ["end_line" ] = i
37
- current_block ["code" ] = "\n " .join (current_block ["code" ])
38
- blocks .append (current_block )
39
- current_block = None
40
-
41
- # Line inside code block
42
- elif in_code_block :
43
- current_block ["code" ].append (line )
16
+ def main ():
17
+ args = _parse_args ()
18
+ for filename in args .input :
19
+ _do_file (args , Path (filename ))
44
20
45
- return blocks
46
21
22
+ def _do_file (args , input_file ):
23
+ """Process a single file."""
47
24
48
- def execute_python_code (code , output_dir , output_figure_stem ):
49
- """Execute Python code and capture output and generated files."""
50
- # Capture stdout and stderr
51
- stdout_buffer = io .StringIO ()
52
- stderr_buffer = io .StringIO ()
25
+ # Validate input file
26
+ if not input_file .exists ():
27
+ print (f"Error: '{ input_file } ' not found" , file = sys .stderr )
28
+ sys .exit (1 )
53
29
54
- # Track files created during execution
55
- output_path = Path (output_dir )
56
- if not output_path .exists ():
57
- output_path .mkdir (parents = True , exist_ok = True )
30
+ # Determine output file path etc.
31
+ stem = input_file .stem
32
+ output_file = args .outdir / f"{ input_file .stem } { input_file .suffix } "
33
+ if input_file .resolve () == output_file .resolve ():
34
+ print (f"Error: output would overwrite input '{ input_file } '" , file = sys .stderr )
35
+ sys .exit (1 )
58
36
59
- files_before = set (f .name for f in output_path .iterdir ())
60
- result = {"stdout" : "" , "stderr" : "" , "error" : None , "images" : [], "html_files" : []}
61
- figures = []
37
+ # Read input
62
38
try :
63
- # Create a custom show function to capture plotly figures
64
- def capture_plotly_show (fig ):
65
- """Custom show function that saves plotly figures instead of displaying them."""
66
- nonlocal figures
67
- figures .append (fig )
68
- png_filename = (
69
- f"{ output_figure_stem } _{ len (figures )} .png"
70
- )
71
- png_path = Path (output_dir ) / png_filename
72
- fig .write_image (png_path , width = 800 , height = 600 )
73
- result ["images" ].append (png_filename )
74
- print (f"Plotly figure saved as PNG: { png_filename } " )
75
- return
76
-
77
- # Create a namespace for code execution
78
- exec_globals = {
79
- "__name__" : "__main__" ,
80
- "__file__" : "<markdown_code>" ,
81
- }
82
-
83
- # Monkey patch plotly show method to capture figures
84
- original_show = None
85
-
86
- # Execute the code with output capture
87
- with redirect_stdout (stdout_buffer ), redirect_stderr (stderr_buffer ):
88
- # Try to import plotly and patch the show method
89
- def patched_show (self , * args , ** kwargs ):
90
- capture_plotly_show (self )
91
- import plotly .graph_objects as go
92
- original_show = go .Figure .show
93
- go .Figure .show = patched_show
39
+ with open (input_file , "r" , encoding = "utf-8" ) as f :
40
+ content = f .read ()
41
+ except Exception as e :
42
+ print (f"Error reading input file: { e } " , file = sys .stderr )
43
+ sys .exit (1 )
94
44
95
- # Execute the code
96
- exec (code , exec_globals )
45
+ # Parse markdown and extract code blocks
46
+ _report (args .verbose , f"Processing { input_file } ..." )
47
+ code_blocks = _parse_md (content )
48
+ _report (args .verbose , f"- Found { len (code_blocks )} code blocks" )
97
49
98
- # Try to find and handle any plotly figures that were created and not already processed
99
- for name , obj in exec_globals .items ():
100
- if (
101
- hasattr (obj , "__class__" )
102
- and "plotly" in str (type (obj )).lower ()
103
- and hasattr (obj , "show" )
104
- ):
105
- # This looks like a plotly figure that wasn't already processed by show()
106
- if obj not in figures :
107
- print ("NOT ALREADY PROCESSED" , obj , file = sys .stderr )
108
- capture_plotly_show (obj )
109
-
110
- # Restore original show method if we patched it
111
- if original_show :
112
- import plotly .graph_objects as go
113
- go .Figure .show = original_show
50
+ # Execute code blocks and collect results
51
+ execution_results = []
52
+ figure_counter = 0
53
+ for i , block in enumerate (code_blocks ):
54
+ _report (args .verbose , f"- Executing block { i + 1 } /{ len (code_blocks )} " )
55
+ figure_counter , result = _run_code (block ["code" ], args .outdir , stem , figure_counter )
56
+ execution_results .append (result )
57
+ _report (result ["error" ], f" - Warning: block { i + 1 } had an error" )
58
+ _report (result ["images" ], f" - Generated { len (result ['images' ])} image(s)" )
114
59
60
+ # Generate and save output
61
+ content = _generate_markdown (args , content , code_blocks , execution_results , args .outdir )
62
+ try :
63
+ with open (output_file , "w" , encoding = "utf-8" ) as f :
64
+ f .write (content )
65
+ _report (args .verbose , f"- Output written to { output_file } " )
66
+ _report (any (result ["images" ] for result in execution_results ), f"- Images saved to { args .outdir } " )
115
67
except Exception as e :
116
- result ["error" ] = f"Error executing code: { str (e )} \n { traceback .format_exc ()} "
68
+ print (f"Error writing output file: { e } " , file = sys .stderr )
69
+ sys .exit (1 )
117
70
118
- result ["stdout" ] = stdout_buffer .getvalue ()
119
- result ["stderr" ] = stderr_buffer .getvalue ()
120
71
121
- # Check for any additional files created
122
- output_path = Path (output_dir )
123
- if output_path .exists ():
124
- files_after = set (f .name for f in output_path .iterdir ())
125
- for f in (files_after - files_before ):
126
- if f not in result ["images" ] and file .lower ().endswith (".png" ):
127
- result ["images" ].append (f )
72
+ def _capture_plotly_show (fig , counter , result , output_dir , stem ):
73
+ """Saves figures instead of displaying them."""
74
+ print (f"CAPTURE SHOW counter is { counter } " )
128
75
129
- return result
76
+ # Save PNG
77
+ png_filename = f"{ stem } _{ counter } .png"
78
+ png_path = output_dir / png_filename
79
+ fig .write_image (png_path , width = 800 , height = 600 )
80
+ result ["images" ].append (png_filename )
130
81
82
+ # Save HTML and get the content for embedding
83
+ html_filename = f"{ stem } _{ counter } .html"
84
+ html_path = output_dir / html_filename
85
+ fig .write_html (html_path , include_plotlyjs = "cdn" )
86
+ html_content = fig .to_html (include_plotlyjs = "cdn" , div_id = f"plotly-div-{ counter } " , full_html = False )
87
+ result ["html_files" ].append (html_filename )
88
+ result .setdefault ("html_content" , []).append (html_content )
131
89
132
- def generate_output_markdown (content , code_blocks , execution_results , output_dir ):
90
+
91
+ def _generate_markdown (args , content , code_blocks , execution_results , output_dir ):
133
92
"""Generate the output markdown with embedded results."""
134
93
lines = content .split ("\n " )
135
- output_lines = []
136
94
137
95
# Sort code blocks by start line in reverse order for safe insertion
138
96
sorted_blocks = sorted (
@@ -173,10 +131,13 @@ def generate_output_markdown(content, code_blocks, execution_results, output_dir
173
131
insert_lines .append ("" )
174
132
insert_lines .append (f"" )
175
133
176
- # Add HTML files (for plotly figures)
177
- for html_file in result .get ("html_files" , []):
178
- insert_lines .append ("" )
179
- insert_lines .append (f"[Interactive Plot](./{ html_file } )" )
134
+ # Embed HTML content for plotly figures
135
+ if args .inline :
136
+ for html_content in result .get ("html_content" , []):
137
+ insert_lines .append ("" )
138
+ insert_lines .append ("**Interactive Plot:**" )
139
+ insert_lines .append ("" )
140
+ insert_lines .extend (html_content .split ("\n " ))
180
141
181
142
# Insert the results after the code block
182
143
if insert_lines :
@@ -187,75 +148,100 @@ def generate_output_markdown(content, code_blocks, execution_results, output_dir
187
148
return "\n " .join (lines )
188
149
189
150
190
- def main ():
191
- parser = argparse .ArgumentParser (
192
- description = "Process Markdown files with Python code blocks and generate output with results"
193
- )
194
- parser .add_argument ("input_file" , help = "Input Markdown file" )
195
- parser .add_argument (
196
- "-o" , "--output" , help = "Output Markdown file (default: input_output.md)"
197
- )
198
- args = parser .parse_args ()
151
+ def _parse_args ():
152
+ """Parse command-line arguments."""
153
+ parser = argparse .ArgumentParser (description = "Process Markdown files with code blocks" )
154
+ parser .add_argument ("input" , nargs = "+" , help = "Input .md file" )
155
+ parser .add_argument ("--inline" , action = "store_true" , help = "Inline HTML in .md" )
156
+ parser .add_argument ("--outdir" , type = Path , help = "Output directory" )
157
+ parser .add_argument ("--verbose" , action = "store_true" , help = "Report progress" )
158
+ return parser .parse_args ()
199
159
200
- # Validate input file
201
- if not Path (args .input_file ).exists ():
202
- print (f"Error: Input file '{ args .input_file } ' not found" , file = sys .stderr )
203
- sys .exit (1 )
204
160
205
- # Determine output file path
206
- if args .output :
207
- output_file = args .output
208
- else :
209
- input_path = Path (args .input_file )
210
- output_file = str (
211
- input_path .parent / f"{ input_path .stem } _output{ input_path .suffix } "
212
- )
161
+ def _parse_md (content ):
162
+ """Parse Markdown and extract Python code blocks."""
163
+ lines = content .split ("\n " )
164
+ blocks = []
165
+ current_block = None
166
+ in_code_block = False
213
167
214
- # Determine output directory for images
215
- output_dir = str (Path (output_file ).parent )
168
+ for i , line in enumerate (lines ):
169
+ # Start of Python code block
170
+ if line .strip ().startswith ("```python" ):
171
+ in_code_block = True
172
+ current_block = {
173
+ "start_line" : i ,
174
+ "end_line" : None ,
175
+ "code" : [],
176
+ "type" : "python" ,
177
+ }
216
178
217
- # Read input file
218
- try :
219
- with open ( args . input_file , "r" , encoding = "utf-8" ) as f :
220
- content = f . read ()
221
- except Exception as e :
222
- print ( f"Error reading input file: { e } " , file = sys . stderr )
223
- sys . exit ( 1 )
179
+ # End of code block
180
+ elif line . strip () == "```" and in_code_block :
181
+ in_code_block = False
182
+ current_block [ "end_line" ] = i
183
+ current_block [ "code" ] = " \n " . join ( current_block [ "code" ])
184
+ blocks . append ( current_block )
185
+ current_block = None
224
186
225
- print (f"Processing { args .input_file } ..." )
226
- output_figure_stem = Path (output_file ).stem
187
+ # Line inside code block
188
+ elif in_code_block :
189
+ current_block ["code" ].append (line )
227
190
228
- # Parse markdown and extract code blocks
229
- code_blocks = parse_markdown (content )
230
- print (f"Found { len (code_blocks )} Python code blocks" )
191
+ return blocks
231
192
232
- # Execute code blocks and collect results
233
- execution_results = []
234
- for i , block in enumerate (code_blocks ):
235
- print (f"Executing code block { i + 1 } /{ len (code_blocks )} ..." )
236
- result = execute_python_code (block ["code" ], output_dir , output_figure_stem )
237
- execution_results .append (result )
238
193
239
- if result [ "error" ] :
240
- print ( f" Warning: Code block { i + 1 } had an error" )
241
- if result [ "images" ] :
242
- print (f" Generated { len ( result [ 'images' ]) } image(s)" )
194
+ def _report ( condition , message ) :
195
+ """Report if condition is true."""
196
+ if condition :
197
+ print (message , file = sys . stderr )
243
198
244
- # Generate output markdown
245
- output_content = generate_output_markdown (
246
- content , code_blocks , execution_results , output_dir
247
- )
248
199
249
- # Write output file
200
+ def _run_code (code , output_dir , stem , figure_counter ):
201
+ """Execute code capturing output and generated files."""
202
+ # Capture stdout and stderr
203
+ stdout_buffer = io .StringIO ()
204
+ stderr_buffer = io .StringIO ()
205
+
206
+ # Track files created during execution
207
+ if not output_dir .exists ():
208
+ output_dir .mkdir (parents = True , exist_ok = True )
209
+
210
+ files_before = set (f .name for f in output_dir .iterdir ())
211
+ result = {"stdout" : "" , "stderr" : "" , "error" : None , "images" : [], "html_files" : []}
250
212
try :
251
- with open (output_file , "w" , encoding = "utf-8" ) as f :
252
- f .write (output_content )
253
- print (f"Output written to { output_file } " )
254
- if any (result ["images" ] for result in execution_results ):
255
- print (f"Images saved to { output_dir } " )
213
+
214
+ # Create a namespace for code execution
215
+ exec_globals = {
216
+ "__name__" : "__main__" ,
217
+ "__file__" : "<markdown_code>" ,
218
+ }
219
+
220
+ # Execute the code with output capture
221
+ with redirect_stdout (stdout_buffer ), redirect_stderr (stderr_buffer ):
222
+ # Try to import plotly and patch the show method
223
+ def patched_show (self , * args , ** kwargs ):
224
+ nonlocal figure_counter
225
+ figure_counter += 1
226
+ _capture_plotly_show (self , figure_counter , result , output_dir , stem )
227
+ original_show = go .Figure .show
228
+ go .Figure .show = patched_show
229
+ exec (code , exec_globals )
230
+ go .Figure .show = original_show
231
+
256
232
except Exception as e :
257
- print (f"Error writing output file: { e } " , file = sys .stderr )
258
- sys .exit (1 )
233
+ result ["error" ] = f"Error executing code: { str (e )} \n { traceback .format_exc ()} "
234
+
235
+ result ["stdout" ] = stdout_buffer .getvalue ()
236
+ result ["stderr" ] = stderr_buffer .getvalue ()
237
+
238
+ # Check for any additional files created
239
+ files_after = set (f .name for f in output_dir .iterdir ())
240
+ for f in (files_after - files_before ):
241
+ if f not in result ["images" ] and f .lower ().endswith (".png" ):
242
+ result ["images" ].append (f )
243
+
244
+ return figure_counter , result
259
245
260
246
261
247
if __name__ == "__main__" :
0 commit comments