Skip to content

Commit

Permalink
Add Route Resolution Benchmarking to Unit Test (#1499)
Browse files Browse the repository at this point in the history
* feat: add benchmark tester for route resolution and cleanup test warnings

Signed-off-by: Harsha Narayana <[email protected]>

* feat: refactor sanic benchmark test util into fixtures

Signed-off-by: Harsha Narayana <[email protected]>
  • Loading branch information
harshanarayana authored and sjsadowski committed Feb 28, 2019
1 parent 8a59907 commit 34fe26e
Show file tree
Hide file tree
Showing 11 changed files with 192 additions and 15 deletions.
53 changes: 53 additions & 0 deletions tests/benchmark/test_route_resolution_benchmark.py
Original file line number Diff line number Diff line change
@@ -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
118 changes: 118 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 1 addition & 4 deletions tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand Down Expand Up @@ -140,7 +140,6 @@ def mock_error_handler_response(*args, **kwargs):
@app.get("/")
def handler(request):
raise Exception
return text("OK")

request, response = app.test_client.get("/")
assert response.status == 500
Expand All @@ -162,7 +161,6 @@ def mock_error_handler_response(*args, **kwargs):
@app.get("/")
def handler(request):
raise Exception
return text("OK")

request, response = app.test_client.get("/", debug=True)
assert response.status == 500
Expand All @@ -186,7 +184,6 @@ def mock_error_handler_response(*args, **kwargs):
@app.get("/")
def handler(request):
raise Exception
return text("OK")

with caplog.at_level(logging.ERROR):
request, response = app.test_client.get("/")
Expand Down
7 changes: 4 additions & 3 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,10 @@ class Config:


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():
Expand Down
2 changes: 1 addition & 1 deletion tests/test_cookies.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ def handler(request):

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():
Expand Down
2 changes: 1 addition & 1 deletion tests/test_dynamic_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,5 @@ async def handler2(request, param):
with pytest.raises(RouteExists):

@app.route("/overload/<param>", methods=["PUT", "DELETE"])
async def handler3(request):
async def handler3(request, param):
return text("Duplicated")
4 changes: 2 additions & 2 deletions tests/test_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,15 +74,15 @@ def handler_abort_message(request):

@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):
raise SanicExceptionTestException("Dummy message!")

@app.exception(SanicExceptionTestException)
def error_in_error_handler_handler(request, exception):
1 / 0
_ = 1 / 0

return app

Expand Down
5 changes: 4 additions & 1 deletion tests/test_request_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,9 +279,12 @@ async def streaming(response):
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
Expand Down
2 changes: 1 addition & 1 deletion tests/test_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ async def handler(request):

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")
Expand Down
4 changes: 2 additions & 2 deletions tests/test_url_building.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,8 +169,8 @@ def fail(request):
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

Expand Down
5 changes: 5 additions & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ deps =
chardet<=2.3.0
beautifulsoup4
gunicorn
pytest-benchmark
commands =
pytest tests --cov sanic --cov-report= {posargs}
- coverage combine --append
Expand All @@ -39,3 +40,7 @@ deps =
pygments
commands =
python setup.py check -r -s

[pytest]
filterwarnings =
ignore:.*async with lock.* instead:DeprecationWarning

0 comments on commit 34fe26e

Please sign in to comment.