diff --git a/pyproject.toml b/pyproject.toml index c6e71c99f..d7981e9c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,7 +55,7 @@ dependencies = [ repository = "https://github.com/allthingslinux/tux" [project.scripts] -cli = "scripts.cli:main" +cli = "scripts:main" tux = "scripts.tux:main" db = "scripts.db:main" dev = "scripts.dev:main" @@ -318,6 +318,10 @@ root = "src" [[tool.basedpyright.executionEnvironments]] root = "scripts" reportMissingTypeStubs = "none" +reportUnknownVariableType = "none" +reportUnknownMemberType = "none" +reportUnknownArgumentType = "none" +reportUnknownParameterType = "none" [tool.coverage.run] source = ["src/tux"] diff --git a/scripts/__init__.py b/scripts/__init__.py index 5fd3c7a49..a99657cd9 100644 --- a/scripts/__init__.py +++ b/scripts/__init__.py @@ -1,30 +1,32 @@ """ -CLI Infrastructure Package. +Unified CLI Entry Point. -This package provides a clean, object-oriented foundation for building CLI applications -with proper separation of concerns and extensibility. +Aggregates all command groups (config, db, dev, docs, test, tux) +into a single root application. """ -from scripts.base import BaseCLI -from scripts.config import ConfigCLI -from scripts.db import DatabaseCLI -from scripts.dev import DevCLI -from scripts.docs import DocsCLI -from scripts.registry import Command, CommandGroup, CommandRegistry -from scripts.rich_utils import RichCLI -from scripts.test import TestCLI -from scripts.tux import TuxCLI - -__all__ = [ - "BaseCLI", - "Command", - "CommandGroup", - "CommandRegistry", - "ConfigCLI", - "DatabaseCLI", - "DevCLI", - "DocsCLI", - "RichCLI", - "TestCLI", - "TuxCLI", -] +from scripts import config, db, dev, docs, test, tux +from scripts.core import create_app + +# Create the root app +app = create_app( + name="uv run", + help_text="Tux CLI", +) + +# Add command groups +app.add_typer(config.app, name="config") +app.add_typer(db.app, name="db") +app.add_typer(dev.app, name="dev") +app.add_typer(docs.app, name="docs") +app.add_typer(test.app, name="test") +app.add_typer(tux.app, name="tux") + + +def main() -> None: + """Root entry point for all Tux CLI commands.""" + app() + + +if __name__ == "__main__": + main() diff --git a/scripts/base.py b/scripts/base.py deleted file mode 100644 index 90c2cf677..000000000 --- a/scripts/base.py +++ /dev/null @@ -1,210 +0,0 @@ -""" -Base CLI Infrastructure. - -Provides the base CLI class that all CLI applications should inherit from. -""" - -import os -import subprocess -from collections.abc import Callable - -from dotenv import load_dotenv -from rich.console import Console -from typer import Typer - -from scripts.registry import CommandRegistry -from scripts.rich_utils import RichCLI -from tux.core.logging import configure_logging - -# Load .env file to make environment variables available to subprocesses -# This ensures env vars are available when running commands via uv run, etc. -load_dotenv() - - -class BaseCLI: - """Base class for all CLI applications. - - Provides the foundation for CLI applications with Rich console support, - command registry integration, and common CLI utilities. - - Parameters - ---------- - name : str, optional - The name of the CLI application (default is "cli"). - description : str, optional - Description of the CLI application (default is "CLI Application"). - - Attributes - ---------- - app : Typer - The main Typer application instance. - console : Console - Rich console for output formatting. - rich : RichCLI - Rich CLI utilities for formatted output. - _command_registry : CommandRegistry - Registry for managing CLI commands. - """ - - app: Typer - console: Console - rich: RichCLI - _command_registry: CommandRegistry - - def __init__(self, name: str = "cli", description: str = "CLI Application"): - """Initialize the base CLI application. - - Sets up the Typer app, console, rich utilities, and command registry. - Subclasses should override _setup_commands() to add their specific commands. - - Parameters - ---------- - name : str, optional - The name of the CLI application (default is "cli"). - description : str, optional - Description of the CLI application (default is "CLI Application"). - """ - self.app = Typer( - name=name, - help=description, - rich_markup_mode="rich", - no_args_is_help=True, - ) - self.console = Console() - self.rich = RichCLI() - self._command_registry = CommandRegistry() - self._setup_commands() - - def _setup_commands(self) -> None: - """Set up commands for the CLI application. - - This method should be overridden by subclasses to add their specific - commands to the CLI application. The base implementation does nothing. - """ - - def create_subcommand_group( - self, - name: str, - help_text: str, - rich_help_panel: str | None = None, - ) -> Typer: - """Create a new subcommand group. - - Creates a Typer application instance configured for use as a subcommand - group with Rich markup support. - - Parameters - ---------- - name : str - The name of the subcommand group. - help_text : str - Help text describing the subcommand group. - rich_help_panel : str, optional - Rich help panel name for grouping commands in help output. - - Returns - ------- - Typer - A configured Typer application instance for the subcommand group. - """ - return Typer( - name=name, - help=help_text, - rich_markup_mode="rich", - no_args_is_help=True, - ) - - def add_command( - self, - func: Callable[..., None], - name: str | None = None, - help_text: str | None = None, - sub_app: Typer | None = None, - ) -> None: - """Add a command to the CLI application. - - Registers a function as a CLI command with the specified Typer application. - - Parameters - ---------- - func : Callable[..., None] - The function to register as a command. - name : str, optional - Custom name for the command. If None, uses the function name. - help_text : str, optional - Help text for the command. If None, uses command registry help text. - sub_app : Typer, optional - The Typer app to add the command to. If None, uses the main app. - """ - target_app = sub_app or self.app - # Always use help_text from command registry as single source of truth - target_app.command(name=name, help=help_text)(func) - - def add_subcommand_group( - self, - sub_app: Typer, - name: str, - rich_help_panel: str | None = None, - ) -> None: - """Add a subcommand group to the main application. - - Registers a Typer subcommand group with the main CLI application. - - Parameters - ---------- - sub_app : Typer - The Typer application to add as a subcommand group. - name : str - The name of the subcommand group. - rich_help_panel : str, optional - Rich help panel name for grouping commands in help output. - """ - self.app.add_typer(sub_app, name=name, rich_help_panel=rich_help_panel) - - def _run_command(self, command: list[str]) -> None: - """Run a shell command and handle output. - - Executes a shell command using subprocess and handles stdout/stderr output. - Explicitly passes environment variables to ensure they're available to - subprocesses, especially when using uv run or other tools that may not - inherit all environment variables. - - Parameters - ---------- - command : list[str] - The command and arguments to execute. - - Raises - ------ - subprocess.CalledProcessError - If the command returns a non-zero exit code. - """ - try: - # Explicitly pass environment variables to ensure they're available - # This is especially important for uv run which may not inherit all env vars - result = subprocess.run( - command, - check=True, - capture_output=True, - text=True, - env=os.environ.copy(), - ) - if result.stdout: - self.console.print(result.stdout) - except subprocess.CalledProcessError as e: - self.rich.print_error(f"Command failed: {' '.join(command)}") - if e.stderr: - self.console.print(f"[red]{e.stderr}[/red]") - raise - - def run(self) -> None: - """Run the CLI application with automatic logging configuration. - - Configures logging and starts the Typer application. This is the main - entry point for running the CLI. - """ - # Load CONFIG to respect DEBUG setting from .env - from tux.shared.config import CONFIG # noqa: PLC0415 - - configure_logging(config=CONFIG) - self.app() diff --git a/scripts/cli.py b/scripts/cli.py deleted file mode 100644 index bda34b15a..000000000 --- a/scripts/cli.py +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env python3 -""" -Unified CLI Entry Point for Documentation. - -This module provides a unified entry point for all CLI commands. -It combines all CLI modules into a single Typer application for documentation generation. -""" - -import sys -from pathlib import Path - -from typer import Typer - -# Add src to path -src_path = Path(__file__).parent.parent / "src" -sys.path.insert(0, str(src_path)) - -from scripts.config import ConfigCLI -from scripts.db import DatabaseCLI -from scripts.dev import DevCLI -from scripts.docs import DocsCLI -from scripts.test import TestCLI -from scripts.tux import TuxCLI - - -def create_unified_cli() -> Typer: - """Create a unified CLI application that combines all CLI modules. - - Returns - ------- - Typer - The unified CLI application with all subcommands registered. - """ - # Create the main app - cli = Typer( - name="uv run", - help="Tux", - rich_markup_mode="rich", - no_args_is_help=True, - ) - - # Create sub-apps for each CLI module - config_cli = ConfigCLI() - db_cli = DatabaseCLI() - dev_cli = DevCLI() - docs_cli = DocsCLI() - test_cli = TestCLI() - tux_cli = TuxCLI() - - # Add each CLI as a subcommand group - cli.add_typer( - config_cli.app, - name="config", - help="Configuration management", - ) - cli.add_typer( - db_cli.app, - name="db", - help="Database operations", - ) - cli.add_typer( - dev_cli.app, - name="dev", - help="Development tools", - ) - cli.add_typer( - docs_cli.app, - name="docs", - help="Documentation operations", - ) - cli.add_typer( - test_cli.app, - name="test", - help="Testing operations", - ) - cli.add_typer( - tux_cli.app, - name="tux", - help="Bot operations", - ) - - return cli - - -# Create the unified CLI app for documentation -cli = create_unified_cli() - - -def main() -> None: - """Entry point for the unified CLI.""" - cli() - - -if __name__ == "__main__": - main() diff --git a/scripts/config.py b/scripts/config.py deleted file mode 100644 index 56ee98b4d..000000000 --- a/scripts/config.py +++ /dev/null @@ -1,269 +0,0 @@ -"""Configuration management CLI for Tux. - -This script provides commands for generating and validating configuration files -in multiple formats using pydantic-settings-export CLI with proper config file handling. -""" - -import subprocess -from pathlib import Path -from typing import Annotated, Literal - -import typer -from rich.panel import Panel -from rich.table import Table -from typer import Option # type: ignore[attr-defined] - -from scripts.base import BaseCLI -from scripts.registry import Command - -# Import custom generators to ensure they're registered before CLI runs -# These imports are needed for registration, even if not directly used -from tux.shared.config.generators import ( - JsonGenerator, # noqa: F401 # pyright: ignore[reportUnusedImport] - YamlGenerator, # noqa: F401 # pyright: ignore[reportUnusedImport] -) -from tux.shared.config.settings import Config - - -class ConfigCLI(BaseCLI): - """Configuration management CLI.""" - - def __init__(self) -> None: - """Initialize the ConfigCLI.""" - super().__init__( - name="config", - description="Configuration management", - ) - self._setup_command_registry() - self._setup_commands() - - def _setup_command_registry(self) -> None: - """Set up the command registry with all configuration commands.""" - all_commands = [ - Command( - "generate", - self.generate, - "Generate configuration example files", - ), - Command( - "validate", - self.validate, - "Validate configuration files", - ), - ] - - for cmd in all_commands: - self._command_registry.register_command(cmd) - - def _setup_commands(self) -> None: - """Set up all configuration CLI commands using the command registry.""" - for command in self._command_registry.get_commands().values(): - self.add_command( - command.func, - name=command.name, - help_text=command.help_text, - ) - - def generate( - self, - format_: Annotated[ - Literal["env", "toml", "yaml", "json", "markdown", "all"], - Option( - "--format", - "-f", - help="Format to generate (env, toml, yaml, json, markdown, all)", - ), - ] = "all", - output: Annotated[ - Path | None, - Option( - "--output", - "-o", - help="Output file path (not supported with CLI approach - uses pyproject.toml paths)", - ), - ] = None, - ) -> None: - """Generate configuration example files in various formats. - - This command uses pydantic-settings-export CLI with the --config-file flag - to ensure proper configuration loading from pyproject.toml. - - Parameters - ---------- - format : Literal["env", "toml", "yaml", "json", "markdown", "all"] - The format(s) to generate - output : Path | None - Not supported - output paths are configured in pyproject.toml - - Raises - ------ - Exit - If configuration generation fails. - """ - self.console.print(Panel.fit("Configuration Generator", style="bold blue")) - - if output is not None: - self.console.print( - "Custom output paths are not supported when using CLI approach", - style="red", - ) - self.console.print( - "Use pyproject.toml configuration to specify custom paths", - style="yellow", - ) - raise typer.Exit(code=1) - - pyproject_path = Path("pyproject.toml") - if not pyproject_path.exists(): - self.console.print( - "pyproject.toml not found in current directory", - style="red", - ) - raise typer.Exit(code=1) - - # Build base command with config file - base_cmd = [ - "uv", - "run", - "pydantic-settings-export", - "--config-file", - str(pyproject_path), - ] - - # Map formats to generators - # Generators are imported at module level to ensure registration - # Use module paths for custom generators, names for built-in ones - format_map = { - "env": ["dotenv"], - "markdown": ["markdown"], # Built-in markdown generator - "toml": ["toml"], # Built-in TOML generator - "yaml": [ - "tux.shared.config.generators:YamlGenerator", - ], # Custom YAML generator - "json": [ - "tux.shared.config.generators:JsonGenerator", - ], # Custom JSON generator - "all": [ - "dotenv", - "markdown", # Built-in markdown generator - "toml", # Built-in TOML generator - "tux.shared.config.generators:YamlGenerator", # Custom YAML generator - "tux.shared.config.generators:JsonGenerator", # Custom JSON generator - ], - } - - formats_to_generate = format_map.get(format_, []) - - # Generate each format - for generator in formats_to_generate: - self.console.print(f"Running generator: {generator}", style="green") - - cmd = [*base_cmd, "--generator", generator] - - try: - result = subprocess.run(cmd, capture_output=True, text=True, check=True) - if result.stdout: - self.console.print( - f"Output: {result.stdout.strip()}", - style="dim", - ) - except subprocess.CalledProcessError as e: - self.console.print(f"Error running {generator}: {e}", style="red") - if e.stdout: - self.console.print(f"Stdout: {e.stdout}", style="dim") - if e.stderr: - self.console.print(f"Stderr: {e.stderr}", style="red") - raise typer.Exit(code=1) from e - - self.console.print( - "\nConfiguration files generated successfully!", - style="bold green", - ) - - def validate(self) -> None: - """Validate the current configuration. - - This command loads the configuration from all sources and reports any issues, - including missing required fields, invalid values, or file loading errors. - - Raises - ------ - Exit - If configuration validation fails. - """ - self.console.print(Panel.fit("Configuration Validator", style="bold blue")) - - try: - # Try to load the config - config = Config() # pyright: ignore[reportCallIssue] - - # Create a summary table - table = Table( - title="Configuration Summary", - show_header=True, - header_style="bold magenta", - ) - table.add_column("Setting", style="cyan", no_wrap=True) - table.add_column("Value", style="green") - table.add_column("Source", style="yellow") - - # Show some key settings - table.add_row("DEBUG", str(config.DEBUG), "✓") - table.add_row( - "BOT_TOKEN", - "***" if config.BOT_TOKEN else "NOT SET", - "✓" if config.BOT_TOKEN else "✗", - ) - table.add_row("Database URL", f"{config.database_url[:50]}...", "✓") - table.add_row("Bot Name", config.BOT_INFO.BOT_NAME, "✓") - table.add_row("Prefix", config.BOT_INFO.PREFIX, "✓") - - self.console.print(table) - - # Check for config files - self.console.print("\n[bold]Configuration Files:[/bold]") - for file_path in ["config.toml", "config.yaml", "config.json", ".env"]: - path = Path(file_path) - if path.exists(): - self.console.print(f" ✓ {file_path} found", style="green") - else: - self.console.print( - f" ○ {file_path} not found (using defaults)", - style="dim", - ) - - # Also check config/ directory for example files - self.console.print("\n[bold]Example Files:[/bold]") - config_dir = Path("config") - if config_dir.exists(): - if example_files := list(config_dir.glob("*.example")): - for example_file in sorted(example_files): - self.console.print(f"✓ {example_file} available", style="green") - else: - self.console.print( - f"✗ No example files in {config_dir}/ (run 'config generate')", - style="red", - ) - - self.console.print("\nConfiguration is valid!", style="bold green") - - except Exception as e: - self.console.print( - f"\nConfiguration validation failed: {e}", - style="bold red", - ) - raise typer.Exit(code=1) from e - - -# Create the CLI app instance -app = ConfigCLI().app - - -def main() -> None: - """Entry point for the configuration CLI script.""" - cli = ConfigCLI() - cli.run() - - -if __name__ == "__main__": - main() diff --git a/scripts/config/__init__.py b/scripts/config/__init__.py new file mode 100644 index 000000000..5fd454b1b --- /dev/null +++ b/scripts/config/__init__.py @@ -0,0 +1,18 @@ +""" +Configuration Command Group. + +Aggregates configuration generation and validation tools. +""" + +from scripts.config import generate, validate +from scripts.core import create_app + +app = create_app(name="config", help_text="Configuration management") + +app.add_typer(generate.app) +app.add_typer(validate.app) + + +def main() -> None: + """Entry point for the config command group.""" + app() diff --git a/scripts/config/generate.py b/scripts/config/generate.py new file mode 100644 index 000000000..93727e3b3 --- /dev/null +++ b/scripts/config/generate.py @@ -0,0 +1,82 @@ +""" +Command: config generate. + +Generates configuration example files. +""" + +from pathlib import Path +from typing import Annotated, Literal + +from rich.panel import Panel +from typer import Exit, Option + +from scripts.core import create_app +from scripts.proc import run_command +from scripts.ui import console + +app = create_app() + + +@app.command(name="generate") +def generate( + format_: Annotated[ + Literal["env", "toml", "yaml", "json", "markdown", "all"], + Option( + "--format", + "-f", + help="Format to generate (env, toml, yaml, json, markdown, all)", + ), + ] = "all", +) -> None: + """Generate configuration example files in various formats.""" + console.print(Panel.fit("Configuration Generator", style="bold blue")) + + pyproject_path = Path("pyproject.toml") + if not pyproject_path.exists(): + console.print("pyproject.toml not found in current directory", style="red") + raise Exit(code=1) + + base_cmd = [ + "uv", + "run", + "pydantic-settings-export", + "--config-file", + str(pyproject_path), + ] + + format_map = { + "env": ["dotenv"], + "markdown": ["markdown"], + "toml": ["toml"], + "yaml": ["tux.shared.config.generators:YamlGenerator"], + "json": ["tux.shared.config.generators:JsonGenerator"], + "all": [ + "dotenv", + "markdown", + "toml", + "tux.shared.config.generators:YamlGenerator", + "tux.shared.config.generators:JsonGenerator", + ], + } + + formats_to_generate = format_map.get(format_) + if formats_to_generate is None: + console.print(f"Unknown format: {format_}", style="red") + raise Exit(code=1) + + for generator in formats_to_generate: + console.print(f"Running generator: {generator}", style="green") + cmd = [*base_cmd, "--generator", generator] + + try: + # Using run_command for consistent error handling and auditing + run_command(cmd, capture_output=True) + except Exception as e: + console.print(f"Error running {generator}: {e}", style="red") + raise Exit(code=1) from e + + console.print("\nConfiguration files generated successfully!", style="bold green") + + +if __name__ == "__main__": + app() diff --git a/scripts/config/validate.py b/scripts/config/validate.py new file mode 100644 index 000000000..dbddf2d41 --- /dev/null +++ b/scripts/config/validate.py @@ -0,0 +1,84 @@ +""" +Command: config validate. + +Validates configuration files. +""" + +from pathlib import Path + +from rich.panel import Panel +from rich.table import Table +from typer import Exit + +from scripts.core import create_app +from scripts.ui import console, create_status +from tux.shared.config.settings import Config + +app = create_app() + + +@app.command(name="validate") +def validate() -> None: + """Validate the current configuration.""" + console.print(Panel.fit("Configuration Validator", style="bold blue")) + + try: + with create_status("Validating configuration...") as status: + config = Config() # pyright: ignore[reportCallIssue] + status.update("[bold green]Validation complete![/bold green]") + + table = Table( + title="Configuration Summary", + show_header=True, + header_style="bold magenta", + ) + table.add_column("Setting", style="cyan", no_wrap=True) + table.add_column("Value", style="green") + table.add_column("Source", style="yellow") + + table.add_row("DEBUG", str(config.DEBUG), "✓") + table.add_row( + "BOT_TOKEN", + "***" if config.BOT_TOKEN else "NOT SET", + "✓" if config.BOT_TOKEN else "✗", + ) + db_url = config.database_url + db_url_display = f"{db_url[:50]}..." if len(db_url) > 50 else db_url + table.add_row("Database URL", db_url_display, "✓") + table.add_row("Bot Name", config.BOT_INFO.BOT_NAME, "✓") + table.add_row("Prefix", config.BOT_INFO.PREFIX, "✓") + + console.print(table) + + console.print("\n[bold]Configuration Files:[/bold]") + for file_path in ["config.toml", "config.yaml", "config.json", ".env"]: + path = Path(file_path) + if path.exists(): + console.print(f" ✓ {file_path} found", style="green") + else: + console.print( + f" ○ {file_path} not found (using defaults)", + style="dim", + ) + + console.print("\n[bold]Example Files:[/bold]") + config_dir = Path("config") + if config_dir.exists(): + if example_files := list(config_dir.glob("*.example")): + for example_file in sorted(example_files): + console.print(f"✓ {example_file} available", style="green") + else: + console.print( + f"✗ No example files in {config_dir}/ (run 'config generate')", + style="red", + ) + + console.print("\nConfiguration is valid!", style="bold green") + + except Exception as e: + console.print(f"\nConfiguration validation failed: {e}", style="bold red") + raise Exit(code=1) from e + + +if __name__ == "__main__": + app() diff --git a/scripts/core.py b/scripts/core.py new file mode 100644 index 000000000..9ba698019 --- /dev/null +++ b/scripts/core.py @@ -0,0 +1,66 @@ +""" +Core CLI Infrastructure. + +Provides the foundation for all CLI scripts, including path setup, +environment loading, and a standardized Typer application factory. +""" + +import sys +from pathlib import Path +from typing import Any + +from dotenv import load_dotenv +from typer import Typer + +# ============================================================================ +# BOOTSTRAP +# ============================================================================ + +# Ensure the 'src' directory is in the Python path so scripts can import 'tux' +ROOT = Path(__file__).parent.parent +SRC = ROOT / "src" + +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) + +# Load .env file to make environment variables available to scripts and subprocesses +load_dotenv() + + +# ============================================================================ +# FACTORY +# ============================================================================ + + +def create_app( + name: str | None = None, + help_text: str | None = None, + no_args_is_help: bool = True, + **kwargs: Any, +) -> Typer: + """ + Create a standardized Typer application. + + Parameters + ---------- + name : str | None, optional + The name of the CLI application or command group. + help_text : str | None, optional + Description text for the application. + no_args_is_help : bool, optional + Whether to show help if no arguments are provided (default is True). + **kwargs : Any + Additional arguments passed to the Typer constructor. + + Returns + ------- + Typer + A configured Typer application instance. + """ + return Typer( + name=name, + help=help_text, + rich_markup_mode="rich", + no_args_is_help=no_args_is_help, + **kwargs, + ) diff --git a/scripts/db.py b/scripts/db.py deleted file mode 100644 index 603ba3e9f..000000000 --- a/scripts/db.py +++ /dev/null @@ -1,988 +0,0 @@ -""" -Database CLI. - -Simple and clean database CLI for SQLModel + Alembic development. -Provides essential commands for database management with clear workflows. -""" - -import asyncio -import pathlib -import subprocess -import sys -import traceback -from typing import Annotated, Any - -import typer -from sqlalchemy import text -from typer import Argument, Option # type: ignore[attr-defined] - -from scripts.base import BaseCLI -from scripts.registry import Command - -# Import here to avoid circular imports -from tux.database.service import DatabaseService -from tux.shared.config import CONFIG - - -class DatabaseCLI(BaseCLI): - """Database CLI with clean, workflow-focused commands for SQLModel + Alembic. - - Provides essential database management commands for development and deployment, - including migration management, database inspection, and administrative operations. - """ - - def __init__(self): - """Initialize the DatabaseCLI application. - - Sets up the CLI with database-specific commands and configures - the command registry for database operations. - """ - super().__init__( - name="db", - description="Database operations", - ) - self._setup_command_registry() - self._setup_commands() - - def _setup_command_registry(self) -> None: - """Set up the command registry with clean database commands.""" - all_commands = [ - # ============================================================================ - # CORE WORKFLOW COMMANDS - # ============================================================================ - Command( - "init", - self.init, - "Initialize database with migrations", - ), - Command( - "dev", - self.dev, - "Generate and apply migration", - ), - Command("push", self.push, "Apply pending migrations"), - Command("status", self.status, "Show migration status"), - # ============================================================================ - # MIGRATION MANAGEMENT - # ============================================================================ - Command( - "new", - self.new_migration, - "Generate new migration", - ), - Command("history", self.history, "Show migration history"), - Command( - "check", - self.check_migrations, - "Validate migration files", - ), - Command( - "show", - self.show_migration, - "Show migration details", - ), - # ============================================================================ - # DATABASE INSPECTION - # ============================================================================ - Command("tables", self.tables, "List database tables"), - Command("health", self.health, "Check database connection"), - Command("schema", self.schema, "Validate database schema"), - Command("queries", self.queries, "Check long-running queries"), - # ============================================================================ - # ADMIN COMMANDS - # ============================================================================ - Command("reset", self.reset, "Reset database to clean state"), - Command( - "downgrade", - self.downgrade, - "Rollback to previous migration", - ), - Command( - "nuke", - self.nuke, - "Complete database reset (destructive)", - ), - Command("version", self.version, "Show version information"), - ] - - # Note: Some useful alembic commands that are available but not exposed: - # - branches: Show branch points (advanced scenarios) - # - edit: Edit migration files (advanced users) - # - ensure_version: Create alembic_version table if missing - # - merge: Merge migration branches (advanced scenarios) - - for cmd in all_commands: - self._command_registry.register_command(cmd) - - def _setup_commands(self) -> None: - """Set up all database CLI commands using the command registry.""" - # Register all commands directly to the main app - for command in self._command_registry.get_commands().values(): - self.add_command( - command.func, - name=command.name, - help_text=command.help_text, - ) - - def _print_section_header(self, title: str, emoji: str) -> None: - """Print a standardized section header for database operations.""" - if emoji: - self.rich.print_section(f"{emoji} {title}", "blue") - else: - self.rich.print_section(title, "blue") - self.rich.rich_print(f"[bold blue]{title}...[/bold blue]") - - # ============================================================================ - # INITIALIZATION COMMANDS - # ============================================================================ - - def init(self) -> None: - """Initialize database with proper migration from empty state. - - This is the RECOMMENDED way to set up migrations for a new project. - Creates a clean initial migration that contains all table creation SQL. - - Workflow: - 1. Ensures database is empty - 2. Generates initial migration with CREATE TABLE statements - 3. Applies the migration - - Use this for new projects or when you want proper migration files. - """ - self.rich.print_section("Initialize Database", "green") - self.rich.rich_print( - "[bold green]Initializing database with migrations...[/bold green]", - ) - self.rich.rich_print( - "[yellow]This will create an initial migration file.[/yellow]", - ) - self.rich.rich_print("") - - # Check if tables exist - async def _check_tables(): - """Check if any tables exist in the database. - - Returns - ------- - int - Number of tables found, or 0 if database is empty or inaccessible. - """ - try: - service = DatabaseService(echo=False) - await service.connect(CONFIG.database_url) - - # Query directly to avoid error logging for fresh database checks - async with service.session() as session: - result = await session.execute( - text( - "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE' AND table_name != 'alembic_version'", - ), - ) - table_count = result.scalar() or 0 - - await service.disconnect() - except Exception: - return 0 - else: - return table_count - - table_count = asyncio.run(_check_tables()) - - # Check if alembic_version table exists (indicating migrations are already set up) - async def _check_migrations(): - """Check if migrations have been initialized in the database. - - Returns - ------- - int - Number of migration records found, or 0 if migrations are not initialized. - """ - try: - service = DatabaseService(echo=False) - await service.connect(CONFIG.database_url) - - # Query directly to avoid error logging for expected table-not-found errors - async with service.session() as session: - result = await session.execute( - text("SELECT COUNT(*) FROM alembic_version"), - ) - migration_count = result.scalar() or 0 - - await service.disconnect() - except Exception: - # Expected on fresh database - alembic_version table doesn't exist yet - return 0 - else: - return migration_count - - migration_count = asyncio.run(_check_migrations()) - - # Check if migration files already exist - migration_dir = pathlib.Path("src/tux/database/migrations/versions") - migration_files = ( - list(migration_dir.glob("*.py")) if migration_dir.exists() else [] - ) - # Exclude __init__.py from count as it's just a package marker - migration_file_count = len( - [f for f in migration_files if f.name != "__init__.py"], - ) - - if table_count > 0 or migration_count > 0 or migration_file_count > 0: - self.rich.rich_print( - f"[red]Database already has {table_count} tables, {migration_count} migrations in DB, and {migration_file_count} migration files![/red]", - ) - self.rich.rich_print( - "[yellow]'db init' only works on completely empty databases with no migration files.[/yellow]", - ) - self.rich.rich_print( - "[yellow]For existing databases, use 'db nuke --force' to reset the database completely.[/yellow]", - ) - self.rich.rich_print( - "[yellow]Use 'db nuke --force --fresh' for a complete fresh start (deletes migration files too).[/yellow]", - ) - self.rich.rich_print( - "[yellow]Or work with your current database state using other commands.[/yellow]", - ) - return - - # Generate initial migration - try: - self.rich.rich_print("[blue]Generating initial migration...[/blue]") - self._run_command( - [ - "uv", - "run", - "alembic", - "revision", - "--autogenerate", - "-m", - "initial schema", - ], - ) - - self.rich.rich_print("[blue]Applying initial migration...[/blue]") - self._run_command(["uv", "run", "alembic", "upgrade", "head"]) - - self.rich.print_success("Database initialized with migrations") - self.rich.rich_print("[green]Ready for development[/green]") - - except subprocess.CalledProcessError: - self.rich.print_error("Failed to initialize database") - - # ============================================================================ - # DEVELOPMENT WORKFLOW COMMANDS - # ============================================================================ - - def dev( - self, - create_only: Annotated[ - bool, - Option("--create-only", help="Create migration but don't apply it"), - ] = False, - name: Annotated[ - str | None, - Option("--name", "-n", help="Name for the migration"), - ] = None, - ) -> None: - """Development workflow: create migration and apply it. - - Similar to `prisma migrate dev` - creates a new migration from model changes - and optionally applies it immediately. - - Examples - -------- - uv run db dev # Create + apply with auto-generated name - uv run db dev --name "add user model" # Create + apply with custom name - uv run db dev --create-only # Create only, don't apply - - Raises - ------ - Exit - If migration creation fails. - """ - self.rich.print_section("Development Workflow", "blue") - - try: - if create_only: - self.rich.rich_print( - "[bold blue]Creating migration only...[/bold blue]", - ) - self._run_command( - [ - "uv", - "run", - "alembic", - "revision", - "--autogenerate", - "-m", - name or "dev migration", - ], - ) - self.rich.print_success( - "Migration created - review and apply with 'db push'", - ) - else: - self.rich.rich_print( - "[bold blue]Creating and applying migration...[/bold blue]", - ) - self._run_command( - [ - "uv", - "run", - "alembic", - "revision", - "--autogenerate", - "-m", - name or "dev migration", - ], - ) - self._run_command(["uv", "run", "alembic", "upgrade", "head"]) - self.rich.print_success("Migration created and applied!") - except subprocess.CalledProcessError: - self.rich.print_error("Failed to create migration") - raise typer.Exit(1) from None - - def push(self) -> None: - """Apply pending migrations to database. - - Applies all pending migrations to bring the database up to date. - Safe to run multiple times - only applies what's needed. - """ - self.rich.print_section("Apply Migrations", "blue") - self.rich.rich_print("[bold blue]Applying pending migrations...[/bold blue]") - - try: - self._run_command(["uv", "run", "alembic", "upgrade", "head"]) - self.rich.print_success("All migrations applied!") - except subprocess.CalledProcessError: - self.rich.print_error("Failed to apply migrations") - - def status(self) -> None: - """Show current migration status and pending changes. - - Displays: - - Current migration revision - - Available migration heads - - Any pending migrations to apply - """ - self.rich.print_section("Migration Status", "blue") - self.rich.rich_print("[bold blue]Checking migration status...[/bold blue]") - - try: - self.rich.rich_print("[cyan]Current revision:[/cyan]") - self._run_command(["uv", "run", "alembic", "current"]) - - self.rich.rich_print("[cyan]Available heads:[/cyan]") - self._run_command(["uv", "run", "alembic", "heads"]) - - self.rich.print_success("Status check complete") - except subprocess.CalledProcessError: - self.rich.print_error("Failed to get migration status") - - # ============================================================================ - # MIGRATION MANAGEMENT COMMANDS - # ============================================================================ - - def new_migration( - self, - message: Annotated[ - str, - Argument(help="Descriptive message for the migration", metavar="MESSAGE"), - ], - auto_generate: Annotated[ - bool, - Option("--auto", help="Auto-generate migration from model changes"), - ] = True, - ) -> None: - """Generate new migration from model changes. - - Creates a new migration file with the specified message. - Always review generated migrations before applying them. - - Examples - -------- - uv run db new "add user email field" # Auto-generate from model changes - uv run db new "custom migration" --no-auto # Empty migration for manual edits - - Raises - ------ - Exit - If migration generation fails. - """ - self.rich.print_section("New Migration", "blue") - self.rich.rich_print(f"[bold blue]Generating migration: {message}[/bold blue]") - - try: - if auto_generate: - self._run_command( - [ - "uv", - "run", - "alembic", - "revision", - "--autogenerate", - "-m", - message, - ], - ) - else: - self._run_command(["uv", "run", "alembic", "revision", "-m", message]) - self.rich.print_success(f"Migration generated: {message}") - self.rich.rich_print( - "[yellow]Review the migration file before applying[/yellow]", - ) - except subprocess.CalledProcessError: - self.rich.print_error("Failed to generate migration") - raise typer.Exit(1) from None - - def history(self) -> None: - """Show migration history with detailed tree view. - - Displays the complete migration history in a tree format - showing revision relationships and messages. - """ - self.rich.print_section("Migration History", "blue") - self.rich.rich_print("[bold blue]Showing migration history...[/bold blue]") - - try: - self._run_command(["uv", "run", "alembic", "history", "--verbose"]) - self.rich.print_success("History displayed") - except subprocess.CalledProcessError: - self.rich.print_error("Failed to get migration history") - - def check_migrations(self) -> None: - """Validate migration files for correctness. - - Checks that all migration files are properly formatted and - can be applied without conflicts. Useful before deployments. - """ - self.rich.print_section("Validate Migrations", "blue") - self.rich.rich_print( - "[bold blue]Checking migration files for issues...[/bold blue]", - ) - - try: - self._run_command(["uv", "run", "alembic", "check"]) - self.rich.print_success("All migrations validated successfully!") - except subprocess.CalledProcessError: - self.rich.print_error( - "Migration validation failed - check your migration files", - ) - - def show_migration( - self, - revision: Annotated[ - str, - Argument( - help="Migration revision ID to show (e.g., 'head', 'base', or specific ID)", - ), - ], - ) -> None: - """Show details of a specific migration. - - Displays the full details of a migration including its changes, - dependencies, and metadata. - - Examples - -------- - uv run db show head # Show latest migration - uv run db show base # Show base revision - uv run db show abc123 # Show specific migration - """ - self.rich.print_section("Show Migration", "blue") - self.rich.rich_print(f"[bold blue]Showing migration: {revision}[/bold blue]") - - try: - self._run_command(["uv", "run", "alembic", "show", revision]) - self.rich.print_success(f"Migration details displayed for: {revision}") - except subprocess.CalledProcessError: - self.rich.print_error(f"Failed to show migration: {revision}") - - # ============================================================================ - # INSPECTION COMMANDS - # ============================================================================ - - def tables(self) -> None: - """List all database tables and their structure. - - Shows all tables in the database with column counts and basic metadata. - Useful for exploring database structure and verifying migrations. - """ - self._print_section_header("Database Tables", "") - - async def _list_tables(): - """List all database tables with their metadata.""" - try: - service = DatabaseService(echo=False) - await service.connect(CONFIG.database_url) - - async def _get_tables(session: Any) -> list[tuple[str, int]]: - """Get list of tables with their column counts. - - Parameters - ---------- - session : Any - Database session object. - - Returns - ------- - list[tuple[str, int]] - List of (table_name, column_count) tuples. - """ - result = await session.execute( - text(""" - SELECT - table_name, - (SELECT COUNT(*) FROM information_schema.columns WHERE table_name = t.table_name) as column_count - FROM information_schema.tables t - WHERE table_schema = 'public' - AND table_type = 'BASE TABLE' - AND table_name != 'alembic_version' - ORDER BY table_name - """), - ) - return result.fetchall() - - tables = await service.execute_query(_get_tables, "get_tables") - - if not tables: - self.rich.print_info("No tables found in database") - return - - self.rich.rich_print(f"[green]Found {len(tables)} tables:[/green]") - for table_name, column_count in tables: - self.rich.rich_print( - f"[cyan]{table_name}[/cyan]: {column_count} columns", - ) - - await service.disconnect() - self.rich.print_success("Database tables listed") - - except Exception as e: - self.rich.print_error(f"Failed to list database tables: {e}") - - asyncio.run(_list_tables()) - - def health(self) -> None: - """Check database connection and health status. - - Performs health checks on the database connection and reports - connection status and response times. - """ - self.rich.print_section("Database Health", "blue") - self.rich.rich_print("[bold blue]Checking database health...[/bold blue]") - - async def _health_check(): - """Check the health status of the database connection.""" - try: - service = DatabaseService(echo=False) - await service.connect(CONFIG.database_url) - - health = await service.health_check() - - if health["status"] == "healthy": - self.rich.rich_print("[green]Database is healthy![/green]") - self.rich.rich_print( - f"[green]Connection: {health.get('connection', 'OK')}[/green]", - ) - self.rich.rich_print( - f"[green]Response time: {health.get('response_time', 'N/A')}[/green]", - ) - else: - self.rich.rich_print("[red]Database is unhealthy![/red]") - self.rich.rich_print( - f"[red]Error: {health.get('error', 'Unknown error')}[/red]", - ) - - await service.disconnect() - self.rich.print_success("Health check completed") - - except Exception as e: - self.rich.print_error(f"Failed to check database health: {e}") - - asyncio.run(_health_check()) - - def schema(self) -> None: - """Validate that database schema matches model definitions. - - Performs a check to ensure all tables and columns - defined in the models exist in the database and are accessible. - Useful for catching schema mismatches after code changes. - """ - self.rich.print_section("Schema Validation", "blue") - self.rich.rich_print( - "[bold blue]Validating database schema against models...[/bold blue]", - ) - - def _exit_with_error() -> None: - """Exit with error status.""" - raise typer.Exit(1) from None - - async def _schema_check(): - """Validate the database schema against model definitions.""" - try: - service = DatabaseService(echo=False) - await service.connect(CONFIG.database_url) - - schema_result = await service.validate_schema() - - if schema_result["status"] == "valid": - self.rich.rich_print( - "[green]Database schema validation passed![/green]", - ) - self.rich.rich_print( - "[green]All tables and columns match model definitions.[/green]", - ) - else: - error_msg = schema_result.get( - "error", - "Unknown schema validation error", - ) - self.rich.rich_print( - "[red]Database schema validation failed![/red]", - ) - self.rich.rich_print(f"[red]Error: {error_msg}[/red]") - self.rich.rich_print("") - self.rich.rich_print("[yellow]Suggested fixes:[/yellow]") - self.rich.rich_print( - " • Run 'uv run db reset' to reset and reapply migrations", - ) - self.rich.rich_print( - " • Run 'uv run db nuke --force' for complete database reset", - ) - self.rich.rich_print( - " • Check that your models match the latest migration files", - ) - _exit_with_error() - - await service.disconnect() - self.rich.print_success("Schema validation completed") - - except Exception as e: - self.rich.print_error(f"Failed to validate database schema: {e}") - raise typer.Exit(1) from None - - asyncio.run(_schema_check()) - - def queries(self) -> None: - """Check for long-running database queries. - - Identifies and displays currently running queries that may be - causing performance issues or blocking operations. - """ - self.rich.print_section("Query Analysis", "blue") - self.rich.rich_print( - "[bold blue]Checking for long-running queries...[/bold blue]", - ) - - async def _check_queries(): - """Check for long-running queries in the database.""" - try: - service = DatabaseService(echo=False) - await service.connect(CONFIG.database_url) - - async def _get_long_queries( - session: Any, - ) -> list[tuple[Any, Any, str, str]]: - """Get list of queries running longer than 5 minutes. - - Parameters - ---------- - session : Any - Database session object. - - Returns - ------- - list[tuple[Any, Any, str, str]] - List of (pid, duration, query, state) tuples for long-running queries. - """ - result = await session.execute( - text(""" - SELECT - pid, - now() - pg_stat_activity.query_start AS duration, - query, - state - FROM pg_stat_activity - WHERE (now() - pg_stat_activity.query_start) > interval '5 minutes' - AND state != 'idle' - ORDER BY duration DESC - """), - ) - return result.fetchall() - - long_queries = await service.execute_query( - _get_long_queries, - "get_long_queries", - ) - - if long_queries: - self.rich.rich_print( - f"[yellow]Found {len(long_queries)} long-running queries:[/yellow]", - ) - for pid, duration, query, state in long_queries: - self.rich.rich_print( - f"[red]PID {pid}[/red]: {state} for {duration}", - ) - self.rich.rich_print(f" Query: {query[:100]}...") - else: - self.rich.rich_print( - "[green]No long-running queries found[/green]", - ) - - await service.disconnect() - self.rich.print_success("Query analysis completed") - - except Exception as e: - self.rich.print_error(f"Failed to check database queries: {e}") - - asyncio.run(_check_queries()) - - # ============================================================================ - # ADMIN COMMANDS - # ============================================================================ - - def reset(self) -> None: - """Reset database to clean state via migrations. - - Resets the database by downgrading to base (empty) and then - reapplying all migrations from scratch. Preserves migration files. - - Use this to test the full migration chain or fix migration issues. - """ - self.rich.print_section("Reset Database", "yellow") - self.rich.rich_print( - "[bold yellow]This will reset your database![/bold yellow]", - ) - self.rich.rich_print( - "[yellow]Downgrading to base and reapplying all migrations...[/yellow]", - ) - - try: - self._run_command(["uv", "run", "alembic", "downgrade", "base"]) - self._run_command(["uv", "run", "alembic", "upgrade", "head"]) - self.rich.print_success("Database reset and migrations reapplied!") - except subprocess.CalledProcessError: - self.rich.print_error("Failed to reset database") - - def nuke( # noqa: PLR0915 - self, - force: Annotated[ - bool, - Option("--force", "-f", help="Skip confirmation prompt"), - ] = False, - fresh: Annotated[ - bool, - Option( - "--fresh", - help="Delete all migration files", - ), - ] = False, - yes: Annotated[ - bool, - Option("--yes", "-y", help="Automatically answer 'yes' to all prompts"), - ] = False, - ) -> None: - """Complete database reset. - - WARNING: This operation is destructive and will permanently delete all data. - - This command will: - 1. Drop all tables and alembic tracking - 2. Leave database completely empty - 3. With --fresh: Also delete all migration files - - After reset, run 'db push' to recreate tables from existing migrations. - With --fresh, run 'db init' to create new migrations from scratch. - - For normal development, use 'db reset' instead. - """ - self.rich.print_section("Complete Database Reset", "red") - self.rich.rich_print( - "[bold red]WARNING: This will DELETE ALL DATA[/bold red]", - ) - self.rich.rich_print( - "[red]This operation is destructive. Use only when migrations are broken.[/red]", - ) - self.rich.rich_print("") - self.rich.rich_print("[yellow]This operation will:[/yellow]") - self.rich.rich_print(" 1. Drop ALL tables and reset migration tracking") - self.rich.rich_print(" 2. Leave database completely empty") - if fresh: - self.rich.rich_print(" 3. Delete ALL migration files") - self.rich.rich_print("") - - # Require explicit confirmation unless --force or --yes is used - if not (force or yes): - if not sys.stdin.isatty(): - self.rich.print_error( - "Cannot run nuke in non-interactive mode without --force or --yes flag", - ) - return - - response = input("Type 'NUKE' to confirm (case sensitive): ") - if response != "NUKE": - self.rich.print_info("Nuclear reset cancelled") - return - - async def _nuclear_reset(): - """Perform a complete database reset by dropping all tables and schemas.""" - try: - service = DatabaseService(echo=False) - await service.connect(CONFIG.database_url) - - # Drop all tables including alembic_version - async def _drop_all_tables(session: Any) -> None: - """Drop all tables and recreate the public schema. - - Parameters - ---------- - session : Any - Database session object. - """ - # Explicitly drop alembic_version first (it may not be in public schema) - await session.execute(text("DROP TABLE IF EXISTS alembic_version")) - # Drop the entire public schema - await session.execute(text("DROP SCHEMA public CASCADE")) - await session.execute(text("CREATE SCHEMA public")) - await session.execute(text("GRANT ALL ON SCHEMA public TO public")) - await session.commit() - - self.rich.rich_print( - "[yellow]Dropping all tables and schema...[/yellow]", - ) - await service.execute_query(_drop_all_tables, "drop_all_tables") - await service.disconnect() - - self.rich.print_success( - "Nuclear reset completed - database is completely empty", - ) - - # Delete migration files if --fresh flag is used - if fresh: - migration_dir = pathlib.Path("src/tux/database/migrations/versions") - if migration_dir.exists(): - self.rich.rich_print( - "[yellow]Deleting all migration files...[/yellow]", - ) - deleted_count = 0 - for migration_file in migration_dir.glob("*.py"): - if migration_file.name != "__init__.py": # Keep __init__.py - migration_file.unlink() - deleted_count += 1 - self.rich.print_success( - f"Deleted {deleted_count} migration files", - ) - - self.rich.rich_print("[yellow]Next steps:[/yellow]") - if fresh: - self.rich.rich_print( - " • Run 'db init' to create new initial migration and setup", - ) - else: - self.rich.rich_print( - " • Run 'db push' to recreate tables from existing migrations", - ) - self.rich.rich_print( - " • For completely fresh start: delete migration files, then run 'db init'", - ) - self.rich.rich_print(" • Or manually recreate tables as needed") - - except Exception as e: - self.rich.print_error(f"Failed to nuclear reset database: {e}") - traceback.print_exc() - - asyncio.run(_nuclear_reset()) - - def downgrade( - self, - revision: Annotated[ - str, - Argument( - help="Revision to downgrade to (e.g., '-1' for one step back, 'base' for initial state, or specific revision ID)", - ), - ], - force: Annotated[ - bool, - Option("--force", "-f", help="Skip confirmation prompt"), - ] = False, - ) -> None: - """Rollback to a previous migration revision. - - Reverts the database schema to an earlier migration state. - Useful for fixing issues or testing different schema versions. - - Examples - -------- - uv run db downgrade -1 # Rollback one migration - uv run db downgrade base # Rollback to initial empty state - uv run db downgrade abc123 # Rollback to specific revision - - WARNING: This can cause data loss if rolling back migrations - that added tables or columns. Always backup first. - """ - self.rich.print_section("Downgrade Database", "yellow") - self.rich.rich_print( - f"[bold yellow]Rolling back to revision: {revision}[/bold yellow]", - ) - self.rich.rich_print( - "[yellow]This may cause data loss. Backup your database first.[/yellow]", - ) - self.rich.rich_print("") - - # Require confirmation for dangerous operations (unless --force is used) - if not force and revision != "-1": # Allow quick rollback without confirmation - response = input(f"Type 'yes' to downgrade to {revision}: ") - if response.lower() != "yes": - self.rich.print_info("Downgrade cancelled") - return - - try: - self._run_command(["uv", "run", "alembic", "downgrade", revision]) - self.rich.print_success(f"Successfully downgraded to revision: {revision}") - except subprocess.CalledProcessError: - self.rich.print_error(f"Failed to downgrade to revision: {revision}") - - def version(self) -> None: - """Show version information for database components. - - Displays version information for the database CLI, alembic, - and database driver components. - """ - self.rich.print_section("Version Information", "blue") - self.rich.rich_print( - "[bold blue]Showing database version information...[/bold blue]", - ) - - try: - self.rich.rich_print("[cyan]Current migration:[/cyan]") - self._run_command(["uv", "run", "alembic", "current"]) - - self.rich.rich_print("[cyan]Database driver:[/cyan]") - self._run_command( - [ - "uv", - "run", - "python", - "-c", - "import psycopg; print(f'psycopg version: {psycopg.__version__}')", - ], - ) - - self.rich.print_success("Version information displayed") - except subprocess.CalledProcessError: - self.rich.print_error("Failed to get version information") - - -# Create the CLI app instance -app = DatabaseCLI().app - - -def main() -> None: - """Entry point for the database CLI script.""" - cli = DatabaseCLI() - cli.run() - - -if __name__ == "__main__": - main() diff --git a/scripts/db/__init__.py b/scripts/db/__init__.py new file mode 100644 index 000000000..0e4e81b95 --- /dev/null +++ b/scripts/db/__init__.py @@ -0,0 +1,49 @@ +""" +Database Command Group. + +Aggregates all database-related operations. +""" + +from scripts.core import create_app +from scripts.db import ( + check, + dev, + downgrade, + health, + history, + init, + new, + nuke, + push, + queries, + reset, + schema, + show, + status, + tables, + version, +) + +app = create_app(name="db", help_text="Database operations") + +app.add_typer(init.app) +app.add_typer(dev.app) +app.add_typer(push.app) +app.add_typer(status.app) +app.add_typer(new.app) +app.add_typer(history.app) +app.add_typer(check.app) +app.add_typer(show.app) +app.add_typer(tables.app) +app.add_typer(health.app) +app.add_typer(schema.app) +app.add_typer(queries.app) +app.add_typer(reset.app) +app.add_typer(downgrade.app) +app.add_typer(nuke.app) +app.add_typer(version.app) + + +def main() -> None: + """Entry point for the db command group.""" + app() diff --git a/scripts/db/check.py b/scripts/db/check.py new file mode 100644 index 000000000..99f06029d --- /dev/null +++ b/scripts/db/check.py @@ -0,0 +1,33 @@ +""" +Command: db check. + +Validates migration files. +""" + +from subprocess import CalledProcessError + +from typer import Exit + +from scripts.core import create_app +from scripts.proc import run_command +from scripts.ui import print_error, print_section, print_success, rich_print + +app = create_app() + + +@app.command(name="check") +def check() -> None: + """Validate migration files for correctness.""" + print_section("Validate Migrations", "blue") + rich_print("[bold blue]Checking migration files for issues...[/bold blue]") + + try: + run_command(["uv", "run", "alembic", "check"]) + print_success("All migrations validated successfully!") + except CalledProcessError as e: + print_error(f"Migration validation failed: {e}") + raise Exit(1) from e + + +if __name__ == "__main__": + app() diff --git a/scripts/db/dev.py b/scripts/db/dev.py new file mode 100644 index 000000000..28be11b5d --- /dev/null +++ b/scripts/db/dev.py @@ -0,0 +1,70 @@ +""" +Command: db dev. + +Development workflow: create and optionally apply a new migration. +""" + +from typing import Annotated + +from typer import Exit, Option + +from scripts.core import create_app +from scripts.proc import run_command +from scripts.ui import print_error, print_section, print_success, rich_print + +app = create_app() + + +@app.command(name="dev") +def dev( + create_only: Annotated[ + bool, + Option("--create-only", help="Create migration but don't apply it"), + ] = False, + name: Annotated[ + str | None, + Option("--name", "-n", help="Name for the migration"), + ] = None, +) -> None: + """Development workflow: create migration and apply it.""" + print_section("Development Workflow", "blue") + + migration_name = name or "dev migration" + + try: + if create_only: + rich_print("[bold blue]Creating migration only...[/bold blue]") + run_command( + [ + "uv", + "run", + "alembic", + "revision", + "--autogenerate", + "-m", + migration_name, + ], + ) + print_success("Migration created - review and apply with 'db push'") + else: + rich_print("[bold blue]Creating and applying migration...[/bold blue]") + run_command( + [ + "uv", + "run", + "alembic", + "revision", + "--autogenerate", + "-m", + migration_name, + ], + ) + run_command(["uv", "run", "alembic", "upgrade", "head"]) + print_success("Migration created and applied!") + except Exception: + print_error("Failed to create migration") + raise Exit(1) from None + + +if __name__ == "__main__": + app() diff --git a/scripts/db/downgrade.py b/scripts/db/downgrade.py new file mode 100644 index 000000000..84ca8dffc --- /dev/null +++ b/scripts/db/downgrade.py @@ -0,0 +1,59 @@ +""" +Command: db downgrade. + +Rolls back to a previous migration revision. +""" + +import sys +from subprocess import CalledProcessError +from typing import Annotated + +from typer import Argument, Option, confirm + +from scripts.core import create_app +from scripts.proc import run_command +from scripts.ui import ( + print_error, + print_info, + print_section, + print_success, + rich_print, +) + +app = create_app() + + +@app.command(name="downgrade") +def downgrade( + revision: Annotated[ + str, + Argument( + help="Revision to downgrade to (e.g., '-1' for one step back, 'base' for initial state, or specific revision ID)", + ), + ], + force: Annotated[ + bool, + Option("--force", "-f", help="Skip confirmation prompt"), + ] = False, +) -> None: + """Rollback to a previous migration revision.""" + print_section("Downgrade Database", "yellow") + rich_print(f"[bold yellow]Rolling back to revision: {revision}[/bold yellow]") + rich_print( + "[yellow]This may cause data loss. Backup your database first.[/yellow]\n", + ) + + if not force and not confirm(f"Downgrade to {revision}?", default=False): + print_info("Downgrade cancelled") + return + + try: + run_command(["uv", "run", "alembic", "downgrade", revision]) + print_success(f"Successfully downgraded to revision: {revision}") + except CalledProcessError as e: + print_error(f"Failed to downgrade to revision {revision}: {e}") + sys.exit(1) + + +if __name__ == "__main__": + app() diff --git a/scripts/db/health.py b/scripts/db/health.py new file mode 100644 index 000000000..de4501a11 --- /dev/null +++ b/scripts/db/health.py @@ -0,0 +1,70 @@ +""" +Command: db health. + +Checks database connection and health status. +""" + +import asyncio + +from typer import Exit + +from scripts.core import create_app +from scripts.ui import ( + create_status, + print_error, + print_pretty, + print_section, + print_success, + rich_print, +) +from tux.database.service import DatabaseService +from tux.shared.config import CONFIG + +app = create_app() + + +def _fail(): + """Raise Exit(1) to satisfy Ruff's TRY301 rule.""" + raise Exit(1) + + +@app.command(name="health") +def health() -> None: + """Check database connection and health status.""" + print_section("Database Health", "blue") + rich_print("[bold blue]Checking database health...[/bold blue]") + + async def _health_check(): + service = DatabaseService(echo=False) + try: + with create_status("Checking database health...") as status: + await service.connect(CONFIG.database_url) + health_data = await service.health_check() + status.update("[bold green]Connection successful![/bold green]") + + if health_data["status"] == "healthy": + rich_print("[green]Database is healthy![/green]") + rich_print(f"[green]Mode: {health_data.get('mode', 'unknown')}[/green]") + print_pretty(health_data) + print_success("Health check completed") + else: + rich_print("[red]Database is unhealthy![/red]") + rich_print( + f"[red]Error: {health_data.get('error', 'Unknown error')}[/red]", + ) + print_error("Health check failed") + _fail() + + except Exception as e: + if not isinstance(e, Exit): + print_error(f"Failed to check database health: {e}") + raise Exit(1) from e + raise + finally: + await service.disconnect() + + asyncio.run(_health_check()) + + +if __name__ == "__main__": + app() diff --git a/scripts/db/history.py b/scripts/db/history.py new file mode 100644 index 000000000..1864ad708 --- /dev/null +++ b/scripts/db/history.py @@ -0,0 +1,33 @@ +""" +Command: db history. + +Shows migration history. +""" + +from subprocess import CalledProcessError + +from typer import Exit + +from scripts.core import create_app +from scripts.proc import run_command +from scripts.ui import print_error, print_section, print_success, rich_print + +app = create_app() + + +@app.command(name="history") +def history() -> None: + """Show migration history with detailed tree view.""" + print_section("Migration History", "blue") + rich_print("[bold blue]Showing migration history...[/bold blue]") + + try: + run_command(["uv", "run", "alembic", "history", "--verbose"]) + print_success("History displayed") + except CalledProcessError as e: + print_error(f"Failed to get migration history: {e}") + raise Exit(1) from e + + +if __name__ == "__main__": + app() diff --git a/scripts/db/init.py b/scripts/db/init.py new file mode 100644 index 000000000..4a6e8265b --- /dev/null +++ b/scripts/db/init.py @@ -0,0 +1,120 @@ +""" +Command: db init. + +Initializes the database with a proper initial migration. +""" + +import asyncio +import contextlib +import pathlib + +from sqlalchemy import text +from typer import Exit + +from scripts.core import create_app +from scripts.proc import run_command +from scripts.ui import ( + print_error, + print_pretty, + print_section, + print_success, + rich_print, +) +from tux.database.service import DatabaseService + +app = create_app() + + +async def _inspect_db_state() -> tuple[int, int]: + """Return (table_count, migration_count).""" + service = DatabaseService(echo=False) + try: + async with service.session() as session: + table_result = await session.execute( + text( + """ + SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = 'public' + AND table_type = 'BASE TABLE' + AND table_name != 'alembic_version' + """, + ), + ) + migration_result = await session.execute( + text("SELECT COUNT(*) FROM alembic_version"), + ) + + # Move .scalar() calls inside the session context for clarity + table_count = table_result.scalar() or 0 + migration_count = migration_result.scalar() or 0 + except Exception: + # Preserve current behavior: treat errors as "0" + return 0, 0 + finally: + with contextlib.suppress(Exception): + await service.disconnect() + return table_count, migration_count + + +@app.command(name="init") +def init() -> None: + """Initialize database with proper migration from empty state.""" + print_section("Initialize Database", "green") + rich_print("[bold green]Initializing database with migrations...[/bold green]") + rich_print("[yellow]This will create an initial migration file.[/yellow]\n") + + table_count, migration_count = asyncio.run(_inspect_db_state()) + + migration_dir = pathlib.Path("src/tux/database/migrations/versions") + migration_files = list(migration_dir.glob("*.py")) if migration_dir.exists() else [] + + # More explicit migration file filtering + migration_file_count = len( + [ + f + for f in migration_files + if f.name != "__init__.py" and not f.name.startswith("_") + ], + ) + + if table_count > 0 or migration_count > 0 or migration_file_count > 0: + rich_print("[red]Database initialization blocked:[/red]") + print_pretty( + { + "tables": table_count, + "migrations_in_db": migration_count, + "migration_files": migration_file_count, + }, + ) + rich_print( + "[yellow]'db init' only works on completely empty databases with no migration files.[/yellow]", + ) + raise Exit(1) + + try: + rich_print("[blue]Generating initial migration...[/blue]") + run_command( + [ + "uv", + "run", + "alembic", + "revision", + "--autogenerate", + "-m", + "initial schema", + ], + ) + + rich_print("[blue]Applying initial migration...[/blue]") + run_command(["uv", "run", "alembic", "upgrade", "head"]) + + print_success("Database initialized with migrations") + rich_print("[green]Ready for development[/green]") + + except Exception as e: + print_error(f"Failed to initialize database: {e}") + raise Exit(1) from e + + +if __name__ == "__main__": + app() diff --git a/scripts/db/new.py b/scripts/db/new.py new file mode 100644 index 000000000..5f6e480ec --- /dev/null +++ b/scripts/db/new.py @@ -0,0 +1,49 @@ +""" +Command: db new. + +Generates a new migration from model changes. +""" + +from subprocess import CalledProcessError +from typing import Annotated + +from typer import Argument, Exit, Option + +from scripts.core import create_app +from scripts.proc import run_command +from scripts.ui import print_error, print_section, print_success, rich_print + +app = create_app() + + +@app.command(name="new") +def new( + message: Annotated[ + str, + Argument(help="Descriptive message for the migration", metavar="MESSAGE"), + ], + auto_generate: Annotated[ + bool, + Option("--auto/--no-auto", help="Auto-generate migration from model changes"), + ] = True, +) -> None: + """Generate new migration from model changes.""" + print_section("New Migration", "blue") + rich_print(f"[bold blue]Generating migration: {message}[/bold blue]") + + cmd = ["uv", "run", "alembic", "revision"] + if auto_generate: + cmd.append("--autogenerate") + cmd.extend(["-m", message]) + + try: + run_command(cmd) + print_success(f"Migration generated: {message}") + rich_print("[yellow]Review the migration file before applying[/yellow]") + except CalledProcessError as e: + print_error(f"Failed to generate migration: {e}") + raise Exit(1) from e + + +if __name__ == "__main__": + app() diff --git a/scripts/db/nuke.py b/scripts/db/nuke.py new file mode 100644 index 000000000..1a5ba2f5f --- /dev/null +++ b/scripts/db/nuke.py @@ -0,0 +1,181 @@ +""" +Command: db nuke. + +Complete database reset (destructive). +""" + +import asyncio +import os +import sys +import traceback +from typing import Annotated + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.sql import text +from typer import Exit, Option + +from scripts.core import ROOT, create_app +from scripts.ui import ( + print_error, + print_info, + print_section, + print_success, + print_warning, + prompt, + rich_print, +) +from tux.database.service import DatabaseService +from tux.shared.config import CONFIG + +app = create_app() + + +async def _drop_and_recreate_schema(session: AsyncSession) -> None: + """Drop the public schema (including all tables) and recreate it.""" + # Dropping the schema with CASCADE will already drop all tables including alembic_version + await session.execute(text("DROP SCHEMA public CASCADE")) + await session.execute(text("CREATE SCHEMA public")) + # Note: We removed GRANT ALL ON SCHEMA public TO public for security reasons. + # The connection user should already have necessary permissions as owner. + + +def _delete_migration_files(): + """Delete all migration files in the versions directory.""" + # Anchor to repo root via ROOT constant from core + migration_dir = ROOT / "src" / "tux" / "database" / "migrations" / "versions" + if migration_dir.exists(): + rich_print("[yellow]Deleting all migration files...[/yellow]") + deleted_count = 0 + for migration_file in migration_dir.glob("*.py"): + if migration_file.name != "__init__.py": + try: + migration_file.unlink() + deleted_count += 1 + except OSError as e: + print_error(f"Failed to delete {migration_file.name}: {e}") + print_success(f"Deleted {deleted_count} migration files") + else: + print_error(f"Migration directory not found: {migration_dir}") + raise Exit(1) + + +async def _nuclear_reset(fresh: bool): + """Perform a complete database reset by dropping all tables and schemas.""" + # Safety check: prevent running against production + is_prod = ( + os.getenv("ENVIRONMENT", "").lower() == "production" + or os.getenv("APP_ENV", "").lower() == "production" + or getattr(CONFIG, "PRODUCTION", False) + ) + + db_url = CONFIG.database_url + # Allow override via env var + prod_keywords = os.getenv("PROD_DB_KEYWORDS", "prod,live,allthingslinux.org").split( + ",", + ) + is_prod_db = any(kw.strip() in db_url.lower() for kw in prod_keywords if kw.strip()) + + if is_prod or is_prod_db: + if os.getenv("FORCE_NUKE") != "true": + print_error( + "CRITICAL: Cannot run nuke command against production database!", + ) + rich_print( + "[yellow]If you are absolutely sure, set FORCE_NUKE=true environment variable.[/yellow]", + ) + raise Exit(1) + print_warning( + "FORCE_NUKE detected. Proceeding with nuclear reset on PRODUCTION database...", + ) + + service = DatabaseService(echo=False) + try: + await service.connect(CONFIG.database_url) + + # Show which database is being nuked for user awareness + db_name = CONFIG.database_url.split("/")[-1].split("?")[0] + rich_print(f"[yellow]Target database: {db_name}[/yellow]") + + rich_print("[yellow]Dropping all tables and schema...[/yellow]") + await service.execute_query( + _drop_and_recreate_schema, + "drop_and_recreate_schema", + ) + + print_success("Nuclear reset completed - database is completely empty") + + if fresh: + _delete_migration_files() + + rich_print("[yellow]Next steps:[/yellow]") + if fresh: + rich_print( + " • Run 'uv run db init' to create new initial migration and setup", + ) + else: + rich_print( + " • Run 'uv run db push' to recreate tables from existing migrations", + ) + rich_print( + " • For completely fresh start: delete migration files, then run 'db init'", + ) + rich_print(" • Or manually recreate tables as needed") + + except Exception as e: + print_error(f"Failed to nuclear reset database: {e}") + traceback.print_exc() + raise Exit(1) from e + finally: + await service.disconnect() + + +@app.command(name="nuke") +def nuke( + force: Annotated[ + bool, + Option("--force", "-f", help="Skip confirmation prompt"), + ] = False, + fresh: Annotated[ + bool, + Option("--fresh", help="Delete all migration files"), + ] = False, + yes: Annotated[ + bool, + Option("--yes", "-y", help="Automatically answer 'yes' to all prompts"), + ] = False, +) -> None: + """ + Complete database reset. + + This command is destructive and should only be used in development + or in case of critical migration failure. + """ + print_section("Complete Database Reset", "red") + rich_print("[bold red]WARNING: This will DELETE ALL DATA[/bold red]") + rich_print( + "[red]This operation is destructive. Use only when migrations are broken.[/red]\n", + ) + rich_print("[yellow]This operation will:[/yellow]") + rich_print(" 1. Drop ALL tables and reset migration tracking") + rich_print(" 2. Leave database completely empty") + if fresh: + rich_print(" 3. Delete ALL migration files") + rich_print("") + + if not (force or yes): + if not sys.stdin.isatty(): + print_error( + "Cannot run nuke in non-interactive mode without --force or --yes flag", + ) + raise Exit(1) + + response = prompt("Type 'NUKE' to confirm (case sensitive): ") + if response != "NUKE": + print_info("Nuclear reset cancelled") + return + + asyncio.run(_nuclear_reset(fresh)) + + +if __name__ == "__main__": + app() diff --git a/scripts/db/push.py b/scripts/db/push.py new file mode 100644 index 000000000..c009fc679 --- /dev/null +++ b/scripts/db/push.py @@ -0,0 +1,33 @@ +""" +Command: db push. + +Applies pending migrations to the database. +""" + +from subprocess import CalledProcessError + +from typer import Exit + +from scripts.core import create_app +from scripts.proc import run_command +from scripts.ui import print_error, print_section, print_success, rich_print + +app = create_app() + + +@app.command(name="push") +def push() -> None: + """Apply pending migrations to database.""" + print_section("Apply Migrations", "blue") + rich_print("[bold blue]Applying pending migrations...[/bold blue]") + + try: + run_command(["uv", "run", "alembic", "upgrade", "head"]) + print_success("All migrations applied!") + except CalledProcessError as e: + print_error(f"Failed to apply migrations: {e}") + raise Exit(1) from e + + +if __name__ == "__main__": + app() diff --git a/scripts/db/queries.py b/scripts/db/queries.py new file mode 100644 index 000000000..eea996e85 --- /dev/null +++ b/scripts/db/queries.py @@ -0,0 +1,84 @@ +""" +Command: db queries. + +Checks for long-running database queries. +""" + +import asyncio +from typing import Any + +from sqlalchemy import text +from typer import Exit + +from scripts.core import create_app +from scripts.ui import ( + create_status, + print_error, + print_pretty, + print_section, + print_success, + rich_print, +) +from tux.database.service import DatabaseService +from tux.shared.config import CONFIG + +app = create_app() + +LONG_RUNNING_QUERIES_SQL = """ +SELECT + pid, + now() - pg_stat_activity.query_start AS duration, + query, + state +FROM pg_stat_activity +WHERE (now() - pg_stat_activity.query_start) > interval '5 minutes' +AND state != 'idle' +ORDER BY duration DESC +""" + + +@app.command(name="queries") +def queries() -> None: + """Check for long-running database queries.""" + print_section("Query Analysis", "blue") + rich_print("[bold blue]Checking for long-running queries...[/bold blue]") + + async def _check_queries(): + service = DatabaseService(echo=False) + try: + with create_status("Analyzing queries...") as status: + await service.connect(CONFIG.database_url) + + async def _get_long_queries( + session: Any, + ) -> list[tuple[Any, Any, str, str]]: + result = await session.execute(text(LONG_RUNNING_QUERIES_SQL)) + return result.fetchall() + + long_queries = await service.execute_query( + _get_long_queries, + "get_long_queries", + ) + status.update("[bold green]Analysis complete![/bold green]") + + if long_queries: + rich_print( + f"[yellow]Found {len(long_queries)} long-running queries:[/yellow]", + ) + print_pretty(long_queries) + else: + rich_print("[green]No long-running queries found[/green]") + + print_success("Query analysis completed") + + except Exception as e: + print_error(f"Failed to check database queries: {e}") + raise Exit(1) from e + finally: + await service.disconnect() + + asyncio.run(_check_queries()) + + +if __name__ == "__main__": + app() diff --git a/scripts/db/reset.py b/scripts/db/reset.py new file mode 100644 index 000000000..1afb1ef88 --- /dev/null +++ b/scripts/db/reset.py @@ -0,0 +1,39 @@ +""" +Command: db reset. + +Resets the database to a clean state via migrations. +""" + +from subprocess import CalledProcessError + +from scripts.core import create_app +from scripts.proc import run_command +from scripts.ui import print_error, print_section, print_success, rich_print + +app = create_app() + + +@app.command(name="reset") +def reset() -> None: + """Reset database to clean state via migrations.""" + print_section("Reset Database", "yellow") + rich_print("[bold yellow]This will reset your database![/bold yellow]") + rich_print("[yellow]Downgrading to base and reapplying all migrations...[/yellow]") + + try: + run_command(["uv", "run", "alembic", "downgrade", "base"]) + except CalledProcessError: + print_error("Failed to downgrade database") + raise + + try: + run_command(["uv", "run", "alembic", "upgrade", "head"]) + print_success("Database reset and migrations reapplied!") + except CalledProcessError: + print_error("Failed to reapply migrations") + print_error("WARNING: Database is at base state with no migrations!") + raise + + +if __name__ == "__main__": + app() diff --git a/scripts/db/schema.py b/scripts/db/schema.py new file mode 100644 index 000000000..4b7f7d5c4 --- /dev/null +++ b/scripts/db/schema.py @@ -0,0 +1,76 @@ +""" +Command: db schema. + +Validates that database schema matches model definitions. +""" + +import asyncio + +from typer import Exit + +from scripts.core import create_app +from scripts.ui import ( + create_status, + print_error, + print_section, + print_success, + rich_print, +) +from tux.database.service import DatabaseService +from tux.shared.config import CONFIG + +app = create_app() + + +@app.command(name="schema") +def schema() -> None: + """Validate that database schema matches model definitions.""" + print_section("Schema Validation", "blue") + rich_print("[bold blue]Validating database schema against models...[/bold blue]") + + async def _schema_check(): + service = DatabaseService(echo=False) + try: + with create_status("Validating schema against models...") as status: + await service.connect(CONFIG.database_url) + schema_result = await service.validate_schema() + status.update("[bold green]Validation complete![/bold green]") + + if schema_result["status"] == "valid": + rich_print("[green]Database schema validation passed![/green]") + rich_print( + "[green]All tables and columns match model definitions.[/green]", + ) + else: + error_msg = schema_result.get( + "error", + "Unknown schema validation error", + ) + rich_print("[red]Database schema validation failed![/red]") + rich_print(f"[red]Error: {error_msg}[/red]\n") + rich_print("[yellow]Suggested fixes:[/yellow]") + rich_print(" • Run 'uv run db reset' to reset and reapply migrations") + rich_print( + " • Run 'uv run db nuke --force' for complete database reset", + ) + rich_print( + " • Check that your models match the latest migration files", + ) + + raise Exit(1) # noqa: TRY301 + + print_success("Schema validation completed") + + except Exception as e: + if not isinstance(e, Exit): + print_error(f"Failed to validate database schema: {e}") + raise Exit(1) from e + raise + finally: + await service.disconnect() + + asyncio.run(_schema_check()) + + +if __name__ == "__main__": + app() diff --git a/scripts/db/show.py b/scripts/db/show.py new file mode 100644 index 000000000..9ef34bbe3 --- /dev/null +++ b/scripts/db/show.py @@ -0,0 +1,44 @@ +""" +Command: db show. + +Shows details of a specific migration. +""" + +from subprocess import CalledProcessError +from typing import Annotated + +from typer import Argument, Exit + +from scripts.core import create_app +from scripts.proc import run_command +from scripts.ui import print_error, print_section, print_success, rich_print + +app = create_app() + + +@app.command(name="show") +def show( + revision: Annotated[ + str, + Argument( + help="Migration revision ID to show (e.g., 'head', 'base', or specific ID)", + ), + ], +) -> None: + """Show details of a specific migration.""" + print_section("Show Migration", "blue") + rich_print(f"[bold blue]Showing migration: {revision}[/bold blue]") + + try: + run_command(["uv", "run", "alembic", "show", revision]) + print_success(f"Migration details displayed for: {revision}") + except CalledProcessError as e: + print_error(f"Failed to show migration '{revision}': {e}") + raise Exit(1) from e + except Exception as e: + print_error(f"An unexpected error occurred: {e}") + raise Exit(1) from e + + +if __name__ == "__main__": + app() diff --git a/scripts/db/status.py b/scripts/db/status.py new file mode 100644 index 000000000..16a47e018 --- /dev/null +++ b/scripts/db/status.py @@ -0,0 +1,36 @@ +""" +Command: db status. + +Shows current migration status. +""" + +from typer import Exit + +from scripts.core import create_app +from scripts.proc import run_command +from scripts.ui import print_error, print_section, print_success, rich_print + +app = create_app() + + +@app.command(name="status") +def status() -> None: + """Show current migration status and pending changes.""" + print_section("Migration Status", "blue") + rich_print("[bold blue]Checking migration status...[/bold blue]") + + try: + rich_print("[cyan]Current revision:[/cyan]") + run_command(["uv", "run", "alembic", "current"]) + + rich_print("[cyan]Available heads:[/cyan]") + run_command(["uv", "run", "alembic", "heads"]) + + print_success("Status check complete") + except Exception as e: + print_error("Failed to get migration status") + raise Exit(1) from e + + +if __name__ == "__main__": + app() diff --git a/scripts/db/tables.py b/scripts/db/tables.py new file mode 100644 index 000000000..d3f95e1a2 --- /dev/null +++ b/scripts/db/tables.py @@ -0,0 +1,87 @@ +""" +Command: db tables. + +Lists all database tables and their structure. +""" + +import asyncio +from typing import Any + +from sqlalchemy import text +from typer import Exit + +from scripts.core import create_app +from scripts.ui import ( + create_progress_bar, + print_error, + print_info, + print_section, + print_success, + rich_print, +) +from tux.database.service import DatabaseService +from tux.shared.config import CONFIG + +app = create_app() + + +@app.command(name="tables") +def tables() -> None: + """List all database tables and their structure.""" + print_section("Database Tables", "blue") + rich_print("[bold blue]Listing database tables...[/bold blue]") + + async def _list_tables(): + service = DatabaseService(echo=False) + try: + await service.connect(CONFIG.database_url) + + async def _get_tables(session: Any) -> list[tuple[str, int]]: + result = await session.execute( + text(""" + SELECT + table_name, + (SELECT COUNT(*) FROM information_schema.columns c + WHERE c.table_name = t.table_name + AND c.table_schema = t.table_schema) as column_count + FROM information_schema.tables t + WHERE table_schema = 'public' + AND table_type = 'BASE TABLE' + AND table_name != 'alembic_version' + ORDER BY table_name + """), + ) + return result.fetchall() + + with create_progress_bar(total=None) as progress: + progress.add_task("Fetching tables...", total=None) + tables_data = await service.execute_query(_get_tables, "get_tables") + + if not tables_data: + print_info("No tables found in database") + return + + rich_print(f"[green]Found {len(tables_data)} tables:[/green]\n") + max_name_len = ( + max(len(name) for name, _ in tables_data) if tables_data else 0 + ) + width = max(max_name_len, 10) # minimum 10 chars + + for table_name, column_count in tables_data: + rich_print( + f" [cyan]{table_name:{width}}[/cyan] {column_count:3} columns", + ) + + print_success("Database tables listed") + + except Exception as e: + print_error(f"Failed to list database tables: {e}") + raise Exit(1) from e + finally: + await service.disconnect() + + asyncio.run(_list_tables()) + + +if __name__ == "__main__": + app() diff --git a/scripts/db/version.py b/scripts/db/version.py new file mode 100644 index 000000000..6bd47a91c --- /dev/null +++ b/scripts/db/version.py @@ -0,0 +1,48 @@ +""" +Command: db version. + +Shows version information for database components. +""" + +import sys +from subprocess import CalledProcessError + +from scripts.core import create_app +from scripts.proc import run_command +from scripts.ui import print_error, print_section, print_success, rich_print + +app = create_app() + + +@app.command(name="version") +def version() -> None: + """Show version information for database components.""" + print_section("Version Information", "blue") + rich_print("[bold blue]Showing database version information...[/bold blue]") + + try: + rich_print("[cyan]Current migration:[/cyan]") + run_command(["uv", "run", "alembic", "current"]) + + rich_print("[cyan]Database driver:[/cyan]") + run_command( + [ + "uv", + "run", + "python", + "-c", + "import psycopg; print(f'psycopg version: {psycopg.__version__}')", + ], + ) + + print_success("Version information displayed") + except CalledProcessError: + print_error("Failed to get version information") + sys.exit(1) + except Exception: + print_error("An unexpected error occurred") + sys.exit(1) + + +if __name__ == "__main__": + app() diff --git a/scripts/dev.py b/scripts/dev.py deleted file mode 100644 index e21b39b35..000000000 --- a/scripts/dev.py +++ /dev/null @@ -1,466 +0,0 @@ -#!/usr/bin/env python3 -""" -Development CLI Script. - -Development tools and workflows. -""" - -import shutil -import subprocess -import sys -from collections.abc import Callable -from pathlib import Path -from typing import Annotated - -from typer import Option # type: ignore[attr-defined] - -# Add current directory to path for scripts imports -scripts_path = Path(__file__).parent -sys.path.insert(0, str(scripts_path)) - -# Add src to path -src_path = Path(__file__).parent.parent / "src" -sys.path.insert(0, str(src_path)) - -from scripts.base import BaseCLI -from scripts.registry import Command - - -class DevCLI(BaseCLI): - """Development tools and workflows. - - Commands for code quality checks, formatting, type checking, - documentation linting, and workflow automation. - """ - - def __init__(self): - """Initialize the DevCLI application. - - Sets up the CLI with development-specific commands and configures - the command registry for development operations. - """ - super().__init__( - name="dev", - description="Development tools", - ) - self._setup_command_registry() - self._setup_commands() - - def _setup_command_registry(self) -> None: - """Set up the command registry with all development commands.""" - # All commands directly registered without groups - all_commands = [ - # Code quality commands - Command("lint", self.lint, "Run linting checks"), - Command("lint-fix", self.lint_fix, "Run linting and apply fixes"), - Command("format", self.format_code, "Format code"), - Command("type-check", self.type_check, "Check types"), - Command( - "lint-docstring", - self.lint_docstring, - "Lint docstrings", - ), - Command( - "docstring-coverage", - self.docstring_coverage, - "Check docstring coverage", - ), - Command("pre-commit", self.pre_commit, "Run pre-commit checks"), - Command("clean", self.clean, "Clean temporary files and cache"), - Command("all", self.run_all_checks, "Run all development checks"), - ] - - for cmd in all_commands: - self._command_registry.register_command(cmd) - - def _setup_commands(self) -> None: - """Set up all development CLI commands using the command registry.""" - # Register all commands directly to the main app - for command in self._command_registry.get_commands().values(): - self.add_command( - command.func, - name=command.name, - help_text=command.help_text, - ) - - def _print_output(self, output: str, is_error: bool = False) -> None: - # sourcery skip: hoist-similar-statement-from-if, hoist-statement-from-if - """Print tool output with proper formatting for single/multi-line content.""" - if "\n" in output: - # Multi-line output: start on new line - cleaned_output = output.rstrip("\n") - self.console.print() # Start on new line - if is_error: - self.console.print(f"[red]{cleaned_output}[/red]") - else: - self.console.print(cleaned_output) - else: - # Single-line output: strip trailing newlines for clean inline display - cleaned_output = output.rstrip("\n") - if is_error: - self.console.print(f"[red]{cleaned_output}[/red]") - else: - self.console.print(cleaned_output) - - def _run_tool_command( - self, - command: list[str], - success_message: str, - print_stderr_on_success: bool = False, - ) -> bool: - """Run a tool command and return success status. - - Returns - ------- - bool - True if command succeeded, False otherwise. - """ - try: - result = subprocess.run(command, check=True, capture_output=True, text=True) - if result.stdout: - self._print_output(result.stdout) - if print_stderr_on_success and result.stderr: - self._print_output(result.stderr) - except subprocess.CalledProcessError as e: - if e.stdout: - self._print_output(e.stdout) - if e.stderr: - self._print_output(e.stderr, is_error=True) - return False - except FileNotFoundError: - self.rich.print_error(f"❌ Command not found: {command[0]}") - return False - else: - self.rich.print_success(success_message) - return True - - # ============================================================================ - # DEVELOPMENT COMMANDS - # ============================================================================ - - def lint( - self, - fix: Annotated[ - bool, - Option("--fix", help="Automatically apply fixes"), - ] = False, - ) -> None: - """Run linting checks with Ruff to ensure code quality.""" - self.rich.print_section("Running Linting", "blue") - self.rich.print_info("Checking code quality with Ruff...") - - cmd = ["uv", "run", "ruff", "check"] - if fix: - cmd.append("--fix") - cmd.append(".") - - success = self._run_tool_command( - cmd, - "Linting completed successfully", - ) - if not success: - self.rich.print_error("Linting did not pass - see issues above") - sys.exit(1) - - def lint_fix(self) -> None: - """Run linting checks with Ruff and automatically apply fixes.""" - self.rich.print_section("Running Linting with Fixes", "blue") - success = self._run_tool_command( - ["uv", "run", "ruff", "check", "--fix", "."], - "Linting with fixes completed successfully", - ) - if not success: - self.rich.print_error( - "Linting with fixes did not complete - see issues above", - ) - sys.exit(1) - - def format_code(self) -> None: - """Format code using Ruff's formatter for consistent styling.""" - self.rich.print_section("Formatting Code", "blue") - success = self._run_tool_command( - ["uv", "run", "ruff", "format", "."], - "Code formatting completed successfully", - ) - if not success: - self.rich.print_error("Code formatting did not pass - see issues above") - sys.exit(1) - - def type_check(self) -> None: - """Perform static type checking using basedpyright.""" - self.rich.print_section("Type Checking", "blue") - success = self._run_tool_command( - ["uv", "run", "basedpyright"], - "Type checking completed successfully", - ) - if not success: - self.rich.print_error("Type checking did not pass - see issues above") - sys.exit(1) - - def lint_docstring(self) -> None: - """Lint docstrings for proper formatting and completeness.""" - self.rich.print_section("Linting Docstrings", "blue") - success = self._run_tool_command( - ["uv", "run", "pydoclint", "--config=pyproject.toml", "."], - "Docstring linting completed successfully", - print_stderr_on_success=True, - ) - if not success: - self.rich.print_error("Docstring linting did not pass - see issues above") - sys.exit(1) - - def docstring_coverage(self) -> None: - """Check docstring coverage across the codebase.""" - self.rich.print_section("Docstring Coverage", "blue") - self._run_tool_command( - ["uv", "run", "docstr-coverage", "--verbose", "2", "."], - "Docstring coverage report generated", - print_stderr_on_success=True, - ) - - def pre_commit(self) -> None: - """Run pre-commit hooks to ensure code quality before commits.""" - self.rich.print_section("Running Pre-commit Checks", "blue") - success = self._run_tool_command( - ["uv", "run", "pre-commit", "run", "--all-files"], - "Pre-commit checks completed successfully", - ) - if not success: - self.rich.print_error("Pre-commit checks did not pass - see issues above") - sys.exit(1) - - def _remove_item(self, item: Path, project_root: Path) -> tuple[int, int]: - """Remove a file or directory and return (count, size). - - Parameters - ---------- - item : Path - File or directory to remove. - project_root : Path - Project root directory for relative path display. - - Returns - ------- - tuple[int, int] - Tuple of (items_removed, bytes_freed). - """ - try: - if item.is_file(): - size = item.stat().st_size - item.unlink() - self.console.print( - f"[dim]Removed: {item.relative_to(project_root)}[/dim]", - ) - return (1, size) - if item.is_dir(): - dir_size = sum(f.stat().st_size for f in item.rglob("*") if f.is_file()) - shutil.rmtree(item) - self.console.print( - f"[dim]Removed: {item.relative_to(project_root)}/[/dim]", - ) - return (1, dir_size) - except OSError as e: - self.rich.print_warning( - f"Could not remove {item.relative_to(project_root)}: {e}", - ) - return (0, 0) - - def _clean_empty_directories(self, project_root: Path) -> int: - """Clean empty directories in tests/unit and scripts. - - Parameters - ---------- - project_root : Path - Project root directory. - - Returns - ------- - int - Number of directories removed. - """ - cleaned = 0 - for dir_path in [project_root / "tests" / "unit", project_root / "scripts"]: - if not dir_path.exists(): - continue - - for subdir in dir_path.iterdir(): - if not subdir.is_dir() or subdir.name in ( - "__pycache__", - ".pytest_cache", - ): - continue - - contents = list(subdir.iterdir()) - if not contents or all( - item.name == "__pycache__" and item.is_dir() for item in contents - ): - try: - shutil.rmtree(subdir) - cleaned += 1 - self.console.print( - f"[dim]Removed empty directory: {subdir.relative_to(project_root)}/[/dim]", - ) - except OSError as e: - self.rich.print_warning( - f"Could not remove {subdir.relative_to(project_root)}: {e}", - ) - return cleaned - - def clean(self) -> None: - """Clean temporary files, cache directories, and build artifacts.""" - self.rich.print_section("Cleaning Project", "blue") - - project_root = Path(__file__).parent.parent - cleaned_count = 0 - total_size = 0 - - # Patterns to clean - patterns_to_clean = [ - # Python cache - ("**/__pycache__", "Python cache directories"), - ("**/*.pyc", "Python compiled files"), - ("**/*.pyo", "Python optimized files"), - ("**/*$py.class", "Python class files"), - # Pytest cache - (".pytest_cache", "Pytest cache directory"), - # Coverage files - (".coverage", "Coverage data file"), - (".coverage.*", "Coverage data files"), - # Ruff cache - (".ruff_cache", "Ruff cache directory"), - # Mypy cache - (".mypy_cache", "Mypy cache directory"), - # Build artifacts - ("build", "Build directory"), - ("dist", "Distribution directory"), - ("*.egg-info", "Egg info directories"), - # Test artifacts - ("htmlcov", "HTML coverage reports"), - ("coverage.xml", "Coverage XML report"), - ("coverage.json", "Coverage JSON report"), - ("lcov.info", "LCOV coverage report"), - ("junit.xml", "JUnit XML report"), - (".hypothesis", "Hypothesis cache"), - ] - - # Directories to never clean - protected_dirs = {".venv", "venv"} - - for pattern, description in patterns_to_clean: - matches = list(project_root.glob(pattern)) - if not matches: - continue - - # Filter out protected directories - matches = [ - m - for m in matches - if all(protected not in str(m) for protected in protected_dirs) - ] - - if not matches: - continue - - self.rich.print_info(f"Cleaning {description}...") - - for item in matches: - count, size = self._remove_item(item, project_root) - cleaned_count += count - total_size += size - - # Clean empty directories - cleaned_count += self._clean_empty_directories(project_root) - - if cleaned_count > 0: - size_mb = total_size / (1024 * 1024) - self.rich.print_success( - f"Cleaned {cleaned_count} item(s), freed {size_mb:.2f} MB", - ) - else: - self.rich.print_info("Nothing to clean - project is already clean!") - - def run_all_checks( - self, - fix: Annotated[ - bool, - Option("--fix", help="Automatically fix issues where possible"), - ] = False, - ) -> None: - """Run all development checks including linting, type checking, and documentation.""" - self.rich.print_section("Running All Development Checks", "blue") - checks: list[tuple[str, Callable[[], None]]] = [ - ("Linting", self.lint_fix if fix else self.lint), - ("Code Formatting", self.format_code), - ("Type Checking", self.type_check), - ("Docstring Linting", self.lint_docstring), - ("Pre-commit Checks", self.pre_commit), - ] - - results: list[tuple[str, bool]] = [] - - # Run checks with progress bar - with self.rich.create_progress_bar( - "Running Development Checks", - len(checks), - ) as progress: - task = progress.add_task("Running Development Checks", total=len(checks)) - - for check_name, check_func in checks: - progress.update(task, description=f"Running {check_name}...") - progress.refresh() # Force refresh to show the update - - try: - check_func() - results.append((check_name, True)) - except Exception: - results.append((check_name, False)) - # Don't exit early, continue with other checks - - progress.advance(task) - progress.refresh() # Force refresh after advance - - # Add newline after progress bar completes - self.console.print() - - # Summary using Rich table - self.rich.print_section("Development Checks Summary", "blue") - - passed = sum(bool(success) for _, success in results) - total = len(results) - - # Create Rich table for results - table_data: list[tuple[str, str, str]] = [ - ( - check_name, - "PASSED" if success else "FAILED", - "Completed" if success else "Failed", - ) - for check_name, success in results - ] - - self.rich.print_rich_table( - "", - [("Check", "cyan"), ("Status", "green"), ("Details", "white")], - table_data, - ) - - self.console.print() - if passed == total: - self.rich.print_success(f"All {total} checks passed!") - else: - self.rich.print_error(f"{passed}/{total} checks passed") - sys.exit(1) - - -# Create the CLI app instance -app = DevCLI().app - - -def main() -> None: - """Entry point for the development CLI script.""" - cli = DevCLI() - cli.run() - - -if __name__ == "__main__": - main() diff --git a/scripts/dev/__init__.py b/scripts/dev/__init__.py new file mode 100644 index 000000000..78a81c051 --- /dev/null +++ b/scripts/dev/__init__.py @@ -0,0 +1,39 @@ +""" +Development Command Group. + +Aggregates all code quality and development tools. +""" + +from scripts.core import create_app +from scripts.dev import ( + all as all_checks, +) +from scripts.dev import ( + clean, + docstring_coverage, + lint, + lint_docstring, + lint_fix, + pre_commit, + type_check, +) +from scripts.dev import ( + format as format_code, +) + +app = create_app(name="dev", help_text="Development tools") + +app.add_typer(lint.app) +app.add_typer(lint_fix.app) +app.add_typer(format_code.app) +app.add_typer(type_check.app) +app.add_typer(lint_docstring.app) +app.add_typer(docstring_coverage.app) +app.add_typer(pre_commit.app) +app.add_typer(clean.app) +app.add_typer(all_checks.app) + + +def main() -> None: + """Entry point for the dev command group.""" + app() diff --git a/scripts/dev/all.py b/scripts/dev/all.py new file mode 100644 index 000000000..ad5d24a06 --- /dev/null +++ b/scripts/dev/all.py @@ -0,0 +1,111 @@ +""" +Command: dev all. + +Runs all development checks including linting, type checking, and documentation. +""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Annotated + +from typer import Exit, Option + +from scripts.core import create_app +from scripts.dev.format import format_code +from scripts.dev.lint import lint +from scripts.dev.lint_docstring import lint_docstring +from scripts.dev.pre_commit import pre_commit +from scripts.dev.type_check import type_check +from scripts.ui import ( + create_progress_bar, + print_error, + print_section, + print_success, + print_table, + rich_print, +) + +app = create_app() + + +@dataclass +class Check: + """Represents a single development check.""" + + name: str + func: Callable[[], None] + + +def run_check(check: Check) -> bool: + """Run a single check, normalizing SystemExit/Exception to a bool result.""" + try: + check.func() + except SystemExit as e: + return e.code == 0 if isinstance(e.code, int) else False + except Exception as e: + print_error(f"Unexpected error in {check.name}: {e}") + return False + else: + return True + + +@app.command(name="all") +def run_all_checks( + fix: Annotated[ + bool, + Option("--fix", help="Automatically fix issues where possible"), + ] = False, +) -> None: + """Run all development checks including linting, type checking, and documentation.""" + print_section("Running All Development Checks", "blue") + + checks: list[Check] = [ + Check("Linting", lambda: lint(fix=fix)), + Check("Code Formatting", format_code), + Check("Type Checking", type_check), + Check("Docstring Linting", lint_docstring), + Check("Pre-commit Checks", pre_commit), + ] + + results: list[tuple[str, bool]] = [] + + with create_progress_bar( + total=len(checks), + ) as progress: + task = progress.add_task("Running Development Checks", total=len(checks)) + + for check in checks: + progress.update(task, description=f"Running {check.name}...") + success = run_check(check) + results.append((check.name, success)) + progress.advance(task) + + # Summary + print_section("Development Checks Summary", "blue") + + table_data: list[tuple[str, str]] = [ + ( + check_name, + "[green]✓ PASSED[/green]" if success else "[red]✗ FAILED[/red]", + ) + for check_name, success in results + ] + + print_table( + "", + [("Check", "cyan"), ("Status", "white")], + table_data, + ) + + passed_count = sum(1 for _, success in results if success) + total_count = len(checks) + + if passed_count == total_count: + print_success(f"\nAll {total_count} checks passed!") + else: + rich_print(f"\n[bold red]{passed_count}/{total_count} checks passed[/bold red]") + raise Exit(1) + + +if __name__ == "__main__": + app() diff --git a/scripts/dev/clean.py b/scripts/dev/clean.py new file mode 100644 index 000000000..f8f2b3f84 --- /dev/null +++ b/scripts/dev/clean.py @@ -0,0 +1,141 @@ +""" +Command: dev clean. + +Cleans temporary files, cache directories, and build artifacts. +""" + +import shutil +from pathlib import Path + +from scripts.core import ROOT, create_app +from scripts.ui import ( + create_progress_bar, + print_info, + print_section, + print_success, + print_warning, + rich_print, +) + +app = create_app() + + +def _remove_item(item: Path, project_root: Path) -> tuple[int, int]: + """Remove a file or directory and return (count, size).""" + try: + if item.is_file(): + size = item.stat().st_size + item.unlink() + rich_print(f"[dim]Removed: {item.relative_to(project_root)}[/dim]") + return (1, size) + if item.is_dir(): + dir_size = sum(f.stat().st_size for f in item.rglob("*") if f.is_file()) + shutil.rmtree(item) + rich_print(f"[dim]Removed: {item.relative_to(project_root)}/[/dim]") + return (1, dir_size) + except OSError as e: + print_warning(f"Could not remove {item.relative_to(project_root)}: {e}") + return (0, 0) + + +def _clean_empty_directories(project_root: Path) -> int: + """Clean empty directories in tests/unit and scripts.""" + cleaned = 0 + for dir_path in [project_root / "tests" / "unit", project_root / "scripts"]: + if not dir_path.exists(): + continue + + for subdir in dir_path.iterdir(): + if not subdir.is_dir() or subdir.name in ("__pycache__", ".pytest_cache"): + continue + + contents = list(subdir.iterdir()) + if not contents or all( + item.name == "__pycache__" and item.is_dir() for item in contents + ): + try: + shutil.rmtree(subdir) + cleaned += 1 + rich_print( + f"[dim]Removed empty directory: {subdir.relative_to(project_root)}/[/dim]", + ) + except OSError as e: + print_warning( + f"Could not remove {subdir.relative_to(project_root)}: {e}", + ) + return cleaned + + +@app.command(name="clean") +def clean() -> None: + """Clean temporary files, cache directories, and build artifacts.""" + print_section("Cleaning Project", "blue") + + project_root = ROOT + cleaned_count = 0 + total_size = 0 + + patterns_to_clean = [ + ("**/__pycache__", "Python cache directories"), + ("**/*.pyc", "Python compiled files"), + ("**/*.pyo", "Python optimized files"), + ("**/*$py.class", "Python class files"), + (".pytest_cache", "Pytest cache directory"), + (".coverage", "Coverage data file"), + (".coverage.*", "Coverage data files"), + (".ruff_cache", "Ruff cache directory"), + (".mypy_cache", "Mypy cache directory"), + ("build", "Build directory"), + ("dist", "Distribution directory"), + ("*.egg-info", "Egg info directories"), + ("htmlcov", "HTML coverage reports"), + ("coverage.xml", "Coverage XML report"), + ("coverage.json", "Coverage JSON report"), + ("lcov.info", "LCOV coverage report"), + ("junit.xml", "JUnit XML report"), + (".hypothesis", "Hypothesis cache"), + ] + + protected_dirs = {".venv", "venv"} + + with create_progress_bar( + total=len(patterns_to_clean), + ) as progress: + task = progress.add_task("Cleaning Project...", total=len(patterns_to_clean)) + + for pattern, description in patterns_to_clean: + progress.update(task, description=f"Cleaning {description}...") + matches = list(project_root.glob(pattern)) + if not matches: + progress.advance(task) + continue + + # Better component-based check for protected dirs + matches = [ + m + for m in matches + if not any(part in protected_dirs for part in m.parts) + ] + + if not matches: + progress.advance(task) + continue + + for item in matches: + count, size = _remove_item(item, project_root) + cleaned_count += count + total_size += size + + progress.advance(task) + + cleaned_count += _clean_empty_directories(project_root) + + if cleaned_count > 0: + size_mb = total_size / (1024 * 1024) + print_success(f"Cleaned {cleaned_count} item(s), freed {size_mb:.2f} MB") + else: + print_info("Nothing to clean - project is already clean!") + + +if __name__ == "__main__": + app() diff --git a/scripts/dev/docstring_coverage.py b/scripts/dev/docstring_coverage.py new file mode 100644 index 000000000..a80b20a30 --- /dev/null +++ b/scripts/dev/docstring_coverage.py @@ -0,0 +1,38 @@ +""" +Command: dev docstring-coverage. + +Checks docstring coverage across the codebase. +""" + +from subprocess import CalledProcessError + +from scripts.core import create_app +from scripts.proc import run_command +from scripts.ui import print_error, print_section, print_success + +app = create_app() + + +@app.command(name="docstring-coverage") +def docstring_coverage() -> None: + """Check docstring coverage across the codebase.""" + print_section("Docstring Coverage", "blue") + + try: + run_command(["uv", "run", "docstr-coverage", "--verbose", "2", "."]) + print_success("Docstring coverage report generated") + except CalledProcessError as e: + # docstr-coverage returns non-zero if coverage is below threshold + # Exit codes 1-99 typically indicate coverage issues (show report) + # Exit codes 100+ indicate actual errors + if e.returncode < 100: + print_success( + "Docstring coverage report generated (coverage below threshold)", + ) + else: + print_error(f"Error running docstring coverage: {e}") + raise + + +if __name__ == "__main__": + app() diff --git a/scripts/dev/format.py b/scripts/dev/format.py new file mode 100644 index 000000000..eec6046fd --- /dev/null +++ b/scripts/dev/format.py @@ -0,0 +1,31 @@ +""" +Command: dev format. + +Formats code using Ruff's formatter. +""" + +import sys +from subprocess import CalledProcessError + +from scripts.core import create_app +from scripts.proc import run_command +from scripts.ui import print_error, print_section, print_success + +app = create_app() + + +@app.command(name="format") +def format_code() -> None: + """Format code using Ruff's formatter for consistent styling.""" + print_section("Formatting Code", "blue") + + try: + run_command(["uv", "run", "ruff", "format", "."]) + print_success("Code formatting completed successfully") + except CalledProcessError as e: + print_error(f"Code formatting failed: {e}") + sys.exit(1) + + +if __name__ == "__main__": + app() diff --git a/scripts/dev/lint.py b/scripts/dev/lint.py new file mode 100644 index 000000000..c16f2af0c --- /dev/null +++ b/scripts/dev/lint.py @@ -0,0 +1,45 @@ +""" +Command: dev lint. + +Runs linting checks with Ruff. +""" + +import sys +from subprocess import CalledProcessError +from typing import Annotated + +from typer import Option + +from scripts.core import create_app +from scripts.proc import run_command +from scripts.ui import print_error, print_info, print_section, print_success + +app = create_app() + + +@app.command(name="lint") +def lint( + fix: Annotated[bool, Option("--fix", help="Automatically apply fixes")] = False, +) -> None: + """Run linting checks with Ruff to ensure code quality.""" + print_section("Running Linting", "blue") + print_info("Checking code quality with Ruff...") + + cmd = ["uv", "run", "ruff", "check"] + if fix: + cmd.append("--fix") + cmd.append(".") + + try: + run_command(cmd) + print_success("Linting completed successfully") + except CalledProcessError as e: + print_error(f"Linting did not pass: {e}") + sys.exit(1) + except Exception as e: + print_error(f"An unexpected error occurred during linting: {e}") + sys.exit(1) + + +if __name__ == "__main__": + app() diff --git a/scripts/dev/lint_docstring.py b/scripts/dev/lint_docstring.py new file mode 100644 index 000000000..7b412deba --- /dev/null +++ b/scripts/dev/lint_docstring.py @@ -0,0 +1,31 @@ +""" +Command: dev lint-docstring. + +Lints docstrings for proper formatting. +""" + +import sys +from subprocess import CalledProcessError + +from scripts.core import create_app +from scripts.proc import run_command +from scripts.ui import print_error, print_section, print_success + +app = create_app() + + +@app.command(name="lint-docstring") +def lint_docstring() -> None: + """Lint docstrings for proper formatting and completeness.""" + print_section("Linting Docstrings", "blue") + + try: + run_command(["uv", "run", "pydoclint", "--config=pyproject.toml", "."]) + print_success("Docstring linting completed successfully") + except CalledProcessError as e: + print_error(f"Docstring linting failed: {e}") + sys.exit(1) + + +if __name__ == "__main__": + app() diff --git a/scripts/dev/lint_fix.py b/scripts/dev/lint_fix.py new file mode 100644 index 000000000..7fdbac319 --- /dev/null +++ b/scripts/dev/lint_fix.py @@ -0,0 +1,30 @@ +""" +Command: dev lint-fix. + +Runs linting checks with Ruff and automatically applies fixes. +""" + +import sys + +from scripts.core import create_app +from scripts.proc import run_command +from scripts.ui import print_error, print_section, print_success + +app = create_app() + + +@app.command(name="lint-fix") +def lint_fix() -> None: + """Run linting checks with Ruff and automatically apply fixes.""" + print_section("Running Linting with Fixes", "blue") + + try: + run_command(["uv", "run", "ruff", "check", "--fix", "."]) + print_success("Linting with fixes completed successfully") + except Exception: + print_error("Linting with fixes did not complete - see issues above") + sys.exit(1) + + +if __name__ == "__main__": + app() diff --git a/scripts/dev/pre_commit.py b/scripts/dev/pre_commit.py new file mode 100644 index 000000000..5d39275f9 --- /dev/null +++ b/scripts/dev/pre_commit.py @@ -0,0 +1,34 @@ +""" +Command: dev pre-commit. + +Runs pre-commit hooks. +""" + +import sys +from subprocess import CalledProcessError + +from scripts.core import create_app +from scripts.proc import run_command +from scripts.ui import print_error, print_section, print_success + +app = create_app() + + +@app.command(name="pre-commit") +def pre_commit() -> None: + """Run pre-commit hooks to ensure code quality before commits.""" + print_section("Running Pre-commit Checks", "blue") + + try: + run_command(["uv", "run", "pre-commit", "run", "--all-files"]) + print_success("Pre-commit checks completed successfully") + except CalledProcessError as e: + print_error(f"Pre-commit checks did not pass: {e}") + sys.exit(1) + except Exception as e: + print_error(f"An unexpected error occurred during pre-commit checks: {e}") + sys.exit(1) + + +if __name__ == "__main__": + app() diff --git a/scripts/dev/type_check.py b/scripts/dev/type_check.py new file mode 100644 index 000000000..2908282f5 --- /dev/null +++ b/scripts/dev/type_check.py @@ -0,0 +1,31 @@ +""" +Command: dev type-check. + +Performs static type checking using basedpyright. +""" + +import sys +from subprocess import CalledProcessError + +from scripts.core import create_app +from scripts.proc import run_command +from scripts.ui import print_error, print_section, print_success + +app = create_app() + + +@app.command(name="type-check") +def type_check() -> None: + """Perform static type checking using basedpyright.""" + print_section("Type Checking", "blue") + + try: + run_command(["uv", "run", "basedpyright"]) + print_success("Type checking completed successfully") + except CalledProcessError as e: + print_error(f"Type checking failed: {e}") + sys.exit(1) + + +if __name__ == "__main__": + app() diff --git a/scripts/docs.py b/scripts/docs.py deleted file mode 100644 index f553843ff..000000000 --- a/scripts/docs.py +++ /dev/null @@ -1,532 +0,0 @@ -#!/usr/bin/env python3 -""" -Documentation CLI Script. - -Documentation operations management. -""" - -import os -import shutil -import subprocess -import sys -from pathlib import Path -from typing import Annotated - -from typer import Option # type: ignore[attr-defined] - -# Add src to path -src_path = Path(__file__).parent.parent / "src" -sys.path.insert(0, str(src_path)) - -from scripts.base import BaseCLI -from scripts.registry import Command - - -class DocsCLI(BaseCLI): - """Documentation operations management. - - Commands for managing Zensical documentation, including serving, - building, deploying, and maintenance operations. - """ - - def __init__(self): - """Initialize the DocsCLI application. - - Sets up the CLI with documentation-specific commands and configures - the command registry for Zensical operations. - """ - super().__init__( - name="docs", - description="Documentation operations", - ) - self._setup_command_registry() - self._setup_commands() - - def _setup_command_registry(self) -> None: - """Set up the command registry with all documentation commands.""" - # All commands directly registered without groups - all_commands = [ - # Core Zensical commands - Command( - "serve", - self.serve, - "Serve documentation locally", - ), - Command("build", self.build, "Build documentation site"), - Command("lint", self.lint, "Lint documentation files"), - # Cloudflare Workers deployment commands - Command( - "wrangler-dev", - self.wrangler_dev, - "Start local Wrangler development server", - ), - Command( - "wrangler-deploy", - self.wrangler_deploy, - "Deploy documentation to Cloudflare Workers", - ), - Command( - "wrangler-deployments", - self.wrangler_deployments, - "List deployment history", - ), - Command( - "wrangler-versions", - self.wrangler_versions, - "List and manage versions", - ), - Command( - "wrangler-tail", - self.wrangler_tail, - "View real-time logs from deployed docs", - ), - Command( - "wrangler-rollback", - self.wrangler_rollback, - "Rollback to a previous deployment", - ), - ] - - for cmd in all_commands: - self._command_registry.register_command(cmd) - - def _setup_commands(self) -> None: - """Set up all documentation CLI commands using the command registry.""" - # Register all commands directly to the main app - for command in self._command_registry.get_commands().values(): - self.add_command( - command.func, - name=command.name, - help_text=command.help_text, - ) - - def _find_zensical_config(self) -> str | None: - """Find the zensical.toml configuration file. - - Returns - ------- - str | None - Path to zensical.toml if found, None otherwise. - """ - current_dir = Path.cwd() - - # Check if we're in the root repo - if (current_dir / "zensical.toml").exists(): - return "zensical.toml" - - self.rich.print_error( - "Can't find zensical.toml file. Please run from the project root.", - ) - return None - - def _run_command(self, command: list[str]) -> None: - """Run a command and return success status. - - Overrides base implementation to print command info before execution. - Environment variables are handled by the base class. - - Raises - ------ - FileNotFoundError - If the command is not found. - CalledProcessError - If the command fails. - """ - try: - self.rich.print_info(f"Running: {' '.join(command)}") - # Call parent implementation which handles env vars - super()._run_command(command) - except subprocess.CalledProcessError as e: - self.rich.print_error(f"Command failed with exit code {e.returncode}") - raise - except FileNotFoundError: - self.rich.print_error(f"Command not found: {command[0]}") - raise - - def _clean_directory(self, path: Path, name: str) -> None: - """Clean a directory if it exists.""" - if path.exists(): - shutil.rmtree(path) - self.rich.print_success(f"{name} cleaned") - else: - self.rich.print_info(f"No {name} found") - - def serve( - self, - dev_addr: Annotated[ - str, - Option( - "--dev-addr", - "-a", - help="IP address and port (default: localhost:8000)", - ), - ] = "localhost:8000", - open_browser: Annotated[ - bool, - Option("--open", "-o", help="Open preview in default browser"), - ] = False, - strict: Annotated[ - bool, - Option("--strict", "-s", help="Strict mode (currently unsupported)"), - ] = False, - ) -> None: - """Serve documentation locally with live reload.""" - self.rich.print_section("Serving Documentation", "blue") - - if not self._find_zensical_config(): - return - - cmd = [ - "uv", - "run", - "zensical", - "serve", - "--dev-addr", - dev_addr, - ] - - if open_browser: - cmd.append("--open") - - if strict: - cmd.append("--strict") - - try: - # Run server command without capturing output (for real-time streaming) - # This allows zensical serve to run interactively and stream output - # Note: --open flag will automatically open browser if provided - self.rich.print_info( - f"Starting documentation server at {dev_addr}", - ) - subprocess.run(cmd, check=True, env=os.environ.copy()) - except subprocess.CalledProcessError: - self.rich.print_error("Failed to start documentation server") - except KeyboardInterrupt: - self.rich.print_info("\nDocumentation server stopped") - - def build( - self, - clean: Annotated[ - bool, - Option("--clean", "-c", help="Clean cache"), - ] = False, - strict: Annotated[ - bool, - Option("--strict", "-s", help="Strict mode (currently unsupported)"), - ] = False, - ) -> None: - """Build documentation site for production.""" - self.rich.print_section("Building Documentation", "blue") - - if not self._find_zensical_config(): - return - - cmd = ["uv", "run", "zensical", "build"] - - if clean: - cmd.append("--clean") - if strict: - cmd.append("--strict") - - try: - # Run build command without capturing output (for real-time streaming) - # This allows zensical build to stream output to the terminal - self.rich.print_info("Building documentation...") - subprocess.run(cmd, check=True, env=os.environ.copy()) - self.rich.print_success("Documentation built successfully") - except subprocess.CalledProcessError: - self.rich.print_error("Failed to build documentation") - except KeyboardInterrupt: - self.rich.print_info("\nBuild interrupted") - - def lint(self) -> None: - """Lint documentation files.""" - self.rich.print_section("Linting Documentation", "blue") - - # Check for common markdown issues - docs_dir = Path("docs/content") - if not docs_dir.exists(): - self.rich.print_error("docs/content directory not found") - return - - issues: list[str] = [] - for md_file in docs_dir.rglob("*.md"): - try: - content = md_file.read_text() - - # Check for common issues - if content.strip() == "": - issues.append(f"Empty file: {md_file}") - elif not content.startswith("#"): - issues.append(f"Missing title: {md_file}") - elif "TODO" in content or "FIXME" in content: - issues.append(f"Contains TODO/FIXME: {md_file}") - - except Exception as e: - issues.append(f"Error reading {md_file}: {e}") - - if issues: - self.rich.print_warning("Documentation linting issues found:") - for issue in issues: - self.rich.print_warning(f" • {issue}") - else: - self.rich.print_success("No documentation linting issues found") - - def wrangler_dev( - self, - port: Annotated[int, Option("--port", "-p", help="Port to serve on")] = 8787, - remote: Annotated[ - bool, - Option("--remote", help="Run on remote Cloudflare infrastructure"), - ] = False, - ) -> None: # sourcery skip: class-extract-method - """Start local Wrangler development server. - - This runs the docs using Cloudflare Workers locally, useful for testing - the production environment before deployment. - """ - self.rich.print_section("Starting Wrangler Dev Server", "blue") - - if not Path("wrangler.toml").exists(): - self.rich.print_error( - "wrangler.toml not found. Please run from the project root.", - ) - return - - # Build docs first - self.rich.print_info("Building documentation...") - self.build(strict=True) - - # Start wrangler dev - cmd = ["wrangler", "dev", f"--port={port}"] - if remote: - cmd.append("--remote") - - self.rich.print_info(f"Starting Wrangler dev server on port {port}...") - - try: - self._run_command(cmd) - self.rich.print_success( - f"Wrangler dev server started at http://localhost:{port}", - ) - except subprocess.CalledProcessError: - self.rich.print_error("Failed to start Wrangler dev server") - except Exception as e: - self.rich.print_error(f"Error: {e}") - - def wrangler_deploy( - self, - env: Annotated[ - str, - Option("--env", "-e", help="Environment to deploy to"), - ] = "production", - dry_run: Annotated[ - bool, - Option("--dry-run", help="Show deployment plan without deploying"), - ] = False, - ) -> None: - """Deploy documentation to Cloudflare Workers. - - Builds the docs and deploys to Cloudflare using the wrangler.toml configuration. - Cloudflare Workers will automatically run tests and include coverage reports. - Use --env to deploy to preview or production environments. - """ - self.rich.print_section("Deploying to Cloudflare Workers", "blue") - - if not Path("wrangler.toml").exists(): - self.rich.print_error( - "wrangler.toml not found. Please run from the project root.", - ) - return - - # Build docs first (without strict to allow warnings) - self.rich.print_info("Building documentation...") - self.build(strict=False) - - # Deploy with wrangler - always specify env to avoid warning - cmd = ["wrangler", "deploy", "--env", env] - if dry_run: - cmd.append("--dry-run") - - self.rich.print_info(f"Deploying to {env} environment...") - - try: - self._run_command(cmd) - self.rich.print_success(f"Documentation deployed successfully to {env}") - except subprocess.CalledProcessError: - self.rich.print_error("Failed to deploy documentation") - except Exception as e: - self.rich.print_error(f"Error: {e}") - - def wrangler_deployments( - self, - limit: Annotated[ - int, - Option("--limit", "-l", help="Maximum number of deployments to show"), - ] = 10, - ) -> None: - """List deployment history for the documentation site. - - Shows recent deployments with their status, version, and timestamp. - """ - self.rich.print_section("Deployment History", "blue") - - if not Path("wrangler.toml").exists(): - self.rich.print_error( - "wrangler.toml not found. Please run from the project root.", - ) - return - - cmd = ["wrangler", "deployments", "list"] - if limit: - cmd.extend(["--limit", str(limit)]) - - try: - self._run_command(cmd) - self.rich.print_success("Deployment history retrieved") - except subprocess.CalledProcessError: - self.rich.print_error("Failed to get deployment history") - except Exception as e: - self.rich.print_error(f"Error: {e}") - - def wrangler_versions( - self, - action: Annotated[ - str, - Option("--action", "-a", help="Action to perform: list, view, or upload"), - ] = "list", - version_id: Annotated[ - str, - Option("--version-id", help="Version ID to view"), - ] = "", - alias: Annotated[ - str, - Option("--alias", help="Preview alias name"), - ] = "", - ) -> None: - """List and manage versions of the documentation. - - Actions: - - list: Show all versions - - view: Show details of a specific version - - upload: Create a new version with optional preview alias - """ - self.rich.print_section("Managing Versions", "blue") - - if not Path("wrangler.toml").exists(): - self.rich.print_error( - "wrangler.toml not found. Please run from the project root.", - ) - return - - cmd = ["wrangler", "versions", action] - - if action == "view" and version_id: - cmd.append(version_id) - elif action == "upload" and alias: - cmd.extend(["--preview-alias", alias]) - - try: - self._run_command(cmd) - self.rich.print_success(f"Version {action} completed") - except subprocess.CalledProcessError: - self.rich.print_error(f"Failed to {action} versions") - except Exception as e: - self.rich.print_error(f"Error: {e}") - - def wrangler_tail( - self, - format_output: Annotated[ - str, - Option("--format", help="Output format: json or pretty"), - ] = "pretty", - status: Annotated[ - str, - Option("--status", help="Filter by status: ok, error, or canceled"), - ] = "", - ) -> None: - """View real-time logs from deployed documentation. - - Tails the logs of your deployed Workers documentation, showing requests and errors. - """ - self.rich.print_section("Tailing Logs", "blue") - - if not Path("wrangler.toml").exists(): - self.rich.print_error( - "wrangler.toml not found. Please run from the project root.", - ) - return - - cmd = ["wrangler", "tail"] - if format_output: - cmd.extend(["--format", format_output]) - if status: - cmd.extend(["--status", status]) - - self.rich.print_info("Starting log tail... (Ctrl+C to stop)") - - try: - self._run_command(cmd) - except subprocess.CalledProcessError: - self.rich.print_error("Failed to tail logs") - except KeyboardInterrupt: - self.rich.print_info("\nLog tail stopped") - except Exception as e: - self.rich.print_error(f"Error: {e}") - - def wrangler_rollback( - self, - version_id: Annotated[ - str, - Option("--version-id", help="Version ID to rollback to"), - ] = "", - message: Annotated[ - str, - Option("--message", "-m", help="Rollback message"), - ] = "", - ) -> None: - """Rollback to a previous deployment. - - Use wrangler-deployments to find the version ID you want to rollback to. - """ - self.rich.print_section("Rolling Back Deployment", "blue") - - if not Path("wrangler.toml").exists(): - self.rich.print_error( - "wrangler.toml not found. Please run from the project root.", - ) - return - - if not version_id: - self.rich.print_error( - "Version ID is required. Use wrangler-deployments to find version IDs.", - ) - return - - cmd = ["wrangler", "rollback", version_id] - if message: - cmd.extend(["--message", message]) - - self.rich.print_warning(f"Rolling back to version: {version_id}") - - try: - self._run_command(cmd) - self.rich.print_success(f"Successfully rolled back to version {version_id}") - except subprocess.CalledProcessError: - self.rich.print_error("Failed to rollback") - except Exception as e: - self.rich.print_error(f"Error: {e}") - - -# Create the CLI app instance -app = DocsCLI().app - - -def main() -> None: - """Entry point for the Documentation CLI script.""" - cli = DocsCLI() - cli.run() - - -if __name__ == "__main__": - main() diff --git a/scripts/docs/__init__.py b/scripts/docs/__init__.py new file mode 100644 index 000000000..a682bd3c1 --- /dev/null +++ b/scripts/docs/__init__.py @@ -0,0 +1,35 @@ +""" +Documentation Command Group. + +Aggregates all Zensical and Wrangler documentation operations. +""" + +from scripts.core import create_app +from scripts.docs import ( + build, + lint, + serve, + wrangler_deploy, + wrangler_deployments, + wrangler_dev, + wrangler_rollback, + wrangler_tail, + wrangler_versions, +) + +app = create_app(name="docs", help_text="Documentation operations") + +app.add_typer(serve.app) +app.add_typer(build.app) +app.add_typer(lint.app) +app.add_typer(wrangler_dev.app) +app.add_typer(wrangler_deploy.app) +app.add_typer(wrangler_deployments.app) +app.add_typer(wrangler_versions.app) +app.add_typer(wrangler_tail.app) +app.add_typer(wrangler_rollback.app) + + +def main() -> None: + """Entry point for the docs command group.""" + app() diff --git a/scripts/docs/build.py b/scripts/docs/build.py new file mode 100644 index 000000000..09f60b488 --- /dev/null +++ b/scripts/docs/build.py @@ -0,0 +1,52 @@ +""" +Command: docs build. + +Builds documentation site for production. +""" + +from typing import Annotated + +from typer import Exit, Option + +from scripts.core import create_app +from scripts.docs.utils import has_zensical_config +from scripts.proc import run_command +from scripts.ui import print_error, print_info, print_section, print_success + +app = create_app() + + +@app.command(name="build") +def build( + clean: Annotated[ + bool, + Option("--clean", "-c", help="Clean cache"), + ] = False, + strict: Annotated[ + bool, + Option("--strict", "-s", help="Strict mode"), + ] = False, +) -> None: + """Build documentation site for production.""" + print_section("Building Documentation", "blue") + + if not has_zensical_config(): + raise Exit(1) + + cmd = ["uv", "run", "zensical", "build"] + if clean: + cmd.append("--clean") + if strict: + cmd.append("--strict") + + try: + print_info("Building documentation...") + run_command(cmd, capture_output=False) + print_success("Documentation built successfully") + except Exception as e: + print_error(f"Failed to build documentation: {e}") + raise Exit(1) from e + + +if __name__ == "__main__": + app() diff --git a/scripts/docs/lint.py b/scripts/docs/lint.py new file mode 100644 index 000000000..b22740a2f --- /dev/null +++ b/scripts/docs/lint.py @@ -0,0 +1,74 @@ +""" +Command: docs lint. + +Lints documentation files. +""" + +from pathlib import Path + +from typer import Exit + +from scripts.core import create_app +from scripts.ui import ( + create_progress_bar, + print_error, + print_pretty, + print_section, + print_success, + print_warning, +) + +app = create_app() + + +@app.command(name="lint") +def lint() -> None: + """Lint documentation files.""" + print_section("Linting Documentation", "blue") + + docs_dir = Path("docs/content") + if not docs_dir.exists(): + print_error("docs/content directory not found") + raise Exit(1) + + all_md_files = list(docs_dir.rglob("*.md")) + issues: list[str] = [] + + with create_progress_bar( + total=len(all_md_files), + ) as progress: + task = progress.add_task("Scanning Documentation...", total=len(all_md_files)) + + for md_file in all_md_files: + progress.update(task, description=f"Scanning {md_file.name}...") + try: + content = md_file.read_text() + + # Skip YAML frontmatter if present + if content.startswith("---"): + parts = content.split("---", 2) + if len(parts) >= 3: + content = parts[2].strip() + else: + content = content.strip() + + if content == "": + issues.append(f"Empty file: {md_file}") + elif not content.startswith("#"): + issues.append(f"Missing title: {md_file}") + elif "TODO" in content.upper() or "FIXME" in content.upper(): + issues.append(f"Contains TODO/FIXME: {md_file}") + except Exception as e: + issues.append(f"Could not read {md_file}: {e}") + + progress.advance(task) + + if issues: + print_warning(f"Found {len(issues)} issues in documentation:") + print_pretty(issues) + raise Exit(1) + print_success("No issues found in documentation!") + + +if __name__ == "__main__": + app() diff --git a/scripts/docs/serve.py b/scripts/docs/serve.py new file mode 100644 index 000000000..e61dd998e --- /dev/null +++ b/scripts/docs/serve.py @@ -0,0 +1,63 @@ +""" +Command: docs serve. + +Serves documentation locally with live reload. +""" + +from typing import Annotated + +from typer import Exit, Option + +from scripts.core import create_app +from scripts.docs.utils import has_zensical_config +from scripts.proc import run_command +from scripts.ui import print_error, print_info, print_section + +app = create_app() + + +@app.command(name="serve") +def serve( + dev_addr: Annotated[ + str, + Option( + "--dev-addr", + "-a", + help="IP address and port (default: localhost:8000)", + ), + ] = "localhost:8000", + open_browser: Annotated[ + bool, + Option("--open", "-o", help="Open preview in default browser"), + ] = False, + strict: Annotated[ + bool, + Option("--strict", "-s", help="Strict mode"), + ] = False, +) -> None: + """Serve documentation locally with live reload.""" + print_section("Serving Documentation", "blue") + + if not has_zensical_config(): + raise Exit(1) + + cmd = ["uv", "run", "zensical", "serve", "--dev-addr", dev_addr] + if open_browser: + cmd.append("--open") + if strict: + print_error("--strict mode is not yet supported by zensical") + raise Exit(1) + + try: + print_info(f"Starting documentation server at {dev_addr}") + # Using run_command for consistency and logging + run_command(cmd, capture_output=False) + except KeyboardInterrupt: + print_info("\nDocumentation server stopped") + except Exception as e: + print_error(f"Failed to start documentation server: {e}") + raise Exit(1) from e + + +if __name__ == "__main__": + app() diff --git a/scripts/docs/utils.py b/scripts/docs/utils.py new file mode 100644 index 000000000..6c02834a0 --- /dev/null +++ b/scripts/docs/utils.py @@ -0,0 +1,21 @@ +"""Shared utilities for documentation scripts.""" + +from pathlib import Path + +from scripts.ui import print_error + + +def has_zensical_config() -> bool: + """Check if the project has a zensical.toml file.""" + if (Path.cwd() / "zensical.toml").exists(): + return True + print_error("Can't find zensical.toml file. Please run from the project root.") + return False + + +def has_wrangler_config() -> bool: + """Check if the project has a wrangler.toml file.""" + if (Path.cwd() / "wrangler.toml").exists(): + return True + print_error("wrangler.toml not found. Please run from the project root.") + return False diff --git a/scripts/docs/wrangler_deploy.py b/scripts/docs/wrangler_deploy.py new file mode 100644 index 000000000..c054cbb2c --- /dev/null +++ b/scripts/docs/wrangler_deploy.py @@ -0,0 +1,61 @@ +""" +Command: docs wrangler-deploy. + +Deploys documentation to Cloudflare Workers. +""" + +from typing import Annotated + +from typer import Exit, Option + +from scripts.core import create_app +from scripts.docs.build import build +from scripts.docs.utils import has_wrangler_config +from scripts.proc import run_command +from scripts.ui import print_error, print_info, print_section, print_success + +app = create_app() + + +@app.command(name="wrangler-deploy") +def wrangler_deploy( + env: Annotated[ + str, + Option("--env", "-e", help="Environment to deploy to"), + ] = "production", + dry_run: Annotated[ + bool, + Option("--dry-run", help="Show deployment plan without deploying"), + ] = False, +) -> None: + """Deploy documentation to Cloudflare Workers.""" + print_section("Deploying to Cloudflare Workers", "blue") + + if not has_wrangler_config(): + raise Exit(1) + + print_info("Building documentation...") + + try: + # Build with strict=True to ensure we fail on any issue + build(strict=True) + except Exception as e: + print_error(f"Build failed, aborting deployment: {e}") + raise Exit(1) from e + + cmd = ["wrangler", "deploy", "--env", env] + if dry_run: + cmd.append("--dry-run") + + print_info(f"Deploying to {env} environment...") + + try: + run_command(cmd, capture_output=False) + print_success(f"Documentation deployed successfully to {env}") + except Exception as e: + print_error(f"Deployment failed: {e}") + raise Exit(1) from e + + +if __name__ == "__main__": + app() diff --git a/scripts/docs/wrangler_deployments.py b/scripts/docs/wrangler_deployments.py new file mode 100644 index 000000000..70b29add2 --- /dev/null +++ b/scripts/docs/wrangler_deployments.py @@ -0,0 +1,49 @@ +""" +Command: docs wrangler-deployments. + +Lists deployment history. +""" + +from subprocess import CalledProcessError +from typing import Annotated + +from typer import Exit, Option + +from scripts.core import create_app +from scripts.docs.utils import has_wrangler_config +from scripts.proc import run_command +from scripts.ui import print_error, print_section, print_success + +app = create_app() + + +@app.command(name="wrangler-deployments") +def wrangler_deployments( + limit: Annotated[ + int, + Option("--limit", "-l", help="Maximum number of deployments to show"), + ] = 10, +) -> None: + """List deployment history for the documentation site.""" + print_section("Deployment History", "blue") + + if not has_wrangler_config(): + raise Exit(1) + + cmd = ["wrangler", "deployments", "list"] + if limit: + cmd.extend(["--limit", str(limit)]) + + try: + run_command(cmd, capture_output=False) + print_success("Deployment history retrieved") + except CalledProcessError as e: + print_error(f"Failed to retrieve deployment history: {e}") + raise Exit(1) from e + except Exception as e: + print_error(f"An unexpected error occurred: {e}") + raise Exit(1) from e + + +if __name__ == "__main__": + app() diff --git a/scripts/docs/wrangler_dev.py b/scripts/docs/wrangler_dev.py new file mode 100644 index 000000000..793517e87 --- /dev/null +++ b/scripts/docs/wrangler_dev.py @@ -0,0 +1,56 @@ +""" +Command: docs wrangler-dev. + +Starts local Wrangler development server. +""" + +from typing import Annotated + +from typer import Exit, Option + +from scripts.core import create_app +from scripts.docs.build import build +from scripts.docs.utils import has_wrangler_config +from scripts.proc import run_command +from scripts.ui import print_error, print_info, print_section + +app = create_app() + + +@app.command(name="wrangler-dev") +def wrangler_dev( + port: Annotated[int, Option("--port", "-p", help="Port to serve on")] = 8787, + remote: Annotated[ + bool, + Option("--remote", help="Run on remote Cloudflare infrastructure"), + ] = False, +) -> None: + """Start local Wrangler development server.""" + print_section("Starting Wrangler Dev Server", "blue") + + if not has_wrangler_config(): + raise Exit(1) + + print_info("Building documentation...") + # build(strict=True) already handles errors and raises Exit(1) + build(strict=True) + + cmd = ["wrangler", "dev", f"--port={port}"] + if remote: + cmd.append("--remote") + + print_info(f"Starting Wrangler dev server on port {port}...") + print_info("Press Ctrl+C to stop the server") + + try: + run_command(cmd, capture_output=False) + except KeyboardInterrupt: + print_info("\nWrangler dev server stopped") + raise Exit(0) from None + except Exception as e: + print_error(f"Wrangler dev server failed: {e}") + raise Exit(1) from e + + +if __name__ == "__main__": + app() diff --git a/scripts/docs/wrangler_rollback.py b/scripts/docs/wrangler_rollback.py new file mode 100644 index 000000000..f74e6cfd5 --- /dev/null +++ b/scripts/docs/wrangler_rollback.py @@ -0,0 +1,51 @@ +""" +Command: docs wrangler-rollback. + +Rolls back to a previous deployment. +""" + +from typing import Annotated + +from typer import Argument, Exit, Option + +from scripts.core import create_app +from scripts.docs.utils import has_wrangler_config +from scripts.proc import run_command +from scripts.ui import print_error, print_section, print_success, print_warning + +app = create_app() + + +@app.command(name="wrangler-rollback") +def wrangler_rollback( + version_id: Annotated[ + str, + Argument(help="Version ID to rollback to"), + ], + message: Annotated[ + str, + Option("--message", "-m", help="Rollback message"), + ] = "", +) -> None: + """Rollback to a previous deployment.""" + print_section("Rolling Back Deployment", "blue") + + if not has_wrangler_config(): + raise Exit(1) + + cmd = ["wrangler", "rollback", version_id] + if message: + cmd.extend(["--message", message]) + + print_warning(f"Rolling back to version: {version_id}") + + try: + run_command(cmd, capture_output=False) + print_success(f"Successfully rolled back to version {version_id}") + except Exception as e: + print_error(f"Error: {e}") + raise Exit(1) from e + + +if __name__ == "__main__": + app() diff --git a/scripts/docs/wrangler_tail.py b/scripts/docs/wrangler_tail.py new file mode 100644 index 000000000..b24bd8b94 --- /dev/null +++ b/scripts/docs/wrangler_tail.py @@ -0,0 +1,71 @@ +""" +Command: docs wrangler-tail. + +Views real-time logs from deployed docs. +""" + +from enum import Enum +from typing import Annotated + +from typer import Exit, Option + +from scripts.core import create_app +from scripts.docs.utils import has_wrangler_config +from scripts.proc import run_command +from scripts.ui import print_error, print_info, print_section + +app = create_app() + + +class OutputFormat(str, Enum): + """Output formats for wrangler tail.""" + + JSON = "json" + PRETTY = "pretty" + + +class LogStatus(str, Enum): + """Log status filters for wrangler tail.""" + + OK = "ok" + ERROR = "error" + CANCELED = "canceled" + + +@app.command(name="wrangler-tail") +def wrangler_tail( + format_output: Annotated[ + OutputFormat | None, + Option("--format", help="Output format: json or pretty"), + ] = None, + status: Annotated[ + LogStatus | None, + Option("--status", help="Filter by status: ok, error, or canceled"), + ] = None, +) -> None: + """View real-time logs from deployed documentation.""" + print_section("Tailing Logs", "blue") + + if not has_wrangler_config(): + raise Exit(1) + + cmd = ["wrangler", "tail"] + if format_output: + cmd.extend(["--format", format_output.value]) + if status: + cmd.extend(["--status", status.value]) + + print_info("Starting log tail... (Ctrl+C to stop)") + + try: + run_command(cmd, capture_output=False) + except Exception as e: + print_error(f"Failed to tail logs: {e}") + raise Exit(1) from e + except KeyboardInterrupt: + print_info("\nLog tail stopped") + raise Exit(0) from None + + +if __name__ == "__main__": + app() diff --git a/scripts/docs/wrangler_versions.py b/scripts/docs/wrangler_versions.py new file mode 100644 index 000000000..4f566645c --- /dev/null +++ b/scripts/docs/wrangler_versions.py @@ -0,0 +1,68 @@ +""" +Command: docs wrangler-versions. + +Lists and manages versions. +""" + +from subprocess import CalledProcessError +from typing import Annotated, Literal + +from typer import Exit, Option + +from scripts.core import create_app +from scripts.docs.utils import has_wrangler_config +from scripts.proc import run_command +from scripts.ui import print_error, print_section, print_success + +app = create_app() + + +@app.command(name="wrangler-versions") +def wrangler_versions( + action: Annotated[ + Literal["list", "view", "upload"], + Option("--action", "-a", help="Action to perform"), + ] = "list", + version_id: Annotated[ + str, + Option("--version-id", help="Version ID to view (required for 'view')"), + ] = "", + alias: Annotated[ + str, + Option("--alias", help="Preview alias name (required for 'upload')"), + ] = "", +) -> None: + """List and manage versions of the documentation.""" + print_section("Managing Versions", "blue") + + if not has_wrangler_config(): + raise Exit(1) + + if action == "view" and not version_id: + print_error("The --version-id option is required when --action view is used.") + raise Exit(1) + + if action == "upload" and not alias: + print_error("The --alias option is required when --action upload is used.") + raise Exit(1) + + cmd = ["wrangler", "versions", action] + + if action == "view": + cmd.append(version_id) + elif action == "upload": + cmd.extend(["--preview-alias", alias]) + + try: + run_command(cmd, capture_output=False) + print_success(f"Version {action} completed") + except CalledProcessError as e: + print_error(f"Version {action} failed: {e}") + raise Exit(1) from e + except Exception as e: + print_error(f"An unexpected error occurred: {e}") + raise Exit(1) from e + + +if __name__ == "__main__": + app() diff --git a/scripts/proc.py b/scripts/proc.py new file mode 100644 index 000000000..d8d82c6e9 --- /dev/null +++ b/scripts/proc.py @@ -0,0 +1,73 @@ +""" +Process Management Utilities for CLI. + +Provides helpers for running shell commands and managing subprocesses. +""" + +import os +import shlex +import subprocess + +from scripts.ui import console, print_error + + +def run_command( + command: list[str], + capture_output: bool = True, + check: bool = True, + env: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: + """ + Run a shell command and handle output. + + Parameters + ---------- + command : list[str] + The command and arguments to execute. + capture_output : bool, optional + Whether to capture stdout and stderr (default is True). + check : bool, optional + Whether to raise CalledProcessError if command fails (default is True). + env : dict[str, str] | None, optional + Custom environment variables. If None, uses a copy of os.environ. + + Returns + ------- + subprocess.CompletedProcess[str] + The result of the command execution. + + Raises + ------ + subprocess.CalledProcessError + If check is True and the command returns a non-zero exit code. + """ + run_env = env if env is not None else os.environ.copy() + + # Log command for auditing + console.print(f"[dim]Executing: {shlex.join(command)}[/dim]") + + try: + result = subprocess.run( + command, + check=check, + capture_output=capture_output, + text=True, + env=run_env, + ) + + if capture_output and result.stdout: + # Use markup=False to avoid interpreting command output as Rich formatting + console.print(result.stdout.strip(), markup=False) + + except subprocess.CalledProcessError as e: + # Many tools (ruff, pyright) print errors to stdout + if capture_output: + if e.stdout: + console.print(e.stdout.strip(), markup=False) + if e.stderr: + console.print(f"[red]{e.stderr.strip()}[/red]", markup=False) + + print_error(f"Command failed: {shlex.join(command)}") + raise + else: + return result diff --git a/scripts/registry.py b/scripts/registry.py deleted file mode 100644 index 43ff24435..000000000 --- a/scripts/registry.py +++ /dev/null @@ -1,189 +0,0 @@ -""" -Command Registry Infrastructure. - -Provides OOP classes for managing CLI commands in a clean, extensible way. -""" - -from collections.abc import Callable - - -class Command: - """Represents a single CLI command. - - A simple data structure that encapsulates a CLI command with its - name, function, and help text. - - Parameters - ---------- - name : str - The name of the command. - func : Callable[..., None] - The function that implements the command. - help_text : str - Help text describing what the command does. - - Attributes - ---------- - name : str - The command name. - func : Callable[..., None] - The command function. - help_text : str - Description of the command. - """ - - def __init__(self, name: str, func: Callable[..., None], help_text: str): - """Initialize a Command instance. - - Parameters - ---------- - name : str - The name of the command. - func : Callable[..., None] - The function that implements the command. - help_text : str - Help text describing what the command does. - """ - self.name = name - self.func = func - self.help_text = help_text - - -class CommandGroup: - """Represents a group of related CLI commands. - - A collection of commands organized under a common name and help panel. - Useful for grouping related functionality in CLI help output. - - Parameters - ---------- - name : str - The name of the command group. - help_text : str - Help text describing the group. - rich_help_panel : str - Rich help panel name for organizing commands in help output. - - Attributes - ---------- - name : str - The group name. - help_text : str - Description of the group. - rich_help_panel : str - Rich help panel identifier. - _commands : dict[str, Command] - Internal dictionary of commands in this group. - """ - - def __init__(self, name: str, help_text: str, rich_help_panel: str): - """Initialize a CommandGroup instance. - - Parameters - ---------- - name : str - The name of the command group. - help_text : str - Help text describing the group. - rich_help_panel : str - Rich help panel name for organizing commands in help output. - """ - self.name = name - self.help_text = help_text - self.rich_help_panel = rich_help_panel - self._commands: dict[str, Command] = {} - - def add_command(self, command: Command) -> None: - """Add a command to this group.""" - self._commands[command.name] = command - - def get_commands(self) -> dict[str, Command]: - """Get all commands in this group. - - Returns - ------- - dict[str, Command] - Copy of commands dictionary. - """ - return self._commands.copy() - - def get_command(self, name: str) -> Command | None: - """Get a specific command by name. - - Returns - ------- - Command | None - The command if found, None otherwise. - """ - return self._commands.get(name) - - -class CommandRegistry: - """Registry for managing CLI commands in an OOP way. - - A central registry that manages both individual commands and command groups. - Provides methods for registering and retrieving commands and groups. - - Attributes - ---------- - _groups : dict[str, CommandGroup] - Internal dictionary of registered command groups. - _commands : dict[str, Command] - Internal dictionary of registered individual commands. - """ - - def __init__(self): - """Initialize a CommandRegistry instance. - - Creates empty dictionaries for storing command groups and individual commands. - """ - self._groups: dict[str, CommandGroup] = {} - self._commands: dict[str, Command] = {} - - def register_group(self, group: CommandGroup) -> None: - """Register a command group.""" - self._groups[group.name] = group - - def register_command(self, command: Command) -> None: - """Register an individual command.""" - self._commands[command.name] = command - - def get_groups(self) -> dict[str, CommandGroup]: - """Get all registered command groups. - - Returns - ------- - dict[str, CommandGroup] - Copy of command groups dictionary. - """ - return self._groups.copy() - - def get_commands(self) -> dict[str, Command]: - """Get all registered individual commands. - - Returns - ------- - dict[str, Command] - Copy of commands dictionary. - """ - return self._commands.copy() - - def get_group(self, name: str) -> CommandGroup | None: - """Get a specific command group by name. - - Returns - ------- - CommandGroup | None - The command group if found, None otherwise. - """ - return self._groups.get(name) - - def get_command(self, name: str) -> Command | None: - """Get a specific individual command by name. - - Returns - ------- - Command | None - The command if found, None otherwise. - """ - return self._commands.get(name) diff --git a/scripts/rich_utils.py b/scripts/rich_utils.py deleted file mode 100644 index c3e9479ff..000000000 --- a/scripts/rich_utils.py +++ /dev/null @@ -1,106 +0,0 @@ -""" -Rich Utilities for CLI. - -Provides Rich formatting utilities for consistent CLI output. -""" - -from rich.console import Console -from rich.progress import BarColumn, Progress, ProgressColumn, SpinnerColumn, TextColumn -from rich.table import Table - - -class RichCLI: - """Rich utilities for CLI applications. - - Provides a set of methods for consistent, colorized CLI output using Rich. - Includes methods for success, error, info, and warning messages, as well - as table printing and progress bars. - - Attributes - ---------- - console : Console - Rich console instance for output formatting. - """ - - def __init__(self): - """Initialize a RichCLI instance. - - Creates a Rich Console instance for formatted output. - """ - self.console = Console() - - def print_success(self, message: str) -> None: - """Print a success message.""" - self.console.print(f"[green]{message}[/green]") - - def print_error(self, message: str) -> None: - """Print an error message.""" - self.console.print(f"[red]{message}[/red]") - - def print_info(self, message: str) -> None: - """Print an info message.""" - self.console.print(f"[blue]{message}[/blue]") - - def print_warning(self, message: str) -> None: - """Print a warning message.""" - self.console.print(f"[yellow]{message}[/yellow]") - - def print_section(self, title: str, color: str = "blue") -> None: - """Print a section header.""" - self.console.print(f"\n[bold {color}]{title}[/bold {color}]") - - def rich_print(self, message: str) -> None: - """Print a rich formatted message.""" - self.console.print(message) - - def print_rich_table( - self, - title: str, - columns: list[tuple[str, str]], - data: list[tuple[str, ...]], - ) -> None: - """Print a Rich table with title, columns, and data.""" - table = Table(title=title) - for column_name, style in columns: - table.add_column(column_name, style=style) - - for row in data: - table.add_row(*[str(item) for item in row]) - - self.console.print(table) - - def create_progress_bar( - self, - description: str = "Processing...", - total: int | None = None, - ) -> Progress: - """Create a Rich progress bar with spinner and text. - - Returns - ------- - Progress - Configured Progress instance. - """ - # Build columns list conditionally based on whether total is provided - columns: list[ProgressColumn] = [ - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - ] - - # Add progress bar and percentage columns only if total is provided - if total is not None: - columns.extend( - [ - BarColumn(), - TextColumn("[progress.percentage]{task.percentage:>3.0f}% "), - ], - ) - - # Always include elapsed time - columns.append(TextColumn("[progress.elapsed]{task.elapsed:.1f}s ")) - - return Progress( - *columns, - transient=False, - console=self.console, - ) diff --git a/scripts/test.py b/scripts/test.py deleted file mode 100644 index 4b78a0948..000000000 --- a/scripts/test.py +++ /dev/null @@ -1,346 +0,0 @@ -#!/usr/bin/env python3 -""" -Test CLI Script. - -Testing operations management. -""" - -import os -import sys -import webbrowser -from pathlib import Path -from typing import Annotated - -import typer -from rich.console import Console -from typer import Argument, Option # type: ignore[attr-defined] - -# Add src to path -src_path = Path(__file__).parent.parent / "src" -sys.path.insert(0, str(src_path)) - -# Note: Logging is configured by pytest via conftest.py -# No need to configure here as pytest will handle it - -from scripts.base import BaseCLI, RichCLI -from scripts.registry import Command, CommandRegistry - - -class TestCLI(BaseCLI): - """Testing operations management. - - Commands for running tests, generating coverage reports, - parallel execution, HTML reports, and benchmarking. - """ - - def __init__(self): - """Initialize the TestCLI application. - - Sets up the CLI with test-specific commands and configures - the command registry for pytest operations. - """ - # Don't call super().__init__() because we need no_args_is_help=False - # Initialize BaseCLI components manually - # Create Typer app directly with no_args_is_help=False for callback support - self.app = typer.Typer( - name="test", - help="Testing operations", - rich_markup_mode="rich", - no_args_is_help=False, # Allow callback to handle arguments - ) - self.console = Console() - self.rich = RichCLI() - self._command_registry = CommandRegistry() - self._setup_command_registry() - self._setup_commands() - self._setup_default_command() - - def _setup_command_registry(self) -> None: - """Set up the command registry with all test commands.""" - # All commands directly registered without groups - all_commands = [ - # Basic test commands - Command( - "all", - self.all_tests, - "Run all tests with coverage", - ), - Command("quick", self.quick_tests, "Run tests without coverage"), - Command("plain", self.plain_tests, "Run tests with plain output"), - Command("parallel", self.parallel_tests, "Run tests in parallel"), - Command("file", self.file_tests, "Run specific test files or paths"), - Command("html", self.html_report, "Generate HTML report"), - Command( - "coverage", - self.coverage_report, - "Generate coverage reports", - ), - Command("benchmark", self.benchmark_tests, "Run benchmark tests"), - ] - - for cmd in all_commands: - self._command_registry.register_command(cmd) - - def _setup_commands(self) -> None: - """Set up all test CLI commands using the command registry.""" - # Register all commands directly to the main app - for command in self._command_registry.get_commands().values(): - self.add_command( - command.func, - name=command.name, - help_text=command.help_text, - ) - - def _setup_default_command(self) -> None: - """Set up default command - runs quick tests if no command specified.""" - - @self.app.callback(invoke_without_command=True) - def _default_callback( # pyright: ignore[reportUnusedFunction] - ctx: typer.Context, - ) -> None: - """Run quick tests if no command specified.""" - # Only run if no subcommand was invoked - if ctx.invoked_subcommand is None: - # Default to quick tests (no coverage, faster) - self.quick_tests() - - def _run_test_command(self, command: list[str], description: str) -> bool: - """Run a test command and return success status. - - Returns - ------- - bool - True if command succeeded, False otherwise. - """ - try: - self.rich.print_info(f"Running: {' '.join(command)}") - # Use exec to replace the current process so signals are properly forwarded - - os.execvp(command[0], command) - except FileNotFoundError: - self.rich.print_error(f"Command not found: {command[0]}") - return False - except KeyboardInterrupt: - self.rich.print_info("Test run interrupted") - return False - - def _build_coverage_command( - self, - specific: str | None = None, - format_type: str | None = None, - quick: bool = False, - fail_under: str | None = None, - ) -> list[str]: - """Build coverage command with various options. - - Returns - ------- - list[str] - Complete pytest command with coverage options. - """ - # Start with base pytest command (coverage options come from pyproject.toml) - cmd = ["uv", "run", "pytest"] - - # Handle specific path override - if specific: - cmd.append(f"--cov={specific}") - - # Handle coverage format overrides - if quick: - cmd.append("--cov-report=") - elif format_type: - match format_type: - case "html": - cmd.append("--cov-report=html") - case "xml": - cmd.append("--cov-report=xml:coverage.xml") - case "json": - cmd.append("--cov-report=json") - case _: - # For unsupported formats, let pyproject.toml handle it - pass - - # Handle fail-under override - if fail_under: - cmd.extend(["--cov-fail-under", fail_under]) - - return cmd - - def _open_coverage_browser(self, format_type: str) -> None: - """Open coverage report in browser if HTML format.""" - if format_type == "html": - html_report_path = Path("docs/htmlcov/index.html") - if html_report_path.exists(): - self.rich.print_info("Opening HTML coverage report in browser...") - webbrowser.open(f"file://{html_report_path.resolve()}") - - # ============================================================================ - # TEST COMMANDS - # ============================================================================ - - def all_tests(self) -> None: - """Run all tests with coverage.""" - self.rich.print_section("Running Tests", "blue") - cmd = ["uv", "run", "pytest"] - self._run_test_command(cmd, "Test run") - - def quick_tests(self) -> None: - """Run tests without coverage (faster).""" - self.rich.print_section("Quick Tests", "blue") - cmd = ["uv", "run", "pytest", "--no-cov"] - self._run_test_command(cmd, "Quick test run") - - def file_tests( - self, - test_paths: Annotated[ - list[str], - Argument(help="Test file paths, directories, or pytest node IDs"), - ], - coverage: Annotated[ - bool, - Option("--coverage", "-c", help="Enable coverage collection"), - ] = False, - ) -> None: - """Run specific test files or paths. - - Examples - -------- - uv run test file tests/unit/test_models.py - uv run test file tests/integration/ tests/unit/ - uv run test file tests/unit/test_models.py::TestModel::test_create - """ - self.rich.print_section("Running Tests", "blue") - cmd = ["uv", "run", "pytest"] - if not coverage: - cmd.append("--no-cov") - cmd.extend(test_paths) - self._run_test_command(cmd, "Test run") - - def plain_tests(self) -> None: - """Run tests with plain output.""" - self.rich.print_section("Plain Tests", "blue") - cmd = ["uv", "run", "pytest", "-p", "no:sugar"] - self._run_test_command(cmd, "Plain test run") - - def parallel_tests( - self, - workers: Annotated[ - int | None, - Option( - "--workers", - "-n", - help="Number of parallel workers (default: auto, uses CPU count)", - ), - ] = None, - load_scope: Annotated[ - str | None, - Option( - "--load-scope", - help="Load balancing scope: module, class, or function (default: module)", - ), - ] = None, - ) -> None: - """Run tests in parallel using pytest-xdist. - - Uses multiprocessing for true parallelism. Coverage is automatically - combined across workers. - - Parameters - ---------- - workers : int | None - Number of parallel workers. If None, uses 'auto' (CPU count). - load_scope : str | None - Load balancing scope. Options: 'module', 'class', 'function'. - Default is 'module' for best performance. - """ - self.rich.print_section("Parallel Tests (pytest-xdist)", "blue") - cmd = ["uv", "run", "pytest"] - - # Add xdist options - if workers is None: - cmd.extend(["-n", "auto"]) - else: - cmd.extend(["-n", str(workers)]) - - # Add load balancing scope if specified - if load_scope: - cmd.extend(["--dist", load_scope]) - - self._run_test_command(cmd, "Parallel test run") - - def html_report( - self, - open_browser: Annotated[ - bool, - Option("--open", help="Automatically open browser with HTML report"), - ] = False, - ) -> None: - """Run tests and generate HTML report.""" - self.rich.print_section("HTML Report", "blue") - cmd = [ - "uv", - "run", - "pytest", - "--cov-report=html", - "--html=reports/test_report.html", - "--self-contained-html", - ] - if self._run_test_command(cmd, "HTML report generation") and open_browser: - self._open_coverage_browser("html") - - def coverage_report( - self, - specific: Annotated[ - str | None, - Option("--specific", help="Path to include in coverage"), - ] = None, - format_type: Annotated[ - str | None, - Option("--format", help="Report format: html, xml, or json"), - ] = None, - quick: Annotated[ - bool, - Option("--quick", help="Skip coverage report generation"), - ] = False, - fail_under: Annotated[ - str | None, - Option("--fail-under", help="Minimum coverage percentage required"), - ] = None, - open_browser: Annotated[ - bool, - Option( - "--open", - help="Automatically open browser for HTML coverage reports", - ), - ] = False, - ) -> None: - """Generate coverage reports.""" - self.rich.print_section("Coverage Report", "blue") - - cmd = self._build_coverage_command(specific, format_type, quick, fail_under) - success = self._run_test_command(cmd, "Coverage report generation") - - if success and open_browser and format_type: - self._open_coverage_browser(format_type) - - def benchmark_tests(self) -> None: - """Run benchmark tests.""" - self.rich.print_section("Benchmark Tests", "blue") - self._run_test_command( - ["uv", "run", "pytest", "--benchmark-only", "--benchmark-sort=mean"], - "Benchmark test run", - ) - - -# Create the CLI app instance -app = TestCLI().app - - -def main() -> None: - """Entry point for the test CLI script.""" - cli = TestCLI() - cli.run() - - -if __name__ == "__main__": - main() diff --git a/scripts/test/__init__.py b/scripts/test/__init__.py new file mode 100644 index 000000000..232ac6d64 --- /dev/null +++ b/scripts/test/__init__.py @@ -0,0 +1,45 @@ +""" +Testing Command Group. + +Aggregates all testing operations. +""" + +from typer import Context + +from scripts.core import create_app +from scripts.test import ( + all as all_tests, +) +from scripts.test import ( + benchmark, + coverage, + file, + html, + parallel, + plain, + quick, +) +from scripts.test.quick import quick_tests + +app = create_app(name="test", help_text="Testing operations", no_args_is_help=False) + +app.add_typer(all_tests.app) +app.add_typer(quick.app) +app.add_typer(plain.app) +app.add_typer(parallel.app) +app.add_typer(file.app) +app.add_typer(html.app) +app.add_typer(coverage.app) +app.add_typer(benchmark.app) + + +@app.callback(invoke_without_command=True) +def default_callback(ctx: Context) -> None: + """Run quick tests if no command specified.""" + if ctx.invoked_subcommand is None: + quick_tests() + + +def main() -> None: + """Entry point for the test command group.""" + app() diff --git a/scripts/test/all.py b/scripts/test/all.py new file mode 100644 index 000000000..a4ecb61b0 --- /dev/null +++ b/scripts/test/all.py @@ -0,0 +1,25 @@ +""" +Command: test all. + +Runs all tests with coverage. +""" + +import os + +from scripts.core import create_app +from scripts.ui import print_info, print_section + +app = create_app() + + +@app.command(name="all") +def all_tests() -> None: + """Run all tests with coverage.""" + print_section("Running Tests", "blue") + cmd = ["uv", "run", "pytest"] + print_info(f"Running: {' '.join(cmd)}") + os.execvp(cmd[0], cmd) + + +if __name__ == "__main__": + app() diff --git a/scripts/test/benchmark.py b/scripts/test/benchmark.py new file mode 100644 index 000000000..72fd55f10 --- /dev/null +++ b/scripts/test/benchmark.py @@ -0,0 +1,29 @@ +""" +Command: test benchmark. + +Runs benchmark tests. +""" + +import os + +from scripts.core import create_app +from scripts.ui import print_error, print_info, print_section + +app = create_app() + + +@app.command(name="benchmark") +def benchmark_tests() -> None: + """Run benchmark tests.""" + print_section("Benchmark Tests", "blue") + cmd = ["uv", "run", "pytest", "--benchmark-only", "--benchmark-sort=mean"] + print_info(f"Running: {' '.join(cmd)}") + try: + os.execvp(cmd[0], cmd) + except OSError as e: + print_error(f"Failed to execute command: {e}") + raise + + +if __name__ == "__main__": + app() diff --git a/scripts/test/coverage.py b/scripts/test/coverage.py new file mode 100644 index 000000000..7e4a4275b --- /dev/null +++ b/scripts/test/coverage.py @@ -0,0 +1,120 @@ +""" +Command: test coverage. + +Generates coverage reports. +""" + +import shlex +import sys +import webbrowser +from pathlib import Path +from subprocess import CalledProcessError +from typing import Annotated, Literal + +from typer import Option + +from scripts.core import create_app +from scripts.proc import run_command +from scripts.ui import print_error, print_info, print_section + +app = create_app() + + +def _build_coverage_cmd( + specific: str | None, + format_type: str | None, + quick: bool, + fail_under: int | None, +) -> list[str]: + """Build the pytest coverage command.""" + cmd = ["uv", "run", "pytest", f"--cov={specific or 'src/tux'}"] + + if quick: + cmd.append("--cov-report=") + elif format_type: + report_arg = "xml:coverage.xml" if format_type == "xml" else format_type + cmd.append(f"--cov-report={report_arg}") + + if fail_under: + cmd.extend(["--cov-fail-under", str(fail_under)]) + + return cmd + + +def _handle_browser_opening(format_type: str | None) -> None: + """Handle opening the coverage report in the browser.""" + if format_type == "html": + html_report_path = Path("htmlcov/index.html") + if html_report_path.exists(): + print_info("Opening HTML coverage report in browser...") + try: + webbrowser.open(f"file://{html_report_path.resolve()}") + except Exception as e: + print_error(f"Failed to open browser: {e}") + else: + print_error("HTML coverage report not found at htmlcov/index.html") + elif format_type: + print_info( + f"Browser opening only supported for HTML format (current: {format_type})", + ) + else: + print_info( + "Browser opening only supported for HTML format. Use --format html.", + ) + + +@app.command(name="coverage") +def coverage_report( + specific: Annotated[ + str | None, + Option("--specific", help="Path to include in coverage"), + ] = None, + format_type: Annotated[ + Literal["html", "xml", "json"] | None, + Option("--format", help="Report format: html, xml, or json"), + ] = None, + quick: Annotated[ + bool, + Option("--quick", help="Skip coverage report generation"), + ] = False, + fail_under: Annotated[ + int | None, + Option("--fail-under", help="Minimum coverage percentage required"), + ] = None, + open_browser: Annotated[ + bool, + Option( + "--open", + help="Automatically open browser for HTML coverage reports", + ), + ] = False, +) -> None: + """Generate coverage reports.""" + print_section("Coverage Report", "blue") + + cmd = _build_coverage_cmd(specific, format_type, quick, fail_under) + + if quick and format_type: + print_info("Note: --quick takes precedence; --format will be ignored") + + print_info(f"Running: {shlex.join(cmd)}") + + try: + run_command(cmd, capture_output=False) + + if open_browser: + _handle_browser_opening(format_type) + + except KeyboardInterrupt: + print_info("\nCoverage generation interrupted by user") + sys.exit(130) + except CalledProcessError as e: + print_error(f"Coverage report generation failed: {e}") + sys.exit(1) + except Exception as e: + print_error(f"An unexpected error occurred during coverage generation: {e}") + sys.exit(1) + + +if __name__ == "__main__": + app() diff --git a/scripts/test/file.py b/scripts/test/file.py new file mode 100644 index 000000000..83101b3de --- /dev/null +++ b/scripts/test/file.py @@ -0,0 +1,45 @@ +""" +Command: test file. + +Runs specific test files or paths. +""" + +import os +from typing import Annotated + +from typer import Argument, Option + +from scripts.core import create_app +from scripts.ui import print_error, print_info, print_section + +app = create_app() + + +@app.command(name="file") +def file_tests( + test_paths: Annotated[ + list[str], + Argument(help="Test file paths, directories, or pytest node IDs"), + ], + coverage: Annotated[ + bool, + Option("--coverage", "-c", help="Enable coverage collection"), + ] = False, +) -> None: + """Run specific test files or paths.""" + print_section("Running Tests", "blue") + cmd = ["uv", "run", "pytest"] + if not coverage: + cmd.append("--no-cov") + cmd.extend(test_paths) + + print_info(f"Running: {' '.join(cmd)}") + try: + os.execvp(cmd[0], cmd) + except OSError as e: + print_error(f"Failed to execute command: {e}") + raise + + +if __name__ == "__main__": + app() diff --git a/scripts/test/html.py b/scripts/test/html.py new file mode 100644 index 000000000..8a11c39d2 --- /dev/null +++ b/scripts/test/html.py @@ -0,0 +1,70 @@ +""" +Command: test html. + +Runs tests and generates an HTML report. +""" + +import shlex +import sys +import webbrowser +from pathlib import Path +from subprocess import CalledProcessError +from typing import Annotated + +from typer import Option + +from scripts.core import create_app +from scripts.proc import run_command +from scripts.ui import print_error, print_info, print_section, print_warning + +app = create_app() + + +@app.command(name="html") +def html_report( + open_browser: Annotated[ + bool, + Option("--open", help="Automatically open browser with HTML report"), + ] = False, +) -> None: + """Run tests and generate HTML report.""" + print_section("HTML Report", "blue") + report_path = "reports/test_report.html" + cmd = [ + "uv", + "run", + "pytest", + "--cov-report=html", + f"--html={report_path}", + "--self-contained-html", + ] + + print_info(f"Running: {shlex.join(cmd)}") + + try: + run_command(cmd, capture_output=False) + + if open_browser: + html_report_path = Path(report_path) + if html_report_path.exists(): + print_info(f"Opening HTML report in browser: {report_path}") + try: + webbrowser.open(f"file://{html_report_path.resolve()}") + except Exception as e: + print_warning(f"Failed to open browser: {e}") + else: + print_warning(f"HTML report not found at {report_path}") + + except KeyboardInterrupt: + print_info("\nTests interrupted by user") + sys.exit(130) + except CalledProcessError as e: + print_error(f"Tests failed: {e}") + sys.exit(1) + except Exception as e: + print_error(f"An unexpected error occurred during HTML report generation: {e}") + sys.exit(1) + + +if __name__ == "__main__": + app() diff --git a/scripts/test/parallel.py b/scripts/test/parallel.py new file mode 100644 index 000000000..82db6ffae --- /dev/null +++ b/scripts/test/parallel.py @@ -0,0 +1,72 @@ +""" +Command: test parallel. + +Runs tests in parallel using pytest-xdist. +""" + +import os +import sys +from typing import Annotated + +from typer import Option + +from scripts.core import create_app +from scripts.ui import print_error, print_info, print_section + +app = create_app() + + +@app.command(name="parallel") +def parallel_tests( + workers: Annotated[ + int | None, + Option( + "--workers", + "-n", + help="Number of parallel workers (default: auto, uses CPU count)", + ), + ] = None, + load_scope: Annotated[ + str | None, + Option( + "--load-scope", + help="Distribution mode: load, loadscope, loadfile, loadgroup, worksteal, or no (default: loadscope)", + ), + ] = None, +) -> None: + """Run tests in parallel using pytest-xdist.""" + # Validate workers + if workers is not None and workers <= 0: + print_error("Workers must be a positive integer") + sys.exit(1) + + # Validate load_scope + valid_scopes = {"load", "loadscope", "loadfile", "loadgroup", "worksteal", "no"} + if load_scope is not None and load_scope not in valid_scopes: + print_error(f"Invalid load scope. Must be one of: {', '.join(valid_scopes)}") + sys.exit(1) + + print_section("Parallel Tests (pytest-xdist)", "blue") + cmd = ["uv", "run", "pytest"] + + if workers is None: + cmd.extend(["-n", "auto"]) + else: + cmd.extend(["-n", str(workers)]) + + if load_scope: + cmd.extend(["--dist", load_scope]) + else: + # Default to loadscope for better stability with our fixtures + cmd.extend(["--dist", "loadscope"]) + + print_info(f"Running: {' '.join(cmd)}") + try: + os.execvp(cmd[0], cmd) + except OSError as e: + print_error(f"Failed to execute command: {e}") + sys.exit(1) + + +if __name__ == "__main__": + app() diff --git a/scripts/test/plain.py b/scripts/test/plain.py new file mode 100644 index 000000000..c8069207f --- /dev/null +++ b/scripts/test/plain.py @@ -0,0 +1,29 @@ +""" +Command: test plain. + +Runs tests with plain output. +""" + +import os + +from scripts.core import create_app +from scripts.ui import print_error, print_info, print_section + +app = create_app() + + +@app.command(name="plain") +def plain_tests() -> None: + """Run tests with plain output.""" + print_section("Plain Tests", "blue") + cmd = ["uv", "run", "pytest", "-p", "no:sugar"] + print_info(f"Running: {' '.join(cmd)}") + try: + os.execvp(cmd[0], cmd) + except OSError as e: + print_error(f"Failed to execute command: {e}") + raise + + +if __name__ == "__main__": + app() diff --git a/scripts/test/quick.py b/scripts/test/quick.py new file mode 100644 index 000000000..d8a9bea17 --- /dev/null +++ b/scripts/test/quick.py @@ -0,0 +1,29 @@ +""" +Command: test quick. + +Runs tests without coverage (faster). +""" + +import os + +from scripts.core import create_app +from scripts.ui import print_error, print_info, print_section + +app = create_app() + + +@app.command(name="quick") +def quick_tests() -> None: + """Run tests without coverage (faster).""" + print_section("Quick Tests", "blue") + cmd = ["uv", "run", "pytest", "--no-cov"] + print_info(f"Running: {' '.join(cmd)}") + try: + os.execvp(cmd[0], cmd) + except OSError as e: + print_error(f"Failed to execute command: {e}") + raise + + +if __name__ == "__main__": + app() diff --git a/scripts/tux.py b/scripts/tux.py deleted file mode 100644 index 839356df6..000000000 --- a/scripts/tux.py +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env python3 - -""" -Tux Bot CLI Script. - -Bot operations management. -""" - -import sys -from pathlib import Path -from typing import Annotated - -from typer import Option # type: ignore[attr-defined] - -# Add src to path -src_path = Path(__file__).parent.parent / "src" -sys.path.insert(0, str(src_path)) - -from scripts.base import BaseCLI -from scripts.registry import Command - - -class TuxCLI(BaseCLI): - """Bot operations management. - - Commands for starting the bot, showing version information, - and other bot-related operations. - """ - - def __init__(self): - """Initialize the Tux CLI application. - - Sets up the CLI with the Tux bot name and description, - configures the command registry, and registers all available commands. - """ - super().__init__( - name="tux", - description="Bot operations", - ) - self._setup_command_registry() - self._setup_commands() - - def _setup_command_registry(self) -> None: - """Set up command registry with all Tux bot commands.""" - # All commands directly registered without groups - all_commands = [ - # Bot operations - Command("start", self.start_bot, "Start the Discord bot"), - Command("version", self.show_version, "Show version information"), - ] - - for cmd in all_commands: - self._command_registry.register_command(cmd) - - def _setup_commands(self) -> None: - """Set up Tux CLI commands using the command registry.""" - # Register all commands directly to the main app - for command in self._command_registry.get_commands().values(): - self.add_command( - command.func, - name=command.name, - help_text=command.help_text, - ) - - # ======================================================================== - # BOT COMMANDS - # ======================================================================== - - def start_bot( - self, - debug: Annotated[bool, Option("--debug", help="Enable debug mode")] = False, - ) -> None: - """Start the Tux Discord bot. - - This command starts the main Tux Discord bot with all its features. - Use --debug to enable debug mode for development. - """ - self.rich.print_section("Starting Tux Bot", "blue") - self.rich.rich_print("[bold blue]Starting Tux Discord bot...[/bold blue]") - - try: - # Import here to avoid circular imports - from tux.main import run # noqa: PLC0415 - - if debug: - self.rich.print_info("Debug mode enabled") - - exit_code = run() - if exit_code == 0: - self.rich.print_success("Bot started successfully") - elif exit_code == 130: - self.rich.print_info("Bot shutdown requested by user (Ctrl+C)") - else: - self.rich.print_error(f"Bot exited with code {exit_code}") - sys.exit(exit_code) - - except RuntimeError as e: - # Handle setup failures (database, container, etc.) - if "setup failed" in str(e).lower(): - # Error already logged in setup method, just exit - self.rich.print_error("Bot setup failed") - sys.exit(1) - elif "Event loop stopped before Future completed" in str(e): - self.rich.print_info("Bot shutdown completed") - sys.exit(0) - else: - self.rich.print_error(f"Runtime error: {e}") - sys.exit(1) - except SystemExit as e: - # Bot failed during startup, exit with the proper code - # Don't log additional error messages since they're already handled - sys.exit(e.code) - except KeyboardInterrupt: - self.rich.print_info("Bot shutdown requested by user (Ctrl+C)") - sys.exit(130) - except Exception as e: - self.rich.print_error(f"Failed to start bot: {e}") - sys.exit(1) - - def show_version(self) -> None: - """Show Tux version information. - - Displays the current version of Tux and related components. - """ - self.rich.print_section("Tux Version Information", "blue") - self.rich.rich_print( - "[bold blue]Showing Tux version information...[/bold blue]", - ) - - try: - from tux import __version__ # type: ignore[attr-defined] # noqa: PLC0415 - - self.rich.rich_print(f"[green]Tux version: {__version__}[/green]") - self.rich.print_success("Version information displayed") - - except ImportError as e: - self.rich.print_error(f"Failed to import version: {e}") - sys.exit(1) - except Exception as e: - self.rich.print_error(f"Failed to show version: {e}") - sys.exit(1) - - -# Create the CLI app instance -app = TuxCLI().app - - -def main() -> None: - """Entry point for the Tux CLI script.""" - cli = TuxCLI() - cli.run() - - -if __name__ == "__main__": - main() diff --git a/scripts/tux/__init__.py b/scripts/tux/__init__.py new file mode 100644 index 000000000..912f44ea8 --- /dev/null +++ b/scripts/tux/__init__.py @@ -0,0 +1,18 @@ +""" +Tux Command Group. + +Aggregates all bot-related operations. +""" + +from scripts.core import create_app +from scripts.tux import start, version + +app = create_app(name="tux", help_text="Bot operations") + +app.add_typer(start.app) +app.add_typer(version.app) + + +def main() -> None: + """Entry point for the tux command group.""" + app() diff --git a/scripts/tux/start.py b/scripts/tux/start.py new file mode 100644 index 000000000..8a0eebd10 --- /dev/null +++ b/scripts/tux/start.py @@ -0,0 +1,49 @@ +""" +Command: tux start. + +Starts the Tux Discord bot. +""" + +import sys +from typing import Annotated + +from typer import Option + +from scripts.core import create_app +from scripts.ui import print_info, print_section, print_success, rich_print +from tux.main import run + +app = create_app() + + +def _run_bot(debug: bool) -> int: + """Run the bot and return the exit code.""" + if debug: + print_info("Debug mode enabled") + + # The run() function in main.py already catches and logs exceptions + # and returns a proper exit code. + return run(debug=debug) + + +@app.command(name="start") +def start( + debug: Annotated[bool, Option("--debug", help="Enable debug mode")] = False, +) -> None: + """Start the Tux Discord bot.""" + print_section("Starting Tux Bot", "blue") + rich_print("[bold blue]Starting Tux Discord bot...[/bold blue]") + + exit_code = _run_bot(debug) + + if exit_code == 0: + print_success("Bot completed successfully") + elif exit_code == 130: + print_info("Bot shutdown requested by user (Ctrl+C)") + # For other exit codes, run() already logged the error + + sys.exit(exit_code) + + +if __name__ == "__main__": + app() diff --git a/scripts/tux/version.py b/scripts/tux/version.py new file mode 100644 index 000000000..be86eaf50 --- /dev/null +++ b/scripts/tux/version.py @@ -0,0 +1,36 @@ +""" +Command: tux version. + +Shows version information for Tux. +""" + +import sys + +from scripts.core import create_app +from scripts.ui import print_error, print_section, print_success, rich_print + +app = create_app() + + +@app.command(name="version") +def show_version() -> None: + """Show Tux version information.""" + print_section("Tux Version Information", "blue") + rich_print("[bold blue]Showing Tux version information...[/bold blue]") + + try: + from tux import __version__ # noqa: PLC0415 # type: ignore[attr-defined] + + rich_print(f"[green]Tux version: {__version__}[/green]") + print_success("Version information displayed") + + except ImportError as e: + print_error(f"Failed to import version: {e}") + sys.exit(1) + except Exception as e: + print_error(f"Failed to show version: {e}") + sys.exit(1) + + +if __name__ == "__main__": + app() diff --git a/scripts/ui.py b/scripts/ui.py new file mode 100644 index 000000000..4d8a2d62d --- /dev/null +++ b/scripts/ui.py @@ -0,0 +1,201 @@ +""" +UI Utilities for CLI. + +Provides functional helpers for consistent Rich-formatted terminal output. +""" + +from typing import Any + +from rich.console import Console +from rich.pretty import pprint +from rich.progress import ( + BarColumn, + Progress, + ProgressColumn, + SpinnerColumn, + TextColumn, +) +from rich.status import Status +from rich.table import Table + +# Shared console instance for all scripts +# We use one instance to avoid interleaving issues when managing the terminal state +console = Console() + + +def print_success(message: str) -> None: + """Print a success message in green.""" + console.print(f"[green]✓[/green] {message}") + + +def print_error(message: str) -> None: + """Print an error message in red.""" + console.print(f"[bold red]✗ Error:[/bold red] {message}") + + +def print_info(message: str) -> None: + """Print an info message in blue.""" + console.print(f"[blue]i[/blue] {message}") + + +def print_warning(message: str) -> None: + """Print a warning message in yellow.""" + console.print(f"[yellow]⚠[/yellow] {message}") + + +def print_section(title: str, color: str = "blue") -> None: + """Print a section title.""" + console.print() + console.print(f"[bold {color}]{title}[/bold {color}]") + + +def rich_print(message: str) -> None: + """Print a rich-formatted string directly.""" + console.print(message) + + +def print_pretty( + obj: Any, + expand_all: bool = False, + max_length: int | None = None, + indent_guides: bool = True, +) -> None: + """ + Pretty print a container (list, dict, set, etc.) using Rich. + + Parameters + ---------- + obj : Any + The object to pretty print. + expand_all : bool, optional + Whether to fully expand all data structures (default is False). + max_length : int | None, optional + Maximum number of elements to show before truncating (default is None). + indent_guides : bool, optional + Whether to show indent guides (default is True). + """ + pprint( + obj, + expand_all=expand_all, + max_length=max_length, + console=console, + indent_guides=indent_guides, + ) + + +def print_json(data: str | Any) -> None: + """ + Pretty print JSON data. + + Parameters + ---------- + data : str | Any + The JSON string or object to print. + """ + if isinstance(data, str): + console.print_json(data) + else: + console.print_json(data=data) + + +def prompt(message: str, password: bool = False) -> str: + """ + Prompt the user for input with rich formatting. + + Parameters + ---------- + message : str + The prompt message. + password : bool, optional + Whether to hide the input (default is False). + + Returns + ------- + str + The user's input. + """ + return console.input(f"[bold blue]?[/bold blue] {message}", password=password) + + +def create_status(message: str, spinner: str = "dots") -> Status: + """ + Create a status context manager with a spinner. + + Parameters + ---------- + message : str + The message to show next to the spinner. + spinner : str, optional + The spinner animation type (default is "dots"). + + Returns + ------- + Status + A Rich Status context manager. + """ + return console.status(message, spinner=spinner) + + +def print_table( + title: str, + columns: list[tuple[str, str]], + data: list[tuple[str, ...]], +) -> None: + """ + Print a Rich table with title, columns, and data. + + Parameters + ---------- + title : str + The title of the table. + columns : list[tuple[str, str]] + List of (column_name, style) tuples. + data : list[tuple[str, ...]] + List of row tuples. + """ + table = Table(title=title) + for column_name, style in columns: + table.add_column(column_name, style=style) + + for row in data: + table.add_row(*[str(item) for item in row]) + + console.print(table) + + +def create_progress_bar( + total: int | None = None, +) -> Progress: + """ + Create a functional Rich progress bar. + + Parameters + ---------- + total : int | None, optional + Total number of steps. If provided, shows percentage and bar. + + Returns + ------- + Progress + A configured Progress instance. + """ + columns: list[ProgressColumn] = [ + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + ] + + if total is not None: + columns.extend( + [ + BarColumn(), + TextColumn("[progress.percentage]{task.percentage:>3.0f}% "), + ], + ) + + columns.append(TextColumn("[progress.elapsed]{task.elapsed:.1f}s ")) + + return Progress( + *columns, + transient=True, + console=console, + ) diff --git a/src/tux/core/app.py b/src/tux/core/app.py index fa1f0f37f..125cc9f6b 100644 --- a/src/tux/core/app.py +++ b/src/tux/core/app.py @@ -20,6 +20,7 @@ from tux.help import TuxHelp from tux.services.sentry import SentryManager, capture_exception_safe from tux.shared.config import CONFIG +from tux.shared.exceptions import TuxSetupError if TYPE_CHECKING: from tux.core.bot import Tux @@ -121,7 +122,7 @@ def run(self) -> int: Raises ------ - RuntimeError + TuxSetupError If a critical application error occurs during startup. """ try: @@ -441,8 +442,14 @@ async def _await_bot_setup(self) -> None: # Wait for setup to complete if self.bot.setup_task: - await self.bot.setup_task - logger.info("Bot setup completed successfully") + try: + await self.bot.setup_task + logger.info("Bot setup completed successfully") + except Exception as e: + # If setup fails, raise TuxSetupError to be caught by run() + msg = f"Bot setup failed: {e}" + logger.critical(msg) + raise TuxSetupError(msg) from e async def _connect_to_gateway(self) -> None: """ diff --git a/src/tux/database/models/base.py b/src/tux/database/models/base.py index 158565cf9..80807a7d5 100644 --- a/src/tux/database/models/base.py +++ b/src/tux/database/models/base.py @@ -13,6 +13,7 @@ from typing import Any, cast from uuid import UUID, uuid4 +import rich.repr from pydantic import field_serializer from sqlalchemy import DateTime, text from sqlmodel import Field, SQLModel # type: ignore[import] @@ -173,6 +174,20 @@ def to_dict( return data + def __rich_repr__(self) -> rich.repr.Result: + """Provide a Rich-formatted representation of the model. + + Yields + ------ + tuple + Positional or keyword arguments for the repr. + """ + # We use __dict__ to get only the current instance's data + # while avoiding full relationship traversal by default + for attr, value in self.__dict__.items(): + if not attr.startswith("_"): + yield attr, value + class UUIDMixin(SQLModel): """ diff --git a/src/tux/main.py b/src/tux/main.py index 87739f6e6..0587a0b7d 100644 --- a/src/tux/main.py +++ b/src/tux/main.py @@ -6,21 +6,29 @@ function that starts the bot with proper lifecycle management. """ -import sys - from loguru import logger from tux.core.app import TuxApp -from tux.shared.exceptions import TuxDatabaseError, TuxError +from tux.shared.exceptions import ( + TuxDatabaseError, + TuxError, + TuxGracefulShutdown, + TuxSetupError, +) -def run() -> int: +def run(debug: bool = False) -> int: # noqa: PLR0911 """ Instantiate and run the Tux application. This function is the entry point for the Tux application. It creates an instance of the TuxApp class. + Parameters + ---------- + debug : bool, optional + Whether to enable debug mode (default is False). + Returns ------- int @@ -28,36 +36,43 @@ def run() -> int: Notes ----- - Logging is configured by the CLI script (scripts/base.py) before this is called. + Logging is configured by the CLI script before this is called. """ try: - logger.info("Starting Tux...") + if debug: + logger.info("Starting Tux in debug mode...") + else: + logger.info("Starting Tux...") + app = TuxApp() return app.run() - except (TuxDatabaseError, TuxError, SystemExit, KeyboardInterrupt, Exception) as e: - # Handle all errors in one place - if isinstance(e, TuxDatabaseError): - logger.error("Database connection failed") - logger.info("To start the database, run: docker compose up") - elif isinstance(e, TuxError): - logger.error(f"Bot startup failed: {e}") - elif isinstance(e, RuntimeError): - logger.critical(f"Application failed to start: {e}") - elif isinstance(e, SystemExit): - return int(e.code) if e.code is not None else 1 - elif isinstance(e, KeyboardInterrupt): - logger.info("Shutdown requested by user") - return 0 - else: - logger.opt(exception=True).critical(f"Application failed to start: {e}") - + except TuxDatabaseError: + logger.error("Database connection failed") + logger.info("To start the database, run: docker compose up -d") return 1 - - else: + except TuxSetupError as e: + logger.error(f"Bot setup failed: {e}") + return 1 + except TuxGracefulShutdown as e: + logger.info(f"Bot shut down gracefully: {e}") return 0 - - -if __name__ == "__main__": - exit_code = run() - sys.exit(exit_code) + except TuxError as e: + logger.error(f"Bot error: {e}") + return 1 + except KeyboardInterrupt: + logger.info("Application interrupted by user") + return 130 + except SystemExit as e: + if e.code is None: + return 1 + if isinstance(e.code, int): + return e.code + logger.warning(f"SystemExit with non-int code: {e.code!r}, returning 1") + return 1 + except RuntimeError: + # RuntimeError is already logged in TuxApp.run() + return 1 + except Exception as e: + logger.opt(exception=True).critical(f"Application failed to start: {e}") + return 1 diff --git a/src/tux/shared/exceptions.py b/src/tux/shared/exceptions.py index 2d137ed73..15d94705a 100644 --- a/src/tux/shared/exceptions.py +++ b/src/tux/shared/exceptions.py @@ -27,6 +27,7 @@ "TuxDependencyResolutionError", "TuxError", "TuxFileWatchError", + "TuxGracefulShutdown", "TuxHotReloadConfigurationError", "TuxHotReloadError", "TuxInvalidCodeFormatError", @@ -37,6 +38,7 @@ "TuxPermissionLevelError", "TuxRuntimeError", "TuxServiceError", + "TuxSetupError", "TuxUnsupportedLanguageError", "handle_case_result", "handle_gather_result", @@ -57,6 +59,14 @@ class TuxRuntimeError(TuxError): """Raised when there's a runtime issue.""" +class TuxSetupError(TuxError): + """Raised when bot setup fails.""" + + +class TuxGracefulShutdown(TuxError): # noqa: N818 + """Raised when bot shuts down gracefully.""" + + # === Database Exceptions === diff --git a/tests/integration/test_database_cli_commands.py b/tests/integration/test_database_cli_commands.py index 85f33163c..e4886407f 100644 --- a/tests/integration/test_database_cli_commands.py +++ b/tests/integration/test_database_cli_commands.py @@ -157,9 +157,8 @@ def test_init_command_fails_on_existing_db(self): """Test that init command properly detects existing database.""" exit_code, stdout, _stderr = self.run_cli_command("init") - assert exit_code == 0 # Command succeeds but shows warning - assert "Database already has" in stdout - assert "tables" in stdout + assert exit_code != 0 # Command should fail when DB/migrations exist + assert "Database initialization blocked" in stdout or "Database already has" in stdout @pytest.mark.integration def test_new_command_help_works(self): @@ -396,6 +395,13 @@ def test_downgrade_without_revision_fails(self): class TestRecoveryScenarios(TestDatabaseCLICommands): """🔧 Test recovery from various failure scenarios.""" + def setup_method(self, method): + """Ensure database is up to date for recovery tests.""" + # Ensure we have a clean, up-to-date database for recovery tests + # Using nuke + push to ensure absolute clean state + self.run_cli_command("nuke --force") + self.run_cli_command("push") + @pytest.mark.integration def test_status_works_after_operations(self): """Test that status command works after various operations."""