From 349ebfa7de06f0a46078a55a4aedfd180c328931 Mon Sep 17 00:00:00 2001 From: Jared Dillard Date: Thu, 2 Apr 2026 16:41:35 -0700 Subject: [PATCH 01/20] Add support for parallel builds --- sphinx_simplepdf/__init__.py | 106 +++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/sphinx_simplepdf/__init__.py b/sphinx_simplepdf/__init__.py index 3c85724..08d01f1 100644 --- a/sphinx_simplepdf/__init__.py +++ b/sphinx_simplepdf/__init__.py @@ -1,13 +1,119 @@ +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile + +from sphinx.util import logging + from sphinx_simplepdf.directives.ifbuilder import IfBuilderDirective from sphinx_simplepdf.directives.ifinclude import IfIncludeDirective from sphinx_simplepdf.directives.pdfinclude import PdfIncludeDirective +logger = logging.getLogger(__name__) + + +class _PdfGenerator: + """Manages an optional parallel simplepdf subprocess build.""" + + def __init__(self, app): + self.app = app + self.process = None + self.build_dir = None + self.log_path = None + + def _on_builder_inited(self, app): + if app.builder.name == "simplepdf": + return + if not app.config.simplepdf_build_parallel: + return + + self._start_subprocess(app) + + def _on_build_finished(self, app, exception): + if app.builder.name == "simplepdf": + return + if not app.config.simplepdf_build_parallel: + return + + if exception: + logger.warning("sphinx-simplepdf: skipping PDF due to build error") + self._cleanup() + return + + if self.process is None: + return + + if self.process.poll() is None: + logger.info("sphinx-simplepdf: waiting for PDF build to finish...") + self.process.wait() + + if self.process.returncode != 0: + log_hint = f" see log: {self.log_path}" if self.log_path else "" + logger.warning( + f"sphinx-simplepdf: PDF build failed (exit {self.process.returncode}){log_hint}" + ) + self._cleanup() + return + + self._copy_pdf(app) + self._cleanup() + + def _start_subprocess(self, app): + self.build_dir = Path(tempfile.mkdtemp(prefix="simplepdf_")) + self.log_path = self.build_dir / "build.log" + + cmd = [ + sys.executable, + "-m", + "sphinx", + "-b", + "simplepdf", + str(app.srcdir), + str(self.build_dir / "output"), + "-d", + str(self.build_dir / "doctrees"), + "-q", + ] + + logger.info("sphinx-simplepdf: starting PDF build subprocess") + log_fh = self.log_path.open("w") + self.process = subprocess.Popen(cmd, stdout=log_fh, stderr=subprocess.STDOUT) + + def _copy_pdf(self, app): + if self.build_dir is None: + return + + output_dir = self.build_dir / "output" + pdf_files = list(output_dir.glob("*.pdf")) + + if not pdf_files: + logger.warning("sphinx-simplepdf: no PDF found in build output") + return + + dest = Path(app.outdir) + for pdf in pdf_files: + target = dest / pdf.name + shutil.copy2(pdf, target) + logger.info(f"sphinx-simplepdf: {pdf.name} -> {target}") + + def _cleanup(self): + if self.build_dir is not None and self.build_dir.exists(): + shutil.rmtree(self.build_dir, ignore_errors=True) + self.build_dir = None + def setup(app): app.add_directive("if-builder", IfBuilderDirective) app.add_directive("if-include", IfIncludeDirective) app.add_directive("pdf-include", PdfIncludeDirective) + app.add_config_value("simplepdf_build_parallel", False, "env", types=[bool]) + + gen = _PdfGenerator(app) + app.connect("builder-inited", gen._on_builder_inited) + app.connect("build-finished", gen._on_build_finished, priority=101) + return { "parallel_read_safe": True, "parallel_write_safe": True, From 6dded0aa83ab2f50d4d2fe5d5c2833c88b706dc7 Mon Sep 17 00:00:00 2001 From: Jared Dillard Date: Thu, 2 Apr 2026 16:50:56 -0700 Subject: [PATCH 02/20] Add tests --- sphinx_simplepdf/__init__.py | 6 + tests/conftest.py | 5 +- tests/test_parallel_build.py | 254 +++++++++++++++++++++++++++++++++++ 3 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 tests/test_parallel_build.py diff --git a/sphinx_simplepdf/__init__.py b/sphinx_simplepdf/__init__.py index 08d01f1..e87f5b3 100644 --- a/sphinx_simplepdf/__init__.py +++ b/sphinx_simplepdf/__init__.py @@ -104,6 +104,12 @@ def _cleanup(self): def setup(app): + # Register builder so it is always available when this extension is loaded, + # even without the package's entry point (e.g. editable installs, subprocess builds). + from sphinx_simplepdf.builders.simplepdf import setup as _builder_setup + + _builder_setup(app) + app.add_directive("if-builder", IfBuilderDirective) app.add_directive("if-include", IfIncludeDirective) app.add_directive("pdf-include", PdfIncludeDirective) diff --git a/tests/conftest.py b/tests/conftest.py index 67b3d08..3b2f5b9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,7 +14,10 @@ from sphinx.testing.util import SphinxTestApp from sphinx.util import logging as sphinx_logging -from .imgcompare import compare_pdfs +try: + from .imgcompare import compare_pdfs +except ImportError: + compare_pdfs = None pytest_plugins = ("sphinx.testing.fixtures",) diff --git a/tests/test_parallel_build.py b/tests/test_parallel_build.py new file mode 100644 index 0000000..2c0f3b4 --- /dev/null +++ b/tests/test_parallel_build.py @@ -0,0 +1,254 @@ +"""Tests for parallel PDF build functionality.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from sphinx_simplepdf import _PdfGenerator + + +class TestPdfGeneratorSkips: + """Test that the parallel hooks are no-ops when they should be.""" + + def _make_app(self, builder_name="html", parallel=True): + app = MagicMock() + app.builder.name = builder_name + app.config.simplepdf_build_parallel = parallel + app.srcdir = "/tmp/src" + app.outdir = "/tmp/out" + return app + + def test_builder_inited_skips_simplepdf_builder(self): + """No subprocess when the current builder IS simplepdf.""" + app = self._make_app(builder_name="simplepdf", parallel=True) + gen = _PdfGenerator(app) + gen._on_builder_inited(app) + assert gen.process is None + + def test_builder_inited_skips_when_disabled(self): + """No subprocess when simplepdf_build_parallel is False.""" + app = self._make_app(parallel=False) + gen = _PdfGenerator(app) + gen._on_builder_inited(app) + assert gen.process is None + + def test_build_finished_skips_simplepdf_builder(self): + """build-finished is a no-op when builder is simplepdf.""" + app = self._make_app(builder_name="simplepdf", parallel=True) + gen = _PdfGenerator(app) + # Should not raise or do anything + gen._on_build_finished(app, None) + assert gen.build_dir is None + + def test_build_finished_skips_when_disabled(self): + """build-finished is a no-op when parallel is disabled.""" + app = self._make_app(parallel=False) + gen = _PdfGenerator(app) + gen._on_build_finished(app, None) + assert gen.build_dir is None + + def test_build_finished_cleans_up_on_exception(self, tmp_path): + """Temp directory is cleaned up when the primary build fails.""" + app = self._make_app(parallel=True) + gen = _PdfGenerator(app) + gen.build_dir = tmp_path / "simplepdf_test" + gen.build_dir.mkdir() + assert gen.build_dir.exists() + + gen._on_build_finished(app, RuntimeError("build error")) + assert gen.build_dir is None + + def test_build_finished_noop_when_no_process(self): + """build-finished does nothing if no subprocess was started.""" + app = self._make_app(parallel=True) + gen = _PdfGenerator(app) + gen.process = None + # Should not raise + gen._on_build_finished(app, None) + + +class TestPdfCopy: + """Test that _copy_pdf copies only PDFs to the output directory.""" + + def test_copies_pdf_files(self, tmp_path): + app = MagicMock() + app.outdir = str(tmp_path / "html_output") + Path(app.outdir).mkdir() + + gen = _PdfGenerator(app) + gen.build_dir = tmp_path / "simplepdf_build" + output = gen.build_dir / "output" + output.mkdir(parents=True) + + # Create a PDF and some HTML artifacts + (output / "MyProject.pdf").write_bytes(b"%PDF-fake") + (output / "index.html").write_text("") + (output / "_static").mkdir() + (output / "_static" / "style.css").write_text("body {}") + + gen._copy_pdf(app) + + dest = Path(app.outdir) + assert (dest / "MyProject.pdf").exists() + assert (dest / "MyProject.pdf").read_bytes() == b"%PDF-fake" + # HTML artifacts should NOT be copied + assert not (dest / "index.html").exists() + assert not (dest / "_static").exists() + + def test_no_pdf_logs_warning(self, tmp_path): + app = MagicMock() + app.outdir = str(tmp_path / "html_output") + Path(app.outdir).mkdir() + + gen = _PdfGenerator(app) + gen.build_dir = tmp_path / "simplepdf_build" + output = gen.build_dir / "output" + output.mkdir(parents=True) + # No PDF file created + + with patch("sphinx_simplepdf.logger") as mock_logger: + gen._copy_pdf(app) + mock_logger.warning.assert_called_once() + assert "no PDF found" in mock_logger.warning.call_args[0][0] + + def test_noop_when_no_build_dir(self): + app = MagicMock() + gen = _PdfGenerator(app) + gen.build_dir = None + # Should not raise + gen._copy_pdf(app) + + +class TestCleanup: + """Test that _cleanup removes the temp directory.""" + + def test_removes_temp_dir(self, tmp_path): + app = MagicMock() + gen = _PdfGenerator(app) + gen.build_dir = tmp_path / "simplepdf_cleanup" + gen.build_dir.mkdir() + (gen.build_dir / "somefile").write_text("data") + + gen._cleanup() + assert gen.build_dir is None + assert not (tmp_path / "simplepdf_cleanup").exists() + + def test_noop_when_no_build_dir(self): + app = MagicMock() + gen = _PdfGenerator(app) + gen.build_dir = None + gen._cleanup() # should not raise + + +class TestStartSubprocess: + """Test that _start_subprocess builds the correct command.""" + + def test_command_structure(self, tmp_path): + app = MagicMock() + app.srcdir = str(tmp_path / "src") + Path(app.srcdir).mkdir() + + gen = _PdfGenerator(app) + + with patch("sphinx_simplepdf.subprocess.Popen") as mock_popen: + mock_popen.return_value = MagicMock() + gen._start_subprocess(app) + + assert gen.build_dir is not None + assert gen.log_path is not None + + cmd = mock_popen.call_args[0][0] + assert cmd[0].endswith("python") or "python" in cmd[0] + assert cmd[1:4] == ["-m", "sphinx", "-b"] + assert cmd[4] == "simplepdf" + assert cmd[5] == str(tmp_path / "src") + assert "-d" in cmd + assert "-q" in cmd + + # Cleanup + gen._cleanup() + + +class TestBuildFinishedIntegration: + """Test the full build-finished flow with a mock subprocess.""" + + def test_successful_subprocess_copies_pdf(self, tmp_path): + app = MagicMock() + app.builder.name = "html" + app.config.simplepdf_build_parallel = True + app.outdir = str(tmp_path / "html_output") + Path(app.outdir).mkdir() + + gen = _PdfGenerator(app) + gen.build_dir = tmp_path / "simplepdf_build" + output = gen.build_dir / "output" + output.mkdir(parents=True) + (output / "Test.pdf").write_bytes(b"%PDF-test") + + # Mock a completed subprocess + gen.process = MagicMock() + gen.process.poll.return_value = 0 # already finished + gen.process.returncode = 0 + + gen._on_build_finished(app, None) + + assert (Path(app.outdir) / "Test.pdf").exists() + # Temp dir should be cleaned up + assert gen.build_dir is None + + def test_failed_subprocess_cleans_up(self, tmp_path): + app = MagicMock() + app.builder.name = "html" + app.config.simplepdf_build_parallel = True + app.outdir = str(tmp_path / "html_output") + Path(app.outdir).mkdir() + + gen = _PdfGenerator(app) + gen.build_dir = tmp_path / "simplepdf_build" + gen.build_dir.mkdir() + gen.log_path = gen.build_dir / "build.log" + + gen.process = MagicMock() + gen.process.poll.return_value = 1 + gen.process.returncode = 1 + + gen._on_build_finished(app, None) + + assert not (Path(app.outdir) / "Test.pdf").exists() + assert gen.build_dir is None + + +@pytest.mark.slow +class TestParallelBuildEndToEnd: + """End-to-end test using actual Sphinx build with parallel PDF.""" + + def test_parallel_build_produces_pdf_in_html_output(self, sphinx_build, capsys): + """Build as HTML with simplepdf_build_parallel=True and verify PDF appears.""" + result = sphinx_build( + buildername="html", + srcdir="basic_doc", + confoverrides={"simplepdf_build_parallel": True}, + ).build() + + captured = capsys.readouterr() + result.warnings += captured.out.splitlines() + + # PDF should exist in the HTML output directory + pdf_files = list(result.outdir.glob("*.pdf")) + assert len(pdf_files) == 1, f"Expected 1 PDF, found: {pdf_files}" + assert pdf_files[0].stat().st_size > 0 + + # HTML files should also exist (it's an HTML build) + assert (result.outdir / "index.html").exists() + + def test_parallel_disabled_no_pdf(self, sphinx_build): + """With parallel disabled, HTML build should not produce a PDF.""" + result = sphinx_build( + buildername="html", + srcdir="basic_doc", + confoverrides={"simplepdf_build_parallel": False}, + ).build() + + pdf_files = list(result.outdir.glob("*.pdf")) + assert len(pdf_files) == 0 From b35bdd19088ae35f76c55c5286432ca52df2c218 Mon Sep 17 00:00:00 2001 From: Jared Dillard Date: Thu, 2 Apr 2026 16:52:43 -0700 Subject: [PATCH 03/20] update docs --- docs/building.rst | 18 ++++++++++++++++++ docs/configuration.rst | 14 ++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/docs/building.rst b/docs/building.rst index b91006b..9c0193c 100644 --- a/docs/building.rst +++ b/docs/building.rst @@ -8,3 +8,21 @@ Inside your ``docs`` folder, run:: or for more control:: sphinx-build -M simplepdf . _build + +Building PDF alongside HTML +---------------------------- + +You can also generate the PDF automatically during your normal HTML build by setting +``simplepdf_build_parallel`` in your ``conf.py``: + +.. code-block:: python + + simplepdf_build_parallel = True + +Then build as usual:: + + make html + +The PDF will appear in your HTML output directory alongside the generated HTML files. +A separate ``simplepdf`` build runs as a subprocess concurrently with the HTML build, +so the overall build time is close to whichever build takes longer rather than the sum of both. diff --git a/docs/configuration.rst b/docs/configuration.rst index 233c40e..5a36dbf 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -191,3 +191,17 @@ To reduce output noise the output can be filtered by a list of regular expressio ``simplepdf_weasyprint_filter = ["WARNING: Ignored"]`` To suppress all output, the quite flag `-q` should be used. + +simplepdf_build_parallel +------------------------ + +A boolean value. If set to ``True``, **Sphinx-SimplePDF** will automatically generate a PDF alongside +your primary build (e.g. HTML). A separate ``sphinx-build -b simplepdf`` subprocess runs concurrently with +the main build, and only the final PDF is copied into the output directory. + +This means you no longer need a dedicated ``simplepdf`` build step — the PDF is produced as a side effect +of your normal build. + +``simplepdf_build_parallel = True`` + +Default: ``False`` From afa688dcd50addc3a1f2f7f5585ee0b0a3318c44 Mon Sep 17 00:00:00 2001 From: Jared Dillard Date: Thu, 2 Apr 2026 16:54:13 -0700 Subject: [PATCH 04/20] update changelog --- docs/changelog.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 97268e8..012b9e1 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,11 @@ Changelog ========= +Unreleased +---------- + +* **Enhancement**: Add ``simplepdf_build_parallel`` config option to generate PDFs alongside HTML builds via a concurrent subprocess. + Release 1.7 ----------- :released: 02.12.2025 From be128e43563a87a8cdffdbd7b5c8c1667ebed138 Mon Sep 17 00:00:00 2001 From: Jared Dillard Date: Thu, 2 Apr 2026 21:09:33 -0700 Subject: [PATCH 05/20] revert local fix --- tests/conftest.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 3b2f5b9..67b3d08 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,10 +14,7 @@ from sphinx.testing.util import SphinxTestApp from sphinx.util import logging as sphinx_logging -try: - from .imgcompare import compare_pdfs -except ImportError: - compare_pdfs = None +from .imgcompare import compare_pdfs pytest_plugins = ("sphinx.testing.fixtures",) From 3324aeef01177deed3d3df651e9632448d9de65b Mon Sep 17 00:00:00 2001 From: Jared Dillard Date: Mon, 6 Apr 2026 10:52:11 -0700 Subject: [PATCH 06/20] Close parallel simplepdf subprocess log file after build Store stdout file object on _PdfGenerator and close it in _cleanup before removing the temp directory to avoid leaking descriptors. --- sphinx_simplepdf/__init__.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/sphinx_simplepdf/__init__.py b/sphinx_simplepdf/__init__.py index e87f5b3..1c15a13 100644 --- a/sphinx_simplepdf/__init__.py +++ b/sphinx_simplepdf/__init__.py @@ -21,6 +21,7 @@ def __init__(self, app): self.process = None self.build_dir = None self.log_path = None + self._log_fh = None def _on_builder_inited(self, app): if app.builder.name == "simplepdf": @@ -50,9 +51,7 @@ def _on_build_finished(self, app, exception): if self.process.returncode != 0: log_hint = f" see log: {self.log_path}" if self.log_path else "" - logger.warning( - f"sphinx-simplepdf: PDF build failed (exit {self.process.returncode}){log_hint}" - ) + logger.warning(f"sphinx-simplepdf: PDF build failed (exit {self.process.returncode}){log_hint}") self._cleanup() return @@ -77,8 +76,8 @@ def _start_subprocess(self, app): ] logger.info("sphinx-simplepdf: starting PDF build subprocess") - log_fh = self.log_path.open("w") - self.process = subprocess.Popen(cmd, stdout=log_fh, stderr=subprocess.STDOUT) + self._log_fh = self.log_path.open("w") + self.process = subprocess.Popen(cmd, stdout=self._log_fh, stderr=subprocess.STDOUT) def _copy_pdf(self, app): if self.build_dir is None: @@ -98,6 +97,11 @@ def _copy_pdf(self, app): logger.info(f"sphinx-simplepdf: {pdf.name} -> {target}") def _cleanup(self): + if self._log_fh is not None: + try: + self._log_fh.close() + finally: + self._log_fh = None if self.build_dir is not None and self.build_dir.exists(): shutil.rmtree(self.build_dir, ignore_errors=True) self.build_dir = None From 2a9e93cfa4ed3bd2551349d6dbbe09d47ffc9fee Mon Sep 17 00:00:00 2001 From: Jared Dillard Date: Mon, 6 Apr 2026 10:55:24 -0700 Subject: [PATCH 07/20] Stop parallel PDF subprocess before cleanup when primary build fails Terminate then kill-with-timeout if needed so we do not rmtree while the child is still running or leave a zombie process. --- sphinx_simplepdf/__init__.py | 22 ++++++++++++++++++++++ tests/test_parallel_build.py | 19 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/sphinx_simplepdf/__init__.py b/sphinx_simplepdf/__init__.py index 1c15a13..622ec60 100644 --- a/sphinx_simplepdf/__init__.py +++ b/sphinx_simplepdf/__init__.py @@ -96,7 +96,29 @@ def _copy_pdf(self, app): shutil.copy2(pdf, target) logger.info(f"sphinx-simplepdf: {pdf.name} -> {target}") + def _stop_pdf_subprocess(self): + """If the PDF child is still running, stop it before temp dir removal.""" + proc = self.process + if proc is None: + return + if proc.poll() is not None: + self.process = None + return + logger.info("sphinx-simplepdf: terminating PDF build subprocess (primary build failed)") + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + logger.warning("sphinx-simplepdf: PDF subprocess did not exit after terminate; killing") + proc.kill() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + logger.warning("sphinx-simplepdf: PDF subprocess did not respond to kill") + self.process = None + def _cleanup(self): + self._stop_pdf_subprocess() if self._log_fh is not None: try: self._log_fh.close() diff --git a/tests/test_parallel_build.py b/tests/test_parallel_build.py index 2c0f3b4..5b71d17 100644 --- a/tests/test_parallel_build.py +++ b/tests/test_parallel_build.py @@ -59,6 +59,25 @@ def test_build_finished_cleans_up_on_exception(self, tmp_path): gen._on_build_finished(app, RuntimeError("build error")) assert gen.build_dir is None + def test_build_finished_terminates_running_subprocess_on_exception(self, tmp_path): + """If the primary build fails, a running PDF subprocess is stopped before cleanup.""" + app = self._make_app(parallel=True) + gen = _PdfGenerator(app) + gen.build_dir = tmp_path / "simplepdf_test" + gen.build_dir.mkdir() + + proc = MagicMock() + proc.poll.return_value = None + proc.wait.return_value = None + gen.process = proc + + gen._on_build_finished(app, RuntimeError("build error")) + + proc.terminate.assert_called_once() + proc.wait.assert_called_once_with(timeout=10) + assert gen.process is None + assert gen.build_dir is None + def test_build_finished_noop_when_no_process(self): """build-finished does nothing if no subprocess was started.""" app = self._make_app(parallel=True) From 176d6535081c1ff4aff3f67e6c686d1f3182d46e Mon Sep 17 00:00:00 2001 From: Jared Dillard Date: Mon, 6 Apr 2026 10:59:02 -0700 Subject: [PATCH 08/20] remove slow marker --- tests/test_parallel_build.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/test_parallel_build.py b/tests/test_parallel_build.py index 5b71d17..74ba39e 100644 --- a/tests/test_parallel_build.py +++ b/tests/test_parallel_build.py @@ -3,8 +3,6 @@ from pathlib import Path from unittest.mock import MagicMock, patch -import pytest - from sphinx_simplepdf import _PdfGenerator @@ -238,7 +236,6 @@ def test_failed_subprocess_cleans_up(self, tmp_path): assert gen.build_dir is None -@pytest.mark.slow class TestParallelBuildEndToEnd: """End-to-end test using actual Sphinx build with parallel PDF.""" From cf4eeea6a7cf25881c9cd7c6e8722c94307060e5 Mon Sep 17 00:00:00 2001 From: Jared Dillard Date: Mon, 6 Apr 2026 18:14:18 -0700 Subject: [PATCH 09/20] make note about passed args --- docs/building.rst | 3 +++ docs/configuration.rst | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/docs/building.rst b/docs/building.rst index 9c0193c..3fdb655 100644 --- a/docs/building.rst +++ b/docs/building.rst @@ -26,3 +26,6 @@ Then build as usual:: The PDF will appear in your HTML output directory alongside the generated HTML files. A separate ``simplepdf`` build runs as a subprocess concurrently with the HTML build, so the overall build time is close to whichever build takes longer rather than the sum of both. + +Command-line options given only to the main build (such as ``-D`` or ``-t``) are not passed through +to the PDF subprocess; see :doc:`configuration` under ``simplepdf_build_parallel``. diff --git a/docs/configuration.rst b/docs/configuration.rst index 5a36dbf..66700ef 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -202,6 +202,13 @@ the main build, and only the final PDF is copied into the output directory. This means you no longer need a dedicated ``simplepdf`` build step — the PDF is produced as a side effect of your normal build. +The PDF subprocess is started with its own arguments (source directory, separate output and doctree +locations, and ``-q``). **Options you pass only on the parent command line**—for example ``-D``, +``-A``, ``-t``, ``-c``, or ``-n``—**are not forwarded** to that subprocess. The PDF build therefore +reads ``conf.py`` (and the environment) like a normal standalone run. If you need the PDF to match +a one-off CLI invocation, put those settings in ``conf.py`` (or run a separate +``sphinx-build -b simplepdf`` with the same flags). + ``simplepdf_build_parallel = True`` Default: ``False`` From e656499853acc9dc6b6d77f1a45c844a778caa36 Mon Sep 17 00:00:00 2001 From: Jared Dillard Date: Tue, 7 Apr 2026 14:48:50 -0700 Subject: [PATCH 10/20] add comment about listner priority --- sphinx_simplepdf/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/sphinx_simplepdf/__init__.py b/sphinx_simplepdf/__init__.py index 622ec60..5ee1e1e 100644 --- a/sphinx_simplepdf/__init__.py +++ b/sphinx_simplepdf/__init__.py @@ -144,6 +144,7 @@ def setup(app): gen = _PdfGenerator(app) app.connect("builder-inited", gen._on_builder_inited) + # Sphinx invokes listeners in ascending priority order (default 500); 101 runs before most. app.connect("build-finished", gen._on_build_finished, priority=101) return { From bb566a9f960ce92d7ad6cc49636364e4578036a3 Mon Sep 17 00:00:00 2001 From: Jared Dillard Date: Tue, 7 Apr 2026 17:52:39 -0700 Subject: [PATCH 11/20] clean up docs --- docs/building.rst | 22 ++-------------------- docs/configuration.rst | 17 +++++++++++++---- 2 files changed, 15 insertions(+), 24 deletions(-) diff --git a/docs/building.rst b/docs/building.rst index 3fdb655..180f4f0 100644 --- a/docs/building.rst +++ b/docs/building.rst @@ -9,23 +9,5 @@ or for more control:: sphinx-build -M simplepdf . _build -Building PDF alongside HTML ----------------------------- - -You can also generate the PDF automatically during your normal HTML build by setting -``simplepdf_build_parallel`` in your ``conf.py``: - -.. code-block:: python - - simplepdf_build_parallel = True - -Then build as usual:: - - make html - -The PDF will appear in your HTML output directory alongside the generated HTML files. -A separate ``simplepdf`` build runs as a subprocess concurrently with the HTML build, -so the overall build time is close to whichever build takes longer rather than the sum of both. - -Command-line options given only to the main build (such as ``-D`` or ``-t``) are not passed through -to the PDF subprocess; see :doc:`configuration` under ``simplepdf_build_parallel``. +.. note:: To produce a PDF during your usual HTML build (for example ``make html``), enable + :ref:`simplepdf_build_parallel` in :doc:`configuration`. diff --git a/docs/configuration.rst b/docs/configuration.rst index 646e1bb..c1cc730 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -219,15 +219,26 @@ To reduce output noise the output can be filtered by a list of regular expressio To suppress all output, the quite flag `-q` should be used. +.. _simplepdf_build_parallel: + simplepdf_build_parallel ------------------------ A boolean value. If set to ``True``, **Sphinx-SimplePDF** will automatically generate a PDF alongside your primary build (e.g. HTML). A separate ``sphinx-build -b simplepdf`` subprocess runs concurrently with -the main build, and only the final PDF is copied into the output directory. +the main build, and only the final PDF is copied into the HTML output directory alongside the generated HTML files. This means you no longer need a dedicated ``simplepdf`` build step — the PDF is produced as a side effect -of your normal build. +of your normal build. Because the PDF build runs in parallel with the main builder, overall wall-clock time +stays close to whichever of the two builds takes longer, not the sum of both. + +**Example** — in ``conf.py``: + +.. code-block:: python + + simplepdf_build_parallel = True + +Then build as usual (for example ``make html`` or ``sphinx-build -M html . _build``). The PDF subprocess is started with its own arguments (source directory, separate output and doctree locations, and ``-q``). **Options you pass only on the parent command line**—for example ``-D``, @@ -236,6 +247,4 @@ reads ``conf.py`` (and the environment) like a normal standalone run. If you nee a one-off CLI invocation, put those settings in ``conf.py`` (or run a separate ``sphinx-build -b simplepdf`` with the same flags). -``simplepdf_build_parallel = True`` - Default: ``False`` From a861a8f153f207eadf2d122b505674ab22bebb03 Mon Sep 17 00:00:00 2001 From: Jared Dillard Date: Tue, 7 Apr 2026 17:58:40 -0700 Subject: [PATCH 12/20] use sphinx_simplepdf/parallel_build.py --- sphinx_simplepdf/__init__.py | 134 +--------------------------- sphinx_simplepdf/parallel_build.py | 137 +++++++++++++++++++++++++++++ tests/test_parallel_build.py | 6 +- 3 files changed, 142 insertions(+), 135 deletions(-) create mode 100644 sphinx_simplepdf/parallel_build.py diff --git a/sphinx_simplepdf/__init__.py b/sphinx_simplepdf/__init__.py index 5ee1e1e..ce0a29b 100644 --- a/sphinx_simplepdf/__init__.py +++ b/sphinx_simplepdf/__init__.py @@ -1,132 +1,7 @@ -from pathlib import Path -import shutil -import subprocess -import sys -import tempfile - -from sphinx.util import logging - from sphinx_simplepdf.directives.ifbuilder import IfBuilderDirective from sphinx_simplepdf.directives.ifinclude import IfIncludeDirective from sphinx_simplepdf.directives.pdfinclude import PdfIncludeDirective - -logger = logging.getLogger(__name__) - - -class _PdfGenerator: - """Manages an optional parallel simplepdf subprocess build.""" - - def __init__(self, app): - self.app = app - self.process = None - self.build_dir = None - self.log_path = None - self._log_fh = None - - def _on_builder_inited(self, app): - if app.builder.name == "simplepdf": - return - if not app.config.simplepdf_build_parallel: - return - - self._start_subprocess(app) - - def _on_build_finished(self, app, exception): - if app.builder.name == "simplepdf": - return - if not app.config.simplepdf_build_parallel: - return - - if exception: - logger.warning("sphinx-simplepdf: skipping PDF due to build error") - self._cleanup() - return - - if self.process is None: - return - - if self.process.poll() is None: - logger.info("sphinx-simplepdf: waiting for PDF build to finish...") - self.process.wait() - - if self.process.returncode != 0: - log_hint = f" see log: {self.log_path}" if self.log_path else "" - logger.warning(f"sphinx-simplepdf: PDF build failed (exit {self.process.returncode}){log_hint}") - self._cleanup() - return - - self._copy_pdf(app) - self._cleanup() - - def _start_subprocess(self, app): - self.build_dir = Path(tempfile.mkdtemp(prefix="simplepdf_")) - self.log_path = self.build_dir / "build.log" - - cmd = [ - sys.executable, - "-m", - "sphinx", - "-b", - "simplepdf", - str(app.srcdir), - str(self.build_dir / "output"), - "-d", - str(self.build_dir / "doctrees"), - "-q", - ] - - logger.info("sphinx-simplepdf: starting PDF build subprocess") - self._log_fh = self.log_path.open("w") - self.process = subprocess.Popen(cmd, stdout=self._log_fh, stderr=subprocess.STDOUT) - - def _copy_pdf(self, app): - if self.build_dir is None: - return - - output_dir = self.build_dir / "output" - pdf_files = list(output_dir.glob("*.pdf")) - - if not pdf_files: - logger.warning("sphinx-simplepdf: no PDF found in build output") - return - - dest = Path(app.outdir) - for pdf in pdf_files: - target = dest / pdf.name - shutil.copy2(pdf, target) - logger.info(f"sphinx-simplepdf: {pdf.name} -> {target}") - - def _stop_pdf_subprocess(self): - """If the PDF child is still running, stop it before temp dir removal.""" - proc = self.process - if proc is None: - return - if proc.poll() is not None: - self.process = None - return - logger.info("sphinx-simplepdf: terminating PDF build subprocess (primary build failed)") - proc.terminate() - try: - proc.wait(timeout=10) - except subprocess.TimeoutExpired: - logger.warning("sphinx-simplepdf: PDF subprocess did not exit after terminate; killing") - proc.kill() - try: - proc.wait(timeout=10) - except subprocess.TimeoutExpired: - logger.warning("sphinx-simplepdf: PDF subprocess did not respond to kill") - self.process = None - - def _cleanup(self): - self._stop_pdf_subprocess() - if self._log_fh is not None: - try: - self._log_fh.close() - finally: - self._log_fh = None - if self.build_dir is not None and self.build_dir.exists(): - shutil.rmtree(self.build_dir, ignore_errors=True) - self.build_dir = None +from sphinx_simplepdf.parallel_build import register as register_parallel_build def setup(app): @@ -140,12 +15,7 @@ def setup(app): app.add_directive("if-include", IfIncludeDirective) app.add_directive("pdf-include", PdfIncludeDirective) - app.add_config_value("simplepdf_build_parallel", False, "env", types=[bool]) - - gen = _PdfGenerator(app) - app.connect("builder-inited", gen._on_builder_inited) - # Sphinx invokes listeners in ascending priority order (default 500); 101 runs before most. - app.connect("build-finished", gen._on_build_finished, priority=101) + register_parallel_build(app) return { "parallel_read_safe": True, diff --git a/sphinx_simplepdf/parallel_build.py b/sphinx_simplepdf/parallel_build.py new file mode 100644 index 0000000..fa58bf5 --- /dev/null +++ b/sphinx_simplepdf/parallel_build.py @@ -0,0 +1,137 @@ +"""Optional parallel simplepdf subprocess during non-simplepdf builds.""" + +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile + +from sphinx.util import logging + +logger = logging.getLogger(__name__) + + +class _PdfGenerator: + """Manages an optional parallel simplepdf subprocess build.""" + + def __init__(self, app): + self.app = app + self.process = None + self.build_dir = None + self.log_path = None + self._log_fh = None + + def _on_builder_inited(self, app): + if app.builder.name == "simplepdf": + return + if not app.config.simplepdf_build_parallel: + return + + self._start_subprocess(app) + + def _on_build_finished(self, app, exception): + if app.builder.name == "simplepdf": + return + if not app.config.simplepdf_build_parallel: + return + + if exception: + logger.warning("sphinx-simplepdf: skipping PDF due to build error") + self._cleanup() + return + + if self.process is None: + return + + if self.process.poll() is None: + logger.info("sphinx-simplepdf: waiting for PDF build to finish...") + self.process.wait() + + if self.process.returncode != 0: + log_hint = f" see log: {self.log_path}" if self.log_path else "" + logger.warning(f"sphinx-simplepdf: PDF build failed (exit {self.process.returncode}){log_hint}") + self._cleanup() + return + + self._copy_pdf(app) + self._cleanup() + + def _start_subprocess(self, app): + self.build_dir = Path(tempfile.mkdtemp(prefix="simplepdf_")) + self.log_path = self.build_dir / "build.log" + + cmd = [ + sys.executable, + "-m", + "sphinx", + "-b", + "simplepdf", + str(app.srcdir), + str(self.build_dir / "output"), + "-d", + str(self.build_dir / "doctrees"), + "-q", + ] + + logger.info("sphinx-simplepdf: starting PDF build subprocess") + self._log_fh = self.log_path.open("w") + self.process = subprocess.Popen(cmd, stdout=self._log_fh, stderr=subprocess.STDOUT) + + def _copy_pdf(self, app): + if self.build_dir is None: + return + + output_dir = self.build_dir / "output" + pdf_files = list(output_dir.glob("*.pdf")) + + if not pdf_files: + logger.warning("sphinx-simplepdf: no PDF found in build output") + return + + dest = Path(app.outdir) + for pdf in pdf_files: + target = dest / pdf.name + shutil.copy2(pdf, target) + logger.info(f"sphinx-simplepdf: {pdf.name} -> {target}") + + def _stop_pdf_subprocess(self): + """If the PDF child is still running, stop it before temp dir removal.""" + proc = self.process + if proc is None: + return + if proc.poll() is not None: + self.process = None + return + logger.info("sphinx-simplepdf: terminating PDF build subprocess (primary build failed)") + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + logger.warning("sphinx-simplepdf: PDF subprocess did not exit after terminate; killing") + proc.kill() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + logger.warning("sphinx-simplepdf: PDF subprocess did not respond to kill") + self.process = None + + def _cleanup(self): + self._stop_pdf_subprocess() + if self._log_fh is not None: + try: + self._log_fh.close() + finally: + self._log_fh = None + if self.build_dir is not None and self.build_dir.exists(): + shutil.rmtree(self.build_dir, ignore_errors=True) + self.build_dir = None + + +def register(app): + """Register config and Sphinx event hooks for parallel PDF builds.""" + app.add_config_value("simplepdf_build_parallel", False, "env", types=[bool]) + + gen = _PdfGenerator(app) + app.connect("builder-inited", gen._on_builder_inited) + # Sphinx invokes listeners in ascending priority order (default 500); 101 runs before most. + app.connect("build-finished", gen._on_build_finished, priority=101) diff --git a/tests/test_parallel_build.py b/tests/test_parallel_build.py index 74ba39e..44daa4b 100644 --- a/tests/test_parallel_build.py +++ b/tests/test_parallel_build.py @@ -3,7 +3,7 @@ from pathlib import Path from unittest.mock import MagicMock, patch -from sphinx_simplepdf import _PdfGenerator +from sphinx_simplepdf.parallel_build import _PdfGenerator class TestPdfGeneratorSkips: @@ -124,7 +124,7 @@ def test_no_pdf_logs_warning(self, tmp_path): output.mkdir(parents=True) # No PDF file created - with patch("sphinx_simplepdf.logger") as mock_logger: + with patch("sphinx_simplepdf.parallel_build.logger") as mock_logger: gen._copy_pdf(app) mock_logger.warning.assert_called_once() assert "no PDF found" in mock_logger.warning.call_args[0][0] @@ -168,7 +168,7 @@ def test_command_structure(self, tmp_path): gen = _PdfGenerator(app) - with patch("sphinx_simplepdf.subprocess.Popen") as mock_popen: + with patch("sphinx_simplepdf.parallel_build.subprocess.Popen") as mock_popen: mock_popen.return_value = MagicMock() gen._start_subprocess(app) From 591bd0cf3421bb56b2d2a06069b5a9e1df51c51f Mon Sep 17 00:00:00 2001 From: Jared Dillard Date: Tue, 7 Apr 2026 18:22:52 -0700 Subject: [PATCH 13/20] copy specific pdf not all pdfs --- docs/configuration.rst | 5 +++ sphinx_simplepdf/parallel_build.py | 31 ++++++++++++----- tests/test_parallel_build.py | 53 +++++++++++++++++++++++++++++- 3 files changed, 80 insertions(+), 9 deletions(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index c1cc730..db2dcb3 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -240,6 +240,11 @@ stays close to whichever of the two builds takes longer, not the sum of both. Then build as usual (for example ``make html`` or ``sphinx-build -M html . _build``). +The PDF copied into the HTML output directory uses the same rules as a normal ``simplepdf`` build: +:ref:`simplepdf_file_name` if set (the file is taken from the same path under the subprocess output directory), +otherwise ``.pdf``. Only the file name is placed in the HTML output directory, not any directory +components from ``simplepdf_file_name``. + The PDF subprocess is started with its own arguments (source directory, separate output and doctree locations, and ``-q``). **Options you pass only on the parent command line**—for example ``-D``, ``-A``, ``-t``, ``-c``, or ``-n``—**are not forwarded** to that subprocess. The PDF build therefore diff --git a/sphinx_simplepdf/parallel_build.py b/sphinx_simplepdf/parallel_build.py index fa58bf5..c9e77de 100644 --- a/sphinx_simplepdf/parallel_build.py +++ b/sphinx_simplepdf/parallel_build.py @@ -11,6 +11,22 @@ logger = logging.getLogger(__name__) +def _configured_pdf_source(app, output_dir: Path) -> Path: + """Path to the PDF under the simplepdf build output (matches the simplepdf builder).""" + raw = app.config.simplepdf_file_name + if raw: + return output_dir / Path(str(raw)) + return output_dir / f"{app.config.project}.pdf" + + +def _configured_pdf_dest(app, html_outdir: Path) -> Path: + """Destination path under the HTML output directory (flat basename, like a normal artifact).""" + raw = app.config.simplepdf_file_name + if raw: + return html_outdir / Path(str(raw)).name + return html_outdir / f"{app.config.project}.pdf" + + class _PdfGenerator: """Manages an optional parallel simplepdf subprocess build.""" @@ -82,17 +98,16 @@ def _copy_pdf(self, app): return output_dir = self.build_dir / "output" - pdf_files = list(output_dir.glob("*.pdf")) + html_out = Path(app.outdir) + expected_path = _configured_pdf_source(app, output_dir) + target = _configured_pdf_dest(app, html_out) - if not pdf_files: - logger.warning("sphinx-simplepdf: no PDF found in build output") + if expected_path.is_file(): + shutil.copy2(expected_path, target) + logger.info(f"sphinx-simplepdf: {expected_path.name} -> {target}") return - dest = Path(app.outdir) - for pdf in pdf_files: - target = dest / pdf.name - shutil.copy2(pdf, target) - logger.info(f"sphinx-simplepdf: {pdf.name} -> {target}") + logger.warning("sphinx-simplepdf: expected PDF not found at %s", expected_path) def _stop_pdf_subprocess(self): """If the PDF child is still running, stop it before temp dir removal.""" diff --git a/tests/test_parallel_build.py b/tests/test_parallel_build.py index 44daa4b..dffe25d 100644 --- a/tests/test_parallel_build.py +++ b/tests/test_parallel_build.py @@ -92,6 +92,8 @@ def test_copies_pdf_files(self, tmp_path): app = MagicMock() app.outdir = str(tmp_path / "html_output") Path(app.outdir).mkdir() + app.config.simplepdf_file_name = None + app.config.project = "MyProject" gen = _PdfGenerator(app) gen.build_dir = tmp_path / "simplepdf_build" @@ -117,6 +119,8 @@ def test_no_pdf_logs_warning(self, tmp_path): app = MagicMock() app.outdir = str(tmp_path / "html_output") Path(app.outdir).mkdir() + app.config.simplepdf_file_name = None + app.config.project = "Proj" gen = _PdfGenerator(app) gen.build_dir = tmp_path / "simplepdf_build" @@ -127,7 +131,7 @@ def test_no_pdf_logs_warning(self, tmp_path): with patch("sphinx_simplepdf.parallel_build.logger") as mock_logger: gen._copy_pdf(app) mock_logger.warning.assert_called_once() - assert "no PDF found" in mock_logger.warning.call_args[0][0] + assert "expected PDF not found" in mock_logger.warning.call_args[0][0] def test_noop_when_no_build_dir(self): app = MagicMock() @@ -136,6 +140,49 @@ def test_noop_when_no_build_dir(self): # Should not raise gen._copy_pdf(app) + def test_copies_to_simplepdf_file_name(self, tmp_path): + """Reads PDF from the same path the simplepdf builder uses; flattens into HTML outdir.""" + app = MagicMock() + app.outdir = str(tmp_path / "html_output") + Path(app.outdir).mkdir() + app.config.simplepdf_file_name = "docs/handbook.pdf" + app.config.project = "Ignored" + + gen = _PdfGenerator(app) + gen.build_dir = tmp_path / "simplepdf_build" + output = gen.build_dir / "output" + nested = output / "docs" + nested.mkdir(parents=True) + (nested / "handbook.pdf").write_bytes(b"%PDF-fake") + + gen._copy_pdf(app) + + dest = Path(app.outdir) + assert (dest / "handbook.pdf").exists() + assert not (dest / "Ignored.pdf").exists() + + def test_wrong_pdf_name_logs_warning(self, tmp_path): + """Only the configured output path is used; other PDFs are ignored.""" + app = MagicMock() + app.outdir = str(tmp_path / "html_output") + Path(app.outdir).mkdir() + app.config.simplepdf_file_name = None + app.config.project = "ReleaseNotes" + + gen = _PdfGenerator(app) + gen.build_dir = tmp_path / "simplepdf_build" + output = gen.build_dir / "output" + output.mkdir(parents=True) + (output / "unexpected.pdf").write_bytes(b"%PDF-x") + + with patch("sphinx_simplepdf.parallel_build.logger") as mock_logger: + gen._copy_pdf(app) + + mock_logger.warning.assert_called_once() + assert "expected PDF not found" in mock_logger.warning.call_args[0][0] + assert not (Path(app.outdir) / "ReleaseNotes.pdf").exists() + assert not (Path(app.outdir) / "unexpected.pdf").exists() + class TestCleanup: """Test that _cleanup removes the temp directory.""" @@ -194,6 +241,8 @@ def test_successful_subprocess_copies_pdf(self, tmp_path): app = MagicMock() app.builder.name = "html" app.config.simplepdf_build_parallel = True + app.config.simplepdf_file_name = None + app.config.project = "Test" app.outdir = str(tmp_path / "html_output") Path(app.outdir).mkdir() @@ -218,6 +267,8 @@ def test_failed_subprocess_cleans_up(self, tmp_path): app = MagicMock() app.builder.name = "html" app.config.simplepdf_build_parallel = True + app.config.simplepdf_file_name = None + app.config.project = "Test" app.outdir = str(tmp_path / "html_output") Path(app.outdir).mkdir() From 6cffe2c4174ce55b59ef0c14bf2f201a66b9959c Mon Sep 17 00:00:00 2001 From: Jared Dillard Date: Fri, 10 Apr 2026 10:03:16 -0700 Subject: [PATCH 14/20] run for all html builder types --- docs/building.rst | 2 +- docs/configuration.rst | 9 +++------ sphinx_simplepdf/parallel_build.py | 15 ++++++++++---- tests/test_parallel_build.py | 32 +++++++++++++++++++++++++++++- 4 files changed, 46 insertions(+), 12 deletions(-) diff --git a/docs/building.rst b/docs/building.rst index 180f4f0..acbd2a0 100644 --- a/docs/building.rst +++ b/docs/building.rst @@ -9,5 +9,5 @@ or for more control:: sphinx-build -M simplepdf . _build -.. note:: To produce a PDF during your usual HTML build (for example ``make html``), enable +.. note:: To produce a PDF during ``html``, ``dirhtml``, or ``singlehtml`` builds (for example ``make html``), enable :ref:`simplepdf_build_parallel` in :doc:`configuration`. diff --git a/docs/configuration.rst b/docs/configuration.rst index db2dcb3..2be509b 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -224,13 +224,10 @@ To suppress all output, the quite flag `-q` should be used. simplepdf_build_parallel ------------------------ -A boolean value. If set to ``True``, **Sphinx-SimplePDF** will automatically generate a PDF alongside -your primary build (e.g. HTML). A separate ``sphinx-build -b simplepdf`` subprocess runs concurrently with -the main build, and only the final PDF is copied into the HTML output directory alongside the generated HTML files. +A boolean value. If set to ``True``, a PDF is generated alongside your primary build when that build is one of Sphinx’s built-in HTML site builders. +Other builders (for example ``latex`` or ``epub``) are unchanged; use a separate ``sphinx-build -b simplepdf`` run if you need a PDF from those workflows. -This means you no longer need a dedicated ``simplepdf`` build step — the PDF is produced as a side effect -of your normal build. Because the PDF build runs in parallel with the main builder, overall wall-clock time -stays close to whichever of the two builds takes longer, not the sum of both. +This means you no longer need a dedicated ``simplepdf`` build step — the PDF is produced as a side effect of your normal build. Because the PDF build runs in parallel with the main builder, overall wall-clock time stays close to whichever of the two builds takes longer, not the sum of both. **Example** — in ``conf.py``: diff --git a/sphinx_simplepdf/parallel_build.py b/sphinx_simplepdf/parallel_build.py index c9e77de..0433c88 100644 --- a/sphinx_simplepdf/parallel_build.py +++ b/sphinx_simplepdf/parallel_build.py @@ -10,6 +10,13 @@ logger = logging.getLogger(__name__) +# Standard HTML-site builders only; simplepdf is omitted so it never nests another simplepdf run. +_PARALLEL_PDF_HTML_BUILDERS = frozenset({"html", "dirhtml", "singlehtml"}) + + +def _parallel_pdf_runs_for_builder(app) -> bool: + return app.builder.name in _PARALLEL_PDF_HTML_BUILDERS + def _configured_pdf_source(app, output_dir: Path) -> Path: """Path to the PDF under the simplepdf build output (matches the simplepdf builder).""" @@ -38,18 +45,18 @@ def __init__(self, app): self._log_fh = None def _on_builder_inited(self, app): - if app.builder.name == "simplepdf": - return if not app.config.simplepdf_build_parallel: return + if not _parallel_pdf_runs_for_builder(app): + return self._start_subprocess(app) def _on_build_finished(self, app, exception): - if app.builder.name == "simplepdf": - return if not app.config.simplepdf_build_parallel: return + if not _parallel_pdf_runs_for_builder(app): + return if exception: logger.warning("sphinx-simplepdf: skipping PDF due to build error") diff --git a/tests/test_parallel_build.py b/tests/test_parallel_build.py index dffe25d..bb89606 100644 --- a/tests/test_parallel_build.py +++ b/tests/test_parallel_build.py @@ -3,6 +3,8 @@ from pathlib import Path from unittest.mock import MagicMock, patch +import pytest + from sphinx_simplepdf.parallel_build import _PdfGenerator @@ -18,12 +20,31 @@ def _make_app(self, builder_name="html", parallel=True): return app def test_builder_inited_skips_simplepdf_builder(self): - """No subprocess when the current builder IS simplepdf.""" + """No subprocess when the current builder IS simplepdf (not an HTML site builder).""" app = self._make_app(builder_name="simplepdf", parallel=True) gen = _PdfGenerator(app) gen._on_builder_inited(app) assert gen.process is None + def test_builder_inited_skips_non_html_builder(self): + """No subprocess for builders outside html / dirhtml / singlehtml.""" + app = self._make_app(builder_name="latex", parallel=True) + gen = _PdfGenerator(app) + gen._on_builder_inited(app) + assert gen.process is None + + @pytest.mark.parametrize("builder_name", ["html", "dirhtml", "singlehtml"]) + def test_builder_inited_starts_subprocess_for_html_family(self, builder_name, tmp_path): + app = self._make_app(builder_name=builder_name, parallel=True) + app.srcdir = str(tmp_path / "src") + Path(app.srcdir).mkdir() + gen = _PdfGenerator(app) + with patch("sphinx_simplepdf.parallel_build.subprocess.Popen") as mock_popen: + mock_popen.return_value = MagicMock() + gen._on_builder_inited(app) + mock_popen.assert_called_once() + gen._cleanup() + def test_builder_inited_skips_when_disabled(self): """No subprocess when simplepdf_build_parallel is False.""" app = self._make_app(parallel=False) @@ -39,6 +60,15 @@ def test_build_finished_skips_simplepdf_builder(self): gen._on_build_finished(app, None) assert gen.build_dir is None + def test_build_finished_skips_non_html_builder(self): + """build-finished ignores parallel PDF when the primary builder is not HTML-site.""" + app = self._make_app(builder_name="latex", parallel=True) + gen = _PdfGenerator(app) + proc = MagicMock() + gen.process = proc + gen._on_build_finished(app, None) + proc.wait.assert_not_called() + def test_build_finished_skips_when_disabled(self): """build-finished is a no-op when parallel is disabled.""" app = self._make_app(parallel=False) From 259697afa3ad960b19e151f39bd43643486f568f Mon Sep 17 00:00:00 2001 From: Jared Dillard Date: Fri, 10 Apr 2026 10:41:35 -0700 Subject: [PATCH 15/20] rename to simplepdf_parallel_build --- docs/building.rst | 2 +- docs/changelog.rst | 2 +- docs/configuration.rst | 6 +++--- sphinx_simplepdf/parallel_build.py | 6 +++--- tests/test_parallel_build.py | 14 +++++++------- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/building.rst b/docs/building.rst index acbd2a0..dd1a23d 100644 --- a/docs/building.rst +++ b/docs/building.rst @@ -10,4 +10,4 @@ or for more control:: sphinx-build -M simplepdf . _build .. note:: To produce a PDF during ``html``, ``dirhtml``, or ``singlehtml`` builds (for example ``make html``), enable - :ref:`simplepdf_build_parallel` in :doc:`configuration`. + :ref:`simplepdf_parallel_build` in :doc:`configuration`. diff --git a/docs/changelog.rst b/docs/changelog.rst index e5411b4..c05b45b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,7 +4,7 @@ Changelog Unreleased ---------- -* **Enhancement**: Add ``simplepdf_build_parallel`` config option to generate PDFs alongside HTML builds via a concurrent subprocess. +* **Enhancement**: Add ``simplepdf_parallel_build`` config option to generate PDFs alongside HTML builds via a concurrent subprocess. * **Bugfix** [#134] Improve support for external theme packages by using a ``get_scss_sources_path()`` convention. - If needed, theme warnings can be suppressed via ``suppress_warnings = ["simplepdf.theme"]``. diff --git a/docs/configuration.rst b/docs/configuration.rst index 2be509b..0bde36f 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -219,9 +219,9 @@ To reduce output noise the output can be filtered by a list of regular expressio To suppress all output, the quite flag `-q` should be used. -.. _simplepdf_build_parallel: +.. _simplepdf_parallel_build: -simplepdf_build_parallel +simplepdf_parallel_build ------------------------ A boolean value. If set to ``True``, a PDF is generated alongside your primary build when that build is one of Sphinx’s built-in HTML site builders. @@ -233,7 +233,7 @@ This means you no longer need a dedicated ``simplepdf`` build step — the PDF i .. code-block:: python - simplepdf_build_parallel = True + simplepdf_parallel_build = True Then build as usual (for example ``make html`` or ``sphinx-build -M html . _build``). diff --git a/sphinx_simplepdf/parallel_build.py b/sphinx_simplepdf/parallel_build.py index 0433c88..0aa1cbd 100644 --- a/sphinx_simplepdf/parallel_build.py +++ b/sphinx_simplepdf/parallel_build.py @@ -45,7 +45,7 @@ def __init__(self, app): self._log_fh = None def _on_builder_inited(self, app): - if not app.config.simplepdf_build_parallel: + if not app.config.simplepdf_parallel_build: return if not _parallel_pdf_runs_for_builder(app): return @@ -53,7 +53,7 @@ def _on_builder_inited(self, app): self._start_subprocess(app) def _on_build_finished(self, app, exception): - if not app.config.simplepdf_build_parallel: + if not app.config.simplepdf_parallel_build: return if not _parallel_pdf_runs_for_builder(app): return @@ -151,7 +151,7 @@ def _cleanup(self): def register(app): """Register config and Sphinx event hooks for parallel PDF builds.""" - app.add_config_value("simplepdf_build_parallel", False, "env", types=[bool]) + app.add_config_value("simplepdf_parallel_build", False, "env", types=[bool]) gen = _PdfGenerator(app) app.connect("builder-inited", gen._on_builder_inited) diff --git a/tests/test_parallel_build.py b/tests/test_parallel_build.py index bb89606..bebc4f2 100644 --- a/tests/test_parallel_build.py +++ b/tests/test_parallel_build.py @@ -14,7 +14,7 @@ class TestPdfGeneratorSkips: def _make_app(self, builder_name="html", parallel=True): app = MagicMock() app.builder.name = builder_name - app.config.simplepdf_build_parallel = parallel + app.config.simplepdf_parallel_build = parallel app.srcdir = "/tmp/src" app.outdir = "/tmp/out" return app @@ -46,7 +46,7 @@ def test_builder_inited_starts_subprocess_for_html_family(self, builder_name, tm gen._cleanup() def test_builder_inited_skips_when_disabled(self): - """No subprocess when simplepdf_build_parallel is False.""" + """No subprocess when simplepdf_parallel_build is False.""" app = self._make_app(parallel=False) gen = _PdfGenerator(app) gen._on_builder_inited(app) @@ -270,7 +270,7 @@ class TestBuildFinishedIntegration: def test_successful_subprocess_copies_pdf(self, tmp_path): app = MagicMock() app.builder.name = "html" - app.config.simplepdf_build_parallel = True + app.config.simplepdf_parallel_build = True app.config.simplepdf_file_name = None app.config.project = "Test" app.outdir = str(tmp_path / "html_output") @@ -296,7 +296,7 @@ def test_successful_subprocess_copies_pdf(self, tmp_path): def test_failed_subprocess_cleans_up(self, tmp_path): app = MagicMock() app.builder.name = "html" - app.config.simplepdf_build_parallel = True + app.config.simplepdf_parallel_build = True app.config.simplepdf_file_name = None app.config.project = "Test" app.outdir = str(tmp_path / "html_output") @@ -321,11 +321,11 @@ class TestParallelBuildEndToEnd: """End-to-end test using actual Sphinx build with parallel PDF.""" def test_parallel_build_produces_pdf_in_html_output(self, sphinx_build, capsys): - """Build as HTML with simplepdf_build_parallel=True and verify PDF appears.""" + """Build as HTML with simplepdf_parallel_build=True and verify PDF appears.""" result = sphinx_build( buildername="html", srcdir="basic_doc", - confoverrides={"simplepdf_build_parallel": True}, + confoverrides={"simplepdf_parallel_build": True}, ).build() captured = capsys.readouterr() @@ -344,7 +344,7 @@ def test_parallel_disabled_no_pdf(self, sphinx_build): result = sphinx_build( buildername="html", srcdir="basic_doc", - confoverrides={"simplepdf_build_parallel": False}, + confoverrides={"simplepdf_parallel_build": False}, ).build() pdf_files = list(result.outdir.glob("*.pdf")) From 6ce722a93bff54e231e9774a837d6c48abce4216 Mon Sep 17 00:00:00 2001 From: Jared Dillard Date: Fri, 10 Apr 2026 10:49:19 -0700 Subject: [PATCH 16/20] Add versionadded --- docs/configuration.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/configuration.rst b/docs/configuration.rst index 0bde36f..f02b953 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -223,6 +223,7 @@ To suppress all output, the quite flag `-q` should be used. simplepdf_parallel_build ------------------------ +.. versionadded:: Unreleased A boolean value. If set to ``True``, a PDF is generated alongside your primary build when that build is one of Sphinx’s built-in HTML site builders. Other builders (for example ``latex`` or ``epub``) are unchanged; use a separate ``sphinx-build -b simplepdf`` run if you need a PDF from those workflows. From 09ff222ad57773368dd085111fbb7f0543f1d5f6 Mon Sep 17 00:00:00 2001 From: Jared Dillard Date: Fri, 10 Apr 2026 10:53:12 -0700 Subject: [PATCH 17/20] start with stdin=subprocess.DEVNULL so it never waits on the parent TTY --- sphinx_simplepdf/parallel_build.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/sphinx_simplepdf/parallel_build.py b/sphinx_simplepdf/parallel_build.py index 0aa1cbd..5e85a8e 100644 --- a/sphinx_simplepdf/parallel_build.py +++ b/sphinx_simplepdf/parallel_build.py @@ -98,7 +98,12 @@ def _start_subprocess(self, app): logger.info("sphinx-simplepdf: starting PDF build subprocess") self._log_fh = self.log_path.open("w") - self.process = subprocess.Popen(cmd, stdout=self._log_fh, stderr=subprocess.STDOUT) + self.process = subprocess.Popen( + cmd, + stdin=subprocess.DEVNULL, + stdout=self._log_fh, + stderr=subprocess.STDOUT, + ) def _copy_pdf(self, app): if self.build_dir is None: From 0dd39a60f1c7b0abdde4f824da7bfa609b50af15 Mon Sep 17 00:00:00 2001 From: Jared Dillard Date: Fri, 10 Apr 2026 11:12:27 -0700 Subject: [PATCH 18/20] Always clear parallel PDF temp dir reference in _cleanup --- sphinx_simplepdf/parallel_build.py | 5 +++-- tests/test_parallel_build.py | 11 +++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/sphinx_simplepdf/parallel_build.py b/sphinx_simplepdf/parallel_build.py index 5e85a8e..53c2c05 100644 --- a/sphinx_simplepdf/parallel_build.py +++ b/sphinx_simplepdf/parallel_build.py @@ -149,8 +149,9 @@ def _cleanup(self): self._log_fh.close() finally: self._log_fh = None - if self.build_dir is not None and self.build_dir.exists(): - shutil.rmtree(self.build_dir, ignore_errors=True) + if self.build_dir is not None: + if self.build_dir.exists(): + shutil.rmtree(self.build_dir, ignore_errors=True) self.build_dir = None diff --git a/tests/test_parallel_build.py b/tests/test_parallel_build.py index bebc4f2..90740bc 100644 --- a/tests/test_parallel_build.py +++ b/tests/test_parallel_build.py @@ -228,6 +228,17 @@ def test_removes_temp_dir(self, tmp_path): assert gen.build_dir is None assert not (tmp_path / "simplepdf_cleanup").exists() + def test_clears_build_dir_when_path_already_removed(self, tmp_path): + """Stale build_dir pointing at a deleted directory should still be cleared.""" + app = MagicMock() + gen = _PdfGenerator(app) + gone = tmp_path / "already_removed" + gen.build_dir = gone + assert not gone.exists() + + gen._cleanup() + assert gen.build_dir is None + def test_noop_when_no_build_dir(self): app = MagicMock() gen = _PdfGenerator(app) From 1efb4ed6f93e5f9ef8b2e97d7d3081eee9a08b4a Mon Sep 17 00:00:00 2001 From: Jared Dillard Date: Fri, 10 Apr 2026 11:35:33 -0700 Subject: [PATCH 19/20] update comment --- sphinx_simplepdf/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sphinx_simplepdf/__init__.py b/sphinx_simplepdf/__init__.py index ce0a29b..31e338e 100644 --- a/sphinx_simplepdf/__init__.py +++ b/sphinx_simplepdf/__init__.py @@ -5,8 +5,8 @@ def setup(app): - # Register builder so it is always available when this extension is loaded, - # even without the package's entry point (e.g. editable installs, subprocess builds). + # Register simplepdf config and builder on every build: Sphinx loads this extension + # for all builders, but only loads builders/simplepdf.py automatically for -b simplepdf. from sphinx_simplepdf.builders.simplepdf import setup as _builder_setup _builder_setup(app) From 19b0f6c3701153d07f427040198d81f5a85cb9eb Mon Sep 17 00:00:00 2001 From: Jared Dillard Date: Sat, 11 Apr 2026 13:25:32 -0700 Subject: [PATCH 20/20] Lazy-import weasyprint so HTML builds skip native deps --- sphinx_simplepdf/builders/simplepdf.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sphinx_simplepdf/builders/simplepdf.py b/sphinx_simplepdf/builders/simplepdf.py index 5714df1..47bd080 100644 --- a/sphinx_simplepdf/builders/simplepdf.py +++ b/sphinx_simplepdf/builders/simplepdf.py @@ -12,7 +12,6 @@ from sphinx.builders.singlehtml import SingleFileHTMLBuilder from sphinx.errors import ExtensionError from sphinx.util import logging -import weasyprint from sphinx_simplepdf.builders.debug import DebugPython from sphinx_simplepdf.writers.simplepdf import SimplepdfTranslator @@ -189,6 +188,10 @@ def finish(self) -> None: filter_pattern = "(?:{})".format("|".join(filter_list)) if len(filter_list) > 0 else None if self.config["simplepdf_use_weasyprint_api"]: + # Import only when this code path runs so ``extensions = ["sphinx_simplepdf"]`` on + # ``-b html`` does not load WeasyPrint's native dependencies (e.g. macOS CI). + import weasyprint + doc = weasyprint.HTML(index_path) doc.write_pdf(