Coverage for lintro / tools / implementations / pytest / markers.py: 22%
64 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"""Pytest marker and plugin utility functions."""
3from __future__ import annotations
5import subprocess # nosec B404 - subprocess used safely with shell=False
6from typing import TYPE_CHECKING
8from loguru import logger
10from lintro.parsers.base_parser import strip_ansi_codes
12if TYPE_CHECKING:
13 from lintro.tools.definitions.pytest import PytestPlugin
16def check_plugin_installed(plugin_name: str) -> bool:
17 """Check if a pytest plugin is installed.
19 Checks for the plugin using importlib.metadata, trying both the exact name
20 and an alternative name with hyphens replaced by underscores (e.g., "pytest-cov"
21 and "pytest_cov").
23 Args:
24 plugin_name: Name of the plugin to check (e.g., 'pytest-cov', 'pytest-xdist').
26 Returns:
27 bool: True if plugin is installed (found under either name), False otherwise.
29 Examples:
30 >>> check_plugin_installed("pytest-cov")
31 True # if pytest-cov is installed
32 >>> check_plugin_installed("pytest-nonexistent")
33 False
34 """
35 import importlib.metadata
37 # Try to find the plugin package
38 try:
39 importlib.metadata.distribution(plugin_name)
40 return True
41 except importlib.metadata.PackageNotFoundError:
42 # Try alternative names (e.g., pytest-cov -> pytest_cov)
43 alt_name = plugin_name.replace("-", "_")
44 try:
45 importlib.metadata.distribution(alt_name)
46 return True
47 except importlib.metadata.PackageNotFoundError:
48 return False
51def list_installed_plugins() -> list[dict[str, str]]:
52 """List all installed pytest plugins.
54 Scans all installed Python packages and filters for those whose names start
55 with "pytest-" or "pytest_". Returns plugin information including name and version.
57 Returns:
58 list[dict[str, str]]: List of plugin information dictionaries, each containing:
59 - 'name': Plugin package name (e.g., "pytest-cov")
60 - 'version': Plugin version string (e.g., "4.1.0")
61 List is sorted alphabetically by plugin name.
63 Examples:
64 >>> plugins = list_installed_plugins()
65 >>> [p['name'] for p in plugins if 'cov' in p['name']]
66 ['pytest-cov']
67 """
68 plugins: list[dict[str, str]] = []
70 import importlib.metadata
72 # Get all installed packages
73 distributions = importlib.metadata.distributions()
75 # Filter for pytest plugins
76 for dist in distributions:
77 dist_name = dist.metadata["Name"] or ""
78 if dist_name.startswith("pytest-") or dist_name.startswith("pytest_"):
79 version = dist.metadata["Version"] or "unknown"
80 plugins.append({"name": dist_name, "version": version})
82 # Sort by name
83 plugins.sort(key=lambda x: x["name"])
84 return plugins
87def get_pytest_version_info() -> str:
88 """Get pytest version and plugin information.
90 Executes `pytest --version` to retrieve version information. Handles errors
91 gracefully by returning a fallback message if the command fails.
93 Returns:
94 str: Formatted string with pytest version information from stdout.
95 Returns "pytest version information unavailable" if the command
96 fails or times out.
98 Examples:
99 >>> version_info = get_pytest_version_info()
100 >>> "pytest" in version_info.lower()
101 True
102 """
103 try:
104 cmd = ["pytest", "--version"]
105 result = subprocess.run( # nosec B603 - pytest is a trusted executable
106 cmd,
107 capture_output=True,
108 text=True,
109 timeout=10,
110 check=False,
111 )
112 return result.stdout.strip()
113 except (OSError, subprocess.SubprocessError) as e:
114 logger.debug(f"Failed to get pytest version: {e}")
115 return "pytest version information unavailable"
118def collect_tests_once(
119 tool: PytestPlugin,
120 target_files: list[str],
121) -> int:
122 """Collect tests and return total count.
124 This function runs pytest --collect-only to count all available tests.
126 Args:
127 tool: PytestTool instance with _get_executable_command and _run_subprocess
128 methods. Must support running pytest commands.
129 target_files: List of file paths or directory paths to check for tests.
130 These are passed directly to pytest --collect-only.
132 Returns:
133 int: Total number of tests found. Returns 0 if collection fails.
135 Examples:
136 >>> tool = PytestTool(...)
137 >>> total = collect_tests_once(tool, ["tests/"])
138 >>> total >= 0
139 True
140 """
141 import re
143 try:
144 # Use pytest --collect-only to list all tests
145 collect_cmd = tool._get_executable_command(tool_name="pytest")
146 collect_cmd.append("--collect-only")
147 collect_cmd.append("-q") # Quiet mode for faster collection
148 collect_cmd.extend(target_files)
150 logger.debug(f"Collecting tests with command: {' '.join(collect_cmd)}")
152 success, output = tool._run_subprocess(collect_cmd)
153 if not success:
154 # Log the failure with output to aid debugging
155 output_preview = output[:500] if output else "(no output)"
156 logger.warning(
157 f"Test collection failed (exit non-zero). "
158 f"Command: {' '.join(collect_cmd[:5])}... "
159 f"Output: {output_preview}",
160 )
161 return 0
163 # Extract the total count from collection output
164 # Formats: "collected XXXX items", "XXXX tests collected in Y.YYs"
165 # Strip ANSI escape codes first to avoid color codes breaking regex
166 cleaned_output = strip_ansi_codes(output or "")
167 total_count = 0
168 match = re.search(r"collected\s+(\d+)\s+items?", cleaned_output)
169 if not match:
170 match = re.search(r"(\d+)\s+tests?\s+collected", cleaned_output)
171 if match:
172 total_count = int(match.group(1))
173 else:
174 # Log when we can't parse the output (use original for debugging)
175 output_preview = output[:300] if output else "(no output)"
176 logger.debug(
177 f"Could not parse test count from collection output: {output_preview}",
178 )
180 return total_count
181 except (OSError, ValueError, RuntimeError) as e:
182 logger.debug(f"Failed to collect tests: {e}")
183 return 0
186def get_total_test_count(
187 tool: PytestPlugin,
188 target_files: list[str],
189) -> int:
190 """Get total count of all available tests.
192 This function delegates to collect_tests_once().
194 Args:
195 tool: PytestTool instance with _get_executable_command and _run_subprocess
196 methods. Must support running pytest commands.
197 target_files: List of file paths or directory paths to check for tests.
198 These are passed directly to pytest --collect-only.
200 Returns:
201 int: Total number of tests that exist.
202 Returns 0 if collection fails or no tests are found.
204 Examples:
205 >>> tool = PytestTool(...)
206 >>> count = get_total_test_count(tool, ["tests/"])
207 >>> count >= 0
208 True
209 """
210 return collect_tests_once(tool, target_files)