Skip to content
Snippets Groups Projects
Commit 041c60ce authored by Carsten Schoenert's avatar Carsten Schoenert
Browse files

New upstream version 1.0.12

parent 78fa049c
No related branches found
No related tags found
No related merge requests found
Showing
with 821 additions and 388 deletions
...@@ -14,19 +14,25 @@ inputs: ...@@ -14,19 +14,25 @@ inputs:
runs: runs:
using: "composite" using: "composite"
steps: steps:
- name: Setup python
id: setup_python
uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
- name: Cache venv - name: Cache venv
id: cache-venv id: cache-venv
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: .venv path: .venv
key: venv-v2-${{ inputs.type }}-${{ runner.os }}-${{ inputs.python-version }}-${{ hashFiles('poetry.lock') }} key: venv-v4-${{ inputs.type }}-${{ runner.os }}-${{ steps.setup_python.outputs.python-version }}-${{ hashFiles('poetry.lock') }}
- name: Cache pre-commit - name: Cache pre-commit
id: cache-pre-commit id: cache-pre-commit
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: ~/.cache/pre-commit path: ~/.cache/pre-commit
key: pre-commit-v2-${{ inputs.type }}-${{ runner.os }}-${{ inputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }} key: pre-commit-v4-${{ inputs.type }}-${{ runner.os }}-${{ steps.setup_python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }}
- name: Cache setup-poetry - name: Cache setup-poetry
id: cache-setup-poetry id: cache-setup-poetry
...@@ -36,13 +42,8 @@ runs: ...@@ -36,13 +42,8 @@ runs:
~/.local/share/pypoetry ~/.local/share/pypoetry
~/.local/share/virtualenv ~/.local/share/virtualenv
~/.local/bin/poetry ~/.local/bin/poetry
key: setup-poetry-v2-${{ runner.os }}-${{ inputs.python-version }}-${{ inputs.poetry-version }} key: setup-poetry-v4-${{ runner.os }}-${{ steps.setup_python.outputs.python-version }}-${{ inputs.poetry-version }}
- name: Setup python
id: setup_python
uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
- name: Setup poetry - name: Setup poetry
uses: Gr1N/setup-poetry@v8 uses: Gr1N/setup-poetry@v8
......
...@@ -6,8 +6,16 @@ updates: ...@@ -6,8 +6,16 @@ updates:
directory: "/" directory: "/"
schedule: schedule:
interval: "monthly" interval: "monthly"
groups:
actions-deps:
patterns:
- "*"
- package-ecosystem: "pip" - package-ecosystem: "pip"
directory: "/" directory: "/"
schedule: schedule:
interval: "monthly" interval: "monthly"
groups:
deps:
patterns:
- "*"
...@@ -9,7 +9,7 @@ on: ...@@ -9,7 +9,7 @@ on:
- master - master
env: env:
POETRY_VERSION: "1.7.1" POETRY_VERSION: "1.8.3"
jobs: jobs:
cs: cs:
...@@ -39,7 +39,7 @@ jobs: ...@@ -39,7 +39,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13-dev"]
steps: steps:
......
name: dependabot validate
on:
pull_request:
paths:
- '.github/dependabot.yml'
- '.github/workflows/dependabot-validate.yml'
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: marocchino/validate-dependabot@v3
id: validate
- uses: marocchino/sticky-pull-request-comment@v2
if: always()
with:
header: validate-dependabot
message: ${{ steps.validate.outputs.markdown }}
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.2.1 rev: v0.5.2
hooks: hooks:
- id: ruff - id: ruff
args: [--fix, --exit-non-zero-on-fix] args: [--fix, --exit-non-zero-on-fix]
- repo: https://github.com/psf/black - repo: https://github.com/psf/black
rev: 23.1.0 rev: 24.4.2
hooks: hooks:
- id: black - id: black
args: [--line-length=120, --safe] args: [--line-length=120, --safe]
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0 rev: v4.6.0
hooks: hooks:
- id: check-case-conflict - id: check-case-conflict
- id: check-merge-conflict - id: check-merge-conflict
...@@ -23,14 +23,14 @@ repos: ...@@ -23,14 +23,14 @@ repos:
- id: requirements-txt-fixer - id: requirements-txt-fixer
- repo: https://github.com/pycqa/isort - repo: https://github.com/pycqa/isort
rev: 5.12.0 rev: 5.13.2
hooks: hooks:
- id: isort - id: isort
name: isort (python) name: isort (python)
args: ['--force-single-line-imports', '--profile', 'black'] args: ['--force-single-line-imports', '--profile', 'black']
- repo: https://github.com/asottile/blacken-docs - repo: https://github.com/asottile/blacken-docs
rev: 1.13.0 rev: 1.18.0
hooks: hooks:
- id: blacken-docs - id: blacken-docs
additional_dependencies: [ black ] additional_dependencies: [ black ]
...@@ -11,10 +11,9 @@ build: ...@@ -11,10 +11,9 @@ build:
jobs: jobs:
post_create_environment: post_create_environment:
- pip install poetry - pip install poetry
- poetry config virtualenvs.create false
post_install: post_install:
- poetry install --with doc - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install --with doc
sphinx: sphinx:
configuration: doc/conf.py configuration: doc/conf.py
{ {
"python.linting.enabled": true, "python.testing.pytestEnabled": true,
"python.linting.flake8Enabled": false, "python.testing.pytestPath": "${workspaceFolder}/.venv/bin/pytest",
"python.linting.pylintEnabled": false,
"python.testing.pytestArgs": [ "python.testing.pytestArgs": [
"tests" "tests"
], ],
"python.testing.pytestEnabled": true,
"python.testing.pytestPath": "${workspaceFolder}/.venv/bin/pytest",
"editor.formatOnSave": true, "editor.formatOnSave": true,
"python.formatting.provider": "none",
"python.formatting.blackPath": "${workspaceFolder}/.venv/bin/black",
"python.formatting.blackArgs": [
"--line-length",
"120",
"--safe"
],
"python.linting.mypyEnabled": true,
"python.linting.mypyPath": "${workspaceFolder}/.venv/bin/mypy",
"isort.args": [ "isort.args": [
"--force-single-line-imports", "--force-single-line-imports",
"--profile", "--profile",
...@@ -29,4 +17,19 @@ ...@@ -29,4 +17,19 @@
"[python]": { "[python]": {
"editor.defaultFormatter": "ms-python.black-formatter" "editor.defaultFormatter": "ms-python.black-formatter"
}, },
"ruff.enable": true,
"ruff.codeAction.disableRuleComment": {
"enable": true
},
"ruff.codeAction.fixViolation": {
"enable": true
},
"ruff.organizeImports": true,
"ruff.path": [
".venv/bin/ruff"
],
"black-formatter.args": [
"--line-length=120",
"--safe"
]
} }
...@@ -2,6 +2,37 @@ ...@@ -2,6 +2,37 @@
Release Notes Release Notes
============= =============
.. _Release Notes_1.0.12:
1.0.12
======
.. _Release Notes_1.0.12_Bug Fixes:
Bug Fixes
---------
- Fix pytest-httpserver's own tests related to log querying. No functional
changes in pytest-httpserver code itself. `#345 <https://github.com/csernazs/pytest-httpserver/issues/345>`_
.. _Release Notes_1.0.11:
1.0.11
======
.. _Release Notes_1.0.11_New Features:
New Features
------------
- Hooks API
- New methods added to query for matching requests in the log.
- Threading support to serve requests in parallel
.. _Release Notes_1.0.10: .. _Release Notes_1.0.10:
1.0.10 1.0.10
......
...@@ -55,6 +55,10 @@ doc: dev ...@@ -55,6 +55,10 @@ doc: dev
doc-clean: doc-clean:
rm -rf doc/_build rm -rf doc/_build
.PHONY: doc-clean
doc-preview:
xdg-open doc/_build/html/index.html
.PHONY: changes .PHONY: changes
changes: dev changes: dev
.venv/bin/reno report --output CHANGES.rst --no-show-source .venv/bin/reno report --output CHANGES.rst --no-show-source
......
...@@ -24,6 +24,13 @@ RequestHandler ...@@ -24,6 +24,13 @@ RequestHandler
:inherited-members: :inherited-members:
RequestMatcher
~~~~~~~~~~~~~~
.. autoclass:: RequestMatcher
:members:
BlockingHTTPServer BlockingHTTPServer
~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~
...@@ -93,3 +100,19 @@ by the user. ...@@ -93,3 +100,19 @@ by the user.
.. autoclass:: pytest_httpserver.httpserver.RequestHandlerList .. autoclass:: pytest_httpserver.httpserver.RequestHandlerList
:members: :members:
pytest_httpserver.hooks
-----------------------
.. automodule:: pytest_httpserver.hooks
.. autoclass:: pytest_httpserver.hooks.Chain
:members:
.. autoclass:: pytest_httpserver.hooks.Delay
:members:
.. autoclass:: pytest_httpserver.hooks.Garbage
:members:
...@@ -24,6 +24,7 @@ from typing import Dict ...@@ -24,6 +24,7 @@ from typing import Dict
sys.path.insert(0, os.path.abspath("..")) sys.path.insert(0, os.path.abspath(".."))
import doc.patch
# -- General configuration ------------------------------------------------ # -- General configuration ------------------------------------------------
...@@ -37,11 +38,12 @@ sys.path.insert(0, os.path.abspath("..")) ...@@ -37,11 +38,12 @@ sys.path.insert(0, os.path.abspath(".."))
extensions = [ extensions = [
"sphinx.ext.autodoc", "sphinx.ext.autodoc",
"sphinx.ext.intersphinx", "sphinx.ext.intersphinx",
"sphinx.ext.autosectionlabel",
] ]
intersphinx_mapping = { intersphinx_mapping = {
"python": ("https://docs.python.org/3", (None, "python-inv.txt")), "python": ("https://docs.python.org/3", (None, "python-inv.txt")),
"werkzeug": ("https://werkzeug.palletsprojects.com/en/2.1.x", None), "werkzeug": ("https://werkzeug.palletsprojects.com/en/3.0.x", None),
} }
# Add any paths that contain templates here, relative to this directory. # Add any paths that contain templates here, relative to this directory.
...@@ -66,7 +68,7 @@ author = "Zsolt Cserna" ...@@ -66,7 +68,7 @@ author = "Zsolt Cserna"
# built documents. # built documents.
# #
# The short X.Y version. # The short X.Y version.
version = "1.0.10" version = "1.0.12"
# The full version, including alpha/beta/rc tags. # The full version, including alpha/beta/rc tags.
release = version release = version
......
...@@ -512,3 +512,149 @@ Example: ...@@ -512,3 +512,149 @@ Example:
.. literalinclude :: ../tests/examples/test_example_blocking_httpserver.py .. literalinclude :: ../tests/examples/test_example_blocking_httpserver.py
:language: python :language: python
Querying the log
----------------
*pytest-httpserver* keeps a log of request-response pairs in a python list. This
log can be accessed by the ``log`` attibute of the httpserver instance, but
there are methods made specifically to query the log.
Each of the log querying methods accepts a
:py:class:`pytest_httpserver.RequestMatcher` object which uses the same matching
logic which is used by the server itself. Its parameters are the same to the
parameters specified for the server's `except_request` (and the similar) methods.
The methods for querying:
* :py:meth:`pytest_httpserver.HTTPServer.get_matching_requests_count` returns
how many requests are matching in the log as an int
* :py:meth:`pytest_httpserver.HTTPServer.assert_request_made` asserts the given
amount of requests are matching in the log. By default it checks for one (1)
request but other value can be specified. For example, 0 can be specified to
check for requests not made.
* :py:meth:`pytest_httpserver.HTTPServer.iter_matching_requests` is a generator
yielding Request-Response tuples of the matching entries in the log. This
offers greater flexibility (compared to the other methods)
Example:
.. literalinclude :: ../tests/examples/test_howto_log_querying.py
:language: python
Serving requests in parallel
----------------------------
*pytest-httpserver* serves the request in a single-threaded, blocking way. That
means that if multiple requests are made to it, those will be served one by one.
There can be cases where parallel processing is required, for those cases
*pytest-httpserver* allows running a server which start one thread per request
handler, so the requests are served in parallel way (depending on Global
Interpreter Lock this is not truly parallel, but from the I/O point of view it
is).
To set this up, you have two possibilities.
Overriding httpserver fixture
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
One is to customize how the HTTPServer object is created. This is possible by
defining the following fixture:
.. code:: python
@pytest.fixture(scope="session")
def make_httpserver() -> Iterable[HTTPServer]:
server = HTTPServer(threaded=True) # set threaded=True to enable thread support
server.start()
yield server
server.clear()
if server.is_running():
server.stop()
This will override the ``httpserver`` fixture in your tests.
Creating a different httpserver fixture
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
This way, you can create a different httpserver fixture and you can use it
besides the main one.
.. code:: python
@pytest.fixture()
def threaded() -> Iterable[HTTPServer]:
server = HTTPServer(threaded=True)
server.start()
yield server
server.clear()
if server.is_running():
server.stop()
def test_threaded(threaded: HTTPServer): ...
This will start and stop the server for each tests, which causes about 0.5
seconds waiting when the server is stopped. It won't override the ``httpserver``
fixture so you can keep the original single-threaded behavior.
.. warning::
Handler threads which are still running when the test is finished, will be
left behind and won't be join()ed between the tests. If you want to ensure
that all threads are properly cleaned up and you want to wait for them,
consider using the second option (:ref:`Creating a different httpserver fixture`)
described above.
Adding side effects
-------------------
Sometimes there's a need to add side effects to the handling of the requests.
Such side effect could be adding some amount of delay to the serving or adding
some garbage to response data.
While these can be achieved by using
:py:meth:`pytest_httpserver.RequestHandler.respond_with_handler` where you can
implement your own function to serve the request, *pytest-httpserver* provides a
hooks API where you can add side effects to request handlers such as
:py:meth:`pytest_httpserver.RequestHandler.respond_with_json` and others.
This allows to use the existing API of registering handlers.
Example:
.. literalinclude :: ../tests/examples/test_howto_hooks.py
:language: python
:py:mod:`pytest_httpserver.hooks` module provides some pre-defined hooks to
use.
You can implement your own hook as well. The requirement is to have a callable
object (a function) ``Callable[[Request, Response], Response]``. In details:
* Parameter :py:class:`werkzeug.Request` which represents the request
sent by the client.
* Parameter :py:class:`werkzeug.Response` which represents the response
made by the handler.
* Returns a :py:class:`werkzeug.Response` object which represents the
response will be returned to the client.
Example:
.. literalinclude :: ../tests/examples/test_howto_custom_hooks.py
:language: python
``with_post_hook`` can be called multiple times, in this case *pytest-httpserver*
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.
# this is required to make sphinx able to find references for classes put inside
# typing.TYPE_CHECKING block
from ssl import SSLContext
from werkzeug import Request
from werkzeug import Response
import pytest_httpserver.blocking_httpserver
import pytest_httpserver.httpserver
pytest_httpserver.httpserver.SSLContext = SSLContext
pytest_httpserver.blocking_httpserver.SSLContext = SSLContext
pytest_httpserver.blocking_httpserver.Request = Request
pytest_httpserver.blocking_httpserver.Response = Response
This diff is collapsed.
[tool.poetry] [tool.poetry]
name = "pytest_httpserver" name = "pytest_httpserver"
version = "1.0.10" version = "1.0.12"
description = "pytest-httpserver is a httpserver for pytest" description = "pytest-httpserver is a httpserver for pytest"
authors = ["Zsolt Cserna <cserna.zsolt@gmail.com>"] authors = ["Zsolt Cserna <cserna.zsolt@gmail.com>"]
license = "MIT" license = "MIT"
...@@ -38,43 +38,43 @@ pytest_httpserver = "pytest_httpserver.pytest_plugin" ...@@ -38,43 +38,43 @@ pytest_httpserver = "pytest_httpserver.pytest_plugin"
optional = true optional = true
[tool.poetry.group.develop.dependencies] [tool.poetry.group.develop.dependencies]
pre-commit = "^2.20.0" pre-commit = ">=2.20,<4.0"
requests = "^2.28.1" requests = "*"
Sphinx = "^5.1.1" Sphinx = ">=5.1.1,<8.0.0"
sphinx-rtd-theme = "^1.0.0" sphinx-rtd-theme = ">=1,<3"
reno = "^3.5.0" reno = "*"
mypy = "^0.971" types-requests = "*"
types-requests = "^2.28.9" pytest = ">=7.1.3,<9.0.0"
pytest = "^7.1.3" pytest-cov = ">=3,<6"
pytest-cov = ">=3,<5"
coverage = ">=6.4.4,<8.0.0" coverage = ">=6.4.4,<8.0.0"
types-toml = "^0.10.8" types-toml = "*"
toml = "^0.10.2" toml = "^0.10.2"
black = "^23.1.0" black = "*"
ruff = "^0.2.1" ruff = "*"
mypy = "*"
[tool.poetry.group.doc] [tool.poetry.group.doc]
optional = true optional = true
[tool.poetry.group.doc.dependencies] [tool.poetry.group.doc.dependencies]
Sphinx = "^5.1.1" Sphinx = ">=5.1.1,<8.0.0"
sphinx-rtd-theme = "^1.0.0" sphinx-rtd-theme = ">=1,<3"
[tool.poetry.group.test] [tool.poetry.group.test]
optional = true optional = true
[tool.poetry.group.test.dependencies] [tool.poetry.group.test.dependencies]
pytest = "^7.1.3" pytest = "*"
pytest-cov = ">=3,<5" pytest-cov = "*"
coverage = ">=6.4.4,<8.0.0" coverage = "*"
requests = "^2.28.1" requests = "*"
mypy = "^0.971" types-requests = "*"
types-requests = "^2.28.9" pre-commit = "*"
pre-commit = "^2.20.0" types-toml = "*"
types-toml = "^0.10.8" toml = "*"
toml = "^0.10.2" mypy = "*"
[build-system] [build-system]
requires = ["poetry-core>=1.0.0"] requires = ["poetry-core>=1.0.0"]
...@@ -87,7 +87,7 @@ markers = [ ...@@ -87,7 +87,7 @@ markers = [
] ]
[tool.mypy] [tool.mypy]
files = ["pytest_httpserver", "scripts", "tests", "doc"] files = ["pytest_httpserver", "scripts", "tests"]
implicit_reexport = false implicit_reexport = false
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
This is package provides the main API for the pytest_httpserver package. This is package provides the main API for the pytest_httpserver package.
""" """
__all__ = [ __all__ = [
"HTTPServer", "HTTPServer",
"HTTPServerError", "HTTPServerError",
...@@ -10,6 +11,7 @@ __all__ = [ ...@@ -10,6 +11,7 @@ __all__ = [
"WaitingSettings", "WaitingSettings",
"HeaderValueMatcher", "HeaderValueMatcher",
"RequestHandler", "RequestHandler",
"RequestMatcher",
"URIPattern", "URIPattern",
"URI_DEFAULT", "URI_DEFAULT",
"METHOD_ALL", "METHOD_ALL",
...@@ -27,5 +29,6 @@ from .httpserver import HTTPServer ...@@ -27,5 +29,6 @@ from .httpserver import HTTPServer
from .httpserver import HTTPServerError from .httpserver import HTTPServerError
from .httpserver import NoHandlerError from .httpserver import NoHandlerError
from .httpserver import RequestHandler from .httpserver import RequestHandler
from .httpserver import RequestMatcher
from .httpserver import URIPattern from .httpserver import URIPattern
from .httpserver import WaitingSettings from .httpserver import WaitingSettings
...@@ -18,8 +18,8 @@ from pytest_httpserver.httpserver import URIPattern ...@@ -18,8 +18,8 @@ from pytest_httpserver.httpserver import URIPattern
if TYPE_CHECKING: if TYPE_CHECKING:
from ssl import SSLContext from ssl import SSLContext
from werkzeug.wrappers import Request from werkzeug import Request
from werkzeug.wrappers import Response from werkzeug import Response
class BlockingRequestHandler(RequestHandlerBase): class BlockingRequestHandler(RequestHandlerBase):
......
"""
Hooks for pytest-httpserver
"""
import os
import time
from typing import Callable
from werkzeug import Request
from werkzeug import Response
class Chain:
"""
Combine multiple hooks into one callable object
Hooks specified will be called one by one.
Each hook will receive the response object made by the previous hook,
similar to reduce.
"""
def __init__(self, *args: Callable[[Request, Response], Response]):
"""
:param *args: callable objects specified in the same order they should
be called.
"""
self._hooks = args
def __call__(self, request: Request, response: Response) -> Response:
"""
Calls the callable object one by one. The second and further callable
objects receive the response returned by the previous one, while the
first one receives the original response object.
"""
for hook in self._hooks:
response = hook(request, response)
return response
class Delay:
"""
Delays returning the response
"""
def __init__(self, seconds: float):
"""
:param seconds: seconds to sleep before returning the response
"""
self._seconds = seconds
def _sleep(self):
"""
Sleeps for the seconds specified in the constructor
"""
time.sleep(self._seconds)
def __call__(self, _request: Request, response: Response) -> Response:
"""
Delays returning the response object for the time specified in the
constructor. Returns the original response unmodified.
"""
self._sleep()
return response
class Garbage:
def __init__(self, prefix_size: int = 0, suffix_size: int = 0):
"""
Adds random bytes to the beginning or to the end of the response data.
:param prefix_size: amount of random bytes to be added to the beginning
of the response data
:param suffix_size: amount of random bytes to be added to the end
of the response data
"""
assert prefix_size >= 0, "prefix_size should be positive integer"
assert suffix_size >= 0, "suffix_size should be positive integer"
self._prefix_size = prefix_size
self._suffix_size = suffix_size
def _get_garbage_bytes(self, size: int) -> bytes:
"""
Returns the specified amount of random bytes.
:param size: amount of bytes to return
"""
return os.urandom(size)
def __call__(self, _request: Request, response: Response) -> Response:
"""
Adds random bytes to the beginning or to the end of the response data.
New random bytes will be generated for every call.
Returns the modified response object.
"""
prefix = self._get_garbage_bytes(self._prefix_size)
suffix = self._get_garbage_bytes(self._suffix_size)
response.set_data(prefix + response.get_data() + suffix)
return response
...@@ -26,11 +26,11 @@ from typing import Tuple ...@@ -26,11 +26,11 @@ from typing import Tuple
from typing import Union from typing import Union
import werkzeug.http import werkzeug.http
from werkzeug import Request
from werkzeug import Response
from werkzeug.datastructures import Authorization from werkzeug.datastructures import Authorization
from werkzeug.datastructures import MultiDict from werkzeug.datastructures import MultiDict
from werkzeug.serving import make_server from werkzeug.serving import make_server
from werkzeug.wrappers import Request
from werkzeug.wrappers import Response
if TYPE_CHECKING: if TYPE_CHECKING:
from ssl import SSLContext from ssl import SSLContext
...@@ -495,7 +495,7 @@ class RequestHandlerBase(abc.ABC): ...@@ -495,7 +495,7 @@ class RequestHandlerBase(abc.ABC):
""" """
Prepares a response with raw data. Prepares a response with raw data.
For detailed description please see the :py:class:`werkzeug.wrappers.Response` object as the For detailed description please see the :py:class:`werkzeug.Response` object as the
parameters are analogue. parameters are analogue.
:param response_data: a string or bytes object representing the body of the response :param response_data: a string or bytes object representing the body of the response
...@@ -529,6 +529,11 @@ class RequestHandler(RequestHandlerBase): ...@@ -529,6 +529,11 @@ class RequestHandler(RequestHandlerBase):
def __init__(self, matcher: RequestMatcher): def __init__(self, matcher: RequestMatcher):
self.matcher = matcher self.matcher = matcher
self.request_handler: Callable[[Request], Response] | None = None self.request_handler: Callable[[Request], Response] | None = None
self._hooks: list[Callable[[Request, Response], Response]] = []
def with_post_hook(self, hook: Callable[[Request, Response], Response]):
self._hooks.append(hook)
return self
def respond(self, request: Request) -> Response: def respond(self, request: Request) -> Response:
""" """
...@@ -546,7 +551,11 @@ class RequestHandler(RequestHandlerBase): ...@@ -546,7 +551,11 @@ class RequestHandler(RequestHandlerBase):
"Matching request handler found but no response defined: {} {}".format(request.method, request.path) "Matching request handler found but no response defined: {} {}".format(request.method, request.path)
) )
else: else:
return self.request_handler(request) response = self.request_handler(request)
for hook in self._hooks:
response = hook(request, response)
return response
def respond_with_handler(self, func: Callable[[Request], Response]): def respond_with_handler(self, func: Callable[[Request], Response]):
""" """
...@@ -598,11 +607,12 @@ class HTTPServerBase(abc.ABC): # pylint: disable=too-many-instance-attributes ...@@ -598,11 +607,12 @@ class HTTPServerBase(abc.ABC): # pylint: disable=too-many-instance-attributes
:param host: the host or IP where the server will listen :param host: the host or IP where the server will listen
:param port: the TCP port where the server will listen :param port: the TCP port where the server will listen
:param ssl_context: the ssl context object to use for https connections :param ssl_context: the ssl context object to use for https connections
:param threaded: whether to handle concurrent requests in separate threads
.. py:attribute:: log .. py:attribute:: log
Attribute containing the list of two-element tuples. Each tuple contains Attribute containing the list of two-element tuples. Each tuple contains
:py:class:`werkzeug.wrappers.Request` and :py:class:`werkzeug.wrappers.Response` object which represents the :py:class:`werkzeug.Request` and :py:class:`werkzeug.Response` object which represents the
incoming request and the outgoing response which happened during the lifetime incoming request and the outgoing response which happened during the lifetime
of the server. of the server.
...@@ -619,6 +629,8 @@ class HTTPServerBase(abc.ABC): # pylint: disable=too-many-instance-attributes ...@@ -619,6 +629,8 @@ class HTTPServerBase(abc.ABC): # pylint: disable=too-many-instance-attributes
host: str, host: str,
port: int, port: int,
ssl_context: SSLContext | None = None, ssl_context: SSLContext | None = None,
*,
threaded: bool = False,
): ):
""" """
Initializes the instance. Initializes the instance.
...@@ -632,6 +644,7 @@ class HTTPServerBase(abc.ABC): # pylint: disable=too-many-instance-attributes ...@@ -632,6 +644,7 @@ class HTTPServerBase(abc.ABC): # pylint: disable=too-many-instance-attributes
self.handler_errors: list[Exception] = [] self.handler_errors: list[Exception] = []
self.log: list[tuple[Request, Response]] = [] self.log: list[tuple[Request, Response]] = []
self.ssl_context = ssl_context self.ssl_context = ssl_context
self.threaded = threaded
self.no_handler_status_code = 500 self.no_handler_status_code = 500
def __repr__(self): def __repr__(self):
...@@ -730,11 +743,11 @@ class HTTPServerBase(abc.ABC): # pylint: disable=too-many-instance-attributes ...@@ -730,11 +743,11 @@ class HTTPServerBase(abc.ABC): # pylint: disable=too-many-instance-attributes
This method returns immediately (e.g. does not block), and it's the caller's This method returns immediately (e.g. does not block), and it's the caller's
responsibility to stop the server (by calling :py:meth:`stop`) when it is no longer needed). responsibility to stop the server (by calling :py:meth:`stop`) when it is no longer needed).
If the sever is not stopped by the caller and execution reaches the end, the If the server is not stopped by the caller and execution reaches the end, the
program needs to be terminated by Ctrl+C or by signal as it will not terminate until program needs to be terminated by Ctrl+C or by signal as it will not terminate until
the thread is stopped. the thread is stopped.
If the sever is already running :py:class:`HTTPServerError` will be raised. If you are If the server is already running :py:class:`HTTPServerError` will be raised. If you are
unsure, call :py:meth:`is_running` first. unsure, call :py:meth:`is_running` first.
There's a context interface of this class which stops the server when the context block ends. There's a context interface of this class which stops the server when the context block ends.
...@@ -742,7 +755,9 @@ class HTTPServerBase(abc.ABC): # pylint: disable=too-many-instance-attributes ...@@ -742,7 +755,9 @@ class HTTPServerBase(abc.ABC): # pylint: disable=too-many-instance-attributes
if self.is_running(): if self.is_running():
raise HTTPServerError("Server is already running") raise HTTPServerError("Server is already running")
self.server = make_server(self.host, self.port, self.application, ssl_context=self.ssl_context) self.server = make_server(
self.host, self.port, self.application, ssl_context=self.ssl_context, threaded=self.threaded
)
self.port = self.server.port # Update port (needed if `port` was set to 0) self.port = self.server.port # Update port (needed if `port` was set to 0)
self.server_thread = threading.Thread(target=self.thread_target) self.server_thread = threading.Thread(target=self.thread_target)
self.server_thread.start() self.server_thread.start()
...@@ -900,6 +915,8 @@ class HTTPServer(HTTPServerBase): # pylint: disable=too-many-instance-attribute ...@@ -900,6 +915,8 @@ class HTTPServer(HTTPServerBase): # pylint: disable=too-many-instance-attribute
:param default_waiting_settings: the waiting settings object to use as default settings for :py:meth:`wait` context :param default_waiting_settings: the waiting settings object to use as default settings for :py:meth:`wait` context
manager manager
:param threaded: whether to handle concurrent requests in separate threads
.. py:attribute:: no_handler_status_code .. py:attribute:: no_handler_status_code
Attribute containing the http status code (int) which will be the response Attribute containing the http status code (int) which will be the response
...@@ -916,11 +933,13 @@ class HTTPServer(HTTPServerBase): # pylint: disable=too-many-instance-attribute ...@@ -916,11 +933,13 @@ class HTTPServer(HTTPServerBase): # pylint: disable=too-many-instance-attribute
port=DEFAULT_LISTEN_PORT, port=DEFAULT_LISTEN_PORT,
ssl_context: SSLContext | None = None, ssl_context: SSLContext | None = None,
default_waiting_settings: WaitingSettings | None = None, default_waiting_settings: WaitingSettings | None = None,
*,
threaded: bool = False,
): ):
""" """
Initializes the instance. Initializes the instance.
""" """
super().__init__(host, port, ssl_context) super().__init__(host, port, ssl_context, threaded=threaded)
self.ordered_handlers: list[RequestHandler] = [] self.ordered_handlers: list[RequestHandler] = []
self.oneshot_handlers = RequestHandlerList() self.oneshot_handlers = RequestHandlerList()
...@@ -1333,3 +1352,73 @@ class HTTPServer(HTTPServerBase): # pylint: disable=too-many-instance-attribute ...@@ -1333,3 +1352,73 @@ class HTTPServer(HTTPServerBase): # pylint: disable=too-many-instance-attribute
) )
if self._waiting_settings.raise_assertions and not waiting.result: if self._waiting_settings.raise_assertions and not waiting.result:
self.check_assertions() self.check_assertions()
def iter_matching_requests(self, matcher: RequestMatcher) -> Iterable[tuple[Request, Response]]:
"""
Queries log for matching requests.
:param matcher: the matcher object to match requests
:return: an iterator with request-response pair from the log
"""
for request, response in self.log:
if matcher.match(request):
yield (request, response)
def get_matching_requests_count(self, matcher: RequestMatcher) -> int:
"""
Queries the log for matching requests, returning the number of log
entries matching for the specified matcher.
:param matcher: the matcher object to match requests
:return: the number of log entries matching
"""
return len(list(self.iter_matching_requests(matcher)))
def assert_request_made(self, matcher: RequestMatcher, *, count: int = 1):
"""
Check the amount of log entries matching for the matcher specified. By
default it verifies that exactly one request matching for the matcher
specified. The expected count can be customized with the count kwarg
(including zero, which asserts that no requests made for the given
matcher).
:param matcher: the matcher object to match requests
:param count: the expected number of matches in the log
:return: ``None`` if the assert succeeded, raises
:py:class:`AssertionError` if not.
"""
matching_count = self.get_matching_requests_count(matcher)
if matching_count != count:
similar_requests: list[Request] = []
for request, _ in self.log:
if request.path == matcher.uri:
similar_requests.append(request)
assert_msg_lines = [
f"Matching request found {matching_count} times but expected {count} times.",
f"Expected request: {matcher}",
]
if similar_requests:
assert_msg_lines.append(f"Found {len(similar_requests)} similar request(s):")
for request in similar_requests:
assert_msg_lines.extend(
(
"--- Similar Request Start",
f"Path: {request.path}",
f"Method: {request.method}",
f"Body: {request.get_data()!r}",
f"Headers: {request.headers}",
f"Query String: {request.query_string.decode('utf-8')!r}",
"--- Similar Request End",
)
)
else:
assert_msg_lines.append("No similar requests found.")
assert_msg = "\n".join(assert_msg_lines) + "\n"
assert matching_count == count, assert_msg
---
fixes:
- |
Fix pytest-httpserver's own tests related to log querying. No functional
changes in pytest-httpserver code itself. `#345 <https://github.com/csernazs/pytest-httpserver/issues/345>`_
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment