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
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""Command building functions for pytest tool.
3This module contains command building logic extracted from PytestTool to improve
4maintainability and reduce file size. Functions are organized by command section.
5"""
7import os
8from typing import TYPE_CHECKING, Any
10from loguru import logger
12from lintro.tools.implementations.pytest.collection import (
13 get_parallel_workers_from_preset,
14)
15from lintro.tools.implementations.pytest.markers import check_plugin_installed
17if TYPE_CHECKING:
18 from lintro.tools.definitions.pytest import PytestPlugin
20# Constants for pytest configuration
21PYTEST_TEST_MODE_ENV: str = "LINTRO_TEST_MODE"
22PYTEST_TEST_MODE_VALUE: str = "1"
25def build_base_command(tool: "PytestPlugin") -> list[str]:
26 """Build the base pytest command.
28 Args:
29 tool: PytestPlugin instance.
31 Returns:
32 list[str]: Base command list starting with pytest executable.
33 """
34 return tool._get_executable_command(tool_name="pytest")
37def add_verbosity_options(cmd: list[str], options: dict[str, Any]) -> None:
38 """Add verbosity and traceback options to command.
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")
50 # Add traceback format
51 tb_format = options.get("tb", "short")
52 cmd.extend(["--tb", tb_format])
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)])
60 # Add no-header
61 if options.get("no_header", True):
62 cmd.append("--no-header")
64 # Add disable-warnings
65 if options.get("disable_warnings", True):
66 cmd.append("--disable-warnings")
69def add_output_options(cmd: list[str], options: dict[str, Any]) -> str | None:
70 """Add output format options (JSON, JUnit XML, HTML) to command.
72 Args:
73 cmd: Command list to modify.
74 options: Options dictionary.
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")
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
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")
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}")
108 return auto_junitxml_path
111def add_parallel_options(cmd: list[str], options: dict[str, Any]) -> None:
112 """Add parallel execution options to command.
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)])
135def add_coverage_options(cmd: list[str], options: dict[str, Any]) -> None:
136 """Add coverage options to command.
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)])
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)
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"
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=.")
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}")
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}")
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")
213def add_test_mode_options(cmd: list[str]) -> None:
214 """Add test mode isolation options to command.
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")
225def add_plugin_options(cmd: list[str], options: dict[str, Any]) -> None:
226 """Add plugin-specific options to command.
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 )
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)])
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")
261def add_ignore_options(cmd: list[str], tool: "PytestPlugin") -> None:
262 """Add ignore options to command for exclude patterns.
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({"*", "?", "[", "]"})
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
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])
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.
302 Args:
303 tool: PytestPlugin instance.
304 files: list[str]: List of files to test.
305 fix: bool: Ignored for pytest (not applicable).
307 Returns:
308 tuple[list[str], str | None]: Tuple of (command arguments, auto junitxml path).
309 """
310 cmd = build_base_command(tool)
312 # Add verbosity options
313 add_verbosity_options(cmd, tool.options)
315 # Add output options and capture auto-enabled junitxml path
316 auto_junitxml_path = add_output_options(cmd, tool.options)
318 # Add parallel options
319 add_parallel_options(cmd, tool.options)
321 # Add coverage options
322 add_coverage_options(cmd, tool.options)
324 # Add plugin options (timeout, reruns, etc.)
325 add_plugin_options(cmd, tool.options)
327 # Add test mode options
328 add_test_mode_options(cmd)
330 # Add ignore options for exclude patterns
331 add_ignore_options(cmd, tool)
333 # Add files
334 cmd.extend(files)
336 return cmd, auto_junitxml_path