Skip to content

Commit 78bca90

Browse files
committed
Add bake() method for pre-configured request expectations
1 parent 7716cda commit 78bca90

6 files changed

Lines changed: 279 additions & 0 deletions

File tree

doc/api.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ RequestMatcher
3131
:members:
3232

3333

34+
BakedHTTPServer
35+
~~~~~~~~~~~~~~~~
36+
37+
.. autoclass:: BakedHTTPServer
38+
:members:
39+
3440
BlockingHTTPServer
3541
~~~~~~~~~~~~~~~~~~
3642

doc/howto.rst

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -704,3 +704,29 @@ Example:
704704
will register the hooks, and hooks will be called sequentially, one by one. Each
705705
hook will receive the response what the previous hook returned, and the last
706706
hook called will return the final response which will be sent back to the client.
707+
708+
709+
Reducing repetition with bake
710+
-----------------------------
711+
712+
When multiple expectations share common parameters (such as headers or method),
713+
the ``bake()`` method creates a proxy with pre-configured defaults. Keyword
714+
arguments passed to ``bake()`` become defaults that are merged with arguments
715+
provided at call time using last-wins semantics: if the same keyword appears in
716+
both, the call-time value is used.
717+
718+
.. literalinclude :: ../tests/examples/test_howto_bake.py
719+
:language: python
720+
721+
The ``bake()`` method can be chained to layer additional defaults:
722+
723+
.. code-block:: python
724+
725+
json_post = httpserver.bake(method="POST").bake(
726+
headers={"Content-Type": "application/json"}
727+
)
728+
729+
All ``expect_request``, ``expect_oneshot_request``, and
730+
``expect_ordered_request`` methods are available on the baked object. Other
731+
attributes such as ``url_for()`` and ``check_assertions()`` are delegated to
732+
the underlying server transparently.

pytest_httpserver/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
__all__ = [
77
"METHOD_ALL",
88
"URI_DEFAULT",
9+
"BakedHTTPServer",
910
"BlockingHTTPServer",
1011
"BlockingRequestHandler",
1112
"Error",
@@ -23,6 +24,7 @@
2324
from .blocking_httpserver import BlockingRequestHandler
2425
from .httpserver import METHOD_ALL
2526
from .httpserver import URI_DEFAULT
27+
from .httpserver import BakedHTTPServer
2628
from .httpserver import Error
2729
from .httpserver import HeaderValueMatcher
2830
from .httpserver import HTTPServer

pytest_httpserver/httpserver.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1495,3 +1495,97 @@ def assert_request_made(self, matcher: RequestMatcher, *, count: int = 1) -> Non
14951495
assert_msg = "\n".join(assert_msg_lines) + "\n"
14961496

14971497
assert matching_count == count, assert_msg
1498+
1499+
def bake(self, **kwargs: Any) -> BakedHTTPServer:
1500+
"""
1501+
Create a proxy with pre-configured defaults for ``expect_request()``.
1502+
1503+
Keyword arguments passed here become defaults for ``expect_request()``
1504+
and related methods. When the same keyword is provided both at bake
1505+
time and at call time, the call-time value wins (last-wins merging).
1506+
1507+
:param kwargs: default keyword arguments for ``expect_request()`` and
1508+
related methods (e.g. ``method``, ``headers``, ``json``, etc.).
1509+
:return: a :py:class:`BakedHTTPServer` proxy object.
1510+
1511+
Example:
1512+
1513+
.. code-block:: python
1514+
1515+
json_server = httpserver.bake(headers={"content-type": "application/json"})
1516+
json_server.expect_request("/foo").respond_with_json({"result": "ok"})
1517+
"""
1518+
return BakedHTTPServer(self, **kwargs)
1519+
1520+
1521+
class BakedHTTPServer:
1522+
"""
1523+
A proxy for :py:class:`HTTPServer` with pre-configured defaults for
1524+
``expect_request()`` and related methods.
1525+
1526+
Created via :py:meth:`HTTPServer.bake`. Keyword arguments stored at bake
1527+
time are merged with arguments provided at call time using last-wins
1528+
semantics: if the same keyword appears in both, the call-time value is
1529+
used.
1530+
1531+
Any attribute not explicitly defined here is delegated to the wrapped
1532+
:py:class:`HTTPServer`, so ``url_for()``, ``check_assertions()``, etc.
1533+
work transparently.
1534+
"""
1535+
1536+
def __init__(self, server: HTTPServer, **kwargs: Any) -> None:
1537+
self._server = server
1538+
self._defaults = kwargs
1539+
1540+
def __enter__(self) -> Self:
1541+
self._server.__enter__()
1542+
return self
1543+
1544+
def __exit__(
1545+
self,
1546+
exc_type: type[BaseException] | None,
1547+
exc_value: BaseException | None,
1548+
traceback: TracebackType | None,
1549+
) -> None:
1550+
self._server.__exit__(exc_type, exc_value, traceback)
1551+
1552+
def __getattr__(self, name: str) -> Any:
1553+
return getattr(self._server, name)
1554+
1555+
def __repr__(self) -> str:
1556+
return f"<{self.__class__.__name__} defaults={self._defaults!r} server={self._server!r}>"
1557+
1558+
def _merge_kwargs(self, kwargs: dict[str, Any]) -> dict[str, Any]:
1559+
return {**self._defaults, **kwargs}
1560+
1561+
def bake(self, **kwargs: Any) -> Self:
1562+
"""
1563+
Create a new :py:class:`BakedHTTPServer` by further layering defaults.
1564+
1565+
The new proxy merges the current defaults with the new ``kwargs``.
1566+
"""
1567+
return self.__class__(self._server, **self._merge_kwargs(kwargs))
1568+
1569+
def expect_request(
1570+
self,
1571+
uri: str | URIPattern | Pattern[str],
1572+
**kwargs: Any,
1573+
) -> RequestHandler:
1574+
"""Create and register a request handler, using baked defaults."""
1575+
return self._server.expect_request(uri, **self._merge_kwargs(kwargs))
1576+
1577+
def expect_oneshot_request(
1578+
self,
1579+
uri: str | URIPattern | Pattern[str],
1580+
**kwargs: Any,
1581+
) -> RequestHandler:
1582+
"""Create and register a oneshot request handler, using baked defaults."""
1583+
return self._server.expect_oneshot_request(uri, **self._merge_kwargs(kwargs))
1584+
1585+
def expect_ordered_request(
1586+
self,
1587+
uri: str | URIPattern | Pattern[str],
1588+
**kwargs: Any,
1589+
) -> RequestHandler:
1590+
"""Create and register an ordered request handler, using baked defaults."""
1591+
return self._server.expect_ordered_request(uri, **self._merge_kwargs(kwargs))

tests/examples/test_howto_bake.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import requests
2+
3+
from pytest_httpserver import HTTPServer
4+
5+
6+
def test_bake_json_api(httpserver: HTTPServer) -> None:
7+
# bake common defaults so you don't repeat them for every expect_request
8+
json_api = httpserver.bake(method="POST", headers={"Content-Type": "application/json"})
9+
10+
json_api.expect_request("/users").respond_with_json({"id": 1, "name": "Alice"}, status=201)
11+
json_api.expect_request("/items").respond_with_json({"id": 42, "name": "Widget"}, status=201)
12+
13+
resp = requests.post(
14+
httpserver.url_for("/users"),
15+
json={"name": "Alice"},
16+
)
17+
assert resp.status_code == 201
18+
assert resp.json() == {"id": 1, "name": "Alice"}
19+
20+
resp = requests.post(
21+
httpserver.url_for("/items"),
22+
json={"name": "Widget"},
23+
)
24+
assert resp.status_code == 201
25+
assert resp.json() == {"id": 42, "name": "Widget"}

tests/test_bake.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
from collections.abc import Callable
2+
3+
import pytest
4+
import requests
5+
6+
from pytest_httpserver import BakedHTTPServer
7+
from pytest_httpserver import HTTPServer
8+
9+
10+
def test_bake_with_headers(httpserver: HTTPServer) -> None:
11+
server = httpserver.bake(headers={"Content-Type": "application/json"})
12+
server.expect_request("/foo").respond_with_json({"result": "ok"})
13+
14+
response = requests.get(
15+
httpserver.url_for("/foo"),
16+
headers={"Content-Type": "application/json"},
17+
)
18+
assert response.status_code == 200
19+
assert response.json() == {"result": "ok"}
20+
21+
22+
@pytest.mark.parametrize(
23+
("bake_chain", "expect_kwargs", "request_method"),
24+
[
25+
pytest.param(
26+
lambda s: s.bake(method="POST"),
27+
{},
28+
"POST",
29+
id="bake-default",
30+
),
31+
pytest.param(
32+
lambda s: s.bake(method="GET"),
33+
{"method": "POST"},
34+
"POST",
35+
id="call-time-override",
36+
),
37+
pytest.param(
38+
lambda s: s.bake(method="GET").bake(method="POST"),
39+
{},
40+
"POST",
41+
id="chained-override",
42+
),
43+
],
44+
)
45+
def test_bake_method_resolution(
46+
httpserver: HTTPServer,
47+
bake_chain: Callable[[HTTPServer], BakedHTTPServer],
48+
expect_kwargs: dict,
49+
request_method: str,
50+
) -> None:
51+
server = bake_chain(httpserver)
52+
server.expect_request("/endpoint", **expect_kwargs).respond_with_data("ok")
53+
54+
response = requests.request(request_method, httpserver.url_for("/endpoint"))
55+
assert response.status_code == 200
56+
assert response.text == "ok"
57+
58+
59+
def test_bake_chained(httpserver: HTTPServer) -> None:
60+
server = httpserver.bake(method="POST").bake(headers={"X-Custom": "value"})
61+
server.expect_request("/chain").respond_with_data("chained")
62+
63+
response = requests.post(
64+
httpserver.url_for("/chain"),
65+
headers={"X-Custom": "value"},
66+
)
67+
assert response.status_code == 200
68+
assert response.text == "chained"
69+
70+
71+
def test_bake_oneshot(httpserver: HTTPServer) -> None:
72+
server = httpserver.bake(method="PUT")
73+
server.expect_oneshot_request("/once").respond_with_data("once")
74+
75+
response = requests.put(httpserver.url_for("/once"))
76+
assert response.status_code == 200
77+
assert response.text == "once"
78+
79+
response = requests.put(httpserver.url_for("/once"))
80+
assert response.status_code == 500
81+
82+
83+
def test_bake_ordered(httpserver: HTTPServer) -> None:
84+
server = httpserver.bake(method="GET")
85+
server.expect_ordered_request("/first").respond_with_data("1")
86+
server.expect_ordered_request("/second").respond_with_data("2")
87+
88+
response = requests.get(httpserver.url_for("/first"))
89+
assert response.status_code == 200
90+
assert response.text == "1"
91+
92+
response = requests.get(httpserver.url_for("/second"))
93+
assert response.status_code == 200
94+
assert response.text == "2"
95+
96+
97+
def test_bake_delegates_url_for(httpserver: HTTPServer) -> None:
98+
server = httpserver.bake(method="GET")
99+
assert server.url_for("/path") == httpserver.url_for("/path")
100+
101+
102+
def test_bake_context_manager() -> None:
103+
server = HTTPServer()
104+
baked = server.bake(method="GET")
105+
with baked:
106+
assert server.is_running()
107+
baked.expect_request("/ctx").respond_with_data("ok")
108+
response = requests.get(baked.url_for("/ctx"))
109+
assert response.status_code == 200
110+
assert response.text == "ok"
111+
assert not server.is_running()
112+
113+
114+
@pytest.mark.parametrize(
115+
"bake_chain",
116+
[
117+
pytest.param(lambda s: s.bake(method="GET"), id="single"),
118+
pytest.param(lambda s: s.bake(method="GET").bake(headers={"X-Foo": "bar"}), id="chained"),
119+
],
120+
)
121+
def test_bake_returns_baked_type(
122+
httpserver: HTTPServer,
123+
bake_chain: Callable[[HTTPServer], BakedHTTPServer],
124+
) -> None:
125+
server = bake_chain(httpserver)
126+
assert isinstance(server, BakedHTTPServer)

0 commit comments

Comments
 (0)