pytest-api-cov 1.0.2__tar.gz → 1.1.1__tar.gz
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.
- {pytest_api_cov-1.0.2 → pytest_api_cov-1.1.1}/PKG-INFO +74 -10
- {pytest_api_cov-1.0.2 → pytest_api_cov-1.1.1}/README.md +73 -9
- {pytest_api_cov-1.0.2 → pytest_api_cov-1.1.1}/pyproject.toml +1 -1
- {pytest_api_cov-1.0.2 → pytest_api_cov-1.1.1}/src/pytest_api_cov/cli.py +3 -0
- {pytest_api_cov-1.0.2 → pytest_api_cov-1.1.1}/src/pytest_api_cov/config.py +2 -0
- {pytest_api_cov-1.0.2 → pytest_api_cov-1.1.1}/src/pytest_api_cov/frameworks.py +26 -5
- {pytest_api_cov-1.0.2 → pytest_api_cov-1.1.1}/src/pytest_api_cov/models.py +42 -23
- {pytest_api_cov-1.0.2 → pytest_api_cov-1.1.1}/src/pytest_api_cov/plugin.py +11 -6
- {pytest_api_cov-1.0.2 → pytest_api_cov-1.1.1}/src/pytest_api_cov/pytest_flags.py +6 -0
- {pytest_api_cov-1.0.2 → pytest_api_cov-1.1.1}/src/pytest_api_cov/report.py +57 -9
- {pytest_api_cov-1.0.2 → pytest_api_cov-1.1.1}/.gitignore +0 -0
- {pytest_api_cov-1.0.2 → pytest_api_cov-1.1.1}/LICENSE +0 -0
- {pytest_api_cov-1.0.2 → pytest_api_cov-1.1.1}/src/pytest_api_cov/__init__.py +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: pytest-api-cov
|
3
|
-
Version: 1.
|
3
|
+
Version: 1.1.1
|
4
4
|
Summary: Pytest Plugin to provide API Coverage statistics for Python Web Frameworks
|
5
5
|
Author-email: Barnaby Gill <barnabasgill@gmail.com>
|
6
6
|
License: Apache-2.0
|
@@ -83,6 +83,10 @@ def read_root():
|
|
83
83
|
def get_user(user_id: int):
|
84
84
|
return {"user_id": user_id}
|
85
85
|
|
86
|
+
@app.post("/users")
|
87
|
+
def create_user(user: dict):
|
88
|
+
return {"message": "User created", "user": user}
|
89
|
+
|
86
90
|
@app.get("/health")
|
87
91
|
def health_check():
|
88
92
|
return {"status": "ok"}
|
@@ -98,6 +102,10 @@ def test_root_endpoint(coverage_client):
|
|
98
102
|
def test_get_user(coverage_client):
|
99
103
|
response = coverage_client.get("/users/123")
|
100
104
|
assert response.status_code == 200
|
105
|
+
|
106
|
+
def test_create_user(coverage_client):
|
107
|
+
response = coverage_client.post("/users", json={"name": "John"})
|
108
|
+
assert response.status_code == 200
|
101
109
|
```
|
102
110
|
|
103
111
|
Running `pytest --api-cov-report` produces:
|
@@ -105,27 +113,69 @@ Running `pytest --api-cov-report` produces:
|
|
105
113
|
```
|
106
114
|
API Coverage Report
|
107
115
|
Uncovered Endpoints:
|
108
|
-
|
116
|
+
❌ GET /health
|
109
117
|
|
110
118
|
Total API Coverage: 66.67%
|
111
119
|
```
|
112
120
|
|
113
|
-
Or running with advanced options
|
121
|
+
Or running with advanced options:
|
122
|
+
```bash
|
123
|
+
pytest --api-cov-report --api-cov-show-covered-endpoints --api-cov-exclusion-patterns="/users*" --api-cov-show-excluded-endpoints --api-cov-report-path=api_coverage.json
|
124
|
+
```
|
114
125
|
|
115
126
|
```
|
116
127
|
API Coverage Report
|
117
128
|
Uncovered Endpoints:
|
118
|
-
|
129
|
+
❌ GET /health
|
119
130
|
Covered Endpoints:
|
120
|
-
|
131
|
+
✅ GET /
|
121
132
|
Excluded Endpoints:
|
122
|
-
|
133
|
+
🚫 GET /users/{user_id}
|
134
|
+
🚫 POST /users
|
123
135
|
|
124
|
-
|
136
|
+
Total API Coverage: 50.0%
|
125
137
|
|
126
138
|
JSON report saved to api_coverage.json
|
127
139
|
```
|
128
140
|
|
141
|
+
## HTTP Method-Aware Coverage
|
142
|
+
|
143
|
+
By default, pytest-api-cov tracks coverage for **each HTTP method separately**. This means `GET /users` and `POST /users` are treated as different endpoints for coverage purposes.
|
144
|
+
|
145
|
+
### Method-Aware (Default Behavior)
|
146
|
+
```
|
147
|
+
Covered Endpoints:
|
148
|
+
✅ GET /users/{id}
|
149
|
+
✅ POST /users
|
150
|
+
Uncovered Endpoints:
|
151
|
+
❌ PUT /users/{id}
|
152
|
+
❌ DELETE /users/{id}
|
153
|
+
|
154
|
+
Total API Coverage: 50.0% # 2 out of 4 method-endpoint combinations
|
155
|
+
```
|
156
|
+
|
157
|
+
### Endpoint Grouping
|
158
|
+
To group all methods on an endpoint are together, use:
|
159
|
+
|
160
|
+
```bash
|
161
|
+
pytest --api-cov-report --api-cov-group-methods-by-endpoint
|
162
|
+
```
|
163
|
+
|
164
|
+
Or in `pyproject.toml`:
|
165
|
+
```toml
|
166
|
+
[tool.pytest_api_cov]
|
167
|
+
group_methods_by_endpoint = true
|
168
|
+
```
|
169
|
+
|
170
|
+
This would show:
|
171
|
+
```
|
172
|
+
Covered Endpoints:
|
173
|
+
✅ /users/{id} # Any method tested
|
174
|
+
✅ /users # Any method tested
|
175
|
+
|
176
|
+
Total API Coverage: 100.0% # All endpoints have at least one method tested
|
177
|
+
```
|
178
|
+
|
129
179
|
## Advanced Configuration
|
130
180
|
|
131
181
|
### Setup Wizard
|
@@ -209,14 +259,17 @@ show_uncovered_endpoints = true
|
|
209
259
|
show_covered_endpoints = false
|
210
260
|
show_excluded_endpoints = false
|
211
261
|
|
212
|
-
# Exclude endpoints from coverage using
|
262
|
+
# Exclude endpoints from coverage using wildcard patterns with negation support
|
213
263
|
# Use * for wildcard matching, all other characters are matched literally
|
264
|
+
# Use ! at the start to negate a pattern (include what would otherwise be excluded)
|
214
265
|
exclusion_patterns = [
|
215
266
|
"/health", # Exact match
|
216
267
|
"/metrics", # Exact match
|
217
268
|
"/docs/*", # Wildcard: matches /docs/swagger, /docs/openapi, etc.
|
218
269
|
"/admin/*", # Wildcard: matches all admin endpoints
|
219
|
-
"/
|
270
|
+
"!/admin/public", # Negation: include /admin/public even though /admin/* excludes it
|
271
|
+
"/api/v1.0/*", # Exact version match (won't match /api/v1x0/*)
|
272
|
+
"!/api/v1.0/health" # Negation: include /api/v1.0/health even though /api/v1.0/* excludes it
|
220
273
|
]
|
221
274
|
|
222
275
|
# Save detailed JSON report
|
@@ -230,6 +283,11 @@ force_sugar_disabled = true
|
|
230
283
|
|
231
284
|
# Wrap an existing custom test client fixture with coverage tracking
|
232
285
|
client_fixture_name = "my_custom_client"
|
286
|
+
|
287
|
+
# Group HTTP methods by endpoint for legacy behavior (default: false)
|
288
|
+
# When true: treats GET /users and POST /users as one "/users" endpoint
|
289
|
+
# When false: treats them as separate "GET /users" and "POST /users" endpoints (recommended)
|
290
|
+
group_methods_by_endpoint = false
|
233
291
|
```
|
234
292
|
|
235
293
|
### Command Line Options
|
@@ -253,14 +311,20 @@ pytest --api-cov-report --api-cov-show-uncovered-endpoints=false
|
|
253
311
|
# Save JSON report
|
254
312
|
pytest --api-cov-report --api-cov-report-path=api_coverage.json
|
255
313
|
|
256
|
-
# Exclude specific endpoints (supports wildcards)
|
314
|
+
# Exclude specific endpoints (supports wildcards and negation)
|
257
315
|
pytest --api-cov-report --api-cov-exclusion-patterns="/health" --api-cov-exclusion-patterns="/docs/*"
|
258
316
|
|
317
|
+
# Exclude with negation (exclude all admin except admin/public)
|
318
|
+
pytest --api-cov-report --api-cov-exclusion-patterns="/admin/*" --api-cov-exclusion-patterns="!/admin/public"
|
319
|
+
|
259
320
|
# Verbose logging (shows discovery process)
|
260
321
|
pytest --api-cov-report -v
|
261
322
|
|
262
323
|
# Debug logging (very detailed)
|
263
324
|
pytest --api-cov-report -vv
|
325
|
+
|
326
|
+
# Group HTTP methods by endpoint (legacy behavior)
|
327
|
+
pytest --api-cov-report --api-cov-group-methods-by-endpoint
|
264
328
|
```
|
265
329
|
|
266
330
|
## Framework Support
|
@@ -65,6 +65,10 @@ def read_root():
|
|
65
65
|
def get_user(user_id: int):
|
66
66
|
return {"user_id": user_id}
|
67
67
|
|
68
|
+
@app.post("/users")
|
69
|
+
def create_user(user: dict):
|
70
|
+
return {"message": "User created", "user": user}
|
71
|
+
|
68
72
|
@app.get("/health")
|
69
73
|
def health_check():
|
70
74
|
return {"status": "ok"}
|
@@ -80,6 +84,10 @@ def test_root_endpoint(coverage_client):
|
|
80
84
|
def test_get_user(coverage_client):
|
81
85
|
response = coverage_client.get("/users/123")
|
82
86
|
assert response.status_code == 200
|
87
|
+
|
88
|
+
def test_create_user(coverage_client):
|
89
|
+
response = coverage_client.post("/users", json={"name": "John"})
|
90
|
+
assert response.status_code == 200
|
83
91
|
```
|
84
92
|
|
85
93
|
Running `pytest --api-cov-report` produces:
|
@@ -87,27 +95,69 @@ Running `pytest --api-cov-report` produces:
|
|
87
95
|
```
|
88
96
|
API Coverage Report
|
89
97
|
Uncovered Endpoints:
|
90
|
-
|
98
|
+
❌ GET /health
|
91
99
|
|
92
100
|
Total API Coverage: 66.67%
|
93
101
|
```
|
94
102
|
|
95
|
-
Or running with advanced options
|
103
|
+
Or running with advanced options:
|
104
|
+
```bash
|
105
|
+
pytest --api-cov-report --api-cov-show-covered-endpoints --api-cov-exclusion-patterns="/users*" --api-cov-show-excluded-endpoints --api-cov-report-path=api_coverage.json
|
106
|
+
```
|
96
107
|
|
97
108
|
```
|
98
109
|
API Coverage Report
|
99
110
|
Uncovered Endpoints:
|
100
|
-
|
111
|
+
❌ GET /health
|
101
112
|
Covered Endpoints:
|
102
|
-
|
113
|
+
✅ GET /
|
103
114
|
Excluded Endpoints:
|
104
|
-
|
115
|
+
🚫 GET /users/{user_id}
|
116
|
+
🚫 POST /users
|
105
117
|
|
106
|
-
|
118
|
+
Total API Coverage: 50.0%
|
107
119
|
|
108
120
|
JSON report saved to api_coverage.json
|
109
121
|
```
|
110
122
|
|
123
|
+
## HTTP Method-Aware Coverage
|
124
|
+
|
125
|
+
By default, pytest-api-cov tracks coverage for **each HTTP method separately**. This means `GET /users` and `POST /users` are treated as different endpoints for coverage purposes.
|
126
|
+
|
127
|
+
### Method-Aware (Default Behavior)
|
128
|
+
```
|
129
|
+
Covered Endpoints:
|
130
|
+
✅ GET /users/{id}
|
131
|
+
✅ POST /users
|
132
|
+
Uncovered Endpoints:
|
133
|
+
❌ PUT /users/{id}
|
134
|
+
❌ DELETE /users/{id}
|
135
|
+
|
136
|
+
Total API Coverage: 50.0% # 2 out of 4 method-endpoint combinations
|
137
|
+
```
|
138
|
+
|
139
|
+
### Endpoint Grouping
|
140
|
+
To group all methods on an endpoint are together, use:
|
141
|
+
|
142
|
+
```bash
|
143
|
+
pytest --api-cov-report --api-cov-group-methods-by-endpoint
|
144
|
+
```
|
145
|
+
|
146
|
+
Or in `pyproject.toml`:
|
147
|
+
```toml
|
148
|
+
[tool.pytest_api_cov]
|
149
|
+
group_methods_by_endpoint = true
|
150
|
+
```
|
151
|
+
|
152
|
+
This would show:
|
153
|
+
```
|
154
|
+
Covered Endpoints:
|
155
|
+
✅ /users/{id} # Any method tested
|
156
|
+
✅ /users # Any method tested
|
157
|
+
|
158
|
+
Total API Coverage: 100.0% # All endpoints have at least one method tested
|
159
|
+
```
|
160
|
+
|
111
161
|
## Advanced Configuration
|
112
162
|
|
113
163
|
### Setup Wizard
|
@@ -191,14 +241,17 @@ show_uncovered_endpoints = true
|
|
191
241
|
show_covered_endpoints = false
|
192
242
|
show_excluded_endpoints = false
|
193
243
|
|
194
|
-
# Exclude endpoints from coverage using
|
244
|
+
# Exclude endpoints from coverage using wildcard patterns with negation support
|
195
245
|
# Use * for wildcard matching, all other characters are matched literally
|
246
|
+
# Use ! at the start to negate a pattern (include what would otherwise be excluded)
|
196
247
|
exclusion_patterns = [
|
197
248
|
"/health", # Exact match
|
198
249
|
"/metrics", # Exact match
|
199
250
|
"/docs/*", # Wildcard: matches /docs/swagger, /docs/openapi, etc.
|
200
251
|
"/admin/*", # Wildcard: matches all admin endpoints
|
201
|
-
"/
|
252
|
+
"!/admin/public", # Negation: include /admin/public even though /admin/* excludes it
|
253
|
+
"/api/v1.0/*", # Exact version match (won't match /api/v1x0/*)
|
254
|
+
"!/api/v1.0/health" # Negation: include /api/v1.0/health even though /api/v1.0/* excludes it
|
202
255
|
]
|
203
256
|
|
204
257
|
# Save detailed JSON report
|
@@ -212,6 +265,11 @@ force_sugar_disabled = true
|
|
212
265
|
|
213
266
|
# Wrap an existing custom test client fixture with coverage tracking
|
214
267
|
client_fixture_name = "my_custom_client"
|
268
|
+
|
269
|
+
# Group HTTP methods by endpoint for legacy behavior (default: false)
|
270
|
+
# When true: treats GET /users and POST /users as one "/users" endpoint
|
271
|
+
# When false: treats them as separate "GET /users" and "POST /users" endpoints (recommended)
|
272
|
+
group_methods_by_endpoint = false
|
215
273
|
```
|
216
274
|
|
217
275
|
### Command Line Options
|
@@ -235,14 +293,20 @@ pytest --api-cov-report --api-cov-show-uncovered-endpoints=false
|
|
235
293
|
# Save JSON report
|
236
294
|
pytest --api-cov-report --api-cov-report-path=api_coverage.json
|
237
295
|
|
238
|
-
# Exclude specific endpoints (supports wildcards)
|
296
|
+
# Exclude specific endpoints (supports wildcards and negation)
|
239
297
|
pytest --api-cov-report --api-cov-exclusion-patterns="/health" --api-cov-exclusion-patterns="/docs/*"
|
240
298
|
|
299
|
+
# Exclude with negation (exclude all admin except admin/public)
|
300
|
+
pytest --api-cov-report --api-cov-exclusion-patterns="/admin/*" --api-cov-exclusion-patterns="!/admin/public"
|
301
|
+
|
241
302
|
# Verbose logging (shows discovery process)
|
242
303
|
pytest --api-cov-report -v
|
243
304
|
|
244
305
|
# Debug logging (very detailed)
|
245
306
|
pytest --api-cov-report -vv
|
307
|
+
|
308
|
+
# Group HTTP methods by endpoint (legacy behavior)
|
309
|
+
pytest --api-cov-report --api-cov-group-methods-by-endpoint
|
246
310
|
```
|
247
311
|
|
248
312
|
## Framework Support
|
@@ -117,6 +117,9 @@ show_excluded_endpoints = false
|
|
117
117
|
|
118
118
|
# Wrap an existing custom test client fixture with coverage tracking (optional)
|
119
119
|
# client_fixture_name = "my_custom_client"
|
120
|
+
|
121
|
+
# Group HTTP methods by endpoint for legacy behavior (optional)
|
122
|
+
# group_methods_by_endpoint = false
|
120
123
|
"""
|
121
124
|
|
122
125
|
|
@@ -21,6 +21,7 @@ class ApiCoverageReportConfig(BaseModel):
|
|
21
21
|
force_sugar: bool = Field(False, alias="api-cov-force-sugar")
|
22
22
|
force_sugar_disabled: bool = Field(False, alias="api-cov-force-sugar-disabled")
|
23
23
|
client_fixture_name: str = Field("coverage_client", alias="api-cov-client-fixture-name")
|
24
|
+
group_methods_by_endpoint: bool = Field(False, alias="api-cov-group-methods-by-endpoint")
|
24
25
|
|
25
26
|
|
26
27
|
def read_toml_config() -> Dict[str, Any]:
|
@@ -45,6 +46,7 @@ def read_session_config(session_config: Any) -> Dict[str, Any]:
|
|
45
46
|
"api-cov-force-sugar": "force_sugar",
|
46
47
|
"api-cov-force-sugar-disabled": "force_sugar_disabled",
|
47
48
|
"api-cov-client-fixture-name": "client_fixture_name",
|
49
|
+
"api-cov-group-methods-by-endpoint": "group_methods_by_endpoint",
|
48
50
|
}
|
49
51
|
config = {}
|
50
52
|
for opt, key in cli_options.items():
|
@@ -21,8 +21,17 @@ class BaseAdapter:
|
|
21
21
|
|
22
22
|
class FlaskAdapter(BaseAdapter):
|
23
23
|
def get_endpoints(self) -> List[str]:
|
24
|
+
"""Return list of 'METHOD /path' strings."""
|
24
25
|
excluded_rules = ("/static/<path:filename>",)
|
25
|
-
|
26
|
+
endpoints = []
|
27
|
+
|
28
|
+
for rule in self.app.url_map.iter_rules():
|
29
|
+
if rule.rule not in excluded_rules:
|
30
|
+
for method in rule.methods:
|
31
|
+
if method not in ("HEAD", "OPTIONS"): # Skip automatic methods
|
32
|
+
endpoints.append(f"{method} {rule.rule}")
|
33
|
+
|
34
|
+
return sorted(endpoints)
|
26
35
|
|
27
36
|
def get_tracked_client(self, recorder: Optional["ApiCallRecorder"], test_name: str) -> Any:
|
28
37
|
from flask.testing import FlaskClient
|
@@ -33,11 +42,13 @@ class FlaskAdapter(BaseAdapter):
|
|
33
42
|
class TrackingFlaskClient(FlaskClient):
|
34
43
|
def open(self, *args: Any, **kwargs: Any) -> Any:
|
35
44
|
path = kwargs.get("path") or (args[0] if args else None)
|
45
|
+
method = kwargs.get("method", "GET").upper()
|
46
|
+
|
36
47
|
if path and hasattr(self.application.url_map, "bind"):
|
37
48
|
try:
|
38
|
-
endpoint_name, _ = self.application.url_map.bind("").match(path, method=
|
49
|
+
endpoint_name, _ = self.application.url_map.bind("").match(path, method=method)
|
39
50
|
endpoint_rule_string = next(self.application.url_map.iter_rules(endpoint_name)).rule
|
40
|
-
recorder.record_call(endpoint_rule_string, test_name) # type: ignore[union-attr]
|
51
|
+
recorder.record_call(endpoint_rule_string, test_name, method) # type: ignore[union-attr]
|
41
52
|
except Exception:
|
42
53
|
pass
|
43
54
|
return super().open(*args, **kwargs)
|
@@ -47,9 +58,17 @@ class FlaskAdapter(BaseAdapter):
|
|
47
58
|
|
48
59
|
class FastAPIAdapter(BaseAdapter):
|
49
60
|
def get_endpoints(self) -> List[str]:
|
61
|
+
"""Return list of 'METHOD /path' strings."""
|
50
62
|
from fastapi.routing import APIRoute
|
51
63
|
|
52
|
-
|
64
|
+
endpoints = []
|
65
|
+
for route in self.app.routes:
|
66
|
+
if isinstance(route, APIRoute):
|
67
|
+
for method in route.methods:
|
68
|
+
if method not in ("HEAD", "OPTIONS"):
|
69
|
+
endpoints.append(f"{method} {route.path}")
|
70
|
+
|
71
|
+
return sorted(endpoints)
|
53
72
|
|
54
73
|
def get_tracked_client(self, recorder: Optional["ApiCallRecorder"], test_name: str) -> Any:
|
55
74
|
from starlette.testclient import TestClient
|
@@ -61,7 +80,9 @@ class FastAPIAdapter(BaseAdapter):
|
|
61
80
|
def send(self, *args: Any, **kwargs: Any) -> Any:
|
62
81
|
request = args[0]
|
63
82
|
if recorder is not None:
|
64
|
-
|
83
|
+
method = request.method.upper()
|
84
|
+
path = request.url.path
|
85
|
+
recorder.record_call(path, test_name, method)
|
65
86
|
return super().send(*args, **kwargs)
|
66
87
|
|
67
88
|
return TrackingFastAPIClient(self.app)
|
@@ -12,19 +12,27 @@ class ApiCallRecorder(BaseModel):
|
|
12
12
|
|
13
13
|
calls: Dict[str, Set[str]] = Field(default_factory=dict)
|
14
14
|
|
15
|
-
def record_call(self, endpoint: str, test_name: str) -> None:
|
16
|
-
"""Record that a test called an endpoint."""
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
15
|
+
def record_call(self, endpoint: str, test_name: str, method: str = "GET") -> None:
|
16
|
+
"""Record that a test called a specific method on an endpoint."""
|
17
|
+
endpoint_method = self._format_endpoint_key(method, endpoint)
|
18
|
+
if endpoint_method not in self.calls:
|
19
|
+
self.calls[endpoint_method] = set()
|
20
|
+
self.calls[endpoint_method].add(test_name)
|
21
|
+
|
22
|
+
@staticmethod
|
23
|
+
def _format_endpoint_key(method: str, endpoint: str) -> str:
|
24
|
+
"""Format method and endpoint into a consistent key format."""
|
25
|
+
return f"{method.upper()} {endpoint}"
|
26
|
+
|
27
|
+
@staticmethod
|
28
|
+
def _parse_endpoint_key(endpoint_key: str) -> tuple[str, str]:
|
29
|
+
"""Parse an endpoint key back into method and endpoint parts."""
|
30
|
+
if " " in endpoint_key:
|
31
|
+
method, endpoint = endpoint_key.split(" ", 1)
|
32
|
+
return method, endpoint
|
33
|
+
else:
|
34
|
+
# Handle legacy format without method
|
35
|
+
return "GET", endpoint_key
|
28
36
|
|
29
37
|
def merge(self, other: "ApiCallRecorder") -> None:
|
30
38
|
"""Merge another recorder's data into this one."""
|
@@ -70,15 +78,17 @@ class EndpointDiscovery(BaseModel):
|
|
70
78
|
endpoints: List[str] = Field(default_factory=list)
|
71
79
|
discovery_source: str = Field(default="unknown")
|
72
80
|
|
73
|
-
def add_endpoint(self, endpoint: str) -> None:
|
74
|
-
"""Add a discovered endpoint."""
|
75
|
-
|
76
|
-
|
81
|
+
def add_endpoint(self, endpoint: str, method: str = "GET") -> None:
|
82
|
+
"""Add a discovered endpoint method."""
|
83
|
+
endpoint_method = ApiCallRecorder._format_endpoint_key(method, endpoint)
|
84
|
+
if endpoint_method not in self.endpoints:
|
85
|
+
self.endpoints.append(endpoint_method)
|
77
86
|
|
78
87
|
def merge(self, other: "EndpointDiscovery") -> None:
|
79
88
|
"""Merge another discovery's endpoints into this one."""
|
80
89
|
for endpoint in other.endpoints:
|
81
|
-
self.
|
90
|
+
if endpoint not in self.endpoints:
|
91
|
+
self.endpoints.append(endpoint)
|
82
92
|
|
83
93
|
def __len__(self) -> int:
|
84
94
|
"""Return number of discovered endpoints."""
|
@@ -95,15 +105,24 @@ class SessionData(BaseModel):
|
|
95
105
|
recorder: ApiCallRecorder = Field(default_factory=ApiCallRecorder)
|
96
106
|
discovered_endpoints: EndpointDiscovery = Field(default_factory=EndpointDiscovery)
|
97
107
|
|
98
|
-
def record_call(self, endpoint: str, test_name: str) -> None:
|
108
|
+
def record_call(self, endpoint: str, test_name: str, method: str = "GET") -> None:
|
99
109
|
"""Record an API call."""
|
100
|
-
self.recorder.record_call(endpoint, test_name)
|
110
|
+
self.recorder.record_call(endpoint, test_name, method)
|
111
|
+
|
112
|
+
def add_discovered_endpoint(self, endpoint: str, method_or_source: str = "GET", source: str = "unknown") -> None:
|
113
|
+
"""Add a discovered endpoint method."""
|
114
|
+
# Handle both old and new method signatures for backward compatibility
|
115
|
+
if method_or_source in ["flask_adapter", "fastapi_adapter", "worker", "unknown"]:
|
116
|
+
# Old signature: add_discovered_endpoint(endpoint, source)
|
117
|
+
method = "GET"
|
118
|
+
source = method_or_source
|
119
|
+
else:
|
120
|
+
# New signature: add_discovered_endpoint(endpoint, method, source)
|
121
|
+
method = method_or_source
|
101
122
|
|
102
|
-
def add_discovered_endpoint(self, endpoint: str, source: str = "unknown") -> None:
|
103
|
-
"""Add a discovered endpoint."""
|
104
123
|
if not self.discovered_endpoints.endpoints:
|
105
124
|
self.discovered_endpoints.discovery_source = source
|
106
|
-
self.discovered_endpoints.add_endpoint(endpoint)
|
125
|
+
self.discovered_endpoints.add_endpoint(endpoint, method)
|
107
126
|
|
108
127
|
def merge_worker_data(self, worker_recorder: Dict[str, Any], worker_endpoints: List[str]) -> None:
|
109
128
|
"""Merge data from a worker process."""
|
@@ -189,14 +189,15 @@ def wrap_client_with_coverage(client: Any, recorder: Any, test_name: str) -> Any
|
|
189
189
|
|
190
190
|
def tracked_method(*args: Any, **kwargs: Any) -> Any:
|
191
191
|
response = attr(*args, **kwargs)
|
192
|
-
# Extract path from args[0]
|
192
|
+
# Extract path from args[0] and method from function name
|
193
193
|
if args and recorder is not None:
|
194
194
|
path = args[0]
|
195
195
|
# Clean up the path to match endpoint format
|
196
196
|
if isinstance(path, str):
|
197
197
|
# Remove query parameters
|
198
198
|
path = path.partition("?")[0]
|
199
|
-
|
199
|
+
method = name.upper() # get -> GET, post -> POST, etc.
|
200
|
+
recorder.record_call(path, test_name, method)
|
200
201
|
return response
|
201
202
|
|
202
203
|
return tracked_method
|
@@ -250,8 +251,10 @@ def coverage_client(request: pytest.FixtureRequest) -> Any:
|
|
250
251
|
if not coverage_data.discovered_endpoints.endpoints:
|
251
252
|
endpoints = adapter.get_endpoints()
|
252
253
|
framework_name = type(app).__name__
|
253
|
-
for
|
254
|
-
|
254
|
+
for endpoint_method in endpoints:
|
255
|
+
# endpoint_method is now in "METHOD /path" format
|
256
|
+
method, path = endpoint_method.split(" ", 1)
|
257
|
+
coverage_data.add_discovered_endpoint(path, method, f"{framework_name.lower()}_adapter")
|
255
258
|
logger.info(f"> pytest-api-coverage: Discovered {len(endpoints)} endpoints.")
|
256
259
|
logger.debug(f"> Discovered endpoints: {endpoints}")
|
257
260
|
except Exception as e:
|
@@ -285,8 +288,10 @@ def coverage_client(request: pytest.FixtureRequest) -> Any:
|
|
285
288
|
try:
|
286
289
|
endpoints = adapter.get_endpoints()
|
287
290
|
framework_name = type(app).__name__
|
288
|
-
for
|
289
|
-
|
291
|
+
for endpoint_method in endpoints:
|
292
|
+
# endpoint_method is now in "METHOD /path" format
|
293
|
+
method, path = endpoint_method.split(" ", 1)
|
294
|
+
coverage_data.add_discovered_endpoint(path, method, f"{framework_name.lower()}_adapter")
|
290
295
|
logger.info(f"> pytest-api-coverage: Discovered {len(endpoints)} endpoints.")
|
291
296
|
logger.debug(f"> Discovered endpoints: {endpoints}")
|
292
297
|
except Exception as e:
|
@@ -74,3 +74,9 @@ def add_pytest_api_cov_flags(parser: pytest.Parser) -> None:
|
|
74
74
|
default=None,
|
75
75
|
help="Name of existing test client fixture to wrap with coverage tracking",
|
76
76
|
)
|
77
|
+
parser.addoption(
|
78
|
+
"--api-cov-group-methods-by-endpoint",
|
79
|
+
action="store_true",
|
80
|
+
default=False,
|
81
|
+
help="Group HTTP methods by endpoint for legacy behavior (default: method-aware coverage)",
|
82
|
+
)
|
@@ -30,21 +30,61 @@ def categorise_endpoints(
|
|
30
30
|
) -> Tuple[List[str], List[str], List[str]]:
|
31
31
|
"""Categorise endpoints into covered, uncovered, and excluded.
|
32
32
|
|
33
|
-
Exclusion patterns support simple wildcard matching:
|
33
|
+
Exclusion patterns support simple wildcard matching with negation:
|
34
34
|
- Use * for wildcard (matches any characters)
|
35
|
+
- Use ! at the start to negate a pattern (include what would otherwise be excluded)
|
35
36
|
- All other characters are matched literally
|
36
|
-
- Examples: "/admin/*", "/health", "/
|
37
|
+
- Examples: "/admin/*", "/health", "!users/bob" (negates exclusion)
|
38
|
+
- Pattern order matters: exclusions are applied first, then negations override them
|
37
39
|
"""
|
38
40
|
covered, uncovered, excluded = [], [], []
|
39
41
|
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
42
|
+
if not exclusion_patterns:
|
43
|
+
compiled_exclusions = None
|
44
|
+
compiled_negations = None
|
45
|
+
else:
|
46
|
+
# Separate exclusion and negation patterns
|
47
|
+
exclusion_only = [p for p in exclusion_patterns if not p.startswith("!")]
|
48
|
+
negation_only = [p[1:] for p in exclusion_patterns if p.startswith("!")] # Remove the '!' prefix
|
49
|
+
|
50
|
+
compiled_exclusions = (
|
51
|
+
[re.compile("^" + re.escape(pattern).replace(r"\*", ".*") + "$") for pattern in exclusion_only]
|
52
|
+
if exclusion_only
|
53
|
+
else None
|
54
|
+
)
|
55
|
+
compiled_negations = (
|
56
|
+
[re.compile("^" + re.escape(pattern).replace(r"\*", ".*") + "$") for pattern in negation_only]
|
57
|
+
if negation_only
|
58
|
+
else None
|
59
|
+
)
|
45
60
|
|
46
61
|
for endpoint in endpoints:
|
47
|
-
|
62
|
+
# Check exclusion patterns against both full "METHOD /path" and just "/path"
|
63
|
+
is_excluded = False
|
64
|
+
if compiled_exclusions:
|
65
|
+
# Extract path from "METHOD /path" format for pattern matching
|
66
|
+
if " " in endpoint:
|
67
|
+
_, path_only = endpoint.split(" ", 1)
|
68
|
+
is_excluded = any(p.match(endpoint) for p in compiled_exclusions) or any(
|
69
|
+
p.match(path_only) for p in compiled_exclusions
|
70
|
+
)
|
71
|
+
else:
|
72
|
+
is_excluded = any(p.match(endpoint) for p in compiled_exclusions)
|
73
|
+
|
74
|
+
# Check negation patterns - these override exclusions
|
75
|
+
if is_excluded and compiled_negations:
|
76
|
+
if " " in endpoint:
|
77
|
+
_, path_only = endpoint.split(" ", 1)
|
78
|
+
is_negated = any(p.match(endpoint) for p in compiled_negations) or any(
|
79
|
+
p.match(path_only) for p in compiled_negations
|
80
|
+
)
|
81
|
+
else:
|
82
|
+
is_negated = any(p.match(endpoint) for p in compiled_negations)
|
83
|
+
|
84
|
+
if is_negated:
|
85
|
+
is_excluded = False # Negation overrides exclusion
|
86
|
+
|
87
|
+
if is_excluded:
|
48
88
|
excluded.append(endpoint)
|
49
89
|
continue
|
50
90
|
elif contains_escape_characters(endpoint):
|
@@ -67,7 +107,15 @@ def print_endpoints(
|
|
67
107
|
if endpoints:
|
68
108
|
console.print(f"[{style}]{label}[/]:")
|
69
109
|
for endpoint in endpoints:
|
70
|
-
|
110
|
+
# Format endpoint with consistent spacing for HTTP methods
|
111
|
+
if " " in endpoint:
|
112
|
+
method, path = endpoint.split(" ", 1)
|
113
|
+
# Pad method to 6 characters (longest common method is DELETE)
|
114
|
+
formatted_endpoint = f"{method:<6} {path}"
|
115
|
+
else:
|
116
|
+
# Handle legacy format without method
|
117
|
+
formatted_endpoint = endpoint
|
118
|
+
console.print(f" {symbol} [{style}]{formatted_endpoint}[/]")
|
71
119
|
|
72
120
|
|
73
121
|
def compute_coverage(covered_count: int, uncovered_count: int) -> float:
|
File without changes
|
File without changes
|
File without changes
|