Skip to content

Commit 9d8d384

Browse files
committed
Improve performance of incremental python api doc builds
1 parent 1c0569f commit 9d8d384

File tree

1 file changed

+66
-44
lines changed

1 file changed

+66
-44
lines changed

api-docs/source/conf.py

Lines changed: 66 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@
1717
# documentation root, use os.path.abspath to make it absolute, like shown here.
1818
#
1919
from __future__ import annotations
20+
from io import StringIO
2021
import os
22+
from pathlib import Path
2123
import sys
2224
import platform
2325
import inspect
24-
import glob
26+
from typing import List
2527

2628
stats = '''
2729
@@ -49,7 +51,12 @@
4951
os.environ["BN_DISABLE_USER_PLUGINS"] = "True"
5052
os.environ["BN_DISABLE_REPOSITORY_PLUGINS"] = "True"
5153
import binaryninja
52-
import binaryninja.debugger
54+
55+
try:
56+
import binaryninja.debugger
57+
except ImportError:
58+
pass
59+
5360
try:
5461
import binaryninja.collaboration
5562
except ImportError:
@@ -85,17 +92,14 @@ def setup(app):
8592
app.add_css_file('css/other.css')
8693
app.is_parallel_allowed('write')
8794

88-
def cleansource():
89-
rstfiles = glob.glob("*.rst")
90-
for f in rstfiles:
91-
try:
92-
os.remove(f)
93-
except OSError:
94-
print(f"Unable to remove {f}")
95-
9695
def generaterst():
97-
pythonrst = open("index.rst", "w")
98-
pythonrst.write('''Binary Ninja Python API Reference
96+
# Generate and index.rst and .rst files for modules, removing any unneeded/old .rst files.
97+
# This should only write to the files if the contents have changed,
98+
# because sphinx uses that .rst mtime to determine if the corresponding .html file should be regenerated.
99+
# Maybe eventually they'll check file contents to see if the file contents changed but until then this works.
100+
used_rst_files: List[Path] = []
101+
index_rst = StringIO()
102+
index_rst.write('''Binary Ninja Python API Reference
99103
=====================================
100104
101105
Welcome to the Binary Ninja API documentation. The below methods are available
@@ -130,19 +134,20 @@ def generaterst():
130134

131135
# Generate docs for both binaryninja and binaryninja.debugger module
132136
modules = modulelist(binaryninja)
133-
modules.extend(modulelist(binaryninja.debugger, basename="debugger"))
137+
if hasattr(binaryninja, "debugger"):
138+
modules.extend(modulelist(binaryninja.debugger, basename="debugger"))
134139
if hasattr(binaryninja, "collaboration"):
135140
modules.extend(modulelist(binaryninja.collaboration, basename="collaboration"))
136141
modules = sorted(modules, key=lambda pair: pair[0])
137142

138143
# Separate top-level and nested modules for proper TOC structure
139144
nested_modules = {"debugger": [], "collaboration": []}
140-
145+
141146
for modulename, module in modules:
142147
filename = f"{module.__name__}-module.rst"
143148
if modulename.count(".") == 0:
144149
# Top-level module - always include in main TOC
145-
pythonrst.write(f" {modulename} <{filename}>\n")
150+
index_rst.write(f" {modulename} <{filename}>\n")
146151
else:
147152
# This is a nested module - collect them for parent modules
148153
parent = modulename.split(".")[0]
@@ -153,42 +158,42 @@ def generaterst():
153158
# Since we put debugger python files in a folder, binaryninja.{modulename} is no longer the
154159
# correct name of the module
155160
filename = f"{module.__name__}-module.rst"
156-
modulefile = open(filename, "w")
161+
module_contents = StringIO()
157162
underline = "="*len(f"{modulename} module")
158-
modulefile.write(f'''{modulename} module
163+
module_contents.write(f'''{modulename} module
159164
{underline}
160165
161166
''')
162167

163168
# Add sub-toctree for parent modules that have nested modules
164169
if modulename.count(".") == 0 and modulename in nested_modules and nested_modules[modulename]:
165-
modulefile.write(".. toctree::\n")
166-
modulefile.write(" :maxdepth: 1\n")
167-
modulefile.write(" :hidden:\n\n")
170+
module_contents.write(".. toctree::\n")
171+
module_contents.write(" :maxdepth: 1\n")
172+
module_contents.write(" :hidden:\n\n")
168173
for nested_name, nested_filename in nested_modules[modulename]:
169-
modulefile.write(f" {nested_name} <{nested_filename}>\n")
170-
modulefile.write("\n")
174+
module_contents.write(f" {nested_name} <{nested_filename}>\n")
175+
module_contents.write("\n")
171176

172177
# Include module-level docstring
173-
modulefile.write(f'''.. automodule:: {module.__name__}
178+
module_contents.write(f'''.. automodule:: {module.__name__}
174179
175180
''')
176181

177182
# Generate custom summary table
178183
classes = list(classlist(module))
179184
if classes:
180-
modulefile.write(".. list-table::\n")
181-
modulefile.write(" :header-rows: 1\n")
182-
modulefile.write(" :widths: 30 70\n\n")
183-
modulefile.write(" * - Class\n")
184-
modulefile.write(" - Description\n")
185-
185+
module_contents.write(".. list-table::\n")
186+
module_contents.write(" :header-rows: 1\n")
187+
module_contents.write(" :widths: 30 70\n\n")
188+
module_contents.write(" * - Class\n")
189+
module_contents.write(" - Description\n")
190+
186191
for (classname, classref) in classes:
187192
if inspect.isclass(classref):
188193
role = 'py:class'
189194
else:
190195
role = 'py:func'
191-
196+
192197
# Get docstring summary (first line)
193198
doc = inspect.getdoc(classref)
194199
summary = ""
@@ -262,18 +267,18 @@ def generaterst():
262267
if last_backtick > 0:
263268
truncate_at = summary.rfind(' ', 0, last_backtick)
264269
summary = summary[:truncate_at] + "..."
265-
266-
modulefile.write(f" * - :{role}:`{inspect.getmodule(classref).__name__}.{classname}`\n")
267-
modulefile.write(f" - {summary}\n")
268270

271+
module_contents.write(f" * - :{role}:`{inspect.getmodule(classref).__name__}.{classname}`\n")
272+
module_contents.write(f" - {summary}\n")
273+
274+
275+
module_contents.write('\n\n')
269276

270-
modulefile.write(f'''\n\n''')
271-
272277
# Generate individual class sections with proper headers
273278
for (classname, classref) in classes:
274279
# Only include classes that actually belong to this module
275280
if inspect.getmodule(classref).__name__ == module.__name__:
276-
modulefile.write(f'''{classname}
281+
module_contents.write(f'''{classname}
277282
{"-" * len(classname)}
278283
279284
.. autoclass:: {module.__name__}.{classname}
@@ -282,14 +287,31 @@ def generaterst():
282287
:show-inheritance:
283288
284289
''')
285-
modulefile.write(stats)
286-
modulefile.close()
287-
288-
pythonrst.write(stats)
289-
pythonrst.close()
290-
291-
292-
cleansource()
290+
module_contents.write(stats)
291+
292+
new_module_contents = module_contents.getvalue()
293+
module_contents.close()
294+
module_file = Path(filename)
295+
# Only write to the module file if the contents are different
296+
if not module_file.is_file() or module_file.read_text() != new_module_contents:
297+
module_file.write_text(new_module_contents)
298+
used_rst_files.append(module_file)
299+
300+
module_contents.close()
301+
302+
index_rst.write(stats)
303+
new_index_contents = index_rst.getvalue()
304+
index_rst.close()
305+
306+
index_file = Path("index.rst")
307+
if not index_file.is_file() or index_file.read_text() != new_index_contents:
308+
index_file.write_text(new_index_contents)
309+
used_rst_files.append(index_file)
310+
311+
# Remove extra .rst files that weren't used
312+
for rst_file in Path('.').glob('*.rst'):
313+
if rst_file not in used_rst_files:
314+
rst_file.unlink()
293315

294316
generaterst()
295317

0 commit comments

Comments
 (0)