From 76924dddcb5ff76fe40239a479f51231ee6a9dac Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <291109589+skupriienko-mailgun@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:02:52 +0300 Subject: [PATCH 01/15] build(dependency): replace an obsolete httpx dependency with a modern httpx2 package --- .pre-commit-config.yaml | 12 +-- environment-dev.yaml | 1 - environment.yaml | 1 - mailgun/_httpx_compat.py | 28 ------ mailgun/client.py | 22 ++--- mailgun/endpoints.py | 30 +++---- mailgun/security.py | 2 +- mailgun/types.py | 4 +- pyproject.toml | 4 +- tests/fuzz/fuzz_async_client.py | 18 ++-- tests/fuzz/fuzz_async_evil_server.py | 26 +++--- tests/fuzz/fuzz_diff_serialization.py | 4 +- tests/fuzz/fuzz_differential.py | 10 +-- tests/fuzz/fuzz_error_parser.py | 6 +- tests/fuzz/fuzz_structure_aware.py | 20 ++--- tests/regression/test_regression.py | 20 ++--- tests/test_perf.py | 16 ++-- tests/unit/test_async_client.py | 122 +++++++++++++------------- tests/unit/test_client_security.py | 10 +-- tests/unit/test_endpoint.py | 10 +-- tests/unit/test_httpx_compat.py | 23 ----- 21 files changed, 168 insertions(+), 221 deletions(-) delete mode 100644 mailgun/_httpx_compat.py delete mode 100644 tests/unit/test_httpx_compat.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fea0dabe..1b692d81 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -105,7 +105,7 @@ repos: name: "🔒 security · Detect private keys" - repo: https://github.com/commitizen-tools/commitizen - rev: v4.18.1 + rev: v4.19.2 hooks: - id: commitizen name: "🌳 git · Validate commit message" @@ -134,7 +134,7 @@ repos: additional_dependencies: [".[toml]"] - repo: https://github.com/semgrep/pre-commit - rev: 'v1.177.0' + rev: 'v1.180.0' hooks: - id: semgrep name: "🔒 security · Static analysis (semgrep)" @@ -143,14 +143,14 @@ repos: # Spelling and typos - repo: https://github.com/crate-ci/typos - rev: v1.50.2 + rev: v1.51.1 hooks: - id: typos name: "📝 spelling · Check typos" # CI/CD validation - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.38.0 + rev: 0.38.2 hooks: - id: check-dependabot name: "🔧 ci/cd · Validate Dependabot config" @@ -172,7 +172,7 @@ repos: - responses - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.16.8 + rev: v0.16.10 hooks: - id: ruff-check name: "🐍 lint · Check with Ruff" @@ -191,7 +191,7 @@ repos: # Python type checking - repo: https://github.com/pre-commit/mirrors-mypy - rev: v2.3.1 + rev: v2.4.0 hooks: - id: mypy name: "🐍 types · Check with mypy" diff --git a/environment-dev.yaml b/environment-dev.yaml index ce5eb38c..07541f1f 100644 --- a/environment-dev.yaml +++ b/environment-dev.yaml @@ -12,7 +12,6 @@ dependencies: # runtime deps - requests >=2.33.0 - httpx2 >=2.7.0 - - httpx >=0.24.0 # extras - fastapi - pydantic >=2.0.0 diff --git a/environment.yaml b/environment.yaml index 5e49b0c1..b222416f 100644 --- a/environment.yaml +++ b/environment.yaml @@ -9,7 +9,6 @@ dependencies: # runtime deps - requests >=2.33.0 - httpx2 >=2.7.0 - - httpx >=0.24.0 # tests - pytest >=9.0.3 # other diff --git a/mailgun/_httpx_compat.py b/mailgun/_httpx_compat.py deleted file mode 100644 index eb9f5322..00000000 --- a/mailgun/_httpx_compat.py +++ /dev/null @@ -1,28 +0,0 @@ -"""HTTPX compatibility layer for the Mailgun SDK. - -Prefers httpx2 (the actively maintained continuation), but gracefully -falls back to the legacy httpx library if httpx2 is unavailable. -""" - -from typing import TYPE_CHECKING - - -if TYPE_CHECKING: - # For static analysis (Mypy/Pyright), we import from httpx since the APIs are identical - import httpx - from httpx import AsyncClient, HTTPError, Response, Timeout - - HAS_HTTPX2: bool -else: - try: - import httpx2 as httpx - from httpx2 import AsyncClient, HTTPError, Response, Timeout - - HAS_HTTPX2 = True - except ImportError: - import httpx - from httpx import AsyncClient, HTTPError, Response, Timeout - - HAS_HTTPX2 = False - -__all__ = ["HAS_HTTPX2", "AsyncClient", "HTTPError", "Response", "Timeout", "httpx"] diff --git a/mailgun/client.py b/mailgun/client.py index 1ceff049..8bccd504 100644 --- a/mailgun/client.py +++ b/mailgun/client.py @@ -22,9 +22,9 @@ from http import HTTPStatus from typing import TYPE_CHECKING, Any, Final, Self +import httpx2 import requests # pyright: ignore[reportMissingModuleSource] -from mailgun._httpx_compat import httpx from mailgun.config import Config from mailgun.endpoints import AsyncEndpoint, BaseEndpoint, Endpoint from mailgun.filters import RedactingFilter @@ -282,7 +282,7 @@ def ping(self) -> bool: class AsyncClient(BaseClient): - """Async client class using httpx.""" + """Async client class using httpx2.""" __slots__ = ("_client_kwargs", "_httpx_client") @@ -301,7 +301,7 @@ def __init__( """ super().__init__(auth=auth, **kwargs) self._client_kwargs = kwargs.get("client_kwargs", {}) - self._httpx_client: httpx.AsyncClient | None = None + self._httpx_client: httpx2.AsyncClient | None = None def __getattr__(self, name: str) -> Any: """Resolve and return the requested API endpoint instance. @@ -341,14 +341,14 @@ def __getattr__(self, name: str) -> Any: return endpoint @property - def _client(self) -> httpx.AsyncClient: - """Provide lazy initialization for the underlying httpx.AsyncClient. + def _client(self) -> httpx2.AsyncClient: + """Provide lazy initialization for the underlying httpx2.AsyncClient. Returns: - The active httpx.AsyncClient instance. + The active httpx2.AsyncClient instance. """ # Assign to a local variable so Pyright can properly narrow the type - current_client: httpx.AsyncClient | None = getattr(self, "_httpx_client", None) + current_client: httpx2.AsyncClient | None = getattr(self, "_httpx_client", None) if current_client is None or current_client.is_closed: # Enforce TLS 1.2+ for httpx (CWE-319) @@ -358,20 +358,20 @@ def _client(self) -> httpx.AsyncClient: # Check if the user already provided a custom transport (e.g. for mocking) kwargs = self._client_kwargs.copy() if "transport" not in kwargs: - limits = httpx.Limits(max_keepalive_connections=100, max_connections=100) - kwargs["transport"] = httpx.AsyncHTTPTransport( + limits = httpx2.Limits(max_keepalive_connections=100, max_connections=100) + kwargs["transport"] = httpx2.AsyncHTTPTransport( retries=3, limits=limits, verify=ssl_context, ) - self._httpx_client = httpx.AsyncClient(**kwargs) + self._httpx_client = httpx2.AsyncClient(**kwargs) return self._httpx_client return current_client async def aclose(self) -> None: - """Close the underlying httpx.AsyncClient and purge memory.""" + """Close the underlying httpx2.AsyncClient and purge memory.""" if self._httpx_client: try: # CWE-316: Clear async session diff --git a/mailgun/endpoints.py b/mailgun/endpoints.py index 95300daa..d2fbf0b9 100644 --- a/mailgun/endpoints.py +++ b/mailgun/endpoints.py @@ -10,11 +10,11 @@ from typing import TYPE_CHECKING, Any, Final from urllib.parse import parse_qs, urlparse +import httpx2 import requests # pyright: ignore[reportMissingModuleSource] from requests.models import Response # pyright: ignore[reportMissingModuleSource] from mailgun import routes -from mailgun._httpx_compat import httpx from mailgun.config import RetryPolicy from mailgun.handlers.error_handler import ApiError, MailgunTimeoutError from mailgun.logger import get_logger @@ -276,12 +276,12 @@ def _handle_api_error(e: Exception, method: str, target_url: str) -> None: MailgunTimeoutError: If the request times out. ApiError: If network routing fails or the API request fails. """ - if isinstance(e, (requests.Timeout, httpx.TimeoutException)): + if isinstance(e, (requests.Timeout, httpx2.TimeoutException)): msg = f"Request timed out for {method.upper()} {target_url}" raise MailgunTimeoutError(msg) from e if isinstance(e, ApiError): raise e - if isinstance(e, (requests.ConnectionError, httpx.ConnectError, httpx.NetworkError)): + if isinstance(e, (requests.ConnectionError, httpx2.ConnectError, httpx2.NetworkError)): msg = f"Network routing failed for {method.upper()} {target_url}: {e}" raise ApiError(msg) from e msg = f"API request failed for {method.upper()} {target_url}: {e}" @@ -343,7 +343,7 @@ def _merge_headers(self, kwargs: dict[str, Any]) -> dict[str, str]: req_headers.update(custom_headers) # CWE-400 / Crash Prevention: Enforce string keys and values to - # prevent HTTP protocol serialization crashes in requests/httpx. + # prevent HTTP protocol serialization crashes in requests/httpx2. return {str(k): str(v) for k, v in req_headers.items()} def _prepare_request( @@ -841,7 +841,7 @@ def stream( class AsyncEndpoint(BaseEndpoint): - """Generate async requests and return responses using httpx.""" + """Generate async requests and return responses using httpx2.""" __slots__ = ("_client",) @@ -850,7 +850,7 @@ def __init__( url: dict[str, Any], headers: dict[str, str], auth: tuple[str, str] | None, - client: httpx.AsyncClient | None = None, + client: httpx2.AsyncClient | None = None, timeout: TimeoutType = 60, *, dry_run: bool = False, @@ -861,12 +861,12 @@ def __init__( url: URL dictionary with pairs {"base": "keys"}. headers: Headers dictionary. auth: httpx auth tuple or None. - client: Optional httpx.AsyncClient instance to reuse. + client: Optional httpx2.AsyncClient instance to reuse. timeout: Base request timeout. dry_run: Execution sandbox flag. """ super().__init__(url, headers, auth, timeout=timeout, dry_run=dry_run) - self._client = client or httpx.AsyncClient() + self._client = client or httpx2.AsyncClient() async def api_call( # noqa: PLR0912, PLR0914, PLR0915 self, @@ -913,15 +913,15 @@ async def api_call( # noqa: PLR0912, PLR0914, PLR0915 safe_method.upper(), safe_url_for_log, ) - mock_request = httpx.Request(safe_method.upper(), target_url) - return httpx.Response( + mock_request = httpx2.Request(safe_method.upper(), target_url) + return httpx2.Response( HTTPStatus.OK, request=mock_request, content=b'{"message": "Dry run successful - request intercepted", "id": ""}', ) if isinstance(safe_timeout, tuple) and len(safe_timeout) == 2: # noqa: PLR2004 - safe_timeout = httpx.Timeout(safe_timeout[1], connect=safe_timeout[0]) + safe_timeout = httpx2.Timeout(safe_timeout[1], connect=safe_timeout[0]) request_kwargs: dict[str, Any] = { "method": safe_method.upper(), @@ -998,7 +998,7 @@ async def api_call( # noqa: PLR0912, PLR0914, PLR0915 break - except httpx.RequestError as e: + except httpx2.RequestError as e: if attempt < max_attempts - 1: delay = policy.calculate_delay(attempt) @@ -1017,14 +1017,14 @@ async def api_call( # noqa: PLR0912, PLR0914, PLR0915 continue - if isinstance(e, httpx.TimeoutException): + if isinstance(e, httpx2.TimeoutException): logger.exception( "Request timed out for %s %s", safe_method.upper(), safe_url_for_log, ) - elif isinstance(e, (httpx.ConnectError, httpx.NetworkError)): + elif isinstance(e, (httpx2.ConnectError, httpx2.NetworkError)): logger.critical( "Network routing failed for %s %s: %s", safe_method.upper(), @@ -1242,7 +1242,7 @@ async def stream( if hasattr(response, "raise_for_status"): try: response.raise_for_status() - except httpx.HTTPStatusError as exc: + except httpx2.HTTPStatusError as exc: raise ApiError(exc.response) from exc data = response.json() diff --git a/mailgun/security.py b/mailgun/security.py index e4497571..d1e28de4 100644 --- a/mailgun/security.py +++ b/mailgun/security.py @@ -272,7 +272,7 @@ def sanitize_timeout(cls, timeout: TimeoutType) -> TimeoutType: ) raise ValueError(msg) - # Extract values from httpx.Timeout object cleanly + # Extract values from httpx2.Timeout object cleanly if hasattr(timeout, "read") and hasattr(timeout, "connect"): timeout = (getattr(timeout, "connect", 60.0), getattr(timeout, "read", 60.0)) diff --git a/mailgun/types.py b/mailgun/types.py index f1e8ddaa..2191ec2a 100644 --- a/mailgun/types.py +++ b/mailgun/types.py @@ -9,8 +9,8 @@ if TYPE_CHECKING: - from mailgun._httpx_compat import Response as HttpxResponse - from mailgun._httpx_compat import Timeout as HttpxTimeout + from httpx2 import Response as HttpxResponse + from httpx2 import Timeout as HttpxTimeout __all__ = [ "APIResponseType", diff --git a/pyproject.toml b/pyproject.toml index dabd1d31..fe9fb1cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,8 +44,7 @@ classifiers = [ dynamic = [ "version" ] dependencies = [ - "httpx2 >=2.7.0", # The new primary async engine - "httpx >=0.24.0", # Kept as a fallback/safety net + "httpx2 >=2.7.0", "requests>=2.33.0", ] @@ -207,6 +206,7 @@ preview = true [tool.ruff.lint.per-file-ignores] "__init__.py" = [ "module-import-not-at-top-of-file" ] "mailgun/endpoints.py" = ["import-outside-top-level"] +"tests/**" = ["assert", "import-outside-top-level"] [tool.ruff.lint.pylint] max-args = 9 # Default is 5. SDK endpoints need more (auth, data, headers, files, etc.) diff --git a/tests/fuzz/fuzz_async_client.py b/tests/fuzz/fuzz_async_client.py index e0c516f1..417b2c4f 100755 --- a/tests/fuzz/fuzz_async_client.py +++ b/tests/fuzz/fuzz_async_client.py @@ -13,7 +13,7 @@ with atheris.instrument_imports(): from mailgun import routes - from mailgun._httpx_compat import httpx as compat_httpx + import httpx2 from mailgun.client import AsyncClient from mailgun.handlers.error_handler import ApiError @@ -25,24 +25,24 @@ _VALID_ENDPOINTS = list(routes.EXACT_ROUTES.keys()) + list(routes.PREFIX_ROUTES.keys()) -class MockAsyncTransport(compat_httpx.AsyncBaseTransport): +class MockAsyncTransport(httpx2.AsyncBaseTransport): """Zero-allocation async mock transport.""" - _static_resp = compat_httpx.Response(200, content=b'{"id": "async-test", "items": []}') + _static_resp = httpx2.Response(200, content=b'{"id": "async-test", "items": []}') - async def handle_async_request(self, request: compat_httpx.Request) -> compat_httpx.Response: + async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response: return self._static_resp -original_init = compat_httpx.AsyncClient.__init__ +original_init = httpx2.AsyncClient.__init__ -def secure_init(self: compat_httpx.AsyncClient, *args: Any, **kwargs: Any) -> None: +def secure_init(self: httpx2.AsyncClient, *args: Any, **kwargs: Any) -> None: kwargs["transport"] = MockAsyncTransport() original_init(self, *args, **kwargs) -compat_httpx.AsyncClient.__init__ = secure_init # type: ignore[method-assign] +httpx2.AsyncClient.__init__ = secure_init # type: ignore[method-assign] async def _worker_task(client: AsyncClient, fdp: atheris.FuzzedDataProvider) -> None: @@ -64,7 +64,7 @@ async def _worker_task(client: AsyncClient, fdp: atheris.FuzzedDataProvider) -> RuntimeError, TypeError, ValueError, - compat_httpx.RequestError, + httpx2.RequestError, ): # Fuzzing intentionally suppresses expected malformed-input/runtime errors # so one failing path does not stop concurrent exploration. @@ -96,7 +96,7 @@ async def _async_fuzz_target(data: bytes) -> None: RuntimeError, TypeError, ValueError, - compat_httpx.RequestError, + httpx2.RequestError, ): pass diff --git a/tests/fuzz/fuzz_async_evil_server.py b/tests/fuzz/fuzz_async_evil_server.py index fd53783a..a1276abc 100755 --- a/tests/fuzz/fuzz_async_evil_server.py +++ b/tests/fuzz/fuzz_async_evil_server.py @@ -15,7 +15,7 @@ import atheris with atheris.instrument_imports(): - from mailgun._httpx_compat import httpx as compat_httpx + import httpx2 from mailgun.client import AsyncClient, Config from mailgun.config import RetryPolicy from mailgun.handlers.error_handler import ApiError, MailgunTimeoutError @@ -55,18 +55,18 @@ def TestOneInput(data: bytes) -> None: fdp = atheris.FuzzedDataProvider(data) - original_send = compat_httpx.AsyncClient.send + original_send = httpx2.AsyncClient.send async def evil_send( - self: compat_httpx.AsyncClient, request: compat_httpx.Request, **kwargs: Any - ) -> compat_httpx.Response: + self: httpx2.AsyncClient, request: httpx2.Request, **kwargs: Any + ) -> httpx2.Response: if fdp.ConsumeBool(): exceptions = [ - compat_httpx.ConnectError("Fuzzed Connection Drop"), - compat_httpx.NetworkError("Fuzzed Network Error"), - compat_httpx.ProtocolError("Fuzzed Protocol Error"), - compat_httpx.ReadTimeout("Fuzzed Timeout"), - compat_httpx.TooManyRedirects("Infinite Redirect Loop"), + httpx2.ConnectError("Fuzzed Connection Drop"), + httpx2.NetworkError("Fuzzed Network Error"), + httpx2.ProtocolError("Fuzzed Protocol Error"), + httpx2.ReadTimeout("Fuzzed Timeout"), + httpx2.TooManyRedirects("Infinite Redirect Loop"), ] raise fdp.PickValueInList(exceptions) @@ -87,14 +87,14 @@ async def evil_send( } garbage_bytes = fdp.ConsumeBytes(512) - return compat_httpx.Response( + return httpx2.Response( status_code=status, headers=headers, content=garbage_bytes, request=request, ) - compat_httpx.AsyncClient.send = evil_send # type: ignore[method-assign] + httpx2.AsyncClient.send = evil_send # type: ignore[method-assign] async def run_fuzz() -> None: with Path(os.devnull).open("w") as devnull, contextlib.redirect_stdout( @@ -117,7 +117,7 @@ async def run_fuzz() -> None: MailgunTimeoutError, TypeError, ValueError, - compat_httpx.RequestError, + httpx2.RequestError, json.JSONDecodeError, TimeoutError, asyncio.TimeoutError, @@ -130,7 +130,7 @@ async def run_fuzz() -> None: f"SDK crashed handling Async Evil Server response: {type(e).__name__} - {e}" ) from e finally: - compat_httpx.AsyncClient.send = original_send # type: ignore[method-assign] + httpx2.AsyncClient.send = original_send # type: ignore[method-assign] # Global patch prevents any residual retry or backoff sleeps from blocking the runner with patch("time.sleep", return_value=None), patch("asyncio.sleep", return_value=None): diff --git a/tests/fuzz/fuzz_diff_serialization.py b/tests/fuzz/fuzz_diff_serialization.py index 33cc8d36..1244ed51 100644 --- a/tests/fuzz/fuzz_diff_serialization.py +++ b/tests/fuzz/fuzz_diff_serialization.py @@ -10,7 +10,7 @@ with atheris.instrument_imports(): import requests - from mailgun._httpx_compat import httpx as compat_httpx + import httpx2 logging.disable(logging.CRITICAL) @@ -48,7 +48,7 @@ def TestOneInput(data: bytes) -> None: payload[key] = fdp.ConsumeBool() sync_req = requests.Request("POST", "https://api.mailgun.net/v3/fuzz", data=payload) - async_req = compat_httpx.Request("POST", "https://api.mailgun.net/v3/fuzz", data=payload) + async_req = httpx2.Request("POST", "https://api.mailgun.net/v3/fuzz", data=payload) sync_exc: Exception | None = None async_exc: Exception | None = None diff --git a/tests/fuzz/fuzz_differential.py b/tests/fuzz/fuzz_differential.py index 152378e3..5489112b 100644 --- a/tests/fuzz/fuzz_differential.py +++ b/tests/fuzz/fuzz_differential.py @@ -10,7 +10,7 @@ import atheris import requests -from mailgun._httpx_compat import httpx as compat_httpx +import httpx2 with atheris.instrument_imports(): @@ -104,16 +104,16 @@ def mock_requests_send( } async def mock_httpx_handle( - self: compat_httpx.AsyncBaseTransport, request: compat_httpx.Request - ) -> compat_httpx.Response: - return compat_httpx.Response( + self: httpx2.AsyncBaseTransport, request: httpx2.Request + ) -> httpx2.Response: + return httpx2.Response( status_code=status_code, headers=byte_headers, content=resp_body, request=request, ) - compat_httpx.AsyncHTTPTransport.handle_async_request = mock_httpx_handle # type: ignore[method-assign] + httpx2.AsyncHTTPTransport.handle_async_request = mock_httpx_handle # type: ignore[method-assign] target_attr = fdp.PickValueInList(_VALID_ENDPOINTS) method_name = fdp.PickValueInList(_HTTP_METHODS) diff --git a/tests/fuzz/fuzz_error_parser.py b/tests/fuzz/fuzz_error_parser.py index 7674de5a..86ab8f0b 100644 --- a/tests/fuzz/fuzz_error_parser.py +++ b/tests/fuzz/fuzz_error_parser.py @@ -8,7 +8,7 @@ import atheris import requests -from mailgun._httpx_compat import httpx as compat_httpx +import httpx2 with atheris.instrument_imports(): @@ -106,11 +106,11 @@ def TestOneInput(data: bytes) -> None: if status_code == 429 and fdp.ConsumeBool(): httpx_headers["retry-after"] = str(fdp.ConsumeIntInRange(0, 300)) - httpx_resp = compat_httpx.Response( + httpx_resp = httpx2.Response( status_code=status_code, headers=httpx_headers, content=content, - request=compat_httpx.Request("POST", "https://api.mailgun.net/v3/messages"), + request=httpx2.Request("POST", "https://api.mailgun.net/v3/messages"), ) try: diff --git a/tests/fuzz/fuzz_structure_aware.py b/tests/fuzz/fuzz_structure_aware.py index 9bc286b2..6747018a 100644 --- a/tests/fuzz/fuzz_structure_aware.py +++ b/tests/fuzz/fuzz_structure_aware.py @@ -15,7 +15,7 @@ import atheris with atheris.instrument_imports(): - from mailgun._httpx_compat import httpx as compat_httpx + import httpx2 from mailgun.client import AsyncClient, Config from mailgun.config import RetryPolicy from mailgun.handlers.error_handler import ApiError @@ -29,7 +29,7 @@ asyncio.set_event_loop(_FUZZ_LOOP) -class ChaosAsyncTransport(compat_httpx.AsyncBaseTransport): +class ChaosAsyncTransport(httpx2.AsyncBaseTransport): """Dynamic mock transport returning chaos responses based on request properties.""" def __init__(self) -> None: @@ -48,9 +48,9 @@ def configure( self.headers = headers or [(b"content-type", b"application/json")] async def handle_async_request( - self, request: compat_httpx.Request - ) -> compat_httpx.Response: - return compat_httpx.Response( + self, request: httpx2.Request + ) -> httpx2.Response: + return httpx2.Response( status_code=self.status_code, content=self.response_body, headers=self.headers, @@ -59,17 +59,17 @@ async def handle_async_request( _CHAOS_TRANSPORT = ChaosAsyncTransport() -_ORIGINAL_INIT = compat_httpx.AsyncClient.__init__ +_ORIGINAL_INIT = httpx2.AsyncClient.__init__ def _secure_init( - self: compat_httpx.AsyncClient, *args: Any, **kwargs: Any + self: httpx2.AsyncClient, *args: Any, **kwargs: Any ) -> None: kwargs["transport"] = _CHAOS_TRANSPORT _ORIGINAL_INIT(self, *args, **kwargs) -compat_httpx.AsyncClient.__init__ = _secure_init # type: ignore[method-assign] +httpx2.AsyncClient.__init__ = _secure_init # type: ignore[method-assign] # Disable retries and backoff to guarantee sub-millisecond turnarounds _NO_RETRY_CONFIG = Config( @@ -192,8 +192,8 @@ def TestOneInput(data: bytes) -> None: except ( ApiError, - compat_httpx.HTTPStatusError, - compat_httpx.RequestError, + httpx2.HTTPStatusError, + httpx2.RequestError, ValueError, TypeError, KeyError, diff --git a/tests/regression/test_regression.py b/tests/regression/test_regression.py index 46e925fe..e3d8b1f2 100644 --- a/tests/regression/test_regression.py +++ b/tests/regression/test_regression.py @@ -11,7 +11,7 @@ from mailgun.logger import get_logger from mailgun.security import SecurityGuard from mailgun.filters import RedactingFilter -from mailgun._httpx_compat import httpx as compat_httpx +import httpx2 import requests from mailgun.handlers.email_validation_handler import handle_address_validate from pydantic import ValidationError @@ -114,7 +114,7 @@ class TestControlCharacters: @pytest.mark.asyncio async def test_async_endpoint_rejects_control_characters(self) -> None: """Ensure the asynchronous client intercepts control characters injected - via endpoint kwargs before they crash httpx. + via endpoint kwargs before they crash httpx2. """ client = AsyncClient(auth=("api", "key")) @@ -540,15 +540,15 @@ def mock_send( # Mock HTTPX async def mock_handle( - self: Any, request: compat_httpx.Request - ) -> compat_httpx.Response: + self: Any, request: httpx2.Request + ) -> httpx2.Response: byte_headers = { k.encode("latin-1"): ( v.encode("latin-1", "replace") if isinstance(v, str) else v ) for k, v in headers.items() } - return compat_httpx.Response( + return httpx2.Response( status_code=status_code, headers=byte_headers, content=body, @@ -556,7 +556,7 @@ async def mock_handle( ) monkeypatch.setattr( - compat_httpx.AsyncHTTPTransport, "handle_async_request", mock_handle + httpx2.AsyncHTTPTransport, "handle_async_request", mock_handle ) sync_client = Client(auth=("api", "key-test")) @@ -626,15 +626,15 @@ def test_sanitize_headers_rejects_null_byte_in_list_value(self) -> None: async def test_async_endpoint_stream_handles_http_error_gracefully(self, monkeypatch: pytest.MonkeyPatch) -> None: """Ensure AsyncEndpoint.stream() wraps HTTP errors into ApiError rather than leaking HTTPStatusError.""" async def mock_handle( - self: Any, request: compat_httpx.Request - ) -> compat_httpx.Response: - return compat_httpx.Response( + self: Any, request: httpx2.Request + ) -> httpx2.Response: + return httpx2.Response( status_code=404, content=b'{"message": "Not Found"}', request=request, ) - monkeypatch.setattr(compat_httpx.AsyncHTTPTransport, "handle_async_request", mock_handle) + monkeypatch.setattr(httpx2.AsyncHTTPTransport, "handle_async_request", mock_handle) client = AsyncClient(auth=("api", "test-key")) with pytest.raises(ApiError) as exc_info: diff --git a/tests/test_perf.py b/tests/test_perf.py index 3c06f8cd..28300bbc 100644 --- a/tests/test_perf.py +++ b/tests/test_perf.py @@ -10,7 +10,7 @@ import requests # pyright: ignore[reportMissingModuleSource] import responses -from mailgun._httpx_compat import httpx +import httpx2 from mailgun.client import AsyncClient, Client @@ -93,14 +93,14 @@ def dispatch_batch() -> None: def test_async_client_concurrent_throughput(benchmark: Any) -> None: """Measures how fast the AsyncClient can dispatch concurrent requests. - This proves that httpx.Limits(max_connections=100) prevents asyncio bottlenecks. + This proves that httpx2.Limits(max_connections=100) prevents asyncio bottlenecks. """ BATCH_SIZE = 50 - async def mock_handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json={"id": "", "message": "Queued."}) + async def mock_handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, json={"id": "", "message": "Queued."}) - mock_transport = httpx.MockTransport(mock_handler) + mock_transport = httpx2.MockTransport(mock_handler) try: # 1. Attempt modern injection (using client_kwargs dictionary) @@ -116,7 +116,7 @@ async def mock_handler(request: httpx.Request) -> httpx.Response: if existing is None or getattr(existing, "_transport", None) != mock_transport: auth = getattr(existing, "auth", getattr(client, "auth", ("api", "key"))) - limits = getattr(existing, "_limits", httpx.Limits(max_connections=100)) + limits = getattr(existing, "_limits", httpx2.Limits(max_connections=100)) kwargs: dict[str, Any] = {"transport": mock_transport, "auth": auth, "limits": limits} @@ -128,7 +128,7 @@ async def mock_handler(request: httpx.Request) -> httpx.Response: if timeout: kwargs["timeout"] = timeout - new_httpx_client = httpx.AsyncClient(**kwargs) + new_httpx_client = httpx2.AsyncClient(**kwargs) try: # Use setattr to bypass static read-only property restrictions @@ -140,7 +140,7 @@ async def mock_handler(request: httpx.Request) -> httpx.Response: setattr(client, "_httpx_client", new_httpx_client) # ------------------------------- - async def send_one_email(i: int) -> httpx.Response: + async def send_one_email(i: int) -> httpx2.Response: return await client.messages.create( domain="test.com", data={ diff --git a/tests/unit/test_async_client.py b/tests/unit/test_async_client.py index b3f6e515..06af2803 100644 --- a/tests/unit/test_async_client.py +++ b/tests/unit/test_async_client.py @@ -7,7 +7,7 @@ import pytest -from mailgun._httpx_compat import httpx as compat_httpx +import httpx2 from mailgun.client import AsyncClient, AsyncEndpoint, Config, SecurityGuard from mailgun.endpoints import Endpoint from mailgun.handlers.error_handler import ApiError @@ -15,8 +15,8 @@ class TestAsyncClient: - @patch("mailgun.client.httpx.AsyncHTTPTransport") - @patch("mailgun.client.httpx.AsyncClient") + @patch("mailgun.client.httpx2.AsyncHTTPTransport") + @patch("mailgun.client.httpx2.AsyncClient") @pytest.mark.asyncio async def test_aclose_closes_httpx_client( self, mock_client_class: MagicMock, _mock_transport: MagicMock @@ -34,7 +34,7 @@ async def test_aclose_frees_memory_and_is_idempotent(self) -> None: """Verify that aclose() nullifies the client for GC and is safe to call twice.""" client = AsyncClient(auth=("api", "key")) - mock_httpx = AsyncMock(spec=compat_httpx.AsyncClient) + mock_httpx = AsyncMock(spec=httpx2.AsyncClient) client._httpx_client = mock_httpx assert client._httpx_client is not None @@ -54,7 +54,7 @@ async def test_async_client_aclose_is_idempotent_and_safe_to_call_multiple_times """Ensures that calling `.aclose()` repeatedly does not crash the client.""" client = AsyncClient(auth=("api", "key")) - client._httpx_client = AsyncMock(spec=compat_httpx.AsyncClient) + client._httpx_client = AsyncMock(spec=httpx2.AsyncClient) assert client._httpx_client is not None await client.aclose() @@ -63,8 +63,8 @@ async def test_async_client_aclose_is_idempotent_and_safe_to_call_multiple_times await client.aclose() assert client._httpx_client is None - @patch("mailgun.client.httpx.AsyncHTTPTransport") - @patch("mailgun.client.httpx.AsyncClient") + @patch("mailgun.client.httpx2.AsyncHTTPTransport") + @patch("mailgun.client.httpx2.AsyncClient") def test_async_client_connection_pooling_configured( self, _mock_httpx: MagicMock, mock_transport: MagicMock ) -> None: @@ -84,19 +84,19 @@ async def test_async_client_context_manager(self) -> None: """Ensures the async context manager correctly initializes and closes the client.""" async with AsyncClient(auth=("api", "key")) as client: assert client.auth == ("api", "key") - client._httpx_client = AsyncMock(spec=compat_httpx.AsyncClient) + client._httpx_client = AsyncMock(spec=httpx2.AsyncClient) assert client._httpx_client is not None assert client._httpx_client is None - @patch("mailgun.client.httpx.AsyncHTTPTransport") - @patch("mailgun.client.httpx.AsyncClient") + @patch("mailgun.client.httpx2.AsyncHTTPTransport") + @patch("mailgun.client.httpx2.AsyncClient") @pytest.mark.asyncio async def test_async_client_context_manager_clean_exit( - self, _mock_httpx: MagicMock, _mock_transport: MagicMock + self, _mock_httpx2: MagicMock, _mock_transport: MagicMock ) -> None: """Cover clean AsyncClient __aexit__.""" - _mock_httpx.return_value.aclose = AsyncMock() + _mock_httpx2.return_value.aclose = AsyncMock() client = AsyncClient(auth=("api", "key")) async with client: @@ -116,8 +116,8 @@ async def test_async_client_context_manager_exception_propagation(self) -> None: assert client.auth is None @pytest.mark.asyncio - @patch("mailgun.endpoints.httpx.AsyncClient.request") - @patch("mailgun.client.httpx.AsyncHTTPTransport") + @patch("mailgun.endpoints.httpx2.AsyncClient.request") + @patch("mailgun.client.httpx2.AsyncHTTPTransport") async def test_async_client_context_manager_reuse( self, mock_transport_class: MagicMock, mock_request: MagicMock ) -> None: @@ -145,7 +145,7 @@ async def test_async_client_context_manager_reuse( @pytest.mark.asyncio async def test_async_client_custom_transport_bypasses_default(self) -> None: """Verify passing a custom HTTPX transport skips the default TLS transport creation.""" - mock_transport = compat_httpx.MockTransport(lambda _: compat_httpx.Response(200)) + mock_transport = httpx2.MockTransport(lambda _: httpx2.Response(200)) client = AsyncClient( auth=("api", "key"), @@ -156,8 +156,8 @@ async def test_async_client_custom_transport_bypasses_default(self) -> None: assert client._httpx_client._transport is mock_transport # pyright: ignore[reportOptionalMemberAccess] - @patch("mailgun.client.httpx.AsyncHTTPTransport") - @patch("mailgun.client.httpx.AsyncClient") + @patch("mailgun.client.httpx2.AsyncHTTPTransport") + @patch("mailgun.client.httpx2.AsyncClient") def test_async_client_dir_includes_endpoints( self, _mock_httpx: MagicMock, _mock_transport: MagicMock ) -> None: @@ -169,8 +169,8 @@ def test_async_client_dir_includes_endpoints( assert "bounces" in client_dir assert "domains" in client_dir - @patch("mailgun.client.httpx.AsyncHTTPTransport") - @patch("mailgun.client.httpx.AsyncClient") + @patch("mailgun.client.httpx2.AsyncHTTPTransport") + @patch("mailgun.client.httpx2.AsyncClient") def test_async_client_getattr_caching_and_dir( self, _mock_httpx: MagicMock, _mock_transport: MagicMock ) -> None: @@ -185,8 +185,8 @@ def test_async_client_getattr_caching_and_dir( assert ep1._url == ep2._url assert ep1._auth == ep2._auth - @patch("mailgun.client.httpx.AsyncHTTPTransport") - @patch("mailgun.client.httpx.AsyncClient") + @patch("mailgun.client.httpx2.AsyncHTTPTransport") + @patch("mailgun.client.httpx2.AsyncClient") def test_async_client_getattr_invalid_route( self, _mock_httpx: MagicMock, _mock_transport: MagicMock ) -> None: @@ -206,8 +206,8 @@ def test_async_client_getattr_magic_methods(self) -> None: assert client_copy is not client assert isinstance(client_copy, AsyncClient) - @patch("mailgun.client.httpx.AsyncHTTPTransport") - @patch("mailgun.client.httpx.AsyncClient") + @patch("mailgun.client.httpx2.AsyncHTTPTransport") + @patch("mailgun.client.httpx2.AsyncClient") def test_async_client_getattr_returns_async_endpoint_type( self, _mock_httpx: MagicMock, _mock_transport: MagicMock ) -> None: @@ -230,13 +230,13 @@ def test_async_client_getattr_suppresses_keyerror(self) -> None: assert exc_info.value.__suppress_context__ is True @pytest.mark.asyncio - @patch("mailgun.endpoints.httpx.AsyncClient.request") - @patch("mailgun.client.httpx.AsyncHTTPTransport") + @patch("mailgun.endpoints.httpx2.AsyncClient.request") + @patch("mailgun.client.httpx2.AsyncHTTPTransport") async def test_async_client_global_timeout_not_shadowed( self, _mock_transport: MagicMock, mock_request: MagicMock ) -> None: """Verify that the global timeout is not shadowed by the method's default value.""" - mock_request.return_value = MagicMock(status_code=200, spec=compat_httpx.Response) + mock_request.return_value = MagicMock(status_code=200, spec=httpx2.Response) client = AsyncClient(auth=("api", "key"), timeout=299.0) await client.messages.create(domain="test.com", data={"to": "test@test.com"}) @@ -247,8 +247,8 @@ async def test_async_client_global_timeout_not_shadowed( assert "timeout" in kwargs assert kwargs["timeout"] == 299.0 - @patch("mailgun.client.httpx.AsyncHTTPTransport") - @patch("mailgun.client.httpx.AsyncClient") + @patch("mailgun.client.httpx2.AsyncHTTPTransport") + @patch("mailgun.client.httpx2.AsyncClient") def test_async_client_inherits_client( self, _mock_httpx: MagicMock, _mock_transport: MagicMock ) -> None: @@ -256,23 +256,23 @@ def test_async_client_inherits_client( assert client.auth == ("api", "key-123") assert client.config.api_url == Config.DEFAULT_API_URL - @patch("mailgun.client.httpx.AsyncHTTPTransport") - @patch("mailgun.client.httpx.AsyncClient") + @patch("mailgun.client.httpx2.AsyncHTTPTransport") + @patch("mailgun.client.httpx2.AsyncClient") @pytest.mark.asyncio async def test_async_context_manager( self, mock_client_class: MagicMock, _mock_transport: MagicMock ) -> None: - mock_instance = mock_httpx = mock_client_class.return_value - mock_httpx.aclose = AsyncMock() + mock_instance = mock_httpx2 = mock_client_class.return_value + mock_httpx2.aclose = AsyncMock() async with AsyncClient() as client: _ = client._client assert client._httpx_client is mock_instance - mock_httpx.aclose.assert_called_once() + mock_httpx2.aclose.assert_called_once() - @patch("mailgun.client.httpx.AsyncHTTPTransport") - @patch("mailgun.client.httpx.AsyncClient") + @patch("mailgun.client.httpx2.AsyncHTTPTransport") + @patch("mailgun.client.httpx2.AsyncClient") def test_async_global_timeout_propagates_to_endpoint( self, _mock_httpx: MagicMock, _mock_transport: MagicMock ) -> None: @@ -344,7 +344,7 @@ async def test_async_client_property_reinitializes_if_closed_unhappy_path(self) assert second_pool is not None assert second_pool.is_closed is False - # Validate that a brand new httpx.AsyncClient was generated + # Validate that a brand new httpx2.AsyncClient was generated assert first_pool is not second_pool, "Failed to regenerate a closed connection pool!" await client.aclose() @@ -446,14 +446,14 @@ def _make_endpoint() -> AsyncEndpoint: url=url, headers={}, auth=None, - client=MagicMock(spec=compat_httpx.AsyncClient), + client=MagicMock(spec=httpx2.AsyncClient), ) @pytest.mark.asyncio async def test_api_call_exception_chaining(self) -> None: """Verify that PEP 3134 exception chaining preserves the original httpx error.""" - mock_client = AsyncMock(spec=compat_httpx.AsyncClient) - original_err = compat_httpx.RequestError("Async DNS resolution failed", request=MagicMock()) + mock_client = AsyncMock(spec=httpx2.AsyncClient) + original_err = httpx2.RequestError("Async DNS resolution failed", request=MagicMock()) mock_client.request.side_effect = original_err url = {"base": f"{BASE_URL_V3}/", "keys": ["messages"]} @@ -469,8 +469,8 @@ async def test_api_call_exception_chaining(self) -> None: @pytest.mark.asyncio async def test_api_call_raises_api_error_on_request_error(self) -> None: url = {"base": f"{BASE_URL_V4}/", "keys": ["domainlist"]} - mock_client = AsyncMock(spec=compat_httpx.AsyncClient) - mock_client.request.side_effect = compat_httpx.RequestError( + mock_client = AsyncMock(spec=httpx2.AsyncClient) + mock_client.request.side_effect = httpx2.RequestError( "network error", request=MagicMock() ) ep = AsyncEndpoint(url=url, headers={}, auth=None, client=mock_client) @@ -480,8 +480,8 @@ async def test_api_call_raises_api_error_on_request_error(self) -> None: @pytest.mark.asyncio async def test_api_call_raises_timeout_error(self) -> None: url = {"base": f"{BASE_URL_V4}/", "keys": ["domainlist"]} - mock_client = AsyncMock(spec=compat_httpx.AsyncClient) - mock_client.request.side_effect = compat_httpx.TimeoutException("timeout") + mock_client = AsyncMock(spec=httpx2.AsyncClient) + mock_client.request.side_effect = httpx2.TimeoutException("timeout") ep = AsyncEndpoint(url=url, headers={}, auth=None, client=mock_client) with pytest.raises(TimeoutError): await ep.get() @@ -493,11 +493,11 @@ async def test_api_call_truncates_long_error_response( ) -> None: """Test that async error responses are NOT logged to prevent secret leakage.""" url = {"base": "https://api.mailgun.net/v4/", "keys": ["domainlist"]} - mock_client = AsyncMock(spec=compat_httpx.AsyncClient) + mock_client = AsyncMock(spec=httpx2.AsyncClient) long_response_text = "A" * 600 mock_resp = MagicMock( - status_code=500, text=long_response_text, spec=compat_httpx.Response + status_code=500, text=long_response_text, spec=httpx2.Response ) mock_resp.json.side_effect = ValueError("No JSON") mock_client.request = AsyncMock(return_value=mock_resp) @@ -514,9 +514,9 @@ async def test_async_endpoint_missing_verbs_and_stream_filters( self, mock_get: AsyncMock ) -> None: """Cover missing verbs and stream filter logic.""" - mock_client = AsyncMock(spec=compat_httpx.AsyncClient) + mock_client = AsyncMock(spec=httpx2.AsyncClient) mock_client.request = AsyncMock( - return_value=MagicMock(status_code=200, spec=compat_httpx.Response) + return_value=MagicMock(status_code=200, spec=httpx2.Response) ) ep = AsyncEndpoint( url={"base": "https://api.mailgun.net/v3/", "keys": ["test"]}, @@ -540,9 +540,9 @@ async def test_async_endpoint_missing_verbs_and_stream_filters( async def test_async_endpoint_payload_is_strictly_minified(self) -> None: """Test that JSON payloads are strictly minified before async transmission.""" url = {"base": f"{BASE_URL_V4}/", "keys": ["domainlist"]} - mock_client = AsyncMock(spec=compat_httpx.AsyncClient) + mock_client = AsyncMock(spec=httpx2.AsyncClient) mock_client.request = AsyncMock( - return_value=MagicMock(status_code=200, spec=compat_httpx.Response) + return_value=MagicMock(status_code=200, spec=httpx2.Response) ) ep = AsyncEndpoint( url=url, @@ -565,9 +565,9 @@ async def test_async_endpoint_payload_is_strictly_minified(self) -> None: @pytest.mark.asyncio async def test_create_sends_post(self) -> None: url = {"base": f"{BASE_URL_V4}/", "keys": ["domainlist"]} - mock_client = AsyncMock(spec=compat_httpx.AsyncClient) + mock_client = AsyncMock(spec=httpx2.AsyncClient) mock_client.request = AsyncMock( - return_value=MagicMock(status_code=200, spec=compat_httpx.Response) + return_value=MagicMock(status_code=200, spec=httpx2.Response) ) ep = AsyncEndpoint(url=url, headers={}, auth=None, client=mock_client) await ep.create(data={"key": "value"}) @@ -577,9 +577,9 @@ async def test_create_sends_post(self) -> None: @pytest.mark.asyncio async def test_delete_calls_client_request(self) -> None: url = {"base": f"{BASE_URL_V4}/", "keys": ["domainlist"]} - mock_client = AsyncMock(spec=compat_httpx.AsyncClient) + mock_client = AsyncMock(spec=httpx2.AsyncClient) mock_client.request = AsyncMock( - return_value=MagicMock(status_code=200, spec=compat_httpx.Response) + return_value=MagicMock(status_code=200, spec=httpx2.Response) ) ep = AsyncEndpoint(url=url, headers={}, auth=None, client=mock_client) await ep.delete() @@ -589,9 +589,9 @@ async def test_delete_calls_client_request(self) -> None: @pytest.mark.asyncio async def test_get_calls_client_request(self) -> None: url = {"base": f"{BASE_URL_V4}/", "keys": ["domainlist"]} - mock_client = AsyncMock(spec=compat_httpx.AsyncClient) + mock_client = AsyncMock(spec=httpx2.AsyncClient) mock_client.request = AsyncMock( - return_value=MagicMock(status_code=200, spec=compat_httpx.Response) + return_value=MagicMock(status_code=200, spec=httpx2.Response) ) ep = AsyncEndpoint( url=url, headers={"User-agent": "test"}, auth=("api", "key"), client=mock_client @@ -603,9 +603,9 @@ async def test_get_calls_client_request(self) -> None: @pytest.mark.asyncio async def test_patch_calls_client_request(self) -> None: url = {"base": f"{BASE_URL_V4}/", "keys": ["domainlist"]} - mock_client = AsyncMock(spec=compat_httpx.AsyncClient) + mock_client = AsyncMock(spec=httpx2.AsyncClient) mock_client.request = AsyncMock( - return_value=MagicMock(status_code=200, spec=compat_httpx.Response) + return_value=MagicMock(status_code=200, spec=httpx2.Response) ) ep = AsyncEndpoint(url=url, headers={}, auth=("api", "key"), client=mock_client) await ep.patch(data={"test": "data"}) @@ -618,9 +618,9 @@ async def test_patch_calls_client_request(self) -> None: @pytest.mark.asyncio async def test_put_calls_client_request(self) -> None: url = {"base": f"{BASE_URL_V4}/", "keys": ["domainlist"]} - mock_client = AsyncMock(spec=compat_httpx.AsyncClient) + mock_client = AsyncMock(spec=httpx2.AsyncClient) mock_client.request = AsyncMock( - return_value=MagicMock(status_code=200, spec=compat_httpx.Response) + return_value=MagicMock(status_code=200, spec=httpx2.Response) ) ep = AsyncEndpoint(url=url, headers={}, auth=("api", "key"), client=mock_client) await ep.put(data={"test": "data"}) @@ -633,9 +633,9 @@ async def test_put_calls_client_request(self) -> None: @pytest.mark.asyncio async def test_update_serializes_json_with_custom_headers(self) -> None: url = {"base": f"{BASE_URL_V4}/", "keys": ["domainlist"]} - mock_client = AsyncMock(spec=compat_httpx.AsyncClient) + mock_client = AsyncMock(spec=httpx2.AsyncClient) mock_client.request = AsyncMock( - return_value=MagicMock(status_code=200, spec=compat_httpx.Response) + return_value=MagicMock(status_code=200, spec=httpx2.Response) ) ep = AsyncEndpoint(url=url, headers={}, auth=None, client=mock_client) await ep.update( diff --git a/tests/unit/test_client_security.py b/tests/unit/test_client_security.py index 7a793aae..ed78e499 100644 --- a/tests/unit/test_client_security.py +++ b/tests/unit/test_client_security.py @@ -13,7 +13,7 @@ import pytest import requests # pyright: ignore[reportMissingModuleSource] -from mailgun._httpx_compat import httpx as compat_httpx +import httpx2 from mailgun.client import ( AsyncClient, Client, @@ -445,14 +445,14 @@ async def test_async_client_emits_audit_hook(self, mock_audit: MagicMock) -> Non client = AsyncClient(auth=("api", "key")) # Patch the transport directly - with patch("httpx.AsyncHTTPTransport") as mock_transport_class: + with patch("httpx2.AsyncHTTPTransport") as mock_transport_class: # Create a mock instance mock_transport_instance = AsyncMock() mock_transport_class.return_value = mock_transport_instance # Ensure handle_async_request is an AsyncMock that returns a valid response mock_transport_instance.handle_async_request = AsyncMock( - return_value=compat_httpx.Response(200) + return_value=httpx2.Response(200) ) await client.domains.get() @@ -479,13 +479,13 @@ async def test_async_connection_exception_logs_safely(self, mock_logger_crit: Ma """Verify that when an async network failure occurs, the logger uses safe_url_for_log.""" client = AsyncClient(auth=("api", "key")) - with patch("mailgun.client.httpx.AsyncHTTPTransport") as mock_transport_class: + with patch("mailgun.client.httpx2.AsyncHTTPTransport") as mock_transport_class: mock_transport_instance = AsyncMock() mock_transport_class.return_value = mock_transport_instance # Set the side_effect on the async handler mock_transport_instance.handle_async_request = AsyncMock( - side_effect=compat_httpx.ConnectError("DNS failure") + side_effect=httpx2.ConnectError("DNS failure") ) with pytest.raises(ApiError, match="Network routing failed"): diff --git a/tests/unit/test_endpoint.py b/tests/unit/test_endpoint.py index 23beef77..821b0d90 100644 --- a/tests/unit/test_endpoint.py +++ b/tests/unit/test_endpoint.py @@ -5,7 +5,7 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, patch -import httpx +import httpx2 import pytest import requests # pyright: ignore[reportMissingModuleSource] @@ -102,7 +102,7 @@ def test_async_api_call_dry_run_intercepts_request(self) -> None: """Ensure Async dry_run mode intercepts email messages and returns a mock response.""" url = {"base": f"{BASE_URL_V3}/", "keys": ["messages"]} - mock_client = AsyncMock(spec=httpx.AsyncClient) + mock_client = AsyncMock(spec=httpx2.AsyncClient) ep = AsyncEndpoint( url=url, headers={}, auth=("api", "key"), dry_run=True, client=mock_client ) @@ -122,7 +122,7 @@ def test_async_api_call_dry_run_standard_route(self) -> None: """Ensure standard async routes fallback to the generic JSON mock.""" url = {"base": f"{BASE_URL_V3}/", "keys": ["domains"]} - mock_client = AsyncMock(spec=httpx.AsyncClient) + mock_client = AsyncMock(spec=httpx2.AsyncClient) ep = AsyncEndpoint( url=url, headers={}, auth=("api", "key"), dry_run=True, client=mock_client ) @@ -227,7 +227,7 @@ def test_async_endpoint_put_delete_methods(self) -> None: """ url = {"base": "https://api.mailgun.net/v3/", "keys": ["domains"]} - mock_client = AsyncMock(spec=httpx.AsyncClient) + mock_client = AsyncMock(spec=httpx2.AsyncClient) ep = AsyncEndpoint(url=url, headers={}, auth=("api", "key"), client=mock_client) with patch( @@ -504,7 +504,7 @@ async def test_async_endpoint_retries_transient_error(self) -> None: url = {"base": "https://api.mailgun.net/v3/", "keys": ["messages"]} policy = RetryPolicy(max_retries=1, base_delay=0.01) - mock_client = AsyncMock(spec=httpx.AsyncClient) + mock_client = AsyncMock(spec=httpx2.AsyncClient) ep = AsyncEndpoint(url=url, headers={}, auth=("api", "key"), client=mock_client) ep.retry_policy = policy diff --git a/tests/unit/test_httpx_compat.py b/tests/unit/test_httpx_compat.py deleted file mode 100644 index cc0f7c8f..00000000 --- a/tests/unit/test_httpx_compat.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Unit tests for httpx compatibility layer fallback behavior.""" - -from __future__ import annotations - -import importlib -import sys - -import mailgun._httpx_compat - - -def test_httpx_compat_import_fallback() -> None: - """Verify that if httpx2 is missing, the compat layer successfully falls back to httpx.""" - saved_httpx2 = sys.modules.pop("httpx2", None) - sys.modules["httpx2"] = None # type: ignore[assignment] - - try: - importlib.reload(mailgun._httpx_compat) - assert mailgun._httpx_compat.HAS_HTTPX2 is False - finally: - sys.modules.pop("httpx2", None) - if saved_httpx2 is not None: - sys.modules["httpx2"] = saved_httpx2 - importlib.reload(mailgun._httpx_compat) From 3a265fbf0c94cd73f17e3f917c0994276b3763ee Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <291109589+skupriienko-mailgun@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:40:45 +0300 Subject: [PATCH 02/15] chore(lint): configure Ruff per-file-ignores for test suites - Relax strict production linter rules across tests/** (assert, magic values, unittest assertions, etc.). - Ignore COM812 (missing-trailing-comma) to avoid conflicts with ruff format. --- pyproject.toml | 106 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 103 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fe9fb1cd..115bfe59 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -177,6 +177,7 @@ docstring-code-format = true select = [ "ALL" ] ignore = [ # --- Formatter Conflicts --- + "missing-trailing-comma", # COM812: Recommended by Ruff when using ruff format "single-line-implicit-string-concatenation", # Implicit string concatenation (Conflicts with ruff-format) "incorrect-blank-line-before-class", # one-blank-line-before-class (conflicts with D211) "multi-line-summary-second-line", # multi-line-summary-second-line (conflicts with D212) @@ -188,7 +189,9 @@ ignore = [ "line-too-long", # Line too long (Let the formatter handle wrapping) # --- Docstring --- - "undocumented-param", "undocumented-public-module", "undocumented-public-package", + "undocumented-param", + "undocumented-public-module", + "undocumented-public-package", # --- Keep existing TODO ignores --- "complex-structure", @@ -203,10 +206,107 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" external = [ "DOC", "PLR" ] preview = true +# Test suite rules relaxation [tool.ruff.lint.per-file-ignores] -"__init__.py" = [ "module-import-not-at-top-of-file" ] +"__init__.py" = ["module-import-not-at-top-of-file"] "mailgun/endpoints.py" = ["import-outside-top-level"] -"tests/**" = ["assert", "import-outside-top-level"] + +# Test suite relaxations +"tests/**" = [ + # Core test assertions & naming + "assert", + "magic-value-comparison", + "invalid-function-name", + "non-lowercase-variable-in-function", + "shebang-not-executable", + "commented-out-code", + "implicit-namespace-package", + "class-as-data-structure", + "collections-named-tuple", + + # Docstring relaxations in tests + "missing-blank-line-after-summary", + "undocumented-public-module", + "undocumented-public-package", + "undocumented-public-class", + "undocumented-public-method", + "undocumented-public-function", + "undocumented-public-init", + "undocumented-public-nested-class", + "undocumented-magic-method", + "docstring-missing-returns", + "docstring-missing-yields", + "docstring-missing-exception", + "missing-terminal-punctuation", + + # Test execution & white-box state inspections + "private-member-access", + "import-private-name", + "import-outside-top-level", + "set-attr-with-constant", + "builtin-argument-shadowing", + "unused-function-argument", + "unused-method-argument", + "unused-lambda-argument", + "unused-loop-control-variable", + "unused-unpacked-variable", + "unused-async", + "manual-list-comprehension", + "if-exp-instead-of-or-operator", + "no-self-use", + + # String & float equality test assertions + "compare-to-empty-string", + "float-equality-comparison", + "boolean-positional-value-in-call", + "boolean-type-hint-positional-argument", + "boolean-default-value-positional-argument", + + # Pytest & Unittest specifics + "pytest-unittest-assertion", + "pytest-unittest-raises-assertion", + "pytest-fixture-param-without-value", + "pytest-raises-too-broad", + "pytest-warns-too-broad", + "pytest-warns-with-multiple-statements", + "pytest-raises-with-multiple-statements", + + # Security & Logging test vectors + "hardcoded-password-string", + "hardcoded-password-func-arg", + "ambiguous-unicode-character-string", + "static-join-to-f-string", + "suspicious-subprocess-import", + "subprocess-without-shell-equals-true", + "start-process-with-partial-path", + "root-logger-call", + "logging-extra-attr-clash", + + # Complexity & Flow in fuzzing harnesses + "too-many-branches", + "too-many-statements", + "too-many-locals", + "too-many-nested-blocks", + "too-many-public-methods", + "f-string-in-exception", + "raise-within-try", + "type-check-without-type-error", + "raise-without-from-inside-except", + "error-suffix-on-exception-name", + "print", + "blind-except", + "try-except-pass", + "suppressible-exception", + "blocking-open-call-in-async-function", + "blocking-sleep-in-async-function", + "unspecified-encoding", + "call-datetime-now-without-tzinfo", + "typing-only-first-party-import", + "typing-only-standard-library-import", + "missing-todo-author", + "missing-todo-link", + "line-contains-todo", +] [tool.ruff.lint.pylint] max-args = 9 # Default is 5. SDK endpoints need more (auth, data, headers, files, etc.) From 59f8403933d2dc9d0b09051d26aea99c2ec0a34d Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <291109589+skupriienko-mailgun@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:40:57 +0300 Subject: [PATCH 03/15] test(unit): apply Ruff formatting and linter fixes across unit tests - Convert tuple membership tests to set literals in conftest.py. - Remove redundant dict.keys() calls in assertions. - Combine nested with statements and format files with ruff format. --- tests/conftest.py | 6 +- tests/unit/test_async_client.py | 143 ++++++++---- tests/unit/test_audit_hooks.py | 21 +- tests/unit/test_builders.py | 2 +- tests/unit/test_client.py | 11 +- tests/unit/test_client_security.py | 112 +++++++--- tests/unit/test_config.py | 36 +-- tests/unit/test_deprecation_warnings.py | 25 ++- tests/unit/test_endpoint.py | 106 ++++++--- tests/unit/test_error_handler.py | 1 - tests/unit/test_ext_pydantic.py | 2 +- tests/unit/test_handlers.py | 54 ++++- tests/unit/test_integration_mirror.py | 280 ++++++++++++++++-------- tests/unit/test_logger.py | 15 +- tests/unit/test_pagination.py | 22 +- tests/unit/test_routes.py | 14 +- tests/unit/test_security_guards.py | 15 +- tests/unit/test_types.py | 5 +- 18 files changed, 601 insertions(+), 269 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index e0b47611..905af353 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -35,14 +35,16 @@ def pytest_addoption(parser: pytest.Parser) -> None: def pytest_collection_modifyitems( - config: pytest.Config, items: list[pytest.Item] + config: pytest.Config, + items: list[pytest.Item], ) -> None: if config.getoption("--run-skipped"): for item in items: for marker in list(item.iter_markers()): - if marker.name in ("skip", "skipif"): + if marker.name in {"skip", "skipif"}: item.own_markers.remove(marker) + @pytest.fixture(autouse=True) def bypass_retry_delays(monkeypatch: pytest.MonkeyPatch) -> None: """Automatically speeds up test execution by blocking the actual delays diff --git a/tests/unit/test_async_client.py b/tests/unit/test_async_client.py index 06af2803..f11cfbd6 100644 --- a/tests/unit/test_async_client.py +++ b/tests/unit/test_async_client.py @@ -5,9 +5,9 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, patch +import httpx2 import pytest -import httpx2 from mailgun.client import AsyncClient, AsyncEndpoint, Config, SecurityGuard from mailgun.endpoints import Endpoint from mailgun.handlers.error_handler import ApiError @@ -19,7 +19,9 @@ class TestAsyncClient: @patch("mailgun.client.httpx2.AsyncClient") @pytest.mark.asyncio async def test_aclose_closes_httpx_client( - self, mock_client_class: MagicMock, _mock_transport: MagicMock + self, + mock_client_class: MagicMock, + _mock_transport: MagicMock, ) -> None: mock_instance = mock_client_class.return_value mock_instance.aclose = AsyncMock() @@ -66,7 +68,9 @@ async def test_async_client_aclose_is_idempotent_and_safe_to_call_multiple_times @patch("mailgun.client.httpx2.AsyncHTTPTransport") @patch("mailgun.client.httpx2.AsyncClient") def test_async_client_connection_pooling_configured( - self, _mock_httpx: MagicMock, mock_transport: MagicMock + self, + _mock_httpx: MagicMock, + mock_transport: MagicMock, ) -> None: """Verify that AsyncHTTPTransport is configured with expanded limits.""" client = AsyncClient(auth=("api", "key")) @@ -93,7 +97,9 @@ async def test_async_client_context_manager(self) -> None: @patch("mailgun.client.httpx2.AsyncClient") @pytest.mark.asyncio async def test_async_client_context_manager_clean_exit( - self, _mock_httpx2: MagicMock, _mock_transport: MagicMock + self, + _mock_httpx2: MagicMock, + _mock_transport: MagicMock, ) -> None: """Cover clean AsyncClient __aexit__.""" _mock_httpx2.return_value.aclose = AsyncMock() @@ -119,7 +125,9 @@ async def test_async_client_context_manager_exception_propagation(self) -> None: @patch("mailgun.endpoints.httpx2.AsyncClient.request") @patch("mailgun.client.httpx2.AsyncHTTPTransport") async def test_async_client_context_manager_reuse( - self, mock_transport_class: MagicMock, mock_request: MagicMock + self, + mock_transport_class: MagicMock, + mock_request: MagicMock, ) -> None: """Verify that reusing the AsyncClient creates a new transport.""" mock_transport_instance = mock_transport_class.return_value @@ -159,7 +167,9 @@ async def test_async_client_custom_transport_bypasses_default(self) -> None: @patch("mailgun.client.httpx2.AsyncHTTPTransport") @patch("mailgun.client.httpx2.AsyncClient") def test_async_client_dir_includes_endpoints( - self, _mock_httpx: MagicMock, _mock_transport: MagicMock + self, + _mock_httpx: MagicMock, + _mock_transport: MagicMock, ) -> None: """Test that IDE introspection via __dir__ exposes config endpoints.""" client = AsyncClient() @@ -172,7 +182,9 @@ def test_async_client_dir_includes_endpoints( @patch("mailgun.client.httpx2.AsyncHTTPTransport") @patch("mailgun.client.httpx2.AsyncClient") def test_async_client_getattr_caching_and_dir( - self, _mock_httpx: MagicMock, _mock_transport: MagicMock + self, + _mock_httpx: MagicMock, + _mock_transport: MagicMock, ) -> None: """Ensures that dynamic endpoints are correctly instantiated.""" client = AsyncClient(auth=("api", "key")) @@ -188,12 +200,17 @@ def test_async_client_getattr_caching_and_dir( @patch("mailgun.client.httpx2.AsyncHTTPTransport") @patch("mailgun.client.httpx2.AsyncClient") def test_async_client_getattr_invalid_route( - self, _mock_httpx: MagicMock, _mock_transport: MagicMock + self, + _mock_httpx: MagicMock, + _mock_transport: MagicMock, ) -> None: """Test that unknown routes in AsyncClient safely throw an AttributeError.""" client = AsyncClient(auth=("api", "key")) - with pytest.raises(AttributeError, match="'AsyncClient' object has no attribute 'some_unknown_feature'"): + with pytest.raises( + AttributeError, + match="'AsyncClient' object has no attribute 'some_unknown_feature'", + ): _ = client.some_unknown_feature def test_async_client_getattr_magic_methods(self) -> None: @@ -209,7 +226,9 @@ def test_async_client_getattr_magic_methods(self) -> None: @patch("mailgun.client.httpx2.AsyncHTTPTransport") @patch("mailgun.client.httpx2.AsyncClient") def test_async_client_getattr_returns_async_endpoint_type( - self, _mock_httpx: MagicMock, _mock_transport: MagicMock + self, + _mock_httpx: MagicMock, + _mock_transport: MagicMock, ) -> None: client = AsyncClient(auth=("api", "key-123")) ep = client.domains @@ -222,7 +241,8 @@ def test_async_client_getattr_suppresses_keyerror(self) -> None: client = AsyncClient(auth=("api", "key")) with pytest.raises( - AttributeError, match="'AsyncClient' object has no attribute '!@#'" + AttributeError, + match="'AsyncClient' object has no attribute '!@#'", ) as exc_info: _ = getattr(client, "!@#") @@ -233,7 +253,9 @@ def test_async_client_getattr_suppresses_keyerror(self) -> None: @patch("mailgun.endpoints.httpx2.AsyncClient.request") @patch("mailgun.client.httpx2.AsyncHTTPTransport") async def test_async_client_global_timeout_not_shadowed( - self, _mock_transport: MagicMock, mock_request: MagicMock + self, + _mock_transport: MagicMock, + mock_request: MagicMock, ) -> None: """Verify that the global timeout is not shadowed by the method's default value.""" mock_request.return_value = MagicMock(status_code=200, spec=httpx2.Response) @@ -250,7 +272,9 @@ async def test_async_client_global_timeout_not_shadowed( @patch("mailgun.client.httpx2.AsyncHTTPTransport") @patch("mailgun.client.httpx2.AsyncClient") def test_async_client_inherits_client( - self, _mock_httpx: MagicMock, _mock_transport: MagicMock + self, + _mock_httpx: MagicMock, + _mock_transport: MagicMock, ) -> None: client = AsyncClient(auth=("api", "key-123")) assert client.auth == ("api", "key-123") @@ -260,7 +284,9 @@ def test_async_client_inherits_client( @patch("mailgun.client.httpx2.AsyncClient") @pytest.mark.asyncio async def test_async_context_manager( - self, mock_client_class: MagicMock, _mock_transport: MagicMock + self, + mock_client_class: MagicMock, + _mock_transport: MagicMock, ) -> None: mock_instance = mock_httpx2 = mock_client_class.return_value mock_httpx2.aclose = AsyncMock() @@ -274,7 +300,9 @@ async def test_async_context_manager( @patch("mailgun.client.httpx2.AsyncHTTPTransport") @patch("mailgun.client.httpx2.AsyncClient") def test_async_global_timeout_propagates_to_endpoint( - self, _mock_httpx: MagicMock, _mock_transport: MagicMock + self, + _mock_httpx: MagicMock, + _mock_transport: MagicMock, ) -> None: """Test that timeout of AsyncClient is used in AsyncEndpoints.""" client = AsyncClient(auth=("api", "key"), timeout=25.0) @@ -361,6 +389,7 @@ def test_async_client_unclosed_resource_warning(self) -> None: async def test_async_unclosed_warning(self, recwarn: pytest.WarningsRecorder) -> None: """Hits the __del__ warning for unclosed async clients.""" import warnings + with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") client = AsyncClient(auth=("api", "key")) @@ -391,12 +420,15 @@ async def test_async_client_ping_success_and_failure(self) -> None: @pytest.mark.asyncio async def test_async_endpoint_stream_type_casting(self) -> None: """Coverage: Endpoints.stream parameter type drift normalization.""" + class MockResp: - def raise_for_status(self) -> None: pass + def raise_for_status(self) -> None: + pass + def json(self) -> dict: return { "items": [{"id": 1}], - "paging": {"next": "http://test?limit=10.5&ascending=true&str_val=hello"} + "paging": {"next": "http://test?limit=10.5&ascending=true&str_val=hello"}, } mock_client = AsyncMock() @@ -405,7 +437,7 @@ def json(self) -> dict: # Force the generator to break mock_client.request.side_effect = [ MockResp(), - MagicMock(json=lambda: {"items": []}, raise_for_status=lambda: None) + MagicMock(json=lambda: {"items": []}, raise_for_status=lambda: None), ] filters = {"limit": 5.0, "ascending": False, "str_val": "old"} @@ -415,6 +447,7 @@ def json(self) -> dict: @pytest.mark.asyncio async def test_async_stream_pagination_type_casting_all_types(self) -> None: """Covers endpoints.py lines 1231-1239: async type casting for int, tuple, set, list.""" + class MockResp: def raise_for_status(self) -> None: pass @@ -461,7 +494,11 @@ async def test_api_call_exception_chaining(self) -> None: with pytest.raises(ApiError) as exc_info: await ep.api_call( - auth=("api", "key"), method="GET", url=url, headers={}, domain="test.com" + auth=("api", "key"), + method="GET", + url=url, + headers={}, + domain="test.com", ) assert exc_info.value.__cause__ is original_err @@ -471,7 +508,8 @@ async def test_api_call_raises_api_error_on_request_error(self) -> None: url = {"base": f"{BASE_URL_V4}/", "keys": ["domainlist"]} mock_client = AsyncMock(spec=httpx2.AsyncClient) mock_client.request.side_effect = httpx2.RequestError( - "network error", request=MagicMock() + "network error", + request=MagicMock(), ) ep = AsyncEndpoint(url=url, headers={}, auth=None, client=mock_client) with pytest.raises(ApiError): @@ -489,7 +527,8 @@ async def test_api_call_raises_timeout_error(self) -> None: @patch("mailgun.endpoints.logger.error") @pytest.mark.asyncio async def test_api_call_truncates_long_error_response( - self, mock_logger_error: MagicMock + self, + mock_logger_error: MagicMock, ) -> None: """Test that async error responses are NOT logged to prevent secret leakage.""" url = {"base": "https://api.mailgun.net/v4/", "keys": ["domainlist"]} @@ -497,7 +536,9 @@ async def test_api_call_truncates_long_error_response( long_response_text = "A" * 600 mock_resp = MagicMock( - status_code=500, text=long_response_text, spec=httpx2.Response + status_code=500, + text=long_response_text, + spec=httpx2.Response, ) mock_resp.json.side_effect = ValueError("No JSON") mock_client.request = AsyncMock(return_value=mock_resp) @@ -511,12 +552,13 @@ async def test_api_call_truncates_long_error_response( @pytest.mark.asyncio @patch.object(AsyncEndpoint, "get") async def test_async_endpoint_missing_verbs_and_stream_filters( - self, mock_get: AsyncMock + self, + mock_get: AsyncMock, ) -> None: """Cover missing verbs and stream filter logic.""" mock_client = AsyncMock(spec=httpx2.AsyncClient) mock_client.request = AsyncMock( - return_value=MagicMock(status_code=200, spec=httpx2.Response) + return_value=MagicMock(status_code=200, spec=httpx2.Response), ) ep = AsyncEndpoint( url={"base": "https://api.mailgun.net/v3/", "keys": ["test"]}, @@ -530,7 +572,8 @@ async def test_async_endpoint_missing_verbs_and_stream_filters( await ep.delete(domain="test.com") mock_get.return_value = MagicMock( - json=lambda: {"items": []}, raise_for_status=lambda: None + json=lambda: {"items": []}, + raise_for_status=lambda: None, ) results = [item async for item in ep.stream(filters={"limit": 10})] # pyright: ignore[reportGeneralTypeIssues] @@ -542,7 +585,7 @@ async def test_async_endpoint_payload_is_strictly_minified(self) -> None: url = {"base": f"{BASE_URL_V4}/", "keys": ["domainlist"]} mock_client = AsyncMock(spec=httpx2.AsyncClient) mock_client.request = AsyncMock( - return_value=MagicMock(status_code=200, spec=httpx2.Response) + return_value=MagicMock(status_code=200, spec=httpx2.Response), ) ep = AsyncEndpoint( url=url, @@ -567,7 +610,7 @@ async def test_create_sends_post(self) -> None: url = {"base": f"{BASE_URL_V4}/", "keys": ["domainlist"]} mock_client = AsyncMock(spec=httpx2.AsyncClient) mock_client.request = AsyncMock( - return_value=MagicMock(status_code=200, spec=httpx2.Response) + return_value=MagicMock(status_code=200, spec=httpx2.Response), ) ep = AsyncEndpoint(url=url, headers={}, auth=None, client=mock_client) await ep.create(data={"key": "value"}) @@ -579,7 +622,7 @@ async def test_delete_calls_client_request(self) -> None: url = {"base": f"{BASE_URL_V4}/", "keys": ["domainlist"]} mock_client = AsyncMock(spec=httpx2.AsyncClient) mock_client.request = AsyncMock( - return_value=MagicMock(status_code=200, spec=httpx2.Response) + return_value=MagicMock(status_code=200, spec=httpx2.Response), ) ep = AsyncEndpoint(url=url, headers={}, auth=None, client=mock_client) await ep.delete() @@ -591,10 +634,13 @@ async def test_get_calls_client_request(self) -> None: url = {"base": f"{BASE_URL_V4}/", "keys": ["domainlist"]} mock_client = AsyncMock(spec=httpx2.AsyncClient) mock_client.request = AsyncMock( - return_value=MagicMock(status_code=200, spec=httpx2.Response) + return_value=MagicMock(status_code=200, spec=httpx2.Response), ) ep = AsyncEndpoint( - url=url, headers={"User-agent": "test"}, auth=("api", "key"), client=mock_client + url=url, + headers={"User-agent": "test"}, + auth=("api", "key"), + client=mock_client, ) await ep.get() mock_client.request.assert_called_once() @@ -605,7 +651,7 @@ async def test_patch_calls_client_request(self) -> None: url = {"base": f"{BASE_URL_V4}/", "keys": ["domainlist"]} mock_client = AsyncMock(spec=httpx2.AsyncClient) mock_client.request = AsyncMock( - return_value=MagicMock(status_code=200, spec=httpx2.Response) + return_value=MagicMock(status_code=200, spec=httpx2.Response), ) ep = AsyncEndpoint(url=url, headers={}, auth=("api", "key"), client=mock_client) await ep.patch(data={"test": "data"}) @@ -620,7 +666,7 @@ async def test_put_calls_client_request(self) -> None: url = {"base": f"{BASE_URL_V4}/", "keys": ["domainlist"]} mock_client = AsyncMock(spec=httpx2.AsyncClient) mock_client.request = AsyncMock( - return_value=MagicMock(status_code=200, spec=httpx2.Response) + return_value=MagicMock(status_code=200, spec=httpx2.Response), ) ep = AsyncEndpoint(url=url, headers={}, auth=("api", "key"), client=mock_client) await ep.put(data={"test": "data"}) @@ -635,11 +681,12 @@ async def test_update_serializes_json_with_custom_headers(self) -> None: url = {"base": f"{BASE_URL_V4}/", "keys": ["domainlist"]} mock_client = AsyncMock(spec=httpx2.AsyncClient) mock_client.request = AsyncMock( - return_value=MagicMock(status_code=200, spec=httpx2.Response) + return_value=MagicMock(status_code=200, spec=httpx2.Response), ) ep = AsyncEndpoint(url=url, headers={}, auth=None, client=mock_client) await ep.update( - data={"key": "value"}, headers={"Content-Type": "application/json"} + data={"key": "value"}, + headers={"Content-Type": "application/json"}, ) mock_client.request.assert_called_once() kwargs = mock_client.request.call_args[1] @@ -663,7 +710,7 @@ def raise_for_status(self) -> None: async def test_async_stream_pagination_empty_items(self, mock_get: AsyncMock) -> None: """Cover the zero-iteration async loop break.""" page_1 = self.MockPaginationResponse( - {"items": [], "paging": {"next": "https://api.mailgun.net/v3/domains?skip=1"}} + {"items": [], "paging": {"next": "https://api.mailgun.net/v3/domains?skip=1"}}, ) mock_get.return_value = page_1 endpoint = AsyncEndpoint( @@ -678,10 +725,11 @@ async def test_async_stream_pagination_empty_items(self, mock_get: AsyncMock) -> @patch.object(AsyncEndpoint, "get") @pytest.mark.asyncio async def test_async_stream_pagination_no_next_url_with_items( - self, mock_get: AsyncMock + self, + mock_get: AsyncMock, ) -> None: page_1 = self.MockPaginationResponse( - {"items": [{"id": "event_1"}], "paging": {}} + {"items": [{"id": "event_1"}], "paging": {}}, ) mock_get.return_value = page_1 endpoint = AsyncEndpoint( @@ -700,7 +748,7 @@ async def test_async_stream_pagination_no_next_url_with_items( def test_sync_stream_pagination_empty_items(self, mock_get: MagicMock) -> None: """Cover the zero-iteration loop break.""" page_1 = self.MockPaginationResponse( - {"items": [], "paging": {"next": "https://api.mailgun.net/v3/domains?skip=1"}} + {"items": [], "paging": {"next": "https://api.mailgun.net/v3/domains?skip=1"}}, ) mock_get.return_value = page_1 endpoint = Endpoint(url={"base": "http://mock", "keys": []}, headers={}, auth=None) @@ -709,10 +757,11 @@ def test_sync_stream_pagination_empty_items(self, mock_get: MagicMock) -> None: @patch.object(Endpoint, "get") def test_sync_stream_pagination_no_next_url_with_items( - self, mock_get: MagicMock + self, + mock_get: MagicMock, ) -> None: page_1 = self.MockPaginationResponse( - {"items": [{"id": "event_1"}], "paging": {}} + {"items": [{"id": "event_1"}], "paging": {}}, ) mock_get.return_value = page_1 endpoint = Endpoint(url={"base": "http://mock", "keys": []}, headers={}, auth=None) @@ -722,6 +771,7 @@ def test_sync_stream_pagination_no_next_url_with_items( def test_stream_pagination_type_drift(self) -> None: """Hits the type-casting logic inside Endpoint.stream()""" + class MockResponse: def __init__(self, items: list[dict[str, Any]], next_url: str | None) -> None: self._items = items @@ -734,11 +784,18 @@ def json(self) -> dict[str, Any]: return {"items": self._items, "paging": {"next": self._next_url}} responses = [ - MockResponse([{"id": 1}], "https://api.mailgun.net/v3/domain/events?limit=1&ascending=yes"), - MockResponse([], None) + MockResponse( + [{"id": 1}], + "https://api.mailgun.net/v3/domain/events?limit=1&ascending=yes", + ), + MockResponse([], None), ] - ep = Endpoint(url={"base": "https://test", "keys": ["events"]}, headers={}, auth=("api", "key")) + ep = Endpoint( + url={"base": "https://test", "keys": ["events"]}, + headers={}, + auth=("api", "key"), + ) with patch.object(Endpoint, "get", side_effect=responses): results = list(ep.stream()) assert len(results) == 1 diff --git a/tests/unit/test_audit_hooks.py b/tests/unit/test_audit_hooks.py index 032b73f1..c0ac4695 100644 --- a/tests/unit/test_audit_hooks.py +++ b/tests/unit/test_audit_hooks.py @@ -11,24 +11,28 @@ class TestEnterpriseAuditHooks: @patch("sys.audit") def test_audit_hook_emits_on_control_characters( - self, mock_audit: MagicMock + self, + mock_audit: MagicMock, ) -> None: """Ensure control characters (CWE-20) trigger an audit event before crashing.""" bad_input = "payload\x00hidden" with pytest.raises(ValueError, match="Security Alert \\(CWE-20\\)"): SecurityGuard.validate_no_control_characters( - bad_input, context="PayloadField" + bad_input, + context="PayloadField", ) # Verify the SecOps team receives the context of the attack mock_audit.assert_called_once_with( - "mailgun.security.control_characters", "PayloadField" + "mailgun.security.control_characters", + "PayloadField", ) @patch("sys.audit") def test_audit_hook_emits_on_crlf_header_injection( - self, mock_audit: MagicMock + self, + mock_audit: MagicMock, ) -> None: """Ensure CRLF injections (CWE-113) trigger an audit event before crashing.""" bad_headers = {"X-Custom": "value\nInjected-Header: bad"} @@ -38,7 +42,8 @@ def test_audit_hook_emits_on_crlf_header_injection( # Verify the exact telemetry event was broadcasted to the runtime mock_audit.assert_called_once_with( - "mailgun.security.header_injection", "X-Custom" + "mailgun.security.header_injection", + "X-Custom", ) @patch("sys.audit") @@ -51,12 +56,14 @@ def test_audit_hook_emits_on_ssrf_attempt(self, mock_audit: MagicMock) -> None: # Verify the exact malicious URL is sent to the audit log mock_audit.assert_called_once_with( - "mailgun.security.ssrf_attempt", untrusted_url + "mailgun.security.ssrf_attempt", + untrusted_url, ) @patch("sys.addaudithook") def test_enable_security_audit_registers_hook( - self, mock_addaudithook: MagicMock + self, + mock_addaudithook: MagicMock, ) -> None: """Ensure the opt-in security audit method successfully binds to the OS runtime.""" # Call the opt-in method diff --git a/tests/unit/test_builders.py b/tests/unit/test_builders.py index 7a5ee083..8ae5fd46 100644 --- a/tests/unit/test_builders.py +++ b/tests/unit/test_builders.py @@ -246,7 +246,7 @@ def test_template_builder_copy_requests(self) -> None: [ {"account_id": "123", "name": "new-template"}, {"account_id": "456", "name": "other-template", "domain": "test.com"}, - ] + ], ) .build() ) diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index a3562750..ecbb75d7 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -83,6 +83,7 @@ def test_client_coverage_enhancement(self) -> None: def test_client_unclosed_resource_warning(self) -> None: """Verify that leaving a Client unclosed triggers a ResourceWarning upon deletion.""" import gc + client = Client(auth=("api", "key")) _ = client._session with pytest.warns(ResourceWarning, match="Unclosed Client detected"): @@ -101,11 +102,13 @@ def test_sync_client_close_clears_auth_and_headers(self) -> None: def test_client_del_attribute_error(self) -> None: """Coverage: Silently catch AttributeError during GC deletion.""" import gc + client = Client(auth=("api", "key")) del client._session # Force the attribute lookup/get_attribute to fail del client gc.collect() # Trigger finalization via GC; must pass silently without crashing + class TestClientPing: def test_sync_client_ping_success(self) -> None: client = Client(auth=("api", "key-123")) @@ -160,7 +163,10 @@ def test_client_init_with_auth(self) -> None: def test_sync_ping_network_failure(self) -> None: """Hits the except Exception branch inside ping().""" with pytest.MonkeyPatch.context() as m: - m.setattr("requests.Session.request", lambda *a, **k: (_ for _ in ()).throw(ConnectionError("Network Down"))) + m.setattr( + "requests.Session.request", + lambda *a, **k: (_ for _ in ()).throw(ConnectionError("Network Down")), + ) client = Client(auth=("api", "key")) assert client.ping() is False @@ -181,7 +187,7 @@ def test_timeout_mapping_sync(self) -> None: # Force the underlying requests session to throw a ReadTimeout m.setattr( "requests.Session.request", - lambda *args, **kwargs: (_ for _ in ()).throw(ReadTimeout("Timeout")) + lambda *args, **kwargs: (_ for _ in ()).throw(ReadTimeout("Timeout")), ) client.domains.get(domain="test.com") @@ -189,6 +195,7 @@ def test_timeout_mapping_sync(self) -> None: def test_deliverability_error_formatting(self) -> None: """Coverage: Ensure the custom SpamGuard exception formats output correctly.""" from mailgun.handlers.error_handler import DeliverabilityError + error = DeliverabilityError(score=45.0, issues=["Missing alt tags"]) assert "Score: 45.0/100" in str(error) diff --git a/tests/unit/test_client_security.py b/tests/unit/test_client_security.py index ed78e499..9624afb0 100644 --- a/tests/unit/test_client_security.py +++ b/tests/unit/test_client_security.py @@ -10,10 +10,10 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, patch +import httpx2 import pytest import requests # pyright: ignore[reportMissingModuleSource] -import httpx2 from mailgun.client import ( AsyncClient, Client, @@ -150,7 +150,7 @@ def test_verify_webhook_invalid_types_and_disabled_ttl(self) -> None: # max_age_seconds <= 0 disables expiration check old_ts = 1000 - msg = f"{old_ts}token".encode("utf-8") + msg = f"{old_ts}token".encode() sig = hmac.new(b"key", msg, hashlib.sha256).hexdigest() assert SecurityGuard.verify_webhook("key", "token", old_ts, sig, max_age_seconds=0) is True @@ -194,12 +194,18 @@ def test_validate_attachment_path_forbidden_roots(self) -> None: mock_path_cls.return_value.resolve.return_value = mock_target with pytest.raises(ValueError, match="Access to sensitive OS system directories"): - SecurityGuard.validate_attachment_path("/etc/sensitive_system_config.conf", safe_base_dir=None) # pyright: ignore[reportArgumentType] + SecurityGuard.validate_attachment_path( + "/etc/sensitive_system_config.conf", + safe_base_dir=None, + ) # pyright: ignore[reportArgumentType] def test_verify_webhook_overflow_error(self) -> None: """Coverage: Massive timestamps triggering an OverflowError in the math blocks.""" massive_ts = 10**310 - with pytest.raises(ValueError, match="Invalid cryptographic payload or timestamp out of bounds"): + with pytest.raises( + ValueError, + match="Invalid cryptographic payload or timestamp out of bounds", + ): SecurityGuard.verify_webhook(b"key", "token", massive_ts, "sig") def test_analyze_html_image_with_alt_tag(self) -> None: @@ -211,6 +217,7 @@ def test_analyze_html_image_with_alt_tag(self) -> None: def test_redacting_filter_deep_redact_types(self) -> None: """Coverage: Execute the deep redaction tree for all edge-case Python types.""" from collections import namedtuple + log_filter = RedactingFilter() LogData = namedtuple("LogData", ["key"]) @@ -236,6 +243,7 @@ def __init__(self) -> None: assert log_filter._deep_redact(["key-123"]) == ["key-[REDACTED]"] assert log_filter._deep_redact({"key-123"}) == {"key-[REDACTED]"} + class TestSecurityGuardSSRFAndURL: """CWE-319, CWE-918, and URL validation logic.""" @@ -248,7 +256,9 @@ def test_cleartext_http_allowed_for_localhost(self) -> None: assert SecurityGuard.sanitize_api_url("http://127.0.0.1:9000") == "http://127.0.0.1:9000" def test_https_is_always_allowed(self) -> None: - assert SecurityGuard.sanitize_api_url("https://api.mailgun.net") == "https://api.mailgun.net" + assert ( + SecurityGuard.sanitize_api_url("https://api.mailgun.net") == "https://api.mailgun.net" + ) def test_validate_mailgun_url_allowed(self) -> None: valid_urls = [ @@ -292,7 +302,10 @@ def test_client_webhook_path_traversal_prevention(self) -> None: client = Client(auth=("api", "key")) with patch("requests.Session.request") as mock_request: with pytest.raises(ValueError, match="CWE-22"): - client.domains_webhooks.delete(domain="test.com", webhook_name="clicked/../../delete") + client.domains_webhooks.delete( + domain="test.com", + webhook_name="clicked/../../delete", + ) mock_request.assert_not_called() def test_sanitize_path_segment_excessive_encoding(self) -> None: @@ -333,10 +346,20 @@ def test_valid_timeout_passes_cleanly(self) -> None: assert SecurityGuard.sanitize_timeout((10.0, 60.0)) == (10.0, 60.0) assert SecurityGuard.sanitize_timeout(5.0) == 5.0 - @pytest.mark.parametrize("invalid_val", [ - float("inf"), float("nan"), 0, -1.5, (5.0,), (5.0, 10.0, 15.0), - (float("nan"), 5.0), (5.0, float("inf")), (-2.0, 5.0), - ]) + @pytest.mark.parametrize( + "invalid_val", + [ + float("inf"), + float("nan"), + 0, + -1.5, + (5.0,), + (5.0, 10.0, 15.0), + (float("nan"), 5.0), + (5.0, float("inf")), + (-2.0, 5.0), + ], + ) def test_sanitize_timeout_invalid_values(self, invalid_val: Any) -> None: with pytest.raises(ValueError, match="Timeout must be"): SecurityGuard.sanitize_timeout(invalid_val) @@ -387,27 +410,39 @@ def test_redacting_filter_scrubs_secrets(self) -> None: fake_zone = "pubkey-safe_zone" record_str = logging.LogRecord( - name="mailgun.test", level=logging.INFO, pathname="client.py", - lineno=10, msg=f"Sending message with api key: {fake_private}", - args=(), exc_info=None + name="mailgun.test", + level=logging.INFO, + pathname="client.py", + lineno=10, + msg=f"Sending message with api key: {fake_private}", + args=(), + exc_info=None, ) assert log_filter.filter(record_str) is True assert fake_private not in record_str.msg assert "key-[REDACTED]" in record_str.msg record_dict = logging.LogRecord( - name="mailgun.test", level=logging.INFO, pathname="client.py", - lineno=20, msg="Auth payload: %(secret)s", - args=({"secret": fake_public},), exc_info=None + name="mailgun.test", + level=logging.INFO, + pathname="client.py", + lineno=20, + msg="Auth payload: %(secret)s", + args=({"secret": fake_public},), + exc_info=None, ) assert log_filter.filter(record_dict) is True if isinstance(record_dict.args, dict): assert record_dict.args["secret"] == "pubkey-[REDACTED]" # pragma: allowlist secret record_tuple = logging.LogRecord( - name="mailgun.test", level=logging.WARNING, pathname="client.py", - lineno=30, msg="Failed to parse key: %s and %s", - args=(fake_live, fake_zone), exc_info=None + name="mailgun.test", + level=logging.WARNING, + pathname="client.py", + lineno=30, + msg="Failed to parse key: %s and %s", + args=(fake_live, fake_zone), + exc_info=None, ) assert log_filter.filter(record_tuple) is True if isinstance(record_tuple.args, tuple): @@ -432,11 +467,19 @@ def test_async_client_enforces_tls12_transport(self) -> None: assert ssl_ctx.minimum_version == ssl.TLSVersion.TLSv1_2 @patch("sys.audit") - def test_sync_client_emits_audit_hook(self, mock_audit: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None: + def test_sync_client_emits_audit_hook( + self, + mock_audit: MagicMock, + monkeypatch: pytest.MonkeyPatch, + ) -> None: client = Client(auth=("api", "key")) monkeypatch.setattr(client._session, "get", MagicMock(return_value=requests.Response())) client.domains.get() - mock_audit.assert_called_with("mailgun.api.request", "GET", "https://api.mailgun.net/v3/domains") + mock_audit.assert_called_with( + "mailgun.api.request", + "GET", + "https://api.mailgun.net/v3/domains", + ) @pytest.mark.asyncio @patch("sys.audit") @@ -452,13 +495,17 @@ async def test_async_client_emits_audit_hook(self, mock_audit: MagicMock) -> Non # Ensure handle_async_request is an AsyncMock that returns a valid response mock_transport_instance.handle_async_request = AsyncMock( - return_value=httpx2.Response(200) + return_value=httpx2.Response(200), ) await client.domains.get() # Verify audit hook was called - mock_audit.assert_called_with("mailgun.api.request", "GET", "https://api.mailgun.net/v3/domains") + mock_audit.assert_called_with( + "mailgun.api.request", + "GET", + "https://api.mailgun.net/v3/domains", + ) await client.aclose() @@ -466,16 +513,27 @@ class TestExceptionSafety: """Tests for secure logging in error blocks.""" @patch("mailgun.endpoints.logger.exception") - def test_sync_timeout_exception_logs_safely(self, mock_logger_exc: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None: + def test_sync_timeout_exception_logs_safely( + self, + mock_logger_exc: MagicMock, + monkeypatch: pytest.MonkeyPatch, + ) -> None: client = Client(auth=("api", "key")) - monkeypatch.setattr(client._session, "get", MagicMock(side_effect=requests.exceptions.Timeout("Read timed out"))) + monkeypatch.setattr( + client._session, + "get", + MagicMock(side_effect=requests.exceptions.Timeout("Read timed out")), + ) with pytest.raises(TimeoutError): client.domains.get() assert "https://api.mailgun.net/v3/domains" in mock_logger_exc.call_args[0][2] @pytest.mark.asyncio @patch("mailgun.endpoints.logger.critical") - async def test_async_connection_exception_logs_safely(self, mock_logger_crit: MagicMock) -> None: + async def test_async_connection_exception_logs_safely( + self, + mock_logger_crit: MagicMock, + ) -> None: """Verify that when an async network failure occurs, the logger uses safe_url_for_log.""" client = AsyncClient(auth=("api", "key")) @@ -485,7 +543,7 @@ async def test_async_connection_exception_logs_safely(self, mock_logger_crit: Ma # Set the side_effect on the async handler mock_transport_instance.handle_async_request = AsyncMock( - side_effect=httpx2.ConnectError("DNS failure") + side_effect=httpx2.ConnectError("DNS failure"), ) with pytest.raises(ApiError, match="Network routing failed"): diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index b3a32f9a..4c7fd5d0 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -21,7 +21,8 @@ def reset_audit_hook_state() -> Generator[None, Any, None]: class TestConfigAuditHook: def test_audit_hook_actual_execution( - self, caplog: pytest.LogCaptureFixture + self, + caplog: pytest.LogCaptureFixture, ) -> None: Config.enable_security_audit() @@ -47,10 +48,7 @@ def test_audit_hook_direct_coverage(self, caplog: pytest.LogCaptureFixture) -> N ) hook_fn("other.event", ("GET", "https://other.com")) - assert ( - "Outbound API call tracked - POST https://api.mailgun.net/v3/messages" - in caplog.text - ) + assert "Outbound API call tracked - POST https://api.mailgun.net/v3/messages" in caplog.text @patch("mailgun.config.logger.info") def test_enable_security_audit_hook_execution(self, mock_logger: MagicMock) -> None: @@ -78,7 +76,8 @@ class TestConfigEdgeCases: def test_config_headers_mapping_proxy_prevents_mutation(self) -> None: config = Config() with pytest.raises( - TypeError, match="'mappingproxy' object does not support item assignment" + TypeError, + match="'mappingproxy' object does not support item assignment", ): config._HEADERS_BASE["X-Malicious-Header"] = "value" # type: ignore[index] @@ -263,8 +262,8 @@ def test_resolve_domains_route_v4_fallback(self) -> None: def test_get_cached_route_data_raises_keyerror_on_invalid_route(self) -> None: """Coverage: Ensure typo'd endpoints raise a descriptive KeyError instead of returning None.""" - from mailgun.config import _get_cached_route_data from mailgun import routes + from mailgun.config import _get_cached_route_data bad_key = "messages_typo" @@ -275,7 +274,7 @@ def test_get_cached_route_data_raises_keyerror_on_invalid_route(self) -> None: assert f"Invalid API endpoint requested: {bad_key}" in error_msg assert "Available endpoints:" in error_msg - assert list(routes.EXACT_ROUTES.keys())[0] in error_msg + assert next(iter(routes.EXACT_ROUTES.keys())) in error_msg class TestConfigSanitization: @@ -328,7 +327,8 @@ def test_config_rejects_embedded_api_versions(self) -> None: Config(api_url=url) def test_config_warns_and_strips_trailing_versions( - self, caplog: pytest.LogCaptureFixture + self, + caplog: pytest.LogCaptureFixture, ) -> None: with caplog.at_level(logging.WARNING): config = Config(api_url="https://api.mailgun.net/v3/") @@ -349,7 +349,8 @@ def test_normalize_api_url_raises_on_embedded_version(self) -> None: @patch("mailgun.config.logger.warning") def test_normalize_api_url_strips_trailing_version( - self, mock_warn: MagicMock + self, + mock_warn: MagicMock, ) -> None: trailing_url = "https://api.mailgun.net/v3/" result = Config._normalize_api_url(trailing_url) @@ -367,7 +368,8 @@ def test_validate_api_url_no_warning_on_https(self, mock_warn: MagicMock) -> Non @patch("mailgun.config.logger.warning") def test_validate_api_url_no_warning_on_localhost( - self, mock_warn: MagicMock + self, + mock_warn: MagicMock, ) -> None: Config(api_url="http://localhost:8000") mock_warn.assert_not_called() @@ -380,7 +382,8 @@ def test_validate_api_url_warns_on_http(self) -> None: @patch("mailgun.security.logger.warning") def test_validate_api_url_warns_on_unrecognized_host( - self, mock_warn: MagicMock + self, + mock_warn: MagicMock, ) -> None: Config(api_url="https://custom.corporate.proxy/") mock_warn.assert_called_once() @@ -395,7 +398,12 @@ class TestRetryPolicy: def test_retry_policy_initialization_and_slots(self) -> None: """Verify immutable properties and memory-efficient __slots__ usage.""" - policy = mailgun.config.RetryPolicy(max_retries=5, base_delay=2.0, max_delay=20.0, respect_retry_after=False) + policy = mailgun.config.RetryPolicy( + max_retries=5, + base_delay=2.0, + max_delay=20.0, + respect_retry_after=False, + ) assert policy.max_retries == 5 assert policy.base_delay == 2.0 assert policy.max_delay == 20.0 @@ -403,7 +411,7 @@ def test_retry_policy_initialization_and_slots(self) -> None: # Prove __slots__ prevents dynamic dict allocation with pytest.raises(AttributeError): - policy.new_attr = "leak" # type: ignore[attr-defined] + policy.new_attr = "leak" # type: ignore[attr-defined] @patch("random.uniform") def test_calculate_delay_applies_full_jitter(self, mock_uniform: MagicMock) -> None: diff --git a/tests/unit/test_deprecation_warnings.py b/tests/unit/test_deprecation_warnings.py index 262f6dfa..2289783b 100644 --- a/tests/unit/test_deprecation_warnings.py +++ b/tests/unit/test_deprecation_warnings.py @@ -13,7 +13,10 @@ def test_legacy_tag_api_triggers_warning() -> None: with pytest.warns(DeprecationWarning) as record: # Directly test the interceptor with a known legacy URL boundary - client.messages._warn_if_deprecated("GET", "https://api.mailgun.net/v3/sandbox.mailgun.org/tag") + client.messages._warn_if_deprecated( + "GET", + "https://api.mailgun.net/v3/sandbox.mailgun.org/tag", + ) assert len(record) >= 1 warning_msg = str(record[0].message) @@ -27,7 +30,10 @@ def test_legacy_bounce_classification_triggers_warning() -> None: client = Client(auth=("api", "key")) with pytest.warns(DeprecationWarning) as record: - client.messages._warn_if_deprecated("GET", "https://api.mailgun.net/v1/bounce-classification/stats") + client.messages._warn_if_deprecated( + "GET", + "https://api.mailgun.net/v1/bounce-classification/stats", + ) assert len(record) >= 1 assert "v1 bounce-classification API is deprecated" in str(record[0].message) @@ -49,7 +55,10 @@ def test_legacy_bulk_validations_trigger_warning() -> None: client = Client(auth=("api", "key")) with pytest.warns(DeprecationWarning) as record: - client.messages._warn_if_deprecated("POST", "https://api.mailgun.net/v3/lists/my-list/validate") + client.messages._warn_if_deprecated( + "POST", + "https://api.mailgun.net/v3/lists/my-list/validate", + ) assert len(record) >= 1 assert "v3 Bulk Validation API is deprecated" in str(record[0].message) @@ -63,10 +72,16 @@ def test_valid_endpoints_do_not_trigger_warnings() -> None: warnings.simplefilter("error", DeprecationWarning) # This is the NEW API (/v3/domain/tags). It should pass without warnings. - client.messages._warn_if_deprecated("GET", "https://api.mailgun.net/v3/sandbox.mailgun.org/tags") + client.messages._warn_if_deprecated( + "GET", + "https://api.mailgun.net/v3/sandbox.mailgun.org/tags", + ) # Standard messages API - client.messages._warn_if_deprecated("POST", "https://api.mailgun.net/v3/sandbox.mailgun.org/messages") + client.messages._warn_if_deprecated( + "POST", + "https://api.mailgun.net/v3/sandbox.mailgun.org/messages", + ) # New v4 validations API client.messages._warn_if_deprecated("GET", "https://api.mailgun.net/v4/address/validate") diff --git a/tests/unit/test_endpoint.py b/tests/unit/test_endpoint.py index 821b0d90..f40bbc0b 100644 --- a/tests/unit/test_endpoint.py +++ b/tests/unit/test_endpoint.py @@ -87,16 +87,15 @@ def test_api_call_dry_run_standard_route(self) -> None: assert "Dry run successful" in resp.json()["message"] def test_api_call_dry_run_logs_interception( - self, caplog: pytest.LogCaptureFixture + self, + caplog: pytest.LogCaptureFixture, ) -> None: url = {"base": f"{BASE_URL_V3}/", "keys": ["messages"]} ep = Endpoint(url=url, headers={}, auth=("api", "key"), dry_run=True) with caplog.at_level(logging.INFO): ep.create(domain="test.com", data={"to": "test@example.com"}) - assert any( - "DRY RUN: Intercepting" in record.message for record in caplog.records - ) + assert any("DRY RUN: Intercepting" in record.message for record in caplog.records) def test_async_api_call_dry_run_intercepts_request(self) -> None: """Ensure Async dry_run mode intercepts email messages and returns a mock response.""" @@ -104,12 +103,17 @@ def test_async_api_call_dry_run_intercepts_request(self) -> None: mock_client = AsyncMock(spec=httpx2.AsyncClient) ep = AsyncEndpoint( - url=url, headers={}, auth=("api", "key"), dry_run=True, client=mock_client + url=url, + headers={}, + auth=("api", "key"), + dry_run=True, + client=mock_client, ) async def run_test() -> None: resp = await ep.create( - domain="test.com", data={"to": "test@example.com"} + domain="test.com", + data={"to": "test@example.com"}, ) mock_client.request.assert_not_called() assert resp.status_code == 200 @@ -124,7 +128,11 @@ def test_async_api_call_dry_run_standard_route(self) -> None: mock_client = AsyncMock(spec=httpx2.AsyncClient) ep = AsyncEndpoint( - url=url, headers={}, auth=("api", "key"), dry_run=True, client=mock_client + url=url, + headers={}, + auth=("api", "key"), + dry_run=True, + client=mock_client, ) async def run_test() -> None: @@ -141,7 +149,7 @@ class TestEndpointEdgeCases: def test_build_path_from_keys_empty_and_iterables(self) -> None: assert build_path_from_keys([]) == "" assert build_path_from_keys(set()) == "" - assert build_path_from_keys(tuple()) == "" + assert build_path_from_keys(()) == "" assert build_path_from_keys(["a", "b"]) == "/a/b" assert build_path_from_keys(iter(["a", "b"])) == "/a/b" @@ -186,24 +194,33 @@ def test_api_call_header_injection_is_blocked(self) -> None: def test_api_call_raises_api_error_on_request_exception(self) -> None: url = {"base": f"{BASE_URL_V4}/", "keys": ["domainlist"]} ep = Endpoint(url=url, headers={}, auth=None) - with patch.object( - requests.Session, - "request", - side_effect=requests.exceptions.RequestException("Boom"), - ), pytest.raises(ApiError, match="Boom"): + with ( + patch.object( + requests.Session, + "request", + side_effect=requests.exceptions.RequestException("Boom"), + ), + pytest.raises(ApiError, match="Boom"), + ): ep.get() def test_api_call_raises_timeout_error_on_timeout(self) -> None: url = {"base": "https://api.mailgun.net/v4/", "keys": ["domainlist"]} ep = Endpoint(url=url, headers={}, auth=None) - with patch.object( - requests.Session, "request", side_effect=requests.exceptions.Timeout() - ), pytest.raises(TimeoutError): + with ( + patch.object( + requests.Session, + "request", + side_effect=requests.exceptions.Timeout(), + ), + pytest.raises(TimeoutError), + ): ep.get() @patch("mailgun.endpoints.logger.error") def test_api_call_truncates_long_error_response( - self, mock_logger_error: MagicMock + self, + mock_logger_error: MagicMock, ) -> None: """Test error responses are NOT logged to prevent secret leakage (CWE-316).""" url = {"base": "https://api.mailgun.net/v4/", "keys": ["domainlist"]} @@ -231,11 +248,16 @@ def test_async_endpoint_put_delete_methods(self) -> None: ep = AsyncEndpoint(url=url, headers={}, auth=("api", "key"), client=mock_client) with patch( - "mailgun.endpoints.AsyncEndpoint.api_call", new_callable=AsyncMock + "mailgun.endpoints.AsyncEndpoint.api_call", + new_callable=AsyncMock, ) as mock_call: asyncio.run(ep.delete(domain="test.com")) mock_call.assert_called_with( - ("api", "key"), "delete", url, headers={}, domain="test.com" + ("api", "key"), + "delete", + url, + headers={}, + domain="test.com", ) asyncio.run(ep.put(domain="test.com", data={"action": "update"})) @@ -327,7 +349,9 @@ def test_build_path_from_keys_with_none(self) -> None: @patch("requests.Session.request") @patch.object(Endpoint, "get") def test_endpoint_missing_verbs_and_stream_filters( - self, mock_get: MagicMock, mock_request: MagicMock + self, + mock_get: MagicMock, + mock_request: MagicMock, ) -> None: """Cover missing HTTP verbs and populated stream filters.""" ep = Endpoint( @@ -341,7 +365,8 @@ def test_endpoint_missing_verbs_and_stream_filters( ep.delete(domain="test.com") mock_get.return_value = MagicMock( - json=lambda: {"items": []}, raise_for_status=lambda: None + json=lambda: {"items": []}, + raise_for_status=lambda: None, ) results = list(ep.stream(filters={"limit": 10})) @@ -379,7 +404,9 @@ def test_endpoint_payload_is_strictly_minified(self) -> None: payload_with_spaces = {"name": "test.com", "spam_action": "disabled"} with patch.object( - requests.Session, "request", return_value=MagicMock(status_code=200) + requests.Session, + "request", + return_value=MagicMock(status_code=200), ) as mock_req: ep.create(data=payload_with_spaces) @@ -396,7 +423,9 @@ def test_endpoint_request_ignores_invalid_custom_headers_type(self) -> None: """ url = {"base": "https://api.mailgun.net/v3/", "keys": ["messages"]} ep = Endpoint( - url=url, headers={"User-Agent": "mailgun-sdk"}, auth=("api", "key") + url=url, + headers={"User-Agent": "mailgun-sdk"}, + auth=("api", "key"), ) with patch("requests.Session.request") as mock_req: @@ -461,11 +490,11 @@ def test_update_serializes_json_with_custom_headers(self) -> None: with patch.object(requests.Session, "request") as mock_req: mock_req.return_value = MagicMock(status_code=200) ep.update( - data={"key": "value"}, headers={"Content-Type": "application/json"} - ) - assert ( - mock_req.call_args[1]["headers"]["Content-Type"] == "application/json" + data={"key": "value"}, + headers={"Content-Type": "application/json"}, ) + assert mock_req.call_args[1]["headers"]["Content-Type"] == "application/json" + class TestEndpointRetryAndStreamPointers: def test_reset_stream_pointers(self, tmp_path: Path) -> None: @@ -528,14 +557,17 @@ def test_sync_endpoint_retries_respect_retry_after_header(self) -> None: resp_429.headers = {"Retry-After": "1"} resp_200 = MagicMock(status_code=200) - with patch.object(requests.Session, "request", side_effect=[resp_429, resp_200]): - with patch("time.sleep") as mock_sleep: - res = ep.create(domain="test.com", data={"to": "user@test.com"}) - assert res.status_code == 200 - mock_sleep.assert_called_with(1.0) + with ( + patch.object(requests.Session, "request", side_effect=[resp_429, resp_200]), + patch("time.sleep") as mock_sleep, + ): + res = ep.create(domain="test.com", data={"to": "user@test.com"}) + assert res.status_code == 200 + mock_sleep.assert_called_with(1.0) def test_stream_pagination_type_casting_all_types(self) -> None: """Covers endpoints.py lines 795-806: cast int, tuple, set, and list.""" + class MockResp: def raise_for_status(self) -> None: pass @@ -543,10 +575,16 @@ def raise_for_status(self) -> None: def json(self) -> dict: return { "items": [{"id": 1}], - "paging": {"next": "https://api.mailgun.net/v3/events?page=2&tags=a&tags=b&limit=10"}, + "paging": { + "next": "https://api.mailgun.net/v3/events?page=2&tags=a&tags=b&limit=10", + }, } - ep = Endpoint(url={"base": "https://test", "keys": ["events"]}, headers={}, auth=("api", "key")) + ep = Endpoint( + url={"base": "https://test", "keys": ["events"]}, + headers={}, + auth=("api", "key"), + ) responses = [ MockResp(), diff --git a/tests/unit/test_error_handler.py b/tests/unit/test_error_handler.py index 11edbc86..269b5cde 100644 --- a/tests/unit/test_error_handler.py +++ b/tests/unit/test_error_handler.py @@ -1,6 +1,5 @@ """Unit tests for custom API exception classes in error_handler.py.""" - from mailgun.handlers.error_handler import ( ApiError, DeliverabilityError, diff --git a/tests/unit/test_ext_pydantic.py b/tests/unit/test_ext_pydantic.py index 43c0b642..9afa03ba 100644 --- a/tests/unit/test_ext_pydantic.py +++ b/tests/unit/test_ext_pydantic.py @@ -13,7 +13,7 @@ def test_schema_aliases_from_keyword_correctly(self) -> None: to="user@example.com", from_="admin@example.com", subject="Secure Test", - text="Hello World" + text="Hello World", ) # Pydantic v2 dump diff --git a/tests/unit/test_handlers.py b/tests/unit/test_handlers.py index d6432db1..230cf722 100644 --- a/tests/unit/test_handlers.py +++ b/tests/unit/test_handlers.py @@ -108,7 +108,10 @@ def test_handle_default_implicit_domain_prepended(self) -> None: def test_handle_default_loop_and_empty_keys(self) -> None: url = {"base": "https://api.mailgun.net/v3/", "keys": []} - assert handle_default(url, domain="test.com", _method="GET") == "https://api.mailgun.net/v3/test.com" + assert ( + handle_default(url, domain="test.com", _method="GET") + == "https://api.mailgun.net/v3/test.com" + ) class TestDomainsHandler: @@ -151,35 +154,50 @@ def test_domain_webhooks_v3_post_single(self) -> None: def test_domain_webhooks_v4_delete_bulk(self) -> None: url = {"base": f"{BASE_URL_V3}/domains/", "keys": ["webhooks"]} result = handle_webhooks( - url, TEST_DOMAIN, "delete", filters={"url": "https://hook.com"} + url, + TEST_DOMAIN, + "delete", + filters={"url": "https://hook.com"}, ) assert result == f"{BASE_URL_V4}/domains/{TEST_DOMAIN}/webhooks" def test_domain_webhooks_v4_dynamic_upgrade_on_delete(self) -> None: url = {"base": "https://api.mailgun.net/v3/domains/", "keys": ["webhooks"]} result = handle_webhooks( - url, "example.com", "DELETE", filters={"url": "https://webhook.site/123"} + url, + "example.com", + "DELETE", + filters={"url": "https://webhook.site/123"}, ) assert result == "https://api.mailgun.net/v4/domains/example.com/webhooks" def test_domain_webhooks_v4_dynamic_upgrade_on_put(self) -> None: url = {"base": "https://api.mailgun.net/v3/domains/", "keys": ["webhooks"]} result = handle_webhooks( - url, "example.com", "PUT", data={"event_types": ["delivered", "opened"]} + url, + "example.com", + "PUT", + data={"event_types": ["delivered", "opened"]}, ) assert result == "https://api.mailgun.net/v4/domains/example.com/webhooks" def test_domain_webhooks_v4_post_multi(self) -> None: url = {"base": f"{BASE_URL_V3}/domains/", "keys": ["webhooks"]} result = handle_webhooks( - url, TEST_DOMAIN, "post", data={"event_types": "clicked,opened"} + url, + TEST_DOMAIN, + "post", + data={"event_types": "clicked,opened"}, ) assert result == f"{BASE_URL_V4}/domains/{TEST_DOMAIN}/webhooks" def test_domain_webhooks_v4_put_multi(self) -> None: url = {"base": "https://api.mailgun.net/v4/", "keys": ["webhooks"]} result = handle_webhooks( - url, "example.com", "PUT", data={"event_types": "clicked,opened"} + url, + "example.com", + "PUT", + data={"event_types": "clicked,opened"}, ) assert result == "https://api.mailgun.net/v4/example.com/webhooks" @@ -191,7 +209,10 @@ def test_handle_domainlist_success(self) -> None: def test_handle_domains_credentials_edge_cases(self) -> None: url = {"base": "https://api.mailgun.net/v3/", "keys": ["credentials"]} res = handle_mailboxes_credentials( - url, domain="test.com", _method="GET", login="user@test.com" + url, + domain="test.com", + _method="GET", + login="user@test.com", ) assert res == "https://api.mailgun.net/v3/test.com/credentials/user%40test.com" @@ -280,7 +301,12 @@ def test_with_test_id_and_checks_true_no_address(self) -> None: def test_with_test_id_and_checks_true_with_address(self) -> None: url = {"base": f"{BASE_URL_V3}/", "keys": ["inbox", "tests"]} result = handle_inbox( - url, None, None, test_id=TEST_123, checks=True, address=TEST_EMAIL + url, + None, + None, + test_id=TEST_123, + checks=True, + address=TEST_EMAIL, ) assert result == "https://api.mailgun.net/v3/inbox/tests/test-123/checks/user%40example.com" @@ -351,7 +377,11 @@ def test_lists_default(self) -> None: def test_lists_member_address(self) -> None: url = {"base": f"{BASE_URL_V3}/", "keys": ["lists", "members"]} result = handle_lists( - url, None, None, address="dev@test", member_address="usr@test" + url, + None, + None, + address="dev@test", + member_address="usr@test", ) assert result == f"{BASE_URL_V3}/lists/dev%40test/members/usr%40test" @@ -496,7 +526,11 @@ def test_template_tag_and_copy(self) -> None: def test_template_versions(self) -> None: url = {"base": f"{BASE_URL_V4}/", "keys": ["templates"]} result = handle_templates( - url, TEST_DOMAIN, None, template_name="promo", versions=True + url, + TEST_DOMAIN, + None, + template_name="promo", + versions=True, ) assert result == f"{BASE_URL_V3}/{TEST_DOMAIN}/templates/promo/versions" diff --git a/tests/unit/test_integration_mirror.py b/tests/unit/test_integration_mirror.py index 66da9bf0..3bf16655 100644 --- a/tests/unit/test_integration_mirror.py +++ b/tests/unit/test_integration_mirror.py @@ -69,9 +69,9 @@ def setUp(self) -> None: self.put_domain_data = {"spam_action": "disabled"} self.post_domain_creds = { "login": f"alice_bob@{self.domain}", - "password": "test_new_creds123", # pragma: allowlist secret + "password": "test_new_creds123", # pragma: allowlist secret } - self.put_domain_creds = {"password": "test_new_creds"} # pragma: allowlist secret + self.put_domain_creds = {"password": "test_new_creds"} # pragma: allowlist secret self.put_domain_connections_data = {"require_tls": "false", "skip_verification": "false"} self.put_domain_tracking_data = {"active": "yes", "skip_verification": "false"} self.put_domain_unsubscribe_data = { @@ -88,7 +88,8 @@ def setUp(self) -> None: def test_post_domain(self, m_post: MagicMock, m_delete: MagicMock) -> None: m_delete.return_value = mock_response(200, {"message": "ok"}) m_post.return_value = mock_response( - 200, {"message": "Domain DNS records have been created"} + 200, + {"message": "Domain DNS records have been created"}, ) request = self.client.domains.create(data=self.post_domain_data) self.assertEqual(request.status_code, 200) @@ -98,7 +99,8 @@ def test_post_domain(self, m_post: MagicMock, m_delete: MagicMock) -> None: def test_post_domain_creds(self, m_post: MagicMock) -> None: m_post.return_value = mock_response(200, {"message": "Created"}) request = self.client.domains_credentials.create( - domain=self.domain, data=self.post_domain_creds + domain=self.domain, + data=self.post_domain_creds, ) self.assertEqual(request.status_code, 200) self.assertIn("message", request.json()) @@ -107,14 +109,18 @@ def test_post_domain_creds(self, m_post: MagicMock) -> None: @patch("requests.Session.post") @patch("requests.Session.put") def test_update_simple_domain( - self, m_put: MagicMock, m_post: MagicMock, m_delete: MagicMock + self, + m_put: MagicMock, + m_post: MagicMock, + m_delete: MagicMock, ) -> None: m_delete.return_value = mock_response() m_post.return_value = mock_response(200, {"domain": {}}) m_put.return_value = mock_response(200, {"message": "Domain has been updated"}) self.client.domains.create(data=self.post_domain_data) request = self.client.domains.put( - data={"spam_action": "disabled"}, domain=self.post_domain_data["name"] + data={"spam_action": "disabled"}, + domain=self.post_domain_data["name"], ) self.assertEqual(request.status_code, 200) self.assertEqual(request.json()["message"], "Domain has been updated") @@ -125,10 +131,13 @@ def test_put_domain_creds(self, m_put: MagicMock, m_post: MagicMock) -> None: m_post.return_value = mock_response(200, {"message": "Created"}) m_put.return_value = mock_response(200, {"message": "Updated"}) self.client.domains_credentials.create( - domain=self.domain, data=self.post_domain_creds + domain=self.domain, + data=self.post_domain_creds, ) request = self.client.domains_credentials.put( - domain=self.domain, data=self.put_domain_creds, login="alice_bob" + domain=self.domain, + data=self.put_domain_creds, + login="alice_bob", ) self.assertEqual(request.status_code, 200) self.assertIn("message", request.json()) @@ -146,10 +155,12 @@ def test_put_mailboxes_credentials(self, m_put: MagicMock, m_post: MagicMock) -> }, ) self.client.domains_credentials.create( - domain=self.domain, data=self.post_domain_creds + domain=self.domain, + data=self.post_domain_creds, ) req = self.client.mailboxes.put( - domain=self.domain, login=f"alice_bob@{self.domain}" + domain=self.domain, + login=f"alice_bob@{self.domain}", ) self.assertEqual(req.status_code, 200) self.assertIn("Password changed", req.json()["message"]) @@ -199,7 +210,8 @@ def test_verify_domain(self, m_post: MagicMock, m_put: MagicMock) -> None: def test_put_domain_connections(self, m_put: MagicMock) -> None: m_put.return_value = mock_response(200, {"message": "Updated"}) request = self.client.domains_connection.put( - domain=self.domain, data=self.put_domain_connections_data + domain=self.domain, + data=self.put_domain_connections_data, ) self.assertEqual(request.status_code, 200) self.assertIn("message", request.json()) @@ -208,7 +220,8 @@ def test_put_domain_connections(self, m_put: MagicMock) -> None: def test_put_domain_tracking_open(self, m_put: MagicMock) -> None: m_put.return_value = mock_response(200, {"message": "Updated"}) request = self.client.domains_tracking_open.put( - domain=self.domain, data=self.put_domain_tracking_data + domain=self.domain, + data=self.put_domain_tracking_data, ) self.assertEqual(request.status_code, 200) self.assertIn("message", request.json()) @@ -217,7 +230,8 @@ def test_put_domain_tracking_open(self, m_put: MagicMock) -> None: def test_put_domain_tracking_click(self, m_put: MagicMock) -> None: m_put.return_value = mock_response(200, {"message": "Updated"}) request = self.client.domains_tracking_click.put( - domain=self.domain, data=self.put_domain_tracking_data + domain=self.domain, + data=self.put_domain_tracking_data, ) self.assertEqual(request.status_code, 200) self.assertIn("message", request.json()) @@ -226,7 +240,8 @@ def test_put_domain_tracking_click(self, m_put: MagicMock) -> None: def test_put_domain_unsubscribe(self, m_put: MagicMock) -> None: m_put.return_value = mock_response(200, {"message": "Updated"}) request = self.client.domains_tracking_unsubscribe.put( - domain=self.domain, data=self.put_domain_unsubscribe_data + domain=self.domain, + data=self.put_domain_unsubscribe_data, ) self.assertEqual(request.status_code, 200) self.assertIn("message", request.json()) @@ -238,7 +253,8 @@ def test_put_dkim_authority(self, m_post: MagicMock, m_put: MagicMock) -> None: m_put.return_value = mock_response(200, {"message": "Updated"}) self.client.domains.create(data=self.post_domain_data) request = self.client.domains_dkimauthority.put( - domain=self.test_domain, data=self.put_domain_dkim_authority_data + domain=self.test_domain, + data=self.put_domain_dkim_authority_data, ) self.assertIn("message", request.json()) @@ -249,7 +265,8 @@ def test_put_webprefix(self, m_post: MagicMock, m_put: MagicMock) -> None: m_put.return_value = mock_response(200, {"message": "Updated"}) self.client.domains.create(data=self.post_domain_data) request = self.client.domains_webprefix.put( - domain=self.test_domain, data=self.put_domain_webprefix_data + domain=self.test_domain, + data=self.put_domain_webprefix_data, ) self.assertIn("message", request.json()) @@ -257,7 +274,8 @@ def test_put_webprefix(self, m_post: MagicMock, m_put: MagicMock) -> None: def test_put_dkim_selector(self, m_put: MagicMock) -> None: m_put.return_value = mock_response(200, {"message": "Updated"}) request = self.client.domains_dkimselector.put( - domain=self.domain, data=self.put_dkim_selector_data + domain=self.domain, + data=self.put_dkim_selector_data, ) self.assertIn("message", request.json()) @@ -271,7 +289,7 @@ def test_get_dkim_keys(self, m_get: MagicMock) -> None: "signing_domain": "python.test.domain5", "selector": "smtp", "dns_record": {}, - } + }, ], "paging": {}, }, @@ -301,29 +319,36 @@ def test_delete_domain_creds(self, m_post: MagicMock, m_delete: MagicMock) -> No m_post.return_value = mock_response() m_delete.return_value = mock_response() self.client.domains_credentials.create( - domain=self.domain, data=self.post_domain_creds + domain=self.domain, + data=self.post_domain_creds, ) request = self.client.domains_credentials.delete( - domain=self.domain, login="alice_bob" + domain=self.domain, + login="alice_bob", ) self.assertEqual(request.status_code, 200) @patch("requests.Session.delete") @patch("requests.Session.post") def test_delete_all_domain_credentials( - self, m_post: MagicMock, m_delete: MagicMock + self, + m_post: MagicMock, + m_delete: MagicMock, ) -> None: m_post.return_value = mock_response() m_delete.return_value = mock_response( - 200, {"message": "All domain credentials have been deleted"} + 200, + {"message": "All domain credentials have been deleted"}, ) self.client.domains_credentials.create( - domain=self.domain, data=self.post_domain_creds + domain=self.domain, + data=self.post_domain_creds, ) request = self.client.domains_credentials.delete(domain=self.domain) self.assertEqual(request.status_code, 200) self.assertIn( - request.json()["message"], "All domain credentials have been deleted" + request.json()["message"], + "All domain credentials have been deleted", ) @patch("requests.Session.delete") @@ -331,12 +356,14 @@ def test_delete_all_domain_credentials( def test_delete_domain(self, m_post: MagicMock, m_delete: MagicMock) -> None: m_post.return_value = mock_response() m_delete.return_value = mock_response( - 200, {"message": "Domain will be deleted in the background"} + 200, + {"message": "Domain will be deleted in the background"}, ) self.client.domains.create(data=self.post_domain_data) request = self.client.domains.delete(domain=self.test_domain) self.assertEqual( - request.json()["message"], "Domain will be deleted in the background" + request.json()["message"], + "Domain will be deleted in the background", ) self.assertEqual(request.status_code, 200) @@ -377,7 +404,8 @@ def test_create_ip(self, m_post: MagicMock) -> None: def test_delete_ip(self, m_delete: MagicMock) -> None: m_delete.return_value = mock_response(200, {"message": "success"}) request = self.client.domains_ips.delete( - domain=self.domain, ip=self.ip_data["ip"] + domain=self.domain, + ip=self.ip_data["ip"], ) self.assertEqual("success", request.json()["message"]) self.assertEqual(request.status_code, 200) @@ -409,7 +437,9 @@ def test_patch_ippool(self, m_post: MagicMock, m_patch: MagicMock) -> None: m_patch.return_value = mock_response(200, {"message": "success"}) self.client.ippools.create(domain=self.domain, data=self.data) req = self.client.ippools.patch( - domain=self.domain, data=self.patch_data, pool_id="pid123" + domain=self.domain, + data=self.patch_data, + pool_id="pid123", ) self.assertEqual("success", req.json()["message"]) self.assertEqual(req.status_code, 200) @@ -418,7 +448,8 @@ def test_patch_ippool(self, m_post: MagicMock, m_patch: MagicMock) -> None: def test_link_domain_ippool(self, m_post: MagicMock) -> None: m_post.return_value = mock_response(200, {"message": "Linked"}) req = self.client.domains_ips.create( - domain=self.domain, data={"pool_id": "pid123"} + domain=self.domain, + data={"pool_id": "pid123"}, ) self.assertIn("message", req.json()) @@ -429,7 +460,8 @@ def test_delete_ippool(self, m_post: MagicMock, m_delete: MagicMock) -> None: m_delete.return_value = mock_response(200, {"message": "started"}) self.client.ippools.create(domain=self.domain, data=self.data) req_del = self.client.ippools.delete( - domain=self.domain, pool_id="pid123" + domain=self.domain, + pool_id="pid123", ) self.assertEqual("started", req_del.json()["message"]) @@ -519,7 +551,8 @@ def test_tags_stats_aggregate_get(self, m_get: MagicMock) -> None: def test_delete_tags(self, m_delete: MagicMock) -> None: m_delete.return_value = mock_response(200, {"message": "Deleted"}) req = self.client.tags.delete( - domain=self.domain, tag_name=self.tag_name + domain=self.domain, + tag_name=self.tag_name, ) self.assertEqual(req.status_code, 200) self.assertIn("message", req.json()) @@ -567,7 +600,8 @@ def test_bounces_get_address(self, m_post: MagicMock, m_get: MagicMock) -> None: m_get.return_value = mock_response(200, {"address": self.bounces_data["address"]}) self.client.bounces.create(data=self.bounces_data, domain=self.domain) req = self.client.bounces.get( - domain=self.domain, bounce_address=self.bounces_data["address"] + domain=self.domain, + bounce_address=self.bounces_data["address"], ) self.assertEqual(req.status_code, 200) self.assertIn("address", req.json()) @@ -592,7 +626,8 @@ def test_bounces_delete_single(self, m_post: MagicMock, m_delete: MagicMock) -> m_delete.return_value = mock_response(200, {"message": "Deleted"}) self.client.bounces.create(data=self.bounces_data, domain=self.domain) req = self.client.bounces.delete( - domain=self.domain, bounce_address=self.bounces_data["address"] + domain=self.domain, + bounce_address=self.bounces_data["address"], ) self.assertEqual(req.status_code, 200) self.assertIn("message", req.json()) @@ -633,7 +668,8 @@ def test_unsub_get(self, m_get: MagicMock) -> None: def test_unsub_get_single(self, m_get: MagicMock) -> None: m_get.return_value = mock_response(200, {"address": self.unsub_data["address"]}) req = self.client.unsubscribes.get( - domain=self.domain, unsubscribe_address=self.unsub_data["address"] + domain=self.domain, + unsubscribe_address=self.unsub_data["address"], ) self.assertEqual(req.status_code, 200) self.assertIn("address", req.json()) @@ -655,7 +691,8 @@ def test_unsub_create_multiple(self, m_post: MagicMock) -> None: def test_unsub_delete(self, m_delete: MagicMock) -> None: m_delete.return_value = mock_response(200, {"message": "Deleted"}) req = self.client.unsubscribes.delete( - domain=self.domain, unsubscribe_address=self.unsub_data["address"] + domain=self.domain, + unsubscribe_address=self.unsub_data["address"], ) self.assertEqual(req.status_code, 200) self.assertIn("message", req.json()) @@ -706,7 +743,8 @@ def test_compl_get_single(self, m_post: MagicMock, m_get: MagicMock) -> None: m_get.return_value = mock_response(200, {"address": self.compl_data["address"]}) self.client.complaints.create(data=self.compl_data, domain=self.domain) req = self.client.complaints.get( - domain=self.domain, complaint_address=self.compl_data["address"] + domain=self.domain, + complaint_address=self.compl_data["address"], ) self.assertEqual(req.status_code, 200) self.assertIn("address", req.json()) @@ -730,7 +768,8 @@ def test_compl_delete_single(self, m_post: MagicMock, m_delete: MagicMock) -> No m_post.return_value = mock_response() m_delete.return_value = mock_response(200, {"message": "Deleted"}) req = self.client.complaints.delete( - domain=self.domain, unsubscribe_address=self.compl_data["address"] + domain=self.domain, + unsubscribe_address=self.compl_data["address"], ) self.assertEqual(req.status_code, 200) self.assertIn("message", req.json()) @@ -769,7 +808,8 @@ def test_whitel_get_simple(self, m_get: MagicMock) -> None: def test_whitel_delete_simple(self, m_delete: MagicMock) -> None: m_delete.return_value = mock_response(200, {"message": "Deleted"}) req = self.client.whitelists.delete( - domain=self.domain, whitelist_address=self.whitel_data["address"] + domain=self.domain, + whitelist_address=self.whitel_data["address"], ) self.assertEqual(req.status_code, 200) self.assertIn("message", req.json()) @@ -795,7 +835,10 @@ def setUp(self) -> None: @patch("requests.Session.delete") @patch("requests.Session.get") def test_routes_create( - self, m_get: MagicMock, m_delete: MagicMock, m_post: MagicMock + self, + m_get: MagicMock, + m_delete: MagicMock, + m_post: MagicMock, ) -> None: m_get.return_value = mock_response(200, {"items": [{"id": "rid"}]}) m_delete.return_value = mock_response() @@ -804,7 +847,8 @@ def test_routes_create( req1 = self.client.routes.get(domain=self.domain, filters=params) if req1.json().get("items"): self.client.routes.delete( - domain=self.domain, route_id=req1.json()["items"][0]["id"] + domain=self.domain, + route_id=req1.json()["items"][0]["id"], ) req = self.client.routes.create(domain=self.domain, data=self.routes_data) self.assertEqual(req.status_code, 200) @@ -823,10 +867,12 @@ def test_get_route_by_id(self, m_post: MagicMock, m_get: MagicMock) -> None: m_post.return_value = mock_response(200, {"route": {"id": "rid123"}}) m_get.return_value = mock_response(200, {"route": {"id": "rid123"}}) req_post = self.client.routes.create( - domain=self.domain, data=self.routes_data + domain=self.domain, + data=self.routes_data, ) req = self.client.routes.get( - domain=self.domain, route_id=req_post.json()["route"]["id"] + domain=self.domain, + route_id=req_post.json()["route"]["id"], ) self.assertEqual(req.status_code, 200) self.assertIn("route", req.json()) @@ -837,7 +883,8 @@ def test_routes_put(self, m_post: MagicMock, m_put: MagicMock) -> None: m_post.return_value = mock_response(200, {"route": {"id": "rid123"}}) m_put.return_value = mock_response(200, {"message": "Updated"}) req_post = self.client.routes.create( - domain=self.domain, data=self.routes_data + domain=self.domain, + data=self.routes_data, ) req = self.client.routes.put( domain=self.domain, @@ -853,10 +900,12 @@ def test_routes_delete(self, m_post: MagicMock, m_delete: MagicMock) -> None: m_post.return_value = mock_response(200, {"route": {"id": "rid123"}}) m_delete.return_value = mock_response(200, {"message": "Deleted"}) req_post = self.client.routes.create( - domain=self.domain, data=self.routes_data + domain=self.domain, + data=self.routes_data, ) req = self.client.routes.delete( - domain=self.domain, route_id=req_post.json()["route"]["id"] + domain=self.domain, + route_id=req_post.json()["route"]["id"], ) self.assertEqual(req.status_code, 200) self.assertIn("message", req.json()) @@ -873,7 +922,7 @@ def test_get_routes_match(self, m_get: MagicMock) -> None: "expression": "", "id": "r1", "priority": 0, - } + }, }, ) query = {"address": self.sender} @@ -881,7 +930,12 @@ def test_get_routes_match(self, m_get: MagicMock) -> None: self.assertEqual(req.status_code, 200) self.assertIn("route", req.json()) expected_keys = [ - "actions", "created_at", "description", "expression", "id", "priority" + "actions", + "created_at", + "description", + "expression", + "id", + "priority", ] for key in expected_keys: self.assertIn(key, req.json()["route"]) @@ -902,7 +956,8 @@ def test_webhooks_create(self, m_post: MagicMock, m_delete: MagicMock) -> None: m_post.return_value = mock_response(200, {"message": "Created"}) m_delete.return_value = mock_response() req = self.client.domains_webhooks.create( - domain=self.domain, data=self.webhooks_data + domain=self.domain, + data=self.webhooks_data, ) self.assertEqual(req.status_code, 200) self.assertIn("message", req.json()) @@ -924,10 +979,12 @@ def test_webhook_put(self, m_post: MagicMock, m_put: MagicMock, m_delete: MagicM m_delete.return_value = mock_response(200) self.client.domains_webhooks.create( - domain=self.domain, data=self.webhooks_data + domain=self.domain, + data=self.webhooks_data, ) req = self.client.domains_webhooks_clicked.put( - domain=self.domain, data=self.webhooks_data_put + domain=self.domain, + data=self.webhooks_data_put, ) self.assertEqual(req.status_code, 200) self.assertIn("message", req.json()) @@ -936,13 +993,19 @@ def test_webhook_put(self, m_post: MagicMock, m_put: MagicMock, m_delete: MagicM @patch("requests.Session.delete") @patch("requests.Session.get") @patch("requests.Session.post") - def test_webhook_get_simple(self, m_post: MagicMock, m_get: MagicMock, m_delete: MagicMock) -> None: + def test_webhook_get_simple( + self, + m_post: MagicMock, + m_get: MagicMock, + m_delete: MagicMock, + ) -> None: m_post.return_value = mock_response() m_get.return_value = mock_response(200, {"webhook": {}}) m_delete.return_value = mock_response(200) self.client.domains_webhooks.create( - domain=self.domain, data=self.webhooks_data + domain=self.domain, + data=self.webhooks_data, ) req = self.client.domains_webhooks_clicked.get(domain=self.domain) self.assertEqual(req.status_code, 200) @@ -955,7 +1018,8 @@ def test_webhook_delete(self, m_post: MagicMock, m_delete: MagicMock) -> None: m_post.return_value = mock_response() m_delete.return_value = mock_response(200, {"message": "Deleted"}) self.client.domains_webhooks.create( - domain=self.domain, data=self.webhooks_data + domain=self.domain, + data=self.webhooks_data, ) req = self.client.domains_webhooks_clicked.delete(domain=self.domain) self.assertEqual(req.status_code, 200) @@ -1004,7 +1068,8 @@ def test_maillist_pages_get(self, m_get: MagicMock) -> None: def test_maillist_lists_get(self, m_get: MagicMock) -> None: m_get.return_value = mock_response(200, {"list": {}}) req = self.client.lists.get( - domain=self.domain, address=self.maillist_address + domain=self.domain, + address=self.maillist_address, ) self.assertEqual(req.status_code, 200) self.assertIn("list", req.json()) @@ -1013,7 +1078,8 @@ def test_maillist_lists_get(self, m_get: MagicMock) -> None: def test_maillist_lists_create(self, m_post: MagicMock) -> None: m_post.return_value = mock_response(200, {"list": {}}) req = self.client.lists.create( - domain=self.domain, data=self.mailing_lists_data + domain=self.domain, + data=self.mailing_lists_data, ) self.assertEqual(req.status_code, 200) self.assertIn("list", req.json()) @@ -1035,13 +1101,16 @@ def test_maillists_lists_put(self, m_post: MagicMock, m_put: MagicMock) -> None: @patch("requests.Session.delete") @patch("requests.Session.post") def test_maillists_lists_delete( - self, m_post: MagicMock, m_delete: MagicMock + self, + m_post: MagicMock, + m_delete: MagicMock, ) -> None: m_post.return_value = mock_response() m_delete.return_value = mock_response() self.client.lists.create(domain=self.domain, data=self.mailing_lists_data) req = self.client.lists.delete( - domain=self.domain, address=f"python_sdk@{self.domain}" + domain=self.domain, + address=f"python_sdk@{self.domain}", ) self.assertEqual(req.status_code, 200) self.client.lists.create(domain=self.domain, data=self.mailing_lists_data) @@ -1050,7 +1119,8 @@ def test_maillists_lists_delete( def test_maillists_lists_members_pages_get(self, m_get: MagicMock) -> None: m_get.return_value = mock_response(200, {"items": []}) req = self.client.lists_members_pages.get( - domain=self.domain, address=self.maillist_address + domain=self.domain, + address=self.maillist_address, ) self.assertEqual(req.status_code, 200) self.assertIn("items", req.json()) @@ -1058,7 +1128,9 @@ def test_maillists_lists_members_pages_get(self, m_get: MagicMock) -> None: @patch("requests.Session.post") @patch("requests.Session.delete") def test_maillists_lists_members_create( - self, m_delete: MagicMock, m_post: MagicMock + self, + m_delete: MagicMock, + m_post: MagicMock, ) -> None: m_delete.return_value = mock_response() m_post.return_value = mock_response(200, {"member": {}}) @@ -1074,7 +1146,8 @@ def test_maillists_lists_members_create( def test_maillists_lists_members_get(self, m_get: MagicMock) -> None: m_get.return_value = mock_response(200, {"items": []}) req = self.client.lists_members.get( - domain=self.domain, address=self.maillist_address + domain=self.domain, + address=self.maillist_address, ) self.assertEqual(req.status_code, 200) self.assertIn("items", req.json()) @@ -1082,7 +1155,9 @@ def test_maillists_lists_members_get(self, m_get: MagicMock) -> None: @patch("requests.Session.put") @patch("requests.Session.post") def test_maillists_lists_members_update( - self, m_post: MagicMock, m_put: MagicMock + self, + m_post: MagicMock, + m_put: MagicMock, ) -> None: m_post.return_value = mock_response() m_put.return_value = mock_response(200, {"member": {}}) @@ -1103,7 +1178,9 @@ def test_maillists_lists_members_update( @patch("requests.Session.delete") @patch("requests.Session.post") def test_maillists_lists_members_delete( - self, m_post: MagicMock, m_delete: MagicMock + self, + m_post: MagicMock, + m_delete: MagicMock, ) -> None: m_post.return_value = mock_response() m_delete.return_value = mock_response() @@ -1169,7 +1246,8 @@ def test_create_template(self, m_post: MagicMock, m_delete: MagicMock) -> None: template_name=self.post_template_data["name"], ) req = self.client.templates.create( - data=self.post_template_data, domain=self.domain + data=self.post_template_data, + domain=self.domain, ) self.assertEqual(req.status_code, 200) self.assertIn("template", req.json()) @@ -1194,7 +1272,8 @@ def test_put_template(self, m_post: MagicMock, m_put: MagicMock) -> None: m_post.return_value = mock_response() m_put.return_value = mock_response(200, {"template": {}}) self.client.templates.create( - data=self.post_template_data, domain=self.domain + data=self.post_template_data, + domain=self.domain, ) req = self.client.templates.put( domain=self.domain, @@ -1210,7 +1289,8 @@ def test_delete_template(self, m_post: MagicMock, m_delete: MagicMock) -> None: m_post.return_value = mock_response() m_delete.return_value = mock_response() self.client.templates.create( - data=self.post_template_data, domain=self.domain + data=self.post_template_data, + domain=self.domain, ) req = self.client.templates.delete( domain=self.domain, @@ -1261,12 +1341,15 @@ def test_put_version_template(self, m_post: MagicMock, m_put: MagicMock) -> None @patch("requests.Session.delete") @patch("requests.Session.post") def test_delete_version_template( - self, m_post: MagicMock, m_delete: MagicMock + self, + m_post: MagicMock, + m_delete: MagicMock, ) -> None: m_post.return_value = mock_response() m_delete.return_value = mock_response() self.client.templates.create( - data=self.post_template_data, domain=self.domain + data=self.post_template_data, + domain=self.domain, ) req = self.client.templates.delete( domain=self.domain, @@ -1319,7 +1402,7 @@ def setUp(self) -> None: now = datetime.now() now_formatted = now.strftime("%a, %d %b %Y %H:%M:%S +0000") yesterday_formatted = (now - timedelta(days=1)).strftime( - "%a, %d %b %Y %H:%M:%S +0000" + "%a, %d %b %Y %H:%M:%S +0000", ) self.account_metrics_data = { "start": yesterday_formatted, @@ -1334,8 +1417,8 @@ def setUp(self) -> None: "attribute": "domain", "comparator": "=", "values": [{"label": self.domain, "value": self.domain}], - } - ] + }, + ], }, "include_subaccounts": True, "include_aggregates": True, @@ -1380,20 +1463,23 @@ def test_post_query_get_account_metrics(self, m_post: MagicMock) -> None: @patch("requests.Session.post") def test_post_query_get_account_metrics_invalid_data( - self, m_post: MagicMock + self, + m_post: MagicMock, ) -> None: m_post.return_value = mock_response( - 400, {"message": "'resolution' attribute is invalid"} + 400, + {"message": "'resolution' attribute is invalid"}, ) req = self.client.analytics_metrics.create( - data=self.invalid_account_metrics_data + data=self.invalid_account_metrics_data, ) self.assertEqual(req.status_code, 400) self.assertIn("'resolution' attribute is invalid", req.json()["message"]) @patch("requests.Session.post") def test_post_query_get_account_metrics_invalid_url( - self, m_post: MagicMock + self, + m_post: MagicMock, ) -> None: m_post.return_value = mock_response(404, {}) req = self.client.analytics_metric.create(data=self.account_metrics_data) @@ -1415,30 +1501,33 @@ def test_post_query_get_account_usage_metrics(self, m_post: MagicMock) -> None: }, ) req = self.client.analytics_usage_metrics.create( - data=self.account_usage_metrics_data + data=self.account_usage_metrics_data, ) self.assertEqual(req.status_code, 200) self.assertIn("email_validation_count", req.json()["items"][0]["metrics"]) @patch("requests.Session.post") def test_post_query_get_account_usage_metrics_invalid_data( - self, m_post: MagicMock + self, + m_post: MagicMock, ) -> None: m_post.return_value = mock_response( - 400, {"message": "'resolution' attribute is invalid"} + 400, + {"message": "'resolution' attribute is invalid"}, ) req = self.client.analytics_usage_metrics.create( - data=self.invalid_account_usage_metrics_data + data=self.invalid_account_usage_metrics_data, ) self.assertEqual(req.status_code, 400) @patch("requests.Session.post") def test_post_query_get_account_usage_metrics_invalid_url( - self, m_post: MagicMock + self, + m_post: MagicMock, ) -> None: m_post.return_value = mock_response(404, {}) req = self.client.analytics_usage_metric.create( - data=self.invalid_account_usage_metrics_data + data=self.invalid_account_usage_metrics_data, ) self.assertEqual(req.status_code, 404) @@ -1452,7 +1541,7 @@ def setUp(self) -> None: now = datetime.now() now_formatted = now.strftime("%a, %d %b %Y %H:%M:%S +0000") yesterday_formatted = (now - timedelta(days=1)).strftime( - "%a, %d %b %Y %H:%M:%S +0000" + "%a, %d %b %Y %H:%M:%S +0000", ) self.account_logs_data = { "start": yesterday_formatted, @@ -1463,8 +1552,8 @@ def setUp(self) -> None: "attribute": "domain", "comparator": "=", "values": [{"label": self.domain, "value": self.domain}], - } - ] + }, + ], }, "include_subaccounts": True, "pagination": {"sort": "timestamp:asc", "limit": 50}, @@ -1478,8 +1567,8 @@ def setUp(self) -> None: "attribute": "test", "comparator": "=", "values": [{"label": "", "value": ""}], - } - ] + }, + ], }, "include_subaccounts": True, "pagination": {"sort": "timestamp:asc", "limit": 0}, @@ -1504,14 +1593,15 @@ def test_post_query_get_account_logs(self, m_post: MagicMock) -> None: @patch("requests.Session.post") def test_post_query_get_account_logs_invalid_data( - self, m_post: MagicMock + self, + m_post: MagicMock, ) -> None: m_post.return_value = mock_response( 400, {"message": "'test' is not a valid filter predicate attribute"}, ) req = self.client.analytics_logs.create( - data=self.invalid_account_logs_data + data=self.invalid_account_logs_data, ) self.assertEqual(req.status_code, 400) self.assertIn( @@ -1521,7 +1611,8 @@ def test_post_query_get_account_logs_invalid_data( @patch("requests.Session.post") def test_post_query_get_account_logs_invalid_url( - self, m_post: MagicMock + self, + m_post: MagicMock, ) -> None: m_post.return_value = mock_response(404, {}) req = self.client.analytics_log.create(data=self.account_logs_data) @@ -1563,7 +1654,8 @@ def test_post_query_get_account_tags(self, m_post: MagicMock) -> None: @patch("requests.Session.post") def test_post_query_get_account_tags_with_incorrect_url( - self, m_post: MagicMock + self, + m_post: MagicMock, ) -> None: """Invalid URL (analytics_tag singular) returns 404.""" m_post.return_value = mock_response(404, {"error": "Not found"}) @@ -1618,7 +1710,8 @@ def test_post_list_statistic_without_dimensions(self, m_post: MagicMock) -> None @patch("requests.Session.post") def test_post_list_statistic_with_old_dates(self, m_post: MagicMock) -> None: m_post.return_value = mock_response( - 400, {"message": "is out of permitted log retention"} + 400, + {"message": "is out of permitted log retention"}, ) data = { "dimensions": ["classification_id"], @@ -1673,7 +1766,7 @@ def test_get_users(self, m_get: MagicMock) -> None: "tfa_active": False, "tfa_created_at": "", "tfa_enabled": False, - } + }, ], }, ) @@ -1788,7 +1881,8 @@ def test_get_keys(self, m_get: MagicMock) -> None: @patch("requests.Session.get") def test_get_keys_without_filtering_data(self, m_get: MagicMock) -> None: m_get.return_value = mock_response( - 200, {"items": [{"id": "k1", "description": "test"}]} + 200, + {"items": [{"id": "k1", "description": "test"}]}, ) req = self.client.keys.get() self.assertEqual(req.status_code, 200) @@ -1808,7 +1902,7 @@ def test_post_keys(self, m_post: MagicMock) -> None: "created_at": "", "updated_at": "", "expires_at": "", - "secret": "secret", # pragma: allowlist secret + "secret": "secret", # pragma: allowlist secret "is_disabled": False, "domain_name": "python.test.domain5", "requestor": self.mailgun_email, diff --git a/tests/unit/test_logger.py b/tests/unit/test_logger.py index 0fbef003..8947d7f3 100644 --- a/tests/unit/test_logger.py +++ b/tests/unit/test_logger.py @@ -12,9 +12,7 @@ def test_get_logger_idempotency(self) -> None: _ = get_logger("mailgun.test_idem") log2 = get_logger("mailgun.test_idem") - redacting_filters = [ - f for f in log2.filters if isinstance(f, RedactingFilter) - ] + redacting_filters = [f for f in log2.filters if isinstance(f, RedactingFilter)] assert len(redacting_filters) == 1 def test_get_logger_non_mailgun_namespace(self) -> None: @@ -24,9 +22,7 @@ def test_get_logger_non_mailgun_namespace(self) -> None: """ logger = get_logger("external_namespace_app") - redacting_filters = [ - f for f in logger.filters if isinstance(f, RedactingFilter) - ] + redacting_filters = [f for f in logger.filters if isinstance(f, RedactingFilter)] assert len(redacting_filters) == 1 # We assert this didn't crash and correctly skipped the namespace block @@ -65,9 +61,7 @@ def test_root_logger_handler_bypass(self) -> None: try: get_logger("mailgun.test_bypass") - assert not any( - isinstance(h, logging.NullHandler) for h in root_logger.handlers - ) + assert not any(isinstance(h, logging.NullHandler) for h in root_logger.handlers) finally: # Clean up to prevent state bleeding into downstream tests root_logger.handlers.clear() @@ -141,6 +135,7 @@ def test_redact_named_tuple(self) -> None: def test_redact_custom_object_dict_fallback(self) -> None: """Hits Line 99: hasattr(data, '__dict__') fallback.""" + class CustomObj: def __init__(self) -> None: self.api_key = "key-supersecret" # pragma: allowlist secret @@ -148,7 +143,7 @@ def __init__(self) -> None: filtr = RedactingFilter() redacted = filtr._deep_redact(CustomObj()) - assert redacted["api_key"] == "key-[REDACTED]" # pragma: allowlist secret + assert redacted["api_key"] == "key-[REDACTED]" # pragma: allowlist secret def test_redact_set(self) -> None: """Hits the isinstance(data, set) branch.""" diff --git a/tests/unit/test_pagination.py b/tests/unit/test_pagination.py index 1a935b80..0a9738bc 100644 --- a/tests/unit/test_pagination.py +++ b/tests/unit/test_pagination.py @@ -25,19 +25,20 @@ class TestStreamPaginationAsync: @patch.object(AsyncEndpoint, "get", new_callable=AsyncMock) @pytest.mark.asyncio async def test_async_stream_pagination_traverses_pages( - self, mock_async_get: AsyncMock + self, + mock_async_get: AsyncMock, ) -> None: page_1 = MockResponse( { "items": [{"id": "async_1"}], "paging": {"next": "https://api.mailgun.net/v3/domains?skip=1"}, - } + }, ) page_2 = MockResponse( { "items": [], "paging": {"next": "https://api.mailgun.net/v3/domains?skip=2"}, - } + }, ) mock_async_get.side_effect = [page_1, page_2] @@ -60,7 +61,9 @@ class TestStreamPaginationErrorHandling: def test_stream_respects_raise_for_status_errors(self, mock_get: MagicMock) -> None: mock_get.return_value = MockResponse({}, status_code=401) endpoint = Endpoint( - url={"base": "http://mock", "keys": []}, headers={}, auth=None + url={"base": "http://mock", "keys": []}, + headers={}, + auth=None, ) with pytest.raises(ValueError, match="HTTP Error: 401"): @@ -75,17 +78,18 @@ def test_sync_stream_pagination_traverses_pages(self, mock_get: MagicMock) -> No "items": [{"id": "event_1"}, {"id": "event_2"}], "paging": { "next": ( - "https://api.mailgun.net/v3/events" - "?event=delivered&page=next_page&limit=2" - ) + "https://api.mailgun.net/v3/events?event=delivered&page=next_page&limit=2" + ), }, - } + }, ) page_2 = MockResponse({"items": [{"id": "event_3"}], "paging": {}}) mock_get.side_effect = [page_1, page_2] endpoint = Endpoint( - url={"base": "http://mock", "keys": []}, headers={}, auth=None + url={"base": "http://mock", "keys": []}, + headers={}, + auth=None, ) results = list(endpoint.stream(filters={"event": "delivered"})) diff --git a/tests/unit/test_routes.py b/tests/unit/test_routes.py index df25e6e5..42f9062a 100644 --- a/tests/unit/test_routes.py +++ b/tests/unit/test_routes.py @@ -23,12 +23,14 @@ def test_get_deprecated_regexes_is_immutable(self) -> None: regexes = routes.get_deprecated_regexes() with pytest.raises( - TypeError, match="'mappingproxy' object does not support item assignment" + TypeError, + match="'mappingproxy' object does not support item assignment", ): - regexes[re.compile("new")] = "hacked" # type: ignore[index] + regexes[re.compile(r"new")] = "hacked" # type: ignore[index] with pytest.raises( - AttributeError, match="'mappingproxy' object has no attribute 'clear'" + AttributeError, + match="'mappingproxy' object has no attribute 'clear'", ): regexes.clear() # type: ignore[attr-defined] @@ -128,7 +130,7 @@ def test_all_endpoints_can_generate_urls(self, mock_request: MagicMock) -> None: mock_request.return_value = MagicMock(status_code=200) all_endpoints = set(routes.EXACT_ROUTES.keys()) | set( - routes.PREFIX_ROUTES.keys() + routes.PREFIX_ROUTES.keys(), ) failed_resolutions = [] @@ -155,7 +157,7 @@ def test_all_endpoints_can_generate_urls(self, mock_request: MagicMock) -> None: target_url = mock_request.call_args[1].get("url") self.assertTrue( - str(target_url).startswith("https://api.mailgun.net/") + str(target_url).startswith("https://api.mailgun.net/"), ) successful_urls.append(f"{endpoint_name} -> {target_url}") @@ -171,5 +173,5 @@ def test_all_endpoints_can_generate_urls(self, mock_request: MagicMock) -> None: if successful_urls: print( - f"\n[ROUTING ENGINE] Successfully validated {len(successful_urls)} routes." + f"\n[ROUTING ENGINE] Successfully validated {len(successful_urls)} routes.", ) diff --git a/tests/unit/test_security_guards.py b/tests/unit/test_security_guards.py index 97848eed..01b0d7d5 100644 --- a/tests/unit/test_security_guards.py +++ b/tests/unit/test_security_guards.py @@ -26,11 +26,15 @@ def test_generate_key_ignores_volatile_options(self) -> None: payload_1 = {"to": "user@test.com", "subject": "Hello", "o:tracking": "yes"} payload_2 = {"to": "user@test.com", "subject": "Hello", "o:tracking": "no"} - assert IdempotencyGuard.generate_key(domain, payload_1) == IdempotencyGuard.generate_key(domain, payload_2) + assert IdempotencyGuard.generate_key(domain, payload_1) == IdempotencyGuard.generate_key( + domain, + payload_2, + ) def test_idempotency_chunked_stream(self) -> None: """Hits the hasattr(file_data, 'read') BytesIO chunking branch.""" import io + stream = io.BytesIO(b"Secure binary attachment data") key = IdempotencyGuard.generate_key("test.com", {}, files=[("report.pdf", stream)]) @@ -98,7 +102,10 @@ def test_spam_guard_absolute_memory_limit(self) -> None: def test_spam_guard_parser_exception(self) -> None: """Hits the try/except block around parser.feed().""" with pytest.MonkeyPatch.context() as m: - m.setattr("mailgun.security._SpamGuardParser.feed", lambda self, data: (_ for _ in ()).throw(RuntimeError("Simulated Parsing Crash"))) + m.setattr( + "mailgun.security._SpamGuardParser.feed", + lambda self, data: (_ for _ in ()).throw(RuntimeError("Simulated Parsing Crash")), + ) report = SpamGuard.check_html("Broken") assert report["is_safe"] is False assert "Fatal HTML parsing error" in report["issues"][0] @@ -123,4 +130,6 @@ def test_spam_guard_blocked_tags_and_event_handlers(self) -> None: html_event = "logo" report_event = SpamGuard.check_html(html_event) assert report_event["is_safe"] is False - assert any("Blocked inline event handler: onerror" in issue for issue in report_event["issues"]) + assert any( + "Blocked inline event handler: onerror" in issue for issue in report_event["issues"] + ) diff --git a/tests/unit/test_types.py b/tests/unit/test_types.py index 781ef735..170c65e7 100644 --- a/tests/unit/test_types.py +++ b/tests/unit/test_types.py @@ -6,7 +6,10 @@ class TestTypesCompatibility: - @pytest.mark.skipif(sys.version_info >= (3, 11), reason="typing_extensions is not installed in >=3.11 test environments") + @pytest.mark.skipif( + sys.version_info >= (3, 11), + reason="typing_extensions is not installed in >=3.11 test environments", + ) def test_types_python_310_fallback(self) -> None: """Ensures that the SDK correctly imports `TypedDict` and `NotRequired` from `typing_extensions` on Python versions older than 3.11. From 0d658ceec521c87033e4196aa9c2bf569d5aa792 Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <291109589+skupriienko-mailgun@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:41:07 +0300 Subject: [PATCH 04/15] test(fuzz): format fuzz targets and resolve linter violations - Convert membership checks in evil server and pagination fuzzers to set literals. - Simplify conditional branching in webhook signature fuzzer. - Replace manual iteration loops with enumerate in pagination stream harness. - Standardize import ordering and code formatting across all fuzz targets. --- tests/fuzz/fuzz_async_client.py | 8 ++--- tests/fuzz/fuzz_async_evil_server.py | 21 ++++++++----- tests/fuzz/fuzz_audit_events.py | 12 ++++---- tests/fuzz/fuzz_builders.py | 16 +++++----- tests/fuzz/fuzz_builders_advanced.py | 6 ++-- tests/fuzz/fuzz_client.py | 5 +-- tests/fuzz/fuzz_config_router.py | 3 +- tests/fuzz/fuzz_crypto_idna.py | 5 ++- tests/fuzz/fuzz_diff_serialization.py | 6 ++-- tests/fuzz/fuzz_differential.py | 21 +++++++------ tests/fuzz/fuzz_endpoint_headers.py | 15 ++++++--- tests/fuzz/fuzz_endpoint_lifecycle.py | 21 ++++++++++--- tests/fuzz/fuzz_error_parser.py | 42 +++++++++++++++----------- tests/fuzz/fuzz_evil_server.py | 30 +++++++++++------- tests/fuzz/fuzz_handlers.py | 13 +++----- tests/fuzz/fuzz_headers.py | 16 +++++----- tests/fuzz/fuzz_log_redaction.py | 14 ++++++--- tests/fuzz/fuzz_logger.py | 3 +- tests/fuzz/fuzz_pagination.py | 12 +++++--- tests/fuzz/fuzz_pagination_stream.py | 13 +++----- tests/fuzz/fuzz_pydantic_models.py | 20 +++++------- tests/fuzz/fuzz_security_primitives.py | 17 ++++++++--- tests/fuzz/fuzz_semantic_payloads.py | 10 +++--- tests/fuzz/fuzz_spamguard.py | 26 +++++++++------- tests/fuzz/fuzz_stateful_client.py | 22 ++++++++++---- tests/fuzz/fuzz_structure_aware.py | 25 ++++++++------- tests/fuzz/fuzz_webhooks.py | 19 ++++-------- tests/fuzz/replay_corpus.py | 3 +- tests/fuzz/seed_harvester.py | 11 ++++--- tests/fuzz/stateful_async_client.py | 11 ++++--- tests/fuzz/strategies.py | 10 +++--- 31 files changed, 258 insertions(+), 198 deletions(-) diff --git a/tests/fuzz/fuzz_async_client.py b/tests/fuzz/fuzz_async_client.py index 417b2c4f..38191e3d 100755 --- a/tests/fuzz/fuzz_async_client.py +++ b/tests/fuzz/fuzz_async_client.py @@ -4,16 +4,16 @@ import asyncio import atexit import logging -import os import sys -from pathlib import Path from typing import Any import atheris + with atheris.instrument_imports(): - from mailgun import routes import httpx2 + + from mailgun import routes from mailgun.client import AsyncClient from mailgun.handlers.error_handler import ApiError @@ -110,5 +110,5 @@ def TestOneInput(data: bytes) -> None: if __name__ == "__main__": atheris.instrument_all() atheris.Setup(sys.argv, TestOneInput) - atexit.register(lambda: _FUZZ_LOOP.close()) + atexit.register(_FUZZ_LOOP.close) atheris.Fuzz() diff --git a/tests/fuzz/fuzz_async_evil_server.py b/tests/fuzz/fuzz_async_evil_server.py index a1276abc..9b6f1a2f 100755 --- a/tests/fuzz/fuzz_async_evil_server.py +++ b/tests/fuzz/fuzz_async_evil_server.py @@ -14,8 +14,10 @@ import atheris + with atheris.instrument_imports(): import httpx2 + from mailgun.client import AsyncClient, Config from mailgun.config import RetryPolicy from mailgun.handlers.error_handler import ApiError, MailgunTimeoutError @@ -58,7 +60,9 @@ def TestOneInput(data: bytes) -> None: original_send = httpx2.AsyncClient.send async def evil_send( - self: httpx2.AsyncClient, request: httpx2.Request, **kwargs: Any + self: httpx2.AsyncClient, + request: httpx2.Request, + **kwargs: Any, ) -> httpx2.Response: if fdp.ConsumeBool(): exceptions = [ @@ -80,7 +84,7 @@ async def evil_send( headers = { b"content-type": fdp.PickValueInList( - [b"application/json", b"image/png", b"text/html", b"application/octet-stream"] + [b"application/json", b"image/png", b"text/html", b"application/octet-stream"], ), b"content-length": str(fdp.ConsumeIntInRange(-100, 10000)).encode(), b"Retry-After": retry_val.encode(errors="ignore"), @@ -97,9 +101,13 @@ async def evil_send( httpx2.AsyncClient.send = evil_send # type: ignore[method-assign] async def run_fuzz() -> None: - with Path(os.devnull).open("w") as devnull, contextlib.redirect_stdout( - devnull - ), contextlib.redirect_stderr(devnull): + with ( + Path(os.devnull).open("w") as devnull, + contextlib.redirect_stdout( + devnull, + ), + contextlib.redirect_stderr(devnull), + ): try: action_choice = fdp.ConsumeIntInRange(0, 2) if action_choice == 0: @@ -120,14 +128,13 @@ async def run_fuzz() -> None: httpx2.RequestError, json.JSONDecodeError, TimeoutError, - asyncio.TimeoutError, ): pass except OverflowError as oe: raise RuntimeError(f"CRASH: Retry-After exponential overflow: {oe}") from oe except Exception as e: raise RuntimeError( - f"SDK crashed handling Async Evil Server response: {type(e).__name__} - {e}" + f"SDK crashed handling Async Evil Server response: {type(e).__name__} - {e}", ) from e finally: httpx2.AsyncClient.send = original_send # type: ignore[method-assign] diff --git a/tests/fuzz/fuzz_audit_events.py b/tests/fuzz/fuzz_audit_events.py index fa3733a7..082ac1f9 100644 --- a/tests/fuzz/fuzz_audit_events.py +++ b/tests/fuzz/fuzz_audit_events.py @@ -8,6 +8,7 @@ import atheris import requests + with atheris.instrument_imports(): from mailgun.client import Client from mailgun.handlers.error_handler import ApiError @@ -65,13 +66,12 @@ def TestOneInput(data: bytes) -> None: action = getattr(client.domains, fuzzed_method) action(domain=fuzzed_domain) - # Probe header injection & control character audit hooks if fdp.ConsumeBool(): hostile_headers = { fdp.ConsumeUnicodeNoSurrogates(16): fdp.PickValueInList( - ["valid", "bad\r\nHeader: 1", "control\x01char", "null\x00byte"] - ) + ["valid", "bad\r\nHeader: 1", "control\x01char", "null\x00byte"], + ), } client.messages.create( domain=fuzzed_domain, @@ -82,7 +82,7 @@ def TestOneInput(data: bytes) -> None: # Probe SSRF URL audit hooks if fdp.ConsumeBool(): hostile_url = fdp.PickValueInList( - ["ftp://api.mailgun.net", "gopher://127.0.0.1", "http://attacker.com/v3"] + ["ftp://api.mailgun.net", "gopher://127.0.0.1", "http://attacker.com/v3"], ) client.messages.api_call(method="get", url=hostile_url) @@ -91,7 +91,7 @@ def TestOneInput(data: bytes) -> None: for arg in args: if isinstance(arg, str) and "\x00" in arg: raise RuntimeError( - f"CRITICAL: Embedded null byte leaked into sys.audit hook '{event}': {arg!r}" + f"CRITICAL: Embedded null byte leaked into sys.audit hook '{event}': {arg!r}", ) except (ApiError, TypeError, ValueError): @@ -100,7 +100,7 @@ def TestOneInput(data: bytes) -> None: except Exception as e: if "embedded null" in str(e).lower(): raise RuntimeError( - f"CRASH: Unsanitized null byte reached runtime boundary: {e}" + f"CRASH: Unsanitized null byte reached runtime boundary: {e}", ) from e raise RuntimeError(f"UNHANDLED CRASH in Audit Events execution: {e}") from e diff --git a/tests/fuzz/fuzz_builders.py b/tests/fuzz/fuzz_builders.py index bc8aed92..cae7882e 100644 --- a/tests/fuzz/fuzz_builders.py +++ b/tests/fuzz/fuzz_builders.py @@ -7,6 +7,7 @@ import atheris + with atheris.instrument_imports(): from mailgun.builders import MailgunMessageBuilder, MailgunTemplateBuilder @@ -57,7 +58,9 @@ def TestOneInput(data: bytes) -> None: elif op_code == 2: builder.add_option( fdp.ConsumeUnicodeNoSurrogates(20), - value=fdp.PickValueInList([True, False, fdp.ConsumeUnicodeNoSurrogates(10)]), + value=fdp.PickValueInList( + [True, False, fdp.ConsumeUnicodeNoSurrogates(10)], + ), ) elif op_code == 3: builder.add_recipient( @@ -82,13 +85,9 @@ def TestOneInput(data: bytes) -> None: else: # Target 2: MailgunTemplateBuilder - template_name = ( - fdp.ConsumeUnicodeNoSurrogates(30) if fdp.ConsumeBool() else None - ) + template_name = fdp.ConsumeUnicodeNoSurrogates(30) if fdp.ConsumeBool() else None t_builder = ( - MailgunTemplateBuilder(template_name) - if template_name - else MailgunTemplateBuilder() + MailgunTemplateBuilder(template_name) if template_name else MailgunTemplateBuilder() ) for _ in range(fdp.ConsumeIntInRange(1, 8)): @@ -99,7 +98,7 @@ def TestOneInput(data: bytes) -> None: t_builder.set_description(fdp.ConsumeUnicodeNoSurrogates(50)) elif op == 2: t_builder.set_engine( - fdp.PickValueInList(["handlebars", "jinja2", "{{7*7}}", "none"]) + fdp.PickValueInList(["handlebars", "jinja2", "{{7*7}}", "none"]), ) elif op == 3: t_builder.set_template_content(fdp.ConsumeUnicodeNoSurrogates(200)) @@ -108,7 +107,6 @@ def TestOneInput(data: bytes) -> None: _ = t_builder.build() - except (ValueError, AttributeError) as e: error_msg = str(e) if isinstance(e, ValueError) and not any(msg in error_msg for msg in _ALLOWED_ERRORS): diff --git a/tests/fuzz/fuzz_builders_advanced.py b/tests/fuzz/fuzz_builders_advanced.py index f77bbbb9..b9815ad7 100644 --- a/tests/fuzz/fuzz_builders_advanced.py +++ b/tests/fuzz/fuzz_builders_advanced.py @@ -21,7 +21,8 @@ def _test_cyclic_and_stream_pointer_invariants( - fdp: atheris.FuzzedDataProvider, builder: MailgunMessageBuilder + fdp: atheris.FuzzedDataProvider, + builder: MailgunMessageBuilder, ) -> None: # Explicitly annotate cyclic_dict as dict[str, Any] to allow self-referential structures cyclic_dict: dict[str, Any] = {"domain": "test.com", "to": ["a@b.com"]} @@ -105,13 +106,12 @@ def TestOneInput(data: bytes) -> None: nested_ast = _generate_nested_ast(fdp) builder.add_custom_variable(var_key, nested_ast) - elif op_code == 5: # Attach in-memory BytesIO directly raw_bytes = io.BytesIO(fdp.ConsumeBytes(128)) try: getattr(builder, "add_attachment", lambda *a, **k: None)( - ("payload.bin", raw_bytes, "application/octet-stream") + ("payload.bin", raw_bytes, "application/octet-stream"), ) except (AttributeError, TypeError, ValueError): # Expected during fuzzing: optional API may be missing or reject malformed input. diff --git a/tests/fuzz/fuzz_client.py b/tests/fuzz/fuzz_client.py index f0bde30b..bde149be 100755 --- a/tests/fuzz/fuzz_client.py +++ b/tests/fuzz/fuzz_client.py @@ -9,6 +9,7 @@ import atheris import requests + with atheris.instrument_imports(): from mailgun import routes from mailgun.client import Client @@ -33,12 +34,12 @@ def _generate_chaotic_file_payload( ".env", "payload.exe\x00.jpg", "\../\../.txt", - ] + ], ) ) content = fdp.ConsumeBytes(64) mime_type = fdp.PickValueInList( - ["application/json", "text/plain", "image/png", fdp.ConsumeUnicodeNoSurrogates(16)] + ["application/json", "text/plain", "image/png", fdp.ConsumeUnicodeNoSurrogates(16)], ) files.append(("attachment", (filename, content, mime_type))) return files diff --git a/tests/fuzz/fuzz_config_router.py b/tests/fuzz/fuzz_config_router.py index e81dfda4..74003a3f 100755 --- a/tests/fuzz/fuzz_config_router.py +++ b/tests/fuzz/fuzz_config_router.py @@ -6,6 +6,7 @@ import atheris + with atheris.instrument_imports(): from mailgun import routes from mailgun.config import Config @@ -33,7 +34,7 @@ def TestOneInput(data: bytes) -> None: "https://user:pass@api.mailgun.net", # pragma: allowlist secret "ftp://api.mailgun.net", "/v3/relative/path", - ] + ], ) ) diff --git a/tests/fuzz/fuzz_crypto_idna.py b/tests/fuzz/fuzz_crypto_idna.py index 7c8ef39f..7d03726f 100644 --- a/tests/fuzz/fuzz_crypto_idna.py +++ b/tests/fuzz/fuzz_crypto_idna.py @@ -7,6 +7,7 @@ import atheris + with atheris.instrument_imports(): from mailgun.security import SecretAuth, SecurityGuard @@ -75,7 +76,9 @@ def TestOneInput(data: bytes) -> None: except ValueError: pass except Exception as e: - raise RuntimeError(f"UNHANDLED CRASH in Crypto/IDNA boundaries: {type(e).__name__} - {e}") from e + raise RuntimeError( + f"UNHANDLED CRASH in Crypto/IDNA boundaries: {type(e).__name__} - {e}", + ) from e if __name__ == "__main__": diff --git a/tests/fuzz/fuzz_diff_serialization.py b/tests/fuzz/fuzz_diff_serialization.py index 1244ed51..38dfe523 100644 --- a/tests/fuzz/fuzz_diff_serialization.py +++ b/tests/fuzz/fuzz_diff_serialization.py @@ -7,10 +7,10 @@ import atheris -with atheris.instrument_imports(): - import requests +with atheris.instrument_imports(): import httpx2 + import requests logging.disable(logging.CRITICAL) @@ -74,7 +74,7 @@ def TestOneInput(data: bytes) -> None: raise RuntimeError( f"DIFFERENTIAL DIVERGENCE: Sync resolved to '{sync_category}' ({type(sync_exc).__name__}), " f"but Async resolved to '{async_category}' ({type(async_exc).__name__}). " - f"Payload: {payload!r}" + f"Payload: {payload!r}", ) diff --git a/tests/fuzz/fuzz_differential.py b/tests/fuzz/fuzz_differential.py index 5489112b..83e3077c 100644 --- a/tests/fuzz/fuzz_differential.py +++ b/tests/fuzz/fuzz_differential.py @@ -8,9 +8,8 @@ from unittest.mock import patch import atheris -import requests - import httpx2 +import requests with atheris.instrument_imports(): @@ -37,7 +36,9 @@ _ASYNC_CLIENT = AsyncClient(auth=("api", "key-test"), config=_NO_RETRY_CONFIG) -def _build_fuzzed_response_data(fdp: atheris.FuzzedDataProvider) -> tuple[int, dict[str, str], bytes]: +def _build_fuzzed_response_data( + fdp: atheris.FuzzedDataProvider, +) -> tuple[int, dict[str, str], bytes]: """Generate dynamic status codes, headers, and payloads for mock responses.""" status_code = fdp.PickValueInList(_STATUS_CODES) content_types = [ @@ -62,7 +63,7 @@ def _build_fuzzed_response_data(fdp: atheris.FuzzedDataProvider) -> tuple[int, d if choice == 0: msg = fdp.ConsumeUnicodeNoSurrogates(32) total = fdp.ConsumeInt(100) - body = f'{{"message": "{msg}", "total": {total}}}'.encode("utf-8") + body = f'{{"message": "{msg}", "total": {total}}}'.encode() elif choice == 1: body = b"{}" if fdp.ConsumeBool() else b"" else: @@ -99,12 +100,12 @@ def mock_requests_send( # Dynamic mock for Async (httpx) byte_headers = { - k.encode("latin-1"): v.encode("latin-1", "replace") - for k, v in resp_headers.items() + k.encode("latin-1"): v.encode("latin-1", "replace") for k, v in resp_headers.items() } async def mock_httpx_handle( - self: httpx2.AsyncBaseTransport, request: httpx2.Request + self: httpx2.AsyncBaseTransport, + request: httpx2.Request, ) -> httpx2.Response: return httpx2.Response( status_code=status_code, @@ -133,7 +134,7 @@ async def mock_httpx_handle( call_kwargs["domain"] = fdp.ConsumeUnicodeNoSurrogates(24) if fdp.ConsumeBool(): call_kwargs["params"] = { - fdp.ConsumeUnicodeNoSurrogates(8): fdp.ConsumeUnicodeNoSurrogates(16) + fdp.ConsumeUnicodeNoSurrogates(8): fdp.ConsumeUnicodeNoSurrogates(16), } if fdp.ConsumeBool(): call_kwargs["data"] = { @@ -159,7 +160,7 @@ async def mock_httpx_handle( ares = _FUZZ_LOOP.run_until_complete(coro) if hasattr(ares, "status_code"): async_result = f"status_{ares.status_code}" - except (TimeoutError, asyncio.TimeoutError): + except TimeoutError: async_result = "TimeoutError" except Exception as exc: async_result = type(exc).__name__ @@ -170,7 +171,7 @@ async def mock_httpx_handle( f"Call kwargs: {call_kwargs!r}\n" f"Mock Status: {status_code}\n" f"Sync Result: {sync_result}\n" - f"Async Result: {async_result}" + f"Async Result: {async_result}", ) diff --git a/tests/fuzz/fuzz_endpoint_headers.py b/tests/fuzz/fuzz_endpoint_headers.py index 20fc3602..26adf63d 100755 --- a/tests/fuzz/fuzz_endpoint_headers.py +++ b/tests/fuzz/fuzz_endpoint_headers.py @@ -54,7 +54,7 @@ def TestOneInput(data: bytes) -> None: "X-Mailgun-Tag", "X-Mailgun-Variables", fdp.ConsumeUnicodeNoSurrogates(12), - ] + ], ) base_headers[header_key] = _generate_header_value(fdp) @@ -81,7 +81,7 @@ def TestOneInput(data: bytes) -> None: "Content-Type", "x-mailgun-tag", fdp.ConsumeUnicodeNoSurrogates(16), - ] + ], ) merged_headers[header_key] = _generate_header_value(fdp) kwargs["headers"] = merged_headers @@ -89,7 +89,14 @@ def TestOneInput(data: bytes) -> None: # HTTP transport kwargs injection for _ in range(fdp.ConsumeIntInRange(0, 5)): prop_key = fdp.PickValueInList( - ["timeout", "verify", "proxies", "params", "allow_redirects", fdp.ConsumeUnicodeNoSurrogates(10)] + [ + "timeout", + "verify", + "proxies", + "params", + "allow_redirects", + fdp.ConsumeUnicodeNoSurrogates(10), + ], ) prop_val: Any val_mode = fdp.ConsumeIntInRange(0, 3) @@ -115,7 +122,7 @@ def TestOneInput(data: bytes) -> None: f"Unhandled exception during _merge_headers:\n" f"Base headers: {base_headers!r}\n" f"Kwargs: {kwargs!r}\n" - f"Exception: {type(exc).__name__}: {exc}" + f"Exception: {type(exc).__name__}: {exc}", ) from exc diff --git a/tests/fuzz/fuzz_endpoint_lifecycle.py b/tests/fuzz/fuzz_endpoint_lifecycle.py index 0d239f17..95c8362d 100755 --- a/tests/fuzz/fuzz_endpoint_lifecycle.py +++ b/tests/fuzz/fuzz_endpoint_lifecycle.py @@ -44,7 +44,7 @@ def _generate_fuzzed_id(fdp: atheris.FuzzedDataProvider) -> str: if choice == 2: # URL encoding and special characters return fdp.PickValueInList( - ["test@example.com", "tag:special", "id#123", "id?filter=1", "id%20space", ""] + ["test@example.com", "tag:special", "id#123", "id?filter=1", "id%20space", ""], ) if choice == 3: # Sub-resource path representation @@ -82,12 +82,18 @@ def TestOneInput(data: bytes) -> None: # CREATE new_id = _generate_fuzzed_id(fdp) if hasattr(endpoint, "create"): - endpoint.create(data={"id": new_id, "name": fdp.ConsumeUnicodeNoSurrogates(10)}) + endpoint.create( + data={"id": new_id, "name": fdp.ConsumeUnicodeNoSurrogates(10)}, + ) active_ids.append(new_id) elif op == 1: # GET - target_id = fdp.PickValueInList(active_ids) if (active_ids and fdp.ConsumeBool()) else _generate_fuzzed_id(fdp) + target_id = ( + fdp.PickValueInList(active_ids) + if (active_ids and fdp.ConsumeBool()) + else _generate_fuzzed_id(fdp) + ) if hasattr(endpoint, "get"): endpoint.get(domain=target_id) @@ -104,7 +110,10 @@ def TestOneInput(data: bytes) -> None: if hasattr(endpoint, "update"): endpoint.update( domain=target_id, - data={"fuzz_key": fdp.ConsumeUnicodeNoSurrogates(12), "val": fdp.ConsumeInt(1000)}, + data={ + "fuzz_key": fdp.ConsumeUnicodeNoSurrogates(12), + "val": fdp.ConsumeInt(1000), + }, ) elif op == 4 and active_ids: @@ -125,7 +134,9 @@ def TestOneInput(data: bytes) -> None: ): pass except Exception as exc: - raise RuntimeError(f"Unexpected crash in endpoint lifecycle harness: {type(exc).__name__}: {exc}") from exc + raise RuntimeError( + f"Unexpected crash in endpoint lifecycle harness: {type(exc).__name__}: {exc}", + ) from exc if __name__ == "__main__": diff --git a/tests/fuzz/fuzz_error_parser.py b/tests/fuzz/fuzz_error_parser.py index 86ab8f0b..c76731af 100644 --- a/tests/fuzz/fuzz_error_parser.py +++ b/tests/fuzz/fuzz_error_parser.py @@ -6,9 +6,8 @@ import sys import atheris -import requests - import httpx2 +import requests with atheris.instrument_imports(): @@ -25,24 +24,32 @@ def _build_fuzzed_error_content(fdp: atheris.FuzzedDataProvider) -> bytes: if payload_type == 0: # Standard Mailgun error structure - return json.dumps({ - "message": fdp.ConsumeUnicodeNoSurrogates(64), - "details": [fdp.ConsumeUnicodeNoSurrogates(32) for _ in range(fdp.ConsumeIntInRange(0, 3))], - }).encode("utf-8") + return json.dumps( + { + "message": fdp.ConsumeUnicodeNoSurrogates(64), + "details": [ + fdp.ConsumeUnicodeNoSurrogates(32) for _ in range(fdp.ConsumeIntInRange(0, 3)) + ], + }, + ).encode("utf-8") if payload_type == 1: # Single error key or legacy dictionary format - return json.dumps({ - "error": fdp.ConsumeUnicodeNoSurrogates(64), - "code": fdp.ConsumeInt(1000), - }).encode("utf-8") + return json.dumps( + { + "error": fdp.ConsumeUnicodeNoSurrogates(64), + "code": fdp.ConsumeInt(1000), + }, + ).encode("utf-8") if payload_type == 2: # Array-wrapped errors - return json.dumps([ - {"message": fdp.ConsumeUnicodeNoSurrogates(32)}, - {"reason": fdp.ConsumeUnicodeNoSurrogates(32)}, - ]).encode("utf-8") + return json.dumps( + [ + {"message": fdp.ConsumeUnicodeNoSurrogates(32)}, + {"reason": fdp.ConsumeUnicodeNoSurrogates(32)}, + ], + ).encode("utf-8") if payload_type == 3: # Non-JSON HTML / XML error page (Cloudflare/Nginx gateway crashes) @@ -67,8 +74,7 @@ def TestOneInput(data: bytes) -> None: try: score = fdp.ConsumeFloat() issues = [ - fdp.ConsumeUnicodeNoSurrogates(24) - for _ in range(fdp.ConsumeIntInRange(0, 20)) + fdp.ConsumeUnicodeNoSurrogates(24) for _ in range(fdp.ConsumeIntInRange(0, 20)) ] err = DeliverabilityError(score=score, issues=issues) _ = str(err) @@ -96,7 +102,7 @@ def TestOneInput(data: bytes) -> None: "text/html", "text/plain", fdp.ConsumeUnicodeNoSurrogates(16), - ] + ], ) # Ensure header values conform to valid ASCII per HTTP header specifications content_type = raw_content_type.encode("ascii", "replace").decode("ascii") @@ -111,7 +117,7 @@ def TestOneInput(data: bytes) -> None: headers=httpx_headers, content=content, request=httpx2.Request("POST", "https://api.mailgun.net/v3/messages"), - ) + ) try: api_error_httpx = ApiError(httpx_resp) diff --git a/tests/fuzz/fuzz_evil_server.py b/tests/fuzz/fuzz_evil_server.py index c2078bd0..3d6f07f6 100755 --- a/tests/fuzz/fuzz_evil_server.py +++ b/tests/fuzz/fuzz_evil_server.py @@ -31,7 +31,11 @@ def TestOneInput(data: bytes) -> None: client = Client(auth=("api", "test-key")) original_send = requests.Session.send - def evil_send(self: requests.Session, request: requests.PreparedRequest, **kwargs: Any) -> requests.Response: + def evil_send( + self: requests.Session, + request: requests.PreparedRequest, + **kwargs: Any, + ) -> requests.Response: # Inject network-level transport faults if fdp.ConsumeBool(): exceptions = [ @@ -47,7 +51,7 @@ def evil_send(self: requests.Session, request: requests.PreparedRequest, **kwarg status = fdp.PickValueInList(_STATUS_CODES) headers: dict[str, str] = { "content-type": fdp.PickValueInList( - ["application/json", "text/html", "application/octet-stream", "image/png"] + ["application/json", "text/html", "application/octet-stream", "image/png"], ), "content-length": str(fdp.ConsumeIntInRange(-1000, 20000)), } @@ -55,15 +59,13 @@ def evil_send(self: requests.Session, request: requests.PreparedRequest, **kwarg # Hostile Retry-After injection if fdp.ConsumeBool(): headers["Retry-After"] = ( - fdp.ConsumeUnicodeNoSurrogates(16) - if fdp.ConsumeBool() - else str(fdp.ConsumeFloat()) + fdp.ConsumeUnicodeNoSurrogates(16) if fdp.ConsumeBool() else str(fdp.ConsumeFloat()) ) # Redirect loop simulation - if status in (301, 302): + if status in {301, 302}: headers["Location"] = fdp.PickValueInList( - ["https://api.mailgun.net/v3/messages", "http://127.0.0.1:80", "/relative/loop"] + ["https://api.mailgun.net/v3/messages", "http://127.0.0.1:80", "/relative/loop"], ) mock_response = requests.Response() @@ -77,9 +79,13 @@ def evil_send(self: requests.Session, request: requests.PreparedRequest, **kwarg requests.Session.send = evil_send # type: ignore[method-assign] - with Path(os.devnull).open("w") as devnull, contextlib.redirect_stdout( - devnull - ), contextlib.redirect_stderr(devnull): + with ( + Path(os.devnull).open("w") as devnull, + contextlib.redirect_stdout( + devnull, + ), + contextlib.redirect_stderr(devnull), + ): try: target_method = fdp.PickValueInList(_HTTP_METHODS) client.messages.api_call( @@ -98,7 +104,9 @@ def evil_send(self: requests.Session, request: requests.PreparedRequest, **kwarg ): pass except Exception as exc: - raise RuntimeError(f"Unhandled crash during evil server simulation: {type(exc).__name__}: {exc}") from exc + raise RuntimeError( + f"Unhandled crash during evil server simulation: {type(exc).__name__}: {exc}", + ) from exc finally: requests.Session.send = original_send # type: ignore[method-assign] if hasattr(client, "close"): diff --git a/tests/fuzz/fuzz_handlers.py b/tests/fuzz/fuzz_handlers.py index 75338449..8573deb0 100755 --- a/tests/fuzz/fuzz_handlers.py +++ b/tests/fuzz/fuzz_handlers.py @@ -13,6 +13,7 @@ import atheris + with atheris.instrument_imports(): from mailgun.handlers.bounce_classification_handler import ( handle_bounce_classification, @@ -148,10 +149,7 @@ def _generate_chaotic_value(fdp: atheris.FuzzedDataProvider, depth: int = 0) -> if choice == 4: return None if choice == 5: - return [ - _generate_chaotic_value(fdp, depth + 1) - for _ in range(fdp.ConsumeIntInRange(0, 4)) - ] + return [_generate_chaotic_value(fdp, depth + 1) for _ in range(fdp.ConsumeIntInRange(0, 4))] if choice == 6: return { fdp.ConsumeUnicodeNoSurrogates(12): _generate_chaotic_value(fdp, depth + 1) @@ -245,8 +243,7 @@ def TestOneInput(data: bytes) -> None: if key == "event_types": kwargs[key] = [ - fdp.ConsumeUnicodeNoSurrogates(8) - for _ in range(fdp.ConsumeIntInRange(1, 4)) + fdp.ConsumeUnicodeNoSurrogates(8) for _ in range(fdp.ConsumeIntInRange(1, 4)) ] elif key == "filters" and fdp.ConsumeBool(): kwargs[key] = { @@ -266,7 +263,7 @@ def TestOneInput(data: bytes) -> None: if not isinstance(result, str): handler_name = getattr(handler, "__name__", type(handler).__name__) raise RuntimeError( - f"CONTRACT VIOLATION: Handler {handler_name} returned non-string: {type(result)}" + f"CONTRACT VIOLATION: Handler {handler_name} returned non-string: {type(result)}", ) except (ApiError, TypeError, ValueError): # Expected rejections for malformed URLs, missing keys, or unsupported methods @@ -274,7 +271,7 @@ def TestOneInput(data: bytes) -> None: except Exception as e: handler_name = getattr(handler, "__name__", type(handler).__name__) raise RuntimeError( - f"UNHANDLED CRASH in {handler_name} with kwargs {list(kwargs.keys())}: {e}" + f"UNHANDLED CRASH in {handler_name} with kwargs {list(kwargs.keys())}: {e}", ) from e diff --git a/tests/fuzz/fuzz_headers.py b/tests/fuzz/fuzz_headers.py index 57fc0aaf..bdae621d 100755 --- a/tests/fuzz/fuzz_headers.py +++ b/tests/fuzz/fuzz_headers.py @@ -12,6 +12,7 @@ import atheris + with atheris.instrument_imports(): from mailgun.security import SecurityGuard @@ -89,10 +90,7 @@ def TestOneInput(data: bytes) -> None: val = fdp.ConsumeIntInRange(-100000, 100000) elif val_choice == 3: # Multi-value headers - val = [ - fdp.ConsumeUnicodeNoSurrogates(24) - for _ in range(fdp.ConsumeIntInRange(1, 3)) - ] + val = [fdp.ConsumeUnicodeNoSurrogates(24) for _ in range(fdp.ConsumeIntInRange(1, 3))] elif val_choice == 4: val = None elif val_choice == 5: @@ -107,26 +105,26 @@ def TestOneInput(data: bytes) -> None: if not isinstance(sanitized, dict): raise RuntimeError( - f"CONTRACT VIOLATION: sanitize_headers returned {type(sanitized)}" + f"CONTRACT VIOLATION: sanitize_headers returned {type(sanitized)}", ) # Invariant checks: sanitized headers must never leak raw CRLF or null bytes for k, v in sanitized.items(): if not isinstance(k, str) or not isinstance(v, str): raise RuntimeError( - f"TYPE DRIFT: Sanitized header contains non-str: {type(k)}={type(v)}" + f"TYPE DRIFT: Sanitized header contains non-str: {type(k)}={type(v)}", ) if "\r" in k or "\n" in k or "\x00" in k: - raise RuntimeError(f"INJECTION LEAK in header key: {repr(k)}") + raise RuntimeError(f"INJECTION LEAK in header key: {k!r}") if "\r" in v or "\n" in v or "\x00" in v: - raise RuntimeError(f"INJECTION LEAK in header value: {repr(v)}") + raise RuntimeError(f"INJECTION LEAK in header value: {v!r}") except (TypeError, ValueError, UnicodeEncodeError): # Expected security rejection for malformed headers or control characters pass except Exception as e: raise RuntimeError( - f"UNHANDLED CRASH in SecurityGuard.sanitize_headers: {e}" + f"UNHANDLED CRASH in SecurityGuard.sanitize_headers: {e}", ) from e diff --git a/tests/fuzz/fuzz_log_redaction.py b/tests/fuzz/fuzz_log_redaction.py index 77fd85da..b33e5194 100755 --- a/tests/fuzz/fuzz_log_redaction.py +++ b/tests/fuzz/fuzz_log_redaction.py @@ -12,6 +12,7 @@ import atheris + with atheris.instrument_imports(): from mailgun.filters import RedactingFilter @@ -56,7 +57,9 @@ def _generate_nested_payload( if choice == 3: return { fdp.ConsumeUnicodeNoSurrogates(10): _generate_nested_payload( - fdp, depth + 1, state + fdp, + depth + 1, + state, ) for _ in range(fdp.ConsumeIntInRange(1, 3)) } @@ -97,7 +100,7 @@ def TestOneInput(data: bytes) -> None: "pubkey-", "secret-", "https://api.mailgun.net/v3/domains/", - ] + ], ) multiplier = fdp.ConsumeIntInRange(10, 80) suffix = fdp.ConsumeUnicodeNoSurrogates(120) @@ -143,14 +146,15 @@ def TestOneInput(data: bytes) -> None: # Invariant 2: String formatting safety # Guard against LibFuzzer OOM on massive format specifiers like %999999999s if isinstance(record.msg, str) and re.search( - r"%[^a-zA-Z%]*[0-9]{4,}", record.msg + r"%[^a-zA-Z%]*[0-9]{4,}", + record.msg, ): return formatted = record.getMessage() if not isinstance(formatted, str): raise RuntimeError( - f"CONTRACT VIOLATION: getMessage returned {type(formatted)}" + f"CONTRACT VIOLATION: getMessage returned {type(formatted)}", ) except (KeyError, OverflowError, TypeError, ValueError): @@ -158,7 +162,7 @@ def TestOneInput(data: bytes) -> None: pass except RecursionError as e: raise RuntimeError( - f"SECURITY BREACH: RedactingFilter failed to prevent infinite recursion: {e}" + f"SECURITY BREACH: RedactingFilter failed to prevent infinite recursion: {e}", ) from e except Exception as e: raise RuntimeError(f"UNHANDLED CRASH in RedactingFilter: {e}") from e diff --git a/tests/fuzz/fuzz_logger.py b/tests/fuzz/fuzz_logger.py index 95f2e33d..eec41a17 100644 --- a/tests/fuzz/fuzz_logger.py +++ b/tests/fuzz/fuzz_logger.py @@ -13,6 +13,7 @@ import atheris + with atheris.instrument_imports(): from mailgun.logger import get_logger @@ -72,7 +73,7 @@ def TestOneInput(data: bytes) -> None: logger.warning, logger.error, logger.critical, - ] + ], ) # Message with log forging payloads diff --git a/tests/fuzz/fuzz_pagination.py b/tests/fuzz/fuzz_pagination.py index b0c86fc9..6f6d257a 100644 --- a/tests/fuzz/fuzz_pagination.py +++ b/tests/fuzz/fuzz_pagination.py @@ -13,6 +13,7 @@ import atheris + with atheris.instrument_imports(): from mailgun.security import SecurityGuard @@ -83,10 +84,10 @@ def TestOneInput(data: bytes) -> None: if "ascending" in params: for a_val in params["ascending"]: - _ = a_val.lower() in ("true", "1", "yes") + _ = a_val.lower() in {"true", "1", "yes"} # SSRF guard on pagination host - if parsed.scheme in ("http", "https") and parsed.netloc: + if parsed.scheme in {"http", "https"} and parsed.netloc: try: SecurityGuard.validate_mailgun_url(target_url) except (ValueError, TypeError): @@ -102,7 +103,9 @@ def TestOneInput(data: bytes) -> None: query_str = fdp.ConsumeUnicodeNoSurrogates(200) try: parsed_qs = urllib.parse.parse_qs( - query_str, keep_blank_values=True, max_num_fields=30 + query_str, + keep_blank_values=True, + max_num_fields=30, ) for k, vals in parsed_qs.items(): if "\x00" in k or any("\x00" in v for v in vals): @@ -111,7 +114,6 @@ def TestOneInput(data: bytes) -> None: # Expected for malformed fuzz inputs; continue fuzzing other paths. pass - else: # URL construction with mutated cursor tokens cursor_token = fdp.ConsumeUnicodeNoSurrogates(128) @@ -119,7 +121,7 @@ def TestOneInput(data: bytes) -> None: constructed_url = f"{base}?page={urllib.parse.quote(cursor_token)}" try: split_url = urllib.parse.urlsplit(constructed_url) - if split_url.scheme not in ("http", "https"): + if split_url.scheme not in {"http", "https"}: raise RuntimeError("Scheme corruption occurred during cursor interpolation") except (ValueError, UnicodeDecodeError): # Expected for malformed fuzz inputs; continue fuzzing other paths. diff --git a/tests/fuzz/fuzz_pagination_stream.py b/tests/fuzz/fuzz_pagination_stream.py index 5782b435..47648103 100644 --- a/tests/fuzz/fuzz_pagination_stream.py +++ b/tests/fuzz/fuzz_pagination_stream.py @@ -11,6 +11,7 @@ import atheris + with atheris.instrument_imports(): from mailgun.client import Endpoint @@ -47,7 +48,7 @@ def TestOneInput(data: bytes) -> None: initial_filters["page"] = fdp.ConsumeUnicodeNoSurrogates(16) if fdp.ConsumeBool(): initial_filters["event"] = fdp.PickValueInList( - ["delivered", "failed", "opened", "clicked", None] + ["delivered", "failed", "opened", "clicked", None], ) if fdp.ConsumeBool(): @@ -72,9 +73,7 @@ def TestOneInput(data: bytes) -> None: { "items": [{"id": "event_2", "recipient": "user2@example.com"}], "paging": { - "next": ( - next_url if fdp.ConsumeBool() else None - ), # 50% test cyclic cursor + "next": (next_url if fdp.ConsumeBool() else None), # 50% test cyclic cursor "previous": None, }, }, @@ -105,9 +104,7 @@ def mock_get(*args: Any, **kwargs: Any) -> Any: stream_gen = ep.stream(filters=initial_filters) # Pull up to 3 pages to test iteration, type casting, and termination - iterations = 0 - for _ in stream_gen: - iterations += 1 + for iterations, _ in enumerate(stream_gen, start=1): if iterations >= 3: # Invariant: prevent endless loops on cyclic next URLs break @@ -117,7 +114,7 @@ def mock_get(*args: Any, **kwargs: Any) -> Any: pass except Exception as e: raise RuntimeError( - f"UNHANDLED CRASH in Endpoint.stream with filters {initial_filters} and url {repr(next_url)}: {e}" + f"UNHANDLED CRASH in Endpoint.stream with filters {initial_filters} and url {next_url!r}: {e}", ) from e diff --git a/tests/fuzz/fuzz_pydantic_models.py b/tests/fuzz/fuzz_pydantic_models.py index 077c954e..5bd26cd0 100644 --- a/tests/fuzz/fuzz_pydantic_models.py +++ b/tests/fuzz/fuzz_pydantic_models.py @@ -12,6 +12,7 @@ import atheris + with atheris.instrument_imports(): from pydantic import ValidationError @@ -25,7 +26,7 @@ "evil\r\nBcc: victim@target.com", "user@localhost", "test@[127.0.0.1]", - "\"Recipient with quotes\" ", + '"Recipient with quotes" ', "user+tag@domain.co.uk", "a" * 255 + "@long.domain.com", "user@" + "sub." * 50 + "com", @@ -44,8 +45,7 @@ def _generate_fuzzed_recipient(fdp: atheris.FuzzedDataProvider) -> Any: return fdp.ConsumeUnicodeNoSurrogates(64) if choice == 2: return [ - fdp.PickValueInList(_MALICIOUS_RECIPIENTS) - for _ in range(fdp.ConsumeIntInRange(1, 4)) + fdp.PickValueInList(_MALICIOUS_RECIPIENTS) for _ in range(fdp.ConsumeIntInRange(1, 4)) ] if choice == 3: return fdp.ConsumeInt(10000) @@ -64,15 +64,9 @@ def TestOneInput(data: bytes) -> None: if fdp.ConsumeBool() else fdp.ConsumeUnicodeNoSurrogates(48) ) - subject_val = ( - fdp.ConsumeUnicodeNoSurrogates(64) if fdp.ConsumeBool() else None - ) - text_val = ( - fdp.ConsumeUnicodeNoSurrogates(128) if fdp.ConsumeBool() else None - ) - html_val = ( - fdp.ConsumeUnicodeNoSurrogates(128) if fdp.ConsumeBool() else None - ) + subject_val = fdp.ConsumeUnicodeNoSurrogates(64) if fdp.ConsumeBool() else None + text_val = fdp.ConsumeUnicodeNoSurrogates(128) if fdp.ConsumeBool() else None + html_val = fdp.ConsumeUnicodeNoSurrogates(128) if fdp.ConsumeBool() else None # Custom parameter mutations (testing h:, v:, o: prefix handling & CRLF rejection) custom_params: dict[str, Any] = {} @@ -117,7 +111,7 @@ def TestOneInput(data: bytes) -> None: for field in ("from_", "subject"): field_val = dumped.get(field) if isinstance(field_val, str) and ("\r" in field_val or "\n" in field_val): - raise RuntimeError(f"CRLF injection leak in model field {field}: {repr(field_val)}") + raise RuntimeError(f"CRLF injection leak in model field {field}: {field_val!r}") except (ValidationError, ValueError, TypeError): # Expected defensive rejections from Pydantic validators diff --git a/tests/fuzz/fuzz_security_primitives.py b/tests/fuzz/fuzz_security_primitives.py index 951a35c6..1abfa343 100755 --- a/tests/fuzz/fuzz_security_primitives.py +++ b/tests/fuzz/fuzz_security_primitives.py @@ -17,6 +17,7 @@ import atheris + with atheris.instrument_imports(): from mailgun.security import SecurityGuard @@ -73,7 +74,7 @@ def TestOneInput(data: bytes) -> None: # Invariant: Output must never contain directory traversal sequences or null bytes if ".." in sanitized or "\x00" in sanitized or "/" in sanitized or "\\" in sanitized: - raise RuntimeError(f"PATH ESCAPE LEAK in sanitized segment: {repr(sanitized)}") + raise RuntimeError(f"PATH ESCAPE LEAK in sanitized segment: {sanitized!r}") elif target == 1: # Target 2: SSRF and Scheme/Host Validation (CWE-918) @@ -102,9 +103,9 @@ def TestOneInput(data: bytes) -> None: if sanitized_headers is not None: for k, v in sanitized_headers.items(): if "\r" in k or "\n" in k or "\x00" in k: - raise RuntimeError(f"CRLF LEAK in header key: {repr(k)}") + raise RuntimeError(f"CRLF LEAK in header key: {k!r}") if "\r" in v or "\n" in v or "\x00" in v: - raise RuntimeError(f"CRLF LEAK in header value: {repr(v)}") + raise RuntimeError(f"CRLF LEAK in header value: {v!r}") elif target == 3: # Target 4: Timeout Bounds and Overflow Defense (CWE-400) @@ -115,14 +116,20 @@ def TestOneInput(data: bytes) -> None: elif timeout_choice == 1: timeout = (fdp.ConsumeFloat(), fdp.ConsumeFloat()) elif timeout_choice == 2: - timeout = fdp.PickValueInList([float("inf"), float("-inf"), float("nan"), -1.0, 0.0]) + timeout = fdp.PickValueInList( + [float("inf"), float("-inf"), float("nan"), -1.0, 0.0], + ) else: timeout = fdp.ConsumeUnicodeNoSurrogates(16) sanitized_timeout = SecurityGuard.sanitize_timeout(timeout) # Invariant: Returned timeout must be positive and non-infinite if isinstance(sanitized_timeout, (int, float)): - if sanitized_timeout <= 0 or math.isnan(sanitized_timeout) or math.isinf(sanitized_timeout): + if ( + sanitized_timeout <= 0 + or math.isnan(sanitized_timeout) + or math.isinf(sanitized_timeout) + ): raise RuntimeError(f"INVALID TIMEOUT escaped: {sanitized_timeout}") elif isinstance(sanitized_timeout, tuple): for t in sanitized_timeout: diff --git a/tests/fuzz/fuzz_semantic_payloads.py b/tests/fuzz/fuzz_semantic_payloads.py index 3ff60e55..9ad890f5 100644 --- a/tests/fuzz/fuzz_semantic_payloads.py +++ b/tests/fuzz/fuzz_semantic_payloads.py @@ -14,6 +14,7 @@ import atheris + with atheris.instrument_imports(): from mailgun.client import Client from mailgun.handlers.error_handler import ApiError @@ -37,8 +38,7 @@ def _generate_nested_json_tree(fdp: atheris.FuzzedDataProvider, depth: int = 0) return None if choice == 4: return [ - _generate_nested_json_tree(fdp, depth + 1) - for _ in range(fdp.ConsumeIntInRange(1, 3)) + _generate_nested_json_tree(fdp, depth + 1) for _ in range(fdp.ConsumeIntInRange(1, 3)) ] return { fdp.ConsumeUnicodeNoSurrogates(8): _generate_nested_json_tree(fdp, depth + 1) @@ -61,8 +61,7 @@ def _generate_semantic_message(fdp: atheris.FuzzedDataProvider) -> dict[str, Any # Option parameters (o:tag, o:tracking, o:deliverytime) if fdp.ConsumeBool(): payload["o:tag"] = [ - fdp.ConsumeUnicodeNoSurrogates(12) - for _ in range(fdp.ConsumeIntInRange(1, 3)) + fdp.ConsumeUnicodeNoSurrogates(12) for _ in range(fdp.ConsumeIntInRange(1, 3)) ] if fdp.ConsumeBool(): payload["o:tracking"] = fdp.PickValueInList([True, False, "yes", "no", None]) @@ -74,7 +73,7 @@ def _generate_semantic_message(fdp: atheris.FuzzedDataProvider) -> dict[str, Any "2024-10-25T23:10:10Z", fdp.ConsumeInt(10**10), "invalid-time-format", - ] + ], ) # Deep recipient-variables serialization @@ -122,6 +121,7 @@ def TestOneInput(data: bytes) -> None: except Exception as e: raise RuntimeError(f"SEMANTIC CRASH: {type(e).__name__} - {e}") from e + if __name__ == "__main__": atheris.instrument_all() atheris.Setup(sys.argv, TestOneInput) diff --git a/tests/fuzz/fuzz_spamguard.py b/tests/fuzz/fuzz_spamguard.py index 797b685a..d96d0ee5 100644 --- a/tests/fuzz/fuzz_spamguard.py +++ b/tests/fuzz/fuzz_spamguard.py @@ -13,6 +13,7 @@ import atheris + with atheris.instrument_imports(): from mailgun.security import SpamGuard @@ -22,11 +23,11 @@ 'Click here', 'No image', '
Hidden Spam
', - '---" "q!-q" "" +"ccccc" +"" "\x15q2&\x15\x15<%do" "<" "<\x1b" "" "<" "=\x18\x00\x00\x00\x00" +"E" "}&l\x7f\x7f\x7f" "}><" "}<" "]>" "}!t" "E" -"im1" -"ccccc" -"---" -"\xff\xff\xff\xff\xff\xff\xff\xf0" -"" -"<" -" None: advanced_data.update( { "o:deliverytime-optimize-period": "24h", - "o:tag": ["async-integration-test", "httpx-sdk"], + "o:tag": ["async-integration-test", "httpx2-sdk"], "v:test-variable": "custom_async_value", "o:testmode": "yes", # CRITICAL: Ensures the email is NOT actually sent }, diff --git a/tests/unit/test_endpoint.py b/tests/unit/test_endpoint.py index f40bbc0b..d4f65e50 100644 --- a/tests/unit/test_endpoint.py +++ b/tests/unit/test_endpoint.py @@ -452,7 +452,7 @@ def test_messages_support_delivery_optimization_and_core_tags(self) -> None: "subject": "Testing STO", "text": "This is a test message.", "o:deliverytime-optimize-period": "24h", - "o:tag": ["newsletter", "python-sdk"], + "o:tag": ["async-integration-test", "httpx2-sdk"], "o:testmode": "yes", "v:custom-id": "USER-12345", } @@ -470,7 +470,7 @@ def test_messages_support_delivery_optimization_and_core_tags(self) -> None: assert actual_data is not None, "Data payload should not be None" assert "o:deliverytime-optimize-period" in actual_data assert actual_data["o:deliverytime-optimize-period"] == "24h" - assert actual_data["o:tag"] == ["newsletter", "python-sdk"] + assert actual_data["o:tag"] == ["async-integration-test", "httpx2-sdk"] def test_update_serializes_json(self) -> None: url = {"base": f"{BASE_URL_V4}/", "keys": ["domainlist"]} From 1a2ab3893f65c326f604bec9c811fffacedd6277 Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <291109589+skupriienko-mailgun@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:35:26 +0300 Subject: [PATCH 10/15] docs: document stream pagination fixes and finalize v1.10.0 release notes - Update README.md runtime dependencies to mandate httpx2 >=2.7.0 exclusively. - Add stream pagination hardening, httpx2 engine migration, and merged PRs to CHANGELOG.md. --- CHANGELOG.md | 28 +++++++++++++++++++++++++++- README.md | 5 ++--- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12e68042..55cee872 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,31 @@ We [keep a changelog.](http://keepachangelog.com/) ## [Unreleased] +## [1.10.0] - 2026-10-08 + +### Changed + +- **Async Engine Migration (`httpx2`):** Migrated the asynchronous engine exclusively to `httpx2 >=2.7.0`, removing `httpx >=0.24.0` from runtime dependencies across `pyproject.toml`, `environment.yaml`, and `environment-dev.yaml`. +- **Core HTTP Import Refactoring:** Updated `mailgun.client`, `mailgun.endpoints`, `mailgun.security`, and `mailgun.types` to interface directly with `httpx2` for client instances (`httpx2.AsyncClient`), requests (`httpx2.Request`), responses (`httpx2.Response`), timeouts (`httpx2.Timeout`), and network exceptions (`httpx2.RequestError`, `httpx2.TimeoutException`, `httpx2.ConnectError`, `httpx2.NetworkError`, `httpx2.HTTPStatusError`). +- **Tooling & Static Analysis Alignment:** Synchronized pre-commit hooks (`commitizen v4.19.2`, `semgrep v1.180.0`, `typos v1.51.1`, `check-jsonschema 0.38.2`, `ruff v0.16.10`, and `mypy v2.4.0`). Added `COM812` (`missing-trailing-comma`) to `[tool.ruff.lint.ignore]` to eliminate Ruff formatter conflicts, and configured test-suite rule relaxations under `[tool.ruff.lint.per-file-ignores]` for `tests/**`. +- **Security Policy Update:** Updated `SECURITY.md` supported versions table to mark `1.10.x` supported and deprecate `< 1.10.0`. + +### Removed + +- **`_httpx_compat` Bridge Layer:** Removed `mailgun/_httpx_compat.py` and its runtime detection flag (`HAS_HTTPX2`), eliminating dual-dependency branching and legacy import redirection. +- **Obsolete Compat Tests:** Removed `tests/unit/test_httpx_compat.py`. + +### Fixed + +- **Stream Pagination Hardening:** Added duplicate and stagnant cursor detection (`prev_url`) in both `Endpoint.stream()` and `AsyncEndpoint.stream()` to prevent infinite loops. Wrapped synchronous `requests.HTTPError` in `ApiError` for contract parity with the async client, and eliminated redundant loop termination checks. +- **Telemetry Log Item Index Guard:** Added an `items` existence check in `test_post_query_get_account_logs` across `test_integration_async.py` and `test_integration_sync.py` to prevent `IndexError` when querying accounts with empty log retention windows. +- **Test Suite Quality & Formatting:** Corrected verbose exception re-raising in `test_integration_coverage.py`, added missing placeholder formatting to `sdk_logger.info` calls, and formatted all unit, integration, and fuzz test targets using `ruff format`. + +### Pull Requests Merged + +- PR #68: build(deps): Bump the minor-and-patch group with 2 updates. +- PR #70: build(deps): Bump the minor-and-patch group with 2 updates. + ## [1.9.1] - 2026-09-21 ### Security @@ -505,6 +530,7 @@ We [keep a changelog.](http://keepachangelog.com/) [1.0.1]: https://github.com/mailgun/mailgun-python/releases/tag/v1.0.1 [1.0.2]: https://github.com/mailgun/mailgun-python/releases/tag/v1.0.2 [1.1.0]: https://github.com/mailgun/mailgun-python/releases/tag/v1.1.0 +[1.10.0]: https://github.com/mailgun/mailgun-python/releases/tag/v1.10.0 [1.2.0]: https://github.com/mailgun/mailgun-python/releases/tag/v1.2.0 [1.3.0]: https://github.com/mailgun/mailgun-python/releases/tag/v1.3.0 [1.4.0]: https://github.com/mailgun/mailgun-python/releases/tag/v1.4.0 @@ -515,4 +541,4 @@ We [keep a changelog.](http://keepachangelog.com/) [1.8.0]: https://github.com/mailgun/mailgun-python/releases/tag/v1.8.0 [1.9.0]: https://github.com/mailgun/mailgun-python/releases/tag/v1.9.0 [1.9.1]: https://github.com/mailgun/mailgun-python/releases/tag/v1.9.1 -[unreleased]: https://github.com/mailgun/mailgun-python/compare/v1.9.1...HEAD +[unreleased]: https://github.com/mailgun/mailgun-python/compare/v1.10.0...HEAD diff --git a/README.md b/README.md index 0d855391..c9dd7bf4 100644 --- a/README.md +++ b/README.md @@ -145,8 +145,7 @@ To build the `mailgun` package from the sources you need `setuptools` (as a buil ### Runtime dependencies -At runtime the package requires `requests >=2.33.0`. For async support, it uses `httpx2 >=2.7.0`. -Async client automatically detects and uses `httpx2` if available, falling back seamlessly to legacy `httpx`. +At runtime the package requires `requests >=2.33.0`. For async support, it requires `httpx2 >=2.7.0`. ### Test dependencies @@ -675,7 +674,7 @@ data = { "to": ["recipient1@example.com", "recipient2@example.com"], "subject": "Advanced Mailgun Features", "text": "Testing out tags, custom variables, and testmode!", - "o:tag": ["newsletter", "python-sdk"], # Multiple tags + "o:tag": ["async-integration-test", "httpx2-sdk"], # Multiple tags "o:testmode": "yes", # Validates payload without actually sending "o:deliverytime-optimize-period": "24h", # Send Time Optimization "v:my-custom-id": "USER-12345", # Custom user-defined variable From 1a9b16982334855adcb5905c55881ccc8ce682b9 Mon Sep 17 00:00:00 2001 From: skupr <291109589+skupriienko-mailgun@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:47:42 +0300 Subject: [PATCH 11/15] Potential fix for pull request finding 'Empty except' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- tests/fuzz/replay_corpus.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/fuzz/replay_corpus.py b/tests/fuzz/replay_corpus.py index a309466c..150c1e1e 100644 --- a/tests/fuzz/replay_corpus.py +++ b/tests/fuzz/replay_corpus.py @@ -40,8 +40,8 @@ def main() -> None: data = filepath.read_bytes() try: TestOneInput(data) - except Exception: - pass + except Exception as exc: + print(f"Error while replaying {filepath}: {exc}") print("✅ Replay complete. Coverage data ready.") From 276141818c0f6555902767425a53184e15844e4e Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <291109589+skupriienko-mailgun@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:22:38 +0300 Subject: [PATCH 12/15] fix(endpoints): prevent retries on non-transient client and protocol errors - Short-circuit retries on unrecoverable requests URL and schema validation exceptions. - Route httpx2 UnsupportedProtocol and DecodingError directly to API error handler. --- mailgun/endpoints.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/mailgun/endpoints.py b/mailgun/endpoints.py index db9aa19b..24e2aa9b 100644 --- a/mailgun/endpoints.py +++ b/mailgun/endpoints.py @@ -564,6 +564,18 @@ def api_call( # noqa: PLR0914, PLR0915 break except requests.RequestException as e: + # Client-side configuration and validation errors are unrecoverable; do not retry + if isinstance( + e, + ( + requests.exceptions.InvalidURL, + requests.exceptions.InvalidHeader, + requests.exceptions.InvalidSchema, + requests.exceptions.MissingSchema, + ), + ): + self._handle_api_error(e, safe_method, target_url) + if attempt < max_attempts - 1: delay = policy.calculate_delay(attempt) @@ -1002,6 +1014,10 @@ async def api_call( # noqa: PLR0912, PLR0914, PLR0915 break except httpx2.RequestError as e: + # Protocol and decoding violations are non-transient; do not retry + if isinstance(e, (httpx2.UnsupportedProtocol, httpx2.DecodingError)): + self._handle_api_error(e, safe_method, target_url) + if attempt < max_attempts - 1: delay = policy.calculate_delay(attempt) From 205135b7aa11c96784c9f3d4d54952d0bebe59aa Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <291109589+skupriienko-mailgun@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:22:52 +0300 Subject: [PATCH 13/15] test(fuzz): disable sleep latency and enforce no-retry policy in audit harness - Mock time.sleep to eliminate execution delays during fuzzing runs. - Configure Client with zero-retry policy and safely catch expected URL validation errors. --- tests/fuzz/fuzz_audit_events.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/fuzz/fuzz_audit_events.py b/tests/fuzz/fuzz_audit_events.py index 082ac1f9..3843cb61 100644 --- a/tests/fuzz/fuzz_audit_events.py +++ b/tests/fuzz/fuzz_audit_events.py @@ -3,6 +3,7 @@ import logging import sys +import time from typing import Any import atheris @@ -11,10 +12,17 @@ with atheris.instrument_imports(): from mailgun.client import Client + from mailgun.config import RetryPolicy from mailgun.handlers.error_handler import ApiError + from mailgun.security import SecurityGuard logging.disable(logging.CRITICAL) +# Eliminate all sleep latency during fuzzing +time.sleep = lambda *args, **kwargs: None + +_NO_RETRY_CONFIG = RetryPolicy(max_retries=0, base_delay=0.0, max_delay=0.0) + _AUDIT_LOG: list[tuple[str, tuple[Any, ...]]] = [] @@ -58,7 +66,7 @@ def TestOneInput(data: bytes) -> None: fuzzed_domain = fdp.ConsumeUnicodeNoSurrogates(64) fuzzed_method = fdp.PickValueInList(["get", "post", "delete", "put"]) - client = Client(auth=("api", "test-key")) + client = Client(auth=("api", "test-key"), retry_policy=_NO_RETRY_CONFIG) try: # Route through actual SDK endpoint execution @@ -84,7 +92,10 @@ def TestOneInput(data: bytes) -> None: hostile_url = fdp.PickValueInList( ["ftp://api.mailgun.net", "gopher://127.0.0.1", "http://attacker.com/v3"], ) - client.messages.api_call(method="get", url=hostile_url) + try: + SecurityGuard.validate_mailgun_url(hostile_url) + except ValueError: + pass # Verify invariant: if audit hook fired, arguments must be safe for event, args in _AUDIT_LOG: From e05addcd0d110fabe5240ba2108b59848f99dbf6 Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <291109589+skupriienko-mailgun@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:24:46 +0300 Subject: [PATCH 14/15] docs(changelog): document unrecoverable error handling and fuzz test latency fixes - Add Unreleased section entries covering retry suppression for requests and httpx2 errors. - Document test execution optimizations in fuzz_audit_events. --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55cee872..76c311ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ We [keep a changelog.](http://keepachangelog.com/) - **Core HTTP Import Refactoring:** Updated `mailgun.client`, `mailgun.endpoints`, `mailgun.security`, and `mailgun.types` to interface directly with `httpx2` for client instances (`httpx2.AsyncClient`), requests (`httpx2.Request`), responses (`httpx2.Response`), timeouts (`httpx2.Timeout`), and network exceptions (`httpx2.RequestError`, `httpx2.TimeoutException`, `httpx2.ConnectError`, `httpx2.NetworkError`, `httpx2.HTTPStatusError`). - **Tooling & Static Analysis Alignment:** Synchronized pre-commit hooks (`commitizen v4.19.2`, `semgrep v1.180.0`, `typos v1.51.1`, `check-jsonschema 0.38.2`, `ruff v0.16.10`, and `mypy v2.4.0`). Added `COM812` (`missing-trailing-comma`) to `[tool.ruff.lint.ignore]` to eliminate Ruff formatter conflicts, and configured test-suite rule relaxations under `[tool.ruff.lint.per-file-ignores]` for `tests/**`. - **Security Policy Update:** Updated `SECURITY.md` supported versions table to mark `1.10.x` supported and deprecate `< 1.10.0`. +- **Fuzzing Harness Latency & Retry Optimization:** Mocked `time.sleep` in `tests/fuzz/fuzz_audit_events.py` to eliminate artificial delays during fuzz test runs, passed a zero-retry policy configuration (`_NO_RETRY_CONFIG`) to the client, and safely handled expected `ValueError` exceptions when testing hostile URLs via `SecurityGuard.validate_mailgun_url`. ### Removed @@ -21,6 +22,7 @@ We [keep a changelog.](http://keepachangelog.com/) ### Fixed - **Stream Pagination Hardening:** Added duplicate and stagnant cursor detection (`prev_url`) in both `Endpoint.stream()` and `AsyncEndpoint.stream()` to prevent infinite loops. Wrapped synchronous `requests.HTTPError` in `ApiError` for contract parity with the async client, and eliminated redundant loop termination checks. +- **Retry Short-Circuiting on Unrecoverable Client Errors:** Suppressed retry attempts in `Endpoint` and `AsyncEndpoint` for permanent client-side exceptions. Configuration errors (`InvalidURL`, `InvalidHeader`, `InvalidSchema`, `MissingSchema`) and protocol/decoding errors (`UnsupportedProtocol`, `DecodingError`) are now dispatched directly to `_handle_api_error` without backoff delays. - **Telemetry Log Item Index Guard:** Added an `items` existence check in `test_post_query_get_account_logs` across `test_integration_async.py` and `test_integration_sync.py` to prevent `IndexError` when querying accounts with empty log retention windows. - **Test Suite Quality & Formatting:** Corrected verbose exception re-raising in `test_integration_coverage.py`, added missing placeholder formatting to `sdk_logger.info` calls, and formatted all unit, integration, and fuzz test targets using `ruff format`. From e93bc4ceb6bb69d6c07a8a31bbf24590fd8fa93e Mon Sep 17 00:00:00 2001 From: skupr <291109589+skupriienko-mailgun@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:55:01 +0300 Subject: [PATCH 15/15] Potential fix for pull request finding 'Empty except' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- tests/fuzz/fuzz_audit_events.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/fuzz/fuzz_audit_events.py b/tests/fuzz/fuzz_audit_events.py index 3843cb61..cf8da970 100644 --- a/tests/fuzz/fuzz_audit_events.py +++ b/tests/fuzz/fuzz_audit_events.py @@ -95,6 +95,7 @@ def TestOneInput(data: bytes) -> None: try: SecurityGuard.validate_mailgun_url(hostile_url) except ValueError: + # Expected for hostile fuzzed URLs; rejection is a valid outcome. pass # Verify invariant: if audit hook fired, arguments must be safe