diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8463ac00..e6a96095 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,17 +11,17 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "pypy3.10"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "pypy3.10"] os: [ubuntu-24.04, windows-latest] exclude: - - os: windows-latest - python-version: "3.9" - os: windows-latest python-version: "3.10" - os: windows-latest python-version: "3.11" - os: windows-latest python-version: "3.13" + - os: windows-latest + python-version: "3.14" - os: windows-latest python-version: "pypy3.10" diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 63eed863..4b36b5af 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -7,9 +7,9 @@ version: 2 # Set the version of Python and other tools you might need build: - os: ubuntu-20.04 + os: ubuntu-24.04 tools: - python: "3.9" + python: "3.14" # Build documentation in the docs/ directory with Sphinx sphinx: diff --git a/README.md b/README.md index 86f380f3..f5fddad4 100644 --- a/README.md +++ b/README.md @@ -30,13 +30,14 @@ The complete documentation for GQL can be found at ## Features -* Execute GraphQL queries using [different protocols](https://gql.readthedocs.io/en/latest/transports/index.html): +* Execute GraphQL requests using [different protocols](https://gql.readthedocs.io/en/latest/transports/index.html): * http + * including the multipart protocol for subscriptions * websockets: * apollo or graphql-ws protocol * Phoenix channels - * AWS AppSync realtime protocol (experimental) -* Possibility to [validate the queries locally](https://gql.readthedocs.io/en/latest/usage/validation.html) using a GraphQL schema provided locally or fetched from the backend using an instrospection query + * AWS AppSync realtime protocol +* Possibility to [validate the requests locally](https://gql.readthedocs.io/en/latest/usage/validation.html) using a GraphQL schema provided locally or fetched from the backend using an instrospection query * Supports GraphQL queries, mutations and [subscriptions](https://gql.readthedocs.io/en/latest/usage/subscriptions.html) * Supports [sync](https://gql.readthedocs.io/en/latest/usage/sync_usage.html) or [async](https://gql.readthedocs.io/en/latest/usage/async_usage.html) usage, [allowing concurrent requests](https://gql.readthedocs.io/en/latest/advanced/async_advanced_usage.html#async-advanced-usage) * Supports [File uploads](https://gql.readthedocs.io/en/latest/usage/file_upload.html) diff --git a/docs/advanced/async_advanced_usage.rst b/docs/advanced/async_advanced_usage.rst index 4164cb37..78952d0f 100644 --- a/docs/advanced/async_advanced_usage.rst +++ b/docs/advanced/async_advanced_usage.rst @@ -6,7 +6,7 @@ Async advanced usage It is possible to send multiple GraphQL queries (query, mutation or subscription) in parallel, on the same websocket connection, using asyncio tasks. -In order to retry in case of connection failure, we can use the great `backoff`_ module. +In order to retry in case of connection failure, we can use the great `tenacity`_ module. .. code-block:: python @@ -28,10 +28,22 @@ In order to retry in case of connection failure, we can use the great `backoff`_ async for result in session.subscribe(subscription2): print(result) - # Then create a couroutine which will connect to your API and run all your queries as tasks. - # We use a `backoff` decorator to reconnect using exponential backoff in case of connection failure. - - @backoff.on_exception(backoff.expo, Exception, max_time=300) + # Then create a couroutine which will connect to your API and run all your + # queries as tasks. We use a `tenacity` retry decorator to reconnect using + # exponential backoff in case of connection failure. + + from tenacity import ( + retry, + retry_if_exception_type, + stop_after_delay, + wait_exponential, + ) + + @retry( + retry=retry_if_exception_type(Exception), + stop=stop_after_delay(300), # max_time in seconds + wait=wait_exponential(), + ) async def graphql_connection(): transport = WebsocketsTransport(url="wss://YOUR_URL") @@ -54,4 +66,4 @@ Subscriptions tasks can be stopped at any time by running task.cancel() -.. _backoff: https://github.com/litl/backoff +.. _tenacity: https://github.com/jd/tenacity diff --git a/docs/advanced/async_permanent_session.rst b/docs/advanced/async_permanent_session.rst index e42010cf..885d2fd2 100644 --- a/docs/advanced/async_permanent_session.rst +++ b/docs/advanced/async_permanent_session.rst @@ -36,19 +36,22 @@ Retries Connection retries ^^^^^^^^^^^^^^^^^^ -With :code:`reconnecting=True`, gql will use the `backoff`_ module to repeatedly try to connect with -exponential backoff and jitter with a maximum delay of 60 seconds by default. +With :code:`reconnecting=True`, gql will use the `tenacity`_ module to repeatedly +try to connect with exponential backoff and jitter with a maximum delay of +60 seconds by default. You can change the default reconnecting profile by providing your own -backoff decorator to the :code:`retry_connect` argument. +retry decorator (from tenacity) to the :code:`retry_connect` argument. .. code-block:: python + from tenacity import retry, retry_if_exception_type, wait_exponential + # Here wait maximum 5 minutes between connection retries - retry_connect = backoff.on_exception( - backoff.expo, # wait generator (here: exponential backoff) - Exception, # which exceptions should cause a retry (here: everything) - max_value=300, # max wait time in seconds + retry_connect = retry( + # which exceptions should cause a retry (here: everything) + retry=retry_if_exception_type(Exception), + wait=wait_exponential(max=300), # max wait time in seconds ) session = await client.connect_async( reconnecting=True, @@ -66,32 +69,49 @@ There is no retry in case of a :code:`TransportQueryError` exception as it indic the connection to the backend is working correctly. You can change the default execute retry profile by providing your own -backoff decorator to the :code:`retry_execute` argument. +retry decorator (from tenacity) to the :code:`retry_execute` argument. .. code-block:: python + from tenacity import ( + retry, + retry_if_exception_type, + stop_after_attempt, + wait_exponential, + ) + # Here Only 3 tries for execute calls - retry_execute = backoff.on_exception( - backoff.expo, - Exception, - max_tries=3, + retry_execute = retry( + retry=retry_if_exception_type(Exception), + stop=stop_after_attempt(3), + wait=wait_exponential(), ) session = await client.connect_async( reconnecting=True, retry_execute=retry_execute, ) -If you don't want any retry on the execute calls, you can disable the retries with :code:`retry_execute=False` +If you don't want any retry on the execute calls, you can disable the retries +with :code:`retry_execute=False` .. note:: If you want to retry even with :code:`TransportQueryError` exceptions, - then you need to make your own backoff decorator on your own method: + then you need to make your own retry decorator (from tenacity) on your own method: .. code-block:: python - @backoff.on_exception(backoff.expo, - Exception, - max_tries=3) + from tenacity import ( + retry, + retry_if_exception_type, + stop_after_attempt, + wait_exponential, + ) + + @retry( + retry=retry_if_exception_type(Exception), + stop=stop_after_attempt(3), + wait=wait_exponential(), + ) async def execute_with_retry(session, query): return await session.execute(query) @@ -100,14 +120,25 @@ Subscription retries There is no :code:`retry_subscribe` as it is not feasible with async generators. If you want retries for your subscriptions, then you can do it yourself -with backoff decorators on your methods. +with retry decorators (from tenacity) on your methods. .. code-block:: python - @backoff.on_exception(backoff.expo, - Exception, - max_tries=3, - giveup=lambda e: isinstance(e, TransportQueryError)) + from tenacity import ( + retry, + retry_if_exception_type, + retry_unless_exception_type, + stop_after_attempt, + wait_exponential, + ) + from gql.transport.exceptions import TransportQueryError + + @retry( + retry=retry_if_exception_type(Exception) + & retry_unless_exception_type(TransportQueryError), + stop=stop_after_attempt(3), + wait=wait_exponential(), + ) async def execute_subscription1(session): async for result in session.subscribe(subscription1): print(result) @@ -123,4 +154,4 @@ Console example .. literalinclude:: ../code_examples/console_async.py .. _difficult to manage: https://github.com/graphql-python/gql/issues/179 -.. _backoff: https://github.com/litl/backoff +.. _tenacity: https://github.com/jd/tenacity diff --git a/docs/advanced/dsl_module.rst b/docs/advanced/dsl_module.rst index 1c2c1c82..e30655b5 100644 --- a/docs/advanced/dsl_module.rst +++ b/docs/advanced/dsl_module.rst @@ -64,11 +64,11 @@ from the :code:`ds` instance ds.Query.hero.select(ds.Character.name) -The select method return the same instance, so it is possible to chain the calls:: +The select method returns the same instance, so it is possible to chain the calls:: ds.Query.hero.select(ds.Character.name).select(ds.Character.id) -Or do it sequencially:: +Or do it sequentially:: hero_query = ds.Query.hero @@ -279,7 +279,7 @@ will generate the request:: Multiple operations in a document ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -It is possible to create an Document with multiple operations:: +It is possible to create a Document with multiple operations:: query = dsl_gql( operation_name_1=DSLQuery( ... ), @@ -373,6 +373,14 @@ this can be written in a concise manner:: DSLInlineFragment().on(ds.Human).select(ds.Human.homePlanet) ) +Alternatively, you can use the DSL shortcut syntax to create an inline fragment by +passing the string ``"..."`` directly to the :meth:`__call__ ` method:: + + query_with_inline_fragment = ds.Query.hero.args(episode=6).select( + ds.Character.name, + ds("...").on(ds.Human).select(ds.Human.homePlanet) + ) + Meta-fields ^^^^^^^^^^^ @@ -384,6 +392,314 @@ you can use the :class:`DSLMetaField ` class:: DSLMetaField("__typename") ) +Alternatively, you can use the DSL shortcut syntax to create the same meta-field by +passing the ``"__typename"`` string directly to the :meth:`__call__ ` method:: + + query = ds.Query.hero.select( + ds.Character.name, + ds("__typename") + ) + + +Directives +^^^^^^^^^^ + +`Directives`_ provide a way to describe alternate runtime execution and type validation +behavior in a GraphQL document. The DSL module supports both built-in GraphQL directives +(:code:`@skip`, :code:`@include`) and custom schema-defined directives. + +To add directives to DSL elements, use the :meth:`DSLSchema.__call__ ` +factory method and the :meth:`directives ` method:: + + # Using built-in @skip directive with DSLSchema.__call__ factory + ds.Query.hero.select( + ds.Character.name.directives(ds("@skip").args(**{"if": True})) + ) + +Directive Arguments +""""""""""""""""""" + +Directive arguments can be passed using the :meth:`args ` method. +For arguments that don't conflict with Python reserved words, you can pass them directly:: + + # Using the args method for non-reserved names + ds("@custom").args(value="foo", reason="testing") + +It can also be done by calling the directive directly:: + + ds("@custom")(value="foo", reason="testing") + +However, when the GraphQL directive argument name conflicts with a Python reserved word +(like :code:`if`), you need to unpack a dictionary to escape it:: + + # Dictionary unpacking for Python reserved words + ds("@skip").args(**{"if": True}) + ds("@include")(**{"if": False}) + +This ensures that the exact GraphQL argument name is passed to the directive and that +no post-processing of arguments is required. + +The :meth:`DSLSchema.__call__ ` factory method automatically handles +schema lookup and validation for both built-in directives (:code:`@skip`, :code:`@include`) +and custom schema-defined directives using the same syntax. + +Directive Locations +""""""""""""""""""" + +The DSL module supports all executable directive locations from the GraphQL specification: + +.. list-table:: + :header-rows: 1 + :widths: 25 35 40 + + * - GraphQL Spec Location + - DSL Class/Method + - Description + * - QUERY + - :code:`DSLQuery.directives()` + - Directives on query operations + * - MUTATION + - :code:`DSLMutation.directives()` + - Directives on mutation operations + * - SUBSCRIPTION + - :code:`DSLSubscription.directives()` + - Directives on subscription operations + * - FIELD + - :code:`DSLField.directives()` + - Directives on fields (including meta-fields) + * - FRAGMENT_DEFINITION + - :code:`DSLFragment.directives()` + - Directives on fragment definitions + * - FRAGMENT_SPREAD + - :code:`DSLFragmentSpread.directives()` + - Directives on fragment spreads (via .spread()) + * - INLINE_FRAGMENT + - :code:`DSLInlineFragment.directives()` + - Directives on inline fragments + * - VARIABLE_DEFINITION + - :code:`DSLVariable.directives()` + - Directives on variable definitions + +Examples by Location +"""""""""""""""""""" + +**Operation directives**:: + + # Query operation + query = DSLQuery(ds.Query.hero.select(ds.Character.name)).directives( + ds("@customQueryDirective") + ) + + # Mutation operation + mutation = DSLMutation( + ds.Mutation.createReview.args(episode=6, review={"stars": 5}).select( + ds.Review.stars + ) + ).directives(ds("@customMutationDirective")) + +**Field directives**:: + + # Single directive on field + ds.Query.hero.select( + ds.Character.name.directives(ds("@customFieldDirective")) + ) + + # Multiple directives on a field + ds.Query.hero.select( + ds.Character.appearsIn.directives( + ds("@repeat").args(value="first"), + ds("@repeat").args(value="second"), + ds("@repeat").args(value="third"), + ) + ) + +**Fragment directives**: + +You can add directives to fragment definitions and to fragment spread instances. +To do this, first define your fragment in the usual way:: + + name_and_appearances = ( + DSLFragment("NameAndAppearances") + .on(ds.Character) + .select(ds.Character.name, ds.Character.appearsIn) + ) + +Then, use :meth:`spread() ` when you need to add +directives to the fragment spread:: + + query_with_fragment = DSLQuery( + ds.Query.hero.select( + name_and_appearances.spread().directives( + ds("@customFragmentSpreadDirective") + ) + ) + ) + +The :meth:`spread() ` method creates a +:class:`DSLFragmentSpread ` instance that allows you to add +directives specific to the fragment spread location, separate from directives on the +fragment definition itself. + +Example with fragment definition and spread-specific directives:: + + # Fragment definition with directive + name_and_appearances = ( + DSLFragment("CharacterInfo") + .on(ds.Character) + .select(ds.Character.name, ds.Character.appearsIn) + .directives(ds("@customFragmentDefinitionDirective")) + ) + + # Using fragment with spread-specific directives + query_without_spread_directive = DSLQuery( + # Direct usage (no spread directives) + ds.Query.hero.select(name_and_appearances) + ) + query_with_spread_directive = DSLQuery( + # Enhanced usage with spread directives + name_and_appearances.spread().directives( + ds("@customFragmentSpreadDirective") + ) + ) + + # Don't forget to include the fragment definition in dsl_gql + query = dsl_gql( + name_and_appearances, + BaseQuery=query_without_spread_directive, + QueryWithDirective=query_with_spread_directive, + ) + +This generates GraphQL equivalent to:: + + fragment CharacterInfo on Character @customFragmentDefinitionDirective { + name + appearsIn + } + + { + BaseQuery hero { + ...CharacterInfo + } + QueryWithDirective hero { + ...CharacterInfo @customFragmentSpreadDirective + } + } + +**Inline fragment directives**: + +Inline fragments also support directives using the +:meth:`directives ` method:: + + query_with_directive = ds.Query.hero.args(episode=6).select( + ds.Character.name, + DSLInlineFragment().on(ds.Human).select(ds.Human.homePlanet).directives( + ds("@customInlineFragmentDirective") + ) + ) + +This generates:: + + { + hero(episode: JEDI) { + name + ... on Human @customInlineFragmentDirective { + homePlanet + } + } + } + +**Variable definition directives**: + +You can also add directives to variable definitions using the +:meth:`directives ` method:: + + var = DSLVariableDefinitions() + var.episode.directives(ds("@customVariableDirective")) + # Note: the directive is attached to the `.episode` variable definition (singular), + # and not the `var` variable definitions (plural) holder. + + op = DSLQuery(ds.Query.hero.args(episode=var.episode).select(ds.Character.name)) + op.variable_definitions = var + +This will generate:: + + query ($episode: Episode @customVariableDirective) { + hero(episode: $episode) { + name + } + } + +Complete Example for Directives +""""""""""""""""""""""""""""""" + +Here's a comprehensive example showing directives on multiple locations: + +.. code-block:: python + + from gql.dsl import DSLFragment, DSLInlineFragment, DSLQuery, dsl_gql + + # Create variables for directive conditions + var = DSLVariableDefinitions() + + # Fragment with directive on definition + character_fragment = DSLFragment("CharacterInfo").on(ds.Character).select( + ds.Character.name, ds.Character.appearsIn + ).directives(ds("@fragmentDefinition")) + + # Query with directives on multiple locations + query = DSLQuery( + ds.Query.hero.args(episode=var.episode).select( + # Field with directive + ds.Character.name.directives(ds("@skip").args(**{"if": var.skipName})), + + # Fragment spread with directive + character_fragment.spread().directives( + ds("@include").args(**{"if": var.includeFragment}) + ), + + # Inline fragment with directive + DSLInlineFragment().on(ds.Human).select(ds.Human.homePlanet).directives( + ds("@skip").args(**{"if": var.skipHuman}) + ), + + # Meta field with directive + DSLMetaField("__typename").directives( + ds("@include").args(**{"if": var.includeType}) + ) + ) + ).directives(ds("@query")) # Operation directive + + # Variable definition with directive + var.episode.directives(ds("@variableDefinition")) + query.variable_definitions = var + + # Generate the document + document = dsl_gql(character_fragment, query) + +This generates GraphQL equivalent to:: + + fragment CharacterInfo on Character @fragmentDefinition { + name + appearsIn + } + + query ( + $episode: Episode @variableDefinition + $skipName: Boolean! + $includeFragment: Boolean! + $skipHuman: Boolean! + $includeType: Boolean! + ) @query { + hero(episode: $episode) { + name @skip(if: $skipName) + ...CharacterInfo @include(if: $includeFragment) + ... on Human @skip(if: $skipHuman) { + homePlanet + } + __typename @include(if: $includeType) + } + } + Executable examples ------------------- @@ -399,4 +715,5 @@ Sync example .. _Fragment: https://graphql.org/learn/queries/#fragments .. _Inline Fragment: https://graphql.org/learn/queries/#inline-fragments +.. _Directives: https://graphql.org/learn/queries/#directives .. _issue #308: https://github.com/graphql-python/gql/issues/308 diff --git a/docs/code_examples/aiohttp_async.py b/docs/code_examples/aiohttp_async.py index bc615fa8..6406b089 100644 --- a/docs/code_examples/aiohttp_async.py +++ b/docs/code_examples/aiohttp_async.py @@ -13,16 +13,14 @@ async def main(): client = Client(transport=transport) # Provide a GraphQL query - query = gql( - """ + query = gql(""" query getContinents { continents { code name } } - """ - ) + """) # Using `async with` on the client will start a connection on the transport # and provide a `session` variable to execute queries on this connection diff --git a/docs/code_examples/aiohttp_multipart_subscription.py b/docs/code_examples/aiohttp_multipart_subscription.py new file mode 100644 index 00000000..020ee0e4 --- /dev/null +++ b/docs/code_examples/aiohttp_multipart_subscription.py @@ -0,0 +1,35 @@ +import asyncio +import logging + +from gql import Client, gql +from gql.transport.aiohttp import AIOHTTPTransport + +logging.basicConfig(level=logging.INFO) + + +async def main(): + + transport = AIOHTTPTransport(url="https://gql-book-server.fly.dev/graphql") + + # Using `async with` on the client will start a connection on the transport + # and provide a `session` variable to execute queries on this connection + async with Client( + transport=transport, + ) as session: + + # Request subscription + subscription = gql(""" + subscription { + book { + title + author + } + } + """) + + # Subscribe and receive streaming updates + async for result in session.subscribe(subscription): + print(f"Received: {result}") + + +asyncio.run(main()) diff --git a/docs/code_examples/aiohttp_sync.py b/docs/code_examples/aiohttp_sync.py index 18dab8ae..c42e4d04 100644 --- a/docs/code_examples/aiohttp_sync.py +++ b/docs/code_examples/aiohttp_sync.py @@ -8,16 +8,14 @@ client = Client(transport=transport) # Provide a GraphQL query -query = gql( - """ +query = gql(""" query getContinents { continents { code name } } -""" -) +""") # Execute the query on the transport result = client.execute(query) diff --git a/docs/code_examples/aiohttp_websockets_async.py b/docs/code_examples/aiohttp_websockets_async.py index 69520053..e64a1002 100644 --- a/docs/code_examples/aiohttp_websockets_async.py +++ b/docs/code_examples/aiohttp_websockets_async.py @@ -20,29 +20,25 @@ async def main(): ) as session: # Execute single query - query = gql( - """ + query = gql(""" query getContinents { continents { code name } } - """ - ) + """) result = await session.execute(query) print(result) # Request subscription - subscription = gql( - """ + subscription = gql(""" subscription { somethingChanged { id } } - """ - ) + """) async for result in session.subscribe(subscription): print(result) diff --git a/docs/code_examples/appsync/mutation_api_key.py b/docs/code_examples/appsync/mutation_api_key.py index 47067aca..3d7c3958 100644 --- a/docs/code_examples/appsync/mutation_api_key.py +++ b/docs/code_examples/appsync/mutation_api_key.py @@ -35,16 +35,14 @@ async def main(): fetch_schema_from_transport=False, ) as session: - query = gql( - """ + query = gql(""" mutation createMessage($message: String!) { createMessage(input: {message: $message}) { id message createdAt } -}""" - ) +}""") query.variable_values = {"message": "Hello world!"} diff --git a/docs/code_examples/appsync/mutation_iam.py b/docs/code_examples/appsync/mutation_iam.py index efe9889b..a6a04d40 100644 --- a/docs/code_examples/appsync/mutation_iam.py +++ b/docs/code_examples/appsync/mutation_iam.py @@ -34,16 +34,14 @@ async def main(): fetch_schema_from_transport=False, ) as session: - query = gql( - """ + query = gql(""" mutation createMessage($message: String!) { createMessage(input: {message: $message}) { id message createdAt } -}""" - ) +}""") query.variable_values = {"message": "Hello world!"} diff --git a/docs/code_examples/appsync/subscription_api_key.py b/docs/code_examples/appsync/subscription_api_key.py index 87bb3611..e89898a5 100644 --- a/docs/code_examples/appsync/subscription_api_key.py +++ b/docs/code_examples/appsync/subscription_api_key.py @@ -34,15 +34,13 @@ async def main(): async with Client(transport=transport) as session: - subscription = gql( - """ + subscription = gql(""" subscription onCreateMessage { onCreateMessage { message } } -""" - ) +""") print("Waiting for messages...") diff --git a/docs/code_examples/appsync/subscription_iam.py b/docs/code_examples/appsync/subscription_iam.py index 1bb540d0..bdab546b 100644 --- a/docs/code_examples/appsync/subscription_iam.py +++ b/docs/code_examples/appsync/subscription_iam.py @@ -25,15 +25,13 @@ async def main(): async with Client(transport=transport) as session: - subscription = gql( - """ + subscription = gql(""" subscription onCreateMessage { onCreateMessage { message } } -""" - ) +""") print("Waiting for messages...") diff --git a/docs/code_examples/fastapi_async.py b/docs/code_examples/fastapi_async.py index 0b174fe5..2ef2016a 100644 --- a/docs/code_examples/fastapi_async.py +++ b/docs/code_examples/fastapi_async.py @@ -22,8 +22,7 @@ client = Client(transport=transport) -query = gql( - """ +query = gql(""" query getContinentInfo($code: ID!) { continent(code:$code) { name @@ -34,8 +33,7 @@ } } } -""" -) +""") app = FastAPI() diff --git a/docs/code_examples/httpx_async.py b/docs/code_examples/httpx_async.py index 9a01232d..87278c5a 100644 --- a/docs/code_examples/httpx_async.py +++ b/docs/code_examples/httpx_async.py @@ -16,16 +16,14 @@ async def main(): ) as session: # Execute single query - query = gql( - """ + query = gql(""" query getContinents { continents { code name } } - """ - ) + """) result = await session.execute(query) print(result) diff --git a/docs/code_examples/httpx_async_trio.py b/docs/code_examples/httpx_async_trio.py index 058b952b..9dba88c2 100644 --- a/docs/code_examples/httpx_async_trio.py +++ b/docs/code_examples/httpx_async_trio.py @@ -16,16 +16,14 @@ async def main(): ) as session: # Execute single query - query = gql( - """ + query = gql(""" query getContinents { continents { code name } } - """ - ) + """) result = await session.execute(query) print(result) diff --git a/docs/code_examples/httpx_sync.py b/docs/code_examples/httpx_sync.py index bd26f658..eea8f4ed 100644 --- a/docs/code_examples/httpx_sync.py +++ b/docs/code_examples/httpx_sync.py @@ -5,16 +5,14 @@ client = Client(transport=transport, fetch_schema_from_transport=True) -query = gql( - """ +query = gql(""" query getContinents { continents { code name } } -""" -) +""") result = client.execute(query) print(result) diff --git a/docs/code_examples/phoenix_channel_async.py b/docs/code_examples/phoenix_channel_async.py index 1fdc2566..f79714e4 100644 --- a/docs/code_examples/phoenix_channel_async.py +++ b/docs/code_examples/phoenix_channel_async.py @@ -15,13 +15,11 @@ async def main(): async with Client(transport=transport) as session: # Execute single query - query = gql( - """ + query = gql(""" query yourQuery { ... } - """ - ) + """) result = await session.execute(query) print(result) diff --git a/docs/code_examples/reconnecting_mutation_http.py b/docs/code_examples/reconnecting_mutation_http.py index 5deb5063..1eaf0111 100644 --- a/docs/code_examples/reconnecting_mutation_http.py +++ b/docs/code_examples/reconnecting_mutation_http.py @@ -1,7 +1,7 @@ import asyncio import logging -import backoff +from tenacity import retry, retry_if_exception_type, wait_exponential from gql import Client, gql from gql.transport.aiohttp import AIOHTTPTransport @@ -17,11 +17,9 @@ async def main(): client = Client(transport=transport) - retry_connect = backoff.on_exception( - backoff.expo, - Exception, - max_value=10, - jitter=None, + retry_connect = retry( + retry=retry_if_exception_type(Exception), + wait=wait_exponential(max=10), ) session = await client.connect_async(reconnecting=True, retry_connect=retry_connect) diff --git a/docs/code_examples/reconnecting_mutation_ws.py b/docs/code_examples/reconnecting_mutation_ws.py index d7e7cfe2..4d083d54 100644 --- a/docs/code_examples/reconnecting_mutation_ws.py +++ b/docs/code_examples/reconnecting_mutation_ws.py @@ -1,7 +1,7 @@ import asyncio import logging -import backoff +from tenacity import retry, retry_if_exception_type, wait_exponential from gql import Client, gql from gql.transport.websockets import WebsocketsTransport @@ -17,11 +17,9 @@ async def main(): client = Client(transport=transport) - retry_connect = backoff.on_exception( - backoff.expo, - Exception, - max_value=10, - jitter=None, + retry_connect = retry( + retry=retry_if_exception_type(Exception), + wait=wait_exponential(max=10), ) session = await client.connect_async(reconnecting=True, retry_connect=retry_connect) diff --git a/docs/code_examples/requests_sync.py b/docs/code_examples/requests_sync.py index 2184f286..a84acc54 100644 --- a/docs/code_examples/requests_sync.py +++ b/docs/code_examples/requests_sync.py @@ -9,16 +9,14 @@ client = Client(transport=transport, fetch_schema_from_transport=True) -query = gql( - """ +query = gql(""" query getContinents { continents { code name } } -""" -) +""") result = client.execute(query) print(result) diff --git a/docs/code_examples/websockets_async.py b/docs/code_examples/websockets_async.py index e645a7ef..c674abcd 100644 --- a/docs/code_examples/websockets_async.py +++ b/docs/code_examples/websockets_async.py @@ -19,29 +19,25 @@ async def main(): ) as session: # Execute single query - query = gql( - """ + query = gql(""" query getContinents { continents { code name } } - """ - ) + """) result = await session.execute(query) print(result) # Request subscription - subscription = gql( - """ + subscription = gql(""" subscription { somethingChanged { id } } - """ - ) + """) async for result in session.subscribe(subscription): print(result) diff --git a/docs/conf.py b/docs/conf.py index 024dd9e6..b3a2aed0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -93,6 +93,7 @@ nitpick_ignore = [ # graphql-core: should be fixed ('py:class', 'graphql.execution.execute.ExecutionResult'), + ('py:class', 'graphql.execution.incremental_publisher.ExecutionResult'), ('py:class', 'Source'), ('py:class', 'GraphQLSchema'), @@ -114,10 +115,13 @@ ('py:class', 'websockets.datastructures.SupportsKeysAndGetItem'), ('py:class', 'websockets.typing.Subprotocol'), - # httpx: no sphinx docs yet https://github.com/encode/httpx/discussions/3091 + # httpx/httpx2: no sphinx docs yet https://github.com/encode/httpx/discussions/3091 ('py:class', 'httpx.AsyncClient'), ('py:class', 'httpx.Client'), ('py:class', 'httpx.Headers'), + ('py:class', 'httpx2.AsyncClient'), + ('py:class', 'httpx2.Client'), + ('py:class', 'httpx2.Headers'), # botocore: no sphinx docs ('py:class', 'botocore.auth.BaseSigner'), diff --git a/docs/intro.rst b/docs/intro.rst index f47166f6..9e3cdd9f 100644 --- a/docs/intro.rst +++ b/docs/intro.rst @@ -51,7 +51,7 @@ The corresponding between extra dependencies required and the GQL classes is: +---------------------+------------------------------------------------------------------+ | requests | :ref:`RequestsHTTPTransport ` | +---------------------+------------------------------------------------------------------+ -| httpx | :ref:`HTTPTXTransport ` | +| httpx2 or httpx | :ref:`HTTPXTransport ` | | | | | | :ref:`HTTPXAsyncTransport ` | +---------------------+------------------------------------------------------------------+ diff --git a/docs/requirements.txt b/docs/requirements.txt index d9ce8ad1..690c3015 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,3 @@ -sphinx>=5.3.0,<6 -sphinx_rtd_theme>=0.4,<1 -sphinx-argparse==0.2.5 -multidict<5.0,>=4.5 +sphinx>=8.1.0,<9 +sphinx_rtd_theme>=3.0.2,<4 +sphinx-argparse==0.5.2 diff --git a/docs/transports/aiohttp.rst b/docs/transports/aiohttp.rst index b852108b..f8f93d64 100644 --- a/docs/transports/aiohttp.rst +++ b/docs/transports/aiohttp.rst @@ -7,15 +7,79 @@ This transport uses the `aiohttp`_ library and allows you to send GraphQL querie Reference: :class:`gql.transport.aiohttp.AIOHTTPTransport` -.. note:: +This transport supports both standard GraphQL operations (queries, mutations) and subscriptions. +Subscriptions are implemented using the `multipart subscription protocol`_ +as implemented by Apollo GraphOS Router and other compatible servers. - GraphQL subscriptions are not supported on the HTTP transport. - For subscriptions you should use a websockets transport: - :ref:`WebsocketsTransport ` or - :ref:`AIOHTTPWebsocketsTransport `. +This provides an HTTP-based alternative to WebSocket transports for receiving streaming +subscription updates. It's particularly useful when: + +- WebSocket connections are not available or blocked by infrastructure +- You want to use standard HTTP with existing load balancers and proxies +- The backend implements the multipart subscription protocol + +Queries +------- .. literalinclude:: ../code_examples/aiohttp_async.py +Subscriptions +------------- + +The transport sends a standard HTTP POST request with an ``Accept`` header indicating +support for multipart responses: + +.. code-block:: text + + Accept: multipart/mixed;subscriptionSpec="1.0", application/json + +The server responds with a ``multipart/mixed`` content type and streams subscription +updates as separate parts in the response body. Each part contains a JSON payload +with GraphQL execution results. + +.. literalinclude:: ../code_examples/aiohttp_multipart_subscription.py + +How It Works +^^^^^^^^^^^^ + +**Message Format** + +Each message part follows this structure: + +.. code-block:: text + + --graphql + Content-Type: application/json + + {"payload": {"data": {...}, "errors": [...]}} + +**Heartbeats** + +Servers may send empty JSON objects (``{}``) as heartbeat messages to keep the +connection alive. These are automatically filtered out by the transport. + +**Error Handling** + +The protocol distinguishes between two types of errors: + +- **GraphQL errors**: Returned within the ``payload`` property alongside data +- **Transport errors**: Returned with a top-level ``errors`` field and ``null`` payload + +**End of Stream** + +The subscription ends when the server sends the final boundary marker: + +.. code-block:: text + + --graphql-- + +Limitations +^^^^^^^^^^^ + +- Subscriptions require the server to implement the multipart subscription protocol +- Long-lived connections may be terminated by intermediate proxies or load balancers +- Some server configurations may not support HTTP/1.1 chunked transfer encoding required for streaming + Authentication -------------- @@ -52,3 +116,5 @@ and you can save these cookies in a cookie jar to reuse them in a following conn .. _aiohttp: https://docs.aiohttp.org .. _issue 197: https://github.com/graphql-python/gql/issues/197 +.. _multipart subscription protocol: https://www.apollographql.com/docs/graphos/routing/operations/subscriptions/multipart-protocol + diff --git a/docs/transports/httpx.rst b/docs/transports/httpx.rst index 25796621..7058b198 100644 --- a/docs/transports/httpx.rst +++ b/docs/transports/httpx.rst @@ -3,7 +3,7 @@ HTTPXTransport ============== -The HTTPXTransport is a sync transport using the `httpx`_ library +The HTTPXTransport is a sync transport using the `httpx2`_ or `httpx`_ library and allows you to send GraphQL queries using the HTTP protocol. Reference: :class:`gql.transport.httpx.HTTPXTransport` @@ -11,3 +11,4 @@ Reference: :class:`gql.transport.httpx.HTTPXTransport` .. literalinclude:: ../code_examples/httpx_sync.py .. _httpx: https://www.python-httpx.org +.. _httpx2: https://httpx2.pydantic.dev diff --git a/docs/transports/httpx_async.rst b/docs/transports/httpx_async.rst index c09d0cdc..2da0f204 100644 --- a/docs/transports/httpx_async.rst +++ b/docs/transports/httpx_async.rst @@ -3,7 +3,7 @@ HTTPXAsyncTransport =================== -This transport uses the `httpx`_ library and allows you to send GraphQL queries using the HTTP protocol. +This transport uses the `httpx2`_ or `httpx`_ library and allows you to send GraphQL queries using the HTTP protocol. Reference: :class:`gql.transport.httpx.HTTPXAsyncTransport` @@ -37,3 +37,4 @@ You can manually set the cookies which will be sent with each connection: transport = HTTPXAsyncTransport(url=url, cookies={"cookie1": "val1"}) .. _httpx: https://www.python-httpx.org +.. _httpx2: https://httpx2.pydantic.dev diff --git a/docs/usage/extensions.rst b/docs/usage/extensions.rst index ec413656..72924711 100644 --- a/docs/usage/extensions.rst +++ b/docs/usage/extensions.rst @@ -3,6 +3,38 @@ Extensions ---------- +Request extensions +^^^^^^^^^^^^^^^^^^ + +The `GraphQL over HTTP spec `_ +defines an optional :code:`extensions` field on requests. This is sent as a +top-level key in the request payload alongside :code:`query`, :code:`variables`, +and :code:`operationName`. + +You can use this to pass protocol extensions such as +`trusted documents `_: + +.. code-block:: python + + from gql import Client, GraphQLRequest + from gql.transport.aiohttp import AIOHTTPTransport + + transport = AIOHTTPTransport(url="https://example.com/graphql") + + async with Client(transport=transport) as session: + + request = GraphQLRequest( + "query { viewer { name } }", + extensions={ + "document-id": "155d6e8f5545...", + }, + ) + + result = await session.execute(request) + +Response extensions +^^^^^^^^^^^^^^^^^^^ + When you execute (or subscribe) GraphQL requests, the server will send responses which may have 3 fields: diff --git a/docs/usage/subscriptions.rst b/docs/usage/subscriptions.rst index 549054b9..59e732da 100644 --- a/docs/usage/subscriptions.rst +++ b/docs/usage/subscriptions.rst @@ -69,7 +69,7 @@ Async async with client as session: # Then get the results using 'async for' - async for result in client.subscribe(query): + async for result in session.subscribe(query): print (result) diff --git a/gql/__version__.py b/gql/__version__.py index ce1305bf..e0dd069d 100644 --- a/gql/__version__.py +++ b/gql/__version__.py @@ -1 +1 @@ -__version__ = "4.0.0" +__version__ = "4.4.0b0" diff --git a/gql/cli.py b/gql/cli.py index 37be3656..01dfb20f 100644 --- a/gql/cli.py +++ b/gql/cli.py @@ -140,7 +140,9 @@ def get_parser(with_examples: bool = False) -> ArgumentParser: - input_value_deprecation:false to omit deprecated input fields - specified_by_url:true - schema_description:true - - directive_is_repeatable:true""" + - directive_is_repeatable:true + - input_object_one_of:true + """ ), dest="schema_download", ) @@ -430,6 +432,7 @@ def get_introspection_args(args: Namespace) -> Dict: "directive_is_repeatable", "schema_description", "input_value_deprecation", + "input_object_one_of", ] if args.schema_download is not None: diff --git a/gql/client.py b/gql/client.py index e17a0b7c..93c1078c 100644 --- a/gql/client.py +++ b/gql/client.py @@ -21,7 +21,6 @@ overload, ) -import backoff from anyio import fail_after from graphql import ( ExecutionResult, @@ -31,6 +30,13 @@ parse, validate, ) +from tenacity import ( + retry, + retry_if_exception_type, + retry_unless_exception_type, + stop_after_attempt, + wait_exponential, +) from .graphql_request import GraphQLRequest, support_deprecated_request from .transport.async_transport import AsyncTransport @@ -1902,11 +1908,12 @@ def __init__( """ :param client: the :class:`client ` used. :param retry_connect: Either a Boolean to activate/deactivate the retries - for the connection to the transport OR a backoff decorator to - provide specific retries parameters for the connections. + for the connection to the transport OR a retry decorator + (e.g., from tenacity) to provide specific retries parameters + for the connections. :param retry_execute: Either a Boolean to activate/deactivate the retries - for the execute method OR a backoff decorator to - provide specific retries parameters for this method. + for the execute method OR a retry decorator (e.g., from tenacity) + to provide specific retries parameters for this method. """ self.client = client self._connect_task = None @@ -1917,10 +1924,9 @@ def __init__( if retry_connect is True: # By default, retry again and again, with maximum 60 seconds # between retries - self.retry_connect = backoff.on_exception( - backoff.expo, - Exception, - max_value=60, + self.retry_connect = retry( + retry=retry_if_exception_type(Exception), + wait=wait_exponential(max=60), ) elif retry_connect is False: self.retry_connect = lambda e: e @@ -1930,11 +1936,11 @@ def __init__( if retry_execute is True: # By default, retry 5 times, except if we receive a TransportQueryError - self.retry_execute = backoff.on_exception( - backoff.expo, - Exception, - max_tries=5, - giveup=lambda e: isinstance(e, TransportQueryError), + self.retry_execute = retry( + retry=retry_if_exception_type(Exception) + & retry_unless_exception_type(TransportQueryError), + stop=stop_after_attempt(5), + wait=wait_exponential(), ) elif retry_execute is False: self.retry_execute = lambda e: e @@ -1943,7 +1949,7 @@ def __init__( self.retry_execute = retry_execute # Creating the _execute_with_retries and _connect_with_retries methods - # using the provided backoff decorators + # using the provided retry decorators self._execute_with_retries = self.retry_execute(self._execute_once) self._connect_with_retries = self.retry_connect(self.transport.connect) diff --git a/gql/dsl.py b/gql/dsl.py index 1a8716c2..544af51d 100644 --- a/gql/dsl.py +++ b/gql/dsl.py @@ -1,17 +1,33 @@ """ -.. image:: http://www.plantuml.com/plantuml/png/ZLAzJWCn3Dxz51vXw1im50ag8L4XwC1OkLTJ8gMvAd4GwEYxGuC8pTbKtUxy_TZEvsaIYfAt7e1MII9rWfsdbF1cSRzWpvtq4GT0JENduX8GXr_g7brQlf5tw-MBOx_-HlS0LV_Kzp8xr1kZav9PfCsMWvolEA_1VylHoZCExKwKv4Tg2s_VkSkca2kof2JDb0yxZYIk3qMZYUe1B1uUZOROXn96pQMugEMUdRnUUqUf6DBXQyIz2zu5RlgUQAFVNYaeRfBI79_JrUTaeg9JZFQj5MmUc69PDmNGE2iU61fDgfri3x36gxHw3gDHD6xqqQ7P4vjKqz2-602xtkO7uo17SCLhVSv25VjRjUAFcUE73Sspb8ADBl8gTT7j2cFAOPst_Wi0 # noqa - :alt: UML diagram +.. image:: https://www.plantuml.com/plantuml/png/hLZXJkGs4FwVft1_NLXOfBR_Lcrrz3Wg93WA2rTL24Kc6LYtMITdErpf5QdFqaVharp6tincS8ZsLlTd8PxnpESltun7UMsTDAvPbichRzm2bY3gKYgT9Bfo8AGLfrNHb73KwDofIjjaCWahWfOca-J_V_yJXIsp-mzbEgbgCD9RziIazvHzL6wHQRc4dPdunSXwSNvo0HyQiCu7aDPbTwPQPW-oR23rltl2FTQGjHlEQWmYo-ltkFwkAk26xx9Wb2pLtr2405cZSM-HhWqlX05T23nkakIbj5OSpa_cUSk559yI8QRJzcStot9PbbcM8lwPiCxipD3nK1d8dNg0u7GFJZfdOh_B5ahoH1d20iKVtNgae2pONahg0-mMtMDMm1rHov0XI-Gs4sH30j1EAUC3JoP_VfJctWwS5vTViZF0xwLHyhQ4GxXJMdar1EWFAuD5JBcxjixizJVSR40GEQDRwvJvmwupfQtNPLENS1t3mFFlYVtz_Hl4As_Rc39tOgq3A25tbGbeBJxXjio2cubvzpW7Xu48wwSkq9DG5jMeYkmEtsBgVriyjrLLhYEc4x_kwoNy5sgbtIYHrmFzoE5n8U2HdYd18WdTiTdR3gSTXKfHKlglWynof1FwVnJbHLKvBsB6PiW_nizWi2CZxvUWtLU9zRL0OGnw3vnLQLq8CnDNMbNwsYSDR-9Obqf3TwAmHkUh3KZlrtjPracdyYU1AlVYW1L6ctOAYlH3wcSunqJ_zY_86-_5YxHVLBCNofgQ2NLQhEcRZQg7yGO40gNiAM0jvQoxLm96kcOoRFepGMRii-Z0u_KSU3E84vqtO1w7aeWVUPRzywkt5xzp4OsN4yjpsZWVQgDKfrUN1vV7P--spZPlRcrkLBrnnldLp_Ct5yU_RfsL14EweZRUtL0aD4JGKn02w2g1EuOGNTXEHgrEPLEwC0VuneIhpuAkhibZNJSE4wpBp5Ke4GyYxSQF3a8GCZVoEuZIfmm6Tzk2FEfyWRnUNubR1cStLZzj6H8_dj17IWDc7dx3MujlzVhIWQ-yqeNFo5qsPsIq__xM8ZX0035B-8UTqWDD_IzD4uEns6lWJJjAmysKRtFQU8fnyhZZwEqSUsyZGSGxokokNwCXr9jmkPO6T2YRxY9SkPpT_W6vhy0zGJNfmDp97Bgwt2ri-Rmfj738lF7uIdXmQS2skRnfnpZhvBJ5XG1EzWYdot_Phg_8Y2ZSkZFp8j-YnM3QSI9uZ2y0-KeSwmKOvQJEGHWe_Qra5wgsINz6_-6VwJGQws8FDk74PXfOnuF4asYIy8ayJZRWm2w5sCmRKfAmS16IP01LxCH2nkPaY01oew5W20gp9_qdRwTfQj140z2WbGqioV0PU8CRPuEx3WSSlWi6F6Dn9yERkKJHYRFCpMIdTMe9M1HlgcLTMNyRyA8GKt4Y7y68RyMgdWH-8H6cgjnEilwwCPt-H5yYPY8t81rORkTV6yXfi_JVYTJd3PiAKVasPJq4J8e9wBGCmU070-zDfYz6yxr86ollGIWjQDQrErp7F0dBZ_agxQJIbXVg44-D1TlNd_U9somTGJmeARgfAtaDkcYMvMS0 # noqa + :alt: UML diagram - rename png to uml to edit """ import logging import re +import sys from abc import ABC, abstractmethod from math import isfinite -from typing import Any, Dict, Iterable, Mapping, Optional, Tuple, Union, cast +from typing import ( + Any, + Dict, + Iterable, + Literal, + Mapping, + Optional, + Set, + Tuple, + Union, + cast, + overload, +) from graphql import ( ArgumentNode, BooleanValueNode, + ConstDirectiveNode, + DirectiveLocation, + DirectiveNode, DocumentNode, EnumValueNode, FieldNode, @@ -19,6 +35,7 @@ FragmentDefinitionNode, FragmentSpreadNode, GraphQLArgument, + GraphQLDirective, GraphQLEnumType, GraphQLError, GraphQLField, @@ -61,12 +78,18 @@ is_non_null_type, is_wrapping_type, print_ast, + specified_directives, ) from graphql.pyutils import inspect from .graphql_request import GraphQLRequest from .utils import to_camel_case +if sys.version_info >= (3, 11): + from typing import Self # pragma: no cover +else: + from typing_extensions import Self # pragma: no cover + log = logging.getLogger(__name__) _re_integer_string = re.compile("^-?(?:0|[1-9][0-9]*)$") @@ -132,8 +155,9 @@ def ast_from_value(value: Any, type_: GraphQLInputType) -> Optional[ValueNode]: Produce a GraphQL Value AST given a Python object. - Raises a GraphQLError instead of returning None if we receive an Undefined - of if we receive a Null value for a Non-Null type. + :raises graphql.error.GraphQLError: + instead of returning None if we receive an Undefined + of if we receive a Null value for a Non-Null type. """ if isinstance(value, DSLVariable): return value.set_type(type_).ast_variable_name @@ -213,67 +237,15 @@ def ast_from_value(value: Any, type_: GraphQLInputType) -> Optional[ValueNode]: raise TypeError(f"Unexpected input type: {inspect(type_)}.") -def dsl_gql( - *operations: "DSLExecutable", **operations_with_name: "DSLExecutable" -) -> GraphQLRequest: - r"""Given arguments instances of :class:`DSLExecutable` - containing GraphQL operations or fragments, - generate a Document which can be executed later in a - gql client or a gql session. - - Similar to the :func:`gql.gql` function but instead of parsing a python - string to describe the request, we are using operations which have been generated - dynamically using instances of :class:`DSLField`, generated - by instances of :class:`DSLType` which themselves originated from - a :class:`DSLSchema` class. - - :param \*operations: the GraphQL operations and fragments - :type \*operations: DSLQuery, DSLMutation, DSLSubscription, DSLFragment - :param \**operations_with_name: the GraphQL operations with an operation name - :type \**operations_with_name: DSLQuery, DSLMutation, DSLSubscription - - :return: a :class:`GraphQLRequest ` - which can be later executed or subscribed by a - :class:`Client `, by an - :class:`async session ` or by a - :class:`sync session ` - - :raises TypeError: if an argument is not an instance of :class:`DSLExecutable` - :raises AttributeError: if a type has not been provided in a :class:`DSLFragment` - """ - - # Concatenate operations without and with name - all_operations: Tuple["DSLExecutable", ...] = ( - *operations, - *(operation for operation in operations_with_name.values()), - ) - - # Set the operation name - for name, operation in operations_with_name.items(): - operation.name = name - - # Check the type - for operation in all_operations: - if not isinstance(operation, DSLExecutable): - raise TypeError( - "Operations should be instances of DSLExecutable " - "(DSLQuery, DSLMutation, DSLSubscription or DSLFragment).\n" - f"Received: {type(operation)}." - ) - - document = DocumentNode( - definitions=[operation.executable_ast for operation in all_operations] - ) - - return GraphQLRequest(document) - - class DSLSchema: """The DSLSchema is the root of the DSL code. Attributes of the DSLSchema class are generated automatically with the `__getattr__` dunder method in order to generate instances of :class:`DSLType` + + .. automethod:: __call__ + .. automethod:: __getattr__ """ def __init__(self, schema: GraphQLSchema): @@ -293,7 +265,56 @@ def __init__(self, schema: GraphQLSchema): self._schema: GraphQLSchema = schema + @overload + def __call__( + self, shortcut: Literal["__typename", "__schema", "__type"] + ) -> "DSLMetaField": ... # pragma: no cover + + @overload + def __call__( + self, shortcut: Literal["..."] + ) -> "DSLInlineFragment": ... # pragma: no cover + + @overload + def __call__(self, shortcut: Any) -> "DSLDirective": ... # pragma: no cover + + def __call__( + self, shortcut: str + ) -> Union["DSLMetaField", "DSLInlineFragment", "DSLDirective"]: + """Factory method for creating DSL objects from a shortcut string. + + The shortcut determines which DSL object is created: + + * "__typename", "__schema", "__type" -> :class:`DSLMetaField` + * "..." -> :class:`DSLInlineFragment` + * "@" -> :class:`DSLDirective` + + :param shortcut: The shortcut string identifying the DSL object. + :type shortcut: str + + :return: A DSL object corresponding to the given shortcut. + :rtype: DSLMetaField | DSLInlineFragment | DSLDirective + + :raises ValueError: If the shortcut is not recognized. + """ + + if shortcut in ("__typename", "__schema", "__type"): + return DSLMetaField(name=shortcut) + if shortcut == "...": + return DSLInlineFragment() + if shortcut.startswith("@"): + return DSLDirective(name=shortcut[1:], dsl_schema=self) + + raise ValueError(f"Unsupported shortcut: {shortcut}") + def __getattr__(self, name: str) -> "DSLType": + """Attributes of the DSLSchema class are generated automatically + with this dunder method in order to generate + instances of :class:`DSLType` + + :return: :class:`DSLType` instance + :raises AttributeError: if the name is not valid + """ type_def: Optional[GraphQLNamedType] = self._schema.get_type(name) @@ -309,6 +330,286 @@ def __getattr__(self, name: str) -> "DSLType": return DSLType(type_def, self) +class DSLDirective: + """The DSLDirective represents a GraphQL directive for the DSL code. + + Directives provide a way to describe alternate runtime execution and type validation + behavior in a GraphQL document. + """ + + def __init__(self, name: str, dsl_schema: DSLSchema): + r"""Initialize the DSLDirective with the given name and arguments. + + :param name: the name of the directive + :param dsl_schema: DSLSchema for directive validation and definition lookup + + :raises graphql.error.GraphQLError: if directive not found or not executable + """ + self._dsl_schema = dsl_schema + + # Find directive definition in schema or built-ins + directive_def = self._dsl_schema._schema.get_directive(name) + + if directive_def is None: + # Try to find in built-in directives using specified_directives + builtins = {builtin.name: builtin for builtin in specified_directives} + directive_def = builtins.get(name) + + if directive_def is None: + available: Set[str] = set() + available.update(f"@{d.name}" for d in self._dsl_schema._schema.directives) + available.update(f"@{d.name}" for d in specified_directives) + raise GraphQLError( + f"Directive '@{name}' not found in schema or built-ins. " + f"Available directives: {', '.join(sorted(available))}" + ) + + # Check directive has at least one executable location + executable_locations = { + DirectiveLocation.QUERY, + DirectiveLocation.MUTATION, + DirectiveLocation.SUBSCRIPTION, + DirectiveLocation.FIELD, + DirectiveLocation.FRAGMENT_DEFINITION, + DirectiveLocation.FRAGMENT_SPREAD, + DirectiveLocation.INLINE_FRAGMENT, + DirectiveLocation.VARIABLE_DEFINITION, + } + + if not any(loc in executable_locations for loc in directive_def.locations): + raise GraphQLError( + f"Directive '@{name}' is not a valid request executable directive. " + f"It can only be used in type system locations, not in requests." + ) + + self.directive_def: GraphQLDirective = directive_def + self.ast_directive = DirectiveNode(name=NameNode(value=name), arguments=()) + + @property + def name(self) -> str: + """Get the directive name.""" + return self.ast_directive.name.value + + def __call__(self, **kwargs: Any) -> Self: + """Add arguments by calling the directive like a function. + + :param kwargs: directive arguments + :return: itself + """ + return self.args(**kwargs) + + def args(self, **kwargs: Any) -> Self: + r"""Set the arguments of a directive + + The arguments are parsed to be stored in the AST of this field. + + .. note:: + You can also call the field directly with your arguments. + :code:`ds("@someDirective").args(value="foo")` is equivalent to: + :code:`ds("@someDirective")(value="foo")` + + :param \**kwargs: the arguments (keyword=value) + + :return: itself + + :raises AttributeError: if arguments already set for this directive + :raises graphql.error.GraphQLError: + if argument doesn't exist in directive definition + """ + if self.ast_directive.arguments and len(self.ast_directive.arguments) > 0: + raise AttributeError(f"Arguments for directive @{self.name} already set.") + + errs = [] + for key, value in kwargs.items(): + if key not in self.directive_def.args: + errs.append( + f"Argument '{key}' does not exist in directive '@{self.name}'" + ) + if errs: + raise GraphQLError("\n".join(errs)) + + # Update AST directive with arguments + self.ast_directive = DirectiveNode( + name=NameNode(value=self.name), + arguments=tuple( + ArgumentNode( + name=NameNode(value=key), + value=cast( + ValueNode, + ast_from_value(value, self.directive_def.args[key].type), + ), + ) + for key, value in kwargs.items() + ), + ) + + return self + + def __repr__(self) -> str: + args_str = ", ".join( + f"{arg.name.value}={getattr(arg.value, 'value')}" + for arg in (self.ast_directive.arguments or ()) + ) + return f"" + + +class DSLDirectable(ABC): + """Mixin class for DSL elements that can have directives. + + Provides the directives() method for adding GraphQL directives to DSL elements. + Classes that need immediate AST updates should override the directives() method. + """ + + _directives: Tuple[DSLDirective, ...] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._directives = () + + @abstractmethod + def is_valid_directive(self, directive: DSLDirective) -> bool: + """Check if a directive is valid for this DSL element. + + :param directive: The DSLDirective to validate + :return: True if the directive can be used at this location + """ + raise NotImplementedError( + "Any DSLDirectable concrete class must have an is_valid_directive method" + ) # pragma: no cover + + def directives(self, *directives: DSLDirective) -> Self: + r"""Add directives to this DSL element. + + :param \*directives: DSLDirective instances to add + :return: itself + + :raises graphql.error.GraphQLError: if directive location is invalid + :raises TypeError: if argument is not a DSLDirective + + Usage: + + .. code-block:: python + + # Using new factory method + element.directives(ds("@include")(**{"if": var.show})) + element.directives(ds("@skip")(**{"if": var.hide})) + """ + validated_directives = [] + + for directive in directives: + if not isinstance(directive, DSLDirective): + raise TypeError( + f"Expected DSLDirective, got {type(directive)}. " + f"Use ds('@directiveName') to create directive instances." + ) + + # Validate directive location using the abstract method + if not self.is_valid_directive(directive): + # Get valid locations for error message + valid_locations = [ + loc.name + for loc in directive.directive_def.locations + if loc + in { + DirectiveLocation.QUERY, + DirectiveLocation.MUTATION, + DirectiveLocation.SUBSCRIPTION, + DirectiveLocation.FIELD, + DirectiveLocation.FRAGMENT_DEFINITION, + DirectiveLocation.FRAGMENT_SPREAD, + DirectiveLocation.INLINE_FRAGMENT, + DirectiveLocation.VARIABLE_DEFINITION, + } + ] + raise GraphQLError( + f"Invalid directive location: '@{directive.name}' " + f"cannot be used on {self.__class__.__name__}. " + f"Valid locations for this directive: {', '.join(valid_locations)}" + ) + + validated_directives.append(directive) + + # Update stored directives + self._directives = self._directives + tuple(validated_directives) + + log.debug( + f"Added directives {[d.name for d in validated_directives]} to {self!r}" + ) + + return self + + @property + def directives_ast(self) -> Tuple[DirectiveNode, ...]: + """Get AST directive nodes for this element.""" + return tuple(directive.ast_directive for directive in self._directives) + + +class DSLSelectable(DSLDirectable): + """DSLSelectable is an abstract class which indicates that + the subclasses can be used as arguments of the + :meth:`select ` method. + + Inherited by + :class:`DSLField `, + :class:`DSLFragment ` + :class:`DSLInlineFragment ` + """ + + ast_field: Union[FieldNode, InlineFragmentNode, FragmentSpreadNode] + + @staticmethod + def get_aliased_fields( + fields: Iterable["DSLSelectable"], + fields_with_alias: Dict[str, "DSLSelectableWithAlias"], + ) -> Tuple["DSLSelectable", ...]: + """ + :meta private: + + Concatenate all the fields (with or without alias) in a Tuple. + + Set the requested alias for the fields with alias. + """ + + return ( + *fields, + *(field.alias(alias) for alias, field in fields_with_alias.items()), + ) + + def __str__(self) -> str: + return print_ast(self.ast_field) + + +class DSLSelectableWithAlias(DSLSelectable): + """DSLSelectableWithAlias is an abstract class which indicates that + the subclasses can be selected with an alias. + """ + + ast_field: FieldNode + + def alias(self, alias: str) -> Self: + """Set an alias + + .. note:: + You can also pass the alias directly at the + :meth:`select ` method. + :code:`ds.Query.human.select(my_name=ds.Character.name)` is equivalent to: + :code:`ds.Query.human.select(ds.Character.name.alias("my_name"))` + + :param alias: the alias + :type alias: str + :return: itself + """ + + self.ast_field = FieldNode( + name=self.ast_field.name, + alias=NameNode(value=alias), + arguments=self.ast_field.arguments, + directives=self.ast_field.directives, + selection_set=self.ast_field.selection_set, + ) + return self + + class DSLSelector(ABC): """DSLSelector is an abstract class which defines the :meth:`select ` method to select @@ -324,8 +625,8 @@ class DSLSelector(ABC): def __init__( self, - *fields: "DSLSelectable", - **fields_with_alias: "DSLSelectableWithAlias", + *fields: DSLSelectable, + **fields_with_alias: DSLSelectableWithAlias, ): """:meta private:""" self.selection_set = SelectionSetNode(selections=()) @@ -334,15 +635,15 @@ def __init__( self.select(*fields, **fields_with_alias) @abstractmethod - def is_valid_field(self, field: "DSLSelectable") -> bool: + def is_valid_field(self, field: DSLSelectable) -> bool: raise NotImplementedError( "Any DSLSelector subclass must have a is_valid_field method" ) # pragma: no cover def select( self, - *fields: "DSLSelectable", - **fields_with_alias: "DSLSelectableWithAlias", + *fields: DSLSelectable, + **fields_with_alias: DSLSelectableWithAlias, ) -> Any: r"""Select the fields which should be added. @@ -355,7 +656,7 @@ def select( :raises graphql.error.GraphQLError: if an argument is not a valid field """ # Concatenate fields without and with alias - added_fields: Tuple["DSLSelectable", ...] = DSLField.get_aliased_fields( + added_fields: Tuple[DSLSelectable, ...] = DSLField.get_aliased_fields( fields, fields_with_alias ) @@ -376,12 +677,14 @@ def select( ] = tuple(field.ast_field for field in added_fields) # Update the current selection list with new selections - self.selection_set.selections = self.selection_set.selections + added_selections + self.selection_set = SelectionSetNode( + selections=self.selection_set.selections + added_selections + ) log.debug(f"Added fields: {added_fields} in {self!r}") -class DSLExecutable(DSLSelector): +class DSLExecutable(DSLSelector, DSLDirectable): """Interface for the root elements which can be executed in the :func:`dsl_gql ` function @@ -404,8 +707,8 @@ def executable_ast(self): def __init__( self, - *fields: "DSLSelectable", - **fields_with_alias: "DSLSelectableWithAlias", + *fields: DSLSelectable, + **fields_with_alias: DSLSelectableWithAlias, ): r"""Given arguments of type :class:`DSLSelectable` containing GraphQL requests, generate an operation which can be converted to a Document @@ -430,6 +733,7 @@ def __init__( self.variable_definitions = DSLVariableDefinitions() DSLSelector.__init__(self, *fields, **fields_with_alias) + DSLDirectable.__init__(self) class DSLRootFieldSelector(DSLSelector): @@ -441,7 +745,7 @@ class DSLRootFieldSelector(DSLSelector): :class:`DSLOperation ` """ - def is_valid_field(self, field: "DSLSelectable") -> bool: + def is_valid_field(self, field: DSLSelectable) -> bool: """Check that a field is valid for a root field. For operations, the fields arguments should be fields of root GraphQL types @@ -507,8 +811,8 @@ def executable_ast(self) -> OperationDefinitionNode: operation=OperationType(self.operation_type), selection_set=self.selection_set, variable_definitions=self.variable_definitions.get_ast_definitions(), - **({"name": NameNode(value=self.name)} if self.name else {}), - directives=(), + name=NameNode(value=self.name) if self.name else None, + directives=self.directives_ast, ) def __repr__(self) -> str: @@ -518,16 +822,28 @@ def __repr__(self) -> str: class DSLQuery(DSLOperation): operation_type = OperationType.QUERY + def is_valid_directive(self, directive: DSLDirective) -> bool: + """Check if directive is valid for Query operations.""" + return DirectiveLocation.QUERY in directive.directive_def.locations + class DSLMutation(DSLOperation): operation_type = OperationType.MUTATION + def is_valid_directive(self, directive: DSLDirective) -> bool: + """Check if directive is valid for Mutation operations.""" + return DirectiveLocation.MUTATION in directive.directive_def.locations + class DSLSubscription(DSLOperation): operation_type = OperationType.SUBSCRIPTION + def is_valid_directive(self, directive: DSLDirective) -> bool: + """Check if directive is valid for Subscription operations.""" + return DirectiveLocation.SUBSCRIPTION in directive.directive_def.locations + -class DSLVariable: +class DSLVariable(DSLDirectable): """The DSLVariable represents a single variable defined in a GraphQL operation Instances of this class are generated for you automatically as attributes @@ -545,13 +861,20 @@ def __init__(self, name: str): self.default_value = None self.type: Optional[GraphQLInputType] = None + DSLDirectable.__init__(self) + def to_ast_type(self, type_: GraphQLInputType) -> TypeNode: if is_wrapping_type(type_): if isinstance(type_, GraphQLList): return ListTypeNode(type=self.to_ast_type(type_.of_type)) elif isinstance(type_, GraphQLNonNull): - return NonNullTypeNode(type=self.to_ast_type(type_.of_type)) + return NonNullTypeNode( + type=cast( + Union[NamedTypeNode, ListTypeNode], + self.to_ast_type(type_.of_type), + ) + ) assert isinstance( type_, (GraphQLScalarType, GraphQLEnumType, GraphQLInputObjectType) @@ -559,15 +882,27 @@ def to_ast_type(self, type_: GraphQLInputType) -> TypeNode: return NamedTypeNode(name=NameNode(value=type_.name)) - def set_type(self, type_: GraphQLInputType) -> "DSLVariable": + def set_type(self, type_: GraphQLInputType) -> Self: self.type = type_ self.ast_variable_type = self.to_ast_type(type_) return self - def default(self, default_value: Any) -> "DSLVariable": + def default(self, default_value: Any) -> Self: self.default_value = default_value return self + def is_valid_directive(self, directive: DSLDirective) -> bool: + """Check if directive is valid for Variable definitions.""" + for arg in directive.ast_directive.arguments or (): + if isinstance(arg.value, VariableNode): + raise GraphQLError( + f"Directive @{directive.name} argument value has " + f"unexpected variable '${arg.value.name}' in constant location." + ) + return ( + DirectiveLocation.VARIABLE_DEFINITION in directive.directive_def.locations + ) + class DSLVariableDefinitions: """The DSLVariableDefinitions represents variable definitions in a GraphQL operation @@ -579,13 +914,21 @@ class DSLVariableDefinitions: with the `__getattr__` dunder method in order to generate instances of :class:`DSLVariable`, that can then be used as values in the :meth:`args ` method. + + .. automethod:: __getattr__ """ def __init__(self): """:meta private:""" self.variables: Dict[str, DSLVariable] = {} - def __getattr__(self, name: str) -> "DSLVariable": + def __getattr__(self, name: str) -> DSLVariable: + """Attributes of the DSLVariableDefinitions class are generated automatically + with this dunder method in order to generate + instances of :class:`DSLVariable` + + :return: :class:`DSLVariable` instance + """ if name not in self.variables: self.variables[name] = DSLVariable(name) return self.variables[name] @@ -598,14 +941,14 @@ def get_ast_definitions(self) -> Tuple[VariableDefinitionNode, ...]: """ return tuple( VariableDefinitionNode( - type=var.ast_variable_type, + type=cast(TypeNode, var.ast_variable_type), variable=var.ast_variable_name, default_value=( None if var.default_value is None else ast_from_value(var.default_value, var.type) ), - directives=(), + directives=cast(Tuple[ConstDirectiveNode, ...], var.directives_ast), ) for var in self.variables.values() if var.type is not None # only variables used @@ -625,6 +968,8 @@ class DSLType: Attributes of the DSLType class are generated automatically with the `__getattr__` dunder method in order to generate instances of :class:`DSLField` + + .. automethod:: __getattr__ """ def __init__( @@ -646,6 +991,13 @@ def __init__( log.debug(f"Creating {self!r})") def __getattr__(self, name: str) -> "DSLField": + """Attributes of the DSLType class are generated automatically + with this dunder method in order to generate + instances of :class:`DSLField` + + :return: :class:`DSLField` instance + :raises AttributeError: if the field name does not exist in the type + """ camel_cased_name = to_camel_case(name) if name in self._type.fields: @@ -665,41 +1017,6 @@ def __repr__(self) -> str: return f"<{self.__class__.__name__} {self._type!r}>" -class DSLSelectable(ABC): - """DSLSelectable is an abstract class which indicates that - the subclasses can be used as arguments of the - :meth:`select ` method. - - Inherited by - :class:`DSLField `, - :class:`DSLFragment ` - :class:`DSLInlineFragment ` - """ - - ast_field: Union[FieldNode, InlineFragmentNode, FragmentSpreadNode] - - @staticmethod - def get_aliased_fields( - fields: Iterable["DSLSelectable"], - fields_with_alias: Dict[str, "DSLSelectableWithAlias"], - ) -> Tuple["DSLSelectable", ...]: - """ - :meta private: - - Concatenate all the fields (with or without alias) in a Tuple. - - Set the requested alias for the fields with alias. - """ - - return ( - *fields, - *(field.alias(alias) for alias, field in fields_with_alias.items()), - ) - - def __str__(self) -> str: - return print_ast(self.ast_field) - - class DSLFragmentSelector(DSLSelector): """Class used to define the :meth:`is_valid_field ` method @@ -715,7 +1032,7 @@ def is_valid_field(self, field: DSLSelectable) -> bool: assert isinstance(self, (DSLFragment, DSLInlineFragment)) - if isinstance(field, (DSLFragment, DSLInlineFragment)): + if isinstance(field, (DSLFragment, DSLFragmentSpread, DSLInlineFragment)): return True assert isinstance(field, DSLField) @@ -747,7 +1064,7 @@ def is_valid_field(self, field: DSLSelectable) -> bool: assert isinstance(self, DSLField) - if isinstance(field, (DSLFragment, DSLInlineFragment)): + if isinstance(field, (DSLFragment, DSLFragmentSpread, DSLInlineFragment)): return True assert isinstance(field, DSLField) @@ -766,31 +1083,6 @@ def is_valid_field(self, field: DSLSelectable) -> bool: return False -class DSLSelectableWithAlias(DSLSelectable): - """DSLSelectableWithAlias is an abstract class which indicates that - the subclasses can be selected with an alias. - """ - - ast_field: FieldNode - - def alias(self, alias: str) -> "DSLSelectableWithAlias": - """Set an alias - - .. note:: - You can also pass the alias directly at the - :meth:`select ` method. - :code:`ds.Query.human.select(my_name=ds.Character.name)` is equivalent to: - :code:`ds.Query.human.select(ds.Character.name.alias("my_name"))` - - :param alias: the alias - :type alias: str - :return: itself - """ - - self.ast_field.alias = NameNode(value=alias) - return self - - class DSLField(DSLSelectableWithAlias, DSLFieldSelector): """The DSLField represents a GraphQL field for the DSL code. @@ -837,16 +1129,17 @@ def __init__( log.debug(f"Creating {self!r}") DSLSelector.__init__(self) + DSLDirectable.__init__(self) @property def name(self): """:meta private:""" return self.ast_field.name.value - def __call__(self, **kwargs: Any) -> "DSLField": + def __call__(self, **kwargs: Any) -> Self: return self.args(**kwargs) - def args(self, **kwargs: Any) -> "DSLField": + def args(self, **kwargs: Any) -> Self: r"""Set the arguments of a field The arguments are parsed to be stored in the AST of this field. @@ -865,13 +1158,23 @@ def args(self, **kwargs: Any) -> "DSLField": assert self.ast_field.arguments is not None - self.ast_field.arguments = self.ast_field.arguments + tuple( + new_arguments = self.ast_field.arguments + tuple( ArgumentNode( name=NameNode(value=name), - value=ast_from_value(value, self._get_argument(name).type), + value=cast( + ValueNode, + ast_from_value(value, self._get_argument(name).type), + ), ) for name, value in kwargs.items() ) + self.ast_field = FieldNode( + name=self.ast_field.name, + alias=self.ast_field.alias, + arguments=new_arguments, + directives=self.ast_field.directives, + selection_set=self.ast_field.selection_set, + ) log.debug(f"Added arguments {kwargs} in field {self!r})") @@ -892,17 +1195,40 @@ def _get_argument(self, name: str) -> GraphQLArgument: return arg def select( - self, *fields: "DSLSelectable", **fields_with_alias: "DSLSelectableWithAlias" - ) -> "DSLField": + self, *fields: DSLSelectable, **fields_with_alias: DSLSelectableWithAlias + ) -> Self: """Calling :meth:`select ` method with corrected typing hints """ super().select(*fields, **fields_with_alias) - self.ast_field.selection_set = self.selection_set + self.ast_field = FieldNode( + name=self.ast_field.name, + alias=self.ast_field.alias, + arguments=self.ast_field.arguments, + directives=self.ast_field.directives, + selection_set=self.selection_set, + ) + + return self + + def directives(self, *directives: DSLDirective) -> Self: + """Add directives to this field.""" + super().directives(*directives) + self.ast_field = FieldNode( + name=self.ast_field.name, + alias=self.ast_field.alias, + arguments=self.ast_field.arguments, + directives=self.directives_ast, + selection_set=self.ast_field.selection_set, + ) return self + def is_valid_directive(self, directive: DSLDirective) -> bool: + """Check if directive is valid for Field locations.""" + return DirectiveLocation.FIELD in directive.directive_def.locations + def __repr__(self) -> str: return f"<{self.__class__.__name__} {self.parent_type.name}" f"::{self.name}>" @@ -941,6 +1267,10 @@ def __init__(self, name: str): super().__init__(name, self.meta_type, field) + def is_valid_directive(self, directive: DSLDirective) -> bool: + """Check if directive is valid for MetaField locations (same as Field).""" + return DirectiveLocation.FIELD in directive.directive_def.locations + class DSLInlineFragment(DSLSelectable, DSLFragmentSelector): """DSLInlineFragment represents an inline fragment for the DSL code.""" @@ -950,8 +1280,8 @@ class DSLInlineFragment(DSLSelectable, DSLFragmentSelector): def __init__( self, - *fields: "DSLSelectable", - **fields_with_alias: "DSLSelectableWithAlias", + *fields: DSLSelectable, + **fields_with_alias: DSLSelectableWithAlias, ): r"""Initialize the DSLInlineFragment. @@ -963,27 +1293,50 @@ def __init__( log.debug(f"Creating {self!r}") - self.ast_field = InlineFragmentNode(directives=()) + self.ast_field = InlineFragmentNode( + selection_set=SelectionSetNode(selections=()), + directives=(), + ) DSLSelector.__init__(self, *fields, **fields_with_alias) + DSLDirectable.__init__(self) def select( - self, *fields: "DSLSelectable", **fields_with_alias: "DSLSelectableWithAlias" - ) -> "DSLInlineFragment": + self, *fields: DSLSelectable, **fields_with_alias: DSLSelectableWithAlias + ) -> Self: """Calling :meth:`select ` method with corrected typing hints """ super().select(*fields, **fields_with_alias) - self.ast_field.selection_set = self.selection_set + self.ast_field = InlineFragmentNode( + selection_set=self.selection_set, + type_condition=self.ast_field.type_condition, + directives=self.ast_field.directives, + ) return self - def on(self, type_condition: DSLType) -> "DSLInlineFragment": + def on(self, type_condition: DSLType) -> Self: """Provides the GraphQL type of this inline fragment.""" self._type = type_condition._type - self.ast_field.type_condition = NamedTypeNode( - name=NameNode(value=self._type.name) + self.ast_field = InlineFragmentNode( + selection_set=self.ast_field.selection_set, + type_condition=NamedTypeNode(name=NameNode(value=self._type.name)), + directives=self.ast_field.directives, + ) + return self + + def directives(self, *directives: DSLDirective) -> Self: + """Add directives to this inline fragment. + + Inline fragments support all directive types through auto-validation. + """ + super().directives(*directives) + self.ast_field = InlineFragmentNode( + selection_set=self.ast_field.selection_set, + type_condition=self.ast_field.type_condition, + directives=self.directives_ast, ) return self @@ -997,13 +1350,65 @@ def __repr__(self) -> str: return f"<{self.__class__.__name__}{type_info}>" + def is_valid_directive(self, directive: DSLDirective) -> bool: + """Check if directive is valid for Inline Fragment locations.""" + return DirectiveLocation.INLINE_FRAGMENT in directive.directive_def.locations + + +class DSLFragmentSpread(DSLSelectable): + """Represents a fragment spread (usage) with its own directives. + + This class is created by calling .spread() on a DSLFragment and allows + adding directives specific to the FRAGMENT_SPREAD location. + """ + + ast_field: FragmentSpreadNode + _fragment: "DSLFragment" + + def __init__(self, fragment: "DSLFragment"): + """Initialize a fragment spread from a fragment definition. + + :param fragment: The DSLFragment to create a spread from + """ + self._fragment = fragment + self.ast_field = FragmentSpreadNode( + name=NameNode(value=fragment.name), directives=() + ) + + log.debug(f"Creating fragment spread for {fragment.name}") + + DSLDirectable.__init__(self) + + @property + def name(self) -> str: + """:meta private:""" + return self.ast_field.name.value + + def directives(self, *directives: DSLDirective) -> Self: + """Add directives to this fragment spread. + + Fragment spreads support all directive types through auto-validation. + """ + super().directives(*directives) + self.ast_field = FragmentSpreadNode( + name=self.ast_field.name, + directives=self.directives_ast, + ) + return self + + def is_valid_directive(self, directive: DSLDirective) -> bool: + """Check if directive is valid for Fragment Spread locations.""" + return DirectiveLocation.FRAGMENT_SPREAD in directive.directive_def.locations + + def __repr__(self) -> str: + return f"" + class DSLFragment(DSLSelectable, DSLFragmentSelector, DSLExecutable): """DSLFragment represents a named GraphQL fragment for the DSL code.""" _type: Optional[Union[GraphQLObjectType, GraphQLInterfaceType]] ast_field: FragmentSpreadNode - name: str def __init__( self, @@ -1017,28 +1422,39 @@ def __init__( DSLExecutable.__init__(self) - self.name = name + self.ast_field = FragmentSpreadNode(name=NameNode(value=name), directives=()) + self._type = None log.debug(f"Creating {self!r}") - @property # type: ignore - def ast_field(self) -> FragmentSpreadNode: # type: ignore - """ast_field property will generate a FragmentSpreadNode with the - provided name. + @property + def name(self) -> str: + """:meta private:""" + return self.ast_field.name.value - Note: We need to ignore the type because of - `issue #4125 of mypy `_. - """ + @name.setter + def name(self, value: str) -> None: + """:meta private:""" + if hasattr(self, "ast_field"): + self.ast_field = FragmentSpreadNode( + name=NameNode(value=value), + directives=self.ast_field.directives, + ) + + def spread(self) -> DSLFragmentSpread: + """Create a fragment spread that can have its own directives. - spread_node = FragmentSpreadNode(directives=()) - spread_node.name = NameNode(value=self.name) + This allows adding directives specific to the FRAGMENT_SPREAD location, + separate from directives on the fragment definition itself. - return spread_node + :return: DSLFragmentSpread instance for this fragment + """ + return DSLFragmentSpread(self) def select( - self, *fields: "DSLSelectable", **fields_with_alias: "DSLSelectableWithAlias" - ) -> "DSLFragment": + self, *fields: DSLSelectable, **fields_with_alias: DSLSelectableWithAlias + ) -> Self: """Calling :meth:`select ` method with corrected typing hints """ @@ -1051,7 +1467,7 @@ def select( return self - def on(self, type_condition: DSLType) -> "DSLFragment": + def on(self, type_condition: DSLType) -> Self: """Provides the GraphQL type of this fragment. :param type_condition: the provided type @@ -1077,6 +1493,8 @@ def executable_ast(self) -> FragmentDefinitionNode: fragment_variable_definitions = self.variable_definitions.get_ast_definitions() + variable_definition_kwargs: Dict[str, Any] + if len(fragment_variable_definitions) == 0: """Fragment variable definitions are obsolete and only supported on graphql-core if the Parser is initialized with: @@ -1094,10 +1512,71 @@ def executable_ast(self) -> FragmentDefinitionNode: return FragmentDefinitionNode( type_condition=NamedTypeNode(name=NameNode(value=self._type.name)), selection_set=self.selection_set, - **variable_definition_kwargs, name=NameNode(value=self.name), - directives=(), + directives=self.directives_ast, + **variable_definition_kwargs, + ) + + def is_valid_directive(self, directive: DSLDirective) -> bool: + """Check if directive is valid for Fragment Definition locations.""" + return ( + DirectiveLocation.FRAGMENT_DEFINITION in directive.directive_def.locations ) def __repr__(self) -> str: return f"<{self.__class__.__name__} {self.name!s}>" + + +def dsl_gql( + *operations: DSLExecutable, **operations_with_name: DSLExecutable +) -> GraphQLRequest: + r"""Given arguments instances of :class:`DSLExecutable` + containing GraphQL operations or fragments, + generate a Document which can be executed later in a + gql client or a gql session. + + Similar to the :func:`gql.gql` function but instead of parsing a python + string to describe the request, we are using operations which have been generated + dynamically using instances of :class:`DSLField`, generated + by instances of :class:`DSLType` which themselves originated from + a :class:`DSLSchema` class. + + :param \*operations: the GraphQL operations and fragments + :type \*operations: DSLQuery, DSLMutation, DSLSubscription, DSLFragment + :param \**operations_with_name: the GraphQL operations with an operation name + :type \**operations_with_name: DSLQuery, DSLMutation, DSLSubscription + + :return: a :class:`GraphQLRequest ` + which can be later executed or subscribed by a + :class:`Client `, by an + :class:`async session ` or by a + :class:`sync session ` + + :raises TypeError: if an argument is not an instance of :class:`DSLExecutable` + :raises AttributeError: if a type has not been provided in a :class:`DSLFragment` + """ + + # Concatenate operations without and with name + all_operations: Tuple[DSLExecutable, ...] = ( + *operations, + *(operation for operation in operations_with_name.values()), + ) + + # Set the operation name + for name, operation in operations_with_name.items(): + operation.name = name + + # Check the type + for operation in all_operations: + if not isinstance(operation, DSLExecutable): + raise TypeError( + "Operations should be instances of DSLExecutable " + "(DSLQuery, DSLMutation, DSLSubscription or DSLFragment).\n" + f"Received: {type(operation)}." + ) + + document = DocumentNode( + definitions=tuple(operation.executable_ast for operation in all_operations) + ) + + return GraphQLRequest(document) diff --git a/gql/graphql_request.py b/gql/graphql_request.py index 5e6f3ee4..e8e4fb7d 100644 --- a/gql/graphql_request.py +++ b/gql/graphql_request.py @@ -13,6 +13,7 @@ def __init__( *, variable_values: Optional[Dict[str, Any]] = None, operation_name: Optional[str] = None, + extensions: Optional[Dict[str, Any]] = None, ): """Initialize a GraphQL request. @@ -21,6 +22,9 @@ def __init__( :param variable_values: Dictionary of input parameters (Default: None). :param operation_name: Name of the operation that shall be executed. Only required in multi-operation documents (Default: None). + :param extensions: Dictionary of protocol extensions (Default: None). + This is passed as the top-level "extensions" key in the request + payload, as defined in the GraphQL over HTTP spec. :return: a :class:`GraphQLRequest ` which can be later executed or subscribed by a :class:`Client `, by an @@ -42,9 +46,12 @@ def __init__( variable_values = request.variable_values if operation_name is None: operation_name = request.operation_name + if extensions is None: + extensions = request.extensions self.variable_values: Optional[Dict[str, Any]] = variable_values self.operation_name: Optional[str] = operation_name + self.extensions: Optional[Dict[str, Any]] = extensions def serialize_variable_values(self, schema: GraphQLSchema) -> "GraphQLRequest": @@ -61,6 +68,7 @@ def serialize_variable_values(self, schema: GraphQLSchema) -> "GraphQLRequest": operation_name=self.operation_name, ), operation_name=self.operation_name, + extensions=self.extensions, ) @property @@ -74,6 +82,9 @@ def payload(self) -> Dict[str, Any]: if self.variable_values: payload["variables"] = self.variable_values + if self.extensions: + payload["extensions"] = self.extensions + return payload def __str__(self): diff --git a/gql/transport/aiohttp.py b/gql/transport/aiohttp.py index e3bfdb3b..261b4e01 100644 --- a/gql/transport/aiohttp.py +++ b/gql/transport/aiohttp.py @@ -16,6 +16,7 @@ ) import aiohttp +from aiohttp import BodyPartReader, MultipartReader from aiohttp.client_exceptions import ClientResponseError from aiohttp.client_reqrep import Fingerprint from aiohttp.helpers import BasicAuth @@ -173,7 +174,7 @@ def _prepare_request( upload_files: bool = False, ) -> Dict[str, Any]: - payload: Dict | List + payload: Union[Dict, List] if isinstance(request, GraphQLRequest): payload = request.payload else: @@ -421,12 +422,191 @@ async def execute_batch( except Exception as e: raise TransportConnectionFailed(str(e)) from e - def subscribe( + async def subscribe( self, request: GraphQLRequest, + *, + extra_args: Optional[Dict[str, Any]] = None, ) -> AsyncGenerator[ExecutionResult, None]: - """Subscribe is not supported on HTTP. + """Execute a GraphQL subscription and yield results from multipart response. + + :param request: GraphQL request to execute + :param extra_args: additional arguments to send to the aiohttp post method + :yields: ExecutionResult objects as they arrive in the multipart stream + """ + if self.session is None: + raise TransportClosed("Transport is not connected") + + post_args = self._prepare_request(request, extra_args) + + headers = dict(post_args.get("headers", {})) + headers.update( + { + "Content-Type": "application/json", + "Accept": ( + "multipart/mixed;boundary=graphql;" + "subscriptionSpec=1.0,application/json" + ), + } + ) + post_args["headers"] = headers - :meta private: + try: + async with self.session.post(self.url, ssl=self.ssl, **post_args) as resp: + # Saving latest response headers in the transport + self.response_headers = resp.headers + + # Check for errors + if resp.status >= 400: + # Raise a TransportServerError if status > 400 + self._raise_transport_server_error_if_status_more_than_400(resp) + + initial_content_type = resp.headers.get("Content-Type", "") + if ( + "application/json" in initial_content_type + and "multipart/mixed" not in initial_content_type + ): + yield await self._prepare_result(resp) + return + + if ( + ("multipart/mixed" not in initial_content_type) + or ("boundary=graphql" not in initial_content_type) + or ("subscriptionSpec=1.0" not in initial_content_type) + ): + raise TransportProtocolError( + f"Unexpected content-type: {initial_content_type}. " + "Server may not support the multipart subscription protocol." + ) + + # Parse multipart response + async for result in self._parse_multipart_response(resp): + yield result + + except TransportError: + raise + except Exception as e: + raise TransportConnectionFailed(str(e)) from e + + async def _parse_multipart_response( + self, + response: aiohttp.ClientResponse, + ) -> AsyncGenerator[ExecutionResult, None]: """ - raise NotImplementedError(" The HTTP transport does not support subscriptions") + Parse a multipart response stream and yield execution results. + + Uses aiohttp's built-in MultipartReader to handle the multipart protocol. + + :param response: The aiohttp response object + :yields: ExecutionResult objects + """ + # Use aiohttp's built-in multipart reader + reader = MultipartReader.from_response(response) + + # Iterate through each part in the multipart response + while True: + try: + part = await reader.next() + except Exception: + # reader.next() throws on empty parts at the end of the stream. + # (some servers may send this.) + # see: https://github.com/aio-libs/aiohttp/pull/11857 + # As an ugly workaround for now, we can check if we've reached + # EOF and assume this was the case. + if reader.at_eof(): + break + + # Otherwise, re-raise unexpected errors + raise # pragma: no cover + + if part is None: + # No more parts + break + + assert not isinstance( + part, MultipartReader + ), "Nested multipart parts are not supported in GraphQL subscriptions" + + result = await self._parse_multipart_part(part) + if result: + yield result + + async def _parse_multipart_part( + self, part: BodyPartReader + ) -> Optional[ExecutionResult]: + """ + Parse a single part from a multipart response. + + :param part: aiohttp BodyPartReader for the part + :return: ExecutionResult or None if part is empty/heartbeat + """ + try: + body = await part.text() + body = body.strip() + except UnicodeDecodeError as e: + log.warning(f"Failed to decode part: {ascii(e)}") + return None + + content_type = part.headers.get(aiohttp.hdrs.CONTENT_TYPE, "") + + if not content_type and not body: + log.debug("Skipping part with no content-type and no body") + return None + + # Verify the part has the correct content type + if not content_type.startswith("application/json"): + raise TransportProtocolError( + f"Unexpected part content-type: {content_type}. " + "Expected 'application/json'." + ) + + if log.isEnabledFor(logging.DEBUG): + log.debug("<<< %s", ascii(body or "(empty body, skipping)")) + + if not body: + return None + + try: + data = self.json_deserialize(body) + except json.JSONDecodeError as e: + log.warning( + f"Failed to parse JSON: {ascii(e)}, " + f"body: {ascii(body[:100]) if body else ''}" + ) + return None + + # Handle heartbeats - empty JSON objects + if not data: + log.debug("Received heartbeat, ignoring") + return None + + # The multipart subscription protocol wraps data in a "payload" property + if "payload" not in data: + log.warning("Invalid response: missing 'payload' field") + return None + + payload = data["payload"] + + # Check for transport-level errors (payload is null) + if payload is None: + # If there are errors, this is a transport-level error + errors = data.get("errors") + if errors: + error_messages = [ + error.get("message", "Unknown transport error") for error in errors + ] + + for message in error_messages: + log.error(f"Transport error: {message}") + + raise TransportServerError("\n\n".join(error_messages)) + else: + # Null payload without errors - just skip this part + return None + + # Extract GraphQL data from payload + return ExecutionResult( + data=payload.get("data"), + errors=payload.get("errors"), + extensions=payload.get("extensions"), + ) diff --git a/gql/transport/file_upload.py b/gql/transport/file_upload.py index 8673ab60..72c7265a 100644 --- a/gql/transport/file_upload.py +++ b/gql/transport/file_upload.py @@ -89,7 +89,6 @@ def recurse_extract(path, obj): replacing any file-like objects with nulls and shunting the originals off to the side. """ - nonlocal files if isinstance(obj, list): nulled_list = [] for key, value in enumerate(obj): diff --git a/gql/transport/httpx.py b/gql/transport/httpx.py index 0a338639..fb0688b9 100644 --- a/gql/transport/httpx.py +++ b/gql/transport/httpx.py @@ -14,7 +14,11 @@ Union, ) -import httpx +try: + import httpx2 as httpx +except ModuleNotFoundError: # pragma: no cover + import httpx # type: ignore[no-redef] + from graphql import ExecutionResult from ..graphql_request import GraphQLRequest @@ -66,7 +70,7 @@ def _prepare_request( upload_files: bool = False, ) -> Dict[str, Any]: - payload: Dict | List + payload: Union[Dict, List] if isinstance(request, GraphQLRequest): payload = request.payload else: diff --git a/gql/transport/requests.py b/gql/transport/requests.py index a29f7f0f..4e4e6ffb 100644 --- a/gql/transport/requests.py +++ b/gql/transport/requests.py @@ -1,6 +1,7 @@ import io import json import logging +from http.cookiejar import CookieJar from typing import ( Any, Callable, @@ -18,7 +19,6 @@ from graphql import ExecutionResult from requests.adapters import HTTPAdapter, Retry from requests.auth import AuthBase -from requests.cookies import RequestsCookieJar from requests.structures import CaseInsensitiveDict from requests_toolbelt.multipart.encoder import MultipartEncoder @@ -52,7 +52,7 @@ def __init__( self, url: str, headers: Optional[Dict[str, Any]] = None, - cookies: Optional[Union[Dict[str, Any], RequestsCookieJar]] = None, + cookies: Optional[Union[Dict[str, Any], CookieJar]] = None, auth: Optional[AuthBase] = None, use_json: bool = True, timeout: Optional[int] = None, @@ -147,7 +147,7 @@ def _prepare_request( upload_files: bool = False, ) -> Dict[str, Any]: - payload: Dict | List + payload: Union[Dict, List] if isinstance(request, GraphQLRequest): payload = request.payload else: diff --git a/gql/transport/websockets_protocol.py b/gql/transport/websockets_protocol.py index 3b66a0cb..1ccf744e 100644 --- a/gql/transport/websockets_protocol.py +++ b/gql/transport/websockets_protocol.py @@ -479,12 +479,18 @@ async def _after_connect(self): # Find the backend subprotocol returned in the response headers try: self.subprotocol = self.response_headers["Sec-WebSocket-Protocol"] + log.debug(f"backend subprotocol returned: {self.subprotocol!r}") except KeyError: - # If the server does not send the subprotocol header, using - # the apollo subprotocol by default - self.subprotocol = self.APOLLO_SUBPROTOCOL - - log.debug(f"backend subprotocol returned: {self.subprotocol!r}") + # If the server does not send the subprotocol header, use + # the apollo subprotocol by default unless we didn't ask for it + if ( + self.adapter.subprotocols is None + or self.APOLLO_SUBPROTOCOL in self.adapter.subprotocols + ): + self.subprotocol = self.APOLLO_SUBPROTOCOL + else: + self.subprotocol = self.GRAPHQLWS_SUBPROTOCOL + log.debug(f"backend returned no subprotocol, using: {self.subprotocol!r}") async def _after_initialize(self): diff --git a/gql/utilities/build_client_schema.py b/gql/utilities/build_client_schema.py index 30402868..8b8f4d67 100644 --- a/gql/utilities/build_client_schema.py +++ b/gql/utilities/build_client_schema.py @@ -23,10 +23,10 @@ "description": "Included when true.", "type": { "kind": "NON_NULL", - "name": "None", - "ofType": {"kind": "SCALAR", "name": "Boolean", "ofType": "None"}, + "name": None, + "ofType": {"kind": "SCALAR", "name": "Boolean", "ofType": None}, }, - "defaultValue": "None", + "defaultValue": None, } ], } @@ -48,10 +48,10 @@ "description": "Skipped when true.", "type": { "kind": "NON_NULL", - "name": "None", - "ofType": {"kind": "SCALAR", "name": "Boolean", "ofType": "None"}, + "name": None, + "ofType": {"kind": "SCALAR", "name": "Boolean", "ofType": None}, }, - "defaultValue": "None", + "defaultValue": None, } ], } diff --git a/gql/utilities/get_introspection_query_ast.py b/gql/utilities/get_introspection_query_ast.py index 0422a225..6d084599 100644 --- a/gql/utilities/get_introspection_query_ast.py +++ b/gql/utilities/get_introspection_query_ast.py @@ -11,6 +11,8 @@ def get_introspection_query_ast( directive_is_repeatable: bool = False, schema_description: bool = False, input_value_deprecation: bool = True, + input_object_one_of: bool = False, + *, type_recursion_level: int = 7, ) -> DocumentNode: """Get a query for introspection as a document using the DSL module. @@ -35,9 +37,9 @@ def get_introspection_query_ast( schema.select(ds.__Schema.description) schema.select( - ds.__Schema.queryType.select(ds.__Type.name), - ds.__Schema.mutationType.select(ds.__Type.name), - ds.__Schema.subscriptionType.select(ds.__Type.name), + ds.__Schema.queryType.select(ds.__Type.name, ds.__Type.kind), + ds.__Schema.mutationType.select(ds.__Type.name, ds.__Type.kind), + ds.__Schema.subscriptionType.select(ds.__Type.name, ds.__Type.kind), ) schema.select(ds.__Schema.types.select(fragment_FullType)) @@ -68,6 +70,13 @@ def get_introspection_query_ast( ) if descriptions: fragment_FullType.select(ds.__Type.description) + if input_object_one_of: + try: + fragment_FullType.select(ds.__Type.isOneOf) + except AttributeError: # pragma: no cover + raise NotImplementedError( + "isOneOf is only supported from graphql-core version 3.3.0a7" + ) if specified_by_url: fragment_FullType.select(ds.__Type.specifiedByURL) @@ -125,13 +134,14 @@ def get_introspection_query_ast( ) if type_recursion_level >= 1: - current_field = ds.__Type.ofType.select(ds.__Type.kind, ds.__Type.name) - fragment_TypeRef.select(current_field) + current_field = ds.__Type.ofType.select(ds.__Type.name, ds.__Type.kind) for _ in repeat(None, type_recursion_level - 1): - new_oftype = ds.__Type.ofType.select(ds.__Type.kind, ds.__Type.name) - current_field.select(new_oftype) - current_field = new_oftype + parent_field = ds.__Type.ofType.select(ds.__Type.name, ds.__Type.kind) + parent_field.select(current_field) + current_field = parent_field + + fragment_TypeRef.select(current_field) query = DSLQuery(schema) diff --git a/gql/utilities/node_tree.py b/gql/utilities/node_tree.py index 08fb1bf5..ad4fe755 100644 --- a/gql/utilities/node_tree.py +++ b/gql/utilities/node_tree.py @@ -14,7 +14,7 @@ def _node_tree_recursive( results = [] - if hasattr(obj, "__slots__"): + if hasattr(obj, "__slots__") or isinstance(obj, Node): results.append(" " * indent + f"{type(obj).__name__}") @@ -29,22 +29,21 @@ def _node_tree_recursive( continue attr_value = getattr(obj, key, None) results.append(" " * (indent + 1) + f"{key}:") - if isinstance(attr_value, Iterable) and not isinstance( + if attr_value is None or ( + isinstance(attr_value, Sized) and len(attr_value) == 0 + ): + results.append(" " * (indent + 2) + "None") + elif isinstance(attr_value, Iterable) and not isinstance( attr_value, (str, bytes) ): - if isinstance(attr_value, Sized) and len(attr_value) == 0: + for item in attr_value: results.append( - " " * (indent + 2) + f"empty {type(attr_value).__name__}" - ) - else: - for item in attr_value: - results.append( - _node_tree_recursive( - item, - indent=indent + 2, - ignored_keys=ignored_keys, - ) + _node_tree_recursive( + item, + indent=indent + 2, + ignored_keys=ignored_keys, ) + ) else: results.append( _node_tree_recursive( @@ -89,4 +88,11 @@ def node_tree( # We are ignoring block attributes by default (in StringValueNode) ignored_keys.append("block") + # Ignore new field added in graphql-core 3.3.0a12 to keep output compatible + ignored_keys.append("nullability_assertion") + + # Ignore description field which was added to OperationDefinitionNode + # in graphql-core 3.3.0b0 + ignored_keys.append("description") + return _node_tree_recursive(obj, ignored_keys=ignored_keys) diff --git a/gql/utilities/parse_result.py b/gql/utilities/parse_result.py index f9bc2e0c..e0d97aa8 100644 --- a/gql/utilities/parse_result.py +++ b/gql/utilities/parse_result.py @@ -124,10 +124,10 @@ def enter_operation_definition( if not hasattr(node.name, "value"): return REMOVE # pragma: no cover - node.name = cast(NameNode, node.name) + name = cast(NameNode, node.name) - if node.name.value != self.operation_name: - log.debug(f"SKIPPING operation {node.name.value}") + if name.value != self.operation_name: + log.debug(f"SKIPPING operation {name.value}") return REMOVE return IDLE @@ -238,7 +238,7 @@ def enter_field( assert isinstance(selection_set_node, SelectionSetNode) # Keep only the current node in a new selection set node - new_node = SelectionSetNode(selections=[node]) + new_node = SelectionSetNode(selections=(node,)) for item in result_value: diff --git a/gql/utilities/serialize_variable_values.py b/gql/utilities/serialize_variable_values.py index 38ad1995..9007f54a 100644 --- a/gql/utilities/serialize_variable_values.py +++ b/gql/utilities/serialize_variable_values.py @@ -14,7 +14,7 @@ OperationDefinitionNode, type_from_ast, ) -from graphql.pyutils import inspect +from graphql.pyutils import inspect, is_iterable def _get_document_operation( @@ -76,6 +76,9 @@ def serialize_value(type_: GraphQLType, value: Any) -> Any: return serialize_value(inner_type, value) elif isinstance(type_, GraphQLList): + if not is_iterable(value): + # Lists accept a non-list value as a list of one. + return [serialize_value(inner_type, value)] return [serialize_value(inner_type, v) for v in value] elif isinstance(type_, (GraphQLScalarType, GraphQLEnumType)): @@ -115,7 +118,7 @@ def serialize_variable_values( operation = _get_document_operation(document, operation_name=operation_name) # Serialize every variable value defined for the operation - for var_def_node in operation.variable_definitions: + for var_def_node in operation.variable_definitions or (): var_name = var_def_node.variable.name.value var_type = type_from_ast(schema, var_def_node.type) diff --git a/pyproject.toml b/pyproject.toml index f5eb5c8d..a888d750 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "gql" readme = "README.md" -requires-python = ">=3.8.1" +requires-python = ">=3.10" dynamic = ["authors", "classifiers", "dependencies", "description", "entry-points", "keywords", "license", "optional-dependencies", "scripts", "version"] [build-system] @@ -20,3 +20,9 @@ asyncio_default_fixture_loop_scope = "function" ignore_missing_imports = true check_untyped_defs = true disallow_incomplete_defs = true + +[tool.black] +target-version = ["py310"] + +[tool.coverage.run] +patch = ["subprocess"] diff --git a/setup.py b/setup.py index 4f0c8537..d7e9a926 100644 --- a/setup.py +++ b/setup.py @@ -3,10 +3,11 @@ from setuptools import setup, find_packages install_requires = [ - "graphql-core>=3.2,<3.3", + "graphql-core>=3.3.0a3,<3.4", "yarl>=1.6,<2.0", - "backoff>=1.11.1,<3.0", + "tenacity>=9.1.2,<10.0", "anyio>=3.0,<5", + "typing_extensions>=4.0.0; python_version<'3.11'", ] console_scripts = [ @@ -15,24 +16,25 @@ tests_requires = [ "parse==1.20.2", - "pytest==8.3.4", - "pytest-asyncio==0.25.3", + "packaging>=21.0", + "pytest==9.1.1", + "pytest-asyncio==1.4.0", "pytest-console-scripts==1.4.1", - "pytest-cov==6.0.0", - "vcrpy==7.0.0", + "pytest-cov==7.1.0", + "vcrpy==8.2.1", "aiofiles", ] dev_requires = [ - "black==25.1.0", + "black==26.5.1", "check-manifest>=0.42,<1", - "flake8==7.1.2", - "isort==6.0.1", - "mypy==1.15", - "sphinx>=7.0.0,<8;python_version<='3.9'", - "sphinx>=8.1.0,<9;python_version>'3.9'", + "flake8==7.3.0", + "isort==8.0.1", + "mypy==2.1.0", + "sphinx>=8.1.0,<9", "sphinx_rtd_theme>=3.0.2,<4", - "sphinx-argparse==0.5.2", + "sphinx-argparse==0.5.2; python_version>='3.10'", + "sphinx-argparse==0.4.0; python_version<'3.10'", "types-aiofiles", "types-requests", ] + tests_requires @@ -50,6 +52,10 @@ "httpx>=0.27.0,<1", ] +install_httpx2_requires = [ + "httpx2>=2.0.0,<3", +] + install_websockets_requires = [ "websockets>=14.2,<16", ] @@ -63,7 +69,7 @@ ] install_all_requires = ( - install_aiohttp_requires + install_requests_requires + install_httpx_requires + install_websockets_requires + install_botocore_requires + install_aiofiles_requires + install_aiohttp_requires + install_requests_requires + install_httpx2_requires + install_websockets_requires + install_botocore_requires + install_aiofiles_requires ) # Get version from __version__.py file @@ -88,11 +94,11 @@ "Topic :: Software Development :: Libraries", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: PyPy", ], keywords="api graphql protocol rest relay gql client", @@ -108,6 +114,7 @@ "aiohttp": install_aiohttp_requires, "requests": install_requests_requires, "httpx": install_httpx_requires, + "httpx2": install_httpx2_requires, "websockets": install_websockets_requires, "botocore": install_botocore_requires, "aiofiles": install_aiofiles_requires, diff --git a/tests/custom_scalars/test_datetime.py b/tests/custom_scalars/test_datetime.py index 4d9589f1..61aef7cb 100644 --- a/tests/custom_scalars/test_datetime.py +++ b/tests/custom_scalars/test_datetime.py @@ -132,11 +132,9 @@ def test_shift_days_serialized_manually_in_query(): client = Client(schema=schema) - query = gql( - """{ + query = gql("""{ shiftDays(time: "2021-11-12T11:58:13.461161", days: 5) - }""" - ) + }""") result = client.execute(query, parse_result=True) diff --git a/tests/custom_scalars/test_enum_colors.py b/tests/custom_scalars/test_enum_colors.py index ff893571..e8ae4799 100644 --- a/tests/custom_scalars/test_enum_colors.py +++ b/tests/custom_scalars/test_enum_colors.py @@ -158,12 +158,10 @@ def test_opposite_color_variable_serialized_manually(): client = Client(schema=schema, parse_results=True) - query = gql( - """ + query = gql(""" query GetOppositeColor($color: Color) { opposite(color:$color) - }""" - ) + }""") query.variable_values = { "color": "RED", @@ -183,12 +181,10 @@ def test_opposite_color_variable_serialized_by_gql(): client = Client(schema=schema, parse_results=True) - query = gql( - """ + query = gql(""" query GetOppositeColor($color: Color) { opposite(color:$color) - }""" - ) + }""") query.variable_values = { "color": RED, @@ -306,8 +302,7 @@ def test_parse_results_with_operation_type(): client = Client(schema=schema, parse_results=True) - query = gql( - """ + query = gql(""" query GetAll { all } @@ -323,8 +318,7 @@ def test_parse_results_with_operation_type(): query GetListOfListOfList { list_of_list_of_list } - """ - ) + """) query.variable_values = { "color": "RED", diff --git a/tests/custom_scalars/test_json.py b/tests/custom_scalars/test_json.py index 903dfa6d..a709b1a9 100644 --- a/tests/custom_scalars/test_json.py +++ b/tests/custom_scalars/test_json.py @@ -112,8 +112,7 @@ def test_json_value_input_in_ast(): client = Client(schema=schema) - query = gql( - """ + query = gql(""" mutation adding_player { addPlayer(player: { name: "Tom", @@ -124,8 +123,7 @@ def test_json_value_input_in_ast(): "John" ] }) -}""" - ) +}""") result = client.execute(query, root_value=root_value) @@ -147,8 +145,7 @@ def test_json_value_input_in_ast_with_variables(): schema.type_map["Int"] = GraphQLInt schema.type_map["Float"] = GraphQLFloat - query = gql( - """ + query = gql(""" mutation adding_player( $name: String!, $level: Int!, @@ -163,8 +160,7 @@ def test_json_value_input_in_ast_with_variables(): score: $score, friends: $friends, }) -}""" - ) +}""") query.variable_values = { "name": "Barbara", @@ -200,12 +196,9 @@ def test_json_value_input_in_dsl_argument(): print(str(query)) - assert ( - strip_braces_spaces(str(query)) - == """addPlayer( + assert strip_braces_spaces(str(query)) == """addPlayer( player: {name: "Tim", level: 0, is_connected: false, score: 5, friends: ["Lea"]} )""" - ) def test_none_json_value_input_in_dsl_argument(): @@ -234,9 +227,6 @@ def test_json_value_input_with_none_list_in_dsl_argument(): print(str(query)) - assert ( - strip_braces_spaces(str(query)) - == """addPlayer( + assert strip_braces_spaces(str(query)) == """addPlayer( player: {name: "Bob", level: 9001, is_connected: true, score: 666.66, friends: null} )""" - ) diff --git a/tests/custom_scalars/test_money.py b/tests/custom_scalars/test_money.py index 55a6577a..8b3971e5 100644 --- a/tests/custom_scalars/test_money.py +++ b/tests/custom_scalars/test_money.py @@ -61,7 +61,11 @@ def parse_money_value(input_value: Any) -> Money: amount = input_value.get("amount", None) currency = input_value.get("currency", None) - if not is_finite(amount) or not isinstance(currency, str): + if ( + not isinstance(amount, (int, float)) + or not is_finite(amount) + or not isinstance(currency, str) + ): raise GraphQLError("Cannot parse money value dict: " + inspect(input_value)) return Money(float(amount), currency) @@ -208,8 +212,7 @@ def test_custom_scalar_in_output_embedded_fragments(): client = Client(schema=schema, parse_results=True) - query = gql( - """ + query = gql(""" fragment LuxMoneyInternal on CountriesBalance { ... on CountriesBalance { Luxembourg @@ -224,8 +227,7 @@ def test_custom_scalar_in_output_embedded_fragments(): fragment LuxMoney on CountriesBalance { ...LuxMoneyInternal } - """ - ) + """) result = client.execute(query, root_value=root_value) @@ -325,16 +327,14 @@ def test_serialize_variable_values_exception_multiple_ops_without_operation_name client = Client(schema=schema) - query = gql( - """ + query = gql(""" query myconversion($money: Money) { toEuros(money: $money) } query mybalance { balance - }""" - ) + }""") money_value = Money(10, "DM") @@ -359,13 +359,11 @@ def test_serialize_variable_values_exception_operation_name_not_found(): client = Client(schema=schema) - query = gql( - """ + query = gql(""" query myconversion($money: Money) { toEuros(money: $money) } -""" - ) +""") money_value = Money(10, "DM") @@ -556,12 +554,10 @@ async def test_custom_scalar_in_input_variable_values_split_with_transport( transport=transport, ) as session: - query = gql( - """ + query = gql(""" query myquery($amount: Float, $currency: String) { toEuros(money: {amount: $amount, currency: $currency}) -}""" - ) +}""") query.variable_values = {"amount": 10, "currency": "DM"} @@ -845,11 +841,8 @@ async def test_gql_cli_print_schema(aiohttp_server, capsys): captured_out = str(captured.out).strip() print(captured_out) - assert ( - """ + assert """ type Subscription { spend(money: Money): Money } -""".strip() - in captured_out - ) +""".strip() in captured_out diff --git a/tests/custom_scalars/test_parse_results.py b/tests/custom_scalars/test_parse_results.py index 32812818..cbf912a2 100644 --- a/tests/custom_scalars/test_parse_results.py +++ b/tests/custom_scalars/test_parse_results.py @@ -78,8 +78,7 @@ def test_parse_results_null_mapping(): """ client = Client(schema=schema, parse_results=True) - query = gql( - """query testQ($count: Int) {test(count: $count){ + query = gql("""query testQ($count: Int) {test(count: $count){ edges { node { from { @@ -90,8 +89,7 @@ def test_parse_results_null_mapping(): } } } - } }""" - ) + } }""") query.variable_values = {"count": 2} assert client.execute(query) == {"test": static_result} diff --git a/tests/starwars/schema.py b/tests/starwars/schema.py index 8f1efe99..f14a4ea1 100644 --- a/tests/starwars/schema.py +++ b/tests/starwars/schema.py @@ -2,7 +2,9 @@ from typing import cast from graphql import ( + DirectiveLocation, GraphQLArgument, + GraphQLDirective, GraphQLEnumType, GraphQLEnumValue, GraphQLField, @@ -19,6 +21,7 @@ get_introspection_query, graphql_sync, print_schema, + specified_directives, ) from .fixtures import ( @@ -264,12 +267,125 @@ async def resolve_review(review, _info, **_args): }, ) +query_directive = GraphQLDirective( + name="query", + description="Test directive for QUERY location", + locations=[DirectiveLocation.QUERY], + args={ + "value": GraphQLArgument( + GraphQLString, description="A string value for the variable" + ) + }, +) + +field_directive = GraphQLDirective( + name="field", + description="Test directive for FIELD location", + locations=[DirectiveLocation.FIELD], + args={ + "value": GraphQLArgument( + GraphQLString, description="A string value for the variable" + ) + }, +) + +fragment_spread_directive = GraphQLDirective( + name="fragmentSpread", + description="Test directive for FRAGMENT_SPREAD location", + locations=[DirectiveLocation.FRAGMENT_SPREAD], + args={ + "value": GraphQLArgument( + GraphQLString, description="A string value for the variable" + ) + }, +) + +inline_fragment_directive = GraphQLDirective( + name="inlineFragment", + description="Test directive for INLINE_FRAGMENT location", + locations=[DirectiveLocation.INLINE_FRAGMENT], + args={ + "value": GraphQLArgument( + GraphQLString, description="A string value for the variable" + ) + }, +) + +fragment_definition_directive = GraphQLDirective( + name="fragmentDefinition", + description="Test directive for FRAGMENT_DEFINITION location", + locations=[DirectiveLocation.FRAGMENT_DEFINITION], + args={ + "value": GraphQLArgument( + GraphQLString, description="A string value for the variable" + ) + }, +) + +mutation_directive = GraphQLDirective( + name="mutation", + description="Test directive for MUTATION location (tests keyword conflict)", + locations=[DirectiveLocation.MUTATION], + args={ + "value": GraphQLArgument( + GraphQLString, description="A string value for the variable" + ) + }, +) + +subscription_directive = GraphQLDirective( + name="subscription", + description="Test directive for SUBSCRIPTION location", + locations=[DirectiveLocation.SUBSCRIPTION], + args={ + "value": GraphQLArgument( + GraphQLString, description="A string value for the variable" + ) + }, +) + +variable_definition_directive = GraphQLDirective( + name="variableDefinition", + description="Test directive for VARIABLE_DEFINITION location", + locations=[DirectiveLocation.VARIABLE_DEFINITION], + args={ + "value": GraphQLArgument( + GraphQLString, description="A string value for the variable" + ) + }, +) + +repeat_directive = GraphQLDirective( + name="repeat", + description="Test repeatable directive for FIELD location", + locations=[DirectiveLocation.FIELD], + args={ + "value": GraphQLArgument( + GraphQLString, + description="A string value for the repeatable directive", + ) + }, + is_repeatable=True, +) + StarWarsSchema = GraphQLSchema( query=query_type, mutation=mutation_type, subscription=subscription_type, types=[human_type, droid_type, review_type, review_input_type], + directives=[ + *specified_directives, + query_directive, + field_directive, + fragment_spread_directive, + inline_fragment_directive, + fragment_definition_directive, + mutation_directive, + subscription_directive, + variable_definition_directive, + repeat_directive, + ], ) diff --git a/tests/starwars/test_dsl.py b/tests/starwars/test_dsl.py index e47a97d8..301d5cb9 100644 --- a/tests/starwars/test_dsl.py +++ b/tests/starwars/test_dsl.py @@ -15,15 +15,22 @@ NonNullTypeNode, NullValueNode, Undefined, +) +from graphql import __version__ as graphql_version +from graphql import ( build_ast_schema, parse, print_ast, ) from graphql.utilities import get_introspection_query +from packaging import version from gql import Client, gql from gql.dsl import ( + DSLDirective, + DSLField, DSLFragment, + DSLFragmentSpread, DSLInlineFragment, DSLMetaField, DSLMutation, @@ -47,6 +54,12 @@ def ds(): return DSLSchema(StarWarsSchema) +@pytest.fixture +def var(): + """Common DSLVariableDefinitions fixture for directive tests""" + return DSLVariableDefinitions() + + @pytest.fixture def client(): return Client(schema=StarWarsSchema) @@ -142,9 +155,7 @@ def test_use_variable_definition_multiple_times(ds): op.variable_definitions = var query = dsl_gql(op) - assert ( - print_ast(query.document) - == """mutation \ + assert print_ast(query.document) == """mutation \ ($badReview: ReviewInput, $episode: Episode, $goodReview: ReviewInput) { badReview: createReview(review: $badReview, episode: $episode) { stars @@ -155,7 +166,6 @@ def test_use_variable_definition_multiple_times(ds): commentary } }""" - ) assert node_tree(query.document) == node_tree( gql(print_ast(query.document)).document @@ -219,16 +229,13 @@ def test_add_variable_definitions_with_default_value_input_object(ds): op.variable_definitions = var query = dsl_gql(op) - assert ( - strip_braces_spaces(print_ast(query.document)) - == """ + assert strip_braces_spaces(print_ast(query.document)) == """ mutation ($review: ReviewInput = {stars: 5, commentary: "Wow!"}, $episode: Episode) { createReview(review: $review, episode: $episode) { stars commentary } }""".strip() - ) assert node_tree(query.document) == node_tree( gql(print_ast(query.document)).document @@ -487,15 +494,12 @@ def test_subscription(ds): ) ) ) - assert ( - print_ast(query.document) - == """subscription { + assert print_ast(query.document) == """subscription { reviewAdded(episode: JEDI) { stars commentary } }""" - ) assert node_tree(query.document) == node_tree( gql(print_ast(query.document)).document @@ -570,14 +574,11 @@ def test_operation_name(ds): ) ) - assert ( - print_ast(query.document) - == """query GetHeroName { + assert print_ast(query.document) == """query GetHeroName { hero { name } }""" - ) assert node_tree(query.document) == node_tree( gql(print_ast(query.document)).document @@ -594,9 +595,7 @@ def test_multiple_operations(ds): ), ) - assert ( - strip_braces_spaces(print_ast(query.document)) - == """query GetHeroName { + assert strip_braces_spaces(print_ast(query.document)) == """query GetHeroName { hero { name } @@ -611,7 +610,6 @@ def test_multiple_operations(ds): commentary } }""" - ) assert node_tree(query.document) == node_tree( gql(print_ast(query.document)).document @@ -659,7 +657,23 @@ def test_fragments_repr(ds): assert repr(DSLInlineFragment()) == "" assert repr(DSLInlineFragment().on(ds.Droid)) == "" assert repr(DSLFragment("fragment_1")) == "" + assert repr(DSLFragment("fragment_1").spread()) == "" assert repr(DSLFragment("fragment_2").on(ds.Droid)) == "" + assert ( + repr(DSLFragment("fragment_2").on(ds.Droid).spread()) + == "" + ) + + +def test_fragment_spread_instances(ds): + """Test that each .spread() creates new DSLFragmentSpread instance""" + fragment = DSLFragment("Test").on(ds.Character).select(ds.Character.name) + spread1 = fragment.spread() + spread2 = fragment.spread() + + assert isinstance(spread1, DSLFragmentSpread) + assert isinstance(spread2, DSLFragmentSpread) + assert spread1 is not spread2 def test_fragments(ds): @@ -1015,6 +1029,10 @@ def test_invalid_meta_field_selection(ds): ds.Query.hero.select(DSLMetaField("__type")) +@pytest.mark.skipif( + version.parse(graphql_version) < version.parse("3.3.0rc0"), + reason="Requires graphql-core >= 3.3.0rc0", +) @pytest.mark.parametrize("option", [True, False]) def test_get_introspection_query_ast(option): @@ -1059,6 +1077,50 @@ def test_get_introspection_query_ast(option): ) +@pytest.mark.skipif( + version.parse(graphql_version) < version.parse("3.3.0rc0"), + reason="Requires graphql-core >= 3.3.0rc0", +) +@pytest.mark.parametrize("option", [True, False]) +def test_get_introspection_query_ast_is_one_of(option): + + introspection_query = print_ast( + gql( + get_introspection_query( + input_value_deprecation=option, + ) + ).document + ) + + # Because the option does not exist yet in graphql-core, + # we add it manually here for now + if option: + introspection_query = introspection_query.replace( + "fields", + "isOneOf\n fields", + ) + + dsl_introspection_query = get_introspection_query_ast( + input_value_deprecation=option, + input_object_one_of=option, + type_recursion_level=9, + ) + + assert introspection_query == print_ast(dsl_introspection_query) + + +@pytest.mark.skipif( + version.parse(graphql_version) >= version.parse("3.3.0a7"), + reason="Test only for older graphql-core versions < 3.3.0a7", +) +def test_get_introspection_query_ast_is_one_of_not_implemented_yet(): + + with pytest.raises(NotImplementedError): + get_introspection_query_ast( + input_object_one_of=True, + ) + + def test_typename_aliased(ds): query = """ hero { @@ -1092,7 +1154,7 @@ def test_node_tree_with_loc(ds): definitions: OperationDefinitionNode directives: - empty tuple + None loc: Location @@ -1115,9 +1177,9 @@ def test_node_tree_with_loc(ds): alias: None arguments: - empty tuple + None directives: - empty tuple + None loc: Location @@ -1140,9 +1202,9 @@ def test_node_tree_with_loc(ds): alias: None arguments: - empty tuple + None directives: - empty tuple + None loc: Location @@ -1158,7 +1220,7 @@ def test_node_tree_with_loc(ds): selection_set: None variable_definitions: - empty tuple + None loc: Location @@ -1169,7 +1231,7 @@ def test_node_tree_with_loc(ds): definitions: OperationDefinitionNode directives: - empty tuple + None loc: Location @@ -1192,9 +1254,9 @@ def test_node_tree_with_loc(ds): alias: None arguments: - empty tuple + None directives: - empty tuple + None loc: Location @@ -1215,9 +1277,9 @@ def test_node_tree_with_loc(ds): alias: None arguments: - empty tuple + None directives: - empty tuple + None loc: Location @@ -1231,7 +1293,7 @@ def test_node_tree_with_loc(ds): selection_set: None variable_definitions: - empty tuple + None loc: Location @@ -1271,3 +1333,287 @@ def test_legacy_fragment_with_variables(ds): } """.strip() assert print_ast(query.document) == expected + + +@pytest.mark.parametrize( + "shortcut,expected", + [ + ("__typename", DSLMetaField("__typename")), + ("__schema", DSLMetaField("__schema")), + ("__type", DSLMetaField("__type")), + ("...", DSLInlineFragment()), + ("@skip", DSLDirective(name="skip", dsl_schema=DSLSchema(StarWarsSchema))), + ], +) +def test_dsl_schema_call_shortcuts(ds, shortcut, expected): + actual = ds(shortcut) + assert getattr(actual, "name", None) == getattr(expected, "name", None) + assert isinstance(actual, type(expected)) + + +def test_dsl_schema_call_validation(ds): + with pytest.raises(ValueError, match="(?i)unsupported shortcut"): + ds("foo") + + +def test_executable_directives(ds, var): + """Test ALL executable directive locations and types in one document""" + + # Fragment with both built-in and custom directives + fragment = ( + DSLFragment("CharacterInfo") + .on(ds.Character) + .select(ds.Character.name, ds.Character.appearsIn) + .directives(ds("@fragmentDefinition")) + ) + + # Query with multiple directive types + query = DSLQuery( + ds.Query.hero.args(episode=var.episode).select( + # Field with both built-in and custom directives + ds.Character.name.directives( + ds("@skip")(**{"if": var.skipName}), + ds("@field"), # custom field directive + ), + # Field with repeated directives (same directive multiple times) + ds.Character.appearsIn.directives( + ds("@repeat")(value="first"), + ds("@repeat")(value="second"), + ds("@repeat")(value="third"), + ), + # Fragment spread with multiple directives + fragment.spread().directives( + ds("@include")(**{"if": var.includeSpread}), + ds("@fragmentSpread"), + ), + # Inline fragment with directives + DSLInlineFragment() + .on(ds.Human) + .select(ds.Human.homePlanet) + .directives( + ds("@skip")(**{"if": var.skipInline}), + ds("@inlineFragment"), + ), + # Meta field with directive + DSLMetaField("__typename").directives( + ds("@include")(**{"if": var.includeType}) + ), + ) + ).directives(ds("@query")) + + # Mutation with directives + mutation = DSLMutation( + ds.Mutation.createReview.args( + episode=6, review={"stars": 5, "commentary": "Great!"} + ).select(ds.Review.stars, ds.Review.commentary) + ).directives(ds("@mutation")) + + # Subscription with directives + subscription = DSLSubscription( + ds.Subscription.reviewAdded.args(episode=6).select( + ds.Review.stars, ds.Review.commentary + ) + ).directives(ds("@subscription")) + + # Variable definitions with directives + var.episode.directives( + # Note that `$episode: Episode @someDirective(value=$someValue)` + # is INVALID GraphQL because variable definitions must be literal values + ds("@variableDefinition"), + ) + query.variable_definitions = var + + # Generate ONE document with everything + doc = dsl_gql( + fragment, HeroQuery=query, CreateReview=mutation, ReviewSub=subscription + ) + + expected = """\ +fragment CharacterInfo on Character @fragmentDefinition { + name + appearsIn +} + +query HeroQuery(\ +$episode: Episode @variableDefinition, \ +$skipName: Boolean!, \ +$includeSpread: Boolean!, \ +$skipInline: Boolean!, \ +$includeType: Boolean!\ +) @query { + hero(episode: $episode) { + name @skip(if: $skipName) @field + appearsIn @repeat(value: "first") @repeat(value: "second") @repeat(value: "third") + ...CharacterInfo @include(if: $includeSpread) @fragmentSpread + ... on Human @skip(if: $skipInline) @inlineFragment { + homePlanet + } + __typename @include(if: $includeType) + } +} + +mutation CreateReview @mutation { + createReview(episode: JEDI, review: {stars: 5, commentary: "Great!"}) { + stars + commentary + } +} + +subscription ReviewSub @subscription { + reviewAdded(episode: JEDI) { + stars + commentary + } +}""" + + assert strip_braces_spaces(print_ast(doc.document)) == expected + assert node_tree(doc.document) == node_tree(gql(expected).document) + + +def test_directive_repr(ds): + """Test DSLDirective string representation""" + directive = ds("@include")(**{"if": True}) + expected = "" + assert repr(directive) == expected + + +def test_directive_error_handling(ds): + """Test error handling for directives""" + # Invalid directive argument type + with pytest.raises(TypeError, match="Expected DSLDirective"): + ds.Query.hero.directives(123) + + # Invalid directive name from `__call__ + with pytest.raises(GraphQLError, match="Directive '@nonexistent' not found"): + ds("@nonexistent") + + # Invalid directive argument + with pytest.raises(GraphQLError, match="Argument 'invalid' does not exist"): + ds("@include")(invalid=True) + + # Tried to set arguments twice + with pytest.raises( + AttributeError, match="Arguments for directive @field already set." + ): + ds("@field").args(value="foo").args(value="bar") + + with pytest.raises( + GraphQLError, + match="(?i)Directive '@deprecated' is not a valid request executable directive", + ): + ds("@deprecated") + + with pytest.raises(GraphQLError, match="unexpected variable"): + # variable definitions must be static, literal values defined in the query! + var = DSLVariableDefinitions() + query = DSLQuery( + ds.Query.hero.args(episode=var.episode).select(ds.Character.name) + ) + var.episode.directives( + ds("@variableDefinition").args(value=var.nonStatic), + ) + query.variable_definitions = var + _ = dsl_gql(query).document + + +# Parametrized tests for comprehensive directive location validation +@pytest.fixture( + params=[ + "@query", + "@mutation", + "@subscription", + "@field", + "@fragmentDefinition", + "@fragmentSpread", + "@inlineFragment", + "@variableDefinition", + ] +) +def directive_name(request): + return request.param + + +@pytest.fixture( + params=[ + (DSLQuery, "QUERY"), + (DSLMutation, "MUTATION"), + (DSLSubscription, "SUBSCRIPTION"), + (DSLField, "FIELD"), + (DSLMetaField, "FIELD"), + (DSLFragment, "FRAGMENT_DEFINITION"), + (DSLFragmentSpread, "FRAGMENT_SPREAD"), + (DSLInlineFragment, "INLINE_FRAGMENT"), + (DSLVariable, "VARIABLE_DEFINITION"), + ] +) +def dsl_class_and_location(request): + return request.param + + +@pytest.fixture +def is_valid_combination(directive_name, dsl_class_and_location): + # Map directive names to their expected locations + directive_to_location = { + "@query": "QUERY", + "@mutation": "MUTATION", + "@subscription": "SUBSCRIPTION", + "@field": "FIELD", + "@fragmentDefinition": "FRAGMENT_DEFINITION", + "@fragmentSpread": "FRAGMENT_SPREAD", + "@inlineFragment": "INLINE_FRAGMENT", + "@variableDefinition": "VARIABLE_DEFINITION", + } + expected_location = directive_to_location[directive_name] + _, actual_location = dsl_class_and_location + return expected_location == actual_location + + +def create_dsl_instance(dsl_class, ds): + """Helper function to create DSL instances for testing""" + if dsl_class == DSLQuery: + return DSLQuery(ds.Query.hero.select(ds.Character.name)) + elif dsl_class == DSLMutation: + return DSLMutation( + ds.Mutation.createReview.args(episode=6, review={"stars": 5}).select( + ds.Review.stars + ) + ) + elif dsl_class == DSLSubscription: + return DSLSubscription( + ds.Subscription.reviewAdded.args(episode=6).select(ds.Review.stars) + ) + elif dsl_class == DSLField: + return ds.Query.hero + elif dsl_class == DSLMetaField: + return DSLMetaField("__typename") + elif dsl_class == DSLFragment: + return DSLFragment("test").on(ds.Character).select(ds.Character.name) + elif dsl_class == DSLFragmentSpread: + fragment = DSLFragment("test").on(ds.Character).select(ds.Character.name) + return fragment.spread() + elif dsl_class == DSLInlineFragment: + return DSLInlineFragment().on(ds.Human).select(ds.Human.homePlanet) + elif dsl_class == DSLVariable: + var = DSLVariableDefinitions() + return var.testVar + else: + raise ValueError(f"Unknown DSL class: {dsl_class}") + + +def test_directive_location_validation( + ds, directive_name, dsl_class_and_location, is_valid_combination +): + """Test all 64 combinations of 8 directives × 8 DSL classes""" + dsl_class, _ = dsl_class_and_location + directive = ds(directive_name) + + # Create instance of DSL class and try to apply directive + instance = create_dsl_instance(dsl_class, ds) + + if is_valid_combination: + # Should work without error + instance.directives(directive) + else: + # Should raise GraphQLError for invalid location + with pytest.raises(GraphQLError, match="Invalid directive location"): + instance.directives(directive) diff --git a/tests/starwars/test_parse_results.py b/tests/starwars/test_parse_results.py index 2ae94ea8..fb335bf4 100644 --- a/tests/starwars/test_parse_results.py +++ b/tests/starwars/test_parse_results.py @@ -9,8 +9,7 @@ def test_hero_name_and_friends_query(): - query = gql( - """ + query = gql(""" query HeroNameAndFriendsQuery { hero { id @@ -20,8 +19,7 @@ def test_hero_name_and_friends_query(): name } } - """ - ) + """) result = { "hero": { @@ -43,8 +41,7 @@ def test_hero_name_and_friends_query(): def test_hero_name_and_friends_query_with_fragment(): """Testing for issue #445""" - query = gql( - """ + query = gql(""" query HeroNameAndFriendsQuery { hero { ...HeroSummary @@ -57,8 +54,7 @@ def test_hero_name_and_friends_query_with_fragment(): id name } - """ - ) + """) result = { "hero": { @@ -79,15 +75,13 @@ def test_hero_name_and_friends_query_with_fragment(): def test_key_not_found_in_result(): - query = gql( - """ + query = gql(""" { hero { id } } - """ - ) + """) # Backend returned an invalid result without the hero key # Should be impossible. In that case, we ignore the missing key @@ -100,15 +94,13 @@ def test_key_not_found_in_result(): def test_invalid_result_raise_error(): - query = gql( - """ + query = gql(""" { hero { id } } - """ - ) + """) result = {"hero": 5} @@ -121,8 +113,7 @@ def test_invalid_result_raise_error(): def test_fragment(): - query = gql( - """ + query = gql(""" query UseFragment { luke: human(id: "1000") { ...HumanFragment @@ -135,8 +126,7 @@ def test_fragment(): name homePlanet } - """ - ) + """) result = { "luke": {"name": "Luke Skywalker", "homePlanet": "Tatooine"}, @@ -150,15 +140,13 @@ def test_fragment(): def test_fragment_not_found(): - query = gql( - """ + query = gql(""" query UseFragment { luke: human(id: "1000") { ...HumanFragment } } - """ - ) + """) result = { "luke": {"name": "Luke Skywalker", "homePlanet": "Tatooine"}, @@ -173,15 +161,13 @@ def test_fragment_not_found(): def test_return_none_if_result_is_none(): - query = gql( - """ + query = gql(""" query { hero { id } } - """ - ) + """) result = None @@ -190,15 +176,13 @@ def test_return_none_if_result_is_none(): def test_null_result_is_allowed(): - query = gql( - """ + query = gql(""" query { hero { id } } - """ - ) + """) result = {"hero": None} @@ -209,8 +193,7 @@ def test_null_result_is_allowed(): def test_inline_fragment(): - query = gql( - """ + query = gql(""" query UseFragment { luke: human(id: "1000") { ... on Human { @@ -219,8 +202,7 @@ def test_inline_fragment(): } } } - """ - ) + """) result = { "luke": {"name": "Luke Skywalker", "homePlanet": "Tatooine"}, diff --git a/tests/starwars/test_query.py b/tests/starwars/test_query.py index ff2af7d7..ca81b12b 100644 --- a/tests/starwars/test_query.py +++ b/tests/starwars/test_query.py @@ -11,23 +11,20 @@ def client(): def test_hero_name_query(client): - query = gql( - """ + query = gql(""" query HeroNameQuery { hero { name } } - """ - ) + """) expected = {"hero": {"name": "R2-D2"}} result = client.execute(query) assert result == expected def test_hero_name_and_friends_query(client): - query = gql( - """ + query = gql(""" query HeroNameAndFriendsQuery { hero { id @@ -37,8 +34,7 @@ def test_hero_name_and_friends_query(client): } } } - """ - ) + """) expected = { "hero": { "id": "2001", @@ -55,8 +51,7 @@ def test_hero_name_and_friends_query(client): def test_nested_query(client): - query = gql( - """ + query = gql(""" query NestedQuery { hero { name @@ -69,8 +64,7 @@ def test_nested_query(client): } } } - """ - ) + """) expected = { "hero": { "name": "R2-D2", @@ -112,30 +106,26 @@ def test_nested_query(client): def test_fetch_luke_query(client): - query = gql( - """ + query = gql(""" query FetchLukeQuery { human(id: "1000") { name } } - """ - ) + """) expected = {"human": {"name": "Luke Skywalker"}} result = client.execute(query) assert result == expected def test_fetch_some_id_query(client): - query = gql( - """ + query = gql(""" query FetchSomeIDQuery($someId: String!) { human(id: $someId) { name } } - """ - ) + """) query.variable_values = { "someId": "1000", } @@ -145,15 +135,13 @@ def test_fetch_some_id_query(client): def test_fetch_some_id_query2(client): - query = gql( - """ + query = gql(""" query FetchSomeIDQuery($someId: String!) { human(id: $someId) { name } } - """ - ) + """) query.variable_values = { "someId": "1002", } @@ -163,15 +151,13 @@ def test_fetch_some_id_query2(client): def test_invalid_id_query(client): - query = gql( - """ + query = gql(""" query humanQuery($id: String!) { human(id: $id) { name } } - """ - ) + """) query.variable_values = { "id": "not a valid id", } @@ -181,23 +167,20 @@ def test_invalid_id_query(client): def test_fetch_luke_aliased(client): - query = gql( - """ + query = gql(""" query FetchLukeAliased { luke: human(id: "1000") { name } } - """ - ) + """) expected = {"luke": {"name": "Luke Skywalker"}} result = client.execute(query) assert result == expected def test_fetch_luke_and_leia_aliased(client): - query = gql( - """ + query = gql(""" query FetchLukeAndLeiaAliased { luke: human(id: "1000") { name @@ -206,16 +189,14 @@ def test_fetch_luke_and_leia_aliased(client): name } } - """ - ) + """) expected = {"luke": {"name": "Luke Skywalker"}, "leia": {"name": "Leia Organa"}} result = client.execute(query) assert result == expected def test_duplicate_fields(client): - query = gql( - """ + query = gql(""" query DuplicateFields { luke: human(id: "1000") { name @@ -226,8 +207,7 @@ def test_duplicate_fields(client): homePlanet } } - """ - ) + """) expected = { "luke": {"name": "Luke Skywalker", "homePlanet": "Tatooine"}, "leia": {"name": "Leia Organa", "homePlanet": "Alderaan"}, @@ -237,8 +217,7 @@ def test_duplicate_fields(client): def test_use_fragment(client): - query = gql( - """ + query = gql(""" query UseFragment { luke: human(id: "1000") { ...HumanFragment @@ -251,8 +230,7 @@ def test_use_fragment(client): name homePlanet } - """ - ) + """) expected = { "luke": {"name": "Luke Skywalker", "homePlanet": "Tatooine"}, "leia": {"name": "Leia Organa", "homePlanet": "Alderaan"}, @@ -262,32 +240,28 @@ def test_use_fragment(client): def test_check_type_of_r2(client): - query = gql( - """ + query = gql(""" query CheckTypeOfR2 { hero { __typename name } } - """ - ) + """) expected = {"hero": {"__typename": "Droid", "name": "R2-D2"}} result = client.execute(query) assert result == expected def test_check_type_of_luke(client): - query = gql( - """ + query = gql(""" query CheckTypeOfLuke { hero(episode: EMPIRE) { __typename name } } - """ - ) + """) expected = {"hero": {"__typename": "Human", "name": "Luke Skywalker"}} result = client.execute(query) assert result == expected @@ -295,27 +269,23 @@ def test_check_type_of_luke(client): def test_parse_error(client): with pytest.raises(Exception) as exc_info: - gql( - """ + gql(""" qeury - """ - ) + """) error = exc_info.value assert isinstance(error, GraphQLError) assert "Syntax Error: Unexpected Name 'qeury'." in str(error) def test_mutation_result(client): - query = gql( - """ + query = gql(""" mutation CreateReviewForEpisode($ep: Episode!, $review: ReviewInput!) { createReview(episode: $ep, review: $review) { stars commentary } } - """ - ) + """) query.variable_values = { "ep": "JEDI", "review": {"stars": 5, "commentary": "This is a great movie!"}, diff --git a/tests/starwars/test_validation.py b/tests/starwars/test_validation.py index 75ce4162..129e8856 100644 --- a/tests/starwars/test_validation.py +++ b/tests/starwars/test_validation.py @@ -14,8 +14,7 @@ def local_schema(): @pytest.fixture def typedef_schema(): - return Client( - schema=""" + return Client(schema=""" schema { query: Query } @@ -53,8 +52,7 @@ def typedef_schema(): droid(id: String!): Droid hero(episode: Episode): Character human(id: String!): Human -}""" - ) +}""") @pytest.fixture diff --git a/tests/test_aiohttp.py b/tests/test_aiohttp.py index 506b04f4..00bd8a0f 100644 --- a/tests/test_aiohttp.py +++ b/tests/test_aiohttp.py @@ -6,7 +6,7 @@ import pytest -from gql import Client, FileVar, gql +from gql import Client, FileVar, GraphQLRequest, gql from gql.cli import get_parser, main from gql.transport.exceptions import ( TransportAlreadyConnected, @@ -87,6 +87,43 @@ async def handler(request): assert transport.response_headers["dummy"] == "test1234" +@pytest.mark.asyncio +async def test_aiohttp_request_extensions(aiohttp_server): + from aiohttp import web + + from gql.transport.aiohttp import AIOHTTPTransport + + extensions = {"persistedQuery": {"version": 1, "sha256Hash": "abc123"}} + + async def handler(request): + body = await request.json() + assert body["extensions"] == extensions + return web.Response( + text=query1_server_answer, + content_type="application/json", + ) + + app = web.Application() + app.router.add_route("POST", "/", handler) + server = await aiohttp_server(app) + + url = server.make_url("/") + + transport = AIOHTTPTransport(url=url, timeout=10) + + request = GraphQLRequest(query1_str, extensions=extensions) + + async with Client(transport=transport) as session: + + # execute + result = await session.execute(request) + assert result["continents"][0]["code"] == "AF" + + # subscribe + async for result in session.subscribe(request): + assert result["continents"][0]["code"] == "AF" + + @pytest.mark.asyncio async def test_aiohttp_ignore_backend_content_type(aiohttp_server): from aiohttp import web @@ -353,32 +390,6 @@ async def handler(request): assert param["expected_exception"] in str(exc_info.value) -@pytest.mark.asyncio -async def test_aiohttp_subscribe_not_supported(aiohttp_server): - from aiohttp import web - - from gql.transport.aiohttp import AIOHTTPTransport - - async def handler(request): - return web.Response(text="does not matter", content_type="application/json") - - app = web.Application() - app.router.add_route("POST", "/", handler) - server = await aiohttp_server(app) - - url = server.make_url("/") - - transport = AIOHTTPTransport(url=url) - - async with Client(transport=transport) as session: - - query = gql(query1_str) - - with pytest.raises(NotImplementedError): - async for result in session.subscribe(query): - pass - - @pytest.mark.asyncio async def test_aiohttp_cannot_connect_twice(aiohttp_server): from aiohttp import web @@ -590,16 +601,17 @@ def test_code(): query = gql(query1_str) - # Note: subscriptions are not supported on the aiohttp transport - # But we add this test in order to have 100% code coverage # It is to check that we will correctly set an event loop # in the subscribe function if there is none (in a Thread for example) # We cannot test this with the websockets transport because # the websockets transport will set an event loop in its init - with pytest.raises(NotImplementedError): - for result in client.subscribe(query): - pass + results = [] + for result in client.subscribe(query): + results.append(result) + + assert len(results) == 1 + assert results[0]["continents"][0]["code"] == "AF" await run_sync_test(server, test_code) diff --git a/tests/test_aiohttp_batch.py b/tests/test_aiohttp_batch.py index ad9924a0..37d8f64e 100644 --- a/tests/test_aiohttp_batch.py +++ b/tests/test_aiohttp_batch.py @@ -89,6 +89,38 @@ async def handler(request): assert transport.response_headers["dummy"] == "test1234" +@pytest.mark.asyncio +async def test_aiohttp_batch_request_extensions(aiohttp_server): + from aiohttp import web + + from gql.transport.aiohttp import AIOHTTPTransport + + extensions = {"persistedQuery": {"version": 1, "sha256Hash": "abc123"}} + + async def handler(request): + body = await request.json() + assert isinstance(body, list) + assert body[0]["extensions"] == extensions + return web.Response( + text=query1_server_answer_list, + content_type="application/json", + ) + + app = web.Application() + app.router.add_route("POST", "/", handler) + server = await aiohttp_server(app) + + url = server.make_url("/") + + transport = AIOHTTPTransport(url=url, timeout=10) + + async with Client(transport=transport) as session: + + query = [GraphQLRequest(query1_str, extensions=extensions)] + results = await session.execute_batch(query) + assert results[0]["continents"][0]["code"] == "AF" + + @pytest.mark.asyncio async def test_aiohttp_batch_query_auto_batch_enabled(aiohttp_server, run_sync_test): from aiohttp import web diff --git a/tests/test_aiohttp_multipart.py b/tests/test_aiohttp_multipart.py new file mode 100644 index 00000000..d43a2722 --- /dev/null +++ b/tests/test_aiohttp_multipart.py @@ -0,0 +1,698 @@ +import asyncio +import json +from unittest.mock import AsyncMock, patch + +import pytest + +from gql import Client, gql +from gql.graphql_request import GraphQLRequest +from gql.transport.exceptions import ( + TransportClosed, + TransportConnectionFailed, + TransportProtocolError, + TransportServerError, +) + +# Marking all tests in this file with the aiohttp marker +pytestmark = pytest.mark.aiohttp + +subscription_str = """ + subscription { + book { + title + author + } + } +""" + +book1 = {"title": "Book 1", "author": "Author 1"} +book2 = {"title": "Book 2", "author": "Author 2"} +book3 = {"title": "Book 3", "author": "Author 3"} + + +def create_multipart_response(books, *, separator="\r\n", include_heartbeat=False): + """Helper to create parts for a streamed response body.""" + parts = [] + + for idx, book in enumerate(books): + data = {"data": {"book": book}} + payload = {"payload": data} + + parts.append(( + f"--graphql{separator}" + f"Content-Type: application/json{separator}" + f"{separator}" + f"{json.dumps(payload)}{separator}" + )) # fmt: skip + + # Add heartbeat after first item if requested + if include_heartbeat and idx == 0: + parts.append(( + f"--graphql{separator}" + f"Content-Type: application/json{separator}" + f"{separator}" + f"{{}}{separator}" + )) # fmt: skip + + # Add end boundary + parts.append(f"--graphql--{separator}") + + return parts + + +@pytest.fixture +def multipart_server(aiohttp_server): + from aiohttp import web + + async def create_server( + parts, + *, + content_type=( + "multipart/mixed;boundary=graphql;subscriptionSpec=1.0,application/json" + ), + request_handler=lambda *args: None, + ): + async def handler(request): + request_handler(request) + response = web.StreamResponse() + response.headers["Content-Type"] = content_type + response.enable_chunked_encoding() + await response.prepare(request) + for part in parts: + if isinstance(part, str): + await response.write(part.encode()) + else: + await response.write(part) + await asyncio.sleep(0) # force the chunk to be written + await response.write_eof() + return response + + app = web.Application() + app.router.add_route("POST", "/", handler) + server = await aiohttp_server(app) + return server + + return create_server + + +@pytest.mark.asyncio +async def test_aiohttp_multipart_subscription(multipart_server): + from gql.transport.aiohttp import AIOHTTPTransport + + def assert_response_headers(request): + # Verify the Accept header follows the spec + accept_header = request.headers["accept"] + assert "multipart/mixed" in accept_header + assert "boundary=graphql" in accept_header + assert "subscriptionSpec=1.0" in accept_header + assert "application/json" in accept_header + + parts = create_multipart_response([book1, book2]) + server = await multipart_server(parts, request_handler=assert_response_headers) + url = server.make_url("/") + transport = AIOHTTPTransport(url=url) + + query = gql(subscription_str) + + async with Client(transport=transport) as session: + results = [] + async for result in session.subscribe(query): + results.append(result) + + # Heartbeats should be filtered out + assert len(results) == 2 + assert results[0]["book"]["title"] == "Book 1" + assert results[1]["book"]["title"] == "Book 2" + + +@pytest.mark.asyncio +async def test_aiohttp_multipart_subscription_with_heartbeat(multipart_server): + from gql.transport.aiohttp import AIOHTTPTransport + + parts = create_multipart_response([book1, book2], include_heartbeat=True) + server = await multipart_server(parts) + url = server.make_url("/") + transport = AIOHTTPTransport(url=url) + + query = gql(subscription_str) + + async with Client(transport=transport) as session: + results = [] + async for result in session.subscribe(query): + results.append(result) + + # Heartbeats should be filtered out + assert len(results) == 2 + assert results[0]["book"]["title"] == "Book 1" + assert results[1]["book"]["title"] == "Book 2" + + +@pytest.mark.aiohttp +@pytest.mark.asyncio +async def test_aiohttp_multipart_unsupported_content_type(aiohttp_server): + from aiohttp import web + + from gql.transport.aiohttp import AIOHTTPTransport + + async def handler(request): + # Return text/html instead of application/json + return web.Response(text="

hello

", content_type="text/html") + + app = web.Application() + app.router.add_route("POST", "/", handler) + server = await aiohttp_server(app) + transport = AIOHTTPTransport(url=server.make_url("/")) + + query = gql(subscription_str) + + async with Client(transport=transport) as session: + with pytest.raises(TransportProtocolError) as exc_info: + async for result in session.subscribe(query): + pass + + assert "Unexpected content-type" in str(exc_info.value) + + +@pytest.mark.aiohttp +@pytest.mark.asyncio +async def test_aiohttp_multipart_server_error(aiohttp_server): + from aiohttp import web + + from gql.transport.aiohttp import AIOHTTPTransport + + async def handler(request): + return web.Response(text="Internal Server Error", status=500) + + app = web.Application() + app.router.add_route("POST", "/", handler) + server = await aiohttp_server(app) + transport = AIOHTTPTransport(url=server.make_url("/")) + + query = gql(subscription_str) + + async with Client(transport=transport) as session: + with pytest.raises(TransportServerError) as exc_info: + async for result in session.subscribe(query): + pass + + assert "Internal Server Error" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_aiohttp_multipart_transport_not_connected(multipart_server): + from gql.transport.aiohttp import AIOHTTPTransport + + parts = create_multipart_response([book1]) + server = await multipart_server(parts) + transport = AIOHTTPTransport(url=server.make_url("/")) + + query = gql(subscription_str) + request = GraphQLRequest(query) + + with pytest.raises(TransportClosed): + async for result in transport.subscribe(request): + pass + + +@pytest.mark.asyncio +async def test_aiohttp_multipart_transport_level_error(multipart_server): + from gql.transport.aiohttp import AIOHTTPTransport + + # Transport error has null payload with errors at top level + error_response = { + "payload": None, + "errors": [{"message": "Transport connection failed"}], + } + parts = [ + ( + "--graphql\r\n" + "Content-Type: application/json\r\n" + "\r\n" + f"{json.dumps(error_response)}\r\n" + ), + "--graphql--\r\n", + ] + + server = await multipart_server(parts) + url = server.make_url("/") + transport = AIOHTTPTransport(url=url) + + async with Client(transport=transport) as session: + query = gql(subscription_str) + + with pytest.raises(TransportServerError) as exc_info: + async for result in session.subscribe(query): + pass + + assert "Transport connection failed" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_aiohttp_multipart_graphql_errors(multipart_server): + from gql.transport.aiohttp import AIOHTTPTransport + from gql.transport.exceptions import TransportQueryError + + # GraphQL errors come inside the payload + response = { + "payload": { + "data": {"book": {**book1, "author": None}}, + "errors": [ + {"message": "could not fetch author", "path": ["book", "author"]} + ], + } + } + parts = [ + ( + f"--graphql\r\n" + f"Content-Type: application/json\r\n" + f"\r\n" + f"{json.dumps(response)}\r\n" + ), + "--graphql--\r\n", + ] + + server = await multipart_server(parts) + url = server.make_url("/") + transport = AIOHTTPTransport(url=url) + + async with Client(transport=transport) as session: + query = gql(subscription_str) + + # Client raises TransportQueryError when there are errors in the result + with pytest.raises(TransportQueryError) as exc_info: + async for result in session.subscribe(query): + pass + + # Verify error details + assert "could not fetch author" in str(exc_info.value).lower() + assert exc_info.value.data is not None + assert exc_info.value.data["book"]["author"] is None + # Verify we can still get data for the non-error fields + assert exc_info.value.data["book"]["title"] == "Book 1" + + +@pytest.mark.asyncio +async def test_aiohttp_multipart_newline_separator(multipart_server): + """Test that LF-only separators are rejected (spec requires CRLF).""" + from gql.transport.aiohttp import AIOHTTPTransport + + # The GraphQL over HTTP spec requires CRLF line endings in multipart responses + # https://github.com/graphql/graphql-over-http/blob/main/rfcs/IncrementalDelivery.md + parts = create_multipart_response([book1], separator="\n") + server = await multipart_server(parts) + transport = AIOHTTPTransport(url=server.make_url("/")) + + query = gql(subscription_str) + + async with Client(transport=transport) as session: + # Non-compliant multipart format (LF instead of CRLF) should fail + with pytest.raises(TransportConnectionFailed): + async for result in session.subscribe(query): + pass + + +@pytest.mark.asyncio +async def test_aiohttp_multipart_ssl_close_timeout(multipart_server): + """Test SSL close timeout during transport close.""" + from gql.transport.aiohttp import AIOHTTPTransport + + parts = create_multipart_response([book1], separator="\n") + server = await multipart_server(parts) + url = server.make_url("/") + + transport = AIOHTTPTransport(url=url, ssl_close_timeout=0.001) + + await transport.connect() + + # Mock the closed event to timeout + with patch( + "gql.transport.common.aiohttp_closed_event.create_aiohttp_closed_event" + ) as mock_event: + mock_wait = AsyncMock() + mock_wait.side_effect = asyncio.TimeoutError() + mock_event.return_value.wait = mock_wait + + # Should handle timeout gracefully + await transport.close() + + +@pytest.mark.asyncio +async def test_aiohttp_multipart_malformed_json(multipart_server): + """Test handling of malformed JSON in multipart response.""" + from gql.transport.aiohttp import AIOHTTPTransport + + parts = [ + ( + "--graphql\r\n" + "Content-Type: application/json\r\n" + "\r\n" + "{invalid json }\r\n" + ), + "--graphql--\r\n", + ] + + server = await multipart_server(parts) + url = server.make_url("/") + transport = AIOHTTPTransport(url=url) + + async with Client(transport=transport) as session: + query = gql(subscription_str) + + results = [] + async for result in session.subscribe(query): + results.append(result) + + # Should skip malformed parts + assert len(results) == 0 + + +@pytest.mark.asyncio +async def test_aiohttp_multipart_payload_null_no_errors(multipart_server): + """Test handling of null payload without errors.""" + from gql.transport.aiohttp import AIOHTTPTransport + + # Null payload but no errors + response = {"payload": None} + parts = [ + ( + "--graphql\r\n" + "Content-Type: application/json\r\n" + "\r\n" + f"{json.dumps(response)}\r\n" + ), + "--graphql--\r\n", + ] + + server = await multipart_server(parts) + url = server.make_url("/") + transport = AIOHTTPTransport(url=url) + + async with Client(transport=transport) as session: + query = gql(subscription_str) + results = [] + async for result in session.subscribe(query): + results.append(result) + + # Null payload without errors should return nothing + assert len(results) == 0 + + +@pytest.mark.asyncio +async def test_aiohttp_multipart_invalid_utf8(multipart_server): + """Test handling of invalid UTF-8 in multipart response.""" + from gql.transport.aiohttp import AIOHTTPTransport + + parts = [ + ( + "--graphql\r\n" + "Content-Type: application/json\r\n" + "\r\n" + "\xff\xfe\r\n" # Contains invalid UTF-8 + ), + "--graphql--\r\n", + ] + + server = await multipart_server(parts) + url = server.make_url("/") + transport = AIOHTTPTransport(url=url) + + async with Client(transport=transport) as session: + query = gql(subscription_str) + results = [] + async for result in session.subscribe(query): + results.append(result) + + # Should skip invalid part + assert len(results) == 0 + + +@pytest.mark.asyncio +async def test_aiohttp_multipart_chunked_boundary_split(multipart_server): + """Test parsing when boundary is split across chunks.""" + from gql.transport.aiohttp import AIOHTTPTransport + + parts = [ + "--gra", + ( + "phql\r\nContent-Type: application/json\r\n\r\n" + '{"payload": {"data": {"book": {"title": "Bo' + ), + 'ok 1"}}}}\r\n--graphql--\r\n', + ] + + server = await multipart_server(parts) + url = server.make_url("/") + transport = AIOHTTPTransport(url=url) + + async with Client(transport=transport) as session: + query = gql(subscription_str) + results = [] + async for result in session.subscribe(query): + results.append(result) + + assert len(results) == 1 + assert results[0]["book"]["title"] == "Book 1" + + +@pytest.mark.asyncio +async def test_aiohttp_multipart_wrong_part_content_type(multipart_server): + """Test that parts with wrong content-type raise an error.""" + from gql.transport.aiohttp import AIOHTTPTransport + + # Part with text/html instead of application/json + parts = [ + ("--graphql\r\n" "Content-Type: text/html\r\n" "\r\n" "

hello

\r\n"), + "--graphql--\r\n", + ] + + server = await multipart_server(parts) + url = server.make_url("/") + transport = AIOHTTPTransport(url=url) + + async with Client(transport=transport) as session: + query = gql(subscription_str) + + with pytest.raises(TransportProtocolError) as exc_info: + async for result in session.subscribe(query): + pass + + assert "Unexpected part content-type" in str(exc_info.value) + assert "text/html" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_aiohttp_multipart_empty_part_no_content_type_skipped(multipart_server): + """Test that empty parts with no content-type are skipped.""" + from gql.transport.aiohttp import AIOHTTPTransport + + book1_payload = json.dumps({"payload": {"data": {"book": book1}}}) + + parts = [ + ("--graphql\r\n" "\r\n" "\r\n"), + ( + "--graphql\r\n" + "Content-Type: application/json\r\n" + "\r\n" + f"{book1_payload}\r\n" + ), + "--graphql--\r\n", + ] + + server = await multipart_server(parts) + url = server.make_url("/") + transport = AIOHTTPTransport(url=url) + + async with Client(transport=transport) as session: + query = gql(subscription_str) + results = [] + async for result in session.subscribe(query): + results.append(result) + + assert len(results) == 1 + assert results[0]["book"]["title"] == "Book 1" + + +@pytest.mark.asyncio +async def test_aiohttp_multipart_response_headers(multipart_server): + """Test that response headers are captured in the transport.""" + from gql.transport.aiohttp import AIOHTTPTransport + + parts = create_multipart_response([book1]) + server = await multipart_server(parts) + url = server.make_url("/") + transport = AIOHTTPTransport(url=url) + + query = gql(subscription_str) + + async with Client(transport=transport) as session: + results = [] + async for result in session.subscribe(query): + results.append(result) + + # Verify response headers are captured + assert transport.response_headers is not None + assert "Content-Type" in transport.response_headers + assert "multipart/mixed" in transport.response_headers["Content-Type"] + + +@pytest.mark.asyncio +async def test_aiohttp_multipart_empty_body(multipart_server): + """Test part with empty body after stripping.""" + from gql.transport.aiohttp import AIOHTTPTransport + + # Part with only whitespace body + parts = [ + "--graphql\r\nContent-Type: application/json\r\n\r\n \r\n", + "--graphql--\r\n", + ] + + server = await multipart_server(parts) + url = server.make_url("/") + transport = AIOHTTPTransport(url=url) + + async with Client(transport=transport) as session: + query = gql(subscription_str) + results = [] + async for result in session.subscribe(query): + results.append(result) + assert len(results) == 0 + + +@pytest.mark.asyncio +async def test_aiohttp_multipart_missing_payload_field(multipart_server): + """Test handling of response missing required 'payload' field.""" + from gql.transport.aiohttp import AIOHTTPTransport + + response = {"foo": "bar"} # No payload field! + parts = [ + ( + "--graphql\r\n" + "Content-Type: application/json\r\n" + "\r\n" + f"{json.dumps(response)}\r\n" + ), + "--graphql--\r\n", + ] + + server = await multipart_server(parts) + url = server.make_url("/") + transport = AIOHTTPTransport(url=url) + + async with Client(transport=transport) as session: + query = gql(subscription_str) + results = [] + async for result in session.subscribe(query): + results.append(result) + + # Should skip invalid response and return no results + assert len(results) == 0 + + +@pytest.mark.asyncio +async def test_aiohttp_multipart_with_content_length_headers(multipart_server): + """Test multipart response with Content-Length headers (like real servers send).""" + from gql.transport.aiohttp import AIOHTTPTransport + + # Simulate real server behavior: each part has Content-Length header + book1_payload = json.dumps({"payload": {"data": {"book": book1}}}) + book2_payload = json.dumps({"payload": {"data": {"book": book2}}}) + heartbeat_payload = "{}" + + parts = [ + ( + "--graphql\r\n" + "Content-Type: application/json; charset=utf-8\r\n" + f"Content-Length: {len(heartbeat_payload)}\r\n" + "\r\n" + f"{heartbeat_payload}\r\n" + ), + ( + "--graphql\r\n" + "Content-Type: application/json; charset=utf-8\r\n" + f"Content-Length: {len(book1_payload)}\r\n" + "\r\n" + f"{book1_payload}\r\n" + ), + ( + "--graphql\r\n" + "Content-Type: application/json; charset=utf-8\r\n" + f"Content-Length: {len(book2_payload)}\r\n" + "\r\n" + f"{book2_payload}\r\n" + ), + "--graphql\r\n", # Extra empty part like real servers + "--graphql--\r\n", # Final boundary + ] + + server = await multipart_server(parts) + url = server.make_url("/") + transport = AIOHTTPTransport(url=url) + + async with Client(transport=transport) as session: + query = gql(subscription_str) + results = [] + async for result in session.subscribe(query): + results.append(result) + + # Should get 2 books (heartbeat and empty part filtered) + assert len(results) == 2 + assert results[0]["book"]["title"] == "Book 1" + assert results[1]["book"]["title"] == "Book 2" + + +@pytest.mark.asyncio +async def test_aiohttp_multipart_actually_invalid_utf8(multipart_server): + """Test handling of ACTUAL invalid UTF-8 bytes in multipart response.""" + from gql.transport.aiohttp import AIOHTTPTransport + + # \\x80 is an invalid start byte in UTF-8 + parts = [ + ( + b"--graphql\r\n" + b"Content-Type: application/json; charset=utf-8\r\n" + b"\r\n" + b"\x80\x81\r\n" + ), + b"--graphql--\r\n", + ] + + server = await multipart_server(parts) + url = server.make_url("/") + transport = AIOHTTPTransport(url=url) + + async with Client(transport=transport) as session: + query = gql(subscription_str) + results = [] + async for result in session.subscribe(query): + results.append(result) + + # Should skip invalid part and not crash + assert len(results) == 0 + + +@pytest.mark.asyncio +async def test_aiohttp_multipart_subscribe_extra_args(multipart_server): + """Test that extra_args are passed through to the post method.""" + from gql.transport.aiohttp import AIOHTTPTransport + + custom_header_received = False + + def check_custom_header(request): + nonlocal custom_header_received + if request.headers.get("X-Custom-Header") == "custom-value": + custom_header_received = True + + parts = create_multipart_response([book1]) + server = await multipart_server(parts, request_handler=check_custom_header) + url = server.make_url("/") + transport = AIOHTTPTransport(url=url) + + query = gql(subscription_str) + + async with Client(transport=transport) as session: + async for result in session.subscribe( + query, extra_args={"headers": {"X-Custom-Header": "custom-value"}} + ): + pass + + assert custom_header_received diff --git a/tests/test_aiohttp_online.py b/tests/test_aiohttp_online.py index a4f2480c..556037f0 100644 --- a/tests/test_aiohttp_online.py +++ b/tests/test_aiohttp_online.py @@ -24,16 +24,14 @@ async def test_aiohttp_simple_query(): # Instanciate client async with Client(transport=transport) as session: - query = gql( - """ + query = gql(""" query getContinents { continents { code name } } - """ - ) + """) # Fetch schema await session.fetch_schema() @@ -64,16 +62,14 @@ async def test_aiohttp_invalid_query(): async with Client(transport=transport) as session: - query = gql( - """ + query = gql(""" query getContinents { continents { code bloh } } - """ - ) + """) with pytest.raises(TransportQueryError): await session.execute(query) @@ -94,25 +90,21 @@ async def test_aiohttp_two_queries_in_parallel_using_two_tasks(): # Instanciate client async with Client(transport=transport) as session: - query1 = gql( - """ + query1 = gql(""" query getContinents { continents { code } } - """ - ) + """) - query2 = gql( - """ + query2 = gql(""" query getContinents { continents { name } } - """ - ) + """) async def query_task1(): result = await session.execute(query1) diff --git a/tests/test_aiohttp_websocket_graphqlws_subscription.py b/tests/test_aiohttp_websocket_graphqlws_subscription.py index e03ad8f9..497caed5 100644 --- a/tests/test_aiohttp_websocket_graphqlws_subscription.py +++ b/tests/test_aiohttp_websocket_graphqlws_subscription.py @@ -111,7 +111,6 @@ async def keepalive_coro(): async def receiving_coro(): print(" Server: receiving task started") try: - nonlocal counting_task while True: try: @@ -314,7 +313,6 @@ async def task_coro(): task = asyncio.ensure_future(task_coro()) async def cancel_task_coro(): - nonlocal task await asyncio.sleep(5.5 * COUNTING_DELAY) @@ -354,7 +352,6 @@ async def task_coro(): task = asyncio.ensure_future(task_coro()) async def close_transport_task_coro(): - nonlocal task await asyncio.sleep(5.5 * COUNTING_DELAY) diff --git a/tests/test_aiohttp_websocket_subscription.py b/tests/test_aiohttp_websocket_subscription.py index f06046df..658e3eff 100644 --- a/tests/test_aiohttp_websocket_subscription.py +++ b/tests/test_aiohttp_websocket_subscription.py @@ -8,7 +8,7 @@ from graphql import ExecutionResult from parse import search -from gql import Client, gql +from gql import Client, GraphQLRequest, gql from gql.client import AsyncClientSession from gql.transport.exceptions import TransportConnectionFailed, TransportServerError @@ -97,7 +97,6 @@ async def server_countdown(ws): logged_messages.clear() - global WITH_KEEPALIVE try: await WebSocketServerHelper.send_connection_ack(ws) if WITH_KEEPALIVE: @@ -126,7 +125,6 @@ async def counting_coro(): counting_task = asyncio.ensure_future(counting_coro()) async def stopping_coro(): - nonlocal counting_task while True: try: @@ -305,7 +303,6 @@ async def task_coro(): task = asyncio.ensure_future(task_coro()) async def cancel_task_coro(): - nonlocal task await asyncio.sleep(11 * MS) @@ -345,7 +342,6 @@ async def task_coro(): task = asyncio.ensure_future(task_coro()) async def close_transport_task_coro(): - nonlocal task await asyncio.sleep(11 * MS) @@ -460,6 +456,37 @@ async def test_aiohttp_websocket_subscription_with_operation_name( assert '"operationName": "CountdownSubscription"' in logged_messages[0] +@pytest.mark.asyncio +@pytest.mark.parametrize("server", [server_countdown], indirect=True) +@pytest.mark.parametrize("subscription_str", [countdown_subscription_str]) +async def test_aiohttp_websocket_subscription_with_extensions( + aiohttp_client_and_server, subscription_str +): + + session, server = aiohttp_client_and_server + + count = 10 + request = GraphQLRequest( + subscription_str.format(count=count), + extensions={"persistedQuery": {"version": 1, "sha256Hash": "abc123"}}, + ) + + async for result in session.subscribe(request): + + number = result["number"] + print(f"Number received: {number}") + + assert number == count + count -= 1 + + assert count == -1 + + message = json.loads(logged_messages[0]) + assert message["payload"]["extensions"] == { + "persistedQuery": {"version": 1, "sha256Hash": "abc123"} + } + + WITH_KEEPALIVE = True diff --git a/tests/test_appsync_http.py b/tests/test_appsync_http.py index 168924bc..c080da46 100644 --- a/tests/test_appsync_http.py +++ b/tests/test_appsync_http.py @@ -53,16 +53,14 @@ async def handler(request): async with Client(transport=transport) as session: - query = gql( - """ + query = gql(""" mutation createMessage($message: String!) { createMessage(input: {message: $message}) { id message createdAt } -}""" - ) +}""") # Execute query asynchronously execution_result = await session.execute(query, get_execution_result=True) diff --git a/tests/test_appsync_websockets.py b/tests/test_appsync_websockets.py index b2299960..2103db8f 100644 --- a/tests/test_appsync_websockets.py +++ b/tests/test_appsync_websockets.py @@ -277,7 +277,6 @@ async def keepalive_coro(): async def receiving_coro(): print(" Server: receiving task started") try: - nonlocal send_message_task while True: try: @@ -503,16 +502,14 @@ async def test_appsync_execute_method_not_allowed(server): client = Client(transport=transport) async with client as session: - query = gql( - """ + query = gql(""" mutation createMessage($message: String!) { createMessage(input: {message: $message}) { id message createdAt } -}""" - ) +}""") query.variable_values = {"message": "Hello world!"} diff --git a/tests/test_async_client_validation.py b/tests/test_async_client_validation.py index ec73593e..e80e91ee 100644 --- a/tests/test_async_client_validation.py +++ b/tests/test_async_client_validation.py @@ -202,15 +202,13 @@ async def test_async_client_validation_fetch_schema_from_server_valid_query( assert client.introspection == StarWarsIntrospection assert client.schema is not None - query = gql( - """ + query = gql(""" query HeroNameQuery { hero { name } } - """ - ) + """) result = await session.execute(query) @@ -231,16 +229,14 @@ async def test_async_client_validation_fetch_schema_from_server_invalid_query( # Fetch schema from server await session.fetch_schema() - query = gql( - """ + query = gql(""" query HeroNameQuery { hero { name sldkfjqlmsdkjfqlskjfmlqkjsfmkjqsdf } } - """ - ) + """) with pytest.raises(graphql.error.GraphQLError): await session.execute(query) @@ -264,16 +260,14 @@ async def test_async_client_validation_fetch_schema_from_server_with_client_argu fetch_schema_from_transport=True, ) as session: - query = gql( - """ + query = gql(""" query HeroNameQuery { hero { name sldkfjqlmsdkjfqlskjfmlqkjsfmkjqsdf } } - """ - ) + """) with pytest.raises(graphql.error.GraphQLError): await session.execute(query) diff --git a/tests/test_cli.py b/tests/test_cli.py index 4c6b7d15..df613afc 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -407,6 +407,7 @@ def test_cli_parse_schema_download(parser): "specified_by_url:True", "schema_description:true", "directive_is_repeatable:true", + "input_object_one_of:true", "--print-schema", ] ) @@ -419,6 +420,7 @@ def test_cli_parse_schema_download(parser): "specified_by_url": True, "schema_description": True, "directive_is_repeatable": True, + "input_object_one_of": True, } assert introspection_args == expected_args diff --git a/tests/test_client.py b/tests/test_client.py index 4e2e9bca..97118cae 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -16,16 +16,14 @@ @pytest.fixture def http_transport_query(): - return gql( - """ + return gql(""" query getContinents { continents { code name } } - """ - ) + """) def test_request_transport_not_implemented(http_transport_query): @@ -74,8 +72,7 @@ def test_retries_on_transport(execute_mock): ) client = Client(transport=transport) - query = gql( - """ + query = gql(""" { myFavoriteFilm: film(id:"RmlsbToz") { id @@ -83,8 +80,7 @@ def test_retries_on_transport(execute_mock): episodeId } } - """ - ) + """) with client as session: # We're using the client as context manager with pytest.raises(Exception): session.execute(query) @@ -123,8 +119,7 @@ def test_execute_result_error(): transport=RequestsHTTPTransport(url="https://countries.trevorblades.com/"), ) - failing_query = gql( - """ + failing_query = gql(""" query getContinents { continents { code @@ -132,8 +127,7 @@ def test_execute_result_error(): id } } - """ - ) + """) with pytest.raises(TransportQueryError) as exc_info: client.execute(failing_query) @@ -214,16 +208,14 @@ def test_gql(): schema = build_ast_schema(document) - query = gql( - """ + query = gql(""" query getUser { user(id: "1000") { id username } } - """ - ) + """) client = Client(schema=schema) result = client.execute(query) diff --git a/tests/test_graphql_request.py b/tests/test_graphql_request.py index ea255c7d..8f314f34 100644 --- a/tests/test_graphql_request.py +++ b/tests/test_graphql_request.py @@ -15,8 +15,9 @@ GraphQLObjectType, GraphQLScalarType, GraphQLSchema, + GraphQLString, ) -from graphql.utilities import value_from_ast_untyped +from graphql.utilities import build_schema, value_from_ast_untyped from gql import GraphQLRequest @@ -57,7 +58,11 @@ def parse_money_value(input_value: Any) -> Money: amount = input_value.get("amount", None) currency = input_value.get("currency", None) - if not is_finite(amount) or not isinstance(currency, str): + if ( + not isinstance(amount, (int, float)) + or not is_finite(amount) + or not isinstance(currency, str) + ): raise GraphQLError("Cannot parse money value dict: " + inspect(input_value)) return Money(float(amount), currency) @@ -202,6 +207,82 @@ def test_serialize_variables_using_money_example(): assert req.variable_values == {"money": {"amount": 10, "currency": "DM"}} +def test_serialize_variables_single_value_for_list_type(): + # A value which is not a list, provided for a list type, should be + # coerced into a list of one instead of being iterated over. + list_schema = build_schema("type Query {f(ids: [String!], ns: [Int]): String}") + + req = GraphQLRequest( + "query q($ids: [String!], $ns: [Int]) {f(ids: $ids, ns: $ns)}", + variable_values={"ids": "abc", "ns": 5}, + ) + + req = req.serialize_variable_values(list_schema) + + assert req.variable_values == {"ids": ["abc"], "ns": [5]} + + +def test_serialize_variables_mapping_as_singleton_input_object(): + schema = build_schema( + "input ItemInput { name: String } type Query { f(items: [ItemInput]): String }" + ) + req = GraphQLRequest( + "query q($items: [ItemInput]) { f(items: $items) }", + variable_values={"items": {"name": "abc"}}, + ) + req = req.serialize_variable_values(schema) + assert req.variable_values == {"items": [{"name": "abc"}]} + + +def test_serialize_variables_recursive_list_coercion(): + schema = build_schema("type Query { f(values: [[Int]]): String }") + + req1 = GraphQLRequest( + "query q($values: [[Int]]) { f(values: $values) }", + variable_values={"values": 1}, + ) + req1 = req1.serialize_variable_values(schema) + assert req1.variable_values == {"values": [[1]]} + + req2 = GraphQLRequest( + "query q($values: [[Int]]) { f(values: $values) }", + variable_values={"values": [1, 2]}, + ) + req2 = req2.serialize_variable_values(schema) + assert req2.variable_values == {"values": [[1], [2]]} + + +def test_serialize_variables_collection_behavior(): + schema = build_schema("type Query { f(values: [Int]): String }") + req = GraphQLRequest( + "query q($values: [Int]) { f(values: $values) }", + variable_values={"values": (1, 2)}, + ) + req = req.serialize_variable_values(schema) + assert req.variable_values == {"values": [1, 2]} + + +def test_serialize_variables_bytes_behavior(): + bytes_scalar = GraphQLScalarType(name="Bytes", serialize=lambda v: v) + schema = GraphQLSchema( + query=GraphQLObjectType( + "Query", + fields={ + "f": GraphQLField( + GraphQLString, + args={"values": GraphQLArgument(GraphQLList(bytes_scalar))}, + ) + }, + ) + ) + req = GraphQLRequest( + "query q($values: [Bytes]) { f(values: $values) }", + variable_values={"values": b"abc"}, + ) + req = req.serialize_variable_values(schema) + assert req.variable_values == {"values": [b"abc"]} + + def test_graphql_request_using_string_instead_of_document(): request = GraphQLRequest("{balance}") @@ -236,3 +317,31 @@ def test_graphql_request_init_with_graphql_request(): assert request_1.variable_values["money"] == money_value_1 assert request_2.variable_values["money"] == money_value_1 assert request_3.variable_values["money"] == money_value_2 + + +def test_graphql_request_extensions(): + extensions_1 = {"persistedQuery": {"version": 1, "sha256Hash": "abc123"}} + extensions_2 = {"custom": "value"} + money_value = Money(10, "DM") + + assert "extensions" not in GraphQLRequest("{balance}").payload + + request_1 = GraphQLRequest("{balance}", extensions=extensions_1) + assert request_1.payload["extensions"] == extensions_1 + + # Copied from another GraphQLRequest + request_2 = GraphQLRequest(request_1) + assert request_2.extensions == extensions_1 + + # Explicit extensions override the copied value + request_3 = GraphQLRequest(request_1, extensions=extensions_2) + assert request_3.extensions == extensions_2 + + # Preserved through serialize_variable_values + request_4 = GraphQLRequest( + "query myquery($money: Money) {toEuros(money: $money)}", + variable_values={"money": money_value}, + extensions=extensions_1, + ) + serialized = request_4.serialize_variable_values(schema) + assert serialized.extensions == extensions_1 diff --git a/tests/test_graphqlws_subscription.py b/tests/test_graphqlws_subscription.py index 416726aa..236a3321 100644 --- a/tests/test_graphqlws_subscription.py +++ b/tests/test_graphqlws_subscription.py @@ -111,7 +111,6 @@ async def keepalive_coro(): async def receiving_coro(): print(" Server: receiving task started") try: - nonlocal counting_task while True: try: @@ -312,7 +311,6 @@ async def task_coro(): task = asyncio.ensure_future(task_coro()) async def cancel_task_coro(): - nonlocal task await asyncio.sleep(5.5 * COUNTING_DELAY) @@ -352,7 +350,6 @@ async def task_coro(): task = asyncio.ensure_future(task_coro()) async def close_transport_task_coro(): - nonlocal task await asyncio.sleep(5.5 * COUNTING_DELAY) @@ -899,3 +896,41 @@ async def test_graphqlws_subscription_reconnecting_session( break assert transport._connected is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize("server", [server_countdown], indirect=True) +@pytest.mark.parametrize("subscription_str", [countdown_subscription_str]) +async def test_graphqlws_subscription_no_server_protocol(server, subscription_str): + """The goal of this test is to verify that if the client requests only the + graphqlws subprotocol AND the server is not returning its subprotocol + in its header, then the client will assume that the protocol used is + the graphqlws subprotocol (See PR #586). + """ + + from gql.transport.websockets import WebsocketsTransport + + url = f"ws://{server.hostname}:{server.port}/graphql" + print(f"url = {url}") + + transport = WebsocketsTransport( + url=url, + subprotocols=[WebsocketsTransport.GRAPHQLWS_SUBPROTOCOL], + keep_alive_timeout=3, + ) + + client = Client(transport=transport) + + count = 10 + subscription = gql(subscription_str.format(count=count)) + + async with client as session: + async for result in session.subscribe(subscription): + + number = result["number"] + print(f"Number received: {number}") + + assert number == count + count -= 1 + + assert count == -1 diff --git a/tests/test_http_async_sync.py b/tests/test_http_async_sync.py index 61dc1809..c8bcd147 100644 --- a/tests/test_http_async_sync.py +++ b/tests/test_http_async_sync.py @@ -23,16 +23,14 @@ async def test_async_client_async_transport(fetch_schema_from_transport): fetch_schema_from_transport=fetch_schema_from_transport, ) as session: - query = gql( - """ + query = gql(""" query getContinents { continents { code name } } - """ - ) + """) # Execute query result = await session.execute(query) @@ -90,16 +88,14 @@ def test_sync_client_async_transport(fetch_schema_from_transport): fetch_schema_from_transport=fetch_schema_from_transport, ) - query = gql( - """ + query = gql(""" query getContinents { continents { code name } } - """ - ) + """) # Execute query synchronously result = client.execute(query) @@ -133,16 +129,14 @@ def test_sync_client_sync_transport(fetch_schema_from_transport): fetch_schema_from_transport=fetch_schema_from_transport, ) - query = gql( - """ + query = gql(""" query getContinents { continents { code name } } - """ - ) + """) # Execute query synchronously result = client.execute(query) diff --git a/tests/test_httpx.py b/tests/test_httpx.py index 0411294b..88612464 100644 --- a/tests/test_httpx.py +++ b/tests/test_httpx.py @@ -3,7 +3,7 @@ import pytest -from gql import Client, FileVar, gql +from gql import Client, FileVar, GraphQLRequest, gql from gql.transport.exceptions import ( TransportAlreadyConnected, TransportClosed, @@ -84,6 +84,40 @@ def test_code(): await run_sync_test(server, test_code) +@pytest.mark.aiohttp +@pytest.mark.asyncio +async def test_httpx_request_extensions(aiohttp_server, run_sync_test): + from aiohttp import web + + from gql.transport.httpx import HTTPXTransport + + extensions = {"persistedQuery": {"version": 1, "sha256Hash": "abc123"}} + + async def handler(request): + body = await request.json() + assert body["extensions"] == extensions + return web.Response( + text=query1_server_answer, + content_type="application/json", + ) + + app = web.Application() + app.router.add_route("POST", "/", handler) + server = await aiohttp_server(app) + + url = str(server.make_url("/")) + + def test_code(): + transport = HTTPXTransport(url=url) + + with Client(transport=transport) as session: + request = GraphQLRequest(query1_str, extensions=extensions) + result = session.execute(request) + assert result["continents"][0]["code"] == "AF" + + await run_sync_test(server, test_code) + + @pytest.mark.aiohttp @pytest.mark.asyncio @pytest.mark.parametrize("verify_https", ["disabled", "cert_provided"]) @@ -182,19 +216,24 @@ def test_code(): query = gql(query1_str) - expected_error = "certificate verify failed: self-signed certificate" + expected_errors = [ + # Linux / OpenSSL error message + "certificate verify failed: self-signed certificate", + # Windows error message + "not trusted by the trust provider", + ] with pytest.raises(TransportConnectionFailed) as exc_info: with Client(transport=transport) as session: session.execute(query) - assert expected_error in str(exc_info.value) + assert any(err in str(exc_info.value) for err in expected_errors) with pytest.raises(TransportConnectionFailed) as exc_info: with Client(transport=transport) as session: session.execute_batch([query]) - assert expected_error in str(exc_info.value) + assert any(err in str(exc_info.value) for err in expected_errors) await run_sync_test(server, test_code) diff --git a/tests/test_httpx_async.py b/tests/test_httpx_async.py index 690b3ee7..5effea79 100644 --- a/tests/test_httpx_async.py +++ b/tests/test_httpx_async.py @@ -436,7 +436,11 @@ async def handler(request): @pytest.mark.aiohttp @pytest.mark.asyncio async def test_httpx_extra_args(aiohttp_server): - import httpx + try: + import httpx2 as httpx + except ModuleNotFoundError: # pragma: no cover + import httpx # type: ignore[no-redef] + from aiohttp import web from gql.transport.httpx import HTTPXAsyncTransport @@ -1179,19 +1183,24 @@ async def handler(request): query = gql(query1_str) - expected_error = "certificate verify failed: self-signed certificate" + expected_errors = [ + # Linux / OpenSSL error message + "certificate verify failed: self-signed certificate", + # Windows error message + "not trusted by the trust provider", + ] with pytest.raises(TransportConnectionFailed) as exc_info: async with Client(transport=transport) as session: await session.execute(query) - assert expected_error in str(exc_info.value) + assert any(err in str(exc_info.value) for err in expected_errors) with pytest.raises(TransportConnectionFailed) as exc_info: async with Client(transport=transport) as session: await session.execute_batch([query]) - assert expected_error in str(exc_info.value) + assert any(err in str(exc_info.value) for err in expected_errors) @pytest.mark.aiohttp @@ -1447,3 +1456,62 @@ async def handler(request): pi = result["pi"] assert pi == Decimal("3.141592653589793238462643383279502884197") + + +@pytest.mark.aiohttp +@pytest.mark.asyncio +async def test_httpx_subscribe_not_supported_cli(aiohttp_server): + """Test that the CLI falls back to execute when subscribe is not supported.""" + from aiohttp import web + + from gql.transport.httpx import HTTPXAsyncTransport + + async def handler(request): + return web.Response(text=query1_server_answer, content_type="application/json") + + app = web.Application() + app.router.add_route("POST", "/", handler) + server = await aiohttp_server(app) + + url = str(server.make_url("/")) + + transport = HTTPXAsyncTransport(url=url) + + async with Client(transport=transport) as _: + + # Define arguments for the CLI + # We use the query "query getContinents..." + import io + import sys + from io import StringIO + + from gql import cli + + test_args = ["gql-cli", url, "--transport", "httpx"] + + # Mock sys.stdin to provide the query + sys.stdin = io.StringIO(query1_str) + + # Capture stdout + captured_output = StringIO() + original_stdout = sys.stdout + sys.stdout = captured_output + + try: + # We need to mock sys.argv as well because cli.get_parser() uses + # usage from sys.argv[0] sometimes, + # but mainly passing args to parse_args is cleaner. + parser = cli.get_parser() + parsed_args = parser.parse_args(test_args[1:]) # skip prog name + + exit_code = await cli.main(parsed_args) + assert exit_code == 0 + + except SystemExit: + pass + finally: + sys.stdout = original_stdout + sys.stdin = sys.__stdin__ # Restore stdin + + output = captured_output.getvalue() + assert "Africa" in output diff --git a/tests/test_httpx_batch.py b/tests/test_httpx_batch.py index 63472dab..9e42d432 100644 --- a/tests/test_httpx_batch.py +++ b/tests/test_httpx_batch.py @@ -118,6 +118,39 @@ def test_code(): await run_sync_test(server, test_code) +@pytest.mark.aiohttp +@pytest.mark.asyncio +async def test_httpx_batch_request_extensions(aiohttp_server): + from aiohttp import web + + from gql.transport.httpx import HTTPXAsyncTransport + + extensions = {"persistedQuery": {"version": 1, "sha256Hash": "abc123"}} + + async def handler(request): + body = await request.json() + assert isinstance(body, list) + assert body[0]["extensions"] == extensions + return web.Response( + text=query1_server_answer_list, + content_type="application/json", + ) + + app = web.Application() + app.router.add_route("POST", "/", handler) + server = await aiohttp_server(app) + + url = str(server.make_url("/")) + + transport = HTTPXAsyncTransport(url=url, timeout=10) + + async with Client(transport=transport) as session: + + query = [GraphQLRequest(query1_str, extensions=extensions)] + results = await session.execute_batch(query) + assert results[0]["continents"][0]["code"] == "AF" + + @pytest.mark.aiohttp @pytest.mark.asyncio async def test_httpx_async_batch_query_without_session(aiohttp_server, run_sync_test): @@ -292,7 +325,11 @@ async def handler(request): @pytest.mark.aiohttp @pytest.mark.asyncio async def test_httpx_async_batch_extra_args(aiohttp_server): - import httpx + try: + import httpx2 as httpx + except ModuleNotFoundError: # pragma: no cover + import httpx # type: ignore[no-redef] + from aiohttp import web from gql.transport.httpx import HTTPXAsyncTransport diff --git a/tests/test_httpx_online.py b/tests/test_httpx_online.py index c6e84368..25bb25fe 100644 --- a/tests/test_httpx_online.py +++ b/tests/test_httpx_online.py @@ -24,16 +24,14 @@ async def test_httpx_simple_query(): # Instanciate client async with Client(transport=transport) as session: - query = gql( - """ + query = gql(""" query getContinents { continents { code name } } - """ - ) + """) # Fetch schema await session.fetch_schema() @@ -64,16 +62,14 @@ async def test_httpx_invalid_query(): async with Client(transport=transport) as session: - query = gql( - """ + query = gql(""" query getContinents { continents { code bloh } } - """ - ) + """) with pytest.raises(TransportQueryError): await session.execute(query) @@ -94,25 +90,21 @@ async def test_httpx_two_queries_in_parallel_using_two_tasks(): # Instanciate client async with Client(transport=transport) as session: - query1 = gql( - """ + query1 = gql(""" query getContinents { continents { code } } - """ - ) + """) - query2 = gql( - """ + query2 = gql(""" query getContinents { continents { name } } - """ - ) + """) async def query_task1(): result = await session.execute(query1) diff --git a/tests/test_phoenix_channel_subscription.py b/tests/test_phoenix_channel_subscription.py index ecda9c38..a54c594c 100644 --- a/tests/test_phoenix_channel_subscription.py +++ b/tests/test_phoenix_channel_subscription.py @@ -117,7 +117,6 @@ async def counting_coro(): counting_task = asyncio.ensure_future(counting_coro()) async def stopping_coro(): - nonlocal counting_task while True: result = await ws.recv() json_result = json.loads(result) diff --git a/tests/test_requests.py b/tests/test_requests.py index fe57f5e3..7de4a12a 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -4,7 +4,7 @@ import pytest -from gql import Client, FileVar, gql +from gql import Client, FileVar, GraphQLRequest, gql from gql.transport.exceptions import ( TransportAlreadyConnected, TransportClosed, @@ -85,6 +85,40 @@ def test_code(): await run_sync_test(server, test_code) +@pytest.mark.aiohttp +@pytest.mark.asyncio +async def test_requests_request_extensions(aiohttp_server, run_sync_test): + from aiohttp import web + + from gql.transport.requests import RequestsHTTPTransport + + extensions = {"persistedQuery": {"version": 1, "sha256Hash": "abc123"}} + + async def handler(request): + body = await request.json() + assert body["extensions"] == extensions + return web.Response( + text=query1_server_answer, + content_type="application/json", + ) + + app = web.Application() + app.router.add_route("POST", "/", handler) + server = await aiohttp_server(app) + + url = server.make_url("/") + + def test_code(): + transport = RequestsHTTPTransport(url=url) + + with Client(transport=transport) as session: + request = GraphQLRequest(query1_str, extensions=extensions) + result = session.execute(request) + assert result["continents"][0]["code"] == "AF" + + await run_sync_test(server, test_code) + + @pytest.mark.aiohttp @pytest.mark.asyncio @pytest.mark.parametrize("verify_https", ["disabled", "cert_provided"]) @@ -235,6 +269,49 @@ def test_code(): await run_sync_test(server, test_code) +@pytest.mark.aiohttp +@pytest.mark.asyncio +async def test_requests_cookies_cookiejar(aiohttp_server, run_sync_test): + import http.cookiejar + + from aiohttp import web + from requests.cookies import cookiejar_from_dict + + from gql.transport.requests import RequestsHTTPTransport + + async def handler(request): + assert "COOKIE" in request.headers + assert "cookie1=val1" == request.headers["COOKIE"] + + return web.Response(text=query1_server_answer, content_type="application/json") + + app = web.Application() + app.router.add_route("POST", "/", handler) + server = await aiohttp_server(app) + + url = server.make_url("/") + + def test_code(): + cookie_jar = cookiejar_from_dict({"cookie1": "val1"}) + assert isinstance(cookie_jar, http.cookiejar.CookieJar) + transport = RequestsHTTPTransport(url=url, cookies=cookie_jar) + + with Client(transport=transport) as session: + + query = gql(query1_str) + + # Execute query synchronously + result = session.execute(query) + + continents = result["continents"] + + africa = continents[0] + + assert africa["code"] == "AF" + + await run_sync_test(server, test_code) + + @pytest.mark.aiohttp @pytest.mark.asyncio async def test_requests_error_code_401(aiohttp_server, run_sync_test): diff --git a/tests/test_requests_batch.py b/tests/test_requests_batch.py index a2f0cdbf..93264824 100644 --- a/tests/test_requests_batch.py +++ b/tests/test_requests_batch.py @@ -90,6 +90,41 @@ def test_code(): await run_sync_test(server, test_code) +@pytest.mark.aiohttp +@pytest.mark.asyncio +async def test_requests_batch_request_extensions(aiohttp_server, run_sync_test): + from aiohttp import web + + from gql.transport.requests import RequestsHTTPTransport + + extensions = {"persistedQuery": {"version": 1, "sha256Hash": "abc123"}} + + async def handler(request): + body = await request.json() + assert isinstance(body, list) + assert body[0]["extensions"] == extensions + return web.Response( + text=query1_server_answer_list, + content_type="application/json", + ) + + app = web.Application() + app.router.add_route("POST", "/", handler) + server = await aiohttp_server(app) + + url = server.make_url("/") + + def test_code(): + transport = RequestsHTTPTransport(url=url) + + with Client(transport=transport) as session: + query = [GraphQLRequest(query1_str, extensions=extensions)] + results = session.execute_batch(query) + assert results[0]["continents"][0]["code"] == "AF" + + await run_sync_test(server, test_code) + + @pytest.mark.aiohttp @pytest.mark.asyncio async def test_requests_query_auto_batch_enabled(aiohttp_server, run_sync_test): @@ -562,22 +597,21 @@ def test_requests_sync_batch_auto(): batch_max=3, ) - query = gql( - """ + query = gql(""" query getContinentName($continent_code: ID!) { continent(code: $continent_code) { name } } - """ - ) + """) def get_continent_name(session, continent_code): variables = { "continent_code": continent_code, } - result = session.execute(query, variable_values=variables) + query.variable_values = variables + result = session.execute(query) name = result["continent"]["name"] print(f"The continent with the code {continent_code} has the name: '{name}'") diff --git a/tests/test_transport.py b/tests/test_transport.py index 7c2a5a8f..da1a48c9 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -12,15 +12,42 @@ def use_cassette(name): + import json + import vcr + # method to ignore introspection changes in graphql-core 3.3.0b0 + def graphql_body_matcher(r1, r2): + try: + b1 = json.loads(r1.body) + b2 = json.loads(r2.body) + if isinstance(b1, dict) and isinstance(b2, dict): + q1 = b1.get("query", "") + q2 = b2.get("query", "") + if "IntrospectionQuery" in q1 and "IntrospectionQuery" in q2: + return True + return b1 == b2 + elif isinstance(b1, list) and isinstance(b2, list) and len(b1) == len(b2): + for item1, item2 in zip(b1, b2): + q1 = item1.get("query", "") + q2 = item2.get("query", "") + if "IntrospectionQuery" in q1 and "IntrospectionQuery" in q2: + continue + if item1 != item2: + return False + return True + except Exception: + pass + return r1.body == r2.body + query_vcr = vcr.VCR( cassette_library_dir=os.path.join( os.path.dirname(__file__), "fixtures", "vcr_cassettes" ), record_mode="new_episodes", - match_on=["uri", "method", "body"], ) + query_vcr.register_matcher("graphql_body", graphql_body_matcher) + query_vcr.match_on = ["uri", "method", "graphql_body"] return query_vcr.use_cassette(name + ".yaml") @@ -50,8 +77,7 @@ def client(): def test_hero_name_query(client): - query = gql( - """ + query = gql(""" { myFavoriteFilm: film(id:"RmlsbToz") { id @@ -66,8 +92,7 @@ def test_hero_name_query(client): } } } - """ - ) + """) expected = { "myFavoriteFilm": { "id": "RmlsbToz", @@ -90,16 +115,14 @@ def test_hero_name_query(client): def test_query_with_variable(client): - query = gql( - """ + query = gql(""" query Planet($id: ID!) { planet(id: $id) { id name } } - """ - ) + """) query.variable_values = {"id": "UGxhbmV0OjEw"} expected = {"planet": {"id": "UGxhbmV0OjEw", "name": "Kamino"}} with use_cassette("queries"): @@ -108,8 +131,7 @@ def test_query_with_variable(client): def test_named_query(client): - query = gql( - """ + query = gql(""" query Planet1 { planet(id: "UGxhbmV0OjEw") { id @@ -122,8 +144,7 @@ def test_named_query(client): name } } - """ - ) + """) query.operation_name = "Planet2" expected = {"planet": {"id": "UGxhbmV0OjEx", "name": "Geonosis"}} with use_cassette("queries"): @@ -132,16 +153,14 @@ def test_named_query(client): def test_header_query(client): - query = gql( - """ + query = gql(""" query Planet($id: ID!) { planet(id: $id) { id name } } - """ - ) + """) expected = {"planet": {"id": "UGxhbmV0OjEx", "name": "Geonosis"}} with use_cassette("queries"): result = client.execute( diff --git a/tests/test_transport_batch.py b/tests/test_transport_batch.py index 671858e7..c3b49d5c 100644 --- a/tests/test_transport_batch.py +++ b/tests/test_transport_batch.py @@ -12,15 +12,42 @@ def use_cassette(name): + import json + import vcr + # method to ignore introspection changes in graphql-core 3.3.0b0 + def graphql_body_matcher(r1, r2): + try: + b1 = json.loads(r1.body) + b2 = json.loads(r2.body) + if isinstance(b1, dict) and isinstance(b2, dict): + q1 = b1.get("query", "") + q2 = b2.get("query", "") + if "IntrospectionQuery" in q1 and "IntrospectionQuery" in q2: + return True + return b1 == b2 + elif isinstance(b1, list) and isinstance(b2, list) and len(b1) == len(b2): + for item1, item2 in zip(b1, b2): + q1 = item1.get("query", "") + q2 = item2.get("query", "") + if "IntrospectionQuery" in q1 and "IntrospectionQuery" in q2: + continue + if item1 != item2: + return False + return True + except Exception: + pass + return r1.body == r2.body + query_vcr = vcr.VCR( cassette_library_dir=os.path.join( os.path.dirname(__file__), "fixtures", "vcr_cassettes" ), record_mode="new_episodes", - match_on=["uri", "method", "body"], ) + query_vcr.register_matcher("graphql_body", graphql_body_matcher) + query_vcr.match_on = ["uri", "method", "graphql_body"] return query_vcr.use_cassette(name + ".yaml") @@ -50,8 +77,7 @@ def client(): def test_hero_name_query(client): - query = gql( - """ + query = gql(""" { myFavoriteFilm: film(id:"RmlsbToz") { id @@ -66,8 +92,7 @@ def test_hero_name_query(client): } } } - """ - ) + """) expected = [ { "myFavoriteFilm": { @@ -92,16 +117,14 @@ def test_hero_name_query(client): def test_query_with_variable(client): - query = gql( - """ + query = gql(""" query Planet($id: ID!) { planet(id: $id) { id name } } - """ - ) + """) query.variable_values = {"id": "UGxhbmV0OjEw"} expected = [{"planet": {"id": "UGxhbmV0OjEw", "name": "Kamino"}}] with use_cassette("queries_batch"): @@ -110,8 +133,7 @@ def test_query_with_variable(client): def test_named_query(client): - query = gql( - """ + query = gql(""" query Planet1 { planet(id: "UGxhbmV0OjEw") { id @@ -124,8 +146,7 @@ def test_named_query(client): name } } - """ - ) + """) query.operation_name = "Planet2" expected = [{"planet": {"id": "UGxhbmV0OjEx", "name": "Geonosis"}}] with use_cassette("queries_batch"): @@ -134,16 +155,14 @@ def test_named_query(client): def test_header_query(client): - query = gql( - """ + query = gql(""" query Planet($id: ID!) { planet(id: $id) { id name } } - """ - ) + """) expected = [{"planet": {"id": "UGxhbmV0OjEx", "name": "Geonosis"}}] with use_cassette("queries_batch"): results = client.execute_batch( diff --git a/tests/test_websocket_online.py b/tests/test_websocket_online.py index c53be5f4..32b131c9 100644 --- a/tests/test_websocket_online.py +++ b/tests/test_websocket_online.py @@ -32,16 +32,14 @@ async def test_websocket_simple_query(): # Instanciate client async with Client(transport=transport) as session: - query = gql( - """ + query = gql(""" query getContinents { continents { code name } } - """ - ) + """) # Fetch schema await session.fetch_schema() @@ -73,16 +71,14 @@ async def test_websocket_invalid_query(): # Instanciate client async with Client(transport=transport) as session: - query = gql( - """ + query = gql(""" query getContinents { continents { code bloh } } - """ - ) + """) # Execute query with pytest.raises(TransportQueryError): @@ -103,15 +99,13 @@ async def test_websocket_sending_invalid_data(): # Instanciate client async with Client(transport=transport) as session: - query = gql( - """ + query = gql(""" query getContinents { continents { code } } - """ - ) + """) # Execute query result = await session.execute(query) @@ -163,15 +157,13 @@ async def test_websocket_sending_invalid_data_while_other_query_is_running(): # Instanciate client async with Client(transport=transport) as session: - query = gql( - """ + query = gql(""" query getContinents { continents { code } } - """ - ) + """) async def query_task1(): await asyncio.sleep(2 * MS) @@ -215,25 +207,21 @@ async def test_websocket_two_queries_in_parallel_using_two_tasks(): # Instanciate client async with Client(transport=transport) as session: - query1 = gql( - """ + query1 = gql(""" query getContinents { continents { code } } - """ - ) + """) - query2 = gql( - """ + query2 = gql(""" query getContinents { continents { name } } - """ - ) + """) async def query_task1(): result = await session.execute(query1) diff --git a/tests/test_websocket_subscription.py b/tests/test_websocket_subscription.py index 5baa0b4e..c2f810e0 100644 --- a/tests/test_websocket_subscription.py +++ b/tests/test_websocket_subscription.py @@ -33,7 +33,6 @@ async def server_countdown(ws): logged_messages.clear() - global WITH_KEEPALIVE try: await WebSocketServerHelper.send_connection_ack(ws) if WITH_KEEPALIVE: @@ -62,7 +61,6 @@ async def counting_coro(): counting_task = asyncio.ensure_future(counting_coro()) async def stopping_coro(): - nonlocal counting_task while True: try: @@ -232,7 +230,6 @@ async def task_coro(): task = asyncio.ensure_future(task_coro()) async def cancel_task_coro(): - nonlocal task await asyncio.sleep(11 * MS) @@ -272,7 +269,6 @@ async def task_coro(): task = asyncio.ensure_future(task_coro()) async def close_transport_task_coro(): - nonlocal task await asyncio.sleep(11 * MS) diff --git a/tox.ini b/tox.ini index f6d4b48e..4bff921d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,15 +1,15 @@ [tox] envlist = black,flake8,import-order,mypy,manifest, - py{39,310,311,312,313,py3} + py{310,311,312,313,314,py3} [gh-actions] python = - 3.9: py39 3.10: py310 3.11: py311 3.12: py312 3.13: py313 + 3.14: py314 pypy-3: pypy3 [testenv] @@ -17,8 +17,8 @@ conda_channels = conda-forge passenv = * setenv = PYTHONPATH = {toxinidir} - MULTIDICT_NO_EXTENSIONS = 1 ; Related to https://github.com/aio-libs/multidict - YARL_NO_EXTENSIONS = 1 ; Related to https://github.com/aio-libs/yarl + MULTIDICT_NO_EXTENSIONS = 1 + YARL_NO_EXTENSIONS = 1 GQL_TESTS_TIMEOUT_FACTOR = 10 install_command = python -m pip install --ignore-installed {opts} {packages} whitelist_externals = @@ -28,7 +28,7 @@ deps = -e.[test] commands = pip install -U setuptools ; run "tox -- tests -s" to show output for debugging - py{39,310,311,312,313,py3}: pytest {posargs:tests} + py{310,311,312,313,314,py3}: pytest {posargs:tests} py{312}: pytest {posargs:tests --cov-report=term-missing --cov=gql} [testenv:black]