mirror of
https://github.com/harvard-edge/cs249r_book.git
synced 2026-03-11 17:49:25 -05:00
2280 lines
96 KiB
Python
Executable File
2280 lines
96 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
|
|
|
|
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
|
|
|
|
console = Console()
|
|
|
|
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 / "config" / "_quarto-html.yml"
|
|
self.pdf_config = self.book_dir / "config" / "_quarto-pdf.yml"
|
|
self.active_config = self.book_dir / "_quarto.yml"
|
|
|
|
def show_banner(self):
|
|
"""Display beautiful banner"""
|
|
banner = Panel.fit(
|
|
"[bold blue]📚 Book Binder[/bold blue]\n"
|
|
"[dim]⚡ I compile ML systems knowledge[/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 = f"Symlink: {target}"
|
|
else:
|
|
active_config = "NOT a symlink"
|
|
|
|
# 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_symlink_status(self):
|
|
"""Show simple symlink status"""
|
|
if self.active_config.is_symlink():
|
|
target = self.active_config.readlink()
|
|
console.print(f"[bold cyan]→ _quarto.yml: {target}[/bold cyan]")
|
|
console.print() # Add blank line for spacing
|
|
else:
|
|
console.print(f"[bold yellow]⚠️ _quarto.yml is NOT a symlink[/bold yellow]")
|
|
console.print() # Add blank line for spacing
|
|
|
|
def show_status(self):
|
|
"""Display beautiful status information"""
|
|
status = self.get_status()
|
|
|
|
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()
|
|
|
|
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"""
|
|
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, capture_for_parsing=False):
|
|
"""Run command with progress indicator and optional output capture"""
|
|
if isinstance(cmd, str):
|
|
cmd = cmd.split()
|
|
|
|
desc = description or f"Running: {' '.join(cmd)}"
|
|
|
|
try:
|
|
with self.progress_context(desc):
|
|
if capture_for_parsing:
|
|
# Capture output for parsing while still showing it
|
|
result = subprocess.run(
|
|
cmd,
|
|
cwd=cwd or self.root_dir,
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
# Print the output so user still sees it
|
|
if result.stdout:
|
|
print(result.stdout, end='')
|
|
if result.stderr:
|
|
print(result.stderr, end='')
|
|
|
|
# Return both success status and captured output
|
|
return result.returncode == 0, result.stdout + result.stderr
|
|
else:
|
|
# Original behavior - stream output directly
|
|
result = subprocess.run(
|
|
cmd,
|
|
cwd=cwd or self.root_dir,
|
|
capture_output=False,
|
|
text=True
|
|
)
|
|
return result.returncode == 0
|
|
except Exception as e:
|
|
console.print(f"[red]Error: {e}[/red]")
|
|
return False
|
|
|
|
def open_output_file(self, output_text):
|
|
"""Parse quarto output for 'Output created:' and open the file"""
|
|
lines = output_text.split('\n')
|
|
for line in lines:
|
|
if 'Output created:' in line:
|
|
# Extract the file path after "Output created: "
|
|
file_path = line.split('Output created: ', 1)[1].strip()
|
|
|
|
# Fix quarto's incorrect relative path calculation
|
|
# Quarto outputs ../../../../build/... but it should be ../build/...
|
|
if file_path.startswith('../../../../build/'):
|
|
file_path = file_path.replace('../../../../build/', '../build/')
|
|
|
|
# Convert relative path to absolute - quarto runs from book dir
|
|
if not os.path.isabs(file_path):
|
|
# Path is relative to book directory, resolve it properly
|
|
full_path = (self.book_dir / file_path).resolve()
|
|
else:
|
|
full_path = Path(file_path)
|
|
|
|
if full_path.exists():
|
|
console.print(f"[green]🌐 Opening in browser: {full_path.name}[/green]")
|
|
|
|
try:
|
|
# Use 'open' command on macOS
|
|
subprocess.run(['open', str(full_path)], check=True)
|
|
return True
|
|
except subprocess.CalledProcessError:
|
|
console.print(f"[yellow]⚠️ Could not open file automatically: {full_path}[/yellow]")
|
|
return False
|
|
else:
|
|
console.print(f"[yellow]⚠️ Output file not found: {full_path}[/yellow]")
|
|
console.print(f"[dim] Tried: {full_path}[/dim]")
|
|
return False
|
|
|
|
# No "Output created:" line found
|
|
return False
|
|
|
|
def open_browser_if_needed(self, file_path):
|
|
"""Open a file in the browser if it exists"""
|
|
if not os.path.isabs(file_path):
|
|
# Path is relative to book directory
|
|
full_path = (self.book_dir / file_path).resolve()
|
|
else:
|
|
full_path = Path(file_path)
|
|
|
|
if full_path.exists():
|
|
console.print(f"[green]🌐 Opening in browser: {full_path.name}[/green]")
|
|
|
|
try:
|
|
# Use 'open' command on macOS
|
|
subprocess.run(['open', str(full_path)], check=True)
|
|
return True
|
|
except subprocess.CalledProcessError:
|
|
console.print(f"[yellow]⚠️ Could not open file automatically: {full_path}[/yellow]")
|
|
return False
|
|
else:
|
|
console.print(f"[yellow]⚠️ Output file not found: {full_path}[/yellow]")
|
|
return False
|
|
|
|
def set_fast_build_mode(self, config_file, target_path):
|
|
"""Set fast build mode - different approach for HTML vs PDF"""
|
|
backup_path = config_file.with_suffix('.yml.fast-build-backup')
|
|
|
|
# Create backup
|
|
shutil.copy2(config_file, backup_path)
|
|
|
|
# Read current config
|
|
with open(config_file, 'r', encoding='utf-8') as f:
|
|
content = f.read()
|
|
|
|
target_stem = target_path.stem
|
|
target_relative_path = target_path.relative_to(self.book_dir).as_posix()
|
|
|
|
# Different approach for HTML (website) vs PDF (book)
|
|
if 'type: website' in content:
|
|
# HTML: Use project.render configuration
|
|
render_config = f""" render:
|
|
- index.qmd
|
|
- 404.qmd
|
|
- contents/frontmatter/
|
|
- {target_relative_path}
|
|
# Fast build: only render essential files and target chapter
|
|
"""
|
|
|
|
# Insert render config after "navigate: true" in project section
|
|
if 'navigate: true' in content:
|
|
content = content.replace(
|
|
'navigate: true',
|
|
f'navigate: true\n{render_config}'
|
|
)
|
|
else:
|
|
console.print(f"[yellow]⚠️ Could not find project navigation section in {config_file}[/yellow]")
|
|
return False
|
|
|
|
elif 'type: book' in content:
|
|
# PDF: Comment out unwanted chapters in book.chapters list
|
|
lines = content.split('\n')
|
|
new_lines = []
|
|
in_chapters_section = False
|
|
target_found = False
|
|
|
|
for line in lines:
|
|
if line.strip() == 'chapters:':
|
|
in_chapters_section = True
|
|
new_lines.append(line)
|
|
continue
|
|
elif in_chapters_section and line.startswith(' - ') and '.qmd' in line:
|
|
# This is a chapter line
|
|
if target_relative_path in line:
|
|
# Keep the target chapter
|
|
new_lines.append(line)
|
|
target_found = True
|
|
elif any(essential in line for essential in ['index.qmd', 'foreword.qmd', 'about/', 'acknowledgements/']):
|
|
# Keep essential frontmatter
|
|
new_lines.append(line)
|
|
else:
|
|
# Comment out other chapters
|
|
new_lines.append(f" # {line.strip()[2:]} # Commented for fast build")
|
|
elif in_chapters_section and line.strip() and not line.startswith(' '):
|
|
# End of chapters section
|
|
in_chapters_section = False
|
|
new_lines.append(line)
|
|
else:
|
|
new_lines.append(line)
|
|
|
|
if not target_found:
|
|
console.print(f"[yellow]⚠️ Target chapter {target_relative_path} not found in chapters list[/yellow]")
|
|
return False
|
|
|
|
content = '\n'.join(new_lines)
|
|
else:
|
|
console.print(f"[yellow]⚠️ Unknown project type in {config_file}[/yellow]")
|
|
return False
|
|
|
|
# Write modified config
|
|
with open(config_file, 'w', encoding='utf-8') as f:
|
|
f.write(content)
|
|
|
|
console.print(f" 📝 Fast build mode: Only rendering {target_stem} + essential files")
|
|
|
|
return True
|
|
|
|
def set_fast_build_mode_multiple(self, config_file, target_paths):
|
|
"""Set fast build mode for multiple chapters in a single render"""
|
|
backup_path = config_file.with_suffix('.yml.fast-build-backup')
|
|
|
|
# Create backup
|
|
shutil.copy2(config_file, backup_path)
|
|
|
|
# Read current config
|
|
with open(config_file, 'r', encoding='utf-8') as f:
|
|
content = f.read()
|
|
|
|
# Get relative paths for all targets
|
|
target_relative_paths = [path.relative_to(self.book_dir).as_posix() for path in target_paths]
|
|
target_stems = [path.stem for path in target_paths]
|
|
|
|
# Different approach for HTML (website) vs PDF (book)
|
|
if 'type: website' in content:
|
|
# HTML: Use project.render configuration with all target chapters
|
|
render_items = [
|
|
" - index.qmd",
|
|
" - 404.qmd",
|
|
" - contents/frontmatter/"
|
|
]
|
|
|
|
# Add all target chapters
|
|
for target_path in target_relative_paths:
|
|
render_items.append(f" - {target_path}")
|
|
|
|
render_config = f""" render:
|
|
{chr(10).join(render_items)}
|
|
# Fast build: only render essential files and target chapters
|
|
"""
|
|
|
|
# Insert render config after "navigate: true" in project section
|
|
if 'navigate: true' in content:
|
|
content = content.replace(
|
|
'navigate: true',
|
|
f'navigate: true\n{render_config}'
|
|
)
|
|
else:
|
|
console.print(f"[yellow]⚠️ Could not find project navigation section in {config_file}[/yellow]")
|
|
return False
|
|
|
|
elif 'type: book' in content:
|
|
# PDF: Comment out unwanted chapters in book.chapters list
|
|
lines = content.split('\n')
|
|
new_lines = []
|
|
in_chapters_section = False
|
|
targets_found = 0
|
|
|
|
for line in lines:
|
|
if line.strip() == 'chapters:':
|
|
in_chapters_section = True
|
|
new_lines.append(line)
|
|
continue
|
|
elif in_chapters_section and line.startswith(' - ') and '.qmd' in line:
|
|
# This is a chapter line
|
|
if any(target_path in line for target_path in target_relative_paths):
|
|
# Keep target chapters
|
|
new_lines.append(line)
|
|
targets_found += 1
|
|
elif any(essential in line for essential in ['index.qmd', 'foreword.qmd', 'about/', 'acknowledgements/']):
|
|
# Keep essential frontmatter
|
|
new_lines.append(line)
|
|
else:
|
|
# Comment out other chapters
|
|
new_lines.append(f" # {line.strip()[2:]} # Commented for fast build")
|
|
elif in_chapters_section and line.strip() and not line.startswith(' '):
|
|
# End of chapters section
|
|
in_chapters_section = False
|
|
new_lines.append(line)
|
|
else:
|
|
new_lines.append(line)
|
|
|
|
if targets_found != len(target_relative_paths):
|
|
console.print(f"[yellow]⚠️ Only {targets_found}/{len(target_relative_paths)} target chapters found in chapters list[/yellow]")
|
|
return False
|
|
|
|
content = '\n'.join(new_lines)
|
|
else:
|
|
console.print(f"[yellow]⚠️ Unknown project type in {config_file}[/yellow]")
|
|
return False
|
|
|
|
# Write modified config
|
|
with open(config_file, 'w', encoding='utf-8') as f:
|
|
f.write(content)
|
|
|
|
console.print(f" 📝 Fast build mode: Only rendering {', '.join(target_stems)} + essential files")
|
|
|
|
return True
|
|
|
|
def restore_config(self, config_file):
|
|
"""Restore configuration from backup"""
|
|
backup_path = config_file.with_suffix('.yml.fast-build-backup')
|
|
|
|
if backup_path.exists():
|
|
shutil.copy2(backup_path, config_file)
|
|
backup_path.unlink()
|
|
console.print(f" 🔄 Restored from backup: {backup_path.name}")
|
|
else:
|
|
console.print(f"[yellow]⚠️ No backup found: {backup_path}[/yellow]")
|
|
|
|
def ensure_clean_config(self, config_file):
|
|
"""Ensure config is in clean state (remove any render restrictions)"""
|
|
backup_path = config_file.with_suffix('.yml.fast-build-backup')
|
|
|
|
if backup_path.exists():
|
|
# Restore from backup if it exists
|
|
shutil.copy2(backup_path, config_file)
|
|
backup_path.unlink()
|
|
console.print(f" ✅ {config_file.name} restored from backup")
|
|
else:
|
|
# Check if file has render restrictions and remove them
|
|
with open(config_file, 'r', encoding='utf-8') as f:
|
|
content = f.read()
|
|
|
|
# Remove render configuration if present
|
|
lines = content.split('\n')
|
|
cleaned_lines = []
|
|
in_render_section = False
|
|
|
|
for line in lines:
|
|
if line.strip().startswith('render:'):
|
|
in_render_section = True
|
|
continue
|
|
elif in_render_section and (line.startswith(' - ') or line.startswith(' #')):
|
|
continue
|
|
elif in_render_section and line.strip() == '':
|
|
continue
|
|
else:
|
|
in_render_section = False
|
|
cleaned_lines.append(line)
|
|
|
|
cleaned_content = '\n'.join(cleaned_lines)
|
|
|
|
if cleaned_content != content:
|
|
with open(config_file, 'w', encoding='utf-8') as f:
|
|
f.write(cleaned_content)
|
|
console.print(f" ✅ {config_file.name} cleaned (removed render restrictions)")
|
|
else:
|
|
console.print(f" ✅ {config_file.name} already clean")
|
|
|
|
def setup_symlink(self, format_type):
|
|
"""Setup _quarto.yml symlink"""
|
|
if format_type == "html":
|
|
config_file = "config/_quarto-html.yml"
|
|
elif format_type == "pdf":
|
|
config_file = "config/_quarto-pdf.yml"
|
|
|
|
else:
|
|
raise ValueError(f"Unknown format type: {format_type}")
|
|
|
|
# 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, chapters, format_type="html"):
|
|
"""Build one or more chapters with beautiful progress display"""
|
|
# Handle both single chapter and comma-separated chapters
|
|
if isinstance(chapters, str):
|
|
if ',' in chapters:
|
|
chapter_list = [ch.strip() for ch in chapters.split(',')]
|
|
return self.build_multiple(chapter_list, format_type)
|
|
else:
|
|
return self.build_single(chapters, format_type)
|
|
else:
|
|
# Assume it's already a list
|
|
return self.build_multiple(chapters, format_type)
|
|
|
|
def build_multiple(self, chapter_list, format_type="html"):
|
|
"""Build multiple chapters together in a single unified build"""
|
|
console.print(f"[green] 🚀 Building {len(chapter_list)} chapters together[/green] [dim]({format_type})[/dim]")
|
|
console.print(f"[dim] 📋 Chapters: {', '.join(chapter_list)}[/dim]")
|
|
|
|
return self.build_multiple_unified(chapter_list, format_type)
|
|
|
|
def build_multiple_unified(self, chapter_list, format_type="html"):
|
|
"""Build multiple chapters in a single Quarto render command"""
|
|
# Resolve chapter paths
|
|
chapter_files = []
|
|
for chapter in chapter_list:
|
|
chapter_file = self.find_chapter_file(chapter)
|
|
if not chapter_file:
|
|
console.print(f"[red]❌ Chapter not found: {chapter}[/red]")
|
|
return False
|
|
chapter_files.append(chapter_file)
|
|
|
|
# Configure build settings
|
|
if format_type == "html":
|
|
config_file = self.html_config
|
|
format_arg = "html"
|
|
build_subdir = "html"
|
|
elif format_type == "pdf":
|
|
config_file = self.pdf_config
|
|
format_arg = "titlepage-pdf"
|
|
build_subdir = "pdf"
|
|
|
|
else:
|
|
raise ValueError(f"Unknown format type: {format_type}")
|
|
|
|
# Create build directory
|
|
(self.build_dir / build_subdir).mkdir(parents=True, exist_ok=True)
|
|
|
|
# Setup correct configuration symlink for the format
|
|
self.setup_symlink(format_type)
|
|
|
|
|
|
|
|
try:
|
|
# Ensure config is clean (remove any render restrictions)
|
|
self.ensure_clean_config(config_file)
|
|
|
|
console.print(f"[green] 🚀 Building {len(chapter_list)} chapters together[/green] [dim]({format_type})[/dim]")
|
|
|
|
# Set up unified fast build mode for all chapters
|
|
success = self.set_fast_build_mode_multiple(config_file, chapter_files)
|
|
if not success:
|
|
return False
|
|
|
|
# Set up signal handlers for graceful shutdown
|
|
def signal_handler(signum, frame):
|
|
self.restore_config(config_file)
|
|
sys.exit(1)
|
|
|
|
signal.signal(signal.SIGINT, signal_handler)
|
|
signal.signal(signal.SIGTERM, signal_handler)
|
|
|
|
# Build with unified project.render configuration
|
|
console.print("[yellow] 🔨 Building all chapters in single render...[/yellow]")
|
|
|
|
# Render project with all target chapters
|
|
render_cmd = ["quarto", "render", "--to", format_arg]
|
|
|
|
# Show the raw command being executed
|
|
cmd_str = " ".join(render_cmd)
|
|
console.print(f"[blue] 💻 Command: {cmd_str}[/blue]")
|
|
|
|
# Capture output to find the created file and open it
|
|
result = self.run_command(
|
|
render_cmd,
|
|
cwd=self.book_dir,
|
|
description=f" Building {len(chapter_list)} chapters ({format_type}) - unified build",
|
|
capture_for_parsing=True
|
|
)
|
|
|
|
if result:
|
|
console.print(f" ✅ Unified build complete: build/{build_subdir}/")
|
|
|
|
# Open browser for HTML builds
|
|
if format_type == "html" and result:
|
|
self.open_browser_if_needed(f"../build/{build_subdir}/index.html")
|
|
|
|
return True
|
|
else:
|
|
console.print(f"[red]❌ Build failed[/red]")
|
|
return False
|
|
|
|
finally:
|
|
# Always restore config, even if build fails
|
|
console.print(" 🛡️ Ensuring pristine config restoration...")
|
|
|
|
self.restore_config(config_file)
|
|
|
|
def build_single(self, chapter, format_type="html", open_browser=True):
|
|
"""Build a single chapter with beautiful progress display"""
|
|
# Find the actual chapter file
|
|
chapter_file = self.find_chapter_file(chapter)
|
|
if not chapter_file:
|
|
console.print(f"[red]❌ No chapter found matching '{chapter}'[/red]")
|
|
console.print("[yellow]💡 Available chapters:[/yellow]")
|
|
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", "")
|
|
|
|
console.print(f"[green] 🚀 Building[/green] [bold]{chapter_name}[/bold] [dim]({format_type})[/dim]")
|
|
console.print(f"[dim] ✅ Found: {chapter_file}[/dim]")
|
|
|
|
# Setup configuration
|
|
if format_type == "html":
|
|
config_file = self.html_config
|
|
format_arg = "html"
|
|
build_subdir = "html"
|
|
elif format_type == "pdf":
|
|
config_file = self.pdf_config
|
|
format_arg = "titlepage-pdf"
|
|
build_subdir = "pdf"
|
|
|
|
else:
|
|
raise ValueError(f"Unknown format type: {format_type}")
|
|
|
|
# Create build directory
|
|
(self.build_dir / build_subdir).mkdir(parents=True, exist_ok=True)
|
|
|
|
# Setup correct configuration symlink for the format
|
|
self.setup_symlink(format_type)
|
|
|
|
|
|
|
|
try:
|
|
# Ensure config is clean (remove any render restrictions)
|
|
self.ensure_clean_config(config_file)
|
|
|
|
# Set fast build mode for the target chapter
|
|
self.set_fast_build_mode(config_file, chapter_file)
|
|
|
|
# Setup signal handler to restore config on Ctrl+C
|
|
def signal_handler(signum, frame):
|
|
console.print("\n[yellow]🛡️ Ctrl+C detected - restoring config and exiting...[/yellow]")
|
|
self.restore_config(config_file)
|
|
console.print("[green]✅ Config restored to pristine state[/green]")
|
|
sys.exit(0)
|
|
|
|
signal.signal(signal.SIGINT, signal_handler)
|
|
signal.signal(signal.SIGTERM, signal_handler)
|
|
|
|
# Build with project.render configuration (fast build)
|
|
console.print("[yellow] 🔨 Building with fast build configuration...[/yellow]")
|
|
|
|
# Render project with limited file scope
|
|
render_cmd = ["quarto", "render", "--to", format_arg]
|
|
|
|
# Show the raw command being executed
|
|
cmd_str = " ".join(render_cmd)
|
|
console.print(f"[blue] 💻 Command: {cmd_str}[/blue]")
|
|
|
|
# Capture output to find the created file and open it
|
|
result = self.run_command(
|
|
render_cmd,
|
|
cwd=self.book_dir,
|
|
description=f"Building {chapter_name} ({format_type}) - fast build",
|
|
capture_for_parsing=True
|
|
)
|
|
|
|
# Handle the returned tuple (success, output)
|
|
if isinstance(result, tuple):
|
|
success, output = result
|
|
else:
|
|
success, output = result, ""
|
|
|
|
if success:
|
|
console.print(f"[green] ✅ Fast build complete: build/{build_subdir}/[/green]")
|
|
|
|
# Automatically open the output file if HTML and requested
|
|
if format_type == "html" and output and open_browser:
|
|
# Check user preference for auto-open
|
|
preferences = self.get_user_preferences()
|
|
auto_open = preferences.get('auto_open', True)
|
|
|
|
if auto_open:
|
|
self.open_output_file(output)
|
|
else:
|
|
console.print(f"[blue]📄 Output ready: {output}[/blue]")
|
|
console.print("[dim]💡 Set auto-open preference with './binder setup'[/dim]")
|
|
else:
|
|
console.print("[red] ❌ Build failed[/red]")
|
|
|
|
return success
|
|
|
|
finally:
|
|
# Always restore config (bulletproof cleanup)
|
|
console.print("[yellow] 🛡️ Ensuring pristine config restoration...[/yellow]")
|
|
|
|
self.restore_config(config_file)
|
|
|
|
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:
|
|
console.print(f"[red]❌ No chapter found matching '{chapter}'[/red]")
|
|
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", "")
|
|
|
|
console.print(f"[blue]🌐 Starting preview for[/blue] [bold]{chapter_name}[/bold]")
|
|
|
|
# Setup for HTML preview
|
|
config_file = self.html_config
|
|
|
|
try:
|
|
# Ensure config is clean (remove any render restrictions)
|
|
self.ensure_clean_config(config_file)
|
|
|
|
# Setup signal handler to restore config on exit
|
|
def signal_handler(signum, frame):
|
|
console.print("\n[yellow]🛡️ Ctrl+C detected - restoring config and exiting...[/yellow]")
|
|
self.restore_config(config_file)
|
|
console.print("[green]✅ Config restored to pristine state[/green]")
|
|
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]
|
|
|
|
# Show the raw command being executed
|
|
cmd_str = " ".join(preview_cmd)
|
|
console.print(f"[blue] 💻 Command: {cmd_str}[/blue]")
|
|
|
|
subprocess.run(
|
|
preview_cmd,
|
|
cwd=self.book_dir
|
|
)
|
|
finally:
|
|
# Restore config
|
|
self.restore_config(config_file)
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
# Always restore config on error
|
|
self.restore_config(config_file)
|
|
console.print(f"[red]❌ Preview failed: {e}[/red]")
|
|
return False
|
|
|
|
def clean(self):
|
|
"""Clean build artifacts and restore configs"""
|
|
console.print("[bold blue]🧹 Fast Build Cleanup[/bold blue]")
|
|
console.print("[dim]💡 Restoring master configs and basic cleanup[/dim]")
|
|
|
|
# Restore HTML config
|
|
html_config = self.book_dir / "config" / "_quarto-html.yml"
|
|
self.ensure_clean_config(html_config)
|
|
|
|
# Restore PDF config
|
|
pdf_config = self.book_dir / "config" / "_quarto-pdf.yml"
|
|
self.ensure_clean_config(pdf_config)
|
|
|
|
|
|
|
|
# Show current symlink status
|
|
symlink_path = self.book_dir / "_quarto.yml"
|
|
if symlink_path.exists() and symlink_path.is_symlink():
|
|
target = symlink_path.readlink()
|
|
console.print(f"[dim] 🔗 Current symlink: _quarto.yml → {target}[/dim]")
|
|
|
|
console.print("[green] ✅ All configs restored to clean state[/green]")
|
|
|
|
# Clean build artifacts
|
|
artifacts_to_clean = [
|
|
(self.root_dir / "build", "Build directory (all formats)"),
|
|
(self.book_dir / "index_files", "Book index files"),
|
|
(self.book_dir / ".quarto", "Quarto cache (book)")
|
|
]
|
|
|
|
# Clean Quarto-generated figure directories
|
|
contents_core = self.book_dir / "contents" / "core"
|
|
if contents_core.exists():
|
|
for chapter_dir in contents_core.glob("*/"):
|
|
if chapter_dir.is_dir():
|
|
# Look for *_files directories containing figure-html
|
|
for files_dir in chapter_dir.glob("*_files"):
|
|
if files_dir.is_dir():
|
|
figure_html_dir = files_dir / "figure-html"
|
|
if figure_html_dir.exists():
|
|
artifacts_to_clean.append((figure_html_dir, f"Quarto figure artifacts ({chapter_dir.name})"))
|
|
|
|
# Also check for any standalone figure-html directories
|
|
figure_html_direct = chapter_dir / "figure-html"
|
|
if figure_html_direct.exists():
|
|
artifacts_to_clean.append((figure_html_direct, f"Quarto figure artifacts ({chapter_dir.name})"))
|
|
|
|
cleaned_count = 0
|
|
for artifact_path, description in artifacts_to_clean:
|
|
if artifact_path.exists():
|
|
if artifact_path.is_dir():
|
|
shutil.rmtree(artifact_path)
|
|
else:
|
|
artifact_path.unlink()
|
|
|
|
console.print(f"[yellow] 🗑️ Removing: {artifact_path.name} ({description})[/yellow]")
|
|
cleaned_count += 1
|
|
|
|
if cleaned_count > 0:
|
|
console.print(f"[green] ✅ Cleaned {cleaned_count} items successfully[/green]")
|
|
else:
|
|
console.print("[green] ✅ No artifacts to clean[/green]")
|
|
|
|
def check_artifacts(self):
|
|
"""Check for build artifacts that shouldn't be committed"""
|
|
console.print("[blue]🔍 Checking for build artifacts...[/blue]")
|
|
|
|
# Check for artifacts that shouldn't be committed
|
|
artifacts_found = []
|
|
|
|
potential_artifacts = [
|
|
(self.root_dir / "build", "Build directory"),
|
|
(self.book_dir / "index_files", "Book index files"),
|
|
(self.book_dir / ".quarto", "Quarto cache"),
|
|
(self.book_dir / "config" / "_quarto-html.yml.fast-build-backup", "HTML config backup"),
|
|
(self.book_dir / "config" / "_quarto-pdf.yml.fast-build-backup", "PDF config backup"),
|
|
|
|
]
|
|
|
|
for path, description in potential_artifacts:
|
|
if path.exists():
|
|
artifacts_found.append((path, description))
|
|
|
|
if artifacts_found:
|
|
console.print("[yellow]⚠️ Build artifacts detected:[/yellow]")
|
|
for path, description in artifacts_found:
|
|
console.print(f"[yellow] 📄 {path} ({description})[/yellow]")
|
|
console.print("[blue]💡 Run './binder clean' to remove these artifacts[/blue]")
|
|
return False
|
|
else:
|
|
console.print("[green]✅ No build artifacts detected.[/green]")
|
|
return True
|
|
|
|
def switch(self, format_type):
|
|
"""Switch configuration format"""
|
|
if format_type not in ["html", "pdf"]:
|
|
console.print("[red]❌ Format must be 'html' or 'pdf'[/red]")
|
|
return False
|
|
|
|
console.print(f"[blue]🔗 Switching to {format_type} config...[/blue]")
|
|
|
|
# Clean up first
|
|
self.clean()
|
|
|
|
# Setup new symlink
|
|
config_name = self.setup_symlink(format_type)
|
|
|
|
console.print(f"[green] ✅ _quarto.yml → {config_name}[/green]")
|
|
|
|
return True
|
|
|
|
def build_full(self, format_type="html"):
|
|
"""Build full book in specified format"""
|
|
console.print(f"[green] 🔨 Building full {format_type.upper()} book...[/green]")
|
|
|
|
# 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)
|
|
|
|
|
|
|
|
if format_type == "html":
|
|
render_to = "html"
|
|
elif format_type == "pdf":
|
|
render_to = "titlepage-pdf"
|
|
|
|
else:
|
|
raise ValueError(f"Unknown format type: {format_type}")
|
|
|
|
render_cmd = ["quarto", "render", "--to", render_to]
|
|
|
|
# Show the raw command being executed
|
|
cmd_str = " ".join(render_cmd)
|
|
console.print(f"[blue] 💻 Command: {cmd_str}[/blue]")
|
|
|
|
success = self.run_command(
|
|
render_cmd,
|
|
cwd=self.book_dir,
|
|
description=f"Building full {format_type.upper()} book"
|
|
)
|
|
|
|
if success:
|
|
console.print(f"[green] ✅ {format_type.upper()} build complete: build/{build_subdir}/[/green]")
|
|
|
|
return success
|
|
|
|
def preview_full(self, format_type="html"):
|
|
"""Start full preview server"""
|
|
console.print(f"[blue]🌐 Starting full {format_type.upper()} preview server...[/blue]")
|
|
|
|
# Setup config
|
|
config_name = self.setup_symlink(format_type)
|
|
|
|
console.print(f"[blue] 🔗 Using {config_name}[/blue]")
|
|
console.print("[dim] 🛑 Press Ctrl+C to stop the server[/dim]")
|
|
|
|
try:
|
|
preview_cmd = ["quarto", "preview"]
|
|
|
|
# Show the raw command being executed
|
|
cmd_str = " ".join(preview_cmd)
|
|
console.print(f"[blue] 💻 Command: {cmd_str}[/blue]")
|
|
|
|
subprocess.run(preview_cmd + ["book/"], cwd=self.root_dir)
|
|
return True
|
|
except KeyboardInterrupt:
|
|
console.print("\n[yellow]🛑 Preview server stopped[/yellow]")
|
|
return True
|
|
except Exception as e:
|
|
console.print(f"[red]❌ Preview failed: {e}[/red]")
|
|
return False
|
|
|
|
def publish(self):
|
|
"""Publish the book (build + deploy) - Step by step process"""
|
|
|
|
console.print("[bold blue]🚀 MLSysBook Publishing Wizard[/bold blue]")
|
|
console.print("[dim]I'll guide you through publishing step by step[/dim]\n")
|
|
|
|
# Step 1: Pre-flight validation
|
|
console.print("[blue]📋 Step 1: Pre-flight Validation[/blue]")
|
|
if not self._validate_publish_environment():
|
|
return False
|
|
|
|
# Step 2: Branch handling
|
|
console.print("\n[blue]📋 Step 2: Branch Management[/blue]")
|
|
if not self._handle_branch_setup():
|
|
return False
|
|
|
|
# Step 3: Version and release planning
|
|
console.print("\n[blue]📋 Step 3: Version & Release Planning[/blue]")
|
|
release_info = self._plan_release()
|
|
if not release_info:
|
|
return False
|
|
|
|
# Step 4: Build process
|
|
console.print("\n[blue]📋 Step 4: Building Book[/blue]")
|
|
if not self._build_for_publication():
|
|
return False
|
|
|
|
# Step 5: Release creation
|
|
if release_info['create_release']:
|
|
console.print("\n[blue]📋 Step 5: Creating Release[/blue]")
|
|
if not self._create_release(release_info):
|
|
return False
|
|
|
|
# Step 6: Deployment
|
|
console.print("\n[blue]📋 Step 6: Deploying to Production[/blue]")
|
|
if not self._deploy_to_production():
|
|
return False
|
|
|
|
# Success!
|
|
self._show_publish_success()
|
|
return True
|
|
|
|
def _validate_publish_environment(self):
|
|
"""Step 1: Validate the publishing environment"""
|
|
try:
|
|
# Check git status
|
|
result = subprocess.run(['git', 'branch', '--show-current'],
|
|
capture_output=True, text=True, cwd=self.root_dir)
|
|
current_branch = result.stdout.strip()
|
|
|
|
if current_branch not in ["main", "dev"]:
|
|
console.print(f"[red]❌ Invalid branch: {current_branch}[/red]")
|
|
console.print("[yellow]Please switch to 'main' or 'dev' branch[/yellow]")
|
|
return False
|
|
|
|
# Check for uncommitted changes
|
|
result = subprocess.run(['git', 'status', '--porcelain'],
|
|
capture_output=True, text=True, cwd=self.root_dir)
|
|
if result.stdout.strip():
|
|
console.print("[red]❌ You have uncommitted changes[/red]")
|
|
console.print("[yellow]Please commit or stash your changes first[/yellow]")
|
|
return False
|
|
|
|
# Check for required tools
|
|
tools_status = []
|
|
|
|
# Check GitHub CLI
|
|
try:
|
|
subprocess.run(['gh', '--version'], capture_output=True, check=True)
|
|
tools_status.append(("GitHub CLI", "✅ Available"))
|
|
except:
|
|
tools_status.append(("GitHub CLI", "⚠️ Not found (releases will be manual)"))
|
|
|
|
# Check Ollama
|
|
try:
|
|
subprocess.run(['ollama', '--version'], capture_output=True, check=True)
|
|
tools_status.append(("Ollama", "✅ Available"))
|
|
except:
|
|
tools_status.append(("Ollama", "⚠️ Not found (manual release notes)"))
|
|
|
|
# Show status
|
|
status_table = Table(show_header=True, header_style="bold blue", box=None)
|
|
status_table.add_column("Component", style="cyan", width=20)
|
|
status_table.add_column("Status", style="white", width=30)
|
|
|
|
status_table.add_row("Git Branch", f"✅ {current_branch}")
|
|
status_table.add_row("Git Status", "✅ Clean")
|
|
for tool, status in tools_status:
|
|
status_table.add_row(tool, status)
|
|
|
|
console.print(status_table)
|
|
console.print("[green]✅ Environment validation passed[/green]")
|
|
return True
|
|
|
|
except Exception as e:
|
|
console.print(f"[red]❌ Validation failed: {e}[/red]")
|
|
return False
|
|
|
|
def _handle_branch_setup(self):
|
|
"""Step 2: Handle branch setup and merging"""
|
|
try:
|
|
result = subprocess.run(['git', 'branch', '--show-current'],
|
|
capture_output=True, text=True, cwd=self.root_dir)
|
|
current_branch = result.stdout.strip()
|
|
|
|
if current_branch == "dev":
|
|
console.print("[yellow]⚠️ You're on dev branch[/yellow]")
|
|
console.print("[blue]Should I merge dev into main?[/blue]")
|
|
console.print("[bold]Merge dev to main? (y/n): [/bold]", end="")
|
|
choice = input().strip().lower()
|
|
|
|
if choice in ['y', 'yes']:
|
|
console.print("[blue]🔄 Merging dev to main...[/blue]")
|
|
subprocess.run(['git', 'checkout', 'main'], cwd=self.root_dir, check=True)
|
|
subprocess.run(['git', 'merge', 'dev'], cwd=self.root_dir, check=True)
|
|
console.print("[green]✅ Successfully merged dev to main[/green]")
|
|
return True
|
|
else:
|
|
console.print("[yellow]⚠️ Please switch to main branch manually[/yellow]")
|
|
return False
|
|
else:
|
|
console.print("[green]✅ Already on main branch[/green]")
|
|
return True
|
|
|
|
except Exception as e:
|
|
console.print(f"[red]❌ Branch setup failed: {e}[/red]")
|
|
return False
|
|
|
|
def _plan_release(self):
|
|
"""Step 3: Plan version and release strategy"""
|
|
try:
|
|
# Get current version and suggest next
|
|
current_version = self._get_current_version()
|
|
suggested_patch = self._get_next_version()
|
|
|
|
# Analyze recent changes to suggest version type
|
|
change_type = self._analyze_changes()
|
|
|
|
console.print(f"[blue]Current version:[/blue] {current_version}")
|
|
console.print(f"[blue]Suggested version:[/blue] {suggested_patch}")
|
|
console.print(f"[blue]Change type:[/blue] {change_type}")
|
|
|
|
# Ask about version type
|
|
console.print("\n[blue]📦 Version Strategy:[/blue]")
|
|
console.print(" • [dim]patch[/dim] - Bug fixes, minor updates (1.0.0 → 1.0.1)")
|
|
console.print(" • [dim]minor[/dim] - New features, chapters (1.0.0 → 1.1.0)")
|
|
console.print(" • [dim]major[/dim] - Breaking changes (1.0.0 → 2.0.0)")
|
|
|
|
console.print(f"[bold]Version type (patch/minor/major) [{change_type}]: [/bold]", end="")
|
|
version_choice = input().strip().lower()
|
|
if not version_choice:
|
|
version_choice = change_type
|
|
|
|
# Generate version based on choice
|
|
if version_choice == "major":
|
|
version = self._increment_major_version(current_version)
|
|
elif version_choice == "minor":
|
|
version = self._increment_minor_version(current_version)
|
|
else:
|
|
version = suggested_patch
|
|
|
|
console.print(f"[green]✅ Selected version: {version}[/green]")
|
|
|
|
# Ask about release creation
|
|
console.print("\n[blue]📦 Create GitHub release?[/blue]")
|
|
console.print("[bold]Create release? (y/n): [/bold]", end="")
|
|
release_choice = input().strip().lower()
|
|
create_release = release_choice in ['y', 'yes']
|
|
|
|
# Ask about AI release notes (only for publishing)
|
|
use_ai_notes = False
|
|
if create_release:
|
|
# Get user preference for AI
|
|
preferences = self.get_user_preferences()
|
|
ai_default = preferences.get('ai_default', True)
|
|
ai_default_text = "y" if ai_default else "n"
|
|
|
|
console.print("\n[blue]🤖 AI Release Notes (requires Ollama):[/blue]")
|
|
console.print(f"[bold]Use AI for release notes? (y/n) [{ai_default_text}]: [/bold]", end="")
|
|
ai_choice = input().strip().lower()
|
|
if not ai_choice:
|
|
ai_choice = ai_default_text
|
|
use_ai_notes = ai_choice in ['y', 'yes']
|
|
|
|
return {
|
|
'version': version,
|
|
'create_release': create_release,
|
|
'use_ai_notes': use_ai_notes,
|
|
'change_type': change_type
|
|
}
|
|
|
|
except Exception as e:
|
|
console.print(f"[red]❌ Release planning failed: {e}[/red]")
|
|
return None
|
|
|
|
def _build_for_publication(self):
|
|
"""Step 4: Build both HTML and PDF for publication"""
|
|
try:
|
|
# Clean previous builds
|
|
console.print("[blue]🧹 Cleaning previous builds...[/blue]")
|
|
self.clean()
|
|
|
|
# Build PDF first (as requested)
|
|
console.print("[blue]📄 Building PDF version...[/blue]")
|
|
if not self.build_full("pdf"):
|
|
console.print("[red]❌ PDF build failed![/red]")
|
|
return False
|
|
console.print("[green]✅ PDF build completed[/green]")
|
|
|
|
# Build HTML
|
|
console.print("[blue]📚 Building HTML version...[/blue]")
|
|
if not self.build_full("html"):
|
|
console.print("[red]❌ HTML build failed![/red]")
|
|
return False
|
|
console.print("[green]✅ HTML build completed[/green]")
|
|
|
|
# Validate builds
|
|
pdf_path = self.build_dir / "pdf" / "Machine-Learning-Systems.pdf"
|
|
html_path = self.build_dir / "html" / "index.html"
|
|
|
|
if not pdf_path.exists():
|
|
console.print(f"[red]❌ PDF not found at {pdf_path}[/red]")
|
|
return False
|
|
|
|
if not html_path.exists():
|
|
console.print(f"[red]❌ HTML not found at {html_path}[/red]")
|
|
return False
|
|
|
|
console.print("[green]✅ All builds validated successfully[/green]")
|
|
return True
|
|
|
|
except Exception as e:
|
|
console.print(f"[red]❌ Build process failed: {e}[/red]")
|
|
return False
|
|
|
|
def _create_release(self, release_info):
|
|
"""Step 5: Create GitHub release with version and notes"""
|
|
try:
|
|
version = release_info['version']
|
|
use_ai_notes = release_info['use_ai_notes']
|
|
tag_name = f"v{version}"
|
|
|
|
console.print(f"[blue]📦 Creating release {tag_name}...[/blue]")
|
|
|
|
# Generate release notes
|
|
if use_ai_notes:
|
|
console.print("[blue]🤖 Generating AI release notes...[/blue]")
|
|
release_notes = self._generate_ai_release_notes()
|
|
else:
|
|
console.print("[blue]📝 Generating manual release notes...[/blue]")
|
|
release_notes = self._generate_manual_release_notes()
|
|
|
|
# Create and push tag
|
|
console.print("[blue]🏷️ Creating git tag...[/blue]")
|
|
subprocess.run(['git', 'tag', '-a', tag_name, '-m', f"Release {tag_name}"],
|
|
cwd=self.root_dir, check=True)
|
|
subprocess.run(['git', 'push', 'origin', tag_name],
|
|
cwd=self.root_dir, check=True)
|
|
|
|
# Create GitHub release
|
|
console.print("[blue]🚀 Creating GitHub release...[/blue]")
|
|
release_cmd = [
|
|
'gh', 'release', 'create', tag_name,
|
|
'--title', f"Release {tag_name}",
|
|
'--notes', release_notes,
|
|
'--target', 'main'
|
|
]
|
|
subprocess.run(release_cmd, cwd=self.root_dir, check=True)
|
|
|
|
console.print(f"[green]✅ Successfully created release {tag_name}[/green]")
|
|
return True
|
|
|
|
except subprocess.CalledProcessError as e:
|
|
console.print(f"[yellow]⚠️ Release creation failed: {e}[/yellow]")
|
|
console.print("[dim]You can create the release manually on GitHub[/dim]")
|
|
return False
|
|
except FileNotFoundError:
|
|
console.print("[yellow]⚠️ GitHub CLI (gh) not found[/yellow]")
|
|
console.print("[dim]You can create the release manually on GitHub[/dim]")
|
|
return False
|
|
|
|
def _deploy_to_production(self):
|
|
"""Step 6: Deploy to production (commit, push, trigger CI/CD)"""
|
|
try:
|
|
# Copy PDF to assets
|
|
console.print("[blue]📦 Copying PDF to assets...[/blue]")
|
|
pdf_source = self.build_dir / "pdf" / "Machine-Learning-Systems.pdf"
|
|
pdf_dest = self.root_dir / "assets" / "Machine-Learning-Systems.pdf"
|
|
|
|
import shutil
|
|
shutil.copy2(pdf_source, pdf_dest)
|
|
console.print("[green]✅ PDF copied to assets[/green]")
|
|
|
|
# Commit changes
|
|
console.print("[blue]💾 Committing changes...[/blue]")
|
|
subprocess.run(['git', 'add', 'assets/Machine-Learning-Systems.pdf'],
|
|
cwd=self.root_dir, check=True)
|
|
subprocess.run(['git', 'commit', '-m', '📄 Add PDF to assets for download'],
|
|
cwd=self.root_dir, check=True)
|
|
console.print("[green]✅ Changes committed[/green]")
|
|
|
|
# Push to main
|
|
console.print("[blue]🚀 Pushing to main...[/blue]")
|
|
subprocess.run(['git', 'push', 'origin', 'main'],
|
|
cwd=self.root_dir, check=True)
|
|
console.print("[green]✅ Successfully pushed to main[/green]")
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
console.print(f"[red]❌ Deployment failed: {e}[/red]")
|
|
return False
|
|
|
|
def _show_publish_success(self):
|
|
"""Show success summary after publishing"""
|
|
console.print("\n[green]🎉 Publication completed successfully![/green]")
|
|
console.print("\n[blue]📊 What happened:[/blue]")
|
|
console.print(" ✅ Validated environment")
|
|
console.print(" ✅ Managed branch setup")
|
|
console.print(" ✅ Planned release strategy")
|
|
console.print(" ✅ Built HTML and PDF versions")
|
|
console.print(" ✅ Created GitHub release")
|
|
console.print(" ✅ Deployed to production")
|
|
console.print("\n[blue]🌐 Your book is now available at:[/blue]")
|
|
console.print(" 📖 Web: https://harvard-edge.github.io/cs249r_book")
|
|
console.print(" 📄 PDF: https://harvard-edge.github.io/cs249r_book/assets/Machine-Learning-Systems.pdf")
|
|
console.print("\n[blue]⏳ GitHub Actions will now:[/blue]")
|
|
console.print(" 🔄 Run quality checks")
|
|
console.print(" 🏗️ Build all formats")
|
|
console.print(" 🚀 Deploy to GitHub Pages")
|
|
console.print("\n[blue]📈 Monitor: https://github.com/harvard-edge/cs249r_book/actions[/blue]")
|
|
|
|
def _get_current_version(self):
|
|
"""Get current version from git tags"""
|
|
try:
|
|
result = subprocess.run(['git', 'tag', '--list', 'v*'],
|
|
capture_output=True, text=True, cwd=self.root_dir)
|
|
tags = result.stdout.strip().split('\n') if result.stdout.strip() else []
|
|
|
|
if not tags:
|
|
return "0.0.0"
|
|
|
|
# Find the highest version
|
|
versions = [tag[1:] for tag in tags if tag.startswith('v')]
|
|
if not versions:
|
|
return "0.0.0"
|
|
|
|
return max(versions, key=lambda v: [int(x) for x in v.split('.')])
|
|
|
|
except Exception:
|
|
return "0.0.0"
|
|
|
|
def _analyze_changes(self):
|
|
"""Analyze recent changes to suggest version type"""
|
|
try:
|
|
# Get recent commits
|
|
result = subprocess.run(['git', 'log', '--oneline', '--since=1.week.ago'],
|
|
capture_output=True, text=True, cwd=self.root_dir)
|
|
commits = result.stdout.strip().split('\n') if result.stdout.strip() else []
|
|
|
|
if not commits:
|
|
return "patch"
|
|
|
|
# Analyze commit messages for keywords
|
|
major_keywords = ['breaking', 'major', 'v2', 'overhaul', 'rewrite']
|
|
minor_keywords = ['feature', 'new', 'chapter', 'add', 'enhance']
|
|
|
|
commit_text = ' '.join(commits).lower()
|
|
|
|
if any(keyword in commit_text for keyword in major_keywords):
|
|
return "major"
|
|
elif any(keyword in commit_text for keyword in minor_keywords):
|
|
return "minor"
|
|
else:
|
|
return "patch"
|
|
|
|
except Exception:
|
|
return "patch"
|
|
|
|
def _increment_major_version(self, version):
|
|
"""Increment major version (1.0.0 → 2.0.0)"""
|
|
try:
|
|
major, minor, patch = map(int, version.split('.'))
|
|
return f"{major + 1}.0.0"
|
|
except:
|
|
return "1.0.0"
|
|
|
|
def _increment_minor_version(self, version):
|
|
"""Increment minor version (1.0.0 → 1.1.0)"""
|
|
try:
|
|
major, minor, patch = map(int, version.split('.'))
|
|
return f"{major}.{minor + 1}.0"
|
|
except:
|
|
return "1.0.0"
|
|
|
|
def _get_next_version(self):
|
|
"""Get the next version number based on existing tags"""
|
|
try:
|
|
# Get all version tags
|
|
result = subprocess.run(['git', 'tag', '--list', 'v*'],
|
|
capture_output=True, text=True, cwd=self.root_dir)
|
|
tags = result.stdout.strip().split('\n') if result.stdout.strip() else []
|
|
|
|
# Parse version numbers
|
|
versions = []
|
|
for tag in tags:
|
|
if tag.startswith('v'):
|
|
try:
|
|
version = tag[1:] # Remove 'v' prefix
|
|
versions.append(version)
|
|
except:
|
|
continue
|
|
|
|
if not versions:
|
|
return "1.0.0"
|
|
|
|
# Find the highest version
|
|
latest = max(versions, key=lambda v: [int(x) for x in v.split('.')])
|
|
major, minor, patch = map(int, latest.split('.'))
|
|
|
|
# Increment patch version
|
|
return f"{major}.{minor}.{patch + 1}"
|
|
|
|
except Exception as e:
|
|
console.print(f"[yellow]⚠️ Could not determine version: {e}[/yellow]")
|
|
return "1.0.0"
|
|
|
|
def _generate_manual_release_notes(self):
|
|
"""Generate manual release notes based on recent commits"""
|
|
try:
|
|
# Get recent commits since last tag
|
|
result = subprocess.run(['git', 'log', '--oneline', '--since=1.week.ago'],
|
|
capture_output=True, text=True, cwd=self.root_dir)
|
|
commits = result.stdout.strip().split('\n') if result.stdout.strip() else []
|
|
|
|
if not commits:
|
|
return "📚 Book update with latest content and improvements."
|
|
|
|
# Format recent commits
|
|
notes = "## What's New\n\n"
|
|
for commit in commits[:10]: # Last 10 commits
|
|
if commit.strip():
|
|
notes += f"• {commit}\n"
|
|
|
|
notes += "\n## 📖 Book Updates\n"
|
|
notes += "• Updated content and chapters\n"
|
|
notes += "• Improved formatting and layout\n"
|
|
notes += "• Enhanced PDF generation\n"
|
|
|
|
return notes
|
|
|
|
except Exception as e:
|
|
return f"📚 Book update with latest content and improvements.\n\nError generating notes: {e}"
|
|
|
|
def _generate_ai_release_notes(self):
|
|
"""Generate AI-powered release notes using Ollama"""
|
|
try:
|
|
# Check if Ollama is available
|
|
subprocess.run(['ollama', '--version'], capture_output=True, check=True)
|
|
|
|
# Get recent commits
|
|
result = subprocess.run(['git', 'log', '--oneline', '--since=1.week.ago'],
|
|
capture_output=True, text=True, cwd=self.root_dir)
|
|
commits = result.stdout.strip().split('\n') if result.stdout.strip() else []
|
|
|
|
if not commits:
|
|
return self._generate_manual_release_notes()
|
|
|
|
# Create prompt for AI
|
|
prompt = f"""Generate professional release notes for a machine learning systems book based on these recent commits:
|
|
|
|
{chr(10).join(commits[:15])}
|
|
|
|
Please create:
|
|
1. A brief summary of changes
|
|
2. Key improvements and updates
|
|
3. Technical details about the book content
|
|
4. Professional tone suitable for academic publishing
|
|
|
|
Format as markdown with clear sections."""
|
|
|
|
# Use Ollama to generate notes
|
|
ai_result = subprocess.run(['ollama', 'run', 'llama2', prompt],
|
|
capture_output=True, text=True, cwd=self.root_dir)
|
|
|
|
if ai_result.returncode == 0 and ai_result.stdout.strip():
|
|
return ai_result.stdout.strip()
|
|
else:
|
|
console.print("[yellow]⚠️ AI generation failed, using manual notes[/yellow]")
|
|
return self._generate_manual_release_notes()
|
|
|
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
console.print("[yellow]⚠️ Ollama not available, using manual notes[/yellow]")
|
|
return self._generate_manual_release_notes()
|
|
except Exception as e:
|
|
console.print(f"[yellow]⚠️ AI generation error: {e}[/yellow]")
|
|
return self._generate_manual_release_notes()
|
|
|
|
def show_about(self):
|
|
"""Display beautiful about screen with book binder theme"""
|
|
|
|
# Create a book-themed layout
|
|
about_panel = Panel(
|
|
"[bold blue]📚 Book Binder[/bold blue]\n"
|
|
"[dim]⚡ I compile ML systems knowledge[/dim]\n\n"
|
|
"[bold green]Version:[/bold green] 1.0.0\n"
|
|
"[bold green]Author:[/bold green] Prof. Vijay Janapa Reddi\n"
|
|
"[bold green]Repository:[/bold green] https://github.com/harvard-edge/cs249r_book\n\n"
|
|
"[bold yellow]🎯 Purpose:[/bold yellow]\n"
|
|
" • Bind chapters into beautiful books\n"
|
|
" • Preview content before publication\n"
|
|
" • Manage build configurations\n"
|
|
" • Publish to the world\n\n"
|
|
"[bold cyan]🛠️ Built with:[/bold cyan]\n"
|
|
" • Python 3.6+ (standard library only)\n"
|
|
" • Rich (beautiful terminal output)\n"
|
|
" • Quarto (academic publishing)\n"
|
|
" • Git (version control)\n\n"
|
|
"[bold magenta]📖 Book Information:[/bold magenta]\n"
|
|
" • Title: Machine Learning Systems\n"
|
|
" • Subtitle: Principles and Practices of Engineering AI Systems\n"
|
|
" • Author: Prof. Vijay Janapa Reddi\n"
|
|
" • Publisher: MIT Press (2026)\n"
|
|
" • License: CC BY-NC-SA 4.0\n\n"
|
|
"[bold blue]🌐 Live at:[/bold blue]\n"
|
|
" • Web: https://mlsysbook.ai\n"
|
|
" • PDF: https://mlsysbook.ai/pdf\n"
|
|
" • Ecosystem: https://mlsysbook.org\n\n"
|
|
"[dim]Made with ❤️ for aspiring AI engineers worldwide[/dim]",
|
|
title="📚 About Book Binder",
|
|
border_style="blue",
|
|
padding=(1, 2)
|
|
)
|
|
|
|
# Show system status
|
|
status_table = Table(show_header=True, header_style="bold green", box=None)
|
|
status_table.add_column("Component", style="cyan", width=20)
|
|
status_table.add_column("Status", style="white", width=15)
|
|
status_table.add_column("Details", style="dim")
|
|
|
|
# Check various components
|
|
status_table.add_row("📁 Book Directory", "✅ Found", str(self.book_dir))
|
|
status_table.add_row("🔗 Active Config", "✅ Active", str(self.active_config))
|
|
status_table.add_row("📚 Chapters", "✅ Available", f"{len(self.find_chapters())} chapters")
|
|
status_table.add_row("🏗️ Build Directory", "✅ Ready", str(self.build_dir))
|
|
|
|
# Check git status
|
|
try:
|
|
result = subprocess.run(['git', 'branch', '--show-current'],
|
|
capture_output=True, text=True, cwd=self.root_dir)
|
|
current_branch = result.stdout.strip()
|
|
status_table.add_row("🌿 Git Branch", "✅ Active", current_branch)
|
|
except:
|
|
status_table.add_row("🌿 Git Branch", "❌ Error", "Could not determine")
|
|
|
|
# Check for uncommitted changes
|
|
try:
|
|
result = subprocess.run(['git', 'status', '--porcelain'],
|
|
capture_output=True, text=True, cwd=self.root_dir)
|
|
if result.stdout.strip():
|
|
status_table.add_row("📝 Git Status", "⚠️ Changes", "Uncommitted changes detected")
|
|
else:
|
|
status_table.add_row("📝 Git Status", "✅ Clean", "No uncommitted changes")
|
|
except:
|
|
status_table.add_row("📝 Git Status", "❌ Error", "Could not determine")
|
|
|
|
# Display everything
|
|
self.show_banner()
|
|
console.print(about_panel)
|
|
console.print(Panel(status_table, title="🔧 System Status", border_style="green"))
|
|
|
|
# Quick commands reminder
|
|
quick_commands = Table(show_header=True, header_style="bold blue", box=None)
|
|
quick_commands.add_column("Command", style="green", width=15)
|
|
quick_commands.add_column("Description", style="white")
|
|
|
|
quick_commands.add_row("./binder list", "See all chapters")
|
|
quick_commands.add_row("./binder status", "Show current config")
|
|
quick_commands.add_row("./binder help", "Show all commands")
|
|
quick_commands.add_row("./binder about", "Show this information")
|
|
|
|
console.print(Panel(quick_commands, title="🚀 Quick Commands", border_style="cyan"))
|
|
|
|
def show_help(self):
|
|
"""Display beautiful help screen"""
|
|
fast_table = Table(show_header=True, header_style="bold green", box=None)
|
|
fast_table.add_column("Command", style="green", width=35)
|
|
fast_table.add_column("Description", style="white", width=30)
|
|
fast_table.add_column("Example", style="dim", width=30)
|
|
|
|
fast_table.add_row("build <chapter[,ch2,...]|-> <format>", "Build chapter(s) or all", "./binder build intro html")
|
|
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=35)
|
|
full_table.add_column("Description", style="white", width=30)
|
|
full_table.add_column("Example", style="dim", width=30)
|
|
|
|
full_table.add_row("build - <format>", "Build complete book", "./binder build - pdf")
|
|
full_table.add_row("preview-full [format]", "Preview complete book", "./binder preview-full")
|
|
full_table.add_row("publish", "Build and publish book", "./binder publish")
|
|
|
|
# Management Commands
|
|
mgmt_table = Table(show_header=True, header_style="bold blue", box=None)
|
|
mgmt_table.add_column("Command", style="green", width=35)
|
|
mgmt_table.add_column("Description", style="white", width=30)
|
|
mgmt_table.add_column("Example", style="dim", width=30)
|
|
|
|
mgmt_table.add_row("clean", "Clean configs & artifacts", "./binder clean")
|
|
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("hello", "Show welcome message", "./binder hello")
|
|
mgmt_table.add_row("setup", "Setup environment", "./binder setup")
|
|
mgmt_table.add_row("about", "Show about information", "./binder about")
|
|
mgmt_table.add_row("help", "Show this help", "./binder help")
|
|
|
|
console.print(mgmt_table)
|
|
|
|
# Shortcuts
|
|
shortcuts_table = Table(show_header=True, header_style="bold blue", box=None)
|
|
shortcuts_table.add_column("Shortcut", style="magenta", 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("pf", "preview-full")
|
|
shortcuts_table.add_row("c", "clean")
|
|
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("he", "hello")
|
|
shortcuts_table.add_row("se", "setup")
|
|
shortcuts_table.add_row("a", "about")
|
|
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"))
|
|
|
|
# Pro Tips
|
|
examples = Text()
|
|
examples.append("🎯 Power User Examples:\n", style="bold magenta")
|
|
examples.append(" ./binder b intro,ml_systems html ", style="cyan")
|
|
examples.append("# Build multiple chapters\n", style="dim")
|
|
examples.append(" ./binder b - pdf ", style="cyan")
|
|
examples.append("# Build all chapters as PDF\n", style="dim")
|
|
examples.append(" ./binder c ", style="cyan")
|
|
examples.append("# 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")
|
|
examples.append("\n", style="dim")
|
|
|
|
console.print(Panel(examples, title="💡 Pro Tips", border_style="magenta"))
|
|
|
|
def show_hello(self):
|
|
"""Display welcome message and getting started guide"""
|
|
welcome_panel = Panel(
|
|
"[bold blue]🎉 Welcome to MLSysBook![/bold blue]\n\n"
|
|
"[green]I'm your Book Binder - I help you build and publish the Machine Learning Systems book.[/green]\n\n"
|
|
"[bold yellow]📚 What I can do:[/bold yellow]\n"
|
|
" • Build individual chapters or the entire book\n"
|
|
" • Preview content in your browser\n"
|
|
" • Publish to the world with smart versioning\n"
|
|
" • Manage build configurations\n"
|
|
" • Clean up artifacts automatically\n\n"
|
|
"[bold cyan]🚀 Getting Started:[/bold cyan]\n"
|
|
" 1. Run './binder setup' to configure your environment\n"
|
|
" 2. Try './binder list' to see available chapters\n"
|
|
" 3. Use './binder preview intro' to preview a chapter\n"
|
|
" 4. Run './binder help' for all commands\n\n"
|
|
"[bold magenta]💡 Quick Examples:[/bold magenta]\n"
|
|
" ./binder preview intro # Preview introduction chapter\n"
|
|
" ./binder build - html # Build complete HTML book\n"
|
|
" ./binder build - pdf # Build complete PDF book\n"
|
|
" ./binder publish # Publish to the world\n\n"
|
|
"[dim]Made with ❤️ for aspiring AI engineers worldwide[/dim]",
|
|
title="👋 Hello from Book Binder",
|
|
border_style="blue",
|
|
padding=(1, 2)
|
|
)
|
|
|
|
console.print(welcome_panel)
|
|
|
|
# Show current status
|
|
status_table = self._create_status_table()
|
|
|
|
# Add environment info
|
|
status_table.add_row("📁 Book Directory", "✅ Found", str(self.book_dir))
|
|
status_table.add_row("🔗 Active Config", "✅ Active", str(self.active_config))
|
|
status_table.add_row("📚 Chapters", "✅ Available", f"{len(self.find_chapters())} chapters")
|
|
|
|
# Add tool status
|
|
tools_to_check = [
|
|
('Quarto', ['quarto', '--version']),
|
|
('Python', ['python3', '--version']),
|
|
('Git', ['git', '--version'])
|
|
]
|
|
|
|
for tool_name, cmd in tools_to_check:
|
|
available, version = self._check_tool_availability(tool_name, cmd)
|
|
status = "✅ Available" if available else "❌ Not found"
|
|
status_table.add_row(tool_name, status.split()[0], status.split()[1] if len(status.split()) > 1 else "")
|
|
|
|
console.print(Panel(status_table, title="🔧 Environment Status", border_style="green"))
|
|
|
|
# Next steps
|
|
next_steps = Table(show_header=True, header_style="bold blue", box=None)
|
|
next_steps.add_column("Step", style="cyan", width=10)
|
|
next_steps.add_column("Command", style="green", width=25)
|
|
next_steps.add_column("Description", style="white")
|
|
|
|
next_steps.add_row("1", "./binder setup", "Configure your environment")
|
|
next_steps.add_row("2", "./binder list", "See available chapters")
|
|
next_steps.add_row("3", "./binder preview intro", "Preview a chapter")
|
|
next_steps.add_row("4", "./binder help", "Learn all commands")
|
|
|
|
console.print(Panel(next_steps, title="🎯 Next Steps", border_style="cyan"))
|
|
|
|
def setup_environment(self):
|
|
"""Setup the development environment"""
|
|
console.print("[bold blue]🔧 MLSysBook Environment Setup[/bold blue]")
|
|
console.print("[dim]I'll help you configure everything you need[/dim]\n")
|
|
|
|
# Step 1: Check current environment
|
|
console.print("[blue]📋 Step 1: Environment Check[/blue]")
|
|
self._check_environment()
|
|
|
|
# Step 2: System dependencies
|
|
console.print("\n[blue]📋 Step 2: System Dependencies[/blue]")
|
|
if not self._check_system_dependencies():
|
|
console.print("[red]❌ System dependency check failed[/red]")
|
|
return
|
|
|
|
# Step 3: Install/configure tools
|
|
console.print("\n[blue]📋 Step 3: Tool Installation[/blue]")
|
|
self._install_tools()
|
|
|
|
# Step 4: Configure Git
|
|
console.print("\n[blue]📋 Step 4: Git Configuration[/blue]")
|
|
self._configure_git()
|
|
|
|
# Step 5: Environment preferences
|
|
console.print("\n[blue]📋 Step 5: Environment Preferences[/blue]")
|
|
self._configure_preferences()
|
|
|
|
# Step 6: Test setup
|
|
console.print("\n[blue]📋 Step 6: Test Setup[/blue]")
|
|
self._test_setup()
|
|
|
|
console.print("\n[green]✅ Environment setup completed![/green]")
|
|
console.print("[blue]💡 Try './binder preview intro' to test your setup[/blue]")
|
|
|
|
def _get_tools_to_check(self):
|
|
"""Get list of tools to check/install"""
|
|
return [
|
|
('Quarto', ['quarto', '--version'], "https://quarto.org/docs/get-started/"),
|
|
('Python', ['python3', '--version'], "https://www.python.org/downloads/"),
|
|
('Git', ['git', '--version'], "https://git-scm.com/downloads"),
|
|
('GitHub CLI', ['gh', '--version'], "https://cli.github.com/"),
|
|
('Ollama', ['ollama', '--version'], "https://ollama.ai/")
|
|
]
|
|
|
|
def _check_tool_availability(self, tool_name, cmd):
|
|
"""Check if a tool is available and return version"""
|
|
try:
|
|
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
version = result.stdout.strip().split('\n')[0]
|
|
return True, version
|
|
except:
|
|
return False, "Not installed"
|
|
|
|
def _check_environment(self):
|
|
"""Check current environment status"""
|
|
console.print("[blue]🔍 Checking your environment...[/blue]")
|
|
|
|
status_table = Table(show_header=True, header_style="bold blue", box=None)
|
|
status_table.add_column("Tool", style="cyan", width=15)
|
|
status_table.add_column("Status", style="white", width=15)
|
|
status_table.add_column("Version", style="dim")
|
|
|
|
# Check various tools
|
|
tools_to_check = self._get_tools_to_check()
|
|
|
|
for tool_name, cmd, _ in tools_to_check:
|
|
available, version = self._check_tool_availability(tool_name, cmd)
|
|
status = "✅ Available" if available else "❌ Missing"
|
|
status_table.add_row(tool_name, status, version)
|
|
|
|
console.print(status_table)
|
|
|
|
def _install_tools(self):
|
|
"""Install missing tools and packages"""
|
|
console.print("[blue]📦 Installing tools and packages...[/blue]")
|
|
|
|
# Check what's missing and install automatically
|
|
missing_tools = []
|
|
tools_to_check = self._get_tools_to_check()
|
|
|
|
for tool_name, cmd, url in tools_to_check:
|
|
available, _ = self._check_tool_availability(tool_name, cmd)
|
|
if available:
|
|
console.print(f"[green]✅ {tool_name} already installed[/green]")
|
|
else:
|
|
missing_tools.append((tool_name, url))
|
|
|
|
if missing_tools:
|
|
console.print("[yellow]⚠️ Missing tools detected:[/yellow]")
|
|
for tool_name, url in missing_tools:
|
|
console.print(f" • {tool_name}: {url}")
|
|
|
|
console.print("\n[blue]🔧 Attempting automatic installation...[/blue]")
|
|
self._auto_install_tools(missing_tools)
|
|
else:
|
|
console.print("[green]✅ All required tools are available[/green]")
|
|
|
|
# Install Python packages
|
|
console.print("\n[blue]📦 Installing Python packages...[/blue]")
|
|
self._install_python_packages()
|
|
|
|
def _auto_install_tools(self, missing_tools):
|
|
"""Attempt to automatically install missing tools"""
|
|
import platform
|
|
system = platform.system().lower()
|
|
|
|
for tool_name, url in missing_tools:
|
|
console.print(f"[blue]🔧 Installing {tool_name}...[/blue]")
|
|
|
|
try:
|
|
self._install_tool(tool_name, system)
|
|
except Exception as e:
|
|
console.print(f"[red]❌ Failed to install {tool_name}: {e}[/red]")
|
|
console.print(f"[yellow]💡 Please install manually: {url}[/yellow]")
|
|
|
|
def _get_installation_methods(self):
|
|
"""Get installation methods for different tools"""
|
|
return {
|
|
'Quarto': {
|
|
'darwin': {'method': 'brew', 'package': 'quarto'},
|
|
'linux': {'method': 'apt', 'package': 'quarto'}
|
|
},
|
|
'GitHub CLI': {
|
|
'darwin': {'method': 'brew', 'package': 'gh'},
|
|
'linux': {'method': 'apt', 'package': 'gh'}
|
|
},
|
|
'Ollama': {
|
|
'darwin': {'method': 'brew', 'package': 'ollama'},
|
|
'linux': {'method': 'curl', 'url': 'https://ollama.ai/install.sh'}
|
|
},
|
|
'Git': {
|
|
'darwin': {'method': 'brew', 'package': 'git'},
|
|
'linux': {'method': 'apt', 'package': 'git'}
|
|
}
|
|
}
|
|
|
|
def _install_tool(self, tool_name, system):
|
|
"""Install a tool using the appropriate method for the system"""
|
|
installation_methods = self._get_installation_methods()
|
|
|
|
if tool_name not in installation_methods:
|
|
raise Exception(f"Automatic {tool_name} installation not supported")
|
|
|
|
if system not in installation_methods[tool_name]:
|
|
raise Exception(f"Automatic {tool_name} installation not supported on this system")
|
|
|
|
method_info = installation_methods[tool_name][system]
|
|
method = method_info['method']
|
|
|
|
if method == 'brew':
|
|
package = method_info['package']
|
|
console.print(f"[blue]📦 Installing {tool_name} via Homebrew...[/blue]")
|
|
subprocess.run(['brew', 'install', package], check=True)
|
|
console.print(f"[green]✅ {tool_name} installed successfully[/green]")
|
|
|
|
elif method == 'apt':
|
|
package = method_info['package']
|
|
console.print(f"[blue]📦 Installing {tool_name} via apt...[/blue]")
|
|
subprocess.run(['sudo', 'apt-get', 'update'], check=True)
|
|
subprocess.run(['sudo', 'apt-get', 'install', '-y', package], check=True)
|
|
console.print(f"[green]✅ {tool_name} installed successfully[/green]")
|
|
|
|
elif method == 'curl':
|
|
url = method_info['url']
|
|
console.print(f"[blue]📦 Installing {tool_name} via curl...[/blue]")
|
|
subprocess.run(['curl', '-fsSL', url, '|', 'sh'], shell=True, check=True)
|
|
console.print(f"[green]✅ {tool_name} installed successfully[/green]")
|
|
|
|
def _install_python_packages(self):
|
|
"""Install required Python packages"""
|
|
console.print("[blue]🐍 Installing Python packages...[/blue]")
|
|
|
|
# Check if pip is available
|
|
try:
|
|
subprocess.run(['pip3', '--version'], capture_output=True, check=True)
|
|
except:
|
|
console.print("[red]❌ pip3 not found. Please install Python and pip first.[/red]")
|
|
return
|
|
|
|
# List of required packages (only external packages)
|
|
required_packages = [
|
|
'rich', # For beautiful terminal output
|
|
]
|
|
|
|
# Check what's already installed
|
|
installed_packages = []
|
|
try:
|
|
result = subprocess.run(['pip3', 'list'], capture_output=True, text=True, check=True)
|
|
installed_lines = result.stdout.split('\n')
|
|
for line in installed_lines:
|
|
if line.strip():
|
|
package_name = line.split()[0].lower()
|
|
installed_packages.append(package_name)
|
|
except:
|
|
console.print("[yellow]⚠️ Could not check installed packages[/yellow]")
|
|
|
|
# Check built-in modules
|
|
builtin_modules = ['pathlib', 'subprocess', 'os', 'sys', 'shutil', 'signal']
|
|
for module in builtin_modules:
|
|
try:
|
|
__import__(module)
|
|
console.print(f"[green]✅ {module} (built-in) available[/green]")
|
|
except ImportError:
|
|
console.print(f"[red]❌ {module} (built-in) not available[/red]")
|
|
|
|
# Install missing packages
|
|
for package in required_packages:
|
|
if package.lower() in installed_packages:
|
|
console.print(f"[green]✅ {package} already installed[/green]")
|
|
else:
|
|
try:
|
|
console.print(f"[blue]📦 Installing {package}...[/blue]")
|
|
subprocess.run(['pip3', 'install', package], check=True)
|
|
console.print(f"[green]✅ {package} installed successfully[/green]")
|
|
except Exception as e:
|
|
console.print(f"[red]❌ Failed to install {package}: {e}[/red]")
|
|
|
|
console.print("[green]✅ Python packages installation completed[/green]")
|
|
|
|
def _check_system_dependencies(self):
|
|
"""Check and install system dependencies"""
|
|
import platform
|
|
system = platform.system().lower()
|
|
|
|
console.print("[blue]🔧 Checking system dependencies...[/blue]")
|
|
|
|
if system == "darwin": # macOS
|
|
# Check for Homebrew
|
|
try:
|
|
subprocess.run(['brew', '--version'], capture_output=True, check=True)
|
|
console.print("[green]✅ Homebrew already installed[/green]")
|
|
except:
|
|
console.print("[blue]📦 Installing Homebrew...[/blue]")
|
|
try:
|
|
install_cmd = '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
|
|
subprocess.run(install_cmd, shell=True, check=True)
|
|
console.print("[green]✅ Homebrew installed successfully[/green]")
|
|
except Exception as e:
|
|
console.print(f"[red]❌ Failed to install Homebrew: {e}[/red]")
|
|
console.print("[yellow]💡 Please install Homebrew manually: https://brew.sh[/yellow]")
|
|
return False
|
|
|
|
elif system == "linux":
|
|
# Check for apt (Ubuntu/Debian)
|
|
try:
|
|
subprocess.run(['apt-get', '--version'], capture_output=True, check=True)
|
|
console.print("[green]✅ apt package manager available[/green]")
|
|
except:
|
|
console.print("[yellow]⚠️ apt not found. Please install package manager manually.[/yellow]")
|
|
return False
|
|
|
|
return True
|
|
|
|
def _get_git_config(self, key):
|
|
"""Get Git configuration value"""
|
|
try:
|
|
result = subprocess.run(['git', 'config', key], capture_output=True, text=True)
|
|
if result.returncode == 0:
|
|
return result.stdout.strip()
|
|
except:
|
|
pass
|
|
return None
|
|
|
|
def _set_git_config(self, key, value):
|
|
"""Set Git configuration value"""
|
|
try:
|
|
subprocess.run(['git', 'config', '--global', key, value], check=True)
|
|
console.print(f"[green]✅ Set Git {key}: {value}[/green]")
|
|
return True
|
|
except subprocess.CalledProcessError as e:
|
|
console.print(f"[red]❌ Failed to set Git {key}: {e}[/red]")
|
|
return False
|
|
|
|
def _get_user_input_with_default(self, prompt, current_value=None):
|
|
"""Get user input with optional default value"""
|
|
if current_value:
|
|
console.print(f"[bold]{prompt} [{current_value}]: [/bold]", end="")
|
|
user_input = input().strip()
|
|
return user_input if user_input else current_value
|
|
else:
|
|
console.print(f"[bold]{prompt}: [/bold]", end="")
|
|
return input().strip()
|
|
|
|
def _configure_git(self):
|
|
"""Configure Git settings interactively"""
|
|
console.print("[blue]🔧 Configuring Git...[/blue]")
|
|
|
|
# Check current Git configuration
|
|
current_name = self._get_git_config('user.name')
|
|
current_email = self._get_git_config('user.email')
|
|
|
|
# Show current configuration
|
|
if current_name and current_email:
|
|
console.print(f"[green]✅ Current Git configuration:[/green]")
|
|
console.print(f" Name: {current_name}")
|
|
console.print(f" Email: {current_email}")
|
|
|
|
console.print("\n[blue]Would you like to update your Git configuration?[/blue]")
|
|
console.print("[bold]Update Git config? (y/n): [/bold]", end="")
|
|
update_choice = input().strip().lower()
|
|
|
|
if update_choice not in ['y', 'yes']:
|
|
console.print("[green]✅ Keeping current Git configuration[/green]")
|
|
return
|
|
else:
|
|
console.print("[yellow]⚠️ Git configuration incomplete[/yellow]")
|
|
|
|
# Get user information
|
|
console.print("\n[blue]📝 Please provide your information:[/blue]")
|
|
|
|
name_input = self._get_user_input_with_default("Full name", current_name)
|
|
email_input = self._get_user_input_with_default("Email", current_email)
|
|
github_username = self._get_user_input_with_default("GitHub username")
|
|
|
|
# Configure Git
|
|
success = True
|
|
if name_input:
|
|
success &= self._set_git_config('user.name', name_input)
|
|
|
|
if email_input:
|
|
success &= self._set_git_config('user.email', email_input)
|
|
|
|
if github_username:
|
|
success &= self._set_git_config('user.github', github_username)
|
|
|
|
if success:
|
|
console.print("[green]✅ Git configuration completed![/green]")
|
|
else:
|
|
console.print("[yellow]💡 You can configure Git manually:[/yellow]")
|
|
console.print(" git config --global user.name 'Your Name'")
|
|
console.print(" git config --global user.email 'your.email@example.com'")
|
|
|
|
def _get_preferences_config(self):
|
|
"""Get preferences configuration structure"""
|
|
return [
|
|
{
|
|
'key': 'binder.default-format',
|
|
'prompt': 'Default build format (html/pdf)',
|
|
'default': 'html',
|
|
'options': ['html', 'pdf'],
|
|
'description': '📚 Build Preferences:\n • html - Web format (faster, interactive)\n • pdf - Print format (slower, academic)'
|
|
},
|
|
{
|
|
'key': 'binder.auto-open',
|
|
'prompt': 'Auto-open browser after builds? (y/n)',
|
|
'default': 'y',
|
|
'options': ['y', 'n'],
|
|
'description': '🌐 Browser Preferences:'
|
|
}
|
|
]
|
|
|
|
def _configure_preferences(self):
|
|
"""Configure user preferences and environment settings"""
|
|
console.print("[blue]⚙️ Configuring preferences...[/blue]")
|
|
|
|
preferences_config = self._get_preferences_config()
|
|
|
|
for pref in preferences_config:
|
|
console.print(f"\n[blue]{pref['description']}[/blue]")
|
|
|
|
# Get current value
|
|
current_value = self._get_git_config(pref['key'])
|
|
if current_value:
|
|
default_display = current_value
|
|
else:
|
|
default_display = pref['default']
|
|
|
|
# Get user input
|
|
user_input = self._get_user_input_with_default(
|
|
pref['prompt'],
|
|
default_display
|
|
)
|
|
|
|
# Validate input
|
|
if not user_input or user_input not in pref['options']:
|
|
user_input = pref['default']
|
|
|
|
# Convert y/n to true/false for boolean settings
|
|
if pref['key'] in ['binder.auto-open', 'binder.ai-default']:
|
|
setting_value = 'true' if user_input in ['y', 'yes'] else 'false'
|
|
else:
|
|
setting_value = user_input
|
|
|
|
# Store preference
|
|
if self._set_git_config(pref['key'], setting_value):
|
|
console.print(f"[green]✅ Set {pref['key']}: {setting_value}[/green]")
|
|
else:
|
|
console.print(f"[yellow]⚠️ Could not save {pref['key']} preference[/yellow]")
|
|
|
|
console.print("[green]✅ Preferences configured![/green]")
|
|
|
|
def get_user_preferences(self):
|
|
"""Get user preferences from Git config"""
|
|
preferences = {}
|
|
|
|
try:
|
|
# Get default format
|
|
result = subprocess.run(['git', 'config', '--global', 'binder.default-format'],
|
|
capture_output=True, text=True)
|
|
if result.returncode == 0:
|
|
preferences['default_format'] = result.stdout.strip()
|
|
else:
|
|
preferences['default_format'] = 'html'
|
|
|
|
# Get auto-open setting
|
|
result = subprocess.run(['git', 'config', '--global', 'binder.auto-open'],
|
|
capture_output=True, text=True)
|
|
if result.returncode == 0:
|
|
preferences['auto_open'] = result.stdout.strip() == 'true'
|
|
else:
|
|
preferences['auto_open'] = True
|
|
|
|
# Get AI default setting (only used in publish flow)
|
|
result = subprocess.run(['git', 'config', '--global', 'binder.ai-default'],
|
|
capture_output=True, text=True)
|
|
if result.returncode == 0:
|
|
preferences['ai_default'] = result.stdout.strip() == 'true'
|
|
else:
|
|
# Default to True for AI features (only used in publish)
|
|
preferences['ai_default'] = True
|
|
|
|
# Get GitHub username
|
|
result = subprocess.run(['git', 'config', '--global', 'user.github'],
|
|
capture_output=True, text=True)
|
|
if result.returncode == 0:
|
|
preferences['github_username'] = result.stdout.strip()
|
|
else:
|
|
preferences['github_username'] = None
|
|
|
|
except Exception:
|
|
# Return defaults if anything fails
|
|
preferences = {
|
|
'default_format': 'html',
|
|
'auto_open': True,
|
|
'ai_default': True,
|
|
'github_username': None
|
|
}
|
|
|
|
return preferences
|
|
|
|
def _create_status_table(self):
|
|
"""Create a standard status table"""
|
|
status_table = Table(show_header=True, header_style="bold green", box=None)
|
|
status_table.add_column("Component", style="cyan", width=20)
|
|
status_table.add_column("Status", style="white", width=15)
|
|
status_table.add_column("Details", style="dim")
|
|
return status_table
|
|
|
|
def _test_setup(self):
|
|
"""Test the setup by building a simple chapter"""
|
|
console.print("[blue]🧪 Testing setup...[/blue]")
|
|
|
|
# Try to build a simple chapter
|
|
try:
|
|
console.print("[blue]📚 Testing chapter build...[/blue]")
|
|
success = self.build("intro", "html")
|
|
if success:
|
|
console.print("[green]✅ Build test successful![/green]")
|
|
else:
|
|
console.print("[yellow]⚠️ Build test failed - check your Quarto installation[/yellow]")
|
|
except Exception as e:
|
|
console.print(f"[red]❌ Test failed: {e}[/red]")
|
|
|
|
def main():
|
|
binder = BookBinder()
|
|
|
|
if len(sys.argv) < 2:
|
|
binder.show_help()
|
|
return
|
|
|
|
command = sys.argv[1].lower()
|
|
|
|
# Enhanced command mapping with aliases
|
|
command_aliases = {
|
|
'b': 'build',
|
|
'p': 'preview',
|
|
'pf': 'preview-full',
|
|
'c': 'clean',
|
|
'ch': 'check',
|
|
's': 'switch',
|
|
'st': 'status',
|
|
'l': 'list',
|
|
'he': 'hello',
|
|
'se': 'setup',
|
|
'h': 'help'
|
|
}
|
|
|
|
# Apply aliases
|
|
if command in command_aliases:
|
|
command = command_aliases[command]
|
|
|
|
# Handle commands
|
|
try:
|
|
if command == "build":
|
|
if len(sys.argv) < 4:
|
|
console.print("[red]❌ Usage: ./binder build <chapter[,chapter2,...]|all|-> <format>[/red]")
|
|
console.print("[dim]Examples: ./binder build - html, ./binder build intro pdf[/dim]")
|
|
return
|
|
binder.show_symlink_status()
|
|
chapters = sys.argv[2]
|
|
format_type = sys.argv[3]
|
|
if format_type not in ["html", "pdf"]:
|
|
console.print("[red]❌ Format must be 'html' or 'pdf'[/red]")
|
|
return
|
|
|
|
if chapters == "-" or chapters == "all":
|
|
# Build all chapters
|
|
binder.build_full(format_type)
|
|
else:
|
|
# Build specific chapters
|
|
binder.build(chapters, format_type)
|
|
|
|
elif command == "preview":
|
|
if len(sys.argv) < 3:
|
|
console.print("[red]❌ Usage: ./binder preview <chapter>[/red]")
|
|
return
|
|
binder.show_symlink_status()
|
|
chapter = sys.argv[2]
|
|
binder.preview(chapter)
|
|
|
|
elif command == "build-full":
|
|
binder.show_symlink_status()
|
|
format_type = sys.argv[2] if len(sys.argv) > 2 else "html"
|
|
if format_type not in ["html", "pdf"]:
|
|
console.print("[red]❌ Format must be 'html' or 'pdf'[/red]")
|
|
return
|
|
binder.build_full(format_type)
|
|
|
|
elif command == "preview-full":
|
|
binder.show_symlink_status()
|
|
format_type = sys.argv[2] if len(sys.argv) > 2 else "html"
|
|
if format_type not in ["html", "pdf"]:
|
|
console.print("[red]❌ Format must be 'html' or 'pdf'[/red]")
|
|
return
|
|
binder.preview_full(format_type)
|
|
|
|
elif command == "clean":
|
|
binder.show_symlink_status()
|
|
binder.clean()
|
|
|
|
elif command == "check":
|
|
success = binder.check_artifacts()
|
|
if not success:
|
|
sys.exit(1)
|
|
|
|
elif command == "switch":
|
|
if len(sys.argv) < 3:
|
|
console.print("[red]❌ Usage: ./binder switch <html|pdf>[/red]")
|
|
return
|
|
binder.show_symlink_status()
|
|
format_type = sys.argv[2]
|
|
binder.switch(format_type)
|
|
|
|
elif command == "status":
|
|
binder.show_status()
|
|
|
|
elif command == "list":
|
|
binder.show_chapters()
|
|
|
|
elif command == "publish":
|
|
binder.show_symlink_status()
|
|
binder.publish()
|
|
|
|
elif command == "about":
|
|
binder.show_about()
|
|
|
|
elif command == "help":
|
|
binder.show_help()
|
|
|
|
elif command == "hello":
|
|
binder.show_hello()
|
|
|
|
elif command == "setup":
|
|
binder.setup_environment()
|
|
|
|
else:
|
|
console.print(f"[red]❌ Unknown command: {command}[/red]")
|
|
console.print("[yellow]💡 Use './binder help' to see available commands[/yellow]")
|
|
|
|
except KeyboardInterrupt:
|
|
console.print("\n[yellow]👋 Goodbye![/yellow]")
|
|
except Exception as e:
|
|
console.print(f"[red]❌ Error: {e}[/red]")
|
|
|
|
if __name__ == "__main__":
|
|
main() |