Skip to content

Commit 310529c

Browse files
HayaoSuzukicsernazs
authored andcommitted
Add bake() method for pre-configured request expectations
- Extract to separate module and fix nested context manager - Add RequestMatcherKwargs TypedDict - Add release note of bake() method and BakedHTTPServer proxy
1 parent 7716cda commit 310529c

9 files changed

Lines changed: 388 additions & 0 deletions

File tree

doc/api.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,25 @@ RequestHandler
2424
:inherited-members:
2525

2626

27+
RequestMatcherKwargs
28+
~~~~~~~~~~~~~~~~~~~~
29+
30+
.. autoclass:: RequestMatcherKwargs
31+
:members:
32+
2733
RequestMatcher
2834
~~~~~~~~~~~~~~
2935

3036
.. autoclass:: RequestMatcher
3137
:members:
3238

3339

40+
BakedHTTPServer
41+
~~~~~~~~~~~~~~~~
42+
43+
.. autoclass:: BakedHTTPServer
44+
:members:
45+
3446
BlockingHTTPServer
3547
~~~~~~~~~~~~~~~~~~
3648

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: 4 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",
@@ -15,10 +16,12 @@
1516
"NoHandlerError",
1617
"RequestHandler",
1718
"RequestMatcher",
19+
"RequestMatcherKwargs",
1820
"URIPattern",
1921
"WaitingSettings",
2022
]
2123

24+
from .bake import BakedHTTPServer
2225
from .blocking_httpserver import BlockingHTTPServer
2326
from .blocking_httpserver import BlockingRequestHandler
2427
from .httpserver import METHOD_ALL
@@ -30,5 +33,6 @@
3033
from .httpserver import NoHandlerError
3134
from .httpserver import RequestHandler
3235
from .httpserver import RequestMatcher
36+
from .httpserver import RequestMatcherKwargs
3337
from .httpserver import URIPattern
3438
from .httpserver import WaitingSettings

pytest_httpserver/bake.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
from typing import Any
5+
6+
if TYPE_CHECKING:
7+
import sys
8+
from re import Pattern
9+
from types import TracebackType
10+
11+
if sys.version_info >= (3, 11):
12+
from typing import Self
13+
else:
14+
from typing_extensions import Self
15+
16+
if sys.version_info >= (3, 12):
17+
from typing import Unpack
18+
else:
19+
from typing_extensions import Unpack
20+
21+
from .httpserver import HTTPServer
22+
from .httpserver import RequestHandler
23+
from .httpserver import RequestMatcherKwargs
24+
from .httpserver import URIPattern
25+
26+
27+
class BakedHTTPServer:
28+
"""
29+
A proxy for :py:class:`HTTPServer` with pre-configured defaults for
30+
``expect_request()`` and related methods.
31+
32+
Created via :py:meth:`HTTPServer.bake`. Keyword arguments stored at bake
33+
time are merged with arguments provided at call time using last-wins
34+
semantics: if the same keyword appears in both, the call-time value is
35+
used.
36+
37+
Any attribute not explicitly defined here is delegated to the wrapped
38+
:py:class:`HTTPServer`, so ``url_for()``, ``check_assertions()``, etc.
39+
work transparently.
40+
"""
41+
42+
def __init__(self, server: HTTPServer, **kwargs: Unpack[RequestMatcherKwargs]) -> None:
43+
self._server = server
44+
self._defaults = kwargs
45+
self._context_depth: int = 0
46+
self._started_server: bool = False
47+
48+
def __enter__(self) -> Self:
49+
if self._context_depth == 0:
50+
self._started_server = not self._server.is_running()
51+
self._server.__enter__()
52+
self._context_depth += 1
53+
return self
54+
55+
def __exit__(
56+
self,
57+
exc_type: type[BaseException] | None,
58+
exc_value: BaseException | None,
59+
traceback: TracebackType | None,
60+
) -> None:
61+
self._context_depth -= 1
62+
if self._started_server and self._context_depth == 0:
63+
self._server.__exit__(exc_type, exc_value, traceback)
64+
self._started_server = False
65+
66+
def __getattr__(self, name: str) -> Any:
67+
return getattr(self._server, name)
68+
69+
def __repr__(self) -> str:
70+
return f"<{self.__class__.__name__} defaults={self._defaults!r} server={self._server!r}>"
71+
72+
def _merge_kwargs(self, kwargs: RequestMatcherKwargs) -> RequestMatcherKwargs:
73+
return self._defaults | kwargs
74+
75+
def bake(self, **kwargs: Unpack[RequestMatcherKwargs]) -> Self:
76+
"""
77+
Create a new :py:class:`BakedHTTPServer` by further layering defaults.
78+
79+
The new proxy merges the current defaults with the new ``kwargs``.
80+
"""
81+
return self.__class__(self._server, **self._merge_kwargs(kwargs))
82+
83+
def expect_request(
84+
self,
85+
uri: str | URIPattern | Pattern[str],
86+
**kwargs: Unpack[RequestMatcherKwargs],
87+
) -> RequestHandler:
88+
"""Create and register a request handler, using baked defaults."""
89+
return self._server.expect_request(uri, **self._merge_kwargs(kwargs))
90+
91+
def expect_oneshot_request(
92+
self,
93+
uri: str | URIPattern | Pattern[str],
94+
**kwargs: Unpack[RequestMatcherKwargs],
95+
) -> RequestHandler:
96+
"""Create and register a oneshot request handler, using baked defaults."""
97+
return self._server.expect_oneshot_request(uri, **self._merge_kwargs(kwargs))
98+
99+
def expect_ordered_request(
100+
self,
101+
uri: str | URIPattern | Pattern[str],
102+
**kwargs: Unpack[RequestMatcherKwargs],
103+
) -> RequestHandler:
104+
"""Create and register an ordered request handler, using baked defaults."""
105+
return self._server.expect_ordered_request(uri, **self._merge_kwargs(kwargs))

pytest_httpserver/httpserver.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from typing import TYPE_CHECKING
2525
from typing import Any
2626
from typing import ClassVar
27+
from typing import TypedDict
2728

2829
import werkzeug.http
2930
from werkzeug import Request
@@ -32,6 +33,8 @@
3233
from werkzeug.datastructures import MultiDict
3334
from werkzeug.serving import make_server
3435

36+
from .bake import BakedHTTPServer
37+
3538
if TYPE_CHECKING:
3639
import sys
3740
from ssl import SSLContext
@@ -44,6 +47,11 @@
4447
else:
4548
from typing_extensions import Self
4649

50+
if sys.version_info >= (3, 12):
51+
from typing import Unpack
52+
else:
53+
from typing_extensions import Unpack
54+
4755
URI_DEFAULT = ""
4856
METHOD_ALL = "__ALL"
4957

@@ -285,6 +293,18 @@ def match(self, uri: str) -> bool:
285293
"""
286294

287295

296+
class RequestMatcherKwargs(TypedDict, total=False):
297+
"""Keyword arguments common to ``expect_request()`` and related methods."""
298+
299+
method: str
300+
data: str | bytes | None
301+
data_encoding: str
302+
headers: Mapping[str, str] | None
303+
query_string: None | QueryMatcher | str | bytes | Mapping[str, str]
304+
header_value_matcher: HVMATCHER_T | None
305+
json: Any
306+
307+
288308
class RequestMatcher:
289309
"""
290310
Matcher object for the incoming request.
@@ -1495,3 +1515,25 @@ def assert_request_made(self, matcher: RequestMatcher, *, count: int = 1) -> Non
14951515
assert_msg = "\n".join(assert_msg_lines) + "\n"
14961516

14971517
assert matching_count == count, assert_msg
1518+
1519+
def bake(self, **kwargs: Unpack[RequestMatcherKwargs]) -> BakedHTTPServer:
1520+
"""
1521+
Create a proxy with pre-configured defaults for ``expect_request()``.
1522+
1523+
Keyword arguments passed here become defaults for ``expect_request()``
1524+
and related methods. When the same keyword is provided both at bake
1525+
time and at call time, the call-time value wins (last-wins merging).
1526+
1527+
Accepts the same keyword arguments as ``expect_request()`` (see
1528+
:py:class:`RequestMatcherKwargs`).
1529+
1530+
:return: a :py:class:`BakedHTTPServer` proxy object.
1531+
1532+
Example:
1533+
1534+
.. code-block:: python
1535+
1536+
json_server = httpserver.bake(headers={"content-type": "application/json"})
1537+
json_server.expect_request("/foo").respond_with_json({"result": "ok"})
1538+
"""
1539+
return BakedHTTPServer(self, **kwargs)
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
features:
3+
- |
4+
Add ``bake()`` method to ``HTTPServer`` for creating pre-configured
5+
request expectation proxies (``BakedHTTPServer``). This allows sharing
6+
common keyword arguments (e.g. ``method``, ``headers``) across multiple
7+
``expect_request()`` calls with last-wins merging semantics.
8+
`#470 <https://github.com/csernazs/pytest-httpserver/pull/470>`_
9+
Contributed by `@HayaoSuzuki <https://github.com/HayaoSuzuki>`_

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"}

0 commit comments

Comments
 (0)