mirror of
https://github.com/harvard-edge/cs249r_book.git
synced 2026-03-11 17:49:25 -05:00
Simplifies frontmatter inclusion logic to retain only 'index.qmd' when building single chapter books, removing unnecessary checks for other frontmatter files.
4067 lines
191 KiB
Python
Executable File
4067 lines
191 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 quarto directory and adjust root_dir accordingly
|
||
current_dir = Path.cwd()
|
||
if current_dir.name == "quarto" and (current_dir.parent / "tools").exists():
|
||
# We're in the quarto 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 / "quarto"
|
||
|
||
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 get_output_dir(self, format_type="html"):
|
||
"""Get the output directory from Quarto configuration"""
|
||
try:
|
||
import yaml
|
||
|
||
# Choose the appropriate config file
|
||
config_file = self.html_config if format_type == "html" else self.pdf_config
|
||
|
||
if not config_file.exists():
|
||
console.print(f"[yellow]⚠️ Config file not found: {config_file}[/yellow]")
|
||
# Fallback to default
|
||
return self.book_dir / f"_build/{format_type}"
|
||
|
||
# Read and parse the YAML config
|
||
with open(config_file, 'r', encoding='utf-8') as f:
|
||
config = yaml.safe_load(f)
|
||
|
||
# Extract output-dir from project section
|
||
output_dir = config.get('project', {}).get('output-dir')
|
||
|
||
if output_dir:
|
||
# Convert to Path object, relative to book_dir
|
||
if output_dir.startswith('/'):
|
||
# Absolute path
|
||
return Path(output_dir)
|
||
else:
|
||
# Relative path (relative to book_dir)
|
||
return self.book_dir / output_dir
|
||
else:
|
||
# Fallback to default Quarto behavior
|
||
return self.book_dir / f"_build/{format_type}"
|
||
|
||
except Exception as e:
|
||
console.print(f"[yellow]⚠️ Could not read output dir from config: {e}[/yellow]")
|
||
# Fallback to default
|
||
return self.book_dir / f"_build/{format_type}"
|
||
|
||
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 'index.qmd' in line:
|
||
# Keep only index.qmd from 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 'index.qmd' in line:
|
||
# Keep only index.qmd from 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
|
||
output_dir = self.get_output_dir(format_type)
|
||
output_dir.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: {self.get_output_dir(format_type)}/")
|
||
|
||
# Open browser for HTML builds
|
||
if format_type == "html" and result:
|
||
self.open_browser_if_needed(f"{self.get_output_dir(format_type)}/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
|
||
output_dir = self.get_output_dir(format_type)
|
||
output_dir.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: {self.get_output_dir(format_type)}/[/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.get_output_dir("html").parent, "Build directory (all formats)"), # Parent of _build/html is _build
|
||
(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.get_output_dir("html").parent, "Build directory"), # Parent of _build/html is _build
|
||
(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
|
||
output_dir = self.get_output_dir(format_type)
|
||
output_dir.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: {self.get_output_dir(format_type)}/[/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):
|
||
"""Deploy website updates to GitHub Pages (no formal release)"""
|
||
self._show_publish_header()
|
||
|
||
# Step 0: Comprehensive Publishing Overview
|
||
console.print("[bold blue]📚 Website Publishing Overview[/bold blue]")
|
||
console.print()
|
||
console.print("[bold white]🎯 What this will do:[/bold white]")
|
||
console.print("[green] ✅ Build HTML version of your textbook[/green]")
|
||
console.print("[green] ✅ Build PDF version of your textbook[/green]")
|
||
console.print("[green] ✅ Compress PDF for faster downloads[/green]")
|
||
console.print("[green] ✅ Deploy to GitHub Pages (https://mlsysbook.ai)[/green]")
|
||
console.print("[green] ✅ Make content publicly available to students and educators[/green]")
|
||
console.print()
|
||
console.print("[bold white]⚠️ Important Notes:[/bold white]")
|
||
console.print("[yellow] • This creates a PUBLIC deployment[/yellow]")
|
||
console.print("[yellow] • Changes will be live within 5-10 minutes[/yellow]")
|
||
console.print("[yellow] • No version tags or formal releases created[/yellow]")
|
||
console.print("[yellow] • Anyone can access the updated content[/yellow]")
|
||
console.print()
|
||
console.print("[bold white]🔍 What will be checked:[/bold white]")
|
||
console.print("[blue] • Git repository status and branch[/blue]")
|
||
console.print("[blue] • Build artifacts and file sizes[/blue]")
|
||
console.print("[blue] • PDF quality and completeness[/blue]")
|
||
console.print("[blue] • HTML build validity[/blue]")
|
||
console.print()
|
||
console.print("[bold white]⏱️ Estimated time:[/bold white]")
|
||
console.print("[cyan] • PDF build: 3-5 minutes[/cyan]")
|
||
console.print("[cyan] • HTML build: 1-2 minutes[/cyan]")
|
||
console.print("[cyan] • PDF compression: 30-60 seconds[/cyan]")
|
||
console.print("[cyan] • GitHub Pages deployment: 1-2 minutes[/cyan]")
|
||
console.print("[cyan] • Total: 6-10 minutes[/cyan]")
|
||
console.print()
|
||
|
||
# Step 1: Detailed Confirmation
|
||
console.print("[bold red]🚨 PRODUCTION DEPLOYMENT CONFIRMATION[/bold red]")
|
||
console.print("[red]This will deploy your textbook to the live website at https://mlsysbook.ai[/red]")
|
||
console.print("[red]The content will be immediately available to students, educators, and the public.[/red]")
|
||
console.print()
|
||
console.print("[bold white]Options:[/bold white]")
|
||
console.print("[green] y/yes[/green] - Proceed with live deployment to production")
|
||
console.print("[red] n/no[/red] - Cancel deployment [default]")
|
||
console.print("[blue] info[/blue] - Show more details about the deployment process")
|
||
console.print()
|
||
console.print("[yellow]Deploy to production? [y/N/info] [default: N]: [/yellow]", end="")
|
||
confirmation = input().strip().lower()
|
||
|
||
if confirmation == "info":
|
||
self._show_detailed_publish_info()
|
||
console.print()
|
||
console.print("[yellow]Deploy to production? [y/N] [default: N]: [/yellow]", end="")
|
||
confirmation = input().strip().lower()
|
||
|
||
if confirmation not in ['y', 'yes']:
|
||
console.print("[blue]ℹ️ Website deployment cancelled[/blue]")
|
||
console.print("[dim]💡 You can run this command again when ready to deploy[/dim]")
|
||
return False
|
||
|
||
console.print("[green]✅ Production deployment confirmed[/green]")
|
||
console.print()
|
||
|
||
# Step 2: Pre-deployment Checks
|
||
console.print("[bold blue]🔍 Pre-deployment Validation[/bold blue]")
|
||
if not self._validate_git_status_for_publish():
|
||
return False
|
||
|
||
# Step 3: Building Phase with Progress Updates
|
||
console.print("[bold blue]🏗️ Building Phase[/bold blue]")
|
||
console.print("[dim]This phase will build both HTML and PDF versions of your textbook[/dim]")
|
||
if not self._execute_build_phase():
|
||
return False
|
||
|
||
# Step 4: Deployment Phase
|
||
console.print("[bold blue]🚀 Deployment Phase[/bold blue]")
|
||
console.print("[dim]This phase will deploy your built content to GitHub Pages[/dim]")
|
||
if not self._deploy_to_github_pages():
|
||
console.print("[red]❌ GitHub Pages deployment failed[/red]")
|
||
return False
|
||
|
||
# Step 5: Success Summary
|
||
self._show_publish_website_success()
|
||
return True
|
||
|
||
def _show_detailed_publish_info(self):
|
||
"""Show detailed information about the publishing process"""
|
||
console.print()
|
||
console.print("[bold blue]📋 Detailed Publishing Information[/bold blue]")
|
||
console.print()
|
||
console.print("[bold white]🔧 Technical Process:[/bold white]")
|
||
console.print("[blue] 1. Git Status Check[/blue]")
|
||
console.print("[dim] • Verify repository is clean or changes are committed[/dim]")
|
||
console.print("[dim] • Ensure we're on the main branch[/dim]")
|
||
console.print("[dim] • Check for any merge conflicts[/dim]")
|
||
console.print()
|
||
console.print("[blue] 2. Build Phase[/blue]")
|
||
console.print("[dim] • Clean previous build artifacts[/dim]")
|
||
console.print("[dim] • Build PDF version (3-5 minutes)[/dim]")
|
||
console.print("[dim] • Build HTML version (1-2 minutes)[/dim]")
|
||
console.print("[dim] • Validate file sizes and completeness[/dim]")
|
||
console.print("[dim] • Compress PDF for web distribution[/dim]")
|
||
console.print()
|
||
console.print("[blue] 3. Deployment Phase[/blue]")
|
||
console.print("[dim] • Copy build artifacts to _site directory[/dim]")
|
||
console.print("[dim] • Copy PDF to assets folder[/dim]")
|
||
console.print("[dim] • Deploy to GitHub Pages via quarto publish[/dim]")
|
||
console.print("[dim] • Clean up temporary files[/dim]")
|
||
console.print()
|
||
console.print("[bold white]🌐 What Gets Deployed:[/bold white]")
|
||
console.print("[green] • Complete HTML website[/green]")
|
||
console.print("[green] • All textbook chapters and content[/green]")
|
||
console.print("[green] • Navigation and search functionality[/green]")
|
||
console.print("[green] • PDF download link[/green]")
|
||
console.print("[green] • All images, figures, and media[/green]")
|
||
console.print()
|
||
console.print("[bold white]📊 Quality Checks:[/bold white]")
|
||
console.print("[yellow] • PDF file size validation (>1MB)[/yellow]")
|
||
console.print("[yellow] • HTML build completeness[/yellow]")
|
||
console.print("[yellow] • Asset file integrity[/yellow]")
|
||
console.print("[yellow] • Deployment success verification[/yellow]")
|
||
console.print()
|
||
console.print("[bold white]⚠️ Potential Issues:[/bold white]")
|
||
console.print("[red] • Build failures due to syntax errors[/red]")
|
||
console.print("[red] • Large file sizes causing timeout[/red]")
|
||
console.print("[red] • Network connectivity issues[/red]")
|
||
console.print("[red] • GitHub Pages deployment limits[/red]")
|
||
console.print()
|
||
console.print("[bold white]🔄 Rollback Options:[/bold white]")
|
||
console.print("[blue] • Previous version remains accessible[/blue]")
|
||
console.print("[blue] • Can redeploy previous commit if needed[/blue]")
|
||
console.print("[blue] • GitHub Pages maintains version history[/blue]")
|
||
console.print()
|
||
console.print("[bold white]📞 Support:[/bold white]")
|
||
console.print("[dim] • Check build logs for detailed error messages[/dim]")
|
||
console.print("[dim] • Review GitHub Actions for deployment status[/dim]")
|
||
console.print("[dim] • Contact maintainers if deployment fails[/dim]")
|
||
|
||
def release(self):
|
||
"""Create formal release with versioning and GitHub release"""
|
||
self._show_release_header()
|
||
|
||
# Validate we're on main branch
|
||
if not self._validate_main_branch_for_release():
|
||
return False
|
||
|
||
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 (textbook-specific)
|
||
console.print("[bold white]📋 Textbook Release Type Guide:[/bold white]")
|
||
console.print("[green] 1. patch[/green] - Typos, corrections, minor fixes (v1.0.0 → v1.0.1)")
|
||
console.print("[yellow] 2. minor[/yellow] - New chapters, labs, major content (v1.0.0 → v1.1.0)")
|
||
console.print("[red] 3. major[/red] - Complete restructuring, new edition (v1.0.0 → v2.0.0)")
|
||
console.print("[blue] 4. custom[/blue] - Specify your own version number")
|
||
console.print()
|
||
console.print("[dim]💡 Examples:[/dim]")
|
||
console.print("[dim] • Patch: Fixed equations in Chapter 8, corrected references[/dim]")
|
||
console.print("[dim] • Minor: Added new \"Federated Learning\" chapter[/dim]")
|
||
console.print("[dim] • Major: Restructured entire book for new academic year[/dim]")
|
||
console.print()
|
||
|
||
console.print("[white]What type of changes are you releasing?[/white]")
|
||
console.print("[white]Select option [1-4] [default: 2 for minor]: [/white]", end="")
|
||
choice = input().strip()
|
||
if not choice:
|
||
choice = "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]⚠️ Git tag {new_version} already exists[/yellow]")
|
||
console.print("[dim] This will delete the existing tag from both local and remote repositories[/dim]")
|
||
console.print("[dim] and recreate it with the new release[/dim]")
|
||
console.print()
|
||
console.print("[bold white]Options:[/bold white]")
|
||
console.print("[green] y/yes[/green] - Delete existing tag and recreate with new release")
|
||
console.print("[red] n/no[/red] - Cancel publishing (keep existing tag) [default]")
|
||
console.print("[yellow]Delete existing tag and recreate? [y/N] [default: N]: [/yellow]", end="")
|
||
choice = input().strip().lower()
|
||
if not choice:
|
||
choice = 'n'
|
||
if choice in ['y', 'yes']:
|
||
console.print("[purple]🔄 Deleting existing tag...[/purple]")
|
||
self._delete_version(new_version)
|
||
console.print(f"[green]✅ Existing tag {new_version} deleted from local and remote[/green]")
|
||
else:
|
||
console.print("[blue]ℹ️ Release cancelled - keeping existing tag[/blue]")
|
||
return False
|
||
|
||
# Get release description
|
||
console.print()
|
||
console.print("[white]Enter release description: [/white]", end="")
|
||
description = input().strip()
|
||
if not description:
|
||
description = f"Release {new_version}"
|
||
|
||
# Ensure we have builds for the release
|
||
html_build_dir = self.get_output_dir("html")
|
||
pdf_build_dir = self.get_output_dir("pdf")
|
||
|
||
if not html_build_dir.exists() or not pdf_build_dir.exists():
|
||
console.print("[yellow]⚠️ Missing builds detected. Building now...[/yellow]")
|
||
if not self._execute_build_phase():
|
||
return False
|
||
|
||
# Create git tag
|
||
console.print(f"[purple]🔄 Creating git tag {new_version}...[/purple]")
|
||
tag_result = subprocess.run(['git', 'tag', '-a', new_version, '-m', f"Release {new_version}: {description}"],
|
||
cwd=self.root_dir, capture_output=True)
|
||
if tag_result.returncode != 0:
|
||
console.print(f"[red]❌ Failed to create git tag: {tag_result.stderr.decode()}[/red]")
|
||
return False
|
||
|
||
# Push tag
|
||
console.print(f"[purple]🔄 Pushing tag to remote...[/purple]")
|
||
push_result = subprocess.run(['git', 'push', 'origin', new_version],
|
||
cwd=self.root_dir, capture_output=True)
|
||
if push_result.returncode != 0:
|
||
console.print(f"[red]❌ Failed to push tag: {push_result.stderr.decode()}[/red]")
|
||
return False
|
||
|
||
# Create GitHub release
|
||
if self._create_github_release(new_version, description):
|
||
console.print(f"[green]✅ Release {new_version} created successfully![/green]")
|
||
console.print()
|
||
console.print("[bold white]📦 Release Information:[/bold white]")
|
||
console.print(f"[blue] 🏷️ Version: {new_version}[/blue]")
|
||
console.print(f"[blue] 📝 Description: {description}[/blue]")
|
||
console.print(f"[blue] 🌐 Release: https://github.com/harvard-edge/cs249r_book/releases/tag/{new_version}[/blue]")
|
||
console.print(f"[blue] 📄 PDF: https://github.com/harvard-edge/cs249r_book/releases/download/{new_version}/Machine-Learning-Systems.pdf[/blue]")
|
||
console.print()
|
||
console.print("[green]🎉 Ready for academic citations and distribution![/green]")
|
||
return True
|
||
else:
|
||
console.print("[red]❌ Failed to create GitHub release[/red]")
|
||
return False
|
||
|
||
except Exception as e:
|
||
console.print(f"[red]❌ Release failed: {e}[/red]")
|
||
return False
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# 🚀 Enhanced Publishing Methods
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
def _show_publish_header(self):
|
||
"""Display header for website publishing"""
|
||
console.print()
|
||
banner = Panel(
|
||
"[bold blue]📚 Website Publisher[/bold blue]\n\n"
|
||
"[cyan]Deploy latest content to GitHub Pages[/cyan]\n"
|
||
"[dim]• Builds HTML + PDF\n"
|
||
"• Updates https://mlsysbook.ai\n"
|
||
"• No versioning or releases[/dim]",
|
||
title="🌐 Binder Publish",
|
||
border_style="blue",
|
||
padding=(1, 2)
|
||
)
|
||
console.print(banner)
|
||
console.print()
|
||
|
||
def _show_release_header(self):
|
||
"""Display header for formal releases"""
|
||
console.print()
|
||
banner = Panel(
|
||
"[bold green]🏷️ Release Manager[/bold green]\n\n"
|
||
"[yellow]Create formal textbook releases[/yellow]\n"
|
||
"[dim]• Semantic versioning\n"
|
||
"• Git tags & GitHub releases\n"
|
||
"• Academic citations ready[/dim]",
|
||
title="📦 Binder Release",
|
||
border_style="green",
|
||
padding=(1, 2)
|
||
)
|
||
console.print(banner)
|
||
console.print()
|
||
|
||
def _validate_git_status_for_publish(self):
|
||
"""Relaxed git validation for website publishing"""
|
||
try:
|
||
# Check if we're in a git repo
|
||
result = subprocess.run(['git', 'status', '--porcelain'],
|
||
capture_output=True, text=True, cwd=self.root_dir)
|
||
if result.returncode != 0:
|
||
console.print("[red]❌ Not in a valid git repository[/red]")
|
||
return False
|
||
|
||
# For publishing, we allow uncommitted changes (just warn)
|
||
if result.stdout.strip():
|
||
console.print("[yellow]⚠️ You have uncommitted changes.[/yellow]")
|
||
console.print("[dim]Website will be built from committed content only.[/dim]")
|
||
console.print()
|
||
|
||
return True
|
||
except Exception as e:
|
||
console.print(f"[red]❌ Git validation failed: {e}[/red]")
|
||
return False
|
||
|
||
def _show_publish_website_success(self):
|
||
"""Show success message for website publishing"""
|
||
console.print()
|
||
console.print("[bold green]🎉 Website Successfully Deployed![/bold green]")
|
||
console.print()
|
||
console.print("[bold white]📋 Access Your Updated Website:[/bold white]")
|
||
console.print("[blue] 🌐 Website: https://mlsysbook.ai[/blue]")
|
||
console.print("[blue] 📄 PDF: https://mlsysbook.ai/assets/downloads/Machine-Learning-Systems.pdf[/blue]")
|
||
console.print()
|
||
console.print("[dim]💡 Changes may take a few minutes to appear due to caching[/dim]")
|
||
console.print("[green]✅ Ready for students and educators![/green]")
|
||
|
||
def _validate_git_status_for_publish(self):
|
||
"""Validate git status for website publishing (main branch required)"""
|
||
try:
|
||
# Check if we're in a git repo
|
||
result = subprocess.run(['git', 'status', '--porcelain'],
|
||
capture_output=True, text=True, cwd=self.root_dir)
|
||
if result.returncode != 0:
|
||
console.print("[red]❌ Not in a valid git repository[/red]")
|
||
return False
|
||
|
||
# Check current branch
|
||
branch_result = subprocess.run(['git', 'branch', '--show-current'],
|
||
capture_output=True, text=True, cwd=self.root_dir)
|
||
current_branch = branch_result.stdout.strip()
|
||
|
||
if current_branch != "main":
|
||
console.print(f"[red]❌ Website publishing requires main branch[/red]")
|
||
console.print(f"[blue]ℹ️ Current branch: {current_branch}[/blue]")
|
||
console.print("[blue]ℹ️ User-facing operations must be from main branch[/blue]")
|
||
console.print()
|
||
console.print("[bold white]Options:[/bold white]")
|
||
console.print("[green] y/yes[/green] - Switch to main branch")
|
||
console.print("[red] n/no[/red] - Cancel publishing [default]")
|
||
console.print("[yellow]Switch to main branch? [y/N] [default: N]: [/yellow]", end="")
|
||
choice = input().strip().lower()
|
||
if not choice:
|
||
choice = 'n'
|
||
|
||
if choice in ['y', 'yes']:
|
||
# Handle uncommitted changes before switching
|
||
if result.stdout.strip():
|
||
console.print("[yellow]⚠️ You have uncommitted changes[/yellow]")
|
||
console.print("[bold white]Options:[/bold white]")
|
||
console.print("[green] y/yes[/green] - Commit changes before switching")
|
||
console.print("[red] n/no[/red] - Cancel (cannot switch with uncommitted changes) [default]")
|
||
console.print("[yellow]Commit current changes before switching? [y/N] [default: N]: [/yellow]", end="")
|
||
commit_choice = input().strip().lower()
|
||
if not commit_choice:
|
||
commit_choice = 'n'
|
||
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
|
||
|
||
# Switch to main
|
||
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]")
|
||
|
||
# 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]")
|
||
else:
|
||
console.print("[blue]ℹ️ Publishing cancelled - main branch required[/blue]")
|
||
return False
|
||
|
||
# For publishing, we allow uncommitted changes (just warn)
|
||
if result.stdout.strip():
|
||
console.print("[yellow]⚠️ You have uncommitted changes.[/yellow]")
|
||
console.print("[dim]Website will be built from committed content only.[/dim]")
|
||
console.print()
|
||
|
||
return True
|
||
except Exception as e:
|
||
console.print(f"[red]❌ Git validation failed: {e}[/red]")
|
||
return False
|
||
|
||
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 _show_publish_header(self):
|
||
"""Display header for website publishing"""
|
||
console.print()
|
||
banner = Panel(
|
||
"[bold blue]📚 Website Publisher[/bold blue]\n\n"
|
||
"[cyan]Deploy latest content to GitHub Pages[/cyan]\n"
|
||
"[dim]• Builds HTML + PDF\n"
|
||
"• Updates https://mlsysbook.ai\n"
|
||
"• No versioning or releases[/dim]",
|
||
title="🌐 Binder Publish",
|
||
border_style="blue",
|
||
padding=(1, 2)
|
||
)
|
||
console.print(banner)
|
||
console.print()
|
||
|
||
def _show_release_header(self):
|
||
"""Display header for formal releases"""
|
||
console.print()
|
||
banner = Panel(
|
||
"[bold green]🏷️ Release Manager[/bold green]\n\n"
|
||
"[yellow]Create formal textbook releases[/yellow]\n"
|
||
"[dim]• Semantic versioning\n"
|
||
"• Git tags & GitHub releases\n"
|
||
"• Academic citations ready[/dim]",
|
||
title="📦 Binder Release",
|
||
border_style="green",
|
||
padding=(1, 2)
|
||
)
|
||
console.print(banner)
|
||
console.print()
|
||
|
||
def _validate_git_status_for_publish(self):
|
||
"""Relaxed git validation for website publishing"""
|
||
try:
|
||
# Check if we're in a git repo
|
||
result = subprocess.run(['git', 'status', '--porcelain'],
|
||
capture_output=True, text=True, cwd=self.root_dir)
|
||
if result.returncode != 0:
|
||
console.print("[red]❌ Not in a valid git repository[/red]")
|
||
return False
|
||
|
||
# For publishing, we allow uncommitted changes (just warn)
|
||
if result.stdout.strip():
|
||
console.print("[yellow]⚠️ You have uncommitted changes.[/yellow]")
|
||
console.print("[dim]Website will be built from committed content only.[/dim]")
|
||
console.print()
|
||
|
||
return True
|
||
except Exception as e:
|
||
console.print(f"[red]❌ Git validation failed: {e}[/red]")
|
||
return False
|
||
|
||
def _show_publish_website_success(self):
|
||
"""Show comprehensive success message for website publishing"""
|
||
console.print()
|
||
console.print("[bold green]🎉 Website Successfully Deployed![/bold green]")
|
||
console.print()
|
||
console.print("[bold white]📋 Access Your Updated Website:[/bold white]")
|
||
console.print("[blue] 🌐 Website: https://mlsysbook.ai[/blue]")
|
||
console.print("[blue] 📄 PDF: https://mlsysbook.ai/assets/downloads/Machine-Learning-Systems.pdf[/blue]")
|
||
console.print()
|
||
console.print("[bold white]📊 What Was Deployed:[/bold white]")
|
||
console.print("[green] ✅ Complete HTML textbook website[/green]")
|
||
console.print("[green] ✅ All chapters and content[/green]")
|
||
console.print("[green] ✅ Navigation and search functionality[/green]")
|
||
console.print("[green] ✅ PDF download link[/green]")
|
||
console.print("[green] ✅ All images, figures, and media[/green]")
|
||
console.print()
|
||
console.print("[bold white]⏱️ Timeline:[/bold white]")
|
||
console.print("[cyan] • Deployment completed: Now[/cyan]")
|
||
console.print("[cyan] • Website update: 5-10 minutes[/cyan]")
|
||
console.print("[cyan] • Full propagation: 15-30 minutes[/cyan]")
|
||
console.print()
|
||
console.print("[bold white]🔍 Verification:[/bold white]")
|
||
console.print("[blue] • Check https://mlsysbook.ai in 10 minutes[/blue]")
|
||
console.print("[blue] • Verify PDF download works[/blue]")
|
||
console.print("[blue] • Test navigation and search[/blue]")
|
||
console.print()
|
||
console.print("[dim]💡 Changes may take a few minutes to appear due to caching[/dim]")
|
||
console.print("[green]✅ Ready for students and educators![/green]")
|
||
console.print()
|
||
console.print("[bold white]📞 If Issues:[/bold white]")
|
||
console.print("[dim] • Check GitHub Pages deployment status[/dim]")
|
||
console.print("[dim] • Review build logs for errors[/dim]")
|
||
console.print("[dim] • Contact maintainers if needed[/dim]")
|
||
|
||
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("[bold white]Options:[/bold white]")
|
||
console.print("[green] y/yes[/green] - Switch to main and merge dev changes (recommended)")
|
||
console.print("[red] n/no[/red] - Cancel publishing (default)")
|
||
console.print("[yellow]Move to main branch and pull in dev changes? [y/N] [default: N]: [/yellow]", end="")
|
||
choice = input().strip().lower()
|
||
if not choice:
|
||
choice = 'n'
|
||
|
||
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("[bold white]Options:[/bold white]")
|
||
console.print("[green] y/yes[/green] - Commit changes before switching")
|
||
console.print("[red] n/no[/red] - Cancel (cannot switch with uncommitted changes) [default]")
|
||
console.print("[yellow]Commit current changes before switching? [y/N] [default: N]: [/yellow]", end="")
|
||
commit_choice = input().strip().lower()
|
||
if not commit_choice:
|
||
commit_choice = 'n'
|
||
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("[bold white]Options:[/bold white]")
|
||
console.print("[green] y/yes[/green] - Commit and push changes before publishing")
|
||
console.print("[red] n/no[/red] - Cancel publishing (cannot continue with uncommitted changes) [default]")
|
||
console.print("[yellow]Commit changes before continuing? [y/N] [default: N]: [/yellow]", end="")
|
||
choice = input().strip().lower()
|
||
if not choice:
|
||
choice = 'n'
|
||
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 (textbook-specific)
|
||
console.print("[bold white]📋 Textbook Release Type Guide:[/bold white]")
|
||
console.print("[green] 1. patch[/green] - Typos, corrections, minor fixes (v1.0.0 → v1.0.1)")
|
||
console.print("[yellow] 2. minor[/yellow] - New chapters, labs, major content (v1.0.0 → v1.1.0)")
|
||
console.print("[red] 3. major[/red] - Complete restructuring, new edition (v1.0.0 → v2.0.0)")
|
||
console.print("[blue] 4. custom[/blue] - Specify your own version number")
|
||
console.print()
|
||
console.print("[dim]💡 Examples:[/dim]")
|
||
console.print("[dim] • Patch: Fixed equations in Chapter 8, corrected references[/dim]")
|
||
console.print("[dim] • Minor: Added new \"Federated Learning\" chapter[/dim]")
|
||
console.print("[dim] • Major: Restructured entire book for new academic year[/dim]")
|
||
console.print()
|
||
|
||
console.print("[white]What type of changes are you publishing?[/white]")
|
||
console.print("[white]Select option [1-4] [default: 2 for minor]: [/white]", end="")
|
||
choice = input().strip()
|
||
if not choice:
|
||
choice = "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]⚠️ Git tag {new_version} already exists[/yellow]")
|
||
console.print("[dim] This will delete the existing tag from both local and remote repositories[/dim]")
|
||
console.print("[dim] and recreate it with the new release[/dim]")
|
||
console.print()
|
||
console.print("[bold white]Options:[/bold white]")
|
||
console.print("[green] y/yes[/green] - Delete existing tag and recreate with new release")
|
||
console.print("[red] n/no[/red] - Cancel publishing (keep existing tag) [default]")
|
||
console.print("[yellow]Delete existing tag and recreate? [y/N] [default: N]: [/yellow]", end="")
|
||
choice = input().strip().lower()
|
||
if not choice:
|
||
choice = 'n'
|
||
if choice in ['y', 'yes']:
|
||
console.print("[purple]🔄 Deleting existing tag...[/purple]")
|
||
self._delete_version(new_version)
|
||
console.print(f"[green]✅ Existing tag {new_version} deleted from local and remote[/green]")
|
||
else:
|
||
console.print("[red]❌ Cannot continue with existing tag[/red]")
|
||
console.print("[blue]💡 Choose a different version or delete the tag manually[/blue]")
|
||
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 [default: Content updates and improvements]: [/white]", end="")
|
||
description = input().strip()
|
||
if not description:
|
||
description = "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("[bold white]Options:[/bold white]")
|
||
console.print("[green] y/yes[/green] - Proceed with publishing to production")
|
||
console.print("[red] n/no[/red] - Cancel publishing [default]")
|
||
console.print("[yellow]Proceed with publishing? [y/N] [default: N]: [/yellow]", end="")
|
||
choice = input().strip().lower()
|
||
if not choice:
|
||
choice = 'n'
|
||
|
||
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:
|
||
# Check for existing builds
|
||
pdf_path = self.get_output_dir("pdf") / "Machine-Learning-Systems.pdf"
|
||
html_dir = self.get_output_dir("html")
|
||
|
||
has_pdf = pdf_path.exists()
|
||
has_html = html_dir.exists() and any(html_dir.iterdir())
|
||
|
||
if has_pdf or has_html:
|
||
console.print("[yellow]🔍 Found existing build artifacts:[/yellow]")
|
||
if has_pdf:
|
||
size_mb = pdf_path.stat().st_size / (1024 * 1024)
|
||
console.print(f"[green] ✅ PDF: {pdf_path} ({size_mb:.1f} MB)[/green]")
|
||
if has_html:
|
||
console.print(f"[green] ✅ HTML: {html_dir}[/green]")
|
||
console.print()
|
||
|
||
console.print("[white]Reuse existing builds? This will skip the build phase and save time.[/white]")
|
||
console.print("[bold white]Options:[/bold white]")
|
||
console.print("[green] y/yes[/green] - Use existing builds (faster)")
|
||
console.print("[blue] n/no[/blue] - Rebuild from scratch (slower, fresher) [default]")
|
||
console.print("[yellow]Reuse existing builds? [y/N] [default: N]: [/yellow]", end="")
|
||
choice = input().strip().lower()
|
||
if not choice:
|
||
choice = 'n'
|
||
|
||
if choice in ['y', 'yes']:
|
||
console.print("[blue]✅ Using existing builds[/blue]")
|
||
|
||
# Ask about PDF compression
|
||
if has_pdf:
|
||
# Show current PDF size
|
||
size_mb = pdf_path.stat().st_size / (1024 * 1024)
|
||
console.print()
|
||
console.print("[bold white]🗜️ PDF Compression Options:[/bold white]")
|
||
console.print("[dim] Uses Ghostscript with /ebook settings to reduce file size[/dim]")
|
||
console.print("[dim] Good balance between size and quality for web distribution[/dim]")
|
||
console.print(f"[blue] Current PDF size: {size_mb:.1f} MB[/blue]")
|
||
console.print("[bold white]Options:[/bold white]")
|
||
console.print("[green] y/yes[/green] - Compress PDF for smaller file size (recommended)")
|
||
console.print("[blue] n/no[/blue] - Keep original PDF without compression [default]")
|
||
console.print("[yellow]Compress PDF? [y/N] [default: N]: [/yellow]", end="")
|
||
compress_choice = input().strip().lower()
|
||
if not compress_choice:
|
||
compress_choice = 'n'
|
||
if compress_choice in ['y', 'yes']:
|
||
console.print("[purple]🔄 Compressing PDF...[/purple]")
|
||
if not self._compress_pdf():
|
||
console.print("[yellow]⚠️ PDF compression failed, using original[/yellow]")
|
||
else:
|
||
console.print("[green]✅ PDF compression completed[/green]")
|
||
else:
|
||
console.print("[blue]ℹ️ Using original PDF without compression[/blue]")
|
||
|
||
return True
|
||
else:
|
||
console.print("[blue]🔄 Rebuilding from scratch...[/blue]")
|
||
|
||
# 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]")
|
||
|
||
# Ask about PDF compression
|
||
pdf_path = self.get_output_dir("pdf") / "Machine-Learning-Systems.pdf"
|
||
if pdf_path.exists():
|
||
size_mb = pdf_path.stat().st_size / (1024 * 1024)
|
||
console.print()
|
||
console.print("[bold white]🗜️ PDF Compression Options:[/bold white]")
|
||
console.print("[dim] Uses Ghostscript with /ebook settings to reduce file size[/dim]")
|
||
console.print("[dim] Good balance between size and quality for web distribution[/dim]")
|
||
console.print(f"[blue] Current PDF size: {size_mb:.1f} MB[/blue]")
|
||
console.print("[bold white]Options:[/bold white]")
|
||
console.print("[green] y/yes[/green] - Compress PDF for smaller file size (recommended)")
|
||
console.print("[blue] n/no[/blue] - Keep original PDF without compression [default]")
|
||
console.print("[yellow]Compress PDF? [y/N] [default: N]: [/yellow]", end="")
|
||
compress_choice = input().strip().lower()
|
||
if not compress_choice:
|
||
compress_choice = 'n'
|
||
if compress_choice in ['y', 'yes']:
|
||
console.print("[purple]🔄 Compressing PDF...[/purple]")
|
||
if not self._compress_pdf():
|
||
console.print("[yellow]⚠️ PDF compression failed, continuing with uncompressed PDF[/yellow]")
|
||
else:
|
||
console.print("[green]✅ PDF compression completed[/green]")
|
||
else:
|
||
console.print("[blue]ℹ️ Skipping PDF compression[/blue]")
|
||
|
||
# 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]")
|
||
|
||
# Validate both builds are complete and valid
|
||
console.print("[purple]🔄 Validating builds...[/purple]")
|
||
|
||
# Check PDF
|
||
pdf_path = self.get_output_dir("pdf") / "Machine-Learning-Systems.pdf"
|
||
if not pdf_path.exists():
|
||
console.print("[red]❌ PDF build validation failed - file not found[/red]")
|
||
return False
|
||
|
||
pdf_size_mb = pdf_path.stat().st_size / (1024 * 1024)
|
||
if pdf_size_mb < 1:
|
||
console.print(f"[red]❌ PDF build validation failed - file too small: {pdf_size_mb:.1f} MB[/red]")
|
||
return False
|
||
|
||
# Check HTML
|
||
html_dir = self.get_output_dir("html")
|
||
if not html_dir.exists() or not any(html_dir.iterdir()):
|
||
console.print("[red]❌ HTML build validation failed - directory empty or missing[/red]")
|
||
return False
|
||
|
||
console.print(f"[green]✅ PDF validated: {pdf_size_mb:.1f} MB[/green]")
|
||
console.print(f"[green]✅ HTML validated: {html_dir}[/green]")
|
||
console.print("[green]✅ Build phase completed successfully[/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, handling orphaned 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 all version tags, sorted by version
|
||
versions = [tag for tag in tags if tag.startswith('v')]
|
||
if not versions:
|
||
return "v0.0.0"
|
||
|
||
# Sort versions to get the latest
|
||
sorted_versions = sorted(versions, key=lambda v: [int(x) for x in v[1:].split('.')])
|
||
latest_version = sorted_versions[-1]
|
||
|
||
# Check for orphaned tags (tags without GitHub releases)
|
||
orphaned_tags = self._find_orphaned_tags(sorted_versions)
|
||
|
||
if orphaned_tags:
|
||
# Handle orphaned tags before proceeding
|
||
handled_version = self._handle_orphaned_tags(orphaned_tags, latest_version)
|
||
if handled_version:
|
||
return handled_version
|
||
|
||
return latest_version
|
||
|
||
except Exception:
|
||
return "v0.0.0"
|
||
|
||
def _find_orphaned_tags(self, versions):
|
||
"""Find git tags that don't have corresponding GitHub releases"""
|
||
orphaned_tags = []
|
||
|
||
try:
|
||
# Check if GitHub CLI is available
|
||
subprocess.run(['gh', '--version'], capture_output=True, check=True)
|
||
|
||
# Get list of GitHub releases
|
||
result = subprocess.run(['gh', 'release', 'list', '--limit', '100'],
|
||
capture_output=True, text=True, cwd=self.root_dir)
|
||
|
||
if result.returncode == 0:
|
||
# Parse release list to get tag names
|
||
release_lines = result.stdout.strip().split('\n') if result.stdout.strip() else []
|
||
release_tags = set()
|
||
|
||
for line in release_lines:
|
||
if line.strip():
|
||
# GitHub CLI output format: "title tag status date"
|
||
parts = line.split('\t')
|
||
if len(parts) >= 2:
|
||
tag = parts[1].strip()
|
||
release_tags.add(tag)
|
||
|
||
# Find tags without releases
|
||
for version in versions:
|
||
if version not in release_tags:
|
||
orphaned_tags.append(version)
|
||
|
||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||
# GitHub CLI not available, can't check for orphaned tags
|
||
pass
|
||
|
||
return orphaned_tags
|
||
|
||
def _handle_orphaned_tags(self, orphaned_tags, latest_version):
|
||
"""Handle orphaned tags with user interaction"""
|
||
console.print()
|
||
console.print("[bold yellow]⚠️ Orphaned Git Tags Detected[/bold yellow]")
|
||
console.print("[dim]These tags exist but don't have corresponding GitHub releases:[/dim]")
|
||
console.print()
|
||
|
||
for tag in orphaned_tags:
|
||
console.print(f"[yellow] 🏷️ {tag}[/yellow]")
|
||
|
||
console.print()
|
||
console.print("[bold white]💡 This usually happens when a previous publish failed after creating the tag[/bold white]")
|
||
console.print("[dim]but before completing the GitHub release. These orphaned tags can cause[/dim]")
|
||
console.print("[dim]version numbering issues and should be cleaned up.[/dim]")
|
||
console.print()
|
||
|
||
console.print("[bold white]Options:[/bold white]")
|
||
console.print("[green] 1. clean[/green] - Delete all orphaned tags and use the latest valid version")
|
||
console.print("[yellow] 2. ignore[/yellow] - Continue with current latest tag (may cause issues)")
|
||
console.print("[blue] 3. manual[/blue] - Let me choose which tags to delete [default]")
|
||
console.print("[red] 4. cancel[/red] - Cancel publishing to handle manually")
|
||
console.print()
|
||
|
||
console.print("[white]How would you like to handle orphaned tags? [1/2/3/4] [default: 3]: [/white]", end="")
|
||
choice = input().strip()
|
||
if not choice:
|
||
choice = "3"
|
||
|
||
if choice == "1":
|
||
# Clean all orphaned tags
|
||
console.print("[purple]🧹 Cleaning all orphaned tags...[/purple]")
|
||
for tag in orphaned_tags:
|
||
self._delete_version(tag)
|
||
console.print(f"[green] ✅ Deleted orphaned tag: {tag}[/green]")
|
||
|
||
# Find the latest non-orphaned version
|
||
# Get all tags again and exclude the orphaned ones
|
||
try:
|
||
result = subprocess.run(['git', 'tag', '--list', 'v*'],
|
||
capture_output=True, text=True, cwd=self.root_dir)
|
||
remaining_tags = result.stdout.strip().split('\n') if result.stdout.strip() else []
|
||
remaining_versions = [tag for tag in remaining_tags if tag.startswith('v')]
|
||
|
||
if remaining_versions:
|
||
return max(remaining_versions, key=lambda v: [int(x) for x in v[1:].split('.')])
|
||
else:
|
||
return "v0.0.0"
|
||
except:
|
||
return "v0.0.0"
|
||
|
||
elif choice == "2":
|
||
# Continue with latest tag
|
||
console.print("[yellow]⚠️ Continuing with potentially orphaned tag. This may cause issues.[/yellow]")
|
||
return latest_version
|
||
|
||
elif choice == "3":
|
||
# Manual selection
|
||
return self._manual_orphaned_tag_cleanup(orphaned_tags, latest_version)
|
||
|
||
else: # choice == "4" or invalid
|
||
console.print("[blue]ℹ️ Publishing cancelled. Please handle orphaned tags manually.[/blue]")
|
||
console.print("[dim]You can delete tags with: git tag -d <tag> && git push origin --delete <tag>[/dim]")
|
||
return None
|
||
|
||
def _manual_orphaned_tag_cleanup(self, orphaned_tags, latest_version):
|
||
"""Manual orphaned tag cleanup with individual choices"""
|
||
console.print()
|
||
console.print("[bold blue]🔧 Manual Orphaned Tag Cleanup[/bold blue]")
|
||
console.print("[dim]Review each orphaned tag and decide whether to delete it:[/dim]")
|
||
console.print()
|
||
|
||
deleted_tags = []
|
||
|
||
for tag in sorted(orphaned_tags, key=lambda v: [int(x) for x in v[1:].split('.')], reverse=True):
|
||
console.print(f"[bold white]Tag: {tag}[/bold white]")
|
||
|
||
# Show when this tag was created
|
||
try:
|
||
result = subprocess.run(['git', 'log', '--format=%ai %s', '-1', tag],
|
||
capture_output=True, text=True, cwd=self.root_dir)
|
||
if result.returncode == 0 and result.stdout.strip():
|
||
tag_info = result.stdout.strip()
|
||
console.print(f"[dim] Created: {tag_info}[/dim]")
|
||
except:
|
||
pass
|
||
|
||
console.print("[bold white]Options:[/bold white]")
|
||
console.print("[red] d/delete[/red] - Delete this orphaned tag")
|
||
console.print("[green] k/keep[/green] - Keep this tag (may cause version issues)")
|
||
console.print("[blue] s/skip[/blue] - Skip for now [default]")
|
||
|
||
console.print(f"[yellow]Delete orphaned tag {tag}? [d/k/s] [default: s]: [/yellow]", end="")
|
||
choice = input().strip().lower()
|
||
if not choice:
|
||
choice = 's'
|
||
|
||
if choice in ['d', 'delete']:
|
||
console.print(f"[purple]🗑️ Deleting orphaned tag {tag}...[/purple]")
|
||
self._delete_version(tag)
|
||
deleted_tags.append(tag)
|
||
console.print(f"[green] ✅ Deleted: {tag}[/green]")
|
||
elif choice in ['k', 'keep']:
|
||
console.print(f"[yellow] ⚠️ Keeping potentially problematic tag: {tag}[/yellow]")
|
||
else:
|
||
console.print(f"[blue] ⏭️ Skipping: {tag}[/blue]")
|
||
|
||
console.print()
|
||
|
||
if deleted_tags:
|
||
console.print(f"[green]✅ Cleanup complete. Deleted {len(deleted_tags)} orphaned tags.[/green]")
|
||
# Recalculate the latest version after cleanup
|
||
try:
|
||
result = subprocess.run(['git', 'tag', '--list', 'v*'],
|
||
capture_output=True, text=True, cwd=self.root_dir)
|
||
remaining_tags = result.stdout.strip().split('\n') if result.stdout.strip() else []
|
||
remaining_versions = [tag for tag in remaining_tags if tag.startswith('v')]
|
||
|
||
if remaining_versions:
|
||
return max(remaining_versions, key=lambda v: [int(x) for x in v[1:].split('.')])
|
||
else:
|
||
return "v0.0.0"
|
||
except:
|
||
return "v0.0.0"
|
||
else:
|
||
console.print("[blue]ℹ️ No tags were deleted.[/blue]")
|
||
return latest_version
|
||
|
||
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 and any associated GitHub release"""
|
||
try:
|
||
# Delete local git tag
|
||
subprocess.run(['git', 'tag', '-d', version], cwd=self.root_dir, check=True)
|
||
|
||
# Delete remote git tag
|
||
subprocess.run(['git', 'push', 'origin', '--delete', version],
|
||
cwd=self.root_dir, capture_output=True)
|
||
|
||
# Delete GitHub release if it exists (requires GitHub CLI)
|
||
try:
|
||
subprocess.run(['gh', '--version'], capture_output=True, check=True)
|
||
# Delete the release (this also deletes associated assets)
|
||
result = subprocess.run(['gh', 'release', 'delete', version, '--yes'],
|
||
cwd=self.root_dir, capture_output=True, text=True)
|
||
if result.returncode == 0:
|
||
console.print(f"[blue] ✅ GitHub release {version} also deleted[/blue]")
|
||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||
# GitHub CLI not available or release doesn't exist, continue
|
||
pass
|
||
|
||
except Exception:
|
||
pass
|
||
|
||
def _get_repo_info(self):
|
||
"""Get repository info and validate URLs"""
|
||
try:
|
||
result = subprocess.run(['git', 'remote', 'get-url', 'origin'],
|
||
capture_output=True, text=True, cwd=self.root_dir)
|
||
if result.returncode != 0:
|
||
return None
|
||
|
||
remote_url = result.stdout.strip()
|
||
if not remote_url:
|
||
return None
|
||
|
||
import re
|
||
match = re.search(r'github\.com[:/]([^/]+)/([^/]+)(\.git)?$', remote_url)
|
||
if match:
|
||
owner = match.group(1)
|
||
repo_name = match.group(2)
|
||
|
||
# Remove .git suffix if present
|
||
if repo_name.endswith('.git'):
|
||
repo_name = repo_name[:-4]
|
||
|
||
repo_info = f"{owner}/{repo_name}"
|
||
|
||
# Validate the extracted repository info
|
||
if self._validate_repo_info(repo_info):
|
||
return repo_info
|
||
else:
|
||
console.print(f"[yellow]⚠️ Warning: Could not validate repository URLs for {repo_info}[/yellow]")
|
||
return None
|
||
except Exception as e:
|
||
console.print(f"[yellow]⚠️ Warning: Failed to get repository info: {e}[/yellow]")
|
||
pass
|
||
return None
|
||
|
||
def _validate_repo_info(self, repo_info):
|
||
"""Validate that the repository info generates valid URLs"""
|
||
try:
|
||
import urllib.request
|
||
import urllib.error
|
||
|
||
# Test if the main repository URL is accessible
|
||
repo_url = f"https://github.com/{repo_info}"
|
||
|
||
# Create a request with a user agent
|
||
req = urllib.request.Request(repo_url, headers={
|
||
'User-Agent': 'MLSysBook-Binder/1.0'
|
||
})
|
||
|
||
# Try to access the repository (just check if it exists)
|
||
with urllib.request.urlopen(req, timeout=10) as response:
|
||
if response.status == 200:
|
||
return True
|
||
except (urllib.error.URLError, urllib.error.HTTPError, Exception):
|
||
# If we can't validate (network issues, etc.), assume it's valid
|
||
# to avoid blocking legitimate publishes due to temporary network issues
|
||
pass
|
||
|
||
# Fallback: basic format validation
|
||
import re
|
||
pattern = r'^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$'
|
||
return bool(re.match(pattern, repo_info))
|
||
|
||
def _compress_pdf(self):
|
||
"""Compress PDF using Ghostscript with ebook settings"""
|
||
pdf_path = self.get_output_dir("pdf") / "Machine-Learning-Systems.pdf"
|
||
compressed_path = self.get_output_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("[bold white]Options:[/bold white]")
|
||
console.print("[green] y/yes[/green] - Deploy website to GitHub Pages (recommended)")
|
||
console.print("[blue] n/no[/blue] - Skip GitHub Pages deployment [default]")
|
||
console.print("[yellow]Deploy to GitHub Pages? [y/N] [default: N]: [/yellow]", end="")
|
||
choice = input().strip().lower()
|
||
if not choice:
|
||
choice = 'n'
|
||
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("[bold white]Options:[/bold white]")
|
||
console.print("[green] y/yes[/green] - Create public release with downloadable PDF (recommended)")
|
||
console.print("[blue] n/no[/blue] - Skip GitHub release creation [default]")
|
||
console.print("[yellow]Create GitHub release? [y/N] [default: N]: [/yellow]", end="")
|
||
choice = input().strip().lower()
|
||
if not choice:
|
||
choice = 'n'
|
||
release_created = False
|
||
if choice in ['y', 'yes']:
|
||
pdf_path = self.get_output_dir("pdf") / "Machine-Learning-Systems.pdf"
|
||
if self._create_github_release(version, description, pdf_path):
|
||
console.print("[green]✅ GitHub release created[/green]")
|
||
release_created = True
|
||
else:
|
||
console.print("[yellow]⚠️ GitHub release creation failed[/yellow]")
|
||
else:
|
||
console.print("[blue]ℹ️ Skipping GitHub release creation[/blue]")
|
||
|
||
# Update GitHub Pages PDF (if we have a PDF and created a release)
|
||
pdf_path = self.get_output_dir("pdf") / "Machine-Learning-Systems.pdf"
|
||
if release_created and pdf_path.exists():
|
||
console.print()
|
||
console.print("[bold white]🌐 GitHub Pages PDF Update:[/bold white]")
|
||
console.print("[dim] Update the PDF in mlsysbook.ai/assets/ to match the release[/dim]")
|
||
console.print("[bold white]Options:[/bold white]")
|
||
console.print("[green] y/yes[/green] - Update PDF on website (mlsysbook.ai/assets/)")
|
||
console.print("[blue] n/no[/blue] - Keep existing PDF on website [default]")
|
||
console.print("[yellow]Update GitHub Pages PDF? [y/N] [default: N]: [/yellow]", end="")
|
||
choice = input().strip().lower()
|
||
if not choice:
|
||
choice = 'n'
|
||
if choice in ['y', 'yes']:
|
||
console.print("[purple]🔄 Updating GitHub Pages PDF...[/purple]")
|
||
if self._update_github_pages_pdf(pdf_path):
|
||
console.print("[green]✅ PDF updated on GitHub Pages (mlsysbook.ai/assets/)[/green]")
|
||
else:
|
||
console.print("[yellow]⚠️ GitHub Pages PDF update failed[/yellow]")
|
||
console.print("[dim] PDF will only be available via GitHub releases[/dim]")
|
||
else:
|
||
console.print("[blue]ℹ️ Skipping GitHub Pages PDF update[/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 (URLs validated ✅):[/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]")
|
||
else:
|
||
console.print(f"\n[yellow]⚠️ Could not validate repository URLs - please check manually[/yellow]")
|
||
|
||
console.print("\n[green]✅ Ready for distribution! 🚀[/green]")
|
||
|
||
def _deploy_to_github_pages(self):
|
||
"""Deploy to GitHub Pages with detailed progress information"""
|
||
try:
|
||
console.print("[bold blue]🔍 Validating Build Artifacts[/bold blue]")
|
||
|
||
# Validate HTML build
|
||
html_build_dir = self.get_output_dir("html")
|
||
if not html_build_dir.exists():
|
||
console.print("[red]❌ No HTML build found. Run build first.[/red]")
|
||
console.print(f"[blue]💡 Expected build at: {html_build_dir}[/blue]")
|
||
return False
|
||
|
||
# Validate PDF build
|
||
pdf_build_dir = self.get_output_dir("pdf")
|
||
pdf_source = pdf_build_dir / "Machine-Learning-Systems.pdf"
|
||
|
||
# Debug: List contents of PDF build directory
|
||
console.print(f"[blue]🔍 Checking PDF build directory: {pdf_build_dir}[/blue]")
|
||
if pdf_build_dir.exists():
|
||
pdf_files = list(pdf_build_dir.glob("*.pdf"))
|
||
console.print(f"[blue] 📄 Found {len(pdf_files)} PDF files:[/blue]")
|
||
for pdf_file in pdf_files:
|
||
size_mb = pdf_file.stat().st_size / (1024 * 1024)
|
||
console.print(f"[blue] • {pdf_file.name} ({size_mb:.1f} MB)[/blue]")
|
||
else:
|
||
console.print(f"[red] ❌ PDF build directory does not exist: {pdf_build_dir}[/red]")
|
||
|
||
if not pdf_source.exists():
|
||
console.print("[red]❌ No PDF build found. Run build first.[/red]")
|
||
console.print(f"[blue]💡 Expected PDF at: {pdf_source}[/blue]")
|
||
console.print("[blue]💡 Both HTML and PDF builds are required for deployment[/blue]")
|
||
console.print("[blue]💡 Try running './binder build-full pdf' first[/blue]")
|
||
return False
|
||
|
||
# Validate PDF file size (should be reasonable)
|
||
pdf_size_mb = pdf_source.stat().st_size / (1024 * 1024)
|
||
if pdf_size_mb < 1: # Less than 1MB is suspicious
|
||
console.print(f"[red]❌ PDF file size too small: {pdf_size_mb:.1f} MB[/red]")
|
||
console.print("[blue]💡 PDF may be corrupted or incomplete[/blue]")
|
||
return False
|
||
|
||
console.print(f"[green]✅ HTML build validated: {html_build_dir}[/green]")
|
||
console.print(f"[green]✅ PDF build validated: {pdf_source} ({pdf_size_mb:.1f} MB)[/green]")
|
||
console.print()
|
||
|
||
# Pre-deployment summary
|
||
console.print("[bold blue]📋 Deployment Summary[/bold blue]")
|
||
console.print(f"[blue] • HTML files: {html_build_dir}[/blue]")
|
||
console.print(f"[blue] • PDF file: {pdf_source} ({pdf_size_mb:.1f} MB)[/blue]")
|
||
console.print(f"[blue] • Target: GitHub Pages (https://mlsysbook.ai)[/blue]")
|
||
console.print(f"[blue] • Estimated deployment time: 1-2 minutes[/blue]")
|
||
console.print()
|
||
|
||
# Final confirmation before deployment
|
||
console.print("[bold yellow]⚠️ Final Deployment Confirmation[/bold yellow]")
|
||
console.print("[yellow]This will upload your built content to GitHub Pages.[/yellow]")
|
||
console.print("[yellow]The website will be updated within 5-10 minutes.[/yellow]")
|
||
console.print()
|
||
console.print("[bold white]Options:[/bold white]")
|
||
console.print("[green] y/yes[/green] - Proceed with GitHub Pages deployment")
|
||
console.print("[red] n/no[/red] - Cancel deployment [default]")
|
||
console.print("[yellow]Deploy to GitHub Pages? [y/N] [default: N]: [/yellow]", end="")
|
||
deploy_choice = input().strip().lower()
|
||
|
||
if deploy_choice not in ['y', 'yes']:
|
||
console.print("[blue]ℹ️ GitHub Pages deployment cancelled[/blue]")
|
||
console.print("[dim]💡 Your builds are ready but not deployed[/dim]")
|
||
return False
|
||
|
||
console.print("[green]✅ GitHub Pages deployment confirmed[/green]")
|
||
console.print()
|
||
|
||
# Step 1: Copy PDF to assets/downloads location
|
||
console.print("[bold blue]📁 Preparing PDF for Deployment[/bold blue]")
|
||
|
||
# Create assets/downloads directory in HTML build
|
||
assets_downloads_dir = html_build_dir / "assets" / "downloads"
|
||
assets_downloads_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
# Copy PDF to assets/downloads location
|
||
pdf_destination = assets_downloads_dir / "Machine-Learning-Systems.pdf"
|
||
import shutil
|
||
shutil.copy2(pdf_source, pdf_destination)
|
||
console.print(f"[green] ✅ Copied PDF to {pdf_destination}[/green]")
|
||
|
||
# Verify the copy was successful
|
||
if pdf_destination.exists():
|
||
copied_size_mb = pdf_destination.stat().st_size / (1024 * 1024)
|
||
console.print(f"[green] ✅ PDF copy verified: {copied_size_mb:.1f} MB[/green]")
|
||
else:
|
||
console.print("[red] ❌ PDF copy failed[/red]")
|
||
return False
|
||
|
||
# Step 2: Deploy to GitHub Pages using quarto publish
|
||
console.print("[bold blue]🚀 Deploying to GitHub Pages[/bold blue]")
|
||
console.print("[dim]This may take 1-2 minutes. Please wait...[/dim]")
|
||
|
||
# Deploy using quarto publish with the specified flags
|
||
result = subprocess.run(['quarto', 'publish', 'gh-pages', '--no-prompt', '--no-render', '--no-browser'],
|
||
cwd=self.book_dir, capture_output=True, text=True)
|
||
|
||
if result.returncode != 0:
|
||
console.print(f"[red]❌ GitHub Pages deployment failed[/red]")
|
||
console.print(f"[red]Error: {result.stderr}[/red]")
|
||
console.print("[blue]💡 Check your GitHub Pages settings and try again[/blue]")
|
||
return False
|
||
|
||
console.print("[green]✅ GitHub Pages deployment completed successfully[/green]")
|
||
console.print("[dim]💡 Changes will be live within 5-10 minutes[/dim]")
|
||
console.print("[blue]💡 PDF should be available at: https://mlsysbook.ai/pdf[/blue]")
|
||
console.print("[dim] (Redirects to assets/downloads/Machine-Learning-Systems.pdf)[/dim]")
|
||
return True
|
||
|
||
except Exception as e:
|
||
console.print(f"[red]❌ GitHub Pages deployment error: {e}[/red]")
|
||
console.print("[blue]💡 Check the error details above and try again[/blue]")
|
||
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)
|
||
|
||
# Save release notes to file for editing
|
||
release_notes_file = self.root_dir / f"release_notes_{version}.md"
|
||
with open(release_notes_file, 'w', encoding='utf-8') as f:
|
||
f.write(release_notes)
|
||
|
||
console.print(f"[green]✅ Release notes generated and saved to: {release_notes_file}[/green]")
|
||
console.print()
|
||
|
||
# Show a preview of the generated release notes
|
||
console.print("[bold white]📝 Generated Release Notes Preview:[/bold white]")
|
||
preview_lines = release_notes.split('\n')[:15] # Show first 15 lines
|
||
preview_panel = Panel(
|
||
'\n'.join(preview_lines) + ('\n...' if len(release_notes.split('\n')) > 15 else ''),
|
||
title="Release Notes Preview",
|
||
border_style="blue",
|
||
padding=(1, 2)
|
||
)
|
||
console.print(preview_panel)
|
||
|
||
console.print("[bold white]📝 Review and Edit Options:[/bold white]")
|
||
console.print(f"[blue] 📄 File location: {release_notes_file}[/blue]")
|
||
console.print("[dim] You can review and edit the release notes before publishing[/dim]")
|
||
console.print()
|
||
console.print("[bold white]Options:[/bold white]")
|
||
console.print("[green] y/yes[/green] - Open file in default editor for review/editing")
|
||
console.print("[blue] n/no[/blue] - Use release notes as-is [default]")
|
||
console.print("[cyan] s/show[/cyan] - Show full release notes content in terminal")
|
||
console.print("[yellow]Open file in default editor? [y/n/s] [default: n]: [/yellow]", end="")
|
||
open_choice = input().strip().lower()
|
||
if not open_choice:
|
||
open_choice = 'n'
|
||
if open_choice in ['y', 'yes']:
|
||
try:
|
||
import platform
|
||
system = platform.system().lower()
|
||
if system == "darwin": # macOS
|
||
subprocess.run(['open', str(release_notes_file)])
|
||
elif system == "linux":
|
||
subprocess.run(['xdg-open', str(release_notes_file)])
|
||
elif system == "windows":
|
||
subprocess.run(['start', str(release_notes_file)], shell=True)
|
||
console.print("[green]✅ File opened in default editor[/green]")
|
||
except Exception as e:
|
||
console.print(f"[yellow]⚠️ Could not open file automatically: {e}[/yellow]")
|
||
console.print(f"[blue]💡 Please open manually: {release_notes_file}[/blue]")
|
||
|
||
console.print()
|
||
console.print("[yellow]Press Enter to continue after reviewing/editing the release notes...[/yellow]", end="")
|
||
input()
|
||
|
||
elif open_choice in ['s', 'show']:
|
||
# Show full release notes in terminal
|
||
console.print()
|
||
full_notes_panel = Panel(
|
||
release_notes,
|
||
title="Full Release Notes Content",
|
||
border_style="cyan",
|
||
padding=(1, 2)
|
||
)
|
||
console.print(full_notes_panel)
|
||
|
||
console.print()
|
||
console.print("[bold white]Options:[/bold white]")
|
||
console.print("[green] e/edit[/green] - Open in editor for modifications")
|
||
console.print("[blue] c/continue[/blue] - Use as-is and continue [default]")
|
||
console.print("[yellow]What would you like to do? [e/c] [default: c]: [/yellow]", end="")
|
||
action = input().strip().lower()
|
||
if not action:
|
||
action = 'c'
|
||
|
||
if action in ['e', 'edit']:
|
||
try:
|
||
import platform
|
||
system = platform.system().lower()
|
||
if system == "darwin": # macOS
|
||
subprocess.run(['open', str(release_notes_file)])
|
||
elif system == "linux":
|
||
subprocess.run(['xdg-open', str(release_notes_file)])
|
||
elif system == "windows":
|
||
subprocess.run(['start', str(release_notes_file)], shell=True)
|
||
console.print("[green]✅ File opened in default editor[/green]")
|
||
console.print()
|
||
console.print("[yellow]Press Enter to continue after editing...[/yellow]", end="")
|
||
input()
|
||
except Exception as e:
|
||
console.print(f"[yellow]⚠️ Could not open file automatically: {e}[/yellow]")
|
||
console.print(f"[blue]💡 Please open manually: {release_notes_file}[/blue]")
|
||
else:
|
||
console.print("[blue]✅ Using release notes as-is[/blue]")
|
||
|
||
else:
|
||
console.print("[blue]✅ Using generated release notes as-is[/blue]")
|
||
|
||
# Read the potentially edited release notes
|
||
try:
|
||
with open(release_notes_file, 'r', encoding='utf-8') as f:
|
||
final_release_notes = f.read()
|
||
console.print("[green]✅ Using edited release notes[/green]")
|
||
except Exception as e:
|
||
console.print(f"[yellow]⚠️ Could not read edited file, using original: {e}[/yellow]")
|
||
final_release_notes = release_notes
|
||
|
||
# Create release
|
||
cmd = [
|
||
'gh', 'release', 'create', version,
|
||
'--title', f"{version}: {description}",
|
||
'--notes', final_release_notes,
|
||
'--draft',
|
||
str(pdf_path)
|
||
]
|
||
|
||
result = subprocess.run(cmd, cwd=self.root_dir, capture_output=True)
|
||
|
||
# Clean up the release notes file after successful creation
|
||
if result.returncode == 0:
|
||
try:
|
||
release_notes_file.unlink()
|
||
console.print(f"[blue]ℹ️ Cleaned up temporary file: {release_notes_file}[/blue]")
|
||
except:
|
||
pass
|
||
|
||
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 _update_github_pages_pdf(self, pdf_path):
|
||
"""Update the PDF in GitHub Pages assets folder"""
|
||
try:
|
||
import tempfile
|
||
import shutil
|
||
|
||
with tempfile.TemporaryDirectory() as temp_dir:
|
||
console.print("[blue] 📥 Cloning gh-pages branch...[/blue]")
|
||
|
||
# Clone gh-pages branch
|
||
result = subprocess.run([
|
||
'git', 'clone', '--depth=1', '--branch=gh-pages',
|
||
'https://github.com/harvard-edge/cs249r_book.git',
|
||
temp_dir + '/gh-pages'
|
||
], capture_output=True, text=True)
|
||
|
||
if result.returncode != 0:
|
||
console.print(f"[red]Failed to clone gh-pages: {result.stderr}[/red]")
|
||
return False
|
||
|
||
gh_pages_dir = Path(temp_dir) / 'gh-pages'
|
||
assets_dir = gh_pages_dir / 'assets'
|
||
|
||
# Create assets directory if it doesn't exist
|
||
assets_dir.mkdir(exist_ok=True)
|
||
|
||
# Copy PDF to assets
|
||
target_pdf = assets_dir / 'Machine-Learning-Systems.pdf'
|
||
console.print(f"[blue] 📄 Copying PDF to {target_pdf}...[/blue]")
|
||
shutil.copy2(pdf_path, target_pdf)
|
||
|
||
# Configure git in the temp directory
|
||
subprocess.run(['git', 'config', 'user.name', 'binder-bot'],
|
||
cwd=gh_pages_dir, check=True)
|
||
subprocess.run(['git', 'config', 'user.email', 'binder@mlsysbook.ai'],
|
||
cwd=gh_pages_dir, check=True)
|
||
|
||
# Add, commit, and push
|
||
subprocess.run(['git', 'add', 'assets/downloads/Machine-Learning-Systems.pdf'],
|
||
cwd=gh_pages_dir, check=True)
|
||
|
||
commit_result = subprocess.run([
|
||
'git', 'commit', '-m',
|
||
f'📄 Update PDF for recreated release\n\nUpdated from binder recreate-release command'
|
||
], cwd=gh_pages_dir, capture_output=True, text=True)
|
||
|
||
if commit_result.returncode != 0:
|
||
if "nothing to commit" in commit_result.stdout.lower():
|
||
console.print("[blue] ℹ️ PDF already up to date on GitHub Pages[/blue]")
|
||
return True
|
||
else:
|
||
console.print(f"[red]Failed to commit: {commit_result.stderr}[/red]")
|
||
return False
|
||
|
||
push_result = subprocess.run(['git', 'push', 'origin', 'gh-pages'],
|
||
cwd=gh_pages_dir, capture_output=True, text=True)
|
||
|
||
if push_result.returncode != 0:
|
||
console.print(f"[red]Failed to push: {push_result.stderr}[/red]")
|
||
return False
|
||
|
||
console.print("[blue] ✅ PDF successfully updated on GitHub Pages[/blue]")
|
||
return True
|
||
|
||
except subprocess.CalledProcessError as e:
|
||
console.print(f"[red]Git operation failed: {e}[/red]")
|
||
return False
|
||
except Exception as e:
|
||
console.print(f"[red]Failed to update GitHub Pages PDF: {e}[/red]")
|
||
return False
|
||
|
||
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 tailored for academic textbook
|
||
prompt = f"""Please create professional release notes for version {version} of the Machine Learning Systems textbook based on the following git commits.
|
||
|
||
Release Description: {description}
|
||
Release Type: minor
|
||
|
||
Git Commits:
|
||
{git_changes}
|
||
|
||
This is an ACADEMIC TEXTBOOK release. Please format as:
|
||
- Brief overview of this release for students and educators
|
||
- Key changes organized by category:
|
||
* 📚 New Content (chapters, sections, concepts)
|
||
* 🔧 Improvements (clarity, examples, explanations)
|
||
* 🐛 Fixes (typos, errors, broken links)
|
||
* 🎯 Enhancements (figures, exercises, references)
|
||
- Focus on educational value and learning outcomes
|
||
- Mention any new chapters, lab exercises, or major content additions
|
||
- Keep it professional but accessible to students
|
||
- Do not include technical git details or development-specific changes
|
||
- Emphasize content that benefits learning ML systems concepts"""
|
||
|
||
# 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", "📋 Dynamic", "Reads from Quarto config files")
|
||
|
||
# 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", "Deploy website updates (no release)", "./binder publish")
|
||
full_table.add_row("release", "Create formal textbook release", "./binder release")
|
||
|
||
# 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("check-tags", "Check for orphaned git tags", "./binder check-tags")
|
||
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("r", "release")
|
||
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_publish_help(self):
|
||
"""Display detailed publish workflow help"""
|
||
self.show_banner()
|
||
|
||
# Overview panel
|
||
overview_panel = Panel(
|
||
"[bold blue]🚀 Binder Publish Workflow[/bold blue]\n\n"
|
||
"[green]The publish command intelligently handles the complete release process with smart build detection.[/green]\n\n"
|
||
"[bold yellow]🎯 What it does:[/bold yellow]\n"
|
||
" • Detects existing builds and offers reuse\n"
|
||
" • Interactive PDF compression with size info\n"
|
||
" • Smart version management with tag handling\n"
|
||
" • Dual PDF distribution (GitHub + Pages)\n"
|
||
" • AI-enhanced release notes generation\n"
|
||
" • Complete URL validation\n\n"
|
||
"[bold cyan]💡 Usage:[/bold cyan]\n"
|
||
" ./binder publish # Interactive publishing\n"
|
||
" ./binder publish help # Show this detailed help\n\n"
|
||
"[dim]All steps are interactive with clear prompts and explanations[/dim]",
|
||
title="📚 Publish Overview",
|
||
border_style="blue",
|
||
padding=(1, 2)
|
||
)
|
||
console.print(overview_panel)
|
||
|
||
# Workflow steps
|
||
workflow_table = Table(show_header=True, header_style="bold green", box=None)
|
||
workflow_table.add_column("Phase", style="cyan", width=20)
|
||
workflow_table.add_column("Steps", style="white", width=50)
|
||
workflow_table.add_column("User Control", style="yellow", width=30)
|
||
|
||
workflow_table.add_row(
|
||
"🔍 Build Detection",
|
||
"• Scans for existing PDF and HTML builds\n• Shows file sizes and details\n• Offers to reuse or rebuild",
|
||
"Choose: Reuse or rebuild"
|
||
)
|
||
|
||
workflow_table.add_row(
|
||
"🗜️ PDF Compression",
|
||
"• Shows current PDF file size\n• Explains compression benefits\n• Uses Ghostscript /ebook settings",
|
||
"Choose: Compress or skip"
|
||
)
|
||
|
||
workflow_table.add_row(
|
||
"📋 Version Management",
|
||
"• Shows current version\n• Offers patch/minor/major/custom\n• Handles existing tag conflicts",
|
||
"Choose: Version type\nResolve: Tag conflicts"
|
||
)
|
||
|
||
workflow_table.add_row(
|
||
"🏷️ Git Operations",
|
||
"• Creates annotated git tags\n• Pushes to remote repository\n• Validates git status",
|
||
"Automatic (with confirmation)"
|
||
)
|
||
|
||
workflow_table.add_row(
|
||
"🌐 GitHub Pages",
|
||
"• Deploys HTML website\n• Updates live site content\n• Validates deployment",
|
||
"Choose: Deploy or skip"
|
||
)
|
||
|
||
workflow_table.add_row(
|
||
"📦 GitHub Release",
|
||
"• Creates public release\n• Attaches PDF as asset\n• Generates AI release notes\n• Saves notes for editing",
|
||
"Choose: Create or skip\nEdit: Release notes"
|
||
)
|
||
|
||
workflow_table.add_row(
|
||
"📄 PDF Distribution",
|
||
"• Updates mlsysbook.ai/assets/\n• Syncs with GitHub Pages\n• Dual download availability",
|
||
"Choose: Update or skip"
|
||
)
|
||
|
||
console.print(Panel(workflow_table, title="🔄 Publishing Workflow", border_style="green"))
|
||
|
||
# Key features
|
||
features_table = Table(show_header=True, header_style="bold blue", box=None)
|
||
features_table.add_column("Feature", style="cyan", width=25)
|
||
features_table.add_column("Description", style="white", width=45)
|
||
features_table.add_column("Benefit", style="green", width=30)
|
||
|
||
features_table.add_row(
|
||
"🔍 Smart Build Detection",
|
||
"Automatically finds existing builds and shows details with file sizes",
|
||
"Save time by reusing builds"
|
||
)
|
||
|
||
features_table.add_row(
|
||
"🗜️ Interactive Compression",
|
||
"Shows current PDF size and explains compression trade-offs",
|
||
"Informed decisions on size vs quality"
|
||
)
|
||
|
||
features_table.add_row(
|
||
"🏷️ Tag Conflict Resolution",
|
||
"Detects existing tags and offers clean deletion/recreation",
|
||
"No git conflicts or failures"
|
||
)
|
||
|
||
features_table.add_row(
|
||
"📄 Dual PDF Distribution",
|
||
"PDF available via both GitHub releases and website assets",
|
||
"Multiple download paths for users"
|
||
)
|
||
|
||
features_table.add_row(
|
||
"🤖 AI Release Notes",
|
||
"Generates professional release notes using Ollama AI and saves for editing",
|
||
"Review and customize before publishing"
|
||
)
|
||
|
||
features_table.add_row(
|
||
"✅ URL Validation",
|
||
"Validates all generated URLs before showing to user",
|
||
"Guaranteed working download links"
|
||
)
|
||
|
||
console.print(Panel(features_table, title="✨ Key Features", border_style="blue"))
|
||
|
||
# Example workflow
|
||
example_panel = Panel(
|
||
"[bold yellow]📋 Example Workflow:[/bold yellow]\n\n"
|
||
"[green]$ ./binder publish[/green]\n"
|
||
"[dim]🔍 Found existing build artifacts:[/dim]\n"
|
||
f"[dim] ✅ PDF: {self.get_output_dir('pdf')}/Machine-Learning-Systems.pdf (45.2 MB)[/dim]\n"
|
||
f"[dim] ✅ HTML: {self.get_output_dir('html')}[/dim]\n"
|
||
"[yellow]Reuse existing builds? [y/N]: y[/yellow]\n\n"
|
||
"[dim]🗜️ PDF Compression Options:[/dim]\n"
|
||
"[dim] Uses Ghostscript with /ebook settings to reduce file size[/dim]\n"
|
||
"[dim] Current PDF size: 45.2 MB[/dim]\n"
|
||
"[yellow]Compress PDF? [y/N]: y[/yellow]\n"
|
||
"[dim]✅ PDF compressed successfully (saved 20.1%)[/dim]\n\n"
|
||
"[dim]📌 New version will be: v0.4.0[/dim]\n"
|
||
"[yellow]Create GitHub release? [y/N]: y[/yellow]\n"
|
||
"[dim]✅ Release notes generated and saved to: release_notes_v0.4.0.md[/dim]\n"
|
||
"[yellow]Open file in default editor? [y/N]: y[/yellow]\n"
|
||
"[dim]✅ File opened in default editor[/dim]\n"
|
||
"[yellow]Press Enter to continue after reviewing/editing...[/yellow]\n"
|
||
"[yellow]Update GitHub Pages PDF? [y/N]: y[/yellow]\n\n"
|
||
"[green]🎉 Publication successful![/green]\n"
|
||
"[dim]📖 Web version: https://mlsysbook.ai[/dim]\n"
|
||
"[dim]📦 Releases: https://github.com/harvard-edge/cs249r_book/releases[/dim]\n"
|
||
"[dim]📄 PDF: https://github.com/.../releases/download/v0.4.0/Machine-Learning-Systems.pdf[/dim]",
|
||
title="🎯 Example Session",
|
||
border_style="yellow",
|
||
padding=(1, 2)
|
||
)
|
||
console.print(example_panel)
|
||
|
||
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 white]Options:[/bold white]")
|
||
console.print("[green] y/yes[/green] - Update Git configuration")
|
||
console.print("[blue] n/no[/blue] - Keep current configuration [default]")
|
||
console.print("[bold]Update Git config? [y/N] [default: 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 check_orphaned_tags(self):
|
||
"""Check for orphaned git tags without GitHub releases"""
|
||
self.show_banner()
|
||
console.print("[bold blue]🔍 Checking for Orphaned Git Tags[/bold blue]")
|
||
console.print("[dim]Scanning for git tags that don't have corresponding GitHub releases...[/dim]")
|
||
console.print()
|
||
|
||
try:
|
||
# Get all version tags
|
||
result = subprocess.run(['git', 'tag', '--list', 'v*'],
|
||
capture_output=True, text=True, cwd=self.root_dir)
|
||
tags = result.stdout.strip().split('\n') if result.stdout.strip() else []
|
||
|
||
if not tags:
|
||
console.print("[blue]ℹ️ No version tags found in repository[/blue]")
|
||
return
|
||
|
||
versions = [tag for tag in tags if tag.startswith('v')]
|
||
if not versions:
|
||
console.print("[blue]ℹ️ No version tags found in repository[/blue]")
|
||
return
|
||
|
||
console.print(f"[blue]ℹ️ Found {len(versions)} version tags[/blue]")
|
||
|
||
# Check for orphaned tags
|
||
orphaned_tags = self._find_orphaned_tags(versions)
|
||
|
||
if not orphaned_tags:
|
||
console.print("[green]✅ No orphaned tags detected![/green]")
|
||
console.print("[dim]All git tags have corresponding GitHub releases[/dim]")
|
||
|
||
# Show current version info
|
||
latest_version = max(versions, key=lambda v: [int(x) for x in v[1:].split('.')])
|
||
console.print(f"[blue]ℹ️ Latest version: {latest_version}[/blue]")
|
||
return
|
||
|
||
# Handle orphaned tags
|
||
console.print(f"[yellow]⚠️ Found {len(orphaned_tags)} orphaned tags[/yellow]")
|
||
handled_version = self._handle_orphaned_tags(orphaned_tags, versions[-1])
|
||
|
||
if handled_version:
|
||
console.print(f"[green]✅ Cleanup complete. Current version: {handled_version}[/green]")
|
||
else:
|
||
console.print("[blue]ℹ️ Tag check cancelled. No changes made.[/blue]")
|
||
|
||
except Exception as e:
|
||
console.print(f"[red]❌ Error checking tags: {e}[/red]")
|
||
|
||
def _validate_main_branch_for_release(self):
|
||
"""Validate main branch for formal releases"""
|
||
try:
|
||
# Check current branch
|
||
branch_result = subprocess.run(['git', 'branch', '--show-current'],
|
||
capture_output=True, text=True, cwd=self.root_dir)
|
||
current_branch = branch_result.stdout.strip()
|
||
|
||
if current_branch != "main":
|
||
console.print(f"[red]❌ Formal releases require main branch[/red]")
|
||
console.print(f"[blue]ℹ️ Current branch: {current_branch}[/blue]")
|
||
console.print("[blue]ℹ️ Releases must be created from main branch for proper versioning[/blue]")
|
||
console.print()
|
||
console.print("[bold white]Options:[/bold white]")
|
||
console.print("[green] y/yes[/green] - Switch to main branch")
|
||
console.print("[red] n/no[/red] - Cancel release [default]")
|
||
console.print("[yellow]Switch to main branch? [y/N] [default: N]: [/yellow]", end="")
|
||
choice = input().strip().lower()
|
||
if not choice:
|
||
choice = 'n'
|
||
|
||
if choice in ['y', 'yes']:
|
||
# Check for uncommitted changes
|
||
result = subprocess.run(['git', 'status', '--porcelain'],
|
||
capture_output=True, text=True, cwd=self.root_dir)
|
||
if result.stdout.strip():
|
||
console.print("[yellow]⚠️ You have uncommitted changes[/yellow]")
|
||
console.print("[bold white]Options:[/bold white]")
|
||
console.print("[green] y/yes[/green] - Commit changes before switching")
|
||
console.print("[red] n/no[/red] - Cancel (cannot switch with uncommitted changes) [default]")
|
||
console.print("[yellow]Commit current changes before switching? [y/N] [default: N]: [/yellow]", end="")
|
||
commit_choice = input().strip().lower()
|
||
if not commit_choice:
|
||
commit_choice = 'n'
|
||
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
|
||
|
||
# Switch to main
|
||
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]")
|
||
|
||
# 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]")
|
||
else:
|
||
console.print("[blue]ℹ️ Release cancelled - main branch required[/blue]")
|
||
return False
|
||
|
||
return True
|
||
except Exception as e:
|
||
console.print(f"[red]❌ Git validation failed: {e}[/red]")
|
||
return False
|
||
|
||
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',
|
||
'r': 'release',
|
||
'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":
|
||
if len(sys.argv) > 2 and sys.argv[2].lower() == "help":
|
||
binder.show_publish_help()
|
||
else:
|
||
binder.show_symlink_status()
|
||
binder.publish()
|
||
|
||
elif command == "release":
|
||
binder.show_symlink_status()
|
||
binder.release()
|
||
|
||
elif command == "about":
|
||
binder.show_about()
|
||
|
||
elif command == "help":
|
||
binder.show_help()
|
||
|
||
elif command == "hello":
|
||
binder.show_hello()
|
||
|
||
elif command == "setup":
|
||
binder.setup_environment()
|
||
|
||
elif command == "check-tags":
|
||
binder.check_orphaned_tags()
|
||
|
||
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() |