Source code for jamb.publish.quarto
"""Locate and invoke the Quarto binary."""
from __future__ import annotations
import os
import shutil
import subprocess
from pathlib import Path
class QuartoNotFoundError(RuntimeError):
"""Raised when no usable Quarto binary can be located."""
class QuartoRenderError(RuntimeError):
"""Raised when a Quarto render invocation fails.
Attributes:
returncode: The process exit status, when available.
stderr: Captured Quarto diagnostic output.
qmd_path: Path to the ``.qmd`` source that failed to render.
"""
def __init__(
self,
message: str,
*,
returncode: int | None = None,
stderr: str = "",
qmd_path: str | None = None,
) -> None:
super().__init__(message)
self.returncode = returncode
self.stderr = stderr
self.qmd_path = qmd_path
def _bundled_quarto() -> str | None:
"""Return the binary shipped with the ``quarto-cli`` package, if present."""
try:
import quarto_cli.quarto as bundled
except ImportError:
return None
bin_dir = Path(bundled.__file__).parent / "bin"
for name in ("quarto", "quarto.cmd", "quarto.exe"):
candidate = bin_dir / name
if candidate.exists():
return str(candidate)
return None
[docs]
def find_quarto() -> str:
"""Resolve the Quarto binary to an absolute path.
Resolution order: the ``JAMB_QUARTO`` environment variable, the binary
bundled with the ``quarto-cli`` package, then any ``quarto`` on ``PATH``.
Returns:
An absolute path to the Quarto binary.
Raises:
QuartoNotFoundError: When no binary can be found.
"""
override = os.environ.get("JAMB_QUARTO")
if override:
if Path(override).exists():
return override
raise QuartoNotFoundError(f"JAMB_QUARTO points to a missing file: {override}")
bundled = _bundled_quarto()
if bundled:
return bundled
on_path = shutil.which("quarto")
if on_path:
return on_path
raise QuartoNotFoundError(
"Quarto is required to render HTML, DOCX, and PDF output but was not found. "
"Install it with: pip install 'quarto-cli'"
)
[docs]
def quarto_version() -> str | None:
"""Return the installed Quarto version string, or ``None`` if unavailable."""
try:
executable = find_quarto()
except QuartoNotFoundError:
return None
try:
result = subprocess.run(
[executable, "--version"],
capture_output=True,
text=True,
timeout=60,
)
except (OSError, subprocess.SubprocessError):
return None
return result.stdout.strip() if result.returncode == 0 else None
def run_quarto(args: list[str], cwd: Path) -> subprocess.CompletedProcess[str]:
"""Run the Quarto binary with the given arguments.
Args:
args: Arguments passed to Quarto (e.g. ``["render", ...]``).
cwd: Working directory for the invocation.
Returns:
The completed process, with stdout and stderr captured as text.
"""
executable = find_quarto()
return subprocess.run([executable, *args], cwd=str(cwd), capture_output=True, text=True)