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:
Harsha Narayana 2019-02-28 20:26:41 +05:30 committed by Stephen Sadowski
parent 8a59907319
commit 34fe26e51b
11 changed files with 192 additions and 15 deletions

View 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

View File

@ -1,12 +1,130 @@
import random
import re
import string
import sys
import uuid
import pytest
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"]:
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
def app(request):
return Sanic(request.node.name)

View File

@ -56,7 +56,7 @@ def test_asyncio_server_start_serving(app):
def test_app_loop_not_running(app):
with pytest.raises(SanicException) as excinfo:
app.loop
_ = app.loop
assert str(excinfo.value) == (
"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("/")
def handler(request):
raise Exception
return text("OK")
request, response = app.test_client.get("/")
assert response.status == 500
@ -162,7 +161,6 @@ def test_handle_request_with_nested_exception_debug(app, monkeypatch):
@app.get("/")
def handler(request):
raise Exception
return text("OK")
request, response = app.test_client.get("/", debug=True)
assert response.status == 500
@ -186,7 +184,6 @@ def test_handle_request_with_nested_sanic_exception(app, monkeypatch, caplog):
@app.get("/")
def handler(request):
raise Exception
return text("OK")
with caplog.at_level(logging.ERROR):
request, response = app.test_client.get("/")

View File

@ -144,9 +144,10 @@ def test_overwrite_exisiting_config_ignore_lowercase(app):
def test_missing_config(app):
with pytest.raises(AttributeError) as e:
app.config.NON_EXISTENT
assert str(e.value) == ("Config has no 'NON_EXISTENT'")
with pytest.raises(
AttributeError, match="Config has no 'NON_EXISTENT'"
) as e:
_ = app.config.NON_EXISTENT
def test_config_defaults():

View File

@ -100,7 +100,7 @@ def test_cookie_deletion(app):
assert int(response_cookies["i_want_to_die"]["max-age"]) == 0
with pytest.raises(KeyError):
response.cookies["i_never_existed"]
_ = response.cookies["i_never_existed"]
def test_cookie_reserved_cookie():

View File

@ -39,5 +39,5 @@ def test_overload_dynamic_routes_exist(app):
with pytest.raises(RouteExists):
@app.route("/overload/<param>", methods=["PUT", "DELETE"])
async def handler3(request):
async def handler3(request, param):
return text("Duplicated")

View File

@ -74,7 +74,7 @@ def exception_app():
@app.route("/divide_by_zero")
def handle_unhandled_exception(request):
1 / 0
_ = 1 / 0
@app.route("/error_in_error_handler_handler")
def custom_error_handler(request):
@ -82,7 +82,7 @@ def exception_app():
@app.exception(SanicExceptionTestException)
def error_in_error_handler_handler(request, exception):
1 / 0
_ = 1 / 0
return app

View File

@ -279,9 +279,12 @@ def test_request_stream_blueprint(app):
if body is None:
break
await response.write(body.decode("utf-8"))
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)
assert app.is_request_stream is True

View File

@ -134,7 +134,7 @@ def test_query_string(app):
def test_uri_template(app):
@app.route("/foo/<id:int>/bar/<name:[A-z]+>")
async def handler(request):
async def handler(request, id, name):
return text("OK")
request, response = app.test_client.get("/foo/123/bar/baz")

View File

@ -169,8 +169,8 @@ def test_fails_with_int_message(app):
app.url_for("fail", **failing_kwargs)
expected_error = (
'Value "not_int" for parameter `foo` '
"does not match pattern for type `int`: -?\d+"
r'Value "not_int" for parameter `foo` '
r'does not match pattern for type `int`: -?\d+'
)
assert str(e.value) == expected_error

View File

@ -16,6 +16,7 @@ deps =
chardet<=2.3.0
beautifulsoup4
gunicorn
pytest-benchmark
commands =
pytest tests --cov sanic --cov-report= {posargs}
- coverage combine --append
@ -39,3 +40,7 @@ deps =
pygments
commands =
python setup.py check -r -s
[pytest]
filterwarnings =
ignore:.*async with lock.* instead:DeprecationWarning