Bug: AiohttpClient passes files= kwarg to aiohttp.ClientSession.request(), unsupported in aiohttp 3.10+
Describe the bug
When using @multipart with Part annotations and an AiohttpClient, uplink builds the multipart request by collecting Part values into request_builder.info["files"] and then passes that dict as a files= keyword argument to aiohttp.ClientSession.request(). However, aiohttp removed the files= parameter from ClientSession._request() in version 3.10+. This causes all multipart file uploads to fail with:
TypeError: ClientSession._request() got an unexpected keyword argument 'files'
To Reproduce
- Define a multipart endpoint using uplink:
from uplink import Consumer, post, multipart, Part, returns
class MyAPI(Consumer):
@returns.json
@multipart
@post("upload")
def upload_file(self, file: Part, description: Part):
"""Upload a file."""
- Instantiate with
AiohttpClient and call the multipart method:
import aiohttp
from uplink import AiohttpClient
api = MyAPI(base_url="https://example.com/api/", client=AiohttpClient())
# In an async context:
await api.upload_file(
file=("filename.png", file_bytes, "image/png"),
description=(None, "my description"),
)
- Observe the error:
TypeError: ClientSession._request() got an unexpected keyword argument 'files'
Expected behavior
Multipart file uploads via AiohttpClient should work with aiohttp 3.10+. The files dict collected from Part annotations should be converted to an aiohttp.FormData object and passed as the data= kwarg, which is the correct aiohttp API for multipart form data.
Root cause
uplink/arguments.py — Part stores values into request_builder.info["files"]
uplink/clients/aiohttp_.py — AiohttpClient.send() passes **extras (which includes files=) directly to session.request() with no conversion
The synchronous requests backend accepts files= natively, so it works fine. Only AiohttpClient is affected.
Environment
uplink==0.10.0
aiohttp>=3.10.0
- Python 3.10+
Workaround
Subclass AiohttpClient and override send() to convert files to aiohttp.FormData before dispatching. Place this in your application's configuration module (not in the venv) so it survives package reinstalls and Docker builds:
import aiohttp
from uplink import AiohttpClient
class FixedAiohttpClient(AiohttpClient):
"""AiohttpClient with aiohttp 3.10+ multipart compatibility fix.
uplink passes multipart Part data as `files=` kwarg to
aiohttp.ClientSession.request(), but aiohttp removed that kwarg in 3.10+.
This subclass converts `files` to an aiohttp.FormData passed as `data=`.
"""
async def send(self, request):
method, url, extras = request
if "files" in extras:
form = aiohttp.FormData()
for field_name, field_value in extras.pop("files").items():
if isinstance(field_value, tuple):
filename = field_value[0]
content = field_value[1]
content_type = field_value[2] if len(field_value) > 2 else "application/octet-stream"
form.add_field(field_name, content, filename=filename, content_type=content_type)
else:
form.add_field(field_name, str(field_value) if field_value is not None else "")
extras["data"] = form
session = await self.session()
response = await session.request(method, url, **extras)
response.status_code = response.status
return response
# Use in place of AiohttpClient():
api = MyAPI(base_url="https://example.com/api/", client=FixedAiohttpClient())
Proposed fix
Apply the same conversion directly in AiohttpClient.send() in uplink/clients/aiohttp_.py:
async def send(self, request):
method, url, extras = request
if "files" in extras:
form = aiohttp.FormData()
for field_name, field_value in extras.pop("files").items():
if isinstance(field_value, tuple):
filename = field_value[0]
content = field_value[1]
content_type = field_value[2] if len(field_value) > 2 else "application/octet-stream"
form.add_field(field_name, content, filename=filename, content_type=content_type)
else:
form.add_field(field_name, str(field_value) if field_value is not None else "")
extras["data"] = form
session = await self.session()
response = await session.request(method, url, **extras)
response.status_code = response.status
return response
This is a minimal, backward-compatible change — it only activates when files is present in the extras dict (i.e., when @multipart / Part is used), leaving all other request types unaffected.
Bug:
AiohttpClientpassesfiles=kwarg toaiohttp.ClientSession.request(), unsupported in aiohttp 3.10+Describe the bug
When using
@multipartwithPartannotations and anAiohttpClient, uplink builds the multipart request by collectingPartvalues intorequest_builder.info["files"]and then passes that dict as afiles=keyword argument toaiohttp.ClientSession.request(). However, aiohttp removed thefiles=parameter fromClientSession._request()in version 3.10+. This causes all multipart file uploads to fail with:To Reproduce
AiohttpClientand call the multipart method:Expected behavior
Multipart file uploads via
AiohttpClientshould work with aiohttp 3.10+. Thefilesdict collected fromPartannotations should be converted to anaiohttp.FormDataobject and passed as thedata=kwarg, which is the correct aiohttp API for multipart form data.Root cause
uplink/arguments.py—Partstores values intorequest_builder.info["files"]uplink/clients/aiohttp_.py—AiohttpClient.send()passes**extras(which includesfiles=) directly tosession.request()with no conversionThe synchronous
requestsbackend acceptsfiles=natively, so it works fine. OnlyAiohttpClientis affected.Environment
uplink==0.10.0aiohttp>=3.10.0Workaround
Subclass
AiohttpClientand overridesend()to convertfilestoaiohttp.FormDatabefore dispatching. Place this in your application's configuration module (not in the venv) so it survives package reinstalls and Docker builds:Proposed fix
Apply the same conversion directly in
AiohttpClient.send()inuplink/clients/aiohttp_.py:This is a minimal, backward-compatible change — it only activates when
filesis present in the extras dict (i.e., when@multipart/Partis used), leaving all other request types unaffected.