mirror of
https://github.com/harvard-edge/cs249r_book.git
synced 2026-03-11 17:49:25 -05:00
- Add comprehensive version type guidance (patch/minor/major/custom) - Improve spacing and line breaks throughout publishing flow - Add helpful explanations for GitHub Pages and release creation - Enhance confirmation dialogs with clear action descriptions - Add contextual prompts explaining what each choice does - Show version transitions (Previous → New) for clarity - Provide skip confirmations for optional deployment steps
2592 lines
113 KiB
Python
Executable File
2592 lines
113 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 datetime import datetime
|
||
|
||
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):
|
||
# Detect if we're in the book directory and adjust root_dir accordingly
|
||
current_dir = Path.cwd()
|
||
if current_dir.name == "book" and (current_dir.parent / "tools").exists():
|
||
# We're in the book directory, use parent as root
|
||
self.root_dir = current_dir.parent
|
||
self.book_dir = current_dir
|
||
else:
|
||
# We're in project root
|
||
self.root_dir = current_dir
|
||
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", "both"]:
|
||
console.print("[red]❌ Format must be 'html', 'pdf', or 'both'[/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]")
|
||
|
||
if format_type == "both":
|
||
# Build both formats sequentially
|
||
console.print("[blue]📚 Building both HTML and PDF formats...[/blue]")
|
||
|
||
# Build HTML first
|
||
console.print("[blue] 📄 Building HTML version...[/blue]")
|
||
html_success = self.build_full("html")
|
||
if not html_success:
|
||
console.print("[red]❌ HTML build failed![/red]")
|
||
return False
|
||
|
||
# Build PDF
|
||
console.print("[blue] 📄 Building PDF version...[/blue]")
|
||
pdf_success = self.build_full("pdf")
|
||
if not pdf_success:
|
||
console.print("[red]❌ PDF build failed![/red]")
|
||
return False
|
||
|
||
console.print("[green]✅ Both HTML and PDF builds completed successfully![/green]")
|
||
return True
|
||
|
||
# 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):
|
||
"""Enhanced manual publisher with integrated functionality"""
|
||
self._show_publisher_header()
|
||
|
||
# Step 0: Production Publishing Confirmation
|
||
console.print("[bold red]⚠️ You are about to publish to LIVE PRODUCTION systems.[/bold red]")
|
||
console.print("[red]This will create public releases and deploy to GitHub Pages.[/red]")
|
||
console.print("[yellow]Type 'PUBLISH' (all caps) to confirm: [/yellow]", end="")
|
||
confirmation = input().strip()
|
||
|
||
if confirmation != "PUBLISH":
|
||
console.print("[blue]ℹ️ Publishing cancelled - confirmation not received[/blue]")
|
||
console.print("[dim]To publish, you must type exactly: PUBLISH[/dim]")
|
||
return False
|
||
|
||
console.print("[green]✅ Production publishing confirmed[/green]")
|
||
|
||
# Step 1: Git Status Check
|
||
if not self._validate_git_status():
|
||
return False
|
||
|
||
# Step 2: Version Management
|
||
version_info = self._plan_version_release()
|
||
if not version_info:
|
||
return False
|
||
|
||
# Step 3: Confirmation
|
||
if not self._confirm_publishing(version_info):
|
||
return False
|
||
|
||
# Step 4: Building Phase
|
||
if not self._execute_build_phase():
|
||
return False
|
||
|
||
# Step 5: Publishing Phase
|
||
if not self._execute_publishing_phase(version_info):
|
||
return False
|
||
|
||
# Step 6: Success
|
||
self._show_publish_success(version_info)
|
||
return True
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# 🚀 Enhanced Publishing Methods
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
def _show_publisher_header(self):
|
||
"""Display the publisher header with prominent warning"""
|
||
# Big red warning box
|
||
warning = Panel(
|
||
"[bold red]⚠️ LIVE PUBLISHING WARNING ⚠️[/bold red]\n\n"
|
||
"[red]This tool publishes to PRODUCTION systems:[/red]\n"
|
||
"[red]• Creates public GitHub releases[/red]\n"
|
||
"[red]• Deploys to live GitHub Pages[/red]\n"
|
||
"[red]• Pushes changes to main branch[/red]\n"
|
||
"[red]• Updates public documentation[/red]\n\n"
|
||
"[bold yellow]⚡ USE WITH CAUTION ⚡[/bold yellow]\n"
|
||
"[dim]Only run when you're ready to publish changes publicly[/dim]",
|
||
title="[bold red]🚨 PRODUCTION DEPLOYMENT 🚨[/bold red]",
|
||
border_style="red",
|
||
padding=(1, 2)
|
||
)
|
||
console.print(warning)
|
||
console.print() # Add space
|
||
|
||
# Regular header below warning
|
||
header = Panel.fit(
|
||
"[bold blue]📚 MLSysBook Manual Publisher[/bold blue]\n"
|
||
"[dim]⚡ I build, compress, and publish your book[/dim]",
|
||
border_style="blue",
|
||
padding=(1, 2)
|
||
)
|
||
console.print(header)
|
||
|
||
def _validate_git_status(self):
|
||
"""Validate git status and handle branch management"""
|
||
console.print("\n[bold cyan]┌─ Git Status Check ─────────────────────────────────────────────────────────[/bold cyan]")
|
||
|
||
try:
|
||
# Get current branch
|
||
result = subprocess.run(['git', 'branch', '--show-current'],
|
||
capture_output=True, text=True, cwd=self.root_dir)
|
||
current_branch = result.stdout.strip()
|
||
|
||
# Get uncommitted changes count
|
||
result = subprocess.run(['git', 'status', '--porcelain'],
|
||
capture_output=True, text=True, cwd=self.root_dir)
|
||
changes_count = len(result.stdout.strip().split('\n')) if result.stdout.strip() else 0
|
||
|
||
console.print(f"[blue]ℹ️ Current branch: {current_branch}[/blue]")
|
||
console.print(f"[blue]ℹ️ Uncommitted changes: {changes_count}[/blue]")
|
||
|
||
# Handle branch management
|
||
if current_branch != "main":
|
||
console.print("[yellow]⚠️ You are not on the main branch[/yellow]")
|
||
console.print(f"[blue]ℹ️ Current branch: {current_branch}[/blue]")
|
||
console.print("[blue]ℹ️ Publishing requires being on main branch with latest dev changes[/blue]")
|
||
console.print()
|
||
console.print("[yellow]Move to main branch and pull in dev changes? [y/N]: [/yellow]", end="")
|
||
choice = input().strip().lower()
|
||
|
||
if choice in ['y', 'yes']:
|
||
# Step 1: Handle uncommitted changes
|
||
if changes_count > 0:
|
||
console.print("[yellow]⚠️ You have uncommitted changes on current branch[/yellow]")
|
||
console.print("[yellow]Commit current changes before switching? [y/N]: [/yellow]", end="")
|
||
commit_choice = input().strip().lower()
|
||
if commit_choice in ['y', 'yes']:
|
||
console.print("[white]Commit message [fix: update before switching to main]: [/white]", end="")
|
||
commit_msg = input().strip() or "fix: update before switching to main"
|
||
subprocess.run(['git', 'add', '.'], cwd=self.root_dir, check=True)
|
||
subprocess.run(['git', 'commit', '-m', commit_msg], cwd=self.root_dir, check=True)
|
||
console.print("[green]✅ Changes committed[/green]")
|
||
else:
|
||
console.print("[red]❌ Cannot switch branches with uncommitted changes[/red]")
|
||
return False
|
||
|
||
# Step 2: Switch to main branch
|
||
console.print("[purple]🔄 Switching to main branch...[/purple]")
|
||
subprocess.run(['git', 'checkout', 'main'], cwd=self.root_dir, check=True)
|
||
console.print("[green]✅ Switched to main branch[/green]")
|
||
|
||
# Step 3: Pull latest main
|
||
console.print("[purple]🔄 Pulling latest main branch changes...[/purple]")
|
||
subprocess.run(['git', 'pull', 'origin', 'main'], cwd=self.root_dir, check=True)
|
||
console.print("[green]✅ Main branch updated[/green]")
|
||
|
||
# Step 4: Merge dev changes
|
||
console.print("[purple]🔄 Merging dev branch changes into main...[/purple]")
|
||
merge_result = subprocess.run(['git', 'merge', 'dev'], cwd=self.root_dir, capture_output=True, text=True)
|
||
if merge_result.returncode == 0:
|
||
console.print("[green]✅ Dev changes merged into main[/green]")
|
||
|
||
# Step 5: Push updated main
|
||
console.print("[purple]🔄 Pushing updated main branch...[/purple]")
|
||
subprocess.run(['git', 'push', 'origin', 'main'], cwd=self.root_dir, check=True)
|
||
console.print("[green]✅ Main branch pushed with dev changes[/green]")
|
||
console.print("[blue]ℹ️ Ready to build and publish from main branch[/blue]")
|
||
else:
|
||
console.print("[red]❌ Merge conflicts detected[/red]")
|
||
console.print("[yellow]⚠️ Please resolve merge conflicts manually and try again[/yellow]")
|
||
console.print(f"[dim]Merge output: {merge_result.stderr}[/dim]")
|
||
return False
|
||
else:
|
||
console.print("[red]❌ Cannot publish without being on main branch[/red]")
|
||
console.print("[blue]ℹ️ Publishing from main ensures stable releases with all dev changes[/blue]")
|
||
return False
|
||
elif changes_count > 0:
|
||
console.print("[yellow]⚠️ You have uncommitted changes[/yellow]")
|
||
console.print("[yellow]Commit changes before continuing? [y/N]: [/yellow]", end="")
|
||
choice = input().strip().lower()
|
||
if choice in ['y', 'yes']:
|
||
console.print("[white]Commit message [fix: update before publishing]: [/white]", end="")
|
||
commit_msg = input().strip() or "fix: update before publishing"
|
||
subprocess.run(['git', 'add', '.'], cwd=self.root_dir, check=True)
|
||
subprocess.run(['git', 'commit', '-m', commit_msg], cwd=self.root_dir, check=True)
|
||
subprocess.run(['git', 'push', 'origin', 'main'], cwd=self.root_dir, check=True)
|
||
console.print("[green]✅ Changes committed and pushed[/green]")
|
||
else:
|
||
console.print("[red]❌ Cannot continue with uncommitted changes[/red]")
|
||
return False
|
||
else:
|
||
console.print("[green]✅ Git status is clean[/green]")
|
||
|
||
return True
|
||
|
||
except Exception as e:
|
||
console.print(f"[red]❌ Git validation failed: {e}[/red]")
|
||
return False
|
||
|
||
def _plan_version_release(self):
|
||
"""Plan version and release strategy"""
|
||
console.print("\n[bold cyan]┌─ Version Management ─────────────────────────────────────────────────────────[/bold cyan]")
|
||
|
||
try:
|
||
# Get current version
|
||
current_version = self._get_current_version()
|
||
console.print(f"[blue]ℹ️ Current version: {current_version}[/blue]")
|
||
console.print()
|
||
|
||
# Show version type guide
|
||
console.print("[bold white]📋 Version Type Guide:[/bold white]")
|
||
console.print("[green] 1. patch[/green] - Bug fixes, typos, small corrections (v1.0.0 → v1.0.1)")
|
||
console.print("[yellow] 2. minor[/yellow] - New content, features, improvements (v1.0.0 → v1.1.0)")
|
||
console.print("[red] 3. major[/red] - Breaking changes, major restructuring (v1.0.0 → v2.0.0)")
|
||
console.print("[blue] 4. custom[/blue] - Specify your own version number")
|
||
console.print()
|
||
|
||
console.print("[white]What type of changes are you publishing?[/white]")
|
||
console.print("[white]Select option [1-4] [2 for minor]: [/white]", end="")
|
||
choice = input().strip() or "2"
|
||
|
||
release_types = ["patch", "minor", "major", "custom"]
|
||
try:
|
||
choice_idx = int(choice) - 1
|
||
if 0 <= choice_idx < len(release_types):
|
||
release_type = release_types[choice_idx]
|
||
else:
|
||
release_type = "minor"
|
||
except ValueError:
|
||
release_type = "minor"
|
||
|
||
# Calculate new version
|
||
if release_type == "custom":
|
||
console.print()
|
||
console.print("[blue]ℹ️ Custom version format: vX.Y.Z (e.g., v1.2.3)[/blue]")
|
||
console.print("[white]Enter your custom version: [/white]", end="")
|
||
new_version = input().strip()
|
||
if not new_version.startswith('v'):
|
||
new_version = f"v{new_version}"
|
||
else:
|
||
new_version = self._calculate_next_version(current_version, release_type)
|
||
|
||
console.print()
|
||
console.print(f"[bold green]📌 New version will be: {new_version}[/bold green]")
|
||
console.print(f"[dim] Previous: {current_version} → New: {new_version}[/dim]")
|
||
|
||
# Check if version exists
|
||
if self._version_exists(new_version):
|
||
console.print(f"[yellow]⚠️ Version {new_version} already exists[/yellow]")
|
||
console.print("[yellow]Replace existing version? [y/N]: [/yellow]", end="")
|
||
choice = input().strip().lower()
|
||
if choice in ['y', 'yes']:
|
||
self._delete_version(new_version)
|
||
console.print("[green]✅ Existing version removed[/green]")
|
||
else:
|
||
console.print("[red]❌ Cannot continue with existing version[/red]")
|
||
return None
|
||
|
||
# Get release description
|
||
console.print()
|
||
console.print("[bold white]📝 Release Description:[/bold white]")
|
||
console.print("[dim] This will appear in the GitHub release and help users understand what changed[/dim]")
|
||
console.print("[white]Enter description [Content updates and improvements]: [/white]", end="")
|
||
description = input().strip() or "Content updates and improvements"
|
||
|
||
return {
|
||
'version': new_version,
|
||
'release_type': release_type,
|
||
'description': description
|
||
}
|
||
|
||
except Exception as e:
|
||
console.print(f"[red]❌ Version planning failed: {e}[/red]")
|
||
return None
|
||
|
||
def _confirm_publishing(self, version_info):
|
||
"""Show publishing summary and get confirmation"""
|
||
console.print("\n[bold cyan]┌─ Publishing Summary ─────────────────────────────────────────────────────────[/bold cyan]")
|
||
|
||
table = Table(show_header=False, box=None)
|
||
table.add_column("", style="white", width=15)
|
||
table.add_column("", style="green")
|
||
|
||
table.add_row("Version:", version_info['version'])
|
||
table.add_row("Type:", version_info['release_type'])
|
||
table.add_row("Description:", version_info['description'])
|
||
table.add_row("Repository:", self._get_repo_info() or "Unknown")
|
||
|
||
console.print(table)
|
||
console.print()
|
||
|
||
console.print("[bold white]🚀 This will:[/bold white]")
|
||
console.print("[green] • Build PDF and HTML versions[/green]")
|
||
console.print("[green] • Compress PDF for distribution[/green]")
|
||
console.print("[green] • Create git tag: {version}[/green]".format(version=version_info['version']))
|
||
console.print("[green] • Deploy to GitHub Pages (optional)[/green]")
|
||
console.print("[green] • Create GitHub release with PDF (optional)[/green]")
|
||
console.print()
|
||
|
||
console.print("[yellow]Proceed with publishing? [y/N]: [/yellow]", end="")
|
||
choice = input().strip().lower()
|
||
|
||
if choice in ['y', 'yes']:
|
||
return True
|
||
else:
|
||
console.print("[blue]ℹ️ Publishing cancelled[/blue]")
|
||
return False
|
||
|
||
def _execute_build_phase(self):
|
||
"""Execute the complete build phase"""
|
||
console.print("\n[bold cyan]┌─ Building Phase ─────────────────────────────────────────────────────────[/bold cyan]")
|
||
|
||
try:
|
||
# Clean builds
|
||
console.print("[purple]🔄 Cleaning previous builds...[/purple]")
|
||
self.clean()
|
||
|
||
# Build PDF first (more important, takes longer)
|
||
console.print("[purple]🔄 Building PDF version...[/purple]")
|
||
if not self.build_full("pdf"):
|
||
console.print("[red]❌ PDF build failed[/red]")
|
||
return False
|
||
console.print("[green]✅ PDF build completed[/green]")
|
||
|
||
# Compress PDF with Ghostscript
|
||
console.print("[purple]🔄 Compressing PDF...[/purple]")
|
||
if not self._compress_pdf():
|
||
console.print("[yellow]⚠️ PDF compression failed, continuing with uncompressed PDF[/yellow]")
|
||
|
||
# Build HTML (faster, after PDF is ready)
|
||
console.print("[purple]🔄 Building HTML version...[/purple]")
|
||
if not self.build_full("html"):
|
||
console.print("[red]❌ HTML build failed[/red]")
|
||
return False
|
||
console.print("[green]✅ HTML build completed[/green]")
|
||
|
||
return True
|
||
|
||
except Exception as e:
|
||
console.print(f"[red]❌ Build phase failed: {e}[/red]")
|
||
return False
|
||
|
||
# Helper methods
|
||
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 "v0.0.0"
|
||
|
||
# Find the highest version
|
||
versions = [tag for tag in tags if tag.startswith('v')]
|
||
if not versions:
|
||
return "v0.0.0"
|
||
|
||
return max(versions, key=lambda v: [int(x) for x in v[1:].split('.')])
|
||
|
||
except Exception:
|
||
return "v0.0.0"
|
||
|
||
def _calculate_next_version(self, current_version, release_type):
|
||
"""Calculate next version based on release type"""
|
||
try:
|
||
version_num = current_version[1:] # Remove 'v'
|
||
major, minor, patch = map(int, version_num.split('.'))
|
||
|
||
if release_type == "major":
|
||
return f"v{major + 1}.0.0"
|
||
elif release_type == "minor":
|
||
return f"v{major}.{minor + 1}.0"
|
||
else: # patch
|
||
return f"v{major}.{minor}.{patch + 1}"
|
||
except:
|
||
return "v1.0.0"
|
||
|
||
def _version_exists(self, version):
|
||
"""Check if a version tag exists"""
|
||
try:
|
||
result = subprocess.run(['git', 'tag', '-l', version],
|
||
capture_output=True, text=True, cwd=self.root_dir)
|
||
return bool(result.stdout.strip())
|
||
except:
|
||
return False
|
||
|
||
def _delete_version(self, version):
|
||
"""Delete a version tag"""
|
||
try:
|
||
subprocess.run(['git', 'tag', '-d', version], cwd=self.root_dir, check=True)
|
||
subprocess.run(['git', 'push', 'origin', '--delete', version],
|
||
cwd=self.root_dir, capture_output=True)
|
||
except:
|
||
pass
|
||
|
||
def _get_repo_info(self):
|
||
"""Get repository info"""
|
||
try:
|
||
result = subprocess.run(['git', 'remote', 'get-url', 'origin'],
|
||
capture_output=True, text=True, cwd=self.root_dir)
|
||
remote_url = result.stdout.strip()
|
||
|
||
import re
|
||
match = re.search(r'github\.com[:/]([^/]+)/([^/]+)(\.git)?$', remote_url)
|
||
if match:
|
||
return f"{match.group(1)}/{match.group(2)}"
|
||
except:
|
||
pass
|
||
return None
|
||
|
||
def _compress_pdf(self):
|
||
"""Compress PDF using Ghostscript with ebook settings"""
|
||
pdf_path = self.build_dir / "pdf" / "Machine-Learning-Systems.pdf"
|
||
compressed_path = self.build_dir / "pdf" / "Machine-Learning-Systems-compressed.pdf"
|
||
|
||
# Check if Ghostscript is available
|
||
try:
|
||
subprocess.run(['gs', '--version'], capture_output=True, check=True)
|
||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||
console.print("[yellow]⚠️ Ghostscript not found - skipping compression[/yellow]")
|
||
console.print("[blue]ℹ️ Install with: brew install ghostscript (macOS) or apt-get install ghostscript (Linux)[/blue]")
|
||
return False
|
||
|
||
if not pdf_path.exists():
|
||
console.print(f"[red]❌ PDF not found for compression: {pdf_path}[/red]")
|
||
return False
|
||
|
||
try:
|
||
# Get original file size
|
||
original_size = pdf_path.stat().st_size
|
||
original_size_mb = original_size / (1024 * 1024)
|
||
|
||
console.print(f"[blue]ℹ️ Original PDF size: {original_size_mb:.1f} MB[/blue]")
|
||
|
||
# Compress with Ghostscript
|
||
with Progress(
|
||
SpinnerColumn(),
|
||
TextColumn("[progress.description]{task.description}"),
|
||
BarColumn(),
|
||
TimeElapsedColumn(),
|
||
console=console
|
||
) as progress:
|
||
task = progress.add_task("Compressing with Ghostscript ebook settings...", total=None)
|
||
|
||
result = subprocess.run([
|
||
'gs', '-sDEVICE=pdfwrite',
|
||
'-dCompatibilityLevel=1.4',
|
||
'-dPDFSETTINGS=/ebook',
|
||
'-dNOPAUSE', '-dQUIET', '-dBATCH',
|
||
f'-sOutputFile={compressed_path}',
|
||
str(pdf_path)
|
||
], capture_output=True)
|
||
|
||
progress.update(task, completed=True)
|
||
|
||
if result.returncode == 0 and compressed_path.exists():
|
||
# Get compressed file size
|
||
compressed_size = compressed_path.stat().st_size
|
||
compressed_size_mb = compressed_size / (1024 * 1024)
|
||
compression_ratio = ((original_size - compressed_size) / original_size) * 100
|
||
|
||
# Replace original with compressed version
|
||
shutil.move(str(compressed_path), str(pdf_path))
|
||
|
||
console.print("[green]✅ PDF compressed successfully[/green]")
|
||
console.print(f"[blue]ℹ️ Compressed size: {compressed_size_mb:.1f} MB (saved {compression_ratio:.1f}%)[/blue]")
|
||
return True
|
||
else:
|
||
console.print("[red]❌ PDF compression failed[/red]")
|
||
# Clean up failed compression file
|
||
if compressed_path.exists():
|
||
compressed_path.unlink()
|
||
return False
|
||
|
||
except Exception as e:
|
||
console.print(f"[red]❌ PDF compression error: {e}[/red]")
|
||
# Clean up failed compression file
|
||
if compressed_path.exists():
|
||
compressed_path.unlink()
|
||
return False
|
||
|
||
def _execute_publishing_phase(self, version_info):
|
||
"""Execute the publishing phase"""
|
||
console.print("\n[bold cyan]┌─ Publishing Phase ─────────────────────────────────────────────────────────[/bold cyan]")
|
||
|
||
try:
|
||
version = version_info['version']
|
||
description = version_info['description']
|
||
|
||
# Create git tag
|
||
console.print("[purple]🔄 Creating git tag...[/purple]")
|
||
subprocess.run(['git', 'tag', '-a', version, '-m', f"Release {version}: {description}"],
|
||
cwd=self.root_dir, check=True)
|
||
subprocess.run(['git', 'push', 'origin', version],
|
||
cwd=self.root_dir, check=True)
|
||
console.print("[green]✅ Git tag created and pushed[/green]")
|
||
|
||
# GitHub Pages deployment
|
||
console.print()
|
||
console.print("[bold white]🌐 GitHub Pages Deployment:[/bold white]")
|
||
console.print("[dim] Deploy the website to your public GitHub Pages (recommended)[/dim]")
|
||
console.print("[yellow]Deploy to GitHub Pages? [y/N]: [/yellow]", end="")
|
||
choice = input().strip().lower()
|
||
if choice in ['y', 'yes']:
|
||
console.print("[purple]🔄 Deploying to GitHub Pages...[/purple]")
|
||
if self._deploy_to_github_pages():
|
||
console.print("[green]✅ Deployed to GitHub Pages[/green]")
|
||
else:
|
||
console.print("[yellow]⚠️ GitHub Pages deployment failed[/yellow]")
|
||
else:
|
||
console.print("[blue]ℹ️ Skipping GitHub Pages deployment[/blue]")
|
||
|
||
# GitHub release creation
|
||
console.print()
|
||
console.print("[bold white]📦 GitHub Release Creation:[/bold white]")
|
||
console.print("[dim] Create a public release with downloadable PDF and AI-generated release notes[/dim]")
|
||
console.print("[yellow]Create GitHub release? [y/N]: [/yellow]", end="")
|
||
choice = input().strip().lower()
|
||
if choice in ['y', 'yes']:
|
||
pdf_path = self.build_dir / "pdf" / "Machine-Learning-Systems.pdf"
|
||
if self._create_github_release(version, description, pdf_path):
|
||
console.print("[green]✅ GitHub release created[/green]")
|
||
else:
|
||
console.print("[yellow]⚠️ GitHub release creation failed[/yellow]")
|
||
else:
|
||
console.print("[blue]ℹ️ Skipping GitHub release creation[/blue]")
|
||
|
||
return True
|
||
|
||
except Exception as e:
|
||
console.print(f"[red]❌ Publishing phase failed: {e}[/red]")
|
||
return False
|
||
|
||
def _show_publish_success(self, version_info):
|
||
"""Show success summary"""
|
||
console.print("\n[bold cyan]┌─ Publication Complete ─────────────────────────────────────────────────────────[/bold cyan]")
|
||
console.print("[green]🎉 Publication successful![/green]")
|
||
|
||
pdf_path = self.build_dir / "pdf" / "Machine-Learning-Systems.pdf"
|
||
pdf_size = "Unknown"
|
||
if pdf_path.exists():
|
||
size_mb = pdf_path.stat().st_size / (1024 * 1024)
|
||
pdf_size = f"{size_mb:.1f} MB"
|
||
|
||
table = Table(show_header=False, box=None)
|
||
table.add_column("", style="white", width=25)
|
||
table.add_column("", style="green")
|
||
|
||
table.add_row("✅ Version:", version_info['version'])
|
||
table.add_row("✅ PDF build:", f"completed and compressed ({pdf_size})")
|
||
table.add_row("✅ HTML build:", "completed")
|
||
table.add_row("✅ Git tag:", "created and pushed")
|
||
|
||
console.print(table)
|
||
|
||
repo_info = self._get_repo_info()
|
||
if repo_info:
|
||
console.print(f"\n[white]🌐 Access your publication:[/white]")
|
||
owner, name = repo_info.split('/')
|
||
console.print(f"[blue] 📖 Web version: https://{owner}.github.io/{name}[/blue]")
|
||
console.print(f"[blue] 📦 Releases: https://github.com/{repo_info}/releases[/blue]")
|
||
console.print(f"[blue] 📄 PDF: https://github.com/{repo_info}/releases/download/{version_info['version']}/Machine-Learning-Systems.pdf[/blue]")
|
||
|
||
console.print("\n[green]✅ Ready for distribution! 🚀[/green]")
|
||
|
||
def _deploy_to_github_pages(self):
|
||
"""Deploy to GitHub Pages"""
|
||
try:
|
||
result = subprocess.run(['quarto', 'publish', 'gh-pages', '--no-render'],
|
||
cwd=self.book_dir, capture_output=True)
|
||
return result.returncode == 0
|
||
except:
|
||
return False
|
||
|
||
def _create_github_release(self, version, description, pdf_path):
|
||
"""Create GitHub release with AI-enhanced release notes"""
|
||
try:
|
||
# Check if GitHub CLI is available
|
||
subprocess.run(['gh', '--version'], capture_output=True, check=True)
|
||
|
||
console.print("[purple]🔄 Generating enhanced release notes...[/purple]")
|
||
|
||
# Generate enhanced release notes with AI
|
||
release_notes = self._generate_enhanced_release_notes(version, description)
|
||
|
||
# Create release
|
||
cmd = [
|
||
'gh', 'release', 'create', version,
|
||
'--title', f"{version}: {description}",
|
||
'--notes', release_notes,
|
||
'--draft',
|
||
str(pdf_path)
|
||
]
|
||
|
||
result = subprocess.run(cmd, cwd=self.root_dir, capture_output=True)
|
||
return result.returncode == 0
|
||
|
||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||
console.print("[yellow]⚠️ GitHub CLI not available - create release manually[/yellow]")
|
||
return False
|
||
|
||
def _generate_enhanced_release_notes(self, version, description):
|
||
"""Generate AI-enhanced release notes with GitHub-style logs"""
|
||
console.print("[blue]ℹ️ Generating release notes with AI enhancement...[/blue]")
|
||
|
||
# Step 1: Generate git log from previous version
|
||
git_changes = self._generate_git_log(version)
|
||
|
||
# Step 2: Try to enhance with Ollama AI
|
||
ai_summary = self._generate_ai_summary(version, description, git_changes)
|
||
|
||
# Step 3: Combine AI summary with raw commits
|
||
if ai_summary:
|
||
release_notes = f"""# Release {version}: {description}
|
||
|
||
{ai_summary}
|
||
|
||
---
|
||
|
||
## Full Change Log
|
||
|
||
{git_changes}"""
|
||
else:
|
||
# Fallback to GitHub-style release notes without AI
|
||
release_notes = f"""# Release {version}: {description}
|
||
|
||
## Overview
|
||
This release includes the following changes:
|
||
|
||
{git_changes}"""
|
||
|
||
return release_notes
|
||
|
||
def _generate_git_log(self, current_version):
|
||
"""Generate detailed git log from previous version"""
|
||
try:
|
||
# Get previous version
|
||
previous_version = self._get_previous_version()
|
||
|
||
console.print(f"[blue]ℹ️ Generating git log from {previous_version} to current commit...[/blue]")
|
||
|
||
git_changes = ""
|
||
|
||
if previous_version and previous_version != "v0.0.0":
|
||
# Get commit log with details
|
||
try:
|
||
result = subprocess.run([
|
||
'git', 'log', f'{previous_version}..HEAD',
|
||
'--pretty=format:- %s (%h)',
|
||
'--no-merges'
|
||
], capture_output=True, text=True, cwd=self.root_dir)
|
||
|
||
if result.stdout.strip():
|
||
git_changes = result.stdout.strip()
|
||
else:
|
||
git_changes = "- No changes since last version"
|
||
|
||
except subprocess.CalledProcessError:
|
||
# Fallback to recent commits
|
||
result = subprocess.run([
|
||
'git', 'log', '--oneline', '--no-merges', '-n', '20'
|
||
], capture_output=True, text=True, cwd=self.root_dir)
|
||
|
||
commits = result.stdout.strip().split('\n') if result.stdout.strip() else []
|
||
git_changes = '\n'.join([f"- {commit}" for commit in commits if commit.strip()])
|
||
else:
|
||
# No previous version, get recent commits
|
||
result = subprocess.run([
|
||
'git', 'log', '--oneline', '--no-merges', '-n', '20'
|
||
], capture_output=True, text=True, cwd=self.root_dir)
|
||
|
||
commits = result.stdout.strip().split('\n') if result.stdout.strip() else []
|
||
git_changes = '\n'.join([f"- {commit}" for commit in commits if commit.strip()])
|
||
|
||
return git_changes if git_changes else "- Initial release"
|
||
|
||
except Exception as e:
|
||
console.print(f"[yellow]⚠️ Could not generate git log: {e}[/yellow]")
|
||
return "- Recent changes (git log unavailable)"
|
||
|
||
def _get_previous_version(self):
|
||
"""Get the previous version tag"""
|
||
try:
|
||
result = subprocess.run(['git', 'tag', '--list', 'v*', '--sort=-version:refname'],
|
||
capture_output=True, text=True, cwd=self.root_dir)
|
||
tags = result.stdout.strip().split('\n') if result.stdout.strip() else []
|
||
|
||
# Get the second most recent tag (first is current, if it exists)
|
||
versions = [tag for tag in tags if tag.startswith('v')]
|
||
return versions[1] if len(versions) > 1 else versions[0] if versions else "v0.0.0"
|
||
|
||
except Exception:
|
||
return "v0.0.0"
|
||
|
||
def _generate_ai_summary(self, version, description, git_changes):
|
||
"""Generate AI-enhanced summary using Ollama"""
|
||
try:
|
||
# Check if Ollama is available
|
||
result = subprocess.run(['ollama', 'list'], capture_output=True, text=True)
|
||
if result.returncode != 0:
|
||
console.print("[yellow]⚠️ Ollama not available - using standard release notes[/yellow]")
|
||
return None
|
||
|
||
console.print("[purple]🤖 Generating AI summary with Ollama...[/purple]")
|
||
|
||
# Find an available model
|
||
available_models = result.stdout.strip().split('\n')[1:] # Skip header
|
||
if not available_models or not available_models[0].strip():
|
||
# Try to pull a default model
|
||
console.print("[blue]ℹ️ Pulling default model (llama3.2:1b)...[/blue]")
|
||
pull_result = subprocess.run(['ollama', 'pull', 'llama3.2:1b'],
|
||
capture_output=True, timeout=120)
|
||
if pull_result.returncode != 0:
|
||
console.print("[yellow]⚠️ Could not pull AI model - using standard release notes[/yellow]")
|
||
return None
|
||
model = 'llama3.2:1b'
|
||
else:
|
||
# Use first available model
|
||
model = available_models[0].split()[0] if available_models[0].strip() else 'llama3.2:1b'
|
||
|
||
console.print(f"[blue]ℹ️ Using AI model: {model}[/blue]")
|
||
|
||
# Create AI prompt
|
||
prompt = f"""Please create professional release notes for version {version} based on the following git commits.
|
||
|
||
Release Description: {description}
|
||
Release Type: minor
|
||
|
||
Git Commits:
|
||
{git_changes}
|
||
|
||
Please format as:
|
||
- Brief overview of this release
|
||
- Key changes organized by category (Features, Bug Fixes, Improvements, etc.)
|
||
- Keep it professional but accessible
|
||
- Include any breaking changes if evident
|
||
- Do not include the git commit hashes or technical details
|
||
- Focus on user-facing changes and improvements"""
|
||
|
||
# Generate AI summary with timeout
|
||
with Progress(
|
||
SpinnerColumn(),
|
||
TextColumn("[progress.description]{task.description}"),
|
||
console=console
|
||
) as progress:
|
||
task = progress.add_task("Generating AI summary...", total=None)
|
||
|
||
result = subprocess.run([
|
||
'ollama', 'run', model
|
||
], input=prompt, capture_output=True, text=True, timeout=60)
|
||
|
||
progress.update(task, completed=True)
|
||
|
||
if result.returncode == 0 and result.stdout.strip():
|
||
console.print("[green]✅ AI summary generated successfully[/green]")
|
||
return result.stdout.strip()
|
||
else:
|
||
console.print("[yellow]⚠️ AI generation failed - using standard release notes[/yellow]")
|
||
return None
|
||
|
||
except subprocess.TimeoutExpired:
|
||
console.print("[yellow]⚠️ AI generation timed out - using standard release notes[/yellow]")
|
||
return None
|
||
except Exception as e:
|
||
console.print(f"[yellow]⚠️ AI generation error: {e} - using standard release notes[/yellow]")
|
||
return None
|
||
|
||
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", "Manual publisher (interactive)", "./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("pu", "publish")
|
||
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 b - both ", style="cyan")
|
||
examples.append("# Build both HTML and 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 build - both # Build both HTML and PDF\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
|
||
step1_panel = Panel(
|
||
"[bold blue]🔍 Checking your environment...[/bold blue]",
|
||
title="📋 Step 1: Environment Check",
|
||
border_style="blue",
|
||
padding=(0, 1)
|
||
)
|
||
console.print(step1_panel)
|
||
self._check_environment()
|
||
|
||
# Step 2: System dependencies
|
||
step2_panel = Panel(
|
||
"[bold blue]🔧 Checking system dependencies...[/bold blue]",
|
||
title="📋 Step 2: System Dependencies",
|
||
border_style="blue",
|
||
padding=(0, 1)
|
||
)
|
||
console.print(step2_panel)
|
||
if not self._check_system_dependencies():
|
||
console.print("[red]❌ System dependency check failed[/red]")
|
||
return
|
||
|
||
# Step 3: Install/configure tools
|
||
step3_panel = Panel(
|
||
"[bold blue]📦 Installing tools and packages...[/bold blue]",
|
||
title="📋 Step 3: Tool Installation",
|
||
border_style="blue",
|
||
padding=(0, 1)
|
||
)
|
||
console.print(step3_panel)
|
||
self._install_tools()
|
||
|
||
# Step 4: Configure Git
|
||
step4_panel = Panel(
|
||
"[bold blue]🔧 Configuring Git...[/bold blue]",
|
||
title="📋 Step 4: Git Configuration",
|
||
border_style="blue",
|
||
padding=(0, 1)
|
||
)
|
||
console.print(step4_panel)
|
||
self._configure_git()
|
||
|
||
# Step 5: Environment preferences
|
||
step5_panel = Panel(
|
||
"[bold blue]⚙️ Configuring preferences...[/bold blue]",
|
||
title="📋 Step 5: Environment Preferences",
|
||
border_style="blue",
|
||
padding=(0, 1)
|
||
)
|
||
console.print(step5_panel)
|
||
self._configure_preferences()
|
||
|
||
# Step 6: Test setup
|
||
step6_panel = Panel(
|
||
"[bold blue]🧪 Testing setup...[/bold blue]",
|
||
title="📋 Step 6: Test Setup",
|
||
border_style="blue",
|
||
padding=(0, 1)
|
||
)
|
||
console.print(step6_panel)
|
||
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) [N]: [/bold]", end="")
|
||
update_choice = input().strip().lower()
|
||
if not update_choice:
|
||
update_choice = 'n'
|
||
|
||
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/both)',
|
||
'default': 'html',
|
||
'options': ['html', 'pdf', 'both'],
|
||
'description': '📚 Build Preferences:\n • html - Web format (faster, interactive)\n • pdf - Print format (slower, academic)\n • both - Build both formats (comprehensive)'
|
||
},
|
||
{
|
||
'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',
|
||
'pu': 'publish',
|
||
'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", "both"]:
|
||
console.print("[red]❌ Format must be 'html', 'pdf', or 'both'[/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", "both"]:
|
||
console.print("[red]❌ Format must be 'html', 'pdf', or 'both'[/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", "both"]:
|
||
console.print("[red]❌ Format must be 'html', 'pdf', or 'both'[/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|both>[/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() |