pytest-api-cov 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,270 @@
1
+ """pytest plugin for API coverage tracking."""
2
+
3
+ import importlib
4
+ import importlib.util
5
+ import logging
6
+ import os
7
+ from typing import Any, Optional
8
+
9
+ import pytest
10
+
11
+ from .config import get_pytest_api_cov_report_config
12
+ from .frameworks import get_framework_adapter
13
+ from .models import SessionData
14
+ from .pytest_flags import add_pytest_api_cov_flags
15
+ from .report import generate_pytest_api_cov_report
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def is_supported_framework(app: Any) -> bool:
21
+ """Check if the app is a supported framework (Flask or FastAPI)."""
22
+ if app is None:
23
+ return False
24
+
25
+ app_type = type(app).__name__
26
+ module_name = getattr(type(app), "__module__", "").split(".")[0]
27
+
28
+ return (module_name == "flask" and app_type == "Flask") or (module_name == "fastapi" and app_type == "FastAPI")
29
+
30
+
31
+ def auto_discover_app() -> Optional[Any]:
32
+ """Automatically discover Flask/FastAPI apps in common locations."""
33
+ logger.debug("> Auto-discovering app in common locations...")
34
+
35
+ common_patterns = [
36
+ ("app.py", ["app", "application", "main"]),
37
+ ("main.py", ["app", "application", "main"]),
38
+ ("server.py", ["app", "application", "server"]),
39
+ ("wsgi.py", ["app", "application"]),
40
+ ("asgi.py", ["app", "application"]),
41
+ ]
42
+
43
+ for filename, attr_names in common_patterns:
44
+ if os.path.exists(filename):
45
+ logger.debug(f"> Found {filename}, checking for app variables...")
46
+ try:
47
+ module_name = filename[:-3] # .py extension
48
+ spec = importlib.util.spec_from_file_location(module_name, filename)
49
+ if spec and spec.loader:
50
+ module = importlib.util.module_from_spec(spec)
51
+ spec.loader.exec_module(module)
52
+
53
+ for attr_name in attr_names:
54
+ if hasattr(module, attr_name):
55
+ app = getattr(module, attr_name)
56
+ if is_supported_framework(app):
57
+ logger.info(
58
+ f"✅ Auto-discovered {type(app).__name__} app in {filename} as '{attr_name}'"
59
+ )
60
+ return app
61
+ else:
62
+ logger.debug(f"> Found '{attr_name}' in {filename} but it's not a supported framework")
63
+
64
+ except Exception as e:
65
+ logger.debug(f"> Could not import {filename}: {e}")
66
+ continue
67
+
68
+ logger.debug("> No app auto-discovered")
69
+ return None
70
+
71
+
72
+ def get_helpful_error_message() -> str:
73
+ """Generate a helpful error message for setup guidance."""
74
+ return """
75
+ 🚫 No API app found!
76
+
77
+ Quick Setup Options:
78
+
79
+ Option 1 - Auto-discovery (Recommended):
80
+ Place your FastAPI/Flask app in one of these files:
81
+ • app.py (with variable named 'app', 'application', or 'main')
82
+ • main.py (with variable named 'app', 'application', or 'main')
83
+ • server.py (with variable named 'app', 'application', or 'server')
84
+
85
+ Example app.py:
86
+ from fastapi import FastAPI
87
+ app = FastAPI() # <- Plugin will auto-discover this
88
+
89
+ Option 2 - Manual fixture:
90
+ Create conftest.py with:
91
+
92
+ import pytest
93
+ from your_module import your_app
94
+
95
+ @pytest.fixture
96
+ def app():
97
+ return your_app
98
+
99
+ Then run: pytest --api-cov-report
100
+
101
+ Need help? Run: pytest-api-cov init (for setup wizard)
102
+ """
103
+
104
+
105
+ def pytest_addoption(parser: pytest.Parser) -> None:
106
+ """Add API coverage flags to the pytest parser."""
107
+ add_pytest_api_cov_flags(parser)
108
+
109
+
110
+ def pytest_configure(config: pytest.Config) -> None:
111
+ """Configure the pytest session and logging."""
112
+ if config.getoption("--api-cov-report"):
113
+ verbosity = config.option.verbose
114
+
115
+ if verbosity >= 2: # -vv or more
116
+ log_level = logging.DEBUG
117
+ elif verbosity >= 1: # -v
118
+ log_level = logging.INFO
119
+ else:
120
+ log_level = logging.WARNING
121
+
122
+ logger.setLevel(log_level)
123
+
124
+ if not logger.handlers:
125
+ handler = logging.StreamHandler()
126
+ handler.setLevel(log_level)
127
+ formatter = logging.Formatter("%(message)s")
128
+ handler.setFormatter(formatter)
129
+ logger.addHandler(handler)
130
+
131
+ logger.info("Initializing API coverage plugin...")
132
+
133
+ if config.pluginmanager.hasplugin("xdist"):
134
+ config.pluginmanager.register(DeferXdistPlugin(), "defer_xdist_plugin")
135
+
136
+
137
+ def pytest_sessionstart(session: pytest.Session) -> None:
138
+ """Initialize the call recorder at the start of the session."""
139
+ if session.config.getoption("--api-cov-report"):
140
+ session.api_coverage_data = SessionData() # type: ignore[attr-defined]
141
+
142
+
143
+ @pytest.fixture
144
+ def client(request: pytest.FixtureRequest) -> Any:
145
+ """
146
+ Smart auto-discovering test client that records API calls for coverage.
147
+
148
+ Tries to find an 'app' fixture first, then auto-discovers apps in common locations.
149
+ """
150
+ session = request.node.session
151
+
152
+ if not session.config.getoption("--api-cov-report"):
153
+ pytest.skip("API coverage not enabled. Use --api-cov-report flag.")
154
+
155
+ app = None
156
+ try:
157
+ app = request.getfixturevalue("app")
158
+ logger.debug("> Found 'app' fixture")
159
+ except pytest.FixtureLookupError:
160
+ logger.debug("> No 'app' fixture found, trying auto-discovery...")
161
+ app = auto_discover_app()
162
+
163
+ if app is None:
164
+ helpful_msg = get_helpful_error_message()
165
+ print(helpful_msg)
166
+ pytest.skip("No API app found. See error message above for setup guidance.")
167
+
168
+ if not is_supported_framework(app):
169
+ pytest.skip(f"Unsupported framework: {type(app).__name__}. pytest-api-coverage supports Flask and FastAPI.")
170
+
171
+ try:
172
+ adapter = get_framework_adapter(app)
173
+ except TypeError as e:
174
+ pytest.skip(f"Framework detection failed: {e}")
175
+
176
+ coverage_data = getattr(session, "api_coverage_data", None)
177
+ if coverage_data is None:
178
+ pytest.skip("API coverage data not initialized. This should not happen.")
179
+
180
+ if not coverage_data.discovered_endpoints.endpoints:
181
+ try:
182
+ endpoints = adapter.get_endpoints()
183
+ framework_name = type(app).__name__
184
+ for endpoint in endpoints:
185
+ coverage_data.add_discovered_endpoint(endpoint, f"{framework_name.lower()}_adapter")
186
+ logger.info(f"> pytest-api-coverage: Discovered {len(endpoints)} endpoints.")
187
+ logger.debug(f"> Discovered endpoints: {endpoints}")
188
+ except Exception as e:
189
+ logger.warning(f"> pytest-api-coverage: Could not discover endpoints. Error: {e}")
190
+
191
+ client = adapter.get_tracked_client(coverage_data.recorder, request.node.name)
192
+ yield client
193
+
194
+
195
+ def pytest_sessionfinish(session: pytest.Session) -> None:
196
+ """Generate the API coverage report at the end of the session."""
197
+ if session.config.getoption("--api-cov-report"):
198
+ coverage_data = getattr(session, "api_coverage_data", None)
199
+ if coverage_data is None:
200
+ logger.warning("> No API coverage data found. Plugin may not have been properly initialized.")
201
+ return
202
+
203
+ logger.debug(f"> pytest-api-coverage: Generating report for {len(coverage_data.recorder)} recorded endpoints.")
204
+ if hasattr(session.config, "workeroutput"):
205
+ serializable_recorder = coverage_data.recorder.to_serializable()
206
+ session.config.workeroutput["api_call_recorder"] = serializable_recorder
207
+ session.config.workeroutput["discovered_endpoints"] = coverage_data.discovered_endpoints.endpoints
208
+ logger.debug("> Sent API call data and discovered endpoints to master process")
209
+ else:
210
+ logger.debug("> No workeroutput found, generating report for master data.")
211
+
212
+ worker_recorder_data = getattr(session.config, "worker_api_call_recorder", {})
213
+ worker_endpoints = getattr(session.config, "worker_discovered_endpoints", [])
214
+
215
+ # Merge worker data into session data
216
+ if worker_recorder_data or worker_endpoints:
217
+ coverage_data.merge_worker_data(worker_recorder_data, worker_endpoints)
218
+ logger.debug(f"> Merged worker data: {len(worker_recorder_data)} endpoints")
219
+
220
+ logger.debug(f"> Final merged data: {len(coverage_data.recorder)} recorded endpoints")
221
+ logger.debug(f"> Using discovered endpoints: {coverage_data.discovered_endpoints.endpoints}")
222
+
223
+ api_cov_config = get_pytest_api_cov_report_config(session.config)
224
+ status = generate_pytest_api_cov_report(
225
+ api_cov_config=api_cov_config,
226
+ called_data=coverage_data.recorder.calls,
227
+ discovered_endpoints=coverage_data.discovered_endpoints.endpoints,
228
+ )
229
+ if session.exitstatus == 0:
230
+ session.exitstatus = status
231
+
232
+ if hasattr(session, "api_coverage_data"):
233
+ delattr(session, "api_coverage_data")
234
+
235
+ if hasattr(session.config, "worker_api_call_recorder"):
236
+ delattr(session.config, "worker_api_call_recorder")
237
+
238
+
239
+ class DeferXdistPlugin:
240
+ """Simple class to defer pytest-xdist hook until we know it is installed."""
241
+
242
+ def pytest_testnodedown(self, node: Any) -> None:
243
+ """Collect API call data from each worker as they finish."""
244
+ logger.debug("> pytest-api-coverage: Worker node down.")
245
+ worker_data = node.workeroutput.get("api_call_recorder", {})
246
+ discovered_endpoints = node.workeroutput.get("discovered_endpoints", [])
247
+ logger.debug(f"> Worker data: {worker_data}")
248
+ logger.debug(f"> Worker discovered endpoints: {discovered_endpoints}")
249
+
250
+ # Merge API call data
251
+ if worker_data:
252
+ logger.debug("> Worker data found, merging with current data.")
253
+ current = getattr(node.config, "worker_api_call_recorder", {})
254
+ logger.debug(f"> Current data before merge: {current}")
255
+
256
+ # Merge the worker data into current
257
+ for endpoint, calls in worker_data.items():
258
+ if endpoint not in current:
259
+ current[endpoint] = set()
260
+ elif not isinstance(current[endpoint], set):
261
+ current[endpoint] = set(current[endpoint])
262
+ current[endpoint].update(calls)
263
+ logger.debug(f"> Updated endpoint {endpoint} with calls: {calls}")
264
+
265
+ node.config.worker_api_call_recorder = current
266
+ logger.debug(f"> Updated current data: {current}")
267
+
268
+ if discovered_endpoints and not getattr(node.config, "worker_discovered_endpoints", []):
269
+ node.config.worker_discovered_endpoints = discovered_endpoints
270
+ logger.debug(f"> Set discovered endpoints from worker: {discovered_endpoints}")
@@ -0,0 +1,68 @@
1
+ """pytest flag configuration for API coverage."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ import pytest
6
+
7
+ if TYPE_CHECKING:
8
+ import pytest
9
+
10
+
11
+ def add_pytest_api_cov_flags(parser: pytest.Parser) -> None:
12
+ """Add API coverage flags to the parser."""
13
+ parser.addoption(
14
+ "--api-cov-report",
15
+ action="store_true",
16
+ default=False,
17
+ help="Generate API coverage report.",
18
+ )
19
+ parser.addoption(
20
+ "--api-cov-fail-under",
21
+ action="store",
22
+ type=float,
23
+ default=None,
24
+ help="Fail if API coverage is below this percentage.",
25
+ )
26
+ parser.addoption(
27
+ "--api-cov-show-uncovered-endpoints",
28
+ action="store_true",
29
+ default=True,
30
+ help="Show uncovered endpoints in the console report.",
31
+ )
32
+ parser.addoption(
33
+ "--api-cov-show-covered-endpoints",
34
+ action="store_true",
35
+ default=False,
36
+ help="Show covered endpoints in the console report.",
37
+ )
38
+ parser.addoption(
39
+ "--api-cov-show-excluded-endpoints",
40
+ action="store_true",
41
+ default=False,
42
+ help="Show excluded endpoints in the console report.",
43
+ )
44
+ parser.addoption(
45
+ "--api-cov-exclusion-patterns",
46
+ action="append",
47
+ default=[],
48
+ help="Patterns for endpoints to exclude from coverage.",
49
+ )
50
+ parser.addoption(
51
+ "--api-cov-report-path",
52
+ action="store",
53
+ type=str,
54
+ default=None,
55
+ help="Path to save the API coverage report.",
56
+ )
57
+ parser.addoption(
58
+ "--api-cov-force-sugar",
59
+ action="store_true",
60
+ default=False,
61
+ help="Force use of API coverage sugar in console report.",
62
+ )
63
+ parser.addoption(
64
+ "--api-cov-force-sugar-disabled",
65
+ action="store_true",
66
+ default=False,
67
+ help="Disable use of API coverage sugar in console report.",
68
+ )
@@ -0,0 +1,185 @@
1
+ """API coverage report generation."""
2
+
3
+ import json
4
+ import re
5
+ from pathlib import Path
6
+ from re import Pattern
7
+ from typing import Any, Dict, List, Set, Tuple
8
+
9
+ from rich.console import Console
10
+
11
+ from .config import ApiCoverageReportConfig
12
+
13
+
14
+ def endpoint_to_regex(endpoint: str) -> Pattern[str]:
15
+ """Create a regex pattern from an endpoint by replacing dynamic segments."""
16
+ placeholder = "___PLACEHOLDER___"
17
+ temp_endpoint = re.escape(re.sub(r"<[^>]+>|\{[^}]+\}", placeholder, endpoint))
18
+ return re.compile("^" + temp_endpoint.replace(placeholder, "(.+)") + "$")
19
+
20
+
21
+ def contains_escape_characters(endpoint: str) -> bool:
22
+ """Escape special characters in the endpoint string."""
23
+ return ("<" in endpoint and ">" in endpoint) or ("{" in endpoint and "}" in endpoint)
24
+
25
+
26
+ def categorise_endpoints(
27
+ endpoints: List[str],
28
+ called_data: Dict[str, Set[str]],
29
+ exclusion_patterns: List[str],
30
+ ) -> Tuple[List[str], List[str], List[str]]:
31
+ """Categorise endpoints into covered, uncovered, and excluded.
32
+
33
+ Exclusion patterns support simple wildcard matching:
34
+ - Use * for wildcard (matches any characters)
35
+ - All other characters are matched literally
36
+ - Examples: "/admin/*", "/health", "/docs/*"
37
+ """
38
+ covered, uncovered, excluded = [], [], []
39
+
40
+ compiled_patterns = (
41
+ [re.compile("^" + re.escape(pattern).replace(r"\*", ".*") + "$") for pattern in exclusion_patterns]
42
+ if exclusion_patterns
43
+ else None
44
+ )
45
+
46
+ for endpoint in endpoints:
47
+ if compiled_patterns and any(p.match(endpoint) for p in compiled_patterns):
48
+ excluded.append(endpoint)
49
+ continue
50
+ elif contains_escape_characters(endpoint):
51
+ pattern = endpoint_to_regex(endpoint)
52
+ is_covered = any(pattern.match(ep) for ep in called_data)
53
+ else:
54
+ is_covered = endpoint in called_data
55
+ covered.append(endpoint) if is_covered else uncovered.append(endpoint)
56
+ return covered, uncovered, excluded
57
+
58
+
59
+ def print_endpoints(
60
+ console: Console,
61
+ label: str,
62
+ endpoints: List[str],
63
+ symbol: str,
64
+ style: str,
65
+ ) -> None:
66
+ """Prints a list of endpoints to the console with a label and style."""
67
+ if endpoints:
68
+ console.print(f"[{style}]{label}[/]:")
69
+ for endpoint in endpoints:
70
+ console.print(f" {symbol} [{style}]{endpoint}[/]")
71
+
72
+
73
+ def compute_coverage(covered_count: int, uncovered_count: int) -> float:
74
+ """Compute API coverage percentage."""
75
+ total = covered_count + uncovered_count
76
+ return round(100 * covered_count / total, 2) if total > 0 else 0.0
77
+
78
+
79
+ def prepare_endpoint_detail(endpoints: List[str], called_data: Dict[str, Set[str]]) -> List[Dict[str, Any]]:
80
+ """Prepare endpoint details by mapping each endpoint to its callers."""
81
+ details = []
82
+ for endpoint in endpoints:
83
+ if contains_escape_characters(endpoint):
84
+ pattern = endpoint_to_regex(endpoint)
85
+ callers = set()
86
+ for call in called_data:
87
+ if pattern.match(call):
88
+ callers.update(called_data[call])
89
+ else:
90
+ callers = called_data.get(endpoint, set())
91
+ details.append({"endpoint": endpoint, "callers": sorted(list(callers))})
92
+ return sorted(details, key=lambda x: len(x["callers"]))
93
+
94
+
95
+ def write_report_file(report_data: Dict[str, Any], report_path: str) -> None:
96
+ """Write the report data to a JSON file."""
97
+ path = Path(report_path).resolve()
98
+ path.parent.mkdir(parents=True, exist_ok=True)
99
+ with open(path, "w") as f:
100
+ json.dump(report_data, f, indent=2)
101
+
102
+
103
+ def generate_pytest_api_cov_report(
104
+ api_cov_config: ApiCoverageReportConfig,
105
+ called_data: Dict[str, Set[str]],
106
+ discovered_endpoints: List[str],
107
+ ) -> int:
108
+ """Generate and print the API coverage report, returning an exit status."""
109
+
110
+ console = Console()
111
+
112
+ if not discovered_endpoints:
113
+ console.print("\n[bold red]No endpoints discovered. Please check your test setup.[/bold red]")
114
+ return 0
115
+
116
+ separator = "=" * 20
117
+ console.print(f"\n\n[bold blue]{separator} API Coverage Report {separator}[/bold blue]")
118
+
119
+ covered, uncovered, excluded = categorise_endpoints(
120
+ discovered_endpoints,
121
+ called_data,
122
+ api_cov_config.exclusion_patterns,
123
+ )
124
+
125
+ if api_cov_config.show_uncovered_endpoints:
126
+ print_endpoints(
127
+ console,
128
+ "Uncovered Endpoints",
129
+ uncovered,
130
+ "❌" if api_cov_config.force_sugar else "[X]",
131
+ "red",
132
+ )
133
+
134
+ if api_cov_config.show_covered_endpoints:
135
+ print_endpoints(
136
+ console,
137
+ "Covered Endpoints",
138
+ covered,
139
+ "✅" if api_cov_config.force_sugar else "[.]",
140
+ "green",
141
+ )
142
+
143
+ if api_cov_config.show_excluded_endpoints:
144
+ print_endpoints(
145
+ console,
146
+ label="Excluded Endpoints",
147
+ endpoints=excluded,
148
+ symbol="🚫" if api_cov_config.force_sugar else "[-]",
149
+ style="grey50",
150
+ )
151
+
152
+ coverage = compute_coverage(len(covered), len(uncovered))
153
+ status = 0
154
+
155
+ if api_cov_config.fail_under is None:
156
+ console.print(f"\n[bold green]Total API Coverage: {coverage}%[/bold green]")
157
+ elif coverage < api_cov_config.fail_under:
158
+ console.print(
159
+ f"\n[bold red]FAIL: Required coverage of {api_cov_config.fail_under}% not met. "
160
+ f"Actual coverage: {coverage}%[/bold red]"
161
+ )
162
+ status = 1
163
+ else:
164
+ console.print(
165
+ f"\n[bold green]SUCCESS: Coverage of {coverage}% meets requirement of "
166
+ f"{api_cov_config.fail_under}%[/bold green]"
167
+ )
168
+
169
+ if api_cov_config.report_path:
170
+ detail = prepare_endpoint_detail(covered + uncovered, called_data)
171
+ final_report = {
172
+ "status": status,
173
+ "coverage": coverage,
174
+ "required_coverage": api_cov_config.fail_under,
175
+ "total_endpoints": len(covered) + len(uncovered),
176
+ "covered_count": len(covered),
177
+ "uncovered_count": len(uncovered),
178
+ "excluded_count": len(excluded),
179
+ "detail": detail,
180
+ }
181
+ write_report_file(final_report, api_cov_config.report_path)
182
+ console.print(f"\n[grey50]JSON report saved to {api_cov_config.report_path}[/grey50]")
183
+
184
+ console.print(f"[bold blue]{'=' * (42 + len(' API Coverage Report '))}[/bold blue]\n")
185
+ return status