Coverage for lintro / cli_utils / commands / config.py: 83%

140 statements  

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

1"""Config command for displaying Lintro configuration status.""" 

2 

3from dataclasses import asdict 

4from pathlib import Path 

5from typing import Any, cast 

6 

7import click 

8from rich.console import Console 

9from rich.panel import Panel 

10from rich.table import Table 

11 

12from lintro.config import LintroConfig, get_config 

13from lintro.utils.unified_config import ( 

14 _load_native_tool_config, 

15 get_ordered_tools, 

16 get_tool_priority, 

17 is_tool_injectable, 

18 validate_config_consistency, 

19) 

20 

21 

22def _get_all_tool_names() -> list[str]: 

23 """Get list of all registered tool names. 

24 

25 Dynamically retrieves tool names from the plugin registry to ensure 

26 all tools are included without manual maintenance. 

27 

28 Returns: 

29 list[str]: Sorted list of tool names. 

30 """ 

31 from lintro.plugins.registry import ToolRegistry 

32 

33 return ToolRegistry.get_names() 

34 

35 

36@click.command() 

37@click.option( 

38 "--verbose", 

39 "-v", 

40 is_flag=True, 

41 help="Show detailed configuration including native tool configs.", 

42) 

43@click.option( 

44 "--json", 

45 "json_output", 

46 is_flag=True, 

47 help="Output configuration as JSON.", 

48) 

49@click.option( 

50 "--export", 

51 "export_path", 

52 type=click.Path(), 

53 help="Export effective configuration as a .lintro-config.yaml file.", 

54) 

55def config_command( 

56 verbose: bool, 

57 json_output: bool, 

58 export_path: str | None, 

59) -> None: 

60 """Display Lintro configuration status. 

61 

62 Shows the unified configuration for all tools including: 

63 - Config source (.lintro-config.yaml or pyproject.toml) 

64 - Global settings (line_length, tool ordering strategy) 

65 - Tool execution order based on configured strategy 

66 - Per-tool effective configuration 

67 - Configuration warnings and inconsistencies 

68 

69 Args: 

70 verbose: Show detailed configuration including native tool configs. 

71 json_output: Output configuration as JSON. 

72 export_path: Path to export effective configuration as YAML file. 

73 """ 

74 console = Console() 

75 config = get_config(reload=True) 

76 

77 if export_path: 

78 _export_yaml(config=config, export_path=export_path, console=console) 

79 return 

80 

81 if json_output: 

82 _output_json(config=config, verbose=verbose) 

83 return 

84 

85 _output_rich( 

86 console=console, 

87 config=config, 

88 verbose=verbose, 

89 ) 

90 

91 

92def _config_to_export_dict(config: LintroConfig) -> dict[str, Any]: 

93 """Convert LintroConfig to a YAML-friendly dictionary. 

94 

95 Args: 

96 config: The LintroConfig instance to convert. 

97 

98 Returns: 

99 dict[str, Any]: Dictionary suitable for YAML serialization. 

100 """ 

101 tools = {name: asdict(cfg) for name, cfg in config.tools.items()} 

102 return { 

103 "enforce": asdict(config.enforce), 

104 "execution": asdict(config.execution), 

105 "defaults": config.defaults, 

106 "tools": tools, 

107 } 

108 

109 

110def _export_yaml( 

111 config: LintroConfig, 

112 export_path: str, 

113 console: Console, 

114) -> None: 

115 """Export the effective configuration as YAML. 

116 

117 Args: 

118 config: Loaded configuration. 

119 export_path: Destination file path. 

120 console: Rich console for user feedback. 

121 

122 Raises: 

123 SystemExit: If PyYAML is not installed. 

124 """ 

125 try: 

126 import yaml 

127 except ImportError as exc: # pragma: no cover - enforced by packaging 

128 console.print( 

129 "[red]PyYAML is required to export configuration. " 

130 "Install it with `pip install pyyaml`.[/red]", 

131 ) 

132 raise SystemExit(1) from exc 

133 

134 export_file = Path(export_path) 

135 data = _config_to_export_dict(config) 

136 header = "# Generated by `lintro config --export`\n" 

137 export_file.write_text( 

138 header + yaml.safe_dump(data, sort_keys=False), 

139 encoding="utf-8", 

140 ) 

141 console.print(f"[green]Exported configuration to {export_file}[/green]") 

142 

143 

144def _output_json( 

145 config: LintroConfig, 

146 verbose: bool = False, 

147) -> None: 

148 """Output configuration as JSON. 

149 

150 Args: 

151 config: LintroConfig instance from get_config() 

152 verbose: Include native configs in output when True 

153 """ 

154 import json 

155 

156 # Get tool order settings 

157 tool_order = config.execution.tool_order 

158 if isinstance(tool_order, list): 

159 order_strategy = "custom" 

160 custom_order = tool_order 

161 else: 

162 order_strategy = tool_order or "priority" 

163 custom_order = [] 

164 

165 # Get list of all known tools 

166 tool_names = _get_all_tool_names() 

167 ordered_tools = get_ordered_tools( 

168 tool_names=tool_names, 

169 tool_order=config.execution.tool_order, 

170 ) 

171 

172 output: dict[str, Any] = { 

173 "config_source": config.config_path or "defaults", 

174 "global_settings": { 

175 "line_length": config.enforce.line_length, 

176 "target_python": config.enforce.target_python, 

177 "tool_order": order_strategy, 

178 "custom_order": custom_order, 

179 }, 

180 "execution": { 

181 "enabled_tools": config.execution.enabled_tools or "all", 

182 "fail_fast": config.execution.fail_fast, 

183 "parallel": config.execution.parallel, 

184 }, 

185 "tool_execution_order": [ 

186 {"tool": t, "priority": get_tool_priority(t)} for t in ordered_tools 

187 ], 

188 "tool_configs": {}, 

189 "warnings": validate_config_consistency(), 

190 } 

191 

192 tool_configs = cast(dict[str, Any], output["tool_configs"]) 

193 

194 for tool_name in tool_names: 

195 tool_config = config.get_tool_config(tool_name) 

196 effective_ll = config.get_effective_line_length(tool_name) 

197 

198 tool_output: dict[str, Any] = { 

199 "enabled": tool_config.enabled, 

200 "is_injectable": is_tool_injectable(tool_name), 

201 "effective_line_length": effective_ll, 

202 "config_source": tool_config.config_source, 

203 } 

204 if verbose: 

205 native = _load_native_tool_config(tool_name) 

206 tool_output["native_config"] = native if native else None 

207 tool_output["defaults"] = config.get_tool_defaults(tool_name) or None 

208 

209 tool_configs[tool_name] = tool_output 

210 

211 print(json.dumps(output, indent=2)) 

212 

213 

214def _output_rich( 

215 console: Console, 

216 config: LintroConfig, 

217 verbose: bool, 

218) -> None: 

219 """Output configuration using Rich formatting. 

220 

221 Args: 

222 console: Rich Console instance 

223 config: LintroConfig instance from get_config() 

224 verbose: Whether to show verbose output 

225 """ 

226 # Header panel 

227 console.print( 

228 Panel.fit( 

229 "[bold cyan]Lintro Configuration Report[/bold cyan]", 

230 border_style="cyan", 

231 ), 

232 ) 

233 console.print() 

234 

235 # Config Source Section 

236 config_source = config.config_path or "[dim]No config file (using defaults)[/dim]" 

237 console.print(f"[bold]Config Source:[/bold] {config_source}") 

238 console.print() 

239 

240 # Global Settings Section 

241 global_table = Table( 

242 title="Enforce Settings", 

243 show_header=False, 

244 box=None, 

245 ) 

246 global_table.add_column("Setting", style="cyan", width=25) 

247 global_table.add_column("Value", style="yellow") 

248 

249 line_length = config.enforce.line_length 

250 global_table.add_row( 

251 "line_length", 

252 str(line_length) if line_length else "[dim]Not configured[/dim]", 

253 ) 

254 

255 target_python = config.enforce.target_python 

256 global_table.add_row( 

257 "target_python", 

258 target_python if target_python else "[dim]Not configured[/dim]", 

259 ) 

260 

261 console.print(global_table) 

262 console.print() 

263 

264 # Execution Settings Section 

265 exec_table = Table( 

266 title="Execution Settings", 

267 show_header=False, 

268 box=None, 

269 ) 

270 exec_table.add_column("Setting", style="cyan", width=25) 

271 exec_table.add_column("Value", style="yellow") 

272 

273 tool_order = config.execution.tool_order 

274 if isinstance(tool_order, list): 

275 order_strategy = "custom" 

276 exec_table.add_row("tool_order", order_strategy) 

277 exec_table.add_row("custom_order", ", ".join(tool_order)) 

278 else: 

279 exec_table.add_row("tool_order", tool_order or "priority") 

280 

281 enabled_tools = config.execution.enabled_tools 

282 exec_table.add_row( 

283 "enabled_tools", 

284 ", ".join(enabled_tools) if enabled_tools else "[dim]all[/dim]", 

285 ) 

286 exec_table.add_row("fail_fast", str(config.execution.fail_fast)) 

287 exec_table.add_row("parallel", str(config.execution.parallel)) 

288 

289 console.print(exec_table) 

290 console.print() 

291 

292 # Tool Execution Order Section 

293 tool_names = _get_all_tool_names() 

294 ordered_tools = get_ordered_tools( 

295 tool_names=tool_names, 

296 tool_order=config.execution.tool_order, 

297 ) 

298 

299 order_table = Table(title="Tool Execution Order") 

300 order_table.add_column("#", style="dim", justify="right", width=3) 

301 order_table.add_column("Tool", style="cyan") 

302 order_table.add_column("Priority", justify="center", style="yellow") 

303 order_table.add_column("Type", style="green") 

304 order_table.add_column("Enabled", justify="center") 

305 

306 for idx, tool_name in enumerate(ordered_tools, 1): 

307 priority = get_tool_priority(tool_name) 

308 injectable = is_tool_injectable(tool_name) 

309 tool_type = "Syncable" if injectable else "Native only" 

310 enabled = config.is_tool_enabled(tool_name) 

311 enabled_display = "[green]✓[/green]" if enabled else "[red]✗[/red]" 

312 

313 order_table.add_row( 

314 str(idx), 

315 tool_name, 

316 str(priority), 

317 tool_type, 

318 enabled_display, 

319 ) 

320 

321 console.print(order_table) 

322 console.print() 

323 

324 # Per-Tool Configuration Section 

325 config_table = Table(title="Per-Tool Configuration") 

326 config_table.add_column("Tool", style="cyan") 

327 config_table.add_column("Sync Status", justify="center") 

328 config_table.add_column("Line Length", justify="center", style="yellow") 

329 config_table.add_column("Config Source", style="dim") 

330 

331 if verbose: 

332 config_table.add_column("Native Config", style="dim") 

333 

334 for tool_name in tool_names: 

335 tool_config = config.get_tool_config(tool_name) 

336 injectable = is_tool_injectable(tool_name) 

337 status = ( 

338 "[green]✓ Syncable[/green]" 

339 if injectable 

340 else "[yellow]⚠ Native only[/yellow]" 

341 ) 

342 effective_ll = config.get_effective_line_length(tool_name) 

343 ll_display = str(effective_ll) if effective_ll else "[dim]default[/dim]" 

344 

345 cfg_source = tool_config.config_source or "[dim]auto[/dim]" 

346 

347 row = [tool_name, status, ll_display, cfg_source] 

348 

349 if verbose: 

350 native = _load_native_tool_config(tool_name) 

351 native_cfg = str(native) if native else "[dim]None[/dim]" 

352 row.append(native_cfg) 

353 

354 config_table.add_row(*row) 

355 

356 console.print(config_table) 

357 console.print() 

358 

359 # Warnings Section 

360 warnings = validate_config_consistency() 

361 if warnings: 

362 console.print("[bold red]Configuration Warnings[/bold red]") 

363 for warning in warnings: 

364 console.print(f" [yellow]⚠️[/yellow] {warning}") 

365 console.print() 

366 console.print( 

367 "[dim]Tools marked 'Native only' cannot be configured by Lintro. " 

368 "Update their config files manually for consistency.[/dim]", 

369 ) 

370 else: 

371 console.print( 

372 "[green]✅ All configurations are consistent![/green]", 

373 ) 

374 

375 console.print() 

376 

377 # Help text 

378 console.print( 

379 "[dim]Configure Lintro in .lintro-config.yaml:[/dim]", 

380 ) 

381 console.print( 

382 "[dim] Run 'lintro init' to create a config file[/dim]", 

383 ) 

384 console.print( 

385 '[dim] tool_order: "priority" | "alphabetical" | ["tool1", "tool2"][/dim]', 

386 )