Files
cs249r_book/binder
Vijay Janapa Reddi 6018830d4c 🔗 Complete pre-commit integration: Book Binder + TikZ cleanup
 Perfect pre-commit integration achieved:
- Added check-build-artifacts hook using ./binder check
- Fixed exit codes: returns 1 when artifacts found, 0 when clean
- Removed tracked TikZ diagram PDFs from git (build artifacts)
- Enhanced artifact detection and cleanup patterns

🧹 TikZ diagram management:
- Removed diagram-*.pdf files from git tracking
- Added cleanup patterns for generated diagrams
- Pre-commit now properly detects and blocks artifact commits

🚀 Complete workflow proven:
1. git commit → pre-commit runs binder check
2. If artifacts found → commit blocked with guidance
3. User runs ./binder clean → artifacts removed
4. Retry commit → pre-commit passes 

🎉 Book Binder: Complete MLSysBook development CLI
- Single tool for all development operations
- Perfect pre-commit integration
- Bulletproof artifact detection and cleanup
- Professional git workflow integration
2025-07-28 12:03:13 -04:00

1097 lines
44 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Book Binder - Beautiful Self-Contained CLI for MLSysBook
A gorgeous, lightning-fast, completely self-contained book development tool
"""
import os
import sys
import subprocess
import shutil
import re
import signal
from pathlib import Path
from contextlib import contextmanager
try:
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TimeElapsedColumn
from rich.tree import Tree
from rich.text import Text
from rich.live import Live
from rich import print as rprint
RICH_AVAILABLE = True
except ImportError:
RICH_AVAILABLE = False
def rprint(*args, **kwargs):
print(*args, **kwargs)
console = Console() if RICH_AVAILABLE else None
class BookBinder:
def __init__(self):
self.root_dir = Path.cwd()
self.book_dir = self.root_dir / "book"
self.build_dir = self.root_dir / "build"
self.html_config = self.book_dir / "_quarto-html.yml"
self.pdf_config = self.book_dir / "_quarto-pdf.yml"
self.active_config = self.book_dir / "_quarto.yml"
def show_banner(self):
"""Display beautiful banner"""
if not RICH_AVAILABLE:
print("📚 Book Binder - Self-Contained MLSysBook CLI")
return
banner = Panel.fit(
"[bold blue]📚 Book Binder[/bold blue]\n"
"[dim]Self-contained lightning-fast MLSysBook development CLI[/dim]",
border_style="blue",
padding=(1, 2)
)
console.print(banner)
def find_chapters(self):
"""Find all available chapters"""
contents_dir = self.book_dir / "contents"
if not contents_dir.exists():
return []
chapters = []
for qmd_file in contents_dir.rglob("*.qmd"):
if "images" not in str(qmd_file):
rel_path = qmd_file.relative_to(contents_dir)
chapter_name = str(rel_path).replace(".qmd", "")
chapters.append(chapter_name)
return sorted(chapters)
def find_chapter_file(self, partial_name):
"""Find chapter file that matches partial name"""
contents_dir = self.book_dir / "contents"
# Try direct glob match first
matches = list(contents_dir.rglob(f"*{partial_name}*.qmd"))
if matches:
# Filter out images directories
valid_matches = [m for m in matches if "images" not in str(m)]
if valid_matches:
return valid_matches[0]
return None
def find_chapter_match(self, partial_name):
"""Find chapter that matches partial name"""
file_path = self.find_chapter_file(partial_name)
if file_path:
rel_path = file_path.relative_to(self.book_dir / "contents")
return str(rel_path).replace(".qmd", "")
return None
def get_status(self):
"""Get current configuration status"""
if self.active_config.is_symlink():
target = self.active_config.readlink()
active_config = str(target)
else:
active_config = "No symlink found"
# Check for commented lines
html_commented = 0
pdf_commented = 0
try:
if self.html_config.exists():
with open(self.html_config, 'r') as f:
html_commented = sum(1 for line in f if "FAST_BUILD_COMMENTED" in line)
except:
pass
try:
if self.pdf_config.exists():
with open(self.pdf_config, 'r') as f:
pdf_commented = sum(1 for line in f if "FAST_BUILD_COMMENTED" in line)
except:
pass
return {
'active_config': active_config,
'html_commented': html_commented,
'pdf_commented': pdf_commented,
'is_clean': html_commented == 0 and pdf_commented == 0
}
def show_status(self):
"""Display beautiful status information"""
status = self.get_status()
if not RICH_AVAILABLE:
print(f"📊 Status:")
print(f" 🔗 Active config: {status['active_config']}")
print(f" ✅ Clean: {status['is_clean']}")
return
table = Table(title="📊 Current Status", show_header=False, box=None)
table.add_column("", style="cyan", no_wrap=True)
table.add_column("", style="white")
table.add_row("🔗 Active Config", f"[bold]{status['active_config']}[/bold]")
if status['is_clean']:
table.add_row("✅ State", "[green]Configs are clean[/green]")
else:
table.add_row("⚠️ State", f"[yellow]{status['html_commented'] + status['pdf_commented']} commented lines[/yellow]")
console.print(Panel(table, border_style="green"))
def show_chapters(self):
"""Display available chapters in a beautiful format"""
chapters = self.find_chapters()
if not RICH_AVAILABLE:
print("📚 Available chapters:")
for chapter in chapters[:10]:
print(f" {chapter}")
if len(chapters) > 10:
print(f" ... and {len(chapters) - 10} more")
return
tree = Tree("📚 [bold blue]Available Chapters[/bold blue]")
# Group by category
categories = {}
for chapter in chapters:
parts = chapter.split('/')
if len(parts) > 1:
category = parts[0]
name = '/'.join(parts[1:])
else:
category = "root"
name = chapter
if category not in categories:
categories[category] = []
categories[category].append(name)
for category, items in sorted(categories.items()):
category_node = tree.add(f"[bold cyan]{category}[/bold cyan]")
for item in sorted(items):
category_node.add(f"[white]{item}[/white]")
console.print(tree)
@contextmanager
def progress_context(self, description):
"""Context manager for progress display"""
if not RICH_AVAILABLE:
print(f"Running: {description}")
yield None
return
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TimeElapsedColumn(),
console=console
) as progress:
task = progress.add_task(description, total=None)
yield progress
progress.update(task, completed=True)
def run_command(self, cmd, cwd=None, description=None):
"""Run command with progress indicator"""
if isinstance(cmd, str):
cmd = cmd.split()
desc = description or f"Running: {' '.join(cmd)}"
try:
with self.progress_context(desc):
result = subprocess.run(
cmd,
cwd=cwd or self.root_dir,
capture_output=False,
text=True
)
return result.returncode == 0
except Exception as e:
if RICH_AVAILABLE:
console.print(f"[red]Error: {e}[/red]")
else:
print(f"Error: {e}")
return False
def comment_qmd_files(self, config_file, target_path):
"""Comment out all .qmd files except index.qmd and target"""
backup_path = config_file.with_suffix('.yml.fast-build-backup')
# Create bulletproof backup
try:
# Remove any existing backup first
if backup_path.exists():
backup_path.unlink()
# Create fresh backup with metadata preservation
shutil.copy2(config_file, backup_path)
# Verify backup was created successfully
if not backup_path.exists():
raise Exception(f"Failed to create backup at {backup_path}")
if RICH_AVAILABLE:
console.print(f"[dim] 💾 Backup created: {backup_path.name}[/dim]")
else:
print(f" 💾 Backup created: {backup_path.name}")
except Exception as e:
if RICH_AVAILABLE:
console.print(f"[red]❌ Failed to create backup: {e}[/red]")
else:
print(f"❌ Failed to create backup: {e}")
raise
# Read file
with open(config_file, 'r') as f:
lines = f.readlines()
# Comment out .qmd lines
modified_lines = []
for line in lines:
if re.search(r'\.qmd($|[^a-zA-Z0-9_-])', line):
# Skip if it's index.qmd or our target
if 'index.qmd' in line or target_path in line:
modified_lines.append(line)
else:
modified_lines.append(f"# FAST_BUILD_COMMENTED: {line}")
else:
modified_lines.append(line)
# Write back
with open(config_file, 'w') as f:
f.writelines(modified_lines)
# Count changes
commented_count = sum(1 for line in modified_lines if "FAST_BUILD_COMMENTED" in line)
active_count = sum(1 for line in modified_lines if ".qmd" in line and "FAST_BUILD_COMMENTED" not in line)
return commented_count, active_count, backup_path
def restore_config(self, config_file, backup_path=None):
"""Restore config from backup or uncomment lines - bulletproof restoration"""
try:
if backup_path and backup_path.exists():
# Restore from backup (preferred method)
if RICH_AVAILABLE:
console.print(f"[dim] 🔄 Restoring from backup: {backup_path.name}[/dim]")
else:
print(f" 🔄 Restoring from backup: {backup_path.name}")
# Ensure target is writable before attempting restore
if config_file.exists():
config_file.chmod(0o644)
shutil.move(backup_path, config_file)
return True
elif config_file.exists():
# Fallback: uncomment lines in-place
if RICH_AVAILABLE:
console.print(f"[dim] 🔄 Uncommenting lines in: {config_file.name}[/dim]")
else:
print(f" 🔄 Uncommenting lines in: {config_file.name}")
with open(config_file, 'r') as f:
content = f.read()
# Remove comment markers
content = re.sub(r'^# FAST_BUILD_COMMENTED: ', '', content, flags=re.MULTILINE)
with open(config_file, 'w') as f:
f.write(content)
return True
except Exception as e:
# Emergency fallback
if RICH_AVAILABLE:
console.print(f"[red]⚠️ Restore error: {e}[/red]")
else:
print(f"⚠️ Restore error: {e}")
return False
def setup_symlink(self, format_type):
"""Setup _quarto.yml symlink"""
config_file = "_quarto-html.yml" if format_type == "html" else "_quarto-pdf.yml"
# Remove existing symlink/file
if self.active_config.exists() or self.active_config.is_symlink():
self.active_config.unlink()
# Create new symlink
self.active_config.symlink_to(config_file)
return config_file
def build(self, chapter, format_type="html"):
"""Build a chapter with beautiful progress display"""
# Find the actual chapter file
chapter_file = self.find_chapter_file(chapter)
if not chapter_file:
if RICH_AVAILABLE:
console.print(f"[red]❌ No chapter found matching '{chapter}'[/red]")
console.print("[yellow]💡 Available chapters:[/yellow]")
self.show_chapters()
else:
print(f"❌ No chapter found matching '{chapter}'")
print("💡 Available chapters:")
self.show_chapters()
return False
# Get relative path from book directory
target_path = str(chapter_file.relative_to(self.book_dir))
chapter_name = str(chapter_file.relative_to(self.book_dir / "contents")).replace(".qmd", "")
if RICH_AVAILABLE:
console.print(f"[green]🚀 Building[/green] [bold]{chapter_name}[/bold] [dim]({format_type})[/dim]")
console.print(f"[dim] ✅ Found: {chapter_file}[/dim]")
else:
print(f"🚀 Building {chapter_name} ({format_type})")
print(f" ✅ Found: {chapter_file}")
# Setup configuration
config_file = self.html_config if format_type == "html" else self.pdf_config
format_arg = "html" if format_type == "html" else "titlepage-pdf"
build_subdir = "html" if format_type == "html" else "pdf"
# Create build directory
(self.build_dir / build_subdir).mkdir(parents=True, exist_ok=True)
try:
# Comment out non-target files for clean navigation
if RICH_AVAILABLE:
console.print(f"[yellow] 📝 Preparing clean navigation in {config_file.name}[/yellow]")
else:
print(f" 📝 Preparing clean navigation in {config_file.name}")
commented_count, active_count, backup_path = self.comment_qmd_files(config_file, target_path)
# Setup signal handler to restore config on Ctrl+C
def signal_handler(signum, frame):
if RICH_AVAILABLE:
console.print("\n[yellow]🛡️ Ctrl+C detected - restoring config and exiting...[/yellow]")
else:
print("\n🛡️ Ctrl+C detected - restoring config and exiting...")
self.restore_config(config_file, backup_path)
if RICH_AVAILABLE:
console.print("[green]✅ Config restored to pristine state[/green]")
else:
print("✅ Config restored to pristine state")
sys.exit(0)
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
if RICH_AVAILABLE:
console.print(f"[dim] 📊 Navigation: {commented_count} hidden, {active_count} visible[/dim]")
else:
print(f" 📊 Navigation: {commented_count} hidden, {active_count} visible")
# Setup symlink
active_config_name = self.setup_symlink(format_type)
if RICH_AVAILABLE:
console.print(f"[blue] 🔗 _quarto.yml → {active_config_name}[/blue]")
else:
print(f" 🔗 _quarto.yml → {active_config_name}")
# Build only the target chapter (single file)
if RICH_AVAILABLE:
console.print("[yellow] 🔨 Building only target file...[/yellow]")
else:
print(" 🔨 Building only target file...")
# Render just the target chapter file
render_cmd = ["quarto", "render", target_path, "--to", format_arg]
success = self.run_command(
render_cmd,
cwd=self.book_dir,
description=f"Building {chapter_name} ({format_type}) - 1 file only"
)
if success:
if RICH_AVAILABLE:
console.print(f"[green] ✅ Fast build complete: build/{build_subdir}/[/green]")
else:
print(f" ✅ Fast build complete: build/{build_subdir}/")
else:
if RICH_AVAILABLE:
console.print("[red] ❌ Build failed[/red]")
else:
print(" ❌ Build failed")
return success
finally:
# Always restore config (bulletproof cleanup)
if RICH_AVAILABLE:
console.print("[yellow] 🛡️ Ensuring pristine config restoration...[/yellow]")
else:
print(" 🛡️ Ensuring pristine config restoration...")
if self.restore_config(config_file, backup_path):
if RICH_AVAILABLE:
console.print("[green] ✅ Config restored to pristine state[/green]")
else:
print(" ✅ Config restored to pristine state")
else:
if RICH_AVAILABLE:
console.print("[red] ⚠️ Warning: Config restoration had issues[/red]")
else:
print(" ⚠️ Warning: Config restoration had issues")
def preview(self, chapter):
"""Start preview server for a chapter"""
# Find the actual chapter file
chapter_file = self.find_chapter_file(chapter)
if not chapter_file:
if RICH_AVAILABLE:
console.print(f"[red]❌ No chapter found matching '{chapter}'[/red]")
else:
print(f"❌ No chapter found matching '{chapter}'")
return False
target_path = str(chapter_file.relative_to(self.book_dir))
chapter_name = str(chapter_file.relative_to(self.book_dir / "contents")).replace(".qmd", "")
if RICH_AVAILABLE:
console.print(f"[blue]🌐 Starting preview for[/blue] [bold]{chapter_name}[/bold]")
else:
print(f"🌐 Starting preview for {chapter_name}")
# Setup for HTML preview
config_file = self.html_config
try:
# Comment out non-target files for clean navigation
commented_count, active_count, backup_path = self.comment_qmd_files(config_file, target_path)
# Setup symlink
self.setup_symlink("html")
if RICH_AVAILABLE:
console.print("[blue] 🌐 Starting preview server for 1 file only...[/blue]")
console.print("[dim] 💡 TIP: Only the target chapter will be rendered[/dim]")
console.print("[dim] 🛑 Press Ctrl+C to stop the server and restore config[/dim]")
else:
print(" 🌐 Starting preview server for 1 file only...")
print(" 💡 TIP: Only the target chapter will be rendered")
print(" 🛑 Press Ctrl+C to stop the server and restore config")
# Setup signal handler to restore config on exit
def signal_handler(signum, frame):
if RICH_AVAILABLE:
console.print("\n[yellow]🛡️ Ctrl+C detected - restoring config and exiting...[/yellow]")
else:
print("\n🛡️ Ctrl+C detected - restoring config and exiting...")
self.restore_config(config_file, backup_path)
if RICH_AVAILABLE:
console.print("[green]✅ Config restored to pristine state[/green]")
else:
print("✅ Config restored to pristine state")
sys.exit(0)
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
# Start preview with only the target file
try:
preview_cmd = ["quarto", "preview", target_path]
subprocess.run(
preview_cmd,
cwd=self.book_dir
)
finally:
# Restore config
self.restore_config(config_file, backup_path)
return True
except Exception as e:
# Always restore config on error
self.restore_config(config_file, backup_path)
if RICH_AVAILABLE:
console.print(f"[red]❌ Preview failed: {e}[/red]")
else:
print(f"❌ Preview failed: {e}")
return False
def clean(self, deep=False, dry_run=False):
"""Clean up configurations and build artifacts"""
clean_type = "Deep Cleanup" if deep else "Fast Build Cleanup"
if RICH_AVAILABLE:
console.print(f"[yellow]🧹 {clean_type}[/yellow]")
if deep:
console.print("[dim]💡 Removing all build artifacts, caches, and temp files[/dim]")
else:
console.print("[dim]💡 Restoring master configs and basic cleanup[/dim]")
else:
print(f"🧹 {clean_type}")
if deep:
print("💡 Removing all build artifacts, caches, and temp files")
else:
print("💡 Restoring master configs and basic cleanup")
restored = False
for config_name in ["_quarto-html.yml", "_quarto-pdf.yml"]:
config_file = self.book_dir / config_name
backup_file = config_file.with_suffix('.yml.fast-build-backup')
if backup_file.exists():
if RICH_AVAILABLE:
console.print(f"[yellow] 🔄 Restoring {config_name} from backup...[/yellow]")
else:
print(f" 🔄 Restoring {config_name} from backup...")
shutil.move(backup_file, config_file)
restored = True
if RICH_AVAILABLE:
console.print(f"[green] ✅ {config_name} restored[/green]")
else:
print(f"{config_name} restored")
elif config_file.exists():
# Check if there are commented lines to uncomment
with open(config_file, 'r') as f:
content = f.read()
if "FAST_BUILD_COMMENTED" in content:
if RICH_AVAILABLE:
console.print(f"[yellow] 🔄 Uncommenting {config_name}...[/yellow]")
else:
print(f" 🔄 Uncommenting {config_name}...")
# Remove comment markers
content = re.sub(r'^# FAST_BUILD_COMMENTED: ', '', content, flags=re.MULTILINE)
with open(config_file, 'w') as f:
f.write(content)
restored = True
if RICH_AVAILABLE:
console.print(f"[green] ✅ {config_name} uncommented[/green]")
else:
print(f"{config_name} uncommented")
else:
if RICH_AVAILABLE:
console.print(f"[green] ✅ {config_name} already clean[/green]")
else:
print(f"{config_name} already clean")
# Show current symlink status
if self.active_config.is_symlink():
current_target = self.active_config.readlink()
if RICH_AVAILABLE:
console.print(f"[blue] 🔗 Current symlink: _quarto.yml → {current_target}[/blue]")
else:
print(f" 🔗 Current symlink: _quarto.yml → {current_target}")
if RICH_AVAILABLE:
console.print("[green] ✅ All configs restored to clean state[/green]")
else:
print(" ✅ All configs restored to clean state")
# Clean build artifacts and cache files
self.clean_build_artifacts(deep, dry_run)
return True
def clean_build_artifacts(self, deep=False, dry_run=False):
"""Clean build artifacts, cache files, and temp files"""
removed_count = 0
# Build artifacts to clean
artifacts = [
(self.build_dir, "Build directory (all formats)"),
(self.root_dir / "_book", "Legacy build output"),
(self.root_dir / "index_files", "Root index files"),
(self.book_dir / "index_files", "Book index files"),
(self.book_dir / "site_libs", "Site libraries"),
(self.book_dir / ".quarto", "Quarto cache (book)"),
(self.root_dir / ".quarto", "Quarto cache (root)")
]
# Add deep clean items
if deep:
artifacts.extend([
(self.root_dir / ".venv", "Python virtual environment"),
(self.root_dir / "venv", "Python virtual environment"),
(self.root_dir / "node_modules", "Node.js modules"),
(self.root_dir / ".pytest_cache", "Pytest cache")
])
# Remove directories and files
for item_path, description in artifacts:
if item_path.exists():
if dry_run:
if RICH_AVAILABLE:
console.print(f"[dim] 📁 Would remove: {item_path.name} ({description})[/dim]")
else:
print(f" 📁 Would remove: {item_path.name} ({description})")
else:
if RICH_AVAILABLE:
console.print(f"[dim] 🗑️ Removing: {item_path.name} ({description})[/dim]")
else:
print(f" 🗑️ Removing: {item_path.name} ({description})")
try:
if item_path.is_dir():
shutil.rmtree(item_path)
else:
item_path.unlink()
removed_count += 1
except Exception as e:
if RICH_AVAILABLE:
console.print(f"[red] ⚠️ Failed to remove {item_path.name}: {e}[/red]")
else:
print(f" ⚠️ Failed to remove {item_path.name}: {e}")
# Clean file patterns
patterns = [
("**/*.html", "HTML build artifacts", "book/contents"),
("**/diagram-*.pdf", "Generated TikZ diagrams", "book/contents"),
("**/__pycache__", "Python cache directories", ""),
("**/*.pyc", "Python compiled files", ""),
("**/*.log", "Log files", "tools"),
("**/.DS_Store", "macOS metadata files", ""),
("**/*~", "Editor backup files", ""),
("**/*.swp", "Vim swap files", "")
]
if deep:
patterns.extend([
("**/figure-*", "Generated figure directories", ""),
("**/diagram-*.pdf", "Generated TikZ diagrams", "book/contents"),
("**/*.aux", "LaTeX auxiliary files", ""),
("**/*.toc", "Table of contents files", ""),
("**/*.out", "LaTeX outline files", ""),
("**/mediabag", "Pandoc media bags", "")
])
# Remove pattern-based files
for pattern, description, base_dir in patterns:
search_dir = self.root_dir / base_dir if base_dir else self.root_dir
if search_dir.exists():
matches = list(search_dir.glob(pattern))
if matches:
if dry_run:
if RICH_AVAILABLE:
console.print(f"[dim] 📄 Would remove {len(matches)} files: {description}[/dim]")
else:
print(f" 📄 Would remove {len(matches)} files: {description}")
else:
if RICH_AVAILABLE:
console.print(f"[dim] 🗑️ Removing {len(matches)} files: {description}[/dim]")
else:
print(f" 🗑️ Removing {len(matches)} files: {description}")
for match in matches:
try:
if match.is_dir():
shutil.rmtree(match)
else:
match.unlink()
removed_count += 1
except Exception as e:
if RICH_AVAILABLE:
console.print(f"[red] ⚠️ Failed to remove {match.name}: {e}[/red]")
else:
print(f" ⚠️ Failed to remove {match.name}: {e}")
# Summary
if dry_run:
if RICH_AVAILABLE:
console.print("[blue] 🔍 Dry run completed - no files were actually removed[/blue]")
else:
print(" 🔍 Dry run completed - no files were actually removed")
else:
if RICH_AVAILABLE:
console.print(f"[green] ✅ Cleaned {removed_count} items successfully[/green]")
else:
print(f" ✅ Cleaned {removed_count} items successfully")
def check_artifacts(self):
"""Check for build artifacts that shouldn't be committed"""
if RICH_AVAILABLE:
console.print("[yellow]🔍 Checking for build artifacts...[/yellow]")
else:
print("🔍 Checking for build artifacts...")
artifacts_found = []
# Check for common build artifacts
artifact_patterns = [
(self.build_dir, "Build directory"),
(self.root_dir / "_book", "Legacy build output"),
(self.book_dir / "index_files", "Index files"),
(self.book_dir / "site_libs", "Site libraries"),
(self.book_dir / ".quarto", "Quarto cache")
]
for path, description in artifact_patterns:
if path.exists():
artifacts_found.append((str(path), description))
# Check for specific file patterns (generated build artifacts)
file_patterns = [
("**/*.html", "HTML files", "book/contents"),
("**/diagram-*.pdf", "Generated diagram PDFs", "book/contents"), # TikZ generated diagrams
("**/*_files/figure-*.pdf", "Generated figure PDFs", "book/contents"), # Quarto figure outputs
("**/*.aux", "LaTeX auxiliary files", ""),
("**/*.log", "Log files", ""),
("**/__pycache__", "Python cache", "")
]
for pattern, description, base_dir in file_patterns:
search_dir = self.root_dir / base_dir if base_dir else self.root_dir
if search_dir.exists():
matches = list(search_dir.glob(pattern))
if matches:
artifacts_found.extend([(str(m), description) for m in matches[:5]]) # Limit to first 5
if artifacts_found:
if RICH_AVAILABLE:
console.print("[yellow]⚠️ Build artifacts detected:[/yellow]")
for artifact, desc in artifacts_found:
console.print(f"[dim] 📄 {artifact} ({desc})[/dim]")
console.print("[blue]💡 Run './binder clean' to remove these artifacts[/blue]")
else:
print("⚠️ Build artifacts detected:")
for artifact, desc in artifacts_found:
print(f" 📄 {artifact} ({desc})")
print("💡 Run './binder clean' to remove these artifacts")
return False
else:
if RICH_AVAILABLE:
console.print("[green]✅ No build artifacts detected[/green]")
else:
print("✅ No build artifacts detected")
return True
def switch(self, format_type):
"""Switch configuration format"""
if format_type not in ["html", "pdf"]:
if RICH_AVAILABLE:
console.print("[red]❌ Format must be 'html' or 'pdf'[/red]")
else:
print("❌ Format must be 'html' or 'pdf'")
return False
if RICH_AVAILABLE:
console.print(f"[blue]🔗 Switching to {format_type} config...[/blue]")
else:
print(f"🔗 Switching to {format_type} config...")
# Clean up first
self.clean()
# Setup new symlink
config_name = self.setup_symlink(format_type)
if RICH_AVAILABLE:
console.print(f"[green] ✅ _quarto.yml → {config_name}[/green]")
else:
print(f" ✅ _quarto.yml → {config_name}")
return True
def build_full(self, format_type="html"):
"""Build full book in specified format"""
if RICH_AVAILABLE:
console.print(f"[green]🔨 Building full {format_type.upper()} book...[/green]")
else:
print(f"🔨 Building full {format_type.upper()} book...")
# Create build directory
build_subdir = format_type
(self.build_dir / build_subdir).mkdir(parents=True, exist_ok=True)
# Setup config
config_name = self.setup_symlink(format_type)
render_cmd = ["quarto", "render", "--to", "html" if format_type == "html" else "titlepage-pdf"]
if RICH_AVAILABLE:
console.print(f"[blue] 🔗 Using {config_name}[/blue]")
else:
print(f" 🔗 Using {config_name}")
success = self.run_command(
render_cmd,
cwd=self.book_dir,
description=f"Building full {format_type.upper()} book"
)
if success:
if RICH_AVAILABLE:
console.print(f"[green] ✅ {format_type.upper()} build complete: build/{build_subdir}/[/green]")
else:
print(f"{format_type.upper()} build complete: build/{build_subdir}/")
return success
def preview_full(self, format_type="html"):
"""Start full preview server"""
if RICH_AVAILABLE:
console.print(f"[blue]🌐 Starting full {format_type.upper()} preview server...[/blue]")
else:
print(f"🌐 Starting full {format_type.upper()} preview server...")
# Setup config
config_name = self.setup_symlink(format_type)
if RICH_AVAILABLE:
console.print(f"[blue] 🔗 Using {config_name}[/blue]")
console.print("[dim] 🛑 Press Ctrl+C to stop the server[/dim]")
else:
print(f" 🔗 Using {config_name}")
print(" 🛑 Press Ctrl+C to stop the server")
try:
subprocess.run(["quarto", "preview"], cwd=self.book_dir)
return True
except KeyboardInterrupt:
if RICH_AVAILABLE:
console.print("\n[yellow]🛑 Preview server stopped[/yellow]")
else:
print("\n🛑 Preview server stopped")
return True
except Exception as e:
if RICH_AVAILABLE:
console.print(f"[red]❌ Preview failed: {e}[/red]")
else:
print(f"❌ Preview failed: {e}")
return False
def show_help(self):
"""Display beautiful help screen"""
if not RICH_AVAILABLE:
print("""
📚 Book Binder - Self-Contained MLSysBook CLI
Fast Chapter Commands:
build <chapter> [format] Build single chapter (html/pdf)
preview <chapter> Build and preview single chapter
Full Book Commands:
build-full [format] Build complete book (html/pdf)
preview-full [format] Preview complete book (html/pdf)
Management:
clean [deep] [dry] Clean up configurations and build artifacts
check Check for build artifacts
switch <format> Switch config (html/pdf)
status Show current status
list List available chapters
help Show this help
Examples:
./binder build intro # Build introduction chapter (HTML)
./binder build intro pdf # Build introduction chapter (PDF)
./binder preview ops # Preview ops chapter
./binder build-full pdf # Build complete PDF book
./binder clean deep # Deep clean (all caches, venv, etc.)
./binder clean dry # Dry run (show what would be cleaned)
./binder switch pdf # Switch to PDF config
""")
return
# Create beautiful help panels
fast_table = Table(show_header=True, header_style="bold green", box=None)
fast_table.add_column("Command", style="green", width=22)
fast_table.add_column("Description", style="white")
fast_table.add_column("Example", style="dim")
fast_table.add_row("build <chapter> [format]", "Build single chapter", "./binder build intro pdf")
fast_table.add_row("preview <chapter>", "Build and preview chapter", "./binder preview ops")
full_table = Table(show_header=True, header_style="bold blue", box=None)
full_table.add_column("Command", style="blue", width=22)
full_table.add_column("Description", style="white")
full_table.add_column("Example", style="dim")
full_table.add_row("build-full [format]", "Build complete book", "./binder build-full pdf")
full_table.add_row("preview-full [format]", "Preview complete book", "./binder preview-full")
mgmt_table = Table(show_header=True, header_style="bold yellow", box=None)
mgmt_table.add_column("Command", style="yellow", width=22)
mgmt_table.add_column("Description", style="white")
mgmt_table.add_column("Example", style="dim")
mgmt_table.add_row("clean [deep] [dry]", "Clean configs & artifacts", "./binder clean deep")
mgmt_table.add_row("check", "Check for build artifacts", "./binder check")
mgmt_table.add_row("switch <format>", "Switch config", "./binder switch pdf")
mgmt_table.add_row("status", "Show current status", "./binder status")
mgmt_table.add_row("list", "List chapters", "./binder list")
mgmt_table.add_row("help", "Show this help", "./binder help")
shortcuts_table = Table(show_header=True, header_style="bold cyan", box=None)
shortcuts_table.add_column("Shortcut", style="cyan", width=10)
shortcuts_table.add_column("Full Command", style="white")
shortcuts_table.add_row("b", "build")
shortcuts_table.add_row("p", "preview")
shortcuts_table.add_row("bf", "build-full")
shortcuts_table.add_row("pf", "preview-full")
shortcuts_table.add_row("c", "clean")
shortcuts_table.add_row("cd", "clean-deep")
shortcuts_table.add_row("ch", "check")
shortcuts_table.add_row("s", "switch")
shortcuts_table.add_row("st", "status")
shortcuts_table.add_row("l", "list")
shortcuts_table.add_row("h", "help")
# Display everything
self.show_banner()
console.print(Panel(fast_table, title="⚡ Fast Chapter Commands", border_style="green"))
console.print(Panel(full_table, title="📚 Full Book Commands", border_style="blue"))
console.print(Panel(mgmt_table, title="🔧 Management", border_style="yellow"))
console.print(Panel(shortcuts_table, title="🚀 Shortcuts", border_style="cyan"))
examples = Text()
examples.append("🎯 Power User Examples:\n", style="bold magenta")
examples.append(" ./binder b intro pdf ", style="cyan")
examples.append("# Quick chapter PDF\n", style="dim")
examples.append(" ./binder cd ", style="cyan")
examples.append("# Deep clean all artifacts\n", style="dim")
examples.append(" ./binder ch ", style="cyan")
examples.append("# Check for build artifacts\n", style="dim")
examples.append(" ./binder st ", style="cyan")
examples.append("# Quick status\n", style="dim")
console.print(Panel(examples, title="💡 Pro Tips", border_style="magenta"))
def main():
binder = BookBinder()
if len(sys.argv) < 2:
binder.show_help()
return
command = sys.argv[1].lower()
# Handle shortcuts
shortcuts = {
'b': 'build',
'p': 'preview',
'bf': 'build-full',
'pf': 'preview-full',
'c': 'clean',
'cd': 'clean-deep',
'ch': 'check',
's': 'switch',
'st': 'status',
'l': 'list',
'h': 'help'
}
command = shortcuts.get(command, command)
try:
if command == "build":
if len(sys.argv) < 3:
if RICH_AVAILABLE:
console.print("[red]❌ Usage: ./binder build <chapter> [format][/red]")
else:
print("❌ Usage: ./binder build <chapter> [format]")
return
chapter = sys.argv[2]
format_type = sys.argv[3] if len(sys.argv) > 3 else "html"
binder.build(chapter, format_type)
elif command == "preview":
if len(sys.argv) < 3:
if RICH_AVAILABLE:
console.print("[red]❌ Usage: ./binder preview <chapter>[/red]")
else:
print("❌ Usage: ./binder preview <chapter>")
return
chapter = sys.argv[2]
binder.preview(chapter)
elif command == "build-full":
format_type = sys.argv[2] if len(sys.argv) > 2 else "html"
if format_type not in ["html", "pdf"]:
if RICH_AVAILABLE:
console.print("[red]❌ Format must be 'html' or 'pdf'[/red]")
else:
print("❌ Format must be 'html' or 'pdf'")
return
binder.build_full(format_type)
elif command == "preview-full":
format_type = sys.argv[2] if len(sys.argv) > 2 else "html"
if format_type not in ["html", "pdf"]:
if RICH_AVAILABLE:
console.print("[red]❌ Format must be 'html' or 'pdf'[/red]")
else:
print("❌ Format must be 'html' or 'pdf'")
return
binder.preview_full(format_type)
elif command == "clean":
deep = "--deep" in sys.argv or "deep" in sys.argv
dry_run = "--dry-run" in sys.argv or "dry" in sys.argv
binder.clean(deep=deep, dry_run=dry_run)
elif command == "clean-deep":
binder.clean(deep=True, dry_run=False)
elif command == "check":
if not binder.check_artifacts():
sys.exit(1) # Exit with error code when artifacts found
elif command == "switch":
if len(sys.argv) < 3:
if RICH_AVAILABLE:
console.print("[red]❌ Usage: ./binder switch <html|pdf>[/red]")
else:
print("❌ Usage: ./binder switch <html|pdf>")
return
format_type = sys.argv[2]
binder.switch(format_type)
elif command == "status":
binder.show_status()
elif command == "list":
binder.show_chapters()
elif command == "help":
binder.show_help()
else:
if RICH_AVAILABLE:
console.print(f"[red]❌ Unknown command: {command}[/red]")
console.print("[yellow]💡 Run './binder help' for usage[/yellow]")
else:
print(f"❌ Unknown command: {command}")
print("💡 Run './binder help' for usage")
except KeyboardInterrupt:
if RICH_AVAILABLE:
console.print("\n[yellow]👋 Goodbye![/yellow]")
else:
print("\n👋 Goodbye!")
except Exception as e:
if RICH_AVAILABLE:
console.print(f"[red]❌ Error: {e}[/red]")
else:
print(f"❌ Error: {e}")
if __name__ == "__main__":
main()