Skip to content

Commit e18f232

Browse files
authored
Merge pull request #12 from BarnabasG/initial
1.2.1 Method specific exclusions
2 parents 937c7c3 + 06f7239 commit e18f232

File tree

8 files changed

+183
-78
lines changed

8 files changed

+183
-78
lines changed

README.md

Lines changed: 57 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,28 @@ Default client fixture names the plugin will look for (in order):
189189

190190
If you use a different fixture name, you can provide one or more names via the CLI flag `--api-cov-client-fixture-names` (repeatable) or in `pyproject.toml` under `[tool.pytest_api_cov]` as `client_fixture_names` (a list).
191191

192-
#### Option 1: Helper Function
192+
193+
#### Option 1: Configuration-Based (recommended for most users)
194+
195+
Configure one or more existing fixture names to be discovered and wrapped automatically by the plugin.
196+
197+
Example `pyproject.toml`:
198+
199+
```toml
200+
[tool.pytest_api_cov]
201+
# Provide a list of candidate fixture names the plugin should try (order matters)
202+
client_fixture_names = ["my_custom_client"]
203+
```
204+
205+
Or use the CLI flag multiple times:
206+
207+
```bash
208+
pytest --api-cov-report --api-cov-client-fixture-names=my_custom_client --api-cov-client-fixture-names=another_fixture
209+
```
210+
211+
If the configured fixture(s) are not found, the plugin will try to use an `app` fixture (if present) to create a tracked client. If neither is available or the plugin cannot extract the app from a discovered client fixture, the tests will still run — coverage will simply be unavailable and a warning will be logged.
212+
213+
#### Option 2: Helper Function
193214

194215
Use the `create_coverage_fixture` helper to create a custom fixture name:
195216

@@ -221,25 +242,6 @@ def test_with_flask_client(flask_client):
221242

222243
The helper returns a pytest fixture you can assign to a name in `conftest.py`.
223244

224-
#### Option 2: Configuration-Based (recommended for most users)
225-
226-
Configure one or more existing fixture names to be discovered and wrapped automatically by the plugin.
227-
228-
Example `pyproject.toml`:
229-
230-
```toml
231-
[tool.pytest_api_cov]
232-
# Provide a list of candidate fixture names the plugin should try (order matters)
233-
client_fixture_names = ["my_custom_client"]
234-
```
235-
236-
Or use the CLI flag multiple times:
237-
238-
```bash
239-
pytest --api-cov-report --api-cov-client-fixture-names=my_custom_client --api-cov-client-fixture-names=another_fixture
240-
```
241-
242-
If the configured fixture(s) are not found, the plugin will try to use an `app` fixture (if present) to create a tracked client. If neither is available or the plugin cannot extract the app from a discovered client fixture, the tests will still run — coverage will simply be unavailable and a warning will be logged.
243245

244246
### Configuration Options
245247

@@ -258,12 +260,14 @@ show_excluded_endpoints = false
258260
# Exclude endpoints from coverage using wildcard patterns with negation support
259261
# Use * for wildcard matching, all other characters are matched literally
260262
# Use ! at the start to negate a pattern (include what would otherwise be excluded)
263+
# Optionally prefix a pattern with one or more HTTP methods to target only those methods,
261264
exclusion_patterns = [
262265
"/health",
263266
"/metrics",
264267
"/docs/*",
265268
"/admin/*",
266269
"!/admin/public",
270+
"GET,POST /users/*"
267271
]
268272

269273
# Save detailed JSON report
@@ -281,6 +285,39 @@ client_fixture_names = ["my_custom_client"]
281285
# Group HTTP methods by endpoint for legacy behavior (default: false)
282286
group_methods_by_endpoint = false
283287

288+
```
289+
Notes on exclusion patterns
290+
291+
- 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/*`.
292+
- Path-only patterns (default): If no method is specified the pattern applies to all methods for the matching path (existing behaviour).
293+
- Wildcards: Use `*` to match any characters in the path portion (not a regex; dots and other characters are treated literally unless `*` is used).
294+
- 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`).
295+
- Matching: Patterns are tested against both the full `METHOD /path` string and the `/path` portion to remain compatible with existing configurations.
296+
297+
Examples (pyproject or CLI):
298+
299+
- Exclude the `/health` path for all methods:
300+
301+
```toml
302+
exclusion_patterns = ["/health"]
303+
```
304+
305+
- Exclude only GET requests to `/health`:
306+
307+
```toml
308+
exclusion_patterns = ["GET /health"]
309+
```
310+
311+
- Exclude GET and POST for `/users/*` but re-include GET /users/42:
312+
313+
```toml
314+
exclusion_patterns = ["GET,POST /users/*", "!GET /users/42"]
315+
```
316+
317+
Or using the CLI flags (repeatable):
318+
319+
```bash
320+
pytest --api-cov-report --api-cov-exclusion-patterns="GET,POST /users/*" --api-cov-exclusion-patterns="!GET /users/42"
284321
```
285322

286323
### Command Line Options

example/conftest.py

Lines changed: 0 additions & 16 deletions
This file was deleted.

example/tests/conftest.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""example/conftest.py"""
2+
3+
import pytest
4+
from fastapi.testclient import TestClient
5+
6+
from example.src.main import app as fastapi_app
7+
from pytest_api_cov.plugin import create_coverage_fixture
8+
9+
10+
@pytest.fixture
11+
def original_client():
12+
"""Original FastAPI test client fixture.
13+
14+
This fixture demonstrates an existing user-provided client that we can wrap
15+
with `create_coverage_fixture` so tests can continue to use the familiar
16+
`client` fixture name while gaining API coverage tracking.
17+
"""
18+
return TestClient(fastapi_app)
19+
20+
21+
# Create a wrapped fixture named 'client' that wraps the existing 'original_client'.
22+
# Tests can continue to request the `client` fixture as before and coverage will be
23+
# collected when pytest is run with --api-cov-report.
24+
client = create_coverage_fixture("client", "original_client")

example/tests/test_endpoints.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,33 @@
11
"""example/tests/test_endpoints.py"""
22

33

4-
def test_root_path(coverage_client):
4+
def test_root_path(client):
55
"""Test the root endpoint."""
6-
response = coverage_client.get("/")
6+
response = client.get("/")
77
assert response.status_code == 200
88
assert response.json() == {"message": "Hello World"}
99

1010

11-
def test_items_path(coverage_client):
11+
def test_items_path(client):
1212
"""Test the items endpoint."""
13-
response = coverage_client.get("/items/42")
13+
response = client.get("/items/42")
1414
assert response.status_code == 200
1515
assert response.json() == {"item_id": 42}
1616

1717

18-
def test_create_item(coverage_client):
18+
def test_create_item(client):
1919
"""Test creating an item."""
20-
response = coverage_client.post("/items", json={"name": "test item"})
20+
response = client.post("/items", json={"name": "test item"})
2121
assert response.status_code == 200
2222
assert response.json()["message"] == "Item created"
2323

2424

25-
def test_xyz_and_root_path(coverage_client):
25+
def test_xyz_and_root_path(client):
2626
"""Test the xyz endpoint."""
27-
response = coverage_client.get("/xyz/123")
27+
response = client.get("/xyz/123")
2828
assert response.status_code == 404
29-
response = coverage_client.get("/xyzzyx")
29+
response = client.get("/xyzzyx")
3030
assert response.status_code == 200
31-
response = coverage_client.get("/")
31+
response = client.get("/")
3232
assert response.status_code == 200
3333
assert response.json() == {"message": "Hello World"}

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)