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()