diff --git a/History.rst b/History.rst
index d447984..efea68a 100644
--- a/History.rst
+++ b/History.rst
@@ -1,6 +1,17 @@
History
=======
+vX.Y.Z / 20xx-xx-xx
+-------------------------
+What's Changed
+^^^^^^^^^^^^^^
+
+ * Add in support for passing a function to response_json, response_body, response_xml
+
+New Contributors
+^^^^^^^^^^^^^^^^
+ * @urkle made their first contribution in https://github.com/h2non/pook/pull/165
+
v2.1.4 / 2025-07-05
-------------------------
diff --git a/README.rst b/README.rst
index c0393ab..bdb5550 100644
--- a/README.rst
+++ b/README.rst
@@ -103,6 +103,25 @@ Basic mocking:
assert resp.json() == {"error": "not found"}
assert mock.calls == 1
+Support dynamic response generation
+
+.. code:: python
+
+ import pook
+ import requests
+
+ @pook.on
+ def test_dynamic():
+ def resp_build(req, resp):
+ return {'test': 1234, 'url': req.url}
+
+ mock = pook.get('http://example.com/test', reply=200, response_json=resp_build)
+
+ resp = requests.get('http://example.com/test')
+ assert resp.status_code == 200
+ assert resp.json() == {'test': 1234, 'url': 'http://example.com/test'}
+ assert mock.calls == 1
+
Using the chainable API DSL:
.. code:: python
diff --git a/pyproject.toml b/pyproject.toml
index 120bec1..38692a8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -74,6 +74,27 @@ dependencies = [
"mocket[pook]~=3.12.2; platform_python_implementation != 'PyPy'",
]
+[dependency-groups]
+dev = [
+ "pre-commit~=4.0",
+ "mypy>=1.11.2",
+
+ "pytest~=8.3",
+ "pytest-asyncio~=0.24",
+ "pytest-pook==0.1.0b0",
+
+ "falcon~=4.0",
+
+ "requests~=2.20",
+ "urllib3~=2.2",
+ "httpx~=0.26",
+
+ "aiohttp~=3.10",
+ "async-timeout~=4.0",
+
+ "mocket[pook]~=3.12.2; platform_python_implementation != 'PyPy'",
+]
+
[tool.hatch.envs.default.scripts]
ci = [
"lint-install",
diff --git a/src/pook/interceptors/_httpx.py b/src/pook/interceptors/_httpx.py
index 07566ae..4c19776 100644
--- a/src/pook/interceptors/_httpx.py
+++ b/src/pook/interceptors/_httpx.py
@@ -78,12 +78,12 @@ def _get_pook_request(self, httpx_request: httpx.Request) -> Request:
return req
def _get_httpx_response(
- self, httpx_request: httpx.Request, mock_response: Response
+ self, httpx_request: httpx.Request, mock_response: Response, pook_request: Request
) -> httpx.Response:
res = httpx.Response(
status_code=mock_response._status,
headers=mock_response._headers,
- content=mock_response._body,
+ content=mock_response.get_body(pook_request),
extensions={
# TODO: Add HTTP2 response support
"http_version": b"HTTP/1.1",
@@ -140,4 +140,4 @@ def handle_request(self, request):
transport = self._original_transport_for_url(self._client, request.url)
return transport.handle_request(request)
- return self._get_httpx_response(request, mock._response)
+ return self._get_httpx_response(request, mock._response, pook_request)
diff --git a/src/pook/interceptors/aiohttp.py b/src/pook/interceptors/aiohttp.py
index 5646513..ca5f700 100644
--- a/src/pook/interceptors/aiohttp.py
+++ b/src/pook/interceptors/aiohttp.py
@@ -145,7 +145,7 @@ async def _on_request(
_res._headers = multidict.CIMultiDictProxy(multidict.CIMultiDict(headers))
if res._body:
- _res.content = SimpleContent(res._body)
+ _res.content = SimpleContent(res.get_body(req))
else:
# Define `_content` attribute with an empty string to
# force do not read from stream (which won't exists)
diff --git a/src/pook/interceptors/http.py b/src/pook/interceptors/http.py
index 567b971..227122a 100644
--- a/src/pook/interceptors/http.py
+++ b/src/pook/interceptors/http.py
@@ -88,9 +88,12 @@ def getresponse():
conn.__response = mockres # type: ignore[attr-defined]
conn.__state = _CS_REQ_SENT # type: ignore[attr-defined]
+
+ body = res.get_body(req)
+
# Path reader
def read():
- return res._body or b""
+ return body or b""
mockres.read = read
diff --git a/src/pook/interceptors/urllib3.py b/src/pook/interceptors/urllib3.py
index 42c1fac..545a03d 100644
--- a/src/pook/interceptors/urllib3.py
+++ b/src/pook/interceptors/urllib3.py
@@ -138,7 +138,7 @@ def _on_request(
# Shortcut to mock response and response body
res = mock._response
- body = res._body
+ body = res.get_body(req)
# Aggregate headers as list of tuples for interface compatibility
headers = []
@@ -152,7 +152,7 @@ def _on_request(
body.fp = FakeChunkedResponseBody(body_chunks) # type:ignore
else:
# Assume that the body is a bytes-like object
- body = io.BytesIO(res._body)
+ body = io.BytesIO(body)
# Return mocked HTTP response
return HTTPResponse(
diff --git a/src/pook/response.py b/src/pook/response.py
index b07b75f..55b55ec 100644
--- a/src/pook/response.py
+++ b/src/pook/response.py
@@ -1,9 +1,45 @@
import json
+from inspect import isfunction
from .constants import TYPES
from .headers import HTTPHeaderDict
from .helpers import trigger_methods
+def encode_body(body):
+ """
+ Shared code to handle encoding a response body into a byte object OR an array of byte objects.
+ """
+ if hasattr(body, "encode"):
+ body = body.encode("utf-8", "backslashreplace")
+ elif isinstance(body, list):
+ for i, chunk in enumerate(body):
+ if hasattr(chunk, "encode"):
+ body[i] = chunk.encode("utf-8", "backslashreplace")
+
+ return body
+
+def encode_json(data):
+ """
+ Shared method to encode "JSON" data into a string.
+ Note, this does not handle encoding a string into a JSON string (e.g. 'MyString'=>'"MyString"'), but does handle dict/list/numbers.
+ """
+ if not isinstance(data, str) and not isinstance(data, bytes):
+ data = json.dumps(data, indent=4)
+ return data
+
+
+class WrapJSON:
+ """
+ A Wrapper callable class that handles a function returning JSON data to be encoded.
+ This performs the same logic as the json() function would with a fixed value
+ """
+ def __init__(self, data):
+ self.data = data
+
+ def __call__(self, request, response):
+ data = self.data(request, response)
+ return encode_json(data)
+
class Response:
"""
@@ -14,9 +50,9 @@ class Response:
Arguments:
status (int): HTTP response status code. Defaults to ``200``.
headers (dict): HTTP response headers.
- body (str|bytes): HTTP response body.
- json (str|dict|list): HTTP response JSON body.
- xml (str): HTTP response XML body.
+ body (str|bytes|function): HTTP response body.
+ json (str|bytes|dict|list|function): HTTP response JSON body.
+ xml (str|function): HTTP response XML body.
type (str): HTTP response content MIME type.
file (str): file path to HTTP body response.
"""
@@ -159,38 +195,38 @@ def body(self, body, *, chunked=False):
Defines response body data.
Arguments:
- body (str|bytes|list): response body to use.
+ body (str|bytes|list|callable): response body to use.
chunked (bool): return a chunked response.
Returns:
self: ``pook.Response`` current instance.
"""
- if hasattr(body, "encode"):
- body = body.encode("utf-8", "backslashreplace")
- elif isinstance(body, list):
- for i, chunk in enumerate(body):
- if hasattr(chunk, "encode"):
- body[i] = chunk.encode("utf-8", "backslashreplace")
-
- self._body = body
+ self._body = encode_body(body)
if chunked:
self.header("Transfer-Encoding", "chunked")
return self
+ def get_body(self, request):
+ if callable(self._body):
+ return encode_body(self._body(request, self))
+ return self._body
+
def json(self, data):
"""
Defines the mock response JSON body.
Arguments:
- data (dict|list|str): JSON body data.
+ data (dict|list|str|callable): JSON body data.
Returns:
self: ``pook.Response`` current instance.
"""
self._headers["Content-Type"] = "application/json"
- if not isinstance(data, str):
- data = json.dumps(data, indent=4)
+ if callable(data):
+ data = WrapJSON(data)
+ else:
+ data = encode_json(data)
return self.body(data)
@@ -198,10 +234,10 @@ def xml(self, xml):
"""
Defines the mock response XML body.
- For not it only supports ``str`` as input type.
+ For now it only supports ``str`` as input type.
Arguments:
- xml (str): XML body data to use.
+ xml (str|callable): XML body data to use.
Returns:
self: ``pook.Response`` current instance.
diff --git a/tests/unit/interceptors/base.py b/tests/unit/interceptors/base.py
index ea49a61..2a3cdcd 100644
--- a/tests/unit/interceptors/base.py
+++ b/tests/unit/interceptors/base.py
@@ -177,6 +177,70 @@ def test_json_request_and_response(self, url_404):
assert body
assert json.loads(body) == json_response
+ @pytest.mark.pook
+ def test_dynamic_text_response_body(self, url_404):
+ """Dynamic mock response body as raw data."""
+
+ def resp_builder(req, resp):
+ return b"hello from pook"
+
+ pook.get(url_404).reply(200).body(resp_builder)
+
+ status, body, *_ = self.make_request("GET", url_404)
+ assert status == 200
+ assert body == b"hello from pook"
+
+ @pytest.mark.pook
+ def test_dynamic_json_response_body(self, url_404):
+ """Dynamic mock response body with JSON."""
+
+ def resp_builder(req, resp):
+ return {"hello": "from pook"}
+
+ pook.get(url_404).reply(200).json(resp_builder)
+
+ status, body, headers = self.make_request("GET", url_404)
+ assert status == 200
+ assert json.loads(body) == {"hello": "from pook"}
+ assert headers["Content-Type"] == "application/json"
+
+ @pytest.mark.pook
+ def test_dynamic_xml_response_body(self, url_404):
+ """Dynamic mock response body with XML."""
+
+ def resp_builder(req, resp):
+ return "hello from pook"
+
+ pook.get(url_404).reply(200).xml(resp_builder)
+
+ status, body, headers = self.make_request("GET", url_404)
+ assert status == 200
+ assert body == b"hello from pook"
+ # TODO what is the purpose of the XML() response method if it does nothing special over body()
+ # assert headers["Content-Type"] == "application/xml"
+
+ @pytest.mark.pook
+ def test_multiple_dynamic_response_body(self, url_404):
+ """Dynamic mock body with multiple requests."""
+
+ class RespBuilder:
+ def __init__(self):
+ self.counter = 0
+
+ def __call__(self, req, resp):
+ self.counter = self.counter + 1
+ return {"hello": "from pook", "value": self.counter}
+
+ pook.get(url_404).persist().reply(200).json(RespBuilder())
+
+ # Make 3 requests to test the dynamic response
+ for i in range(1, 4):
+ status, body, headers = self.make_request("GET", url_404)
+ print(status, body)
+ assert status == 200
+ assert json.loads(body) == {"hello": "from pook", "value": i}
+ assert headers["Content-Type"] == "application/json"
+
@pytest.mark.pook
def test_header_sent(self, url_404):
"""Sent headers can be matched."""
diff --git a/tests/unit/mock_test.py b/tests/unit/mock_test.py
index f195893..3a58eaa 100644
--- a/tests/unit/mock_test.py
+++ b/tests/unit/mock_test.py
@@ -10,6 +10,8 @@
from pook.exceptions import PookNoMatches
from pook.mock import Mock
from pook.request import Request
+from pook.response import WrapJSON
+
from tests.unit.fixtures import BINARY_FILE, BINARY_FILE_PATH
@@ -71,6 +73,30 @@ def test_mock_constructor(param_kwargs, query_string, url_404):
assert res.status == 200
assert json.loads(res.read()) == {"hello": "from pook"}
+def test_setting_dynamic_body(url_404):
+ def resp_builder(req, resp):
+ return b"hello from pook"
+
+ mock = Mock(
+ url=url_404,
+ reply_status=200,
+ response_body=resp_builder,
+ )
+
+ assert mock._response._body == resp_builder
+
+def test_dynamic_mock_response_json(url_404):
+ def resp_builder(req, resp):
+ return {"hello": "from pook"}
+
+ mock = Mock(
+ url=url_404,
+ reply_status=200,
+ response_json=resp_builder,
+ )
+
+ assert isinstance(mock._response._body, WrapJSON)
+ assert mock._response._body.data == resp_builder
@pytest.mark.parametrize(
"params, req_params, expected",