Skip to content

Commit

Permalink
Fix aiohttp session match (#157)
Browse files Browse the repository at this point in the history
* Add tests proving bug

* Match on aiohttp client base url and headers

* Fix small aiohttp edge case bugs and typing

* Ensure examples tests always run with the current interpreter

* Bump to 2.1.2
  • Loading branch information
sarayourfriend authored Nov 20, 2024
1 parent b1d4a10 commit 94dede1
Show file tree
Hide file tree
Showing 5 changed files with 67 additions and 28 deletions.
5 changes: 4 additions & 1 deletion History.rst
Original file line number Diff line number Diff line change
@@ -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
-------------------------
Expand Down
2 changes: 1 addition & 1 deletion src/pook/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,4 @@
__license__ = "MIT"

# Current version
__version__ = "2.1.1"
__version__ = "2.1.2"
63 changes: 38 additions & 25 deletions src/pook/interceptors/aiohttp.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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,
Expand All @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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()
3 changes: 2 additions & 1 deletion tests/integration/examples_test.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import platform
import subprocess
from pathlib import Path
import sys

import pytest

Expand All @@ -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
22 changes: 22 additions & 0 deletions tests/unit/interceptors/aiohttp_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

0 comments on commit 94dede1

Please sign in to comment.