From 6f102e3588e5911ba5989387863db12d33b8f2e5 Mon Sep 17 00:00:00 2001 From: Hayao Date: Tue, 10 Feb 2026 23:41:20 +0900 Subject: [PATCH] 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 --- doc/api.rst | 12 ++ doc/howto.rst | 26 +++ pytest_httpserver/__init__.py | 4 + pytest_httpserver/bake.py | 105 ++++++++++++ pytest_httpserver/httpserver.py | 42 +++++ ...ackable-expectations-9a36a602a6264d16.yaml | 9 + tests/examples/test_howto_bake.py | 25 +++ tests/test_bake.py | 162 ++++++++++++++++++ tests/test_release.py | 3 + 9 files changed, 388 insertions(+) create mode 100644 pytest_httpserver/bake.py create mode 100644 releasenotes/notes/stackable-expectations-9a36a602a6264d16.yaml create mode 100644 tests/examples/test_howto_bake.py create mode 100644 tests/test_bake.py diff --git a/doc/api.rst b/doc/api.rst index 110fdcf3..d3a0573e 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -24,6 +24,12 @@ RequestHandler :inherited-members: +RequestMatcherKwargs +~~~~~~~~~~~~~~~~~~~~ + + .. autoclass:: RequestMatcherKwargs + :members: + RequestMatcher ~~~~~~~~~~~~~~ @@ -31,6 +37,12 @@ RequestMatcher :members: +BakedHTTPServer +~~~~~~~~~~~~~~~~ + + .. autoclass:: BakedHTTPServer + :members: + BlockingHTTPServer ~~~~~~~~~~~~~~~~~~ diff --git a/doc/howto.rst b/doc/howto.rst index 952955dc..d3226717 100644 --- a/doc/howto.rst +++ b/doc/howto.rst @@ -704,3 +704,29 @@ Example: will register the hooks, and hooks will be called sequentially, one by one. Each hook will receive the response what the previous hook returned, and the last hook called will return the final response which will be sent back to the client. + + +Reducing repetition with bake +----------------------------- + +When multiple expectations share common parameters (such as headers or method), +the ``bake()`` method creates a proxy with pre-configured defaults. Keyword +arguments passed to ``bake()`` become defaults that are merged with arguments +provided at call time using last-wins semantics: if the same keyword appears in +both, the call-time value is used. + +.. literalinclude :: ../tests/examples/test_howto_bake.py + :language: python + +The ``bake()`` method can be chained to layer additional defaults: + +.. code-block:: python + + json_post = httpserver.bake(method="POST").bake( + headers={"Content-Type": "application/json"} + ) + +All ``expect_request``, ``expect_oneshot_request``, and +``expect_ordered_request`` methods are available on the baked object. Other +attributes such as ``url_for()`` and ``check_assertions()`` are delegated to +the underlying server transparently. diff --git a/pytest_httpserver/__init__.py b/pytest_httpserver/__init__.py index f285b891..e81e58ae 100644 --- a/pytest_httpserver/__init__.py +++ b/pytest_httpserver/__init__.py @@ -6,6 +6,7 @@ __all__ = [ "METHOD_ALL", "URI_DEFAULT", + "BakedHTTPServer", "BlockingHTTPServer", "BlockingRequestHandler", "Error", @@ -15,10 +16,12 @@ "NoHandlerError", "RequestHandler", "RequestMatcher", + "RequestMatcherKwargs", "URIPattern", "WaitingSettings", ] +from .bake import BakedHTTPServer from .blocking_httpserver import BlockingHTTPServer from .blocking_httpserver import BlockingRequestHandler from .httpserver import METHOD_ALL @@ -30,5 +33,6 @@ from .httpserver import NoHandlerError from .httpserver import RequestHandler from .httpserver import RequestMatcher +from .httpserver import RequestMatcherKwargs from .httpserver import URIPattern from .httpserver import WaitingSettings diff --git a/pytest_httpserver/bake.py b/pytest_httpserver/bake.py new file mode 100644 index 00000000..c4d9d2bb --- /dev/null +++ b/pytest_httpserver/bake.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING +from typing import Any + +if TYPE_CHECKING: + import sys + from re import Pattern + from types import TracebackType + + if sys.version_info >= (3, 11): + from typing import Self + else: + from typing_extensions import Self + + if sys.version_info >= (3, 12): + from typing import Unpack + else: + from typing_extensions import Unpack + + from .httpserver import HTTPServer + from .httpserver import RequestHandler + from .httpserver import RequestMatcherKwargs + from .httpserver import URIPattern + + +class BakedHTTPServer: + """ + A proxy for :py:class:`HTTPServer` with pre-configured defaults for + ``expect_request()`` and related methods. + + Created via :py:meth:`HTTPServer.bake`. Keyword arguments stored at bake + time are merged with arguments provided at call time using last-wins + semantics: if the same keyword appears in both, the call-time value is + used. + + Any attribute not explicitly defined here is delegated to the wrapped + :py:class:`HTTPServer`, so ``url_for()``, ``check_assertions()``, etc. + work transparently. + """ + + def __init__(self, server: HTTPServer, **kwargs: Unpack[RequestMatcherKwargs]) -> None: + self._server = server + self._defaults = kwargs + self._context_depth: int = 0 + self._started_server: bool = False + + def __enter__(self) -> Self: + if self._context_depth == 0: + self._started_server = not self._server.is_running() + self._server.__enter__() + self._context_depth += 1 + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + self._context_depth -= 1 + if self._started_server and self._context_depth == 0: + self._server.__exit__(exc_type, exc_value, traceback) + self._started_server = False + + def __getattr__(self, name: str) -> Any: + return getattr(self._server, name) + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} defaults={self._defaults!r} server={self._server!r}>" + + def _merge_kwargs(self, kwargs: RequestMatcherKwargs) -> RequestMatcherKwargs: + return self._defaults | kwargs + + def bake(self, **kwargs: Unpack[RequestMatcherKwargs]) -> Self: + """ + Create a new :py:class:`BakedHTTPServer` by further layering defaults. + + The new proxy merges the current defaults with the new ``kwargs``. + """ + return self.__class__(self._server, **self._merge_kwargs(kwargs)) + + def expect_request( + self, + uri: str | URIPattern | Pattern[str], + **kwargs: Unpack[RequestMatcherKwargs], + ) -> RequestHandler: + """Create and register a request handler, using baked defaults.""" + return self._server.expect_request(uri, **self._merge_kwargs(kwargs)) + + def expect_oneshot_request( + self, + uri: str | URIPattern | Pattern[str], + **kwargs: Unpack[RequestMatcherKwargs], + ) -> RequestHandler: + """Create and register a oneshot request handler, using baked defaults.""" + return self._server.expect_oneshot_request(uri, **self._merge_kwargs(kwargs)) + + def expect_ordered_request( + self, + uri: str | URIPattern | Pattern[str], + **kwargs: Unpack[RequestMatcherKwargs], + ) -> RequestHandler: + """Create and register an ordered request handler, using baked defaults.""" + return self._server.expect_ordered_request(uri, **self._merge_kwargs(kwargs)) diff --git a/pytest_httpserver/httpserver.py b/pytest_httpserver/httpserver.py index ba3f06f9..110c4281 100644 --- a/pytest_httpserver/httpserver.py +++ b/pytest_httpserver/httpserver.py @@ -24,6 +24,7 @@ from typing import TYPE_CHECKING from typing import Any from typing import ClassVar +from typing import TypedDict import werkzeug.http from werkzeug import Request @@ -32,6 +33,8 @@ from werkzeug.datastructures import MultiDict from werkzeug.serving import make_server +from .bake import BakedHTTPServer + if TYPE_CHECKING: import sys from ssl import SSLContext @@ -44,6 +47,11 @@ else: from typing_extensions import Self + if sys.version_info >= (3, 12): + from typing import Unpack + else: + from typing_extensions import Unpack + URI_DEFAULT = "" METHOD_ALL = "__ALL" @@ -285,6 +293,18 @@ def match(self, uri: str) -> bool: """ +class RequestMatcherKwargs(TypedDict, total=False): + """Keyword arguments common to ``expect_request()`` and related methods.""" + + method: str + data: str | bytes | None + data_encoding: str + headers: Mapping[str, str] | None + query_string: None | QueryMatcher | str | bytes | Mapping[str, str] + header_value_matcher: HVMATCHER_T | None + json: Any + + class RequestMatcher: """ Matcher object for the incoming request. @@ -1495,3 +1515,25 @@ def assert_request_made(self, matcher: RequestMatcher, *, count: int = 1) -> Non assert_msg = "\n".join(assert_msg_lines) + "\n" assert matching_count == count, assert_msg + + def bake(self, **kwargs: Unpack[RequestMatcherKwargs]) -> BakedHTTPServer: + """ + Create a proxy with pre-configured defaults for ``expect_request()``. + + Keyword arguments passed here become defaults for ``expect_request()`` + and related methods. When the same keyword is provided both at bake + time and at call time, the call-time value wins (last-wins merging). + + Accepts the same keyword arguments as ``expect_request()`` (see + :py:class:`RequestMatcherKwargs`). + + :return: a :py:class:`BakedHTTPServer` proxy object. + + Example: + + .. code-block:: python + + json_server = httpserver.bake(headers={"content-type": "application/json"}) + json_server.expect_request("/foo").respond_with_json({"result": "ok"}) + """ + return BakedHTTPServer(self, **kwargs) diff --git a/releasenotes/notes/stackable-expectations-9a36a602a6264d16.yaml b/releasenotes/notes/stackable-expectations-9a36a602a6264d16.yaml new file mode 100644 index 00000000..33743f7e --- /dev/null +++ b/releasenotes/notes/stackable-expectations-9a36a602a6264d16.yaml @@ -0,0 +1,9 @@ +--- +features: + - | + Add ``bake()`` method to ``HTTPServer`` for creating pre-configured + request expectation proxies (``BakedHTTPServer``). This allows sharing + common keyword arguments (e.g. ``method``, ``headers``) across multiple + ``expect_request()`` calls with last-wins merging semantics. + `#470 `_ + Contributed by `@HayaoSuzuki `_ diff --git a/tests/examples/test_howto_bake.py b/tests/examples/test_howto_bake.py new file mode 100644 index 00000000..f53c35e0 --- /dev/null +++ b/tests/examples/test_howto_bake.py @@ -0,0 +1,25 @@ +import requests + +from pytest_httpserver import HTTPServer + + +def test_bake_json_api(httpserver: HTTPServer) -> None: + # bake common defaults so you don't repeat them for every expect_request + json_api = httpserver.bake(method="POST", headers={"Content-Type": "application/json"}) + + json_api.expect_request("/users").respond_with_json({"id": 1, "name": "Alice"}, status=201) + json_api.expect_request("/items").respond_with_json({"id": 42, "name": "Widget"}, status=201) + + resp = requests.post( + httpserver.url_for("/users"), + json={"name": "Alice"}, + ) + assert resp.status_code == 201 + assert resp.json() == {"id": 1, "name": "Alice"} + + resp = requests.post( + httpserver.url_for("/items"), + json={"name": "Widget"}, + ) + assert resp.status_code == 201 + assert resp.json() == {"id": 42, "name": "Widget"} diff --git a/tests/test_bake.py b/tests/test_bake.py new file mode 100644 index 00000000..fc347008 --- /dev/null +++ b/tests/test_bake.py @@ -0,0 +1,162 @@ +from collections.abc import Callable + +import pytest +import requests + +from pytest_httpserver import BakedHTTPServer +from pytest_httpserver import HTTPServer +from pytest_httpserver import RequestMatcherKwargs + + +def test_bake_with_headers(httpserver: HTTPServer) -> None: + server = httpserver.bake(headers={"Content-Type": "application/json"}) + server.expect_request("/foo").respond_with_json({"result": "ok"}) + + response = requests.get( + httpserver.url_for("/foo"), + headers={"Content-Type": "application/json"}, + ) + assert response.status_code == 200 + assert response.json() == {"result": "ok"} + + +@pytest.mark.parametrize( + ("bake_chain", "expect_kwargs", "request_method"), + [ + pytest.param( + lambda s: s.bake(method="POST"), + {}, + "POST", + id="bake-default", + ), + pytest.param( + lambda s: s.bake(method="GET"), + {"method": "POST"}, + "POST", + id="call-time-override", + ), + pytest.param( + lambda s: s.bake(method="GET").bake(method="POST"), + {}, + "POST", + id="chained-override", + ), + ], +) +def test_bake_method_resolution( + httpserver: HTTPServer, + bake_chain: Callable[[HTTPServer], BakedHTTPServer], + expect_kwargs: RequestMatcherKwargs, + request_method: str, +) -> None: + server = bake_chain(httpserver) + server.expect_request("/endpoint", **expect_kwargs).respond_with_data("ok") + + response = requests.request(request_method, httpserver.url_for("/endpoint")) + assert response.status_code == 200 + assert response.text == "ok" + + +def test_bake_chained(httpserver: HTTPServer) -> None: + server = httpserver.bake(method="POST").bake(headers={"X-Custom": "value"}) + server.expect_request("/chain").respond_with_data("chained") + + response = requests.post( + httpserver.url_for("/chain"), + headers={"X-Custom": "value"}, + ) + assert response.status_code == 200 + assert response.text == "chained" + + +def test_bake_oneshot(httpserver: HTTPServer) -> None: + server = httpserver.bake(method="PUT") + server.expect_oneshot_request("/once").respond_with_data("once") + + response = requests.put(httpserver.url_for("/once")) + assert response.status_code == 200 + assert response.text == "once" + + response = requests.put(httpserver.url_for("/once")) + assert response.status_code == 500 + + +def test_bake_ordered(httpserver: HTTPServer) -> None: + server = httpserver.bake(method="GET") + server.expect_ordered_request("/first").respond_with_data("1") + server.expect_ordered_request("/second").respond_with_data("2") + + response = requests.get(httpserver.url_for("/first")) + assert response.status_code == 200 + assert response.text == "1" + + response = requests.get(httpserver.url_for("/second")) + assert response.status_code == 200 + assert response.text == "2" + + +def test_bake_delegates_url_for(httpserver: HTTPServer) -> None: + server = httpserver.bake(method="GET") + assert server.url_for("/path") == httpserver.url_for("/path") + + +def test_bake_context_manager() -> None: + server = HTTPServer() + baked = server.bake(method="GET") + with baked: + assert server.is_running() + baked.expect_request("/ctx").respond_with_data("ok") + response = requests.get(baked.url_for("/ctx")) + assert response.status_code == 200 + assert response.text == "ok" + assert not server.is_running() + + +def test_bake_nested_context_manager() -> None: + server = HTTPServer() + with server: + with server.bake(method="GET"): + assert server.is_running() + assert server.is_running() # inner exit must not stop the server + assert not server.is_running() + + +def test_bake_reentrant_context_manager() -> None: + server = HTTPServer() + baked = server.bake(method="GET") + with baked: + assert server.is_running() + with baked: + assert server.is_running() + assert server.is_running() # inner exit must not stop the server + assert not server.is_running() # outer exit must stop the server + + +def test_bake_from_server_returns_new_object(httpserver: HTTPServer) -> None: + assert httpserver.bake(method="GET") is not httpserver.bake(method="GET") + + +def test_bake_from_baked_returns_new_object(httpserver: HTTPServer) -> None: + baked = httpserver.bake(method="GET") + assert baked.bake(headers={"X": "1"}) is not baked + + +def test_bake_repr() -> None: + server = HTTPServer(host="localhost", port=12345) + baked = server.bake(method="GET") + assert repr(baked) == ">" + + +@pytest.mark.parametrize( + "bake_chain", + [ + pytest.param(lambda s: s.bake(method="GET"), id="single"), + pytest.param(lambda s: s.bake(method="GET").bake(headers={"X-Foo": "bar"}), id="chained"), + ], +) +def test_bake_returns_baked_type( + httpserver: HTTPServer, + bake_chain: Callable[[HTTPServer], BakedHTTPServer], +) -> None: + server = bake_chain(httpserver) + assert isinstance(server, BakedHTTPServer) diff --git a/tests/test_release.py b/tests/test_release.py index dc3e12e3..8643d11b 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -163,6 +163,7 @@ def test_wheel_no_extra_contents(build: Build, version: str): package_contents = {path.name for path in wheel_dir.joinpath(NAME_UNDERSCORE).iterdir()} assert package_contents == { "__init__.py", + "bake.py", "blocking_httpserver.py", "hooks.py", "httpserver.py", @@ -205,6 +206,7 @@ def test_sdist_contents(build: Build, version: str): }, "pytest_httpserver": { "__init__.py", + "bake.py", "blocking_httpserver.py", "hooks.py", "httpserver.py", @@ -215,6 +217,7 @@ def test_sdist_contents(build: Build, version: str): "assets", "conftest.py", "examples", + "test_bake.py", "test_blocking_httpserver.py", "test_handler_errors.py", "test_headers.py",