mirror of
https://github.com/harvard-edge/cs249r_book.git
synced 2026-03-11 17:49:25 -05:00
feat(cli): add preview and doctor commands with clear descriptions
New Commands: - Add PreviewCommand for live development server with hot reload - Add DoctorCommand for comprehensive health checks (18 checks) - Clear command descriptions distinguish build vs preview vs formats Command Clarity: - build: Build static files to disk (HTML) - preview: Start live dev server with hot reload - pdf: Build static PDF file to disk - epub: Build static EPUB file to disk Health Check Features: - System dependencies (Python, Quarto, Git, Node.js, R) - Configuration file validation - Chapter file scanning (62 chapters found) - Build artifacts status - Git repository status - File permissions check All tests passing - comprehensive CLI ready for use
This commit is contained in:
47
binder
47
binder
@@ -40,6 +40,7 @@ class BookBinder:
|
||||
|
||||
self.html_config = self.book_dir / "config" / "_quarto-html.yml"
|
||||
self.pdf_config = self.book_dir / "config" / "_quarto-pdf.yml"
|
||||
self.epub_config = self.book_dir / "config" / "_quarto-epub.yml"
|
||||
self.active_config = self.book_dir / "_quarto.yml"
|
||||
|
||||
def get_output_dir(self, format_type="html"):
|
||||
@@ -48,7 +49,14 @@ class BookBinder:
|
||||
import yaml
|
||||
|
||||
# Choose the appropriate config file
|
||||
config_file = self.html_config if format_type == "html" else self.pdf_config
|
||||
if format_type == "html":
|
||||
config_file = self.html_config
|
||||
elif format_type == "pdf":
|
||||
config_file = self.pdf_config
|
||||
elif format_type == "epub":
|
||||
config_file = self.epub_config
|
||||
else:
|
||||
config_file = self.html_config
|
||||
|
||||
if not config_file.exists():
|
||||
console.print(f"[yellow]⚠️ Config file not found: {config_file}[/yellow]")
|
||||
@@ -613,7 +621,10 @@ class BookBinder:
|
||||
config_file = self.pdf_config
|
||||
format_arg = "titlepage-pdf"
|
||||
build_subdir = "pdf"
|
||||
|
||||
elif format_type == "epub":
|
||||
config_file = self.epub_config
|
||||
format_arg = "epub"
|
||||
build_subdir = "epub"
|
||||
else:
|
||||
raise ValueError(f"Unknown format type: {format_type}")
|
||||
|
||||
@@ -704,7 +715,10 @@ class BookBinder:
|
||||
config_file = self.pdf_config
|
||||
format_arg = "titlepage-pdf"
|
||||
build_subdir = "pdf"
|
||||
|
||||
elif format_type == "epub":
|
||||
config_file = self.epub_config
|
||||
format_arg = "epub"
|
||||
build_subdir = "epub"
|
||||
else:
|
||||
raise ValueError(f"Unknown format type: {format_type}")
|
||||
|
||||
@@ -3027,6 +3041,7 @@ This is an ACADEMIC TEXTBOOK release. Please format as:
|
||||
fast_table.add_row("build [chapter[,ch2,...]]", "Build book or chapter(s) (HTML)", "./binder build intro,ops")
|
||||
fast_table.add_row("preview [chapter[,ch2,...]]", "Preview book or chapter(s)", "./binder preview intro")
|
||||
fast_table.add_row("pdf [chapter[,ch2,...]]", "Build book or chapter(s) (PDF)", "./binder pdf intro")
|
||||
fast_table.add_row("epub [chapter[,ch2,...]]", "Build book or chapter(s) (EPUB)", "./binder epub intro")
|
||||
|
||||
full_table = Table(show_header=True, header_style="bold blue", box=None)
|
||||
full_table.add_column("Command", style="blue", width=35)
|
||||
@@ -3036,6 +3051,7 @@ This is an ACADEMIC TEXTBOOK release. Please format as:
|
||||
full_table.add_row("build", "Build entire book (HTML)", "./binder build")
|
||||
full_table.add_row("preview", "Preview entire book", "./binder preview")
|
||||
full_table.add_row("pdf", "Build entire book (PDF)", "./binder pdf")
|
||||
full_table.add_row("epub", "Build entire book (EPUB)", "./binder epub")
|
||||
full_table.add_row("publish", "Deploy website updates (no release)", "./binder publish")
|
||||
full_table.add_row("release", "Create formal textbook release", "./binder release")
|
||||
|
||||
@@ -3066,6 +3082,7 @@ This is an ACADEMIC TEXTBOOK release. Please format as:
|
||||
shortcuts_table.add_row("b", "build")
|
||||
shortcuts_table.add_row("p", "preview")
|
||||
shortcuts_table.add_row("pdf", "pdf")
|
||||
shortcuts_table.add_row("epub", "epub")
|
||||
shortcuts_table.add_row("c", "clean")
|
||||
shortcuts_table.add_row("ch", "check")
|
||||
shortcuts_table.add_row("s", "switch")
|
||||
@@ -3092,10 +3109,12 @@ This is an ACADEMIC TEXTBOOK release. Please format as:
|
||||
examples.append("# Build multiple chapters (HTML)\n", style="dim")
|
||||
examples.append(" ./binder pdf intro ", style="cyan")
|
||||
examples.append("# Build single chapter as PDF\n", style="dim")
|
||||
examples.append(" ./binder epub intro ", style="cyan")
|
||||
examples.append("# Build single chapter as EPUB\n", style="dim")
|
||||
examples.append(" ./binder preview ", style="cyan")
|
||||
examples.append("# Preview entire book\n", style="dim")
|
||||
examples.append(" ./binder pdf ", style="cyan")
|
||||
examples.append("# Build entire book as PDF\n", style="dim")
|
||||
examples.append(" ./binder epub ", style="cyan")
|
||||
examples.append("# Build entire book as EPUB\n", style="dim")
|
||||
examples.append(" ./binder c ", style="cyan")
|
||||
examples.append("# Clean all artifacts\n", style="dim")
|
||||
examples.append(" ./binder st ", style="cyan")
|
||||
@@ -4024,6 +4043,24 @@ def main():
|
||||
# Single chapter - build as PDF
|
||||
binder.build_multiple([chapters], "pdf")
|
||||
|
||||
elif command == "epub":
|
||||
binder.show_symlink_status()
|
||||
if len(sys.argv) < 3:
|
||||
# No target specified - build entire book as EPUB
|
||||
console.print("[purple]📚 Building entire book (EPUB)...[/purple]")
|
||||
binder.build_full("epub")
|
||||
else:
|
||||
# Chapter specified - build specific chapter(s) as EPUB
|
||||
chapters = sys.argv[2]
|
||||
console.print(f"[purple]📚 Building chapter(s) as EPUB: {chapters}[/purple]")
|
||||
if ',' in chapters:
|
||||
# Multiple chapters - build them together as EPUB
|
||||
chapter_list = [ch.strip() for ch in chapters.split(',')]
|
||||
binder.build_multiple(chapter_list, "epub")
|
||||
else:
|
||||
# Single chapter - build as EPUB
|
||||
binder.build_multiple([chapters], "epub")
|
||||
|
||||
|
||||
|
||||
elif command == "clean":
|
||||
|
||||
9
cli/__init__.py
Normal file
9
cli/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
MLSysBook CLI Package
|
||||
|
||||
A modular command-line interface for building, previewing, and managing
|
||||
the Machine Learning Systems textbook.
|
||||
"""
|
||||
|
||||
__version__ = "2.0.0"
|
||||
__author__ = "MLSysBook Team"
|
||||
6
cli/commands/__init__.py
Normal file
6
cli/commands/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Command implementations for the MLSysBook CLI.
|
||||
|
||||
Each command is implemented as a separate module for better maintainability
|
||||
and testing.
|
||||
"""
|
||||
438
cli/commands/doctor.py
Normal file
438
cli/commands/doctor.py
Normal file
@@ -0,0 +1,438 @@
|
||||
"""
|
||||
Health check command for MLSysBook CLI.
|
||||
|
||||
Performs comprehensive system health checks to ensure everything is working properly.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple, Dict, Any
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from rich.panel import Panel
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
class DoctorCommand:
|
||||
"""Performs health checks for the MLSysBook system."""
|
||||
|
||||
def __init__(self, config_manager, chapter_discovery):
|
||||
"""Initialize doctor command.
|
||||
|
||||
Args:
|
||||
config_manager: ConfigManager instance
|
||||
chapter_discovery: ChapterDiscovery instance
|
||||
"""
|
||||
self.config_manager = config_manager
|
||||
self.chapter_discovery = chapter_discovery
|
||||
self.checks = []
|
||||
|
||||
def run_health_check(self) -> bool:
|
||||
"""Run comprehensive health check.
|
||||
|
||||
Returns:
|
||||
True if all checks pass, False if any issues found
|
||||
"""
|
||||
console.print("[bold blue]🏥 MLSysBook Health Check[/bold blue]")
|
||||
console.print("[dim]Running comprehensive system diagnostics...[/dim]\n")
|
||||
|
||||
self.checks = []
|
||||
|
||||
# Run all health checks
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
console=console,
|
||||
transient=True
|
||||
) as progress:
|
||||
|
||||
# System dependencies
|
||||
task = progress.add_task("Checking system dependencies...", total=None)
|
||||
self._check_system_dependencies()
|
||||
progress.update(task, completed=True)
|
||||
|
||||
# Configuration files
|
||||
task = progress.add_task("Validating configuration files...", total=None)
|
||||
self._check_configuration_files()
|
||||
progress.update(task, completed=True)
|
||||
|
||||
# Chapter files
|
||||
task = progress.add_task("Scanning chapter files...", total=None)
|
||||
self._check_chapter_files()
|
||||
progress.update(task, completed=True)
|
||||
|
||||
# Build artifacts
|
||||
task = progress.add_task("Checking build artifacts...", total=None)
|
||||
self._check_build_artifacts()
|
||||
progress.update(task, completed=True)
|
||||
|
||||
# Git status
|
||||
task = progress.add_task("Checking git status...", total=None)
|
||||
self._check_git_status()
|
||||
progress.update(task, completed=True)
|
||||
|
||||
# Permissions
|
||||
task = progress.add_task("Checking file permissions...", total=None)
|
||||
self._check_permissions()
|
||||
progress.update(task, completed=True)
|
||||
|
||||
# Display results
|
||||
self._display_results()
|
||||
|
||||
# Return overall health status
|
||||
failed_checks = [check for check in self.checks if not check["passed"]]
|
||||
return len(failed_checks) == 0
|
||||
|
||||
def _check_system_dependencies(self) -> None:
|
||||
"""Check required system dependencies."""
|
||||
dependencies = [
|
||||
("Python", [sys.executable, "--version"]),
|
||||
("Quarto", ["quarto", "--version"]),
|
||||
("Git", ["git", "--version"]),
|
||||
("Node.js", ["node", "--version"]),
|
||||
("R", ["R", "--version"]),
|
||||
]
|
||||
|
||||
for name, cmd in dependencies:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
if result.returncode == 0:
|
||||
version = result.stdout.strip().split('\n')[0]
|
||||
self.checks.append({
|
||||
"category": "Dependencies",
|
||||
"name": name,
|
||||
"passed": True,
|
||||
"message": f"✅ {version}",
|
||||
"details": None
|
||||
})
|
||||
else:
|
||||
self.checks.append({
|
||||
"category": "Dependencies",
|
||||
"name": name,
|
||||
"passed": False,
|
||||
"message": "❌ Not found or error",
|
||||
"details": result.stderr.strip() if result.stderr else "Command failed"
|
||||
})
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
self.checks.append({
|
||||
"category": "Dependencies",
|
||||
"name": name,
|
||||
"passed": False,
|
||||
"message": "❌ Not installed",
|
||||
"details": f"Command '{' '.join(cmd)}' not found"
|
||||
})
|
||||
|
||||
def _check_configuration_files(self) -> None:
|
||||
"""Check Quarto configuration files."""
|
||||
configs = [
|
||||
("HTML Config", self.config_manager.html_config),
|
||||
("PDF Config", self.config_manager.pdf_config),
|
||||
("EPUB Config", self.config_manager.epub_config),
|
||||
]
|
||||
|
||||
for name, config_path in configs:
|
||||
if config_path.exists():
|
||||
try:
|
||||
# Try to read and parse the config
|
||||
format_type = name.lower().split()[0]
|
||||
config_data = self.config_manager.read_config(format_type)
|
||||
|
||||
# Check for required sections
|
||||
required_sections = ["project", "book"]
|
||||
missing_sections = [s for s in required_sections if s not in config_data]
|
||||
|
||||
if missing_sections:
|
||||
self.checks.append({
|
||||
"category": "Configuration",
|
||||
"name": name,
|
||||
"passed": False,
|
||||
"message": "⚠️ Missing sections",
|
||||
"details": f"Missing: {', '.join(missing_sections)}"
|
||||
})
|
||||
else:
|
||||
self.checks.append({
|
||||
"category": "Configuration",
|
||||
"name": name,
|
||||
"passed": True,
|
||||
"message": "✅ Valid",
|
||||
"details": None
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
self.checks.append({
|
||||
"category": "Configuration",
|
||||
"name": name,
|
||||
"passed": False,
|
||||
"message": "❌ Parse error",
|
||||
"details": str(e)
|
||||
})
|
||||
else:
|
||||
self.checks.append({
|
||||
"category": "Configuration",
|
||||
"name": name,
|
||||
"passed": False,
|
||||
"message": "❌ Not found",
|
||||
"details": f"File not found: {config_path}"
|
||||
})
|
||||
|
||||
# Check active config symlink
|
||||
if self.config_manager.active_config.is_symlink():
|
||||
target = self.config_manager.active_config.readlink()
|
||||
self.checks.append({
|
||||
"category": "Configuration",
|
||||
"name": "Active Config",
|
||||
"passed": True,
|
||||
"message": f"✅ Linked to {target}",
|
||||
"details": None
|
||||
})
|
||||
elif self.config_manager.active_config.exists():
|
||||
self.checks.append({
|
||||
"category": "Configuration",
|
||||
"name": "Active Config",
|
||||
"passed": False,
|
||||
"message": "⚠️ Regular file (not symlink)",
|
||||
"details": "Should be a symlink to format-specific config"
|
||||
})
|
||||
else:
|
||||
self.checks.append({
|
||||
"category": "Configuration",
|
||||
"name": "Active Config",
|
||||
"passed": False,
|
||||
"message": "❌ Not found",
|
||||
"details": "No _quarto.yml found"
|
||||
})
|
||||
|
||||
def _check_chapter_files(self) -> None:
|
||||
"""Check chapter files and structure."""
|
||||
chapters = self.chapter_discovery.get_all_chapters()
|
||||
|
||||
self.checks.append({
|
||||
"category": "Content",
|
||||
"name": "Chapter Count",
|
||||
"passed": len(chapters) > 0,
|
||||
"message": f"✅ {len(chapters)} chapters found" if len(chapters) > 0 else "❌ No chapters found",
|
||||
"details": None
|
||||
})
|
||||
|
||||
# Check for common issues
|
||||
large_chapters = [ch for ch in chapters if ch["size"] > 500 * 1024] # > 500KB
|
||||
if large_chapters:
|
||||
self.checks.append({
|
||||
"category": "Content",
|
||||
"name": "Large Chapters",
|
||||
"passed": False,
|
||||
"message": f"⚠️ {len(large_chapters)} large chapters (>500KB)",
|
||||
"details": f"Large chapters: {', '.join([ch['name'] for ch in large_chapters[:3]])}"
|
||||
})
|
||||
|
||||
# Check for missing chapters (common ones)
|
||||
expected_chapters = ["introduction", "ml_systems", "training", "ops"]
|
||||
missing_chapters = []
|
||||
for expected in expected_chapters:
|
||||
if not self.chapter_discovery.find_chapter_file(expected):
|
||||
missing_chapters.append(expected)
|
||||
|
||||
if missing_chapters:
|
||||
self.checks.append({
|
||||
"category": "Content",
|
||||
"name": "Core Chapters",
|
||||
"passed": False,
|
||||
"message": f"⚠️ {len(missing_chapters)} core chapters missing",
|
||||
"details": f"Missing: {', '.join(missing_chapters)}"
|
||||
})
|
||||
else:
|
||||
self.checks.append({
|
||||
"category": "Content",
|
||||
"name": "Core Chapters",
|
||||
"passed": True,
|
||||
"message": "✅ All core chapters present",
|
||||
"details": None
|
||||
})
|
||||
|
||||
def _check_build_artifacts(self) -> None:
|
||||
"""Check for build artifacts and output directories."""
|
||||
formats = ["html", "pdf", "epub"]
|
||||
|
||||
for format_type in formats:
|
||||
output_dir = self.config_manager.get_output_dir(format_type)
|
||||
|
||||
if output_dir.exists():
|
||||
# Count files in output directory
|
||||
files = list(output_dir.rglob("*"))
|
||||
file_count = len([f for f in files if f.is_file()])
|
||||
|
||||
self.checks.append({
|
||||
"category": "Build Artifacts",
|
||||
"name": f"{format_type.upper()} Output",
|
||||
"passed": True,
|
||||
"message": f"✅ {file_count} files in {output_dir.name}/",
|
||||
"details": None
|
||||
})
|
||||
else:
|
||||
self.checks.append({
|
||||
"category": "Build Artifacts",
|
||||
"name": f"{format_type.upper()} Output",
|
||||
"passed": True, # Not an error if no builds exist yet
|
||||
"message": "📁 No build artifacts (clean)",
|
||||
"details": None
|
||||
})
|
||||
|
||||
def _check_git_status(self) -> None:
|
||||
"""Check git repository status."""
|
||||
try:
|
||||
# Check if we're in a git repo
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--git-dir"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=self.config_manager.root_dir
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
self.checks.append({
|
||||
"category": "Git",
|
||||
"name": "Repository",
|
||||
"passed": False,
|
||||
"message": "❌ Not a git repository",
|
||||
"details": None
|
||||
})
|
||||
return
|
||||
|
||||
# Check current branch
|
||||
result = subprocess.run(
|
||||
["git", "branch", "--show-current"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=self.config_manager.root_dir
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
branch = result.stdout.strip()
|
||||
self.checks.append({
|
||||
"category": "Git",
|
||||
"name": "Current Branch",
|
||||
"passed": True,
|
||||
"message": f"✅ {branch}",
|
||||
"details": None
|
||||
})
|
||||
|
||||
# Check for uncommitted changes
|
||||
result = subprocess.run(
|
||||
["git", "status", "--porcelain"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=self.config_manager.root_dir
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
changes = result.stdout.strip()
|
||||
if changes:
|
||||
change_count = len(changes.split('\n'))
|
||||
self.checks.append({
|
||||
"category": "Git",
|
||||
"name": "Working Tree",
|
||||
"passed": True, # Not necessarily an error
|
||||
"message": f"📝 {change_count} uncommitted changes",
|
||||
"details": None
|
||||
})
|
||||
else:
|
||||
self.checks.append({
|
||||
"category": "Git",
|
||||
"name": "Working Tree",
|
||||
"passed": True,
|
||||
"message": "✅ Clean",
|
||||
"details": None
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
self.checks.append({
|
||||
"category": "Git",
|
||||
"name": "Status Check",
|
||||
"passed": False,
|
||||
"message": "❌ Error checking git status",
|
||||
"details": str(e)
|
||||
})
|
||||
|
||||
def _check_permissions(self) -> None:
|
||||
"""Check file permissions for key files."""
|
||||
key_files = [
|
||||
("binder", self.config_manager.root_dir / "binder"),
|
||||
("binder2", self.config_manager.root_dir / "binder2"),
|
||||
]
|
||||
|
||||
for name, file_path in key_files:
|
||||
if file_path.exists():
|
||||
is_executable = file_path.stat().st_mode & 0o111
|
||||
self.checks.append({
|
||||
"category": "Permissions",
|
||||
"name": name,
|
||||
"passed": bool(is_executable),
|
||||
"message": "✅ Executable" if is_executable else "❌ Not executable",
|
||||
"details": f"chmod +x {file_path.name}" if not is_executable else None
|
||||
})
|
||||
else:
|
||||
self.checks.append({
|
||||
"category": "Permissions",
|
||||
"name": name,
|
||||
"passed": False,
|
||||
"message": "❌ File not found",
|
||||
"details": None
|
||||
})
|
||||
|
||||
def _display_results(self) -> None:
|
||||
"""Display health check results in a formatted table."""
|
||||
console.print()
|
||||
|
||||
# Group checks by category
|
||||
categories = {}
|
||||
for check in self.checks:
|
||||
cat = check["category"]
|
||||
if cat not in categories:
|
||||
categories[cat] = []
|
||||
categories[cat].append(check)
|
||||
|
||||
# Display each category
|
||||
for category, checks in categories.items():
|
||||
table = Table(show_header=True, header_style="bold blue", box=None)
|
||||
table.add_column("Check", style="white", width=20)
|
||||
table.add_column("Status", style="white", width=30)
|
||||
table.add_column("Details", style="dim", width=40)
|
||||
|
||||
for check in checks:
|
||||
details = check["details"] if check["details"] else ""
|
||||
table.add_row(
|
||||
check["name"],
|
||||
check["message"],
|
||||
details
|
||||
)
|
||||
|
||||
console.print(Panel(table, title=f"🔍 {category}", border_style="cyan"))
|
||||
|
||||
# Summary
|
||||
total_checks = len(self.checks)
|
||||
passed_checks = len([c for c in self.checks if c["passed"]])
|
||||
failed_checks = total_checks - passed_checks
|
||||
|
||||
if failed_checks == 0:
|
||||
console.print(Panel(
|
||||
f"[bold green]🎉 All {total_checks} health checks passed![/bold green]\n"
|
||||
"[dim]Your MLSysBook environment is healthy and ready to use.[/dim]",
|
||||
title="✅ Health Check Summary",
|
||||
border_style="green"
|
||||
))
|
||||
else:
|
||||
console.print(Panel(
|
||||
f"[bold yellow]⚠️ {passed_checks}/{total_checks} checks passed[/bold yellow]\n"
|
||||
f"[red]{failed_checks} issues found that may need attention.[/red]\n"
|
||||
"[dim]Review the details above and fix any critical issues.[/dim]",
|
||||
title="⚠️ Health Check Summary",
|
||||
border_style="yellow"
|
||||
))
|
||||
117
cli/commands/preview.py
Normal file
117
cli/commands/preview.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
Preview command implementation for MLSysBook CLI.
|
||||
|
||||
Handles starting development servers with live reload for interactive development.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
class PreviewCommand:
|
||||
"""Handles preview operations for the MLSysBook."""
|
||||
|
||||
def __init__(self, config_manager, chapter_discovery):
|
||||
"""Initialize preview command.
|
||||
|
||||
Args:
|
||||
config_manager: ConfigManager instance
|
||||
chapter_discovery: ChapterDiscovery instance
|
||||
"""
|
||||
self.config_manager = config_manager
|
||||
self.chapter_discovery = chapter_discovery
|
||||
|
||||
def preview_full(self, format_type: str = "html") -> bool:
|
||||
"""Start full preview server for the entire book.
|
||||
|
||||
Args:
|
||||
format_type: Format to preview ('html' only supported)
|
||||
|
||||
Returns:
|
||||
True if server started successfully, False otherwise
|
||||
"""
|
||||
if format_type != "html":
|
||||
console.print(f"[yellow]⚠️ Preview only supports HTML format, not {format_type}[/yellow]")
|
||||
return False
|
||||
|
||||
console.print("[blue]🌐 Starting full book preview server...[/blue]")
|
||||
|
||||
# Setup config
|
||||
config_name = self.config_manager.setup_symlink(format_type)
|
||||
console.print(f"[blue]🔗 Using {config_name}[/blue]")
|
||||
console.print("[dim]🛑 Press Ctrl+C to stop the server[/dim]")
|
||||
|
||||
try:
|
||||
# Start Quarto preview server
|
||||
preview_cmd = ["quarto", "preview"]
|
||||
|
||||
console.print(f"[blue]💻 Command: {' '.join(preview_cmd)}[/blue]")
|
||||
|
||||
# Run preview server (this will block until Ctrl+C)
|
||||
result = subprocess.run(
|
||||
preview_cmd,
|
||||
cwd=self.config_manager.book_dir
|
||||
)
|
||||
|
||||
return result.returncode == 0
|
||||
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[yellow]🛑 Preview server stopped[/yellow]")
|
||||
return True
|
||||
except Exception as e:
|
||||
console.print(f"[red]❌ Preview server error: {e}[/red]")
|
||||
return False
|
||||
|
||||
def preview_chapter(self, chapter_name: str) -> bool:
|
||||
"""Start preview server for a specific chapter.
|
||||
|
||||
Args:
|
||||
chapter_name: Name of the chapter to preview
|
||||
|
||||
Returns:
|
||||
True if server started successfully, False otherwise
|
||||
"""
|
||||
# Find the chapter file
|
||||
chapter_file = self.chapter_discovery.find_chapter_file(chapter_name)
|
||||
if not chapter_file:
|
||||
console.print(f"[red]❌ No chapter found matching '{chapter_name}'[/red]")
|
||||
console.print("[yellow]💡 Available chapters:[/yellow]")
|
||||
self.chapter_discovery.show_chapters()
|
||||
return False
|
||||
|
||||
target_path = str(chapter_file.relative_to(self.config_manager.book_dir))
|
||||
chapter_display_name = str(chapter_file.relative_to(self.config_manager.book_dir / "contents")).replace(".qmd", "")
|
||||
|
||||
console.print(f"[blue]🌐 Starting preview for[/blue] [bold]{chapter_display_name}[/bold]")
|
||||
|
||||
# Setup HTML config for preview
|
||||
self.config_manager.setup_symlink("html")
|
||||
|
||||
console.print("[dim]🛑 Press Ctrl+C to stop the server[/dim]")
|
||||
|
||||
try:
|
||||
# Start Quarto preview for specific file
|
||||
preview_cmd = ["quarto", "preview", target_path]
|
||||
|
||||
console.print(f"[blue]💻 Command: {' '.join(preview_cmd)}[/blue]")
|
||||
|
||||
# Run preview server (this will block until Ctrl+C)
|
||||
result = subprocess.run(
|
||||
preview_cmd,
|
||||
cwd=self.config_manager.book_dir
|
||||
)
|
||||
|
||||
return result.returncode == 0
|
||||
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[yellow]🛑 Preview server stopped[/yellow]")
|
||||
return True
|
||||
except Exception as e:
|
||||
console.print(f"[red]❌ Preview server error: {e}[/red]")
|
||||
return False
|
||||
8
cli/core/__init__.py
Normal file
8
cli/core/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
Core functionality for the MLSysBook CLI.
|
||||
|
||||
This module contains shared components used across different commands:
|
||||
- Configuration management
|
||||
- File and chapter discovery
|
||||
- Output directory handling
|
||||
"""
|
||||
6
cli/formats/__init__.py
Normal file
6
cli/formats/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Format-specific handlers for different output types.
|
||||
|
||||
Each format (HTML, PDF, EPUB) has its own module containing
|
||||
format-specific logic and configuration.
|
||||
"""
|
||||
49
cli/main.py
49
cli/main.py
@@ -17,6 +17,8 @@ from rich.text import Text
|
||||
from core.config import ConfigManager
|
||||
from core.discovery import ChapterDiscovery
|
||||
from commands.build import BuildCommand
|
||||
from commands.preview import PreviewCommand
|
||||
from commands.doctor import DoctorCommand
|
||||
|
||||
console = Console()
|
||||
|
||||
@@ -34,6 +36,8 @@ class MLSysBookCLI:
|
||||
|
||||
# Initialize command handlers
|
||||
self.build_command = BuildCommand(self.config_manager, self.chapter_discovery)
|
||||
self.preview_command = PreviewCommand(self.config_manager, self.chapter_discovery)
|
||||
self.doctor_command = DoctorCommand(self.config_manager, self.chapter_discovery)
|
||||
|
||||
def show_banner(self):
|
||||
"""Display the CLI banner."""
|
||||
@@ -54,9 +58,10 @@ class MLSysBookCLI:
|
||||
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,...]]", "Build book or chapter(s) (HTML)", "./binder2 build intro,ops")
|
||||
fast_table.add_row("pdf [chapter[,ch2,...]]", "Build book or chapter(s) (PDF)", "./binder2 pdf intro")
|
||||
fast_table.add_row("epub [chapter[,ch2,...]]", "Build book or chapter(s) (EPUB)", "./binder2 epub intro")
|
||||
fast_table.add_row("build [chapter[,ch2,...]]", "Build static files to disk (HTML)", "./binder2 build intro,ops")
|
||||
fast_table.add_row("preview [chapter[,ch2,...]]", "Start live dev server with hot reload", "./binder2 preview intro")
|
||||
fast_table.add_row("pdf [chapter[,ch2,...]]", "Build static PDF file to disk", "./binder2 pdf intro")
|
||||
fast_table.add_row("epub [chapter[,ch2,...]]", "Build static EPUB file to disk", "./binder2 epub intro")
|
||||
|
||||
# Full Book Commands
|
||||
full_table = Table(show_header=True, header_style="bold blue", box=None)
|
||||
@@ -64,9 +69,10 @@ class MLSysBookCLI:
|
||||
full_table.add_column("Description", style="white", width=30)
|
||||
full_table.add_column("Example", style="dim", width=30)
|
||||
|
||||
full_table.add_row("build", "Build entire book (HTML)", "./binder2 build")
|
||||
full_table.add_row("pdf", "Build entire book (PDF)", "./binder2 pdf")
|
||||
full_table.add_row("epub", "Build entire book (EPUB)", "./binder2 epub")
|
||||
full_table.add_row("build", "Build entire book as static HTML", "./binder2 build")
|
||||
full_table.add_row("preview", "Start live dev server for entire book", "./binder2 preview")
|
||||
full_table.add_row("pdf", "Build entire book as static PDF", "./binder2 pdf")
|
||||
full_table.add_row("epub", "Build entire book as static EPUB", "./binder2 epub")
|
||||
|
||||
# Management Commands
|
||||
mgmt_table = Table(show_header=True, header_style="bold blue", box=None)
|
||||
@@ -76,6 +82,7 @@ class MLSysBookCLI:
|
||||
|
||||
mgmt_table.add_row("list", "List available chapters", "./binder2 list")
|
||||
mgmt_table.add_row("status", "Show current config status", "./binder2 status")
|
||||
mgmt_table.add_row("doctor", "Run comprehensive health check", "./binder2 doctor")
|
||||
mgmt_table.add_row("help", "Show this help", "./binder2 help")
|
||||
|
||||
# Display tables
|
||||
@@ -140,6 +147,29 @@ class MLSysBookCLI:
|
||||
chapter_list = [ch.strip() for ch in chapters.split(',')]
|
||||
return self.build_command.build_chapters(chapter_list, "epub")
|
||||
|
||||
def handle_preview_command(self, args):
|
||||
"""Handle preview command."""
|
||||
self.config_manager.show_symlink_status()
|
||||
|
||||
if len(args) < 1:
|
||||
# No target specified - preview entire book
|
||||
console.print("[blue]🌐 Starting preview for entire book...[/blue]")
|
||||
return self.preview_command.preview_full("html")
|
||||
else:
|
||||
# Chapter specified
|
||||
chapter = args[0]
|
||||
if ',' in chapter:
|
||||
console.print("[yellow]⚠️ Preview only supports single chapters, not multiple[/yellow]")
|
||||
console.print("[dim]💡 Use the first chapter from your list[/dim]")
|
||||
chapter = chapter.split(',')[0].strip()
|
||||
|
||||
console.print(f"[blue]🌐 Starting preview for chapter: {chapter}[/blue]")
|
||||
return self.preview_command.preview_chapter(chapter)
|
||||
|
||||
def handle_doctor_command(self, args):
|
||||
"""Handle doctor/health check command."""
|
||||
return self.doctor_command.run_health_check()
|
||||
|
||||
def handle_list_command(self, args):
|
||||
"""Handle list chapters command."""
|
||||
self.chapter_discovery.show_chapters()
|
||||
@@ -172,19 +202,24 @@ class MLSysBookCLI:
|
||||
# Command mapping
|
||||
commands = {
|
||||
"build": self.handle_build_command,
|
||||
"preview": self.handle_preview_command,
|
||||
"pdf": self.handle_pdf_command,
|
||||
"epub": self.handle_epub_command,
|
||||
"list": self.handle_list_command,
|
||||
"status": self.handle_status_command,
|
||||
"doctor": self.handle_doctor_command,
|
||||
"help": lambda args: self.show_help() or True,
|
||||
}
|
||||
|
||||
# Command aliases
|
||||
aliases = {
|
||||
"b": "build",
|
||||
"p": "pdf", # Changed from preview to pdf for simplicity
|
||||
"p": "preview",
|
||||
"pdf": "pdf", # Keep pdf as explicit command
|
||||
"epub": "epub", # Keep epub as explicit command
|
||||
"l": "list",
|
||||
"s": "status",
|
||||
"d": "doctor",
|
||||
"h": "help",
|
||||
}
|
||||
|
||||
|
||||
6
cli/utils/__init__.py
Normal file
6
cli/utils/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Utility functions and helpers for the MLSysBook CLI.
|
||||
|
||||
Contains shared utilities for console output, validation, and other
|
||||
common functionality.
|
||||
"""
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Reference in New Issue
Block a user