Coverage for lintro / cli_utils / commands / test.py: 95%

95 statements  

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

1"""Test command implementation for running pytest tests.""" 

2 

3from typing import Any, cast 

4 

5import click 

6from click.testing import CliRunner 

7 

8from lintro.utils.tool_executor import run_lint_tools_simple 

9 

10# Constants 

11DEFAULT_PATHS: list[str] = ["."] 

12DEFAULT_EXIT_CODE: int = 0 

13DEFAULT_ACTION: str = "test" 

14 

15 

16def _ensure_pytest_prefix(option_fragment: str) -> str: 

17 """Normalize tool option fragments to use the pytest prefix. 

18 

19 Args: 

20 option_fragment: Raw option fragment from --tool-options. 

21 

22 Returns: 

23 str: Fragment guaranteed to start with ``pytest:``. 

24 """ 

25 fragment = option_fragment.strip() 

26 if not fragment: 

27 return fragment 

28 

29 lowered = fragment.lower() 

30 if lowered.startswith("pytest:"): 

31 _, rest = fragment.split(":", 1) 

32 return f"pytest:{rest}" 

33 return f"pytest:{fragment}" 

34 

35 

36@click.command("test") 

37@click.argument("paths", nargs=-1, type=click.Path(exists=True)) 

38@click.option( 

39 "--exclude", 

40 type=str, 

41 help="Comma-separated list of patterns to exclude from testing", 

42) 

43@click.option( 

44 "--include-venv", 

45 is_flag=True, 

46 help="Include virtual environment directories in testing", 

47) 

48@click.option( 

49 "--output", 

50 type=click.Path(), 

51 help="Output file path for writing results", 

52) 

53@click.option( 

54 "--output-format", 

55 type=click.Choice(["plain", "grid", "markdown", "html", "json", "csv", "github"]), 

56 default="grid", 

57 help="Output format for displaying results", 

58) 

59@click.option( 

60 "--group-by", 

61 type=click.Choice(["file", "code", "none", "auto"]), 

62 default="file", 

63 help="How to group issues in the output", 

64) 

65@click.option( 

66 "--verbose", 

67 "-v", 

68 is_flag=True, 

69 help="Show verbose output", 

70) 

71@click.option( 

72 "--raw-output", 

73 is_flag=True, 

74 help="Show raw tool output instead of formatted output", 

75) 

76@click.option( 

77 "--tool-options", 

78 type=str, 

79 help="Tool-specific options in the format option=value,option=value", 

80) 

81@click.option( 

82 "--list-plugins", 

83 is_flag=True, 

84 default=False, 

85 help="List all installed pytest plugins", 

86) 

87@click.option( 

88 "--check-plugins", 

89 is_flag=True, 

90 default=False, 

91 help=( 

92 "Check if required plugins are installed " 

93 "(use with --tool-options pytest:required_plugins=plugin1,plugin2)" 

94 ), 

95) 

96@click.option( 

97 "--collect-only", 

98 is_flag=True, 

99 default=False, 

100 help="List tests without executing them", 

101) 

102@click.option( 

103 "--fixtures", 

104 is_flag=True, 

105 default=False, 

106 help="List all available fixtures", 

107) 

108@click.option( 

109 "--fixture-info", 

110 type=str, 

111 default=None, 

112 help="Show detailed information about a specific fixture", 

113) 

114@click.option( 

115 "--markers", 

116 is_flag=True, 

117 default=False, 

118 help="List all available markers", 

119) 

120@click.option( 

121 "--parametrize-help", 

122 is_flag=True, 

123 default=False, 

124 help="Show help for parametrized tests", 

125) 

126@click.option( 

127 "--coverage", 

128 is_flag=True, 

129 default=False, 

130 help="Generate test coverage report with missing lines shown in terminal", 

131) 

132@click.option( 

133 "--debug", 

134 is_flag=True, 

135 help="Enable debug output on console", 

136) 

137@click.option( 

138 "--yes", 

139 "-y", 

140 is_flag=True, 

141 help="Skip confirmation prompt and proceed immediately", 

142) 

143def test_command( 

144 paths: tuple[str, ...], 

145 exclude: str | None, 

146 include_venv: bool, 

147 output: str | None, 

148 output_format: str, 

149 group_by: str, 

150 verbose: bool, 

151 raw_output: bool, 

152 tool_options: str | None, 

153 list_plugins: bool, 

154 check_plugins: bool, 

155 collect_only: bool, 

156 fixtures: bool, 

157 fixture_info: str | None, 

158 markers: bool, 

159 parametrize_help: bool, 

160 coverage: bool, 

161 debug: bool, 

162 yes: bool, 

163) -> None: 

164 """Run tests using pytest. 

165 

166 This CLI command wraps pytest with lintro's output formatting. 

167 

168 Args: 

169 paths: Paths to test files or directories. 

170 exclude: Pattern to exclude paths. 

171 include_venv: Whether to include virtual environment directories. 

172 output: Output file path. 

173 output_format: Output format for displaying results. 

174 group_by: How to group issues in the output. 

175 verbose: Show verbose output. 

176 raw_output: Show raw tool output instead of formatted output. 

177 tool_options: Tool-specific options in the format option=value. 

178 list_plugins: List all installed pytest plugins. 

179 check_plugins: Check if required plugins are installed. 

180 collect_only: List tests without executing them. 

181 fixtures: List all available fixtures. 

182 fixture_info: Show detailed information about a specific fixture. 

183 markers: List all available markers. 

184 parametrize_help: Show help for parametrized tests. 

185 coverage: Generate test coverage report with missing lines. 

186 debug: Enable debug output on console. 

187 yes: Skip confirmation prompt and proceed immediately. 

188 

189 Raises: 

190 SystemExit: Process exit with the aggregated exit code. 

191 """ 

192 # Add default paths if none provided 

193 path_list: list[str] = list(paths) if paths else list(DEFAULT_PATHS) 

194 

195 # Build tool options with pytest prefix 

196 tool_option_parts: list[str] = [] 

197 

198 # Add special mode flags 

199 boolean_flags: list[tuple[bool, str]] = [ 

200 (list_plugins, "pytest:list_plugins=True"), 

201 (check_plugins, "pytest:check_plugins=True"), 

202 (collect_only, "pytest:collect_only=True"), 

203 (fixtures, "pytest:list_fixtures=True"), 

204 (markers, "pytest:list_markers=True"), 

205 (parametrize_help, "pytest:parametrize_help=True"), 

206 (coverage, "pytest:coverage_term_missing=True"), 

207 ] 

208 

209 for flag_value, option_string in boolean_flags: 

210 if flag_value: 

211 tool_option_parts.append(option_string) 

212 

213 # Handle fixture_info as special case (requires non-empty value) 

214 if fixture_info: 

215 tool_option_parts.append(f"pytest:fixture_info={fixture_info}") 

216 

217 if tool_options: 

218 # Prefix with "pytest:" for pytest tool 

219 # Parse options carefully to handle values containing commas 

220 # Format: key=value,key=value where values can contain commas 

221 prefixed_options: list[str] = [] 

222 parts = tool_options.split(",") 

223 i = 0 

224 

225 while i < len(parts): 

226 current_part = parts[i].strip() 

227 if not current_part: 

228 i += 1 

229 continue 

230 

231 # Check if this part looks like a complete option (contains =) 

232 # or starts with pytest prefix (already namespaced) 

233 if "=" in current_part or current_part.lower().startswith("pytest:"): 

234 normalized_part = _ensure_pytest_prefix(current_part) 

235 prefixed_options.append(normalized_part) 

236 i += 1 

237 else: 

238 # This part doesn't have =, might be a value continuation 

239 # Merge with previous part if it exists and had an = 

240 if prefixed_options and "=" in prefixed_options[-1]: 

241 # Merge with previous option's value 

242 prefixed_options[-1] = f"{prefixed_options[-1]},{current_part}" 

243 else: 

244 # Standalone option without =, prefix it 

245 prefixed_options.append(_ensure_pytest_prefix(current_part)) 

246 i += 1 

247 

248 tool_option_parts.append(",".join(prefixed_options)) 

249 

250 combined_tool_options: str | None = ( 

251 ",".join(tool_option_parts) if tool_option_parts else None 

252 ) 

253 

254 # Run with pytest tool 

255 exit_code: int = run_lint_tools_simple( 

256 action=DEFAULT_ACTION, 

257 paths=path_list, 

258 tools="pytest", 

259 tool_options=combined_tool_options, 

260 exclude=exclude, 

261 include_venv=include_venv, 

262 group_by=group_by, 

263 output_format=output_format, 

264 verbose=verbose, 

265 raw_output=raw_output, 

266 output_file=output, 

267 debug=debug, 

268 yes=yes, 

269 ) 

270 

271 # Exit with code only 

272 raise SystemExit(exit_code) 

273 

274 

275# Exclude from pytest collection - this is a Click command, not a test function 

276cast(Any, test_command).__test__ = False 

277 

278 

279def test( 

280 paths: tuple[str, ...], 

281 exclude: str | None, 

282 include_venv: bool, 

283 output: str | None, 

284 output_format: str, 

285 group_by: str, 

286 verbose: bool, 

287 raw_output: bool = False, 

288 tool_options: str | None = None, 

289 yes: bool = False, 

290) -> None: 

291 """Programmatic test function for backward compatibility. 

292 

293 Args: 

294 paths: tuple: List of file/directory paths to test. 

295 exclude: str | None: Comma-separated patterns of files/dirs to exclude. 

296 include_venv: bool: Whether to include virtual environment directories. 

297 output: str | None: Path to output file for results. 

298 output_format: str: Format for displaying results. 

299 group_by: str: How to group issues in output. 

300 verbose: bool: Whether to show verbose output during execution. 

301 raw_output: bool: Whether to show raw tool output instead of formatted output. 

302 tool_options: str | None: Tool-specific options. 

303 yes: bool: Skip confirmation prompt and proceed immediately. 

304 

305 Returns: 

306 None: This function does not return a value. 

307 """ 

308 # Build arguments for the click command 

309 args: list[str] = [] 

310 if paths: 

311 args.extend(list(paths)) 

312 if exclude: 

313 args.extend(["--exclude", exclude]) 

314 if include_venv: 

315 args.append("--include-venv") 

316 if output: 

317 args.extend(["--output", output]) 

318 if output_format: 

319 args.extend(["--output-format", output_format]) 

320 if group_by: 

321 args.extend(["--group-by", group_by]) 

322 if verbose: 

323 args.append("--verbose") 

324 if raw_output: 

325 args.append("--raw-output") 

326 if tool_options: 

327 args.extend(["--tool-options", tool_options]) 

328 if yes: 

329 args.append("--yes") 

330 

331 runner = CliRunner() 

332 result = runner.invoke(test_command, args) 

333 

334 if result.exit_code != DEFAULT_EXIT_CODE: 

335 import sys 

336 

337 sys.exit(result.exit_code) 

338 return None