Skip to content

Bug: AiohttpClient passes files= kwarg to aiohttp.ClientSession.request(), unsupported in aiohttp 3.10+ #351

Description

@TerraX3000

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

  1. 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."""
  1. 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"),
)
  1. 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.pyPart stores values into request_builder.info["files"]
  • uplink/clients/aiohttp_.pyAiohttpClient.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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions