Skip to content

Commit 795d785

Browse files
authored
Merge pull request #7 from BarnabasG/initial
1.1.4 Ruff, mypy, vulture, ci updates
2 parents b509b0a + 1b92209 commit 795d785

File tree

16 files changed

+660
-477
lines changed

16 files changed

+660
-477
lines changed

.github/workflows/ci.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ jobs:
5151
- name: Run tests
5252
run: |
5353
make test
54+
55+
- name: Run test example
56+
run: |
57+
make test-example
58+
59+
- name: Run test example parallel (xdist)
60+
run: |
61+
make test-example-parallel
5462
5563
- name: Run integration tests
5664
run: |

Makefile

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,20 @@ version:
99
@uv version
1010

1111
ruff:
12-
@echo "Running ruff..."
13-
@uv run ruff format .
14-
@uv run ruff check .
12+
@echo "Running ruff format on src, tests, and example..."
13+
@uv run ruff format src tests example
14+
@echo "Running ruff check on src"
15+
@uv run ruff check src
1516

1617
mypy:
1718
@echo "Running mypy..."
1819
@uv run mypy
1920

20-
format: ruff mypy
21+
vulture:
22+
@echo "Running vulture..."
23+
@uv run vulture
24+
25+
format: ruff mypy vulture
2126

2227
test:
2328
@echo "Running plugin tests..."

pyproject.toml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ dev = [
2727
"pytest-xdist>=3.8.0",
2828
"ruff>=0.12.3",
2929
"typeguard>=4.4.4",
30+
"vulture>=2.14",
3031
]
3132

3233
# API COVERAGE
@@ -134,6 +135,22 @@ lint.ignore = [
134135
"TD003",
135136
"FIX002",
136137
"PLC0415",
138+
"PLR0912",
139+
"PLR0915",
140+
"C901",
141+
# Print statements are fine for CLI tools
142+
"T201",
143+
# Any types are common in plugin/interop code
144+
"ANN401",
145+
# Exception patterns
146+
"EM102",
147+
# Try-except patterns
148+
"S110",
149+
"S112",
150+
# Magic numbers
151+
"PLR2004",
152+
# Private member access
153+
"SLF001",
137154
]
138155

139156
[tool.ruff.lint.per-file-ignores]
@@ -157,3 +174,10 @@ pretty = true
157174
show_column_numbers = true
158175
show_error_codes = true
159176
show_error_context = true
177+
178+
[tool.vulture]
179+
exclude = []
180+
min_confidence = 80
181+
paths = ["src", "tests", "example"]
182+
sort_by_size = true
183+
verbose = false

src/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1+
"""Package root."""
2+
13
# This file makes the src directory a Python package

src/pytest_api_cov/cli.py

Lines changed: 17 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,42 @@
11
"""CLI commands for setup and configuration."""
22

33
import argparse
4-
import os
54
import sys
5+
from pathlib import Path
66
from typing import Optional, Tuple
77

88

99
def detect_framework_and_app() -> Optional[Tuple[str, str, str]]:
1010
"""Detect framework and app location.
11+
1112
Returns (framework, file_path, app_variable) or None.
1213
"""
13-
import glob
14-
1514
# Search for app files at any depth in current directory
1615
app_patterns = ["app.py", "main.py", "server.py", "wsgi.py", "asgi.py"]
1716
common_vars = ["app", "application", "main", "server"]
1817

1918
# Find all matching files recursively
20-
found_files = []
21-
for pattern in app_patterns:
22-
found_files.extend(glob.glob(f"**/{pattern}", recursive=True))
19+
found_files = [file_path for pattern in app_patterns for file_path in Path().rglob(pattern)]
2320

2421
# Sort by depth (shallowest first) and then by filename priority
25-
found_files.sort(key=lambda x: (x.count(os.sep), app_patterns.index(os.path.basename(x))))
22+
found_files.sort(key=lambda p: (len(p.parts), app_patterns.index(p.name)))
2623

2724
for file_path in found_files:
2825
try:
29-
with open(file_path, "r") as f:
30-
content = f.read()
26+
content = file_path.read_text()
3127

3228
if "from fastapi import" in content or "import fastapi" in content:
3329
framework = "FastAPI"
3430
elif "from flask import" in content or "import flask" in content:
3531
framework = "Flask"
3632
else:
37-
continue
33+
continue # Not a framework file we care about
3834

3935
for var_name in common_vars:
4036
if f"{var_name} = " in content:
41-
return framework, file_path, var_name
37+
return framework, file_path.as_posix(), var_name
4238

43-
except Exception:
39+
except (IOError, UnicodeDecodeError):
4440
continue
4541

4642
return None
@@ -88,7 +84,7 @@ def app():
8884
'''
8985

9086

91-
def generate_pyproject_config(framework: str) -> str:
87+
def generate_pyproject_config() -> str:
9288
"""Generate pyproject.toml configuration section."""
9389
return """
9490
# pytest-api-cov configuration
@@ -133,7 +129,7 @@ def cmd_init() -> int:
133129
framework, file_path, app_variable = detection_result
134130
print(f"✅ Detected {framework} app in {file_path} (variable: {app_variable})")
135131

136-
conftest_exists = os.path.exists("conftest.py")
132+
conftest_exists = Path("conftest.py").exists()
137133
if conftest_exists:
138134
print("⚠️ conftest.py already exists")
139135
create_conftest = input("Do you want to overwrite it? (y/N): ").lower().startswith("y")
@@ -142,28 +138,28 @@ def cmd_init() -> int:
142138

143139
if create_conftest:
144140
conftest_content = generate_conftest_content(framework, file_path, app_variable)
145-
with open("conftest.py", "w") as f:
141+
with Path("conftest.py").open("w") as f:
146142
f.write(conftest_content)
147143
print("✅ Created conftest.py")
148144

149-
pyproject_exists = os.path.exists("pyproject.toml")
145+
pyproject_exists = Path("pyproject.toml").exists()
150146
if pyproject_exists:
151-
print("ℹ️ pyproject.toml already exists")
147+
print("ℹ️ pyproject.toml already exists") # noqa: RUF001
152148
print("Add this configuration to your pyproject.toml:")
153-
print(generate_pyproject_config(framework))
149+
print(generate_pyproject_config())
154150
else:
155151
create_pyproject = input("Create pyproject.toml with pytest-api-cov config? (Y/n): ").lower()
156152
if not create_pyproject.startswith("n"):
157153
pyproject_content = f"""[project]
158154
name = "your-project"
159155
version = "0.1.0"
160156
161-
{generate_pyproject_config(framework)}
157+
{generate_pyproject_config()}
162158
163159
[tool.pytest.ini_options]
164160
testpaths = ["tests"]
165161
"""
166-
with open("pyproject.toml", "w") as f:
162+
with Path("pyproject.toml").open("w") as f:
167163
f.write(pyproject_content)
168164
print("✅ Created pyproject.toml")
169165

@@ -205,7 +201,7 @@ def read_root():
205201

206202

207203
def main() -> int:
208-
"""Main CLI entry point."""
204+
"""Run the main CLI entry point."""
209205
parser = argparse.ArgumentParser(prog="pytest-api-cov", description="pytest API coverage plugin CLI tools")
210206

211207
subparsers = parser.add_subparsers(dest="command", help="Available commands")

src/pytest_api_cov/config.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Configuration handling for the API coverage report."""
22

33
import sys
4+
from pathlib import Path
45
from typing import Any, Dict, List, Optional
56

67
import tomli
@@ -13,21 +14,21 @@ class ApiCoverageReportConfig(BaseModel):
1314
model_config = ConfigDict(populate_by_name=True)
1415

1516
fail_under: Optional[float] = Field(None, alias="api-cov-fail-under")
16-
show_uncovered_endpoints: bool = Field(True, alias="api-cov-show-uncovered-endpoints")
17-
show_covered_endpoints: bool = Field(False, alias="api-cov-show-covered-endpoints")
18-
show_excluded_endpoints: bool = Field(False, alias="api-cov-show-excluded-endpoints")
19-
exclusion_patterns: List[str] = Field([], alias="api-cov-exclusion-patterns")
17+
show_uncovered_endpoints: bool = Field(default=True, alias="api-cov-show-uncovered-endpoints")
18+
show_covered_endpoints: bool = Field(default=False, alias="api-cov-show-covered-endpoints")
19+
show_excluded_endpoints: bool = Field(default=False, alias="api-cov-show-excluded-endpoints")
20+
exclusion_patterns: List[str] = Field(default=[], alias="api-cov-exclusion-patterns")
2021
report_path: Optional[str] = Field(None, alias="api-cov-report-path")
21-
force_sugar: bool = Field(False, alias="api-cov-force-sugar")
22-
force_sugar_disabled: bool = Field(False, alias="api-cov-force-sugar-disabled")
22+
force_sugar: bool = Field(default=False, alias="api-cov-force-sugar")
23+
force_sugar_disabled: bool = Field(default=False, alias="api-cov-force-sugar-disabled")
2324
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")
25+
group_methods_by_endpoint: bool = Field(default=False, alias="api-cov-group-methods-by-endpoint")
2526

2627

2728
def read_toml_config() -> Dict[str, Any]:
2829
"""Read the [tool.pytest_api_cov] section from pyproject.toml."""
2930
try:
30-
with open("pyproject.toml", "rb") as f:
31+
with Path("pyproject.toml").open("rb") as f:
3132
toml_config = tomli.load(f)
3233
return toml_config.get("tool", {}).get("pytest_api_cov", {}) # type: ignore[no-any-return]
3334
except (FileNotFoundError, tomli.TOMLDecodeError):
@@ -65,7 +66,8 @@ def supports_unicode() -> bool:
6566

6667
def get_pytest_api_cov_report_config(session_config: Any) -> ApiCoverageReportConfig:
6768
"""Get the final API coverage configuration by merging sources.
68-
Priority: CLI > pyproject.toml > Defaults
69+
70+
Priority: CLI > pyproject.toml > Defaults.
6971
"""
7072
toml_config = read_toml_config()
7173
cli_config = read_session_config(session_config)

src/pytest_api_cov/frameworks.py

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@
77

88

99
class BaseAdapter:
10-
def __init__(self, app: Any):
10+
"""Base adapter for framework applications."""
11+
12+
def __init__(self, app: Any) -> None:
13+
"""Initialize the adapter."""
1114
self.app = app
1215

1316
def get_endpoints(self) -> List[str]:
@@ -20,20 +23,23 @@ def get_tracked_client(self, recorder: Optional["ApiCallRecorder"], test_name: s
2023

2124

2225
class FlaskAdapter(BaseAdapter):
26+
"""Adapter for Flask applications."""
27+
2328
def get_endpoints(self) -> List[str]:
2429
"""Return list of 'METHOD /path' strings."""
2530
excluded_rules = ("/static/<path:filename>",)
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}")
31+
endpoints = [
32+
f"{method} {rule.rule}"
33+
for rule in self.app.url_map.iter_rules()
34+
if rule.rule not in excluded_rules
35+
for method in rule.methods
36+
if method not in ("HEAD", "OPTIONS")
37+
]
3338

3439
return sorted(endpoints)
3540

3641
def get_tracked_client(self, recorder: Optional["ApiCallRecorder"], test_name: str) -> Any:
42+
"""Return a patched test client that records calls."""
3743
from flask.testing import FlaskClient
3844

3945
if recorder is None:
@@ -49,28 +55,32 @@ def open(self, *args: Any, **kwargs: Any) -> Any:
4955
endpoint_name, _ = self.application.url_map.bind("").match(path, method=method)
5056
endpoint_rule_string = next(self.application.url_map.iter_rules(endpoint_name)).rule
5157
recorder.record_call(endpoint_rule_string, test_name, method) # type: ignore[union-attr]
52-
except Exception:
58+
except Exception: # noqa: BLE001
5359
pass
5460
return super().open(*args, **kwargs)
5561

5662
return TrackingFlaskClient(self.app, self.app.response_class)
5763

5864

5965
class FastAPIAdapter(BaseAdapter):
66+
"""Adapter for FastAPI applications."""
67+
6068
def get_endpoints(self) -> List[str]:
6169
"""Return list of 'METHOD /path' strings."""
6270
from fastapi.routing import APIRoute
6371

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}")
72+
endpoints = [
73+
f"{method} {route.path}"
74+
for route in self.app.routes
75+
if isinstance(route, APIRoute)
76+
for method in route.methods
77+
if method not in ("HEAD", "OPTIONS")
78+
]
7079

7180
return sorted(endpoints)
7281

7382
def get_tracked_client(self, recorder: Optional["ApiCallRecorder"], test_name: str) -> Any:
83+
"""Return a patched test client that records calls."""
7484
from starlette.testclient import TestClient
7585

7686
if recorder is None:
@@ -89,7 +99,7 @@ def send(self, *args: Any, **kwargs: Any) -> Any:
8999

90100

91101
def get_framework_adapter(app: Any) -> BaseAdapter:
92-
"""Detects the framework and returns the appropriate adapter."""
102+
"""Detect the framework and return the appropriate adapter."""
93103
app_type = type(app).__name__
94104
module_name = getattr(type(app), "__module__", "").split(".")[0]
95105

src/pytest_api_cov/models.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,6 @@ def __len__(self) -> int:
9393
"""Return number of discovered endpoints."""
9494
return len(self.endpoints)
9595

96-
def __iter__(self) -> Iterable[str]: # type: ignore[override]
97-
"""Iterate over discovered endpoints."""
98-
return iter(self.endpoints)
99-
10096

10197
class SessionData(BaseModel):
10298
"""Model for session-level API coverage data."""

0 commit comments

Comments
 (0)