pytest_httpserver 1.0.10__py3-none-any.whl → 1.0.11__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,7 @@
2
2
  This is package provides the main API for the pytest_httpserver package.
3
3
 
4
4
  """
5
+
5
6
  __all__ = [
6
7
  "HTTPServer",
7
8
  "HTTPServerError",
@@ -10,6 +11,7 @@ __all__ = [
10
11
  "WaitingSettings",
11
12
  "HeaderValueMatcher",
12
13
  "RequestHandler",
14
+ "RequestMatcher",
13
15
  "URIPattern",
14
16
  "URI_DEFAULT",
15
17
  "METHOD_ALL",
@@ -27,5 +29,6 @@ from .httpserver import HTTPServer
27
29
  from .httpserver import HTTPServerError
28
30
  from .httpserver import NoHandlerError
29
31
  from .httpserver import RequestHandler
32
+ from .httpserver import RequestMatcher
30
33
  from .httpserver import URIPattern
31
34
  from .httpserver import WaitingSettings
@@ -18,8 +18,8 @@ from pytest_httpserver.httpserver import URIPattern
18
18
  if TYPE_CHECKING:
19
19
  from ssl import SSLContext
20
20
 
21
- from werkzeug.wrappers import Request
22
- from werkzeug.wrappers import Response
21
+ from werkzeug import Request
22
+ from werkzeug import Response
23
23
 
24
24
 
25
25
  class BlockingRequestHandler(RequestHandlerBase):
@@ -0,0 +1,103 @@
1
+ """
2
+ Hooks for pytest-httpserver
3
+ """
4
+
5
+ import os
6
+ import time
7
+ from typing import Callable
8
+
9
+ from werkzeug import Request
10
+ from werkzeug import Response
11
+
12
+
13
+ class Chain:
14
+ """
15
+ Combine multiple hooks into one callable object
16
+
17
+ Hooks specified will be called one by one.
18
+
19
+ Each hook will receive the response object made by the previous hook,
20
+ similar to reduce.
21
+ """
22
+
23
+ def __init__(self, *args: Callable[[Request, Response], Response]):
24
+ """
25
+ :param *args: callable objects specified in the same order they should
26
+ be called.
27
+ """
28
+ self._hooks = args
29
+
30
+ def __call__(self, request: Request, response: Response) -> Response:
31
+ """
32
+ Calls the callable object one by one. The second and further callable
33
+ objects receive the response returned by the previous one, while the
34
+ first one receives the original response object.
35
+ """
36
+ for hook in self._hooks:
37
+ response = hook(request, response)
38
+ return response
39
+
40
+
41
+ class Delay:
42
+ """
43
+ Delays returning the response
44
+ """
45
+
46
+ def __init__(self, seconds: float):
47
+ """
48
+ :param seconds: seconds to sleep before returning the response
49
+ """
50
+ self._seconds = seconds
51
+
52
+ def _sleep(self):
53
+ """
54
+ Sleeps for the seconds specified in the constructor
55
+ """
56
+ time.sleep(self._seconds)
57
+
58
+ def __call__(self, _request: Request, response: Response) -> Response:
59
+ """
60
+ Delays returning the response object for the time specified in the
61
+ constructor. Returns the original response unmodified.
62
+ """
63
+ self._sleep()
64
+ return response
65
+
66
+
67
+ class Garbage:
68
+ def __init__(self, prefix_size: int = 0, suffix_size: int = 0):
69
+ """
70
+ Adds random bytes to the beginning or to the end of the response data.
71
+
72
+ :param prefix_size: amount of random bytes to be added to the beginning
73
+ of the response data
74
+
75
+ :param suffix_size: amount of random bytes to be added to the end
76
+ of the response data
77
+
78
+ """
79
+ assert prefix_size >= 0, "prefix_size should be positive integer"
80
+ assert suffix_size >= 0, "suffix_size should be positive integer"
81
+ self._prefix_size = prefix_size
82
+ self._suffix_size = suffix_size
83
+
84
+ def _get_garbage_bytes(self, size: int) -> bytes:
85
+ """
86
+ Returns the specified amount of random bytes.
87
+
88
+ :param size: amount of bytes to return
89
+ """
90
+ return os.urandom(size)
91
+
92
+ def __call__(self, _request: Request, response: Response) -> Response:
93
+ """
94
+ Adds random bytes to the beginning or to the end of the response data.
95
+
96
+ New random bytes will be generated for every call.
97
+
98
+ Returns the modified response object.
99
+ """
100
+ prefix = self._get_garbage_bytes(self._prefix_size)
101
+ suffix = self._get_garbage_bytes(self._suffix_size)
102
+ response.set_data(prefix + response.get_data() + suffix)
103
+ return response
@@ -26,11 +26,11 @@ from typing import Tuple
26
26
  from typing import Union
27
27
 
28
28
  import werkzeug.http
29
+ from werkzeug import Request
30
+ from werkzeug import Response
29
31
  from werkzeug.datastructures import Authorization
30
32
  from werkzeug.datastructures import MultiDict
31
33
  from werkzeug.serving import make_server
32
- from werkzeug.wrappers import Request
33
- from werkzeug.wrappers import Response
34
34
 
35
35
  if TYPE_CHECKING:
36
36
  from ssl import SSLContext
@@ -495,7 +495,7 @@ class RequestHandlerBase(abc.ABC):
495
495
  """
496
496
  Prepares a response with raw data.
497
497
 
498
- For detailed description please see the :py:class:`werkzeug.wrappers.Response` object as the
498
+ For detailed description please see the :py:class:`werkzeug.Response` object as the
499
499
  parameters are analogue.
500
500
 
501
501
  :param response_data: a string or bytes object representing the body of the response
@@ -529,6 +529,11 @@ class RequestHandler(RequestHandlerBase):
529
529
  def __init__(self, matcher: RequestMatcher):
530
530
  self.matcher = matcher
531
531
  self.request_handler: Callable[[Request], Response] | None = None
532
+ self._hooks: list[Callable[[Request, Response], Response]] = []
533
+
534
+ def with_post_hook(self, hook: Callable[[Request, Response], Response]):
535
+ self._hooks.append(hook)
536
+ return self
532
537
 
533
538
  def respond(self, request: Request) -> Response:
534
539
  """
@@ -546,7 +551,11 @@ class RequestHandler(RequestHandlerBase):
546
551
  "Matching request handler found but no response defined: {} {}".format(request.method, request.path)
547
552
  )
548
553
  else:
549
- return self.request_handler(request)
554
+ response = self.request_handler(request)
555
+
556
+ for hook in self._hooks:
557
+ response = hook(request, response)
558
+ return response
550
559
 
551
560
  def respond_with_handler(self, func: Callable[[Request], Response]):
552
561
  """
@@ -598,11 +607,12 @@ class HTTPServerBase(abc.ABC): # pylint: disable=too-many-instance-attributes
598
607
  :param host: the host or IP where the server will listen
599
608
  :param port: the TCP port where the server will listen
600
609
  :param ssl_context: the ssl context object to use for https connections
610
+ :param threaded: whether to handle concurrent requests in separate threads
601
611
 
602
612
  .. py:attribute:: log
603
613
 
604
614
  Attribute containing the list of two-element tuples. Each tuple contains
605
- :py:class:`werkzeug.wrappers.Request` and :py:class:`werkzeug.wrappers.Response` object which represents the
615
+ :py:class:`werkzeug.Request` and :py:class:`werkzeug.Response` object which represents the
606
616
  incoming request and the outgoing response which happened during the lifetime
607
617
  of the server.
608
618
 
@@ -619,6 +629,8 @@ class HTTPServerBase(abc.ABC): # pylint: disable=too-many-instance-attributes
619
629
  host: str,
620
630
  port: int,
621
631
  ssl_context: SSLContext | None = None,
632
+ *,
633
+ threaded: bool = False,
622
634
  ):
623
635
  """
624
636
  Initializes the instance.
@@ -632,6 +644,7 @@ class HTTPServerBase(abc.ABC): # pylint: disable=too-many-instance-attributes
632
644
  self.handler_errors: list[Exception] = []
633
645
  self.log: list[tuple[Request, Response]] = []
634
646
  self.ssl_context = ssl_context
647
+ self.threaded = threaded
635
648
  self.no_handler_status_code = 500
636
649
 
637
650
  def __repr__(self):
@@ -730,11 +743,11 @@ class HTTPServerBase(abc.ABC): # pylint: disable=too-many-instance-attributes
730
743
  This method returns immediately (e.g. does not block), and it's the caller's
731
744
  responsibility to stop the server (by calling :py:meth:`stop`) when it is no longer needed).
732
745
 
733
- If the sever is not stopped by the caller and execution reaches the end, the
746
+ If the server is not stopped by the caller and execution reaches the end, the
734
747
  program needs to be terminated by Ctrl+C or by signal as it will not terminate until
735
748
  the thread is stopped.
736
749
 
737
- If the sever is already running :py:class:`HTTPServerError` will be raised. If you are
750
+ If the server is already running :py:class:`HTTPServerError` will be raised. If you are
738
751
  unsure, call :py:meth:`is_running` first.
739
752
 
740
753
  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
755
  if self.is_running():
743
756
  raise HTTPServerError("Server is already running")
744
757
 
745
- self.server = make_server(self.host, self.port, self.application, ssl_context=self.ssl_context)
758
+ self.server = make_server(
759
+ self.host, self.port, self.application, ssl_context=self.ssl_context, threaded=self.threaded
760
+ )
746
761
  self.port = self.server.port # Update port (needed if `port` was set to 0)
747
762
  self.server_thread = threading.Thread(target=self.thread_target)
748
763
  self.server_thread.start()
@@ -900,6 +915,8 @@ class HTTPServer(HTTPServerBase): # pylint: disable=too-many-instance-attribute
900
915
  :param default_waiting_settings: the waiting settings object to use as default settings for :py:meth:`wait` context
901
916
  manager
902
917
 
918
+ :param threaded: whether to handle concurrent requests in separate threads
919
+
903
920
  .. py:attribute:: no_handler_status_code
904
921
 
905
922
  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
933
  port=DEFAULT_LISTEN_PORT,
917
934
  ssl_context: SSLContext | None = None,
918
935
  default_waiting_settings: WaitingSettings | None = None,
936
+ *,
937
+ threaded: bool = False,
919
938
  ):
920
939
  """
921
940
  Initializes the instance.
922
941
  """
923
- super().__init__(host, port, ssl_context)
942
+ super().__init__(host, port, ssl_context, threaded=threaded)
924
943
 
925
944
  self.ordered_handlers: list[RequestHandler] = []
926
945
  self.oneshot_handlers = RequestHandlerList()
@@ -1333,3 +1352,73 @@ class HTTPServer(HTTPServerBase): # pylint: disable=too-many-instance-attribute
1333
1352
  )
1334
1353
  if self._waiting_settings.raise_assertions and not waiting.result:
1335
1354
  self.check_assertions()
1355
+
1356
+ def iter_matching_requests(self, matcher: RequestMatcher) -> Iterable[tuple[Request, Response]]:
1357
+ """
1358
+ Queries log for matching requests.
1359
+
1360
+
1361
+ :param matcher: the matcher object to match requests
1362
+ :return: an iterator with request-response pair from the log
1363
+ """
1364
+
1365
+ for request, response in self.log:
1366
+ if matcher.match(request):
1367
+ yield (request, response)
1368
+
1369
+ def get_matching_requests_count(self, matcher: RequestMatcher) -> int:
1370
+ """
1371
+ Queries the log for matching requests, returning the number of log
1372
+ entries matching for the specified matcher.
1373
+
1374
+ :param matcher: the matcher object to match requests
1375
+ :return: the number of log entries matching
1376
+ """
1377
+ return len(list(self.iter_matching_requests(matcher)))
1378
+
1379
+ def assert_request_made(self, matcher: RequestMatcher, *, count: int = 1):
1380
+ """
1381
+ Check the amount of log entries matching for the matcher specified. By
1382
+ default it verifies that exactly one request matching for the matcher
1383
+ specified. The expected count can be customized with the count kwarg
1384
+ (including zero, which asserts that no requests made for the given
1385
+ matcher).
1386
+
1387
+ :param matcher: the matcher object to match requests
1388
+ :param count: the expected number of matches in the log
1389
+ :return: ``None`` if the assert succeeded, raises
1390
+ :py:class:`AssertionError` if not.
1391
+ """
1392
+
1393
+ matching_count = self.get_matching_requests_count(matcher)
1394
+ if matching_count != count:
1395
+ similar_requests: list[Request] = []
1396
+ for request, _ in self.log:
1397
+ if request.path == matcher.uri:
1398
+ similar_requests.append(request)
1399
+
1400
+ assert_msg_lines = [
1401
+ f"Matching request found {matching_count} times but expected {count} times.",
1402
+ f"Expected request: {matcher}",
1403
+ ]
1404
+
1405
+ if similar_requests:
1406
+ assert_msg_lines.append(f"Found {len(similar_requests)} similar request(s):")
1407
+ for request in similar_requests:
1408
+ assert_msg_lines.extend(
1409
+ (
1410
+ "--- Similar Request Start",
1411
+ f"Path: {request.path}",
1412
+ f"Method: {request.method}",
1413
+ f"Body: {request.get_data()!r}",
1414
+ f"Headers: {request.headers}",
1415
+ f"Query String: {request.query_string.decode('utf-8')!r}",
1416
+ "--- Similar Request End",
1417
+ )
1418
+ )
1419
+ else:
1420
+ assert_msg_lines.append("No similar requests found.")
1421
+
1422
+ assert_msg = "\n".join(assert_msg_lines) + "\n"
1423
+
1424
+ assert matching_count == count, assert_msg
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pytest_httpserver
3
- Version: 1.0.10
3
+ Version: 1.0.11
4
4
  Summary: pytest-httpserver is a httpserver for pytest
5
5
  Home-page: https://github.com/csernazs/pytest-httpserver
6
6
  License: MIT
@@ -0,0 +1,11 @@
1
+ pytest_httpserver/__init__.py,sha256=mxclzFNYdCWBmbsmOP5ctA_7334LMPg2B-dzO6Q4cKs,897
2
+ pytest_httpserver/blocking_httpserver.py,sha256=X83nmJUnDt1ntWopD6Ob0V8iUjzEVES64IpxPtwjLis,7227
3
+ pytest_httpserver/hooks.py,sha256=dPQih1u-RJdsRrU5JBlaYaaLFBGIWH8F9nN0qqYa5OU,3022
4
+ pytest_httpserver/httpserver.py,sha256=zrGItVnHTdiCMzlQEq4bMwEROl0vSRjhy9fXr3YuDiw,53603
5
+ pytest_httpserver/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ pytest_httpserver/pytest_plugin.py,sha256=5xFe-nF3QfnuAOk3WP76zljxJlAF1jmd3gJQFYY6OFI,2320
7
+ pytest_httpserver-1.0.11.dist-info/LICENSE,sha256=KUr7ZKG_73p9_Z8ODdU7FI-Tw6CHVhcxNjXJhUzUpak,1069
8
+ pytest_httpserver-1.0.11.dist-info/METADATA,sha256=Ixu3pzo29b9ylP7fNFthWmPqiV9-HHrAnYJIsQ0f-J0,6164
9
+ pytest_httpserver-1.0.11.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
10
+ pytest_httpserver-1.0.11.dist-info/entry_points.txt,sha256=bsXb6tjhUsMGU1mzPAjf92HukEHrLB0TebYnXbhY9rw,62
11
+ pytest_httpserver-1.0.11.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 1.8.1
2
+ Generator: poetry-core 1.9.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,10 +0,0 @@
1
- pytest_httpserver/__init__.py,sha256=a0TGt29_kd-4xhMOqJmh370EjpujzDY0c7ZLK2DplW4,835
2
- pytest_httpserver/blocking_httpserver.py,sha256=HWxcXLjCcke4PlK-i2aZy-7T6dtR8E7DPS0hBxAaY_8,7245
3
- pytest_httpserver/httpserver.py,sha256=4YrUFp0RMVLDP5SCYJSClm2XP54-6p_bfsYJ9uoeoh0,50073
4
- pytest_httpserver/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
- pytest_httpserver/pytest_plugin.py,sha256=5xFe-nF3QfnuAOk3WP76zljxJlAF1jmd3gJQFYY6OFI,2320
6
- pytest_httpserver-1.0.10.dist-info/LICENSE,sha256=KUr7ZKG_73p9_Z8ODdU7FI-Tw6CHVhcxNjXJhUzUpak,1069
7
- pytest_httpserver-1.0.10.dist-info/METADATA,sha256=Btn3EcgiCGKCYCDo-6NfTT3-6UHqgODJlla3FuzcJm4,6164
8
- pytest_httpserver-1.0.10.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
9
- pytest_httpserver-1.0.10.dist-info/entry_points.txt,sha256=bsXb6tjhUsMGU1mzPAjf92HukEHrLB0TebYnXbhY9rw,62
10
- pytest_httpserver-1.0.10.dist-info/RECORD,,