diff --git a/docs/building.rst b/docs/building.rst index b91006b..dd1a23d 100644 --- a/docs/building.rst +++ b/docs/building.rst @@ -8,3 +8,6 @@ Inside your ``docs`` folder, run:: 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_parallel_build` in :doc:`configuration`. diff --git a/docs/changelog.rst b/docs/changelog.rst index 7c76a38..868efa6 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,7 @@ Changelog Unreleased ---------- +* **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 4d81636..b2f98ea 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -218,3 +218,36 @@ 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 quiet flag `-q` should be used. + +.. _simplepdf_parallel_build: + +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. + +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``: + +.. code-block:: python + + simplepdf_parallel_build = True + +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 +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). + +Default: ``False`` diff --git a/sphinx_simplepdf/__init__.py b/sphinx_simplepdf/__init__.py index 3c85724..31e338e 100644 --- a/sphinx_simplepdf/__init__.py +++ b/sphinx_simplepdf/__init__.py @@ -1,13 +1,22 @@ from sphinx_simplepdf.directives.ifbuilder import IfBuilderDirective from sphinx_simplepdf.directives.ifinclude import IfIncludeDirective from sphinx_simplepdf.directives.pdfinclude import PdfIncludeDirective +from sphinx_simplepdf.parallel_build import register as register_parallel_build def setup(app): + # 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) + app.add_directive("if-builder", IfBuilderDirective) app.add_directive("if-include", IfIncludeDirective) app.add_directive("pdf-include", PdfIncludeDirective) + register_parallel_build(app) + return { "parallel_read_safe": True, "parallel_write_safe": True, diff --git a/sphinx_simplepdf/builders/simplepdf.py b/sphinx_simplepdf/builders/simplepdf.py index 74283c5..772b25a 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( diff --git a/sphinx_simplepdf/parallel_build.py b/sphinx_simplepdf/parallel_build.py new file mode 100644 index 0000000..53c2c05 --- /dev/null +++ b/sphinx_simplepdf/parallel_build.py @@ -0,0 +1,165 @@ +"""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__) + +# 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).""" + 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.""" + + 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 not app.config.simplepdf_parallel_build: + return + if not _parallel_pdf_runs_for_builder(app): + return + + self._start_subprocess(app) + + def _on_build_finished(self, app, exception): + if not app.config.simplepdf_parallel_build: + return + if not _parallel_pdf_runs_for_builder(app): + 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, + stdin=subprocess.DEVNULL, + 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" + html_out = Path(app.outdir) + expected_path = _configured_pdf_source(app, output_dir) + target = _configured_pdf_dest(app, html_out) + + if expected_path.is_file(): + shutil.copy2(expected_path, target) + logger.info(f"sphinx-simplepdf: {expected_path.name} -> {target}") + return + + 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.""" + 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: + if 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_parallel_build", 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 new file mode 100644 index 0000000..90740bc --- /dev/null +++ b/tests/test_parallel_build.py @@ -0,0 +1,362 @@ +"""Tests for parallel PDF build functionality.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from sphinx_simplepdf.parallel_build 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_parallel_build = 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 (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_parallel_build 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_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) + 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_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) + 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() + app.config.simplepdf_file_name = None + app.config.project = "MyProject" + + 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() + app.config.simplepdf_file_name = None + app.config.project = "Proj" + + 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.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] + + def test_noop_when_no_build_dir(self): + app = MagicMock() + gen = _PdfGenerator(app) + gen.build_dir = None + # 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.""" + + 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_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) + 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.parallel_build.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_parallel_build = True + app.config.simplepdf_file_name = None + app.config.project = "Test" + 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_parallel_build = True + app.config.simplepdf_file_name = None + app.config.project = "Test" + 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 + + +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_parallel_build=True and verify PDF appears.""" + result = sphinx_build( + buildername="html", + srcdir="basic_doc", + confoverrides={"simplepdf_parallel_build": 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_parallel_build": False}, + ).build() + + pdf_files = list(result.outdir.glob("*.pdf")) + assert len(pdf_files) == 0