mirror of
https://github.com/harvard-edge/cs249r_book.git
synced 2026-04-29 00:59:07 -05:00
✅ 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
1097 lines
44 KiB
Python
Executable File
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() |