From 9a1f534a7e5ce123e08d769e0e7f6697b863df70 Mon Sep 17 00:00:00 2001 From: BarnabasG Date: Tue, 14 Apr 2026 23:19:28 +0100 Subject: [PATCH 1/2] 1.3.7 Performance enhancements --- pyproject.toml | 2 +- src/pytest_api_cov/config.py | 8 ++++++- src/pytest_api_cov/frameworks.py | 38 ++++++++++++++++++++++---------- src/pytest_api_cov/models.py | 4 ++-- src/pytest_api_cov/openapi.py | 12 ++++++---- src/pytest_api_cov/plugin.py | 34 ++++++++++++++-------------- src/pytest_api_cov/report.py | 2 ++ tests/unit/test_config.py | 14 +++++++----- uv.lock | 2 +- 9 files changed, 73 insertions(+), 43 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c5dbfcf..c4c08dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pytest-api-cov" -version = "1.3.6" +version = "1.3.7" description = "Pytest Plugin to provide API Coverage statistics for Python Web Frameworks" readme = "README.md" authors = [{ name = "Barnaby Gill", email = "barnabasgill@gmail.com" }] diff --git a/src/pytest_api_cov/config.py b/src/pytest_api_cov/config.py index 40e7618..069ce9f 100644 --- a/src/pytest_api_cov/config.py +++ b/src/pytest_api_cov/config.py @@ -80,6 +80,10 @@ def supports_unicode() -> bool: def get_pytest_api_cov_report_config(session_config: Any) -> ApiCoverageReportConfig: """Build final config by merging sources. Priority: CLI > pyproject.toml > defaults.""" + cached = getattr(session_config, "_api_cov_config_cache", None) + if isinstance(cached, ApiCoverageReportConfig): + return cached + toml_config = read_toml_config() cli_config = read_session_config(session_config) @@ -90,4 +94,6 @@ def get_pytest_api_cov_report_config(session_config: Any) -> ApiCoverageReportCo elif "force_sugar" not in final_config: final_config["force_sugar"] = supports_unicode() - return ApiCoverageReportConfig.model_validate(final_config) + result = ApiCoverageReportConfig.model_validate(final_config) + session_config._api_cov_config_cache = result + return result diff --git a/src/pytest_api_cov/frameworks.py b/src/pytest_api_cov/frameworks.py index 297a97c..c29a309 100644 --- a/src/pytest_api_cov/frameworks.py +++ b/src/pytest_api_cov/frameworks.py @@ -48,14 +48,18 @@ def get_tracked_client(self, recorder: ApiCallRecorder | None, test_name: str) - if recorder is None: return self.app.test_client() + url_adapter = None + if hasattr(self.app.url_map, "bind"): + url_adapter = self.app.url_map.bind("") + class TrackingFlaskClient(FlaskClient): def open(self, *args: Any, **kwargs: Any) -> Any: path = kwargs.get("path") or (args[0] if args else None) method = kwargs.get("method", "GET").upper() - if path and hasattr(self.application.url_map, "bind"): + if path and url_adapter is not None: try: - endpoint_name, _ = self.application.url_map.bind("").match(path, method=method) + endpoint_name, _ = url_adapter.match(path, method=method) endpoint_rule_string = next(self.application.url_map.iter_rules(endpoint_name)).rule recorder.record_call(endpoint_rule_string, test_name, method) # type: ignore[union-attr] except Exception: # noqa: BLE001 @@ -176,29 +180,39 @@ def _unwrap_wsgi_app(app: Any) -> Any: return None +def _detect_framework(app: Any) -> str | None: + """Lightweight check to detect the framework.""" + app_type = type(app).__name__ + module_name = getattr(type(app), "__module__", "").split(".")[0] + + if (module_name == "flask" and app_type == "Flask") or (module_name == "flask_openapi3" and app_type == "OpenAPI"): + return "flask" + if module_name == "fastapi" and app_type == "FastAPI": + return "fastapi" + if module_name == "django" or "django" in module_name: + return "django" + 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 + return _detect_framework(app) is not None def get_framework_adapter(app: Any) -> BaseAdapter: """Detect the framework and return the appropriate adapter.""" - app_type = type(app).__name__ - module_name = getattr(type(app), "__module__", "").split(".")[0] + framework = _detect_framework(app) - if (module_name == "flask" and app_type == "Flask") or (module_name == "flask_openapi3" and app_type == "OpenAPI"): + if framework == "flask": return FlaskAdapter(app) - if module_name == "fastapi" and app_type == "FastAPI": + if framework == "fastapi": return FastAPIAdapter(app) - if module_name == "django" or "django" in module_name: + if framework == "django": return DjangoAdapter(app) + app_type = type(app).__name__ raise TypeError( f"Unsupported application type: {app_type}. pytest-api-coverage supports Flask, FastAPI, and Django." ) diff --git a/src/pytest_api_cov/models.py b/src/pytest_api_cov/models.py index 60a6426..2eece45 100644 --- a/src/pytest_api_cov/models.py +++ b/src/pytest_api_cov/models.py @@ -4,7 +4,7 @@ from typing import Any -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, PrivateAttr class ApiCallRecorder(BaseModel): @@ -72,7 +72,7 @@ class EndpointDiscovery(BaseModel): """Discovered API endpoints.""" endpoints: list[str] = Field(default_factory=list) - _seen: set[str] = set() + _seen: set[str] = PrivateAttr(default_factory=set) discovery_source: str = Field(default="unknown") def model_post_init(self, _: Any, /) -> None: diff --git a/src/pytest_api_cov/openapi.py b/src/pytest_api_cov/openapi.py index 23967cb..e8f20b6 100644 --- a/src/pytest_api_cov/openapi.py +++ b/src/pytest_api_cov/openapi.py @@ -2,12 +2,9 @@ from __future__ import annotations -import json import logging from pathlib import Path -import yaml - logger = logging.getLogger(__name__) HTTP_METHODS = {"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "TRACE"} @@ -22,7 +19,14 @@ def parse_openapi_spec(path: str) -> list[str]: try: with spec_path.open("r", encoding="utf-8") as f: - spec = yaml.safe_load(f) if spec_path.suffix.lower() in (".yaml", ".yml") else json.load(f) + if spec_path.suffix.lower() in (".yaml", ".yml"): + import yaml + + spec = yaml.safe_load(f) + else: + import json + + spec = json.load(f) except Exception: logger.exception("Failed to parse OpenAPI spec", exc_info=True) return [] diff --git a/src/pytest_api_cov/plugin.py b/src/pytest_api_cov/plugin.py index 00b3ad5..16494f4 100644 --- a/src/pytest_api_cov/plugin.py +++ b/src/pytest_api_cov/plugin.py @@ -228,14 +228,14 @@ def _extract_path_and_method(self, name: str, args: Any, kwargs: Any) -> tuple[s req_method = (args[0] if args else kwargs.get("method", "GET")).upper() req_url = args[1] if len(args) > 1 else kwargs.get("url") if isinstance(req_url, str): - return req_url.partition("?")[0], req_method + return req_url if "?" not in req_url else req_url.partition("?")[0], req_method return None # .get(url), .post(url), .open(url), etc. - url is first arg if args: first = args[0] if isinstance(first, str): - path = first.partition("?")[0] + path = first if "?" not in first else first.partition("?")[0] method = kwargs.get("method", name).upper() return path, ("GET" if method == "OPEN" else method) @@ -248,7 +248,7 @@ def _extract_path_and_method(self, name: str, args: Any, kwargs: Any) -> tuple[s if kwargs: path_kw = kwargs.get("path") or kwargs.get("url") or kwargs.get("uri") if isinstance(path_kw, str): - path = path_kw.partition("?")[0] + path = path_kw if "?" not in path_kw else path_kw.partition("?")[0] method = kwargs.get("method", name).upper() return path, ("GET" if method == "OPEN" else method) @@ -256,20 +256,20 @@ def _extract_path_and_method(self, name: str, args: Any, kwargs: Any) -> tuple[s def __getattr__(self, name: str) -> Any: attr = getattr(self._wrapped, name) - if name in self._TRACKED_NAMES: - - def tracked(*args: Any, **kwargs: Any) -> Any: - response = attr(*args, **kwargs) - if recorder is not None: - pm = self._extract_path_and_method(name, args, kwargs) - if pm: - path, method = pm - recorder.record_call(path, test_name, method) - return response - - return tracked - - return attr + if name not in self._TRACKED_NAMES: + return attr + + def tracked(*args: Any, **kwargs: Any) -> Any: + response = attr(*args, **kwargs) + if recorder is not None: + pm = self._extract_path_and_method(name, args, kwargs) + if pm: + path, method = pm + recorder.record_call(path, test_name, method) + return response + + object.__setattr__(self, name, tracked) + return tracked return CoverageWrapper(client) diff --git a/src/pytest_api_cov/report.py b/src/pytest_api_cov/report.py index 1db77e1..906d3a2 100644 --- a/src/pytest_api_cov/report.py +++ b/src/pytest_api_cov/report.py @@ -4,6 +4,7 @@ import json import re +from functools import lru_cache from pathlib import Path from re import Pattern from typing import TYPE_CHECKING, Any @@ -14,6 +15,7 @@ from .config import ApiCoverageReportConfig +@lru_cache(maxsize=512) def endpoint_to_regex(endpoint: str) -> Pattern[str]: """Create a regex pattern from an endpoint by replacing dynamic segments.""" placeholder = "___PLACEHOLDER___" diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index b7bbdb6..5404aae 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -130,7 +130,7 @@ def test_config_priority_cli_over_toml(self, mock_read_toml, mock_read_session): mock_read_toml.return_value = {"fail_under": 90.0, "report_path": "toml.json"} mock_read_session.return_value = {"fail_under": 75.0} - mock_session_config = Mock() + mock_session_config = Mock(spec=["getoption"]) final_config = get_pytest_api_cov_report_config(mock_session_config) assert final_config.fail_under == 75.0 @@ -143,7 +143,8 @@ def test_pydantic_model_validation(self, mock_read_toml, mock_read_session): """Pydantic model validates and sets defaults correctly.""" mock_read_toml.return_value = {"fail_under": 90.0} - final_config = get_pytest_api_cov_report_config(Mock()) + mock_session_config = Mock(spec=["getoption"]) + final_config = get_pytest_api_cov_report_config(mock_session_config) assert final_config.fail_under == 90.0 assert final_config.show_covered_endpoints is False @@ -159,15 +160,18 @@ def test_force_sugar_setting(self, mock_supports_unicode, mock_read_toml, mock_r mock_read_toml.return_value = {} mock_read_session.return_value = {"force_sugar_disabled": True} - config = get_pytest_api_cov_report_config(Mock()) + mock_session_config = Mock(spec=["getoption"]) + config = get_pytest_api_cov_report_config(mock_session_config) assert config.force_sugar is False mock_read_session.return_value = {} - config = get_pytest_api_cov_report_config(Mock()) + mock_session_config = Mock(spec=["getoption"]) + config = get_pytest_api_cov_report_config(mock_session_config) assert config.force_sugar is True mock_read_session.return_value = {"force_sugar": False} - config = get_pytest_api_cov_report_config(Mock()) + mock_session_config = Mock(spec=["getoption"]) + config = get_pytest_api_cov_report_config(mock_session_config) assert config.force_sugar is False def test_pydantic_validation_error(self): diff --git a/uv.lock b/uv.lock index 23dffec..83236ed 100644 --- a/uv.lock +++ b/uv.lock @@ -696,7 +696,7 @@ wheels = [ [[package]] name = "pytest-api-cov" -version = "1.3.6" +version = "1.3.7" source = { editable = "." } dependencies = [ { name = "pydantic" }, From 739287e939e9c4df3b0f415a0a782d50f014305f Mon Sep 17 00:00:00 2001 From: BarnabasG Date: Tue, 14 Apr 2026 23:29:58 +0100 Subject: [PATCH 2/2] Cleanup, strenum, match statement --- pyproject.toml | 1 + src/pytest_api_cov/frameworks.py | 58 ++++++++++++++++++++------------ uv.lock | 11 ++++++ 3 files changed, 49 insertions(+), 21 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c4c08dd..858a06b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "tomli>=1.2.0", "pytest>=6.0.0", "PyYAML>=6.0", + "backports.strenum>=1.3.1; python_version < '3.11'", ] [project.optional-dependencies] diff --git a/src/pytest_api_cov/frameworks.py b/src/pytest_api_cov/frameworks.py index c29a309..d0ef6dc 100644 --- a/src/pytest_api_cov/frameworks.py +++ b/src/pytest_api_cov/frameworks.py @@ -2,9 +2,24 @@ from __future__ import annotations +import sys from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any +if sys.version_info >= (3, 11): + from enum import StrEnum +else: + from backports.strenum import StrEnum + + +class SupportedFramework(StrEnum): + """String enum representing officially supported web frameworks.""" + + FLASK = "flask" + FASTAPI = "fastapi" + DJANGO = "django" + + if TYPE_CHECKING: from .models import ApiCallRecorder @@ -180,18 +195,20 @@ def _unwrap_wsgi_app(app: Any) -> Any: return None -def _detect_framework(app: Any) -> str | None: +def _detect_framework(app: Any) -> SupportedFramework | None: """Lightweight check to detect the framework.""" app_type = type(app).__name__ module_name = getattr(type(app), "__module__", "").split(".")[0] - if (module_name == "flask" and app_type == "Flask") or (module_name == "flask_openapi3" and app_type == "OpenAPI"): - return "flask" - if module_name == "fastapi" and app_type == "FastAPI": - return "fastapi" - if module_name == "django" or "django" in module_name: - return "django" - return None + match (module_name, app_type): + case ("flask", "Flask") | ("flask_openapi3", "OpenAPI"): + return SupportedFramework.FLASK + case ("fastapi", "FastAPI"): + return SupportedFramework.FASTAPI + case (module, _) if module == "django" or "django" in module: + return SupportedFramework.DJANGO + case _: + return None def is_supported_framework(app: Any) -> bool: @@ -203,16 +220,15 @@ def is_supported_framework(app: Any) -> bool: def get_framework_adapter(app: Any) -> BaseAdapter: """Detect the framework and return the appropriate adapter.""" - framework = _detect_framework(app) - - if framework == "flask": - return FlaskAdapter(app) - if framework == "fastapi": - return FastAPIAdapter(app) - if framework == "django": - return DjangoAdapter(app) - - app_type = type(app).__name__ - raise TypeError( - f"Unsupported application type: {app_type}. pytest-api-coverage supports Flask, FastAPI, and Django." - ) + match _detect_framework(app): + case SupportedFramework.FLASK: + return FlaskAdapter(app) + case SupportedFramework.FASTAPI: + return FastAPIAdapter(app) + case SupportedFramework.DJANGO: + return DjangoAdapter(app) + case _: + app_type = type(app).__name__ + raise TypeError( + f"Unsupported application type: {app_type}. pytest-api-coverage supports Flask, FastAPI, and Django." + ) diff --git a/uv.lock b/uv.lock index 83236ed..caadf8a 100644 --- a/uv.lock +++ b/uv.lock @@ -47,6 +47,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" }, ] +[[package]] +name = "backports-strenum" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/c7/2ed54c32fed313591ffb21edbd48db71e68827d43a61938e5a0bc2b6ec91/backports_strenum-1.3.1.tar.gz", hash = "sha256:77c52407342898497714f0596e86188bb7084f89063226f4ba66863482f42414", size = 7257, upload-time = "2023-12-09T14:36:40.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/50/56cf20e2ee5127b603b81d5a69580a1a325083e2b921aa8f067da83927c0/backports_strenum-1.3.1-py3-none-any.whl", hash = "sha256:cdcfe36dc897e2615dc793b7d3097f54d359918fc448754a517e6f23044ccf83", size = 8304, upload-time = "2023-12-09T14:36:39.905Z" }, +] + [[package]] name = "blinker" version = "1.9.0" @@ -699,6 +708,7 @@ name = "pytest-api-cov" version = "1.3.7" source = { editable = "." } dependencies = [ + { name = "backports-strenum", marker = "python_full_version < '3.11'" }, { name = "pydantic" }, { name = "pytest" }, { name = "pyyaml" }, @@ -740,6 +750,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "backports-strenum", marker = "python_full_version < '3.11'", specifier = ">=1.3.1" }, { name = "django", marker = "extra == 'django'", specifier = ">=4.0.0" }, { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.68.0" }, { name = "flask", marker = "extra == 'flask'", specifier = ">=2.0.0" },