From c671d83921fcd6cb75f95cc0b5469285b8b6ab7a Mon Sep 17 00:00:00 2001 From: Kien Dang Date: Fri, 30 Dec 2022 15:35:04 +0800 Subject: [PATCH 1/6] fix: asynchronously gather execution results instead of awaiting them sequentially --- graphql_server/aiohttp/graphqlview.py | 17 ++++++++++++----- graphql_server/quart/graphqlview.py | 17 ++++++++++++----- graphql_server/sanic/graphqlview.py | 19 ++++++++++++------- graphql_server/utils.py | 25 +++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 17 deletions(-) create mode 100644 graphql_server/utils.py diff --git a/graphql_server/aiohttp/graphqlview.py b/graphql_server/aiohttp/graphqlview.py index d98becd..5c3ec8f 100644 --- a/graphql_server/aiohttp/graphqlview.py +++ b/graphql_server/aiohttp/graphqlview.py @@ -1,10 +1,12 @@ +import asyncio import copy from collections.abc import MutableMapping from functools import partial from typing import List from aiohttp import web -from graphql import ExecutionResult, GraphQLError, specified_rules +from graphql import GraphQLError, specified_rules +from graphql.pyutils import is_awaitable from graphql.type.schema import GraphQLSchema from graphql_server import ( @@ -22,6 +24,7 @@ GraphiQLOptions, render_graphiql_async, ) +from graphql_server.utils import ensure_async class GraphQLView: @@ -161,10 +164,14 @@ async def __call__(self, request): ) exec_res = ( - [ - ex if ex is None or isinstance(ex, ExecutionResult) else await ex - for ex in execution_results - ] + await asyncio.gather( + *( + ex + if ex is not None and is_awaitable(ex) + else ensure_async(lambda: ex)() + for ex in execution_results + ) + ) if self.enable_async else execution_results ) diff --git a/graphql_server/quart/graphqlview.py b/graphql_server/quart/graphqlview.py index 107cfdc..ef64307 100644 --- a/graphql_server/quart/graphqlview.py +++ b/graphql_server/quart/graphqlview.py @@ -1,10 +1,12 @@ +import asyncio import copy from collections.abc import MutableMapping from functools import partial from typing import List -from graphql import ExecutionResult, specified_rules +from graphql import specified_rules from graphql.error import GraphQLError +from graphql.pyutils import is_awaitable from graphql.type.schema import GraphQLSchema from quart import Response, render_template_string, request from quart.views import View @@ -24,6 +26,7 @@ GraphiQLOptions, render_graphiql_sync, ) +from graphql_server.utils import ensure_async class GraphQLView(View): @@ -108,10 +111,14 @@ async def dispatch_request(self): validation_rules=self.get_validation_rules(), ) exec_res = ( - [ - ex if ex is None or isinstance(ex, ExecutionResult) else await ex - for ex in execution_results - ] + await asyncio.gather( + *( + ex + if ex is not None and is_awaitable(ex) + else ensure_async(lambda: ex)() + for ex in execution_results + ) + ) if self.enable_async else execution_results ) diff --git a/graphql_server/sanic/graphqlview.py b/graphql_server/sanic/graphqlview.py index 8604e33..af03f11 100644 --- a/graphql_server/sanic/graphqlview.py +++ b/graphql_server/sanic/graphqlview.py @@ -1,10 +1,12 @@ +import asyncio import copy from cgi import parse_header from collections.abc import MutableMapping from functools import partial from typing import List -from graphql import ExecutionResult, GraphQLError, specified_rules +from graphql import GraphQLError, specified_rules +from graphql.pyutils import is_awaitable from graphql.type.schema import GraphQLSchema from sanic.response import HTTPResponse, html from sanic.views import HTTPMethodView @@ -24,6 +26,7 @@ GraphiQLOptions, render_graphiql_async, ) +from graphql_server.utils import ensure_async class GraphQLView(HTTPMethodView): @@ -114,12 +117,14 @@ async def __handle_request(self, request, *args, **kwargs): validation_rules=self.get_validation_rules(), ) exec_res = ( - [ - ex - if ex is None or isinstance(ex, ExecutionResult) - else await ex - for ex in execution_results - ] + await asyncio.gather( + *( + ex + if ex is not None and is_awaitable(ex) + else ensure_async(lambda: ex)() + for ex in execution_results + ) + ) if self.enable_async else execution_results ) diff --git a/graphql_server/utils.py b/graphql_server/utils.py new file mode 100644 index 0000000..ec9c6bc --- /dev/null +++ b/graphql_server/utils.py @@ -0,0 +1,25 @@ +import sys +from typing import Awaitable, Callable, TypeVar + +if sys.version_info >= (3, 10): + from typing import ParamSpec +else: + from typing_extensions import ParamSpec + + +__all__ = ["ensure_async"] + +P = ParamSpec("P") +R = TypeVar("R") + + +def ensure_async(f: Callable[P, R]) -> Callable[P, Awaitable[R]]: + """Convert a sync callable (normal def or lambda) to a coroutine (async def). + + This is similar to asyncio.coroutine which was deprecated in Python 3.8. + """ + + async def f_async(*args: P.args, **kwargs: P.kwargs) -> R: + return f(*args, **kwargs) + + return f_async From 914791e5d2167ecc45fd7dce26e62bd551efa570 Mon Sep 17 00:00:00 2001 From: Kien Dang Date: Sun, 1 Jan 2023 22:10:54 +0800 Subject: [PATCH 2/6] chore: add async schema test for quart --- tests/quart/app.py | 4 +-- tests/quart/schema.py | 50 ++++++++++++++++++++++++++++++++- tests/quart/test_graphqlview.py | 15 ++++++++++ 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/tests/quart/app.py b/tests/quart/app.py index 2313f99..adfce41 100644 --- a/tests/quart/app.py +++ b/tests/quart/app.py @@ -4,11 +4,11 @@ from tests.quart.schema import Schema -def create_app(path="/graphql", **kwargs): +def create_app(path="/graphql", schema=Schema, **kwargs): server = Quart(__name__) server.debug = True server.add_url_rule( - path, view_func=GraphQLView.as_view("graphql", schema=Schema, **kwargs) + path, view_func=GraphQLView.as_view("graphql", schema=schema, **kwargs) ) return server diff --git a/tests/quart/schema.py b/tests/quart/schema.py index eb51e26..7aca9d8 100644 --- a/tests/quart/schema.py +++ b/tests/quart/schema.py @@ -1,3 +1,5 @@ +import asyncio + from graphql.type.definition import ( GraphQLArgument, GraphQLField, @@ -12,6 +14,7 @@ def resolve_raises(*_): raise Exception("Throws!") +# Sync schema QueryRootType = GraphQLObjectType( name="QueryRoot", fields={ @@ -36,7 +39,7 @@ def resolve_raises(*_): "test": GraphQLField( type_=GraphQLString, args={"who": GraphQLArgument(GraphQLString)}, - resolve=lambda obj, info, who="World": "Hello %s" % who, + resolve=lambda obj, info, who="World": f"Hello {who}", ), }, ) @@ -49,3 +52,48 @@ def resolve_raises(*_): ) Schema = GraphQLSchema(QueryRootType, MutationRootType) + + +# Schema with async methods +async def resolver_field_async_1(_obj, info): + await asyncio.sleep(0.001) + return "hey" + + +async def resolver_field_async_2(_obj, info): + await asyncio.sleep(0.003) + return "hey2" + + +def resolver_field_sync(_obj, info): + return "hey3" + + +AsyncQueryType = GraphQLObjectType( + name="AsyncQueryType", + fields={ + "a": GraphQLField(GraphQLString, resolve=resolver_field_async_1), + "b": GraphQLField(GraphQLString, resolve=resolver_field_async_2), + "c": GraphQLField(GraphQLString, resolve=resolver_field_sync), + }, +) + + +def resolver_field_sync_1(_obj, info): + return "synced_one" + + +def resolver_field_sync_2(_obj, info): + return "synced_two" + + +SyncQueryType = GraphQLObjectType( + "SyncQueryType", + { + "a": GraphQLField(GraphQLString, resolve=resolver_field_sync_1), + "b": GraphQLField(GraphQLString, resolve=resolver_field_sync_2), + }, +) + +AsyncSchema = GraphQLSchema(AsyncQueryType) +SyncSchema = GraphQLSchema(SyncQueryType) diff --git a/tests/quart/test_graphqlview.py b/tests/quart/test_graphqlview.py index 8e0833c..7b83c14 100644 --- a/tests/quart/test_graphqlview.py +++ b/tests/quart/test_graphqlview.py @@ -8,6 +8,7 @@ from werkzeug.datastructures import Headers from .app import create_app +from .schema import AsyncSchema @pytest.fixture @@ -733,3 +734,17 @@ async def test_batch_allows_post_with_operation_name( assert response_json(result) == [ {"data": {"test": "Hello World", "shared": "Hello Everyone"}} ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(schema=AsyncSchema, enable_async=True)]) +async def test_async_schema(app, client): + response = await execute_client( + app, + client, + query="{a,b,c}", + ) + + assert response.status_code == 200 + result = await response.get_data(as_text=True) + assert response_json(result) == {"data": {"a": "hey", "b": "hey2", "c": "hey3"}} From 2bfa6efbbfcdd78c79ae84040d8bdf6a7c0ad5ec Mon Sep 17 00:00:00 2001 From: Kien Dang Date: Sun, 1 Jan 2023 22:19:27 +0800 Subject: [PATCH 3/6] chore: isort --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 992b980..bf3f24f 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,6 @@ from re import search -from setuptools import setup, find_packages + +from setuptools import find_packages, setup install_requires = [ "graphql-core>=3.2,<3.3", From 28d0cac68abafd4ec29964c0579923e9d2d38c11 Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Tue, 3 Jan 2023 21:40:44 +0100 Subject: [PATCH 4/6] fix: resolve merge conflict --- tests/quart/test_graphqlview.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/quart/test_graphqlview.py b/tests/quart/test_graphqlview.py index 0e9e093..9414da3 100644 --- a/tests/quart/test_graphqlview.py +++ b/tests/quart/test_graphqlview.py @@ -744,7 +744,10 @@ async def test_async_schema(app, client): app, client, query="{a,b,c}", - + ) + + assert response.status_code == 200 + result = await response.get_data(as_text=True) assert response_json(result) == {"data": {"a": "hey", "b": "hey2", "c": "hey3"}} @pytest.mark.asyncio @@ -758,7 +761,6 @@ async def test_custom_execution_context_class(app: Quart, client: TestClientProt method="POST", data=json_dump_kwarg(query="{test}"), headers=Headers({"Content-Type": "application/json"}), - ) assert response.status_code == 200 From bf800d3a6842a53c664d5a8a56f32a20564a3c7d Mon Sep 17 00:00:00 2001 From: Kien Dang Date: Wed, 4 Jan 2023 06:17:54 +0800 Subject: [PATCH 5/6] chore: black --- tests/quart/test_graphqlview.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/quart/test_graphqlview.py b/tests/quart/test_graphqlview.py index 9414da3..9b2daa2 100644 --- a/tests/quart/test_graphqlview.py +++ b/tests/quart/test_graphqlview.py @@ -750,6 +750,7 @@ async def test_async_schema(app, client): result = await response.get_data(as_text=True) assert response_json(result) == {"data": {"a": "hey", "b": "hey2", "c": "hey3"}} + @pytest.mark.asyncio @pytest.mark.parametrize( "app", [create_app(execution_context_class=RepeatExecutionContext)] From 34497ccab125d0d6e58be1ae49e0ff9c3bad486d Mon Sep 17 00:00:00 2001 From: Kien Dang Date: Wed, 4 Jan 2023 10:24:31 +0800 Subject: [PATCH 6/6] chore: better name for dummy async wrapper 'ensure_async' kinda implies it also accepts passing in an async function and returns as is. since we only accepts passing in a normal def function here 'wrap_in_async' is a better name. --- graphql_server/aiohttp/graphqlview.py | 4 ++-- graphql_server/quart/graphqlview.py | 4 ++-- graphql_server/sanic/graphqlview.py | 4 ++-- graphql_server/utils.py | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/graphql_server/aiohttp/graphqlview.py b/graphql_server/aiohttp/graphqlview.py index 6793ee9..b5891d1 100644 --- a/graphql_server/aiohttp/graphqlview.py +++ b/graphql_server/aiohttp/graphqlview.py @@ -24,7 +24,7 @@ GraphiQLOptions, render_graphiql_async, ) -from graphql_server.utils import ensure_async +from graphql_server.utils import wrap_in_async class GraphQLView: @@ -173,7 +173,7 @@ async def __call__(self, request): *( ex if ex is not None and is_awaitable(ex) - else ensure_async(lambda: ex)() + else wrap_in_async(lambda: ex)() for ex in execution_results ) ) diff --git a/graphql_server/quart/graphqlview.py b/graphql_server/quart/graphqlview.py index a29e203..d7b209f 100644 --- a/graphql_server/quart/graphqlview.py +++ b/graphql_server/quart/graphqlview.py @@ -26,7 +26,7 @@ GraphiQLOptions, render_graphiql_sync, ) -from graphql_server.utils import ensure_async +from graphql_server.utils import wrap_in_async class GraphQLView(View): @@ -120,7 +120,7 @@ async def dispatch_request(self): *( ex if ex is not None and is_awaitable(ex) - else ensure_async(lambda: ex)() + else wrap_in_async(lambda: ex)() for ex in execution_results ) ) diff --git a/graphql_server/sanic/graphqlview.py b/graphql_server/sanic/graphqlview.py index 490b65c..814d489 100644 --- a/graphql_server/sanic/graphqlview.py +++ b/graphql_server/sanic/graphqlview.py @@ -26,7 +26,7 @@ GraphiQLOptions, render_graphiql_async, ) -from graphql_server.utils import ensure_async +from graphql_server.utils import wrap_in_async class GraphQLView(HTTPMethodView): @@ -126,7 +126,7 @@ async def __handle_request(self, request, *args, **kwargs): *( ex if ex is not None and is_awaitable(ex) - else ensure_async(lambda: ex)() + else wrap_in_async(lambda: ex)() for ex in execution_results ) ) diff --git a/graphql_server/utils.py b/graphql_server/utils.py index ec9c6bc..c52a24b 100644 --- a/graphql_server/utils.py +++ b/graphql_server/utils.py @@ -7,13 +7,13 @@ from typing_extensions import ParamSpec -__all__ = ["ensure_async"] +__all__ = ["wrap_in_async"] P = ParamSpec("P") R = TypeVar("R") -def ensure_async(f: Callable[P, R]) -> Callable[P, Awaitable[R]]: +def wrap_in_async(f: Callable[P, R]) -> Callable[P, Awaitable[R]]: """Convert a sync callable (normal def or lambda) to a coroutine (async def). This is similar to asyncio.coroutine which was deprecated in Python 3.8.