#!/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.build_dir = self.root_dir / "build" self.html_config = self.book_dir / "config" / "_quarto-html.yml" self.pdf_config = self.book_dir / "config" / "_quarto-pdf.yml" self.active_config = self.book_dir / "_quarto.yml" def show_banner(self): """Display beautiful banner""" banner = Panel.fit( "[bold blue]๐Ÿ“š Book Binder[/bold blue]\n" "[dim]โšก I compile ML systems knowledge[/dim]", border_style="blue", padding=(1, 2) ) console.print(banner) def find_chapters(self): """Find all available chapters""" contents_dir = self.book_dir / "contents" if not contents_dir.exists(): return [] chapters = [] for qmd_file in contents_dir.rglob("*.qmd"): if "images" not in str(qmd_file): rel_path = qmd_file.relative_to(contents_dir) chapter_name = str(rel_path).replace(".qmd", "") chapters.append(chapter_name) return sorted(chapters) def find_chapter_file(self, partial_name): """Find chapter file that matches partial name""" contents_dir = self.book_dir / "contents" # Try direct glob match first matches = list(contents_dir.rglob(f"*{partial_name}*.qmd")) if matches: # Filter out images directories valid_matches = [m for m in matches if "images" not in str(m)] if valid_matches: return valid_matches[0] return None def find_chapter_match(self, partial_name): """Find chapter that matches partial name""" file_path = self.find_chapter_file(partial_name) if file_path: rel_path = file_path.relative_to(self.book_dir / "contents") return str(rel_path).replace(".qmd", "") return None def get_status(self): """Get current configuration status""" if self.active_config.is_symlink(): target = self.active_config.readlink() active_config = f"Symlink: {target}" else: active_config = "NOT a symlink" # Check for commented lines html_commented = 0 pdf_commented = 0 try: if self.html_config.exists(): with open(self.html_config, 'r') as f: html_commented = sum(1 for line in f if "FAST_BUILD_COMMENTED" in line) except: pass try: if self.pdf_config.exists(): with open(self.pdf_config, 'r') as f: pdf_commented = sum(1 for line in f if "FAST_BUILD_COMMENTED" in line) except: pass return { 'active_config': active_config, 'html_commented': html_commented, 'pdf_commented': pdf_commented, 'is_clean': html_commented == 0 and pdf_commented == 0 } def show_symlink_status(self): """Show simple symlink status""" if self.active_config.is_symlink(): target = self.active_config.readlink() console.print(f"[bold cyan]โ†’ _quarto.yml: {target}[/bold cyan]") console.print() # Add blank line for spacing else: console.print(f"[bold yellow]โš ๏ธ _quarto.yml is NOT a symlink[/bold yellow]") console.print() # Add blank line for spacing def show_status(self): """Display beautiful status information""" status = self.get_status() table = Table(title="๐Ÿ“Š Current Status", show_header=False, box=None) table.add_column("", style="cyan", no_wrap=True) table.add_column("", style="white") table.add_row("๐Ÿ”— Active Config", f"[bold]{status['active_config']}[/bold]") if status['is_clean']: table.add_row("โœ… State", "[green]Configs are clean[/green]") else: table.add_row("โš ๏ธ State", f"[yellow]{status['html_commented'] + status['pdf_commented']} commented lines[/yellow]") console.print(Panel(table, border_style="green")) def show_chapters(self): """Display available chapters in a beautiful format""" chapters = self.find_chapters() tree = Tree("๐Ÿ“š [bold blue]Available Chapters[/bold blue]") # Group by category categories = {} for chapter in chapters: parts = chapter.split('/') if len(parts) > 1: category = parts[0] name = '/'.join(parts[1:]) else: category = "root" name = chapter if category not in categories: categories[category] = [] categories[category].append(name) for category, items in sorted(categories.items()): category_node = tree.add(f"[bold cyan]{category}[/bold cyan]") for item in sorted(items): category_node.add(f"[white]{item}[/white]") console.print(tree) @contextmanager def progress_context(self, description): """Context manager for progress display""" with Progress( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), BarColumn(), TimeElapsedColumn(), console=console ) as progress: task = progress.add_task(description, total=None) yield progress progress.update(task, completed=True) def run_command(self, cmd, cwd=None, description=None, capture_for_parsing=False): """Run command with progress indicator and optional output capture""" if isinstance(cmd, str): cmd = cmd.split() desc = description or f"Running: {' '.join(cmd)}" try: with self.progress_context(desc): if capture_for_parsing: # Capture output for parsing while still showing it result = subprocess.run( cmd, cwd=cwd or self.root_dir, capture_output=True, text=True ) # Print the output so user still sees it if result.stdout: print(result.stdout, end='') if result.stderr: print(result.stderr, end='') # Return both success status and captured output return result.returncode == 0, result.stdout + result.stderr else: # Original behavior - stream output directly result = subprocess.run( cmd, cwd=cwd or self.root_dir, capture_output=False, text=True ) return result.returncode == 0 except Exception as e: console.print(f"[red]Error: {e}[/red]") return False def open_output_file(self, output_text): """Parse quarto output for 'Output created:' and open the file""" lines = output_text.split('\n') for line in lines: if 'Output created:' in line: # Extract the file path after "Output created: " file_path = line.split('Output created: ', 1)[1].strip() # Fix quarto's incorrect relative path calculation # Quarto outputs ../../../../build/... but it should be ../build/... if file_path.startswith('../../../../build/'): file_path = file_path.replace('../../../../build/', '../build/') # Convert relative path to absolute - quarto runs from book dir if not os.path.isabs(file_path): # Path is relative to book directory, resolve it properly full_path = (self.book_dir / file_path).resolve() else: full_path = Path(file_path) if full_path.exists(): console.print(f"[green]๐ŸŒ Opening in browser: {full_path.name}[/green]") try: # Use 'open' command on macOS subprocess.run(['open', str(full_path)], check=True) return True except subprocess.CalledProcessError: console.print(f"[yellow]โš ๏ธ Could not open file automatically: {full_path}[/yellow]") return False else: console.print(f"[yellow]โš ๏ธ Output file not found: {full_path}[/yellow]") console.print(f"[dim] Tried: {full_path}[/dim]") return False # No "Output created:" line found return False def open_browser_if_needed(self, file_path): """Open a file in the browser if it exists""" if not os.path.isabs(file_path): # Path is relative to book directory full_path = (self.book_dir / file_path).resolve() else: full_path = Path(file_path) if full_path.exists(): console.print(f"[green]๐ŸŒ Opening in browser: {full_path.name}[/green]") try: # Use 'open' command on macOS subprocess.run(['open', str(full_path)], check=True) return True except subprocess.CalledProcessError: console.print(f"[yellow]โš ๏ธ Could not open file automatically: {full_path}[/yellow]") return False else: console.print(f"[yellow]โš ๏ธ Output file not found: {full_path}[/yellow]") return False def set_fast_build_mode(self, config_file, target_path): """Set fast build mode - different approach for HTML vs PDF""" backup_path = config_file.with_suffix('.yml.fast-build-backup') # Create backup shutil.copy2(config_file, backup_path) # Read current config with open(config_file, 'r', encoding='utf-8') as f: content = f.read() target_stem = target_path.stem target_relative_path = target_path.relative_to(self.book_dir).as_posix() # Different approach for HTML (website) vs PDF (book) if 'type: website' in content: # HTML: Use project.render configuration render_config = f""" render: - index.qmd - 404.qmd - contents/frontmatter/ - {target_relative_path} # Fast build: only render essential files and target chapter """ # Insert render config after "navigate: true" in project section if 'navigate: true' in content: content = content.replace( 'navigate: true', f'navigate: true\n{render_config}' ) else: console.print(f"[yellow]โš ๏ธ Could not find project navigation section in {config_file}[/yellow]") return False elif 'type: book' in content: # PDF: Comment out unwanted chapters in book.chapters list lines = content.split('\n') new_lines = [] in_chapters_section = False target_found = False for line in lines: if line.strip() == 'chapters:': in_chapters_section = True new_lines.append(line) continue elif in_chapters_section and line.startswith(' - ') and '.qmd' in line: # This is a chapter line if target_relative_path in line: # Keep the target chapter new_lines.append(line) target_found = True elif any(essential in line for essential in ['index.qmd', 'foreword.qmd', 'about/', 'acknowledgements/']): # Keep essential frontmatter new_lines.append(line) else: # Comment out other chapters new_lines.append(f" # {line.strip()[2:]} # Commented for fast build") elif in_chapters_section and line.strip() and not line.startswith(' '): # End of chapters section in_chapters_section = False new_lines.append(line) else: new_lines.append(line) if not target_found: console.print(f"[yellow]โš ๏ธ Target chapter {target_relative_path} not found in chapters list[/yellow]") return False content = '\n'.join(new_lines) else: console.print(f"[yellow]โš ๏ธ Unknown project type in {config_file}[/yellow]") return False # Write modified config with open(config_file, 'w', encoding='utf-8') as f: f.write(content) console.print(f" ๐Ÿ“ Fast build mode: Only rendering {target_stem} + essential files") return True def set_fast_build_mode_multiple(self, config_file, target_paths): """Set fast build mode for multiple chapters in a single render""" backup_path = config_file.with_suffix('.yml.fast-build-backup') # Create backup shutil.copy2(config_file, backup_path) # Read current config with open(config_file, 'r', encoding='utf-8') as f: content = f.read() # Get relative paths for all targets target_relative_paths = [path.relative_to(self.book_dir).as_posix() for path in target_paths] target_stems = [path.stem for path in target_paths] # Different approach for HTML (website) vs PDF (book) if 'type: website' in content: # HTML: Use project.render configuration with all target chapters render_items = [ " - index.qmd", " - 404.qmd", " - contents/frontmatter/" ] # Add all target chapters for target_path in target_relative_paths: render_items.append(f" - {target_path}") render_config = f""" render: {chr(10).join(render_items)} # Fast build: only render essential files and target chapters """ # Insert render config after "navigate: true" in project section if 'navigate: true' in content: content = content.replace( 'navigate: true', f'navigate: true\n{render_config}' ) else: console.print(f"[yellow]โš ๏ธ Could not find project navigation section in {config_file}[/yellow]") return False elif 'type: book' in content: # PDF: Comment out unwanted chapters in book.chapters list lines = content.split('\n') new_lines = [] in_chapters_section = False targets_found = 0 for line in lines: if line.strip() == 'chapters:': in_chapters_section = True new_lines.append(line) continue elif in_chapters_section and line.startswith(' - ') and '.qmd' in line: # This is a chapter line if any(target_path in line for target_path in target_relative_paths): # Keep target chapters new_lines.append(line) targets_found += 1 elif any(essential in line for essential in ['index.qmd', 'foreword.qmd', 'about/', 'acknowledgements/']): # Keep essential frontmatter new_lines.append(line) else: # Comment out other chapters new_lines.append(f" # {line.strip()[2:]} # Commented for fast build") elif in_chapters_section and line.strip() and not line.startswith(' '): # End of chapters section in_chapters_section = False new_lines.append(line) else: new_lines.append(line) if targets_found != len(target_relative_paths): console.print(f"[yellow]โš ๏ธ Only {targets_found}/{len(target_relative_paths)} target chapters found in chapters list[/yellow]") return False content = '\n'.join(new_lines) else: console.print(f"[yellow]โš ๏ธ Unknown project type in {config_file}[/yellow]") return False # Write modified config with open(config_file, 'w', encoding='utf-8') as f: f.write(content) console.print(f" ๐Ÿ“ Fast build mode: Only rendering {', '.join(target_stems)} + essential files") return True def restore_config(self, config_file): """Restore configuration from backup""" backup_path = config_file.with_suffix('.yml.fast-build-backup') if backup_path.exists(): shutil.copy2(backup_path, config_file) backup_path.unlink() console.print(f" ๐Ÿ”„ Restored from backup: {backup_path.name}") else: console.print(f"[yellow]โš ๏ธ No backup found: {backup_path}[/yellow]") def ensure_clean_config(self, config_file): """Ensure config is in clean state (remove any render restrictions)""" backup_path = config_file.with_suffix('.yml.fast-build-backup') if backup_path.exists(): # Restore from backup if it exists shutil.copy2(backup_path, config_file) backup_path.unlink() console.print(f" โœ… {config_file.name} restored from backup") else: # Check if file has render restrictions and remove them with open(config_file, 'r', encoding='utf-8') as f: content = f.read() # Remove render configuration if present lines = content.split('\n') cleaned_lines = [] in_render_section = False for line in lines: if line.strip().startswith('render:'): in_render_section = True continue elif in_render_section and (line.startswith(' - ') or line.startswith(' #')): continue elif in_render_section and line.strip() == '': continue else: in_render_section = False cleaned_lines.append(line) cleaned_content = '\n'.join(cleaned_lines) if cleaned_content != content: with open(config_file, 'w', encoding='utf-8') as f: f.write(cleaned_content) console.print(f" โœ… {config_file.name} cleaned (removed render restrictions)") else: console.print(f" โœ… {config_file.name} already clean") def setup_symlink(self, format_type): """Setup _quarto.yml symlink""" if format_type == "html": config_file = "config/_quarto-html.yml" elif format_type == "pdf": config_file = "config/_quarto-pdf.yml" else: raise ValueError(f"Unknown format type: {format_type}") # Remove existing symlink/file if self.active_config.exists() or self.active_config.is_symlink(): self.active_config.unlink() # Create new symlink self.active_config.symlink_to(config_file) return config_file def build(self, chapters, format_type="html"): """Build one or more chapters with beautiful progress display""" # Handle both single chapter and comma-separated chapters if isinstance(chapters, str): if ',' in chapters: chapter_list = [ch.strip() for ch in chapters.split(',')] return self.build_multiple(chapter_list, format_type) else: return self.build_single(chapters, format_type) else: # Assume it's already a list return self.build_multiple(chapters, format_type) def build_multiple(self, chapter_list, format_type="html"): """Build multiple chapters together in a single unified build""" console.print(f"[green] ๐Ÿš€ Building {len(chapter_list)} chapters together[/green] [dim]({format_type})[/dim]") console.print(f"[dim] ๐Ÿ“‹ Chapters: {', '.join(chapter_list)}[/dim]") return self.build_multiple_unified(chapter_list, format_type) def build_multiple_unified(self, chapter_list, format_type="html"): """Build multiple chapters in a single Quarto render command""" # Resolve chapter paths chapter_files = [] for chapter in chapter_list: chapter_file = self.find_chapter_file(chapter) if not chapter_file: console.print(f"[red]โŒ Chapter not found: {chapter}[/red]") return False chapter_files.append(chapter_file) # Configure build settings if format_type == "html": config_file = self.html_config format_arg = "html" build_subdir = "html" elif format_type == "pdf": config_file = self.pdf_config format_arg = "titlepage-pdf" build_subdir = "pdf" else: raise ValueError(f"Unknown format type: {format_type}") # Create build directory (self.build_dir / build_subdir).mkdir(parents=True, exist_ok=True) # Setup correct configuration symlink for the format self.setup_symlink(format_type) try: # Ensure config is clean (remove any render restrictions) self.ensure_clean_config(config_file) console.print(f"[green] ๐Ÿš€ Building {len(chapter_list)} chapters together[/green] [dim]({format_type})[/dim]") # Set up unified fast build mode for all chapters success = self.set_fast_build_mode_multiple(config_file, chapter_files) if not success: return False # Set up signal handlers for graceful shutdown def signal_handler(signum, frame): self.restore_config(config_file) sys.exit(1) signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) # Build with unified project.render configuration console.print("[yellow] ๐Ÿ”จ Building all chapters in single render...[/yellow]") # Render project with all target chapters render_cmd = ["quarto", "render", "--to", format_arg] # Show the raw command being executed cmd_str = " ".join(render_cmd) console.print(f"[blue] ๐Ÿ’ป Command: {cmd_str}[/blue]") # Capture output to find the created file and open it result = self.run_command( render_cmd, cwd=self.book_dir, description=f" Building {len(chapter_list)} chapters ({format_type}) - unified build", capture_for_parsing=True ) if result: console.print(f" โœ… Unified build complete: build/{build_subdir}/") # Open browser for HTML builds if format_type == "html" and result: self.open_browser_if_needed(f"../build/{build_subdir}/index.html") return True else: console.print(f"[red]โŒ Build failed[/red]") return False finally: # Always restore config, even if build fails console.print(" ๐Ÿ›ก๏ธ Ensuring pristine config restoration...") self.restore_config(config_file) def build_single(self, chapter, format_type="html", open_browser=True): """Build a single chapter with beautiful progress display""" # Find the actual chapter file chapter_file = self.find_chapter_file(chapter) if not chapter_file: console.print(f"[red]โŒ No chapter found matching '{chapter}'[/red]") console.print("[yellow]๐Ÿ’ก Available chapters:[/yellow]") self.show_chapters() return False # Get relative path from book directory target_path = str(chapter_file.relative_to(self.book_dir)) chapter_name = str(chapter_file.relative_to(self.book_dir / "contents")).replace(".qmd", "") console.print(f"[green] ๐Ÿš€ Building[/green] [bold]{chapter_name}[/bold] [dim]({format_type})[/dim]") console.print(f"[dim] โœ… Found: {chapter_file}[/dim]") # Setup configuration if format_type == "html": config_file = self.html_config format_arg = "html" build_subdir = "html" elif format_type == "pdf": config_file = self.pdf_config format_arg = "titlepage-pdf" build_subdir = "pdf" else: raise ValueError(f"Unknown format type: {format_type}") # Create build directory (self.build_dir / build_subdir).mkdir(parents=True, exist_ok=True) # Setup correct configuration symlink for the format self.setup_symlink(format_type) try: # Ensure config is clean (remove any render restrictions) self.ensure_clean_config(config_file) # Set fast build mode for the target chapter self.set_fast_build_mode(config_file, chapter_file) # Setup signal handler to restore config on Ctrl+C def signal_handler(signum, frame): console.print("\n[yellow]๐Ÿ›ก๏ธ Ctrl+C detected - restoring config and exiting...[/yellow]") self.restore_config(config_file) console.print("[green]โœ… Config restored to pristine state[/green]") sys.exit(0) signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) # Build with project.render configuration (fast build) console.print("[yellow] ๐Ÿ”จ Building with fast build configuration...[/yellow]") # Render project with limited file scope render_cmd = ["quarto", "render", "--to", format_arg] # Show the raw command being executed cmd_str = " ".join(render_cmd) console.print(f"[blue] ๐Ÿ’ป Command: {cmd_str}[/blue]") # Capture output to find the created file and open it result = self.run_command( render_cmd, cwd=self.book_dir, description=f"Building {chapter_name} ({format_type}) - fast build", capture_for_parsing=True ) # Handle the returned tuple (success, output) if isinstance(result, tuple): success, output = result else: success, output = result, "" if success: console.print(f"[green] โœ… Fast build complete: build/{build_subdir}/[/green]") # Automatically open the output file if HTML and requested if format_type == "html" and output and open_browser: # Check user preference for auto-open preferences = self.get_user_preferences() auto_open = preferences.get('auto_open', True) if auto_open: self.open_output_file(output) else: console.print(f"[blue]๐Ÿ“„ Output ready: {output}[/blue]") console.print("[dim]๐Ÿ’ก Set auto-open preference with './binder setup'[/dim]") else: console.print("[red] โŒ Build failed[/red]") return success finally: # Always restore config (bulletproof cleanup) console.print("[yellow] ๐Ÿ›ก๏ธ Ensuring pristine config restoration...[/yellow]") self.restore_config(config_file) def preview(self, chapter): """Start preview server for a chapter""" # Find the actual chapter file chapter_file = self.find_chapter_file(chapter) if not chapter_file: console.print(f"[red]โŒ No chapter found matching '{chapter}'[/red]") return False target_path = str(chapter_file.relative_to(self.book_dir)) chapter_name = str(chapter_file.relative_to(self.book_dir / "contents")).replace(".qmd", "") console.print(f"[blue]๐ŸŒ Starting preview for[/blue] [bold]{chapter_name}[/bold]") # Setup for HTML preview config_file = self.html_config try: # Ensure config is clean (remove any render restrictions) self.ensure_clean_config(config_file) # Setup signal handler to restore config on exit def signal_handler(signum, frame): console.print("\n[yellow]๐Ÿ›ก๏ธ Ctrl+C detected - restoring config and exiting...[/yellow]") self.restore_config(config_file) console.print("[green]โœ… Config restored to pristine state[/green]") sys.exit(0) signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) # Start preview with only the target file try: preview_cmd = ["quarto", "preview", target_path] # Show the raw command being executed cmd_str = " ".join(preview_cmd) console.print(f"[blue] ๐Ÿ’ป Command: {cmd_str}[/blue]") subprocess.run( preview_cmd, cwd=self.book_dir ) finally: # Restore config self.restore_config(config_file) return True except Exception as e: # Always restore config on error self.restore_config(config_file) console.print(f"[red]โŒ Preview failed: {e}[/red]") return False def clean(self): """Clean build artifacts and restore configs""" console.print("[bold blue]๐Ÿงน Fast Build Cleanup[/bold blue]") console.print("[dim]๐Ÿ’ก Restoring master configs and basic cleanup[/dim]") # Restore HTML config html_config = self.book_dir / "config" / "_quarto-html.yml" self.ensure_clean_config(html_config) # Restore PDF config pdf_config = self.book_dir / "config" / "_quarto-pdf.yml" self.ensure_clean_config(pdf_config) # Show current symlink status symlink_path = self.book_dir / "_quarto.yml" if symlink_path.exists() and symlink_path.is_symlink(): target = symlink_path.readlink() console.print(f"[dim] ๐Ÿ”— Current symlink: _quarto.yml โ†’ {target}[/dim]") console.print("[green] โœ… All configs restored to clean state[/green]") # Clean build artifacts artifacts_to_clean = [ (self.root_dir / "build", "Build directory (all formats)"), (self.book_dir / "index_files", "Book index files"), (self.book_dir / ".quarto", "Quarto cache (book)") ] # Clean Quarto-generated figure directories contents_core = self.book_dir / "contents" / "core" if contents_core.exists(): for chapter_dir in contents_core.glob("*/"): if chapter_dir.is_dir(): # Look for *_files directories containing figure-html for files_dir in chapter_dir.glob("*_files"): if files_dir.is_dir(): figure_html_dir = files_dir / "figure-html" if figure_html_dir.exists(): artifacts_to_clean.append((figure_html_dir, f"Quarto figure artifacts ({chapter_dir.name})")) # Also check for any standalone figure-html directories figure_html_direct = chapter_dir / "figure-html" if figure_html_direct.exists(): artifacts_to_clean.append((figure_html_direct, f"Quarto figure artifacts ({chapter_dir.name})")) cleaned_count = 0 for artifact_path, description in artifacts_to_clean: if artifact_path.exists(): if artifact_path.is_dir(): shutil.rmtree(artifact_path) else: artifact_path.unlink() console.print(f"[yellow] ๐Ÿ—‘๏ธ Removing: {artifact_path.name} ({description})[/yellow]") cleaned_count += 1 if cleaned_count > 0: console.print(f"[green] โœ… Cleaned {cleaned_count} items successfully[/green]") else: console.print("[green] โœ… No artifacts to clean[/green]") def check_artifacts(self): """Check for build artifacts that shouldn't be committed""" console.print("[blue]๐Ÿ” Checking for build artifacts...[/blue]") # Check for artifacts that shouldn't be committed artifacts_found = [] potential_artifacts = [ (self.root_dir / "build", "Build directory"), (self.book_dir / "index_files", "Book index files"), (self.book_dir / ".quarto", "Quarto cache"), (self.book_dir / "config" / "_quarto-html.yml.fast-build-backup", "HTML config backup"), (self.book_dir / "config" / "_quarto-pdf.yml.fast-build-backup", "PDF config backup"), ] for path, description in potential_artifacts: if path.exists(): artifacts_found.append((path, description)) if artifacts_found: console.print("[yellow]โš ๏ธ Build artifacts detected:[/yellow]") for path, description in artifacts_found: console.print(f"[yellow] ๐Ÿ“„ {path} ({description})[/yellow]") console.print("[blue]๐Ÿ’ก Run './binder clean' to remove these artifacts[/blue]") return False else: console.print("[green]โœ… No build artifacts detected.[/green]") return True def switch(self, format_type): """Switch configuration format""" if format_type not in ["html", "pdf", "both"]: console.print("[red]โŒ Format must be 'html', 'pdf', or 'both'[/red]") return False console.print(f"[blue]๐Ÿ”— Switching to {format_type} config...[/blue]") # Clean up first self.clean() # Setup new symlink config_name = self.setup_symlink(format_type) console.print(f"[green] โœ… _quarto.yml โ†’ {config_name}[/green]") return True def build_full(self, format_type="html"): """Build full book in specified format""" console.print(f"[green] ๐Ÿ”จ Building full {format_type.upper()} book...[/green]") if format_type == "both": # Build both formats sequentially console.print("[blue]๐Ÿ“š Building both HTML and PDF formats...[/blue]") # Build HTML first console.print("[blue] ๐Ÿ“„ Building HTML version...[/blue]") html_success = self.build_full("html") if not html_success: console.print("[red]โŒ HTML build failed![/red]") return False # Build PDF console.print("[blue] ๐Ÿ“„ Building PDF version...[/blue]") pdf_success = self.build_full("pdf") if not pdf_success: console.print("[red]โŒ PDF build failed![/red]") return False console.print("[green]โœ… Both HTML and PDF builds completed successfully![/green]") return True # Create build directory build_subdir = format_type (self.build_dir / build_subdir).mkdir(parents=True, exist_ok=True) # Setup config config_name = self.setup_symlink(format_type) if format_type == "html": render_to = "html" elif format_type == "pdf": render_to = "titlepage-pdf" else: raise ValueError(f"Unknown format type: {format_type}") render_cmd = ["quarto", "render", "--to", render_to] # Show the raw command being executed cmd_str = " ".join(render_cmd) console.print(f"[blue] ๐Ÿ’ป Command: {cmd_str}[/blue]") success = self.run_command( render_cmd, cwd=self.book_dir, description=f"Building full {format_type.upper()} book" ) if success: console.print(f"[green] โœ… {format_type.upper()} build complete: build/{build_subdir}/[/green]") return success def preview_full(self, format_type="html"): """Start full preview server""" console.print(f"[blue]๐ŸŒ Starting full {format_type.upper()} preview server...[/blue]") # Setup config config_name = self.setup_symlink(format_type) console.print(f"[blue] ๐Ÿ”— Using {config_name}[/blue]") console.print("[dim] ๐Ÿ›‘ Press Ctrl+C to stop the server[/dim]") try: preview_cmd = ["quarto", "preview"] # Show the raw command being executed cmd_str = " ".join(preview_cmd) console.print(f"[blue] ๐Ÿ’ป Command: {cmd_str}[/blue]") subprocess.run(preview_cmd + ["book/"], cwd=self.root_dir) return True except KeyboardInterrupt: console.print("\n[yellow]๐Ÿ›‘ Preview server stopped[/yellow]") return True except Exception as e: console.print(f"[red]โŒ Preview failed: {e}[/red]") return False def publish(self): """Enhanced manual publisher with integrated functionality""" self._show_publisher_header() # Step 0: Production Publishing Confirmation console.print("[bold red]โš ๏ธ You are about to publish to LIVE PRODUCTION systems.[/bold red]") console.print("[red]This will create public releases and deploy to GitHub Pages.[/red]") console.print("[yellow]Type 'PUBLISH' (all caps) to confirm: [/yellow]", end="") confirmation = input().strip() if confirmation != "PUBLISH": console.print("[blue]โ„น๏ธ Publishing cancelled - confirmation not received[/blue]") console.print("[dim]To publish, you must type exactly: PUBLISH[/dim]") return False console.print("[green]โœ… Production publishing confirmed[/green]") # Step 1: Git Status Check if not self._validate_git_status(): return False # Step 2: Version Management version_info = self._plan_version_release() if not version_info: return False # Step 3: Confirmation if not self._confirm_publishing(version_info): return False # Step 4: Building Phase if not self._execute_build_phase(): return False # Step 5: Publishing Phase if not self._execute_publishing_phase(version_info): return False # Step 6: Success self._show_publish_success(version_info) return True # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• # ๐Ÿš€ Enhanced Publishing Methods # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• def _show_publisher_header(self): """Display the publisher header with prominent warning""" # Big red warning box warning = Panel( "[bold red]โš ๏ธ LIVE PUBLISHING WARNING โš ๏ธ[/bold red]\n\n" "[red]This tool publishes to PRODUCTION systems:[/red]\n" "[red]โ€ข Creates public GitHub releases[/red]\n" "[red]โ€ข Deploys to live GitHub Pages[/red]\n" "[red]โ€ข Pushes changes to main branch[/red]\n" "[red]โ€ข Updates public documentation[/red]\n\n" "[bold yellow]โšก USE WITH CAUTION โšก[/bold yellow]\n" "[dim]Only run when you're ready to publish changes publicly[/dim]", title="[bold red]๐Ÿšจ PRODUCTION DEPLOYMENT ๐Ÿšจ[/bold red]", border_style="red", padding=(1, 2) ) console.print(warning) console.print() # Add space # Regular header below warning header = Panel.fit( "[bold blue]๐Ÿ“š MLSysBook Manual Publisher[/bold blue]\n" "[dim]โšก I build, compress, and publish your book[/dim]", border_style="blue", padding=(1, 2) ) console.print(header) def _validate_git_status(self): """Validate git status and handle branch management""" console.print("\n[bold cyan]โ”Œโ”€ Git Status Check โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€[/bold cyan]") try: # Get current branch result = subprocess.run(['git', 'branch', '--show-current'], capture_output=True, text=True, cwd=self.root_dir) current_branch = result.stdout.strip() # Get uncommitted changes count result = subprocess.run(['git', 'status', '--porcelain'], capture_output=True, text=True, cwd=self.root_dir) changes_count = len(result.stdout.strip().split('\n')) if result.stdout.strip() else 0 console.print(f"[blue]โ„น๏ธ Current branch: {current_branch}[/blue]") console.print(f"[blue]โ„น๏ธ Uncommitted changes: {changes_count}[/blue]") # Handle branch management if current_branch != "main": console.print("[yellow]โš ๏ธ You are not on the main branch[/yellow]") console.print(f"[blue]โ„น๏ธ Current branch: {current_branch}[/blue]") console.print("[blue]โ„น๏ธ Publishing requires being on main branch with latest dev changes[/blue]") console.print() console.print("[yellow]Move to main branch and pull in dev changes? [y/N]: [/yellow]", end="") choice = input().strip().lower() if choice in ['y', 'yes']: # Step 1: Handle uncommitted changes if changes_count > 0: console.print("[yellow]โš ๏ธ You have uncommitted changes on current branch[/yellow]") console.print("[yellow]Commit current changes before switching? [y/N]: [/yellow]", end="") commit_choice = input().strip().lower() if commit_choice in ['y', 'yes']: console.print("[white]Commit message [fix: update before switching to main]: [/white]", end="") commit_msg = input().strip() or "fix: update before switching to main" subprocess.run(['git', 'add', '.'], cwd=self.root_dir, check=True) subprocess.run(['git', 'commit', '-m', commit_msg], cwd=self.root_dir, check=True) console.print("[green]โœ… Changes committed[/green]") else: console.print("[red]โŒ Cannot switch branches with uncommitted changes[/red]") return False # Step 2: Switch to main branch console.print("[purple]๐Ÿ”„ Switching to main branch...[/purple]") subprocess.run(['git', 'checkout', 'main'], cwd=self.root_dir, check=True) console.print("[green]โœ… Switched to main branch[/green]") # Step 3: Pull latest main console.print("[purple]๐Ÿ”„ Pulling latest main branch changes...[/purple]") subprocess.run(['git', 'pull', 'origin', 'main'], cwd=self.root_dir, check=True) console.print("[green]โœ… Main branch updated[/green]") # Step 4: Merge dev changes console.print("[purple]๐Ÿ”„ Merging dev branch changes into main...[/purple]") merge_result = subprocess.run(['git', 'merge', 'dev'], cwd=self.root_dir, capture_output=True, text=True) if merge_result.returncode == 0: console.print("[green]โœ… Dev changes merged into main[/green]") # Step 5: Push updated main console.print("[purple]๐Ÿ”„ Pushing updated main branch...[/purple]") subprocess.run(['git', 'push', 'origin', 'main'], cwd=self.root_dir, check=True) console.print("[green]โœ… Main branch pushed with dev changes[/green]") console.print("[blue]โ„น๏ธ Ready to build and publish from main branch[/blue]") else: console.print("[red]โŒ Merge conflicts detected[/red]") console.print("[yellow]โš ๏ธ Please resolve merge conflicts manually and try again[/yellow]") console.print(f"[dim]Merge output: {merge_result.stderr}[/dim]") return False else: console.print("[red]โŒ Cannot publish without being on main branch[/red]") console.print("[blue]โ„น๏ธ Publishing from main ensures stable releases with all dev changes[/blue]") return False elif changes_count > 0: console.print("[yellow]โš ๏ธ You have uncommitted changes[/yellow]") console.print("[yellow]Commit changes before continuing? [y/N]: [/yellow]", end="") choice = input().strip().lower() if choice in ['y', 'yes']: console.print("[white]Commit message [fix: update before publishing]: [/white]", end="") commit_msg = input().strip() or "fix: update before publishing" subprocess.run(['git', 'add', '.'], cwd=self.root_dir, check=True) subprocess.run(['git', 'commit', '-m', commit_msg], cwd=self.root_dir, check=True) subprocess.run(['git', 'push', 'origin', 'main'], cwd=self.root_dir, check=True) console.print("[green]โœ… Changes committed and pushed[/green]") else: console.print("[red]โŒ Cannot continue with uncommitted changes[/red]") return False else: console.print("[green]โœ… Git status is clean[/green]") return True except Exception as e: console.print(f"[red]โŒ Git validation failed: {e}[/red]") return False def _plan_version_release(self): """Plan version and release strategy""" console.print("\n[bold cyan]โ”Œโ”€ Version Management โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€[/bold cyan]") try: # Get current version current_version = self._get_current_version() console.print(f"[blue]โ„น๏ธ Current version: {current_version}[/blue]") console.print() # Show version type guide console.print("[bold white]๐Ÿ“‹ Version Type Guide:[/bold white]") console.print("[green] 1. patch[/green] - Bug fixes, typos, small corrections (v1.0.0 โ†’ v1.0.1)") console.print("[yellow] 2. minor[/yellow] - New content, features, improvements (v1.0.0 โ†’ v1.1.0)") console.print("[red] 3. major[/red] - Breaking changes, major restructuring (v1.0.0 โ†’ v2.0.0)") console.print("[blue] 4. custom[/blue] - Specify your own version number") console.print() console.print("[white]What type of changes are you publishing?[/white]") console.print("[white]Select option [1-4] [2 for minor]: [/white]", end="") choice = input().strip() or "2" release_types = ["patch", "minor", "major", "custom"] try: choice_idx = int(choice) - 1 if 0 <= choice_idx < len(release_types): release_type = release_types[choice_idx] else: release_type = "minor" except ValueError: release_type = "minor" # Calculate new version if release_type == "custom": console.print() console.print("[blue]โ„น๏ธ Custom version format: vX.Y.Z (e.g., v1.2.3)[/blue]") console.print("[white]Enter your custom version: [/white]", end="") new_version = input().strip() if not new_version.startswith('v'): new_version = f"v{new_version}" else: new_version = self._calculate_next_version(current_version, release_type) console.print() console.print(f"[bold green]๐Ÿ“Œ New version will be: {new_version}[/bold green]") console.print(f"[dim] Previous: {current_version} โ†’ New: {new_version}[/dim]") # Check if version exists if self._version_exists(new_version): console.print(f"[yellow]โš ๏ธ Version {new_version} already exists[/yellow]") console.print("[yellow]Replace existing version? [y/N]: [/yellow]", end="") choice = input().strip().lower() if choice in ['y', 'yes']: self._delete_version(new_version) console.print("[green]โœ… Existing version removed[/green]") else: console.print("[red]โŒ Cannot continue with existing version[/red]") return None # Get release description console.print() console.print("[bold white]๐Ÿ“ Release Description:[/bold white]") console.print("[dim] This will appear in the GitHub release and help users understand what changed[/dim]") console.print("[white]Enter description [Content updates and improvements]: [/white]", end="") description = input().strip() or "Content updates and improvements" return { 'version': new_version, 'release_type': release_type, 'description': description } except Exception as e: console.print(f"[red]โŒ Version planning failed: {e}[/red]") return None def _confirm_publishing(self, version_info): """Show publishing summary and get confirmation""" console.print("\n[bold cyan]โ”Œโ”€ Publishing Summary โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€[/bold cyan]") table = Table(show_header=False, box=None) table.add_column("", style="white", width=15) table.add_column("", style="green") table.add_row("Version:", version_info['version']) table.add_row("Type:", version_info['release_type']) table.add_row("Description:", version_info['description']) table.add_row("Repository:", self._get_repo_info() or "Unknown") console.print(table) console.print() console.print("[bold white]๐Ÿš€ This will:[/bold white]") console.print("[green] โ€ข Build PDF and HTML versions[/green]") console.print("[green] โ€ข Compress PDF for distribution[/green]") console.print("[green] โ€ข Create git tag: {version}[/green]".format(version=version_info['version'])) console.print("[green] โ€ข Deploy to GitHub Pages (optional)[/green]") console.print("[green] โ€ข Create GitHub release with PDF (optional)[/green]") console.print() console.print("[yellow]Proceed with publishing? [y/N]: [/yellow]", end="") choice = input().strip().lower() if choice in ['y', 'yes']: return True else: console.print("[blue]โ„น๏ธ Publishing cancelled[/blue]") return False def _execute_build_phase(self): """Execute the complete build phase""" console.print("\n[bold cyan]โ”Œโ”€ Building Phase โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€[/bold cyan]") try: # Clean builds console.print("[purple]๐Ÿ”„ Cleaning previous builds...[/purple]") self.clean() # Build PDF first (more important, takes longer) console.print("[purple]๐Ÿ”„ Building PDF version...[/purple]") if not self.build_full("pdf"): console.print("[red]โŒ PDF build failed[/red]") return False console.print("[green]โœ… PDF build completed[/green]") # Compress PDF with Ghostscript console.print("[purple]๐Ÿ”„ Compressing PDF...[/purple]") if not self._compress_pdf(): console.print("[yellow]โš ๏ธ PDF compression failed, continuing with uncompressed PDF[/yellow]") # Build HTML (faster, after PDF is ready) console.print("[purple]๐Ÿ”„ Building HTML version...[/purple]") if not self.build_full("html"): console.print("[red]โŒ HTML build failed[/red]") return False console.print("[green]โœ… HTML build completed[/green]") return True except Exception as e: console.print(f"[red]โŒ Build phase failed: {e}[/red]") return False # Helper methods def _get_current_version(self): """Get current version from git tags""" try: result = subprocess.run(['git', 'tag', '--list', 'v*'], capture_output=True, text=True, cwd=self.root_dir) tags = result.stdout.strip().split('\n') if result.stdout.strip() else [] if not tags: return "v0.0.0" # Find the highest version versions = [tag for tag in tags if tag.startswith('v')] if not versions: return "v0.0.0" return max(versions, key=lambda v: [int(x) for x in v[1:].split('.')]) except Exception: return "v0.0.0" def _calculate_next_version(self, current_version, release_type): """Calculate next version based on release type""" try: version_num = current_version[1:] # Remove 'v' major, minor, patch = map(int, version_num.split('.')) if release_type == "major": return f"v{major + 1}.0.0" elif release_type == "minor": return f"v{major}.{minor + 1}.0" else: # patch return f"v{major}.{minor}.{patch + 1}" except: return "v1.0.0" def _version_exists(self, version): """Check if a version tag exists""" try: result = subprocess.run(['git', 'tag', '-l', version], capture_output=True, text=True, cwd=self.root_dir) return bool(result.stdout.strip()) except: return False def _delete_version(self, version): """Delete a version tag""" try: subprocess.run(['git', 'tag', '-d', version], cwd=self.root_dir, check=True) subprocess.run(['git', 'push', 'origin', '--delete', version], cwd=self.root_dir, capture_output=True) except: pass def _get_repo_info(self): """Get repository info""" try: result = subprocess.run(['git', 'remote', 'get-url', 'origin'], capture_output=True, text=True, cwd=self.root_dir) remote_url = result.stdout.strip() import re match = re.search(r'github\.com[:/]([^/]+)/([^/]+)(\.git)?$', remote_url) if match: return f"{match.group(1)}/{match.group(2)}" except: pass return None def _compress_pdf(self): """Compress PDF using Ghostscript with ebook settings""" pdf_path = self.build_dir / "pdf" / "Machine-Learning-Systems.pdf" compressed_path = self.build_dir / "pdf" / "Machine-Learning-Systems-compressed.pdf" # Check if Ghostscript is available try: subprocess.run(['gs', '--version'], capture_output=True, check=True) except (subprocess.CalledProcessError, FileNotFoundError): console.print("[yellow]โš ๏ธ Ghostscript not found - skipping compression[/yellow]") console.print("[blue]โ„น๏ธ Install with: brew install ghostscript (macOS) or apt-get install ghostscript (Linux)[/blue]") return False if not pdf_path.exists(): console.print(f"[red]โŒ PDF not found for compression: {pdf_path}[/red]") return False try: # Get original file size original_size = pdf_path.stat().st_size original_size_mb = original_size / (1024 * 1024) console.print(f"[blue]โ„น๏ธ Original PDF size: {original_size_mb:.1f} MB[/blue]") # Compress with Ghostscript with Progress( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), BarColumn(), TimeElapsedColumn(), console=console ) as progress: task = progress.add_task("Compressing with Ghostscript ebook settings...", total=None) result = subprocess.run([ 'gs', '-sDEVICE=pdfwrite', '-dCompatibilityLevel=1.4', '-dPDFSETTINGS=/ebook', '-dNOPAUSE', '-dQUIET', '-dBATCH', f'-sOutputFile={compressed_path}', str(pdf_path) ], capture_output=True) progress.update(task, completed=True) if result.returncode == 0 and compressed_path.exists(): # Get compressed file size compressed_size = compressed_path.stat().st_size compressed_size_mb = compressed_size / (1024 * 1024) compression_ratio = ((original_size - compressed_size) / original_size) * 100 # Replace original with compressed version shutil.move(str(compressed_path), str(pdf_path)) console.print("[green]โœ… PDF compressed successfully[/green]") console.print(f"[blue]โ„น๏ธ Compressed size: {compressed_size_mb:.1f} MB (saved {compression_ratio:.1f}%)[/blue]") return True else: console.print("[red]โŒ PDF compression failed[/red]") # Clean up failed compression file if compressed_path.exists(): compressed_path.unlink() return False except Exception as e: console.print(f"[red]โŒ PDF compression error: {e}[/red]") # Clean up failed compression file if compressed_path.exists(): compressed_path.unlink() return False def _execute_publishing_phase(self, version_info): """Execute the publishing phase""" console.print("\n[bold cyan]โ”Œโ”€ Publishing Phase โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€[/bold cyan]") try: version = version_info['version'] description = version_info['description'] # Create git tag console.print("[purple]๐Ÿ”„ Creating git tag...[/purple]") subprocess.run(['git', 'tag', '-a', version, '-m', f"Release {version}: {description}"], cwd=self.root_dir, check=True) subprocess.run(['git', 'push', 'origin', version], cwd=self.root_dir, check=True) console.print("[green]โœ… Git tag created and pushed[/green]") # GitHub Pages deployment console.print() console.print("[bold white]๐ŸŒ GitHub Pages Deployment:[/bold white]") console.print("[dim] Deploy the website to your public GitHub Pages (recommended)[/dim]") console.print("[yellow]Deploy to GitHub Pages? [y/N]: [/yellow]", end="") choice = input().strip().lower() if choice in ['y', 'yes']: console.print("[purple]๐Ÿ”„ Deploying to GitHub Pages...[/purple]") if self._deploy_to_github_pages(): console.print("[green]โœ… Deployed to GitHub Pages[/green]") else: console.print("[yellow]โš ๏ธ GitHub Pages deployment failed[/yellow]") else: console.print("[blue]โ„น๏ธ Skipping GitHub Pages deployment[/blue]") # GitHub release creation console.print() console.print("[bold white]๐Ÿ“ฆ GitHub Release Creation:[/bold white]") console.print("[dim] Create a public release with downloadable PDF and AI-generated release notes[/dim]") console.print("[yellow]Create GitHub release? [y/N]: [/yellow]", end="") choice = input().strip().lower() if choice in ['y', 'yes']: pdf_path = self.build_dir / "pdf" / "Machine-Learning-Systems.pdf" if self._create_github_release(version, description, pdf_path): console.print("[green]โœ… GitHub release created[/green]") else: console.print("[yellow]โš ๏ธ GitHub release creation failed[/yellow]") else: console.print("[blue]โ„น๏ธ Skipping GitHub release creation[/blue]") return True except Exception as e: console.print(f"[red]โŒ Publishing phase failed: {e}[/red]") return False def _show_publish_success(self, version_info): """Show success summary""" console.print("\n[bold cyan]โ”Œโ”€ Publication Complete โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€[/bold cyan]") console.print("[green]๐ŸŽ‰ Publication successful![/green]") pdf_path = self.build_dir / "pdf" / "Machine-Learning-Systems.pdf" pdf_size = "Unknown" if pdf_path.exists(): size_mb = pdf_path.stat().st_size / (1024 * 1024) pdf_size = f"{size_mb:.1f} MB" table = Table(show_header=False, box=None) table.add_column("", style="white", width=25) table.add_column("", style="green") table.add_row("โœ… Version:", version_info['version']) table.add_row("โœ… PDF build:", f"completed and compressed ({pdf_size})") table.add_row("โœ… HTML build:", "completed") table.add_row("โœ… Git tag:", "created and pushed") console.print(table) repo_info = self._get_repo_info() if repo_info: console.print(f"\n[white]๐ŸŒ Access your publication:[/white]") owner, name = repo_info.split('/') console.print(f"[blue] ๐Ÿ“– Web version: https://{owner}.github.io/{name}[/blue]") console.print(f"[blue] ๐Ÿ“ฆ Releases: https://github.com/{repo_info}/releases[/blue]") console.print(f"[blue] ๐Ÿ“„ PDF: https://github.com/{repo_info}/releases/download/{version_info['version']}/Machine-Learning-Systems.pdf[/blue]") console.print("\n[green]โœ… Ready for distribution! ๐Ÿš€[/green]") def _deploy_to_github_pages(self): """Deploy to GitHub Pages""" try: result = subprocess.run(['quarto', 'publish', 'gh-pages', '--no-render'], cwd=self.book_dir, capture_output=True) return result.returncode == 0 except: return False def _create_github_release(self, version, description, pdf_path): """Create GitHub release with AI-enhanced release notes""" try: # Check if GitHub CLI is available subprocess.run(['gh', '--version'], capture_output=True, check=True) console.print("[purple]๐Ÿ”„ Generating enhanced release notes...[/purple]") # Generate enhanced release notes with AI release_notes = self._generate_enhanced_release_notes(version, description) # Create release cmd = [ 'gh', 'release', 'create', version, '--title', f"{version}: {description}", '--notes', release_notes, '--draft', str(pdf_path) ] result = subprocess.run(cmd, cwd=self.root_dir, capture_output=True) return result.returncode == 0 except (subprocess.CalledProcessError, FileNotFoundError): console.print("[yellow]โš ๏ธ GitHub CLI not available - create release manually[/yellow]") return False def _generate_enhanced_release_notes(self, version, description): """Generate AI-enhanced release notes with GitHub-style logs""" console.print("[blue]โ„น๏ธ Generating release notes with AI enhancement...[/blue]") # Step 1: Generate git log from previous version git_changes = self._generate_git_log(version) # Step 2: Try to enhance with Ollama AI ai_summary = self._generate_ai_summary(version, description, git_changes) # Step 3: Combine AI summary with raw commits if ai_summary: release_notes = f"""# Release {version}: {description} {ai_summary} --- ## Full Change Log {git_changes}""" else: # Fallback to GitHub-style release notes without AI release_notes = f"""# Release {version}: {description} ## Overview This release includes the following changes: {git_changes}""" return release_notes def _generate_git_log(self, current_version): """Generate detailed git log from previous version""" try: # Get previous version previous_version = self._get_previous_version() console.print(f"[blue]โ„น๏ธ Generating git log from {previous_version} to current commit...[/blue]") git_changes = "" if previous_version and previous_version != "v0.0.0": # Get commit log with details try: result = subprocess.run([ 'git', 'log', f'{previous_version}..HEAD', '--pretty=format:- %s (%h)', '--no-merges' ], capture_output=True, text=True, cwd=self.root_dir) if result.stdout.strip(): git_changes = result.stdout.strip() else: git_changes = "- No changes since last version" except subprocess.CalledProcessError: # Fallback to recent commits result = subprocess.run([ 'git', 'log', '--oneline', '--no-merges', '-n', '20' ], capture_output=True, text=True, cwd=self.root_dir) commits = result.stdout.strip().split('\n') if result.stdout.strip() else [] git_changes = '\n'.join([f"- {commit}" for commit in commits if commit.strip()]) else: # No previous version, get recent commits result = subprocess.run([ 'git', 'log', '--oneline', '--no-merges', '-n', '20' ], capture_output=True, text=True, cwd=self.root_dir) commits = result.stdout.strip().split('\n') if result.stdout.strip() else [] git_changes = '\n'.join([f"- {commit}" for commit in commits if commit.strip()]) return git_changes if git_changes else "- Initial release" except Exception as e: console.print(f"[yellow]โš ๏ธ Could not generate git log: {e}[/yellow]") return "- Recent changes (git log unavailable)" def _get_previous_version(self): """Get the previous version tag""" try: result = subprocess.run(['git', 'tag', '--list', 'v*', '--sort=-version:refname'], capture_output=True, text=True, cwd=self.root_dir) tags = result.stdout.strip().split('\n') if result.stdout.strip() else [] # Get the second most recent tag (first is current, if it exists) versions = [tag for tag in tags if tag.startswith('v')] return versions[1] if len(versions) > 1 else versions[0] if versions else "v0.0.0" except Exception: return "v0.0.0" def _generate_ai_summary(self, version, description, git_changes): """Generate AI-enhanced summary using Ollama""" try: # Check if Ollama is available result = subprocess.run(['ollama', 'list'], capture_output=True, text=True) if result.returncode != 0: console.print("[yellow]โš ๏ธ Ollama not available - using standard release notes[/yellow]") return None console.print("[purple]๐Ÿค– Generating AI summary with Ollama...[/purple]") # Find an available model available_models = result.stdout.strip().split('\n')[1:] # Skip header if not available_models or not available_models[0].strip(): # Try to pull a default model console.print("[blue]โ„น๏ธ Pulling default model (llama3.2:1b)...[/blue]") pull_result = subprocess.run(['ollama', 'pull', 'llama3.2:1b'], capture_output=True, timeout=120) if pull_result.returncode != 0: console.print("[yellow]โš ๏ธ Could not pull AI model - using standard release notes[/yellow]") return None model = 'llama3.2:1b' else: # Use first available model model = available_models[0].split()[0] if available_models[0].strip() else 'llama3.2:1b' console.print(f"[blue]โ„น๏ธ Using AI model: {model}[/blue]") # Create AI prompt prompt = f"""Please create professional release notes for version {version} based on the following git commits. Release Description: {description} Release Type: minor Git Commits: {git_changes} Please format as: - Brief overview of this release - Key changes organized by category (Features, Bug Fixes, Improvements, etc.) - Keep it professional but accessible - Include any breaking changes if evident - Do not include the git commit hashes or technical details - Focus on user-facing changes and improvements""" # Generate AI summary with timeout with Progress( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), console=console ) as progress: task = progress.add_task("Generating AI summary...", total=None) result = subprocess.run([ 'ollama', 'run', model ], input=prompt, capture_output=True, text=True, timeout=60) progress.update(task, completed=True) if result.returncode == 0 and result.stdout.strip(): console.print("[green]โœ… AI summary generated successfully[/green]") return result.stdout.strip() else: console.print("[yellow]โš ๏ธ AI generation failed - using standard release notes[/yellow]") return None except subprocess.TimeoutExpired: console.print("[yellow]โš ๏ธ AI generation timed out - using standard release notes[/yellow]") return None except Exception as e: console.print(f"[yellow]โš ๏ธ AI generation error: {e} - using standard release notes[/yellow]") return None def show_about(self): """Display beautiful about screen with book binder theme""" # Create a book-themed layout about_panel = Panel( "[bold blue]๐Ÿ“š Book Binder[/bold blue]\n" "[dim]โšก I compile ML systems knowledge[/dim]\n\n" "[bold green]Version:[/bold green] 1.0.0\n" "[bold green]Author:[/bold green] Prof. Vijay Janapa Reddi\n" "[bold green]Repository:[/bold green] https://github.com/harvard-edge/cs249r_book\n\n" "[bold yellow]๐ŸŽฏ Purpose:[/bold yellow]\n" " โ€ข Bind chapters into beautiful books\n" " โ€ข Preview content before publication\n" " โ€ข Manage build configurations\n" " โ€ข Publish to the world\n\n" "[bold cyan]๐Ÿ› ๏ธ Built with:[/bold cyan]\n" " โ€ข Python 3.6+ (standard library only)\n" " โ€ข Rich (beautiful terminal output)\n" " โ€ข Quarto (academic publishing)\n" " โ€ข Git (version control)\n\n" "[bold magenta]๐Ÿ“– Book Information:[/bold magenta]\n" " โ€ข Title: Machine Learning Systems\n" " โ€ข Subtitle: Principles and Practices of Engineering AI Systems\n" " โ€ข Author: Prof. Vijay Janapa Reddi\n" " โ€ข Publisher: MIT Press (2026)\n" " โ€ข License: CC BY-NC-SA 4.0\n\n" "[bold blue]๐ŸŒ Live at:[/bold blue]\n" " โ€ข Web: https://mlsysbook.ai\n" " โ€ข PDF: https://mlsysbook.ai/pdf\n" " โ€ข Ecosystem: https://mlsysbook.org\n\n" "[dim]Made with โค๏ธ for aspiring AI engineers worldwide[/dim]", title="๐Ÿ“š About Book Binder", border_style="blue", padding=(1, 2) ) # Show system status status_table = Table(show_header=True, header_style="bold green", box=None) status_table.add_column("Component", style="cyan", width=20) status_table.add_column("Status", style="white", width=15) status_table.add_column("Details", style="dim") # Check various components status_table.add_row("๐Ÿ“ Book Directory", "โœ… Found", str(self.book_dir)) status_table.add_row("๐Ÿ”— Active Config", "โœ… Active", str(self.active_config)) status_table.add_row("๐Ÿ“š Chapters", "โœ… Available", f"{len(self.find_chapters())} chapters") status_table.add_row("๐Ÿ—๏ธ Build Directory", "โœ… Ready", str(self.build_dir)) # Check git status try: result = subprocess.run(['git', 'branch', '--show-current'], capture_output=True, text=True, cwd=self.root_dir) current_branch = result.stdout.strip() status_table.add_row("๐ŸŒฟ Git Branch", "โœ… Active", current_branch) except: status_table.add_row("๐ŸŒฟ Git Branch", "โŒ Error", "Could not determine") # Check for uncommitted changes try: result = subprocess.run(['git', 'status', '--porcelain'], capture_output=True, text=True, cwd=self.root_dir) if result.stdout.strip(): status_table.add_row("๐Ÿ“ Git Status", "โš ๏ธ Changes", "Uncommitted changes detected") else: status_table.add_row("๐Ÿ“ Git Status", "โœ… Clean", "No uncommitted changes") except: status_table.add_row("๐Ÿ“ Git Status", "โŒ Error", "Could not determine") # Display everything self.show_banner() console.print(about_panel) console.print(Panel(status_table, title="๐Ÿ”ง System Status", border_style="green")) # Quick commands reminder quick_commands = Table(show_header=True, header_style="bold blue", box=None) quick_commands.add_column("Command", style="green", width=15) quick_commands.add_column("Description", style="white") quick_commands.add_row("./binder list", "See all chapters") quick_commands.add_row("./binder status", "Show current config") quick_commands.add_row("./binder help", "Show all commands") quick_commands.add_row("./binder about", "Show this information") console.print(Panel(quick_commands, title="๐Ÿš€ Quick Commands", border_style="cyan")) def show_help(self): """Display beautiful help screen""" fast_table = Table(show_header=True, header_style="bold green", box=None) fast_table.add_column("Command", style="green", width=35) fast_table.add_column("Description", style="white", width=30) fast_table.add_column("Example", style="dim", width=30) fast_table.add_row("build ", "Build chapter(s) or all", "./binder build intro html") fast_table.add_row("preview ", "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 - ", "Build complete book", "./binder build - pdf") full_table.add_row("preview-full [format]", "Preview complete book", "./binder preview-full") full_table.add_row("publish", "Manual publisher (interactive)", "./binder publish") # Management Commands mgmt_table = Table(show_header=True, header_style="bold blue", box=None) mgmt_table.add_column("Command", style="green", width=35) mgmt_table.add_column("Description", style="white", width=30) mgmt_table.add_column("Example", style="dim", width=30) mgmt_table.add_row("clean", "Clean configs & artifacts", "./binder clean") mgmt_table.add_row("check", "Check for build artifacts", "./binder check") mgmt_table.add_row("switch ", "Switch config", "./binder switch pdf") mgmt_table.add_row("status", "Show current status", "./binder status") mgmt_table.add_row("list", "List chapters", "./binder list") mgmt_table.add_row("hello", "Show welcome message", "./binder hello") mgmt_table.add_row("setup", "Setup environment", "./binder setup") mgmt_table.add_row("about", "Show about information", "./binder about") mgmt_table.add_row("help", "Show this help", "./binder help") console.print(mgmt_table) # Shortcuts shortcuts_table = Table(show_header=True, header_style="bold blue", box=None) shortcuts_table.add_column("Shortcut", style="magenta", width=10) shortcuts_table.add_column("Full Command", style="white") shortcuts_table.add_row("b", "build") shortcuts_table.add_row("p", "preview") shortcuts_table.add_row("pf", "preview-full") shortcuts_table.add_row("c", "clean") shortcuts_table.add_row("ch", "check") shortcuts_table.add_row("s", "switch") shortcuts_table.add_row("st", "status") shortcuts_table.add_row("l", "list") shortcuts_table.add_row("he", "hello") shortcuts_table.add_row("se", "setup") shortcuts_table.add_row("pu", "publish") shortcuts_table.add_row("a", "about") shortcuts_table.add_row("h", "help") # Display everything self.show_banner() console.print(Panel(fast_table, title="โšก Fast Chapter Commands", border_style="green")) console.print(Panel(full_table, title="๐Ÿ“š Full Book Commands", border_style="blue")) console.print(Panel(mgmt_table, title="๐Ÿ”ง Management", border_style="yellow")) console.print(Panel(shortcuts_table, title="๐Ÿš€ Shortcuts", border_style="cyan")) # Pro Tips examples = Text() examples.append("๐ŸŽฏ Power User Examples:\n", style="bold magenta") examples.append(" ./binder b intro,ml_systems html ", style="cyan") examples.append("# Build multiple chapters\n", style="dim") examples.append(" ./binder b - pdf ", style="cyan") examples.append("# Build all chapters as PDF\n", style="dim") examples.append(" ./binder b - both ", style="cyan") examples.append("# Build both HTML and PDF\n", style="dim") examples.append(" ./binder c ", style="cyan") examples.append("# Clean all artifacts\n", style="dim") examples.append(" ./binder ch ", style="cyan") examples.append("# Check for build artifacts\n", style="dim") examples.append(" ./binder st ", style="cyan") examples.append("# Quick status\n", style="dim") examples.append("\n", style="dim") console.print(Panel(examples, title="๐Ÿ’ก Pro Tips", border_style="magenta")) def show_hello(self): """Display welcome message and getting started guide""" welcome_panel = Panel( "[bold blue]๐ŸŽ‰ Welcome to MLSysBook![/bold blue]\n\n" "[green]I'm your Book Binder - I help you build and publish the Machine Learning Systems book.[/green]\n\n" "[bold yellow]๐Ÿ“š What I can do:[/bold yellow]\n" " โ€ข Build individual chapters or the entire book\n" " โ€ข Preview content in your browser\n" " โ€ข Publish to the world with smart versioning\n" " โ€ข Manage build configurations\n" " โ€ข Clean up artifacts automatically\n\n" "[bold cyan]๐Ÿš€ Getting Started:[/bold cyan]\n" " 1. Run './binder setup' to configure your environment\n" " 2. Try './binder list' to see available chapters\n" " 3. Use './binder preview intro' to preview a chapter\n" " 4. Run './binder help' for all commands\n\n" "[bold magenta]๐Ÿ’ก Quick Examples:[/bold magenta]\n" " ./binder preview intro # Preview introduction chapter\n" " ./binder build - html # Build complete HTML book\n" " ./binder build - pdf # Build complete PDF book\n" " ./binder build - both # Build both HTML and PDF\n" " ./binder publish # Publish to the world\n\n" "[dim]Made with โค๏ธ for aspiring AI engineers worldwide[/dim]", title="๐Ÿ‘‹ Hello from Book Binder", border_style="blue", padding=(1, 2) ) console.print(welcome_panel) # Show current status status_table = self._create_status_table() # Add environment info status_table.add_row("๐Ÿ“ Book Directory", "โœ… Found", str(self.book_dir)) status_table.add_row("๐Ÿ”— Active Config", "โœ… Active", str(self.active_config)) status_table.add_row("๐Ÿ“š Chapters", "โœ… Available", f"{len(self.find_chapters())} chapters") # Add tool status tools_to_check = [ ('Quarto', ['quarto', '--version']), ('Python', ['python3', '--version']), ('Git', ['git', '--version']) ] for tool_name, cmd in tools_to_check: available, version = self._check_tool_availability(tool_name, cmd) status = "โœ… Available" if available else "โŒ Not found" status_table.add_row(tool_name, status.split()[0], status.split()[1] if len(status.split()) > 1 else "") console.print(Panel(status_table, title="๐Ÿ”ง Environment Status", border_style="green")) # Next steps next_steps = Table(show_header=True, header_style="bold blue", box=None) next_steps.add_column("Step", style="cyan", width=10) next_steps.add_column("Command", style="green", width=25) next_steps.add_column("Description", style="white") next_steps.add_row("1", "./binder setup", "Configure your environment") next_steps.add_row("2", "./binder list", "See available chapters") next_steps.add_row("3", "./binder preview intro", "Preview a chapter") next_steps.add_row("4", "./binder help", "Learn all commands") console.print(Panel(next_steps, title="๐ŸŽฏ Next Steps", border_style="cyan")) def setup_environment(self): """Setup the development environment""" console.print("[bold blue]๐Ÿ”ง MLSysBook Environment Setup[/bold blue]") console.print("[dim]I'll help you configure everything you need[/dim]\n") # Step 1: Check current environment step1_panel = Panel( "[bold blue]๐Ÿ” Checking your environment...[/bold blue]", title="๐Ÿ“‹ Step 1: Environment Check", border_style="blue", padding=(0, 1) ) console.print(step1_panel) self._check_environment() # Step 2: System dependencies step2_panel = Panel( "[bold blue]๐Ÿ”ง Checking system dependencies...[/bold blue]", title="๐Ÿ“‹ Step 2: System Dependencies", border_style="blue", padding=(0, 1) ) console.print(step2_panel) if not self._check_system_dependencies(): console.print("[red]โŒ System dependency check failed[/red]") return # Step 3: Install/configure tools step3_panel = Panel( "[bold blue]๐Ÿ“ฆ Installing tools and packages...[/bold blue]", title="๐Ÿ“‹ Step 3: Tool Installation", border_style="blue", padding=(0, 1) ) console.print(step3_panel) self._install_tools() # Step 4: Configure Git step4_panel = Panel( "[bold blue]๐Ÿ”ง Configuring Git...[/bold blue]", title="๐Ÿ“‹ Step 4: Git Configuration", border_style="blue", padding=(0, 1) ) console.print(step4_panel) self._configure_git() # Step 5: Environment preferences step5_panel = Panel( "[bold blue]โš™๏ธ Configuring preferences...[/bold blue]", title="๐Ÿ“‹ Step 5: Environment Preferences", border_style="blue", padding=(0, 1) ) console.print(step5_panel) self._configure_preferences() # Step 6: Test setup step6_panel = Panel( "[bold blue]๐Ÿงช Testing setup...[/bold blue]", title="๐Ÿ“‹ Step 6: Test Setup", border_style="blue", padding=(0, 1) ) console.print(step6_panel) self._test_setup() console.print("\n[green]โœ… Environment setup completed![/green]") console.print("[blue]๐Ÿ’ก Try './binder preview intro' to test your setup[/blue]") def _get_tools_to_check(self): """Get list of tools to check/install""" return [ ('Quarto', ['quarto', '--version'], "https://quarto.org/docs/get-started/"), ('Python', ['python3', '--version'], "https://www.python.org/downloads/"), ('Git', ['git', '--version'], "https://git-scm.com/downloads"), ('GitHub CLI', ['gh', '--version'], "https://cli.github.com/"), ('Ollama', ['ollama', '--version'], "https://ollama.ai/") ] def _check_tool_availability(self, tool_name, cmd): """Check if a tool is available and return version""" try: result = subprocess.run(cmd, capture_output=True, text=True, check=True) version = result.stdout.strip().split('\n')[0] return True, version except: return False, "Not installed" def _check_environment(self): """Check current environment status""" console.print("[blue]๐Ÿ” Checking your environment...[/blue]") status_table = Table(show_header=True, header_style="bold blue", box=None) status_table.add_column("Tool", style="cyan", width=15) status_table.add_column("Status", style="white", width=15) status_table.add_column("Version", style="dim") # Check various tools tools_to_check = self._get_tools_to_check() for tool_name, cmd, _ in tools_to_check: available, version = self._check_tool_availability(tool_name, cmd) status = "โœ… Available" if available else "โŒ Missing" status_table.add_row(tool_name, status, version) console.print(status_table) def _install_tools(self): """Install missing tools and packages""" console.print("[blue]๐Ÿ“ฆ Installing tools and packages...[/blue]") # Check what's missing and install automatically missing_tools = [] tools_to_check = self._get_tools_to_check() for tool_name, cmd, url in tools_to_check: available, _ = self._check_tool_availability(tool_name, cmd) if available: console.print(f"[green]โœ… {tool_name} already installed[/green]") else: missing_tools.append((tool_name, url)) if missing_tools: console.print("[yellow]โš ๏ธ Missing tools detected:[/yellow]") for tool_name, url in missing_tools: console.print(f" โ€ข {tool_name}: {url}") console.print("\n[blue]๐Ÿ”ง Attempting automatic installation...[/blue]") self._auto_install_tools(missing_tools) else: console.print("[green]โœ… All required tools are available[/green]") # Install Python packages console.print("\n[blue]๐Ÿ“ฆ Installing Python packages...[/blue]") self._install_python_packages() def _auto_install_tools(self, missing_tools): """Attempt to automatically install missing tools""" import platform system = platform.system().lower() for tool_name, url in missing_tools: console.print(f"[blue]๐Ÿ”ง Installing {tool_name}...[/blue]") try: self._install_tool(tool_name, system) except Exception as e: console.print(f"[red]โŒ Failed to install {tool_name}: {e}[/red]") console.print(f"[yellow]๐Ÿ’ก Please install manually: {url}[/yellow]") def _get_installation_methods(self): """Get installation methods for different tools""" return { 'Quarto': { 'darwin': {'method': 'brew', 'package': 'quarto'}, 'linux': {'method': 'apt', 'package': 'quarto'} }, 'GitHub CLI': { 'darwin': {'method': 'brew', 'package': 'gh'}, 'linux': {'method': 'apt', 'package': 'gh'} }, 'Ollama': { 'darwin': {'method': 'brew', 'package': 'ollama'}, 'linux': {'method': 'curl', 'url': 'https://ollama.ai/install.sh'} }, 'Git': { 'darwin': {'method': 'brew', 'package': 'git'}, 'linux': {'method': 'apt', 'package': 'git'} } } def _install_tool(self, tool_name, system): """Install a tool using the appropriate method for the system""" installation_methods = self._get_installation_methods() if tool_name not in installation_methods: raise Exception(f"Automatic {tool_name} installation not supported") if system not in installation_methods[tool_name]: raise Exception(f"Automatic {tool_name} installation not supported on this system") method_info = installation_methods[tool_name][system] method = method_info['method'] if method == 'brew': package = method_info['package'] console.print(f"[blue]๐Ÿ“ฆ Installing {tool_name} via Homebrew...[/blue]") subprocess.run(['brew', 'install', package], check=True) console.print(f"[green]โœ… {tool_name} installed successfully[/green]") elif method == 'apt': package = method_info['package'] console.print(f"[blue]๐Ÿ“ฆ Installing {tool_name} via apt...[/blue]") subprocess.run(['sudo', 'apt-get', 'update'], check=True) subprocess.run(['sudo', 'apt-get', 'install', '-y', package], check=True) console.print(f"[green]โœ… {tool_name} installed successfully[/green]") elif method == 'curl': url = method_info['url'] console.print(f"[blue]๐Ÿ“ฆ Installing {tool_name} via curl...[/blue]") subprocess.run(['curl', '-fsSL', url, '|', 'sh'], shell=True, check=True) console.print(f"[green]โœ… {tool_name} installed successfully[/green]") def _install_python_packages(self): """Install required Python packages""" console.print("[blue]๐Ÿ Installing Python packages...[/blue]") # Check if pip is available try: subprocess.run(['pip3', '--version'], capture_output=True, check=True) except: console.print("[red]โŒ pip3 not found. Please install Python and pip first.[/red]") return # List of required packages (only external packages) required_packages = [ 'rich', # For beautiful terminal output ] # Check what's already installed installed_packages = [] try: result = subprocess.run(['pip3', 'list'], capture_output=True, text=True, check=True) installed_lines = result.stdout.split('\n') for line in installed_lines: if line.strip(): package_name = line.split()[0].lower() installed_packages.append(package_name) except: console.print("[yellow]โš ๏ธ Could not check installed packages[/yellow]") # Check built-in modules builtin_modules = ['pathlib', 'subprocess', 'os', 'sys', 'shutil', 'signal'] for module in builtin_modules: try: __import__(module) console.print(f"[green]โœ… {module} (built-in) available[/green]") except ImportError: console.print(f"[red]โŒ {module} (built-in) not available[/red]") # Install missing packages for package in required_packages: if package.lower() in installed_packages: console.print(f"[green]โœ… {package} already installed[/green]") else: try: console.print(f"[blue]๐Ÿ“ฆ Installing {package}...[/blue]") subprocess.run(['pip3', 'install', package], check=True) console.print(f"[green]โœ… {package} installed successfully[/green]") except Exception as e: console.print(f"[red]โŒ Failed to install {package}: {e}[/red]") console.print("[green]โœ… Python packages installation completed[/green]") def _check_system_dependencies(self): """Check and install system dependencies""" import platform system = platform.system().lower() console.print("[blue]๐Ÿ”ง Checking system dependencies...[/blue]") if system == "darwin": # macOS # Check for Homebrew try: subprocess.run(['brew', '--version'], capture_output=True, check=True) console.print("[green]โœ… Homebrew already installed[/green]") except: console.print("[blue]๐Ÿ“ฆ Installing Homebrew...[/blue]") try: install_cmd = '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"' subprocess.run(install_cmd, shell=True, check=True) console.print("[green]โœ… Homebrew installed successfully[/green]") except Exception as e: console.print(f"[red]โŒ Failed to install Homebrew: {e}[/red]") console.print("[yellow]๐Ÿ’ก Please install Homebrew manually: https://brew.sh[/yellow]") return False elif system == "linux": # Check for apt (Ubuntu/Debian) try: subprocess.run(['apt-get', '--version'], capture_output=True, check=True) console.print("[green]โœ… apt package manager available[/green]") except: console.print("[yellow]โš ๏ธ apt not found. Please install package manager manually.[/yellow]") return False return True def _get_git_config(self, key): """Get Git configuration value""" try: result = subprocess.run(['git', 'config', key], capture_output=True, text=True) if result.returncode == 0: return result.stdout.strip() except: pass return None def _set_git_config(self, key, value): """Set Git configuration value""" try: subprocess.run(['git', 'config', '--global', key, value], check=True) console.print(f"[green]โœ… Set Git {key}: {value}[/green]") return True except subprocess.CalledProcessError as e: console.print(f"[red]โŒ Failed to set Git {key}: {e}[/red]") return False def _get_user_input_with_default(self, prompt, current_value=None): """Get user input with optional default value""" if current_value: console.print(f"[bold]{prompt} [{current_value}]: [/bold]", end="") user_input = input().strip() return user_input if user_input else current_value else: console.print(f"[bold]{prompt}: [/bold]", end="") return input().strip() def _configure_git(self): """Configure Git settings interactively""" console.print("[blue]๐Ÿ”ง Configuring Git...[/blue]") # Check current Git configuration current_name = self._get_git_config('user.name') current_email = self._get_git_config('user.email') # Show current configuration if current_name and current_email: console.print(f"[green]โœ… Current Git configuration:[/green]") console.print(f" Name: {current_name}") console.print(f" Email: {current_email}") console.print("\n[blue]Would you like to update your Git configuration?[/blue]") console.print("[bold]Update Git config? (y/N) [N]: [/bold]", end="") update_choice = input().strip().lower() if not update_choice: update_choice = 'n' if update_choice not in ['y', 'yes']: console.print("[green]โœ… Keeping current Git configuration[/green]") return else: console.print("[yellow]โš ๏ธ Git configuration incomplete[/yellow]") # Get user information console.print("\n[blue]๐Ÿ“ Please provide your information:[/blue]") name_input = self._get_user_input_with_default("Full name", current_name) email_input = self._get_user_input_with_default("Email", current_email) github_username = self._get_user_input_with_default("GitHub username") # Configure Git success = True if name_input: success &= self._set_git_config('user.name', name_input) if email_input: success &= self._set_git_config('user.email', email_input) if github_username: success &= self._set_git_config('user.github', github_username) if success: console.print("[green]โœ… Git configuration completed![/green]") else: console.print("[yellow]๐Ÿ’ก You can configure Git manually:[/yellow]") console.print(" git config --global user.name 'Your Name'") console.print(" git config --global user.email 'your.email@example.com'") def _get_preferences_config(self): """Get preferences configuration structure""" return [ { 'key': 'binder.default-format', 'prompt': 'Default build format (html/pdf/both)', 'default': 'html', 'options': ['html', 'pdf', 'both'], 'description': '๐Ÿ“š Build Preferences:\n โ€ข html - Web format (faster, interactive)\n โ€ข pdf - Print format (slower, academic)\n โ€ข both - Build both formats (comprehensive)' }, { 'key': 'binder.auto-open', 'prompt': 'Auto-open browser after builds? (Y/n)', 'default': 'y', 'options': ['y', 'n'], 'description': '๐ŸŒ Browser Preferences:' } ] def _configure_preferences(self): """Configure user preferences and environment settings""" console.print("[blue]โš™๏ธ Configuring preferences...[/blue]") preferences_config = self._get_preferences_config() for pref in preferences_config: console.print(f"\n[blue]{pref['description']}[/blue]") # Get current value current_value = self._get_git_config(pref['key']) if current_value: default_display = current_value else: default_display = pref['default'] # Get user input user_input = self._get_user_input_with_default( pref['prompt'], default_display ) # Validate input if not user_input or user_input not in pref['options']: user_input = pref['default'] # Convert y/n to true/false for boolean settings if pref['key'] in ['binder.auto-open', 'binder.ai-default']: setting_value = 'true' if user_input in ['y', 'yes'] else 'false' else: setting_value = user_input # Store preference if self._set_git_config(pref['key'], setting_value): console.print(f"[green]โœ… Set {pref['key']}: {setting_value}[/green]") else: console.print(f"[yellow]โš ๏ธ Could not save {pref['key']} preference[/yellow]") console.print("[green]โœ… Preferences configured![/green]") def get_user_preferences(self): """Get user preferences from Git config""" preferences = {} try: # Get default format result = subprocess.run(['git', 'config', '--global', 'binder.default-format'], capture_output=True, text=True) if result.returncode == 0: preferences['default_format'] = result.stdout.strip() else: preferences['default_format'] = 'html' # Get auto-open setting result = subprocess.run(['git', 'config', '--global', 'binder.auto-open'], capture_output=True, text=True) if result.returncode == 0: preferences['auto_open'] = result.stdout.strip() == 'true' else: preferences['auto_open'] = True # Get AI default setting (only used in publish flow) result = subprocess.run(['git', 'config', '--global', 'binder.ai-default'], capture_output=True, text=True) if result.returncode == 0: preferences['ai_default'] = result.stdout.strip() == 'true' else: # Default to True for AI features (only used in publish) preferences['ai_default'] = True # Get GitHub username result = subprocess.run(['git', 'config', '--global', 'user.github'], capture_output=True, text=True) if result.returncode == 0: preferences['github_username'] = result.stdout.strip() else: preferences['github_username'] = None except Exception: # Return defaults if anything fails preferences = { 'default_format': 'html', 'auto_open': True, 'ai_default': True, 'github_username': None } return preferences def _create_status_table(self): """Create a standard status table""" status_table = Table(show_header=True, header_style="bold green", box=None) status_table.add_column("Component", style="cyan", width=20) status_table.add_column("Status", style="white", width=15) status_table.add_column("Details", style="dim") return status_table def _test_setup(self): """Test the setup by building a simple chapter""" console.print("[blue]๐Ÿงช Testing setup...[/blue]") # Try to build a simple chapter try: console.print("[blue]๐Ÿ“š Testing chapter build...[/blue]") success = self.build("intro", "html") if success: console.print("[green]โœ… Build test successful![/green]") else: console.print("[yellow]โš ๏ธ Build test failed - check your Quarto installation[/yellow]") except Exception as e: console.print(f"[red]โŒ Test failed: {e}[/red]") def main(): binder = BookBinder() if len(sys.argv) < 2: binder.show_help() return command = sys.argv[1].lower() # Enhanced command mapping with aliases command_aliases = { 'b': 'build', 'p': 'preview', 'pf': 'preview-full', 'c': 'clean', 'ch': 'check', 's': 'switch', 'st': 'status', 'l': 'list', 'he': 'hello', 'se': 'setup', 'pu': 'publish', 'h': 'help' } # Apply aliases if command in command_aliases: command = command_aliases[command] # Handle commands try: if command == "build": if len(sys.argv) < 4: console.print("[red]โŒ Usage: ./binder build [/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 [/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 [/red]") return binder.show_symlink_status() format_type = sys.argv[2] binder.switch(format_type) elif command == "status": binder.show_status() elif command == "list": binder.show_chapters() elif command == "publish": binder.show_symlink_status() binder.publish() elif command == "about": binder.show_about() elif command == "help": binder.show_help() elif command == "hello": binder.show_hello() elif command == "setup": binder.setup_environment() else: console.print(f"[red]โŒ Unknown command: {command}[/red]") console.print("[yellow]๐Ÿ’ก Use './binder help' to see available commands[/yellow]") except KeyboardInterrupt: console.print("\n[yellow]๐Ÿ‘‹ Goodbye![/yellow]") except Exception as e: console.print(f"[red]โŒ Error: {e}[/red]") if __name__ == "__main__": main()