From fc6f83b3e147e8641b44de2c7f7f20a26ddf2031 Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Mon, 2 Mar 2026 23:29:54 -0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(seedgo):=20add=20Rich=20format?= =?UTF-8?q?ting=20to=20CLI=20output?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace raw ANSI escape codes with Rich library for all human-facing seedgo CLI output. Adds display.py module with styled components: header panels, plugin result lines, check items with severity markers, summary sections, and plugin tables. - seedgo check: branded header, styled verdicts, hint formatting - seedgo list: Rich table with bordered columns - seedgo init: styled success message - drone_adapter: use direct import instead of parsing CLI output - Add rich>=13.0 as dependency Co-Authored-By: Claude Opus 4.6 --- pyproject.toml | 4 +- src/seedgo/cli.py | 56 ++++-------- src/seedgo/display.py | 165 +++++++++++++++++++++++++++++++++++ src/seedgo/drone_adapter.py | 60 +++++-------- src/seedgo/reporter.py | 167 ++++++++++++------------------------ tests/test_seedgo_cli.py | 43 ++++++---- 6 files changed, 288 insertions(+), 207 deletions(-) create mode 100644 src/seedgo/display.py diff --git a/pyproject.toml b/pyproject.toml index 8dac4f58..1b19982d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,9 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Scientific/Engineering :: Artificial Intelligence", ] -dependencies = [] +dependencies = [ + "rich>=13.0", +] [project.urls] Homepage = "https://github.com/AIOSAI/AIPass" diff --git a/src/seedgo/cli.py b/src/seedgo/cli.py index f69c6394..96691e7b 100644 --- a/src/seedgo/cli.py +++ b/src/seedgo/cli.py @@ -32,6 +32,13 @@ from pathlib import Path from .config import ConfigError, create_default_config, find_project_root from .discovery import discover_plugins +from .display import ( + print_error, + print_header, + print_init_success, + print_plugin_table, + print_warning, +) from .reporter import report_results from .runner import run_checks @@ -159,17 +166,10 @@ def _cmd_init(args: argparse.Namespace) -> None: try: config_path = create_default_config(project_root, profile=profile) - print("Seed Go initialized.") - print(f" Config: {config_path}") - print(f" Plugins: {str(Path(config_path).parent / 'plugins')}") - if profile: - print(f" Profile: {profile}") - print("") - print("Next steps:") - print(" 1. Add plugins to .seedgo/plugins/") - print(" 2. Run: seedgo check") + plugins_path = str(Path(config_path).parent / "plugins") + print_init_success(config_path, plugins_path, profile=profile) except ConfigError as exc: - print(f"Error: {exc}", file=sys.stderr) + print_error(str(exc), suggestion="Check directory permissions and try again.") sys.exit(1) @@ -220,13 +220,14 @@ def _cmd_check(args: argparse.Namespace) -> None: overall["passed"] = overall["overall_score"] >= args.threshold and error_count == 0 output = report_results(results, overall, format=args.format) - print(output) + if output: + print(output) except ConfigError as exc: - print(f"Config error: {exc}", file=sys.stderr) + print_error(f"Config error: {exc}") sys.exit(1) except Exception as exc: - print(f"Unexpected error: {exc}", file=sys.stderr) + print_error(f"Unexpected error: {exc}") sys.exit(1) sys.exit(0 if overall.get("passed", True) else 1) @@ -235,7 +236,7 @@ def _cmd_check(args: argparse.Namespace) -> None: def _cmd_list(_args: argparse.Namespace) -> None: """Handle `seedgo list`. - Discovers all plugins and prints a formatted table showing: + Discovers all plugins and prints a Rich table showing: name, description, source, file types. Exits 0 always (listing is informational). @@ -247,30 +248,11 @@ def _cmd_list(_args: argparse.Namespace) -> None: plugins = discover_plugins(project_root) if not plugins: - print("No plugins found.") - print("") - print("Add plugins to .seedgo/plugins/ or install plugin packages.") + print_warning("No plugins found.") + print_header("SEEDGO — Plugins", "Add plugins to .seedgo/plugins/ or install plugin packages.") sys.exit(0) - print(f"Found {len(plugins)} plugin(s):\n") - - col_name = max(len(p["name"]) for p in plugins) + 2 - col_source = max(len(p["source"]) for p in plugins) + 2 - - header = ( - f" {'NAME':<{col_name}}{'SOURCE':<{col_source}}{'FILE TYPES':<20} DESCRIPTION" - ) - print(header) - print(" " + "-" * (len(header) - 2)) - - for plugin in plugins: - module = plugin["module"] - name = plugin["name"] - source = plugin["source"] - description = getattr(module, "PLUGIN_DESCRIPTION", "") - file_types = getattr(module, "FILE_TYPES", ["*"]) - types_str = ", ".join(file_types) - - print(f" {name:<{col_name}}{source:<{col_source}}{types_str:<20} {description}") + print_header("SEEDGO — Plugins", f"{len(plugins)} plugin(s) discovered") + print_plugin_table(plugins) sys.exit(0) diff --git a/src/seedgo/display.py b/src/seedgo/display.py new file mode 100644 index 00000000..f6681b26 --- /dev/null +++ b/src/seedgo/display.py @@ -0,0 +1,165 @@ +"""Rich-based display utilities for seedgo CLI output. + +Provides consistent, styled terminal output using the Rich library. +All seedgo CLI commands use these functions for human-facing output. + +Functions: + console — Shared Rich Console instance. + print_header — Branded section header with optional subtitle. + print_plugin — Plugin check result line (name + verdict + score). + print_check_item — Individual check item with severity marker. + print_summary — Overall score and verdict. + print_counts — Check count summary (passed/failed/warnings). + print_plugin_table — Table of discovered plugins (for `seedgo list`). + print_init — Init success message. +""" + +from __future__ import annotations + +from rich.console import Console +from rich.panel import Panel +from rich.table import Table +from rich import box + +from .models import Severity + +console = Console() + + +def print_header(title: str, subtitle: str | None = None) -> None: + """Print a branded seedgo header panel.""" + content = f"[bold cyan]{title}[/bold cyan]" + if subtitle: + content += f"\n[dim]{subtitle}[/dim]" + console.print(Panel(content, border_style="cyan", box=box.ROUNDED, expand=False, padding=(0, 2))) + console.print() + + +def print_plugin(name: str, file_path: str, passed: bool, score: int) -> None: + """Print a plugin result header line.""" + if passed: + verdict = "[green]PASS[/green]" + icon = "[green]✓[/green]" + else: + verdict = "[red]FAIL[/red]" + icon = "[red]✗[/red]" + + # Truncate long paths for readability + path_display = file_path + if len(path_display) > 40: + path_display = "..." + path_display[-37:] + + dots = "[dim]" + "·" * max(1, 48 - len(name) - len(path_display)) + "[/dim]" + console.print(f" {icon} [bold]{name}[/bold] [dim]{path_display}[/dim] {dots} {verdict} [dim]({score}/100)[/dim]") + + +def print_check_item(name: str, passed: bool, message: str, severity: Severity, + line: int | None = None, fix_hint: str | None = None) -> None: + """Print a single check item with appropriate severity marker.""" + if passed: + marker = "[green]✓[/green]" + name_style = "dim" + elif severity == Severity.ERROR: + marker = "[red]✗[/red]" + name_style = "red" + elif severity == Severity.WARNING: + marker = "[yellow]⚠[/yellow]" + name_style = "yellow" + else: + marker = "[cyan]ℹ[/cyan]" + name_style = "cyan" + + line_ref = f" [dim]\\[line {line}][/dim]" if line is not None else "" + console.print(f" {marker} [{name_style}]{name}[/{name_style}]: {message}{line_ref}") + + if fix_hint and not passed: + console.print(f" [dim]hint: {fix_hint}[/dim]") + + +def print_summary(score: int, passed: bool, threshold: int) -> None: + """Print the overall score and verdict.""" + if passed: + verdict = "[bold green]PASS[/bold green]" + else: + verdict = "[bold red]FAIL[/bold red]" + + console.print(f" [bold]Overall:[/bold] {score}/100 — {verdict} [dim](threshold: {threshold})[/dim]") + + +def print_counts(plugins_passed: int, plugins_failed: int, + error_count: int = 0, warning_count: int = 0) -> None: + """Print the check count summary line.""" + total = plugins_passed + plugins_failed + parts = [f"{total} check(s) ran", f"{plugins_passed} passed", f"{plugins_failed} failed"] + if error_count: + parts.append(f"[red]{error_count} error(s)[/red]") + if warning_count: + parts.append(f"[yellow]{warning_count} warning(s)[/yellow]") + + console.print(" " + ", ".join(parts)) + + +def print_separator() -> None: + """Print a horizontal separator.""" + console.print() + console.print("[dim]" + "─" * 56 + "[/dim]") + console.print() + + +def print_plugin_table(plugins: list[dict]) -> None: + """Print a Rich table of discovered plugins.""" + table = Table( + show_header=True, + header_style="bold cyan", + border_style="dim", + box=box.ROUNDED, + expand=False, + ) + table.add_column("Plugin", style="bold") + table.add_column("Source", style="dim") + table.add_column("File Types") + table.add_column("Description", style="dim") + + for plugin in plugins: + module = plugin["module"] + name = plugin["name"] + source = plugin["source"] + description = getattr(module, "PLUGIN_DESCRIPTION", "") + file_types = getattr(module, "FILE_TYPES", ["*"]) + types_str = ", ".join(file_types) + table.add_row(name, source, types_str, description) + + console.print(table) + + +def print_init_success(config_path: str, plugins_path: str, profile: str | None = None) -> None: + """Print init success message.""" + console.print() + console.print("[green]✓[/green] [bold]Seed Go initialized[/bold]") + console.print() + console.print(f" Config: [dim]{config_path}[/dim]") + console.print(f" Plugins: [dim]{plugins_path}[/dim]") + if profile: + console.print(f" Profile: [cyan]{profile}[/cyan]") + console.print() + console.print(" [bold]Next steps:[/bold]") + console.print(" 1. Add plugins to .seedgo/plugins/") + console.print(" 2. Run: [cyan]seedgo check[/cyan]") + console.print() + + +def print_no_results() -> None: + """Print message when no checks ran.""" + console.print("[dim]No checks ran.[/dim]") + + +def print_error(message: str, suggestion: str | None = None) -> None: + """Print an error message.""" + console.print(f"[red]✗[/red] [red bold]{message}[/red bold]") + if suggestion: + console.print(f" [yellow]→ {suggestion}[/yellow]") + + +def print_warning(message: str) -> None: + """Print a warning message.""" + console.print(f"[yellow]⚠[/yellow] [yellow]{message}[/yellow]") diff --git a/src/seedgo/drone_adapter.py b/src/seedgo/drone_adapter.py index 62ab82ad..0ad6d9fe 100644 --- a/src/seedgo/drone_adapter.py +++ b/src/seedgo/drone_adapter.py @@ -48,40 +48,20 @@ def get_introspective() -> str: Called when user types `drone @seedgo` with no arguments. Shows connected plugins and available commands at a glance. - """ - # Discover plugins by calling seedgo list and parsing output - try: - result = subprocess.run( - [sys.executable, "-m", "seedgo", "list"], - capture_output=True, - text=True, - timeout=10, - ) - plugin_output = result.stdout if result.stdout else "" - except (subprocess.TimeoutExpired, FileNotFoundError): - plugin_output = "" - # Count plugins from the output - # seedgo list outputs lines like " plugin-name source file_types description" - plugin_lines = [] - in_table = False - for line in plugin_output.splitlines(): - stripped = line.strip() - if stripped.startswith("---"): - in_table = True - continue - if in_table and stripped: - parts = stripped.split() - if len(parts) >= 2: - name = parts[0] - # Find description — everything after the file types column - # The format is: NAME SOURCE FILE_TYPES DESCRIPTION - desc = "" - if len(parts) >= 4: - # Rejoin from 3rd column onwards as description - # Actually the columns are fixed-width, let's just grab the name - desc = " ".join(parts[3:]) if len(parts) > 3 else "" - plugin_lines.append((name, desc)) + Uses direct import of seedgo's discovery module instead of parsing + CLI output, since the adapter lives in the same package. + """ + # Import discovery directly — no subprocess parsing needed + try: + from seedgo.discovery import discover_plugins + except ImportError: + return f"SEEDGO — Code Standards Framework (v{DRONE_MODULE['version']})\n\nDiscovered Plugins: 0\n" + + try: + plugins = discover_plugins(None) + except Exception: + plugins = [] lines = [] lines.append(f"SEEDGO — Code Standards Framework (v{DRONE_MODULE['version']})") @@ -89,16 +69,16 @@ def get_introspective() -> str: lines.append("Auto-discovered plugin orchestration") lines.append("") - if plugin_lines: - lines.append(f"Discovered Plugins: {len(plugin_lines)}") + if plugins: + lines.append(f"Discovered Plugins: {len(plugins)}") lines.append("") - # Find max name length for alignment - max_name = max(len(name) for name, _ in plugin_lines) if plugin_lines else 20 - for name, desc in plugin_lines: + max_name = max(len(p["name"]) for p in plugins) + for p in plugins: + desc = getattr(p["module"], "PLUGIN_DESCRIPTION", "") if desc: - lines.append(f" * {name:<{max_name}} {desc}") + lines.append(f" * {p['name']:<{max_name}} {desc}") else: - lines.append(f" * {name}") + lines.append(f" * {p['name']}") else: lines.append("Discovered Plugins: 0") diff --git a/src/seedgo/reporter.py b/src/seedgo/reporter.py index caec2fb4..9ed0d139 100644 --- a/src/seedgo/reporter.py +++ b/src/seedgo/reporter.py @@ -3,10 +3,9 @@ Seed Go Result Reporter Formats check results for display in three output modes: - "human" — Colored terminal output using raw ANSI escape codes. + "human" — Rich-formatted terminal output using the display module. Shows plugin names, pass/fail verdicts, scores, and per-check items with severity markers. Summary line at the bottom. - No external dependencies (no Rich, no colorama). "json" — Machine-readable JSON dict. Includes all result data and the overall summary. Safe for piping to other tools. @@ -20,19 +19,15 @@ All formats are produced by a single entry point: report_results(). import dataclasses import json from .models import CheckResult, Severity - - -# --------------------------------------------------------------------------- -# ANSI color codes (raw escape sequences — zero dependencies) -# --------------------------------------------------------------------------- - -_RESET = "\033[0m" -_BOLD = "\033[1m" -_RED = "\033[31m" -_GREEN = "\033[32m" -_YELLOW = "\033[33m" -_CYAN = "\033[36m" -_DIM = "\033[2m" +from .display import ( + print_header, + print_plugin, + print_check_item, + print_summary, + print_counts, + print_separator, + print_no_results, +) def report_results( @@ -51,18 +46,21 @@ def report_results( threshold, plugins_passed, plugins_failed, error_count, warning_count, info_count. format: Output format. One of: - "human" — Colored terminal output (default). + "human" — Rich terminal output (default). Prints directly, + returns empty string. "json" — JSON-encoded string, machine-readable. "github" — GitHub Actions annotation lines. Returns: Formatted string ready to print or write to stdout. + For "human" format, prints directly and returns empty string. Raises: ValueError: If format is not one of the three supported values. """ if format == "human": - return _format_human(results, overall) + _format_human(results, overall) + return "" elif format == "json": return _format_json(results, overall) elif format == "github": @@ -76,112 +74,59 @@ def report_results( # --------------------------------------------------------------------------- -def _format_human(results: list[CheckResult], overall: dict) -> str: - """Produce colored terminal output for human consumption. +def _format_human(results: list[CheckResult], overall: dict) -> None: + """Print Rich-formatted terminal output for human consumption. + + Prints directly to the Rich console via the display module. Layout: - plugin-name .............. PASS (100/100) - another-plugin ........... FAIL (60/100) - ✗ check-name: message [line N] - hint: fix_hint text - ✓ passing-check: message + ╭─ SEEDGO ─────────────────────────────╮ + │ Code Standards Check │ + │ 5 plugins · 3 files · threshold: 75 │ + ╰───────────────────────────────────────╯ + ✓ plugin-name file.py ········· PASS (100/100) + ✓ check-name: message + ✗ another-plugin file.py ······ FAIL (60/100) + ✗ check-name: message [line N] + hint: fix_hint text + + ───────────────────────────────────────── Overall: 80/100 — PASS (threshold: 75) 2 checks ran, 1 passed, 1 failed """ - lines: list[str] = [] - - if not results: - lines.append(f"{_DIM}No checks ran.{_RESET}") - lines.append(_summary_line(overall)) - return "\n".join(lines) - - # Group results by plugin name for a cleaner display - for result in results: - lines.append(_plugin_header_line(result)) - for item in result.checks: - lines.extend(_check_item_lines(item)) - - lines.append("") - lines.append(_summary_line(overall)) - lines.append(_counts_line(overall)) - - return "\n".join(lines) - - -def _plugin_header_line(result: CheckResult) -> str: - """Format the plugin name + verdict + score header line.""" - name = result.plugin - score = result.score - file_label = f" [{result.file_path}]" if result.file_path else "" - - dots = "." * max(1, 50 - len(name) - len(file_label)) - - if result.passed: - verdict = f"{_GREEN}PASS{_RESET}" - else: - verdict = f"{_RED}FAIL{_RESET}" - - score_str = f"({score}/100)" - return f" {_BOLD}{name}{_RESET}{file_label} {_DIM}{dots}{_RESET} {verdict} {_DIM}{score_str}{_RESET}" - - -def _check_item_lines(item) -> list[str]: - """Format a single CheckItem into one or two display lines.""" - lines: list[str] = [] - - if item.passed: - marker = f"{_GREEN}✓{_RESET}" - color = _DIM - elif item.severity == Severity.ERROR: - marker = f"{_RED}✗{_RESET}" - color = _RED - elif item.severity == Severity.WARNING: - marker = f"{_YELLOW}⚠{_RESET}" - color = _YELLOW - else: - marker = f"{_CYAN}ℹ{_RESET}" - color = _CYAN - - line_ref = f" [line {item.line}]" if item.line is not None else "" - main = f" {marker} {color}{item.name}{_RESET}: {item.message}{line_ref}" - lines.append(main) - - if item.fix_hint and not item.passed: - lines.append(f" {_DIM}hint: {item.fix_hint}{_RESET}") - - return lines - - -def _summary_line(overall: dict) -> str: - """Format the overall score summary line.""" score = overall.get("overall_score", 100) - passed = overall.get("passed", True) threshold = overall.get("threshold", 75) - - if passed: - verdict = f"{_GREEN}{_BOLD}PASS{_RESET}" - else: - verdict = f"{_RED}{_BOLD}FAIL{_RESET}" - - return f" {_BOLD}Overall:{_RESET} {score}/100 — {verdict} {_DIM}(threshold: {threshold}){_RESET}" - - -def _counts_line(overall: dict) -> str: - """Format the plugin count summary line.""" + passed = overall.get("passed", True) p_passed = overall.get("plugins_passed", 0) p_failed = overall.get("plugins_failed", 0) - total = p_passed + p_failed - errors = overall.get("error_count", 0) - warnings = overall.get("warning_count", 0) - parts = [f"{total} check(s) ran", f"{p_passed} passed", f"{p_failed} failed"] - if errors: - parts.append(f"{_RED}{errors} error(s){_RESET}") - if warnings: - parts.append(f"{_YELLOW}{warnings} warning(s){_RESET}") + # Header + total_plugins = p_passed + p_failed + subtitle = f"{total_plugins} plugin(s) · threshold: {threshold}" + print_header("SEEDGO — Code Standards Check", subtitle) - return " " + ", ".join(parts) + if not results: + print_no_results() + print_separator() + print_summary(score, passed, threshold) + return + + for result in results: + print_plugin(result.plugin, result.file_path, result.passed, result.score) + for item in result.checks: + print_check_item( + item.name, item.passed, item.message, item.severity, + line=item.line, fix_hint=item.fix_hint, + ) + + print_separator() + print_summary(score, passed, threshold) + print_counts( + p_passed, p_failed, + error_count=overall.get("error_count", 0), + warning_count=overall.get("warning_count", 0), + ) # --------------------------------------------------------------------------- diff --git a/tests/test_seedgo_cli.py b/tests/test_seedgo_cli.py index 816b5b35..aee2e5b1 100644 --- a/tests/test_seedgo_cli.py +++ b/tests/test_seedgo_cli.py @@ -548,6 +548,8 @@ def check(file_path, config=None): class TestReportHuman: + """Human format prints directly via Rich console. Tests capture stdout.""" + def _make_result(self, passed=True, score=100, checks=None): return CheckResult( plugin="test-plugin", @@ -569,34 +571,39 @@ class TestReportHuman: "info_count": 0, } - def test_output_contains_plugin_name(self): + def _report_captured(self, results, overall, capsys): + """Call report_results for human format and return captured output.""" + report_results(results, overall, format="human") + return capsys.readouterr().out + + def test_output_contains_plugin_name(self, capsys): result = self._make_result() - output = report_results([result], self._overall()) + output = self._report_captured([result], self._overall(), capsys) assert "test-plugin" in output - def test_passing_result_contains_pass(self): + def test_passing_result_contains_pass(self, capsys): result = self._make_result(passed=True, score=100) - output = report_results([result], self._overall(score=100, passed=True)) + output = self._report_captured([result], self._overall(score=100, passed=True), capsys) assert "PASS" in output - def test_failing_result_contains_fail(self): + def test_failing_result_contains_fail(self, capsys): checks = [CheckItem(name="c", passed=False, message="fail", severity=Severity.ERROR)] result = self._make_result(passed=False, score=0, checks=checks) - output = report_results([result], self._overall(score=0, passed=False)) + output = self._report_captured([result], self._overall(score=0, passed=False), capsys) assert "FAIL" in output - def test_score_shown_in_output(self): + def test_score_shown_in_output(self, capsys): result = self._make_result(score=75) - output = report_results([result], self._overall(score=75)) + output = self._report_captured([result], self._overall(score=75), capsys) assert "75" in output - def test_check_item_message_shown(self): + def test_check_item_message_shown(self, capsys): checks = [CheckItem(name="bare-except", passed=False, message="Found bare except", severity=Severity.ERROR)] result = self._make_result(passed=False, checks=checks) - output = report_results([result], self._overall(passed=False)) + output = self._report_captured([result], self._overall(passed=False), capsys) assert "Found bare except" in output - def test_fix_hint_shown_for_failing_check(self): + def test_fix_hint_shown_for_failing_check(self, capsys): checks = [ CheckItem( name="c", @@ -607,22 +614,22 @@ class TestReportHuman: ) ] result = self._make_result(passed=False, checks=checks) - output = report_results([result], self._overall(passed=False)) + output = self._report_captured([result], self._overall(passed=False), capsys) assert "Use except Exception:" in output - def test_no_results_shows_no_checks_ran(self): - output = report_results([], self._overall()) + def test_no_results_shows_no_checks_ran(self, capsys): + output = self._report_captured([], self._overall(), capsys) assert "No checks ran" in output - def test_threshold_shown_in_summary(self): + def test_threshold_shown_in_summary(self, capsys): result = self._make_result() - output = report_results([result], self._overall(threshold=80)) + output = self._report_captured([result], self._overall(threshold=80), capsys) assert "80" in output - def test_line_number_shown_when_present(self): + def test_line_number_shown_when_present(self, capsys): checks = [CheckItem(name="c", passed=False, message="fail", severity=Severity.ERROR, line=42)] result = self._make_result(passed=False, checks=checks) - output = report_results([result], self._overall(passed=False)) + output = self._report_captured([result], self._overall(passed=False), capsys) assert "42" in output def test_invalid_format_raises_value_error(self):