mirror of
https://github.com/harvard-edge/cs249r_book.git
synced 2026-11-09 10:39:41 -06:00
291 lines
12 KiB
Python
291 lines
12 KiB
Python
"""
|
|
Configuration management for MLSysBook CLI.
|
|
|
|
Handles Quarto configuration files, the generated _quarto.yml, and format-specific settings.
|
|
"""
|
|
|
|
import yaml
|
|
from pathlib import Path
|
|
from typing import Dict, Any, Optional
|
|
from rich.console import Console
|
|
from .volume_index import volume_index_bytes, write_volume_index
|
|
|
|
console = Console()
|
|
|
|
ACTIVE_CONFIG_MARKER = "# binder: generated copy of "
|
|
|
|
|
|
def write_active_config(active_config: Path, source: Path, book_dir: Path) -> None:
|
|
"""Write *source* to the project's ``_quarto.yml`` with a provenance header.
|
|
|
|
Quarto only reads ``_quarto.yml``, so every build copies the chosen
|
|
configuration there. The first line records the source file so status
|
|
output and post-render scripts can tell which configuration is active.
|
|
|
|
Args:
|
|
active_config: Destination path (usually ``book_dir / "_quarto.yml"``).
|
|
source: Source configuration file to copy.
|
|
book_dir: Project books root directory for relative provenance path.
|
|
"""
|
|
header = (
|
|
f"{ACTIVE_CONFIG_MARKER}{source.relative_to(book_dir).as_posix()}\n"
|
|
"# Regenerated on every build; edit the source file, not this copy.\n"
|
|
)
|
|
# Earlier binder versions left a symlink here. Writing through it would
|
|
# overwrite the source configuration, so remove it first.
|
|
if active_config.is_symlink():
|
|
active_config.unlink()
|
|
active_config.write_text(header + source.read_text(encoding="utf-8"), encoding="utf-8")
|
|
|
|
|
|
def active_config_source(active_config: Path) -> Optional[str]:
|
|
"""Return the source file recorded in a generated ``_quarto.yml``, or None.
|
|
|
|
Args:
|
|
active_config: Path to the active ``_quarto.yml`` file.
|
|
|
|
Returns:
|
|
Relative path string recorded in the header marker, or None if invalid or missing.
|
|
"""
|
|
if not active_config.is_file():
|
|
return None
|
|
with active_config.open(encoding="utf-8") as fh:
|
|
first_line = fh.readline()
|
|
if not first_line.startswith(ACTIVE_CONFIG_MARKER):
|
|
return None
|
|
return first_line[len(ACTIVE_CONFIG_MARKER):].strip()
|
|
|
|
|
|
def get_output_file(output_dir: Path, format_type: str) -> Optional[Path]:
|
|
"""Return the primary output file for a build: any .pdf, any .epub, or index.html.
|
|
|
|
Used by build (open output) and the parallel runner (success check) so all
|
|
commands use the same rule: PDF = first .pdf in dir, EPUB = first .epub in
|
|
dir, HTML = index.html.
|
|
|
|
Args:
|
|
output_dir: Directory where build output was emitted.
|
|
format_type: Output format ("html", "pdf", or "epub").
|
|
|
|
Returns:
|
|
Path to primary output file if found, otherwise None.
|
|
"""
|
|
if not output_dir.exists():
|
|
return None
|
|
if format_type == "pdf":
|
|
for p in sorted(output_dir.iterdir()):
|
|
if p.is_file() and p.suffix.lower() == ".pdf":
|
|
return p
|
|
return None
|
|
if format_type == "epub":
|
|
for p in sorted(output_dir.iterdir()):
|
|
if p.is_file() and p.suffix.lower() == ".epub":
|
|
return p
|
|
return None
|
|
if format_type == "html":
|
|
index = output_dir / "index.html"
|
|
return index if index.exists() else None
|
|
return None
|
|
|
|
|
|
class ConfigManager:
|
|
"""Manages Quarto configuration files and format switching."""
|
|
|
|
def __init__(self, root_dir: Path):
|
|
"""Initialize configuration manager.
|
|
|
|
Args:
|
|
root_dir: Directory binder was started from: the repository root,
|
|
``books/``, or a sibling such as ``binder/``.
|
|
"""
|
|
self.root_dir = Path(root_dir)
|
|
|
|
# Determine the book directory, which is the Quarto project root.
|
|
#
|
|
# Book sources live in books/ at the repository root: one directory per
|
|
# volume, plus shared/ for anything cross-volume, plus the config/ and
|
|
# _extensions/ that Quarto needs at its project root. Before 2026-09
|
|
# this lived at books/ with the volumes under contents/,
|
|
# and books/ held a second, drifting copy that fed nothing. See
|
|
# docs/REPO_LAYOUT.md.
|
|
if (self.root_dir / "books" / "config").exists():
|
|
# Running from the repository root
|
|
self.book_dir = self.root_dir / "books"
|
|
elif (self.root_dir / "config").exists() and (self.root_dir / "vol1").exists():
|
|
# Already inside books/
|
|
self.book_dir = self.root_dir
|
|
elif (self.root_dir.parent / "books" / "config").exists():
|
|
# Running from a sibling directory such as binder/
|
|
self.book_dir = self.root_dir.parent / "books"
|
|
else:
|
|
# Fallback
|
|
self.book_dir = self.root_dir
|
|
|
|
# Commands treat root_dir as the repository root (bibs/, binder/,
|
|
# mlsysim/), so normalize it when binder runs from books/ or binder/.
|
|
if self.book_dir.name == "books" and self.book_dir != self.root_dir / "books":
|
|
self.root_dir = self.book_dir.parent
|
|
|
|
# Configuration file paths (default to vol1 configs since combined configs don't exist)
|
|
self.html_config = self.book_dir / "config" / "_quarto-html-vol1.yml"
|
|
self.pdf_config = self.book_dir / "config" / "_quarto-pdf-vol1.yml"
|
|
self.epub_config = self.book_dir / "config" / "_quarto-epub-vol1.yml"
|
|
|
|
# Volume I and II configs, which `binder reset` restores when no volume is given.
|
|
self.html_vol1_config = self.book_dir / "config" / "_quarto-html-vol1.yml"
|
|
self.html_vol2_config = self.book_dir / "config" / "_quarto-html-vol2.yml"
|
|
self.pdf_vol1_config = self.book_dir / "config" / "_quarto-pdf-vol1.yml"
|
|
self.pdf_vol2_config = self.book_dir / "config" / "_quarto-pdf-vol2.yml"
|
|
self.epub_vol1_config = self.book_dir / "config" / "_quarto-epub-vol1.yml"
|
|
self.epub_vol2_config = self.book_dir / "config" / "_quarto-epub-vol2.yml"
|
|
|
|
self.active_config = self.book_dir / "_quarto.yml"
|
|
self.active_index = self.book_dir / "index.qmd"
|
|
|
|
def get_config_file(self, format_type: str, volume: Optional[str] = None) -> Path:
|
|
"""Get the configuration file for a specific format and optional volume.
|
|
|
|
Args:
|
|
format_type: Format type ('html', 'pdf', 'epub')
|
|
volume: Optional volume (e.g. 'vol1', 'vol2', 'vol3', 'vol4', 'vol5')
|
|
|
|
Returns:
|
|
Path to the configuration file
|
|
|
|
Raises:
|
|
ValueError: If format_type is not supported
|
|
"""
|
|
if format_type not in ("html", "pdf", "epub"):
|
|
raise ValueError(f"Unsupported format type: {format_type}")
|
|
|
|
if volume:
|
|
config_file = self.book_dir / "config" / f"_quarto-{format_type}-{volume}.yml"
|
|
if config_file.exists():
|
|
return config_file
|
|
console.print(f"[yellow]⚠️ Volume config not found: {config_file}, falling back to default config[/yellow]")
|
|
|
|
default_config = self.book_dir / "config" / f"_quarto-{format_type}-vol1.yml"
|
|
if default_config.exists():
|
|
return default_config
|
|
return self.book_dir / "config" / f"_quarto-{format_type}.yml"
|
|
|
|
def activate_config(self, format_type: str, volume: Optional[str] = None) -> str:
|
|
"""Copy the config for a format and optional volume to ``_quarto.yml``.
|
|
|
|
Args:
|
|
format_type: Format type ('html', 'pdf', 'epub')
|
|
volume: Optional volume (e.g., 'vol1', 'vol2', 'vol4', 'tinytorch') for volume-specific builds
|
|
|
|
Returns:
|
|
Name of the config file that was copied
|
|
|
|
Raises:
|
|
ValueError: If format_type is not supported
|
|
FileNotFoundError: If the config file does not exist
|
|
"""
|
|
config_file = self.get_config_file(format_type, volume)
|
|
|
|
if not config_file.exists():
|
|
raise FileNotFoundError(f"Config file not found: {config_file}")
|
|
|
|
self.activate_config_file(config_file)
|
|
|
|
# Volume builds need a root entry point for the selected format.
|
|
if volume:
|
|
self._activate_index(volume, format_type)
|
|
|
|
return config_file.name
|
|
|
|
def activate_config_file(self, source: Path) -> None:
|
|
"""Write *source* as the active ``_quarto.yml``."""
|
|
write_active_config(self.active_config, source, self.book_dir)
|
|
|
|
def active_config_source(self) -> Optional[str]:
|
|
"""Return the config file the active ``_quarto.yml`` was copied from."""
|
|
return active_config_source(self.active_config)
|
|
|
|
def _activate_index(self, volume: str, format_type: str = "pdf") -> None:
|
|
"""Generate ``index.qmd`` from the canonical volume/format source."""
|
|
source = write_volume_index(self.book_dir, volume, format_type)
|
|
console.print(f"[dim]📄 Copied {source.relative_to(self.book_dir).as_posix()} → index.qmd[/dim]")
|
|
|
|
def active_index_source(self) -> Optional[str]:
|
|
"""Return the volume index whose content ``index.qmd`` holds, or None."""
|
|
if not self.active_index.is_file():
|
|
return None
|
|
content = self.active_index.read_bytes()
|
|
for index_candidate in sorted(self.book_dir.glob("index-*.qmd")):
|
|
if index_candidate.is_file() and volume_index_bytes(self.book_dir, index_candidate) == content:
|
|
return index_candidate.relative_to(self.book_dir).as_posix()
|
|
for vol_dir in self.book_dir.iterdir():
|
|
if vol_dir.is_dir() and (vol_dir.name.startswith("vol") or vol_dir.name == "tinytorch"):
|
|
for rel in ("index.qmd", "frontmatter/about.qmd"):
|
|
idx = vol_dir / rel
|
|
if idx.is_file() and volume_index_bytes(self.book_dir, idx) == content:
|
|
return idx.relative_to(self.book_dir).as_posix()
|
|
return None
|
|
|
|
def get_output_dir(self, format_type: str, volume: Optional[str] = None) -> Path:
|
|
"""Get the output directory from Quarto configuration.
|
|
|
|
Args:
|
|
format_type: Format type ('html', 'pdf', 'epub')
|
|
volume: Optional volume (e.g., 'vol1', 'vol2', 'vol4') for volume-specific builds
|
|
|
|
Returns:
|
|
Path to the output directory
|
|
"""
|
|
default = self.book_dir / "_build" / f"{format_type}{f'-{volume}' if volume else ''}"
|
|
config_file = self.get_config_file(format_type, volume)
|
|
if not config_file.exists():
|
|
console.print(f"[yellow]⚠️ Config file not found: {config_file}; using {default}[/yellow]")
|
|
return default
|
|
try:
|
|
config = yaml.safe_load(config_file.read_text(encoding="utf-8"))
|
|
except (OSError, yaml.YAMLError) as e:
|
|
console.print(f"[yellow]⚠️ Could not read project.output-dir from {config_file} ({e}); "
|
|
f"using {default}[/yellow]")
|
|
return default
|
|
output_path = ((config or {}).get("project") or {}).get("output-dir")
|
|
return self.book_dir / output_path if output_path else default
|
|
|
|
def read_config(self, format_type: str, volume: Optional[str] = None) -> Dict[str, Any]:
|
|
"""Read and parse a configuration file.
|
|
|
|
Args:
|
|
format_type: Format type ('html', 'pdf', 'epub')
|
|
volume: Optional volume (e.g., 'vol1', 'vol2', 'vol4') for volume-specific builds
|
|
|
|
Returns:
|
|
Parsed configuration as dictionary
|
|
|
|
Raises:
|
|
FileNotFoundError: If config file doesn't exist
|
|
yaml.YAMLError: If config file is invalid YAML
|
|
"""
|
|
config_file = self.get_config_file(format_type, volume)
|
|
|
|
if not config_file.exists():
|
|
raise FileNotFoundError(f"Config file not found: {config_file}")
|
|
|
|
with open(config_file, 'r', encoding='utf-8') as f:
|
|
return yaml.safe_load(f)
|
|
|
|
def show_active_config(self) -> None:
|
|
"""Display which configuration and index the project is using."""
|
|
source = self.active_config_source()
|
|
if source:
|
|
console.print(f"[dim] 📄 Active config: {source}[/dim]")
|
|
elif self.active_config.exists():
|
|
console.print("[dim] 📄 Active config: _quarto.yml (not generated by binder)[/dim]")
|
|
else:
|
|
console.print("[dim] ❌ No active config found[/dim]")
|
|
|
|
index_source = self.active_index_source()
|
|
if index_source:
|
|
console.print(f"[dim] 📄 Active index: {index_source}[/dim]")
|
|
elif self.active_index.exists():
|
|
console.print("[dim] 📄 Active index: index.qmd (matches no volume index)[/dim]")
|
|
else:
|
|
console.print("[dim] ❌ No index.qmd found[/dim]")
|