33import os
44import shutil
55import textwrap
6+ from concurrent .futures import ThreadPoolExecutor , as_completed
67from pathlib import Path
7- from subprocess import DEVNULL , STDOUT , check_call
8+ from subprocess import DEVNULL , check_call
89
910from tqdm import tqdm
1011
1112# TODO full rebuild option
1213
1314
14- def build_examples (destination_directory : Path , dummy : bool , remove_dir : bool ):
15+ def _convert_example (file : Path , sub_directory : Path ) -> None :
16+ """Convert a single example file into its documentation markdown page.
17+
18+ Args:
19+ file: The example ``.py`` file to convert.
20+ sub_directory: The example folder the file belongs to (used to locate figures).
21+ """
22+ file_name = file .stem
23+
24+ # Convert the file to a jupyter notebook.
25+ # Note: stdout is discarded to keep the parallel build log clean, but stderr is
26+ # left untouched so the actual error surfaces when a conversion fails.
27+ check_call (["jupytext" , "--to" , "notebook" , file ], stdout = DEVNULL )
28+
29+ notebook_path = file .with_suffix (".ipynb" )
30+
31+ # Execute the notebook, then convert it to markdown
32+ env = os .environ | {"PYTHONPATH" : os .getcwd ()}
33+ convert_execute = [
34+ "jupyter" ,
35+ "nbconvert" ,
36+ "--to" ,
37+ "notebook" ,
38+ "--inplace" ,
39+ "--execute" ,
40+ notebook_path ,
41+ ]
42+ check_call (convert_execute , stdout = DEVNULL , env = env )
43+ to_markdown = ["jupyter" , "nbconvert" , "--to" , "markdown" , notebook_path ]
44+ check_call (to_markdown , stdout = DEVNULL , env = env )
45+
46+ # Wrap long lines, except those containing a link (detected via "](")
47+ markdown_path = file .with_suffix (".md" )
48+ with open (markdown_path , encoding = "UTF-8" ) as markdown_file :
49+ content = markdown_file .read ()
50+ wrapped_lines = []
51+ ignored_substrings = (
52+ "![svg]" ,
53+ "![png]" ,
54+ "<Figure size" ,
55+ "it/s" ,
56+ "s/it" ,
57+ )
58+ for line in content .splitlines ():
59+ # Skip formatter control lines
60+ if "fmt: off" in line or "fmt: on" in line :
61+ continue
62+ if any (substring in line for substring in ignored_substrings ):
63+ continue
64+ if (
65+ len (line ) > 88
66+ and "](" not in line
67+ and not line .lstrip ().startswith ("#" )
68+ ):
69+ wrapped = textwrap .wrap (line , width = 88 )
70+ wrapped_lines .extend (wrapped )
71+ else :
72+ wrapped_lines .append (line )
73+
74+ lines = [line + "\n " for line in wrapped_lines ]
75+ # Append light/dark figures if both exist, else a single figure if present
76+ light_figure = Path (sub_directory / (file_name + "_light.svg" ))
77+ dark_figure = Path (sub_directory / (file_name + "_dark.svg" ))
78+ figure = Path (sub_directory / (file_name + ".svg" ))
79+ if light_figure .is_file () and dark_figure .is_file ():
80+ lines .append (f"```{{image}} { file_name } _light.svg\n " )
81+ lines .append (":align: center\n " )
82+ lines .append (":class: only-light\n " )
83+ lines .append ("```\n " )
84+ lines .append (f"```{{image}} { file_name } _dark.svg\n " )
85+ lines .append (":align: center\n " )
86+ lines .append (":class: only-dark\n " )
87+ lines .append ("```\n " )
88+ elif figure .is_file ():
89+ lines .append (f"```{{image}} { file_name } .svg\n " )
90+ lines .append (":align: center\n " )
91+ lines .append ("```\n " )
92+
93+ with open (markdown_path , "w" , encoding = "UTF-8" ) as markdown_file :
94+ markdown_file .writelines (lines )
95+
96+
97+ def build_examples (
98+ destination_directory : Path ,
99+ dummy : bool ,
100+ remove_dir : bool ,
101+ max_workers : int | None = None ,
102+ ):
15103 """Create the documentation version of the examples files.
16104
17105 Note that this deletes the destination directory if it already exists.
@@ -20,6 +108,8 @@ def build_examples(destination_directory: Path, dummy: bool, remove_dir: bool):
20108 destination_directory: The destination directory.
21109 dummy: Only build a dummy version of the files.
22110 remove_dir: Remove the examples directory if it already exists.
111+ max_workers: Number of examples to convert concurrently. Defaults to the number
112+ of available CPUs. Lower it if the build runs out of memory.
23113
24114 Raises:
25115 OSError: If the directory already exists but should not be removed.
@@ -43,12 +133,9 @@ def ignore_pycache(_, contents: list[str]):
43133 # examples
44134 ex_file = """# Examples\n \n ```{toctree}\n :maxdepth: 2\n \n """
45135
46- # List all directories in the examples folder
47136 ex_directories = [d for d in destination_directory .iterdir () if d .is_dir ()]
48137
49- # This list contains the order of the examples as we want to have them in the end.
50- # The examples that should be the first ones are already included here and skipped
51- # later on. All others are just included.
138+ # Desired example order; entries listed here come first, all others are appended
52139 ex_order = [
53140 "Basics<Basics/Basics>\n " ,
54141 "Searchspaces<Searchspaces/Searchspaces>\n " ,
@@ -59,147 +146,50 @@ def ignore_pycache(_, contents: list[str]):
59146 "Custom Surrogates<Custom_Surrogates/Custom_Surrogates>\n " ,
60147 ]
61148
62- # Iterate over the directories.
63- for sub_directory in (pbar := tqdm (ex_directories )):
64- # Get the name of the current folder
65- # Format it by replacing underscores and capitalizing the words
149+ # Files to convert, collected across all folders so conversion can be batched
150+ files_to_convert : list [tuple [Path , Path ]] = []
151+
152+ # First pass (sequential): build the toctree files and collect the conversion work
153+ for sub_directory in ex_directories :
154+ # Folder name, formatted for display (underscores to spaces, capitalized)
66155 folder_name = sub_directory .stem
67156 formatted = " " .join (word .capitalize () for word in folder_name .split ("_" ))
68157
69- # Create the link to the folder to the top level toctree.
158+ # Add the folder to the top level toctree if not already present
70159 ex_file_entry = formatted + f"<{ folder_name } /{ folder_name } >\n "
71- # Add it to the list of examples if it is not already contained
72160 if ex_file_entry not in ex_order :
73161 ex_order .append (ex_file_entry )
74162
75- # We need to create a file for the inclusion of the folder.
76- # We thus get the content of the corresponding header file.
163+ # Start the folder's toctree from its header file
77164 header_folder_name = sub_directory / f"{ folder_name } _Header.md"
78165 header = header_folder_name .read_text ()
79166
80167 subdir_toctree = header + "\n ```{toctree}\n :maxdepth: 1\n \n "
81168
82- # Set description of progressbar
83- pbar .set_description ("Overall progress" )
84-
85- # list all .py files in the subdirectory that need to be converted
86169 py_files = list (sub_directory .glob ("**/*.py" ))
87170
88- # Iterate through the individual example files
89- for file in (inner_pbar := tqdm (py_files , leave = False )):
90- # Include the name of the file to the toctree
91- # Format it by replacing underscores and capitalizing the words
171+ for file in py_files :
172+ # Add the file to the folder's toctree, with a formatted display name
92173 file_name = file .stem
93174
94175 formatted = " " .join (word .capitalize () for word in file_name .split ("_" ))
95- # Remove duplicate "constraints" for the files in the constraints folder.
176+ # Drop duplicate "Constraints" in the constraints folders
96177 if "Constraints" in folder_name and "Constraints" in formatted :
97178 formatted = formatted .replace ("Constraints" , "" )
98179
99- # Also format the Prodsum name to Product/Sum
180+ # Format " Prodsum" as " Product/Sum"
100181 if "Prodsum" in formatted :
101182 formatted = formatted .replace ("Prodsum" , "Product/Sum" )
102183 subdir_toctree += formatted + f"<{ file_name } >\n "
103184
104- # If we ignore the examples, we do not want to actually execute or convert
105- # anything. Still, due to existing links, it is necessary to construct a
106- # dummy file and then continue.
185+ # In dummy mode, write a placeholder so links still resolve
107186 if dummy :
108187 markdown_path = file .with_suffix (".md" )
109- # Rewrite the file
110188 with open (markdown_path , "w" , encoding = "UTF-8" ) as markdown_file :
111189 markdown_file .writelines ("# DUMMY FILE" )
112190 continue
113191
114- # Set description for progress bar
115- inner_pbar .set_description (f"Progressing { folder_name } " )
116-
117- # Create the Markdown file:
118-
119- # 1. Convert the file to jupyter notebook
120- check_call (
121- ["jupytext" , "--to" , "notebook" , file ], stdout = DEVNULL , stderr = STDOUT
122- )
123-
124- notebook_path = file .with_suffix (".ipynb" )
125-
126- # 2. Execute the notebook and convert to markdown.
127- # This is only done if we decide not to ignore the examples.
128- # The creation of the files themselves and converting them to markdown still
129- # happens since we need the files to check for link integrity.
130- convert_execute = [
131- "jupyter" ,
132- "nbconvert" ,
133- "--to" ,
134- "notebook" ,
135- "--inplace" ,
136- notebook_path ,
137- ]
138- convert_execute .append ("--execute" )
139- to_markdown = ["jupyter" , "nbconvert" , "--to" , "markdown" , notebook_path ]
140- env = os .environ | {"PYTHONPATH" : os .getcwd ()}
141- check_call (convert_execute , stdout = DEVNULL , stderr = STDOUT , env = env )
142- check_call (to_markdown , stdout = DEVNULL , stderr = STDOUT , env = env )
143-
144- # CLEANUP
145- markdown_path = file .with_suffix (".md" )
146- # We wrap lines which are too long as long as they do not contain a link.
147- # To discover whether a line contains a link, we check if the string "]("
148- # is contained.
149- with open (markdown_path , encoding = "UTF-8" ) as markdown_file :
150- content = markdown_file .read ()
151- wrapped_lines = []
152- ignored_substrings = (
153- "![svg]" ,
154- "![png]" ,
155- "<Figure size" ,
156- "it/s" ,
157- "s/it" ,
158- )
159- for line in content .splitlines ():
160- # Skip formatter control lines so they don't appear in docs
161- if "fmt: off" in line or "fmt: on" in line :
162- continue
163- if any (substring in line for substring in ignored_substrings ):
164- continue
165- if (
166- len (line ) > 88
167- and "](" not in line
168- and not line .lstrip ().startswith ("#" )
169- ):
170- wrapped = textwrap .wrap (line , width = 88 )
171- wrapped_lines .extend (wrapped )
172- else :
173- wrapped_lines .append (line )
174-
175- # Add a manual new line to each of the lines
176- lines = [line + "\n " for line in wrapped_lines ]
177- # Delete lines we do not want to have in our documentation
178- # lines = [line for line in lines if "![svg]" not in line]
179- # We check whether pre-built light and dark plots exist. If so, we append
180- # corresponding lines to our markdown file for including them.
181- # If not, we check if a single plot version exists and append it
182- # regardless of light/dark mode.
183- light_figure = Path (sub_directory / (file_name + "_light.svg" ))
184- dark_figure = Path (sub_directory / (file_name + "_dark.svg" ))
185- figure = Path (sub_directory / (file_name + ".svg" ))
186- if light_figure .is_file () and dark_figure .is_file ():
187- lines .append (f"```{{image}} { file_name } _light.svg\n " )
188- lines .append (":align: center\n " )
189- lines .append (":class: only-light\n " )
190- lines .append ("```\n " )
191- lines .append (f"```{{image}} { file_name } _dark.svg\n " )
192- lines .append (":align: center\n " )
193- lines .append (":class: only-dark\n " )
194- lines .append ("```\n " )
195- elif figure .is_file ():
196- lines .append (f"```{{image}} { file_name } .svg\n " )
197- lines .append (":align: center\n " )
198- lines .append ("```\n " )
199-
200- # Rewrite the file
201- with open (markdown_path , "w" , encoding = "UTF-8" ) as markdown_file :
202- markdown_file .writelines (lines )
192+ files_to_convert .append ((file , sub_directory ))
203193
204194 # Write last line of toctree file for this directory and write the file
205195 subdir_toctree += "```"
@@ -208,6 +198,30 @@ def ignore_pycache(_, contents: list[str]):
208198 ) as f :
209199 f .write (subdir_toctree )
210200
201+ # Second pass: convert the independent example files concurrently
202+ if files_to_convert :
203+ if max_workers is None :
204+ max_workers = os .cpu_count () or 1
205+ workers = max (1 , min (max_workers , len (files_to_convert )))
206+
207+ with ThreadPoolExecutor (max_workers = workers ) as executor :
208+ futures = {
209+ executor .submit (_convert_example , file , sub_directory ): file
210+ for file , sub_directory in files_to_convert
211+ }
212+ # `result()` re-raises so a failed example aborts the build
213+ for future in tqdm (
214+ as_completed (futures ), total = len (futures ), desc = "Converting examples"
215+ ):
216+ file = futures [future ]
217+ try :
218+ future .result ()
219+ except Exception as ex :
220+ # Fail fast: cancel not-yet-started conversions instead of waiting
221+ # for the whole batch to finish before the error propagates.
222+ executor .shutdown (wait = False , cancel_futures = True )
223+ raise RuntimeError (f"Failed to convert example: { file } " ) from ex
224+
211225 # Append the ordered list of examples to the file for the top level folder
212226 ex_file += "" .join (ex_order )
213227 # Write last line of top level toctree file and write the file
0 commit comments