Coverage for lintro / tools / implementations / pytest / pytest_command_builder.py: 69%

131 statements  

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

1"""Command building functions for pytest tool. 

2 

3This module contains command building logic extracted from PytestTool to improve 

4maintainability and reduce file size. Functions are organized by command section. 

5""" 

6 

7import os 

8from typing import TYPE_CHECKING, Any 

9 

10from loguru import logger 

11 

12from lintro.tools.implementations.pytest.collection import ( 

13 get_parallel_workers_from_preset, 

14) 

15from lintro.tools.implementations.pytest.markers import check_plugin_installed 

16 

17if TYPE_CHECKING: 

18 from lintro.tools.definitions.pytest import PytestPlugin 

19 

20# Constants for pytest configuration 

21PYTEST_TEST_MODE_ENV: str = "LINTRO_TEST_MODE" 

22PYTEST_TEST_MODE_VALUE: str = "1" 

23 

24 

25def build_base_command(tool: "PytestPlugin") -> list[str]: 

26 """Build the base pytest command. 

27 

28 Args: 

29 tool: PytestPlugin instance. 

30 

31 Returns: 

32 list[str]: Base command list starting with pytest executable. 

33 """ 

34 return tool._get_executable_command(tool_name="pytest") 

35 

36 

37def add_verbosity_options(cmd: list[str], options: dict[str, Any]) -> None: 

38 """Add verbosity and traceback options to command. 

39 

40 Args: 

41 cmd: Command list to modify. 

42 options: Options dictionary. 

43 """ 

44 # Add verbosity - ensure it's enabled if show_progress is True 

45 show_progress = options.get("show_progress", True) 

46 verbose = options.get("verbose", show_progress) # Default to show_progress value 

47 if verbose or show_progress: 

48 cmd.append("-v") 

49 

50 # Add traceback format 

51 tb_format = options.get("tb", "short") 

52 cmd.extend(["--tb", tb_format]) 

53 

54 # Add maxfail only if specified 

55 # Note: We default to None to avoid stopping early and run all tests 

56 maxfail = options.get("maxfail") 

57 if maxfail is not None: 

58 cmd.extend(["--maxfail", str(maxfail)]) 

59 

60 # Add no-header 

61 if options.get("no_header", True): 

62 cmd.append("--no-header") 

63 

64 # Add disable-warnings 

65 if options.get("disable_warnings", True): 

66 cmd.append("--disable-warnings") 

67 

68 

69def add_output_options(cmd: list[str], options: dict[str, Any]) -> str | None: 

70 """Add output format options (JSON, JUnit XML, HTML) to command. 

71 

72 Args: 

73 cmd: Command list to modify. 

74 options: Options dictionary. 

75 

76 Returns: 

77 str | None: The junitxml path if auto-enabled, None otherwise. 

78 """ 

79 # Add output format options 

80 if options.get("json_report", False): 

81 cmd.append("--json-report") 

82 cmd.append("--json-report-file=pytest-report.json") 

83 

84 # Track if junitxml was explicitly provided 

85 junitxml_explicit = "junitxml" in options 

86 junitxml_value = options.get("junitxml") 

87 auto_junitxml_path: str | None = None 

88 

89 if junitxml_value: 

90 # User provided a truthy value, use it 

91 cmd.extend(["--junitxml", junitxml_value]) 

92 else: 

93 # Auto-enable junitxml to capture all test results including skipped tests 

94 # Only if user didn't explicitly disable it 

95 # (junitxml_explicit True but falsy value) 

96 auto_junitxml = options.get("auto_junitxml", True) 

97 if not junitxml_explicit and auto_junitxml: 

98 cmd.extend(["--junitxml", "report.xml"]) 

99 auto_junitxml_path = "report.xml" 

100 logger.debug("Auto-enabled junitxml=report.xml to capture skipped tests") 

101 

102 # Add pytest-html HTML report if specified 

103 html_report = options.get("html_report") 

104 if html_report: 

105 cmd.extend(["--html", html_report]) 

106 logger.debug(f"HTML report enabled: {html_report}") 

107 

108 return auto_junitxml_path 

109 

110 

111def add_parallel_options(cmd: list[str], options: dict[str, Any]) -> None: 

112 """Add parallel execution options to command. 

113 

114 Args: 

115 cmd: Command list to modify. 

116 options: Options dictionary. 

117 """ 

118 # Add pytest-xdist parallel execution 

119 # Priority: parallel_preset > workers > default (auto) 

120 workers = options.get("workers") 

121 parallel_preset = options.get("parallel_preset") 

122 if parallel_preset: 

123 # Convert preset to worker count 

124 workers = get_parallel_workers_from_preset(parallel_preset) 

125 logger.debug( 

126 f"Using parallel preset '{parallel_preset}' -> workers={workers}", 

127 ) 

128 # Default to auto if not explicitly disabled (workers=0 or workers="0") 

129 if workers is None: 

130 workers = "auto" 

131 if workers and str(workers) != "0": 

132 cmd.extend(["-n", str(workers)]) 

133 

134 

135def add_coverage_options(cmd: list[str], options: dict[str, Any]) -> None: 

136 """Add coverage options to command. 

137 

138 Args: 

139 cmd: Command list to modify. 

140 options: Options dictionary. 

141 """ 

142 # Add coverage threshold if specified 

143 coverage_threshold = options.get("coverage_threshold") 

144 if coverage_threshold is not None: 

145 cmd.extend(["--cov-fail-under", str(coverage_threshold)]) 

146 

147 # Add coverage report options (requires pytest-cov) 

148 coverage_html = options.get("coverage_html") 

149 coverage_xml = options.get("coverage_xml") 

150 coverage_report = options.get("coverage_report", False) 

151 coverage_term_missing = options.get("coverage_term_missing", False) 

152 

153 # If coverage_report is True, generate both HTML and XML 

154 if coverage_report: 

155 if not coverage_html: 

156 coverage_html = "htmlcov" 

157 if not coverage_xml: 

158 coverage_xml = "coverage.xml" 

159 

160 # Add coverage collection if any coverage options are specified 

161 needs_coverage = ( 

162 coverage_html or coverage_xml or coverage_term_missing or coverage_threshold 

163 ) 

164 if needs_coverage: 

165 # Add --cov flag to enable coverage collection 

166 # Default to current directory, but can be overridden 

167 cmd.append("--cov=.") 

168 

169 # Add coverage HTML report 

170 if coverage_html: 

171 # pytest-cov uses --cov-report=html or --cov-report=html:dir 

172 # Only use default --cov-report=html for exact "htmlcov" match 

173 # Custom paths ending in "htmlcov" should use the custom directory format 

174 if coverage_html == "htmlcov": 

175 cmd.append("--cov-report=html") 

176 else: 

177 # Custom directory (remove trailing /index.html if present) 

178 html_dir = coverage_html.replace( 

179 "/index.html", 

180 "", 

181 ).replace("index.html", "") 

182 if html_dir: 

183 cmd.extend(["--cov-report", f"html:{html_dir}"]) 

184 else: 

185 cmd.append("--cov-report=html") 

186 logger.debug(f"Coverage HTML report enabled: {coverage_html}") 

187 

188 # Add coverage XML report 

189 if coverage_xml: 

190 # pytest-cov uses --cov-report=xml or --cov-report=xml:file 

191 # (without .xml extension) 

192 if coverage_xml == "coverage.xml": 

193 cmd.append("--cov-report=xml") 

194 else: 

195 # Custom file path (remove .xml extension for the flag) 

196 xml_file = ( 

197 coverage_xml.replace(".xml", "") 

198 if coverage_xml.endswith(".xml") 

199 else coverage_xml 

200 ) 

201 if xml_file: 

202 cmd.extend(["--cov-report", f"xml:{xml_file}"]) 

203 else: 

204 cmd.append("--cov-report=xml") 

205 logger.debug(f"Coverage XML report enabled: {coverage_xml}") 

206 

207 # Add terminal coverage report with missing lines 

208 if coverage_term_missing: 

209 cmd.append("--cov-report=term-missing") 

210 logger.debug("Coverage terminal report with missing lines enabled") 

211 

212 

213def add_test_mode_options(cmd: list[str]) -> None: 

214 """Add test mode isolation options to command. 

215 

216 Args: 

217 cmd: Command list to modify. 

218 """ 

219 # Add test mode isolation if in test mode 

220 if os.environ.get(PYTEST_TEST_MODE_ENV) == PYTEST_TEST_MODE_VALUE: 

221 cmd.append("--strict-markers") 

222 cmd.append("--strict-config") 

223 

224 

225def add_plugin_options(cmd: list[str], options: dict[str, Any]) -> None: 

226 """Add plugin-specific options to command. 

227 

228 Args: 

229 cmd: Command list to modify. 

230 options: Options dictionary. 

231 """ 

232 # Add pytest-timeout options if timeout is specified 

233 # Only add timeout arguments if pytest-timeout plugin is installed 

234 timeout = options.get("timeout") 

235 if timeout is not None: 

236 if check_plugin_installed("pytest-timeout"): 

237 cmd.extend(["--timeout", str(timeout)]) 

238 # Default timeout method to 'signal' if not specified 

239 timeout_method = options.get("timeout_method", "signal") 

240 cmd.extend(["--timeout-method", timeout_method]) 

241 logger.debug(f"Timeout enabled: {timeout}s (method: {timeout_method})") 

242 else: 

243 logger.warning( 

244 "pytest-timeout plugin not installed; timeout option ignored. " 

245 "Install with: pip install pytest-timeout", 

246 ) 

247 

248 # Add pytest-rerunfailures options 

249 reruns = options.get("reruns") 

250 if reruns is not None and reruns > 0: 

251 cmd.extend(["--reruns", str(reruns)]) 

252 

253 reruns_delay = options.get("reruns_delay") 

254 if reruns_delay is not None and reruns_delay > 0: 

255 cmd.extend(["--reruns-delay", str(reruns_delay)]) 

256 logger.debug(f"Reruns enabled: {reruns} times with {reruns_delay}s delay") 

257 else: 

258 logger.debug(f"Reruns enabled: {reruns} times") 

259 

260 

261def add_ignore_options(cmd: list[str], tool: "PytestPlugin") -> None: 

262 """Add ignore options to command for exclude patterns. 

263 

264 Args: 

265 cmd: Command list to modify. 

266 tool: PytestPlugin instance. 

267 """ 

268 # Glob characters that pytest --ignore doesn't support 

269 # These patterns should be skipped as they can't be used with --ignore 

270 glob_chars = frozenset({"*", "?", "[", "]"}) 

271 

272 # Add --ignore flags for each exclude pattern 

273 for pattern in tool.exclude_patterns: 

274 # Skip patterns containing glob characters - pytest --ignore only works 

275 # with exact directory/file paths, not glob patterns 

276 if any(char in pattern for char in glob_chars): 

277 continue 

278 

279 # pytest --ignore expects directory paths, not glob patterns 

280 # Convert glob patterns to directory paths where possible 

281 if pattern.endswith("/*"): 

282 # Remove /* from the end to get directory path 

283 ignore_path = pattern[:-2] 

284 cmd.extend(["--ignore", ignore_path]) 

285 elif pattern.endswith("/"): 

286 # Pattern already ends with /, remove it 

287 ignore_path = pattern[:-1] 

288 cmd.extend(["--ignore", ignore_path]) 

289 else: 

290 # For other patterns, try to use them as-is 

291 # pytest --ignore works with directory names 

292 cmd.extend(["--ignore", pattern]) 

293 

294 

295def build_check_command( 

296 tool: "PytestPlugin", 

297 files: list[str], 

298 fix: bool = False, 

299) -> tuple[list[str], str | None]: 

300 """Build the pytest command. 

301 

302 Args: 

303 tool: PytestPlugin instance. 

304 files: list[str]: List of files to test. 

305 fix: bool: Ignored for pytest (not applicable). 

306 

307 Returns: 

308 tuple[list[str], str | None]: Tuple of (command arguments, auto junitxml path). 

309 """ 

310 cmd = build_base_command(tool) 

311 

312 # Add verbosity options 

313 add_verbosity_options(cmd, tool.options) 

314 

315 # Add output options and capture auto-enabled junitxml path 

316 auto_junitxml_path = add_output_options(cmd, tool.options) 

317 

318 # Add parallel options 

319 add_parallel_options(cmd, tool.options) 

320 

321 # Add coverage options 

322 add_coverage_options(cmd, tool.options) 

323 

324 # Add plugin options (timeout, reruns, etc.) 

325 add_plugin_options(cmd, tool.options) 

326 

327 # Add test mode options 

328 add_test_mode_options(cmd) 

329 

330 # Add ignore options for exclude patterns 

331 add_ignore_options(cmd, tool) 

332 

333 # Add files 

334 cmd.extend(files) 

335 

336 return cmd, auto_junitxml_path