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

1"""Pytest marker and plugin utility functions.""" 

2 

3from __future__ import annotations 

4 

5import subprocess # nosec B404 - subprocess used safely with shell=False 

6from typing import TYPE_CHECKING 

7 

8from loguru import logger 

9 

10from lintro.parsers.base_parser import strip_ansi_codes 

11 

12if TYPE_CHECKING: 

13 from lintro.tools.definitions.pytest import PytestPlugin 

14 

15 

16def check_plugin_installed(plugin_name: str) -> bool: 

17 """Check if a pytest plugin is installed. 

18 

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"). 

22 

23 Args: 

24 plugin_name: Name of the plugin to check (e.g., 'pytest-cov', 'pytest-xdist'). 

25 

26 Returns: 

27 bool: True if plugin is installed (found under either name), False otherwise. 

28 

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 

36 

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 

49 

50 

51def list_installed_plugins() -> list[dict[str, str]]: 

52 """List all installed pytest plugins. 

53 

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. 

56 

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. 

62 

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]] = [] 

69 

70 import importlib.metadata 

71 

72 # Get all installed packages 

73 distributions = importlib.metadata.distributions() 

74 

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}) 

81 

82 # Sort by name 

83 plugins.sort(key=lambda x: x["name"]) 

84 return plugins 

85 

86 

87def get_pytest_version_info() -> str: 

88 """Get pytest version and plugin information. 

89 

90 Executes `pytest --version` to retrieve version information. Handles errors 

91 gracefully by returning a fallback message if the command fails. 

92 

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. 

97 

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" 

116 

117 

118def collect_tests_once( 

119 tool: PytestPlugin, 

120 target_files: list[str], 

121) -> int: 

122 """Collect tests and return total count. 

123 

124 This function runs pytest --collect-only to count all available tests. 

125 

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. 

131 

132 Returns: 

133 int: Total number of tests found. Returns 0 if collection fails. 

134 

135 Examples: 

136 >>> tool = PytestTool(...) 

137 >>> total = collect_tests_once(tool, ["tests/"]) 

138 >>> total >= 0 

139 True 

140 """ 

141 import re 

142 

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) 

149 

150 logger.debug(f"Collecting tests with command: {' '.join(collect_cmd)}") 

151 

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 

162 

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 ) 

179 

180 return total_count 

181 except (OSError, ValueError, RuntimeError) as e: 

182 logger.debug(f"Failed to collect tests: {e}") 

183 return 0 

184 

185 

186def get_total_test_count( 

187 tool: PytestPlugin, 

188 target_files: list[str], 

189) -> int: 

190 """Get total count of all available tests. 

191 

192 This function delegates to collect_tests_once(). 

193 

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. 

199 

200 Returns: 

201 int: Total number of tests that exist. 

202 Returns 0 if collection fails or no tests are found. 

203 

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)