`
+ badges.
If no version is specified in the marker, the current entry's label is used.
@@ -1303,7 +1303,7 @@ def _rewrite_quarto_yml_for_version(
# Stage 2: Parallel Quarto renders
# ---------------------------------------------------------------------------
-_PAGE_RE = re.compile(r"\[\s*(\d+)/(\d+)\]")
+_PAGE_RE = re.compile(r"\[\s*(\d+)/(\d+)\]\s+(.+)")
def _render_single_version(
@@ -1340,14 +1340,16 @@ def _render_single_version_streaming(
build_dir: str,
env_vars: dict[str, str] | None,
on_progress: Callable[[int, int], None] | None = None,
-) -> tuple[str, int, str, str]:
+) -> tuple[str, int, str, str, list[dict[str, Any]]]:
"""
Render a single version with streaming progress.
- Like :func:`_render_single_version` but streams stderr to parse Quarto `[cur/total]` progress
- lines and calls *on_progress(current, total)* for each update. Returns the same
- `(build_dir, returncode, stdout, stderr)` tuple.
+ Like :func:`_render_single_version` but streams stderr to parse Quarto `[cur/total] page`
+ progress lines and calls *on_progress(current, total)* for each update. Returns
+ `(build_dir, returncode, stdout, stderr, page_timings)`.
"""
+ import time as _time_mod
+
env = os.environ.copy()
if env_vars:
env.update(env_vars)
@@ -1363,16 +1365,21 @@ def _render_single_version_streaming(
bufsize=1,
)
except Exception as e:
- return (build_dir, -1, "", str(e))
+ return (build_dir, -1, "", str(e), [])
stderr_lines: list[str] = []
+ # Each entry: (page_path, timestamp)
+ _page_timestamps: list[tuple[str, float]] = []
+ _ansi_re = re.compile(r"\033\[[0-9;]*m")
def _read_stderr():
for line in proc.stderr: # type: ignore[union-attr]
stderr_lines.append(line)
- if on_progress:
- m = _PAGE_RE.search(line)
- if m:
+ m = _PAGE_RE.search(line)
+ if m:
+ page_path = _ansi_re.sub("", m.group(3)).strip()
+ _page_timestamps.append((page_path, _time_mod.monotonic()))
+ if on_progress:
on_progress(int(m.group(1)), int(m.group(2)))
stderr_thread = threading.Thread(target=_read_stderr, daemon=True)
@@ -1382,7 +1389,16 @@ def _read_stderr():
proc.wait()
stderr_thread.join(timeout=10)
- return (build_dir, proc.returncode, stdout_data, "".join(stderr_lines))
+ # Compute per-page durations from consecutive timestamps
+ page_timings: list[dict[str, Any]] = []
+ for i, (page_path, ts) in enumerate(_page_timestamps):
+ if i + 1 < len(_page_timestamps):
+ duration = _page_timestamps[i + 1][1] - ts
+ else:
+ duration = _time_mod.monotonic() - ts
+ page_timings.append({"page": page_path, "seconds": round(duration, 3)})
+
+ return (build_dir, proc.returncode, stdout_data, "".join(stderr_lines), page_timings)
def render_versions_parallel(
@@ -1390,7 +1406,7 @@ def render_versions_parallel(
env_vars: dict[str, str] | None = None,
max_workers: int | None = None,
progress_callback: Callable[[int, int, int], None] | None = None,
-) -> list[tuple[str, int, str, str]]:
+) -> list[tuple[str, int, str, str, list[dict[str, Any]]]]:
"""
Run `quarto render` in parallel for each version build directory.
@@ -1408,32 +1424,36 @@ def render_versions_parallel(
Returns
-------
- list[tuple[str, int, str, str]]
- List of `(build_dir, returncode, stdout, stderr)` tuples in the same order as `build_dirs`.
+ list[tuple[str, int, str, str, list[dict[str, Any]]]]
+ List of `(build_dir, returncode, stdout, stderr, page_timings)` tuples in the same order
+ as `build_dirs`.
"""
if max_workers is None:
max_workers = min(os.cpu_count() or 4, 4)
if progress_callback is None:
# Original fire-and-forget mode (ProcessPoolExecutor)
- results: list[tuple[str, int, str, str]] = []
+ results: list[tuple[str, int, str, str, list[dict[str, Any]]]] = []
if len(build_dirs) == 1:
r = _render_single_version(str(build_dirs[0]), env_vars)
- results.append(r)
+ # Non-streaming mode has no page timings
+ results.append((*r, []))
return results
with ProcessPoolExecutor(max_workers=max_workers) as pool:
futures = {pool.submit(_render_single_version, str(d), env_vars): d for d in build_dirs}
for future in as_completed(futures):
- results.append(future.result())
+ results.append((*future.result(), []))
return results
# Streaming mode: use threads so callbacks can update the parent process.
dir_to_idx = {str(d): i for i, d in enumerate(build_dirs)}
- ordered_results: list[tuple[str, int, str, str] | None] = [None] * len(build_dirs)
+ ordered_results: list[tuple[str, int, str, str, list[dict[str, Any]]] | None] = [None] * len(
+ build_dirs
+ )
- def _run(build_dir: Path) -> tuple[str, int, str, str]:
+ def _run(build_dir: Path) -> tuple[str, int, str, str, list[dict[str, Any]]]:
idx = dir_to_idx[str(build_dir)]
def _on_progress(current: int, total: int) -> None:
@@ -1712,14 +1732,17 @@ def run_versioned_build(
errors: list[str] = []
versions_built: list[str] = []
+ timings_by_version: dict[str, list[dict[str, Any]]] = {}
# Map build dir back to version tag
dir_to_tag = {str(_version_build_dir(build_root, e, latest_tag)): e.tag for e in targets}
- for build_dir, returncode, stdout, stderr in render_results:
+ for build_dir, returncode, stdout, stderr, page_timings in render_results:
tag = dir_to_tag.get(build_dir, build_dir)
if returncode == 0:
versions_built.append(tag)
+ if page_timings:
+ timings_by_version[tag] = page_timings
else:
errors.append(f"Version {tag}: Quarto render failed (exit {returncode})\n{stderr}")
@@ -1732,6 +1755,7 @@ def run_versioned_build(
"success": False,
"versions_built": [],
"pages_by_version": pages_by_version,
+ "timings_by_version": {},
"errors": errors,
}
@@ -1752,6 +1776,7 @@ def run_versioned_build(
"success": len(errors) == 0,
"versions_built": versions_built,
"pages_by_version": pages_by_version,
+ "timings_by_version": timings_by_version,
"errors": errors,
}
diff --git a/great_docs/assets/github-workflow-template.yml b/great_docs/assets/github-workflow-template.yml
index 472de9f..4989b9e 100644
--- a/great_docs/assets/github-workflow-template.yml
+++ b/great_docs/assets/github-workflow-template.yml
@@ -35,6 +35,12 @@ jobs:
name: docs-html
path: great-docs/_site
+ - name: Upload build timings
+ uses: actions/upload-artifact@v7
+ with:
+ name: build-timings
+ path: great-docs/_site/build-timings.json
+
publish-docs:
name: "Publish Docs"
runs-on: ubuntu-latest
diff --git a/great_docs/assets/post-render.py b/great_docs/assets/post-render.py
index ffdf4fd..10870a1 100644
--- a/great_docs/assets/post-render.py
+++ b/great_docs/assets/post-render.py
@@ -1518,13 +1518,13 @@ def translate_sphinx_roles(html_content):
"""
Convert Sphinx cross-reference roles into clean HTML.
- The renderer sometimes passes through Sphinx-style roles verbatim. The most
- common rendered patterns are:
+ The renderer sometimes passes through Sphinx-style roles verbatim. The most common rendered
+ patterns are:
* `:py:exc:ValueError` -> `ValueError`
* `:class:Foo` -> `Foo`
* `:func:bar` -> `bar()`
- * `:func:`bar``` (inside ``) -> `bar()`
+ * `:func:`bar`` (inside ``) -> `bar()`
For *function* and *method* roles the name gets trailing `()` so the reader can tell it is
callable.
diff --git a/great_docs/cli.py b/great_docs/cli.py
index d088963..2d2e6ee 100644
--- a/great_docs/cli.py
+++ b/great_docs/cli.py
@@ -1003,6 +1003,189 @@ def freeze(
cli.add_command(freeze)
+# ---------------------------------------------------------------------------
+# great-docs timing
+# ---------------------------------------------------------------------------
+
+
+def _format_seconds(s: float) -> str:
+ """Format seconds as a human-readable duration string."""
+ if s < 60:
+ return f"{s:.1f}s"
+ m = int(s) // 60
+ sec = s - m * 60
+ return f"{m}m {sec:.1f}s"
+
+
+def _find_build_timing(project_path: Path, output_dir: Path | None = None) -> Path | None:
+ """Locate build-timings.json in the site output directory."""
+ # Explicit output-dir takes priority
+ if output_dir is not None:
+ candidate = output_dir / "build-timings.json"
+ if candidate.exists():
+ return candidate
+ # Multi-version: built into great-docs/_site/
+ candidate = project_path / "great-docs" / "_site" / "build-timings.json"
+ if candidate.exists():
+ return candidate
+ # Single-version or build dir fallback
+ candidate = project_path / "_site" / "build-timings.json"
+ if candidate.exists():
+ return candidate
+ return None
+
+
+def _print_timing_table(data: dict, top: int | None, version_filter: str | None) -> None:
+ """Print an ASCII table from build-timings.json data."""
+
+ build_time = data.get("build_time", "unknown")
+ total = data.get("total_seconds", 0)
+
+ click.echo()
+ click.echo(f" Build time: {build_time}")
+ click.echo(f" Total: {_format_seconds(total)}")
+ click.echo()
+
+ if "versions" in data:
+ versions = data["versions"]
+ if version_filter:
+ if version_filter not in versions:
+ click.echo(f" Version '{version_filter}' not found.", err=True)
+ click.echo(f" Available: {', '.join(sorted(versions.keys()))}", err=True)
+ sys.exit(1)
+ versions = {version_filter: versions[version_filter]}
+
+ for tag, vdata in versions.items():
+ pages = vdata["pages"]
+ if top:
+ pages = pages[:top]
+ click.echo(f" Version: {tag} ({_format_seconds(vdata['seconds'])})")
+ _print_page_table(pages)
+ click.echo()
+ elif "pages" in data:
+ pages = data["pages"]
+ if top:
+ pages = pages[:top]
+ _print_page_table(pages)
+ click.echo()
+
+
+def _print_page_table(pages: list[dict]) -> None:
+ """Print a page timing table."""
+ if not pages:
+ click.echo(" No page timings recorded.")
+ return
+
+ # Check if any pages have freeze info
+ has_frozen = any(p.get("frozen") for p in pages)
+
+ # Compute column widths
+ max_page = max(len(p["page"]) for p in pages)
+ max_page = max(max_page, 4) # minimum width for "Page" header
+ time_col = 10 # width for time column
+ bar_col = 20 # width for bar chart
+
+ slowest = pages[0]["seconds"] if pages else 1
+
+ # Header
+ if has_frozen:
+ header = f" {'Page':<{max_page}} {'Time':>{time_col}} Bar"
+ click.echo(header)
+ click.echo(f" {'─' * max_page} {'─' * time_col} {'─' * (bar_col + 2)}")
+ else:
+ header = f" {'Page':<{max_page}} {'Time':>{time_col}} Bar"
+ click.echo(header)
+ click.echo(f" {'─' * max_page} {'─' * time_col} {'─' * bar_col}")
+
+ # Rows
+ for p in pages:
+ page = p["page"]
+ secs = p["seconds"]
+ time_str = _format_seconds(secs)
+ bar_len = int((secs / slowest) * bar_col) if slowest > 0 else 0
+ bar = "█" * bar_len
+ if has_frozen:
+ marker = "❄" if p.get("frozen") else " "
+ click.echo(f" {page:<{max_page}} {time_str:>{time_col}} {marker} {bar}")
+ else:
+ click.echo(f" {page:<{max_page}} {time_str:>{time_col}} {bar}")
+
+ if has_frozen:
+ click.echo()
+ click.echo(" ❄ = served from freeze cache")
+
+
+@click.command()
+@click.option(
+ "--project-path",
+ type=click.Path(exists=True, file_okay=False, dir_okay=True),
+ help="Path to your project root directory (default: current directory)",
+)
+@click.option(
+ "--top",
+ type=int,
+ default=None,
+ help="Show only the N slowest pages.",
+)
+@click.option(
+ "--version",
+ "version_filter",
+ type=str,
+ default=None,
+ help="Show timings for a specific version only (multi-version builds).",
+)
+@click.option(
+ "--json",
+ "output_json",
+ is_flag=True,
+ help="Output raw JSON instead of a table.",
+)
+@click.option(
+ "--output-dir",
+ type=click.Path(exists=True, file_okay=False, dir_okay=True),
+ default=None,
+ help="Path to the build output directory (if different from default _site).",
+)
+def timings(project_path, top, version_filter, output_json, output_dir):
+ """Show page-level build timings from the last build.
+
+ Reads the build-timings.json artifact generated during 'great-docs build' and
+ displays per-page render durations as a sorted table. Pages are listed
+ slowest-first to help identify bottlenecks.
+
+ Run 'great-docs build' first to generate the timing data.
+
+ \b
+ Examples:
+ great-docs timings
+ great-docs timings --top 10
+ great-docs timings --version 0.10
+ great-docs timings --output-dir ./public
+ great-docs timings --json
+ """
+ import json
+
+ project_root = Path(project_path) if project_path else Path.cwd()
+ out_dir = Path(output_dir) if output_dir else None
+ timing_path = _find_build_timing(project_root, output_dir=out_dir)
+
+ if not timing_path:
+ click.echo("No build-timings.json found.", err=True)
+ click.echo("Run 'great-docs build' first to generate timing data.", err=True)
+ sys.exit(1)
+
+ data = json.loads(timing_path.read_text())
+
+ if output_json:
+ click.echo(json.dumps(data, indent=2))
+ return
+
+ _print_timing_table(data, top=top, version_filter=version_filter)
+
+
+cli.add_command(timings)
+
+
@click.command(name="setup-github-pages")
@click.option(
"--project-path",
diff --git a/great_docs/core.py b/great_docs/core.py
index ef48899..7ddc682 100644
--- a/great_docs/core.py
+++ b/great_docs/core.py
@@ -14,8 +14,8 @@
def _patch_griffe():
"""Ensure griffe has CyclicAliasError and AliasResolutionError at top level.
- Older griffe versions don't re-export these from the top-level package.
- This patches them in so `griffe.CyclicAliasError` etc. work everywhere.
+ Older griffe versions don't re-export these from the top-level package. This patches them in so
+ `griffe.CyclicAliasError` etc. work everywhere.
"""
import griffe
@@ -12552,6 +12552,79 @@ def _generate_seo_files(self) -> None:
self._generate_sitemap_xml()
self._generate_robots_txt()
+ def _write_build_timing(
+ self,
+ page_timings: list[dict[str, object]] | None = None,
+ timings_by_version: dict[str, list[dict[str, object]]] | None = None,
+ ) -> Path | None:
+ """
+ Write `_site/build-timings.json` with per-page render durations.
+
+ For single-version builds, *page_timings* is a flat list. For multi-version builds,
+ *timings_by_version* is a `{tag: [timings]}` dict. Returns the path written, or `None` if
+ there was nothing to write.
+ """
+ import json
+ from datetime import datetime, timezone
+
+ site_dir = self.project_path / "_site"
+ if not site_dir.exists():
+ return None # pragma: no cover
+
+ # Discover which pages had freeze cache entries.
+ # Check both the project root (where users persist _freeze/) and the
+ # build/project path (where it's restored during rendering).
+ frozen_stems: set[str] = set()
+ for freeze_dir in (self.project_root / "_freeze", self.project_path / "_freeze"):
+ if freeze_dir.is_dir():
+ for html_json in freeze_dir.rglob("execute-results/html.json"):
+ # _freeze/user-guide/benchmarks/execute-results/html.json
+ # → relative stem is "user-guide/benchmarks"
+ rel = html_json.relative_to(freeze_dir)
+ # Drop the last two parts (execute-results/html.json)
+ stem = str(rel.parent.parent)
+ frozen_stems.add(stem)
+
+ def _annotate(pages: list[dict[str, object]]) -> list[dict[str, object]]:
+ """Add 'frozen' boolean to each page entry."""
+ for p in pages:
+ page_str = str(p["page"])
+ # Strip .qmd/.html extension to get the stem
+ stem = page_str.rsplit(".", 1)[0] if "." in page_str else page_str
+ p["frozen"] = stem in frozen_stems
+ return pages
+
+ payload: dict[str, object] = {
+ "build_time": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
+ }
+
+ if timings_by_version:
+ versions_payload: dict[str, object] = {}
+ total_seconds = 0.0
+ for tag, timings in timings_by_version.items():
+ ver_total = round(sum(t["seconds"] for t in timings), 3)
+ total_seconds += ver_total
+ versions_payload[tag] = {
+ "seconds": ver_total,
+ "pages": sorted(
+ _annotate(timings), key=lambda t: t["seconds"], reverse=True
+ ),
+ }
+ payload["total_seconds"] = round(total_seconds, 3)
+ payload["versions"] = versions_payload
+ elif page_timings:
+ total_seconds = round(sum(t["seconds"] for t in page_timings), 3)
+ payload["total_seconds"] = total_seconds
+ payload["pages"] = sorted(
+ _annotate(page_timings), key=lambda t: t["seconds"], reverse=True
+ )
+ else:
+ return None # pragma: no cover
+
+ out_path = site_dir / "build-timings.json"
+ out_path.write_text(json.dumps(payload, indent=2) + "\n")
+ return out_path
+
def _get_cli_help_text_for_llms(self) -> str:
"""
Get CLI help text formatted for llms-full.txt.
@@ -12732,11 +12805,11 @@ def uninstall(self) -> None:
print("✅ Great-docs uninstalled successfully!")
def _prepare_for_freeze(self) -> None:
- """Run the build preparation steps (1–14 + normalization) without rendering.
+ """Run the build preparation steps (1-14 + normalization) without rendering.
- This ensures the build directory contains files in exactly the same state
- as a full ``build()`` would produce, so that freeze cache hashes remain valid
- across subsequent full builds.
+ This ensures the build directory contains files in exactly the same state as a full
+ `build()` would produce, so that freeze cache hashes remain valid across subsequent full
+ builds.
"""
import os
from contextlib import redirect_stdout
@@ -13030,7 +13103,13 @@ def run_streaming(
Lines starting with `##GD:PASS:` are collected into `result.passes` (a list of label
strings). If *on_pass* is provided it is called with each label as it arrives. If
*on_bar_done* is provided it is called once when the progress bar reaches 100%.
+
+ Page-level timing is recorded automatically: each `[i/N] page.qmd` progress line from
+ Quarto is timestamped so that per-page render durations can be computed after the
+ process exits.
"""
+ import time as _time_mod
+
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
@@ -13044,7 +13123,9 @@ def run_streaming(
pass_labels: list[str] = []
quarto_warnings: list[str] = []
bar_finished = [False] # mutable flag for closure
- page_re = _re_build.compile(r"\[\s*(\d+)/(\d+)\]")
+ page_re = _re_build.compile(r"\[\s*(\d+)/(\d+)\]\s+(.+)")
+ # Each entry: (page_path, timestamp)
+ _page_timestamps: list[tuple[str, float]] = []
def _finish_bar():
"""Finish the progress bar, emit warnings, and notify caller."""
@@ -13069,6 +13150,8 @@ def _handle_line(stripped):
m = page_re.search(stripped)
if m:
cur, tot = int(m.group(1)), int(m.group(2))
+ page_path = _ansi_re.sub("", m.group(3)).strip()
+ _page_timestamps.append((page_path, _time_mod.monotonic()))
if tot != progress_bar.total:
progress_bar.total = tot
progress_bar.update(cur)
@@ -13104,6 +13187,16 @@ def read_stderr():
process.wait()
stderr_thread.join(timeout=5)
+ # Compute per-page durations from consecutive timestamps
+ page_timings: list[dict[str, Any]] = []
+ for i, (page_path, ts) in enumerate(_page_timestamps):
+ if i + 1 < len(_page_timestamps):
+ duration = _page_timestamps[i + 1][1] - ts
+ else:
+ # Last page: measure until process exit
+ duration = _time_mod.monotonic() - ts
+ page_timings.append({"page": page_path, "seconds": round(duration, 3)})
+
class Result:
pass
@@ -13112,6 +13205,7 @@ class Result:
r.stderr = "".join(stderr_lines)
r.stdout = ""
r.passes = pass_labels
+ r.page_timings = page_timings
return r
# ── Step 1: Prepare build directory ────────────────────────────
@@ -13483,6 +13577,13 @@ def _on_renders_done() -> None:
log.warn(f"Error generating SEO files: {e}")
log.step_done("SEO files had issues")
+ # ── Write build-timings.json ───────────────────────
+ timing_path = self._write_build_timing(
+ timings_by_version=vb_result.get("timings_by_version"),
+ )
+ if timing_path:
+ log.detail(f"Wrote {timing_path.name}")
+
# ── Auto-save API snapshot (Strategy C) ────────────
try:
self._auto_save_snapshot()
@@ -13565,6 +13666,13 @@ def _on_pass(label):
log.warn(f"Error generating SEO files: {e}") # pragma: no cover
log.step_done("SEO files had issues") # pragma: no cover
+ # ── Write build-timings.json ───────────────────────
+ timing_path = self._write_build_timing( # pragma: no cover
+ page_timings=result.page_timings, # pragma: no cover
+ ) # pragma: no cover
+ if timing_path: # pragma: no cover
+ log.detail(f"Wrote {timing_path.name}") # pragma: no cover
+
# ── Auto-save API snapshot (Strategy C) ────────────
if self._config.has_versions: # pragma: no cover
try: # pragma: no cover
diff --git a/tests/test_build_timing.py b/tests/test_build_timing.py
new file mode 100644
index 0000000..c934a04
--- /dev/null
+++ b/tests/test_build_timing.py
@@ -0,0 +1,315 @@
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+
+# ---------------------------------------------------------------------------
+# Regex: matches Quarto progress lines and captures page path
+# ---------------------------------------------------------------------------
+
+_PAGE_RE = re.compile(r"\[\s*(\d+)/(\d+)\]\s+(.+)")
+
+
+class TestPageRegex:
+ """Ensure the regex captures page path from Quarto progress lines."""
+
+ def test_basic_line(self):
+ line = "[ 1/42] user-guide/overview.html"
+ m = _PAGE_RE.search(line)
+ assert m
+ assert m.group(1) == "1"
+ assert m.group(2) == "42"
+ assert m.group(3).strip() == "user-guide/overview.html"
+
+ def test_double_digit(self):
+ line = "[ 12/42] reference/GT.html"
+ m = _PAGE_RE.search(line)
+ assert m
+ assert m.group(1) == "12"
+ assert m.group(3).strip() == "reference/GT.html"
+
+ def test_no_leading_space(self):
+ line = "[3/5] recipes/freeze-demo.html"
+ m = _PAGE_RE.search(line)
+ assert m
+ assert m.group(1) == "3"
+ assert m.group(2) == "5"
+ assert m.group(3).strip() == "recipes/freeze-demo.html"
+
+ def test_non_matching_line(self):
+ line = "WARN: something went wrong"
+ m = _PAGE_RE.search(line)
+ assert m is None
+
+ def test_line_with_ansi(self):
+ # Quarto sometimes emits ANSI color codes around the line
+ line = "\x1b[32m[ 5/10] user-guide/config.html\x1b[0m"
+ m = _PAGE_RE.search(line)
+ assert m
+ assert m.group(1) == "5"
+ assert m.group(2) == "10"
+
+
+# ---------------------------------------------------------------------------
+# _write_build_timing
+# ---------------------------------------------------------------------------
+
+
+class TestWriteBuildTiming:
+ """Test the _write_build_timing method writes correct JSON."""
+
+ def _make_gd(self, tmp_path: Path):
+ """Create a minimal GreatDocs instance with a project_path pointing to tmp_path."""
+ from great_docs.core import GreatDocs
+
+ # Create a minimal great-docs.yml so Config doesn't fail
+ (tmp_path / "great-docs.yml").write_text("name: test-pkg\n")
+ gd = GreatDocs.__new__(GreatDocs)
+ gd.project_path = tmp_path
+ gd.project_root = tmp_path
+ return gd
+
+ def test_single_version_flat(self, tmp_path: Path):
+ gd = self._make_gd(tmp_path)
+ site_dir = tmp_path / "_site"
+ site_dir.mkdir()
+
+ timings = [
+ {"page": "user-guide/overview.html", "seconds": 1.2},
+ {"page": "user-guide/benchmarks.html", "seconds": 28.4},
+ {"page": "reference/GT.html", "seconds": 2.1},
+ ]
+
+ result = gd._write_build_timing(page_timings=timings)
+ assert result is not None
+ assert result.name == "build-timings.json"
+
+ data = json.loads(result.read_text())
+ assert "build_time" in data
+ assert data["total_seconds"] == pytest.approx(31.7, abs=0.01)
+ # Pages should be sorted by seconds descending
+ assert data["pages"][0]["page"] == "user-guide/benchmarks.html"
+ assert data["pages"][-1]["page"] == "user-guide/overview.html"
+ assert len(data["pages"]) == 3
+ # No versions key for single-version
+ assert "versions" not in data
+
+ def test_multi_version(self, tmp_path: Path):
+ gd = self._make_gd(tmp_path)
+ site_dir = tmp_path / "_site"
+ site_dir.mkdir()
+
+ timings_by_version = {
+ "0.10": [
+ {"page": "user-guide/overview.html", "seconds": 1.2},
+ {"page": "user-guide/benchmarks.html", "seconds": 28.4},
+ ],
+ "0.9": [
+ {"page": "user-guide/overview.html", "seconds": 0.9},
+ {"page": "reference/GT.html", "seconds": 1.8},
+ ],
+ }
+
+ result = gd._write_build_timing(timings_by_version=timings_by_version)
+ assert result is not None
+
+ data = json.loads(result.read_text())
+ assert "build_time" in data
+ assert data["total_seconds"] == pytest.approx(32.3, abs=0.01)
+ assert "versions" in data
+ assert set(data["versions"].keys()) == {"0.10", "0.9"}
+
+ v010 = data["versions"]["0.10"]
+ assert v010["seconds"] == pytest.approx(29.6, abs=0.01)
+ assert len(v010["pages"]) == 2
+ # Sorted descending by seconds
+ assert v010["pages"][0]["page"] == "user-guide/benchmarks.html"
+
+ v09 = data["versions"]["0.9"]
+ assert v09["seconds"] == pytest.approx(2.7, abs=0.01)
+ assert len(v09["pages"]) == 2
+
+ def test_no_site_dir_returns_none(self, tmp_path: Path):
+ gd = self._make_gd(tmp_path)
+ # No _site/ directory
+ result = gd._write_build_timing(page_timings=[{"page": "x.html", "seconds": 1.0}])
+ assert result is None
+
+ def test_no_timings_returns_none(self, tmp_path: Path):
+ gd = self._make_gd(tmp_path)
+ site_dir = tmp_path / "_site"
+ site_dir.mkdir()
+ result = gd._write_build_timing()
+ assert result is None
+
+ def test_empty_timings_returns_none(self, tmp_path: Path):
+ gd = self._make_gd(tmp_path)
+ site_dir = tmp_path / "_site"
+ site_dir.mkdir()
+ result = gd._write_build_timing(page_timings=[])
+ assert result is None
+
+ def test_build_time_is_utc_iso(self, tmp_path: Path):
+ gd = self._make_gd(tmp_path)
+ site_dir = tmp_path / "_site"
+ site_dir.mkdir()
+
+ timings = [{"page": "index.html", "seconds": 0.5}]
+ result = gd._write_build_timing(page_timings=timings)
+ data = json.loads(result.read_text())
+
+ # Should be ISO 8601 UTC format
+ bt = data["build_time"]
+ assert bt.endswith("Z")
+ assert "T" in bt
+
+ def test_frozen_annotation_single_version(self, tmp_path: Path):
+ gd = self._make_gd(tmp_path)
+ site_dir = tmp_path / "_site"
+ site_dir.mkdir()
+
+ # Create a _freeze/ entry for one page
+ freeze_entry = tmp_path / "_freeze" / "recipes" / "freeze-demo" / "execute-results"
+ freeze_entry.mkdir(parents=True)
+ (freeze_entry / "html.json").write_text("{}")
+
+ timings = [
+ {"page": "recipes/freeze-demo.qmd", "seconds": 0.3},
+ {"page": "user-guide/overview.qmd", "seconds": 1.2},
+ ]
+
+ result = gd._write_build_timing(page_timings=timings)
+ data = json.loads(result.read_text())
+
+ pages_by_name = {p["page"]: p for p in data["pages"]}
+ assert pages_by_name["recipes/freeze-demo.qmd"]["frozen"] is True
+ assert pages_by_name["user-guide/overview.qmd"]["frozen"] is False
+
+ def test_frozen_annotation_multi_version(self, tmp_path: Path):
+ gd = self._make_gd(tmp_path)
+ site_dir = tmp_path / "_site"
+ site_dir.mkdir()
+
+ # Create a _freeze/ entry
+ freeze_entry = tmp_path / "_freeze" / "user-guide" / "benchmarks" / "execute-results"
+ freeze_entry.mkdir(parents=True)
+ (freeze_entry / "html.json").write_text("{}")
+
+ timings_by_version = {
+ "0.10": [
+ {"page": "user-guide/benchmarks.qmd", "seconds": 0.5},
+ {"page": "user-guide/overview.qmd", "seconds": 1.2},
+ ],
+ }
+
+ result = gd._write_build_timing(timings_by_version=timings_by_version)
+ data = json.loads(result.read_text())
+
+ pages = data["versions"]["0.10"]["pages"]
+ pages_by_name = {p["page"]: p for p in pages}
+ assert pages_by_name["user-guide/benchmarks.qmd"]["frozen"] is True
+ assert pages_by_name["user-guide/overview.qmd"]["frozen"] is False
+
+ def test_no_freeze_dir_all_false(self, tmp_path: Path):
+ gd = self._make_gd(tmp_path)
+ site_dir = tmp_path / "_site"
+ site_dir.mkdir()
+
+ timings = [{"page": "index.qmd", "seconds": 0.5}]
+ result = gd._write_build_timing(page_timings=timings)
+ data = json.loads(result.read_text())
+ assert data["pages"][0]["frozen"] is False
+
+
+# ---------------------------------------------------------------------------
+# Timing computation from timestamps (unit logic)
+# ---------------------------------------------------------------------------
+
+
+class TestTimingComputation:
+ """Test the delta-computation logic used in both paths."""
+
+ def test_consecutive_timestamps_give_deltas(self):
+ """Simulate the timing computation from _page_timestamps."""
+ import time
+
+ # Simulate: 3 pages at known intervals
+ base = time.monotonic()
+ _page_timestamps = [
+ ("page-a.html", base),
+ ("page-b.html", base + 1.5),
+ ("page-c.html", base + 3.0),
+ ]
+
+ page_timings = []
+ # Use a fixed "end" time for the last page
+ end_time = base + 4.2
+ for i, (page_path, ts) in enumerate(_page_timestamps):
+ if i + 1 < len(_page_timestamps):
+ duration = _page_timestamps[i + 1][1] - ts
+ else:
+ duration = end_time - ts
+ page_timings.append({"page": page_path, "seconds": round(duration, 3)})
+
+ assert page_timings[0] == {"page": "page-a.html", "seconds": 1.5}
+ assert page_timings[1] == {"page": "page-b.html", "seconds": 1.5}
+ assert page_timings[2] == {"page": "page-c.html", "seconds": 1.2}
+
+ def test_single_page(self):
+ """A single page gets the full elapsed time."""
+ import time
+
+ base = time.monotonic()
+ _page_timestamps = [("only-page.html", base)]
+ end_time = base + 5.0
+
+ page_timings = []
+ for i, (page_path, ts) in enumerate(_page_timestamps):
+ if i + 1 < len(_page_timestamps):
+ duration = _page_timestamps[i + 1][1] - ts
+ else:
+ duration = end_time - ts
+ page_timings.append({"page": page_path, "seconds": round(duration, 3)})
+
+ assert page_timings == [{"page": "only-page.html", "seconds": 5.0}]
+
+
+# ---------------------------------------------------------------------------
+# Versioned build: page_timings in result tuple
+# ---------------------------------------------------------------------------
+
+
+class TestVersionedBuildTimings:
+ """Verify render_versions_parallel returns page_timings in result tuples."""
+
+ def test_non_streaming_returns_empty_timings(self, tmp_path: Path):
+ """Non-streaming (ProcessPoolExecutor) mode returns empty page_timings list."""
+ from great_docs._versioned_build import _render_single_version
+
+ # Create a fake build dir that will fail (no quarto project)
+ fake_dir = tmp_path / "fake"
+ fake_dir.mkdir()
+ (fake_dir / "_quarto.yml").write_text("project:\n type: website\n")
+
+ # This will fail since there's nothing to render, but we can check the tuple length
+ result = _render_single_version(str(fake_dir), None)
+ # Non-streaming returns 4-tuple (no timings)
+ assert len(result) == 4
+
+ def test_streaming_returns_5_tuple(self, tmp_path: Path):
+ """Streaming mode returns 5-tuple with page_timings as last element."""
+ from great_docs._versioned_build import _render_single_version_streaming
+
+ fake_dir = tmp_path / "fake"
+ fake_dir.mkdir()
+ (fake_dir / "_quarto.yml").write_text("project:\n type: website\n")
+
+ result = _render_single_version_streaming(str(fake_dir), None)
+ assert len(result) == 5
+ # Last element is page_timings (list)
+ assert isinstance(result[4], list)
diff --git a/tests/test_versioned_build.py b/tests/test_versioned_build.py
index abc3aa8..7b0628f 100644
--- a/tests/test_versioned_build.py
+++ b/tests/test_versioned_build.py
@@ -2620,7 +2620,7 @@ def mock_render(build_dirs, env_vars=None, max_workers=None, progress_callback=N
site_dir = d / "_site"
site_dir.mkdir(parents=True, exist_ok=True)
(site_dir / "index.html").write_text("hi")
- results.append((str(d), 0, "", ""))
+ results.append((str(d), 0, "", "", []))
return results
with patch(
@@ -2654,7 +2654,7 @@ def mock_render(build_dirs, **kwargs):
site_dir = d / "_site"
site_dir.mkdir(parents=True, exist_ok=True)
(site_dir / "index.html").write_text("
")
- results.append((str(d), 0, "", ""))
+ results.append((str(d), 0, "", "", []))
return results
with patch(
@@ -2686,7 +2686,7 @@ def mock_render(build_dirs, **kwargs):
site_dir = d / "_site"
site_dir.mkdir(parents=True, exist_ok=True)
(site_dir / "index.html").write_text("
")
- results.append((str(d), 0, "", ""))
+ results.append((str(d), 0, "", "", []))
return results
with patch(
@@ -2714,7 +2714,7 @@ def test_render_failure_reported(self, tmp_path: Path):
def mock_render(build_dirs, **kwargs):
# All versions fail
- return [(str(d), 1, "", "ERROR: something broke") for d in build_dirs]
+ return [(str(d), 1, "", "ERROR: something broke", []) for d in build_dirs]
with patch(
"great_docs._versioned_build.render_versions_parallel",
@@ -2748,7 +2748,7 @@ def mock_render(build_dirs, **kwargs):
site_dir = d / "_site"
site_dir.mkdir(parents=True, exist_ok=True)
(site_dir / "index.html").write_text("")
- results.append((str(d), 0, "", ""))
+ results.append((str(d), 0, "", "", []))
return results
with patch(
@@ -2782,7 +2782,7 @@ def mock_render(build_dirs, **kwargs):
site_dir = d / "_site"
site_dir.mkdir(parents=True, exist_ok=True)
(site_dir / "index.html").write_text("