From 3bafe4ddc5e16398df173bdac7202d1b7545fdcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Cepl?= Date: Wed, 8 Jul 2026 16:05:05 +0200 Subject: [PATCH] Stabilize the test suite This PR stabilizes `pook`'s patching mechanisms for modern runtimes (Python 3.13+) and newer third-party package APIs: - **Robust `mock.patch` helper (`resolve_path`):** Replaced calls to the undocumented/internal `patcher.get_original()` method with a robust dynamic resolver (`resolve_path`). This safely resolves original, unpatched target objects before `unittest.mock.patch` wraps them. - **Graceful Optional Dependencies:** All resolving and patching are safely wrapped inside `try...except Exception: pass` blocks, preserving `pook`'s clean fallback behavior in environments where optional clients (like `httpx` or `urllib3`) are not installed. - **Fixed `aiohttp` compatibility:** Added dynamic signature inspection to `session._response_class.__init__` to gracefully supply a mock `stream_writer` argument. This directly resolves `TypeError: ClientResponse.__init__() missing 1 required keyword-only argument: 'stream_writer'` failures on newer `aiohttp >= 3.9` releases. - **Refactored `headers.py` assertions:** Converted fragile `assert` statements into explicit `if not ...: raise AssertionError(...)` blocks, ensuring validation logic is consistently executed and not stripped out under Python's optimized mode (`-O`). Recent changes to internal Python mocking details and third-party package APIs (like `aiohttp`) broke `pook`'s interceptors. This patch resolves these issues cleanly and ensures full compatibility without introducing hard dependency requirements on optional HTTP clients. --- src/pook/interceptors/_httpx.py | 10 +++++---- src/pook/interceptors/aiohttp.py | 13 ++++++++++-- src/pook/interceptors/base.py | 24 ++++++++++++++++++++++ src/pook/interceptors/http.py | 35 ++++++++++++++++---------------- src/pook/interceptors/urllib3.py | 25 +++++++++++------------ src/pook/matchers/headers.py | 11 ++++------ 6 files changed, 74 insertions(+), 44 deletions(-) diff --git a/src/pook/interceptors/_httpx.py b/src/pook/interceptors/_httpx.py index 07566ae..6ee0e03 100644 --- a/src/pook/interceptors/_httpx.py +++ b/src/pook/interceptors/_httpx.py @@ -35,12 +35,14 @@ def _patch(self, path): else: transport_cls = SyncTransport - def handler(client, *_): - return transport_cls(self, client, _original_transport_for_url) - try: + # Resolve the original function before patching + original_func = self.resolve_path(path) + + def handler(client, *_): + return transport_cls(self, client, original_func) + patcher = mock.patch(path, handler) - _original_transport_for_url = patcher.get_original()[0] # type: ignore[var-annotated] patcher.start() except Exception: pass diff --git a/src/pook/interceptors/aiohttp.py b/src/pook/interceptors/aiohttp.py index be5a9ac..f3f1b5b 100644 --- a/src/pook/interceptors/aiohttp.py +++ b/src/pook/interceptors/aiohttp.py @@ -84,10 +84,14 @@ def _request(session, *args, **kwargs): return super_request(session, *args, **kwargs) try: + # Resolve the original function before patching + path = "aiohttp.client.ClientSession._request" + original_request = self.resolve_path(path) + # Patch ClientSession init to append this interceptor as an aiohttp # middleware to all session's middlewares - patcher = mock.patch("aiohttp.client.ClientSession._request", _request) - super_request = patcher.get_original()[0] + patcher = mock.patch(path, _request) + super_request = original_request # Start patching function calls patcher.start() except Exception: @@ -116,6 +120,11 @@ async def read(self, n=-1): def HTTPResponse(session: aiohttp.ClientSession, *args, **kw): + import inspect + sig = inspect.signature(session._response_class.__init__) + if "stream_writer" in sig.parameters: + kw.setdefault("stream_writer", mock.Mock()) + return session._response_class( *args, request_info=mock.Mock(), diff --git a/src/pook/interceptors/base.py b/src/pook/interceptors/base.py index 048cf73..2874858 100644 --- a/src/pook/interceptors/base.py +++ b/src/pook/interceptors/base.py @@ -1,4 +1,5 @@ from abc import ABCMeta, abstractmethod +import importlib class BaseInterceptor: @@ -9,6 +10,29 @@ class BaseInterceptor: __metaclass__ = ABCMeta + def resolve_path(self, path: str): + """ + Resolves a dot-separated path to the actual object. + Example: 'httpx.Client._transport_for_url' -> the function object. + """ + parts = path.split('.') + for i in range(len(parts) - 1, 0, -1): + module_path = '.'.join(parts[:i]) + try: + module = importlib.import_module(module_path) + obj = module + for part in parts[i:]: + obj = getattr(obj, part) + return obj + except (ImportError, AttributeError): + continue + + # Fallback for single-part paths + try: + return importlib.import_module(path) + except ImportError: + raise ImportError(f"Could not resolve path: {path}") + def __init__(self, engine): self.patchers = [] self.engine = engine diff --git a/src/pook/interceptors/http.py b/src/pook/interceptors/http.py index bf6553f..6750600 100644 --- a/src/pook/interceptors/http.py +++ b/src/pook/interceptors/http.py @@ -98,28 +98,27 @@ def read(): return mockres def _patch(self, path): - def handler(conn, method, url, body=None, headers=None, **kw): - # Detect if httplib was called by urllib3 interceptor - # This is a bit ugly, I know. Ideas are welcome! - if headers and URLLIB3_BYPASS in headers: - # Remove bypass header used as flag - headers.pop(URLLIB3_BYPASS) - # Call original patched function - return request(conn, method, url, body=body, headers=headers, **kw) - - # Otherwise call the request interceptor - return self._on_request( - request, conn, method, url, body=body, headers=headers, **kw - ) - try: + # Resolve the original function before patching + original_func = self.resolve_path(path) + + def handler(conn, method, url, body=None, headers=None, **kw): + # Detect if httplib was called by urllib3 interceptor + # This is a bit ugly, I know. Ideas are welcome! + if headers and URLLIB3_BYPASS in headers: + # Remove bypass header used as flag + headers.pop(URLLIB3_BYPASS) + # Call original patched function + return original_func(conn, method, url, body=body, headers=headers, **kw) + + # Otherwise call the request interceptor + return self._on_request( + original_func, conn, method, url, body=body, headers=headers, **kw + ) + # Create a new patcher for Urllib3 urlopen function # used as entry point for all the HTTP communications patcher = mock.patch(path, handler) - # Retrieve original patched function that we might need for real - # networking - request = patcher.get_original()[0] - # Start patching function calls patcher.start() except Exception: # Exceptions may accur due to missing package diff --git a/src/pook/interceptors/urllib3.py b/src/pook/interceptors/urllib3.py index 320bea2..e1da7df 100644 --- a/src/pook/interceptors/urllib3.py +++ b/src/pook/interceptors/urllib3.py @@ -168,24 +168,23 @@ def _on_request( ) def _patch(self, path): - def handler(conn, method, url, body=None, headers=None, **kw): - # Flag that the current request as urllib3 intercepted - headers = headers or {} - headers[URLLIB3_BYPASS] = "1" + try: + # Resolve the original function before patching + original_func = self.resolve_path(path) - # Call request interceptor - return self._on_request( - urlopen, path, conn, method, url, body=body, headers=headers, **kw - ) + def handler(conn, method, url, body=None, headers=None, **kw): + # Flag that the current request as urllib3 intercepted + headers = headers or {} + headers[URLLIB3_BYPASS] = "1" + + # Call request interceptor + return self._on_request( + original_func, path, conn, method, url, body=body, headers=headers, **kw + ) - try: # Create a new patcher for Urllib3 urlopen function # used as entry point for all the HTTP communications patcher = mock.patch(path, handler) - # Retrieve original patched function that we might need for real - # networking - urlopen = patcher.get_original()[0] - # Start patching function calls patcher.start() except Exception: # Exceptions may accur due to missing package diff --git a/src/pook/matchers/headers.py b/src/pook/matchers/headers.py index 072bbd6..f1f0516 100644 --- a/src/pook/matchers/headers.py +++ b/src/pook/matchers/headers.py @@ -16,19 +16,16 @@ def __init__(self, headers): @BaseMatcher.matcher def match(self, req): for key in self.expectation: - assert key in req.headers, f"Header '{key}' not present" + if key not in req.headers: + raise AssertionError(f"Header '{key}' not present") expected_value = self.to_comparable_value(self.expectation[key]) # Retrieve header value by key actual_value = req.headers.get(key) - assert not all( - [ - expected_value is not None, - actual_value is None, - ] - ), f"Expected a value `{expected_value}` " f"for '{key}' but found `None`" + if expected_value is not None and actual_value is None: + raise AssertionError(f"Expected a value `{expected_value}` for '{key}' but found `None`") # Compare header value if not self.compare(expected_value, actual_value, regex_expr=True):