MAJOR: Separate CLI from framework - proper architectural separation

BREAKING CHANGE: CLI moved from tinytorch/cli/ to tito/

Perfect Senior Engineer Architecture:
- tinytorch/ = Pure ML framework (production)
- tito/ = Development/management CLI tool
- modules/ = Educational content

Benefits:
 Clean separation of concerns
 Framework stays lightweight (no CLI dependencies)
 Clear mental model for users
 Professional project organization
 Proper dependency management

Structure:
tinytorch/          # 🧠 Core ML Framework
├── core/          # Tensors, layers, operations
├── training/      # Training loops, optimizers
├── models/        # Model architectures
└── ...           # Pure ML functionality

tito/              # 🔧 Development CLI Tool
├── main.py        # CLI entry point
├── core/          # CLI configuration & console
├── commands/      # Command implementations
└── tools/         # CLI utilities

Key Changes:
- Moved all CLI code from tinytorch/cli/ to tito/
- Updated imports and entry points
- Separated dependencies (Rich only for dev tools)
- Updated documentation to reflect proper separation
- Maintained backward compatibility with bin/tito wrapper

This demonstrates how senior engineers separate:
- Production code (framework) from development tools (CLI)
- Core functionality from management utilities
- User-facing APIs from internal tooling

Educational Value:
- Shows proper software architecture
- Teaches separation of concerns
- Demonstrates dependency management
- Models real-world project organization
This commit is contained in:
Vijay Janapa Reddi
2025-07-10 22:08:56 -04:00
parent 13eb0e4009
commit a92a5530ef
14 changed files with 150 additions and 173 deletions

View File

@@ -1,9 +0,0 @@
"""
TinyTorch CLI Package
A professional command-line interface for the TinyTorch ML system.
Organized with clean separation of concerns and proper error handling.
"""
__version__ = "0.1.0"
__author__ = "TinyTorch Team"

View File

@@ -1,13 +0,0 @@
"""
CLI Commands package.
Each command is implemented as a separate module with proper separation of concerns.
"""
from .base import BaseCommand
from .notebooks import NotebooksCommand
__all__ = [
'BaseCommand',
'NotebooksCommand'
]

View File

@@ -1,62 +0,0 @@
"""
Base command class for TinyTorch CLI.
"""
from abc import ABC, abstractmethod
from argparse import ArgumentParser, Namespace
from typing import Optional
import logging
from ..core.config import CLIConfig
from ..core.console import get_console
from ..core.exceptions import TinyTorchCLIError
logger = logging.getLogger(__name__)
class BaseCommand(ABC):
"""Base class for all CLI commands."""
def __init__(self, config: CLIConfig):
"""Initialize the command with configuration."""
self.config = config
self.console = get_console()
@property
@abstractmethod
def name(self) -> str:
"""Return the command name."""
pass
@property
@abstractmethod
def description(self) -> str:
"""Return the command description."""
pass
@abstractmethod
def add_arguments(self, parser: ArgumentParser) -> None:
"""Add command-specific arguments to the parser."""
pass
@abstractmethod
def run(self, args: Namespace) -> int:
"""Execute the command and return exit code."""
pass
def validate_args(self, args: Namespace) -> None:
"""Validate command arguments. Override in subclasses if needed."""
pass
def execute(self, args: Namespace) -> int:
"""Execute the command with error handling."""
try:
self.validate_args(args)
return self.run(args)
except TinyTorchCLIError as e:
logger.error(f"Command failed: {e}")
self.console.print(f"[red]❌ {e}[/red]")
return 1
except Exception as e:
logger.exception(f"Unexpected error in command {self.name}")
self.console.print(f"[red]❌ Unexpected error: {e}[/red]")
return 1

View File

@@ -1,160 +0,0 @@
"""
Notebooks command for building Jupyter notebooks from Python files.
"""
import subprocess
import sys
from argparse import ArgumentParser, Namespace
from pathlib import Path
from typing import List, Tuple
from rich.panel import Panel
from rich.text import Text
from .base import BaseCommand
from ..core.exceptions import ExecutionError, ModuleNotFoundError
class NotebooksCommand(BaseCommand):
"""Command to build Jupyter notebooks from Python files."""
@property
def name(self) -> str:
return "notebooks"
@property
def description(self) -> str:
return "Build notebooks from Python files"
def add_arguments(self, parser: ArgumentParser) -> None:
"""Add notebooks command arguments."""
parser.add_argument(
'--module',
help='Build notebook for specific module'
)
parser.add_argument(
'--force',
action='store_true',
help='Force rebuild even if notebook exists'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Show what would be built without actually building'
)
def validate_args(self, args: Namespace) -> None:
"""Validate notebooks command arguments."""
if args.module:
module_file = self.config.modules_dir / args.module / f"{args.module}_dev.py"
if not module_file.exists():
raise ModuleNotFoundError(
f"Module '{args.module}' not found or no {args.module}_dev.py file"
)
def _find_dev_files(self) -> List[Path]:
"""Find all *_dev.py files in modules directory."""
dev_files = []
for module_dir in self.config.modules_dir.iterdir():
if module_dir.is_dir():
dev_py = module_dir / f"{module_dir.name}_dev.py"
if dev_py.exists():
dev_files.append(dev_py)
return dev_files
def _convert_file(self, dev_file: Path) -> Tuple[bool, str]:
"""Convert a single Python file to notebook."""
try:
py_to_notebook_tool = self.config.bin_dir / "py_to_notebook.py"
result = subprocess.run([
sys.executable, str(py_to_notebook_tool), str(dev_file)
], capture_output=True, text=True, timeout=30)
if result.returncode == 0:
# Extract success message from the tool output
output_lines = result.stdout.strip().split('\n')
success_msg = output_lines[-1] if output_lines else f"{dev_file.name}{dev_file.with_suffix('.ipynb').name}"
# Clean up the message
clean_msg = success_msg.replace('', '').replace('Converted ', '')
return True, clean_msg
else:
error_msg = result.stderr.strip() if result.stderr.strip() else "Conversion failed"
return False, error_msg
except subprocess.TimeoutExpired:
return False, "Conversion timed out"
except Exception as e:
return False, f"Error: {str(e)}"
def run(self, args: Namespace) -> int:
"""Execute the notebooks command."""
self.console.print(Panel(
"📓 Building Notebooks from Python Files",
title="Notebook Generation",
border_style="bright_cyan"
))
# Find files to convert
if args.module:
dev_files = [self.config.modules_dir / args.module / f"{args.module}_dev.py"]
self.console.print(f"🔄 Building notebook for module: {args.module}")
else:
dev_files = self._find_dev_files()
if not dev_files:
self.console.print(Panel(
"[yellow]⚠️ No *_dev.py files found in modules/[/yellow]",
title="Nothing to Convert",
border_style="yellow"
))
return 0
self.console.print(f"🔄 Building notebooks for {len(dev_files)} modules...")
# Dry run mode
if args.dry_run:
self.console.print("\n[cyan]Dry run mode - would convert:[/cyan]")
for dev_file in dev_files:
module_name = dev_file.parent.name
self.console.print(f"{module_name}: {dev_file.name}")
return 0
# Convert files
success_count = 0
error_count = 0
for dev_file in dev_files:
success, message = self._convert_file(dev_file)
module_name = dev_file.parent.name
if success:
success_count += 1
self.console.print(f"{module_name}: {message}")
else:
error_count += 1
self.console.print(f"{module_name}: {message}")
# Summary
self._print_summary(success_count, error_count)
return 0 if error_count == 0 else 1
def _print_summary(self, success_count: int, error_count: int) -> None:
"""Print command execution summary."""
summary_text = Text()
if success_count > 0:
summary_text.append(f"✅ Successfully built {success_count} notebook(s)\n", style="bold green")
if error_count > 0:
summary_text.append(f"❌ Failed to build {error_count} notebook(s)\n", style="bold red")
if success_count > 0:
summary_text.append("\n💡 Next steps:\n", style="bold yellow")
summary_text.append(" • Open notebooks with: jupyter lab\n", style="white")
summary_text.append(" • Work interactively in the notebooks\n", style="white")
summary_text.append(" • Export code with: tito sync\n", style="white")
summary_text.append(" • Run tests with: tito test\n", style="white")
border_style = "green" if error_count == 0 else "yellow"
self.console.print(Panel(
summary_text,
title="Notebook Generation Complete",
border_style=border_style
))

View File

@@ -1,15 +0,0 @@
"""
Core CLI functionality and shared utilities.
"""
from .console import get_console
from .exceptions import TinyTorchCLIError, ValidationError, ExecutionError
from .config import CLIConfig
__all__ = [
'get_console',
'TinyTorchCLIError',
'ValidationError',
'ExecutionError',
'CLIConfig'
]

View File

@@ -1,84 +0,0 @@
"""
Configuration management for TinyTorch CLI.
"""
import os
import sys
from pathlib import Path
from typing import Dict, Any, Optional
from dataclasses import dataclass
@dataclass
class CLIConfig:
"""Configuration for TinyTorch CLI."""
# Project paths
project_root: Path
modules_dir: Path
tinytorch_dir: Path
bin_dir: Path
# Environment settings
python_min_version: tuple = (3, 8)
required_packages: list = None
# CLI settings
verbose: bool = False
no_color: bool = False
def __post_init__(self):
"""Initialize default values."""
if self.required_packages is None:
self.required_packages = ['numpy', 'pytest', 'rich']
@classmethod
def from_project_root(cls, project_root: Optional[Path] = None) -> 'CLIConfig':
"""Create config from project root directory."""
if project_root is None:
# Auto-detect project root
current = Path.cwd()
while current != current.parent:
if (current / 'pyproject.toml').exists():
project_root = current
break
current = current.parent
else:
project_root = Path.cwd()
return cls(
project_root=project_root,
modules_dir=project_root / 'modules',
tinytorch_dir=project_root / 'tinytorch',
bin_dir=project_root / 'bin'
)
def validate(self) -> list[str]:
"""Validate the configuration and return any issues."""
issues = []
# Check Python version
if sys.version_info < self.python_min_version:
issues.append(f"Python {'.'.join(map(str, self.python_min_version))}+ required, "
f"found {sys.version_info.major}.{sys.version_info.minor}")
# Check virtual environment
in_venv = (hasattr(sys, 'real_prefix') or
(hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix))
if not in_venv:
issues.append("Virtual environment not activated. Run: source .venv/bin/activate")
# Check required directories
if not self.modules_dir.exists():
issues.append(f"Modules directory not found: {self.modules_dir}")
if not self.tinytorch_dir.exists():
issues.append(f"TinyTorch package not found: {self.tinytorch_dir}")
# Check required packages
for package in self.required_packages:
try:
__import__(package)
except ImportError:
issues.append(f"Missing dependency: {package}. Run: pip install -r requirements.txt")
return issues

View File

@@ -1,48 +0,0 @@
"""
Console management for consistent CLI output.
"""
from rich.console import Console
from rich.panel import Panel
from rich.text import Text
from rich.table import Table
from rich.tree import Tree
from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn
from typing import Optional
import sys
# Global console instance
_console: Optional[Console] = None
def get_console() -> Console:
"""Get the global console instance."""
global _console
if _console is None:
_console = Console(stderr=False)
return _console
def print_banner():
"""Print the TinyTorch banner using Rich."""
console = get_console()
banner_text = Text("Tiny🔥Torch: Build ML Systems from Scratch", style="bold red")
console.print(Panel(banner_text, style="bright_blue", padding=(1, 2)))
def print_error(message: str, title: str = "Error"):
"""Print an error message with consistent formatting."""
console = get_console()
console.print(Panel(f"[red]❌ {message}[/red]", title=title, border_style="red"))
def print_success(message: str, title: str = "Success"):
"""Print a success message with consistent formatting."""
console = get_console()
console.print(Panel(f"[green]✅ {message}[/green]", title=title, border_style="green"))
def print_warning(message: str, title: str = "Warning"):
"""Print a warning message with consistent formatting."""
console = get_console()
console.print(Panel(f"[yellow]⚠️ {message}[/yellow]", title=title, border_style="yellow"))
def print_info(message: str, title: str = "Info"):
"""Print an info message with consistent formatting."""
console = get_console()
console.print(Panel(f"[cyan] {message}[/cyan]", title=title, border_style="cyan"))

View File

@@ -1,23 +0,0 @@
"""
Exception hierarchy for TinyTorch CLI.
"""
class TinyTorchCLIError(Exception):
"""Base exception for all CLI errors."""
pass
class ValidationError(TinyTorchCLIError):
"""Raised when validation fails."""
pass
class ExecutionError(TinyTorchCLIError):
"""Raised when command execution fails."""
pass
class EnvironmentError(TinyTorchCLIError):
"""Raised when environment setup is invalid."""
pass
class ModuleNotFoundError(TinyTorchCLIError):
"""Raised when a requested module is not found."""
pass

View File

@@ -1,161 +0,0 @@
"""
TinyTorch CLI Main Entry Point
A professional command-line interface with proper architecture:
- Clean separation of concerns
- Proper error handling
- Logging support
- Configuration management
- Extensible command system
"""
import argparse
import logging
import sys
from pathlib import Path
from typing import Dict, Type
from .core.config import CLIConfig
from .core.console import get_console, print_banner, print_error
from .core.exceptions import TinyTorchCLIError
from .commands.base import BaseCommand
from .commands.notebooks import NotebooksCommand
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('tinytorch-cli.log'),
logging.StreamHandler(sys.stderr)
]
)
logger = logging.getLogger(__name__)
class TinyTorchCLI:
"""Main CLI application class."""
def __init__(self):
"""Initialize the CLI application."""
self.config = CLIConfig.from_project_root()
self.console = get_console()
self.commands: Dict[str, Type[BaseCommand]] = {
'notebooks': NotebooksCommand,
# Add other commands here as we refactor them
}
def create_parser(self) -> argparse.ArgumentParser:
"""Create the main argument parser."""
parser = argparse.ArgumentParser(
prog="tito",
description="TinyTorch CLI - Build ML systems from scratch",
formatter_class=argparse.RawDescriptionHelpFormatter
)
# Global options
parser.add_argument(
'--version',
action='version',
version='TinyTorch CLI 0.1.0'
)
parser.add_argument(
'--verbose', '-v',
action='store_true',
help='Enable verbose output'
)
parser.add_argument(
'--no-color',
action='store_true',
help='Disable colored output'
)
# Subcommands
subparsers = parser.add_subparsers(
dest='command',
help='Available commands',
metavar='COMMAND'
)
# Add command parsers
for command_name, command_class in self.commands.items():
# Create temporary instance to get metadata
temp_command = command_class(self.config)
cmd_parser = subparsers.add_parser(
command_name,
help=temp_command.description
)
temp_command.add_arguments(cmd_parser)
return parser
def validate_environment(self) -> bool:
"""Validate the environment and show issues if any."""
issues = self.config.validate()
if issues:
print_error(
"Environment validation failed:\n" + "\n".join(f"{issue}" for issue in issues),
"Environment Issues"
)
self.console.print("\n[dim]Run 'tito doctor' for detailed diagnosis[/dim]")
return False
return True
def run(self, args: list = None) -> int:
"""Run the CLI application."""
try:
parser = self.create_parser()
parsed_args = parser.parse_args(args)
# Update config with global options
if hasattr(parsed_args, 'verbose') and parsed_args.verbose:
self.config.verbose = True
logging.getLogger().setLevel(logging.DEBUG)
if hasattr(parsed_args, 'no_color') and parsed_args.no_color:
self.config.no_color = True
# Show banner for interactive commands
if parsed_args.command and not self.config.no_color:
print_banner()
# Validate environment for most commands
if parsed_args.command not in [None, 'version', 'help']:
if not self.validate_environment():
return 1
# Handle no command
if not parsed_args.command:
parser.print_help()
return 0
# Execute command
if parsed_args.command in self.commands:
command_class = self.commands[parsed_args.command]
command = command_class(self.config)
return command.execute(parsed_args)
else:
print_error(f"Unknown command: {parsed_args.command}")
return 1
except KeyboardInterrupt:
self.console.print("\n[yellow]Operation cancelled by user[/yellow]")
return 130
except TinyTorchCLIError as e:
logger.error(f"CLI error: {e}")
print_error(str(e))
return 1
except Exception as e:
logger.exception("Unexpected error in CLI")
print_error(f"Unexpected error: {e}")
return 1
def main() -> int:
"""Main entry point for the CLI."""
cli = TinyTorchCLI()
return cli.run()
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,9 +0,0 @@
"""
CLI Tools package.
Contains utility tools used by the CLI commands.
"""
from .py_to_notebook import convert_py_to_notebook
__all__ = ['convert_py_to_notebook']

View File

@@ -1,122 +0,0 @@
#!/usr/bin/env python3
"""
Convert Python files with cell markers to Jupyter notebooks.
Usage:
python3 bin/py_to_notebook.py modules/tensor/tensor_dev.py
python3 bin/py_to_notebook.py modules/tensor/tensor_dev.py --output custom_name.ipynb
"""
import argparse
import json
import re
import sys
from pathlib import Path
def convert_py_to_notebook(py_file: Path, output_file: Path = None):
"""Convert Python file with cell markers to notebook."""
if not py_file.exists():
print(f"❌ File not found: {py_file}")
return False
# Read the Python file
with open(py_file, 'r') as f:
content = f.read()
# Split into cells based on # %% markers
cells = re.split(r'^# %%.*$', content, flags=re.MULTILINE)
cells = [cell.strip() for cell in cells if cell.strip()]
# Create notebook structure
notebook = {
'cells': [],
'metadata': {
'kernelspec': {
'display_name': 'Python 3',
'language': 'python',
'name': 'python3'
},
'language_info': {
'name': 'python',
'version': '3.8.0'
}
},
'nbformat': 4,
'nbformat_minor': 4
}
for i, cell_content in enumerate(cells):
if not cell_content:
continue
# Check if this is a markdown cell
if cell_content.startswith('# ') and '\n' in cell_content:
lines = cell_content.split('\n')
if lines[0].startswith('# ') and not any(line.strip() and not line.startswith('#') for line in lines[:5]):
# This looks like a markdown cell
cell = {
'cell_type': 'markdown',
'metadata': {},
'source': []
}
for line in lines:
if line.startswith('# '):
cell['source'].append(line[2:] + '\n')
elif line.startswith('#'):
cell['source'].append(line[1:] + '\n')
elif line.strip() == '':
cell['source'].append('\n')
notebook['cells'].append(cell)
continue
# Code cell
cell = {
'cell_type': 'code',
'execution_count': None,
'metadata': {},
'outputs': [],
'source': []
}
for line in cell_content.split('\n'):
cell['source'].append(line + '\n')
# Remove trailing newline from last line
if cell['source'] and cell['source'][-1].endswith('\n'):
cell['source'][-1] = cell['source'][-1][:-1]
notebook['cells'].append(cell)
# Determine output file
if output_file is None:
output_file = py_file.with_suffix('.ipynb')
# Write notebook
output_file.parent.mkdir(parents=True, exist_ok=True)
with open(output_file, 'w') as f:
json.dump(notebook, f, indent=2)
print(f"✅ Converted {py_file}{output_file}")
return True
def main():
parser = argparse.ArgumentParser(description="Convert Python files to Jupyter notebooks")
parser.add_argument('input_file', type=Path, help='Input Python file')
parser.add_argument('--output', '-o', type=Path, help='Output notebook file')
parser.add_argument('--verbose', '-v', action='store_true', help='Verbose output')
args = parser.parse_args()
success = convert_py_to_notebook(args.input_file, args.output)
if not success:
sys.exit(1)
if args.verbose:
print("🎉 Conversion complete!")
if __name__ == "__main__":
main()