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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-api-cov
3
- Version: 1.0.2
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
- [X] /health
116
+ GET /health
109
117
 
110
118
  Total API Coverage: 66.67%
111
119
  ```
112
120
 
113
- Or running with advanced options `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 --api-cov-fail-under=49` produces:
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
- [X] /health
129
+ GET /health
119
130
  Covered Endpoints:
120
- [.] /
131
+ GET /
121
132
  Excluded Endpoints:
122
- [-] /users/{user_id}
133
+ 🚫 GET /users/{user_id}
134
+ 🚫 POST /users
123
135
 
124
- SUCCESS: Coverage of 50.0% meets requirement of 49.0%
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 simple wildcard patterns
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
- "/api/v1.0/*" # Exact version match (won't match /api/v1x0/*)
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
- [X] /health
98
+ GET /health
91
99
 
92
100
  Total API Coverage: 66.67%
93
101
  ```
94
102
 
95
- Or running with advanced options `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 --api-cov-fail-under=49` produces:
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
- [X] /health
111
+ GET /health
101
112
  Covered Endpoints:
102
- [.] /
113
+ GET /
103
114
  Excluded Endpoints:
104
- [-] /users/{user_id}
115
+ 🚫 GET /users/{user_id}
116
+ 🚫 POST /users
105
117
 
106
- SUCCESS: Coverage of 50.0% meets requirement of 49.0%
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 simple wildcard patterns
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
- "/api/v1.0/*" # Exact version match (won't match /api/v1x0/*)
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pytest-api-cov"
3
- version = "1.0.2"
3
+ version = "1.1.1"
4
4
  description = "Pytest Plugin to provide API Coverage statistics for Python Web Frameworks"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Barnaby Gill", email = "barnabasgill@gmail.com" }]
@@ -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
- return sorted([rule.rule for rule in self.app.url_map.iter_rules() if rule.rule not in excluded_rules])
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=kwargs.get("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
- return sorted([route.path for route in self.app.routes if isinstance(route, APIRoute)])
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
- recorder.record_call(request.url.path, test_name)
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
- if endpoint not in self.calls:
18
- self.calls[endpoint] = set()
19
- self.calls[endpoint].add(test_name)
20
-
21
- def get_called_endpoints(self) -> List[str]:
22
- """Get list of all endpoints that have been called."""
23
- return list(self.calls.keys())
24
-
25
- def get_callers(self, endpoint: str) -> Set[str]:
26
- """Get the set of test names that called a specific endpoint."""
27
- return self.calls.get(endpoint, set())
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
- if endpoint not in self.endpoints:
76
- self.endpoints.append(endpoint)
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.add_endpoint(endpoint)
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
- recorder.record_call(path, test_name)
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 endpoint in endpoints:
254
- coverage_data.add_discovered_endpoint(endpoint, f"{framework_name.lower()}_adapter")
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 endpoint in endpoints:
289
- coverage_data.add_discovered_endpoint(endpoint, f"{framework_name.lower()}_adapter")
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", "/docs/*"
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
- compiled_patterns = (
41
- [re.compile("^" + re.escape(pattern).replace(r"\*", ".*") + "$") for pattern in exclusion_patterns]
42
- if exclusion_patterns
43
- else None
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
- if compiled_patterns and any(p.match(endpoint) for p in compiled_patterns):
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
- console.print(f" {symbol} [{style}]{endpoint}[/]")
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