Skip to content

Commit fb10177

Browse files
committed
1.2.1 allow for method specific exclusions
1 parent 43a8243 commit fb10177

File tree

5 files changed

+127
-32
lines changed

5 files changed

+127
-32
lines changed

README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,12 +258,14 @@ show_excluded_endpoints = false
258258
# Exclude endpoints from coverage using wildcard patterns with negation support
259259
# Use * for wildcard matching, all other characters are matched literally
260260
# Use ! at the start to negate a pattern (include what would otherwise be excluded)
261+
# Optionally prefix a pattern with one or more HTTP methods to target only those methods,
261262
exclusion_patterns = [
262263
"/health",
263264
"/metrics",
264265
"/docs/*",
265266
"/admin/*",
266267
"!/admin/public",
268+
"GET,POST /users/*"
267269
]
268270

269271
# Save detailed JSON report
@@ -281,6 +283,39 @@ client_fixture_names = ["my_custom_client"]
281283
# Group HTTP methods by endpoint for legacy behavior (default: false)
282284
group_methods_by_endpoint = false
283285

286+
```
287+
Notes on exclusion patterns
288+
289+
- Method prefixes (optional): If a pattern starts with one or more HTTP method names followed by whitespace, the pattern applies only to those methods. Methods may be comma-separated and are matched case-insensitively. Example: `GET,POST /users/*`.
290+
- Path-only patterns (default): If no method is specified the pattern applies to all methods for the matching path (existing behaviour).
291+
- Wildcards: Use `*` to match any characters in the path portion (not a regex; dots and other characters are treated literally unless `*` is used).
292+
- Negation: Prefix a pattern with `!` to override earlier exclusions and re-include a path (or method-specific path). Negations can also include method prefixes (e.g. `!GET /admin/health`).
293+
- Matching: Patterns are tested against both the full `METHOD /path` string and the `/path` portion to remain compatible with existing configurations.
294+
295+
Examples (pyproject or CLI):
296+
297+
- Exclude the `/health` path for all methods:
298+
299+
```toml
300+
exclusion_patterns = ["/health"]
301+
```
302+
303+
- Exclude only GET requests to `/health`:
304+
305+
```toml
306+
exclusion_patterns = ["GET /health"]
307+
```
308+
309+
- Exclude GET and POST for `/users/*` but re-include GET /users/42:
310+
311+
```toml
312+
exclusion_patterns = ["GET,POST /users/*", "!GET /users/42"]
313+
```
314+
315+
Or using the CLI flags (repeatable):
316+
317+
```bash
318+
pytest --api-cov-report --api-cov-exclusion-patterns="GET,POST /users/*" --api-cov-exclusion-patterns="!GET /users/42"
284319
```
285320

286321
### Command Line Options

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "pytest-api-cov"
3-
version = "1.2.0"
3+
version = "1.2.1"
44
description = "Pytest Plugin to provide API Coverage statistics for Python Web Frameworks"
55
readme = "README.md"
66
authors = [{ name = "Barnaby Gill", email = "barnabasgill@gmail.com" }]

src/pytest_api_cov/report.py

Lines changed: 57 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import re
55
from pathlib import Path
66
from re import Pattern
7-
from typing import Any, Dict, List, Set, Tuple
7+
from typing import Any, Dict, List, Optional, Set, Tuple
88

99
from rich.console import Console
1010

@@ -30,11 +30,14 @@ def categorise_endpoints(
3030
) -> Tuple[List[str], List[str], List[str]]:
3131
"""Categorise endpoints into covered, uncovered, and excluded.
3232
33-
Exclusion patterns support simple wildcard matching with negation:
33+
Exclusion patterns support simple wildcard matching with negation and optional
34+
HTTP method prefixes:
3435
- Use * for wildcard (matches any characters)
3536
- Use ! at the start to negate a pattern (include what would otherwise be excluded)
37+
- Optionally prefix a pattern with one or more HTTP methods to target only those methods,
38+
e.g. "GET /health" or "GET,POST /users/*" (methods are case-insensitive)
3639
- All other characters are matched literally
37-
- Examples: "/admin/*", "/health", "!users/bob" (negates exclusion)
40+
- Examples: "/admin/*", "/health", "!users/bob", "GET /health", "GET,POST /users/*"
3841
- Pattern order matters: exclusions are applied first, then negations override them
3942
"""
4043
covered, uncovered, excluded = [], [], []
@@ -47,39 +50,63 @@ def categorise_endpoints(
4750
exclusion_only = [p for p in exclusion_patterns if not p.startswith("!")]
4851
negation_only = [p[1:] for p in exclusion_patterns if p.startswith("!")] # Remove the '!' prefix
4952

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-
)
53+
def compile_patterns(patterns: List[str]) -> List[Tuple[Optional[Set[str]], Pattern[str]]]:
54+
compiled: List[Tuple[Optional[Set[str]], Pattern[str]]] = []
55+
for pat in patterns:
56+
path_pattern = pat.strip()
57+
methods: Optional[Set[str]] = None
58+
# Detect method prefix
59+
m = re.match(r"^([A-Za-z,]+)\s+(.+)$", pat)
60+
if m:
61+
methods = {mname.strip().upper() for mname in m.group(1).split(",") if mname.strip()}
62+
path_pattern = m.group(2)
63+
# Build regex from the path part
64+
regex = re.compile("^" + re.escape(path_pattern).replace(r"\*", ".*") + "$")
65+
compiled.append((methods, regex))
66+
return compiled
67+
68+
compiled_exclusions = compile_patterns(exclusion_only) if exclusion_only else None
69+
compiled_negations = compile_patterns(negation_only) if negation_only else None
6070

6171
for endpoint in endpoints:
6272
# Check exclusion patterns against both full "METHOD /path" and just "/path"
6373
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)
74+
endpoint_method = None
75+
path_only = endpoint
76+
if " " in endpoint:
77+
endpoint_method, path_only = endpoint.split(" ", 1)
78+
endpoint_method = endpoint_method.upper()
7379

74-
# Check negation patterns - these override exclusions
80+
if compiled_exclusions:
81+
for methods_set, regex in compiled_exclusions:
82+
if methods_set:
83+
if not endpoint_method:
84+
continue
85+
if endpoint_method not in methods_set:
86+
continue
87+
if regex.match(path_only) or regex.match(endpoint):
88+
is_excluded = True
89+
break
90+
# No methods specified
91+
elif regex.match(path_only) or regex.match(endpoint):
92+
is_excluded = True
93+
break
94+
95+
# Negation patterns
7596
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)
97+
is_negated = False
98+
for methods_set, regex in compiled_negations:
99+
if methods_set:
100+
if not endpoint_method:
101+
continue
102+
if endpoint_method not in methods_set:
103+
continue
104+
if regex.match(path_only) or regex.match(endpoint):
105+
is_negated = True
106+
break
107+
elif regex.match(path_only) or regex.match(endpoint):
108+
is_negated = True
109+
break
83110

84111
if is_negated:
85112
is_excluded = False # Negation overrides exclusion

tests/unit/test_report.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,39 @@ def test_categorise_complex_exclusion_negation_scenario(self):
159159
assert set(uncovered) == {"/api/v2/users", "/api/v2/admin", "/docs"}
160160
assert set(excluded_out) == {"/api/v1/admin", "/metrics"}
161161

162+
def test_categorise_with_method_specific_exclusion(self):
163+
"""Exclude a specific HTTP method for an endpoint using 'METHOD /path' patterns."""
164+
discovered = ["GET /items", "POST /items", "GET /health"]
165+
called = {"POST /items"}
166+
patterns = ["GET /items"] # Exclude only GET /items
167+
168+
covered, uncovered, excluded_out = categorise_endpoints(discovered, called, patterns)
169+
assert set(covered) == {"POST /items"}
170+
assert set(uncovered) == {"GET /health"}
171+
assert set(excluded_out) == {"GET /items"}
172+
173+
def test_categorise_with_multiple_method_prefixes(self):
174+
"""Support comma-separated method prefixes to exclude multiple methods for a path."""
175+
discovered = ["GET /users/1", "POST /users/1", "PUT /users/1"]
176+
called = {"PUT /users/1"}
177+
patterns = ["GET,POST /users/*"] # Exclude GET and POST for users
178+
179+
covered, uncovered, excluded_out = categorise_endpoints(discovered, called, patterns)
180+
assert set(covered) == {"PUT /users/1"}
181+
assert set(uncovered) == set()
182+
assert set(excluded_out) == {"GET /users/1", "POST /users/1"}
183+
184+
def test_categorise_method_prefixed_negation(self):
185+
"""Negation with a method prefix should re-include only that method."""
186+
discovered = ["GET /users/alice", "POST /users/alice", "GET /users/bob"]
187+
called = {"GET /users/bob"}
188+
patterns = ["/users/*", "!GET /users/bob"] # Exclude all users but re-include GET /users/bob
189+
190+
covered, uncovered, excluded_out = categorise_endpoints(discovered, called, patterns)
191+
assert set(covered) == {"GET /users/bob"}
192+
assert set(uncovered) == set()
193+
assert set(excluded_out) == {"GET /users/alice", "POST /users/alice"}
194+
162195

163196
class TestCoverageCalculationAndReporting:
164197
"""Tests for coverage computation and report generation."""

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)