Coverage for lintro / cli_utils / commands / setup.py: 50%

163 statements  

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

1"""Interactive setup command for project onboarding. 

2 

3Detects project languages, recommends tools, installs missing ones, and 

4generates a project-appropriate .lintro-config.yaml. Inspired by Trunk's 

5zero-friction init and rustup's profile system. 

6 

7Usage: 

8 lintro setup # Interactive onboarding 

9 lintro setup --profile ci --yes # Non-interactive for CI 

10 lintro setup --dry-run # Show plan without executing 

11""" 

12 

13from __future__ import annotations 

14 

15import sys 

16from pathlib import Path 

17 

18import click 

19from rich.console import Console 

20 

21from lintro.tools.core.install_context import RuntimeContext 

22from lintro.tools.core.tool_installer import ToolInstaller 

23from lintro.tools.core.tool_registry import ToolRegistry 

24from lintro.utils.project_detection import ( 

25 detect_package_managers, 

26 detect_project_languages, 

27) 

28 

29 

30def _generate_config( 

31 tools: list[str], 

32 detected_langs: list[str], 

33) -> str: 

34 """Generate .lintro-config.yaml content for the given tools. 

35 

36 Args: 

37 tools: List of tool names to enable. 

38 detected_langs: Detected languages for enforce settings. 

39 

40 Returns: 

41 YAML config string. 

42 """ 

43 lines = [ 

44 "# Lintro configuration — generated by 'lintro setup'", 

45 "# Docs: https://github.com/lgtm-hq/py-lintro/tree/main/docs", 

46 "", 

47 "enforce:", 

48 ] 

49 

50 # Python-specific enforce settings 

51 if "python" in detected_langs: 

52 lines.append(" line_length: 88") 

53 

54 lines.extend( 

55 [ 

56 "", 

57 "execution:", 

58 f" enabled_tools: [{', '.join(sorted(tools))}]", 

59 " tool_order: priority", 

60 " fail_fast: false", 

61 " parallel: true", 

62 "", 

63 ], 

64 ) 

65 

66 if "mypy" in tools: 

67 lines.extend( 

68 [ 

69 "defaults:", 

70 " mypy:", 

71 " strict: true", 

72 " ignore_missing_imports: true", 

73 "", 

74 ], 

75 ) 

76 

77 lines.append("tools:") 

78 for tool_name in sorted(tools): 

79 lines.append(f" {tool_name}:") 

80 lines.append(" enabled: true") 

81 

82 lines.append("") 

83 return "\n".join(lines) 

84 

85 

86@click.command() 

87@click.option( 

88 "--profile", 

89 type=str, 

90 help="Named profile (e.g., minimal, recommended, complete, ci).", 

91) 

92@click.option( 

93 "--yes", 

94 "-y", 

95 is_flag=True, 

96 help="Non-interactive mode (accept defaults).", 

97) 

98@click.option( 

99 "--dry-run", 

100 is_flag=True, 

101 help="Show what would happen without making changes.", 

102) 

103@click.option( 

104 "--skip-install", 

105 is_flag=True, 

106 help="Generate config only, don't install tools.", 

107) 

108@click.option( 

109 "--config", 

110 "config_path", 

111 type=str, 

112 default=".lintro-config.yaml", 

113 help="Config output path (default: .lintro-config.yaml).", 

114) 

115def setup_command( 

116 profile: str | None, 

117 *, 

118 yes: bool, 

119 dry_run: bool, 

120 skip_install: bool, 

121 config_path: str, 

122) -> None: 

123 """Set up lintro for your project. 

124 

125 Scans your project, detects languages and frameworks, recommends tools, 

126 installs missing ones, and generates a .lintro-config.yaml. 

127 

128 Args: 

129 profile: Named profile to use. 

130 yes: Non-interactive mode. 

131 dry_run: Show plan without executing. 

132 skip_install: Skip tool installation. 

133 config_path: Output path for config file. 

134 

135 Raises: 

136 click.ClickException: When an invalid profile name is provided. 

137 

138 Examples: 

139 lintro setup 

140 lintro setup --profile minimal --yes 

141 lintro setup --dry-run 

142 lintro setup --skip-install 

143 """ 

144 console = Console() 

145 

146 console.print() 

147 console.print(" [bold]Lintro Setup[/bold]") 

148 console.print(" [dim]Scanning project...[/dim]") 

149 console.print() 

150 

151 # ── Detection ── 

152 detected_langs = detect_project_languages() 

153 pkg_managers = detect_package_managers() 

154 

155 console.print(" [bold]Detected:[/bold]") 

156 if detected_langs: 

157 console.print( 

158 f" Languages: {', '.join(lang.title() for lang in detected_langs)}", 

159 ) 

160 else: 

161 console.print(" Languages: [dim]none detected[/dim]") 

162 if pkg_managers: 

163 mgr_display = ", ".join( 

164 f"{name} ({manifest})" for name, manifest in pkg_managers.items() 

165 ) 

166 console.print(f" Pkg Mgrs: {mgr_display}") 

167 console.print() 

168 

169 # ── Load registry ── 

170 registry = ToolRegistry.load() 

171 context = RuntimeContext.detect() 

172 

173 # ── Resolve profile ── 

174 if not profile: 

175 # Show recommended tools (use the same resolver the install path uses) 

176 recommended = registry.tools_for_profile("recommended", detected_langs) 

177 if recommended: 

178 console.print( 

179 f" [bold]Recommended tools for this project " 

180 f"({len(recommended)} tools):[/bold]", 

181 ) 

182 console.print() 

183 

184 # Group by language for display 

185 lang_tools: dict[str, list[str]] = {} 

186 for tool in recommended: 

187 for lang in tool.languages: 

188 lang_tools.setdefault(lang, []).append(tool.name) 

189 

190 for lang in sorted(lang_tools): 

191 tool_display = ", ".join(sorted(set(lang_tools[lang]))) 

192 console.print(f" {lang.title():<20}{tool_display}") 

193 

194 console.print() 

195 

196 if yes: 

197 profile = "recommended" 

198 else: 

199 # Interactive profile selection 

200 profiles = registry.profiles 

201 console.print(" [bold]Select profile:[/bold]") 

202 # Preferred display order, then any additional profiles 

203 preferred_order = ["recommended", "minimal", "complete", "ci"] 

204 profile_list = [p for p in preferred_order if p in profiles] 

205 profile_list.extend(p for p in sorted(profiles) if p not in preferred_order) 

206 default_idx = ( 

207 profile_list.index("recommended") + 1 

208 if "recommended" in profile_list 

209 else 1 

210 ) 

211 for i, pname in enumerate(profile_list, 1): 

212 desc = profiles[pname].description 

213 default = " (default)" if i == default_idx else "" 

214 console.print(f" [{i}] {pname:<16}{desc}{default}") 

215 

216 console.print() 

217 choice = click.prompt( 

218 " Choice", 

219 type=click.IntRange(1, len(profile_list)), 

220 default=default_idx, 

221 show_default=False, 

222 ) 

223 profile = profile_list[choice - 1] 

224 

225 # Resolve profile to tool list 

226 if profile not in registry.profile_names: 

227 raise click.ClickException( 

228 f"Unknown profile {profile!r}. " 

229 f"Available: {', '.join(registry.profile_names)}", 

230 ) 

231 selected_tools = registry.tools_for_profile(profile, detected_langs) 

232 tool_names: list[str] = [t.name for t in selected_tools] 

233 

234 console.print() 

235 console.print( 

236 f" [dim]Profile: {profile} ({len(selected_tools)} tools)[/dim]", 

237 ) 

238 

239 # ── Install check ── 

240 if not skip_install: 

241 installer = ToolInstaller(registry, context) 

242 plan = installer.plan( 

243 tools=tool_names, 

244 detected_langs=detected_langs, 

245 ) 

246 

247 if plan.has_work: 

248 console.print() 

249 console.print( 

250 f" [bold]Missing tools ({len(plan.to_install)}):[/bold]", 

251 ) 

252 for tool, cmd in plan.to_install: 

253 console.print( 

254 f" {tool.name:<20}({tool.install_type}) [dim]{cmd}[/dim]", 

255 ) 

256 

257 if dry_run: 

258 console.print() 

259 console.print( 

260 f" [dim]Dry run: would install " 

261 f"{len(plan.to_install)} tool(s).[/dim]", 

262 ) 

263 elif yes or click.confirm( 

264 "\n Install missing tools?", 

265 default=True, 

266 ): 

267 console.print() 

268 results = installer.execute(plan) 

269 succeeded = sum(1 for r in results if r.success) 

270 failed = sum(1 for r in results if not r.success) 

271 for r in results: 

272 if r.success: 

273 console.print(f" [green]OK[/green] {r.tool.name}") 

274 else: 

275 console.print( 

276 f" [red]FAIL[/red] {r.tool.name}: {r.message}", 

277 ) 

278 if failed > 0: 

279 console.print( 

280 f"\n [yellow]{succeeded} installed, " 

281 f"{failed} failed[/yellow]", 

282 ) 

283 # Report outdated/skipped before exiting 

284 if plan.outdated: 

285 console.print() 

286 console.print( 

287 f" [yellow]Outdated tools" 

288 f" ({len(plan.outdated)}):[/yellow]", 

289 ) 

290 for tool, current_ver in plan.outdated: 

291 console.print( 

292 f" {tool.name:<20}" 

293 f"[yellow]{current_ver}[/yellow]" 

294 f" → [green]{tool.version}[/green]", 

295 ) 

296 if plan.skipped: 

297 console.print() 

298 console.print( 

299 f" [yellow]Skipped tools" 

300 f" ({len(plan.skipped)}):[/yellow]", 

301 ) 

302 for tool, reason in plan.skipped: 

303 console.print( 

304 f" {tool.name:<20}[dim]{reason}[/dim]", 

305 ) 

306 sys.exit(1) 

307 else: 

308 console.print( 

309 "\n [yellow]Setup incomplete: tool installation " 

310 "declined.[/yellow]", 

311 ) 

312 if plan.outdated: 

313 console.print( 

314 f" [yellow]Outdated: {len(plan.outdated)} tool(s)" 

315 f" (use --upgrade to update)[/yellow]", 

316 ) 

317 if plan.skipped: 

318 console.print( 

319 f" [yellow]Skipped: {len(plan.skipped)} tool(s)" 

320 f" require manual installation[/yellow]", 

321 ) 

322 sys.exit(1) 

323 

324 # Always surface skipped/outdated regardless of install outcome 

325 if plan.outdated: 

326 console.print() 

327 console.print( 

328 f" [yellow]Outdated tools ({len(plan.outdated)}):[/yellow]", 

329 ) 

330 for tool, current_ver in plan.outdated: 

331 console.print( 

332 f" {tool.name:<20}[yellow]{current_ver}[/yellow]" 

333 f" → [green]{tool.version}[/green]", 

334 ) 

335 if plan.skipped: 

336 console.print() 

337 console.print( 

338 f" [yellow]Skipped tools ({len(plan.skipped)}):[/yellow]", 

339 ) 

340 for tool, reason in plan.skipped: 

341 console.print(f" {tool.name:<20}[dim]{reason}[/dim]") 

342 if plan.outdated: 

343 console.print() 

344 console.print( 

345 " [yellow]Run 'lintro install --upgrade' to update.[/yellow]", 

346 ) 

347 if plan.skipped: 

348 console.print() 

349 console.print( 

350 " [yellow]Some tools require manual installation " 

351 "(see details above).[/yellow]", 

352 ) 

353 if (plan.outdated or plan.skipped) and not dry_run: 

354 sys.exit(1) 

355 elif not plan.has_work and not dry_run: 

356 console.print( 

357 f" [green]All {profile} tools already installed.[/green]", 

358 ) 

359 

360 # ── Generate config ── 

361 config_file = Path(config_path) 

362 if ( 

363 config_file.exists() 

364 and not yes 

365 and not dry_run 

366 and not click.confirm( 

367 f"\n {config_path} already exists. Overwrite?", 

368 default=False, 

369 ) 

370 ): 

371 console.print(" [dim]Skipping config generation.[/dim]") 

372 _print_next_steps(console) 

373 return 

374 

375 config_content = _generate_config(tool_names, detected_langs) 

376 

377 if dry_run: 

378 console.print() 

379 console.print(f" [dim]Dry run: would write {config_path}[/dim]") 

380 else: 

381 try: 

382 config_file.parent.mkdir(parents=True, exist_ok=True) 

383 config_file.write_text(config_content, encoding="utf-8") 

384 except OSError as exc: 

385 raise click.ClickException( 

386 f"Failed to write {config_path}: {exc}", 

387 ) from exc 

388 console.print() 

389 console.print( 

390 f" [green]Generated {config_path} " 

391 f"with {len(tool_names)} tools enabled.[/green]", 

392 ) 

393 

394 _print_next_steps(console) 

395 

396 

397def _print_next_steps(console: Console) -> None: 

398 """Print post-setup guidance.""" 

399 console.print() 

400 console.print(" [bold]Next steps:[/bold]") 

401 console.print(" lintro check . [dim]Check all files[/dim]") 

402 console.print(" lintro fmt . [dim]Auto-fix formatting[/dim]") 

403 console.print(" lintro doctor [dim]Verify tool health[/dim]") 

404 console.print()