Skip to content

Commit db12c9c

Browse files
committed
pytest-httpserver: Initial commit
0 parents  commit db12c9c

14 files changed

Lines changed: 389 additions & 0 deletions

.gitignore

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
.venv/
2+
.pytest_cache/
3+
.vscode/.ropeproject/
4+
*.egg-info/
5+
.cache/
6+
.coverage
7+
coverage.xml
8+
htmlcov/
9+
build/
10+
dist/
11+
.eggs/

.vscode/settings.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"python.pythonPath": "${workspaceFolder}/.venv/bin/python3",
3+
"editor.formatOnSave": true,
4+
"python.linting.pylintPath": "${workspaceFolder}/.venv/bin/pylint",
5+
"python.unitTest.pyTestArgs": [
6+
"tests"
7+
],
8+
"python.unitTest.pyTestEnabled": true,
9+
"python.unitTest.pyTestPath": "${workspaceFolder}/.venv/bin/pytest"
10+
}

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2018 Zsolt Cserna
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

Makefile

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
2+
venv:
3+
python3 -m venv .venv
4+
.venv/bin/pip3 install --upgrade pip wheel
5+
6+
dev: venv
7+
.venv/bin/pip3 install -r requirements-dev.txt
8+
.venv/bin/pip3 install -r requirements.txt
9+
.venv/bin/pip3 install -e .
10+
11+
mrproper: clean
12+
rm -rf dist
13+
14+
clean: cov-clean
15+
rm -rf .venv cluster.egg-info build
16+
17+
test:
18+
.venv/bin/pytest tests -s -vv
19+
20+
test-pdb:
21+
.venv/bin/pytest tests -s -vv --pdb
22+
23+
cov: cov-clean
24+
.venv/bin/pytest --cov pytest_httpserver --cov-report=term --cov-report=html --cov-report=xml tests
25+
26+
cov-clean:
27+
rm -rf htmlcov

README.rst

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
pytest_httpserver
2+
~~~~~~~~~~~~~~~~~
3+
HTTP server for pytest
4+
5+
6+
Nutshell
7+
--------
8+
9+
This library is designed to help to test http clients without contacting the real http server.
10+
In other words, it is a fake http server which is accessible via localhost can be started with
11+
the pre-defined expected http requests and their responses.
12+
13+
Example
14+
-------
15+
16+
.. code-block:: python
17+
18+
def test_my_client(server): # server is a pytest fixture which starts the server
19+
# set up the server to serve /foobar with the json
20+
server.expect_request("/foobar").respond_with_json({"foo": "bar"})
21+
# check sthat it is served
22+
assert requests.get(server.url_for("/foobar")).json() == {'foo': 'bar'}
23+

example.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#!.venv/bin/python3
2+
3+
from pytest_httpserver.httpserver import Server
4+
import time
5+
import urllib.request
6+
import urllib.error
7+
8+
9+
def foobar(request):
10+
return "Hello world!"
11+
12+
13+
server = Server()
14+
server.expect_request("/foobar").respond_with_json({"foo": "bar"})
15+
server.start()
16+
try:
17+
print(urllib.request.urlopen("http://localhost:4000/foobar?name=John%20Smith&age=123").read())
18+
except urllib.error.HTTPError as err:
19+
print(err)
20+
21+
server.stop()

pytest_httpserver/__init__.py

Whitespace-only changes.

pytest_httpserver/httpserver.py

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
2+
import threading
3+
import json
4+
import signal
5+
import collections
6+
7+
from werkzeug.wrappers import Request, Response
8+
from werkzeug.serving import run_simple, make_server
9+
10+
URI_DEFAULT = ""
11+
METHOD_ALL = "__ALL"
12+
13+
14+
class RequestMatcher:
15+
def __init__(self, uri, method="GET", data=None, data_encoding="utf-8", headers=None, query_string=None):
16+
self.uri = uri
17+
self.method = method
18+
self.query_string = query_string
19+
20+
if headers is None:
21+
self.headers = {}
22+
else:
23+
self.headers = headers
24+
25+
if isinstance(data, str):
26+
data = data.encode(data_encoding)
27+
28+
self.data = data
29+
30+
def match_data(self, request):
31+
return request.data != self.data
32+
33+
def difference(self, request: Request):
34+
retval = []
35+
if self.uri != URI_DEFAULT and request.path != self.uri:
36+
retval.append(("uri", request.path, self.uri))
37+
38+
if self.method != METHOD_ALL and self.method != request.method:
39+
retval.append(("method", request.method, self.method))
40+
41+
if self.query_string is not None and self.query_string != request.query_string:
42+
retval.append(("query_string", request.query_string, self.query_string))
43+
44+
request_headers = {}
45+
expected_headers = {}
46+
for key, value in self.headers.items():
47+
if request.headers.get(key) != value:
48+
request_headers[key] = request.headers.get(key)
49+
expected_headers[key] = value
50+
51+
if request_headers and expected_headers:
52+
retval.append(("headers", request_headers, expected_headers))
53+
54+
if not self.match_data(request):
55+
retval.append(("data", request.data, self.data))
56+
57+
return retval
58+
59+
def match(self, request: Request):
60+
if self.difference(request):
61+
return False
62+
else:
63+
return True
64+
65+
66+
class NoHandlerError(Exception):
67+
pass
68+
69+
70+
class RequestHandler:
71+
def __init__(self, matcher: RequestMatcher):
72+
self.matcher = matcher
73+
self.request_handler = None
74+
75+
def respond(self, request):
76+
if self.request_handler is None:
77+
raise NoHandlerError("No handler found for request: {} {}".format(request.method, request.path))
78+
else:
79+
return self.request_handler(request)
80+
81+
def respond_with_json(self, response_json, status=200, headers=None, content_type="application/json"):
82+
response_data = json.dumps(response_json, indent=4)
83+
self.respond_with_data(response_data, status, headers, content_type=content_type)
84+
85+
def respond_with_data(self, response_data="", status=200, headers=None, mimetype=None, content_type=None):
86+
def handler(request):
87+
return Response(response_data, status, headers, mimetype, content_type)
88+
89+
self.request_handler = handler
90+
91+
def respond_with_response(self, response):
92+
self.request_handler = lambda request: response
93+
94+
def respond_with_handler(self, func):
95+
self.request_handler = func
96+
97+
98+
class RequestHandlerList(list):
99+
def match(self, request):
100+
for requesthandler in self:
101+
if requesthandler.matcher.match(request):
102+
return requesthandler
103+
104+
105+
class Server:
106+
def __init__(self, host="localhost", port=4000):
107+
self.host = host
108+
self.port = port
109+
self.handlers = {}
110+
self.assertions = []
111+
self.server = None
112+
self.server_thread = None
113+
self.log = []
114+
self.ordered_handlers = []
115+
self.oneshot_handlers = RequestHandlerList()
116+
self.handlers = RequestHandlerList()
117+
118+
def clear_all_handlers(self):
119+
self.ordered_handlers = []
120+
self.oneshot_handlers = RequestHandlerList()
121+
self.handlers = RequestHandlerList()
122+
123+
def url_for(self, suffix: str):
124+
if not suffix.startswith("/"):
125+
suffix = "/" + suffix
126+
127+
return "http://{}:{}{}".format(self.host, self.port, suffix)
128+
129+
def create_matcher(self, *args, **kwargs):
130+
return RequestMatcher(*args, **kwargs)
131+
132+
def expect_oneshot_request(self, uri, method="GET", data=None, data_encoding="utf-8", headers=None, ordered=False):
133+
matcher = self.create_matcher(uri, method="GET", data=None, data_encoding="utf-8", headers=None)
134+
request_handler = RequestHandler(matcher)
135+
if ordered:
136+
self.ordered_handlers.append(request_handler)
137+
else:
138+
self.oneshot_handlers.append(request_handler)
139+
140+
return request_handler
141+
142+
def expect_request(self, uri, method="GET", data=None, data_encoding="utf-8", headers=None):
143+
matcher = self.create_matcher(uri, method="GET", data=None, data_encoding="utf-8", headers=None)
144+
request_handler = RequestHandler(matcher)
145+
self.handlers.append(request_handler)
146+
return request_handler
147+
148+
def thread_target(self):
149+
self.server.serve_forever()
150+
151+
def start(self):
152+
self.server = make_server(self.host, self.port, self.application)
153+
self.server_thread = threading.Thread(target=self.thread_target)
154+
self.server_thread.start()
155+
156+
def stop(self):
157+
self.server.shutdown()
158+
self.server_thread.join()
159+
self.server = None
160+
self.server_thread = None
161+
162+
def add_assertion(self, obj):
163+
self.assertions.append(obj)
164+
165+
def check_assertions(self):
166+
if self.assertions:
167+
raise AssertionError(self.assertions.pop(0))
168+
169+
def respond_nohandler(self, request: Request):
170+
self.add_assertion("No handler found for request {!r}".format(request))
171+
return Response("No handler found for this request", 500)
172+
173+
def dispatch(self, request):
174+
if self.ordered_handlers:
175+
handler = self.ordered_handlers.pop()
176+
if not handler.matcher.match(request):
177+
return self.respond_nohandler(request)
178+
179+
handler = self.oneshot_handlers.match(request)
180+
if handler:
181+
self.oneshot_handlers.remove(handler)
182+
else:
183+
handler = self.handlers.match(request)
184+
185+
if not handler:
186+
return self.respond_nohandler(request)
187+
188+
response = handler.respond(request)
189+
190+
if response is None:
191+
response = Response("")
192+
if isinstance(response, str):
193+
response = Response(response)
194+
195+
return response
196+
197+
@Request.application
198+
def application(self, request: Request):
199+
request.get_data()
200+
response = self.dispatch(request)
201+
self.log.append((request, response))
202+
return response

pytest_httpserver/pytest_plugin.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
2+
3+
import pytest
4+
from .httpserver import Server
5+
6+
7+
@pytest.fixture
8+
def server():
9+
retval = Server()
10+
retval.start()
11+
yield retval
12+
retval.stop()

requirements-dev.txt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
pep8
2+
pylint
3+
wheel
4+
rope
5+
pytest
6+
pytest-cov
7+
coverage
8+
ipdb
9+
requests

0 commit comments

Comments
 (0)