Add Route Resolution Benchmarking to Unit Test (#1499)
* feat: add benchmark tester for route resolution and cleanup test warnings Signed-off-by: Harsha Narayana <harsha2k4@gmail.com> * feat: refactor sanic benchmark test util into fixtures Signed-off-by: Harsha Narayana <harsha2k4@gmail.com>
This commit is contained in:
parent
8a59907319
commit
34fe26e51b
53
tests/benchmark/test_route_resolution_benchmark.py
Normal file
53
tests/benchmark/test_route_resolution_benchmark.py
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
from random import choice, seed
|
||||||
|
from pytest import mark
|
||||||
|
|
||||||
|
import sanic.router
|
||||||
|
|
||||||
|
seed("Pack my box with five dozen liquor jugs.")
|
||||||
|
|
||||||
|
# Disable Caching for testing purpose
|
||||||
|
sanic.router.ROUTER_CACHE_SIZE = 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestSanicRouteResolution:
|
||||||
|
@mark.asyncio
|
||||||
|
async def test_resolve_route_no_arg_string_path(
|
||||||
|
self, sanic_router, route_generator, benchmark
|
||||||
|
):
|
||||||
|
simple_routes = route_generator.generate_random_direct_route(
|
||||||
|
max_route_depth=4
|
||||||
|
)
|
||||||
|
router, simple_routes = sanic_router(route_details=simple_routes)
|
||||||
|
route_to_call = choice(simple_routes)
|
||||||
|
|
||||||
|
result = benchmark.pedantic(
|
||||||
|
router._get,
|
||||||
|
("/{}".format(route_to_call[-1]), route_to_call[0], "localhost"),
|
||||||
|
iterations=1000,
|
||||||
|
rounds=1000,
|
||||||
|
)
|
||||||
|
assert await result[0](None) == 1
|
||||||
|
|
||||||
|
@mark.asyncio
|
||||||
|
async def test_resolve_route_with_typed_args(
|
||||||
|
self, sanic_router, route_generator, benchmark
|
||||||
|
):
|
||||||
|
typed_routes = route_generator.add_typed_parameters(
|
||||||
|
route_generator.generate_random_direct_route(max_route_depth=4),
|
||||||
|
max_route_depth=8,
|
||||||
|
)
|
||||||
|
router, typed_routes = sanic_router(route_details=typed_routes)
|
||||||
|
route_to_call = choice(typed_routes)
|
||||||
|
url = route_generator.generate_url_for_template(
|
||||||
|
template=route_to_call[-1]
|
||||||
|
)
|
||||||
|
|
||||||
|
print("{} -> {}".format(route_to_call[-1], url))
|
||||||
|
|
||||||
|
result = benchmark.pedantic(
|
||||||
|
router._get,
|
||||||
|
("/{}".format(url), route_to_call[0], "localhost"),
|
||||||
|
iterations=1000,
|
||||||
|
rounds=1000,
|
||||||
|
)
|
||||||
|
assert await result[0](None) == 1
|
|
@ -1,12 +1,130 @@
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import string
|
||||||
import sys
|
import sys
|
||||||
|
import uuid
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from sanic import Sanic
|
from sanic import Sanic
|
||||||
|
from sanic.router import RouteExists, Router
|
||||||
|
|
||||||
|
random.seed("Pack my box with five dozen liquor jugs.")
|
||||||
|
|
||||||
if sys.platform in ["win32", "cygwin"]:
|
if sys.platform in ["win32", "cygwin"]:
|
||||||
collect_ignore = ["test_worker.py"]
|
collect_ignore = ["test_worker.py"]
|
||||||
|
|
||||||
|
|
||||||
|
async def _handler(request):
|
||||||
|
"""
|
||||||
|
Dummy placeholder method used for route resolver when creating a new
|
||||||
|
route into the sanic router. This router is not actually called by the
|
||||||
|
sanic app. So do not worry about the arguments to this method.
|
||||||
|
|
||||||
|
If you change the return value of this method, make sure to propagate the
|
||||||
|
change to any test case that leverages RouteStringGenerator.
|
||||||
|
"""
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
TYPE_TO_GENERATOR_MAP = {
|
||||||
|
"string": lambda: "".join(
|
||||||
|
[random.choice(string.ascii_letters + string.digits) for _ in range(4)]
|
||||||
|
),
|
||||||
|
"int": lambda: random.choice(range(1000000)),
|
||||||
|
"number": lambda: random.random(),
|
||||||
|
"alpha": lambda: "".join(
|
||||||
|
[random.choice(string.ascii_letters) for _ in range(4)]
|
||||||
|
),
|
||||||
|
"uuid": lambda: str(uuid.uuid1()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RouteStringGenerator:
|
||||||
|
|
||||||
|
ROUTE_COUNT_PER_DEPTH = 100
|
||||||
|
HTTP_METHODS = ["GET", "PUT", "POST", "PATCH", "DELETE", "OPTION"]
|
||||||
|
ROUTE_PARAM_TYPES = ["string", "int", "number", "alpha", "uuid"]
|
||||||
|
|
||||||
|
def generate_random_direct_route(self, max_route_depth=4):
|
||||||
|
routes = []
|
||||||
|
for depth in range(1, max_route_depth + 1):
|
||||||
|
for _ in range(self.ROUTE_COUNT_PER_DEPTH):
|
||||||
|
route = "/".join(
|
||||||
|
[
|
||||||
|
TYPE_TO_GENERATOR_MAP.get("string")()
|
||||||
|
for _ in range(depth)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
route = route.replace(".", "", -1)
|
||||||
|
route_detail = (random.choice(self.HTTP_METHODS), route)
|
||||||
|
|
||||||
|
if route_detail not in routes:
|
||||||
|
routes.append(route_detail)
|
||||||
|
return routes
|
||||||
|
|
||||||
|
def add_typed_parameters(self, current_routes, max_route_depth=8):
|
||||||
|
routes = []
|
||||||
|
for method, route in current_routes:
|
||||||
|
current_length = len(route.split("/"))
|
||||||
|
new_route_part = "/".join(
|
||||||
|
[
|
||||||
|
"<{}:{}>".format(
|
||||||
|
TYPE_TO_GENERATOR_MAP.get("string")(),
|
||||||
|
random.choice(self.ROUTE_PARAM_TYPES),
|
||||||
|
)
|
||||||
|
for _ in range(max_route_depth - current_length)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
route = "/".join([route, new_route_part])
|
||||||
|
route = route.replace(".", "", -1)
|
||||||
|
routes.append((method, route))
|
||||||
|
return routes
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_url_for_template(template):
|
||||||
|
url = template
|
||||||
|
for pattern, param_type in re.findall(
|
||||||
|
re.compile(r"((?:<\w+:(string|int|number|alpha|uuid)>)+)"),
|
||||||
|
template,
|
||||||
|
):
|
||||||
|
value = TYPE_TO_GENERATOR_MAP.get(param_type)()
|
||||||
|
url = url.replace(pattern, str(value), -1)
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
def sanic_router():
|
||||||
|
# noinspection PyProtectedMember
|
||||||
|
def _setup(route_details: tuple) -> (Router, tuple):
|
||||||
|
router = Router()
|
||||||
|
added_router = []
|
||||||
|
for method, route in route_details:
|
||||||
|
try:
|
||||||
|
router._add(
|
||||||
|
uri="/{}".format(route),
|
||||||
|
methods=frozenset({method}),
|
||||||
|
host="localhost",
|
||||||
|
handler=_handler,
|
||||||
|
)
|
||||||
|
added_router.append((method, route))
|
||||||
|
except RouteExists:
|
||||||
|
pass
|
||||||
|
return router, added_router
|
||||||
|
|
||||||
|
return _setup
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
def route_generator() -> RouteStringGenerator:
|
||||||
|
return RouteStringGenerator()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
def url_param_generator():
|
||||||
|
return TYPE_TO_GENERATOR_MAP
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def app(request):
|
def app(request):
|
||||||
return Sanic(request.node.name)
|
return Sanic(request.node.name)
|
||||||
|
|
|
@ -56,7 +56,7 @@ def test_asyncio_server_start_serving(app):
|
||||||
|
|
||||||
def test_app_loop_not_running(app):
|
def test_app_loop_not_running(app):
|
||||||
with pytest.raises(SanicException) as excinfo:
|
with pytest.raises(SanicException) as excinfo:
|
||||||
app.loop
|
_ = app.loop
|
||||||
|
|
||||||
assert str(excinfo.value) == (
|
assert str(excinfo.value) == (
|
||||||
"Loop can only be retrieved after the app has started "
|
"Loop can only be retrieved after the app has started "
|
||||||
|
@ -140,7 +140,6 @@ def test_handle_request_with_nested_exception(app, monkeypatch):
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def handler(request):
|
def handler(request):
|
||||||
raise Exception
|
raise Exception
|
||||||
return text("OK")
|
|
||||||
|
|
||||||
request, response = app.test_client.get("/")
|
request, response = app.test_client.get("/")
|
||||||
assert response.status == 500
|
assert response.status == 500
|
||||||
|
@ -162,7 +161,6 @@ def test_handle_request_with_nested_exception_debug(app, monkeypatch):
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def handler(request):
|
def handler(request):
|
||||||
raise Exception
|
raise Exception
|
||||||
return text("OK")
|
|
||||||
|
|
||||||
request, response = app.test_client.get("/", debug=True)
|
request, response = app.test_client.get("/", debug=True)
|
||||||
assert response.status == 500
|
assert response.status == 500
|
||||||
|
@ -186,7 +184,6 @@ def test_handle_request_with_nested_sanic_exception(app, monkeypatch, caplog):
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def handler(request):
|
def handler(request):
|
||||||
raise Exception
|
raise Exception
|
||||||
return text("OK")
|
|
||||||
|
|
||||||
with caplog.at_level(logging.ERROR):
|
with caplog.at_level(logging.ERROR):
|
||||||
request, response = app.test_client.get("/")
|
request, response = app.test_client.get("/")
|
||||||
|
|
|
@ -144,9 +144,10 @@ def test_overwrite_exisiting_config_ignore_lowercase(app):
|
||||||
|
|
||||||
|
|
||||||
def test_missing_config(app):
|
def test_missing_config(app):
|
||||||
with pytest.raises(AttributeError) as e:
|
with pytest.raises(
|
||||||
app.config.NON_EXISTENT
|
AttributeError, match="Config has no 'NON_EXISTENT'"
|
||||||
assert str(e.value) == ("Config has no 'NON_EXISTENT'")
|
) as e:
|
||||||
|
_ = app.config.NON_EXISTENT
|
||||||
|
|
||||||
|
|
||||||
def test_config_defaults():
|
def test_config_defaults():
|
||||||
|
|
|
@ -100,7 +100,7 @@ def test_cookie_deletion(app):
|
||||||
|
|
||||||
assert int(response_cookies["i_want_to_die"]["max-age"]) == 0
|
assert int(response_cookies["i_want_to_die"]["max-age"]) == 0
|
||||||
with pytest.raises(KeyError):
|
with pytest.raises(KeyError):
|
||||||
response.cookies["i_never_existed"]
|
_ = response.cookies["i_never_existed"]
|
||||||
|
|
||||||
|
|
||||||
def test_cookie_reserved_cookie():
|
def test_cookie_reserved_cookie():
|
||||||
|
|
|
@ -39,5 +39,5 @@ def test_overload_dynamic_routes_exist(app):
|
||||||
with pytest.raises(RouteExists):
|
with pytest.raises(RouteExists):
|
||||||
|
|
||||||
@app.route("/overload/<param>", methods=["PUT", "DELETE"])
|
@app.route("/overload/<param>", methods=["PUT", "DELETE"])
|
||||||
async def handler3(request):
|
async def handler3(request, param):
|
||||||
return text("Duplicated")
|
return text("Duplicated")
|
||||||
|
|
|
@ -74,7 +74,7 @@ def exception_app():
|
||||||
|
|
||||||
@app.route("/divide_by_zero")
|
@app.route("/divide_by_zero")
|
||||||
def handle_unhandled_exception(request):
|
def handle_unhandled_exception(request):
|
||||||
1 / 0
|
_ = 1 / 0
|
||||||
|
|
||||||
@app.route("/error_in_error_handler_handler")
|
@app.route("/error_in_error_handler_handler")
|
||||||
def custom_error_handler(request):
|
def custom_error_handler(request):
|
||||||
|
@ -82,7 +82,7 @@ def exception_app():
|
||||||
|
|
||||||
@app.exception(SanicExceptionTestException)
|
@app.exception(SanicExceptionTestException)
|
||||||
def error_in_error_handler_handler(request, exception):
|
def error_in_error_handler_handler(request, exception):
|
||||||
1 / 0
|
_ = 1 / 0
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
|
@ -279,9 +279,12 @@ def test_request_stream_blueprint(app):
|
||||||
if body is None:
|
if body is None:
|
||||||
break
|
break
|
||||||
await response.write(body.decode("utf-8"))
|
await response.write(body.decode("utf-8"))
|
||||||
|
|
||||||
return stream(streaming)
|
return stream(streaming)
|
||||||
|
|
||||||
bp.add_route(post_add_route, '/post/add_route', methods=['POST'], stream=True)
|
bp.add_route(
|
||||||
|
post_add_route, "/post/add_route", methods=["POST"], stream=True
|
||||||
|
)
|
||||||
app.blueprint(bp)
|
app.blueprint(bp)
|
||||||
|
|
||||||
assert app.is_request_stream is True
|
assert app.is_request_stream is True
|
||||||
|
|
|
@ -134,7 +134,7 @@ def test_query_string(app):
|
||||||
|
|
||||||
def test_uri_template(app):
|
def test_uri_template(app):
|
||||||
@app.route("/foo/<id:int>/bar/<name:[A-z]+>")
|
@app.route("/foo/<id:int>/bar/<name:[A-z]+>")
|
||||||
async def handler(request):
|
async def handler(request, id, name):
|
||||||
return text("OK")
|
return text("OK")
|
||||||
|
|
||||||
request, response = app.test_client.get("/foo/123/bar/baz")
|
request, response = app.test_client.get("/foo/123/bar/baz")
|
||||||
|
|
|
@ -169,8 +169,8 @@ def test_fails_with_int_message(app):
|
||||||
app.url_for("fail", **failing_kwargs)
|
app.url_for("fail", **failing_kwargs)
|
||||||
|
|
||||||
expected_error = (
|
expected_error = (
|
||||||
'Value "not_int" for parameter `foo` '
|
r'Value "not_int" for parameter `foo` '
|
||||||
"does not match pattern for type `int`: -?\d+"
|
r'does not match pattern for type `int`: -?\d+'
|
||||||
)
|
)
|
||||||
assert str(e.value) == expected_error
|
assert str(e.value) == expected_error
|
||||||
|
|
||||||
|
|
5
tox.ini
5
tox.ini
|
@ -16,6 +16,7 @@ deps =
|
||||||
chardet<=2.3.0
|
chardet<=2.3.0
|
||||||
beautifulsoup4
|
beautifulsoup4
|
||||||
gunicorn
|
gunicorn
|
||||||
|
pytest-benchmark
|
||||||
commands =
|
commands =
|
||||||
pytest tests --cov sanic --cov-report= {posargs}
|
pytest tests --cov sanic --cov-report= {posargs}
|
||||||
- coverage combine --append
|
- coverage combine --append
|
||||||
|
@ -39,3 +40,7 @@ deps =
|
||||||
pygments
|
pygments
|
||||||
commands =
|
commands =
|
||||||
python setup.py check -r -s
|
python setup.py check -r -s
|
||||||
|
|
||||||
|
[pytest]
|
||||||
|
filterwarnings =
|
||||||
|
ignore:.*async with lock.* instead:DeprecationWarning
|
||||||
|
|
Loading…
Reference in New Issue
Block a user