Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ Or use the CLI flag multiple times:
pytest --api-cov-report --api-cov-client-fixture-names=my_custom_client --api-cov-client-fixture-names=another_fixture
```

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.
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.

#### Option 2: Helper Function

Expand Down Expand Up @@ -514,7 +514,7 @@ If coverage is not running because the plugin could not locate an app, check the
- Ensure you are running pytest with `--api-cov-report` enabled.
- Confirm you have a test client fixture (e.g. `client`, `test_client`, `api_client`) or an `app` fixture in your test suite.
- If you use a custom client fixture, add its name to `client_fixture_names` in `pyproject.toml` or pass it via the CLI using `--api-cov-client-fixture-names` (repeatable) so the plugin can find and wrap it.
- If the plugin finds the client fixture but cannot extract the underlying app (for example the client type is not supported or wrapped in an unexpected way), you will see a message like "Could not extract app from client" in that case either provide an `app` fixture directly or wrap your existing client using `create_coverage_fixture`.
- If the plugin finds the client fixture but cannot extract the underlying app (for example the client type is not supported or wrapped in an unexpected way), you will see a message like "Could not extract app from client" - in that case either provide an `app` fixture directly or wrap your existing client using `create_coverage_fixture`.

### No endpoints Discovered

Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "pytest-api-cov"
version = "1.3.4"
version = "1.3.5"
description = "Pytest Plugin to provide API Coverage statistics for Python Web Frameworks"
readme = "README.md"
authors = [{ name = "Barnaby Gill", email = "barnabasgill@gmail.com" }]
Expand Down Expand Up @@ -39,6 +39,7 @@ dev = [
"flask>=2.0.0",
"httpx>=0.20.0",
"starlette>=0.14.0",
"pybencher>=2.1.0",
]

# API COVERAGE
Expand Down
29 changes: 7 additions & 22 deletions src/pytest_api_cov/cli.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,10 @@
"""CLI commands for setup and configuration."""

import argparse
import sys


def generate_conftest_content(framework: str, file_path: str, app_variable: str) -> str:
"""Generate conftest.py content based on provided framework/module/app variable.

This is a non-interactive helper that returns example content — the project
no longer performs automatic file-scanning. Use this helper to bootstrap a
`conftest.py` if desired.
"""
"""Generate example conftest.py content for a given framework."""
module_path = file_path.replace("/", ".").replace("\\", ".").replace(".py", "")

if framework == "FastAPI":
Expand All @@ -23,7 +17,7 @@ def generate_conftest_content(framework: str, file_path: str, app_variable: str)
test_client_import = ""
client_creation = "# Create and return a test client for your framework"

return f'''"""conftest.py - Example generated by pytest-api-cov CLI (non-interactive)"""
return f'''"""conftest.py - Example generated by pytest-api-cov CLI"""

import pytest
{test_client_import}
Expand All @@ -33,11 +27,7 @@ def generate_conftest_content(framework: str, file_path: str, app_variable: str)

@pytest.fixture
def client():
"""Standard test client fixture for {framework}.

The pytest-api-cov plugin can extract the app from your client fixture
and wrap it with coverage tracking when enabled.
"""
"""Test client fixture for {framework}."""
{client_creation}


Expand All @@ -49,7 +39,7 @@ def client():


def generate_pyproject_config() -> str:
"""Generate pyproject.toml configuration section."""
"""Generate example pyproject.toml configuration section."""
return """
# pytest-api-cov configuration
[tool.pytest_api_cov]
Expand Down Expand Up @@ -83,18 +73,11 @@ def generate_pyproject_config() -> str:


def main() -> int:
"""Run the main CLI entry point.

Note: the previous interactive "init" wizard was removed. This CLI
provides programmatic helpers to generate example `conftest.py` and
`pyproject.toml` content; use those functions or create a manual
`conftest.py`/`pyproject.toml` as described in the README.
"""
"""CLI entry point."""
parser = argparse.ArgumentParser(prog="pytest-api-cov", description="pytest API coverage plugin CLI tools")

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

# Keep a non-interactive 'show-config' command for convenience
subparsers.add_parser("show-pyproject", help="Print example pyproject.toml configuration")
show_conftest = subparsers.add_parser("show-conftest", help="Print example conftest content")
show_conftest.add_argument("framework", nargs=1, help="Framework name (FastAPI|Flask)")
Expand All @@ -119,4 +102,6 @@ def main() -> int:


if __name__ == "__main__":
import sys

sys.exit(main())
64 changes: 33 additions & 31 deletions src/pytest_api_cov/config.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"""Configuration handling for the API coverage report."""

from __future__ import annotations

import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any

import tomli
from pydantic import BaseModel, ConfigDict, Field
Expand All @@ -13,22 +15,22 @@ class ApiCoverageReportConfig(BaseModel):

model_config = ConfigDict(populate_by_name=True)

fail_under: Optional[float] = Field(None, alias="api-cov-fail-under")
fail_under: float | None = Field(None, alias="api-cov-fail-under")
show_uncovered_endpoints: bool = Field(default=True, alias="api-cov-show-uncovered-endpoints")
show_covered_endpoints: bool = Field(default=False, alias="api-cov-show-covered-endpoints")
show_excluded_endpoints: bool = Field(default=False, alias="api-cov-show-excluded-endpoints")
exclusion_patterns: List[str] = Field(default=[], alias="api-cov-exclusion-patterns")
report_path: Optional[str] = Field(None, alias="api-cov-report-path")
exclusion_patterns: list[str] = Field(default=[], alias="api-cov-exclusion-patterns")
report_path: str | None = Field(None, alias="api-cov-report-path")
force_sugar: bool = Field(default=False, alias="api-cov-force-sugar")
force_sugar_disabled: bool = Field(default=False, alias="api-cov-force-sugar-disabled")
client_fixture_names: List[str] = Field(
client_fixture_names: list[str] = Field(
["client", "test_client", "api_client", "app_client"], alias="api-cov-client-fixture-names"
)
group_methods_by_endpoint: bool = Field(default=False, alias="api-cov-group-methods-by-endpoint")
openapi_spec: Optional[str] = Field(None, alias="api-cov-openapi-spec")
openapi_spec: str | None = Field(None, alias="api-cov-openapi-spec")


def read_toml_config() -> Dict[str, Any]:
def read_toml_config() -> dict[str, Any]:
"""Read the [tool.pytest_api_cov] section from pyproject.toml."""
try:
with Path("pyproject.toml").open("rb") as f:
Expand All @@ -38,46 +40,46 @@ def read_toml_config() -> Dict[str, Any]:
return {}


def read_session_config(session_config: Any) -> Dict[str, Any]:
_CLI_OPTIONS = {
"api-cov-fail-under": "fail_under",
"api-cov-show-uncovered-endpoints": "show_uncovered_endpoints",
"api-cov-show-covered-endpoints": "show_covered_endpoints",
"api-cov-show-excluded-endpoints": "show_excluded_endpoints",
"api-cov-exclusion-patterns": "exclusion_patterns",
"api-cov-report-path": "report_path",
"api-cov-force-sugar": "force_sugar",
"api-cov-force-sugar-disabled": "force_sugar_disabled",
"api-cov-client-fixture-names": "client_fixture_names",
"api-cov-group-methods-by-endpoint": "group_methods_by_endpoint",
"api-cov-openapi-spec": "openapi_spec",
}

_UNSET: tuple[Any, ...] = (None, [], False)


def read_session_config(session_config: Any) -> dict[str, Any]:
"""Read configuration from pytest session config (command-line flags)."""
cli_options = {
"api-cov-fail-under": "fail_under",
"api-cov-show-uncovered-endpoints": "show_uncovered_endpoints",
"api-cov-show-covered-endpoints": "show_covered_endpoints",
"api-cov-show-excluded-endpoints": "show_excluded_endpoints",
"api-cov-exclusion-patterns": "exclusion_patterns",
"api-cov-report-path": "report_path",
"api-cov-force-sugar": "force_sugar",
"api-cov-force-sugar-disabled": "force_sugar_disabled",
"api-cov-client-fixture-names": "client_fixture_names",
"api-cov-group-methods-by-endpoint": "group_methods_by_endpoint",
"api-cov-openapi-spec": "openapi_spec",
}
config = {}
for opt, key in cli_options.items():
config: dict[str, Any] = {}
for opt, key in _CLI_OPTIONS.items():
value = session_config.getoption(f"--{opt}")
if value is not None and value != [] and value is not False:
if value not in _UNSET:
config[key] = value

# Validating negation flags
if session_config.getoption("--api-cov-hide-uncovered-endpoints"):
config["show_uncovered_endpoints"] = False

return config


def supports_unicode() -> bool:
"""Check if the environment supports Unicode characters."""
"""Check if the terminal supports Unicode output."""
if not sys.stdout.isatty():
return False
return bool(sys.stdout) and sys.stdout.encoding.lower() in ["utf-8", "utf8"]
return sys.stdout.encoding.lower() in ("utf-8", "utf8")


def get_pytest_api_cov_report_config(session_config: Any) -> ApiCoverageReportConfig:
"""Get the final API coverage configuration by merging sources.

Priority: CLI > pyproject.toml > Defaults.
"""
"""Build final config by merging sources. Priority: CLI > pyproject.toml > defaults."""
toml_config = read_toml_config()
cli_config = read_session_config(session_config)

Expand Down
73 changes: 38 additions & 35 deletions src/pytest_api_cov/frameworks.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,34 @@
"""Framework adapters for Flask and FastAPI."""
"""Framework adapters for Flask, FastAPI, and Django."""

from typing import TYPE_CHECKING, Any, List, Optional
from __future__ import annotations

from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from .models import ApiCallRecorder


class BaseAdapter:
"""Base adapter for framework applications."""
class BaseAdapter(ABC):
"""Abstract base for framework adapters."""

def __init__(self, app: Any) -> None:
"""Initialize the adapter."""
"""Bind the framework app instance."""
self.app = app

def get_endpoints(self) -> List[str]:
"""Return a list of all endpoint paths."""
raise NotImplementedError
@abstractmethod
def get_endpoints(self) -> list[str]:
"""Return a list of 'METHOD /path' strings."""

def get_tracked_client(self, recorder: Optional["ApiCallRecorder"], test_name: str) -> Any:
"""Return a patched test client that records calls."""
raise NotImplementedError
@abstractmethod
def get_tracked_client(self, recorder: ApiCallRecorder | None, test_name: str) -> Any:
"""Return a test client that records calls."""


class FlaskAdapter(BaseAdapter):
"""Adapter for Flask applications."""

def get_endpoints(self) -> List[str]:
def get_endpoints(self) -> list[str]:
"""Return list of 'METHOD /path' strings."""
excluded_rules = ("/static/<path:filename>",)
endpoints = [
Expand All @@ -38,8 +41,8 @@ def get_endpoints(self) -> List[str]:

return sorted(endpoints)

def get_tracked_client(self, recorder: Optional["ApiCallRecorder"], test_name: str) -> Any:
"""Return a patched test client that records calls."""
def get_tracked_client(self, recorder: ApiCallRecorder | None, test_name: str) -> Any:
"""Return a Flask test client with call tracking."""
from flask.testing import FlaskClient

if recorder is None:
Expand All @@ -65,13 +68,13 @@ def open(self, *args: Any, **kwargs: Any) -> Any:
class FastAPIAdapter(BaseAdapter):
"""Adapter for FastAPI applications."""

def get_endpoints(self) -> List[str]:
def get_endpoints(self) -> list[str]:
"""Return list of 'METHOD /path' strings."""
endpoints: List[str] = []
endpoints: list[str] = []
self._collect_routes(self.app.routes, "", endpoints)
return sorted(endpoints)

def _collect_routes(self, routes: List[Any], prefix: str, endpoints: List[str]) -> None:
def _collect_routes(self, routes: list[Any], prefix: str, endpoints: list[str]) -> None:
"""Recursively collect endpoints from routes, including mounted sub-apps."""
from fastapi.routing import APIRoute
from starlette.routing import Mount
Expand All @@ -83,10 +86,8 @@ def _collect_routes(self, routes: List[Any], prefix: str, endpoints: List[str])
)
elif isinstance(route, Mount):
mount_prefix = prefix + route.path
# Sub-app with its own routes (FastAPI/Starlette router)
if hasattr(route, "routes") and route.routes:
self._collect_routes(route.routes, mount_prefix, endpoints)
# WSGI middleware wrapping a supported framework
elif hasattr(route, "app"):
inner = _unwrap_wsgi_app(route.app)
if inner is not None:
Expand All @@ -95,8 +96,8 @@ def _collect_routes(self, routes: List[Any], prefix: str, endpoints: List[str])
method, path = ep.split(" ", 1)
endpoints.append(f"{method} {mount_prefix}{path}")

def get_tracked_client(self, recorder: Optional["ApiCallRecorder"], test_name: str) -> Any:
"""Return a patched test client that records calls."""
def get_tracked_client(self, recorder: ApiCallRecorder | None, test_name: str) -> Any:
"""Return a FastAPI/Starlette test client with call tracking."""
from starlette.testclient import TestClient

if recorder is None:
Expand All @@ -117,14 +118,14 @@ def send(self, *args: Any, **kwargs: Any) -> Any:
class DjangoAdapter(BaseAdapter):
"""Adapter for Django applications."""

def get_endpoints(self) -> List[str]:
def get_endpoints(self) -> list[str]:
"""Return list of 'METHOD /path' strings."""
from django.urls import get_resolver # type: ignore[import-untyped]
from django.urls.resolvers import URLPattern, URLResolver # type: ignore[import-untyped]

endpoints: List[str] = []
endpoints: list[str] = []

def _extract_patterns(patterns: List[Any], prefix: str = "") -> None:
def _extract_patterns(patterns: list[Any], prefix: str = "") -> None:
for pattern in patterns:
if isinstance(pattern, URLPattern):
route = str(pattern.pattern).strip("^$")
Expand All @@ -145,8 +146,8 @@ def _extract_patterns(patterns: List[Any], prefix: str = "") -> None:
_extract_patterns(get_resolver().url_patterns)
return sorted(endpoints)

def get_tracked_client(self, recorder: Optional["ApiCallRecorder"], test_name: str) -> Any:
"""Return a patched test client that records calls."""
def get_tracked_client(self, recorder: ApiCallRecorder | None, test_name: str) -> Any:
"""Return a Django test client with call tracking."""
from django.test import Client # type: ignore[import-untyped]

if recorder is None:
Expand All @@ -167,8 +168,6 @@ def request(self, **request: Any) -> Any:

def _unwrap_wsgi_app(app: Any) -> Any:
"""Extract the inner WSGI app from middleware wrappers, if supported."""
from .plugin import is_supported_framework

type_name = type(app).__name__
if type_name in ("WSGIMiddleware", "WSGIResponder"):
inner = getattr(app, "app", None)
Expand All @@ -177,6 +176,17 @@ def _unwrap_wsgi_app(app: Any) -> Any:
return None


def is_supported_framework(app: Any) -> bool:
"""Check if the app is a supported framework."""
if app is None:
return False
try:
get_framework_adapter(app)
except TypeError:
return False
return True


def get_framework_adapter(app: Any) -> BaseAdapter:
"""Detect the framework and return the appropriate adapter."""
app_type = type(app).__name__
Expand All @@ -186,16 +196,9 @@ def get_framework_adapter(app: Any) -> BaseAdapter:
return FlaskAdapter(app)
if module_name == "fastapi" and app_type == "FastAPI":
return FastAPIAdapter(app)

# Django detection
# Django apps are often WSGIHandlers or just the module 'django' is present
if module_name == "django" or "django" in module_name:
return DjangoAdapter(app)

# Check for Django WSGI handler specifically
if app_type == "WSGIHandler" and module_name == "django.core.handlers.wsgi":
return DjangoAdapter(app)

raise TypeError(
f"Unsupported application type: {app_type}. pytest-api-coverage supports Flask, FastAPI, and Django."
)
Loading
Loading