Coverage for lintro / cli.py: 98%

127 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-04-03 18:53 +0000

1"""Command-line interface for Lintro.""" 

2 

3from typing import Any, cast 

4 

5import click 

6from rich.console import Console 

7from rich.panel import Panel 

8from rich.table import Table 

9from rich.text import Text 

10 

11from lintro import __version__ 

12from lintro.cli_utils.command_chainer import CommandChainer 

13from lintro.utils.logger_setup import setup_cli_logging 

14 

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() 

18 

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 

34 

35 

36class LintroGroup(click.Group): 

37 """Custom Click group with enhanced help rendering and command chaining. 

38 

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 """ 

43 

44 def format_help( 

45 self, 

46 ctx: click.Context, 

47 formatter: click.HelpFormatter, 

48 ) -> None: 

49 """Render help with Rich formatting. 

50 

51 Args: 

52 ctx: click.Context: The Click context. 

53 formatter: click.HelpFormatter: The help formatter (unused, we use Rich). 

54 """ 

55 console = Console() 

56 

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() 

63 

64 # Description 

65 console.print( 

66 "[white]Unified CLI for code formatting, linting, " 

67 "and quality assurance.[/white]", 

68 ) 

69 console.print() 

70 

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() 

76 

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) 

92 

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") 

97 

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()) 

101 

102 console.print(table) 

103 console.print() 

104 

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() 

110 

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") 

121 

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. 

128 

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 

135 

136 def invoke( 

137 self, 

138 ctx: click.Context, 

139 ) -> int: 

140 """Handle command execution with support for command chaining. 

141 

142 Supports chaining commands with commas, e.g.: lintro fmt , chk , tst 

143 

144 Args: 

145 ctx: click.Context: The Click context. 

146 

147 Returns: 

148 int: Exit code from command execution. 

149 

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() 

157 

158 all_args = ctx.protected_args + ctx.args 

159 

160 if all_args: 

161 chainer = CommandChainer(self) 

162 

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) 

167 

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 

173 

174 # Normal single command execution 

175 result = super().invoke(ctx) 

176 return int(result) if isinstance(result, int) else 0 

177 

178 

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 

188 

189 

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" 

201 

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") 

212 

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") 

226 

227 

228def main() -> None: 

229 """Entry point for the CLI.""" 

230 cli()