From ba62a0b278d51a35c2929650a1bb64b5c00f1fae Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Fri, 9 Oct 2026 16:21:34 -0700 Subject: [PATCH] MAINT Separate strict HTML and checked PDF documentation builds Correct the Commons attribution and remove unsupported grid options. Keep API preparation and RSS generation while making HTML independent of LaTeX and verifying PDF prerequisites, fresh output, and native logs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Makefile | 34 +-- build_scripts/build_docs_pdf.py | 106 +++++++++ doc/code/executor/8_modality_feedback.ipynb | 4 +- doc/code/executor/8_modality_feedback.py | 4 +- doc/code/framework.md | 1 - doc/contributing/7_notebooks.md | 69 ++++++ doc/getting_started/README.md | 1 - doc/getting_started/configuration.md | 1 - doc/getting_started/install.md | 2 - .../unit/build_scripts/test_build_docs_pdf.py | 222 ++++++++++++++++++ .../build_scripts/test_docs_build_contract.py | 75 ++++++ 11 files changed, 488 insertions(+), 31 deletions(-) create mode 100644 build_scripts/build_docs_pdf.py create mode 100644 tests/unit/build_scripts/test_build_docs_pdf.py create mode 100644 tests/unit/build_scripts/test_docs_build_contract.py diff --git a/Makefile b/Makefile index 22ad43c0ce..501a5b92e3 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all pre-commit ty unit-test unit-test-junit unit-test-cov-html unit-test-cov-xml diff-cover unit-test-diff-cover +.PHONY: all pre-commit ty docs-api docs-build docs-build-pdf docs-build-all unit-test unit-test-junit unit-test-cov-html unit-test-cov-xml diff-cover unit-test-diff-cover CMD:=uv run -m PYMODULE:=pyrit @@ -19,29 +19,19 @@ pre-commit: ty: $(CMD) ty check $(PYMODULE) $(UNIT_TESTS) -# Build the full documentation site: -# 1. Generate API reference JSON from Python source (griffe) -# 2. Convert API JSON to MyST markdown pages -# 3. Build the Jupyter Book site (HTML only — fast, no LaTeX needed) -# 4. Generate RSS feed -docs-build: - uv run python -m build_scripts.pydoc2json pyrit --submodules -o doc/_api/pyrit_all.json - uv run python -m build_scripts.gen_api_md - # --strict validates URLs and cross-refs; skips are configured in doc/myst.yml under error_rules - cd doc && uv run jupyter-book build --all --html --strict +# Build strict HTML and the RSS feed after generating the API reference. +# --all would also select the configured PDF export and require LaTeX. +docs-build: docs-api + cd doc && uv run jupyter-book build --html --strict uv run python -m build_scripts.generate_rss -# Build the full documentation site including the PDF export. -# Mirrors the ReadTheDocs build (.readthedocs.yaml) so CI catches PDF-only issues -# such as missing images that the HTML-only build silently ignores. -# Requires xelatex / latexmk on PATH (texlive-xetex + texlive-fonts-recommended + -# texlive-plain-generic + latexmk on Ubuntu). -docs-build-all: - uv run python -m build_scripts.pydoc2json pyrit --submodules -o doc/_api/pyrit_all.json - uv run python -m build_scripts.gen_api_md - # --strict validates URLs and cross-refs; skips are configured in doc/myst.yml under error_rules - cd doc && uv run jupyter-book build --all --html --pdf --strict - uv run python -m build_scripts.generate_rss +# PDF is a separate, checked export requiring latexmk and xelatex. +docs-build-pdf: docs-api + uv run python -m build_scripts.build_docs_pdf + +# Build HTML first, then check the PDF export without repeating API generation. +docs-build-all: docs-build + uv run python -m build_scripts.build_docs_pdf # Regenerate only the API reference pages (without building the full site) docs-api: diff --git a/build_scripts/build_docs_pdf.py b/build_scripts/build_docs_pdf.py new file mode 100644 index 0000000000..9e48bea069 --- /dev/null +++ b/build_scripts/build_docs_pdf.py @@ -0,0 +1,106 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Build the configured documentation PDF and verify the export, not just the exit code.""" + +from __future__ import annotations + +import re +import shutil +import subprocess +import sys +from pathlib import Path + +import yaml +from pypdf import PdfReader +from pypdf.errors import PdfReadError + + +def _pdf_output(doc_root: Path) -> Path: + config = yaml.safe_load((doc_root / "myst.yml").read_text(encoding="utf-8")) + project = config.get("project") if isinstance(config, dict) else None + exports = project.get("exports") if isinstance(project, dict) else None + if not isinstance(exports, list): + raise ValueError("myst.yml must declare a project PDF export.") + pdf_exports = [export for export in exports if isinstance(export, dict) and export.get("format") == "pdf"] + if len(pdf_exports) != 1: + raise ValueError("Expected exactly one project PDF export in myst.yml.") + export = pdf_exports[0] + if export.get("template") != "plain_latex_book": + raise ValueError("The checked PDF build supports plain_latex_book with xelatex.") + output = export.get("output") + if not isinstance(output, str) or not output or Path(output).suffix != ".pdf": + raise ValueError("The PDF export must declare an output ending in .pdf.") + output_path = (doc_root / output).resolve() + if not output_path.is_relative_to(doc_root.resolve()): + raise ValueError("The PDF output must stay inside the documentation directory.") + return output_path + + +def _signature(path: Path) -> tuple[int, int, int] | None: + if not path.exists(): + return None + stat = path.stat() + return stat.st_mtime_ns, stat.st_ctime_ns, stat.st_size + + +def _require_fresh_file(*, path: Path, previous: tuple[int, int, int] | None) -> None: + if not path.is_file() or path.stat().st_size == 0: + raise ValueError(f"PDF export did not produce a nonempty file: {path}") + if _signature(path) == previous: + raise ValueError(f"PDF export left a stale file unchanged: {path}") + + +def _check_engine_log(path: Path) -> None: + fatal = re.compile( + r"^!|(?:LaTeX|Package \S+|Class \S+) Error:|Emergency stop|Fatal error occurred" + r"|^Latexmk: (?:Errors|Failure)|^Collected error summary" + ) + for line_number, line in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), start=1): + if fatal.search(line): + raise ValueError(f"Fatal LaTeX diagnostic in {path}:{line_number}: {line}") + + +def build_pdf(doc_root: Path) -> int: + """Build the repository's XeLaTeX PDF export and reject missing, stale, or failed output.""" + output = _pdf_output(doc_root) + missing = [tool for tool in ("latexmk", "xelatex") if shutil.which(tool) is None] + if missing: + raise ValueError( + f"Missing PDF prerequisites on PATH: {', '.join(missing)}. " + "Provision LaTeX separately; use the HTML-only build if a PDF is not needed." + ) + logs_dir = output.parent / f"{output.stem}_pdf_logs" + logs = [logs_dir / f"{output.stem}.log", logs_dir / f"{output.stem}.shell.log"] + previous = {path: _signature(path) for path in [output, *logs]} + result = subprocess.run( + [sys.executable, "-m", "jupyter_book", "build", "--site", "--pdf", "--strict", "--logs"], + cwd=doc_root, + check=False, + ) + if result.returncode != 0: + print(f"ERROR: Jupyter Book PDF build exited with code {result.returncode}.", file=sys.stderr) + return result.returncode + for path in [output, *logs]: + _require_fresh_file(path=path, previous=previous[path]) + for log in logs: + _check_engine_log(log) + reader = PdfReader(output, strict=True) + if not reader.pages: + raise ValueError(f"PDF export contains no pages: {output}") + print(f"[OK] Verified fresh PDF export and native LaTeX logs: {output}") + return 0 + + +def main() -> int: + """Run the checked export from this checkout's documentation directory.""" + doc_root = Path(__file__).resolve().parents[1] / "doc" + try: + return build_pdf(doc_root) + except (OSError, ValueError, yaml.YAMLError, PdfReadError) as error: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/doc/code/executor/8_modality_feedback.ipynb b/doc/code/executor/8_modality_feedback.ipynb index 26ceaa37b6..9b81575aa0 100644 --- a/doc/code/executor/8_modality_feedback.ipynb +++ b/doc/code/executor/8_modality_feedback.ipynb @@ -172,8 +172,8 @@ "- `roakey.png` is loaded from the docs root.\n", "- A modern color photo of a three-masted ship is loaded from a checked-in asset.\n", "- Ship photo source: [Gorch Fock unter Segeln Kieler Foerde 2006](\n", - " https://en.wikipedia.org/wiki/German_training_ship_Gorch_Fock_%281958%29#/media/File:Gorch_Fock_unter_Segeln_Kieler_Foerde_2006.jpg\n", - " ) (Wikimedia Commons), licensed under CC BY-SA 2.5." + " https://commons.wikimedia.org/wiki/File:Gorch_Fock_unter_Segeln_Kieler_Foerde_2006.jpg\n", + " ) (Wikimedia Commons). Photo by Felix Koenig, cropped by Ibn Battuta, licensed under CC BY-SA 2.5." ] }, { diff --git a/doc/code/executor/8_modality_feedback.py b/doc/code/executor/8_modality_feedback.py index 71faea7236..4a6e1c9994 100644 --- a/doc/code/executor/8_modality_feedback.py +++ b/doc/code/executor/8_modality_feedback.py @@ -113,8 +113,8 @@ # - `roakey.png` is loaded from the docs root. # - A modern color photo of a three-masted ship is loaded from a checked-in asset. # - Ship photo source: [Gorch Fock unter Segeln Kieler Foerde 2006]( -# https://en.wikipedia.org/wiki/German_training_ship_Gorch_Fock_%281958%29#/media/File:Gorch_Fock_unter_Segeln_Kieler_Foerde_2006.jpg -# ) (Wikimedia Commons), licensed under CC BY-SA 2.5. +# https://commons.wikimedia.org/wiki/File:Gorch_Fock_unter_Segeln_Kieler_Foerde_2006.jpg +# ) (Wikimedia Commons). Photo by Felix Koenig, cropped by Ibn Battuta, licensed under CC BY-SA 2.5. # %% roakey_seed_path = (Path(".") / ".." / ".." / "roakey.png").resolve() diff --git a/doc/code/framework.md b/doc/code/framework.md index da38d1c3a6..991a54f5df 100644 --- a/doc/code/framework.md +++ b/doc/code/framework.md @@ -3,7 +3,6 @@ Learn how to use PyRIT's components to build red teaming workflows. :::::{grid} 1 1 2 3 -:gutter: 3 ::::{card} 📦 Datasets :link: ./datasets/0_dataset diff --git a/doc/contributing/7_notebooks.md b/doc/contributing/7_notebooks.md index 53cd79d7d8..82ce902e03 100644 --- a/doc/contributing/7_notebooks.md +++ b/doc/contributing/7_notebooks.md @@ -18,3 +18,72 @@ Here are contributor guidelines: - Before a release, re-generate all notebooks by using [pct_to_ipynb.py](../generate_docs/pct_to_ipynb.py). Because this executes against real systems, it can detect many issues. - Please do not re-commit updated generated `.ipynb` files with slight changes if nothing has changed in the source - We use [Jupyter-Book](https://jupyterbook.org/) with [Markedly Structured Text (MyST)](https://mystmd.org/). + +## Building the documentation + +Rendering the committed notebooks does not execute their code or call their AI +providers. Notebook execution is a separate step that requires configured +credentials. Keep the committed notebook outputs and JupyText metadata when +editing documentation. + +Use this checkout's uv environment and locked development dependencies: + +```powershell +uv sync --frozen --group dev --python 3.13 +``` + +Generate the API reference before building the book. The TOC includes generated +pages, so a documentation-structure check alone is not a substitute for this +preparation: + +```powershell +uv run --frozen --no-sync python -m build_scripts.pydoc2json pyrit --submodules -o doc\_api\pyrit_all.json +uv run --frozen --no-sync python -m build_scripts.gen_api_md +uv run --frozen --no-sync python -m build_scripts.validate_docs +``` + +For HTML only, run from `doc`: + +```powershell +Set-Location doc +uv run --frozen --no-sync jupyter-book build --html --strict +``` + +Do not add `--all` to an HTML-only build. It selects the PDF export declared in +`myst.yml` too, even when `--html` is present. With the locked Jupyter Book 2 +version, `--strict` fails on errors and reports warnings separately; a successful +strict build is not necessarily warning-free. Do not suppress errors to make a +build pass. + +### PDF prerequisites and validation + +The `plain_latex_book` export requires `latexmk`, `xelatex`, and the template's +LaTeX packages. Provision those tools separately before requesting a PDF build. +For example, Ubuntu needs `latexmk`, `texlive-xetex`, +`texlive-fonts-recommended`, and `texlive-plain-generic`. PDF image conversion can +also require ImageMagick. HTML builds do not need this toolchain. + +After generating the API reference, run the checked PDF export from the +repository root: + +```powershell +uv run --frozen --no-sync python -m build_scripts.build_docs_pdf +``` + +The helper checks prerequisites before building, runs +`jupyter-book build --site --pdf --strict --logs`, and requires a fresh, readable +`doc\exports\book.pdf` with fresh native logs free of fatal LaTeX diagnostics. +`--site` validates the book content in strict mode without building static HTML; +the locked Jupyter Book version only applies strict error checking to site +content, not to standalone exports. +Jupyter Book can log an export failure without returning a nonzero exit, so its +exit code alone is not sufficient PDF evidence. A stale PDF does not count as a +successful new export. Inspect the PDF's chapters, images, citations, and layout +as well; compilation does not prove that every interactive HTML element has a +correct print representation. + +Where GNU Make is available, `make docs-build` prepares the API reference, builds +strict HTML, and generates RSS. `make docs-build-pdf` prepares the API reference +and runs the checked PDF export. `make docs-build-all` builds HTML first and then +checks the PDF export. These local targets do not change the hosted multiversion +publication workflow. diff --git a/doc/getting_started/README.md b/doc/getting_started/README.md index b5795569d7..f021b7a9a0 100644 --- a/doc/getting_started/README.md +++ b/doc/getting_started/README.md @@ -3,7 +3,6 @@ Welcome to PyRIT! Getting up and running takes two steps: **install** the package, then **configure** your AI endpoints. :::::{grid} 1 1 3 3 -:gutter: 3 ::::{card} 📦 Install PyRIT :link: ./install diff --git a/doc/getting_started/configuration.md b/doc/getting_started/configuration.md index 216b09eb65..3eb149f200 100644 --- a/doc/getting_started/configuration.md +++ b/doc/getting_started/configuration.md @@ -44,7 +44,6 @@ This gives you an in-memory database with configured targets and scorers registe For anything beyond a quick test — especially `pyrit_scan`, scenarios, and repeated use — you'll want to save your configuration to files in `~/.pyrit/`: :::::{grid} 1 1 2 2 -:gutter: 3 ::::{card} 🔑 Populating Secrets :link: ./populating_secrets diff --git a/doc/getting_started/install.md b/doc/getting_started/install.md index d420161169..f57409bcc9 100644 --- a/doc/getting_started/install.md +++ b/doc/getting_started/install.md @@ -5,7 +5,6 @@ Choose the installation method that best fits your use case. ## For Users :::::{grid} 1 1 2 2 -:gutter: 3 ::::{card} 🐋 User Docker Installation :link: ./install_docker @@ -26,7 +25,6 @@ Install with pip or uv. Best if you need to integrate PyRIT into existing Python ## For Contributors :::::{grid} 1 1 2 2 -:gutter: 3 ::::{card} 🐋 Contributor Docker Installation :link: ./install_devcontainers diff --git a/tests/unit/build_scripts/test_build_docs_pdf.py b/tests/unit/build_scripts/test_build_docs_pdf.py new file mode 100644 index 0000000000..5e7bf0cafb --- /dev/null +++ b/tests/unit/build_scripts/test_build_docs_pdf.py @@ -0,0 +1,222 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from collections.abc import Iterator +from pathlib import Path +from subprocess import CompletedProcess +from unittest.mock import MagicMock, patch + +import pytest +from pypdf import PdfWriter +from pypdf.errors import PdfReadError + +from build_scripts import build_docs_pdf + + +@pytest.fixture +def doc_root(tmp_path: Path) -> Path: + (tmp_path / "myst.yml").write_text( + "project:\n exports:\n - format: pdf\n template: plain_latex_book\n output: exports/book.pdf\n", + encoding="utf-8", + ) + return tmp_path + + +@pytest.fixture +def command(tmp_path: Path) -> Iterator[MagicMock]: + run = MagicMock(spec=build_docs_pdf.subprocess.run, return_value=CompletedProcess([], 0)) + with ( + patch.object(build_docs_pdf.shutil, "which", return_value=str(tmp_path / "tool")), + patch.object(build_docs_pdf.subprocess, "run", new=run), + ): + yield run + + +def _write_output(*, doc_root: Path, log: str = "LaTeX Warning: Label may have changed.\n") -> None: + output = doc_root / "exports" / "book.pdf" + output.parent.mkdir(exist_ok=True) + writer = PdfWriter() + writer.add_blank_page(width=72, height=72) + writer.write(output) + logs = output.parent / "book_pdf_logs" + logs.mkdir(exist_ok=True) + (logs / "book.log").write_text(log, encoding="utf-8") + (logs / "book.shell.log").write_text("Latexmk: All targets (book.pdf) are up-to-date\n", encoding="utf-8") + + +def test_build_pdf_requires_tools_before_running(*, doc_root: Path, command: MagicMock) -> None: + with patch.object(build_docs_pdf.shutil, "which", return_value=None): + with pytest.raises(ValueError, match="Missing PDF prerequisites on PATH: latexmk, xelatex"): + build_docs_pdf.build_pdf(doc_root) + command.assert_not_called() + + +@pytest.mark.parametrize("missing", ["latexmk", "xelatex"]) +def test_build_pdf_checks_each_tool(*, doc_root: Path, command: MagicMock, missing: str) -> None: + with patch.object(build_docs_pdf.shutil, "which", side_effect=lambda tool: None if tool == missing else tool): + with pytest.raises(ValueError, match=f"Missing PDF prerequisites on PATH: {missing}"): + build_docs_pdf.build_pdf(doc_root) + command.assert_not_called() + + +def test_build_pdf_preserves_native_failure( + *, doc_root: Path, command: MagicMock, capsys: pytest.CaptureFixture[str] +) -> None: + command.return_value = CompletedProcess([], 7) + assert build_docs_pdf.build_pdf(doc_root) == 7 + assert "exited with code 7" in capsys.readouterr().err + + +def test_build_pdf_rejects_missing_output_after_zero_exit(*, doc_root: Path, command: MagicMock) -> None: + with pytest.raises(ValueError, match="did not produce a nonempty file"): + build_docs_pdf.build_pdf(doc_root) + command.assert_called_once() + + +def test_build_pdf_rejects_stale_output(*, doc_root: Path, command: MagicMock) -> None: + _write_output(doc_root=doc_root) + with pytest.raises(ValueError, match="left a stale file unchanged"): + build_docs_pdf.build_pdf(doc_root) + command.assert_called_once() + + +def test_build_pdf_validates_output_and_preserves_warnings( + *, doc_root: Path, command: MagicMock, capsys: pytest.CaptureFixture[str] +) -> None: + def render(*args: object, **kwargs: object) -> CompletedProcess[bytes]: + _write_output(doc_root=doc_root) + return CompletedProcess([], 0) + + command.side_effect = render + assert build_docs_pdf.build_pdf(doc_root) == 0 + assert "Verified fresh PDF export" in capsys.readouterr().out + command.assert_called_once() + args, kwargs = command.call_args + assert args[0] == [ + build_docs_pdf.sys.executable, + "-m", + "jupyter_book", + "build", + "--site", + "--pdf", + "--strict", + "--logs", + ] + assert kwargs == {"cwd": doc_root, "check": False} + + +@pytest.mark.parametrize("filename", ["book.log", "book.shell.log"]) +@pytest.mark.parametrize( + "diagnostic", + [ + "! Undefined control sequence.", + "./book.tex:12: LaTeX Error: File `missing.sty' not found.", + "Package fontspec Error: The font could not be found.", + "Emergency stop.", + "Fatal error occurred, no output PDF file produced!", + "Latexmk: Errors, so I did not complete making targets", + "Collected error summary (may duplicate other messages):", + ], +) +def test_build_pdf_rejects_native_errors_with_readable_pdf( + *, doc_root: Path, command: MagicMock, diagnostic: str, filename: str +) -> None: + def render(*args: object, **kwargs: object) -> CompletedProcess[bytes]: + _write_output(doc_root=doc_root) + (doc_root / "exports" / "book_pdf_logs" / filename).write_text(f"{diagnostic}\n", encoding="utf-8") + return CompletedProcess([], 0) + + command.side_effect = render + with pytest.raises(ValueError, match="Fatal LaTeX diagnostic"): + build_docs_pdf.build_pdf(doc_root) + + +@pytest.mark.parametrize("filename", ["book.log", "book.shell.log"]) +def test_build_pdf_requires_fresh_native_logs(*, doc_root: Path, command: MagicMock, filename: str) -> None: + def render(*args: object, **kwargs: object) -> CompletedProcess[bytes]: + _write_output(doc_root=doc_root) + (doc_root / "exports" / "book_pdf_logs" / filename).unlink() + return CompletedProcess([], 0) + + command.side_effect = render + with pytest.raises(ValueError, match="did not produce a nonempty file"): + build_docs_pdf.build_pdf(doc_root) + + +def test_build_pdf_rejects_stale_logs_with_fresh_pdf(*, doc_root: Path, command: MagicMock) -> None: + _write_output(doc_root=doc_root) + + def render(*args: object, **kwargs: object) -> CompletedProcess[bytes]: + writer = PdfWriter() + writer.add_blank_page(width=144, height=144) + writer.write(doc_root / "exports" / "book.pdf") + return CompletedProcess([], 0) + + command.side_effect = render + with pytest.raises(ValueError, match="left a stale file unchanged:.*book.log"): + build_docs_pdf.build_pdf(doc_root) + + +def test_build_pdf_rejects_invalid_pdf(*, doc_root: Path, command: MagicMock) -> None: + def render(*args: object, **kwargs: object) -> CompletedProcess[bytes]: + _write_output(doc_root=doc_root) + (doc_root / "exports" / "book.pdf").write_bytes(b"not a PDF") + return CompletedProcess([], 0) + + command.side_effect = render + with pytest.raises(PdfReadError): + build_docs_pdf.build_pdf(doc_root) + + +def test_build_pdf_rejects_empty_pdf(*, doc_root: Path, command: MagicMock) -> None: + def render(*args: object, **kwargs: object) -> CompletedProcess[bytes]: + _write_output(doc_root=doc_root) + (doc_root / "exports" / "book.pdf").write_bytes(b"") + return CompletedProcess([], 0) + + command.side_effect = render + with pytest.raises(ValueError, match="did not produce a nonempty file"): + build_docs_pdf.build_pdf(doc_root) + + +def test_build_pdf_rejects_pdf_without_pages(*, doc_root: Path, command: MagicMock) -> None: + def render(*args: object, **kwargs: object) -> CompletedProcess[bytes]: + _write_output(doc_root=doc_root) + PdfWriter().write(doc_root / "exports" / "book.pdf") + return CompletedProcess([], 0) + + command.side_effect = render + with pytest.raises(ValueError, match="PDF export contains no pages"): + build_docs_pdf.build_pdf(doc_root) + + +@pytest.mark.parametrize( + "config", + [ + "null\n", + "project: {}\n", + "project:\n exports: []\n", + "project:\n exports:\n - format: pdf\n template: other\n output: book.pdf\n", + "project:\n exports:\n - format: pdf\n template: plain_latex_book\n", + "project:\n exports:\n - format: pdf\n template: plain_latex_book\n output: ../book.pdf\n", + ], +) +def test_build_pdf_rejects_invalid_config(*, doc_root: Path, command: MagicMock, config: str) -> None: + (doc_root / "myst.yml").write_text(config, encoding="utf-8") + with pytest.raises(ValueError): + build_docs_pdf.build_pdf(doc_root) + command.assert_not_called() + + +@pytest.mark.parametrize( + "error", + [ + ValueError("Missing PDF prerequisites"), + PdfReadError("Invalid PDF"), + FileNotFoundError("Missing configuration"), + ], +) +def test_main_reports_errors(*, capsys: pytest.CaptureFixture[str], error: Exception) -> None: + with patch.object(build_docs_pdf, "build_pdf", side_effect=error): + assert build_docs_pdf.main() == 1 + assert f"ERROR: {error}" in capsys.readouterr().err diff --git a/tests/unit/build_scripts/test_docs_build_contract.py b/tests/unit/build_scripts/test_docs_build_contract.py new file mode 100644 index 0000000000..746c881a34 --- /dev/null +++ b/tests/unit/build_scripts/test_docs_build_contract.py @@ -0,0 +1,75 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import json +from pathlib import Path + +import jupytext +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] +COMMONS_SOURCE = "https://commons.wikimedia.org/wiki/File:Gorch_Fock_unter_Segeln_Kieler_Foerde_2006.jpg" + + +def _target(target: str) -> tuple[list[str], list[str]]: + lines = (REPO_ROOT / "Makefile").read_text(encoding="utf-8").splitlines() + index = next(index for index, line in enumerate(lines) if line.startswith(f"{target}:")) + dependencies = lines[index].split(":", 1)[1].split() + commands = [] + for line in lines[index + 1 :]: + if not line.startswith("\t"): + break + commands.append(line.strip()) + return dependencies, commands + + +def test_html_build_prepares_api_without_selecting_pdf() -> None: + dependencies, commands = _target("docs-build") + assert dependencies == ["docs-api"] + assert "cd doc && uv run jupyter-book build --html --strict" in commands + assert all("--all" not in command and "--pdf" not in command for command in commands) + assert commands[-1] == "uv run python -m build_scripts.generate_rss" + _, api_commands = _target("docs-api") + assert "build_scripts.pydoc2json" in api_commands[0] + assert "build_scripts.gen_api_md" in api_commands[1] + + +def test_pdf_targets_share_checked_export_and_all_builds_html_first() -> None: + pdf_dependencies, pdf_commands = _target("docs-build-pdf") + all_dependencies, all_commands = _target("docs-build-all") + assert pdf_dependencies == ["docs-api"] + assert all_dependencies == ["docs-build"] + assert pdf_commands == all_commands == ["uv run python -m build_scripts.build_docs_pdf"] + + +@pytest.mark.parametrize( + ("path", "grids"), + [ + ("getting_started/README.md", 1), + ("getting_started/install.md", 2), + ("getting_started/configuration.md", 1), + ("code/framework.md", 1), + ], +) +def test_grids_keep_layout_without_unsupported_gutter(*, path: str, grids: int) -> None: + content = (REPO_ROOT / "doc" / path).read_text(encoding="utf-8") + assert content.count("{grid}") == grids + assert ":gutter:" not in content + + +def test_modality_feedback_citation_and_paired_cells() -> None: + path = REPO_ROOT / "doc" / "code" / "executor" / "8_modality_feedback" + notebook = json.loads(path.with_suffix(".ipynb").read_text(encoding="utf-8")) + companion = jupytext.read(path.with_suffix(".py"), fmt="py:percent") + assert [cell["source"].rstrip() for cell in companion.cells] == [ + "".join(cell["source"]).rstrip() for cell in notebook["cells"] + ] + attribution = next( + "".join(cell["source"]) for cell in notebook["cells"] if COMMONS_SOURCE in "".join(cell["source"]) + ) + assert "Felix Koenig" in attribution + assert "Ibn Battuta" in attribution + assert "CC BY-SA 2.5" in attribution + assert "#/media/" not in attribution + assert any(cell.get("outputs") for cell in notebook["cells"]) + assert (path.parent / "assets" / "three_masted_ship_color.jpg").is_file()