Files
Vijay Janapa Reddi 27d4f8114b binder: fix repo-root, build, doctor and info bugs; clearer errors; dedup
Bug fixes
- Build lock is keyed on books/ so builds started from different
  directories block each other (fixes two tests broken by 89b828efa3).
- ConfigManager resolves the repo root when binder runs from books/ or
  binder/, so bib, newsletter, doctor and render paths are right.
- `build <word> --all` treats intro/scaling/agentic/physical as chapter
  names (it built every volume), and Ctrl-C stops a --all loop.
- doctor checked chapter names that no longer exist and failed on the
  generated _quarto.yml; info defaulted to a deleted books/contents.
- `headings apply --path <typo>` no longer rewrites the whole book;
  `bib clean` stops on unreadable chapters instead of dropping entries.
- Discovery falls back to a line scan on invalid YAML instead of crashing.
- Volume III/IV were labeled Volume II in layout and release output.

Output
- Unexpected errors print the command, exception type, and raising
  file:line (new cli/core/errors.py), with the traceback on stderr.
- Chapter-not-found suggests the closest names and the list command.
- Silent except blocks in bib lookups, info, headings, layout,
  maintenance, preview and debug now say what went wrong.

Cleanup
- Shared qmd_files_under/display_path, file-rewrite loop, doctor result
  helper, preview server, clean helpers, volume display names.
- Removed legacy import fallbacks, dead config attributes, legacy backup
  restore logic, and unused imports.
2026-10-19 19:28:49 +02:00

614 lines
23 KiB
Python

"""
File and chapter discovery for MLSysBook CLI.
Handles finding chapter files, validating paths, and managing file operations.
Supports volume-aware discovery for vol1 through vol4.
Single source of truth for chapter ordering: `get_chapters_from_config()` reads
the PDF YAML config for a volume and returns the ordered list of testable chapter
stems. All commands (debug, build, validate, etc.) should call this method rather
than maintaining their own exclusion lists or filesystem scans.
"""
import re
import sys
import fnmatch
from pathlib import Path
from typing import List, Optional, Dict, Any
from rich.console import Console
console = Console()
def discover_volumes(book_dir: Path) -> List[str]:
"""Dynamically discover all volume directories under book_dir.
Scans the given directory for subdirectories matching the pattern ``vol\\d+``
as well as standalone named volumes like ``tinytorch``. Sorts volume names
numerically (e.g., vol1, vol2, vol3, vol4), followed by non-numeric volumes.
If the directory does not exist or yields no volume folders, returns the
canonical default list (``["vol1", "vol2", "vol3", "vol4"]``).
Args:
book_dir: Path to the books root directory containing volume folders.
Returns:
Sorted list of volume directory names.
"""
if not book_dir.exists():
return ["vol1", "vol2", "vol3", "vol4"]
vols = [
d.name for d in book_dir.iterdir()
if d.is_dir() and re.match(r"^vol\d+$", d.name)
]
if (book_dir / "tinytorch").is_dir():
vols.append("tinytorch")
if not vols:
return ["vol1", "vol2", "vol3", "vol4"]
return sorted(
vols,
key=lambda v: (0, int(v[3:])) if v.startswith("vol") and v[3:].isdigit() else (1, v)
)
def format_volume_display_name(volume: str) -> str:
"""Format volume identifier into human-friendly display name.
Converts identifiers like ``"vol1"`` to ``"Volume I"``, ``"vol4"`` to
``"Volume IV"``, and ``"tinytorch"`` to ``"TinyTorch"``. Any other volume
identifier is capitalized.
Args:
volume: Volume directory identifier (e.g., ``"vol1"``, ``"tinytorch"``).
Returns:
Formatted human-readable display string.
"""
roman_map = {1: "I", 2: "II", 3: "III", 4: "IV", 5: "V", 6: "VI", 7: "VII", 8: "VIII", 9: "IX", 10: "X"}
if volume.startswith("vol") and volume[3:].isdigit():
num = int(volume[3:])
return f"Volume {roman_map.get(num, str(num))}"
if volume == "tinytorch":
return "TinyTorch"
return volume.capitalize()
def qmd_files_under(root: Path) -> List[Path]:
"""Return *root* if it is a ``.qmd`` file, else every ``.qmd`` file below it, sorted."""
if root.is_file():
return [root] if root.suffix == ".qmd" else []
return sorted(root.rglob("*.qmd"))
def display_path(path: Path, base: Path) -> str:
"""Return *path* relative to *base* for display, or unchanged when it lies outside."""
try:
return str(path.relative_to(base))
except ValueError:
return str(path)
# Default volume directories; dynamic discovery should be preferred via ChapterDiscovery or discover_volumes()
VOLUME_DIRS = ["vol1", "vol2", "vol3", "vol4"]
# Shared content directory (sibling to vol1/, vol2/ under contents/)
SHARED_DIR = "shared"
# Chapter stems that cannot be rendered standalone and are always excluded from
# per-chapter build/debug operations.
SKIP_STEMS = frozenset({"index", "references"})
def _chapters_from_html_sidebar(book_dir: Path, volume: str) -> List[str]:
"""Extract buildable chapter stems from the HTML config sidebar (href entries).
Parses ``binder/config/_quarto-html-{volume}.yml`` for sidebar hrefs matching
the volume, excluding stems in ``SKIP_STEMS`` (e.g. index, references).
Args:
book_dir: Path to the books root directory.
volume: Target volume identifier (e.g., ``"vol1"``, ``"vol4"``).
Returns:
Ordered list of buildable chapter file stems from the sidebar.
"""
html_config = book_dir / "config" / f"_quarto-html-{volume}.yml"
if not html_config.is_file():
return []
content = html_config.read_text(encoding="utf-8")
chapters: List[str] = []
seen: set = set()
for m in re.finditer(r'href:\s*(?:contents/)?([^\s#]+\.qmd)', content):
path_str = m.group(1)
if f"/{volume}/" not in path_str and not path_str.startswith(f"{volume}/"):
continue
stem = Path(path_str).stem
if stem in SKIP_STEMS or stem in seen:
continue
seen.add(stem)
chapters.append(stem)
return chapters
def get_chapters_from_config(book_dir: Path, volume: str) -> List[str]:
"""Return the ordered list of buildable file stems from the PDF config.
Reads ``books/config/_quarto-pdf-{volume}.yml`` and extracts every entry
under ``book.chapters`` — including frontmatter, parts pages, and shared
files. Appendices are excluded. Only ``index.qmd`` and ``references.qmd``
are skipped, as they cannot be rendered standalone.
Args:
book_dir: Path to the ``books/`` directory.
volume: Volume identifier (e.g., ``"vol1"``, ``"vol2"``, ``"vol4"``).
Returns:
Ordered list of file stems in YAML order (e.g. ``["dedication",
"introduction", "distributed_training", ...]``). Empty list if the
config is missing or cannot be parsed.
"""
config_file = book_dir / "config" / f"_quarto-pdf-{volume}.yml"
if not config_file.exists():
sidebar = _chapters_from_html_sidebar(book_dir, volume)
if sidebar:
return sidebar
vol_dir = book_dir / volume
if vol_dir.is_dir():
return sorted([
p.stem for p in vol_dir.rglob("*.qmd")
if p.stem not in SKIP_STEMS and not p.name.startswith("_")
])
return []
def _is_testable(path_str: str) -> bool:
"""Return True unless the path's stem is in ``SKIP_STEMS``."""
return Path(path_str).stem not in SKIP_STEMS
chapters: List[str] = []
seen: set = set()
# --- YAML-aware path (preferred); the regex scan below is the fallback ---
try:
import yaml # type: ignore
raw = yaml.safe_load(config_file.read_text())
chapter_entries = raw.get("book", {}).get("chapters", [])
for entry in chapter_entries:
if isinstance(entry, str):
path = entry
elif isinstance(entry, dict):
path = entry.get("file", "")
else:
continue
if not path or not _is_testable(path):
continue
stem = Path(path).stem
if stem and stem not in seen:
seen.add(stem)
chapters.append(stem)
except Exception as exc:
print(f"binder: could not read the chapter list in {config_file} as YAML ({exc}); "
"falling back to a line scan", file=sys.stderr)
if len(chapters) < 5:
content = config_file.read_text()
chapters_block_match = re.search(
r'^\s{2}chapters:\s*\n(.*?)(?=^\s{2}\w|\Z)',
content,
re.MULTILINE | re.DOTALL,
)
block = chapters_block_match.group(1) if chapters_block_match else content
for line in block.splitlines():
if line.lstrip().startswith("#"):
continue
m = re.search(r'-\s*(?:contents/)?([^\s#]+\.qmd)', line)
if not m:
continue
path_str = m.group(1)
if not _is_testable(path_str):
continue
stem = Path(path_str).stem
if stem not in seen:
seen.add(stem)
chapters.append(stem)
if len(chapters) < 5:
sidebar = _chapters_from_html_sidebar(book_dir, volume)
if len(sidebar) > len(chapters):
return sidebar
return chapters
class AmbiguousChapterError(Exception):
"""Raised when a query identifies more than one chapter."""
def __init__(self, chapter_name: str, locations: List[str]):
"""Record the query and its candidate locations and build the error message."""
self.chapter_name = chapter_name
self.locations = locations
super().__init__(
f"'{chapter_name}' matches multiple chapters: {', '.join(locations)}"
)
class ChapterDiscovery:
"""Discovers and manages chapter files in the MLSysBook project."""
def __init__(self, book_dir: Path):
"""Initialize chapter discovery.
Args:
book_dir: Path to the book directory (``books/``)
"""
self.book_dir = Path(book_dir)
self.contents_dir = self.book_dir
def get_available_volumes(self) -> List[str]:
"""Return list of dynamically discovered volume names."""
return discover_volumes(self.contents_dir)
def get_chapters_from_config(self, volume: str) -> List[str]:
"""Return the ordered list of testable chapter stems for a volume.
Delegates to the module-level ``get_chapters_from_config`` function so
that all CLI commands share a single implementation. Use it whenever
the canonical build order matters.
Args:
volume: Volume name (e.g. ``"vol1"``, ``"vol2"``, etc.).
Returns:
Ordered list of chapter stems from the PDF config (e.g.
``["introduction", "distributed_training", ...]``).
"""
return get_chapters_from_config(self.book_dir, volume)
def _get_volume_from_path(self, path: Path) -> Optional[str]:
"""Extract volume from a file path.
Args:
path: Path to check
Returns:
Volume string (e.g. 'vol1'), or None if not in a volume directory
"""
try:
rel_path = path.relative_to(self.contents_dir)
parts = rel_path.parts
if parts and (parts[0] in self.get_available_volumes() or re.match(r"^vol\d+$", parts[0])):
return parts[0]
except ValueError:
pass
return None
def _parse_chapter_spec(self, chapter_spec: str) -> tuple[Optional[str], str]:
"""Parse a chapter specification that may include volume prefix.
Args:
chapter_spec: Chapter name, optionally with volume prefix (e.g., 'vol1/intro')
Returns:
Tuple of (volume, chapter_name) where volume may be None
"""
if "/" in chapter_spec:
parts = chapter_spec.split("/", 1)
if parts[0] in self.get_available_volumes() or re.match(r"^vol\d+$", parts[0]) or parts[0] == "tinytorch":
return parts[0], parts[1]
return None, chapter_spec
@staticmethod
def _normalized_name(value: str) -> str:
"""Normalize chapter name or title for case- and punctuation-insensitive matching.
Args:
value: Raw string to normalize.
Returns:
Space-delimited string of lowercased alphanumeric tokens.
"""
return " ".join(re.findall(r"[a-z0-9]+", value.lower()))
@staticmethod
def _chapter_title(path: Path) -> str:
"""Read the first H1 or YAML title without scanning the full chapter body.
Args:
path: Path to the target QMD file.
Returns:
Extracted title string, or an empty string if neither H1 nor YAML title was found.
"""
with path.open(encoding="utf-8") as source:
opening = source.read(16384)
heading = re.search(r"^# +(.+?)\s*(?:\{[^}]*\})?\s*$", opening, re.MULTILINE)
if heading:
return heading.group(1)
if opening.startswith("---\n"):
import yaml
try:
metadata = yaml.safe_load(opening.split("---", 2)[1]) or {}
return str(metadata.get("title", ""))
except (ValueError, yaml.YAMLError):
pass
return ""
def find_chapter_file(self, chapter_spec: str, allow_fuzzy: bool = False) -> Optional[Path]:
"""Resolve a filename, relative path, or readable chapter title.
Explicit volume prefixes constrain every matching stage. Exact paths
and stems win, then normalized titles, unique substrings, and finally
conservative typo matching. Ambiguous queries always list candidates.
Args:
chapter_spec: Chapter filename, path, or title (e.g., 'intro', 'vol1/intro', '01_intro').
allow_fuzzy: If True, enable substring and typo-tolerant fuzzy matching.
Returns:
Resolved Path to the chapter QMD file, or None if not found.
Raises:
AmbiguousChapterError: If the specification matches multiple chapters.
"""
from difflib import SequenceMatcher
volume, name = self._parse_chapter_spec(chapter_spec.strip())
name = name.removesuffix(".qmd")
search_dir = self.contents_dir / volume if volume else self.contents_dir
if not search_dir.is_dir():
return None
candidates = [p for p in sorted(search_dir.rglob("*.qmd"))
if self._get_volume_from_path(p)]
shared_dir = self.contents_dir / SHARED_DIR
if shared_dir.is_dir():
candidates.extend(sorted(shared_dir.rglob("*.qmd")))
def choose(matches):
"""Return the single distinct match or None; raise AmbiguousChapterError for several."""
matches =list(dict.fromkeys(matches))
if len(matches) > 1:
raise AmbiguousChapterError(chapter_spec, [
p.relative_to(self.contents_dir).as_posix().removesuffix(".qmd")
for p in matches
])
return matches[0] if matches else None
# Match relative paths literally; never interpret path traversal/globs.
if "/" in name:
matches = [p for p in candidates if
p.relative_to(search_dir if volume and self._get_volume_from_path(p)
else self.contents_dir).as_posix().removesuffix(".qmd") == name]
return choose(matches)
exact = [p for p in candidates if p.stem.lower() == name.lower()]
if exact:
return choose(exact)
if not allow_fuzzy:
return None
query = self._normalized_name(name)
if not query:
return None
names = {p: (self._normalized_name(p.stem),
self._normalized_name(self._chapter_title(p))) for p in candidates}
exact = [p for p, aliases in names.items() if query in aliases]
if exact:
return choose(exact)
partial = [p for p, aliases in names.items() if any(query in alias for alias in aliases)]
if partial:
return choose(partial)
# A typo must resemble most of the name; a two-letter coincidence is
# insufficient. Close runners-up are shown instead of chosen by order.
scored = sorted(((max(SequenceMatcher(None, query, alias).ratio()
for alias in aliases), p) for p, aliases in names.items()),
key=lambda item: (-item[0], str(item[1])))
if not scored or scored[0][0] < 0.82:
return None
return choose([p for score, p in scored if score >= scored[0][0] - 0.08])
def get_all_chapters(self, volume: Optional[str] = None) -> List[Dict[str, Any]]:
"""Get all chapter files with metadata.
Args:
volume: Optional volume filter (e.g., 'vol1', 'vol2', 'vol4', or None for all)
Returns:
List of dictionaries containing chapter information
"""
chapters = []
if not self.contents_dir.exists():
return chapters
# Determine search directory
if volume:
search_dir = self.contents_dir / volume
if not search_dir.is_dir():
console.print(f"[red]Volume directory not found: {volume}[/red]")
return chapters
else:
search_dir = self.contents_dir
for qmd_file in search_dir.rglob("*.qmd"):
# Skip certain files
if qmd_file.name in ["index.qmd", "404.qmd"]:
continue
# Get relative path from contents directory
rel_path = qmd_file.relative_to(self.contents_dir)
# Determine volume
vol = self._get_volume_from_path(qmd_file)
# Skip non-volume files (frontmatter, backmatter) unless searching all
if not volume and not vol:
continue
# Extract chapter info
chapter_info = {
"name": qmd_file.stem,
"path": qmd_file,
"relative_path": rel_path,
"directory": qmd_file.parent.name,
"volume": vol,
"size": qmd_file.stat().st_size if qmd_file.exists() else 0
}
chapters.append(chapter_info)
# Sort by path for consistent ordering
chapters.sort(key=lambda x: str(x["relative_path"]))
return chapters
def show_chapters(self, volume: Optional[str] = None) -> None:
"""Display available chapters in a formatted table.
Args:
volume: Optional volume filter (e.g., 'vol1', 'vol2', 'vol4', or None for all)
"""
from rich.table import Table
chapters = self.get_all_chapters(volume=volume)
if not chapters:
console.print("[yellow]No chapters found[/yellow]")
return
table = Table(show_header=True, header_style="bold blue")
table.add_column("Chapter", style="green", width=25)
table.add_column("Volume", style="magenta", width=8)
table.add_column("Directory", style="cyan", width=20)
table.add_column("Size", style="dim", width=10)
for chapter in chapters:
size_kb = chapter["size"] / 1024 if chapter["size"] > 0 else 0
size_str = f"{size_kb:.1f} KB" if size_kb > 0 else "0 KB"
table.add_row(
chapter["name"],
chapter["volume"] or "-",
chapter["directory"],
size_str
)
console.print(table)
# Show volume summary
counts = ", ".join(f"{vol}: {sum(ch['volume'] == vol for ch in chapters)}"
for vol in self.get_available_volumes())
if volume:
console.print(f"\n[dim]Found {len(chapters)} chapters in {volume}[/dim]")
else:
console.print(f"\n[dim]Found {len(chapters)} chapters ({counts})[/dim]")
def validate_chapters(self, chapter_names: List[str]) -> List[Path]:
"""Validate a list of chapter names and return their paths.
Args:
chapter_names: List of chapter names to validate
Returns:
List of valid chapter file paths
Raises:
FileNotFoundError: If any chapter is not found
AmbiguousChapterError: If chapter exists in multiple volumes
"""
chapter_files = []
for chapter_name in chapter_names:
try:
chapter_file = self.find_chapter_file(chapter_name, allow_fuzzy=True)
except AmbiguousChapterError as e:
console.print(f"[red]Ambiguous chapter: {e.chapter_name}[/red]")
console.print("[yellow]Use a more specific title or one of these paths:[/yellow]")
for loc in e.locations:
console.print(f" - {loc}")
raise
if not chapter_file:
from difflib import get_close_matches
volume, name = self._parse_chapter_spec(chapter_name.strip())
available = sorted({ch["name"] for ch in self.get_all_chapters(volume=volume)})
console.print(f"[red]Chapter not found: {chapter_name}[/red]")
close = get_close_matches(name.removesuffix(".qmd"), available, n=5, cutoff=0.5)
if close:
console.print(f"[yellow]Closest chapter names: {', '.join(close)}[/yellow]")
list_cmd = f"./binder/binder list --{volume}" if volume else "./binder/binder list --volN"
console.print(f"[yellow]See every chapter with: {list_cmd}[/yellow]")
raise FileNotFoundError(f"Chapter not found: {chapter_name}")
chapter_files.append(chapter_file)
return chapter_files
def expand_chapter_patterns(
self,
chapter_specs: List[str],
*,
volume: Optional[str] = None,
) -> List[str]:
"""
Expand glob/regex chapter patterns into concrete chapter specs.
Supported pattern forms:
- **Glob** (default): `appendix*`, `*principles`, `vol1/appendix_*`
- **Regex**: prefix with `re:` (matched with `re.search`), e.g. `re:^appendix_`
Notes:
- If a token has no wildcard/meta and doesn't start with `re:`, it is returned unchanged.
- If a pattern matches nothing, it is returned unchanged (so existing fuzzy matching
behavior remains available); callers may still fail later during validation.
- Order is preserved; duplicates are removed.
"""
# Candidate names come from discovery; includes front/backmatter too (useful for appendix*).
all_candidates = [ch["name"] for ch in self.get_all_chapters(volume=volume)]
expanded: List[str] = []
seen = set()
def _append(spec: str) -> None:
"""Add *spec* to the expanded list unless it is already present."""
if spec not in seen:
expanded.append(spec)
seen.add(spec)
for spec in chapter_specs:
spec = spec.strip()
if not spec:
continue
spec_volume, name_or_pat = self._parse_chapter_spec(spec)
local_volume = spec_volume or volume
candidates = (
[ch["name"] for ch in self.get_all_chapters(volume=local_volume)]
if local_volume
else all_candidates
)
is_regex = name_or_pat.startswith("re:")
is_glob = any(ch in name_or_pat for ch in ["*", "?", "["])
matches: List[str] = []
if is_regex:
pat = name_or_pat[len("re:") :]
try:
rx = re.compile(pat)
matches = [c for c in candidates if rx.search(c)]
except re.error as exc:
console.print(f"[red]Invalid chapter regex {pat!r}: {exc}[/red]")
matches = []
elif is_glob:
matches = [c for c in candidates if fnmatch.fnmatchcase(c, name_or_pat)]
if matches:
for m in matches:
_append(f"{local_volume}/{m}" if spec_volume else m)
else:
# Not a pattern, or didn't match: keep original token for existing behavior.
_append(spec)
return expanded