diff --git a/docs/great-docs.yml b/docs/great-docs.yml index 78603120..ab4d317b 100644 --- a/docs/great-docs.yml +++ b/docs/great-docs.yml @@ -323,6 +323,10 @@ reference: - GreatDocs.build - GreatDocs.preview - GreatDocs.check_links + - title: Errors + desc: The exception raised when a build fails. + contents: + - BuildError - title: Table Preview/Explorer desc: >- Generate styled HTML table previews from any tabular data source with `tbl_preview()`, and diff --git a/great_docs/__init__.py b/great_docs/__init__.py index 4ade7254..02b3469d 100644 --- a/great_docs/__init__.py +++ b/great_docs/__init__.py @@ -9,6 +9,7 @@ __version__ = "0.0.0" from . import _builtin # noqa: F401 — registers great-docs' built-in event handlers +from ._build.steps import BuildError from ._skill_install import check_skill, install_skill, list_skills from ._tbl_display import disable_tbl_preview, enable_tbl_preview from ._tbl_explorer import tbl_explorer @@ -18,6 +19,7 @@ from .core import GreatDocs __all__ = [ + "BuildError", "Config", "GreatDocs", "check_skill", diff --git a/great_docs/_build/__init__.py b/great_docs/_build/__init__.py new file mode 100644 index 00000000..7103523f --- /dev/null +++ b/great_docs/_build/__init__.py @@ -0,0 +1 @@ +"""Documentation build support""" diff --git a/great_docs/_build/steps.py b/great_docs/_build/steps.py new file mode 100644 index 00000000..79be0c09 --- /dev/null +++ b/great_docs/_build/steps.py @@ -0,0 +1,14 @@ +""" +Errors that stop documentation builds +""" + +__all__ = ["BuildError"] + + +class BuildError(RuntimeError): + """ + A build failure that leaves no usable site + + The failure is reported in the build log or on standard error before this exception is + raised. Command-line callers can exit without printing the message again. + """ diff --git a/great_docs/cli.py b/great_docs/cli.py index fa51fae1..644b926d 100644 --- a/great_docs/cli.py +++ b/great_docs/cli.py @@ -10,6 +10,7 @@ import click from . import __version__ +from ._build.steps import BuildError from ._layout import CONVENTIONAL_DOC_DIRS, Layout, _find_package_root from ._subprocess import TEXT_MODE_KWARGS from .config import ConfigError @@ -408,6 +409,9 @@ def build( ) except KeyboardInterrupt: click.echo("\nšŸ‘‹ Stopped watching") + except BuildError: + # Exit without repeating the build's failure report + sys.exit(1) except Exception as e: click.echo(f"Error: {e}", err=True) sys.exit(1) @@ -606,6 +610,9 @@ def preview( click.echo("\nšŸ‘‹ Server stopped") except SystemExit: raise + except BuildError: + # Exit without repeating the build's failure report + sys.exit(1) except Exception as e: click.echo(f"Error: {e}", err=True) sys.exit(1) @@ -1454,6 +1461,9 @@ def freeze( docs._prepare_for_freeze() except SystemExit: pass # Build prep may exit but that's OK + except BuildError: + # Exit without repeating the preparation failure report + sys.exit(1) except Exception as e: click.echo(f"Error during build prep: {e}", err=True) sys.exit(1) @@ -3807,12 +3817,16 @@ def api_snapshot_cmd( from great_docs.core import GreatDocs - documented = ( - GreatDocs(project_path=str(project_root), config_path=config_path).documented_symbol_names( - pkg_name + try: + documented = ( + GreatDocs( + project_path=str(project_root), config_path=config_path + ).documented_symbol_names(pkg_name) + or None ) - or None - ) + except BuildError as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) # Default snapshot directory snap_dir = project_root / ".great-docs" / "snapshots" diff --git a/great_docs/core.py b/great_docs/core.py index 2fcf3354..3385a9b1 100644 --- a/great_docs/core.py +++ b/great_docs/core.py @@ -14,6 +14,7 @@ from yaml12 import format_yaml, read_yaml +from ._build.steps import BuildError from ._content_naming import fix_numeric_prefix_links, section_slug, strip_numeric_prefix from ._layout import Layout, LayoutError from ._source_refs import source_reference_spans @@ -91,12 +92,12 @@ ] -class QuartoNotFoundError(RuntimeError): - """Raised when the Quarto CLI is not available on the system.""" +class QuartoNotFoundError(BuildError): + """A missing Quarto CLI""" -class QuartoVersionError(RuntimeError): - """Raised when the installed Quarto CLI is older than great-docs supports.""" +class QuartoVersionError(BuildError): + """An installed Quarto CLI older than great-docs supports""" # The bundled extensions set the stage at which their filters run, which @@ -8729,12 +8730,14 @@ def _categorize_referenced_objects(self, package_name: str, reference_config: li if pkg_ver is not None: items_str = ", ".join(f"`{n}`" for n in missing_names) - raise SystemExit( - f"ERROR: Configured reference item(s) not found in " + message = ( + f"Configured reference item(s) not found in " f"`{package_name}` v{pkg_ver}: {items_str}\n" f"Check that the correct version of `{package_name}` is installed " f"and that these names exist in the package's public API." ) + print(f"ERROR: {message}", file=sys.stderr) + raise BuildError(message) else: # Package not installed — treat missing items as "other" (old behavior) categories["other"].extend(missing_names) @@ -15325,6 +15328,16 @@ def build( process. Sites that can't be split safely are rendered in one process anyway. Ignored for multi-version sites and in watch mode. + Raises + ------ + FileNotFoundError + If the project has no `great-docs.yml`. + BuildError + If the build fails and leaves no usable site: Quarto is missing or too old, a + configured reference name is missing from the installed package, the API reference + cannot be generated, or Quarto fails to render the site. The failure has already + been reported in the build log or on standard error. + Examples -------- Build the documentation (with API refresh): @@ -15357,7 +15370,11 @@ def build( "generate a configuration file." ) - _ensure_quarto_installed() + try: + _ensure_quarto_installed() + except BuildError as e: + print(f"Error: {e}", file=sys.stderr) + raise import threading from contextlib import contextmanager @@ -16013,12 +16030,12 @@ class Result: except Exception as e2: log.step_fail(f"API reference build failed: {e2}") log.footer() - sys.exit(1) + raise BuildError(f"API reference build failed: {e2}") from e2 else: log.step_fail(f"API reference build failed: {e}") log.error_detail(str(e)) log.footer() - sys.exit(1) + raise BuildError(f"API reference build failed: {e}") from e else: ref = None log.step_skip(step, "API reference disabled") @@ -16171,7 +16188,7 @@ def _on_renders_done() -> None: log.step_skip(step, "build failed") log.footer() - sys.exit(1) + raise BuildError("All version builds failed") else: n_ok = len(vb_result["versions_built"]) n_err = len(vb_result["errors"]) @@ -16282,7 +16299,7 @@ def _on_render_page(_page: str) -> None: log.step_skip(step, "build failed") log.footer() - sys.exit(1) + raise BuildError("Quarto render failed") else: log.step_done("Site rendered") diff --git a/great_docs/mcp.py b/great_docs/mcp.py index 319d21ad..c1cb0307 100644 --- a/great_docs/mcp.py +++ b/great_docs/mcp.py @@ -33,6 +33,7 @@ ) from pydantic import AnyUrl +from ._build.steps import BuildError from ._layout import CONVENTIONAL_DOC_DIRS, Layout, LayoutError from ._utils import is_in_great_docs_build_dir, recognised_build_dirs @@ -449,10 +450,17 @@ async def _handle_build(arguments: dict) -> list[TextContent]: for ver_dir in _sibling_build_dirs(_get_project_root(project_path), _get_layout(arguments)): shutil.rmtree(ver_dir) - # Capture build output + # Include the build log in both success and error responses output = io.StringIO() - with redirect_stdout(output): - docs.build() + try: + with redirect_stdout(output): + docs.build() + except BuildError as e: + result = _ToolCallContent( + [TextContent(type="text", text=f"Build failed: {e}\n\n{output.getvalue()}")] + ) + result.is_error = True + return result return [TextContent(type="text", text=f"Build complete.\n\n{output.getvalue()}")] diff --git a/pyproject.toml b/pyproject.toml index 049bc116..ffdac46e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -180,6 +180,7 @@ pythonVersion = "3.11" typeCheckingMode = "strict" include = [ "great_docs/_apiref", + "great_docs/_build", "great_docs/_builtin", "great_docs/_git.py", "great_docs/_icons.py", diff --git a/tests/gauntlet_sites.py b/tests/gauntlet_sites.py index 25e35057..f1cf25bf 100644 --- a/tests/gauntlet_sites.py +++ b/tests/gauntlet_sites.py @@ -23,6 +23,7 @@ from pathlib import Path from typing import Callable, Iterable +from great_docs import BuildError from great_docs._layout import Layout # These packages cover both citation backlink forms. The first has three @@ -36,15 +37,14 @@ _TEST_PACKAGES_DIR = Path(__file__).resolve().parent.parent / "test-packages" RENDERED_DIR = _TEST_PACKAGES_DIR / "_rendered" -# Treat these exceptions as an unavailable rendered fixture. The dependent -# tests already skip when a site is absent. Fatal API-reference configuration -# errors raise `SystemExit`; any unlisted exception indicates a builder defect -# and propagates. +# Treat these failures as unavailable rendered fixtures; dependent tests skip +# absent sites. Fatal API reference configuration errors raise `BuildError`. +# Propagate unlisted exceptions as builder defects. BUILD_FAILURES: tuple[type[BaseException], ...] = ( OSError, subprocess.SubprocessError, ImportError, - SystemExit, + BuildError, ValueError, KeyError, ) diff --git a/tests/test_cli.py b/tests/test_cli.py index f5578440..d1c1d41e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -9,6 +9,7 @@ import pytest from click.testing import CliRunner +from great_docs import BuildError from great_docs.cli import ( _detect_current_package, _detect_optional_dependencies, @@ -236,7 +237,7 @@ def fail_selection(_layout: object) -> Path: if mode.startswith("preview"): docs.preview() elif mode == "render-error": - with pytest.raises(SystemExit): + with pytest.raises(BuildError): docs.build(refresh=False) else: docs.build(watch=mode == "watch", refresh=False) @@ -491,6 +492,42 @@ def build_selected(docs: GreatDocs, **kwargs: object) -> None: assert (tmp_path / "website/_quarto/default/selection.txt").read_text() == "selected" +@pytest.mark.parametrize("command", ["build", "preview"]) +def test_failed_build_exits_1_and_reports_once( + command: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + from great_docs import GreatDocs + + def fail(docs: GreatDocs, **kwargs: object) -> None: + # Report the failure before raising, as the build log does + print("FAIL Quarto render failed") + raise BuildError("Quarto render failed") + + monkeypatch.setattr(GreatDocs, "build", fail) + result = CliRunner().invoke(cli, [command, "--project-path", str(tmp_path)]) + + assert result.exit_code == 1 + assert result.output.count("Quarto render failed") == 1 + + +def test_api_snapshot_reports_missing_reference_name( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + from great_docs import GreatDocs + + def fail(docs: GreatDocs, package_name: str) -> list[str]: + raise BuildError("Configured reference item(s) not found in `pkg` v1.0: `gone`") + + monkeypatch.setattr(GreatDocs, "documented_symbol_names", fail) + result = CliRunner().invoke( + cli, ["api-snapshot", "--project-path", str(tmp_path), "--package", "pkg"] + ) + + assert result.exit_code == 1 + assert "Error: Configured reference item(s) not found in `pkg` v1.0: `gone`" in result.output + assert result.exception is None or isinstance(result.exception, SystemExit) + + def test_preview_serves_selected_deployment( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -2858,6 +2895,23 @@ def test_prepare_for_freeze_system_exit_continues( # Should NOT exit 1 just because prepare raised SystemExit assert result.exit_code in (0, 1) + @patch("great_docs.cli.GreatDocs") + @patch("subprocess.run") + def test_prepare_for_freeze_build_error_exits( + self, mock_run, mock_gd_cls, tmp_path, monkeypatch + ): + """Stop freezing after preparation fails, without rendering or reporting again""" + monkeypatch.chdir(tmp_path) + self._make_project(tmp_path) + mock_docs = MagicMock() + mock_docs._prepare_for_freeze.side_effect = BuildError("reference item(s) not found") + mock_gd_cls.return_value = mock_docs + result = CliRunner().invoke(cli, ["freeze", "mypage.qmd", "--project-path", str(tmp_path)]) + + assert result.exit_code == 1 + assert "not found" not in result.output + mock_run.assert_not_called() + @patch("great_docs.cli.GreatDocs") def test_prepare_for_freeze_exception_exits(self, mock_gd_cls, tmp_path, monkeypatch): """Exception from _prepare_for_freeze prints error and exits 1.""" diff --git a/tests/test_core.py b/tests/test_core.py index 2ea525d1..60bef977 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -909,7 +909,7 @@ def test_generic_exception_on_kind_goes_to_other(self, tmp_path, monkeypatch): assert "BrokenObj" in result["other"] def test_missing_name_with_installed_package_raises(self, tmp_path, monkeypatch): - """Missing name + installed package raises SystemExit.""" + """Raise `BuildError` when an installed package lacks a configured name""" import pytest gd = self._setup(tmp_path, monkeypatch) @@ -917,7 +917,9 @@ def test_missing_name_with_installed_package_raises(self, tmp_path, monkeypatch) pkg.members = {} monkeypatch.setattr(gd, "_get_griffe_package", lambda name: pkg) monkeypatch.setattr("importlib.metadata.version", lambda name: "1.2.3") - with pytest.raises(SystemExit, match="not found"): + from great_docs import BuildError + + with pytest.raises(BuildError, match="not found"): gd._categorize_referenced_objects("mypkg", [{"contents": ["Missing"]}]) def test_missing_name_uninstalled_package_goes_to_other(self, tmp_path, monkeypatch): diff --git a/tests/test_documented_symbols.py b/tests/test_documented_symbols.py index 514eeb64..6b5641ed 100644 --- a/tests/test_documented_symbols.py +++ b/tests/test_documented_symbols.py @@ -7,6 +7,7 @@ import pytest +from great_docs import BuildError from great_docs.core import GreatDocs @@ -210,6 +211,26 @@ def fail_search_paths() -> list[str]: gd.documented_symbol_names("mypkg") +def test_missing_configured_name_raises_build_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +): + """ + Raise `BuildError` when the installed package lacks a configured name + + The query captures the standard-error report. The caller is responsible for displaying it. + """ + _write_pkg(tmp_path) + config = tmp_path / "great-docs.yml" + config.write_text(config.read_text().replace("- TopClass", "- TopClass\n - Gone")) + monkeypatch.setattr("importlib.metadata.version", lambda name: "0.1.0") + gd = GreatDocs(project_path=str(tmp_path)) + + with pytest.raises(BuildError, match="`Gone`"): + gd.documented_symbol_names("mypkg") + + assert capsys.readouterr().err == "" + + def test_restores_cached_package_modules(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: _write_pkg(tmp_path) gd = GreatDocs(project_path=str(tmp_path)) diff --git a/tests/test_great_docs.py b/tests/test_great_docs.py index 7e10eaf7..e48728b4 100644 --- a/tests/test_great_docs.py +++ b/tests/test_great_docs.py @@ -29,7 +29,7 @@ from yaml12 import format_yaml as _format_yaml from yaml12 import parse_yaml as _parse_yaml -from great_docs import Config, GreatDocs, create_default_config, load_config +from great_docs import BuildError, Config, GreatDocs, create_default_config, load_config from great_docs._content_naming import fix_numeric_prefix_links, strip_numeric_prefix from great_docs._website.scripts import script_include, stylesheet_include from great_docs._apiref import _globals, content, spec, write @@ -16974,8 +16974,8 @@ def test_format_authors_yaml_basic(): assert "role: Maintainer" in result -def test_categorize_referenced_objects_missing_item_raises(): - """Test that _categorize_referenced_objects raises SystemExit for missing config items.""" +def test_categorize_referenced_objects_missing_item_raises(capsys: pytest.CaptureFixture[str]): + """Report missing configured names and raise `BuildError`""" with tempfile.TemporaryDirectory() as tmp_dir: # Create a minimal package with one export pkg_dir = Path(tmp_dir) / "mypkg" @@ -17001,13 +17001,14 @@ def test_categorize_referenced_objects_missing_item_raises(): # Mock importlib.metadata.version to simulate an installed package with patch("importlib.metadata.version", return_value="1.0.0"): - with pytest.raises(SystemExit) as exc_info: + with pytest.raises(BuildError) as exc_info: docs._categorize_referenced_objects("mypkg", reference_config) error_msg = str(exc_info.value) assert "nonexistent_item" in error_msg assert "mypkg" in error_msg assert "not found" in error_msg + assert capsys.readouterr().err == f"ERROR: {error_msg}\n" finally: sys.path.remove(tmp_dir) @@ -21880,8 +21881,8 @@ def test_build_dynamic_fallback_preserves_the_quarto_yml_header(tmp_path): assert quarto_yml.read_text(encoding="utf-8").startswith(QUARTO_YML_HEADER) -def test_build_static_mode_failure_exits(tmp_path): - """Test build() exits when static mode also fails.""" +def test_build_static_mode_failure_raises(tmp_path): + """Raise `BuildError` when static API reference generation also fails""" docs = _buildable_docs( tmp_path, api_reference=True, @@ -21897,13 +21898,13 @@ def test_build_static_mode_failure_exits(tmp_path): patch( "great_docs._apiref.api_reference.APIReference", MagicMock(return_value=mock_builder) ), - pytest.raises(SystemExit), + pytest.raises(BuildError, match="API reference build failed"), ): docs.build(watch=False, refresh=True) -def test_build_non_dynamic_failure_exits(tmp_path): - """Test build() exits when non-dynamic build fails.""" +def test_build_non_dynamic_failure_raises(tmp_path): + """Raise `BuildError` when non-dynamic API reference generation fails""" docs = _buildable_docs( tmp_path, api_reference=True, @@ -21917,7 +21918,7 @@ def test_build_non_dynamic_failure_exits(tmp_path): patch( "great_docs._apiref.api_reference.APIReference", MagicMock(return_value=mock_builder) ), - pytest.raises(SystemExit), + pytest.raises(BuildError, match="API reference build failed"), ): docs.build(watch=False, refresh=True) @@ -22015,17 +22016,34 @@ def test_build_watch_mode(tmp_path): def test_build_quarto_render_failure(tmp_path): - """Test build() exits when quarto render fails.""" + """Raise `BuildError` when Quarto fails to render""" docs = _buildable_docs(tmp_path) with stubbed_build(docs) as stubs: stubs.render.stderr = iter(["Error: something\n"]) stubs.render.returncode = 1 - with pytest.raises(SystemExit): + with pytest.raises(BuildError, match="Quarto render failed"): docs.build(watch=False, refresh=False) +def test_build_versioned_all_failed_raises(tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """Report failure and raise `BuildError` when every version fails to render""" + docs = _buildable_docs(tmp_path, has_versions=True, versions=["0.2", "0.1"]) + failed = {"success": False, "errors": ["0.2: render boom"], "versions_built": []} + + with ( + stubbed_build(docs), + patch("great_docs._versioned_build.run_versioned_build", return_value=failed), + pytest.raises(BuildError, match="All version builds failed"), + ): + docs.build(watch=False, refresh=False) + + out = capsys.readouterr().out + assert "0.2: render boom" in out + assert "All version builds failed" in out + + def test_build_cli_error_handled(tmp_path, capsys): """ Check that CLI discovery failures warn and skip CLI page generation diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 3f607099..91e37aac 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -531,6 +531,23 @@ def test_explicit_project_path(self, tmp_path: Path): result = asyncio.run(_handle_build({"project_path": str(tmp_path)})) assert "Build complete" in result[0].text + def test_failed_build_returns_error_with_log(self, tmp_path: Path): + """Return the failure message and build log in an MCP error response""" + from great_docs import BuildError + + def fail() -> None: + print("Step 16 FAIL Quarto render failed") + raise BuildError("Quarto render failed") + + mock_docs = MagicMock() + mock_docs.build = MagicMock(side_effect=fail) + with patch("great_docs.mcp._get_great_docs", return_value=mock_docs): + result = asyncio.run(call_tool("gd_build", {"project_path": str(tmp_path)})) + + assert getattr(result, "is_error", False) + assert result[0].text.startswith("Build failed: Quarto render failed") + assert "Step 16 FAIL Quarto render failed" in result[0].text + # --------------------------------------------------------------------------- # _handle_scan diff --git a/tests/test_quarto_check.py b/tests/test_quarto_check.py index 799aee42..5e5cae7c 100644 --- a/tests/test_quarto_check.py +++ b/tests/test_quarto_check.py @@ -4,7 +4,7 @@ import pytest -from great_docs import GreatDocs +from great_docs import BuildError, GreatDocs from great_docs.core import QuartoNotFoundError, QuartoVersionError, _ensure_quarto_installed @@ -49,12 +49,22 @@ def test_quarto_not_found_error_is_runtime_error(): assert issubclass(QuartoNotFoundError, RuntimeError) -def test_build_raises_quarto_not_found_when_quarto_missing(monkeypatch): +def test_quarto_errors_are_build_errors(): + assert issubclass(QuartoNotFoundError, BuildError) + assert issubclass(QuartoVersionError, BuildError) + + +def test_build_reports_and_raises_quarto_not_found_when_quarto_missing( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +): monkeypatch.setattr("great_docs.core.shutil.which", lambda name: None) with tempfile.TemporaryDirectory() as tmp_dir: docs = GreatDocs(project_path=tmp_dir) docs.install(force=True) + capsys.readouterr() - with pytest.raises(QuartoNotFoundError, match="great-docs"): + with pytest.raises(QuartoNotFoundError, match="great-docs") as excinfo: docs.build() + + assert capsys.readouterr().err == f"Error: {excinfo.value}\n"