Skip to content

Commit 39a111d

Browse files
committed
imgmath: Avoid parallel workers depth write race
Ensure parallel workers do not try to write the image depth multiple times to achieve reproducible builds. Typically my svg files ended up like this: ``` ... <!-- DEPTH=0 --> <!-- DEPTH=0 --> ``` So images were not the same and sometimes the depth was incorrect too. Introduces a new dependency to the filelock module.
1 parent 3c4b4e3 commit 39a111d

File tree

2 files changed

+50
-32
lines changed

2 files changed

+50
-32
lines changed

pyproject.toml

+1
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ dependencies = [
8484
"roman-numerals-py>=1.0.0",
8585
"packaging>=23.0",
8686
"colorama>=0.4.6; sys_platform == 'win32'",
87+
"filelock>=3.0",
8788
]
8889
dynamic = ["version"]
8990

sphinx/ext/imgmath.py

+49-32
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from subprocess import CalledProcessError
1818
from typing import TYPE_CHECKING
1919

20+
import filelock
2021
from docutils import nodes
2122

2223
import sphinx
@@ -29,6 +30,8 @@
2930
from sphinx.util.template import LaTeXRenderer
3031

3132
if TYPE_CHECKING:
33+
from typing import Any
34+
3235
from docutils.nodes import Element
3336

3437
from sphinx.application import Sphinx
@@ -82,7 +85,7 @@ def read_svg_depth(filename: str | os.PathLike[str]) -> int | None:
8285
def write_svg_depth(filename: Path, depth: int) -> None:
8386
"""Write the depth to SVG file as a comment at end of file"""
8487
with open(filename, 'a', encoding='utf-8') as f:
85-
f.write('\n<!-- DEPTH=%s -->' % depth)
88+
f.write(f'\n<!-- DEPTH={depth} -->')
8689

8790

8891
def generate_latex_macro(
@@ -266,36 +269,45 @@ def render_math(
266269
)
267270
generated_path = self.builder.outdir / self.builder.imagedir / 'math' / filename
268271
generated_path.parent.mkdir(parents=True, exist_ok=True)
269-
if generated_path.is_file():
270-
if image_format == 'png':
271-
depth = read_png_depth(generated_path)
272-
elif image_format == 'svg':
273-
depth = read_svg_depth(generated_path)
274-
return generated_path, depth
275-
276-
# if latex or dvipng (dvisvgm) has failed once, don't bother to try again
277-
latex_failed = hasattr(self.builder, '_imgmath_warned_latex')
278-
trans_failed = hasattr(self.builder, '_imgmath_warned_image_translator')
279-
if latex_failed or trans_failed:
280-
return None, None
281-
282-
# .tex -> .dvi
283-
try:
284-
dvipath = compile_math(latex, self.builder)
285-
except InvokeError:
286-
self.builder._imgmath_warned_latex = True # type: ignore[attr-defined]
287-
return None, None
288-
289-
# .dvi -> .png/.svg
290-
try:
291-
if image_format == 'png':
292-
depth = convert_dvi_to_png(dvipath, self.builder, generated_path)
293-
elif image_format == 'svg':
294-
depth = convert_dvi_to_svg(dvipath, self.builder, generated_path)
295-
except InvokeError:
296-
self.builder._imgmath_warned_image_translator = True # type: ignore[attr-defined]
297-
return None, None
298272

273+
# ensure parallel workers do not try to write the image depth
274+
# multiple times to achieve reproducible builds
275+
lock: Any = contextlib.nullcontext()
276+
if self.builder.parallel_ok:
277+
lock = filelock.FileLock(generated_path.with_suffix(generated_path.suffix + '.lock'))
278+
279+
with lock:
280+
if not generated_path.is_file():
281+
# if latex or dvipng (dvisvgm) has failed once, don't bother to try again
282+
latex_failed = hasattr(self.builder, '_imgmath_warned_latex')
283+
trans_failed = hasattr(self.builder, '_imgmath_warned_image_translator')
284+
if latex_failed or trans_failed:
285+
return None, None
286+
287+
# .tex -> .dvi
288+
try:
289+
dvipath = compile_math(latex, self.builder)
290+
except InvokeError:
291+
self.builder._imgmath_warned_latex = True # type: ignore[attr-defined]
292+
return None, None
293+
294+
# .dvi -> .png/.svg
295+
try:
296+
if image_format == 'png':
297+
depth = convert_dvi_to_png(dvipath, self.builder, generated_path)
298+
elif image_format == 'svg':
299+
depth = convert_dvi_to_svg(dvipath, self.builder, generated_path)
300+
except InvokeError:
301+
self.builder._imgmath_warned_image_translator = True # type: ignore[attr-defined]
302+
return None, None
303+
304+
return generated_path, depth
305+
306+
# at this point it has been created
307+
if image_format == 'png':
308+
depth = read_png_depth(generated_path)
309+
elif image_format == 'svg':
310+
depth = read_svg_depth(generated_path)
299311
return generated_path, depth
300312

301313

@@ -319,11 +331,16 @@ def clean_up_files(app: Sphinx, exc: Exception) -> None:
319331
with contextlib.suppress(Exception):
320332
shutil.rmtree(app.builder._imgmath_tempdir)
321333

334+
math_outdir = app.builder.outdir / app.builder.imagedir / 'math'
322335
if app.builder.config.imgmath_embed:
323336
# in embed mode, the images are still generated in the math output dir
324337
# to be shared across workers, but are not useful to the final document
325338
with contextlib.suppress(Exception):
326-
shutil.rmtree(app.builder.outdir / app.builder.imagedir / 'math')
339+
shutil.rmtree(math_outdir)
340+
else:
341+
# cleanup lock files when using parallel workers
342+
for lockfile in math_outdir.glob('*.lock'):
343+
Path.unlink(lockfile)
327344

328345

329346
def get_tooltip(self: HTML5Translator, node: Element) -> str:
@@ -383,7 +400,7 @@ def html_visit_displaymath(self: HTML5Translator, node: nodes.math_block) -> Non
383400
self.body.append('<p>')
384401
if node['number']:
385402
number = get_node_equation_number(self, node)
386-
self.body.append('<span class="eqno">(%s)' % number)
403+
self.body.append(f'<span class="eqno">({number})')
387404
self.add_permalink_ref(node, _('Link to this equation'))
388405
self.body.append('</span>')
389406

0 commit comments

Comments
 (0)