Skip to content

Commit b030c94

Browse files
committed
feat: converting doc/python using bin/run_markdown.py
1 parent 96af9f6 commit b030c94

File tree

23 files changed

+409
-107
lines changed

23 files changed

+409
-107
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ doc/python/raw.githubusercontent.com/
1515

1616
docs/
1717
docs_tmp/
18+
pages/examples/
1819

1920
# Don't ignore dataset files
2021
!*.csv.gz

Makefile

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
RUN = uv run
44
PACKAGE_DIRS = _plotly_utils plotly
55
CODE_DIRS = ${PACKAGE_DIRS} scripts
6+
EXAMPLE_SRC = $(wildcard doc/python/*.md)
7+
EXAMPLE_DST = $(patsubst doc/python/%.md,pages/examples/%.md,${EXAMPLE_SRC})
68

79
## commands: show available commands
810
commands:
@@ -21,6 +23,12 @@ docs-lint:
2123
docs-tmp:
2224
MKDOCS_TEMP_DIR=./docs_tmp ${RUN} mkdocs build
2325

26+
## examples: temporary target to copy and run doc/python
27+
examples: ${EXAMPLE_DST}
28+
29+
pages/examples/%.md: doc/python/%.md
30+
${RUN} bin/run_markdown.py --output $@ $<
31+
2432
## format: reformat code
2533
format:
2634
${RUN} ruff format ${CODE_DIRS}
@@ -52,6 +60,7 @@ clean:
5260
@rm -rf .pytest_cache
5361
@rm -rf .ruff_cache
5462
@rm -rf dist
63+
@rm -rf pages/examples
5564

5665
## sync: update Python packages
5766
sync:

bin/codegen/datatypes.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,8 +218,8 @@ def _subplot_re_match(self, prop):
218218
else:
219219
property_docstring = property_description
220220

221-
# Fix `][`.
222-
property_docstring = property_docstring.replace("][", "]\\[")
221+
# FIXME: replace '][' with ']\[' to avoid confusion with Markdown reference links
222+
# property_docstring = property_docstring.replace("][", "]\\[")
223223

224224
# Write get property
225225
buffer.write(

bin/codegen/utils.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,8 +163,8 @@ def format_description(desc):
163163
# replace {2D arrays} with 2D lists
164164
desc = desc.replace("{2D arrays}", "2D lists")
165165

166-
# replace '][' with ']\[' to avoid confusion with Markdown reference links
167-
desc = desc.replace("][", r"]\\[")
166+
# FIXME: replace '][' with ']\[' to avoid confusion with Markdown reference links
167+
# desc = desc.replace("][", r"]\\[")
168168

169169
return desc
170170

bin/run_markdown.py

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Process Markdown files with embedded Python code blocks, saving
4+
the output and images.
5+
"""
6+
7+
import argparse
8+
from contextlib import redirect_stdout, redirect_stderr
9+
import io
10+
from pathlib import Path
11+
import sys
12+
import traceback
13+
14+
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)
44+
45+
return blocks
46+
47+
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()
53+
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)
58+
59+
files_before = set(f.name for f in output_path.iterdir())
60+
result = {"stdout": "", "stderr": "", "error": None, "images": [], "html_files": []}
61+
figures = []
62+
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
94+
95+
# Execute the code
96+
exec(code, exec_globals)
97+
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
114+
115+
except Exception as e:
116+
result["error"] = f"Error executing code: {str(e)}\n{traceback.format_exc()}"
117+
118+
result["stdout"] = stdout_buffer.getvalue()
119+
result["stderr"] = stderr_buffer.getvalue()
120+
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)
128+
129+
return result
130+
131+
132+
def generate_output_markdown(content, code_blocks, execution_results, output_dir):
133+
"""Generate the output markdown with embedded results."""
134+
lines = content.split("\n")
135+
output_lines = []
136+
137+
# Sort code blocks by start line in reverse order for safe insertion
138+
sorted_blocks = sorted(
139+
enumerate(code_blocks), key=lambda x: x[1]["start_line"], reverse=True
140+
)
141+
142+
# Process each code block and insert results
143+
for block_idx, block in sorted_blocks:
144+
result = execution_results[block_idx]
145+
insert_lines = []
146+
147+
# Add output if there's stdout
148+
if result["stdout"].strip():
149+
insert_lines.append("")
150+
insert_lines.append("**Output:**")
151+
insert_lines.append("```")
152+
insert_lines.extend(result["stdout"].rstrip().split("\n"))
153+
insert_lines.append("```")
154+
155+
# Add error if there was one
156+
if result["error"]:
157+
insert_lines.append("")
158+
insert_lines.append("**Error:**")
159+
insert_lines.append("```")
160+
insert_lines.extend(result["error"].rstrip().split("\n"))
161+
insert_lines.append("```")
162+
163+
# Add stderr if there's content
164+
if result["stderr"].strip():
165+
insert_lines.append("")
166+
insert_lines.append("**Warnings/Messages:**")
167+
insert_lines.append("```")
168+
insert_lines.extend(result["stderr"].rstrip().split("\n"))
169+
insert_lines.append("```")
170+
171+
# Add images
172+
for image in result["images"]:
173+
insert_lines.append("")
174+
insert_lines.append(f"![Generated Plot](./{image})")
175+
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})")
180+
181+
# Insert the results after the code block
182+
if insert_lines:
183+
# Insert after the closing ``` of the code block
184+
insertion_point = block["end_line"] + 1
185+
lines[insertion_point:insertion_point] = insert_lines
186+
187+
return "\n".join(lines)
188+
189+
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()
199+
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+
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+
)
213+
214+
# Determine output directory for images
215+
output_dir = str(Path(output_file).parent)
216+
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)
224+
225+
print(f"Processing {args.input_file}...")
226+
output_figure_stem = Path(output_file).stem
227+
228+
# Parse markdown and extract code blocks
229+
code_blocks = parse_markdown(content)
230+
print(f"Found {len(code_blocks)} Python code blocks")
231+
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+
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)")
243+
244+
# Generate output markdown
245+
output_content = generate_output_markdown(
246+
content, code_blocks, execution_results, output_dir
247+
)
248+
249+
# Write output file
250+
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}")
256+
except Exception as e:
257+
print(f"Error writing output file: {e}", file=sys.stderr)
258+
sys.exit(1)
259+
260+
261+
if __name__ == "__main__":
262+
main()

notes.txt

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
- Can we get rid of `plotly/api` entirely?
2+
- Can we eliminate `plotly/conftest.py` and fix breaking tests?
3+
- Why the distinction between `graph_objects` and `graph_objs`?
4+
- Historical reasons, but `graph_objs` is widely used.
5+
- Generate code into `graph_objects` and have `graph_objs` point at it
6+
instead of vice versa.
7+
- Switch focus for now to the main documentation in `./doc`.
8+
9+
- Ran this to create a `.ipynb` file:
10+
11+
```
12+
jupytext --to ipynb --execute --output pages/strip-charts.ipynb doc/python/strip-charts.md
13+
```
14+
15+
- Loading the notebook like this, the charts don't show up:
16+
17+
```
18+
jupyter notebook pages/strip-charts.ipynb
19+
```
20+
21+
- Had to add this in a cell at the top:
22+
23+
```
24+
import plotly.io as pio
25+
pio.renderers.default = "notebook"
26+
```
27+
28+
- `mkdocs build` produces many (many) lines like this that did *not* appear
29+
before `mkdocs-jupyter` was added to `mkdocs.yml`:
30+
31+
```
32+
[WARNING] Div at /var/folders/w2/l51fjbjd25n9zbwkz9fw9jp00000gn/T/tmpgvlxh1sq line 3 column 1 unclosed at /var/folders/w2/l51fjbjd25n9zbwkz9fw9jp00000gn/T/tmpgvlxh1sq line 6 column 1, closing implicitly.
33+
```
34+
35+
- But with the `plotly.io` line, the `.ipynb` file is converted to usable HTML.
36+
- Still clearly originated as a notebook, but the chart shows up.

plotly/graph_objs/_figure.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9977,7 +9977,7 @@ def add_image(
99779977
source
99789978
Specifies the data URI of the image to be visualized.
99799979
The URI consists of "data:image/[<media
9980-
subtype>]\\[;base64],<data>"
9980+
subtype>][;base64],<data>"
99819981
stream
99829982
:class:`plotly.graph_objects.image.Stream` instance or
99839983
dict with compatible properties

plotly/graph_objs/_figurewidget.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9979,7 +9979,7 @@ def add_image(
99799979
source
99809980
Specifies the data URI of the image to be visualized.
99819981
The URI consists of "data:image/[<media
9982-
subtype>]\\[;base64],<data>"
9982+
subtype>][;base64],<data>"
99839983
stream
99849984
:class:`plotly.graph_objects.image.Stream` instance or
99859985
dict with compatible properties

0 commit comments

Comments
 (0)