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

534 lines
21 KiB
Python

"""
Format commands for MLSysBook CLI.
Auto-formatters for QMD content: blank lines, Python code blocks,
list spacing, div spacing, and table formatting.
Usage:
binder format blanks — Collapse extra blank lines
binder format python — Format hidden executable Python cells (Black; 150)
binder format python --include-display
— Also format displayed code (layout-changing opt-in)
binder format lists — Fix bullet list spacing
binder format divs — Fix div/callout spacing
binder format tables — Prettify grid tables
binder format all — Run all formatters
"""
import re
import sys
import subprocess
from pathlib import Path
from typing import List
from rich.console import Console
from rich.markup import escape as _rich_escape
from rich.panel import Panel
from rich.table import Table
from ..core.discovery import display_path
console = Console()
# Paths to legacy scripts (used for complex formatters not yet natively ported)
_SCRIPTS_DIR = Path(__file__).resolve().parent.parent.parent / "tools" / "scripts"
_SCRIPT_PATHS = {
"python": _SCRIPTS_DIR / "content" / "format_python_in_qmd.py",
"tables": _SCRIPTS_DIR / "content" / "format_tables.py",
"divs": _SCRIPTS_DIR / "content" / "format_div_spacing.py",
"prettify": _SCRIPTS_DIR / "utilities" / "prettify_pipe_tables.py",
}
class FormatCommand:
"""Auto-format QMD content."""
TARGETS = ["blanks", "python", "lists", "divs", "percent-tables", "mitpress-terms", "tables", "prettify", "all"]
def __init__(self, config_manager, chapter_discovery):
"""Store the shared config manager and chapter discovery helpers."""
self.config_manager = config_manager
self.chapter_discovery = chapter_discovery
def run(self, args: List[str]) -> bool:
"""Entry point — parse args and dispatch."""
if not args or args[0] in ("help", "-h", "--help"):
self._print_help()
return True
target = args[0]
if target not in self.TARGETS:
console.print(f"[red]Unknown format target: {target}[/red]")
self._print_help()
return False
# Remaining args are file paths or flags
rest = args[1:]
include_display = "--include-display" in rest
files, check_only = self._parse_rest(rest)
if target == "all":
return self._run_all(files, check_only)
if target == "python":
return self._run_python(
files, check_only, include_display=include_display
)
dispatch = {
"blanks": self._run_blanks,
"lists": self._run_lists,
"divs": self._run_divs,
"percent-tables": self._run_percent_tables,
"mitpress-terms": self._run_mitpress_terms,
"tables": self._run_tables,
"prettify": self._run_prettify,
}
return dispatch[target](files, check_only)
# ------------------------------------------------------------------
# Argument helpers
# ------------------------------------------------------------------
def _parse_rest(self, rest: List[str]) -> tuple:
"""Parse remaining args into (file_list, check_only)."""
check_only = False
files: List[str] = []
for arg in rest:
if arg in ("--check", "-c"):
check_only = True
elif arg.startswith("-"):
pass # ignore unknown flags gracefully
else:
files.append(arg)
return files, check_only
def _resolve_files(self, file_args: List[str]) -> List[Path]:
"""Resolve file arguments to a list of QMD paths."""
if file_args:
result = []
for f in file_args:
p = Path(f)
if not p.is_absolute():
p = (Path.cwd() / p).resolve()
if p.is_dir():
result.extend(sorted(p.rglob("*.qmd")))
elif p.suffix == ".qmd" and p.exists():
result.append(p)
return result
# Default: all content files
base = self.config_manager.book_dir
return sorted(base.rglob("*.qmd"))
# ------------------------------------------------------------------
# Help
# ------------------------------------------------------------------
def _print_help(self) -> None:
"""Print the format targets and usage examples."""
table = Table(show_header=True, header_style="bold cyan", box=None)
table.add_column("Target", style="cyan", width=12)
table.add_column("Description", style="white", width=45)
table.add_row("blanks", "Collapse extra blank lines (native)")
table.add_row("python", "Format hidden executable Python cells (Black; 150)")
table.add_row("lists", "Fix bullet list spacing (blank line before lists)")
table.add_row("divs", "Fix div/callout spacing (paragraph ↔ list gaps)")
table.add_row("percent-tables", "Rewrite 'percent' → % inside pipe tables (native)")
table.add_row("mitpress-terms", "Apply §10.7 canonical spellings (trade-off, Wi-Fi, …)")
table.add_row("tables", "Prettify grid tables (align columns, bold headers)")
table.add_row("prettify", "Prettify pipe tables (align columns)")
table.add_row("all", "Run all formatters")
console.print(Panel(
table,
title=_rich_escape("binder format <target> [files...] [--check]"),
border_style="cyan",
))
console.print("[dim]Examples:[/dim]")
console.print(" [cyan]./binder/binder format blanks[/cyan] [dim]# fix all files[/dim]")
console.print(" [cyan]./binder/binder format tables --check[/cyan] [dim]# check only, no writes[/dim]")
console.print(" [cyan]./binder/binder format python path/to/ch.qmd[/cyan] [dim]# single file[/dim]")
console.print(" [cyan]./binder/binder format python --include-display path/to/ch.qmd[/cyan]")
console.print(" [dim]# intentional layout-changing opt-in[/dim]")
console.print()
# ------------------------------------------------------------------
# Run all
# ------------------------------------------------------------------
def _run_all(self, files: List[str], check_only: bool) -> bool:
"""Run every formatter in a fixed order and print a summary table.
All formatters run even after one reports changes, and
``--include-display`` is not forwarded to the Python formatter. Returns
True only when every formatter reports its files clean.
"""
results = []
for target in ("blanks", "lists", "divs", "python", "percent-tables", "mitpress-terms", "tables", "prettify"):
dispatch = {
"blanks": self._run_blanks,
"python": self._run_python,
"lists": self._run_lists,
"divs": self._run_divs,
"percent-tables": self._run_percent_tables,
"mitpress-terms": self._run_mitpress_terms,
"tables": self._run_tables,
"prettify": self._run_prettify,
}
ok = dispatch[target](files, check_only)
results.append((target, ok))
table = Table(show_header=True, header_style="bold cyan", box=None)
table.add_column("Formatter", style="cyan")
table.add_column("Status", style="white")
for name, ok in results:
status = "[green]PASS[/green]" if ok else "[red]MODIFIED[/red]"
table.add_row(name, status)
console.print(Panel(table, title="Binder Format Summary", border_style="cyan"))
return all(ok for _, ok in results)
# ------------------------------------------------------------------
# Blanks (native — ported from format_blank_lines.py)
# ------------------------------------------------------------------
def _run_mitpress_terms(self, file_args: List[str], check_only: bool) -> bool:
"""Apply the MIT Press canonical spelling dictionary (§10.7).
Rewrites unambiguous spelling/hyphenation errors and proper-noun
miscapitalizations (tradeoff → trade-off, datacenter → data center,
WiFi → Wi-Fi, …). Shares its detection/rewrite with the
``spelling-dict`` validator scope via ``cli.checks.mitpress_terms`` so
the fixer fixes exactly what the check flags; skips glossary files.
"""
from cli.checks.mitpress_terms import fix_text, should_skip_file
files = [p for p in self._resolve_files(file_args) if not should_skip_file(p)]
return self._rewrite_files("mitpress-terms", files, lambda text: fix_text(text)[0], check_only)
def _run_percent_tables(self, file_args: List[str], check_only: bool) -> bool:
"""Rewrite the word 'percent' to the % symbol inside pipe tables.
Tables invert the prose convention: dense cells use the % symbol
('86.4%') where body prose spells out 'percent'. Shares its detection
and rewrite with the ``percent-in-tables`` validator scope via
``cli.checks.percent_tables`` so the fixer fixes exactly what the
check flags. Must run BEFORE ``prettify`` so columns realign after the
substitution shortens cells.
"""
from cli.checks.percent_tables import fix_text
files = self._resolve_files(file_args)
return self._rewrite_files("percent-tables", files, lambda text: fix_text(text)[0], check_only)
def _run_blanks(self, file_args: List[str], check_only: bool) -> bool:
"""Collapse multiple consecutive blank lines into single blank lines."""
files = self._resolve_files(file_args)
return self._rewrite_files("blanks", files, self._collapse_blank_lines, check_only)
@staticmethod
def _collapse_blank_lines(content: str) -> str:
"""Replace multiple consecutive blank lines with a single blank line.
Preserves content inside code blocks and HTML comments. Fence
tracking must ignore HTML comments: commented-out figures often
contain partial code blocks whose unbalanced ``` lines would
otherwise desync the toggle, making the formatter treat real
code cells as prose for the rest of the file.
"""
lines = content.split("\n")
result = []
in_code_block = False
in_html_comment = False
blank_count = 0
for line in lines:
if in_html_comment:
# Leave commented-out regions verbatim; their fences
# (often unbalanced) must not toggle code-block state.
result.append(line)
if "-->" in line:
in_html_comment = False
continue
if not in_code_block and "<!--" in line and "-->" not in line:
if blank_count > 0:
result.append("")
blank_count = 0
result.append(line)
in_html_comment = True
continue
if line.strip().startswith("```"):
in_code_block = not in_code_block
if blank_count > 0:
result.append("")
blank_count = 0
result.append(line)
continue
if in_code_block:
result.append(line)
continue
if line.strip() == "":
blank_count += 1
else:
if blank_count > 0:
result.append("")
blank_count = 0
result.append(line)
if blank_count > 0:
result.append("")
return "\n".join(result)
# ------------------------------------------------------------------
# Lists (native — ported from fix_bullet_spacing.py)
# ------------------------------------------------------------------
_LIST_ITEM_RE = re.compile(r"^\s*(?:[-*+]\s+\S|\d+[.)]\s+\S)")
_BOLD_MARKDOWN_RE = re.compile(r"(?<!\\)\*\*[^*\n]+\*\*")
def _run_lists(self, file_args: List[str], check_only: bool) -> bool:
"""Fix list spacing: blank line before lists, tight consecutive items."""
files = self._resolve_files(file_args)
return self._rewrite_files("lists", files, self._fix_list_spacing, check_only)
@classmethod
def _is_list_item(cls, line: str) -> bool:
"""Check if a line is a bullet or numbered list item."""
return bool(cls._LIST_ITEM_RE.match(line))
@classmethod
def _has_bold_markdown(cls, line: str) -> bool:
"""Check if a line contains Markdown bold text."""
return bool(cls._BOLD_MARKDOWN_RE.search(line))
@classmethod
def _fix_list_spacing(cls, content: str) -> str:
"""Fix list spacing in content.
1. Add blank line before lists when preceded by paragraph text ending with ':'
or a bold lead-in paragraph
2. Remove blank lines between consecutive list items (tight lists)
Skips code blocks.
"""
lines = content.split("\n")
result: List[str] = []
in_code = False
i = 0
while i < len(lines):
line = lines[i]
stripped = line.strip()
# Track code blocks
if stripped.startswith("```"):
in_code = not in_code
result.append(line)
i += 1
continue
if in_code:
result.append(line)
i += 1
continue
# Fix 1: Add blank line before list when missing
if i < len(lines) - 1:
next_line = lines[i + 1]
if (
(stripped.endswith(":") or cls._has_bold_markdown(stripped))
and not stripped.startswith("```")
and not stripped.startswith("#|")
and "://" not in stripped
and not stripped.startswith("def ")
and not stripped.startswith("class ")
and not cls._is_list_item(line)
and cls._is_list_item(next_line)
):
result.append(line)
result.append("")
i += 1
continue
# Fix 2: Remove blank lines between consecutive list items
if cls._is_list_item(line):
result.append(line)
j = i + 1
blank_count = 0
while j < len(lines) and lines[j].strip() == "":
blank_count += 1
j += 1
if blank_count > 0 and j < len(lines) and cls._is_list_item(lines[j]):
# Skip the blank lines — go directly to next list item
i = j
continue
i += 1
continue
result.append(line)
i += 1
return "\n".join(result)
# ------------------------------------------------------------------
# Python (delegates to format_python_in_qmd.py)
# ------------------------------------------------------------------
def _run_python(
self,
file_args: List[str],
check_only: bool,
include_display: bool = False,
) -> bool:
"""Format hidden Python cells, with displayed code as an opt-in."""
script = _SCRIPT_PATHS["python"]
if not script.exists():
console.print(f"[red]python: Script not found: {script}[/red]")
return False
qmd_files = self._resolve_files(file_args)
if not qmd_files:
console.print("[green]python: No files to process[/green]")
return True
cmd = [sys.executable, str(script)]
if include_display:
cmd.append("--include-display")
cmd.extend(str(f) for f in qmd_files)
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
console.print("[green]python: All files clean[/green]")
return True
else:
if result.stdout:
for line in result.stdout.strip().splitlines()[:10]:
console.print(f" [yellow]{line}[/yellow]")
console.print(f"[yellow]python: {len(qmd_files)} file(s) processed[/yellow]")
return False
# ------------------------------------------------------------------
# Divs (delegates to format_div_spacing.py)
# ------------------------------------------------------------------
def _run_divs(self, file_args: List[str], check_only: bool) -> bool:
"""Fix div/callout spacing."""
script = _SCRIPT_PATHS["divs"]
if not script.exists():
console.print(f"[red]divs: Script not found: {script}[/red]")
return False
qmd_files = self._resolve_files(file_args)
if not qmd_files:
console.print("[green]divs: No files to process[/green]")
return True
mode = "--check" if check_only else "-f"
modified = [
path for path in qmd_files
if subprocess.run([sys.executable, str(script), mode, str(path)],
capture_output=True, text=True).returncode != 0
]
return self._report_modified("divs", modified, check_only)
# ------------------------------------------------------------------
# Tables (delegates to format_tables.py)
# ------------------------------------------------------------------
def _run_tables(self, file_args: List[str], check_only: bool) -> bool:
"""Prettify grid tables."""
script = _SCRIPT_PATHS["tables"]
if not script.exists():
console.print(f"[red]tables: Script not found: {script}[/red]")
return False
mode = "--check" if check_only else "--fix"
if file_args:
cmd = [sys.executable, str(script), mode]
for f in file_args:
cmd.extend(["-f", f])
else:
content_dir = self.config_manager.book_dir
cmd = [sys.executable, str(script), mode, "-d", str(content_dir)]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
console.print("[green]tables: All files clean[/green]")
return True
else:
if result.stdout:
for line in result.stdout.strip().splitlines()[:10]:
console.print(f" [yellow]{line}[/yellow]")
console.print(f"[yellow]tables: Issues found (exit {result.returncode})[/yellow]")
return False
def _run_prettify(self, file_args: List[str], check_only: bool) -> bool:
"""Prettify pipe tables (align columns)."""
script = _SCRIPT_PATHS["prettify"]
if not script.exists():
console.print(f"[red]prettify: Script not found: {script}[/red]")
return False
cmd = [sys.executable, str(script)]
if check_only:
cmd.append("--check")
if file_args:
cmd.extend(file_args)
else:
content_dir = self.config_manager.book_dir
cmd.append(str(content_dir))
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
return True
else:
if result.stdout:
for line in result.stdout.strip().splitlines()[:10]:
console.print(f" [yellow]{line}[/yellow]")
console.print(f"[yellow]prettify: Tables reformatted (exit {result.returncode})[/yellow]")
return False
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _rewrite_files(self, name: str, files: List[Path], transform, check_only: bool) -> bool:
"""Apply *transform* (text -> text) to each file, writing changes unless *check_only*.
Returns False when any file changed (or would change): the pre-commit
convention that a modifying hook exits 1.
"""
modified = []
for path in files:
content = path.read_text(encoding="utf-8")
new_content = transform(content)
if new_content != content:
if not check_only:
path.write_text(new_content, encoding="utf-8")
modified.append(path)
return self._report_modified(name, modified, check_only)
def _report_modified(self, name: str, modified: List[Path], check_only: bool) -> bool:
"""Print which files *name* changed (first ten) and return True only when none did."""
if not modified:
console.print(f"[green]{name}: All files clean[/green]")
return True
label = "Would modify" if check_only else "Modified"
console.print(f"[yellow]{name}: {label} {len(modified)} file(s)[/yellow]")
for path in modified[:10]:
console.print(f" {display_path(path, self.config_manager.book_dir)}")
if len(modified) > 10:
console.print(f" [dim]... {len(modified) - 10} more[/dim]")
if check_only:
console.print(f"[dim]Apply by rerunning ./binder/binder format {name} without --check.[/dim]")
return False