diff --git a/History.rst b/History.rst index 0dabe19..edd6634 100644 --- a/History.rst +++ b/History.rst @@ -1,11 +1,14 @@ History ======= -vX.Y.Z / 20xx-xx-xx +v2.1.2 / 2024-11-21 ------------------------- * Return the correct type of ``headers`` object for standard library urllib by @sarayourfriend in https://github.com/h2non/pook/pull/154. * Support ``Sequence[tuple[str, str]]`` header input with aiohttp by @sarayourfriend in https://github.com/h2non/pook/pull/154. + * Fix network filters when multiple filters are active by @rsmeral in https://github.com/h2non/pook/pull/155. + * Fix aiohttp matching not working with session base URL or headers by @sarayourfriend in https://github.com/h2non/pook/pull/157. + * Add support for Python 3.13 by @sarayourfriend in https://github.com/h2non/pook/pull/149. v2.1.1 / 2024-10-15 ------------------------- diff --git a/src/pook/__init__.py b/src/pook/__init__.py index a871118..2c00095 100644 --- a/src/pook/__init__.py +++ b/src/pook/__init__.py @@ -44,4 +44,4 @@ __license__ = "MIT" # Current version -__version__ = "2.1.1" +__version__ = "2.1.2" diff --git a/src/pook/interceptors/aiohttp.py b/src/pook/interceptors/aiohttp.py index eff673b..3737ee3 100644 --- a/src/pook/interceptors/aiohttp.py +++ b/src/pook/interceptors/aiohttp.py @@ -1,9 +1,11 @@ import asyncio from http.client import responses as http_reasons +from typing import Callable, Optional from unittest import mock from urllib.parse import urlencode, urlunparse from collections.abc import Mapping +import aiohttp from aiohttp.helpers import TimerNoop from aiohttp.streams import EmptyStreamReader @@ -29,13 +31,8 @@ async def read(self, n=-1): return self.content -def HTTPResponse(*args, **kw): - # Dynamically load package - module = __import__(RESPONSE_PATH, fromlist=(RESPONSE_CLASS,)) - ClientResponse = getattr(module, RESPONSE_CLASS) - - # Return response instance - return ClientResponse( +def HTTPResponse(session: aiohttp.ClientSession, *args, **kw): + return session._response_class( *args, request_info=mock.Mock(), writer=None, @@ -53,22 +50,17 @@ class AIOHTTPInterceptor(BaseInterceptor): aiohttp HTTP client traffic interceptor. """ - def _url(self, url): + def _url(self, url) -> Optional[yarl.URL]: return yarl.URL(url) if yarl else None - async def _on_request( - self, _request, session, method, url, data=None, headers=None, **kw - ): - # Create request contract based on incoming params - req = Request(method) - + def set_headers(self, req, headers) -> None: # aiohttp's interface allows various mappings, as well as an iterable of key/value tuples # ``pook.request`` only allows a dict, so we need to map the iterable to the matchable interface if headers: if isinstance(headers, Mapping): req.headers = headers else: - req_headers = {} + req_headers: dict[str, str] = {} # If it isn't a mapping, then its an Iterable[Tuple[Union[str, istr], str]] for req_header, req_header_value in headers: normalised_header = req_header.lower() @@ -79,17 +71,37 @@ async def _on_request( req.headers = req_headers + async def _on_request( + self, + _request: Callable, + session: aiohttp.ClientSession, + method: str, + url: str, + data=None, + headers=None, + **kw, + ) -> aiohttp.ClientResponse: + # Create request contract based on incoming params + req = Request(method) + + self.set_headers(req, headers) + self.set_headers(req, session.headers) + req.body = data # Expose extra variadic arguments req.extra = kw + full_url = session._build_url(url) + # Compose URL if not kw.get("params"): - req.url = str(url) + req.url = str(full_url) else: req.url = ( - str(url) + "?" + urlencode([(x, y) for x, y in kw["params"].items()]) + str(full_url) + + "?" + + urlencode([(x, y) for x, y in kw["params"].items()]) ) # If a json payload is provided, serialize it for JSONMatcher support @@ -122,13 +134,12 @@ async def _on_request( headers.append((key, res._headers[key])) # Create mock equivalent HTTP response - _res = HTTPResponse(req.method, self._url(urlunparse(req.url))) + _res = HTTPResponse(session, req.method, self._url(urlunparse(req.url))) # response status - _res.version = (1, 1) + _res.version = aiohttp.HttpVersion(1, 1) _res.status = res._status _res.reason = http_reasons.get(res._status) - _res._should_close = False # Add response headers _res._raw_headers = tuple(headers) @@ -144,7 +155,7 @@ async def _on_request( # Return response based on mock definition return _res - def _patch(self, path): + def _patch(self, path: str) -> None: # If not able to import aiohttp dependencies, skip if not yarl or not multidict: return None @@ -170,16 +181,18 @@ async def handler(session, method, url, data=None, headers=None, **kw): else: self.patchers.append(patcher) - def activate(self): + def activate(self) -> None: """ Activates the traffic interceptor. This method must be implemented by any interceptor. """ - [self._patch(path) for path in PATCHES] + for path in PATCHES: + self._patch(path) - def disable(self): + def disable(self) -> None: """ Disables the traffic interceptor. This method must be implemented by any interceptor. """ - [patch.stop() for patch in self.patchers] + for patch in self.patchers: + patch.stop() diff --git a/tests/integration/examples_test.py b/tests/integration/examples_test.py index 0ae8e66..f21ef4e 100644 --- a/tests/integration/examples_test.py +++ b/tests/integration/examples_test.py @@ -1,6 +1,7 @@ import platform import subprocess from pathlib import Path +import sys import pytest @@ -16,6 +17,6 @@ @pytest.mark.parametrize("example", examples) def test_examples(example): - result = subprocess.run(["python", f"examples/{example}"], check=False) + result = subprocess.run([sys.executable, f"examples/{example}"], check=False) assert result.returncode == 0, result.stdout diff --git a/tests/unit/interceptors/aiohttp_test.py b/tests/unit/interceptors/aiohttp_test.py index 855f37a..78195b1 100644 --- a/tests/unit/interceptors/aiohttp_test.py +++ b/tests/unit/interceptors/aiohttp_test.py @@ -64,3 +64,25 @@ async def test_json_matcher_json_payload(URL): async with aiohttp.ClientSession() as session: req = await session.post(URL, json=payload) assert await req.read() == BINARY_FILE + + +@pytest.mark.asyncio +async def test_client_base_url(httpbin): + """Client base url should be matched.""" + pook.get(httpbin + "/status/404").reply(200).body("hello from pook") + async with aiohttp.ClientSession(base_url=httpbin.url) as session: + res = await session.get("/status/404") + assert res.status == 200 + assert await res.read() == b"hello from pook" + + +@pytest.mark.asyncio +async def test_client_headers(httpbin): + """Headers set on the client should be matched.""" + pook.get(httpbin + "/status/404").header("x-pook", "hello").reply(200).body( + "hello from pook" + ) + async with aiohttp.ClientSession(headers={"x-pook": "hello"}) as session: + res = await session.get(httpbin + "/status/404") + assert res.status == 200 + assert await res.read() == b"hello from pook"