Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions doc/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,25 @@ RequestHandler
:inherited-members:


RequestMatcherKwargs
~~~~~~~~~~~~~~~~~~~~

.. autoclass:: RequestMatcherKwargs
:members:

RequestMatcher
~~~~~~~~~~~~~~

.. autoclass:: RequestMatcher
:members:


BakedHTTPServer
~~~~~~~~~~~~~~~~

.. autoclass:: BakedHTTPServer
:members:

BlockingHTTPServer
~~~~~~~~~~~~~~~~~~

Expand Down
26 changes: 26 additions & 0 deletions doc/howto.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 4 additions & 0 deletions pytest_httpserver/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
__all__ = [
"METHOD_ALL",
"URI_DEFAULT",
"BakedHTTPServer",
"BlockingHTTPServer",
"BlockingRequestHandler",
"Error",
Expand All @@ -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
Expand 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
105 changes: 105 additions & 0 deletions pytest_httpserver/bake.py
Original file line number Diff line number Diff line change
@@ -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))
42 changes: 42 additions & 0 deletions pytest_httpserver/httpserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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"

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Original file line number Diff line number Diff line change
@@ -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 <https://github.com/csernazs/pytest-httpserver/pull/470>`_
Contributed by `@HayaoSuzuki <https://github.com/HayaoSuzuki>`_
25 changes: 25 additions & 0 deletions tests/examples/test_howto_bake.py
Original file line number Diff line number Diff line change
@@ -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"}
Loading