diff --git a/docs/usage/file_upload.rst b/docs/usage/file_upload.rst index 10903585..7793354b 100644 --- a/docs/usage/file_upload.rst +++ b/docs/usage/file_upload.rst @@ -14,11 +14,14 @@ Single File In order to upload a single file, you need to: * set the file as a variable value in the mutation -* provide the opened file to the `variable_values` argument of `execute` +* create a :class:`FileVar ` object with your file path +* provide the `FileVar` instance to the `variable_values` argument of `execute` * set the `upload_files` argument to True .. code-block:: python + from gql import client, gql, FileVar + transport = AIOHTTPTransport(url='YOUR_URL') # Or transport = RequestsHTTPTransport(url='YOUR_URL') # Or transport = HTTPXTransport(url='YOUR_URL') @@ -34,32 +37,38 @@ In order to upload a single file, you need to: } ''') - with open("YOUR_FILE_PATH", "rb") as f: - - params = {"file": f} + params = {"file": FileVar("YOUR_FILE_PATH")} - result = client.execute( - query, variable_values=params, upload_files=True - ) + result = client.execute( + query, variable_values=params, upload_files=True + ) Setting the content-type ^^^^^^^^^^^^^^^^^^^^^^^^ If you need to set a specific Content-Type attribute to a file, -you can set the :code:`content_type` attribute of the file like this: +you can set the :code:`content_type` attribute of :class:`FileVar `: .. code-block:: python - with open("YOUR_FILE_PATH", "rb") as f: + # Setting the content-type to a pdf file for example + filevar = FileVar( + "YOUR_FILE_PATH", + content_type="application/pdf", + ) - # Setting the content-type to a pdf file for example - f.content_type = "application/pdf" +Setting the uploaded file name +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - params = {"file": f} +To modify the uploaded filename, use the :code:`filename` attribute of :class:`FileVar `: - result = client.execute( - query, variable_values=params, upload_files=True - ) +.. code-block:: python + + # Setting the content-type to a pdf file for example + filevar = FileVar( + "YOUR_FILE_PATH", + filename="filename1.txt", + ) File list --------- @@ -68,6 +77,8 @@ It is also possible to upload multiple files using a list. .. code-block:: python + from gql import client, gql, FileVar + transport = AIOHTTPTransport(url='YOUR_URL') # Or transport = RequestsHTTPTransport(url='YOUR_URL') # Or transport = HTTPXTransport(url='YOUR_URL') @@ -83,8 +94,8 @@ It is also possible to upload multiple files using a list. } ''') - f1 = open("YOUR_FILE_PATH_1", "rb") - f2 = open("YOUR_FILE_PATH_2", "rb") + f1 = FileVar("YOUR_FILE_PATH_1") + f2 = FileVar("YOUR_FILE_PATH_2") params = {"files": [f1, f2]} @@ -92,9 +103,6 @@ It is also possible to upload multiple files using a list. query, variable_values=params, upload_files=True ) - f1.close() - f2.close() - Streaming --------- @@ -120,18 +128,8 @@ Streaming local files aiohttp allows to upload files using an asynchronous generator. See `Streaming uploads on aiohttp docs`_. - -In order to stream local files, instead of providing opened files to the -`variable_values` argument of `execute`, you need to provide an async generator -which will provide parts of the files. - -You can use `aiofiles`_ -to read the files in chunks and create this asynchronous generator. - -.. _Streaming uploads on aiohttp docs: https://docs.aiohttp.org/en/stable/client_quickstart.html#streaming-uploads -.. _aiofiles: https://github.com/Tinche/aiofiles - -Example: +From gql version 4.0, it is possible to activate file streaming simply by +setting the `streaming` argument of :class:`FileVar ` to `True` .. code-block:: python @@ -147,18 +145,38 @@ Example: } ''') + f1 = FileVar( + file_name='YOUR_FILE_PATH', + streaming=True, + ) + + params = {"file": f1} + + result = client.execute( + query, variable_values=params, upload_files=True + ) + +Another option is to use an async generator to provide parts of the file. + +You can use `aiofiles`_ +to read the files in chunks and create this asynchronous generator. + +.. _Streaming uploads on aiohttp docs: https://docs.aiohttp.org/en/stable/client_quickstart.html#streaming-uploads +.. _aiofiles: https://github.com/Tinche/aiofiles + +.. code-block:: python + async def file_sender(file_name): async with aiofiles.open(file_name, 'rb') as f: - chunk = await f.read(64*1024) - while chunk: - yield chunk - chunk = await f.read(64*1024) + while chunk := await f.read(64*1024): + yield chunk - params = {"file": file_sender(file_name='YOUR_FILE_PATH')} + f1 = FileVar(file_sender(file_name='YOUR_FILE_PATH')) + params = {"file": f1} result = client.execute( - query, variable_values=params, upload_files=True - ) + query, variable_values=params, upload_files=True + ) Streaming downloaded files ^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -200,7 +218,7 @@ Example: } ''') - params = {"file": resp.content} + params = {"file": FileVar(resp.content)} result = client.execute( query, variable_values=params, upload_files=True diff --git a/gql/__init__.py b/gql/__init__.py index 8eaa0b7c..4c9a6aa0 100644 --- a/gql/__init__.py +++ b/gql/__init__.py @@ -11,10 +11,12 @@ from .client import Client from .gql import gql from .graphql_request import GraphQLRequest +from .transport.file_upload import FileVar __all__ = [ "__version__", "gql", "Client", "GraphQLRequest", + "FileVar", ] diff --git a/gql/transport/aiohttp.py b/gql/transport/aiohttp.py index 76b46c35..b2633abb 100644 --- a/gql/transport/aiohttp.py +++ b/gql/transport/aiohttp.py @@ -23,7 +23,6 @@ from graphql import DocumentNode, ExecutionResult, print_ast from multidict import CIMultiDictProxy -from ..utils import extract_files from .appsync_auth import AppSyncAuthentication from .async_transport import AsyncTransport from .common.aiohttp_closed_event import create_aiohttp_closed_event @@ -33,6 +32,7 @@ TransportProtocolError, TransportServerError, ) +from .file_upload import FileVar, close_files, extract_files, open_files log = logging.getLogger(__name__) @@ -207,6 +207,10 @@ async def execute( file_classes=self.file_classes, ) + # Opening the files using the FileVar parameters + open_files(list(files.values()), transport_supports_streaming=True) + self.files = files + # Save the nulled variable values in the payload payload["variables"] = nulled_variable_values @@ -220,8 +224,8 @@ async def execute( file_map = {str(i): [path] for i, path in enumerate(files)} # Enumerate the file streams - # Will generate something like {'0': <_io.BufferedReader ...>} - file_streams = {str(i): files[path] for i, path in enumerate(files)} + # Will generate something like {'0': FileVar object} + file_vars = {str(i): files[path] for i, path in enumerate(files)} # Add the payload to the operations field operations_str = self.json_serialize(payload) @@ -235,12 +239,15 @@ async def execute( log.debug("file_map %s", file_map_str) data.add_field("map", file_map_str, content_type="application/json") - # Add the extracted files as remaining fields - for k, f in file_streams.items(): - name = getattr(f, "name", k) - content_type = getattr(f, "content_type", None) + for k, file_var in file_vars.items(): + assert isinstance(file_var, FileVar) - data.add_field(k, f, filename=name, content_type=content_type) + data.add_field( + k, + file_var.f, + filename=file_var.filename, + content_type=file_var.content_type, + ) post_args: Dict[str, Any] = {"data": data} @@ -267,51 +274,59 @@ async def execute( if self.session is None: raise TransportClosed("Transport is not connected") - 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 + try: + async with self.session.post(self.url, ssl=self.ssl, **post_args) as resp: - async def raise_response_error( - resp: aiohttp.ClientResponse, reason: str - ) -> NoReturn: - # We raise a TransportServerError if the status code is 400 or higher - # We raise a TransportProtocolError in the other cases + # Saving latest response headers in the transport + self.response_headers = resp.headers - try: - # Raise a ClientResponseError if response status is 400 or higher - resp.raise_for_status() - except ClientResponseError as e: - raise TransportServerError(str(e), e.status) from e - - result_text = await resp.text() - raise TransportProtocolError( - f"Server did not return a GraphQL result: " - f"{reason}: " - f"{result_text}" - ) + async def raise_response_error( + resp: aiohttp.ClientResponse, reason: str + ) -> NoReturn: + # We raise a TransportServerError if status code is 400 or higher + # We raise a TransportProtocolError in the other cases - try: - result = await resp.json(loads=self.json_deserialize, content_type=None) + try: + # Raise ClientResponseError if response status is 400 or higher + resp.raise_for_status() + except ClientResponseError as e: + raise TransportServerError(str(e), e.status) from e - if log.isEnabledFor(logging.INFO): result_text = await resp.text() - log.info("<<< %s", result_text) + raise TransportProtocolError( + f"Server did not return a GraphQL result: " + f"{reason}: " + f"{result_text}" + ) - except Exception: - await raise_response_error(resp, "Not a JSON answer") + try: + result = await resp.json( + loads=self.json_deserialize, content_type=None + ) - if result is None: - await raise_response_error(resp, "Not a JSON answer") + if log.isEnabledFor(logging.INFO): + result_text = await resp.text() + log.info("<<< %s", result_text) - if "errors" not in result and "data" not in result: - await raise_response_error(resp, 'No "data" or "errors" keys in answer') + except Exception: + await raise_response_error(resp, "Not a JSON answer") - return ExecutionResult( - errors=result.get("errors"), - data=result.get("data"), - extensions=result.get("extensions"), - ) + if result is None: + await raise_response_error(resp, "Not a JSON answer") + + if "errors" not in result and "data" not in result: + await raise_response_error( + resp, 'No "data" or "errors" keys in answer' + ) + + return ExecutionResult( + errors=result.get("errors"), + data=result.get("data"), + extensions=result.get("extensions"), + ) + finally: + if upload_files: + close_files(list(self.files.values())) def subscribe( self, diff --git a/gql/transport/file_upload.py b/gql/transport/file_upload.py new file mode 100644 index 00000000..8673ab60 --- /dev/null +++ b/gql/transport/file_upload.py @@ -0,0 +1,126 @@ +import io +import os +import warnings +from typing import Any, Dict, List, Optional, Tuple, Type + + +class FileVar: + def __init__( + self, + f: Any, # str | io.IOBase | aiohttp.StreamReader | AsyncGenerator + *, + filename: Optional[str] = None, + content_type: Optional[str] = None, + streaming: bool = False, + streaming_block_size: int = 64 * 1024, + ): + self.f = f + self.filename = filename + self.content_type = content_type + self.streaming = streaming + self.streaming_block_size = streaming_block_size + + self._file_opened: bool = False + + def open_file( + self, + transport_supports_streaming: bool = False, + ) -> None: + assert self._file_opened is False + + if self.streaming: + assert ( + transport_supports_streaming + ), "streaming not supported on this transport" + self._make_file_streamer() + else: + if isinstance(self.f, str): + if self.filename is None: + # By default we set the filename to the basename + # of the opened file + self.filename = os.path.basename(self.f) + self.f = open(self.f, "rb") + self._file_opened = True + + def close_file(self) -> None: + if self._file_opened: + assert isinstance(self.f, io.IOBase) + self.f.close() + self._file_opened = False + + def _make_file_streamer(self) -> None: + assert isinstance(self.f, str), "streaming option needs a filepath str" + + import aiofiles + + async def file_sender(file_name): + async with aiofiles.open(file_name, "rb") as f: + while chunk := await f.read(self.streaming_block_size): + yield chunk + + self.f = file_sender(self.f) + + +def open_files( + filevars: List[FileVar], + transport_supports_streaming: bool = False, +) -> None: + + for filevar in filevars: + filevar.open_file(transport_supports_streaming=transport_supports_streaming) + + +def close_files(filevars: List[FileVar]) -> None: + for filevar in filevars: + filevar.close_file() + + +FILE_UPLOAD_DOCS = "https://gql.readthedocs.io/en/latest/usage/file_upload.html" + + +def extract_files( + variables: Dict, file_classes: Tuple[Type[Any], ...] +) -> Tuple[Dict, Dict[str, FileVar]]: + files: Dict[str, FileVar] = {} + + def recurse_extract(path, obj): + """ + recursively traverse obj, doing a deepcopy, but + 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): + value = recurse_extract(f"{path}.{key}", value) + nulled_list.append(value) + return nulled_list + elif isinstance(obj, dict): + nulled_dict = {} + for key, value in obj.items(): + value = recurse_extract(f"{path}.{key}", value) + nulled_dict[key] = value + return nulled_dict + elif isinstance(obj, file_classes): + # extract obj from its parent and put it into files instead. + warnings.warn( + "Not using FileVar for file upload is deprecated. " + f"See {FILE_UPLOAD_DOCS} for details.", + DeprecationWarning, + ) + name = getattr(obj, "name", None) + content_type = getattr(obj, "content_type", None) + files[path] = FileVar(obj, filename=name, content_type=content_type) + return None + elif isinstance(obj, FileVar): + # extract obj from its parent and put it into files instead. + files[path] = obj + return None + else: + # base case: pass through unchanged + return obj + + nulled_variables = recurse_extract("variables", variables) + + return nulled_variables, files diff --git a/gql/transport/httpx.py b/gql/transport/httpx.py index 4c5d33d0..eb15ac57 100644 --- a/gql/transport/httpx.py +++ b/gql/transport/httpx.py @@ -12,13 +12,11 @@ Tuple, Type, Union, - cast, ) import httpx from graphql import DocumentNode, ExecutionResult, print_ast -from ..utils import extract_files from . import AsyncTransport, Transport from .exceptions import ( TransportAlreadyConnected, @@ -26,6 +24,7 @@ TransportProtocolError, TransportServerError, ) +from .file_upload import close_files, extract_files, open_files log = logging.getLogger(__name__) @@ -104,6 +103,10 @@ def _prepare_file_uploads( file_classes=self.file_classes, ) + # Opening the files using the FileVar parameters + open_files(list(files.values())) + self.files = files + # Save the nulled variable values in the payload payload["variables"] = nulled_variable_values @@ -112,7 +115,7 @@ def _prepare_file_uploads( file_map: Dict[str, List[str]] = {} file_streams: Dict[str, Tuple[str, ...]] = {} - for i, (path, f) in enumerate(files.items()): + for i, (path, file_var) in enumerate(files.items()): key = str(i) # Generate the file map @@ -121,16 +124,12 @@ def _prepare_file_uploads( # Will generate something like {"0": ["variables.file"]} file_map[key] = [path] - # Generate the file streams - # Will generate something like - # {"0": ("variables.file", <_io.BufferedReader ...>)} - name = cast(str, getattr(f, "name", key)) - content_type = getattr(f, "content_type", None) + name = key if file_var.filename is None else file_var.filename - if content_type is None: - file_streams[key] = (name, f) + if file_var.content_type is None: + file_streams[key] = (name, file_var.f) else: - file_streams[key] = (name, f, content_type) + file_streams[key] = (name, file_var.f, file_var.content_type) # Add the payload to the operations field operations_str = self.json_serialize(payload) @@ -232,7 +231,11 @@ def execute( # type: ignore upload_files, ) - response = self.client.post(self.url, **post_args) + try: + response = self.client.post(self.url, **post_args) + finally: + if upload_files: + close_files(list(self.files.values())) return self._prepare_result(response) @@ -295,7 +298,11 @@ async def execute( upload_files, ) - response = await self.client.post(self.url, **post_args) + try: + response = await self.client.post(self.url, **post_args) + finally: + if upload_files: + close_files(list(self.files.values())) return self._prepare_result(response) diff --git a/gql/transport/requests.py b/gql/transport/requests.py index 44f8a362..5fb7e827 100644 --- a/gql/transport/requests.py +++ b/gql/transport/requests.py @@ -25,13 +25,13 @@ from gql.transport import Transport from ..graphql_request import GraphQLRequest -from ..utils import extract_files from .exceptions import ( TransportAlreadyConnected, TransportClosed, TransportProtocolError, TransportServerError, ) +from .file_upload import FileVar, close_files, extract_files, open_files log = logging.getLogger(__name__) @@ -190,6 +190,10 @@ def execute( # type: ignore file_classes=self.file_classes, ) + # Opening the files using the FileVar parameters + open_files(list(files.values())) + self.files = files + # Save the nulled variable values in the payload payload["variables"] = nulled_variable_values @@ -204,8 +208,8 @@ def execute( # type: ignore file_map = {str(i): [path] for i, path in enumerate(files)} # Enumerate the file streams - # Will generate something like {'0': <_io.BufferedReader ...>} - file_streams = {str(i): files[path] for i, path in enumerate(files)} + # Will generate something like {'0': FileVar object} + file_vars = {str(i): files[path] for i, path in enumerate(files)} # Add the file map field file_map_str = self.json_serialize(file_map) @@ -214,14 +218,14 @@ def execute( # type: ignore fields = {"operations": operations_str, "map": file_map_str} # Add the extracted files as remaining fields - for k, f in file_streams.items(): - name = getattr(f, "name", k) - content_type = getattr(f, "content_type", None) + for k, file_var in file_vars.items(): + assert isinstance(file_var, FileVar) + name = k if file_var.filename is None else file_var.filename - if content_type is None: - fields[k] = (name, f) + if file_var.content_type is None: + fields[k] = (name, file_var.f) else: - fields[k] = (name, f, content_type) + fields[k] = (name, file_var.f, file_var.content_type) # Prepare requests http to send multipart-encoded data data = MultipartEncoder(fields=fields) @@ -254,9 +258,14 @@ def execute( # type: ignore post_args.update(extra_args) # Using the created session to perform requests - response = self.session.request( - self.method, self.url, **post_args # type: ignore - ) + try: + response = self.session.request( + self.method, self.url, **post_args # type: ignore + ) + finally: + if upload_files: + close_files(list(self.files.values())) + self.response_headers = response.headers def raise_response_error(resp: requests.Response, reason: str) -> NoReturn: diff --git a/gql/utils.py b/gql/utils.py index 6a7d0791..f7f0f5a7 100644 --- a/gql/utils.py +++ b/gql/utils.py @@ -1,6 +1,6 @@ """Utilities to manipulate several python objects.""" -from typing import Any, Dict, List, Tuple, Type +from typing import List # From this response in Stackoverflow @@ -12,43 +12,6 @@ def to_camel_case(snake_str): return components[0] + "".join(x.title() if x else "_" for x in components[1:]) -def extract_files( - variables: Dict, file_classes: Tuple[Type[Any], ...] -) -> Tuple[Dict, Dict]: - files = {} - - def recurse_extract(path, obj): - """ - recursively traverse obj, doing a deepcopy, but - 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): - value = recurse_extract(f"{path}.{key}", value) - nulled_list.append(value) - return nulled_list - elif isinstance(obj, dict): - nulled_dict = {} - for key, value in obj.items(): - value = recurse_extract(f"{path}.{key}", value) - nulled_dict[key] = value - return nulled_dict - elif isinstance(obj, file_classes): - # extract obj from its parent and put it into files instead. - files[path] = obj - return None - else: - # base case: pass through unchanged - return obj - - nulled_variables = recurse_extract("variables", variables) - - return nulled_variables, files - - def str_first_element(errors: List) -> str: try: first_error = errors[0] diff --git a/setup.py b/setup.py index aed15440..706a80c3 100644 --- a/setup.py +++ b/setup.py @@ -58,8 +58,12 @@ "botocore>=1.21,<2", ] +install_aiofiles_requires = [ + "aiofiles", +] + install_all_requires = ( - install_aiohttp_requires + install_requests_requires + install_httpx_requires + install_websockets_requires + install_botocore_requires + install_aiohttp_requires + install_requests_requires + install_httpx_requires + install_websockets_requires + install_botocore_requires + install_aiofiles_requires ) # Get version from __version__.py file @@ -107,6 +111,7 @@ "httpx": install_httpx_requires, "websockets": install_websockets_requires, "botocore": install_botocore_requires, + "aiofiles": install_aiofiles_requires, }, include_package_data=True, zip_safe=False, diff --git a/tests/conftest.py b/tests/conftest.py index c69551b0..cef561f7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -763,3 +763,62 @@ def strip_braces_spaces(s): strip_back = re.sub(r"([^\s]) }", r"\1}", strip_front) return strip_back + + +def make_upload_handler( + nb_files=1, + filenames=None, + request_headers=None, + file_headers=None, + binary=False, + expected_contents=None, + expected_operations=None, + expected_map=None, + server_answer='{"data":{"success":true}}', +): + assert expected_contents is not None + assert expected_operations is not None + assert expected_map is not None + + async def single_upload_handler(request): + from aiohttp import web + + reader = await request.multipart() + + if request_headers is not None: + for k, v in request_headers.items(): + assert request.headers[k] == v + + field_0 = await reader.next() + assert field_0.name == "operations" + field_0_text = await field_0.text() + assert strip_braces_spaces(field_0_text) == expected_operations + + field_1 = await reader.next() + assert field_1.name == "map" + field_1_text = await field_1.text() + assert field_1_text == expected_map + + for i in range(nb_files): + field = await reader.next() + assert field.name == str(i) + if filenames is not None: + assert field.filename == filenames[i] + + if binary: + field_content = await field.read() + assert field_content == expected_contents[i] + else: + field_text = await field.text() + assert field_text == expected_contents[i] + + if file_headers is not None: + for k, v in file_headers[i].items(): + assert field.headers[k] == v + + final_field = await reader.next() + assert final_field is None + + return web.Response(text=server_answer, content_type="application/json") + + return single_upload_handler diff --git a/tests/test_aiohttp.py b/tests/test_aiohttp.py index 04417c4e..fe36585e 100644 --- a/tests/test_aiohttp.py +++ b/tests/test_aiohttp.py @@ -1,10 +1,12 @@ import io import json +import os +import warnings from typing import Mapping import pytest -from gql import Client, gql +from gql import Client, FileVar, gql from gql.cli import get_parser, main from gql.transport.exceptions import ( TransportAlreadyConnected, @@ -17,7 +19,7 @@ from .conftest import ( TemporaryFile, get_localhost_ssl_context_client, - strip_braces_spaces, + make_upload_handler, ) query1_str = """ @@ -600,8 +602,6 @@ def test_code(): await run_sync_test(server, test_code) -file_upload_server_answer = '{"data":{"success":true}}' - file_upload_mutation_1 = """ mutation($file: Upload!) { uploadFile(input:{other_var:$other_var, file:$file}) { @@ -624,33 +624,6 @@ def test_code(): """ -async def single_upload_handler(request): - - from aiohttp import web - - reader = await request.multipart() - - field_0 = await reader.next() - assert field_0.name == "operations" - field_0_text = await field_0.text() - assert strip_braces_spaces(field_0_text) == file_upload_mutation_1_operations - - field_1 = await reader.next() - assert field_1.name == "map" - field_1_text = await field_1.text() - assert field_1_text == file_upload_mutation_1_map - - field_2 = await reader.next() - assert field_2.name == "0" - field_2_text = await field_2.text() - assert field_2_text == file_1_content - - field_3 = await reader.next() - assert field_3 is None - - return web.Response(text=file_upload_server_answer, content_type="application/json") - - @pytest.mark.asyncio async def test_aiohttp_file_upload(aiohttp_server): from aiohttp import web @@ -658,7 +631,15 @@ async def test_aiohttp_file_upload(aiohttp_server): from gql.transport.aiohttp import AIOHTTPTransport app = web.Application() - app.router.add_route("POST", "/", single_upload_handler) + app.router.add_route( + "POST", + "/", + make_upload_handler( + expected_map=file_upload_mutation_1_map, + expected_operations=file_upload_mutation_1_operations, + expected_contents=[file_1_content], + ), + ) server = await aiohttp_server(app) url = server.make_url("/") @@ -673,48 +654,45 @@ async def test_aiohttp_file_upload(aiohttp_server): file_path = test_file.filename + # Using an opened file with open(file_path, "rb") as f: params = {"file": f, "other_var": 42} # Execute query asynchronously - result = await session.execute( - query, variable_values=params, upload_files=True - ) + with pytest.warns( + DeprecationWarning, + match="Not using FileVar for file upload is deprecated", + ): + result = await session.execute( + query, variable_values=params, upload_files=True + ) success = result["success"] - assert success + # Using an opened file inside a FileVar object + with open(file_path, "rb") as f: -async def single_upload_handler_with_content_type(request): - - from aiohttp import web - - reader = await request.multipart() - - field_0 = await reader.next() - assert field_0.name == "operations" - field_0_text = await field_0.text() - assert strip_braces_spaces(field_0_text) == file_upload_mutation_1_operations - - field_1 = await reader.next() - assert field_1.name == "map" - field_1_text = await field_1.text() - assert field_1_text == file_upload_mutation_1_map + params = {"file": FileVar(f), "other_var": 42} - field_2 = await reader.next() - assert field_2.name == "0" - field_2_text = await field_2.text() - assert field_2_text == file_1_content + with warnings.catch_warnings(): + warnings.simplefilter("error") # Turn warnings into errors + result = await session.execute( + query, variable_values=params, upload_files=True + ) - # Verifying the content_type - assert field_2.headers["Content-Type"] == "application/pdf" + success = result["success"] + assert success - field_3 = await reader.next() - assert field_3 is None + # Using an filename string inside a FileVar object + params = {"file": FileVar(file_path), "other_var": 42} + result = await session.execute( + query, variable_values=params, upload_files=True + ) - return web.Response(text=file_upload_server_answer, content_type="application/json") + success = result["success"] + assert success @pytest.mark.asyncio @@ -724,7 +702,16 @@ async def test_aiohttp_file_upload_with_content_type(aiohttp_server): from gql.transport.aiohttp import AIOHTTPTransport app = web.Application() - app.router.add_route("POST", "/", single_upload_handler_with_content_type) + app.router.add_route( + "POST", + "/", + make_upload_handler( + file_headers=[{"Content-Type": "application/pdf"}], + expected_map=file_upload_mutation_1_map, + expected_operations=file_upload_mutation_1_operations, + expected_contents=[file_1_content], + ), + ) server = await aiohttp_server(app) url = server.make_url("/") @@ -739,6 +726,7 @@ async def test_aiohttp_file_upload_with_content_type(aiohttp_server): file_path = test_file.filename + # Using an opened file with open(file_path, "rb") as f: # Setting the content_type @@ -746,83 +734,185 @@ async def test_aiohttp_file_upload_with_content_type(aiohttp_server): params = {"file": f, "other_var": 42} - # Execute query asynchronously + with pytest.warns( + DeprecationWarning, + match="Not using FileVar for file upload is deprecated", + ): + result = await session.execute( + query, variable_values=params, upload_files=True + ) + + success = result["success"] + assert success + + # Using an opened file inside a FileVar object + with open(file_path, "rb") as f: + + params = { + "file": FileVar( + f, + content_type="application/pdf", + ), + "other_var": 42, + } + result = await session.execute( query, variable_values=params, upload_files=True ) success = result["success"] + assert success + + # Using an filename string inside a FileVar object + params = { + "file": FileVar( + file_path, + content_type="application/pdf", + ), + "other_var": 42, + } + result = await session.execute( + query, variable_values=params, upload_files=True + ) + + success = result["success"] assert success @pytest.mark.asyncio -async def test_aiohttp_file_upload_without_session(aiohttp_server, run_sync_test): +async def test_aiohttp_file_upload_default_filename_is_basename(aiohttp_server): from aiohttp import web from gql.transport.aiohttp import AIOHTTPTransport app = web.Application() - app.router.add_route("POST", "/", single_upload_handler) - server = await aiohttp_server(app) - url = server.make_url("/") + with TemporaryFile(file_1_content) as test_file: + file_path = test_file.filename + file_basename = os.path.basename(file_path) + + app.router.add_route( + "POST", + "/", + make_upload_handler( + filenames=[file_basename], + expected_map=file_upload_mutation_1_map, + expected_operations=file_upload_mutation_1_operations, + expected_contents=[file_1_content], + ), + ) + server = await aiohttp_server(app) - def test_code(): - transport = AIOHTTPTransport(url=url, timeout=10) + url = server.make_url("/") - with TemporaryFile(file_1_content) as test_file: + transport = AIOHTTPTransport(url=url, timeout=10) - client = Client(transport=transport) + async with Client(transport=transport) as session: query = gql(file_upload_mutation_1) - file_path = test_file.filename + params = { + "file": FileVar( + file_path, + ), + "other_var": 42, + } - with open(file_path, "rb") as f: + result = await session.execute( + query, variable_values=params, upload_files=True + ) - params = {"file": f, "other_var": 42} + success = result["success"] + assert success - result = client.execute( - query, variable_values=params, upload_files=True - ) - success = result["success"] +@pytest.mark.asyncio +async def test_aiohttp_file_upload_with_filename(aiohttp_server): + from aiohttp import web - assert success + from gql.transport.aiohttp import AIOHTTPTransport - await run_sync_test(server, test_code) + app = web.Application() + + with TemporaryFile(file_1_content) as test_file: + file_path = test_file.filename + + app.router.add_route( + "POST", + "/", + make_upload_handler( + filenames=["filename1.txt"], + expected_map=file_upload_mutation_1_map, + expected_operations=file_upload_mutation_1_operations, + expected_contents=[file_1_content], + ), + ) + server = await aiohttp_server(app) + url = server.make_url("/") -# This is a sample binary file content containing all possible byte values -binary_file_content = bytes(range(0, 256)) + transport = AIOHTTPTransport(url=url, timeout=10) + + async with Client(transport=transport) as session: + query = gql(file_upload_mutation_1) -async def binary_upload_handler(request): + params = { + "file": FileVar( + file_path, + filename="filename1.txt", + ), + "other_var": 42, + } + result = await session.execute( + query, variable_values=params, upload_files=True + ) + + success = result["success"] + assert success + + +@pytest.mark.asyncio +async def test_aiohttp_file_upload_without_session(aiohttp_server, run_sync_test): from aiohttp import web - reader = await request.multipart() + from gql.transport.aiohttp import AIOHTTPTransport + + app = web.Application() + app.router.add_route( + "POST", + "/", + make_upload_handler( + expected_map=file_upload_mutation_1_map, + expected_operations=file_upload_mutation_1_operations, + expected_contents=[file_1_content], + ), + ) + server = await aiohttp_server(app) + + url = server.make_url("/") + + def test_code(): + transport = AIOHTTPTransport(url=url, timeout=10) + + with TemporaryFile(file_1_content) as test_file: + + client = Client(transport=transport) - field_0 = await reader.next() - assert field_0.name == "operations" - field_0_text = await field_0.text() - assert strip_braces_spaces(field_0_text) == file_upload_mutation_1_operations + query = gql(file_upload_mutation_1) - field_1 = await reader.next() - assert field_1.name == "map" - field_1_text = await field_1.text() - assert field_1_text == file_upload_mutation_1_map + file_path = test_file.filename - field_2 = await reader.next() - assert field_2.name == "0" - field_2_binary = await field_2.read() - assert field_2_binary == binary_file_content + params = {"file": FileVar(file_path), "other_var": 42} - field_3 = await reader.next() - assert field_3 is None + result = client.execute(query, variable_values=params, upload_files=True) - return web.Response(text=file_upload_server_answer, content_type="application/json") + success = result["success"] + assert success + + await run_sync_test(server, test_code) @pytest.mark.asyncio @@ -831,8 +921,20 @@ async def test_aiohttp_binary_file_upload(aiohttp_server): from gql.transport.aiohttp import AIOHTTPTransport + # This is a sample binary file content containing all possible byte values + binary_file_content = bytes(range(0, 256)) + app = web.Application() - app.router.add_route("POST", "/", binary_upload_handler) + app.router.add_route( + "POST", + "/", + make_upload_handler( + binary=True, + expected_contents=[binary_file_content], + expected_map=file_upload_mutation_1_map, + expected_operations=file_upload_mutation_1_operations, + ), + ) server = await aiohttp_server(app) url = server.make_url("/") @@ -847,14 +949,12 @@ async def test_aiohttp_binary_file_upload(aiohttp_server): file_path = test_file.filename - with open(file_path, "rb") as f: - - params = {"file": f, "other_var": 42} + params = {"file": FileVar(file_path), "other_var": 42} - # Execute query asynchronously - result = await session.execute( - query, variable_values=params, upload_files=True - ) + # Execute query asynchronously + result = await session.execute( + query, variable_values=params, upload_files=True + ) success = result["success"] @@ -867,13 +967,25 @@ async def test_aiohttp_stream_reader_upload(aiohttp_server): from gql.transport.aiohttp import AIOHTTPTransport + # This is a sample binary file content containing all possible byte values + binary_file_content = bytes(range(0, 256)) + async def binary_data_handler(request): return web.Response( body=binary_file_content, content_type="binary/octet-stream" ) app = web.Application() - app.router.add_route("POST", "/", binary_upload_handler) + app.router.add_route( + "POST", + "/", + make_upload_handler( + binary=True, + expected_contents=[binary_file_content], + expected_map=file_upload_mutation_1_map, + expected_operations=file_upload_mutation_1_operations, + ), + ) app.router.add_route("GET", "/binary_data", binary_data_handler) server = await aiohttp_server(app) @@ -883,19 +995,36 @@ async def binary_data_handler(request): transport = AIOHTTPTransport(url=url, timeout=10) + # Not using FileVar async with Client(transport=transport) as session: query = gql(file_upload_mutation_1) async with ClientSession() as client: async with client.get(binary_data_url) as resp: params = {"file": resp.content, "other_var": 42} - # Execute query asynchronously + with pytest.warns( + DeprecationWarning, + match="Not using FileVar for file upload is deprecated", + ): + result = await session.execute( + query, variable_values=params, upload_files=True + ) + + success = result["success"] + assert success + + # Using FileVar + async with Client(transport=transport) as session: + query = gql(file_upload_mutation_1) + async with ClientSession() as client: + async with client.get(binary_data_url) as resp: + params = {"file": FileVar(resp.content), "other_var": 42} + result = await session.execute( query, variable_values=params, upload_files=True ) success = result["success"] - assert success @@ -906,30 +1035,59 @@ async def test_aiohttp_async_generator_upload(aiohttp_server): from gql.transport.aiohttp import AIOHTTPTransport + # This is a sample binary file content containing all possible byte values + binary_file_content = bytes(range(0, 256)) + app = web.Application() - app.router.add_route("POST", "/", binary_upload_handler) + app.router.add_route( + "POST", + "/", + make_upload_handler( + binary=True, + expected_contents=[binary_file_content], + expected_map=file_upload_mutation_1_map, + expected_operations=file_upload_mutation_1_operations, + ), + ) server = await aiohttp_server(app) url = server.make_url("/") transport = AIOHTTPTransport(url=url, timeout=10) + query = gql(file_upload_mutation_1) + with TemporaryFile(binary_file_content) as test_file: + file_path = test_file.filename + + async def file_sender(file_name): + async with aiofiles.open(file_name, "rb") as f: + chunk = await f.read(64 * 1024) + while chunk: + yield chunk + chunk = await f.read(64 * 1024) + + # Not using FileVar async with Client(transport=transport) as session: - query = gql(file_upload_mutation_1) + params = {"file": file_sender(file_path), "other_var": 42} - file_path = test_file.filename + with pytest.warns( + DeprecationWarning, + match="Not using FileVar for file upload is deprecated", + ): + result = await session.execute( + query, variable_values=params, upload_files=True + ) - async def file_sender(file_name): - async with aiofiles.open(file_name, "rb") as f: - chunk = await f.read(64 * 1024) - while chunk: - yield chunk - chunk = await f.read(64 * 1024) + success = result["success"] + assert success - params = {"file": file_sender(file_path), "other_var": 42} + # Using FileVar + async with Client(transport=transport) as session: + + params = {"file": FileVar(file_sender(file_path)), "other_var": 42} # Execute query asynchronously result = await session.execute( @@ -937,30 +1095,23 @@ async def file_sender(file_name): ) success = result["success"] - assert success + # Using FileVar with new streaming support + async with Client(transport=transport) as session: -file_upload_mutation_2 = """ - mutation($file1: Upload!, $file2: Upload!) { - uploadFile(input:{file1:$file, file2:$file}) { - success - } - } -""" - -file_upload_mutation_2_operations = ( - '{"query": "mutation ($file1: Upload!, $file2: Upload!) {\\n ' - 'uploadFile(input: {file1: $file, file2: $file}) {\\n success\\n }\\n}", ' - '"variables": {"file1": null, "file2": null}}' -) + params = { + "file": FileVar(file_path, streaming=True), + "other_var": 42, + } -file_upload_mutation_2_map = '{"0": ["variables.file1"], "1": ["variables.file2"]}' + # Execute query asynchronously + result = await session.execute( + query, variable_values=params, upload_files=True + ) -file_2_content = """ -This is a second test file -This file will also be sent in the GraphQL mutation -""" + success = result["success"] + assert success @pytest.mark.asyncio @@ -969,39 +1120,38 @@ async def test_aiohttp_file_upload_two_files(aiohttp_server): from gql.transport.aiohttp import AIOHTTPTransport - async def handler(request): - - reader = await request.multipart() - - field_0 = await reader.next() - assert field_0.name == "operations" - field_0_text = await field_0.text() - assert strip_braces_spaces(field_0_text) == file_upload_mutation_2_operations - - field_1 = await reader.next() - assert field_1.name == "map" - field_1_text = await field_1.text() - assert field_1_text == file_upload_mutation_2_map - - field_2 = await reader.next() - assert field_2.name == "0" - field_2_text = await field_2.text() - assert field_2_text == file_1_content + file_upload_mutation_2 = """ + mutation($file1: Upload!, $file2: Upload!) { + uploadFile(input:{file1:$file, file2:$file}) { + success + } + } + """ - field_3 = await reader.next() - assert field_3.name == "1" - field_3_text = await field_3.text() - assert field_3_text == file_2_content + file_upload_mutation_2_operations = ( + '{"query": "mutation ($file1: Upload!, $file2: Upload!) {\\n ' + 'uploadFile(input: {file1: $file, file2: $file}) {\\n success\\n }\\n}", ' + '"variables": {"file1": null, "file2": null}}' + ) - field_4 = await reader.next() - assert field_4 is None + file_upload_mutation_2_map = '{"0": ["variables.file1"], "1": ["variables.file2"]}' - return web.Response( - text=file_upload_server_answer, content_type="application/json" - ) + file_2_content = """ + This is a second test file + This file will also be sent in the GraphQL mutation + """ app = web.Application() - app.router.add_route("POST", "/", handler) + app.router.add_route( + "POST", + "/", + make_upload_handler( + nb_files=2, + expected_map=file_upload_mutation_2_map, + expected_operations=file_upload_mutation_2_operations, + expected_contents=[file_1_content, file_2_content], + ), + ) server = await aiohttp_server(app) url = server.make_url("/") @@ -1018,82 +1168,60 @@ async def handler(request): file_path_1 = test_file_1.filename file_path_2 = test_file_2.filename - f1 = open(file_path_1, "rb") - f2 = open(file_path_2, "rb") - params = { - "file1": f1, - "file2": f2, + "file1": FileVar(file_path_1), + "file2": FileVar(file_path_2), } result = await session.execute( query, variable_values=params, upload_files=True ) - f1.close() - f2.close() - success = result["success"] assert success -file_upload_mutation_3 = """ - mutation($files: [Upload!]!) { - uploadFiles(input:{files:$files}) { - success - } - } -""" - -file_upload_mutation_3_operations = ( - '{"query": "mutation ($files: [Upload!]!) {\\n uploadFiles(' - "input: {files: $files})" - ' {\\n success\\n }\\n}", "variables": {"files": [null, null]}}' -) - -file_upload_mutation_3_map = '{"0": ["variables.files.0"], "1": ["variables.files.1"]}' - - @pytest.mark.asyncio async def test_aiohttp_file_upload_list_of_two_files(aiohttp_server): from aiohttp import web from gql.transport.aiohttp import AIOHTTPTransport - async def handler(request): - - reader = await request.multipart() - - field_0 = await reader.next() - assert field_0.name == "operations" - field_0_text = await field_0.text() - assert strip_braces_spaces(field_0_text) == file_upload_mutation_3_operations - - field_1 = await reader.next() - assert field_1.name == "map" - field_1_text = await field_1.text() - assert field_1_text == file_upload_mutation_3_map - - field_2 = await reader.next() - assert field_2.name == "0" - field_2_text = await field_2.text() - assert field_2_text == file_1_content + file_upload_mutation_3 = """ + mutation($files: [Upload!]!) { + uploadFiles(input:{files:$files}) { + success + } + } + """ - field_3 = await reader.next() - assert field_3.name == "1" - field_3_text = await field_3.text() - assert field_3_text == file_2_content + file_upload_mutation_3_operations = ( + '{"query": "mutation ($files: [Upload!]!) {\\n uploadFiles' + "(input: {files: $files})" + ' {\\n success\\n }\\n}", "variables": {"files": [null, null]}}' + ) - field_4 = await reader.next() - assert field_4 is None + file_upload_mutation_3_map = ( + '{"0": ["variables.files.0"], "1": ["variables.files.1"]}' + ) - return web.Response( - text=file_upload_server_answer, content_type="application/json" - ) + file_2_content = """ + This is a second test file + This file will also be sent in the GraphQL mutation + """ app = web.Application() - app.router.add_route("POST", "/", handler) + app.router.add_route( + "POST", + "/", + make_upload_handler( + nb_files=2, + expected_map=file_upload_mutation_3_map, + expected_operations=file_upload_mutation_3_operations, + expected_contents=[file_1_content, file_2_content], + ), + ) server = await aiohttp_server(app) url = server.make_url("/") @@ -1110,19 +1238,18 @@ async def handler(request): file_path_1 = test_file_1.filename file_path_2 = test_file_2.filename - f1 = open(file_path_1, "rb") - f2 = open(file_path_2, "rb") - - params = {"files": [f1, f2]} + params = { + "files": [ + FileVar(file_path_1), + FileVar(file_path_2), + ], + } # Execute query asynchronously result = await session.execute( query, variable_values=params, upload_files=True ) - f1.close() - f2.close() - success = result["success"] assert success diff --git a/tests/test_httpx.py b/tests/test_httpx.py index d129f022..9558e137 100644 --- a/tests/test_httpx.py +++ b/tests/test_httpx.py @@ -1,8 +1,9 @@ +import os from typing import Any, Dict, Mapping import pytest -from gql import Client, gql +from gql import Client, FileVar, gql from gql.transport.exceptions import ( TransportAlreadyConnected, TransportClosed, @@ -14,7 +15,7 @@ from .conftest import ( TemporaryFile, get_localhost_ssl_context_client, - strip_braces_spaces, + make_upload_handler, ) # Marking all tests in this file with the httpx marker @@ -516,8 +517,6 @@ def test_code(): await run_sync_test(server, test_code) -file_upload_server_answer = '{"data":{"success":true}}' - file_upload_mutation_1 = """ mutation($file: Upload!) { uploadFile(input:{other_var:$other_var, file:$file}) { @@ -547,35 +546,16 @@ async def test_httpx_file_upload(aiohttp_server, run_sync_test): from gql.transport.httpx import HTTPXTransport - async def single_upload_handler(request): - from aiohttp import web - - reader = await request.multipart() - - field_0 = await reader.next() - assert field_0.name == "operations" - field_0_text = await field_0.text() - assert strip_braces_spaces(field_0_text) == file_upload_mutation_1_operations - - field_1 = await reader.next() - assert field_1.name == "map" - field_1_text = await field_1.text() - assert field_1_text == file_upload_mutation_1_map - - field_2 = await reader.next() - assert field_2.name == "0" - field_2_text = await field_2.text() - assert field_2_text == file_1_content - - field_3 = await reader.next() - assert field_3 is None - - return web.Response( - text=file_upload_server_answer, content_type="application/json" - ) - app = web.Application() - app.router.add_route("POST", "/", single_upload_handler) + app.router.add_route( + "POST", + "/", + make_upload_handler( + expected_map=file_upload_mutation_1_map, + expected_operations=file_upload_mutation_1_operations, + expected_contents=[file_1_content], + ), + ) server = await aiohttp_server(app) url = str(server.make_url("/")) @@ -589,15 +569,41 @@ def test_code(): file_path = test_file.filename + # Using an opened file with open(file_path, "rb") as f: params = {"file": f, "other_var": 42} + with pytest.warns( + DeprecationWarning, + match="Not using FileVar for file upload is deprecated", + ): + execution_result = session._execute( + query, variable_values=params, upload_files=True + ) + + assert execution_result.data["success"] + + # Using an opened file inside a FileVar object + with open(file_path, "rb") as f: + + params = {"file": FileVar(f), "other_var": 42} execution_result = session._execute( query, variable_values=params, upload_files=True ) assert execution_result.data["success"] + # Using an filename string inside a FileVar object + params = { + "file": FileVar(file_path), + "other_var": 42, + } + execution_result = session._execute( + query, variable_values=params, upload_files=True + ) + + assert execution_result.data["success"] + await run_sync_test(server, test_code) @@ -608,38 +614,17 @@ async def test_httpx_file_upload_with_content_type(aiohttp_server, run_sync_test from gql.transport.httpx import HTTPXTransport - async def single_upload_handler(request): - from aiohttp import web - - reader = await request.multipart() - - field_0 = await reader.next() - assert field_0.name == "operations" - field_0_text = await field_0.text() - assert strip_braces_spaces(field_0_text) == file_upload_mutation_1_operations - - field_1 = await reader.next() - assert field_1.name == "map" - field_1_text = await field_1.text() - assert field_1_text == file_upload_mutation_1_map - - field_2 = await reader.next() - assert field_2.name == "0" - field_2_text = await field_2.text() - assert field_2_text == file_1_content - - # Verifying the content_type - assert field_2.headers["Content-Type"] == "application/pdf" - - field_3 = await reader.next() - assert field_3 is None - - return web.Response( - text=file_upload_server_answer, content_type="application/json" - ) - app = web.Application() - app.router.add_route("POST", "/", single_upload_handler) + app.router.add_route( + "POST", + "/", + make_upload_handler( + file_headers=[{"Content-Type": "application/pdf"}], + expected_map=file_upload_mutation_1_map, + expected_operations=file_upload_mutation_1_operations, + expected_contents=[file_1_content], + ), + ) server = await aiohttp_server(app) url = str(server.make_url("/")) @@ -653,59 +638,104 @@ def test_code(): file_path = test_file.filename + # Using an opened file with open(file_path, "rb") as f: # Setting the content_type f.content_type = "application/pdf" # type: ignore params = {"file": f, "other_var": 42} - execution_result = session._execute( - query, variable_values=params, upload_files=True - ) + with pytest.warns( + DeprecationWarning, + match="Not using FileVar for file upload is deprecated", + ): + execution_result = session._execute( + query, variable_values=params, upload_files=True + ) assert execution_result.data["success"] + # Using FileVar + params = { + "file": FileVar(file_path, content_type="application/pdf"), + "other_var": 42, + } + execution_result = session._execute( + query, variable_values=params, upload_files=True + ) + + assert execution_result.data["success"] + await run_sync_test(server, test_code) @pytest.mark.aiohttp @pytest.mark.asyncio -async def test_httpx_file_upload_additional_headers(aiohttp_server, run_sync_test): +async def test_httpx_file_upload_default_filename_is_basename( + aiohttp_server, run_sync_test +): from aiohttp import web from gql.transport.httpx import HTTPXTransport - async def single_upload_handler(request): - from aiohttp import web + app = web.Application() - assert request.headers["X-Auth"] == "foobar" + with TemporaryFile(file_1_content) as test_file: + file_path = test_file.filename + file_basename = os.path.basename(file_path) + + app.router.add_route( + "POST", + "/", + make_upload_handler( + filenames=[file_basename], + expected_map=file_upload_mutation_1_map, + expected_operations=file_upload_mutation_1_operations, + expected_contents=[file_1_content], + ), + ) + server = await aiohttp_server(app) - reader = await request.multipart() + url = str(server.make_url("/")) - field_0 = await reader.next() - assert field_0.name == "operations" - field_0_text = await field_0.text() - assert strip_braces_spaces(field_0_text) == file_upload_mutation_1_operations + def test_code(): + transport = HTTPXTransport(url=url) - field_1 = await reader.next() - assert field_1.name == "map" - field_1_text = await field_1.text() - assert field_1_text == file_upload_mutation_1_map + with Client(transport=transport) as session: + query = gql(file_upload_mutation_1) - field_2 = await reader.next() - assert field_2.name == "0" - field_2_text = await field_2.text() - assert field_2_text == file_1_content + # Using FileVar + params = { + "file": FileVar(file_path), + "other_var": 42, + } + execution_result = session._execute( + query, variable_values=params, upload_files=True + ) - field_3 = await reader.next() - assert field_3 is None + assert execution_result.data["success"] - return web.Response( - text=file_upload_server_answer, content_type="application/json" - ) + await run_sync_test(server, test_code) + + +@pytest.mark.aiohttp +@pytest.mark.asyncio +async def test_httpx_file_upload_additional_headers(aiohttp_server, run_sync_test): + from aiohttp import web + + from gql.transport.httpx import HTTPXTransport app = web.Application() - app.router.add_route("POST", "/", single_upload_handler) + app.router.add_route( + "POST", + "/", + make_upload_handler( + request_headers={"X-Auth": "foobar"}, + expected_map=file_upload_mutation_1_map, + expected_operations=file_upload_mutation_1_operations, + expected_contents=[file_1_content], + ), + ) server = await aiohttp_server(app) url = str(server.make_url("/")) @@ -719,14 +749,12 @@ def test_code(): file_path = test_file.filename - with open(file_path, "rb") as f: - - params = {"file": f, "other_var": 42} - execution_result = session._execute( - query, variable_values=params, upload_files=True - ) + params = {"file": FileVar(file_path), "other_var": 42} + execution_result = session._execute( + query, variable_values=params, upload_files=True + ) - assert execution_result.data["success"] + assert execution_result.data["success"] await run_sync_test(server, test_code) @@ -741,36 +769,17 @@ async def test_httpx_binary_file_upload(aiohttp_server, run_sync_test): # This is a sample binary file content containing all possible byte values binary_file_content = bytes(range(0, 256)) - async def binary_upload_handler(request): - - from aiohttp import web - - reader = await request.multipart() - - field_0 = await reader.next() - assert field_0.name == "operations" - field_0_text = await field_0.text() - assert strip_braces_spaces(field_0_text) == file_upload_mutation_1_operations - - field_1 = await reader.next() - assert field_1.name == "map" - field_1_text = await field_1.text() - assert field_1_text == file_upload_mutation_1_map - - field_2 = await reader.next() - assert field_2.name == "0" - field_2_binary = await field_2.read() - assert field_2_binary == binary_file_content - - field_3 = await reader.next() - assert field_3 is None - - return web.Response( - text=file_upload_server_answer, content_type="application/json" - ) - app = web.Application() - app.router.add_route("POST", "/", binary_upload_handler) + app.router.add_route( + "POST", + "/", + make_upload_handler( + binary=True, + expected_contents=[binary_file_content], + expected_map=file_upload_mutation_1_map, + expected_operations=file_upload_mutation_1_operations, + ), + ) server = await aiohttp_server(app) url = str(server.make_url("/")) @@ -785,26 +794,17 @@ def test_code(): file_path = test_file.filename - with open(file_path, "rb") as f: + params = {"file": FileVar(file_path), "other_var": 42} - params = {"file": f, "other_var": 42} - - execution_result = session._execute( - query, variable_values=params, upload_files=True - ) + execution_result = session._execute( + query, variable_values=params, upload_files=True + ) - assert execution_result.data["success"] + assert execution_result.data["success"] await run_sync_test(server, test_code) -file_upload_mutation_2_operations = ( - '{"query": "mutation ($file1: Upload!, $file2: Upload!) {\\n ' - 'uploadFile(input: {file1: $file, file2: $file}) {\\n success\\n }\\n}", ' - '"variables": {"file1": null, "file2": null}}' -) - - @pytest.mark.aiohttp @pytest.mark.asyncio async def test_httpx_file_upload_two_files(aiohttp_server, run_sync_test): @@ -820,6 +820,12 @@ async def test_httpx_file_upload_two_files(aiohttp_server, run_sync_test): } """ + file_upload_mutation_2_operations = ( + '{"query": "mutation ($file1: Upload!, $file2: Upload!) {\\n ' + 'uploadFile(input: {file1: $file, file2: $file}) {\\n success\\n }\\n}", ' + '"variables": {"file1": null, "file2": null}}' + ) + file_upload_mutation_2_map = '{"0": ["variables.file1"], "1": ["variables.file2"]}' file_2_content = """ @@ -827,39 +833,17 @@ async def test_httpx_file_upload_two_files(aiohttp_server, run_sync_test): This file will also be sent in the GraphQL mutation """ - async def handler(request): - - reader = await request.multipart() - - field_0 = await reader.next() - assert field_0.name == "operations" - field_0_text = await field_0.text() - assert strip_braces_spaces(field_0_text) == file_upload_mutation_2_operations - - field_1 = await reader.next() - assert field_1.name == "map" - field_1_text = await field_1.text() - assert field_1_text == file_upload_mutation_2_map - - field_2 = await reader.next() - assert field_2.name == "0" - field_2_text = await field_2.text() - assert field_2_text == file_1_content - - field_3 = await reader.next() - assert field_3.name == "1" - field_3_text = await field_3.text() - assert field_3_text == file_2_content - - field_4 = await reader.next() - assert field_4 is None - - return web.Response( - text=file_upload_server_answer, content_type="application/json" - ) - app = web.Application() - app.router.add_route("POST", "/", handler) + app.router.add_route( + "POST", + "/", + make_upload_handler( + nb_files=2, + expected_map=file_upload_mutation_2_map, + expected_operations=file_upload_mutation_2_operations, + expected_contents=[file_1_content, file_2_content], + ), + ) server = await aiohttp_server(app) url = str(server.make_url("/")) @@ -877,12 +861,9 @@ def test_code(): file_path_1 = test_file_1.filename file_path_2 = test_file_2.filename - f1 = open(file_path_1, "rb") - f2 = open(file_path_2, "rb") - params = { - "file1": f1, - "file2": f2, + "file1": FileVar(file_path_1), + "file2": FileVar(file_path_2), } execution_result = session._execute( @@ -891,19 +872,9 @@ def test_code(): assert execution_result.data["success"] - f1.close() - f2.close() - await run_sync_test(server, test_code) -file_upload_mutation_3_operations = ( - '{"query": "mutation ($files: [Upload!]!) {\\n uploadFiles' - "(input: {files: $files})" - ' {\\n success\\n }\\n}", "variables": {"files": [null, null]}}' -) - - @pytest.mark.aiohttp @pytest.mark.asyncio async def test_httpx_file_upload_list_of_two_files(aiohttp_server, run_sync_test): @@ -919,6 +890,12 @@ async def test_httpx_file_upload_list_of_two_files(aiohttp_server, run_sync_test } """ + file_upload_mutation_3_operations = ( + '{"query": "mutation ($files: [Upload!]!) {\\n uploadFiles' + "(input: {files: $files})" + ' {\\n success\\n }\\n}", "variables": {"files": [null, null]}}' + ) + file_upload_mutation_3_map = ( '{"0": ["variables.files.0"], "1": ["variables.files.1"]}' ) @@ -928,39 +905,17 @@ async def test_httpx_file_upload_list_of_two_files(aiohttp_server, run_sync_test This file will also be sent in the GraphQL mutation """ - async def handler(request): - - reader = await request.multipart() - - field_0 = await reader.next() - assert field_0.name == "operations" - field_0_text = await field_0.text() - assert strip_braces_spaces(field_0_text) == file_upload_mutation_3_operations - - field_1 = await reader.next() - assert field_1.name == "map" - field_1_text = await field_1.text() - assert field_1_text == file_upload_mutation_3_map - - field_2 = await reader.next() - assert field_2.name == "0" - field_2_text = await field_2.text() - assert field_2_text == file_1_content - - field_3 = await reader.next() - assert field_3.name == "1" - field_3_text = await field_3.text() - assert field_3_text == file_2_content - - field_4 = await reader.next() - assert field_4 is None - - return web.Response( - text=file_upload_server_answer, content_type="application/json" - ) - app = web.Application() - app.router.add_route("POST", "/", handler) + app.router.add_route( + "POST", + "/", + make_upload_handler( + nb_files=2, + expected_map=file_upload_mutation_3_map, + expected_operations=file_upload_mutation_3_operations, + expected_contents=[file_1_content, file_2_content], + ), + ) server = await aiohttp_server(app) url = str(server.make_url("/")) @@ -977,10 +932,12 @@ def test_code(): file_path_1 = test_file_1.filename file_path_2 = test_file_2.filename - f1 = open(file_path_1, "rb") - f2 = open(file_path_2, "rb") - - params = {"files": [f1, f2]} + params = { + "files": [ + FileVar(file_path_1), + FileVar(file_path_2), + ], + } execution_result = session._execute( query, variable_values=params, upload_files=True @@ -988,9 +945,6 @@ def test_code(): assert execution_result.data["success"] - f1.close() - f2.close() - await run_sync_test(server, test_code) diff --git a/tests/test_httpx_async.py b/tests/test_httpx_async.py index 49ea6a24..ddacbc14 100644 --- a/tests/test_httpx_async.py +++ b/tests/test_httpx_async.py @@ -4,7 +4,7 @@ import pytest -from gql import Client, gql +from gql import Client, FileVar, gql from gql.cli import get_parser, main from gql.transport.exceptions import ( TransportAlreadyConnected, @@ -17,7 +17,7 @@ from .conftest import ( TemporaryFile, get_localhost_ssl_context_client, - strip_braces_spaces, + make_upload_handler, ) query1_str = """ @@ -613,8 +613,6 @@ def test_code(): await run_sync_test(server, test_code) -file_upload_server_answer = '{"data":{"success":true}}' - file_upload_mutation_1 = """ mutation($file: Upload!) { uploadFile(input:{other_var:$other_var, file:$file}) { @@ -637,33 +635,6 @@ def test_code(): """ -async def single_upload_handler(request): - - from aiohttp import web - - reader = await request.multipart() - - field_0 = await reader.next() - assert field_0.name == "operations" - field_0_text = await field_0.text() - assert strip_braces_spaces(field_0_text) == file_upload_mutation_1_operations - - field_1 = await reader.next() - assert field_1.name == "map" - field_1_text = await field_1.text() - assert field_1_text == file_upload_mutation_1_map - - field_2 = await reader.next() - assert field_2.name == "0" - field_2_text = await field_2.text() - assert field_2_text == file_1_content - - field_3 = await reader.next() - assert field_3 is None - - return web.Response(text=file_upload_server_answer, content_type="application/json") - - @pytest.mark.aiohttp @pytest.mark.asyncio async def test_httpx_file_upload(aiohttp_server): @@ -672,7 +643,15 @@ async def test_httpx_file_upload(aiohttp_server): from gql.transport.httpx import HTTPXAsyncTransport app = web.Application() - app.router.add_route("POST", "/", single_upload_handler) + app.router.add_route( + "POST", + "/", + make_upload_handler( + expected_map=file_upload_mutation_1_map, + expected_operations=file_upload_mutation_1_operations, + expected_contents=[file_1_content], + ), + ) server = await aiohttp_server(app) url = str(server.make_url("/")) @@ -687,17 +666,45 @@ async def test_httpx_file_upload(aiohttp_server): file_path = test_file.filename + # Using an opened file with open(file_path, "rb") as f: params = {"file": f, "other_var": 42} + # Execute query asynchronously + with pytest.warns( + DeprecationWarning, + match="Not using FileVar for file upload is deprecated", + ): + result = await session.execute( + query, variable_values=params, upload_files=True + ) + + success = result["success"] + assert success + + # Using an opened file inside a FileVar object + with open(file_path, "rb") as f: + + params = {"file": FileVar(f), "other_var": 42} + # Execute query asynchronously result = await session.execute( query, variable_values=params, upload_files=True ) success = result["success"] + assert success + + # Using an filename string inside a FileVar object + params = {"file": FileVar(file_path), "other_var": 42} + + # Execute query asynchronously + result = await session.execute( + query, variable_values=params, upload_files=True + ) + success = result["success"] assert success @@ -709,7 +716,15 @@ async def test_httpx_file_upload_without_session(aiohttp_server, run_sync_test): from gql.transport.httpx import HTTPXAsyncTransport app = web.Application() - app.router.add_route("POST", "/", single_upload_handler) + app.router.add_route( + "POST", + "/", + make_upload_handler( + expected_map=file_upload_mutation_1_map, + expected_operations=file_upload_mutation_1_operations, + expected_contents=[file_1_content], + ), + ) server = await aiohttp_server(app) url = str(server.make_url("/")) @@ -725,52 +740,17 @@ def test_code(): file_path = test_file.filename - with open(file_path, "rb") as f: + params = {"file": FileVar(file_path), "other_var": 42} - params = {"file": f, "other_var": 42} + result = client.execute(query, variable_values=params, upload_files=True) - result = client.execute( - query, variable_values=params, upload_files=True - ) - - success = result["success"] + success = result["success"] - assert success + assert success await run_sync_test(server, test_code) -# This is a sample binary file content containing all possible byte values -binary_file_content = bytes(range(0, 256)) - - -async def binary_upload_handler(request): - - from aiohttp import web - - reader = await request.multipart() - - field_0 = await reader.next() - assert field_0.name == "operations" - field_0_text = await field_0.text() - assert strip_braces_spaces(field_0_text) == file_upload_mutation_1_operations - - field_1 = await reader.next() - assert field_1.name == "map" - field_1_text = await field_1.text() - assert field_1_text == file_upload_mutation_1_map - - field_2 = await reader.next() - assert field_2.name == "0" - field_2_binary = await field_2.read() - assert field_2_binary == binary_file_content - - field_3 = await reader.next() - assert field_3 is None - - return web.Response(text=file_upload_server_answer, content_type="application/json") - - @pytest.mark.aiohttp @pytest.mark.asyncio async def test_httpx_binary_file_upload(aiohttp_server): @@ -778,8 +758,20 @@ async def test_httpx_binary_file_upload(aiohttp_server): from gql.transport.httpx import HTTPXAsyncTransport + # This is a sample binary file content containing all possible byte values + binary_file_content = bytes(range(0, 256)) + app = web.Application() - app.router.add_route("POST", "/", binary_upload_handler) + app.router.add_route( + "POST", + "/", + make_upload_handler( + binary=True, + expected_contents=[binary_file_content], + expected_map=file_upload_mutation_1_map, + expected_operations=file_upload_mutation_1_operations, + ), + ) server = await aiohttp_server(app) url = str(server.make_url("/")) @@ -794,42 +786,18 @@ async def test_httpx_binary_file_upload(aiohttp_server): file_path = test_file.filename - with open(file_path, "rb") as f: - - params = {"file": f, "other_var": 42} + params = {"file": FileVar(file_path), "other_var": 42} - # Execute query asynchronously - result = await session.execute( - query, variable_values=params, upload_files=True - ) + # Execute query asynchronously + result = await session.execute( + query, variable_values=params, upload_files=True + ) success = result["success"] assert success -file_upload_mutation_2 = """ - mutation($file1: Upload!, $file2: Upload!) { - uploadFile(input:{file1:$file, file2:$file}) { - success - } - } -""" - -file_upload_mutation_2_operations = ( - '{"query": "mutation ($file1: Upload!, $file2: Upload!) {\\n ' - 'uploadFile(input: {file1: $file, file2: $file}) {\\n success\\n }\\n}", ' - '"variables": {"file1": null, "file2": null}}' -) - -file_upload_mutation_2_map = '{"0": ["variables.file1"], "1": ["variables.file2"]}' - -file_2_content = """ -This is a second test file -This file will also be sent in the GraphQL mutation -""" - - @pytest.mark.aiohttp @pytest.mark.asyncio async def test_httpx_file_upload_two_files(aiohttp_server): @@ -837,39 +805,38 @@ async def test_httpx_file_upload_two_files(aiohttp_server): from gql.transport.httpx import HTTPXAsyncTransport - async def handler(request): - - reader = await request.multipart() - - field_0 = await reader.next() - assert field_0.name == "operations" - field_0_text = await field_0.text() - assert strip_braces_spaces(field_0_text) == file_upload_mutation_2_operations - - field_1 = await reader.next() - assert field_1.name == "map" - field_1_text = await field_1.text() - assert field_1_text == file_upload_mutation_2_map - - field_2 = await reader.next() - assert field_2.name == "0" - field_2_text = await field_2.text() - assert field_2_text == file_1_content + file_upload_mutation_2 = """ + mutation($file1: Upload!, $file2: Upload!) { + uploadFile(input:{file1:$file, file2:$file}) { + success + } + } + """ - field_3 = await reader.next() - assert field_3.name == "1" - field_3_text = await field_3.text() - assert field_3_text == file_2_content + file_upload_mutation_2_operations = ( + '{"query": "mutation ($file1: Upload!, $file2: Upload!) {\\n ' + 'uploadFile(input: {file1: $file, file2: $file}) {\\n success\\n }\\n}", ' + '"variables": {"file1": null, "file2": null}}' + ) - field_4 = await reader.next() - assert field_4 is None + file_upload_mutation_2_map = '{"0": ["variables.file1"], "1": ["variables.file2"]}' - return web.Response( - text=file_upload_server_answer, content_type="application/json" - ) + file_2_content = """ + This is a second test file + This file will also be sent in the GraphQL mutation + """ app = web.Application() - app.router.add_route("POST", "/", handler) + app.router.add_route( + "POST", + "/", + make_upload_handler( + nb_files=2, + expected_map=file_upload_mutation_2_map, + expected_operations=file_upload_mutation_2_operations, + expected_contents=[file_1_content, file_2_content], + ), + ) server = await aiohttp_server(app) url = str(server.make_url("/")) @@ -886,43 +853,19 @@ async def handler(request): file_path_1 = test_file_1.filename file_path_2 = test_file_2.filename - f1 = open(file_path_1, "rb") - f2 = open(file_path_2, "rb") - params = { - "file1": f1, - "file2": f2, + "file1": FileVar(file_path_1), + "file2": FileVar(file_path_2), } result = await session.execute( query, variable_values=params, upload_files=True ) - f1.close() - f2.close() - success = result["success"] - assert success -file_upload_mutation_3 = """ - mutation($files: [Upload!]!) { - uploadFiles(input:{files:$files}) { - success - } - } -""" - -file_upload_mutation_3_operations = ( - '{"query": "mutation ($files: [Upload!]!) {\\n uploadFiles(' - "input: {files: $files})" - ' {\\n success\\n }\\n}", "variables": {"files": [null, null]}}' -) - -file_upload_mutation_3_map = '{"0": ["variables.files.0"], "1": ["variables.files.1"]}' - - @pytest.mark.aiohttp @pytest.mark.asyncio async def test_httpx_file_upload_list_of_two_files(aiohttp_server): @@ -930,39 +873,40 @@ async def test_httpx_file_upload_list_of_two_files(aiohttp_server): from gql.transport.httpx import HTTPXAsyncTransport - async def handler(request): - - reader = await request.multipart() - - field_0 = await reader.next() - assert field_0.name == "operations" - field_0_text = await field_0.text() - assert strip_braces_spaces(field_0_text) == file_upload_mutation_3_operations - - field_1 = await reader.next() - assert field_1.name == "map" - field_1_text = await field_1.text() - assert field_1_text == file_upload_mutation_3_map - - field_2 = await reader.next() - assert field_2.name == "0" - field_2_text = await field_2.text() - assert field_2_text == file_1_content + file_upload_mutation_3 = """ + mutation($files: [Upload!]!) { + uploadFiles(input:{files:$files}) { + success + } + } + """ - field_3 = await reader.next() - assert field_3.name == "1" - field_3_text = await field_3.text() - assert field_3_text == file_2_content + file_upload_mutation_3_operations = ( + '{"query": "mutation ($files: [Upload!]!) {\\n uploadFiles' + "(input: {files: $files})" + ' {\\n success\\n }\\n}", "variables": {"files": [null, null]}}' + ) - field_4 = await reader.next() - assert field_4 is None + file_upload_mutation_3_map = ( + '{"0": ["variables.files.0"], "1": ["variables.files.1"]}' + ) - return web.Response( - text=file_upload_server_answer, content_type="application/json" - ) + file_2_content = """ + This is a second test file + This file will also be sent in the GraphQL mutation + """ app = web.Application() - app.router.add_route("POST", "/", handler) + app.router.add_route( + "POST", + "/", + make_upload_handler( + nb_files=2, + expected_map=file_upload_mutation_3_map, + expected_operations=file_upload_mutation_3_operations, + expected_contents=[file_1_content, file_2_content], + ), + ) server = await aiohttp_server(app) url = str(server.make_url("/")) @@ -979,21 +923,19 @@ async def handler(request): file_path_1 = test_file_1.filename file_path_2 = test_file_2.filename - f1 = open(file_path_1, "rb") - f2 = open(file_path_2, "rb") - - params = {"files": [f1, f2]} + params = { + "files": [ + FileVar(file_path_1), + FileVar(file_path_2), + ], + } # Execute query asynchronously result = await session.execute( query, variable_values=params, upload_files=True ) - f1.close() - f2.close() - success = result["success"] - assert success diff --git a/tests/test_requests.py b/tests/test_requests.py index 9c0334bd..c184e230 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -1,8 +1,10 @@ +import os +import warnings from typing import Any, Dict, Mapping import pytest -from gql import Client, gql +from gql import Client, FileVar, gql from gql.transport.exceptions import ( TransportAlreadyConnected, TransportClosed, @@ -14,7 +16,7 @@ from .conftest import ( TemporaryFile, get_localhost_ssl_context_client, - strip_braces_spaces, + make_upload_handler, ) # Marking all tests in this file with the requests marker @@ -86,8 +88,6 @@ def test_code(): @pytest.mark.asyncio @pytest.mark.parametrize("verify_https", ["disabled", "cert_provided"]) async def test_requests_query_https(ssl_aiohttp_server, run_sync_test, verify_https): - import warnings - from aiohttp import web from gql.transport.requests import RequestsHTTPTransport @@ -519,8 +519,6 @@ def test_code(): await run_sync_test(server, test_code) -file_upload_server_answer = '{"data":{"success":true}}' - file_upload_mutation_1 = """ mutation($file: Upload!) { uploadFile(input:{other_var:$other_var, file:$file}) { @@ -550,35 +548,16 @@ async def test_requests_file_upload(aiohttp_server, run_sync_test): from gql.transport.requests import RequestsHTTPTransport - async def single_upload_handler(request): - from aiohttp import web - - reader = await request.multipart() - - field_0 = await reader.next() - assert field_0.name == "operations" - field_0_text = await field_0.text() - assert strip_braces_spaces(field_0_text) == file_upload_mutation_1_operations - - field_1 = await reader.next() - assert field_1.name == "map" - field_1_text = await field_1.text() - assert field_1_text == file_upload_mutation_1_map - - field_2 = await reader.next() - assert field_2.name == "0" - field_2_text = await field_2.text() - assert field_2_text == file_1_content - - field_3 = await reader.next() - assert field_3 is None - - return web.Response( - text=file_upload_server_answer, content_type="application/json" - ) - app = web.Application() - app.router.add_route("POST", "/", single_upload_handler) + app.router.add_route( + "POST", + "/", + make_upload_handler( + expected_map=file_upload_mutation_1_map, + expected_operations=file_upload_mutation_1_operations, + expected_contents=[file_1_content], + ), + ) server = await aiohttp_server(app) url = server.make_url("/") @@ -592,15 +571,41 @@ def test_code(): file_path = test_file.filename + # Using an opened file with open(file_path, "rb") as f: params = {"file": f, "other_var": 42} - execution_result = session._execute( - query, variable_values=params, upload_files=True - ) + + with pytest.warns( + DeprecationWarning, + match="Not using FileVar for file upload is deprecated", + ): + execution_result = session._execute( + query, variable_values=params, upload_files=True + ) + + assert execution_result.data["success"] + + # Using an opened file inside a FileVar object + with open(file_path, "rb") as f: + + params = {"file": FileVar(f), "other_var": 42} + with warnings.catch_warnings(): + warnings.simplefilter("error") # Turn warnings into errors + execution_result = session._execute( + query, variable_values=params, upload_files=True + ) assert execution_result.data["success"] + # Using an filename string inside a FileVar object + params = {"file": FileVar(file_path), "other_var": 42} + execution_result = session._execute( + query, variable_values=params, upload_files=True + ) + + assert execution_result.data["success"] + await run_sync_test(server, test_code) @@ -611,38 +616,17 @@ async def test_requests_file_upload_with_content_type(aiohttp_server, run_sync_t from gql.transport.requests import RequestsHTTPTransport - async def single_upload_handler(request): - from aiohttp import web - - reader = await request.multipart() - - field_0 = await reader.next() - assert field_0.name == "operations" - field_0_text = await field_0.text() - assert strip_braces_spaces(field_0_text) == file_upload_mutation_1_operations - - field_1 = await reader.next() - assert field_1.name == "map" - field_1_text = await field_1.text() - assert field_1_text == file_upload_mutation_1_map - - field_2 = await reader.next() - assert field_2.name == "0" - field_2_text = await field_2.text() - assert field_2_text == file_1_content - - # Verifying the content_type - assert field_2.headers["Content-Type"] == "application/pdf" - - field_3 = await reader.next() - assert field_3 is None - - return web.Response( - text=file_upload_server_answer, content_type="application/json" - ) - app = web.Application() - app.router.add_route("POST", "/", single_upload_handler) + app.router.add_route( + "POST", + "/", + make_upload_handler( + file_headers=[{"Content-Type": "application/pdf"}], + expected_map=file_upload_mutation_1_map, + expected_operations=file_upload_mutation_1_operations, + expected_contents=[file_1_content], + ), + ) server = await aiohttp_server(app) url = server.make_url("/") @@ -656,12 +640,30 @@ def test_code(): file_path = test_file.filename + # Using an opened file with open(file_path, "rb") as f: # Setting the content_type f.content_type = "application/pdf" # type: ignore params = {"file": f, "other_var": 42} + with pytest.warns( + DeprecationWarning, + match="Not using FileVar for file upload is deprecated", + ): + execution_result = session._execute( + query, variable_values=params, upload_files=True + ) + + assert execution_result.data["success"] + + # Using an opened file inside a FileVar object + with open(file_path, "rb") as f: + + params = { + "file": FileVar(f, content_type="application/pdf"), + "other_var": 42, + } execution_result = session._execute( query, variable_values=params, upload_files=True ) @@ -673,48 +675,78 @@ def test_code(): @pytest.mark.aiohttp @pytest.mark.asyncio -async def test_requests_file_upload_additional_headers(aiohttp_server, run_sync_test): +async def test_requests_file_upload_default_filename_is_basename( + aiohttp_server, run_sync_test +): from aiohttp import web from gql.transport.requests import RequestsHTTPTransport - async def single_upload_handler(request): - from aiohttp import web + app = web.Application() - assert request.headers["X-Auth"] == "foobar" + with TemporaryFile(file_1_content) as test_file: + file_path = test_file.filename + file_basename = os.path.basename(file_path) + + app.router.add_route( + "POST", + "/", + make_upload_handler( + filenames=[file_basename], + expected_map=file_upload_mutation_1_map, + expected_operations=file_upload_mutation_1_operations, + expected_contents=[file_1_content], + ), + ) + server = await aiohttp_server(app) - reader = await request.multipart() + url = server.make_url("/") - field_0 = await reader.next() - assert field_0.name == "operations" - field_0_text = await field_0.text() - assert strip_braces_spaces(field_0_text) == file_upload_mutation_1_operations + def test_code(): - field_1 = await reader.next() - assert field_1.name == "map" - field_1_text = await field_1.text() - assert field_1_text == file_upload_mutation_1_map + transport = RequestsHTTPTransport(url=url) - field_2 = await reader.next() - assert field_2.name == "0" - field_2_text = await field_2.text() - assert field_2_text == file_1_content + with Client(transport=transport) as session: + query = gql(file_upload_mutation_1) - field_3 = await reader.next() - assert field_3 is None + params = { + "file": FileVar(file_path), + "other_var": 42, + } + execution_result = session._execute( + query, variable_values=params, upload_files=True + ) + + assert execution_result.data["success"] + + await run_sync_test(server, test_code) - return web.Response( - text=file_upload_server_answer, content_type="application/json" - ) + +@pytest.mark.aiohttp +@pytest.mark.asyncio +async def test_requests_file_upload_with_filename(aiohttp_server, run_sync_test): + from aiohttp import web + + from gql.transport.requests import RequestsHTTPTransport app = web.Application() - app.router.add_route("POST", "/", single_upload_handler) + app.router.add_route( + "POST", + "/", + make_upload_handler( + filenames=["filename1.txt"], + expected_map=file_upload_mutation_1_map, + expected_operations=file_upload_mutation_1_operations, + expected_contents=[file_1_content], + ), + ) server = await aiohttp_server(app) url = server.make_url("/") def test_code(): - transport = RequestsHTTPTransport(url=url, headers={"X-Auth": "foobar"}) + + transport = RequestsHTTPTransport(url=url) with TemporaryFile(file_1_content) as test_file: with Client(transport=transport) as session: @@ -724,7 +756,10 @@ def test_code(): with open(file_path, "rb") as f: - params = {"file": f, "other_var": 42} + params = { + "file": FileVar(f, filename="filename1.txt"), + "other_var": 42, + } execution_result = session._execute( query, variable_values=params, upload_files=True ) @@ -736,44 +771,72 @@ def test_code(): @pytest.mark.aiohttp @pytest.mark.asyncio -async def test_requests_binary_file_upload(aiohttp_server, run_sync_test): +async def test_requests_file_upload_additional_headers(aiohttp_server, run_sync_test): from aiohttp import web from gql.transport.requests import RequestsHTTPTransport - # This is a sample binary file content containing all possible byte values - binary_file_content = bytes(range(0, 256)) + app = web.Application() + app.router.add_route( + "POST", + "/", + make_upload_handler( + request_headers={"X-Auth": "foobar"}, + expected_map=file_upload_mutation_1_map, + expected_operations=file_upload_mutation_1_operations, + expected_contents=[file_1_content], + ), + ) + server = await aiohttp_server(app) - async def binary_upload_handler(request): + url = server.make_url("/") - from aiohttp import web + def test_code(): + transport = RequestsHTTPTransport(url=url, headers={"X-Auth": "foobar"}) - reader = await request.multipart() + with TemporaryFile(file_1_content) as test_file: + with Client(transport=transport) as session: + query = gql(file_upload_mutation_1) - field_0 = await reader.next() - assert field_0.name == "operations" - field_0_text = await field_0.text() - assert strip_braces_spaces(field_0_text) == file_upload_mutation_1_operations + file_path = test_file.filename - field_1 = await reader.next() - assert field_1.name == "map" - field_1_text = await field_1.text() - assert field_1_text == file_upload_mutation_1_map + with open(file_path, "rb") as f: - field_2 = await reader.next() - assert field_2.name == "0" - field_2_binary = await field_2.read() - assert field_2_binary == binary_file_content + params = {"file": f, "other_var": 42} + with pytest.warns( + DeprecationWarning, + match="Not using FileVar for file upload is deprecated", + ): + execution_result = session._execute( + query, variable_values=params, upload_files=True + ) - field_3 = await reader.next() - assert field_3 is None + assert execution_result.data["success"] + + await run_sync_test(server, test_code) - return web.Response( - text=file_upload_server_answer, content_type="application/json" - ) + +@pytest.mark.aiohttp +@pytest.mark.asyncio +async def test_requests_binary_file_upload(aiohttp_server, run_sync_test): + from aiohttp import web + + from gql.transport.requests import RequestsHTTPTransport + + # This is a sample binary file content containing all possible byte values + binary_file_content = bytes(range(0, 256)) app = web.Application() - app.router.add_route("POST", "/", binary_upload_handler) + app.router.add_route( + "POST", + "/", + make_upload_handler( + binary=True, + expected_contents=[binary_file_content], + expected_map=file_upload_mutation_1_map, + expected_operations=file_upload_mutation_1_operations, + ), + ) server = await aiohttp_server(app) url = server.make_url("/") @@ -792,22 +855,19 @@ def test_code(): params = {"file": f, "other_var": 42} - execution_result = session._execute( - query, variable_values=params, upload_files=True - ) + with pytest.warns( + DeprecationWarning, + match="Not using FileVar for file upload is deprecated", + ): + execution_result = session._execute( + query, variable_values=params, upload_files=True + ) assert execution_result.data["success"] await run_sync_test(server, test_code) -file_upload_mutation_2_operations = ( - '{"query": "mutation ($file1: Upload!, $file2: Upload!) {\\n ' - 'uploadFile(input: {file1: $file, file2: $file}) {\\n success\\n }\\n}", ' - '"variables": {"file1": null, "file2": null}}' -) - - @pytest.mark.aiohttp @pytest.mark.asyncio async def test_requests_file_upload_two_files(aiohttp_server, run_sync_test): @@ -823,6 +883,12 @@ async def test_requests_file_upload_two_files(aiohttp_server, run_sync_test): } """ + file_upload_mutation_2_operations = ( + '{"query": "mutation ($file1: Upload!, $file2: Upload!) {\\n ' + 'uploadFile(input: {file1: $file, file2: $file}) {\\n success\\n }\\n}", ' + '"variables": {"file1": null, "file2": null}}' + ) + file_upload_mutation_2_map = '{"0": ["variables.file1"], "1": ["variables.file2"]}' file_2_content = """ @@ -830,39 +896,17 @@ async def test_requests_file_upload_two_files(aiohttp_server, run_sync_test): This file will also be sent in the GraphQL mutation """ - async def handler(request): - - reader = await request.multipart() - - field_0 = await reader.next() - assert field_0.name == "operations" - field_0_text = await field_0.text() - assert strip_braces_spaces(field_0_text) == file_upload_mutation_2_operations - - field_1 = await reader.next() - assert field_1.name == "map" - field_1_text = await field_1.text() - assert field_1_text == file_upload_mutation_2_map - - field_2 = await reader.next() - assert field_2.name == "0" - field_2_text = await field_2.text() - assert field_2_text == file_1_content - - field_3 = await reader.next() - assert field_3.name == "1" - field_3_text = await field_3.text() - assert field_3_text == file_2_content - - field_4 = await reader.next() - assert field_4 is None - - return web.Response( - text=file_upload_server_answer, content_type="application/json" - ) - app = web.Application() - app.router.add_route("POST", "/", handler) + app.router.add_route( + "POST", + "/", + make_upload_handler( + nb_files=2, + expected_map=file_upload_mutation_2_map, + expected_operations=file_upload_mutation_2_operations, + expected_contents=[file_1_content, file_2_content], + ), + ) server = await aiohttp_server(app) url = server.make_url("/") @@ -877,19 +921,45 @@ def test_code(): query = gql(file_upload_mutation_2) + # Old method file_path_1 = test_file_1.filename file_path_2 = test_file_2.filename f1 = open(file_path_1, "rb") f2 = open(file_path_2, "rb") - params = { + params_1 = { "file1": f1, "file2": f2, } + with pytest.warns( + DeprecationWarning, + match="Not using FileVar for file upload is deprecated", + ): + execution_result = session._execute( + query, variable_values=params_1, upload_files=True + ) + + assert execution_result.data["success"] + + f1.close() + f2.close() + + # Using FileVar + file_path_1 = test_file_1.filename + file_path_2 = test_file_2.filename + + f1 = open(file_path_1, "rb") + f2 = open(file_path_2, "rb") + + params_2 = { + "file1": FileVar(f1), + "file2": FileVar(f2), + } + execution_result = session._execute( - query, variable_values=params, upload_files=True + query, variable_values=params_2, upload_files=True ) assert execution_result.data["success"] @@ -900,13 +970,6 @@ def test_code(): await run_sync_test(server, test_code) -file_upload_mutation_3_operations = ( - '{"query": "mutation ($files: [Upload!]!) {\\n uploadFiles' - "(input: {files: $files})" - ' {\\n success\\n }\\n}", "variables": {"files": [null, null]}}' -) - - @pytest.mark.aiohttp @pytest.mark.asyncio async def test_requests_file_upload_list_of_two_files(aiohttp_server, run_sync_test): @@ -922,6 +985,12 @@ async def test_requests_file_upload_list_of_two_files(aiohttp_server, run_sync_t } """ + file_upload_mutation_3_operations = ( + '{"query": "mutation ($files: [Upload!]!) {\\n uploadFiles' + "(input: {files: $files})" + ' {\\n success\\n }\\n}", "variables": {"files": [null, null]}}' + ) + file_upload_mutation_3_map = ( '{"0": ["variables.files.0"], "1": ["variables.files.1"]}' ) @@ -931,39 +1000,17 @@ async def test_requests_file_upload_list_of_two_files(aiohttp_server, run_sync_t This file will also be sent in the GraphQL mutation """ - async def handler(request): - - reader = await request.multipart() - - field_0 = await reader.next() - assert field_0.name == "operations" - field_0_text = await field_0.text() - assert strip_braces_spaces(field_0_text) == file_upload_mutation_3_operations - - field_1 = await reader.next() - assert field_1.name == "map" - field_1_text = await field_1.text() - assert field_1_text == file_upload_mutation_3_map - - field_2 = await reader.next() - assert field_2.name == "0" - field_2_text = await field_2.text() - assert field_2_text == file_1_content - - field_3 = await reader.next() - assert field_3.name == "1" - field_3_text = await field_3.text() - assert field_3_text == file_2_content - - field_4 = await reader.next() - assert field_4 is None - - return web.Response( - text=file_upload_server_answer, content_type="application/json" - ) - app = web.Application() - app.router.add_route("POST", "/", handler) + app.router.add_route( + "POST", + "/", + make_upload_handler( + nb_files=2, + expected_map=file_upload_mutation_3_map, + expected_operations=file_upload_mutation_3_operations, + expected_contents=[file_1_content, file_2_content], + ), + ) server = await aiohttp_server(app) url = server.make_url("/") @@ -977,6 +1024,7 @@ def test_code(): query = gql(file_upload_mutation_3) + # Old method file_path_1 = test_file_1.filename file_path_2 = test_file_2.filename @@ -985,8 +1033,30 @@ def test_code(): params = {"files": [f1, f2]} + with pytest.warns( + DeprecationWarning, + match="Not using FileVar for file upload is deprecated", + ): + execution_result = session._execute( + query, variable_values=params, upload_files=True + ) + + assert execution_result.data["success"] + + f1.close() + f2.close() + + # Using FileVar + file_path_1 = test_file_1.filename + file_path_2 = test_file_2.filename + + f1 = open(file_path_1, "rb") + f2 = open(file_path_2, "rb") + + params_2 = {"files": [FileVar(f1), FileVar(f2)]} + execution_result = session._execute( - query, variable_values=params, upload_files=True + query, variable_values=params_2, upload_files=True ) assert execution_result.data["success"]