pytest-api-cov 1.0.1__tar.gz → 1.1.0__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.1 → pytest_api_cov-1.1.0}/PKG-INFO +74 -16
- {pytest_api_cov-1.0.1 → pytest_api_cov-1.1.0}/README.md +64 -6
- {pytest_api_cov-1.0.1 → pytest_api_cov-1.1.0}/pyproject.toml +15 -27
- {pytest_api_cov-1.0.1 → pytest_api_cov-1.1.0}/src/pytest_api_cov/cli.py +4 -1
- {pytest_api_cov-1.0.1 → pytest_api_cov-1.1.0}/src/pytest_api_cov/config.py +2 -0
- {pytest_api_cov-1.0.1 → pytest_api_cov-1.1.0}/src/pytest_api_cov/frameworks.py +26 -5
- {pytest_api_cov-1.0.1 → pytest_api_cov-1.1.0}/src/pytest_api_cov/models.py +42 -23
- {pytest_api_cov-1.0.1 → pytest_api_cov-1.1.0}/src/pytest_api_cov/plugin.py +28 -12
- {pytest_api_cov-1.0.1 → pytest_api_cov-1.1.0}/src/pytest_api_cov/pytest_flags.py +6 -0
- {pytest_api_cov-1.0.1 → pytest_api_cov-1.1.0}/src/pytest_api_cov/report.py +22 -2
- {pytest_api_cov-1.0.1 → pytest_api_cov-1.1.0}/.gitignore +0 -0
- {pytest_api_cov-1.0.1 → pytest_api_cov-1.1.0}/LICENSE +0 -0
- {pytest_api_cov-1.0.1 → pytest_api_cov-1.1.0}/src/pytest_api_cov/__init__.py +0 -0
@@ -1,19 +1,19 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: pytest-api-cov
|
3
|
-
Version: 1.0
|
4
|
-
Summary:
|
3
|
+
Version: 1.1.0
|
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
|
7
7
|
License-File: LICENSE
|
8
8
|
Requires-Python: >=3.10
|
9
|
-
Requires-Dist: fastapi>=0.
|
10
|
-
Requires-Dist: flask>=
|
11
|
-
Requires-Dist: httpx>=0.
|
12
|
-
Requires-Dist: pydantic>=2.
|
13
|
-
Requires-Dist: pytest>=
|
14
|
-
Requires-Dist: rich>=
|
15
|
-
Requires-Dist: starlette>=0.
|
16
|
-
Requires-Dist: tomli>=
|
9
|
+
Requires-Dist: fastapi>=0.68.0
|
10
|
+
Requires-Dist: flask>=2.0.0
|
11
|
+
Requires-Dist: httpx>=0.20.0
|
12
|
+
Requires-Dist: pydantic>=2.0.0
|
13
|
+
Requires-Dist: pytest>=6.0.0
|
14
|
+
Requires-Dist: rich>=10.0.0
|
15
|
+
Requires-Dist: starlette>=0.14.0
|
16
|
+
Requires-Dist: tomli>=1.2.0
|
17
17
|
Description-Content-Type: text/markdown
|
18
18
|
|
19
19
|
# pytest-api-cov
|
@@ -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
|
@@ -230,6 +280,11 @@ force_sugar_disabled = true
|
|
230
280
|
|
231
281
|
# Wrap an existing custom test client fixture with coverage tracking
|
232
282
|
client_fixture_name = "my_custom_client"
|
283
|
+
|
284
|
+
# Group HTTP methods by endpoint for legacy behavior (default: false)
|
285
|
+
# When true: treats GET /users and POST /users as one "/users" endpoint
|
286
|
+
# When false: treats them as separate "GET /users" and "POST /users" endpoints (recommended)
|
287
|
+
group_methods_by_endpoint = false
|
233
288
|
```
|
234
289
|
|
235
290
|
### Command Line Options
|
@@ -261,6 +316,9 @@ pytest --api-cov-report -v
|
|
261
316
|
|
262
317
|
# Debug logging (very detailed)
|
263
318
|
pytest --api-cov-report -vv
|
319
|
+
|
320
|
+
# Group HTTP methods by endpoint (legacy behavior)
|
321
|
+
pytest --api-cov-report --api-cov-group-methods-by-endpoint
|
264
322
|
```
|
265
323
|
|
266
324
|
## 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
|
@@ -212,6 +262,11 @@ force_sugar_disabled = true
|
|
212
262
|
|
213
263
|
# Wrap an existing custom test client fixture with coverage tracking
|
214
264
|
client_fixture_name = "my_custom_client"
|
265
|
+
|
266
|
+
# Group HTTP methods by endpoint for legacy behavior (default: false)
|
267
|
+
# When true: treats GET /users and POST /users as one "/users" endpoint
|
268
|
+
# When false: treats them as separate "GET /users" and "POST /users" endpoints (recommended)
|
269
|
+
group_methods_by_endpoint = false
|
215
270
|
```
|
216
271
|
|
217
272
|
### Command Line Options
|
@@ -243,6 +298,9 @@ pytest --api-cov-report -v
|
|
243
298
|
|
244
299
|
# Debug logging (very detailed)
|
245
300
|
pytest --api-cov-report -vv
|
301
|
+
|
302
|
+
# Group HTTP methods by endpoint (legacy behavior)
|
303
|
+
pytest --api-cov-report --api-cov-group-methods-by-endpoint
|
246
304
|
```
|
247
305
|
|
248
306
|
## Framework Support
|
@@ -1,23 +1,21 @@
|
|
1
1
|
[project]
|
2
2
|
name = "pytest-api-cov"
|
3
|
-
version = "1.0
|
4
|
-
description = "
|
3
|
+
version = "1.1.0"
|
4
|
+
description = "Pytest Plugin to provide API Coverage statistics for Python Web Frameworks"
|
5
5
|
readme = "README.md"
|
6
|
-
authors = [
|
7
|
-
|
8
|
-
]
|
9
|
-
license = {text = "Apache-2.0"}
|
6
|
+
authors = [{ name = "Barnaby Gill", email = "barnabasgill@gmail.com" }]
|
7
|
+
license = { text = "Apache-2.0" }
|
10
8
|
requires-python = ">=3.10"
|
11
9
|
|
12
10
|
dependencies = [
|
13
|
-
"fastapi>=0.
|
14
|
-
"flask>=
|
15
|
-
"httpx>=0.
|
16
|
-
"pydantic>=2.
|
17
|
-
"rich>=
|
18
|
-
"starlette>=0.
|
19
|
-
"tomli>=
|
20
|
-
"pytest>=
|
11
|
+
"fastapi>=0.68.0",
|
12
|
+
"flask>=2.0.0",
|
13
|
+
"httpx>=0.20.0",
|
14
|
+
"pydantic>=2.0.0",
|
15
|
+
"rich>=10.0.0",
|
16
|
+
"starlette>=0.14.0",
|
17
|
+
"tomli>=1.2.0",
|
18
|
+
"pytest>=6.0.0",
|
21
19
|
]
|
22
20
|
|
23
21
|
[dependency-groups]
|
@@ -36,9 +34,7 @@ fail_under = 70
|
|
36
34
|
show_covered_endpoints = true
|
37
35
|
show_uncovered_endpoints = true
|
38
36
|
show_excluded_endpoints = true
|
39
|
-
exclusion_patterns = [
|
40
|
-
"xyz",
|
41
|
-
]
|
37
|
+
exclusion_patterns = ["xyz"]
|
42
38
|
report_path = "reports/pytest_api_cov.json"
|
43
39
|
|
44
40
|
[tool.pytest.ini_options]
|
@@ -46,10 +42,7 @@ testpaths = ["tests/unit", "tests/integration", "example/tests"]
|
|
46
42
|
pythonpath = ["src"]
|
47
43
|
|
48
44
|
[tool.coverage.report]
|
49
|
-
exclude_lines = [
|
50
|
-
'if __name__ == "__main__":',
|
51
|
-
"if TYPE_CHECKING:",
|
52
|
-
]
|
45
|
+
exclude_lines = ['if __name__ == "__main__":', "if TYPE_CHECKING:"]
|
53
46
|
show_missing = true
|
54
47
|
|
55
48
|
|
@@ -61,12 +54,7 @@ build-backend = "hatchling.build"
|
|
61
54
|
packages = ["src/pytest_api_cov"]
|
62
55
|
|
63
56
|
[tool.hatch.build.targets.sdist]
|
64
|
-
include = [
|
65
|
-
"src/pytest_api_cov/",
|
66
|
-
"README.md",
|
67
|
-
"LICENSE",
|
68
|
-
"pyproject.toml",
|
69
|
-
]
|
57
|
+
include = ["src/pytest_api_cov/", "README.md", "LICENSE", "pyproject.toml"]
|
70
58
|
|
71
59
|
# [[tool.uv.index]]
|
72
60
|
# name = "testpypi"
|
@@ -63,7 +63,7 @@ from {module_path} import {app_variable}
|
|
63
63
|
@pytest.fixture
|
64
64
|
def app():
|
65
65
|
"""Provide the {framework} app for API coverage testing.
|
66
|
-
|
66
|
+
|
67
67
|
You can import from any location - just change the import path above
|
68
68
|
to match your project structure.
|
69
69
|
"""
|
@@ -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."""
|
@@ -65,10 +65,21 @@ def auto_discover_app() -> Optional[Any]:
|
|
65
65
|
f"✅ Auto-discovered {type(app).__name__} app in {filename} as '{attr_name}'"
|
66
66
|
)
|
67
67
|
# Check if there are more files to scan
|
68
|
-
remaining_files = [
|
68
|
+
remaining_files = [
|
69
|
+
f
|
70
|
+
for f in [
|
71
|
+
p[0]
|
72
|
+
for p in common_patterns[common_patterns.index((filename, attr_names)) :]
|
73
|
+
]
|
74
|
+
if os.path.exists(f) and f != filename
|
75
|
+
]
|
69
76
|
if remaining_files:
|
70
|
-
logger.debug(
|
71
|
-
|
77
|
+
logger.debug(
|
78
|
+
f"> Note: Also found files {remaining_files} but using first discovered app"
|
79
|
+
)
|
80
|
+
logger.debug(
|
81
|
+
"> To use a different app, create a conftest.py with an 'app' fixture"
|
82
|
+
)
|
72
83
|
return app
|
73
84
|
else:
|
74
85
|
logger.debug(f"> Found '{attr_name}' in {filename} but it's not a supported framework")
|
@@ -80,9 +91,9 @@ def auto_discover_app() -> Optional[Any]:
|
|
80
91
|
# If we get here, no apps were found
|
81
92
|
if found_files:
|
82
93
|
logger.debug(f"> Found files {found_files} but no supported Flask/FastAPI apps in them")
|
83
|
-
logger.debug(
|
84
|
-
logger.debug(
|
85
|
-
|
94
|
+
logger.debug("> If your app is in one of these files with a different variable name,")
|
95
|
+
logger.debug("> create a conftest.py with an 'app' fixture to specify it")
|
96
|
+
|
86
97
|
logger.debug("> No app auto-discovered")
|
87
98
|
return None
|
88
99
|
|
@@ -178,14 +189,15 @@ def wrap_client_with_coverage(client: Any, recorder: Any, test_name: str) -> Any
|
|
178
189
|
|
179
190
|
def tracked_method(*args: Any, **kwargs: Any) -> Any:
|
180
191
|
response = attr(*args, **kwargs)
|
181
|
-
# Extract path from args[0]
|
192
|
+
# Extract path from args[0] and method from function name
|
182
193
|
if args and recorder is not None:
|
183
194
|
path = args[0]
|
184
195
|
# Clean up the path to match endpoint format
|
185
196
|
if isinstance(path, str):
|
186
197
|
# Remove query parameters
|
187
198
|
path = path.partition("?")[0]
|
188
|
-
|
199
|
+
method = name.upper() # get -> GET, post -> POST, etc.
|
200
|
+
recorder.record_call(path, test_name, method)
|
189
201
|
return response
|
190
202
|
|
191
203
|
return tracked_method
|
@@ -239,8 +251,10 @@ def coverage_client(request: pytest.FixtureRequest) -> Any:
|
|
239
251
|
if not coverage_data.discovered_endpoints.endpoints:
|
240
252
|
endpoints = adapter.get_endpoints()
|
241
253
|
framework_name = type(app).__name__
|
242
|
-
for
|
243
|
-
|
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")
|
244
258
|
logger.info(f"> pytest-api-coverage: Discovered {len(endpoints)} endpoints.")
|
245
259
|
logger.debug(f"> Discovered endpoints: {endpoints}")
|
246
260
|
except Exception as e:
|
@@ -274,8 +288,10 @@ def coverage_client(request: pytest.FixtureRequest) -> Any:
|
|
274
288
|
try:
|
275
289
|
endpoints = adapter.get_endpoints()
|
276
290
|
framework_name = type(app).__name__
|
277
|
-
for
|
278
|
-
|
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")
|
279
295
|
logger.info(f"> pytest-api-coverage: Discovered {len(endpoints)} endpoints.")
|
280
296
|
logger.debug(f"> Discovered endpoints: {endpoints}")
|
281
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
|
+
)
|
@@ -44,7 +44,19 @@ def categorise_endpoints(
|
|
44
44
|
)
|
45
45
|
|
46
46
|
for endpoint in endpoints:
|
47
|
-
|
47
|
+
# Check exclusion patterns against both full "METHOD /path" and just "/path"
|
48
|
+
is_excluded = False
|
49
|
+
if compiled_patterns:
|
50
|
+
# Extract path from "METHOD /path" format for pattern matching
|
51
|
+
if " " in endpoint:
|
52
|
+
_, path_only = endpoint.split(" ", 1)
|
53
|
+
is_excluded = any(p.match(endpoint) for p in compiled_patterns) or any(
|
54
|
+
p.match(path_only) for p in compiled_patterns
|
55
|
+
)
|
56
|
+
else:
|
57
|
+
is_excluded = any(p.match(endpoint) for p in compiled_patterns)
|
58
|
+
|
59
|
+
if is_excluded:
|
48
60
|
excluded.append(endpoint)
|
49
61
|
continue
|
50
62
|
elif contains_escape_characters(endpoint):
|
@@ -67,7 +79,15 @@ def print_endpoints(
|
|
67
79
|
if endpoints:
|
68
80
|
console.print(f"[{style}]{label}[/]:")
|
69
81
|
for endpoint in endpoints:
|
70
|
-
|
82
|
+
# Format endpoint with consistent spacing for HTTP methods
|
83
|
+
if " " in endpoint:
|
84
|
+
method, path = endpoint.split(" ", 1)
|
85
|
+
# Pad method to 6 characters (longest common method is DELETE)
|
86
|
+
formatted_endpoint = f"{method:<6} {path}"
|
87
|
+
else:
|
88
|
+
# Handle legacy format without method
|
89
|
+
formatted_endpoint = endpoint
|
90
|
+
console.print(f" {symbol} [{style}]{formatted_endpoint}[/]")
|
71
91
|
|
72
92
|
|
73
93
|
def compute_coverage(covered_count: int, uncovered_count: int) -> float:
|
File without changes
|
File without changes
|
File without changes
|