Skip to content

Commit edf492e

Browse files
rominfcsernazs
authored andcommitted
Add HTTPServer.wait context manager
This context manager waits until the first of following event occurs: all ordered and oneshot handlers were executed, unexpected request was received (if `stop_on_nohandler` is set to `True`), or time was out. Fixes #3.
1 parent d3a02cc commit edf492e

4 files changed

Lines changed: 192 additions & 2 deletions

File tree

.vscode/settings.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
{
22
"python.pythonPath": "${workspaceFolder}/.venv/bin/python3",
3-
"editor.formatOnSave": true,
43
"python.linting.pylintPath": "${workspaceFolder}/.venv/bin/pylint",
54
"python.unitTest.pyTestArgs": [
65
"tests"

pytest_httpserver/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@
55

66
from .httpserver import HTTPServer
77
from .httpserver import HTTPServerError, Error, NoHandlerError
8+
from .httpserver import WaitingSettings
89
from .httpserver import URI_DEFAULT, METHOD_ALL

pytest_httpserver/httpserver.py

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11

2+
import queue
23
import threading
34
import json
5+
import time
46
from collections import defaultdict
57
from enum import Enum
8+
from contextlib import suppress, contextmanager
9+
from copy import copy
610
from typing import Callable, Mapping, Optional, Union
711
from ssl import SSLContext
812

@@ -46,6 +50,44 @@ class NoMethodFoundForMatchingHeaderValueError(Error):
4650
pass
4751

4852

53+
class WaitingSettings:
54+
"""Class for providing default settings and storing them in HTTPServer
55+
56+
:param raise_assertions: whether raise assertions on unexpected request or timeout or not
57+
:param stop_on_nohandler: whether stop on unexpected request or not
58+
:param timeout: time (in seconds) until time is out
59+
"""
60+
61+
def __init__(self, raise_assertions: bool = True, stop_on_nohandler: bool = True, timeout: float = 5):
62+
self.raise_assertions = raise_assertions
63+
self.stop_on_nohandler = stop_on_nohandler
64+
self.timeout = timeout
65+
66+
67+
class Waiting:
68+
"""Class for HTTPServer.wait context manager
69+
70+
This class should not be instantiated directly."""
71+
72+
def __init__(self):
73+
self._result = None
74+
self._start = time.monotonic()
75+
self._stop = None
76+
77+
def complete(self, result: bool):
78+
self._result = result
79+
self._stop = time.monotonic()
80+
81+
@property
82+
def result(self) -> bool:
83+
return self._result
84+
85+
@property
86+
def elapsed_time(self) -> float:
87+
"""Elapsed time in seconds"""
88+
return self._stop - self._start
89+
90+
4991
class HeaderValueMatcher:
5092
"""
5193
Matcher object for the header value of incoming request.
@@ -310,6 +352,8 @@ class HTTPServer: # pylint: disable=too-many-instance-attributes
310352
:param host: the host or IP where the server will listen
311353
:param port: the TCP port where the server will listen
312354
:param ssl_context: the ssl context object to use for https connections
355+
:param default_waiting_settings: the waiting settings object to use as default settings for :py:meth:`wait` context
356+
manager
313357
314358
.. py:attribute:: log
315359
@@ -322,7 +366,8 @@ class HTTPServer: # pylint: disable=too-many-instance-attributes
322366
DEFAULT_LISTEN_HOST = "localhost"
323367
DEFAULT_LISTEN_PORT = 0 # Use ephemeral port
324368

325-
def __init__(self, host=DEFAULT_LISTEN_HOST, port=DEFAULT_LISTEN_PORT, ssl_context: Optional[SSLContext] = None):
369+
def __init__(self, host=DEFAULT_LISTEN_HOST, port=DEFAULT_LISTEN_PORT, ssl_context: Optional[SSLContext] = None,
370+
default_waiting_settings: Optional[WaitingSettings] = None):
326371
"""
327372
Initializes the instance.
328373
@@ -338,6 +383,12 @@ def __init__(self, host=DEFAULT_LISTEN_HOST, port=DEFAULT_LISTEN_PORT, ssl_conte
338383
self.handlers = RequestHandlerList()
339384
self.permanently_failed = False
340385
self.ssl_context = ssl_context
386+
if default_waiting_settings is not None:
387+
self.default_waiting_settings = default_waiting_settings
388+
else:
389+
self.default_waiting_settings = WaitingSettings()
390+
self._waiting_settings = copy(self.default_waiting_settings)
391+
self._waiting_result = queue.LifoQueue(maxsize=1)
341392

342393
def clear(self):
343394
"""
@@ -654,6 +705,8 @@ def respond_nohandler(self, request: Request):
654705
As the result, there's an assertion added (which can be raised by :py:meth:`check_assertions`).
655706
656707
"""
708+
if self._waiting_settings.stop_on_nohandler:
709+
self._set_waiting_result(False)
657710
text = "No handler found for request {!r}.\n".format(request)
658711
self.add_assertion(text + self.format_matchers())
659712
return Response("No handler found for this request", 500)
@@ -700,11 +753,13 @@ def dispatch(self, request: Request) -> Response:
700753
return response
701754

702755
self.ordered_handlers.pop(0)
756+
self._update_waiting_result()
703757

704758
if not handler:
705759
handler = self.oneshot_handlers.match(request)
706760
if handler:
707761
self.oneshot_handlers.remove(handler)
762+
self._update_waiting_result()
708763
else:
709764
handler = self.handlers.match(request)
710765

@@ -720,6 +775,69 @@ def dispatch(self, request: Request) -> Response:
720775

721776
return response
722777

778+
def _set_waiting_result(self, value: bool) -> None:
779+
"""Set waiting_result
780+
781+
Setting is implemented as putting value to queue without waiting. If queue is full we simply ignore the
782+
exception, because that means that waiting_result was already set, but not read.
783+
"""
784+
with suppress(queue.Full):
785+
self._waiting_result.put_nowait(value)
786+
787+
def _update_waiting_result(self) -> None:
788+
if not self.oneshot_handlers and not self.ordered_handlers:
789+
self._set_waiting_result(True)
790+
791+
@contextmanager
792+
def wait(self, raise_assertions: Optional[bool] = None, stop_on_nohandler: Optional[bool] = None,
793+
timeout: Optional[float] = None):
794+
"""Context manager to wait until the first of following event occurs: all ordered and oneshot handlers were
795+
executed, unexpected request was received (if `stop_on_nohandler` is set to `True`), or time was out
796+
797+
:param raise_assertions: whether raise assertions on unexpected request or timeout or not
798+
:param stop_on_nohandler: whether stop on unexpected request or not
799+
:param timeout: time (in seconds) until time is out
800+
801+
Example:
802+
def test_wait(httpserver):
803+
httpserver.expect_oneshot_request('/').respond_with_data('OK')
804+
with httpserver.wait(raise_assertions=False, stop_on_nohandler=False, timeout=1) as waiting:
805+
requests.get(httpserver.url_for('/'))
806+
# `waiting` is :py:class:`Waiting`
807+
assert waiting.result
808+
print('Elapsed time: {} sec'.format(waiting.elapsed_time))
809+
"""
810+
if raise_assertions is None:
811+
self._waiting_settings.raise_assertions = self.default_waiting_settings.raise_assertions
812+
else:
813+
self._waiting_settings.raise_assertions = raise_assertions
814+
if stop_on_nohandler is None:
815+
self._waiting_settings.stop_on_nohandler = self.default_waiting_settings.stop_on_nohandler
816+
else:
817+
self._waiting_settings.stop_on_nohandler = stop_on_nohandler
818+
if timeout is None:
819+
self._waiting_settings.timeout = self.default_waiting_settings.timeout
820+
else:
821+
self._waiting_settings.timeout = timeout
822+
823+
# Ensure that waiting_result is empty
824+
with suppress(queue.Empty):
825+
self._waiting_result.get_nowait()
826+
827+
waiting = Waiting()
828+
yield waiting
829+
830+
try:
831+
waiting_result = self._waiting_result.get(timeout=self._waiting_settings.timeout)
832+
waiting.complete(result=waiting_result)
833+
except queue.Empty:
834+
waiting.complete(result=False)
835+
if self._waiting_settings.raise_assertions:
836+
raise AssertionError('Wait timeout occurred, but some handlers left:\n'
837+
'{}'.format(self.format_matchers()))
838+
if self._waiting_settings.raise_assertions and not waiting.result:
839+
self.check_assertions()
840+
723841
@Request.application
724842
def application(self, request: Request):
725843
"""

tests/test_wait.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
2+
import requests
3+
from pytest import approx, raises
4+
5+
from pytest_httpserver import HTTPServer
6+
7+
8+
def test_wait_success(httpserver: HTTPServer):
9+
waiting_timeout = 0.1
10+
11+
with httpserver.wait(stop_on_nohandler=False, timeout=waiting_timeout) as waiting:
12+
requests.get(httpserver.url_for("/foobar"))
13+
httpserver.expect_oneshot_request("/foobar").respond_with_data("OK foobar")
14+
requests.get(httpserver.url_for("/foobar"))
15+
assert waiting.result
16+
17+
httpserver.expect_oneshot_request("/foobar").respond_with_data("OK foobar")
18+
httpserver.expect_oneshot_request("/foobaz").respond_with_data("OK foobaz")
19+
with httpserver.wait(timeout=waiting_timeout) as waiting:
20+
requests.get(httpserver.url_for("/foobar"))
21+
requests.get(httpserver.url_for("/foobaz"))
22+
assert waiting.result
23+
24+
25+
def test_wait_unexpected_request(httpserver: HTTPServer):
26+
def make_unexpected_request_and_wait() -> None:
27+
with raises(AssertionError) as error:
28+
waiting_timeout = 0.1
29+
with httpserver.wait(raise_assertions=True, stop_on_nohandler=True, timeout=waiting_timeout) as waiting:
30+
requests.get(httpserver.url_for("/foobaz"))
31+
assert not waiting.result
32+
no_handler_text = 'No handler found for request'
33+
assert no_handler_text in str(error)
34+
35+
make_unexpected_request_and_wait()
36+
37+
httpserver.expect_ordered_request("/foobar").respond_with_data("OK foobar")
38+
httpserver.expect_ordered_request("/foobaz").respond_with_data("OK foobaz")
39+
make_unexpected_request_and_wait()
40+
41+
42+
def test_wait_timeout(httpserver: HTTPServer):
43+
httpserver.expect_oneshot_request("/foobar").respond_with_data("OK foobar")
44+
httpserver.expect_oneshot_request("/foobaz").respond_with_data("OK foobaz")
45+
waiting_timeout = 1
46+
with raises(AssertionError) as error:
47+
with httpserver.wait(raise_assertions=True, timeout=waiting_timeout) as waiting:
48+
requests.get(httpserver.url_for("/foobar"))
49+
assert not waiting.result
50+
waiting_time_error = 0.1
51+
assert waiting.elapsed_time == approx(waiting_timeout, abs=waiting_time_error)
52+
assert 'Wait timeout occurred, but some handlers left' in str(error)
53+
54+
55+
def test_wait_raise_assertion_false(httpserver: HTTPServer):
56+
waiting_timeout = 0.1
57+
58+
try:
59+
with httpserver.wait(raise_assertions=False, stop_on_nohandler=True, timeout=waiting_timeout) as waiting:
60+
requests.get(httpserver.url_for("/foobaz"))
61+
except AssertionError as error:
62+
raise AssertionError('raise_assertions was set to False, but assertion was raised: {}'.format(error))
63+
assert not waiting.result
64+
65+
try:
66+
with httpserver.wait(raise_assertions=False, stop_on_nohandler=True, timeout=waiting_timeout) as waiting:
67+
pass
68+
except AssertionError as error:
69+
raise AssertionError('raise_assertions was set to False, but assertion was raised: {}'.format(error))
70+
assert not waiting.result
71+
waiting_time_error = 0.1
72+
assert waiting.elapsed_time == approx(waiting_timeout, abs=waiting_time_error)

0 commit comments

Comments
 (0)