Skip to content

Commit f5f8a3f

Browse files
Return the correct headers type for mocked urllib responses (#154)
* Return correct header type for urllib response Also add tests for headers for all interceptors * Add standard tests implementation for requests * Fix PR template typo * Add provisional history entries
1 parent e871218 commit f5f8a3f

File tree

9 files changed

+183
-54
lines changed

9 files changed

+183
-54
lines changed

.github/pull_request_template.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@
55

66
## PR Checklist
77

8-
- [ ] I've added tests any code changes
8+
- [ ] I've added tests for any code changes
99
- [ ] I've documented any new features

History.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
History
22
=======
33

4+
vX.Y.Z / 20xx-xx-xx
5+
-------------------------
6+
7+
* Return the correct type of ``headers`` object for standard library urllib by @sarayourfriend in https://github.com/h2non/pook/pull/154.
8+
* Support ``Sequence[tuple[str, str]]`` header input with aiohttp by @sarayourfriend in https://github.com/h2non/pook/pull/154.
9+
410
v2.1.1 / 2024-10-15
511
-------------------------
612

src/pook/interceptors/aiohttp.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from http.client import responses as http_reasons
33
from unittest import mock
44
from urllib.parse import urlencode, urlunparse
5+
from collections.abc import Mapping
56

67
from aiohttp.helpers import TimerNoop
78
from aiohttp.streams import EmptyStreamReader
@@ -60,7 +61,24 @@ async def _on_request(
6061
):
6162
# Create request contract based on incoming params
6263
req = Request(method)
63-
req.headers = headers or {}
64+
65+
# aiohttp's interface allows various mappings, as well as an iterable of key/value tuples
66+
# ``pook.request`` only allows a dict, so we need to map the iterable to the matchable interface
67+
if headers:
68+
if isinstance(headers, Mapping):
69+
req.headers = headers
70+
else:
71+
req_headers = {}
72+
# If it isn't a mapping, then its an Iterable[Tuple[Union[str, istr], str]]
73+
for req_header, req_header_value in headers:
74+
normalised_header = req_header.lower()
75+
if normalised_header in req_headers:
76+
req_headers[normalised_header] += f", {req_header_value}"
77+
else:
78+
req_headers[normalised_header] = req_header_value
79+
80+
req.headers = req_headers
81+
6482
req.body = data
6583

6684
# Expose extra variadic arguments

src/pook/interceptors/http.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import socket
2-
from http.client import _CS_REQ_SENT # type: ignore[attr-defined]
2+
from http.client import _CS_REQ_SENT, HTTPMessage # type: ignore[attr-defined]
33
from http.client import HTTPSConnection
44

55
from http.client import (
@@ -69,18 +69,16 @@ def _on_request(self, _request, conn, method, url, body=None, headers=None, **kw
6969
# Shortcut to mock response
7070
res = mock._response
7171

72-
# Aggregate headers as list of tuples for interface compatibility
73-
headers = []
74-
for key in res._headers:
75-
headers.append((key, res._headers[key]))
76-
7772
mockres = HTTPResponse(SocketMock(), method=method, url=url)
7873
mockres.version = (1, 1)
7974
mockres.status = res._status
8075
# urllib requires `code` to be set, rather than `status`
8176
mockres.code = res._status
8277
mockres.reason = http_reasons.get(res._status)
83-
mockres.headers = res._headers.to_dict()
78+
mockres.headers = HTTPMessage()
79+
80+
for hkey, hval in res._headers.itermerged():
81+
mockres.headers.add_header(hkey, hval)
8482

8583
def getresponse():
8684
return mockres

tests/unit/interceptors/aiohttp_test.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@
1111
class TestStandardAiohttp(StandardTests):
1212
is_async = True
1313

14-
async def amake_request(self, method, url, content=None):
14+
async def amake_request(self, method, url, content=None, headers=None):
1515
async with aiohttp.ClientSession(loop=self.loop) as session:
16-
req = await session.request(method=method, url=url, data=content)
17-
response_content = await req.read()
18-
return req.status, response_content
16+
response = await session.request(
17+
method=method, url=url, data=content, headers=headers
18+
)
19+
response_content = await response.read()
20+
return response.status, response_content, response.headers
1921

2022

2123
def _pook_url(URL):

tests/unit/interceptors/base.py

Lines changed: 97 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import asyncio
2+
from collections.abc import Sequence
23
import json
3-
from typing import Optional, Tuple, Union
4+
from typing import Mapping, Optional, Tuple
45

56
import pytest
67

@@ -12,18 +13,26 @@ class StandardTests:
1213
loop: asyncio.AbstractEventLoop
1314

1415
async def amake_request(
15-
self, method: str, url: str, content: Union[bytes, None] = None
16-
) -> Tuple[int, Optional[bytes]]:
16+
self,
17+
method: str,
18+
url: str,
19+
content: Optional[bytes] = None,
20+
headers: Optional[Sequence[tuple[str, str]]] = None,
21+
) -> Tuple[int, Optional[bytes], Mapping[str, str]]:
1722
raise NotImplementedError(
1823
"Sub-classes for async transports must implement `amake_request`"
1924
)
2025

2126
def make_request(
22-
self, method: str, url: str, content: Union[bytes, None] = None
23-
) -> Tuple[int, Optional[bytes]]:
27+
self,
28+
method: str,
29+
url: str,
30+
content: Optional[bytes] = None,
31+
headers: Optional[Sequence[tuple[str, str]]] = None,
32+
) -> Tuple[int, Optional[bytes], Mapping[str, str]]:
2433
if self.is_async:
2534
return self.loop.run_until_complete(
26-
self.amake_request(method, url, content)
35+
self.amake_request(method, url, content, headers)
2736
)
2837

2938
raise NotImplementedError("Sub-classes must implement `make_request`")
@@ -37,68 +46,127 @@ def _loop(self, request):
3746
else:
3847
yield
3948

49+
@pytest.fixture
50+
def url_404(self, httpbin):
51+
"""404 httpbin URL.
52+
53+
Useful in tests if pook is configured to reply 200, and the status is checked.
54+
If pook does not match the request (and if that was the intended behaviour)
55+
then the 404 status code makes that obvious!"""
56+
return f"{httpbin.url}/status/404"
57+
58+
@pytest.fixture
59+
def url_500(self, httpbin):
60+
return f"{httpbin.url}/status/500"
61+
4062
@pytest.mark.pook
41-
def test_activate_deactivate(self, httpbin):
42-
url = f"{httpbin.url}/status/404"
43-
pook.get(url).reply(200).body("hello from pook")
63+
def test_activate_deactivate(self, url_404):
64+
"""Deactivating pook allows requests to go through."""
65+
pook.get(url_404).reply(200).body("hello from pook")
4466

45-
status, body = self.make_request("GET", url)
67+
status, body, *_ = self.make_request("GET", url_404)
4668

4769
assert status == 200
4870
assert body == b"hello from pook"
4971

5072
pook.disable()
5173

52-
status, body = self.make_request("GET", url)
74+
status, body, *_ = self.make_request("GET", url_404)
5375

5476
assert status == 404
5577

5678
@pytest.mark.pook(allow_pending_mocks=True)
57-
def test_network_mode(self, httpbin):
58-
upstream_url = f"{httpbin.url}/status/500"
59-
mocked_url = f"{httpbin.url}/status/404"
60-
pook.get(mocked_url).reply(200).body("hello from pook")
79+
def test_network_mode(self, url_404, url_500):
80+
"""Enabling network mode allows requests to pass through even if no mock is matched."""
81+
pook.get(url_404).reply(200).body("hello from pook")
6182
pook.enable_network()
6283

6384
# Avoid matching the mocks
64-
status, body = self.make_request("POST", upstream_url)
85+
status, *_ = self.make_request("POST", url_500)
6586

6687
assert status == 500
6788

6889
@pytest.mark.pook
69-
def test_json_request(self, httpbin):
70-
url = f"{httpbin.url}/status/404"
90+
def test_json_request(self, url_404):
91+
"""JSON request bodies are correctly matched."""
7192
json_request = {"hello": "json-request"}
72-
pook.get(url).json(json_request).reply(200).body("hello from pook")
93+
pook.get(url_404).json(json_request).reply(200).body("hello from pook")
7394

74-
status, body = self.make_request("GET", url, json.dumps(json_request).encode())
95+
status, body, *_ = self.make_request(
96+
"GET", url_404, json.dumps(json_request).encode()
97+
)
7598

7699
assert status == 200
77100
assert body == b"hello from pook"
78101

79102
@pytest.mark.pook
80-
def test_json_response(self, httpbin):
81-
url = f"{httpbin.url}/status/404"
103+
def test_json_response(self, url_404):
104+
"""JSON responses are correctly mocked."""
82105
json_response = {"hello": "json-request"}
83-
pook.get(url).reply(200).json(json_response)
106+
pook.get(url_404).reply(200).json(json_response)
84107

85-
status, body = self.make_request("GET", url)
108+
status, body, *_ = self.make_request("GET", url_404)
86109

87110
assert status == 200
88111
assert body
89112
assert json.loads(body) == json_response
90113

91114
@pytest.mark.pook
92-
def test_json_request_and_response(self, httpbin):
93-
url = f"{httpbin.url}/status/404"
115+
def test_json_request_and_response(self, url_404):
116+
"""JSON requests and responses do not interfere with each other."""
94117
json_request = {"id": "123abc"}
95118
json_response = {"title": "123abc title"}
96-
pook.get(url).json(json_request).reply(200).json(json_response)
119+
pook.get(url_404).json(json_request).reply(200).json(json_response)
97120

98-
status, body = self.make_request(
99-
"GET", url, content=json.dumps(json_request).encode()
121+
status, body, *_ = self.make_request(
122+
"GET", url_404, content=json.dumps(json_request).encode()
100123
)
101124

102125
assert status == 200
103126
assert body
104127
assert json.loads(body) == json_response
128+
129+
@pytest.mark.pook
130+
def test_header_sent(self, url_404):
131+
"""Sent headers can be matched."""
132+
headers = [("x-hello", "from pook")]
133+
pook.get(url_404).header("x-hello", "from pook").reply(200).body(
134+
"hello from pook"
135+
)
136+
137+
status, body, _ = self.make_request("GET", url_404, headers=headers)
138+
139+
assert status == 200
140+
assert body == b"hello from pook"
141+
142+
@pytest.mark.pook
143+
def test_mocked_resposne_headers(self, url_404):
144+
"""Mocked response headers are appropriately returned."""
145+
pook.get(url_404).reply(200).header("x-hello", "from pook")
146+
147+
status, _, headers = self.make_request("GET", url_404)
148+
149+
assert status == 200
150+
assert headers["x-hello"] == "from pook"
151+
152+
@pytest.mark.pook
153+
def test_mutli_value_headers(self, url_404):
154+
"""Multi-value headers can be matched."""
155+
match_headers = [("x-hello", "from pook"), ("x-hello", "another time")]
156+
pook.get(url_404).header("x-hello", "from pook, another time").reply(200)
157+
158+
status, *_ = self.make_request("GET", url_404, headers=match_headers)
159+
160+
assert status == 200
161+
162+
@pytest.mark.pook
163+
def test_mutli_value_response_headers(self, url_404):
164+
"""Multi-value response headers can be mocked."""
165+
pook.get(url_404).reply(200).header("x-hello", "from pook").header(
166+
"x-hello", "another time"
167+
)
168+
169+
status, _, headers = self.make_request("GET", url_404)
170+
171+
assert status == 200
172+
assert headers["x-hello"] == "from pook, another time"

tests/unit/interceptors/httpx_test.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,22 @@
1212
class TestStandardAsyncHttpx(StandardTests):
1313
is_async = True
1414

15-
async def amake_request(self, method, url, content=None):
15+
async def amake_request(self, method, url, content=None, headers=None):
1616
async with httpx.AsyncClient() as client:
17-
response = await client.request(method=method, url=url, content=content)
17+
response = await client.request(
18+
method=method, url=url, content=content, headers=headers
19+
)
1820
content = await response.aread()
19-
return response.status_code, content
21+
return response.status_code, content, response.headers
2022

2123

2224
class TestStandardSyncHttpx(StandardTests):
23-
def make_request(self, method, url, content=None):
24-
response = httpx.request(method=method, url=url, content=content)
25+
def make_request(self, method, url, content=None, headers=None):
26+
response = httpx.request(
27+
method=method, url=url, content=content, headers=headers
28+
)
2529
content = response.read()
26-
return response.status_code, content
30+
return response.status_code, content, response.headers
2731

2832

2933
@pytest.fixture

tests/unit/interceptors/urllib3_test.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,39 @@
11
import pytest
22
import urllib3
3+
import requests
34

45
import pook
56
from tests.unit.fixtures import BINARY_FILE
67
from tests.unit.interceptors.base import StandardTests
78

89

910
class TestStandardUrllib3(StandardTests):
10-
def make_request(self, method, url, content=None):
11+
def make_request(self, method, url, content=None, headers=None):
12+
req_headers = {}
13+
if headers:
14+
for header, value in headers:
15+
if header in req_headers:
16+
req_headers[header] += f", {value}"
17+
else:
18+
req_headers[header] = value
19+
1120
http = urllib3.PoolManager()
12-
response = http.request(method, url, content)
13-
return response.status, response.read()
21+
response = http.request(method, url, content, headers=req_headers)
22+
return response.status, response.read(), response.headers
23+
24+
25+
class TestStandardRequests(StandardTests):
26+
def make_request(self, method, url, content=None, headers=None):
27+
req_headers = {}
28+
if headers:
29+
for header, value in headers:
30+
if header in req_headers:
31+
req_headers[header] += f", {value}"
32+
else:
33+
req_headers[header] = value
34+
35+
response = requests.request(method, url, data=content, headers=req_headers)
36+
return response.status_code, response.content, response.headers
1437

1538

1639
@pytest.fixture

0 commit comments

Comments
 (0)