Coverage for lintro / cli.py: 98%
127 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""Command-line interface for Lintro."""
3from typing import Any, cast
5import click
6from rich.console import Console
7from rich.panel import Panel
8from rich.table import Table
9from rich.text import Text
11from lintro import __version__
12from lintro.cli_utils.command_chainer import CommandChainer
13from lintro.utils.logger_setup import setup_cli_logging
15# Configure loguru for CLI commands (help, version, etc.)
16# Only WARNING and above will show. DEBUG logs go to file when tool_executor runs.
17setup_cli_logging()
19# E402: Module level imports below setup_cli_logging() are intentional.
20# Logging must be configured BEFORE importing modules that use loguru,
21# otherwise log messages during import get silently dropped or misconfigured.
22from lintro.cli_utils.commands.check import check_command # noqa: E402
23from lintro.cli_utils.commands.config import config_command # noqa: E402
24from lintro.cli_utils.commands.doctor import doctor_command # noqa: E402
25from lintro.cli_utils.commands.format import format_command # noqa: E402
26from lintro.cli_utils.commands.init import init_command # noqa: E402
27from lintro.cli_utils.commands.install import install_command # noqa: E402
28from lintro.cli_utils.commands.list_tools import list_tools_command # noqa: E402
29from lintro.cli_utils.commands.setup import setup_command # noqa: E402
30from lintro.cli_utils.commands.test import test_command # noqa: E402
31from lintro.cli_utils.commands.versions import versions_command # noqa: E402
32from lintro.tools.core.runtime_discovery import clear_discovery_cache # noqa: E402
33from lintro.utils.config import clear_pyproject_cache # noqa: E402
36class LintroGroup(click.Group):
37 """Custom Click group with enhanced help rendering and command chaining.
39 This group prints command aliases alongside their canonical names to make
40 the CLI help output more discoverable. It also supports command chaining
41 with comma-separated commands (e.g., lintro fmt , chk , tst).
42 """
44 def format_help(
45 self,
46 ctx: click.Context,
47 formatter: click.HelpFormatter,
48 ) -> None:
49 """Render help with Rich formatting.
51 Args:
52 ctx: click.Context: The Click context.
53 formatter: click.HelpFormatter: The help formatter (unused, we use Rich).
54 """
55 console = Console()
57 # Header panel
58 header = Text()
59 header.append("🔧 Lintro", style="bold cyan")
60 header.append(f" v{__version__}", style="dim")
61 console.print(Panel(header, border_style="cyan"))
62 console.print()
64 # Description
65 console.print(
66 "[white]Unified CLI for code formatting, linting, "
67 "and quality assurance.[/white]",
68 )
69 console.print()
71 # Usage
72 console.print("[bold cyan]Usage:[/bold cyan]")
73 console.print(" lintro [OPTIONS] COMMAND [ARGS]...")
74 console.print(" lintro COMMAND1 , COMMAND2 , ... [dim](chain commands)[/dim]")
75 console.print()
77 # Commands table
78 commands = self.list_commands(ctx)
79 canonical_map: dict[str, tuple[click.Command, list[str]]] = {}
80 for name in commands:
81 cmd = self.get_command(ctx, name)
82 if cmd is None:
83 continue
84 cmd_any = cast(Any, cmd)
85 if not hasattr(cmd_any, "_canonical_name"):
86 cmd_any._canonical_name = name
87 canonical = cast(str, getattr(cmd_any, "_canonical_name", name))
88 if canonical not in canonical_map:
89 canonical_map[canonical] = (cmd, [])
90 if name != canonical:
91 canonical_map[canonical][1].append(name)
93 table = Table(title="Commands", show_header=True, header_style="bold cyan")
94 table.add_column("Command", style="cyan", no_wrap=True)
95 table.add_column("Alias", style="yellow", no_wrap=True)
96 table.add_column("Description", style="white")
98 for canonical, (cmd, aliases) in sorted(canonical_map.items()):
99 alias_str = ", ".join(aliases) if aliases else "-"
100 table.add_row(canonical, alias_str, cmd.get_short_help_str())
102 console.print(table)
103 console.print()
105 # Options
106 console.print("[bold cyan]Options:[/bold cyan]")
107 console.print(" [yellow]-v, --version[/yellow] Show the version and exit.")
108 console.print(" [yellow]-h, --help[/yellow] Show this message and exit.")
109 console.print()
111 # Examples
112 console.print("[bold cyan]Examples:[/bold cyan]")
113 console.print(" [dim]# Check all files[/dim]")
114 console.print(" lintro check .")
115 console.print()
116 console.print(" [dim]# Format and then check[/dim]")
117 console.print(" lintro fmt . , chk .")
118 console.print()
119 console.print(" [dim]# Show tool versions[/dim]")
120 console.print(" lintro versions")
122 def format_commands(
123 self,
124 ctx: click.Context,
125 formatter: click.HelpFormatter,
126 ) -> None:
127 """Render command list with aliases in the help output.
129 Args:
130 ctx: click.Context: The Click context.
131 formatter: click.HelpFormatter: The help formatter to write to.
132 """
133 # This is now handled by format_help, but keep for compatibility
134 pass
136 def invoke(
137 self,
138 ctx: click.Context,
139 ) -> int:
140 """Handle command execution with support for command chaining.
142 Supports chaining commands with commas, e.g.: lintro fmt , chk , tst
144 Args:
145 ctx: click.Context: The Click context.
147 Returns:
148 int: Exit code from command execution.
150 Raises:
151 SystemExit: If a command exits with a non-zero exit code.
152 """
153 # Clear caches at start of each invocation to ensure fresh tool
154 # detection and pyproject.toml loading across working directories
155 clear_discovery_cache()
156 clear_pyproject_cache()
158 all_args = ctx.protected_args + ctx.args
160 if all_args:
161 chainer = CommandChainer(self)
163 if chainer.should_chain(all_args):
164 # Normalize arguments and group into command chains
165 normalized = chainer.normalize_args(all_args)
166 groups = chainer.group_commands(normalized)
168 # Execute command chain
169 final_exit_code = chainer.execute_chain(ctx, groups)
170 if final_exit_code != 0:
171 raise SystemExit(final_exit_code)
172 return 0
174 # Normal single command execution
175 result = super().invoke(ctx)
176 return int(result) if isinstance(result, int) else 0
179@click.group(
180 cls=LintroGroup,
181 invoke_without_command=True,
182 context_settings={"help_option_names": ["-h", "--help"]},
183)
184@click.version_option(__version__, "-v", "--version")
185def cli() -> None:
186 """Lintro: Unified CLI for code formatting, linting, and quality assurance."""
187 pass
190# Register canonical commands and set _canonical_name for help
191cast(Any, check_command)._canonical_name = "check"
192cast(Any, config_command)._canonical_name = "config"
193cast(Any, doctor_command)._canonical_name = "doctor"
194cast(Any, format_command)._canonical_name = "format"
195cast(Any, init_command)._canonical_name = "init"
196cast(Any, install_command)._canonical_name = "install"
197cast(Any, setup_command)._canonical_name = "setup"
198cast(Any, test_command)._canonical_name = "test"
199cast(Any, list_tools_command)._canonical_name = "list-tools"
200cast(Any, versions_command)._canonical_name = "versions"
202cli.add_command(check_command, name="check")
203cli.add_command(config_command, name="config")
204cli.add_command(doctor_command, name="doctor")
205cli.add_command(format_command, name="format")
206cli.add_command(init_command, name="init")
207cli.add_command(install_command, name="install")
208cli.add_command(setup_command, name="setup")
209cli.add_command(test_command, name="test")
210cli.add_command(list_tools_command, name="list-tools")
211cli.add_command(versions_command, name="versions")
213# Register aliases
214cli.add_command(check_command, name="chk")
215cli.add_command(check_command, name="lint")
216cli.add_command(config_command, name="cfg")
217cli.add_command(format_command, name="fmt")
218cli.add_command(format_command, name="fix")
219cli.add_command(test_command, name="tst")
220cli.add_command(list_tools_command, name="ls")
221cli.add_command(list_tools_command, name="tools")
222cli.add_command(install_command, name="ins")
223cli.add_command(setup_command, name="su")
224cli.add_command(versions_command, name="ver")
225cli.add_command(versions_command, name="version")
228def main() -> None:
229 """Entry point for the CLI."""
230 cli()