pytest-api-cov 1.0.0__py3-none-any.whl → 1.0.2__py3-none-any.whl

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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  try:
4
4
  from importlib.metadata import version
5
+
5
6
  __version__ = version("pytest-api-cov")
6
7
  except ImportError:
7
8
  __version__ = "unknown"
pytest_api_cov/cli.py CHANGED
@@ -56,22 +56,36 @@ def generate_conftest_content(framework: str, file_path: str, app_variable: str)
56
56
 
57
57
  import pytest
58
58
 
59
- # Import your {framework} app
59
+ # Import your {framework} app from anywhere in your project
60
60
  from {module_path} import {app_variable}
61
61
 
62
62
 
63
63
  @pytest.fixture
64
- def client():
65
- """Provide the {framework} client for API coverage testing.
66
-
67
- In your test:
68
- ```
69
- def test_root_endpoint(client):
70
- response = client.get("/")
71
- assert response.status_code == 200
72
- ```
64
+ def app():
65
+ """Provide the {framework} app for API coverage testing.
66
+
67
+ You can import from any location - just change the import path above
68
+ to match your project structure.
73
69
  """
74
70
  return {app_variable}
71
+
72
+
73
+ # The plugin will automatically create a 'coverage_client' fixture that uses your 'app' fixture
74
+ # You can use either:
75
+ # - def test_endpoint(app): ... # Direct app access
76
+ # - def test_endpoint(coverage_client): ... # Test client with API coverage tracking
77
+ #
78
+ # To wrap an existing custom fixture instead, specify the fixture name in pyproject.toml:
79
+ # [tool.pytest_api_cov]
80
+ # client_fixture_name = "my_custom_client"
81
+ #
82
+ # Example custom fixture:
83
+ # @pytest.fixture
84
+ # def my_custom_client(app):
85
+ # client = app.test_client() # Flask
86
+ # # or client = TestClient(app) # FastAPI
87
+ # # Add custom setup here (auth headers, etc.)
88
+ # return client
75
89
  '''
76
90
 
77
91
 
@@ -100,6 +114,9 @@ show_excluded_endpoints = false
100
114
 
101
115
  # Force Unicode symbols in terminal output (optional)
102
116
  # force_sugar = true
117
+
118
+ # Wrap an existing custom test client fixture with coverage tracking (optional)
119
+ # client_fixture_name = "my_custom_client"
103
120
  """
104
121
 
105
122
 
@@ -152,13 +169,13 @@ testpaths = ["tests"]
152
169
  print("🎉 Setup complete!")
153
170
  print()
154
171
  print("Next steps:")
155
- print("1. Write your tests using the 'client' fixture")
172
+ print("1. Write your tests using the 'coverage_client' fixture")
156
173
  print("2. Run: pytest --api-cov-report")
157
174
  print()
158
175
  print("Example test:")
159
176
  print("""
160
- def test_root_endpoint(client):
161
- response = client.get("/")
177
+ def test_root_endpoint(coverage_client):
178
+ response = coverage_client.get("/")
162
179
  assert response.status_code == 200
163
180
  """)
164
181
 
pytest_api_cov/config.py CHANGED
@@ -20,6 +20,7 @@ class ApiCoverageReportConfig(BaseModel):
20
20
  report_path: Optional[str] = Field(None, alias="api-cov-report-path")
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
+ client_fixture_name: str = Field("coverage_client", alias="api-cov-client-fixture-name")
23
24
 
24
25
 
25
26
  def read_toml_config() -> Dict[str, Any]:
@@ -43,6 +44,7 @@ def read_session_config(session_config: Any) -> Dict[str, Any]:
43
44
  "api-cov-report-path": "report_path",
44
45
  "api-cov-force-sugar": "force_sugar",
45
46
  "api-cov-force-sugar-disabled": "force_sugar_disabled",
47
+ "api-cov-client-fixture-name": "client_fixture_name",
46
48
  }
47
49
  config = {}
48
50
  for opt, key in cli_options.items():
pytest_api_cov/plugin.py CHANGED
@@ -1,270 +1,374 @@
1
- """pytest plugin for API coverage tracking."""
2
-
3
- import importlib
4
- import importlib.util
5
- import logging
6
- import os
7
- from typing import Any, Optional
8
-
9
- import pytest
10
-
11
- from .config import get_pytest_api_cov_report_config
12
- from .frameworks import get_framework_adapter
13
- from .models import SessionData
14
- from .pytest_flags import add_pytest_api_cov_flags
15
- from .report import generate_pytest_api_cov_report
16
-
17
- logger = logging.getLogger(__name__)
18
-
19
-
20
- def is_supported_framework(app: Any) -> bool:
21
- """Check if the app is a supported framework (Flask or FastAPI)."""
22
- if app is None:
23
- return False
24
-
25
- app_type = type(app).__name__
26
- module_name = getattr(type(app), "__module__", "").split(".")[0]
27
-
28
- return (module_name == "flask" and app_type == "Flask") or (module_name == "fastapi" and app_type == "FastAPI")
29
-
30
-
31
- def auto_discover_app() -> Optional[Any]:
32
- """Automatically discover Flask/FastAPI apps in common locations."""
33
- logger.debug("> Auto-discovering app in common locations...")
34
-
35
- common_patterns = [
36
- ("app.py", ["app", "application", "main"]),
37
- ("main.py", ["app", "application", "main"]),
38
- ("server.py", ["app", "application", "server"]),
39
- ("wsgi.py", ["app", "application"]),
40
- ("asgi.py", ["app", "application"]),
41
- ]
42
-
43
- for filename, attr_names in common_patterns:
44
- if os.path.exists(filename):
45
- logger.debug(f"> Found {filename}, checking for app variables...")
46
- try:
47
- module_name = filename[:-3] # .py extension
48
- spec = importlib.util.spec_from_file_location(module_name, filename)
49
- if spec and spec.loader:
50
- module = importlib.util.module_from_spec(spec)
51
- spec.loader.exec_module(module)
52
-
53
- for attr_name in attr_names:
54
- if hasattr(module, attr_name):
55
- app = getattr(module, attr_name)
56
- if is_supported_framework(app):
57
- logger.info(
58
- f"✅ Auto-discovered {type(app).__name__} app in {filename} as '{attr_name}'"
59
- )
60
- return app
61
- else:
62
- logger.debug(f"> Found '{attr_name}' in {filename} but it's not a supported framework")
63
-
64
- except Exception as e:
65
- logger.debug(f"> Could not import {filename}: {e}")
66
- continue
67
-
68
- logger.debug("> No app auto-discovered")
69
- return None
70
-
71
-
72
- def get_helpful_error_message() -> str:
73
- """Generate a helpful error message for setup guidance."""
74
- return """
75
- 🚫 No API app found!
76
-
77
- Quick Setup Options:
78
-
79
- Option 1 - Auto-discovery (Recommended):
80
- Place your FastAPI/Flask app in one of these files:
81
- app.py (with variable named 'app', 'application', or 'main')
82
- • main.py (with variable named 'app', 'application', or 'main')
83
- server.py (with variable named 'app', 'application', or 'server')
84
-
85
- Example app.py:
86
- from fastapi import FastAPI
87
- app = FastAPI() # <- Plugin will auto-discover this
88
-
89
- Option 2 - Manual fixture:
90
- Create conftest.py with:
91
-
92
- import pytest
93
- from your_module import your_app
94
-
95
- @pytest.fixture
96
- def app():
97
- return your_app
98
-
99
- Then run: pytest --api-cov-report
100
-
101
- Need help? Run: pytest-api-cov init (for setup wizard)
102
- """
103
-
104
-
105
- def pytest_addoption(parser: pytest.Parser) -> None:
106
- """Add API coverage flags to the pytest parser."""
107
- add_pytest_api_cov_flags(parser)
108
-
109
-
110
- def pytest_configure(config: pytest.Config) -> None:
111
- """Configure the pytest session and logging."""
112
- if config.getoption("--api-cov-report"):
113
- verbosity = config.option.verbose
114
-
115
- if verbosity >= 2: # -vv or more
116
- log_level = logging.DEBUG
117
- elif verbosity >= 1: # -v
118
- log_level = logging.INFO
119
- else:
120
- log_level = logging.WARNING
121
-
122
- logger.setLevel(log_level)
123
-
124
- if not logger.handlers:
125
- handler = logging.StreamHandler()
126
- handler.setLevel(log_level)
127
- formatter = logging.Formatter("%(message)s")
128
- handler.setFormatter(formatter)
129
- logger.addHandler(handler)
130
-
131
- logger.info("Initializing API coverage plugin...")
132
-
133
- if config.pluginmanager.hasplugin("xdist"):
134
- config.pluginmanager.register(DeferXdistPlugin(), "defer_xdist_plugin")
135
-
136
-
137
- def pytest_sessionstart(session: pytest.Session) -> None:
138
- """Initialize the call recorder at the start of the session."""
139
- if session.config.getoption("--api-cov-report"):
140
- session.api_coverage_data = SessionData() # type: ignore[attr-defined]
141
-
142
-
143
- @pytest.fixture
144
- def client(request: pytest.FixtureRequest) -> Any:
145
- """
146
- Smart auto-discovering test client that records API calls for coverage.
147
-
148
- Tries to find an 'app' fixture first, then auto-discovers apps in common locations.
149
- """
150
- session = request.node.session
151
-
152
- if not session.config.getoption("--api-cov-report"):
153
- pytest.skip("API coverage not enabled. Use --api-cov-report flag.")
154
-
155
- app = None
156
- try:
157
- app = request.getfixturevalue("app")
158
- logger.debug("> Found 'app' fixture")
159
- except pytest.FixtureLookupError:
160
- logger.debug("> No 'app' fixture found, trying auto-discovery...")
161
- app = auto_discover_app()
162
-
163
- if app is None:
164
- helpful_msg = get_helpful_error_message()
165
- print(helpful_msg)
166
- pytest.skip("No API app found. See error message above for setup guidance.")
167
-
168
- if not is_supported_framework(app):
169
- pytest.skip(f"Unsupported framework: {type(app).__name__}. pytest-api-coverage supports Flask and FastAPI.")
170
-
171
- try:
172
- adapter = get_framework_adapter(app)
173
- except TypeError as e:
174
- pytest.skip(f"Framework detection failed: {e}")
175
-
176
- coverage_data = getattr(session, "api_coverage_data", None)
177
- if coverage_data is None:
178
- pytest.skip("API coverage data not initialized. This should not happen.")
179
-
180
- if not coverage_data.discovered_endpoints.endpoints:
181
- try:
182
- endpoints = adapter.get_endpoints()
183
- framework_name = type(app).__name__
184
- for endpoint in endpoints:
185
- coverage_data.add_discovered_endpoint(endpoint, f"{framework_name.lower()}_adapter")
186
- logger.info(f"> pytest-api-coverage: Discovered {len(endpoints)} endpoints.")
187
- logger.debug(f"> Discovered endpoints: {endpoints}")
188
- except Exception as e:
189
- logger.warning(f"> pytest-api-coverage: Could not discover endpoints. Error: {e}")
190
-
191
- client = adapter.get_tracked_client(coverage_data.recorder, request.node.name)
192
- yield client
193
-
194
-
195
- def pytest_sessionfinish(session: pytest.Session) -> None:
196
- """Generate the API coverage report at the end of the session."""
197
- if session.config.getoption("--api-cov-report"):
198
- coverage_data = getattr(session, "api_coverage_data", None)
199
- if coverage_data is None:
200
- logger.warning("> No API coverage data found. Plugin may not have been properly initialized.")
201
- return
202
-
203
- logger.debug(f"> pytest-api-coverage: Generating report for {len(coverage_data.recorder)} recorded endpoints.")
204
- if hasattr(session.config, "workeroutput"):
205
- serializable_recorder = coverage_data.recorder.to_serializable()
206
- session.config.workeroutput["api_call_recorder"] = serializable_recorder
207
- session.config.workeroutput["discovered_endpoints"] = coverage_data.discovered_endpoints.endpoints
208
- logger.debug("> Sent API call data and discovered endpoints to master process")
209
- else:
210
- logger.debug("> No workeroutput found, generating report for master data.")
211
-
212
- worker_recorder_data = getattr(session.config, "worker_api_call_recorder", {})
213
- worker_endpoints = getattr(session.config, "worker_discovered_endpoints", [])
214
-
215
- # Merge worker data into session data
216
- if worker_recorder_data or worker_endpoints:
217
- coverage_data.merge_worker_data(worker_recorder_data, worker_endpoints)
218
- logger.debug(f"> Merged worker data: {len(worker_recorder_data)} endpoints")
219
-
220
- logger.debug(f"> Final merged data: {len(coverage_data.recorder)} recorded endpoints")
221
- logger.debug(f"> Using discovered endpoints: {coverage_data.discovered_endpoints.endpoints}")
222
-
223
- api_cov_config = get_pytest_api_cov_report_config(session.config)
224
- status = generate_pytest_api_cov_report(
225
- api_cov_config=api_cov_config,
226
- called_data=coverage_data.recorder.calls,
227
- discovered_endpoints=coverage_data.discovered_endpoints.endpoints,
228
- )
229
- if session.exitstatus == 0:
230
- session.exitstatus = status
231
-
232
- if hasattr(session, "api_coverage_data"):
233
- delattr(session, "api_coverage_data")
234
-
235
- if hasattr(session.config, "worker_api_call_recorder"):
236
- delattr(session.config, "worker_api_call_recorder")
237
-
238
-
239
- class DeferXdistPlugin:
240
- """Simple class to defer pytest-xdist hook until we know it is installed."""
241
-
242
- def pytest_testnodedown(self, node: Any) -> None:
243
- """Collect API call data from each worker as they finish."""
244
- logger.debug("> pytest-api-coverage: Worker node down.")
245
- worker_data = node.workeroutput.get("api_call_recorder", {})
246
- discovered_endpoints = node.workeroutput.get("discovered_endpoints", [])
247
- logger.debug(f"> Worker data: {worker_data}")
248
- logger.debug(f"> Worker discovered endpoints: {discovered_endpoints}")
249
-
250
- # Merge API call data
251
- if worker_data:
252
- logger.debug("> Worker data found, merging with current data.")
253
- current = getattr(node.config, "worker_api_call_recorder", {})
254
- logger.debug(f"> Current data before merge: {current}")
255
-
256
- # Merge the worker data into current
257
- for endpoint, calls in worker_data.items():
258
- if endpoint not in current:
259
- current[endpoint] = set()
260
- elif not isinstance(current[endpoint], set):
261
- current[endpoint] = set(current[endpoint])
262
- current[endpoint].update(calls)
263
- logger.debug(f"> Updated endpoint {endpoint} with calls: {calls}")
264
-
265
- node.config.worker_api_call_recorder = current
266
- logger.debug(f"> Updated current data: {current}")
267
-
268
- if discovered_endpoints and not getattr(node.config, "worker_discovered_endpoints", []):
269
- node.config.worker_discovered_endpoints = discovered_endpoints
270
- logger.debug(f"> Set discovered endpoints from worker: {discovered_endpoints}")
1
+ """pytest plugin for API coverage tracking."""
2
+
3
+ import importlib
4
+ import importlib.util
5
+ import logging
6
+ import os
7
+ from typing import Any, Optional
8
+
9
+ import pytest
10
+
11
+ from .config import get_pytest_api_cov_report_config
12
+ from .frameworks import get_framework_adapter
13
+ from .models import SessionData
14
+ from .pytest_flags import add_pytest_api_cov_flags
15
+ from .report import generate_pytest_api_cov_report
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def is_supported_framework(app: Any) -> bool:
21
+ """Check if the app is a supported framework (Flask or FastAPI)."""
22
+ if app is None:
23
+ return False
24
+
25
+ app_type = type(app).__name__
26
+ module_name = getattr(type(app), "__module__", "").split(".")[0]
27
+
28
+ return (module_name == "flask" and app_type == "Flask") or (module_name == "fastapi" and app_type == "FastAPI")
29
+
30
+
31
+ def auto_discover_app() -> Optional[Any]:
32
+ """Automatically discover Flask/FastAPI apps in common locations."""
33
+ logger.debug("> Auto-discovering app in common locations...")
34
+
35
+ common_patterns = [
36
+ ("app.py", ["app", "application", "main"]),
37
+ ("main.py", ["app", "application", "main"]),
38
+ ("server.py", ["app", "application", "server"]),
39
+ ("wsgi.py", ["app", "application"]),
40
+ ("asgi.py", ["app", "application"]),
41
+ ]
42
+
43
+ found_apps = [] # Track all discovered apps
44
+ found_files = [] # Track all files that exist
45
+
46
+ for filename, attr_names in common_patterns:
47
+ if os.path.exists(filename):
48
+ found_files.append(filename)
49
+ logger.debug(f"> Found {filename}, checking for app variables...")
50
+ try:
51
+ module_name = filename[:-3] # .py extension
52
+ spec = importlib.util.spec_from_file_location(module_name, filename)
53
+ if spec and spec.loader:
54
+ module = importlib.util.module_from_spec(spec)
55
+ spec.loader.exec_module(module)
56
+
57
+ for attr_name in attr_names:
58
+ if hasattr(module, attr_name):
59
+ app = getattr(module, attr_name)
60
+ if is_supported_framework(app):
61
+ found_apps.append((filename, attr_name, type(app).__name__))
62
+ # Return the first valid app found, but log what we're doing
63
+ if len(found_apps) == 1:
64
+ logger.info(
65
+ f" Auto-discovered {type(app).__name__} app in {filename} as '{attr_name}'"
66
+ )
67
+ # Check if there are more files to scan
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
+ ]
76
+ if remaining_files:
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
+ )
83
+ return app
84
+ else:
85
+ logger.debug(f"> Found '{attr_name}' in {filename} but it's not a supported framework")
86
+
87
+ except Exception as e:
88
+ logger.debug(f"> Could not import {filename}: {e}")
89
+ continue
90
+
91
+ # If we get here, no apps were found
92
+ if found_files:
93
+ logger.debug(f"> Found files {found_files} but no supported Flask/FastAPI apps in them")
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
+
97
+ logger.debug("> No app auto-discovered")
98
+ return None
99
+
100
+
101
+ def get_helpful_error_message() -> str:
102
+ """Generate a helpful error message for setup guidance."""
103
+ return """
104
+ 🚫 No API app found!
105
+
106
+ Quick Setup Options:
107
+
108
+ Option 1 - Auto-discovery (Zero Config):
109
+ Place your FastAPI/Flask app in one of these files:
110
+ app.py (with variable named 'app', 'application', or 'main')
111
+ main.py (with variable named 'app', 'application', or 'main')
112
+ server.py (with variable named 'app', 'application', or 'server')
113
+
114
+ Example app.py:
115
+ from fastapi import FastAPI
116
+ app = FastAPI() # <- Plugin will auto-discover this
117
+
118
+ Option 2 - Custom Location or Override Auto-discovery:
119
+ Create conftest.py to specify exactly which app to use:
120
+
121
+ import pytest
122
+ from my_project.api.server import my_app # Any import path!
123
+ # or from app import my_real_app # Override auto-discovery
124
+
125
+ @pytest.fixture
126
+ def app():
127
+ return my_app
128
+
129
+ This works for:
130
+ • Apps in custom locations
131
+ • Multiple app files (specify which one to use)
132
+ • Different variable names in standard files
133
+
134
+ Option 3 - Setup Wizard:
135
+ Run: pytest-api-cov init
136
+
137
+ Then run: pytest --api-cov-report
138
+ """
139
+
140
+
141
+ def pytest_addoption(parser: pytest.Parser) -> None:
142
+ """Add API coverage flags to the pytest parser."""
143
+ add_pytest_api_cov_flags(parser)
144
+
145
+
146
+ def pytest_configure(config: pytest.Config) -> None:
147
+ """Configure the pytest session and logging."""
148
+ if config.getoption("--api-cov-report"):
149
+ verbosity = config.option.verbose
150
+
151
+ if verbosity >= 2: # -vv or more
152
+ log_level = logging.DEBUG
153
+ elif verbosity >= 1: # -v
154
+ log_level = logging.INFO
155
+ else:
156
+ log_level = logging.WARNING
157
+
158
+ logger.setLevel(log_level)
159
+
160
+ if not logger.handlers:
161
+ handler = logging.StreamHandler()
162
+ handler.setLevel(log_level)
163
+ formatter = logging.Formatter("%(message)s")
164
+ handler.setFormatter(formatter)
165
+ logger.addHandler(handler)
166
+
167
+ logger.info("Initializing API coverage plugin...")
168
+
169
+ if config.pluginmanager.hasplugin("xdist"):
170
+ config.pluginmanager.register(DeferXdistPlugin(), "defer_xdist_plugin")
171
+
172
+
173
+ def pytest_sessionstart(session: pytest.Session) -> None:
174
+ """Initialize the call recorder at the start of the session."""
175
+ if session.config.getoption("--api-cov-report"):
176
+ session.api_coverage_data = SessionData() # type: ignore[attr-defined]
177
+
178
+
179
+ def wrap_client_with_coverage(client: Any, recorder: Any, test_name: str) -> Any:
180
+ """Wrap an existing test client with coverage tracking."""
181
+
182
+ class CoverageWrapper:
183
+ def __init__(self, wrapped_client: Any):
184
+ self._wrapped = wrapped_client
185
+
186
+ def __getattr__(self, name: str) -> Any:
187
+ attr = getattr(self._wrapped, name)
188
+ if name in ["get", "post", "put", "delete", "patch", "head", "options"]:
189
+
190
+ def tracked_method(*args: Any, **kwargs: Any) -> Any:
191
+ response = attr(*args, **kwargs)
192
+ # Extract path from args[0]
193
+ if args and recorder is not None:
194
+ path = args[0]
195
+ # Clean up the path to match endpoint format
196
+ if isinstance(path, str):
197
+ # Remove query parameters
198
+ path = path.partition("?")[0]
199
+ recorder.record_call(path, test_name)
200
+ return response
201
+
202
+ return tracked_method
203
+ return attr
204
+
205
+ return CoverageWrapper(client)
206
+
207
+
208
+ def get_app_from_fixture_or_auto_discover(request: pytest.FixtureRequest) -> Any:
209
+ """Get app from fixture or auto-discovery."""
210
+ app = None
211
+ try:
212
+ app = request.getfixturevalue("app")
213
+ logger.debug("> Found 'app' fixture")
214
+ except pytest.FixtureLookupError:
215
+ logger.debug("> No 'app' fixture found, trying auto-discovery...")
216
+ app = auto_discover_app()
217
+ return app
218
+
219
+
220
+ @pytest.fixture
221
+ def coverage_client(request: pytest.FixtureRequest) -> Any:
222
+ """
223
+ Smart auto-discovering test coverage_client that records API calls for coverage.
224
+
225
+ Tries to find an 'app' fixture first, then auto-discovers apps in common locations.
226
+ Can also wrap existing custom fixtures if configured.
227
+ """
228
+ session = request.node.session
229
+
230
+ if not session.config.getoption("--api-cov-report"):
231
+ pytest.skip("API coverage not enabled. Use --api-cov-report flag.")
232
+
233
+ config = get_pytest_api_cov_report_config(request.config)
234
+ coverage_data = getattr(session, "api_coverage_data", None)
235
+ if coverage_data is None:
236
+ pytest.skip("API coverage data not initialized. This should not happen.")
237
+
238
+ # Check if we should wrap an existing fixture
239
+ if config.client_fixture_name != "coverage_client":
240
+ try:
241
+ # Get the existing custom fixture
242
+ existing_client = request.getfixturevalue(config.client_fixture_name)
243
+ logger.info(f"> Found custom fixture '{config.client_fixture_name}', wrapping with coverage tracking")
244
+
245
+ # We still need to discover endpoints, so try to get the app
246
+ app = get_app_from_fixture_or_auto_discover(request)
247
+ if app and is_supported_framework(app):
248
+ try:
249
+ adapter = get_framework_adapter(app)
250
+ if not coverage_data.discovered_endpoints.endpoints:
251
+ endpoints = adapter.get_endpoints()
252
+ framework_name = type(app).__name__
253
+ for endpoint in endpoints:
254
+ coverage_data.add_discovered_endpoint(endpoint, f"{framework_name.lower()}_adapter")
255
+ logger.info(f"> pytest-api-coverage: Discovered {len(endpoints)} endpoints.")
256
+ logger.debug(f"> Discovered endpoints: {endpoints}")
257
+ except Exception as e:
258
+ logger.warning(f"> pytest-api-coverage: Could not discover endpoints from app. Error: {e}")
259
+
260
+ # Wrap the existing client with coverage tracking
261
+ wrapped_client = wrap_client_with_coverage(existing_client, coverage_data.recorder, request.node.name)
262
+ yield wrapped_client
263
+ return
264
+
265
+ except pytest.FixtureLookupError:
266
+ logger.warning(f"> Custom fixture '{config.client_fixture_name}' not found, falling back to auto-discovery")
267
+
268
+ # Original auto-discovery logic
269
+ app = get_app_from_fixture_or_auto_discover(request)
270
+
271
+ if app is None:
272
+ helpful_msg = get_helpful_error_message()
273
+ print(helpful_msg)
274
+ pytest.skip("No API app found. See error message above for setup guidance.")
275
+
276
+ if not is_supported_framework(app):
277
+ pytest.skip(f"Unsupported framework: {type(app).__name__}. pytest-api-coverage supports Flask and FastAPI.")
278
+
279
+ try:
280
+ adapter = get_framework_adapter(app)
281
+ except TypeError as e:
282
+ pytest.skip(f"Framework detection failed: {e}")
283
+
284
+ if not coverage_data.discovered_endpoints.endpoints:
285
+ try:
286
+ endpoints = adapter.get_endpoints()
287
+ framework_name = type(app).__name__
288
+ for endpoint in endpoints:
289
+ coverage_data.add_discovered_endpoint(endpoint, f"{framework_name.lower()}_adapter")
290
+ logger.info(f"> pytest-api-coverage: Discovered {len(endpoints)} endpoints.")
291
+ logger.debug(f"> Discovered endpoints: {endpoints}")
292
+ except Exception as e:
293
+ logger.warning(f"> pytest-api-coverage: Could not discover endpoints. Error: {e}")
294
+
295
+ client = adapter.get_tracked_client(coverage_data.recorder, request.node.name)
296
+ yield client
297
+
298
+
299
+ def pytest_sessionfinish(session: pytest.Session) -> None:
300
+ """Generate the API coverage report at the end of the session."""
301
+ if session.config.getoption("--api-cov-report"):
302
+ coverage_data = getattr(session, "api_coverage_data", None)
303
+ if coverage_data is None:
304
+ logger.warning("> No API coverage data found. Plugin may not have been properly initialized.")
305
+ return
306
+
307
+ logger.debug(f"> pytest-api-coverage: Generating report for {len(coverage_data.recorder)} recorded endpoints.")
308
+ if hasattr(session.config, "workeroutput"):
309
+ serializable_recorder = coverage_data.recorder.to_serializable()
310
+ session.config.workeroutput["api_call_recorder"] = serializable_recorder
311
+ session.config.workeroutput["discovered_endpoints"] = coverage_data.discovered_endpoints.endpoints
312
+ logger.debug("> Sent API call data and discovered endpoints to master process")
313
+ else:
314
+ logger.debug("> No workeroutput found, generating report for master data.")
315
+
316
+ worker_recorder_data = getattr(session.config, "worker_api_call_recorder", {})
317
+ worker_endpoints = getattr(session.config, "worker_discovered_endpoints", [])
318
+
319
+ # Merge worker data into session data
320
+ if worker_recorder_data or worker_endpoints:
321
+ coverage_data.merge_worker_data(worker_recorder_data, worker_endpoints)
322
+ logger.debug(f"> Merged worker data: {len(worker_recorder_data)} endpoints")
323
+
324
+ logger.debug(f"> Final merged data: {len(coverage_data.recorder)} recorded endpoints")
325
+ logger.debug(f"> Using discovered endpoints: {coverage_data.discovered_endpoints.endpoints}")
326
+
327
+ api_cov_config = get_pytest_api_cov_report_config(session.config)
328
+ status = generate_pytest_api_cov_report(
329
+ api_cov_config=api_cov_config,
330
+ called_data=coverage_data.recorder.calls,
331
+ discovered_endpoints=coverage_data.discovered_endpoints.endpoints,
332
+ )
333
+ if session.exitstatus == 0:
334
+ session.exitstatus = status
335
+
336
+ if hasattr(session, "api_coverage_data"):
337
+ delattr(session, "api_coverage_data")
338
+
339
+ if hasattr(session.config, "worker_api_call_recorder"):
340
+ delattr(session.config, "worker_api_call_recorder")
341
+
342
+
343
+ class DeferXdistPlugin:
344
+ """Simple class to defer pytest-xdist hook until we know it is installed."""
345
+
346
+ def pytest_testnodedown(self, node: Any) -> None:
347
+ """Collect API call data from each worker as they finish."""
348
+ logger.debug("> pytest-api-coverage: Worker node down.")
349
+ worker_data = node.workeroutput.get("api_call_recorder", {})
350
+ discovered_endpoints = node.workeroutput.get("discovered_endpoints", [])
351
+ logger.debug(f"> Worker data: {worker_data}")
352
+ logger.debug(f"> Worker discovered endpoints: {discovered_endpoints}")
353
+
354
+ # Merge API call data
355
+ if worker_data:
356
+ logger.debug("> Worker data found, merging with current data.")
357
+ current = getattr(node.config, "worker_api_call_recorder", {})
358
+ logger.debug(f"> Current data before merge: {current}")
359
+
360
+ # Merge the worker data into current
361
+ for endpoint, calls in worker_data.items():
362
+ if endpoint not in current:
363
+ current[endpoint] = set()
364
+ elif not isinstance(current[endpoint], set):
365
+ current[endpoint] = set(current[endpoint])
366
+ current[endpoint].update(calls)
367
+ logger.debug(f"> Updated endpoint {endpoint} with calls: {calls}")
368
+
369
+ node.config.worker_api_call_recorder = current
370
+ logger.debug(f"> Updated current data: {current}")
371
+
372
+ if discovered_endpoints and not getattr(node.config, "worker_discovered_endpoints", []):
373
+ node.config.worker_discovered_endpoints = discovered_endpoints
374
+ logger.debug(f"> Set discovered endpoints from worker: {discovered_endpoints}")
@@ -66,3 +66,11 @@ def add_pytest_api_cov_flags(parser: pytest.Parser) -> None:
66
66
  default=False,
67
67
  help="Disable use of API coverage sugar in console report.",
68
68
  )
69
+
70
+ parser.addoption(
71
+ "--api-cov-client-fixture-name",
72
+ action="store",
73
+ type=str,
74
+ default=None,
75
+ help="Name of existing test client fixture to wrap with coverage tracking",
76
+ )
@@ -1,19 +1,19 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-api-cov
3
- Version: 1.0.0
4
- Summary: Api Coverage Report Pytest Plugin
3
+ Version: 1.0.2
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.116.1
10
- Requires-Dist: flask>=3.1.1
11
- Requires-Dist: httpx>=0.28.1
12
- Requires-Dist: pydantic>=2.11.7
13
- Requires-Dist: pytest>=8.4.1
14
- Requires-Dist: rich>=14.0.0
15
- Requires-Dist: starlette>=0.47.1
16
- Requires-Dist: tomli>=2.2.1
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
@@ -44,11 +44,28 @@ For most projects, no configuration is needed:
44
44
  pytest --api-cov-report
45
45
  ```
46
46
 
47
+ ### App Location Flexibility
48
+
49
+ **Zero Config**: Works automatically if your app is in `app.py`, `main.py`, or `server.py`
50
+
51
+ **Any Location**: Place your app anywhere in your project - just create a `conftest.py`:
52
+
53
+ ```python
54
+ import pytest
55
+ from my_project.backend.api import my_app # Any import path!
56
+
57
+ @pytest.fixture
58
+ def app():
59
+ return my_app
60
+ ```
61
+
47
62
  The plugin will automatically discover your Flask/FastAPI app if it's in common locations:
48
63
  - `app.py` (with variable `app`, `application`, or `main`)
49
64
  - `main.py` (with variable `app`, `application`, or `main`)
50
65
  - `server.py` (with variable `app`, `application`, or `server`)
51
66
 
67
+ **Your app can be located anywhere!** If it's not in a standard location, just create a `conftest.py` file to tell the plugin where to find it.
68
+
52
69
  ### Example
53
70
 
54
71
  Given this FastAPI app in `app.py`:
@@ -74,12 +91,12 @@ def health_check():
74
91
  And this test file:
75
92
 
76
93
  ```python
77
- def test_root_endpoint(client):
78
- response = client.get("/")
94
+ def test_root_endpoint(coverage_client):
95
+ response = coverage_client.get("/")
79
96
  assert response.status_code == 200
80
97
 
81
- def test_get_user(client):
82
- response = client.get("/users/123")
98
+ def test_get_user(coverage_client):
99
+ response = coverage_client.get("/users/123")
83
100
  assert response.status_code == 200
84
101
  ```
85
102
 
@@ -93,6 +110,22 @@ Uncovered Endpoints:
93
110
  Total API Coverage: 66.67%
94
111
  ```
95
112
 
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:
114
+
115
+ ```
116
+ API Coverage Report
117
+ Uncovered Endpoints:
118
+ [X] /health
119
+ Covered Endpoints:
120
+ [.] /
121
+ Excluded Endpoints:
122
+ [-] /users/{user_id}
123
+
124
+ SUCCESS: Coverage of 50.0% meets requirement of 49.0%
125
+
126
+ JSON report saved to api_coverage.json
127
+ ```
128
+
96
129
  ## Advanced Configuration
97
130
 
98
131
  ### Setup Wizard
@@ -110,15 +143,56 @@ This will:
110
143
 
111
144
  ### Manual Configuration
112
145
 
113
- Create a `conftest.py` file:
146
+ Create a `conftest.py` file to specify your app location (works with **any** file path or structure):
114
147
 
115
148
  ```python
116
149
  import pytest
117
- from your_app import app
150
+
151
+ # Import from anywhere in your project
152
+ from my_project.backend.api import flask_app
153
+ # or from src.services.web_server import fastapi_instance
154
+ # or from deeply.nested.modules import my_app
118
155
 
119
156
  @pytest.fixture
120
157
  def app():
121
- return app
158
+ return flask_app # Return your app instance
159
+ ```
160
+
161
+ This approach works with any project structure - the plugin doesn't care where your app is located as long as you can import it.
162
+
163
+ ### Custom Test Client Fixtures
164
+
165
+ If you have an existing test client fixture with custom setup (authentication, headers, etc.), you can wrap it with coverage tracking:
166
+
167
+ ```python
168
+ import pytest
169
+ from fastapi.testclient import TestClient
170
+ from your_app import app
171
+
172
+ @pytest.fixture
173
+ def my_custom_client():
174
+ """Custom test client with authentication."""
175
+ client = TestClient(app)
176
+ client.headers.update({"Authorization": "Bearer test-token"})
177
+ return client
178
+
179
+ def test_endpoint(coverage_client):
180
+ # coverage_client will be your custom client with coverage tracking
181
+ response = coverage_client.get("/protected-endpoint")
182
+ assert response.status_code == 200
183
+ ```
184
+
185
+ Configure it in `pyproject.toml`:
186
+
187
+ ```toml
188
+ [tool.pytest_api_cov]
189
+ client_fixture_name = "my_custom_client"
190
+ ```
191
+
192
+ Or via command line:
193
+
194
+ ```bash
195
+ pytest --api-cov-report --api-cov-client-fixture-name=my_custom_client
122
196
  ```
123
197
 
124
198
  ### Configuration Options
@@ -153,6 +227,9 @@ force_sugar = true
153
227
 
154
228
  # Force no Unicode symbols in output
155
229
  force_sugar_disabled = true
230
+
231
+ # Wrap an existing custom test client fixture with coverage tracking
232
+ client_fixture_name = "my_custom_client"
156
233
  ```
157
234
 
158
235
  ### Command Line Options
@@ -194,7 +271,6 @@ Works automatically with FastAPI and Flask applications.
194
271
 
195
272
  ```python
196
273
  from fastapi import FastAPI
197
- from fastapi.testclient import TestClient
198
274
 
199
275
  app = FastAPI()
200
276
 
@@ -202,9 +278,9 @@ app = FastAPI()
202
278
  def read_item(item_id: int):
203
279
  return {"item_id": item_id}
204
280
 
205
- # Tests automatically get a 'client' fixture
206
- def test_read_item(client):
207
- response = client.get("/items/42")
281
+ # Tests automatically get a 'coverage_client' fixture
282
+ def test_read_item(coverage_client):
283
+ response = coverage_client.get("/items/42")
208
284
  assert response.status_code == 200
209
285
  ```
210
286
 
@@ -219,9 +295,9 @@ app = Flask(__name__)
219
295
  def get_user(user_id):
220
296
  return {"user_id": user_id}
221
297
 
222
- # Tests automatically get a 'client' fixture
223
- def test_get_user(client):
224
- response = client.get("/users/123")
298
+ # Tests automatically get a 'coverage_client' fixture
299
+ def test_get_user(coverage_client):
300
+ response = coverage_client.get("/users/123")
225
301
  assert response.status_code == 200
226
302
  ```
227
303
 
@@ -301,13 +377,56 @@ jobs:
301
377
 
302
378
  ### No App Found
303
379
 
304
- If you see "No API app found", ensure:
380
+ If you see "No API app found", you have several options:
381
+
382
+ **Option 1 - Auto-discovery (Zero Config)**
383
+ Place your app in a standard location with a standard name:
384
+ - Files: `app.py`, `main.py`, `server.py`, `wsgi.py`, `asgi.py`
385
+ - Variable names: `app`, `application`, `main`, `server`
386
+
387
+ **Option 2 - Custom Location (Any File/Path)**
388
+ Create a `conftest.py` file to specify your app location:
389
+
390
+ ```python
391
+ import pytest
392
+ from my_project.api.server import my_flask_app # Any import path
393
+ # or from src.backend.main import fastapi_instance
394
+ # or from anywhere import your_app
395
+
396
+ @pytest.fixture
397
+ def app():
398
+ return my_flask_app # Return your app instance
399
+ ```
400
+
401
+ **Option 3 - Override Auto-discovery**
402
+ If you have multiple auto-discoverable files or want to use a different app:
403
+
404
+ ```python
405
+ # Even if you have app.py, you can override it
406
+ import pytest
407
+ from main import my_real_app # Use this instead of app.py
408
+
409
+ @pytest.fixture
410
+ def app():
411
+ return my_real_app
412
+ ```
413
+
414
+ **Option 4 - Setup Wizard**
415
+ Run the interactive setup: `pytest-api-cov init`
416
+
417
+ The plugin will automatically find your app using the `app` fixture first, then fall back to auto-discovery in common locations. This means you can place your app **anywhere** as long as you create the fixture.
418
+
419
+ ### Multiple App Files
420
+
421
+ If you have multiple files that could be auto-discovered (e.g., both `app.py` and `main.py`), the plugin will use the **first valid app it finds** in this priority order:
305
422
 
306
- 1. Your app is in a standard location (`app.py`, `main.py`, etc.)
307
- 2. Your app variable has a standard name (`app`, `application`, `main`)
308
- 3. Your app imports are correct (`from fastapi import FastAPI` or `from flask import Flask`)
423
+ 1. `app.py`
424
+ 2. `main.py`
425
+ 3. `server.py`
426
+ 4. `wsgi.py`
427
+ 5. `asgi.py`
309
428
 
310
- Or use the setup wizard: `pytest-api-cov init`
429
+ To use a specific app when multiple exist, create a `conftest.py` with an `app` fixture pointing to your preferred app.
311
430
 
312
431
  ### No Endpoints Discovered
313
432
 
@@ -315,7 +434,7 @@ If you see "No endpoints discovered":
315
434
 
316
435
  1. Check that your app is properly instantiated
317
436
  2. Verify your routes/endpoints are defined
318
- 3. Ensure the `client` fixture is working in your tests
437
+ 3. Ensure the `coverage_client` fixture is working in your tests
319
438
  4. Use `-v` or `-vv` for debug information
320
439
 
321
440
  ### Framework Not Detected
@@ -0,0 +1,13 @@
1
+ pytest_api_cov/__init__.py,sha256=7ZX-XIlYwdB0AkSSaXG2O6mah3CBCjZGTiBSEf5dTqk,177
2
+ pytest_api_cov/cli.py,sha256=TCnldhRBi9BMLaPrnp7sfoWNc0Ktu8XnoRQKUuXFGWo,7186
3
+ pytest_api_cov/config.py,sha256=51GaJgAtctAcNb2F-7Q65qZuNusf3_s1-4bE-AWv5QE,3323
4
+ pytest_api_cov/frameworks.py,sha256=5d6wnEsb2BykPtpnpkv98GMPuyoG4elXRJ7vtGHF06A,3228
5
+ pytest_api_cov/models.py,sha256=YiXLiEEyNREiodzn1pGqSNIHrm3zV6kFn0XgyN8_8Rs,4893
6
+ pytest_api_cov/plugin.py,sha256=rZVTvhqKv5GCIbwgFRH3KcrwNXdecFDRrtbMepZfjs8,16118
7
+ pytest_api_cov/pytest_flags.py,sha256=OX1NMcQHfI9uyPrZXj0Q_VG1daU8uKKB4sY-4JLm220,2175
8
+ pytest_api_cov/report.py,sha256=VxfWag20V8o9ArbxOkR_gWQ2TE-sthrS0PCJvn4YCSU,6642
9
+ pytest_api_cov-1.0.2.dist-info/METADATA,sha256=jR8gMyw5frvKQzWeyPrjHmACqa1S6xPZuYXgoshK9Vc,11303
10
+ pytest_api_cov-1.0.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
11
+ pytest_api_cov-1.0.2.dist-info/entry_points.txt,sha256=hWqEhsBKzbwSwcxCzKgSA8NElQxk0K4PKERrYsi3csk,110
12
+ pytest_api_cov-1.0.2.dist-info/licenses/LICENSE,sha256=HrhfyXIkWY2tGFK11kg7vPCqhgh5DcxleloqdhrpyMY,11558
13
+ pytest_api_cov-1.0.2.dist-info/RECORD,,
@@ -1,13 +0,0 @@
1
- pytest_api_cov/__init__.py,sha256=o5esC2mfxHTQ7_KBZPGipOQ1OrTTeb-WeE8zPdVzQj8,175
2
- pytest_api_cov/cli.py,sha256=vEkVCFKxxOUo_-vUCMuo5u49aAT9aHZ5EU5JcCBuqfc,6383
3
- pytest_api_cov/config.py,sha256=cB8N9bpkGmYhXLW3QL08atJy3ukC1vJcSD2qc8tRol0,3166
4
- pytest_api_cov/frameworks.py,sha256=5d6wnEsb2BykPtpnpkv98GMPuyoG4elXRJ7vtGHF06A,3228
5
- pytest_api_cov/models.py,sha256=YiXLiEEyNREiodzn1pGqSNIHrm3zV6kFn0XgyN8_8Rs,4893
6
- pytest_api_cov/plugin.py,sha256=Sa4tWSXmwcXDmxRSAwlM6n4RjU-HqY3S9G8Esicy2nw,11078
7
- pytest_api_cov/pytest_flags.py,sha256=aH-MpUQCX7JdPA7f_Qulf0HzOtFpyTqDbHFDy48epOg,1949
8
- pytest_api_cov/report.py,sha256=VxfWag20V8o9ArbxOkR_gWQ2TE-sthrS0PCJvn4YCSU,6642
9
- pytest_api_cov-1.0.0.dist-info/METADATA,sha256=yevOYWGatlPnDmoZjWkOn17KGVq8FIZoszzB3yHr2uM,7528
10
- pytest_api_cov-1.0.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
11
- pytest_api_cov-1.0.0.dist-info/entry_points.txt,sha256=hWqEhsBKzbwSwcxCzKgSA8NElQxk0K4PKERrYsi3csk,110
12
- pytest_api_cov-1.0.0.dist-info/licenses/LICENSE,sha256=HrhfyXIkWY2tGFK11kg7vPCqhgh5DcxleloqdhrpyMY,11558
13
- pytest_api_cov-1.0.0.dist-info/RECORD,,