Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/great-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions great_docs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,6 +19,7 @@
from .core import GreatDocs

__all__ = [
"BuildError",
"Config",
"GreatDocs",
"check_skill",
Expand Down
1 change: 1 addition & 0 deletions great_docs/_build/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Documentation build support"""
14 changes: 14 additions & 0 deletions great_docs/_build/steps.py
Original file line number Diff line number Diff line change
@@ -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.
"""
24 changes: 19 additions & 5 deletions great_docs/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand Down
39 changes: 28 additions & 11 deletions great_docs/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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"])
Expand Down Expand Up @@ -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")

Expand Down
14 changes: 11 additions & 3 deletions great_docs/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()}")]

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 5 additions & 5 deletions tests/gauntlet_sites.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
)
Expand Down
56 changes: 55 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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."""
Expand Down
6 changes: 4 additions & 2 deletions tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -909,15 +909,17 @@ 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)
pkg = MagicMock()
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):
Expand Down
Loading
Loading