Skip to content

Commit c17959c

Browse files
authored
Feat: Add Streaming functionalities to file uploads (#174)
1 parent d3f79e7 commit c17959c

File tree

5 files changed

+207
-14
lines changed

5 files changed

+207
-14
lines changed

docs/usage/file_upload.rst

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ In order to upload a single file, you need to:
1919
2020
transport = AIOHTTPTransport(url='YOUR_URL')
2121
22-
client = Client(transport=sample_transport)
22+
client = Client(transport=transport)
2323
2424
query = gql('''
2525
mutation($file: Upload!) {
@@ -46,7 +46,7 @@ It is also possible to upload multiple files using a list.
4646
4747
transport = AIOHTTPTransport(url='YOUR_URL')
4848
49-
client = Client(transport=sample_transport)
49+
client = Client(transport=transport)
5050
5151
query = gql('''
5252
mutation($files: [Upload!]!) {
@@ -67,3 +67,111 @@ It is also possible to upload multiple files using a list.
6767
6868
f1.close()
6969
f2.close()
70+
71+
72+
Streaming
73+
---------
74+
75+
If you use the above methods to send files, then the entire contents of the files
76+
must be loaded in memory before the files are sent.
77+
If the files are not too big and you have enough RAM, it is not a problem.
78+
On another hand if you want to avoid using too much memory, then it is better
79+
to read the files and send them in small chunks so that the entire file contents
80+
don't have to be in memory at once.
81+
82+
We provide methods to do that for two different uses cases:
83+
84+
* Sending local files
85+
* Streaming downloaded files from an external URL to the GraphQL API
86+
87+
Streaming local files
88+
^^^^^^^^^^^^^^^^^^^^^
89+
90+
aiohttp allows to upload files using an asynchronous generator.
91+
See `Streaming uploads on aiohttp docs`_.
92+
93+
94+
In order to stream local files, instead of providing opened files to the
95+
`variables_values` argument of `execute`, you need to provide an async generator
96+
which will provide parts of the files.
97+
98+
You can use `aiofiles`_
99+
to read the files in chunks and create this asynchronous generator.
100+
101+
.. _Streaming uploads on aiohttp docs: https://docs.aiohttp.org/en/stable/client_quickstart.html#streaming-uploads
102+
.. _aiofiles: https://github.com/Tinche/aiofiles
103+
104+
Example:
105+
106+
.. code-block:: python
107+
108+
transport = AIOHTTPTransport(url='YOUR_URL')
109+
110+
client = Client(transport=transport)
111+
112+
query = gql('''
113+
mutation($file: Upload!) {
114+
singleUpload(file: $file) {
115+
id
116+
}
117+
}
118+
''')
119+
120+
async def file_sender(file_name):
121+
async with aiofiles.open(file_name, 'rb') as f:
122+
chunk = await f.read(64*1024)
123+
while chunk:
124+
yield chunk
125+
chunk = await f.read(64*1024)
126+
127+
params = {"file": file_sender(file_name='YOUR_FILE_PATH')}
128+
129+
result = client.execute(
130+
query, variable_values=params, upload_files=True
131+
)
132+
133+
Streaming downloaded files
134+
^^^^^^^^^^^^^^^^^^^^^^^^^^
135+
136+
If the file you want to upload to the GraphQL API is not present locally
137+
and needs to be downloaded from elsewhere, then it is possible to chain the download
138+
and the upload in order to limit the amout of memory used.
139+
140+
Because the `content` attribute of an aiohttp response is a `StreamReader`
141+
(it provides an async iterator protocol), you can chain the download and the upload
142+
together.
143+
144+
In order to do that, you need to:
145+
146+
* get the response from an aiohttp request and then get the StreamReader instance
147+
from `resp.content`
148+
* provide the StreamReader instance to the `variable_values` argument of `execute`
149+
150+
Example:
151+
152+
.. code-block:: python
153+
154+
# First request to download your file with aiohttp
155+
async with aiohttp.ClientSession() as http_client:
156+
async with http_client.get('YOUR_DOWNLOAD_URL') as resp:
157+
158+
# We now have a StreamReader instance in resp.content
159+
# and we provide it to the variable_values argument of execute
160+
161+
transport = AIOHTTPTransport(url='YOUR_GRAPHQL_URL')
162+
163+
client = Client(transport=transport)
164+
165+
query = gql('''
166+
mutation($file: Upload!) {
167+
singleUpload(file: $file) {
168+
id
169+
}
170+
}
171+
''')
172+
173+
params = {"file": resp.content}
174+
175+
result = client.execute(
176+
query, variable_values=params, upload_files=True
177+
)

gql/transport/aiohttp.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import io
12
import json
23
import logging
34
from ssl import SSLContext
4-
from typing import Any, AsyncGenerator, Dict, Optional, Union
5+
from typing import Any, AsyncGenerator, Dict, Optional, Tuple, Type, Union
56

67
import aiohttp
78
from aiohttp.client_exceptions import ClientResponseError
@@ -29,6 +30,12 @@ class AIOHTTPTransport(AsyncTransport):
2930
This transport use the aiohttp library with asyncio.
3031
"""
3132

33+
file_classes: Tuple[Type[Any], ...] = (
34+
io.IOBase,
35+
aiohttp.StreamReader,
36+
AsyncGenerator,
37+
)
38+
3239
def __init__(
3340
self,
3441
url: str,
@@ -144,7 +151,9 @@ async def execute(
144151

145152
# If we upload files, we will extract the files present in the
146153
# variable_values dict and replace them by null values
147-
nulled_variable_values, files = extract_files(variable_values)
154+
nulled_variable_values, files = extract_files(
155+
variables=variable_values, file_classes=self.file_classes,
156+
)
148157

149158
# Save the nulled variable values in the payload
150159
payload["variables"] = nulled_variable_values
@@ -175,7 +184,8 @@ async def execute(
175184
data.add_field("map", file_map_str, content_type="application/json")
176185

177186
# Add the extracted files as remaining fields
178-
data.add_fields(*file_streams.items())
187+
for k, v in file_streams.items():
188+
data.add_field(k, v, filename=k)
179189

180190
post_args: Dict[str, Any] = {"data": data}
181191

gql/utils.py

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
"""Utilities to manipulate several python objects."""
22

3-
import io
4-
from typing import Any, Dict, Tuple
3+
from typing import Any, Dict, Tuple, Type
54

65

76
# From this response in Stackoverflow
@@ -13,12 +12,9 @@ def to_camel_case(snake_str):
1312
return components[0] + "".join(x.title() if x else "_" for x in components[1:])
1413

1514

16-
def is_file_like(value: Any) -> bool:
17-
"""Check if a value represents a file like object"""
18-
return isinstance(value, io.IOBase)
19-
20-
21-
def extract_files(variables: Dict) -> Tuple[Dict, Dict]:
15+
def extract_files(
16+
variables: Dict, file_classes: Tuple[Type[Any], ...]
17+
) -> Tuple[Dict, Dict]:
2218
files = {}
2319

2420
def recurse_extract(path, obj):
@@ -40,7 +36,7 @@ def recurse_extract(path, obj):
4036
value = recurse_extract(f"{path}.{key}", value)
4137
nulled_obj[key] = value
4238
return nulled_obj
43-
elif is_file_like(obj):
39+
elif isinstance(obj, file_classes):
4440
# extract obj from its parent and put it into files instead.
4541
files[path] = obj
4642
return None

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"pytest-cov==2.8.1",
1919
"mock==4.0.2",
2020
"vcrpy==4.0.2",
21+
"aiofiles",
2122
]
2223

2324
dev_requires = [

tests/test_aiohttp.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,84 @@ async def test_aiohttp_binary_file_upload(event_loop, aiohttp_server):
582582
assert success
583583

584584

585+
@pytest.mark.asyncio
586+
async def test_aiohttp_stream_reader_upload(event_loop, aiohttp_server):
587+
from aiohttp import web, ClientSession
588+
from gql.transport.aiohttp import AIOHTTPTransport
589+
590+
async def binary_data_handler(request):
591+
return web.Response(
592+
body=binary_file_content, content_type="binary/octet-stream"
593+
)
594+
595+
app = web.Application()
596+
app.router.add_route("POST", "/", binary_upload_handler)
597+
app.router.add_route("GET", "/binary_data", binary_data_handler)
598+
599+
server = await aiohttp_server(app)
600+
601+
url = server.make_url("/")
602+
binary_data_url = server.make_url("/binary_data")
603+
604+
sample_transport = AIOHTTPTransport(url=url, timeout=10)
605+
606+
async with Client(transport=sample_transport) as session:
607+
query = gql(file_upload_mutation_1)
608+
async with ClientSession() as client:
609+
async with client.get(binary_data_url) as resp:
610+
params = {"file": resp.content, "other_var": 42}
611+
612+
# Execute query asynchronously
613+
result = await session.execute(
614+
query, variable_values=params, upload_files=True
615+
)
616+
617+
success = result["success"]
618+
619+
assert success
620+
621+
622+
@pytest.mark.asyncio
623+
async def test_aiohttp_async_generator_upload(event_loop, aiohttp_server):
624+
import aiofiles
625+
from aiohttp import web
626+
from gql.transport.aiohttp import AIOHTTPTransport
627+
628+
app = web.Application()
629+
app.router.add_route("POST", "/", binary_upload_handler)
630+
server = await aiohttp_server(app)
631+
632+
url = server.make_url("/")
633+
634+
sample_transport = AIOHTTPTransport(url=url, timeout=10)
635+
636+
with TemporaryFile(binary_file_content) as test_file:
637+
638+
async with Client(transport=sample_transport,) as session:
639+
640+
query = gql(file_upload_mutation_1)
641+
642+
file_path = test_file.filename
643+
644+
async def file_sender(file_name):
645+
async with aiofiles.open(file_name, "rb") as f:
646+
chunk = await f.read(64 * 1024)
647+
while chunk:
648+
yield chunk
649+
chunk = await f.read(64 * 1024)
650+
651+
params = {"file": file_sender(file_path), "other_var": 42}
652+
653+
# Execute query asynchronously
654+
result = await session.execute(
655+
query, variable_values=params, upload_files=True
656+
)
657+
658+
success = result["success"]
659+
660+
assert success
661+
662+
585663
file_upload_mutation_2 = """
586664
mutation($file1: Upload!, $file2: Upload!) {
587665
uploadFile(input:{file1:$file, file2:$file}) {

0 commit comments

Comments
 (0)