Skip to content

Commit ab9e93d

Browse files
authored
Define an alternative can_handle logic by passing a callable. (#306)
1 parent d193c96 commit ab9e93d

File tree

7 files changed

+156
-18
lines changed

7 files changed

+156
-18
lines changed

README.rst

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,37 @@ It's very important that we test non-happy paths.
225225
with self.assertRaises(requests.exceptions.ConnectionError):
226226
requests.get(url)
227227
228+
Example of how to mock a call with a custom `can_handle` function
229+
=================================================================
230+
.. code-block:: python
231+
232+
import json
233+
234+
from mocket import mocketize
235+
from mocket.mocks.mockhttp import Entry
236+
import requests
237+
238+
@mocketize
239+
def test_can_handle():
240+
Entry.single_register(
241+
Entry.GET,
242+
url,
243+
body=json.dumps({"message": "Nope... not this time!"}),
244+
headers={"content-type": "application/json"},
245+
can_handle_fun=lambda path, qs_dict: path == "/ip" and qs_dict,
246+
)
247+
Entry.single_register(
248+
Entry.GET,
249+
url,
250+
body=json.dumps({"message": "There you go!"}),
251+
headers={"content-type": "application/json"},
252+
can_handle_fun=lambda path, qs_dict: path == "/ip" and not qs_dict,
253+
)
254+
resp = requests.get("https://httpbin.org/ip")
255+
assert resp.status_code == 200
256+
assert resp.json() == {"message": "There you go!"}
257+
258+
228259
Example of how to record real socket traffic
229260
============================================
230261

@@ -251,10 +282,12 @@ You probably know what *VCRpy* is capable of, that's the *mocket*'s way of achie
251282
252283
HTTPretty compatibility layer
253284
=============================
254-
Mocket HTTP mock can work as *HTTPretty* replacement for many different use cases. Two main features are missing:
285+
Mocket HTTP mock can work as *HTTPretty* replacement for many different use cases. Two main features are missing, or better said, are implemented differently:
286+
287+
- URL entries containing regular expressions, *Mocket* implements `can_handle_fun` which is way simpler to use and more powerful;
288+
- response body from functions (used mostly to fake errors, *Mocket* accepts an `exception` instead).
255289

256-
- URL entries containing regular expressions;
257-
- response body from functions (used mostly to fake errors, *mocket* doesn't need to do it this way).
290+
Both features are documented above.
258291

259292
Two features which are against the Zen of Python, at least imho (*mindflayer*), but of course I am open to call it into question.
260293

mocket/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,4 @@
3131
"FakeSSLContext",
3232
)
3333

34-
__version__ = "3.13.10"
34+
__version__ = "3.13.11"

mocket/mocks/mockhttp.py

Lines changed: 52 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import time
33
from functools import cached_property
44
from http.server import BaseHTTPRequestHandler
5+
from typing import Callable, Optional
56
from urllib.parse import parse_qs, unquote, urlsplit
67

78
from h11 import SERVER, Connection, Data
@@ -82,9 +83,7 @@ def __init__(self, body="", status=200, headers=None):
8283
self.status = status
8384

8485
self.set_base_headers()
85-
86-
if headers is not None:
87-
self.set_extra_headers(headers)
86+
self.set_extra_headers(headers)
8887

8988
self.data = self.get_protocol_data() + self.body
9089

@@ -142,9 +141,19 @@ class Entry(MocketEntry):
142141
request_cls = Request
143142
response_cls = Response
144143

145-
default_config = {"match_querystring": True}
144+
default_config = {"match_querystring": True, "can_handle_fun": None}
145+
_can_handle_fun: Optional[Callable] = None
146+
147+
def __init__(
148+
self,
149+
uri,
150+
method,
151+
responses,
152+
match_querystring: bool = True,
153+
can_handle_fun: Optional[Callable] = None,
154+
):
155+
self._can_handle_fun = can_handle_fun if can_handle_fun else self._can_handle
146156

147-
def __init__(self, uri, method, responses, match_querystring: bool = True):
148157
uri = urlsplit(uri)
149158

150159
port = uri.port
@@ -177,6 +186,18 @@ def collect(self, data):
177186

178187
return consume_response
179188

189+
def _can_handle(self, path: str, qs_dict: dict) -> bool:
190+
"""
191+
The default can_handle function, which checks if the path match,
192+
and if match_querystring is True, also checks if the querystring matches.
193+
"""
194+
can_handle = path == self.path
195+
if self._match_querystring:
196+
can_handle = can_handle and qs_dict == parse_qs(
197+
self.query, keep_blank_values=True
198+
)
199+
return can_handle
200+
180201
def can_handle(self, data):
181202
r"""
182203
>>> e = Entry('http://www.github.com/?bar=foo&foobar', Entry.GET, (Response(b'<html/>'),))
@@ -192,13 +213,12 @@ def can_handle(self, data):
192213
except ValueError:
193214
return self is getattr(Mocket, "_last_entry", None)
194215

195-
uri = urlsplit(path)
196-
can_handle = uri.path == self.path and method == self.method
197-
if self._match_querystring:
198-
kw = dict(keep_blank_values=True)
199-
can_handle = can_handle and parse_qs(uri.query, **kw) == parse_qs(
200-
self.query, **kw
201-
)
216+
_request = urlsplit(path)
217+
218+
can_handle = method == self.method and self._can_handle_fun(
219+
_request.path, parse_qs(_request.query, keep_blank_values=True)
220+
)
221+
202222
if can_handle:
203223
Mocket._last_entry = self
204224
return can_handle
@@ -249,8 +269,27 @@ def single_register(
249269
headers=None,
250270
exception=None,
251271
match_querystring=True,
272+
can_handle_fun=None,
252273
**config,
253274
):
275+
"""
276+
A helper method to register a single Response for a given URI and method.
277+
Instead of passing a list of Response objects, you can just pass the response
278+
parameters directly.
279+
280+
Args:
281+
method (str): The HTTP method (e.g., 'GET', 'POST').
282+
uri (str): The URI to register the response for.
283+
body (str, optional): The body of the response. Defaults to an empty string.
284+
status (int, optional): The HTTP status code. Defaults to 200.
285+
headers (dict, optional): A dictionary of headers to include in the response. Defaults to None.
286+
exception (Exception, optional): An exception to raise instead of returning a response. Defaults to None.
287+
match_querystring (bool, optional): Whether to match the querystring in the URI. Defaults to True.
288+
can_handle_fun (Callable, optional): A custom function to determine if the Entry can handle a request.
289+
Defaults to None. If None, the default matching logic is used. The function should accept two parameters:
290+
path (str), and querystring params (dict), and return a boolean. Method is matched before the function call.
291+
**config: Additional configuration options.
292+
"""
254293
response = (
255294
exception
256295
if exception
@@ -262,5 +301,6 @@ def single_register(
262301
uri,
263302
response,
264303
match_querystring=match_querystring,
304+
can_handle_fun=can_handle_fun,
265305
**config,
266306
)

mocket/plugins/httpretty/__init__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,4 @@ def __getattr__(self, name):
139139
"HEAD",
140140
"PATCH",
141141
"register_uri",
142-
"str",
143-
"bytes",
144142
)

tests/test_http.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,3 +455,30 @@ def test_mocket_with_no_path(self):
455455
response = urlopen("http://httpbin.local/")
456456
self.assertEqual(response.code, 202)
457457
self.assertEqual(Mocket._entries[("httpbin.local", 80)][0].path, "/")
458+
459+
@mocketize
460+
def test_can_handle(self):
461+
Entry.single_register(
462+
Entry.POST,
463+
"http://testme.org/foobar",
464+
body=json.dumps({"message": "Spooky!"}),
465+
match_querystring=False,
466+
)
467+
Entry.single_register(
468+
Entry.GET,
469+
"http://testme.org/",
470+
body=json.dumps({"message": "Gotcha!"}),
471+
can_handle_fun=lambda p, q: p.endswith("/foobar") and "a" in q,
472+
)
473+
Entry.single_register(
474+
Entry.GET,
475+
"http://testme.org/foobar",
476+
body=json.dumps({"message": "Missed!"}),
477+
match_querystring=False,
478+
)
479+
response = requests.get("http://testme.org/foobar?a=1")
480+
self.assertEqual(response.status_code, 200)
481+
self.assertEqual(response.json(), {"message": "Gotcha!"})
482+
response = requests.get("http://testme.org/foobar?b=2")
483+
self.assertEqual(response.status_code, 200)
484+
self.assertEqual(response.json(), {"message": "Missed!"})

tests/test_https.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,24 @@ def test_raise_exception_from_single_register():
9191
Entry.single_register(Entry.GET, url, exception=OSError())
9292
with pytest.raises(requests.exceptions.ConnectionError):
9393
requests.get(url)
94+
95+
96+
@mocketize
97+
def test_can_handle():
98+
Entry.single_register(
99+
Entry.GET,
100+
"https://httpbin.org",
101+
body=json.dumps({"message": "Nope... not this time!"}),
102+
headers={"content-type": "application/json"},
103+
can_handle_fun=lambda path, qs_dict: path == "/ip" and qs_dict,
104+
)
105+
Entry.single_register(
106+
Entry.GET,
107+
"https://httpbin.org",
108+
body=json.dumps({"message": "There you go!"}),
109+
headers={"content-type": "application/json"},
110+
can_handle_fun=lambda path, qs_dict: path == "/ip" and not qs_dict,
111+
)
112+
resp = requests.get("https://httpbin.org/ip")
113+
assert resp.status_code == 200
114+
assert resp.json() == {"message": "There you go!"}

tests/test_httpx.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,3 +194,22 @@ async def test_httpx_fixture(httpx_client):
194194
response = await client.get(url)
195195

196196
assert response.json() == data
197+
198+
199+
@pytest.mark.asyncio
200+
async def test_httpx_fixture_with_can_handle_fun(httpx_client):
201+
url = "https://foo.bar/barfoo"
202+
data = {"message": "Gotcha!"}
203+
204+
Entry.single_register(
205+
Entry.GET,
206+
"https://foo.bar",
207+
body=json.dumps(data),
208+
headers={"content-type": "application/json"},
209+
can_handle_fun=lambda p, q: p.endswith("foo"),
210+
)
211+
212+
async with httpx_client as client:
213+
response = await client.get(url)
214+
215+
assert response.json() == data

0 commit comments

Comments
 (0)