Coverage for lintro / cli_utils / commands / install.py: 84%

100 statements  

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

1"""Install command for managing lintro tool dependencies. 

2 

3Installs, upgrades, and manages the external tools that lintro uses for 

4linting, formatting, and code quality checks. 

5 

6Usage: 

7 lintro install # Install all missing tools 

8 lintro install ruff prettier # Install specific tools 

9 lintro install --profile minimal # Install a profile's tools 

10 lintro install --upgrade # Upgrade to manifest versions 

11 lintro install --dry-run # Show plan without executing 

12""" 

13 

14from __future__ import annotations 

15 

16import click 

17from rich.console import Console 

18 

19from lintro.tools.core.install_context import RuntimeContext 

20from lintro.tools.core.tool_installer import ToolInstaller 

21from lintro.tools.core.tool_registry import ToolRegistry 

22 

23 

24@click.command() 

25@click.argument("tools", nargs=-1) 

26@click.option( 

27 "--profile", 

28 type=str, 

29 help="Install tools for a named profile (minimal, recommended, complete, ci).", 

30) 

31@click.option( 

32 "--upgrade", 

33 is_flag=True, 

34 help="Upgrade already-installed tools to manifest versions.", 

35) 

36@click.option( 

37 "--dry-run", 

38 is_flag=True, 

39 help="Show what would be installed without executing.", 

40) 

41@click.option( 

42 "--all", 

43 "install_all", 

44 is_flag=True, 

45 help="Install all supported tools.", 

46) 

47def install_command( 

48 tools: tuple[str, ...], 

49 profile: str | None, 

50 *, 

51 upgrade: bool, 

52 dry_run: bool, 

53 install_all: bool, 

54) -> None: 

55 """Install or upgrade external tools used by lintro. 

56 

57 Without arguments, installs all missing tools for the current project. 

58 Specify tool names to install specific tools, or use --profile for 

59 predefined sets. 

60 

61 Args: 

62 tools: Tool names to install (positional args). 

63 profile: Named profile to install. 

64 upgrade: Upgrade existing tools. 

65 dry_run: Show plan only. 

66 install_all: Install all tools. 

67 

68 Raises: 

69 SystemExit: When tool installation fails. 

70 click.UsageError: When conflicting options or invalid profile given. 

71 

72 Examples: 

73 lintro install 

74 lintro install ruff prettier hadolint 

75 lintro install --profile minimal 

76 lintro install --upgrade 

77 lintro install --dry-run 

78 """ 

79 console = Console() 

80 

81 registry = ToolRegistry.load() 

82 context = RuntimeContext.detect() 

83 installer = ToolInstaller(registry, context) 

84 

85 # Determine tool list — reject conflicting selectors 

86 tool_list: list[str] | None = list(tools) if tools else None 

87 effective_profile = profile 

88 selectors = sum(bool(x) for x in (tool_list, install_all, profile)) 

89 if selectors > 1: 

90 raise click.UsageError( 

91 "Cannot combine tool names, --profile, and --all; supply exactly one", 

92 ) 

93 if install_all: 

94 effective_profile = "complete" 

95 

96 # Validate tool names against registry 

97 if tool_list: 

98 unknown = [n for n in tool_list if n not in registry] 

99 if unknown: 

100 available = ", ".join( 

101 sorted(t.name for t in registry.all_tools(include_dev=True)), 

102 ) 

103 raise click.UsageError( 

104 f"Unknown tools: {', '.join(unknown)}. " f"Available: {available}", 

105 ) 

106 

107 # Validate profile name 

108 if effective_profile and effective_profile not in registry.profile_names: 

109 raise click.UsageError( 

110 f"Unknown profile {effective_profile!r}. " 

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

112 ) 

113 

114 # Detect project languages for auto-detect / recommended profile 

115 detected_langs: list[str] | None = None 

116 if not tool_list: 

117 if not effective_profile: 

118 effective_profile = "recommended" 

119 if effective_profile == "recommended": 

120 detected_langs = _detect_languages() 

121 

122 # Create plan 

123 plan = installer.plan( 

124 tools=tool_list, 

125 profile=effective_profile, 

126 upgrade=upgrade, 

127 detected_langs=detected_langs, 

128 ) 

129 

130 # Display plan 

131 console.print() 

132 if plan.to_install: 

133 console.print(f" [bold]To install ({len(plan.to_install)}):[/bold]") 

134 for tool, cmd in plan.to_install: 

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

136 

137 if plan.to_upgrade: 

138 console.print(f" [bold]To upgrade ({len(plan.to_upgrade)}):[/bold]") 

139 for tool, current, cmd in plan.to_upgrade: 

140 console.print( 

141 f" {tool.name:<20} [yellow]{current}[/yellow] → " 

142 f"[green]{tool.version}[/green] [dim]{cmd}[/dim]", 

143 ) 

144 

145 if plan.already_ok: 

146 console.print( 

147 f" [dim]Already installed: {len(plan.already_ok)} tools[/dim]", 

148 ) 

149 

150 if plan.skipped: 

151 console.print(f" [yellow]Skipped ({len(plan.skipped)}):[/yellow]") 

152 for tool, reason in plan.skipped: 

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

154 

155 if not plan.has_work: 

156 if plan.outdated: 

157 console.print( 

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

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

160 ) 

161 if not plan.outdated and not plan.skipped: 

162 console.print(" [green]All tools are already installed.[/green]") 

163 console.print() 

164 if plan.skipped or plan.outdated: 

165 raise SystemExit(1) 

166 return 

167 

168 if dry_run: 

169 console.print() 

170 console.print( 

171 f" [dim]Dry run: {plan.total_actions} tool(s) would be installed.[/dim]", 

172 ) 

173 if plan.outdated: 

174 console.print( 

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

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

177 ) 

178 console.print() 

179 if plan.skipped or plan.outdated: 

180 raise SystemExit(1) 

181 return 

182 

183 # Execute 

184 console.print() 

185 results = installer.execute(plan) 

186 

187 # Report results 

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

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

190 

191 for r in results: 

192 if r.success: 

193 console.print( 

194 f" [green]OK[/green] {r.tool.name} " 

195 f"[dim]({r.duration_seconds:.1f}s)[/dim]", 

196 ) 

197 else: 

198 console.print(f" [red]FAIL[/red] {r.tool.name}: {r.message}") 

199 

200 console.print() 

201 has_issues = failed > 0 or plan.skipped or plan.outdated 

202 

203 if failed > 0: 

204 console.print( 

205 f" [yellow]{succeeded} installed, {failed} failed[/yellow]", 

206 ) 

207 elif has_issues: 

208 console.print(f" [green]{succeeded} tools installed.[/green]") 

209 else: 

210 console.print(f" [green]All {succeeded} tools installed.[/green]") 

211 

212 if plan.outdated: 

213 console.print( 

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

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

216 ) 

217 

218 console.print() 

219 if has_issues: 

220 raise SystemExit(1) 

221 

222 

223def _detect_languages() -> list[str]: 

224 """Detect project languages for profile resolution. 

225 

226 Uses the full-ecosystem detector to cover Docker, YAML, Markdown, 

227 TOML, Shell, SQL, GitHub Actions, Astro, Svelte, Vue, etc. 

228 

229 Returns: 

230 List of detected language identifiers. 

231 """ 

232 try: 

233 from lintro.utils.project_detection import detect_project_languages 

234 except ImportError: 

235 return [] 

236 try: 

237 return detect_project_languages() 

238 except OSError: 

239 return []