From 601e8e2fc397f580e252abc2d2284150e0b6120b Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 06:26:13 -0500 Subject: [PATCH 01/79] feat(config): add configuration management commands for generation and validation - Introduced a new command group for configuration management, including `generate` and `validate` commands. - The `generate` command creates configuration example files in various formats (env, toml, yaml, json, markdown). - The `validate` command checks the validity of configuration files and provides a summary of settings and their sources. - Enhanced user feedback with console output for successful operations and error handling. --- scripts/config/__init__.py | 18 +++++++ scripts/config/generate.py | 103 +++++++++++++++++++++++++++++++++++++ scripts/config/validate.py | 82 +++++++++++++++++++++++++++++ 3 files changed, 203 insertions(+) create mode 100644 scripts/config/__init__.py create mode 100644 scripts/config/generate.py create mode 100644 scripts/config/validate.py 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..1eb234f9c --- /dev/null +++ b/scripts/config/generate.py @@ -0,0 +1,103 @@ +""" +Command: config generate. + +Generates configuration example files. +""" + +import subprocess +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.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", + 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.""" + console.print(Panel.fit("Configuration Generator", style="bold blue")) + + if output is not None: + console.print( + "Custom output paths are not supported when using CLI approach", + style="red", + ) + console.print( + "Use pyproject.toml configuration to specify custom paths", + style="yellow", + ) + raise Exit(code=1) + + 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_, []) + + for generator in formats_to_generate: + 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: + console.print(f"Output: {result.stdout.strip()}", style="dim") + except subprocess.CalledProcessError as e: + console.print(f"Error running {generator}: {e}", style="red") + if e.stdout: + console.print(f"Stdout: {e.stdout}", style="dim") + if e.stderr: + console.print(f"Stderr: {e.stderr}", 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..ff9243035 --- /dev/null +++ b/scripts/config/validate.py @@ -0,0 +1,82 @@ +""" +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_progress_bar +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_progress_bar("Validating configuration...") as progress: + progress.add_task("Loading settings...", total=None) + config = Config() # pyright: ignore[reportCallIssue] + + 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 "✗", + ) + 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, "✓") + + 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() From 139e253b889956cdc8c5618af2a3ec89a8977be7 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 06:26:28 -0500 Subject: [PATCH 02/79] feat(db): introduce comprehensive database command group - Added a new command group for database operations, encapsulating various commands such as `init`, `check`, `dev`, `downgrade`, `health`, `history`, `new`, `nuke`, `push`, `reset`, `schema`, `show`, `status`, `tables`, and `version`. - Each command provides specific functionalities for managing database migrations, health checks, and schema validation. - Enhanced user feedback with informative console outputs for successful operations and error handling. --- scripts/db/__init__.py | 49 +++++++++++++++++ scripts/db/check.py | 28 ++++++++++ scripts/db/dev.py | 70 ++++++++++++++++++++++++ scripts/db/downgrade.py | 52 ++++++++++++++++++ scripts/db/health.py | 61 +++++++++++++++++++++ scripts/db/history.py | 28 ++++++++++ scripts/db/init.py | 101 +++++++++++++++++++++++++++++++++++ scripts/db/new.py | 48 +++++++++++++++++ scripts/db/nuke.py | 115 ++++++++++++++++++++++++++++++++++++++++ scripts/db/push.py | 28 ++++++++++ scripts/db/queries.py | 82 ++++++++++++++++++++++++++++ scripts/db/reset.py | 30 +++++++++++ scripts/db/schema.py | 80 ++++++++++++++++++++++++++++ scripts/db/show.py | 39 ++++++++++++++ scripts/db/status.py | 33 ++++++++++++ scripts/db/tables.py | 66 +++++++++++++++++++++++ scripts/db/version.py | 41 ++++++++++++++ 17 files changed, 951 insertions(+) create mode 100644 scripts/db/__init__.py create mode 100644 scripts/db/check.py create mode 100644 scripts/db/dev.py create mode 100644 scripts/db/downgrade.py create mode 100644 scripts/db/health.py create mode 100644 scripts/db/history.py create mode 100644 scripts/db/init.py create mode 100644 scripts/db/new.py create mode 100644 scripts/db/nuke.py create mode 100644 scripts/db/push.py create mode 100644 scripts/db/queries.py create mode 100644 scripts/db/reset.py create mode 100644 scripts/db/schema.py create mode 100644 scripts/db/show.py create mode 100644 scripts/db/status.py create mode 100644 scripts/db/tables.py create mode 100644 scripts/db/version.py 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..673cd666f --- /dev/null +++ b/scripts/db/check.py @@ -0,0 +1,28 @@ +""" +Command: db check. + +Validates migration files. +""" + +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 Exception: + print_error("Migration validation failed - check your migration files") + + +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..5e950761d --- /dev/null +++ b/scripts/db/downgrade.py @@ -0,0 +1,52 @@ +""" +Command: db downgrade. + +Rolls back to a previous migration revision. +""" + +from typing import Annotated + +from typer import Argument, 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, 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 revision != "-1": + response = input(f"Type 'yes' to downgrade to {revision}: ") + if response.lower() != "yes": + print_info("Downgrade cancelled") + return + + try: + run_command(["uv", "run", "alembic", "downgrade", revision]) + print_success(f"Successfully downgraded to revision: {revision}") + except Exception: + print_error(f"Failed to downgrade to revision: {revision}") + + +if __name__ == "__main__": + app() diff --git a/scripts/db/health.py b/scripts/db/health.py new file mode 100644 index 000000000..2e72680cd --- /dev/null +++ b/scripts/db/health.py @@ -0,0 +1,61 @@ +""" +Command: db health. + +Checks database connection and health status. +""" + +import asyncio + +from scripts.core import create_app +from scripts.ui import ( + create_progress_bar, + 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="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(): + try: + with create_progress_bar("Connecting to database...") as progress: + progress.add_task("Checking database health...", total=None) + service = DatabaseService(echo=False) + await service.connect(CONFIG.database_url) + health_data = await service.health_check() + await service.disconnect() + + if health_data["status"] == "healthy": + rich_print("[green]Database is healthy![/green]") + rich_print( + f"[green]Connection: {health_data.get('connection', 'OK')}[/green]", + ) + rich_print( + f"[green]Response time: {health_data.get('response_time', 'N/A')}[/green]", + ) + else: + rich_print("[red]Database is unhealthy![/red]") + rich_print( + f"[red]Error: {health_data.get('error', 'Unknown error')}[/red]", + ) + + print_success("Health check completed") + + except Exception as e: + print_error(f"Failed to check database health: {e}") + + 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..b96e29c7e --- /dev/null +++ b/scripts/db/history.py @@ -0,0 +1,28 @@ +""" +Command: db history. + +Shows migration history. +""" + +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 Exception: + print_error("Failed to get migration history") + + +if __name__ == "__main__": + app() diff --git a/scripts/db/init.py b/scripts/db/init.py new file mode 100644 index 000000000..bfab9906e --- /dev/null +++ b/scripts/db/init.py @@ -0,0 +1,101 @@ +""" +Command: db init. + +Initializes the database with a proper initial migration. +""" + +import asyncio +import pathlib + +from sqlalchemy import text + +from scripts.core import create_app +from scripts.proc import run_command +from scripts.ui import 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="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") + + async def _check_tables(): + try: + service = DatabaseService(echo=False) + await service.connect(CONFIG.database_url) + 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 + + async def _check_migrations(): + try: + service = DatabaseService(echo=False) + await service.connect(CONFIG.database_url) + 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: + return 0 + else: + return migration_count + + table_count = asyncio.run(_check_tables()) + migration_count = asyncio.run(_check_migrations()) + + migration_dir = pathlib.Path("src/tux/database/migrations/versions") + migration_files = list(migration_dir.glob("*.py")) if migration_dir.exists() else [] + 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: + rich_print( + f"[red]Database already has {table_count} tables, {migration_count} migrations in DB, and {migration_file_count} migration files![/red]", + ) + rich_print( + "[yellow]'db init' only works on completely empty databases with no migration files.[/yellow]", + ) + return + + 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: + print_error("Failed to initialize database") + + +if __name__ == "__main__": + app() diff --git a/scripts/db/new.py b/scripts/db/new.py new file mode 100644 index 000000000..8179347fa --- /dev/null +++ b/scripts/db/new.py @@ -0,0 +1,48 @@ +""" +Command: db new. + +Generates a new migration from model changes. +""" + +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 Exception: + print_error("Failed to generate migration") + raise Exit(1) from None + + +if __name__ == "__main__": + app() diff --git a/scripts/db/nuke.py b/scripts/db/nuke.py new file mode 100644 index 000000000..f5c06239f --- /dev/null +++ b/scripts/db/nuke.py @@ -0,0 +1,115 @@ +""" +Command: db nuke. + +Complete database reset (destructive). +""" + +import asyncio +import pathlib +import sys +import traceback +from typing import Annotated, Any + +from sqlalchemy import text +from typer import Option + +from scripts.core import create_app +from scripts.ui import 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="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.""" + 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", + ) + return + + response = input("Type 'NUKE' to confirm (case sensitive): ") + if response != "NUKE": + print_info("Nuclear reset cancelled") + return + + async def _nuclear_reset(): + try: + service = DatabaseService(echo=False) + await service.connect(CONFIG.database_url) + + async def _drop_all_tables(session: Any) -> None: + await session.execute(text("DROP TABLE IF EXISTS alembic_version")) + 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() + + rich_print("[yellow]Dropping all tables and schema...[/yellow]") + await service.execute_query(_drop_all_tables, "drop_all_tables") + await service.disconnect() + + print_success("Nuclear reset completed - database is completely empty") + + if fresh: + migration_dir = pathlib.Path("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": + migration_file.unlink() + deleted_count += 1 + print_success(f"Deleted {deleted_count} 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() + + asyncio.run(_nuclear_reset()) + + +if __name__ == "__main__": + app() diff --git a/scripts/db/push.py b/scripts/db/push.py new file mode 100644 index 000000000..72506003f --- /dev/null +++ b/scripts/db/push.py @@ -0,0 +1,28 @@ +""" +Command: db push. + +Applies pending migrations to the database. +""" + +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 Exception: + print_error("Failed to apply migrations") + + +if __name__ == "__main__": + app() diff --git a/scripts/db/queries.py b/scripts/db/queries.py new file mode 100644 index 000000000..ee2116d2d --- /dev/null +++ b/scripts/db/queries.py @@ -0,0 +1,82 @@ +""" +Command: db queries. + +Checks for long-running database queries. +""" + +import asyncio +from typing import Any + +from sqlalchemy import text + +from scripts.core import create_app +from scripts.ui import ( + create_progress_bar, + 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="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(): + try: + with create_progress_bar("Analyzing queries...") as progress: + progress.add_task("Checking for long-running queries...", total=None) + service = DatabaseService(echo=False) + await service.connect(CONFIG.database_url) + + async def _get_long_queries( + session: Any, + ) -> list[tuple[Any, Any, str, str]]: + 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", + ) + await service.disconnect() + + if long_queries: + rich_print( + f"[yellow]Found {len(long_queries)} long-running queries:[/yellow]", + ) + for pid, duration, query_text, state in long_queries: + rich_print(f"[red]PID {pid}[/red]: {state} for {duration}") + rich_print(f" Query: {query_text[:100]}...") + 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}") + + 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..dc265f33a --- /dev/null +++ b/scripts/db/reset.py @@ -0,0 +1,30 @@ +""" +Command: db reset. + +Resets the database to a clean state via migrations. +""" + +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"]) + run_command(["uv", "run", "alembic", "upgrade", "head"]) + print_success("Database reset and migrations reapplied!") + except Exception: + print_error("Failed to reset database") + + +if __name__ == "__main__": + app() diff --git a/scripts/db/schema.py b/scripts/db/schema.py new file mode 100644 index 000000000..1b1b883f9 --- /dev/null +++ b/scripts/db/schema.py @@ -0,0 +1,80 @@ +""" +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_progress_bar, + print_error, + 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="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(): + try: + with create_progress_bar("Validating schema...") as progress: + progress.add_task("Validating schema against models...", total=None) + service = DatabaseService(echo=False) + await service.connect(CONFIG.database_url) + schema_result = await service.validate_schema() + await service.disconnect() + + 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", + ) + + _fail() + + 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 + + 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..93cb56ef5 --- /dev/null +++ b/scripts/db/show.py @@ -0,0 +1,39 @@ +""" +Command: db show. + +Shows details of a specific migration. +""" + +from typing import Annotated + +from typer import Argument + +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 Exception: + print_error(f"Failed to show migration: {revision}") + + +if __name__ == "__main__": + app() diff --git a/scripts/db/status.py b/scripts/db/status.py new file mode 100644 index 000000000..682d3c21b --- /dev/null +++ b/scripts/db/status.py @@ -0,0 +1,33 @@ +""" +Command: db status. + +Shows current migration status. +""" + +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: + print_error("Failed to get migration status") + + +if __name__ == "__main__": + app() diff --git a/scripts/db/tables.py b/scripts/db/tables.py new file mode 100644 index 000000000..b21aba6b9 --- /dev/null +++ b/scripts/db/tables.py @@ -0,0 +1,66 @@ +""" +Command: db tables. + +Lists all database tables and their structure. +""" + +import asyncio +from typing import Any + +from sqlalchemy import text + +from scripts.core import create_app +from scripts.ui import 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(): + try: + service = DatabaseService(echo=False) + 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 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_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]") + for table_name, column_count in tables_data: + rich_print(f"[cyan]{table_name}[/cyan]: {column_count} columns") + + await service.disconnect() + print_success("Database tables listed") + + except Exception as e: + print_error(f"Failed to list database tables: {e}") + + 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..e9b1b2f73 --- /dev/null +++ b/scripts/db/version.py @@ -0,0 +1,41 @@ +""" +Command: db version. + +Shows version information for database components. +""" + +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 Exception: + print_error("Failed to get version information") + + +if __name__ == "__main__": + app() From 80adc4c9a97e5ef7ecedcbf28d010980d6a21eb4 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 06:26:39 -0500 Subject: [PATCH 03/79] feat(dev): introduce comprehensive development command group - Added a new command group for development tools, encapsulating commands such as `lint`, `lint-fix`, `format`, `type-check`, `docstring-coverage`, `pre-commit`, `clean`, and `all`. - Each command provides specific functionalities for code quality checks, formatting, and cleaning up project artifacts. - Enhanced user feedback with informative console outputs for successful operations and error handling. --- scripts/dev/__init__.py | 39 +++++++++ scripts/dev/all.py | 105 ++++++++++++++++++++++ scripts/dev/clean.py | 141 ++++++++++++++++++++++++++++++ scripts/dev/docstring_coverage.py | 29 ++++++ scripts/dev/format.py | 30 +++++++ scripts/dev/lint.py | 41 +++++++++ scripts/dev/lint_docstring.py | 30 +++++++ scripts/dev/lint_fix.py | 30 +++++++ scripts/dev/pre_commit.py | 30 +++++++ scripts/dev/type_check.py | 30 +++++++ 10 files changed, 505 insertions(+) create mode 100644 scripts/dev/__init__.py create mode 100644 scripts/dev/all.py create mode 100644 scripts/dev/clean.py create mode 100644 scripts/dev/docstring_coverage.py create mode 100644 scripts/dev/format.py create mode 100644 scripts/dev/lint.py create mode 100644 scripts/dev/lint_docstring.py create mode 100644 scripts/dev/lint_fix.py create mode 100644 scripts/dev/pre_commit.py create mode 100644 scripts/dev/type_check.py 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..a770a1910 --- /dev/null +++ b/scripts/dev/all.py @@ -0,0 +1,105 @@ +""" +Command: dev all. + +Runs all development checks including linting, type checking, and documentation. +""" + +from collections.abc import Callable +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.lint_fix import lint_fix +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() + + +@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[tuple[str, Callable[[], None]]] = [ + ("Linting", lint_fix if fix else lint), + ("Code Formatting", format_code), + ("Type Checking", type_check), + ("Docstring Linting", lint_docstring), + ("Pre-commit Checks", pre_commit), + ] + + results: list[tuple[str, bool]] = [] + + with 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() + + try: + # Note: These functions call sys.exit(1) on failure. + # In a combined run, we might want them to raise an exception instead. + # For now, we'll try to catch exceptions if any, but they might still exit. + check_func() + results.append((check_name, True)) + except SystemExit as e: + if e.code == 0: + results.append((check_name, True)) + else: + results.append((check_name, False)) + except Exception: + results.append((check_name, False)) + + progress.advance(task) + progress.refresh() + + rich_print("") + print_section("Development Checks Summary", "blue") + + passed = sum(bool(success) for _, success in results) + total = len(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 + ] + + print_table( + "", + [("Check", "cyan"), ("Status", "green"), ("Details", "white")], + table_data, + ) + + rich_print("") + if passed == total: + print_success(f"All {total} checks passed!") + else: + print_error(f"{passed}/{total} checks passed") + 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..4e6aa54a2 --- /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 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 = Path(__file__).parent.parent.parent + 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( + "Cleaning Project...", + 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 + + matches = [ + m + for m in matches + if all(protected not in str(m) for protected in protected_dirs) + ] + + 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..c9ec3c359 --- /dev/null +++ b/scripts/dev/docstring_coverage.py @@ -0,0 +1,29 @@ +""" +Command: dev docstring-coverage. + +Checks docstring coverage across the codebase. +""" + +from scripts.core import create_app +from scripts.proc import run_command +from scripts.ui import 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 Exception: + # docstr-coverage might return non-zero if coverage is below threshold + # but we still want to show the report + pass + + +if __name__ == "__main__": + app() diff --git a/scripts/dev/format.py b/scripts/dev/format.py new file mode 100644 index 000000000..c0eb4f608 --- /dev/null +++ b/scripts/dev/format.py @@ -0,0 +1,30 @@ +""" +Command: dev format. + +Formats code using Ruff's formatter. +""" + +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="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 Exception: + print_error("Code formatting did not pass - see issues above") + 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..60d0f48ad --- /dev/null +++ b/scripts/dev/lint.py @@ -0,0 +1,41 @@ +""" +Command: dev lint. + +Runs linting checks with Ruff. +""" + +import sys +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 Exception: + print_error("Linting did not pass - see issues above") + 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..5963e2178 --- /dev/null +++ b/scripts/dev/lint_docstring.py @@ -0,0 +1,30 @@ +""" +Command: dev lint-docstring. + +Lints docstrings for proper formatting. +""" + +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-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 Exception: + print_error("Docstring linting did not pass - see issues above") + 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..9f0738f32 --- /dev/null +++ b/scripts/dev/pre_commit.py @@ -0,0 +1,30 @@ +""" +Command: dev pre-commit. + +Runs pre-commit hooks. +""" + +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="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 Exception: + print_error("Pre-commit checks did not pass - see issues above") + 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..beeb4f853 --- /dev/null +++ b/scripts/dev/type_check.py @@ -0,0 +1,30 @@ +""" +Command: dev type-check. + +Performs static type checking using basedpyright. +""" + +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="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 Exception: + print_error("Type checking did not pass - see issues above") + sys.exit(1) + + +if __name__ == "__main__": + app() From ce949969f88190a18166276a6386c5d314a92395 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 06:26:53 -0500 Subject: [PATCH 04/79] feat(docs): introduce comprehensive documentation command group - Added a new command group for documentation operations, encapsulating commands such as `build`, `lint`, `serve`, and various Wrangler commands (`deploy`, `deployments`, `dev`, `rollback`, `tail`, `versions`). - Each command provides specific functionalities for building, serving, and managing documentation, as well as deploying to Cloudflare Workers. - Enhanced user feedback with informative console outputs for successful operations and error handling. --- scripts/docs/__init__.py | 35 ++++++++++++++ scripts/docs/build.py | 62 +++++++++++++++++++++++++ scripts/docs/lint.py | 64 ++++++++++++++++++++++++++ scripts/docs/serve.py | 69 ++++++++++++++++++++++++++++ scripts/docs/wrangler_deploy.py | 56 ++++++++++++++++++++++ scripts/docs/wrangler_deployments.py | 45 ++++++++++++++++++ scripts/docs/wrangler_dev.py | 53 +++++++++++++++++++++ scripts/docs/wrangler_rollback.py | 57 +++++++++++++++++++++++ scripts/docs/wrangler_tail.py | 57 +++++++++++++++++++++++ scripts/docs/wrangler_versions.py | 56 ++++++++++++++++++++++ 10 files changed, 554 insertions(+) create mode 100644 scripts/docs/__init__.py create mode 100644 scripts/docs/build.py create mode 100644 scripts/docs/lint.py create mode 100644 scripts/docs/serve.py create mode 100644 scripts/docs/wrangler_deploy.py create mode 100644 scripts/docs/wrangler_deployments.py create mode 100644 scripts/docs/wrangler_dev.py create mode 100644 scripts/docs/wrangler_rollback.py create mode 100644 scripts/docs/wrangler_tail.py create mode 100644 scripts/docs/wrangler_versions.py 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..810434869 --- /dev/null +++ b/scripts/docs/build.py @@ -0,0 +1,62 @@ +""" +Command: docs build. + +Builds documentation site for production. +""" + +import os +import subprocess +from pathlib import Path +from typing import Annotated + +from typer import Option + +from scripts.core import create_app +from scripts.ui import print_error, print_info, print_section, print_success + +app = create_app() + + +def _find_zensical_config() -> str | None: + current_dir = Path.cwd() + if (current_dir / "zensical.toml").exists(): + return "zensical.toml" + print_error("Can't find zensical.toml file. Please run from the project root.") + return None + + +@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 (currently unsupported)"), + ] = False, +) -> None: + """Build documentation site for production.""" + print_section("Building Documentation", "blue") + + if not _find_zensical_config(): + return + + cmd = ["uv", "run", "zensical", "build"] + if clean: + cmd.append("--clean") + if strict: + cmd.append("--strict") + + try: + print_info("Building documentation...") + subprocess.run(cmd, check=True, env=os.environ.copy()) + print_success("Documentation built successfully") + except subprocess.CalledProcessError: + print_error("Failed to build documentation") + except KeyboardInterrupt: + print_info("\nBuild interrupted") + + +if __name__ == "__main__": + app() diff --git a/scripts/docs/lint.py b/scripts/docs/lint.py new file mode 100644 index 000000000..2efacf527 --- /dev/null +++ b/scripts/docs/lint.py @@ -0,0 +1,64 @@ +""" +Command: docs lint. + +Lints documentation files. +""" + +from pathlib import Path + +from scripts.core import create_app +from scripts.ui import ( + create_progress_bar, + print_error, + 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") + return + + all_md_files = list(docs_dir.rglob("*.md")) + issues: list[str] = [] + + with create_progress_bar( + "Scanning Documentation...", + 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() + 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}") + + progress.advance(task) + + if issues: + print_warning("\nDocumentation linting issues found:") + for issue in issues: + print_warning(f" • {issue}") + else: + print_success("No documentation linting issues found") + + +if __name__ == "__main__": + app() diff --git a/scripts/docs/serve.py b/scripts/docs/serve.py new file mode 100644 index 000000000..516ffb788 --- /dev/null +++ b/scripts/docs/serve.py @@ -0,0 +1,69 @@ +""" +Command: docs serve. + +Serves documentation locally with live reload. +""" + +import os +import subprocess +from pathlib import Path +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() + + +def _find_zensical_config() -> str | None: + current_dir = Path.cwd() + if (current_dir / "zensical.toml").exists(): + return "zensical.toml" + print_error("Can't find zensical.toml file. Please run from the project root.") + return None + + +@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 (currently unsupported)"), + ] = False, +) -> None: + """Serve documentation locally with live reload.""" + print_section("Serving Documentation", "blue") + + if not _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: + print_info(f"Starting documentation server at {dev_addr}") + subprocess.run(cmd, check=True, env=os.environ.copy()) + except subprocess.CalledProcessError: + print_error("Failed to start documentation server") + except KeyboardInterrupt: + print_info("\nDocumentation server stopped") + + +if __name__ == "__main__": + app() diff --git a/scripts/docs/wrangler_deploy.py b/scripts/docs/wrangler_deploy.py new file mode 100644 index 000000000..b037c84a5 --- /dev/null +++ b/scripts/docs/wrangler_deploy.py @@ -0,0 +1,56 @@ +""" +Command: docs wrangler-deploy. + +Deploys documentation to Cloudflare Workers. +""" + +from pathlib import Path +from typing import Annotated + +from typer import Option + +from scripts.core import create_app +from scripts.docs.build import build +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 Path("wrangler.toml").exists(): + print_error("wrangler.toml not found. Please run from the project root.") + return + + print_info("Building documentation...") + + build(strict=False) + + 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"Error: {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..fc3c1cd31 --- /dev/null +++ b/scripts/docs/wrangler_deployments.py @@ -0,0 +1,45 @@ +""" +Command: docs wrangler-deployments. + +Lists deployment history. +""" + +from pathlib import Path +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_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 Path("wrangler.toml").exists(): + 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: + run_command(cmd, capture_output=False) + print_success("Deployment history retrieved") + except Exception as e: + print_error(f"Error: {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..71aff4811 --- /dev/null +++ b/scripts/docs/wrangler_dev.py @@ -0,0 +1,53 @@ +""" +Command: docs wrangler-dev. + +Starts local Wrangler development server. +""" + +from pathlib import Path +from typing import Annotated + +from typer import Option + +from scripts.core import create_app +from scripts.docs.build import build +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-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 Path("wrangler.toml").exists(): + print_error("wrangler.toml not found. Please run from the project root.") + return + + print_info("Building documentation...") + + build(strict=True) + + cmd = ["wrangler", "dev", f"--port={port}"] + if remote: + cmd.append("--remote") + + print_info(f"Starting Wrangler dev server on port {port}...") + + try: + run_command(cmd, capture_output=False) + print_success(f"Wrangler dev server started at http://localhost:{port}") + except Exception as e: + print_error(f"Error: {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..88ab53917 --- /dev/null +++ b/scripts/docs/wrangler_rollback.py @@ -0,0 +1,57 @@ +""" +Command: docs wrangler-rollback. + +Rolls back to a previous deployment. +""" + +from pathlib import Path +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_section, print_success, print_warning + +app = create_app() + + +@app.command(name="wrangler-rollback") +def wrangler_rollback( + 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.""" + print_section("Rolling Back Deployment", "blue") + + if not Path("wrangler.toml").exists(): + print_error("wrangler.toml not found. Please run from the project root.") + return + + if not version_id: + 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]) + + 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}") + + +if __name__ == "__main__": + app() diff --git a/scripts/docs/wrangler_tail.py b/scripts/docs/wrangler_tail.py new file mode 100644 index 000000000..15c15a5fa --- /dev/null +++ b/scripts/docs/wrangler_tail.py @@ -0,0 +1,57 @@ +""" +Command: docs wrangler-tail. + +Views real-time logs from deployed docs. +""" + +import os +import subprocess +from pathlib import Path +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="wrangler-tail") +def wrangler_tail( + 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.""" + print_section("Tailing Logs", "blue") + + if not Path("wrangler.toml").exists(): + 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]) + + print_info("Starting log tail... (Ctrl+C to stop)") + + try: + subprocess.run(cmd, check=True, env=os.environ.copy()) + except subprocess.CalledProcessError: + print_error("Failed to tail logs") + except KeyboardInterrupt: + print_info("\nLog tail stopped") + except Exception as e: + print_error(f"Error: {e}") + + +if __name__ == "__main__": + app() diff --git a/scripts/docs/wrangler_versions.py b/scripts/docs/wrangler_versions.py new file mode 100644 index 000000000..e82fa014e --- /dev/null +++ b/scripts/docs/wrangler_versions.py @@ -0,0 +1,56 @@ +""" +Command: docs wrangler-versions. + +Lists and manages versions. +""" + +from pathlib import Path +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_section, print_success + +app = create_app() + + +@app.command(name="wrangler-versions") +def wrangler_versions( + 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.""" + print_section("Managing Versions", "blue") + + if not Path("wrangler.toml").exists(): + 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: + run_command(cmd, capture_output=False) + print_success(f"Version {action} completed") + except Exception as e: + print_error(f"Error: {e}") + + +if __name__ == "__main__": + app() From fa3fd9f82f1904b82ea57f0337640cad4f7efb55 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 06:27:01 -0500 Subject: [PATCH 05/79] feat(test): introduce comprehensive testing command group - Added a new command group for testing operations, encapsulating commands such as `all`, `benchmark`, `coverage`, `file`, `html`, `parallel`, `plain`, and `quick`. - Each command provides specific functionalities for running tests, generating reports, and managing coverage. - Enhanced user feedback with informative console outputs for test execution and results. --- scripts/test/__init__.py | 45 +++++++++++++++++++++ scripts/test/all.py | 25 ++++++++++++ scripts/test/benchmark.py | 25 ++++++++++++ scripts/test/coverage.py | 85 +++++++++++++++++++++++++++++++++++++++ scripts/test/file.py | 41 +++++++++++++++++++ scripts/test/html.py | 52 ++++++++++++++++++++++++ scripts/test/parallel.py | 53 ++++++++++++++++++++++++ scripts/test/plain.py | 25 ++++++++++++ scripts/test/quick.py | 25 ++++++++++++ 9 files changed, 376 insertions(+) create mode 100644 scripts/test/__init__.py create mode 100644 scripts/test/all.py create mode 100644 scripts/test/benchmark.py create mode 100644 scripts/test/coverage.py create mode 100644 scripts/test/file.py create mode 100644 scripts/test/html.py create mode 100644 scripts/test/parallel.py create mode 100644 scripts/test/plain.py create mode 100644 scripts/test/quick.py 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..685a1dc2a --- /dev/null +++ b/scripts/test/benchmark.py @@ -0,0 +1,25 @@ +""" +Command: test benchmark. + +Runs benchmark tests. +""" + +import os + +from scripts.core import create_app +from scripts.ui import 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)}") + os.execvp(cmd[0], cmd) + + +if __name__ == "__main__": + app() diff --git a/scripts/test/coverage.py b/scripts/test/coverage.py new file mode 100644 index 000000000..50c27225f --- /dev/null +++ b/scripts/test/coverage.py @@ -0,0 +1,85 @@ +""" +Command: test coverage. + +Generates coverage reports. +""" + +import webbrowser +from pathlib import Path +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_info, print_section + +app = create_app() + + +@app.command(name="coverage") +def coverage_report( + 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.""" + print_section("Coverage Report", "blue") + + cmd = ["uv", "run", "pytest"] + + if specific: + cmd.append(f"--cov={specific}") + + 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 _: + pass + + if fail_under: + cmd.extend(["--cov-fail-under", fail_under]) + + print_info(f"Running: {' '.join(cmd)}") + + try: + run_command(cmd, capture_output=False) + + if open_browser and format_type == "html": + html_report_path = Path("htmlcov/index.html") + if html_report_path.exists(): + print_info("Opening HTML coverage report in browser...") + webbrowser.open(f"file://{html_report_path.resolve()}") + except Exception: + pass + + +if __name__ == "__main__": + app() diff --git a/scripts/test/file.py b/scripts/test/file.py new file mode 100644 index 000000000..ba0e0eddb --- /dev/null +++ b/scripts/test/file.py @@ -0,0 +1,41 @@ +""" +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_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)}") + os.execvp(cmd[0], cmd) + + +if __name__ == "__main__": + app() diff --git a/scripts/test/html.py b/scripts/test/html.py new file mode 100644 index 000000000..0ff56cc67 --- /dev/null +++ b/scripts/test/html.py @@ -0,0 +1,52 @@ +""" +Command: test html. + +Runs tests and generates an HTML report. +""" + +import webbrowser +from pathlib import Path +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_info, print_section + +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") + cmd = [ + "uv", + "run", + "pytest", + "--cov-report=html", + "--html=reports/test_report.html", + "--self-contained-html", + ] + + print_info(f"Running: {' '.join(cmd)}") + + try: + run_command(cmd, capture_output=False) + if open_browser: + html_report_path = Path("htmlcov/index.html") + if html_report_path.exists(): + print_info("Opening HTML coverage report in browser...") + webbrowser.open(f"file://{html_report_path.resolve()}") + except Exception: + pass + + +if __name__ == "__main__": + app() diff --git a/scripts/test/parallel.py b/scripts/test/parallel.py new file mode 100644 index 000000000..5c75079d9 --- /dev/null +++ b/scripts/test/parallel.py @@ -0,0 +1,53 @@ +""" +Command: test parallel. + +Runs tests in parallel using pytest-xdist. +""" + +import os +from typing import Annotated + +from typer import Option + +from scripts.core import create_app +from scripts.ui import 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="Load balancing scope: module, class, or function (default: module)", + ), + ] = None, +) -> None: + """Run tests in parallel using pytest-xdist.""" + 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]) + + print_info(f"Running: {' '.join(cmd)}") + os.execvp(cmd[0], cmd) + + +if __name__ == "__main__": + app() diff --git a/scripts/test/plain.py b/scripts/test/plain.py new file mode 100644 index 000000000..90ee42e2f --- /dev/null +++ b/scripts/test/plain.py @@ -0,0 +1,25 @@ +""" +Command: test plain. + +Runs tests with plain output. +""" + +import os + +from scripts.core import create_app +from scripts.ui import 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)}") + os.execvp(cmd[0], cmd) + + +if __name__ == "__main__": + app() diff --git a/scripts/test/quick.py b/scripts/test/quick.py new file mode 100644 index 000000000..9edc3d645 --- /dev/null +++ b/scripts/test/quick.py @@ -0,0 +1,25 @@ +""" +Command: test quick. + +Runs tests without coverage (faster). +""" + +import os + +from scripts.core import create_app +from scripts.ui import 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)}") + os.execvp(cmd[0], cmd) + + +if __name__ == "__main__": + app() From 3c20b1e3fc9f3a2f2b8e9a253d5cf42e1aaea5b3 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 06:27:08 -0500 Subject: [PATCH 06/79] feat(tux): introduce Tux command group with start and version commands - Added a new command group for Tux bot operations, encapsulating `start` and `version` commands. - The `start` command initiates the Tux Discord bot with options for debug mode and provides user feedback on the bot's status. - The `version` command displays the current version of the Tux bot, enhancing user experience with informative console outputs. --- scripts/tux/__init__.py | 18 ++++++++++++ scripts/tux/start.py | 61 +++++++++++++++++++++++++++++++++++++++++ scripts/tux/version.py | 36 ++++++++++++++++++++++++ 3 files changed, 115 insertions(+) create mode 100644 scripts/tux/__init__.py create mode 100644 scripts/tux/start.py create mode 100644 scripts/tux/version.py 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..4ee3ce012 --- /dev/null +++ b/scripts/tux/start.py @@ -0,0 +1,61 @@ +""" +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_error, print_info, print_section, print_success, rich_print +from tux.main import run + +app = create_app() + + +@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]") + + try: + if debug: + print_info("Debug mode enabled") + + exit_code = run() + if exit_code == 0: + print_success("Bot started successfully") + elif exit_code == 130: + print_info("Bot shutdown requested by user (Ctrl+C)") + else: + print_error(f"Bot exited with code {exit_code}") + sys.exit(exit_code) + + except RuntimeError as e: + if "setup failed" in str(e).lower(): + print_error("Bot setup failed") + sys.exit(1) + elif "Event loop stopped before Future completed" in str(e): + print_info("Bot shutdown completed") + sys.exit(0) + else: + print_error(f"Runtime error: {e}") + sys.exit(1) + except SystemExit as e: + sys.exit(e.code) + except KeyboardInterrupt: + print_info("Bot shutdown requested by user (Ctrl+C)") + sys.exit(130) + except Exception as e: + print_error(f"Failed to start bot: {e}") + sys.exit(1) + + +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() From c89ab6e72e14777049c7565c1c19814bcffba454 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 06:27:29 -0500 Subject: [PATCH 07/79] feat(cli): unify CLI entry point and streamline command structure - Refactored the CLI infrastructure to create a unified entry point for all commands, enhancing organization and usability. - Removed the BaseCLI, CLI, and Registry modules, consolidating functionality into a new core structure. - Introduced a new `create_app` function for standardized Typer application creation and command group registration. - Added process management utilities for running shell commands and managing subprocesses. - Enhanced user feedback with consistent console output across all command groups. --- scripts/__init__.py | 54 +++++------ scripts/base.py | 210 ------------------------------------------ scripts/cli.py | 95 ------------------- scripts/core.py | 66 +++++++++++++ scripts/proc.py | 63 +++++++++++++ scripts/registry.py | 189 ------------------------------------- scripts/rich_utils.py | 106 --------------------- scripts/ui.py | 110 ++++++++++++++++++++++ 8 files changed, 267 insertions(+), 626 deletions(-) delete mode 100644 scripts/base.py delete mode 100644 scripts/cli.py create mode 100644 scripts/core.py create mode 100644 scripts/proc.py delete mode 100644 scripts/registry.py delete mode 100644 scripts/rich_utils.py create mode 100644 scripts/ui.py 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/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/proc.py b/scripts/proc.py new file mode 100644 index 000000000..c92caaff8 --- /dev/null +++ b/scripts/proc.py @@ -0,0 +1,63 @@ +""" +Process Management Utilities for CLI. + +Provides helpers for running shell commands and managing subprocesses. +""" + +import os +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() + + try: + result = subprocess.run( + command, + check=check, + capture_output=capture_output, + text=True, + env=run_env, + ) + + if capture_output and result.stdout: + console.print(result.stdout.strip()) + + except subprocess.CalledProcessError as e: + print_error(f"Command failed: {' '.join(command)}") + if e.stderr: + console.print(f"[red]{e.stderr.strip()}[/red]") + 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/ui.py b/scripts/ui.py new file mode 100644 index 000000000..e9bbab997 --- /dev/null +++ b/scripts/ui.py @@ -0,0 +1,110 @@ +""" +UI Utilities for CLI. + +Provides functional helpers for consistent Rich-formatted terminal output. +""" + +from rich.console import Console +from rich.progress import BarColumn, Progress, ProgressColumn, SpinnerColumn, TextColumn +from rich.table import Table + +# Shared console instance for all scripts +console = Console() + + +def print_success(message: str) -> None: + """Print a success message in green.""" + console.print(f"[green]{message}[/green]") + + +def print_error(message: str) -> None: + """Print an error message in red.""" + console.print(f"[red]{message}[/red]") + + +def print_info(message: str) -> None: + """Print an info message in blue.""" + console.print(f"[blue]{message}[/blue]") + + +def print_warning(message: str) -> None: + """Print a warning message in yellow.""" + console.print(f"[yellow]{message}[/yellow]") + + +def print_section(title: str, color: str = "blue") -> None: + """Print a bold section header with optional color.""" + console.print(f"\n[bold {color}]{title}[/bold {color}]") + + +def rich_print(message: str) -> None: + """Print a rich-formatted string directly.""" + console.print(message) + + +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( + description: str = "Processing...", + total: int | None = None, +) -> Progress: + """ + Create a functional Rich progress bar. + + Parameters + ---------- + description : str, optional + Text to show next to the progress bar. + 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=False, + console=console, + ) From 77eb29847938337485c55db1d071d2d4fbc6a0b6 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 06:27:39 -0500 Subject: [PATCH 08/79] refactor(cli): remove legacy CLI scripts to streamline codebase - Deleted multiple CLI scripts including `config.py`, `db.py`, `dev.py`, `docs.py`, `test.py`, and `tux.py` to simplify the project structure. - This cleanup enhances maintainability and reduces complexity by consolidating command functionalities into a unified CLI framework. - Improved overall organization and usability of the CLI commands. --- scripts/config.py | 269 ------------- scripts/db.py | 988 ---------------------------------------------- scripts/dev.py | 466 ---------------------- scripts/docs.py | 532 ------------------------- scripts/test.py | 346 ---------------- scripts/tux.py | 155 -------- 6 files changed, 2756 deletions(-) delete mode 100644 scripts/config.py delete mode 100644 scripts/db.py delete mode 100644 scripts/dev.py delete mode 100644 scripts/docs.py delete mode 100644 scripts/test.py delete mode 100644 scripts/tux.py 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/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/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/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/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/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() From 1ec8749b6c7cf4366a064b02622e8baae65c721b Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 06:27:46 -0500 Subject: [PATCH 09/79] fix(cli): update CLI entry point for script organization - Changed the CLI entry point in `pyproject.toml` from `scripts.cli:main` to `scripts:main` to reflect the new structure of the CLI scripts. - This adjustment aligns with recent refactoring efforts to streamline the command organization and improve maintainability. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c6e71c99f..78e71b63b 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" From 3ce698e57fa4aa4af451d655075c5bce0a15ea22 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 06:55:16 -0500 Subject: [PATCH 10/79] feat(db): enhance database initialization with state inspection - Introduced a new asynchronous function `_inspect_db_state` to consolidate the logic for checking the database's table and migration counts. - Replaced the previous separate table and migration check functions with a single call to `_inspect_db_state`, improving code clarity and maintainability. - Enhanced error handling during database initialization to provide more informative feedback on failures. --- scripts/db/init.py | 75 +++++++++++++++++++++++----------------------- 1 file changed, 38 insertions(+), 37 deletions(-) diff --git a/scripts/db/init.py b/scripts/db/init.py index bfab9906e..efbfa325c 100644 --- a/scripts/db/init.py +++ b/scripts/db/init.py @@ -5,6 +5,7 @@ """ import asyncio +import contextlib import pathlib from sqlalchemy import text @@ -18,6 +19,37 @@ app = create_app() +async def _inspect_db_state() -> tuple[int, int]: + """Return (table_count, migration_count).""" + service = DatabaseService(echo=False) + try: + await service.connect(CONFIG.database_url) + 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"), + ) + + 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.""" @@ -25,40 +57,7 @@ def init() -> None: rich_print("[bold green]Initializing database with migrations...[/bold green]") rich_print("[yellow]This will create an initial migration file.[/yellow]\n") - async def _check_tables(): - try: - service = DatabaseService(echo=False) - await service.connect(CONFIG.database_url) - 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 - - async def _check_migrations(): - try: - service = DatabaseService(echo=False) - await service.connect(CONFIG.database_url) - 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: - return 0 - else: - return migration_count - - table_count = asyncio.run(_check_tables()) - migration_count = asyncio.run(_check_migrations()) + 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 [] @@ -66,7 +65,9 @@ async def _check_migrations(): if table_count > 0 or migration_count > 0 or migration_file_count > 0: rich_print( - f"[red]Database already has {table_count} tables, {migration_count} migrations in DB, and {migration_file_count} migration files![/red]", + f"[red]Database already has {table_count} tables, " + f"{migration_count} migrations in DB, and " + f"{migration_file_count} migration files![/red]", ) rich_print( "[yellow]'db init' only works on completely empty databases with no migration files.[/yellow]", @@ -93,8 +94,8 @@ async def _check_migrations(): print_success("Database initialized with migrations") rich_print("[green]Ready for development[/green]") - except Exception: - print_error("Failed to initialize database") + except Exception as e: + print_error(f"Failed to initialize database: {e}") if __name__ == "__main__": From 645497d4f15458e71fb62fa0ea11e07a4ce18aa5 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 06:55:28 -0500 Subject: [PATCH 11/79] refactor(db): improve long-running query handling and service disconnection - Moved the instantiation of the DatabaseService to the beginning of the `_check_queries` function for better clarity. - Simplified the execution of the long query retrieval by combining parameters into a single line. - Enhanced the query preview output to handle cases with no query text. - Ensured the service disconnects in a `finally` block to guarantee proper resource management. --- scripts/db/queries.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/scripts/db/queries.py b/scripts/db/queries.py index ee2116d2d..0f9b72e38 100644 --- a/scripts/db/queries.py +++ b/scripts/db/queries.py @@ -30,10 +30,10 @@ def queries() -> None: rich_print("[bold blue]Checking for long-running queries...[/bold blue]") async def _check_queries(): + service = DatabaseService(echo=False) try: with create_progress_bar("Analyzing queries...") as progress: progress.add_task("Checking for long-running queries...", total=None) - service = DatabaseService(echo=False) await service.connect(CONFIG.database_url) async def _get_long_queries( @@ -58,7 +58,6 @@ async def _get_long_queries( _get_long_queries, "get_long_queries", ) - await service.disconnect() if long_queries: rich_print( @@ -66,7 +65,10 @@ async def _get_long_queries( ) for pid, duration, query_text, state in long_queries: rich_print(f"[red]PID {pid}[/red]: {state} for {duration}") - rich_print(f" Query: {query_text[:100]}...") + query_preview = ( + (query_text[:100] + "...") if query_text else "" + ) + rich_print(f" Query: {query_preview}") else: rich_print("[green]No long-running queries found[/green]") @@ -74,6 +76,8 @@ async def _get_long_queries( except Exception as e: print_error(f"Failed to check database queries: {e}") + finally: + await service.disconnect() asyncio.run(_check_queries()) From 326b6c1d57a630a09cbc42a8c941a3ccfb0d2245 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 06:55:36 -0500 Subject: [PATCH 12/79] refactor(db): enhance table listing with progress indication and improved query structure - Refactored the `tables` function to include a progress bar during the fetching of database tables, improving user experience. - Moved the instantiation of the `DatabaseService` to the beginning of the `_list_tables` function for better clarity. - Updated the SQL query to ensure accurate column counting by including the schema in the condition. - Ensured the service disconnects in a `finally` block to guarantee proper resource management. --- scripts/db/tables.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/scripts/db/tables.py b/scripts/db/tables.py index b21aba6b9..082c074e6 100644 --- a/scripts/db/tables.py +++ b/scripts/db/tables.py @@ -10,7 +10,14 @@ from sqlalchemy import text from scripts.core import create_app -from scripts.ui import print_error, print_info, print_section, print_success, rich_print +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 @@ -24,8 +31,8 @@ def tables() -> None: rich_print("[bold blue]Listing database tables...[/bold blue]") async def _list_tables(): + service = DatabaseService(echo=False) try: - service = DatabaseService(echo=False) await service.connect(CONFIG.database_url) async def _get_tables(session: Any) -> list[tuple[str, int]]: @@ -33,7 +40,9 @@ async def _get_tables(session: Any) -> list[tuple[str, int]]: text(""" SELECT table_name, - (SELECT COUNT(*) FROM information_schema.columns WHERE table_name = t.table_name) as column_count + (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' @@ -43,7 +52,9 @@ async def _get_tables(session: Any) -> list[tuple[str, int]]: ) return result.fetchall() - tables_data = await service.execute_query(_get_tables, "get_tables") + with create_progress_bar("Fetching tables...") 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") @@ -53,11 +64,12 @@ async def _get_tables(session: Any) -> list[tuple[str, int]]: for table_name, column_count in tables_data: rich_print(f"[cyan]{table_name}[/cyan]: {column_count} columns") - await service.disconnect() print_success("Database tables listed") except Exception as e: print_error(f"Failed to list database tables: {e}") + finally: + await service.disconnect() asyncio.run(_list_tables()) From a5ea422538abfcb2b9984410d782be3133a66a56 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 06:55:49 -0500 Subject: [PATCH 13/79] refactor(dev): restructure development checks with a new Check class - Introduced a `Check` dataclass to encapsulate individual development checks, improving code organization and readability. - Refactored the `run_all_checks` function to utilize the new `Check` class, enhancing the clarity of check execution and result handling. - Simplified error handling during check execution by centralizing it in the `run_check` function, ensuring consistent behavior across all checks. --- scripts/dev/all.py | 53 +++++++++++++++++++++++++++------------------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/scripts/dev/all.py b/scripts/dev/all.py index a770a1910..3fed77f90 100644 --- a/scripts/dev/all.py +++ b/scripts/dev/all.py @@ -5,6 +5,7 @@ """ from collections.abc import Callable +from dataclasses import dataclass from typing import Annotated from typer import Exit, Option @@ -13,7 +14,6 @@ from scripts.dev.format import format_code from scripts.dev.lint import lint from scripts.dev.lint_docstring import lint_docstring -from scripts.dev.lint_fix import lint_fix from scripts.dev.pre_commit import pre_commit from scripts.dev.type_check import type_check from scripts.ui import ( @@ -28,6 +28,26 @@ 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 + except Exception: + return False + else: + return True + + @app.command(name="all") def run_all_checks( fix: Annotated[ @@ -38,12 +58,12 @@ def run_all_checks( """Run all development checks including linting, type checking, and documentation.""" print_section("Running All Development Checks", "blue") - checks: list[tuple[str, Callable[[], None]]] = [ - ("Linting", lint_fix if fix else lint), - ("Code Formatting", format_code), - ("Type Checking", type_check), - ("Docstring Linting", lint_docstring), - ("Pre-commit Checks", pre_commit), + 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]] = [] @@ -51,23 +71,12 @@ def run_all_checks( with 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}...") + for check in checks: + progress.update(task, description=f"Running {check.name}...") progress.refresh() - try: - # Note: These functions call sys.exit(1) on failure. - # In a combined run, we might want them to raise an exception instead. - # For now, we'll try to catch exceptions if any, but they might still exit. - check_func() - results.append((check_name, True)) - except SystemExit as e: - if e.code == 0: - results.append((check_name, True)) - else: - results.append((check_name, False)) - except Exception: - results.append((check_name, False)) + success = run_check(check) + results.append((check.name, success)) progress.advance(task) progress.refresh() From 1281371e40f62f95759859de5b01786ca3316e86 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 06:55:59 -0500 Subject: [PATCH 14/79] refactor(dev): update clean script to use ROOT constant and improve directory checks - Replaced the hardcoded project root path with the ROOT constant for better maintainability. - Enhanced the directory protection check to utilize a more efficient component-based approach. - Cleaned up code formatting for improved readability. --- scripts/dev/clean.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/dev/clean.py b/scripts/dev/clean.py index 4e6aa54a2..328fb9540 100644 --- a/scripts/dev/clean.py +++ b/scripts/dev/clean.py @@ -7,7 +7,7 @@ import shutil from pathlib import Path -from scripts.core import create_app +from scripts.core import ROOT, create_app from scripts.ui import ( create_progress_bar, print_info, @@ -71,7 +71,7 @@ def clean() -> None: """Clean temporary files, cache directories, and build artifacts.""" print_section("Cleaning Project", "blue") - project_root = Path(__file__).parent.parent.parent + project_root = ROOT cleaned_count = 0 total_size = 0 @@ -111,10 +111,11 @@ def clean() -> None: progress.advance(task) continue + # Better component-based check for protected dirs matches = [ m for m in matches - if all(protected not in str(m) for protected in protected_dirs) + if not any(part in protected_dirs for part in m.parts) ] if not matches: From 20c92d23b0de2fa92bbc68fb9abb9fbfe71bc3d0 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 06:56:06 -0500 Subject: [PATCH 15/79] refactor(docs): streamline documentation build process and improve configuration checks - Replaced the custom zensical configuration check with a utility function for better clarity and maintainability. - Updated the help text for the strict mode option to remove the unsupported note. - Enhanced error handling during the documentation build process to ensure exceptions are raised appropriately. --- scripts/docs/build.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/scripts/docs/build.py b/scripts/docs/build.py index 810434869..34cb6ef6f 100644 --- a/scripts/docs/build.py +++ b/scripts/docs/build.py @@ -6,25 +6,17 @@ import os import subprocess -from pathlib import Path from typing import Annotated from typer import Option from scripts.core import create_app +from scripts.docs.utils import has_zensical_config from scripts.ui import print_error, print_info, print_section, print_success app = create_app() -def _find_zensical_config() -> str | None: - current_dir = Path.cwd() - if (current_dir / "zensical.toml").exists(): - return "zensical.toml" - print_error("Can't find zensical.toml file. Please run from the project root.") - return None - - @app.command(name="build") def build( clean: Annotated[ @@ -33,13 +25,13 @@ def build( ] = False, strict: Annotated[ bool, - Option("--strict", "-s", help="Strict mode (currently unsupported)"), + Option("--strict", "-s", help="Strict mode"), ] = False, ) -> None: """Build documentation site for production.""" print_section("Building Documentation", "blue") - if not _find_zensical_config(): + if not has_zensical_config(): return cmd = ["uv", "run", "zensical", "build"] @@ -54,6 +46,7 @@ def build( print_success("Documentation built successfully") except subprocess.CalledProcessError: print_error("Failed to build documentation") + raise except KeyboardInterrupt: print_info("\nBuild interrupted") From 51e9c997e2a1d20091a754797657e26739b6c43f Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 06:56:14 -0500 Subject: [PATCH 16/79] refactor(docs): replace zensical config check with utility function - Updated the documentation serving script to utilize a utility function for zensical configuration checks, improving clarity and maintainability. - Revised the help text for the strict mode option to remove the unsupported note while adding a warning message for its current state. - Enhanced error handling to ensure proper feedback when the zensical configuration is not found. --- scripts/docs/serve.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/scripts/docs/serve.py b/scripts/docs/serve.py index 516ffb788..f885e4b58 100644 --- a/scripts/docs/serve.py +++ b/scripts/docs/serve.py @@ -6,25 +6,17 @@ import os import subprocess -from pathlib import Path from typing import Annotated from typer import Option from scripts.core import create_app +from scripts.docs.utils import has_zensical_config from scripts.ui import print_error, print_info, print_section app = create_app() -def _find_zensical_config() -> str | None: - current_dir = Path.cwd() - if (current_dir / "zensical.toml").exists(): - return "zensical.toml" - print_error("Can't find zensical.toml file. Please run from the project root.") - return None - - @app.command(name="serve") def serve( dev_addr: Annotated[ @@ -41,19 +33,20 @@ def serve( ] = False, strict: Annotated[ bool, - Option("--strict", "-s", help="Strict mode (currently unsupported)"), + Option("--strict", "-s", help="Strict mode"), ] = False, ) -> None: """Serve documentation locally with live reload.""" print_section("Serving Documentation", "blue") - if not _find_zensical_config(): + if not has_zensical_config(): return cmd = ["uv", "run", "zensical", "serve", "--dev-addr", dev_addr] if open_browser: cmd.append("--open") if strict: + print_info("Warning: --strict mode is currently unsupported by zensical") cmd.append("--strict") try: From 9e07732669fde8915f21b09e225b38d1045c91ca Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 06:56:24 -0500 Subject: [PATCH 17/79] refactor(docs): improve error handling and configuration checks in wrangler_deploy - Replaced the direct file existence check for 'wrangler.toml' with a utility function to enhance clarity and maintainability. - Updated error handling to raise exceptions with informative messages during the documentation build and deployment processes, ensuring better feedback on failures. - Ensured strict mode is enforced during the build process to catch issues early. --- scripts/docs/wrangler_deploy.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/scripts/docs/wrangler_deploy.py b/scripts/docs/wrangler_deploy.py index b037c84a5..c054cbb2c 100644 --- a/scripts/docs/wrangler_deploy.py +++ b/scripts/docs/wrangler_deploy.py @@ -4,13 +4,13 @@ Deploys documentation to Cloudflare Workers. """ -from pathlib import Path from typing import Annotated -from typer import Option +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 @@ -31,13 +31,17 @@ def wrangler_deploy( """Deploy documentation to Cloudflare Workers.""" print_section("Deploying to Cloudflare Workers", "blue") - if not Path("wrangler.toml").exists(): - print_error("wrangler.toml not found. Please run from the project root.") - return + if not has_wrangler_config(): + raise Exit(1) print_info("Building documentation...") - build(strict=False) + 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: @@ -49,7 +53,8 @@ def wrangler_deploy( run_command(cmd, capture_output=False) print_success(f"Documentation deployed successfully to {env}") except Exception as e: - print_error(f"Error: {e}") + print_error(f"Deployment failed: {e}") + raise Exit(1) from e if __name__ == "__main__": From 117a5d91879024a4a84731083de613e780b11912 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 06:56:31 -0500 Subject: [PATCH 18/79] refactor(docs): update wrangler_rollback to use argument for version ID and improve error handling - Replaced the option for version ID with a required argument for clearer usage. - Enhanced error handling by utilizing a utility function to check for wrangler configuration, improving maintainability and user feedback. - Updated exception handling to ensure consistent error reporting during rollback operations. --- scripts/docs/wrangler_rollback.py | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/scripts/docs/wrangler_rollback.py b/scripts/docs/wrangler_rollback.py index 88ab53917..f74e6cfd5 100644 --- a/scripts/docs/wrangler_rollback.py +++ b/scripts/docs/wrangler_rollback.py @@ -4,12 +4,12 @@ Rolls back to a previous deployment. """ -from pathlib import Path from typing import Annotated -from typer import Option +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 @@ -20,8 +20,8 @@ def wrangler_rollback( version_id: Annotated[ str, - Option("--version-id", help="Version ID to rollback to"), - ] = "", + Argument(help="Version ID to rollback to"), + ], message: Annotated[ str, Option("--message", "-m", help="Rollback message"), @@ -30,15 +30,8 @@ def wrangler_rollback( """Rollback to a previous deployment.""" print_section("Rolling Back Deployment", "blue") - if not Path("wrangler.toml").exists(): - print_error("wrangler.toml not found. Please run from the project root.") - return - - if not version_id: - print_error( - "Version ID is required. Use wrangler-deployments to find version IDs.", - ) - return + if not has_wrangler_config(): + raise Exit(1) cmd = ["wrangler", "rollback", version_id] if message: @@ -51,6 +44,7 @@ def wrangler_rollback( 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__": From ec22f55ecca9cacf93ccf520abdb81ed72fc0cdc Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 06:56:38 -0500 Subject: [PATCH 19/79] refactor(docs): enhance wrangler-versions command with improved argument handling and error checks - Updated the action parameter to use a Literal type for better clarity and restricted options. - Replaced the direct file existence check with a utility function to verify wrangler configuration. - Added error handling to ensure required options are provided for 'view' and 'upload' actions, improving user feedback. --- scripts/docs/wrangler_versions.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/scripts/docs/wrangler_versions.py b/scripts/docs/wrangler_versions.py index e82fa014e..a6fcf41a4 100644 --- a/scripts/docs/wrangler_versions.py +++ b/scripts/docs/wrangler_versions.py @@ -4,12 +4,12 @@ Lists and manages versions. """ -from pathlib import Path -from typing import Annotated +from typing import Annotated, Literal from typer import 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 @@ -19,30 +19,37 @@ @app.command(name="wrangler-versions") def wrangler_versions( action: Annotated[ - str, - Option("--action", "-a", help="Action to perform: list, view, or upload"), + Literal["list", "view", "upload"], + Option("--action", "-a", help="Action to perform"), ] = "list", version_id: Annotated[ str, - Option("--version-id", help="Version ID to view"), + Option("--version-id", help="Version ID to view (required for 'view')"), ] = "", alias: Annotated[ str, - Option("--alias", help="Preview alias name"), + Option("--alias", help="Preview alias name (required for 'upload')"), ] = "", ) -> None: """List and manage versions of the documentation.""" print_section("Managing Versions", "blue") - if not Path("wrangler.toml").exists(): - print_error("wrangler.toml not found. Please run from the project root.") + if not has_wrangler_config(): + return + + if action == "view" and not version_id: + print_error("The --version-id option is required when --action view is used.") + return + + if action == "upload" and not alias: + print_error("The --alias option is required when --action upload is used.") return cmd = ["wrangler", "versions", action] - if action == "view" and version_id: + if action == "view": cmd.append(version_id) - elif action == "upload" and alias: + elif action == "upload": cmd.extend(["--preview-alias", alias]) try: From 561615494a6788f6809ea26d9d7a3542b47c3405 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 06:56:45 -0500 Subject: [PATCH 20/79] feat(docs): add utility functions for configuration checks - Introduced `has_zensical_config` and `has_wrangler_config` functions to verify the presence of configuration files, improving maintainability and user feedback. - Enhanced error messaging to guide users when configuration files are missing, ensuring they run scripts from the project root. --- scripts/docs/utils.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 scripts/docs/utils.py 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 From dafcbdbda85db06ee21b483164dbef713577a798 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 06:56:52 -0500 Subject: [PATCH 21/79] refactor(test): enhance coverage report generation and error handling - Updated the coverage report command to accept an integer for the `--fail-under` option, improving type safety. - Simplified the command construction for coverage report formats, ensuring only valid formats are accepted. - Improved error handling during report generation, providing clearer feedback for failures and unexpected errors. --- scripts/test/coverage.py | 39 +++++++++++++++++++-------------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/scripts/test/coverage.py b/scripts/test/coverage.py index 50c27225f..a4a80e10b 100644 --- a/scripts/test/coverage.py +++ b/scripts/test/coverage.py @@ -4,15 +4,17 @@ Generates coverage reports. """ +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_info, print_section +from scripts.ui import print_error, print_info, print_section app = create_app() @@ -32,7 +34,7 @@ def coverage_report( Option("--quick", help="Skip coverage report generation"), ] = False, fail_under: Annotated[ - str | None, + int | None, Option("--fail-under", help="Minimum coverage percentage required"), ] = None, open_browser: Annotated[ @@ -46,26 +48,16 @@ def coverage_report( """Generate coverage reports.""" print_section("Coverage Report", "blue") - cmd = ["uv", "run", "pytest"] - - if specific: - cmd.append(f"--cov={specific}") + cmd = ["uv", "run", "pytest", f"--cov={specific or 'src/tux'}"] 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 _: - pass + elif format_type in {"html", "xml", "json"}: + 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", fail_under]) + cmd.extend(["--cov-fail-under", str(fail_under)]) print_info(f"Running: {' '.join(cmd)}") @@ -76,9 +68,16 @@ def coverage_report( html_report_path = Path("htmlcov/index.html") if html_report_path.exists(): print_info("Opening HTML coverage report in browser...") - webbrowser.open(f"file://{html_report_path.resolve()}") - except Exception: - pass + try: + webbrowser.open(f"file://{html_report_path.resolve()}") + except Exception as e: + print_error(f"Failed to open browser: {e}") + except CalledProcessError: + print_error("Coverage report generation failed") + sys.exit(1) + except Exception as e: + print_error(f"An unexpected error occurred: {e}") + sys.exit(1) if __name__ == "__main__": From 0e539f334daaa31558f5065fc454711bf1dd3cf8 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 06:56:59 -0500 Subject: [PATCH 22/79] refactor(test): improve HTML report generation and error handling - Updated the HTML report command to use a variable for the report path, enhancing maintainability. - Improved error handling to provide clearer feedback when the report fails to open or when tests fail. - Added warnings for missing HTML reports and exceptions during browser opening, ensuring better user feedback. --- scripts/test/html.py | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/scripts/test/html.py b/scripts/test/html.py index 0ff56cc67..07212d77e 100644 --- a/scripts/test/html.py +++ b/scripts/test/html.py @@ -4,15 +4,17 @@ Runs tests and generates an HTML report. """ +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_info, print_section +from scripts.ui import print_error, print_info, print_section, print_warning app = create_app() @@ -26,12 +28,13 @@ def html_report( ) -> 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", - "--html=reports/test_report.html", + f"--html={report_path}", "--self-contained-html", ] @@ -39,13 +42,24 @@ def html_report( try: run_command(cmd, capture_output=False) + if open_browser: - html_report_path = Path("htmlcov/index.html") + html_report_path = Path(report_path) if html_report_path.exists(): - print_info("Opening HTML coverage report in browser...") - webbrowser.open(f"file://{html_report_path.resolve()}") - except Exception: - pass + 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 CalledProcessError: + print_error("Tests failed - see output above") + sys.exit(1) + except Exception as e: + print_error(f"An unexpected error occurred: {e}") + sys.exit(1) if __name__ == "__main__": From ff1e0178ce0864be91dc28daf6705658c324b3d1 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 06:57:05 -0500 Subject: [PATCH 23/79] refactor(bot): improve bot startup process and error handling - Introduced a new PostRunBehavior enum to manage bot behavior after execution. - Refactored the bot startup logic into a separate _run_bot function for better organization and clarity. - Enhanced error handling to provide clearer feedback on various runtime exceptions, improving user experience during bot startup. --- scripts/tux/start.py | 65 ++++++++++++++++++++++++++++---------------- 1 file changed, 42 insertions(+), 23 deletions(-) diff --git a/scripts/tux/start.py b/scripts/tux/start.py index 4ee3ce012..eb17c4d2e 100644 --- a/scripts/tux/start.py +++ b/scripts/tux/start.py @@ -5,6 +5,7 @@ """ import sys +from enum import Enum, auto from typing import Annotated from typer import Option @@ -16,6 +17,44 @@ app = create_app() +class PostRunBehavior(Enum): + """Behavior after the bot runs.""" + + NORMAL = auto() # Run generic status messages + SKIP = auto() # Messages already handled + + +def _run_bot(debug: bool) -> tuple[int, PostRunBehavior]: + """Run the bot and handle exceptions.""" + exit_code = 1 + behavior = PostRunBehavior.SKIP + + try: + if debug: + print_info("Debug mode enabled") + + exit_code = run(debug=debug) + behavior = PostRunBehavior.NORMAL + except RuntimeError as e: + msg = str(e) + if "setup failed" in msg.lower(): + print_error("Bot setup failed") + elif "Event loop stopped before Future completed" in msg: + print_info("Bot shutdown completed") + exit_code = 0 + else: + print_error(f"Runtime error: {e}") + except KeyboardInterrupt: + print_info("Bot shutdown requested by user (Ctrl+C)") + exit_code = 130 + except SystemExit as e: + exit_code = int(e.code) if e.code is not None else 1 + except Exception as e: + print_error(f"Failed to start bot: {e}") + + return exit_code, behavior + + @app.command(name="start") def start( debug: Annotated[bool, Option("--debug", help="Enable debug mode")] = False, @@ -24,37 +63,17 @@ def start( print_section("Starting Tux Bot", "blue") rich_print("[bold blue]Starting Tux Discord bot...[/bold blue]") - try: - if debug: - print_info("Debug mode enabled") + exit_code, behavior = _run_bot(debug) - exit_code = run() + if behavior is PostRunBehavior.NORMAL: if exit_code == 0: print_success("Bot started successfully") elif exit_code == 130: print_info("Bot shutdown requested by user (Ctrl+C)") else: print_error(f"Bot exited with code {exit_code}") - sys.exit(exit_code) - except RuntimeError as e: - if "setup failed" in str(e).lower(): - print_error("Bot setup failed") - sys.exit(1) - elif "Event loop stopped before Future completed" in str(e): - print_info("Bot shutdown completed") - sys.exit(0) - else: - print_error(f"Runtime error: {e}") - sys.exit(1) - except SystemExit as e: - sys.exit(e.code) - except KeyboardInterrupt: - print_info("Bot shutdown requested by user (Ctrl+C)") - sys.exit(130) - except Exception as e: - print_error(f"Failed to start bot: {e}") - sys.exit(1) + sys.exit(exit_code) if __name__ == "__main__": From b0d3a4ff8b09abcb63b682d9d96978279208b19d Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 06:57:11 -0500 Subject: [PATCH 24/79] refactor(proc): enhance command execution logging and error reporting - Added logging for command execution using shlex.join for improved security and clarity. - Updated error reporting to utilize shlex.join for consistent command formatting in failure messages. --- scripts/proc.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/proc.py b/scripts/proc.py index c92caaff8..5deb44314 100644 --- a/scripts/proc.py +++ b/scripts/proc.py @@ -5,6 +5,7 @@ """ import os +import shlex import subprocess from scripts.ui import console, print_error @@ -42,6 +43,9 @@ def run_command( """ run_env = env if env is not None else os.environ.copy() + # Log command for auditing (security suggestion) + # console.print(f"[dim]Executing: {shlex.join(command)}[/dim]") + try: result = subprocess.run( command, @@ -55,7 +59,7 @@ def run_command( console.print(result.stdout.strip()) except subprocess.CalledProcessError as e: - print_error(f"Command failed: {' '.join(command)}") + print_error(f"Command failed: {shlex.join(command)}") if e.stderr: console.print(f"[red]{e.stderr.strip()}[/red]") raise From e3d194beb0f76a60b50db543d5514511e2343559 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 06:57:33 -0500 Subject: [PATCH 25/79] refactor(main): add debug mode to application startup logging - Updated the run function to accept an optional debug parameter, allowing for enhanced logging during application startup. - Adjusted logging messages to indicate whether the application is starting in debug mode or normal mode, improving clarity for users. --- src/tux/main.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/tux/main.py b/src/tux/main.py index 87739f6e6..d7c67b93a 100644 --- a/src/tux/main.py +++ b/src/tux/main.py @@ -14,13 +14,18 @@ from tux.shared.exceptions import TuxDatabaseError, TuxError -def run() -> int: +def run(debug: bool = False) -> int: """ 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,10 +33,14 @@ 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() From c4e348f11f075f16a0c8b6b9eb9527f5577fb816 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 07:15:17 -0500 Subject: [PATCH 26/79] refactor(db): enhance error handling and service management across database scripts - Added `Exit` handling to improve error reporting in health, queries, status, and show commands. - Refactored the nuke command to streamline the nuclear reset process and ensure proper service disconnection. - Introduced helper functions for dropping tables and deleting migration files to improve code organization and clarity. - Enhanced error handling in the reset command to provide clearer feedback on migration failures. --- scripts/db/health.py | 8 ++- scripts/db/nuke.py | 110 +++++++++++++++++++++++------------------- scripts/db/queries.py | 2 + scripts/db/reset.py | 13 ++++- scripts/db/show.py | 5 +- scripts/db/status.py | 5 +- scripts/db/tables.py | 2 + 7 files changed, 88 insertions(+), 57 deletions(-) diff --git a/scripts/db/health.py b/scripts/db/health.py index 2e72680cd..49ae9e61d 100644 --- a/scripts/db/health.py +++ b/scripts/db/health.py @@ -6,6 +6,8 @@ import asyncio +from typer import Exit + from scripts.core import create_app from scripts.ui import ( create_progress_bar, @@ -27,13 +29,12 @@ def health() -> None: rich_print("[bold blue]Checking database health...[/bold blue]") async def _health_check(): + service = DatabaseService(echo=False) try: with create_progress_bar("Connecting to database...") as progress: progress.add_task("Checking database health...", total=None) - service = DatabaseService(echo=False) await service.connect(CONFIG.database_url) health_data = await service.health_check() - await service.disconnect() if health_data["status"] == "healthy": rich_print("[green]Database is healthy![/green]") @@ -53,6 +54,9 @@ async def _health_check(): except Exception as e: print_error(f"Failed to check database health: {e}") + raise Exit(1) from e + finally: + await service.disconnect() asyncio.run(_health_check()) diff --git a/scripts/db/nuke.py b/scripts/db/nuke.py index f5c06239f..27d611b41 100644 --- a/scripts/db/nuke.py +++ b/scripts/db/nuke.py @@ -11,7 +11,7 @@ from typing import Annotated, Any from sqlalchemy import text -from typer import Option +from typer import Exit, Option from scripts.core import create_app from scripts.ui import print_error, print_info, print_section, print_success, rich_print @@ -21,6 +21,63 @@ app = create_app() +async def _drop_all_tables(session: Any) -> None: + """Drop all tables and recreate the public schema.""" + await session.execute(text("DROP TABLE IF EXISTS alembic_version")) + 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() + + +def _delete_migration_files(): + """Delete all migration files in the versions directory.""" + migration_dir = pathlib.Path("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": + migration_file.unlink() + deleted_count += 1 + print_success(f"Deleted {deleted_count} migration files") + + +async def _nuclear_reset(fresh: bool): + """Perform a complete database reset by dropping all tables and schemas.""" + service = DatabaseService(echo=False) + try: + await service.connect(CONFIG.database_url) + rich_print("[yellow]Dropping all tables and schema...[/yellow]") + await service.execute_query(_drop_all_tables, "drop_all_tables") + + 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[ @@ -54,61 +111,14 @@ def nuke( print_error( "Cannot run nuke in non-interactive mode without --force or --yes flag", ) - return + raise Exit(1) response = input("Type 'NUKE' to confirm (case sensitive): ") if response != "NUKE": print_info("Nuclear reset cancelled") return - async def _nuclear_reset(): - try: - service = DatabaseService(echo=False) - await service.connect(CONFIG.database_url) - - async def _drop_all_tables(session: Any) -> None: - await session.execute(text("DROP TABLE IF EXISTS alembic_version")) - 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() - - rich_print("[yellow]Dropping all tables and schema...[/yellow]") - await service.execute_query(_drop_all_tables, "drop_all_tables") - await service.disconnect() - - print_success("Nuclear reset completed - database is completely empty") - - if fresh: - migration_dir = pathlib.Path("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": - migration_file.unlink() - deleted_count += 1 - print_success(f"Deleted {deleted_count} 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() - - asyncio.run(_nuclear_reset()) + asyncio.run(_nuclear_reset(fresh)) if __name__ == "__main__": diff --git a/scripts/db/queries.py b/scripts/db/queries.py index 0f9b72e38..f880bc167 100644 --- a/scripts/db/queries.py +++ b/scripts/db/queries.py @@ -8,6 +8,7 @@ from typing import Any from sqlalchemy import text +from typer import Exit from scripts.core import create_app from scripts.ui import ( @@ -76,6 +77,7 @@ async def _get_long_queries( except Exception as e: print_error(f"Failed to check database queries: {e}") + raise Exit(1) from e finally: await service.disconnect() diff --git a/scripts/db/reset.py b/scripts/db/reset.py index dc265f33a..1afb1ef88 100644 --- a/scripts/db/reset.py +++ b/scripts/db/reset.py @@ -4,6 +4,8 @@ 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 @@ -20,10 +22,17 @@ def reset() -> None: 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 Exception: - print_error("Failed to reset database") + except CalledProcessError: + print_error("Failed to reapply migrations") + print_error("WARNING: Database is at base state with no migrations!") + raise if __name__ == "__main__": diff --git a/scripts/db/show.py b/scripts/db/show.py index 93cb56ef5..7ce4a28b7 100644 --- a/scripts/db/show.py +++ b/scripts/db/show.py @@ -6,7 +6,7 @@ from typing import Annotated -from typer import Argument +from typer import Argument, Exit from scripts.core import create_app from scripts.proc import run_command @@ -31,8 +31,9 @@ def show( try: run_command(["uv", "run", "alembic", "show", revision]) print_success(f"Migration details displayed for: {revision}") - except Exception: + except Exception as e: print_error(f"Failed to show migration: {revision}") + raise Exit(1) from e if __name__ == "__main__": diff --git a/scripts/db/status.py b/scripts/db/status.py index 682d3c21b..16a47e018 100644 --- a/scripts/db/status.py +++ b/scripts/db/status.py @@ -4,6 +4,8 @@ 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 @@ -25,8 +27,9 @@ def status() -> None: run_command(["uv", "run", "alembic", "heads"]) print_success("Status check complete") - except Exception: + except Exception as e: print_error("Failed to get migration status") + raise Exit(1) from e if __name__ == "__main__": diff --git a/scripts/db/tables.py b/scripts/db/tables.py index 082c074e6..c2d159ef5 100644 --- a/scripts/db/tables.py +++ b/scripts/db/tables.py @@ -8,6 +8,7 @@ from typing import Any from sqlalchemy import text +from typer import Exit from scripts.core import create_app from scripts.ui import ( @@ -68,6 +69,7 @@ async def _get_tables(session: Any) -> list[tuple[str, int]]: except Exception as e: print_error(f"Failed to list database tables: {e}") + raise Exit(1) from e finally: await service.disconnect() From 6969e935d64e4184959e833bd89e593b6207bcec Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 07:15:24 -0500 Subject: [PATCH 27/79] refactor(docs): improve error handling in documentation build and serve scripts - Replaced return statements with `Exit(1)` in the build and serve functions to enhance error reporting when configuration checks fail. - Updated the wrangler_versions function to raise exceptions for missing required options, improving user feedback and maintainability. --- scripts/docs/build.py | 4 ++-- scripts/docs/serve.py | 4 ++-- scripts/docs/wrangler_versions.py | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/scripts/docs/build.py b/scripts/docs/build.py index 34cb6ef6f..c56bcd568 100644 --- a/scripts/docs/build.py +++ b/scripts/docs/build.py @@ -8,7 +8,7 @@ import subprocess from typing import Annotated -from typer import Option +from typer import Exit, Option from scripts.core import create_app from scripts.docs.utils import has_zensical_config @@ -32,7 +32,7 @@ def build( print_section("Building Documentation", "blue") if not has_zensical_config(): - return + raise Exit(1) cmd = ["uv", "run", "zensical", "build"] if clean: diff --git a/scripts/docs/serve.py b/scripts/docs/serve.py index f885e4b58..6813d3cab 100644 --- a/scripts/docs/serve.py +++ b/scripts/docs/serve.py @@ -8,7 +8,7 @@ import subprocess from typing import Annotated -from typer import Option +from typer import Exit, Option from scripts.core import create_app from scripts.docs.utils import has_zensical_config @@ -40,7 +40,7 @@ def serve( print_section("Serving Documentation", "blue") if not has_zensical_config(): - return + raise Exit(1) cmd = ["uv", "run", "zensical", "serve", "--dev-addr", dev_addr] if open_browser: diff --git a/scripts/docs/wrangler_versions.py b/scripts/docs/wrangler_versions.py index a6fcf41a4..f1a974ce8 100644 --- a/scripts/docs/wrangler_versions.py +++ b/scripts/docs/wrangler_versions.py @@ -6,7 +6,7 @@ from typing import Annotated, Literal -from typer import Option +from typer import Exit, Option from scripts.core import create_app from scripts.docs.utils import has_wrangler_config @@ -35,15 +35,15 @@ def wrangler_versions( print_section("Managing Versions", "blue") if not has_wrangler_config(): - return + raise Exit(1) if action == "view" and not version_id: print_error("The --version-id option is required when --action view is used.") - return + raise Exit(1) if action == "upload" and not alias: print_error("The --alias option is required when --action upload is used.") - return + raise Exit(1) cmd = ["wrangler", "versions", action] From e2377e1e6b253eb068b78d1afda78bdb1ef70e37 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 07:15:31 -0500 Subject: [PATCH 28/79] refactor(bot): update success message for bot startup - Changed the success message from "Bot started successfully" to "Bot completed successfully" to better reflect the bot's execution outcome. --- scripts/tux/start.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/tux/start.py b/scripts/tux/start.py index eb17c4d2e..853d6f9a0 100644 --- a/scripts/tux/start.py +++ b/scripts/tux/start.py @@ -67,7 +67,7 @@ def start( if behavior is PostRunBehavior.NORMAL: if exit_code == 0: - print_success("Bot started successfully") + print_success("Bot completed successfully") elif exit_code == 130: print_info("Bot shutdown requested by user (Ctrl+C)") else: From 816aa1b2b5825bb6e37cc8e158f153756305f1df Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 07:15:42 -0500 Subject: [PATCH 29/79] refactor(validate): improve database URL display in validation output - Updated the database URL display logic to conditionally truncate the URL for better readability in the validation table. - Ensured that the full URL is shown if it is 50 characters or less, enhancing clarity for users during configuration checks. --- scripts/config/validate.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/config/validate.py b/scripts/config/validate.py index ff9243035..361cd7b0f 100644 --- a/scripts/config/validate.py +++ b/scripts/config/validate.py @@ -42,7 +42,9 @@ def validate() -> None: "***" if config.BOT_TOKEN else "NOT SET", "✓" if config.BOT_TOKEN else "✗", ) - table.add_row("Database URL", f"{config.database_url[:50]}...", "✓") + 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, "✓") From 1941ff6d60def60cb434477b5605ff5fbcdac095 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 07:26:00 -0500 Subject: [PATCH 30/79] refactor(app): improve error handling during bot setup - Updated the bot setup process to raise TuxSetupError on failure, enhancing clarity in error reporting. - Added TuxGracefulShutdown exception to handle graceful application shutdowns, improving overall error management. --- src/tux/core/app.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/tux/core/app.py b/src/tux/core/app.py index fa1f0f37f..c16f01483 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,8 +122,10 @@ def run(self) -> int: Raises ------ - RuntimeError + TuxSetupError If a critical application error occurs during startup. + TuxGracefulShutdown + If the application is stopped gracefully. """ try: # Create a fresh event loop for this application run @@ -441,8 +444,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: """ From 3877ad2a0618921a30c44931b12fde0b2dab0d31 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 07:28:16 -0500 Subject: [PATCH 31/79] refactor(exceptions): add new exception classes for setup and graceful shutdown - Introduced TuxSetupError to handle failures during bot setup, improving error clarity. - Added TuxGracefulShutdown to manage graceful shutdown scenarios, enhancing overall error management. --- src/tux/shared/exceptions.py | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 === From c3357568c46ec5483a2246282b6fe4a4f5987945 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 07:28:25 -0500 Subject: [PATCH 32/79] refactor(main): enhance error handling in the run function - Updated the run function to include new exception classes TuxSetupError and TuxGracefulShutdown for improved error clarity. - Expanded the exception handling to provide specific logging for setup failures and graceful shutdown scenarios, enhancing overall error management. --- src/tux/main.py | 37 +++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/src/tux/main.py b/src/tux/main.py index d7c67b93a..2a91e447b 100644 --- a/src/tux/main.py +++ b/src/tux/main.py @@ -6,12 +6,15 @@ 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(debug: bool = False) -> int: @@ -44,15 +47,25 @@ def run(debug: bool = False) -> int: app = TuxApp() return app.run() - except (TuxDatabaseError, TuxError, SystemExit, KeyboardInterrupt, Exception) as e: - # Handle all errors in one place + except ( + TuxDatabaseError, + TuxSetupError, + TuxGracefulShutdown, + TuxError, + SystemExit, + KeyboardInterrupt, + Exception, + ) as e: if isinstance(e, TuxDatabaseError): logger.error("Database connection failed") logger.info("To start the database, run: docker compose up") + elif isinstance(e, TuxSetupError): + logger.error(f"Bot setup failed: {e}") + elif isinstance(e, TuxGracefulShutdown): + logger.info(f"Bot shut down gracefully: {e}") + return 0 elif isinstance(e, TuxError): - logger.error(f"Bot startup failed: {e}") - elif isinstance(e, RuntimeError): - logger.critical(f"Application failed to start: {e}") + logger.error(f"Bot error: {e}") elif isinstance(e, SystemExit): return int(e.code) if e.code is not None else 1 elif isinstance(e, KeyboardInterrupt): @@ -62,11 +75,3 @@ def run(debug: bool = False) -> int: logger.opt(exception=True).critical(f"Application failed to start: {e}") return 1 - - else: - return 0 - - -if __name__ == "__main__": - exit_code = run() - sys.exit(exit_code) From 706c1f16bb800af3ae6bfb60c7f33158fed706d6 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 07:28:32 -0500 Subject: [PATCH 33/79] refactor(start): streamline bot execution and error handling - Removed redundant exception handling in the _run_bot function, leveraging the existing error management in the run function. - Simplified the return values to focus on exit code and post-run behavior, enhancing clarity and maintainability. - Updated comments to reflect the changes in error logging and behavior assumptions. --- scripts/tux/start.py | 41 ++++++++++++----------------------------- 1 file changed, 12 insertions(+), 29 deletions(-) diff --git a/scripts/tux/start.py b/scripts/tux/start.py index 853d6f9a0..f0ce2625b 100644 --- a/scripts/tux/start.py +++ b/scripts/tux/start.py @@ -11,7 +11,7 @@ from typer import Option from scripts.core import create_app -from scripts.ui import print_error, print_info, print_section, print_success, rich_print +from scripts.ui import print_info, print_section, print_success, rich_print from tux.main import run app = create_app() @@ -25,34 +25,16 @@ class PostRunBehavior(Enum): def _run_bot(debug: bool) -> tuple[int, PostRunBehavior]: - """Run the bot and handle exceptions.""" - exit_code = 1 - behavior = PostRunBehavior.SKIP - - try: - if debug: - print_info("Debug mode enabled") - - exit_code = run(debug=debug) - behavior = PostRunBehavior.NORMAL - except RuntimeError as e: - msg = str(e) - if "setup failed" in msg.lower(): - print_error("Bot setup failed") - elif "Event loop stopped before Future completed" in msg: - print_info("Bot shutdown completed") - exit_code = 0 - else: - print_error(f"Runtime error: {e}") - except KeyboardInterrupt: - print_info("Bot shutdown requested by user (Ctrl+C)") - exit_code = 130 - except SystemExit as e: - exit_code = int(e.code) if e.code is not None else 1 - except Exception as e: - print_error(f"Failed to start bot: {e}") + """Run the bot and return the exit code and post-run behavior.""" + if debug: + print_info("Debug mode enabled") + + # The run() function in main.py already catches and logs exceptions + # and returns a proper exit code. + exit_code = run(debug=debug) - return exit_code, behavior + # We assume run() has already handled the logging for errors and shutdowns + return exit_code, PostRunBehavior.NORMAL @app.command(name="start") @@ -71,7 +53,8 @@ def start( elif exit_code == 130: print_info("Bot shutdown requested by user (Ctrl+C)") else: - print_error(f"Bot exited with code {exit_code}") + # We don't print a generic error here because run() already logged it + pass sys.exit(exit_code) From a45ec6e3890b76ff9a49c4395fb99225fdc494f2 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 07:28:38 -0500 Subject: [PATCH 34/79] refactor(docs): improve error handling in documentation server startup - Enhanced error handling in the serve function by raising Exit(1) on subprocess failure, providing clearer feedback on server startup issues. - Updated exception handling to include the original exception for better debugging context. --- scripts/docs/serve.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/docs/serve.py b/scripts/docs/serve.py index 6813d3cab..d3ceb60fb 100644 --- a/scripts/docs/serve.py +++ b/scripts/docs/serve.py @@ -52,8 +52,9 @@ def serve( try: print_info(f"Starting documentation server at {dev_addr}") subprocess.run(cmd, check=True, env=os.environ.copy()) - except subprocess.CalledProcessError: + except subprocess.CalledProcessError as e: print_error("Failed to start documentation server") + raise Exit(1) from e except KeyboardInterrupt: print_info("\nDocumentation server stopped") From 7a10f8d904f39ec3e501748d9617aa8ffedb3d0a Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 07:28:48 -0500 Subject: [PATCH 35/79] refactor(db): streamline health check output and update nuke command documentation - Simplified the health check output by removing redundant connection and response time messages, focusing on the database mode. - Updated comments in the nuke command to clarify the implications of dropping the public schema and improved the formatting of warning messages for better readability. - Refactored long-running queries SQL into a constant for improved maintainability and clarity in the queries command. --- scripts/db/health.py | 7 +------ scripts/db/nuke.py | 2 +- scripts/db/queries.py | 26 +++++++++++++------------- 3 files changed, 15 insertions(+), 20 deletions(-) diff --git a/scripts/db/health.py b/scripts/db/health.py index 49ae9e61d..563e32a8d 100644 --- a/scripts/db/health.py +++ b/scripts/db/health.py @@ -38,12 +38,7 @@ async def _health_check(): if health_data["status"] == "healthy": rich_print("[green]Database is healthy![/green]") - rich_print( - f"[green]Connection: {health_data.get('connection', 'OK')}[/green]", - ) - rich_print( - f"[green]Response time: {health_data.get('response_time', 'N/A')}[/green]", - ) + rich_print(f"[green]Mode: {health_data.get('mode', 'unknown')}[/green]") else: rich_print("[red]Database is unhealthy![/red]") rich_print( diff --git a/scripts/db/nuke.py b/scripts/db/nuke.py index 27d611b41..73dff2f20 100644 --- a/scripts/db/nuke.py +++ b/scripts/db/nuke.py @@ -23,7 +23,7 @@ async def _drop_all_tables(session: Any) -> None: """Drop all tables and recreate the public schema.""" - await session.execute(text("DROP TABLE IF EXISTS alembic_version")) + # 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")) await session.execute(text("GRANT ALL ON SCHEMA public TO public")) diff --git a/scripts/db/queries.py b/scripts/db/queries.py index f880bc167..086eacba6 100644 --- a/scripts/db/queries.py +++ b/scripts/db/queries.py @@ -23,6 +23,18 @@ 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: @@ -40,19 +52,7 @@ async def _check_queries(): async def _get_long_queries( session: Any, ) -> list[tuple[Any, Any, str, str]]: - 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 - """), - ) + result = await session.execute(text(LONG_RUNNING_QUERIES_SQL)) return result.fetchall() long_queries = await service.execute_query( From 40e33d8d318e26a09b6e3d849bc205a323d45b9d Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 07:43:21 -0500 Subject: [PATCH 36/79] refactor(db): improve health check error handling and update nuke command security - Introduced a helper function to raise Exit(1) for better compliance with error handling standards in the health check command. - Enhanced error reporting for failed health checks, ensuring clearer feedback on database status. - Updated comments in the nuke command to reflect security improvements by removing unnecessary permissions on the public schema. --- scripts/db/health.py | 16 ++++++++++++---- scripts/db/init.py | 2 -- scripts/db/nuke.py | 3 ++- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/scripts/db/health.py b/scripts/db/health.py index 563e32a8d..638bd50da 100644 --- a/scripts/db/health.py +++ b/scripts/db/health.py @@ -22,6 +22,11 @@ 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.""" @@ -39,17 +44,20 @@ async def _health_check(): if health_data["status"] == "healthy": rich_print("[green]Database is healthy![/green]") rich_print(f"[green]Mode: {health_data.get('mode', 'unknown')}[/green]") + 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_success("Health check completed") + print_error("Health check failed") + _fail() except Exception as e: - print_error(f"Failed to check database health: {e}") - raise Exit(1) from 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() diff --git a/scripts/db/init.py b/scripts/db/init.py index efbfa325c..ea4ffdb9c 100644 --- a/scripts/db/init.py +++ b/scripts/db/init.py @@ -14,7 +14,6 @@ from scripts.proc import run_command from scripts.ui import print_error, print_section, print_success, rich_print from tux.database.service import DatabaseService -from tux.shared.config import CONFIG app = create_app() @@ -23,7 +22,6 @@ async def _inspect_db_state() -> tuple[int, int]: """Return (table_count, migration_count).""" service = DatabaseService(echo=False) try: - await service.connect(CONFIG.database_url) async with service.session() as session: table_result = await session.execute( text( diff --git a/scripts/db/nuke.py b/scripts/db/nuke.py index 73dff2f20..cf7f0c801 100644 --- a/scripts/db/nuke.py +++ b/scripts/db/nuke.py @@ -26,7 +26,8 @@ async def _drop_all_tables(session: Any) -> None: # 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")) - await session.execute(text("GRANT ALL ON SCHEMA public TO public")) + # Note: We removed GRANT ALL ON SCHEMA public TO public for security reasons. + # The connection user should already have necessary permissions as owner. await session.commit() From 965f186441399b7debd47e40604d406c058dc090 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 07:43:31 -0500 Subject: [PATCH 37/79] refactor(start): simplify bot execution flow and enhance error handling - Removed the PostRunBehavior enum and streamlined the _run_bot function to return only the exit code. - Updated the start command to handle exit codes directly, improving clarity in success and shutdown messages. - Enhanced comments to reflect the changes in error logging and execution flow. --- scripts/tux/start.py | 32 +++++++++----------------------- 1 file changed, 9 insertions(+), 23 deletions(-) diff --git a/scripts/tux/start.py b/scripts/tux/start.py index f0ce2625b..8a0eebd10 100644 --- a/scripts/tux/start.py +++ b/scripts/tux/start.py @@ -5,7 +5,6 @@ """ import sys -from enum import Enum, auto from typing import Annotated from typer import Option @@ -17,24 +16,14 @@ app = create_app() -class PostRunBehavior(Enum): - """Behavior after the bot runs.""" - - NORMAL = auto() # Run generic status messages - SKIP = auto() # Messages already handled - - -def _run_bot(debug: bool) -> tuple[int, PostRunBehavior]: - """Run the bot and return the exit code and post-run behavior.""" +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. - exit_code = run(debug=debug) - - # We assume run() has already handled the logging for errors and shutdowns - return exit_code, PostRunBehavior.NORMAL + return run(debug=debug) @app.command(name="start") @@ -45,16 +34,13 @@ def start( print_section("Starting Tux Bot", "blue") rich_print("[bold blue]Starting Tux Discord bot...[/bold blue]") - exit_code, behavior = _run_bot(debug) + exit_code = _run_bot(debug) - if behavior is PostRunBehavior.NORMAL: - if exit_code == 0: - print_success("Bot completed successfully") - elif exit_code == 130: - print_info("Bot shutdown requested by user (Ctrl+C)") - else: - # We don't print a generic error here because run() already logged it - pass + 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) From 21b0d65cd9ba5c69dadb97c570c89d8ccaaa6ca5 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 07:43:37 -0500 Subject: [PATCH 38/79] refactor(app): remove TuxGracefulShutdown from documentation - Removed the mention of TuxGracefulShutdown from the application startup documentation, as it is no longer relevant. - Updated comments to reflect the current state of error handling during application startup. --- src/tux/core/app.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/tux/core/app.py b/src/tux/core/app.py index c16f01483..125cc9f6b 100644 --- a/src/tux/core/app.py +++ b/src/tux/core/app.py @@ -124,8 +124,6 @@ def run(self) -> int: ------ TuxSetupError If a critical application error occurs during startup. - TuxGracefulShutdown - If the application is stopped gracefully. """ try: # Create a fresh event loop for this application run From 45259214d14cf916b2a67c443aa95631d144dd73 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 08:03:49 -0500 Subject: [PATCH 39/79] refactor(db): enhance error handling across database commands - Updated error handling in the check, downgrade, history, init, new, push, and nuke commands to use CalledProcessError for more specific error reporting. - Improved feedback messages to include exception details, aiding in debugging and user clarity. - Ensured consistent use of Exit(1) for error scenarios to standardize command behavior. --- scripts/db/check.py | 9 +++++++-- scripts/db/downgrade.py | 8 +++++--- scripts/db/history.py | 9 +++++++-- scripts/db/init.py | 2 ++ scripts/db/new.py | 7 ++++--- scripts/db/nuke.py | 8 +++++--- scripts/db/push.py | 9 +++++++-- 7 files changed, 37 insertions(+), 15 deletions(-) diff --git a/scripts/db/check.py b/scripts/db/check.py index 673cd666f..99f06029d 100644 --- a/scripts/db/check.py +++ b/scripts/db/check.py @@ -4,6 +4,10 @@ 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 @@ -20,8 +24,9 @@ def check() -> None: try: run_command(["uv", "run", "alembic", "check"]) print_success("All migrations validated successfully!") - except Exception: - print_error("Migration validation failed - check your migration files") + except CalledProcessError as e: + print_error(f"Migration validation failed: {e}") + raise Exit(1) from e if __name__ == "__main__": diff --git a/scripts/db/downgrade.py b/scripts/db/downgrade.py index 5e950761d..ea6f34b05 100644 --- a/scripts/db/downgrade.py +++ b/scripts/db/downgrade.py @@ -4,9 +4,10 @@ Rolls back to a previous migration revision. """ +from subprocess import CalledProcessError from typing import Annotated -from typer import Argument, Option +from typer import Argument, Exit, Option from scripts.core import create_app from scripts.proc import run_command @@ -44,8 +45,9 @@ def downgrade( try: run_command(["uv", "run", "alembic", "downgrade", revision]) print_success(f"Successfully downgraded to revision: {revision}") - except Exception: - print_error(f"Failed to downgrade to revision: {revision}") + except CalledProcessError as e: + print_error(f"Failed to downgrade to revision {revision}: {e}") + raise Exit(1) from e if __name__ == "__main__": diff --git a/scripts/db/history.py b/scripts/db/history.py index b96e29c7e..1864ad708 100644 --- a/scripts/db/history.py +++ b/scripts/db/history.py @@ -4,6 +4,10 @@ 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 @@ -20,8 +24,9 @@ def history() -> None: try: run_command(["uv", "run", "alembic", "history", "--verbose"]) print_success("History displayed") - except Exception: - print_error("Failed to get migration history") + except CalledProcessError as e: + print_error(f"Failed to get migration history: {e}") + raise Exit(1) from e if __name__ == "__main__": diff --git a/scripts/db/init.py b/scripts/db/init.py index ea4ffdb9c..87f03ae4f 100644 --- a/scripts/db/init.py +++ b/scripts/db/init.py @@ -9,6 +9,7 @@ import pathlib from sqlalchemy import text +from typer import Exit from scripts.core import create_app from scripts.proc import run_command @@ -94,6 +95,7 @@ def init() -> None: except Exception as e: print_error(f"Failed to initialize database: {e}") + raise Exit(1) from e if __name__ == "__main__": diff --git a/scripts/db/new.py b/scripts/db/new.py index 8179347fa..5f6e480ec 100644 --- a/scripts/db/new.py +++ b/scripts/db/new.py @@ -4,6 +4,7 @@ Generates a new migration from model changes. """ +from subprocess import CalledProcessError from typing import Annotated from typer import Argument, Exit, Option @@ -39,9 +40,9 @@ def new( run_command(cmd) print_success(f"Migration generated: {message}") rich_print("[yellow]Review the migration file before applying[/yellow]") - except Exception: - print_error("Failed to generate migration") - raise Exit(1) from None + except CalledProcessError as e: + print_error(f"Failed to generate migration: {e}") + raise Exit(1) from e if __name__ == "__main__": diff --git a/scripts/db/nuke.py b/scripts/db/nuke.py index cf7f0c801..02bc4baf1 100644 --- a/scripts/db/nuke.py +++ b/scripts/db/nuke.py @@ -5,7 +5,6 @@ """ import asyncio -import pathlib import sys import traceback from typing import Annotated, Any @@ -13,7 +12,7 @@ from sqlalchemy import text from typer import Exit, Option -from scripts.core import create_app +from scripts.core import ROOT, create_app from scripts.ui import print_error, print_info, print_section, print_success, rich_print from tux.database.service import DatabaseService from tux.shared.config import CONFIG @@ -33,7 +32,7 @@ async def _drop_all_tables(session: Any) -> None: def _delete_migration_files(): """Delete all migration files in the versions directory.""" - migration_dir = pathlib.Path("src/tux/database/migrations/versions") + migration_dir = ROOT / "src" / "tux" / "database" / "migrations" / "versions" if migration_dir.exists(): rich_print("[yellow]Deleting all migration files...[/yellow]") deleted_count = 0 @@ -42,6 +41,9 @@ def _delete_migration_files(): migration_file.unlink() deleted_count += 1 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): diff --git a/scripts/db/push.py b/scripts/db/push.py index 72506003f..c009fc679 100644 --- a/scripts/db/push.py +++ b/scripts/db/push.py @@ -4,6 +4,10 @@ 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 @@ -20,8 +24,9 @@ def push() -> None: try: run_command(["uv", "run", "alembic", "upgrade", "head"]) print_success("All migrations applied!") - except Exception: - print_error("Failed to apply migrations") + except CalledProcessError as e: + print_error(f"Failed to apply migrations: {e}") + raise Exit(1) from e if __name__ == "__main__": From a9905b1e44e7087b011a18f36aecbc9d766e2d41 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 08:04:02 -0500 Subject: [PATCH 40/79] refactor(dev): enhance error handling in development scripts - Updated error handling in docstring coverage, formatting, linting, and type checking scripts to use CalledProcessError for more specific error reporting. - Improved feedback messages to include exception details, aiding in debugging and user clarity. - Ensured consistent use of sys.exit(1) for error scenarios to standardize command behavior. --- scripts/dev/docstring_coverage.py | 19 ++++++++++++++----- scripts/dev/format.py | 5 +++-- scripts/dev/lint_docstring.py | 5 +++-- scripts/dev/type_check.py | 5 +++-- 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/scripts/dev/docstring_coverage.py b/scripts/dev/docstring_coverage.py index c9ec3c359..a80b20a30 100644 --- a/scripts/dev/docstring_coverage.py +++ b/scripts/dev/docstring_coverage.py @@ -4,9 +4,11 @@ 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_section, print_success +from scripts.ui import print_error, print_section, print_success app = create_app() @@ -19,10 +21,17 @@ def docstring_coverage() -> None: try: run_command(["uv", "run", "docstr-coverage", "--verbose", "2", "."]) print_success("Docstring coverage report generated") - except Exception: - # docstr-coverage might return non-zero if coverage is below threshold - # but we still want to show the report - pass + 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__": diff --git a/scripts/dev/format.py b/scripts/dev/format.py index c0eb4f608..eec6046fd 100644 --- a/scripts/dev/format.py +++ b/scripts/dev/format.py @@ -5,6 +5,7 @@ """ import sys +from subprocess import CalledProcessError from scripts.core import create_app from scripts.proc import run_command @@ -21,8 +22,8 @@ def format_code() -> None: try: run_command(["uv", "run", "ruff", "format", "."]) print_success("Code formatting completed successfully") - except Exception: - print_error("Code formatting did not pass - see issues above") + except CalledProcessError as e: + print_error(f"Code formatting failed: {e}") sys.exit(1) diff --git a/scripts/dev/lint_docstring.py b/scripts/dev/lint_docstring.py index 5963e2178..7b412deba 100644 --- a/scripts/dev/lint_docstring.py +++ b/scripts/dev/lint_docstring.py @@ -5,6 +5,7 @@ """ import sys +from subprocess import CalledProcessError from scripts.core import create_app from scripts.proc import run_command @@ -21,8 +22,8 @@ def lint_docstring() -> None: try: run_command(["uv", "run", "pydoclint", "--config=pyproject.toml", "."]) print_success("Docstring linting completed successfully") - except Exception: - print_error("Docstring linting did not pass - see issues above") + except CalledProcessError as e: + print_error(f"Docstring linting failed: {e}") sys.exit(1) diff --git a/scripts/dev/type_check.py b/scripts/dev/type_check.py index beeb4f853..2908282f5 100644 --- a/scripts/dev/type_check.py +++ b/scripts/dev/type_check.py @@ -5,6 +5,7 @@ """ import sys +from subprocess import CalledProcessError from scripts.core import create_app from scripts.proc import run_command @@ -21,8 +22,8 @@ def type_check() -> None: try: run_command(["uv", "run", "basedpyright"]) print_success("Type checking completed successfully") - except Exception: - print_error("Type checking did not pass - see issues above") + except CalledProcessError as e: + print_error(f"Type checking failed: {e}") sys.exit(1) From 279c5e646a7099923694a7b2daeb291165659b07 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 08:04:18 -0500 Subject: [PATCH 41/79] refactor(docs): unify error handling and improve feedback in documentation scripts - Replaced subprocess calls with a centralized run_command function for consistent error handling across documentation scripts. - Enhanced error messages to include exception details, improving clarity for users and aiding in debugging. - Standardized the use of Exit(1) for error scenarios to ensure uniform behavior across all documentation commands. --- scripts/docs/build.py | 13 +++++-------- scripts/docs/lint.py | 7 +++++++ scripts/docs/serve.py | 13 ++++++------- scripts/docs/wrangler_deployments.py | 16 ++++++++++------ scripts/docs/wrangler_tail.py | 23 ++++++++++------------- scripts/docs/wrangler_versions.py | 7 ++++++- 6 files changed, 44 insertions(+), 35 deletions(-) diff --git a/scripts/docs/build.py b/scripts/docs/build.py index c56bcd568..09f60b488 100644 --- a/scripts/docs/build.py +++ b/scripts/docs/build.py @@ -4,14 +4,13 @@ Builds documentation site for production. """ -import os -import subprocess 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() @@ -42,13 +41,11 @@ def build( try: print_info("Building documentation...") - subprocess.run(cmd, check=True, env=os.environ.copy()) + run_command(cmd, capture_output=False) print_success("Documentation built successfully") - except subprocess.CalledProcessError: - print_error("Failed to build documentation") - raise - except KeyboardInterrupt: - print_info("\nBuild interrupted") + except Exception as e: + print_error(f"Failed to build documentation: {e}") + raise Exit(1) from e if __name__ == "__main__": diff --git a/scripts/docs/lint.py b/scripts/docs/lint.py index 2efacf527..9a614c3e2 100644 --- a/scripts/docs/lint.py +++ b/scripts/docs/lint.py @@ -41,6 +41,13 @@ def lint() -> None: 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() + if content.strip() == "": issues.append(f"Empty file: {md_file}") elif not content.startswith("#"): diff --git a/scripts/docs/serve.py b/scripts/docs/serve.py index d3ceb60fb..aa8a4a2a8 100644 --- a/scripts/docs/serve.py +++ b/scripts/docs/serve.py @@ -4,14 +4,13 @@ Serves documentation locally with live reload. """ -import os -import subprocess 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() @@ -46,14 +45,14 @@ def serve( if open_browser: cmd.append("--open") if strict: - print_info("Warning: --strict mode is currently unsupported by zensical") - cmd.append("--strict") + print_error("--strict mode is currently unsupported by zensical") + raise Exit(1) try: print_info(f"Starting documentation server at {dev_addr}") - subprocess.run(cmd, check=True, env=os.environ.copy()) - except subprocess.CalledProcessError as e: - print_error("Failed to start documentation server") + run_command(cmd, capture_output=False) + except Exception as e: + print_error(f"Failed to start documentation server: {e}") raise Exit(1) from e except KeyboardInterrupt: print_info("\nDocumentation server stopped") diff --git a/scripts/docs/wrangler_deployments.py b/scripts/docs/wrangler_deployments.py index fc3c1cd31..70b29add2 100644 --- a/scripts/docs/wrangler_deployments.py +++ b/scripts/docs/wrangler_deployments.py @@ -4,12 +4,13 @@ Lists deployment history. """ -from pathlib import Path +from subprocess import CalledProcessError from typing import Annotated -from typer import Option +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 @@ -26,9 +27,8 @@ def wrangler_deployments( """List deployment history for the documentation site.""" print_section("Deployment History", "blue") - if not Path("wrangler.toml").exists(): - print_error("wrangler.toml not found. Please run from the project root.") - return + if not has_wrangler_config(): + raise Exit(1) cmd = ["wrangler", "deployments", "list"] if limit: @@ -37,8 +37,12 @@ def wrangler_deployments( 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"Error: {e}") + print_error(f"An unexpected error occurred: {e}") + raise Exit(1) from e if __name__ == "__main__": diff --git a/scripts/docs/wrangler_tail.py b/scripts/docs/wrangler_tail.py index 15c15a5fa..6e52f8c51 100644 --- a/scripts/docs/wrangler_tail.py +++ b/scripts/docs/wrangler_tail.py @@ -4,14 +4,13 @@ Views real-time logs from deployed docs. """ -import os -import subprocess -from pathlib import Path from typing import Annotated -from typer import Option +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() @@ -22,7 +21,7 @@ def wrangler_tail( 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"), @@ -31,9 +30,8 @@ def wrangler_tail( """View real-time logs from deployed documentation.""" print_section("Tailing Logs", "blue") - if not Path("wrangler.toml").exists(): - print_error("wrangler.toml not found. Please run from the project root.") - return + if not has_wrangler_config(): + raise Exit(1) cmd = ["wrangler", "tail"] if format_output: @@ -44,13 +42,12 @@ def wrangler_tail( print_info("Starting log tail... (Ctrl+C to stop)") try: - subprocess.run(cmd, check=True, env=os.environ.copy()) - except subprocess.CalledProcessError: - print_error("Failed to tail logs") + 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") - except Exception as e: - print_error(f"Error: {e}") if __name__ == "__main__": diff --git a/scripts/docs/wrangler_versions.py b/scripts/docs/wrangler_versions.py index f1a974ce8..4f566645c 100644 --- a/scripts/docs/wrangler_versions.py +++ b/scripts/docs/wrangler_versions.py @@ -4,6 +4,7 @@ Lists and manages versions. """ +from subprocess import CalledProcessError from typing import Annotated, Literal from typer import Exit, Option @@ -55,8 +56,12 @@ def wrangler_versions( 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"Error: {e}") + print_error(f"An unexpected error occurred: {e}") + raise Exit(1) from e if __name__ == "__main__": From 25047531849027f2a532061dd37f788ce170734d Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 08:04:26 -0500 Subject: [PATCH 42/79] refactor(tests): improve error handling and feedback in test scripts - Updated test scripts to include error handling for command execution failures, providing clearer feedback with exception details. - Standardized the use of print_error for consistent error reporting across various test types. - Enhanced user experience by ensuring that all test scripts handle exceptions gracefully and provide informative messages. --- scripts/test/benchmark.py | 8 ++++++-- scripts/test/coverage.py | 6 +++--- scripts/test/file.py | 8 ++++++-- scripts/test/html.py | 6 +++--- scripts/test/parallel.py | 9 +++++++-- scripts/test/plain.py | 8 ++++++-- scripts/test/quick.py | 8 ++++++-- 7 files changed, 37 insertions(+), 16 deletions(-) diff --git a/scripts/test/benchmark.py b/scripts/test/benchmark.py index 685a1dc2a..72fd55f10 100644 --- a/scripts/test/benchmark.py +++ b/scripts/test/benchmark.py @@ -7,7 +7,7 @@ import os from scripts.core import create_app -from scripts.ui import print_info, print_section +from scripts.ui import print_error, print_info, print_section app = create_app() @@ -18,7 +18,11 @@ def benchmark_tests() -> None: print_section("Benchmark Tests", "blue") cmd = ["uv", "run", "pytest", "--benchmark-only", "--benchmark-sort=mean"] print_info(f"Running: {' '.join(cmd)}") - os.execvp(cmd[0], cmd) + try: + os.execvp(cmd[0], cmd) + except OSError as e: + print_error(f"Failed to execute command: {e}") + raise if __name__ == "__main__": diff --git a/scripts/test/coverage.py b/scripts/test/coverage.py index a4a80e10b..b362b47fd 100644 --- a/scripts/test/coverage.py +++ b/scripts/test/coverage.py @@ -72,11 +72,11 @@ def coverage_report( webbrowser.open(f"file://{html_report_path.resolve()}") except Exception as e: print_error(f"Failed to open browser: {e}") - except CalledProcessError: - print_error("Coverage report generation failed") + 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: {e}") + print_error(f"An unexpected error occurred during coverage generation: {e}") sys.exit(1) diff --git a/scripts/test/file.py b/scripts/test/file.py index ba0e0eddb..83101b3de 100644 --- a/scripts/test/file.py +++ b/scripts/test/file.py @@ -10,7 +10,7 @@ from typer import Argument, Option from scripts.core import create_app -from scripts.ui import print_info, print_section +from scripts.ui import print_error, print_info, print_section app = create_app() @@ -34,7 +34,11 @@ def file_tests( cmd.extend(test_paths) print_info(f"Running: {' '.join(cmd)}") - os.execvp(cmd[0], cmd) + try: + os.execvp(cmd[0], cmd) + except OSError as e: + print_error(f"Failed to execute command: {e}") + raise if __name__ == "__main__": diff --git a/scripts/test/html.py b/scripts/test/html.py index 07212d77e..d3092051a 100644 --- a/scripts/test/html.py +++ b/scripts/test/html.py @@ -54,11 +54,11 @@ def html_report( else: print_warning(f"HTML report not found at {report_path}") - except CalledProcessError: - print_error("Tests failed - see output above") + except CalledProcessError as e: + print_error(f"Tests failed: {e}") sys.exit(1) except Exception as e: - print_error(f"An unexpected error occurred: {e}") + print_error(f"An unexpected error occurred during HTML report generation: {e}") sys.exit(1) diff --git a/scripts/test/parallel.py b/scripts/test/parallel.py index 5c75079d9..f61ace091 100644 --- a/scripts/test/parallel.py +++ b/scripts/test/parallel.py @@ -5,12 +5,13 @@ """ import os +import sys from typing import Annotated from typer import Option from scripts.core import create_app -from scripts.ui import print_info, print_section +from scripts.ui import print_error, print_info, print_section app = create_app() @@ -46,7 +47,11 @@ def parallel_tests( cmd.extend(["--dist", load_scope]) print_info(f"Running: {' '.join(cmd)}") - os.execvp(cmd[0], 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__": diff --git a/scripts/test/plain.py b/scripts/test/plain.py index 90ee42e2f..c8069207f 100644 --- a/scripts/test/plain.py +++ b/scripts/test/plain.py @@ -7,7 +7,7 @@ import os from scripts.core import create_app -from scripts.ui import print_info, print_section +from scripts.ui import print_error, print_info, print_section app = create_app() @@ -18,7 +18,11 @@ def plain_tests() -> None: print_section("Plain Tests", "blue") cmd = ["uv", "run", "pytest", "-p", "no:sugar"] print_info(f"Running: {' '.join(cmd)}") - os.execvp(cmd[0], cmd) + try: + os.execvp(cmd[0], cmd) + except OSError as e: + print_error(f"Failed to execute command: {e}") + raise if __name__ == "__main__": diff --git a/scripts/test/quick.py b/scripts/test/quick.py index 9edc3d645..d8a9bea17 100644 --- a/scripts/test/quick.py +++ b/scripts/test/quick.py @@ -7,7 +7,7 @@ import os from scripts.core import create_app -from scripts.ui import print_info, print_section +from scripts.ui import print_error, print_info, print_section app = create_app() @@ -18,7 +18,11 @@ def quick_tests() -> None: print_section("Quick Tests", "blue") cmd = ["uv", "run", "pytest", "--no-cov"] print_info(f"Running: {' '.join(cmd)}") - os.execvp(cmd[0], cmd) + try: + os.execvp(cmd[0], cmd) + except OSError as e: + print_error(f"Failed to execute command: {e}") + raise if __name__ == "__main__": From 5649e96a930a4bb0f0898b9df0017828e51f1de5 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 08:04:34 -0500 Subject: [PATCH 43/79] refactor(config): improve error handling and streamline command execution in configuration generation - Replaced subprocess calls with a centralized run_command function for consistent error handling. - Enhanced error messages to provide clearer feedback on unknown formats and command execution failures. - Removed unsupported custom output path handling when using the CLI approach, simplifying the function's interface. --- scripts/config/generate.py | 36 +++++++----------------------------- 1 file changed, 7 insertions(+), 29 deletions(-) diff --git a/scripts/config/generate.py b/scripts/config/generate.py index 1eb234f9c..002f6f986 100644 --- a/scripts/config/generate.py +++ b/scripts/config/generate.py @@ -4,7 +4,6 @@ Generates configuration example files. """ -import subprocess from pathlib import Path from typing import Annotated, Literal @@ -12,6 +11,7 @@ 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() @@ -27,29 +27,10 @@ def generate( 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.""" console.print(Panel.fit("Configuration Generator", style="bold blue")) - if output is not None: - console.print( - "Custom output paths are not supported when using CLI approach", - style="red", - ) - console.print( - "Use pyproject.toml configuration to specify custom paths", - style="yellow", - ) - raise Exit(code=1) - pyproject_path = Path("pyproject.toml") if not pyproject_path.exists(): console.print("pyproject.toml not found in current directory", style="red") @@ -78,22 +59,19 @@ def generate( ], } - formats_to_generate = format_map.get(format_, []) + 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: - result = subprocess.run(cmd, capture_output=True, text=True, check=True) - if result.stdout: - console.print(f"Output: {result.stdout.strip()}", style="dim") - except subprocess.CalledProcessError as e: + run_command(cmd, capture_output=True) + except Exception as e: console.print(f"Error running {generator}: {e}", style="red") - if e.stdout: - console.print(f"Stdout: {e.stdout}", style="dim") - if e.stderr: - console.print(f"Stderr: {e.stderr}", style="red") raise Exit(code=1) from e console.print("\nConfiguration files generated successfully!", style="bold green") From e5d98857dba46546211ca9cea53d871383b4bbd9 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 08:05:03 -0500 Subject: [PATCH 44/79] refactor(proc): update logging for command execution - Enhanced command execution logging by uncommenting the console print statement for better auditing visibility. - Maintained the focus on security by ensuring command execution details are logged appropriately. --- scripts/proc.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/proc.py b/scripts/proc.py index 5deb44314..6e45abaaf 100644 --- a/scripts/proc.py +++ b/scripts/proc.py @@ -43,8 +43,8 @@ def run_command( """ run_env = env if env is not None else os.environ.copy() - # Log command for auditing (security suggestion) - # console.print(f"[dim]Executing: {shlex.join(command)}[/dim]") + # Log command for auditing + console.print(f"[dim]Executing: {shlex.join(command)}[/dim]") try: result = subprocess.run( From f1d87f4e675cca5f38c6d33c283f6a194c3b5148 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 08:05:15 -0500 Subject: [PATCH 45/79] refactor(main): add RuntimeError to exception handling - Included RuntimeError in the list of exceptions handled in the run function to improve error management and robustness. - This change enhances the application's ability to gracefully handle runtime issues during execution. --- src/tux/main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/tux/main.py b/src/tux/main.py index 2a91e447b..520111152 100644 --- a/src/tux/main.py +++ b/src/tux/main.py @@ -52,6 +52,7 @@ def run(debug: bool = False) -> int: TuxSetupError, TuxGracefulShutdown, TuxError, + RuntimeError, SystemExit, KeyboardInterrupt, Exception, From 8651630723b4bd099dd29aeee62bcbe03161e766 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 08:22:25 -0500 Subject: [PATCH 46/79] refactor(config): enhance error handling in command execution - Updated the generate function to use run_command for consistent error handling and auditing. - Improved clarity of error messages when running generators, aiding in debugging and user feedback. --- scripts/config/generate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/config/generate.py b/scripts/config/generate.py index 002f6f986..93727e3b3 100644 --- a/scripts/config/generate.py +++ b/scripts/config/generate.py @@ -69,6 +69,7 @@ def generate( 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") From 1bc1b2200e7ed821a7a1c5e66a6fac3a81067f98 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 08:22:36 -0500 Subject: [PATCH 47/79] refactor(db): improve error handling and clarity in database scripts - Enhanced error handling in downgrade, init, nuke, and show commands to provide more specific feedback using CalledProcessError. - Improved user prompts and messages for better clarity and safety, especially in destructive operations. - Streamlined migration file filtering and added safety checks to prevent accidental operations on production databases. --- scripts/db/downgrade.py | 23 ++++++++----- scripts/db/init.py | 19 ++++++++--- scripts/db/nuke.py | 73 ++++++++++++++++++++++++++++++++++------- scripts/db/show.py | 6 +++- 4 files changed, 97 insertions(+), 24 deletions(-) diff --git a/scripts/db/downgrade.py b/scripts/db/downgrade.py index ea6f34b05..54e19d196 100644 --- a/scripts/db/downgrade.py +++ b/scripts/db/downgrade.py @@ -7,11 +7,17 @@ from subprocess import CalledProcessError from typing import Annotated -from typer import Argument, Exit, Option +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 +from scripts.ui import ( + print_error, + print_info, + print_section, + print_success, + rich_print, +) app = create_app() @@ -36,18 +42,19 @@ def downgrade( "[yellow]This may cause data loss. Backup your database first.[/yellow]\n", ) - if not force and revision != "-1": - response = input(f"Type 'yes' to downgrade to {revision}: ") - if response.lower() != "yes": - print_info("Downgrade cancelled") - return + if ( + not force + and revision != "-1" + 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}") - raise Exit(1) from e if __name__ == "__main__": diff --git a/scripts/db/init.py b/scripts/db/init.py index 87f03ae4f..558ee4db5 100644 --- a/scripts/db/init.py +++ b/scripts/db/init.py @@ -38,8 +38,9 @@ async def _inspect_db_state() -> tuple[int, int]: text("SELECT COUNT(*) FROM alembic_version"), ) - table_count = table_result.scalar() or 0 - migration_count = migration_result.scalar() or 0 + # 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 @@ -60,7 +61,17 @@ def init() -> None: migration_dir = pathlib.Path("src/tux/database/migrations/versions") migration_files = list(migration_dir.glob("*.py")) if migration_dir.exists() else [] - migration_file_count = len([f for f in migration_files if f.name != "__init__.py"]) + + # 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("_") + and f.suffix == ".py" + ], + ) if table_count > 0 or migration_count > 0 or migration_file_count > 0: rich_print( @@ -71,7 +82,7 @@ def init() -> None: rich_print( "[yellow]'db init' only works on completely empty databases with no migration files.[/yellow]", ) - return + raise Exit(1) try: rich_print("[blue]Generating initial migration...[/blue]") diff --git a/scripts/db/nuke.py b/scripts/db/nuke.py index 02bc4baf1..2e49b8c79 100644 --- a/scripts/db/nuke.py +++ b/scripts/db/nuke.py @@ -5,54 +5,100 @@ """ import asyncio +import os import sys import traceback -from typing import Annotated, Any +from typing import Annotated -from sqlalchemy import text +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, rich_print +from scripts.ui import ( + print_error, + print_info, + print_section, + print_success, + print_warning, + rich_print, +) from tux.database.service import DatabaseService from tux.shared.config import CONFIG app = create_app() -async def _drop_all_tables(session: Any) -> None: - """Drop all tables and recreate the public schema.""" +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. - await session.commit() 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": - migration_file.unlink() - deleted_count += 1 + 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}") + print_error(f"Migration directory not found at: {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 + # We check for a generic ENVIRONMENT variable or explicit PRODUCTION flag in CONFIG + is_prod = ( + os.getenv("ENVIRONMENT", "").lower() == "production" + or os.getenv("APP_ENV", "").lower() == "production" + or getattr(CONFIG, "PRODUCTION", False) + ) + + db_url = CONFIG.database_url + is_prod_db = any( + kw in db_url.lower() for kw in ["prod", "live", "allthingslinux.org"] + ) + + 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_all_tables, "drop_all_tables") + await service.execute_query( + _drop_and_recreate_schema, + "drop_and_recreate_schema", + ) print_success("Nuclear reset completed - database is completely empty") @@ -96,7 +142,12 @@ def nuke( Option("--yes", "-y", help="Automatically answer 'yes' to all prompts"), ] = False, ) -> None: - """Complete database reset.""" + """ + 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( diff --git a/scripts/db/show.py b/scripts/db/show.py index 7ce4a28b7..9ef34bbe3 100644 --- a/scripts/db/show.py +++ b/scripts/db/show.py @@ -4,6 +4,7 @@ Shows details of a specific migration. """ +from subprocess import CalledProcessError from typing import Annotated from typer import Argument, Exit @@ -31,8 +32,11 @@ def show( 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"Failed to show migration: {revision}") + print_error(f"An unexpected error occurred: {e}") raise Exit(1) from e From feabb9b30341b05bf081e896c404d710cf899915 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 08:22:47 -0500 Subject: [PATCH 48/79] refactor(docs): enhance error handling and feedback in documentation scripts - Updated linting script to raise Exit(1) for missing documentation directory, improving error handling. - Improved error messages for reading markdown files and standardized output for linting issues. - Enhanced Wrangler development script to raise Exit(1) for missing configuration and added error handling for build failures. - Introduced output format and log status enums in the wrangler tail script for better clarity and type safety. - Standardized exit codes and error messages across documentation commands for consistent user feedback. --- scripts/docs/lint.py | 12 +++++++----- scripts/docs/serve.py | 1 + scripts/docs/wrangler_dev.py | 28 ++++++++++++++++++---------- scripts/docs/wrangler_tail.py | 29 +++++++++++++++++++++++------ 4 files changed, 49 insertions(+), 21 deletions(-) diff --git a/scripts/docs/lint.py b/scripts/docs/lint.py index 9a614c3e2..367b02e0b 100644 --- a/scripts/docs/lint.py +++ b/scripts/docs/lint.py @@ -6,6 +6,8 @@ from pathlib import Path +from typer import Exit + from scripts.core import create_app from scripts.ui import ( create_progress_bar, @@ -26,7 +28,7 @@ def lint() -> None: docs_dir = Path("docs/content") if not docs_dir.exists(): print_error("docs/content directory not found") - return + raise Exit(1) all_md_files = list(docs_dir.rglob("*.md")) issues: list[str] = [] @@ -55,16 +57,16 @@ def lint() -> None: 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}") + issues.append(f"Could not read {md_file}: {e}") progress.advance(task) if issues: - print_warning("\nDocumentation linting issues found:") + print_warning(f"Found {len(issues)} issues in documentation:") for issue in issues: - print_warning(f" • {issue}") + print_error(f" • {issue}") else: - print_success("No documentation linting issues found") + print_success("No issues found in documentation!") if __name__ == "__main__": diff --git a/scripts/docs/serve.py b/scripts/docs/serve.py index aa8a4a2a8..ce837c5fc 100644 --- a/scripts/docs/serve.py +++ b/scripts/docs/serve.py @@ -50,6 +50,7 @@ def serve( try: print_info(f"Starting documentation server at {dev_addr}") + # Using run_command for consistency and logging run_command(cmd, capture_output=False) except Exception as e: print_error(f"Failed to start documentation server: {e}") diff --git a/scripts/docs/wrangler_dev.py b/scripts/docs/wrangler_dev.py index 71aff4811..6b06019d1 100644 --- a/scripts/docs/wrangler_dev.py +++ b/scripts/docs/wrangler_dev.py @@ -4,15 +4,15 @@ Starts local Wrangler development server. """ -from pathlib import Path from typing import Annotated -from typer import Option +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 +from scripts.ui import print_error, print_info, print_section app = create_app() @@ -28,25 +28,33 @@ def wrangler_dev( """Start local Wrangler development server.""" print_section("Starting Wrangler Dev Server", "blue") - if not Path("wrangler.toml").exists(): - print_error("wrangler.toml not found. Please run from the project root.") - return + if not has_wrangler_config(): + raise Exit(1) print_info("Building documentation...") - - build(strict=True) + try: + build(strict=True) + except Exception as e: + print_error(f"Build failed, aborting Wrangler dev: {e}") + raise Exit(1) from e 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) - print_success(f"Wrangler dev server started at http://localhost:{port}") except Exception as e: - print_error(f"Error: {e}") + # KeyboardInterrupt is handled by the shell/parent + # but we catch other exceptions here + if not isinstance(e, KeyboardInterrupt): + print_error(f"Wrangler dev server failed: {e}") + raise Exit(1) from e + finally: + print_info("\nWrangler dev server stopped") if __name__ == "__main__": diff --git a/scripts/docs/wrangler_tail.py b/scripts/docs/wrangler_tail.py index 6e52f8c51..b24bd8b94 100644 --- a/scripts/docs/wrangler_tail.py +++ b/scripts/docs/wrangler_tail.py @@ -4,6 +4,7 @@ Views real-time logs from deployed docs. """ +from enum import Enum from typing import Annotated from typer import Exit, Option @@ -16,16 +17,31 @@ 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[ - str, + OutputFormat | None, Option("--format", help="Output format: json or pretty"), - ] = "", + ] = None, status: Annotated[ - str, + 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") @@ -35,9 +51,9 @@ def wrangler_tail( cmd = ["wrangler", "tail"] if format_output: - cmd.extend(["--format", format_output]) + cmd.extend(["--format", format_output.value]) if status: - cmd.extend(["--status", status]) + cmd.extend(["--status", status.value]) print_info("Starting log tail... (Ctrl+C to stop)") @@ -48,6 +64,7 @@ def wrangler_tail( raise Exit(1) from e except KeyboardInterrupt: print_info("\nLog tail stopped") + raise Exit(0) from None if __name__ == "__main__": From 1b7f4e133bd8f9680bd5a3943a4fbdbfe6de6d4a Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 08:23:02 -0500 Subject: [PATCH 49/79] refactor(coverage, parallel): enhance error handling and command execution - Updated coverage report generation to use shlex.join for improved command formatting. - Refined browser opening logic for HTML reports, adding error handling for missing reports and unsupported formats. - Added validation for worker count and load scope in parallel tests, ensuring positive integers and valid distribution modes. - Improved error messages for better user feedback and debugging clarity. --- scripts/test/coverage.py | 37 +++++++++++++++++++++++++------------ scripts/test/parallel.py | 16 +++++++++++++++- 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/scripts/test/coverage.py b/scripts/test/coverage.py index b362b47fd..b09596548 100644 --- a/scripts/test/coverage.py +++ b/scripts/test/coverage.py @@ -4,11 +4,12 @@ Generates coverage reports. """ +import shlex import sys import webbrowser from pathlib import Path from subprocess import CalledProcessError -from typing import Annotated +from typing import Annotated, Literal from typer import Option @@ -26,7 +27,7 @@ def coverage_report( Option("--specific", help="Path to include in coverage"), ] = None, format_type: Annotated[ - str | None, + Literal["html", "xml", "json"] | None, Option("--format", help="Report format: html, xml, or json"), ] = None, quick: Annotated[ @@ -52,26 +53,38 @@ def coverage_report( if quick: cmd.append("--cov-report=") - elif format_type in {"html", "xml", "json"}: + 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)]) - print_info(f"Running: {' '.join(cmd)}") + print_info(f"Running: {shlex.join(cmd)}") try: run_command(cmd, capture_output=False) - if open_browser and 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}") + if open_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.", + ) + except CalledProcessError as e: print_error(f"Coverage report generation failed: {e}") sys.exit(1) diff --git a/scripts/test/parallel.py b/scripts/test/parallel.py index f61ace091..82db6ffae 100644 --- a/scripts/test/parallel.py +++ b/scripts/test/parallel.py @@ -30,11 +30,22 @@ def parallel_tests( str | None, Option( "--load-scope", - help="Load balancing scope: module, class, or function (default: module)", + 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"] @@ -45,6 +56,9 @@ def parallel_tests( 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: From 9485b88e83cf3a290af36f4172e865cba932d8ab Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 08:23:07 -0500 Subject: [PATCH 50/79] refactor(main): improve runtime error logging - Added critical logging for RuntimeError exceptions in the run function to enhance error management and provide clearer feedback during application execution. --- src/tux/main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/tux/main.py b/src/tux/main.py index 520111152..b95af95af 100644 --- a/src/tux/main.py +++ b/src/tux/main.py @@ -72,6 +72,8 @@ def run(debug: bool = False) -> int: elif isinstance(e, KeyboardInterrupt): logger.info("Shutdown requested by user") return 0 + elif isinstance(e, RuntimeError): + logger.critical(f"Runtime error: {e}") else: logger.opt(exception=True).critical(f"Application failed to start: {e}") From 3bc52f2369645858eecb68d5076e817a2f58d4b3 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 10:41:49 -0500 Subject: [PATCH 51/79] refactor(db): enhance user prompts and error handling in database scripts - Updated downgrade, health, init, nuke, queries, schema, and tables scripts to replace progress bars with status indicators for improved user feedback. - Enhanced user prompts for critical operations to ensure intentional actions, reducing the risk of accidental data loss. - Improved error messages and structured output for better clarity and consistency across database commands. --- scripts/db/downgrade.py | 17 +++++++++-------- scripts/db/health.py | 8 +++++--- scripts/db/init.py | 19 ++++++++++++++----- scripts/db/nuke.py | 6 +++--- scripts/db/queries.py | 14 +++++--------- scripts/db/schema.py | 6 +++--- scripts/db/tables.py | 4 ++-- 7 files changed, 41 insertions(+), 33 deletions(-) diff --git a/scripts/db/downgrade.py b/scripts/db/downgrade.py index 54e19d196..3cef19f18 100644 --- a/scripts/db/downgrade.py +++ b/scripts/db/downgrade.py @@ -4,10 +4,11 @@ Rolls back to a previous migration revision. """ +import sys from subprocess import CalledProcessError from typing import Annotated -from typer import Argument, Option, confirm +from typer import Argument, Option from scripts.core import create_app from scripts.proc import run_command @@ -16,6 +17,7 @@ print_info, print_section, print_success, + prompt, rich_print, ) @@ -42,19 +44,18 @@ def downgrade( "[yellow]This may cause data loss. Backup your database first.[/yellow]\n", ) - if ( - not force - and revision != "-1" - and not confirm(f"Downgrade to {revision}?", default=False) - ): - print_info("Downgrade cancelled") - return + if not force: + response = prompt(f"Type 'yes' to downgrade to {revision}: ") + if response.lower() != "yes": + 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__": diff --git a/scripts/db/health.py b/scripts/db/health.py index 638bd50da..de4501a11 100644 --- a/scripts/db/health.py +++ b/scripts/db/health.py @@ -10,8 +10,9 @@ from scripts.core import create_app from scripts.ui import ( - create_progress_bar, + create_status, print_error, + print_pretty, print_section, print_success, rich_print, @@ -36,14 +37,15 @@ def health() -> None: async def _health_check(): service = DatabaseService(echo=False) try: - with create_progress_bar("Connecting to database...") as progress: - progress.add_task("Checking database health...", total=None) + 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]") diff --git a/scripts/db/init.py b/scripts/db/init.py index 558ee4db5..719133e57 100644 --- a/scripts/db/init.py +++ b/scripts/db/init.py @@ -13,7 +13,13 @@ from scripts.core import create_app from scripts.proc import run_command -from scripts.ui import print_error, print_section, print_success, rich_print +from scripts.ui import ( + print_error, + print_pretty, + print_section, + print_success, + rich_print, +) from tux.database.service import DatabaseService app = create_app() @@ -74,10 +80,13 @@ def init() -> None: ) if table_count > 0 or migration_count > 0 or migration_file_count > 0: - rich_print( - f"[red]Database already has {table_count} tables, " - f"{migration_count} migrations in DB, and " - f"{migration_file_count} migration files![/red]", + 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]", diff --git a/scripts/db/nuke.py b/scripts/db/nuke.py index 2e49b8c79..d66e13b05 100644 --- a/scripts/db/nuke.py +++ b/scripts/db/nuke.py @@ -21,6 +21,7 @@ print_section, print_success, print_warning, + prompt, rich_print, ) from tux.database.service import DatabaseService @@ -54,14 +55,13 @@ def _delete_migration_files(): 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 at: {migration_dir}") + 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 - # We check for a generic ENVIRONMENT variable or explicit PRODUCTION flag in CONFIG is_prod = ( os.getenv("ENVIRONMENT", "").lower() == "production" or os.getenv("APP_ENV", "").lower() == "production" @@ -167,7 +167,7 @@ def nuke( ) raise Exit(1) - response = input("Type 'NUKE' to confirm (case sensitive): ") + response = prompt("Type 'NUKE' to confirm (case sensitive): ") if response != "NUKE": print_info("Nuclear reset cancelled") return diff --git a/scripts/db/queries.py b/scripts/db/queries.py index 086eacba6..eea996e85 100644 --- a/scripts/db/queries.py +++ b/scripts/db/queries.py @@ -12,8 +12,9 @@ from scripts.core import create_app from scripts.ui import ( - create_progress_bar, + create_status, print_error, + print_pretty, print_section, print_success, rich_print, @@ -45,8 +46,7 @@ def queries() -> None: async def _check_queries(): service = DatabaseService(echo=False) try: - with create_progress_bar("Analyzing queries...") as progress: - progress.add_task("Checking for long-running queries...", total=None) + with create_status("Analyzing queries...") as status: await service.connect(CONFIG.database_url) async def _get_long_queries( @@ -59,17 +59,13 @@ async def _get_long_queries( _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]", ) - for pid, duration, query_text, state in long_queries: - rich_print(f"[red]PID {pid}[/red]: {state} for {duration}") - query_preview = ( - (query_text[:100] + "...") if query_text else "" - ) - rich_print(f" Query: {query_preview}") + print_pretty(long_queries) else: rich_print("[green]No long-running queries found[/green]") diff --git a/scripts/db/schema.py b/scripts/db/schema.py index 1b1b883f9..741615215 100644 --- a/scripts/db/schema.py +++ b/scripts/db/schema.py @@ -10,7 +10,7 @@ from scripts.core import create_app from scripts.ui import ( - create_progress_bar, + create_status, print_error, print_section, print_success, @@ -35,12 +35,12 @@ def schema() -> None: async def _schema_check(): try: - with create_progress_bar("Validating schema...") as progress: - progress.add_task("Validating schema against models...", total=None) + with create_status("Validating schema against models...") as status: service = DatabaseService(echo=False) await service.connect(CONFIG.database_url) schema_result = await service.validate_schema() await service.disconnect() + status.update("[bold green]Validation complete![/bold green]") if schema_result["status"] == "valid": rich_print("[green]Database schema validation passed![/green]") diff --git a/scripts/db/tables.py b/scripts/db/tables.py index c2d159ef5..ade302a75 100644 --- a/scripts/db/tables.py +++ b/scripts/db/tables.py @@ -15,6 +15,7 @@ create_progress_bar, print_error, print_info, + print_pretty, print_section, print_success, rich_print, @@ -62,8 +63,7 @@ async def _get_tables(session: Any) -> list[tuple[str, int]]: return rich_print(f"[green]Found {len(tables_data)} tables:[/green]") - for table_name, column_count in tables_data: - rich_print(f"[cyan]{table_name}[/cyan]: {column_count} columns") + print_pretty(tables_data) print_success("Database tables listed") From 2b07f577608dae4778977bce37c2f7f67016b0cd Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 10:41:57 -0500 Subject: [PATCH 52/79] refactor(validate): replace progress bar with status indicator for configuration validation - Updated the configuration validation script to use a status indicator instead of a progress bar, enhancing user feedback during the validation process. - Improved the completion message to provide clearer confirmation of validation success. --- scripts/config/validate.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/config/validate.py b/scripts/config/validate.py index 361cd7b0f..dbddf2d41 100644 --- a/scripts/config/validate.py +++ b/scripts/config/validate.py @@ -11,7 +11,7 @@ from typer import Exit from scripts.core import create_app -from scripts.ui import console, create_progress_bar +from scripts.ui import console, create_status from tux.shared.config.settings import Config app = create_app() @@ -23,9 +23,9 @@ def validate() -> None: console.print(Panel.fit("Configuration Validator", style="bold blue")) try: - with create_progress_bar("Validating configuration...") as progress: - progress.add_task("Loading settings...", total=None) + 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", From 79d9552f5ea7d9f680d5eca265241c85baa392c1 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 10:42:06 -0500 Subject: [PATCH 53/79] refactor(dev): enhance error handling and user feedback in development scripts - Updated all.py to improve progress bar usage and summary reporting for development checks. - Enhanced lint.py and pre_commit.py to provide more specific error messages using CalledProcessError, improving clarity on failures. - Standardized exit behavior across scripts to ensure consistent user feedback during error scenarios. --- scripts/dev/all.py | 32 ++++++++++++++------------------ scripts/dev/lint.py | 8 ++++++-- scripts/dev/pre_commit.py | 8 ++++++-- 3 files changed, 26 insertions(+), 22 deletions(-) diff --git a/scripts/dev/all.py b/scripts/dev/all.py index 3fed77f90..253ffe98b 100644 --- a/scripts/dev/all.py +++ b/scripts/dev/all.py @@ -18,7 +18,6 @@ from scripts.dev.type_check import type_check from scripts.ui import ( create_progress_bar, - print_error, print_section, print_success, print_table, @@ -68,45 +67,42 @@ def run_all_checks( results: list[tuple[str, bool]] = [] - with create_progress_bar("Running Development Checks", len(checks)) as progress: + with create_progress_bar( + "Running Development Checks", + 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}...") - progress.refresh() - success = run_check(check) results.append((check.name, success)) - progress.advance(task) - progress.refresh() - rich_print("") + # Summary print_section("Development Checks Summary", "blue") - passed = sum(bool(success) for _, success in results) - total = len(results) - - table_data: list[tuple[str, str, str]] = [ + table_data: list[tuple[str, str]] = [ ( check_name, - "PASSED" if success else "FAILED", - "Completed" if success else "Failed", + "[green]✓ PASSED[/green]" if success else "[red]✗ FAILED[/red]", ) for check_name, success in results ] print_table( "", - [("Check", "cyan"), ("Status", "green"), ("Details", "white")], + [("Check", "cyan"), ("Status", "white")], table_data, ) - rich_print("") - if passed == total: - print_success(f"All {total} checks passed!") + 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: - print_error(f"{passed}/{total} checks passed") + rich_print(f"\n[bold red]{passed_count}/{total_count} checks passed[/bold red]") raise Exit(1) diff --git a/scripts/dev/lint.py b/scripts/dev/lint.py index 60d0f48ad..c16f2af0c 100644 --- a/scripts/dev/lint.py +++ b/scripts/dev/lint.py @@ -5,6 +5,7 @@ """ import sys +from subprocess import CalledProcessError from typing import Annotated from typer import Option @@ -32,8 +33,11 @@ def lint( try: run_command(cmd) print_success("Linting completed successfully") - except Exception: - print_error("Linting did not pass - see issues above") + 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) diff --git a/scripts/dev/pre_commit.py b/scripts/dev/pre_commit.py index 9f0738f32..5d39275f9 100644 --- a/scripts/dev/pre_commit.py +++ b/scripts/dev/pre_commit.py @@ -5,6 +5,7 @@ """ import sys +from subprocess import CalledProcessError from scripts.core import create_app from scripts.proc import run_command @@ -21,8 +22,11 @@ def pre_commit() -> None: try: run_command(["uv", "run", "pre-commit", "run", "--all-files"]) print_success("Pre-commit checks completed successfully") - except Exception: - print_error("Pre-commit checks did not pass - see issues above") + 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) From 879a9e1280cae197cf46ee013756b7713524e11a Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 10:42:18 -0500 Subject: [PATCH 54/79] refactor(docs): improve linting and Wrangler development scripts for better error handling and user feedback - Enhanced lint.py to utilize print_pretty for displaying issues, improving clarity in documentation feedback. - Updated wrangler_dev.py to streamline error handling during the build process and ensure consistent exit behavior across exceptions. --- scripts/docs/lint.py | 8 ++++---- scripts/docs/wrangler_dev.py | 19 +++++++------------ 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/scripts/docs/lint.py b/scripts/docs/lint.py index 367b02e0b..5efe88bf5 100644 --- a/scripts/docs/lint.py +++ b/scripts/docs/lint.py @@ -12,6 +12,7 @@ from scripts.ui import ( create_progress_bar, print_error, + print_pretty, print_section, print_success, print_warning, @@ -63,10 +64,9 @@ def lint() -> None: if issues: print_warning(f"Found {len(issues)} issues in documentation:") - for issue in issues: - print_error(f" • {issue}") - else: - print_success("No issues found in documentation!") + print_pretty(issues) + raise Exit(1) + print_success("No issues found in documentation!") if __name__ == "__main__": diff --git a/scripts/docs/wrangler_dev.py b/scripts/docs/wrangler_dev.py index 6b06019d1..793517e87 100644 --- a/scripts/docs/wrangler_dev.py +++ b/scripts/docs/wrangler_dev.py @@ -32,11 +32,8 @@ def wrangler_dev( raise Exit(1) print_info("Building documentation...") - try: - build(strict=True) - except Exception as e: - print_error(f"Build failed, aborting Wrangler dev: {e}") - raise Exit(1) from e + # build(strict=True) already handles errors and raises Exit(1) + build(strict=True) cmd = ["wrangler", "dev", f"--port={port}"] if remote: @@ -47,14 +44,12 @@ def wrangler_dev( try: run_command(cmd, capture_output=False) - except Exception as e: - # KeyboardInterrupt is handled by the shell/parent - # but we catch other exceptions here - if not isinstance(e, KeyboardInterrupt): - print_error(f"Wrangler dev server failed: {e}") - raise Exit(1) from e - finally: + 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__": From 5a3d2ce3348dd6d20973c396ff8fb7aada40083a Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 10:42:32 -0500 Subject: [PATCH 55/79] refactor(proc): improve command output handling and error reporting - Updated run_command to use markup=False when printing command output, preventing Rich formatting interpretation. - Enhanced error handling to print stdout and stderr separately when a command fails, improving clarity in error reporting. --- scripts/proc.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/scripts/proc.py b/scripts/proc.py index 6e45abaaf..d8d82c6e9 100644 --- a/scripts/proc.py +++ b/scripts/proc.py @@ -56,12 +56,18 @@ def run_command( ) if capture_output and result.stdout: - console.print(result.stdout.strip()) + # 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)}") - if e.stderr: - console.print(f"[red]{e.stderr.strip()}[/red]") raise else: return result From e6e588c3d67f7be055b3145a735156f54747eaf4 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 10:42:40 -0500 Subject: [PATCH 56/79] refactor(ui): enhance terminal output formatting and add new utility functions - Updated existing print functions to include symbols for better visual feedback. - Introduced new utility functions: print_pretty for pretty printing containers, print_json for JSON data, prompt for user input, and create_status for status indicators with spinners. - Improved the print_section function for better section title formatting. - Adjusted progress bar settings for transient behavior. --- scripts/ui.py | 101 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 93 insertions(+), 8 deletions(-) diff --git a/scripts/ui.py b/scripts/ui.py index e9bbab997..a778c66d8 100644 --- a/scripts/ui.py +++ b/scripts/ui.py @@ -4,37 +4,49 @@ Provides functional helpers for consistent Rich-formatted terminal output. """ +from typing import Any + from rich.console import Console -from rich.progress import BarColumn, Progress, ProgressColumn, SpinnerColumn, TextColumn +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]{message}[/green]") + console.print(f"[green]✓[/green] {message}") def print_error(message: str) -> None: """Print an error message in red.""" - console.print(f"[red]{message}[/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]{message}[/blue]") + console.print(f"[blue]i[/blue] {message}") def print_warning(message: str) -> None: """Print a warning message in yellow.""" - console.print(f"[yellow]{message}[/yellow]") + console.print(f"[yellow]⚠[/yellow] {message}") def print_section(title: str, color: str = "blue") -> None: - """Print a bold section header with optional color.""" - console.print(f"\n[bold {color}]{title}[/bold {color}]") + """Print a section title.""" + console.print() + console.print(f"[bold {color}]{title}[/bold {color}]") def rich_print(message: str) -> None: @@ -42,6 +54,79 @@ def rich_print(message: str) -> None: console.print(message) +def print_pretty( + obj: Any, + expand_all: bool = False, + max_length: int | None = None, +) -> 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). + """ + pprint(obj, expand_all=expand_all, max_length=max_length, console=console) + + +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]], @@ -105,6 +190,6 @@ def create_progress_bar( return Progress( *columns, - transient=False, + transient=True, console=console, ) From 558700b31b0ba2742a28ebf184ef6be6ffa3d547 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 10:42:49 -0500 Subject: [PATCH 57/79] feat(model): add Rich representation for BaseModel - Implemented a __rich_repr__ method in BaseModel to provide a Rich-formatted representation of the model's attributes. - This enhancement allows for better visualization of model instances while avoiding full relationship traversal by default. --- src/tux/database/models/base.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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): """ From 78bf98e1ac4e3c7a5e9389d85ae9d93468945100 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 10:42:58 -0500 Subject: [PATCH 58/79] refactor(main): streamline error handling in the run function - Refactored the error handling in the run function to use specific exception blocks for improved clarity and maintainability. - Each exception now returns a consistent exit code, enhancing the application's response to different error scenarios. --- src/tux/main.py | 55 +++++++++++++++++++++---------------------------- 1 file changed, 24 insertions(+), 31 deletions(-) diff --git a/src/tux/main.py b/src/tux/main.py index b95af95af..82c65f8a7 100644 --- a/src/tux/main.py +++ b/src/tux/main.py @@ -17,7 +17,7 @@ ) -def run(debug: bool = False) -> int: +def run(debug: bool = False) -> int: # noqa: PLR0911 """ Instantiate and run the Tux application. @@ -47,34 +47,27 @@ def run(debug: bool = False) -> int: app = TuxApp() return app.run() - except ( - TuxDatabaseError, - TuxSetupError, - TuxGracefulShutdown, - TuxError, - RuntimeError, - SystemExit, - KeyboardInterrupt, - Exception, - ) as e: - if isinstance(e, TuxDatabaseError): - logger.error("Database connection failed") - logger.info("To start the database, run: docker compose up") - elif isinstance(e, TuxSetupError): - logger.error(f"Bot setup failed: {e}") - elif isinstance(e, TuxGracefulShutdown): - logger.info(f"Bot shut down gracefully: {e}") - return 0 - elif isinstance(e, TuxError): - logger.error(f"Bot error: {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 - elif isinstance(e, RuntimeError): - logger.critical(f"Runtime error: {e}") - 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") + return 1 + 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 + 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: + return int(e.code) if e.code is not None else 1 + except RuntimeError as e: + logger.critical(f"Runtime error: {e}") + return 1 + except Exception as e: + logger.opt(exception=True).critical(f"Application failed to start: {e}") return 1 From ed4656a4db6ff594850e1a712a28ed33dc6d9978 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 10:43:06 -0500 Subject: [PATCH 59/79] fix(tests): update database CLI command tests for error handling - Modified the init command test to assert failure when the database already exists, ensuring proper error handling. - Added setup method in recovery scenarios to ensure a clean and up-to-date database state before tests. --- tests/integration/test_database_cli_commands.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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.""" From bc53e584dbce4db105281b505b11190c043b04b5 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 10:43:13 -0500 Subject: [PATCH 60/79] feat(pyproject): add type reporting options for unknown variables and parameters - Introduced new reporting options in the pyproject.toml for handling unknown variable, member, argument, and parameter types, enhancing type checking capabilities. --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 78e71b63b..d7981e9c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] From 58c63eeb29d091a1ffd09933145f4ec01864c988 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 11:01:51 -0500 Subject: [PATCH 61/79] refactor(db): improve user confirmation and cleanup in database scripts - Replaced manual input prompt with a confirmation dialog in the downgrade script to enhance user experience and prevent accidental downgrades. - Streamlined the filtering of migration files in the init script for better readability. - Allowed environment variable override for production database keywords in the nuke script, improving flexibility. - Ensured proper disconnection of the database service in the schema script after validation. - Adjusted progress bar settings in the tables script for clearer output formatting. --- scripts/db/downgrade.py | 11 ++++------- scripts/db/init.py | 4 +--- scripts/db/nuke.py | 6 ++++-- scripts/db/schema.py | 5 +++-- scripts/db/tables.py | 8 ++++---- 5 files changed, 16 insertions(+), 18 deletions(-) diff --git a/scripts/db/downgrade.py b/scripts/db/downgrade.py index 3cef19f18..84ca8dffc 100644 --- a/scripts/db/downgrade.py +++ b/scripts/db/downgrade.py @@ -8,7 +8,7 @@ from subprocess import CalledProcessError from typing import Annotated -from typer import Argument, Option +from typer import Argument, Option, confirm from scripts.core import create_app from scripts.proc import run_command @@ -17,7 +17,6 @@ print_info, print_section, print_success, - prompt, rich_print, ) @@ -44,11 +43,9 @@ def downgrade( "[yellow]This may cause data loss. Backup your database first.[/yellow]\n", ) - if not force: - response = prompt(f"Type 'yes' to downgrade to {revision}: ") - if response.lower() != "yes": - print_info("Downgrade cancelled") - return + 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]) diff --git a/scripts/db/init.py b/scripts/db/init.py index 719133e57..4a6e8265b 100644 --- a/scripts/db/init.py +++ b/scripts/db/init.py @@ -73,9 +73,7 @@ def init() -> None: [ f for f in migration_files - if f.name != "__init__.py" - and not f.name.startswith("_") - and f.suffix == ".py" + if f.name != "__init__.py" and not f.name.startswith("_") ], ) diff --git a/scripts/db/nuke.py b/scripts/db/nuke.py index d66e13b05..1a5ba2f5f 100644 --- a/scripts/db/nuke.py +++ b/scripts/db/nuke.py @@ -69,9 +69,11 @@ async def _nuclear_reset(fresh: bool): ) db_url = CONFIG.database_url - is_prod_db = any( - kw in db_url.lower() for kw in ["prod", "live", "allthingslinux.org"] + # 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": diff --git a/scripts/db/schema.py b/scripts/db/schema.py index 741615215..b87c711f6 100644 --- a/scripts/db/schema.py +++ b/scripts/db/schema.py @@ -34,12 +34,11 @@ def schema() -> None: 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: - service = DatabaseService(echo=False) await service.connect(CONFIG.database_url) schema_result = await service.validate_schema() - await service.disconnect() status.update("[bold green]Validation complete![/bold green]") if schema_result["status"] == "valid": @@ -72,6 +71,8 @@ async def _schema_check(): print_error(f"Failed to validate database schema: {e}") raise Exit(1) from e raise + finally: + await service.disconnect() asyncio.run(_schema_check()) diff --git a/scripts/db/tables.py b/scripts/db/tables.py index ade302a75..cb8312ca2 100644 --- a/scripts/db/tables.py +++ b/scripts/db/tables.py @@ -15,7 +15,6 @@ create_progress_bar, print_error, print_info, - print_pretty, print_section, print_success, rich_print, @@ -54,7 +53,7 @@ async def _get_tables(session: Any) -> list[tuple[str, int]]: ) return result.fetchall() - with create_progress_bar("Fetching tables...") as progress: + 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") @@ -62,8 +61,9 @@ async def _get_tables(session: Any) -> list[tuple[str, int]]: print_info("No tables found in database") return - rich_print(f"[green]Found {len(tables_data)} tables:[/green]") - print_pretty(tables_data) + rich_print(f"[green]Found {len(tables_data)} tables:[/green]\n") + for table_name, column_count in tables_data: + rich_print(f" [cyan]{table_name:40}[/cyan] {column_count:3} columns") print_success("Database tables listed") From 68da0d54d020d2006f68705ea5757b06c9ffdad3 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 11:01:58 -0500 Subject: [PATCH 62/79] refactor(dev): improve error reporting and progress bar usage in development scripts - Enhanced error handling in all.py to print specific error messages for unexpected exceptions during checks. - Updated progress bar messages in all.py and clean.py for better clarity during execution. --- scripts/dev/all.py | 5 +++-- scripts/dev/clean.py | 1 - 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/dev/all.py b/scripts/dev/all.py index 253ffe98b..b16d99fe6 100644 --- a/scripts/dev/all.py +++ b/scripts/dev/all.py @@ -18,6 +18,7 @@ from scripts.dev.type_check import type_check from scripts.ui import ( create_progress_bar, + print_error, print_section, print_success, print_table, @@ -41,7 +42,8 @@ def run_check(check: Check) -> bool: check.func() except SystemExit as e: return e.code == 0 - except Exception: + except Exception as e: + print_error(f"Unexpected error in {check.name}: {e}") return False else: return True @@ -68,7 +70,6 @@ def run_all_checks( results: list[tuple[str, bool]] = [] with create_progress_bar( - "Running Development Checks", total=len(checks), ) as progress: task = progress.add_task("Running Development Checks", total=len(checks)) diff --git a/scripts/dev/clean.py b/scripts/dev/clean.py index 328fb9540..f8f2b3f84 100644 --- a/scripts/dev/clean.py +++ b/scripts/dev/clean.py @@ -99,7 +99,6 @@ def clean() -> None: protected_dirs = {".venv", "venv"} with create_progress_bar( - "Cleaning Project...", total=len(patterns_to_clean), ) as progress: task = progress.add_task("Cleaning Project...", total=len(patterns_to_clean)) From 2c072db600888fe37387f232a74a98b842b2f713 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 11:02:08 -0500 Subject: [PATCH 63/79] refactor(docs): improve linting logic and progress bar messaging - Removed redundant progress message in lint.py for cleaner output. - Updated TODO and FIXME checks to be case-insensitive, enhancing linting accuracy. --- scripts/docs/lint.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/docs/lint.py b/scripts/docs/lint.py index 5efe88bf5..a9ffa9f29 100644 --- a/scripts/docs/lint.py +++ b/scripts/docs/lint.py @@ -35,7 +35,6 @@ def lint() -> None: issues: list[str] = [] with create_progress_bar( - "Scanning Documentation...", total=len(all_md_files), ) as progress: task = progress.add_task("Scanning Documentation...", total=len(all_md_files)) @@ -55,7 +54,7 @@ def lint() -> None: 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: + 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}") From a9fe06415dbc7e9a80c983855c5c97eaaf010bcf Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 11:02:17 -0500 Subject: [PATCH 64/79] refactor(ui): enhance print_pretty function with indent guides option - Added an optional parameter `indent_guides` to the `print_pretty` function for improved visual representation of nested structures. - Updated the function call to include the new parameter, ensuring consistent behavior with the default value set to True. - Removed the redundant `description` parameter from the `create_progress_bar` function for cleaner code. --- scripts/ui.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/scripts/ui.py b/scripts/ui.py index a778c66d8..4d8a2d62d 100644 --- a/scripts/ui.py +++ b/scripts/ui.py @@ -58,6 +58,7 @@ 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. @@ -70,8 +71,16 @@ def print_pretty( 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) + pprint( + obj, + expand_all=expand_all, + max_length=max_length, + console=console, + indent_guides=indent_guides, + ) def print_json(data: str | Any) -> None: @@ -155,7 +164,6 @@ def print_table( def create_progress_bar( - description: str = "Processing...", total: int | None = None, ) -> Progress: """ @@ -163,8 +171,6 @@ def create_progress_bar( Parameters ---------- - description : str, optional - Text to show next to the progress bar. total : int | None, optional Total number of steps. If provided, shows percentage and bar. From c30c03e240f482518ffcd2661da4e706fccf0434 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 11:02:27 -0500 Subject: [PATCH 65/79] refactor(main): enhance error handling in run function - Improved the error handling logic in the run function to ensure consistent exit codes for SystemExit exceptions. - Added checks for None and non-integer exit codes, returning 1 as a default for unexpected cases, enhancing robustness. --- src/tux/main.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/tux/main.py b/src/tux/main.py index 82c65f8a7..306887224 100644 --- a/src/tux/main.py +++ b/src/tux/main.py @@ -64,7 +64,11 @@ def run(debug: bool = False) -> int: # noqa: PLR0911 logger.info("Application interrupted by user") return 130 except SystemExit as e: - return int(e.code) if e.code is not None else 1 + if e.code is None: + return 1 + if isinstance(e.code, int): + return e.code + return 1 except RuntimeError as e: logger.critical(f"Runtime error: {e}") return 1 From a5253f6d5c70a9867ced05084679210cfef82231 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 13:29:00 -0500 Subject: [PATCH 66/79] refactor(main): add warning for non-int SystemExit codes - Introduced a warning log for cases where a SystemExit exception is raised with a non-integer exit code, improving error visibility and debugging capabilities in the run function. --- src/tux/main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/tux/main.py b/src/tux/main.py index 306887224..875c70308 100644 --- a/src/tux/main.py +++ b/src/tux/main.py @@ -68,6 +68,7 @@ def run(debug: bool = False) -> int: # noqa: PLR0911 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 as e: logger.critical(f"Runtime error: {e}") From d8ae609cc6b4aa0aca0b2fc6678e6113f21c7750 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 13:29:07 -0500 Subject: [PATCH 67/79] refactor(db): simplify error handling in schema validation - Removed the redundant _fail function and directly raised Exit(1) for schema validation errors, streamlining the error handling process. - This change enhances code clarity and maintains compliance with Ruff's TRY301 rule. --- scripts/db/schema.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/scripts/db/schema.py b/scripts/db/schema.py index b87c711f6..4b7f7d5c4 100644 --- a/scripts/db/schema.py +++ b/scripts/db/schema.py @@ -22,11 +22,6 @@ app = create_app() -def _fail(): - """Raise Exit(1) to satisfy Ruff's TRY301 rule.""" - raise Exit(1) - - @app.command(name="schema") def schema() -> None: """Validate that database schema matches model definitions.""" @@ -62,7 +57,7 @@ async def _schema_check(): " • Check that your models match the latest migration files", ) - _fail() + raise Exit(1) # noqa: TRY301 print_success("Schema validation completed") From 79df8bd951b2f5d22d78cce4086340c52dc9feb6 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 13:29:13 -0500 Subject: [PATCH 68/79] refactor(dev): improve SystemExit handling in run_check function - Updated the run_check function to return False for non-integer SystemExit codes, enhancing error handling consistency. - This change ensures that unexpected exit codes are managed appropriately, improving robustness in the development scripts. --- scripts/dev/all.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/dev/all.py b/scripts/dev/all.py index b16d99fe6..ad5d24a06 100644 --- a/scripts/dev/all.py +++ b/scripts/dev/all.py @@ -41,7 +41,7 @@ def run_check(check: Check) -> bool: try: check.func() except SystemExit as e: - return e.code == 0 + 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 From 064b788208fb5268da83cd33c961ac3a95742e2d Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 13:29:20 -0500 Subject: [PATCH 69/79] fix(docs): update error message for strict mode in serve.py - Changed the error message for the unsupported strict mode in serve.py to clarify that it is not yet supported by zensical, improving user understanding of the feature's status. --- scripts/docs/serve.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/docs/serve.py b/scripts/docs/serve.py index ce837c5fc..06e5b3de7 100644 --- a/scripts/docs/serve.py +++ b/scripts/docs/serve.py @@ -45,7 +45,7 @@ def serve( if open_browser: cmd.append("--open") if strict: - print_error("--strict mode is currently unsupported by zensical") + print_error("--strict mode is not yet supported by zensical") raise Exit(1) try: From e3caabe1f02b93c32c24da584efa17fc48e4f060 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 13:29:27 -0500 Subject: [PATCH 70/79] refactor(docs): improve content validation in lint.py - Enhanced the content validation logic in the lint function to ensure that empty content is correctly identified and handled. - Added a condition to strip whitespace from content before checking for emptiness, improving linting accuracy. --- scripts/docs/lint.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/docs/lint.py b/scripts/docs/lint.py index a9ffa9f29..b22740a2f 100644 --- a/scripts/docs/lint.py +++ b/scripts/docs/lint.py @@ -49,8 +49,10 @@ def lint() -> None: parts = content.split("---", 2) if len(parts) >= 3: content = parts[2].strip() + else: + content = content.strip() - if content.strip() == "": + if content == "": issues.append(f"Empty file: {md_file}") elif not content.startswith("#"): issues.append(f"Missing title: {md_file}") From b854c580ef3a8ccb37021eb692d174b53b1d9fcb Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 15:09:00 -0500 Subject: [PATCH 71/79] refactor(db): enhance table listing and error handling in version script - Improved the table listing output by dynamically adjusting the column width based on the longest table name, ensuring better readability. - Enhanced error handling in the version function to provide more specific error messages for exceptions, improving user feedback and robustness. --- scripts/db/tables.py | 9 ++++++++- scripts/db/version.py | 11 +++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/scripts/db/tables.py b/scripts/db/tables.py index cb8312ca2..d3f95e1a2 100644 --- a/scripts/db/tables.py +++ b/scripts/db/tables.py @@ -62,8 +62,15 @@ async def _get_tables(session: Any) -> list[tuple[str, int]]: 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:40}[/cyan] {column_count:3} columns") + rich_print( + f" [cyan]{table_name:{width}}[/cyan] {column_count:3} columns", + ) print_success("Database tables listed") diff --git a/scripts/db/version.py b/scripts/db/version.py index e9b1b2f73..dfcde38c8 100644 --- a/scripts/db/version.py +++ b/scripts/db/version.py @@ -4,6 +4,9 @@ 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 @@ -33,8 +36,12 @@ def version() -> None: ) print_success("Version information displayed") - except Exception: - print_error("Failed to get version information") + except CalledProcessError as e: + print_error(f"Failed to get version information: {e}") + sys.exit(1) + except Exception as e: + print_error(f"An unexpected error occurred: {e}") + sys.exit(1) if __name__ == "__main__": From 0cfa9e3abfe62ed27ab8deae01dc5c8aee702804 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 15:09:22 -0500 Subject: [PATCH 72/79] fix(docs): handle KeyboardInterrupt gracefully in serve.py - Moved the KeyboardInterrupt exception handling to the correct position to ensure the documentation server stops gracefully, improving user experience during server operation. --- scripts/docs/serve.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/docs/serve.py b/scripts/docs/serve.py index 06e5b3de7..e61dd998e 100644 --- a/scripts/docs/serve.py +++ b/scripts/docs/serve.py @@ -52,11 +52,11 @@ def serve( 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 - except KeyboardInterrupt: - print_info("\nDocumentation server stopped") if __name__ == "__main__": From b360de3f1b2657d71b79628fab2166db22be741b Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 15:09:42 -0500 Subject: [PATCH 73/79] refactor(coverage): modularize coverage command and browser handling - Extracted the coverage command building logic into a separate function `_build_coverage_cmd` for better readability and maintainability. - Created a new function `_handle_browser_opening` to manage the opening of the coverage report in the browser, improving code organization and clarity. - Enhanced user feedback for browser opening scenarios based on the report format. --- scripts/test/coverage.py | 75 +++++++++++++++++++++++++--------------- 1 file changed, 47 insertions(+), 28 deletions(-) diff --git a/scripts/test/coverage.py b/scripts/test/coverage.py index b09596548..bd1db6a5d 100644 --- a/scripts/test/coverage.py +++ b/scripts/test/coverage.py @@ -20,6 +20,49 @@ 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[ @@ -49,16 +92,7 @@ def coverage_report( """Generate coverage reports.""" print_section("Coverage Report", "blue") - 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)]) + cmd = _build_coverage_cmd(specific, format_type, quick, fail_under) print_info(f"Running: {shlex.join(cmd)}") @@ -66,25 +100,10 @@ def coverage_report( run_command(cmd, capture_output=False) if open_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.", - ) + _handle_browser_opening(format_type) + except KeyboardInterrupt: + print_info("\nCoverage generation interrupted by user") except CalledProcessError as e: print_error(f"Coverage report generation failed: {e}") sys.exit(1) From 47ed55909fde3d98cbf83e4169f946a45a81ac47 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 15:09:51 -0500 Subject: [PATCH 74/79] fix(html): handle KeyboardInterrupt in HTML report generation - Added exception handling for KeyboardInterrupt to provide user feedback when tests are interrupted. - Updated command printing to use shlex.join for better command formatting. --- scripts/test/html.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/test/html.py b/scripts/test/html.py index d3092051a..50de8ad3f 100644 --- a/scripts/test/html.py +++ b/scripts/test/html.py @@ -4,6 +4,7 @@ Runs tests and generates an HTML report. """ +import shlex import sys import webbrowser from pathlib import Path @@ -38,7 +39,7 @@ def html_report( "--self-contained-html", ] - print_info(f"Running: {' '.join(cmd)}") + print_info(f"Running: {shlex.join(cmd)}") try: run_command(cmd, capture_output=False) @@ -54,6 +55,8 @@ def html_report( else: print_warning(f"HTML report not found at {report_path}") + except KeyboardInterrupt: + print_info("\nTests interrupted by user") except CalledProcessError as e: print_error(f"Tests failed: {e}") sys.exit(1) From 65a803b4f4f1466d3712ab27b5cfdd082687abb3 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 15:10:14 -0500 Subject: [PATCH 75/79] fix(main): update database startup command in error logging - Changed the database startup command in the error log from "docker compose up" to "docker compose up -d" for clarity and to ensure the database runs in detached mode. --- src/tux/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tux/main.py b/src/tux/main.py index 875c70308..3698c5d7f 100644 --- a/src/tux/main.py +++ b/src/tux/main.py @@ -49,7 +49,7 @@ def run(debug: bool = False) -> int: # noqa: PLR0911 except TuxDatabaseError: logger.error("Database connection failed") - logger.info("To start the database, run: docker compose up") + logger.info("To start the database, run: docker compose up -d") return 1 except TuxSetupError as e: logger.error(f"Bot setup failed: {e}") From 21339eb48c0d30b7b2fb69e01b30bace8eb10fa9 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 15:25:28 -0500 Subject: [PATCH 76/79] fix(main): streamline RuntimeError handling in run function - Simplified the RuntimeError exception handling by removing redundant logging, as it is already managed in TuxApp.run(). This change enhances code clarity and maintains consistent error management. --- src/tux/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tux/main.py b/src/tux/main.py index 3698c5d7f..0587a0b7d 100644 --- a/src/tux/main.py +++ b/src/tux/main.py @@ -70,8 +70,8 @@ def run(debug: bool = False) -> int: # noqa: PLR0911 return e.code logger.warning(f"SystemExit with non-int code: {e.code!r}, returning 1") return 1 - except RuntimeError as e: - logger.critical(f"Runtime error: {e}") + 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}") From e88a0bba4da5399de2d77b581df05dee08193843 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 15:25:34 -0500 Subject: [PATCH 77/79] fix(coverage): clarify behavior of quick mode in coverage report - Added a note indicating that when --quick is used alongside --format, the format option will be ignored. This improves user understanding of command behavior and ensures clarity in reporting. --- scripts/test/coverage.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/test/coverage.py b/scripts/test/coverage.py index bd1db6a5d..43c796df9 100644 --- a/scripts/test/coverage.py +++ b/scripts/test/coverage.py @@ -94,6 +94,9 @@ def coverage_report( 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: From b1aac69ec9e5bc66072330f734d07f0dd0c37f80 Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 15:25:43 -0500 Subject: [PATCH 78/79] fix(db): simplify error message in version function - Updated the error handling in the version function to remove the exception details from the error message, providing a cleaner output while maintaining user feedback on failure. --- scripts/db/version.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/db/version.py b/scripts/db/version.py index dfcde38c8..d64a977b2 100644 --- a/scripts/db/version.py +++ b/scripts/db/version.py @@ -36,8 +36,8 @@ def version() -> None: ) print_success("Version information displayed") - except CalledProcessError as e: - print_error(f"Failed to get version information: {e}") + except CalledProcessError: + print_error("Failed to get version information") sys.exit(1) except Exception as e: print_error(f"An unexpected error occurred: {e}") From f11c48f672ee90a9bea234113ba17707849dbe6e Mon Sep 17 00:00:00 2001 From: Logan Honeycutt Date: Sun, 21 Dec 2025 15:53:43 -0500 Subject: [PATCH 79/79] fix(db, coverage, html): improve error handling for KeyboardInterrupt - Added handling for KeyboardInterrupt in coverage and HTML report generation to ensure graceful exits with a specific exit code. - Simplified the error message in the version function to provide a cleaner output while maintaining user feedback on unexpected errors. --- scripts/db/version.py | 4 ++-- scripts/test/coverage.py | 1 + scripts/test/html.py | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/db/version.py b/scripts/db/version.py index d64a977b2..6bd47a91c 100644 --- a/scripts/db/version.py +++ b/scripts/db/version.py @@ -39,8 +39,8 @@ def version() -> None: except CalledProcessError: print_error("Failed to get version information") sys.exit(1) - except Exception as e: - print_error(f"An unexpected error occurred: {e}") + except Exception: + print_error("An unexpected error occurred") sys.exit(1) diff --git a/scripts/test/coverage.py b/scripts/test/coverage.py index 43c796df9..7e4a4275b 100644 --- a/scripts/test/coverage.py +++ b/scripts/test/coverage.py @@ -107,6 +107,7 @@ def coverage_report( 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) diff --git a/scripts/test/html.py b/scripts/test/html.py index 50de8ad3f..8a11c39d2 100644 --- a/scripts/test/html.py +++ b/scripts/test/html.py @@ -57,6 +57,7 @@ def html_report( except KeyboardInterrupt: print_info("\nTests interrupted by user") + sys.exit(130) except CalledProcessError as e: print_error(f"Tests failed: {e}") sys.exit(1)