Coverage for lintro / tools / implementations / pytest / output.py: 71%
79 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"""Output processing functions for pytest tool.
3This module contains output parsing, summary extraction, performance warnings,
4and flaky test detection logic extracted from PytestTool to improve
5maintainability and reduce file size.
6"""
8from __future__ import annotations
10import configparser
11import os
12from pathlib import Path
13from typing import TYPE_CHECKING, Any
15from loguru import logger
17if TYPE_CHECKING:
18 from lintro.tools.definitions.pytest import PytestPlugin
20from lintro.tools.implementations.pytest.collection import (
21 PYTEST_FLAKY_CACHE_FILE,
22 PYTEST_FLAKY_FAILURE_RATE,
23 PYTEST_FLAKY_MIN_RUNS,
24)
26PYTEST_SLOW_TEST_THRESHOLD: float = 1.0
27PYTEST_TOTAL_TIME_WARNING: float = 60.0
29# Path to flaky test history file - use constant from collection.py
30FLAKY_TEST_HISTORY_PATH = Path(PYTEST_FLAKY_CACHE_FILE)
33def detect_flaky_tests(
34 history: dict[str, dict[str, int]],
35 min_runs: int = PYTEST_FLAKY_MIN_RUNS,
36 failure_rate: float = PYTEST_FLAKY_FAILURE_RATE,
37) -> list[tuple[str, float]]:
38 """Detect flaky tests from history.
40 A test is considered flaky if:
41 - It has been run at least min_runs times
42 - It has failures but not 100% failure rate
43 - Failure rate >= failure_rate threshold
45 Args:
46 history: Test history dictionary.
47 min_runs: Minimum number of runs before considering flaky.
48 failure_rate: Minimum failure rate to consider flaky (0.0 to 1.0).
50 Returns:
51 list[tuple[str, float]]: List of (test_node_id, failure_rate) tuples.
52 """
53 flaky_tests: list[tuple[str, float]] = []
55 for node_id, counts in history.items():
56 total_runs = (
57 counts.get("passed", 0) + counts.get("failed", 0) + counts.get("error", 0)
58 )
60 if total_runs < min_runs:
61 continue
63 failed_count = counts.get("failed", 0) + counts.get("error", 0)
64 current_failure_rate = failed_count / total_runs
66 # Consider flaky if:
67 # 1. Has failures (failure_rate > 0)
68 # 2. Not always failing (failure_rate < 1.0)
69 # 3. Failure rate >= threshold
70 if 0 < current_failure_rate < 1.0 and current_failure_rate >= failure_rate:
71 flaky_tests.append((node_id, current_failure_rate))
73 # Sort by failure rate descending
74 flaky_tests.sort(key=lambda x: x[1], reverse=True)
75 return flaky_tests
78# Module-level cache for pytest config to avoid repeated file parsing
79_PYTEST_CONFIG_CACHE: dict[tuple[str, float, float], dict[str, Any]] = {}
82def clear_pytest_config_cache() -> None:
83 """Clear the pytest config cache.
85 This function is primarily intended for testing to ensure
86 config files are re-read when needed.
87 """
88 _PYTEST_CONFIG_CACHE.clear()
91def load_pytest_config() -> dict[str, Any]:
92 """Load pytest configuration from pyproject.toml or pytest.ini.
94 Priority order (highest to lowest):
95 1. pyproject.toml [tool.pytest.ini_options] (pytest convention)
96 2. pyproject.toml [tool.pytest] (backward compatibility)
97 3. pytest.ini [pytest]
99 This function uses caching to avoid repeatedly parsing config files
100 during the same process run. Cache is keyed by working directory and
101 file modification times to ensure freshness.
103 Returns:
104 dict: Pytest configuration dictionary.
105 """
106 cwd = os.getcwd()
107 pyproject_path = Path("pyproject.toml")
108 pytest_ini_path = Path("pytest.ini")
110 # Create cache key from working directory and file modification times
111 cache_key = (
112 cwd,
113 pyproject_path.stat().st_mtime if pyproject_path.exists() else 0.0,
114 pytest_ini_path.stat().st_mtime if pytest_ini_path.exists() else 0.0,
115 )
117 # Return cached result if available
118 if cache_key in _PYTEST_CONFIG_CACHE:
119 return _PYTEST_CONFIG_CACHE[cache_key].copy()
121 config: dict[str, Any] = {}
123 # Check pyproject.toml first
124 if pyproject_path.exists():
125 try:
126 import tomllib
128 with open(pyproject_path, "rb") as f:
129 pyproject_data = tomllib.load(f)
130 if "tool" in pyproject_data and "pytest" in pyproject_data["tool"]:
131 pytest_tool_data = pyproject_data["tool"]["pytest"]
132 # Check for ini_options first (pytest convention)
133 if (
134 isinstance(pytest_tool_data, dict)
135 and "ini_options" in pytest_tool_data
136 ):
137 config = pytest_tool_data["ini_options"]
138 # Fall back to direct pytest config (backward compatibility)
139 elif isinstance(pytest_tool_data, dict):
140 config = pytest_tool_data
141 except (OSError, KeyError, TypeError, ValueError) as e:
142 logger.warning(
143 f"Failed to load pytest configuration from pyproject.toml: {e}",
144 )
146 # Check pytest.ini (lowest priority, updates existing config)
147 if pytest_ini_path.exists():
148 try:
149 parser = configparser.ConfigParser()
150 parser.read(pytest_ini_path)
151 if "pytest" in parser:
152 ini_config = dict(parser["pytest"])
153 # Merge with pyproject.toml having higher priority
154 for key, value in ini_config.items():
155 if key not in config:
156 config[key] = value
157 except (OSError, configparser.Error) as e:
158 logger.warning(f"Failed to load pytest configuration from pytest.ini: {e}")
160 # Cache the result
161 _PYTEST_CONFIG_CACHE[cache_key] = config.copy()
162 return config.copy()
165def load_file_patterns_from_config(
166 pytest_config: dict[str, Any],
167) -> list[str]:
168 """Load file patterns from pytest configuration.
170 Args:
171 pytest_config: Pytest configuration dictionary.
173 Returns:
174 list[str]: File patterns from config, or empty list if not configured.
175 """
176 if not pytest_config:
177 return []
179 # Get python_files from config
180 python_files = pytest_config.get("python_files")
181 if not python_files:
182 return []
184 # Handle both string and list formats
185 if isinstance(python_files, str):
186 # Split on whitespace and commas
187 patterns = [
188 p.strip() for p in python_files.replace(",", " ").split() if p.strip()
189 ]
190 return patterns
191 elif isinstance(python_files, list):
192 return python_files
193 else:
194 logger.warning(f"Unexpected python_files type: {type(python_files)}")
195 return []
198def initialize_pytest_tool_config(tool: PytestPlugin) -> None:
199 """Initialize pytest tool configuration from config files.
201 Loads pytest config, file patterns, and default options.
202 Updates tool._file_patterns_from_config and tool.options.
204 Args:
205 tool: PytestPlugin instance to initialize.
206 """
207 # Load pytest configuration
208 pytest_config = load_pytest_config()
210 # Load file patterns from config if available
211 config_file_patterns = load_file_patterns_from_config(pytest_config)
212 if config_file_patterns:
213 # Override default patterns with config patterns
214 tool._file_patterns_from_config = config_file_patterns
216 # Apply any additional config options from pytest_config
217 # Merge pytest_config options into tool.options with safe defaults
218 if pytest_config and "options" in pytest_config:
219 tool.options.update(pytest_config.get("options", {}))