Skip to content

Add a requests-style files argument for multipart uploads - #159

Merged
FlorianREGAZ merged 4 commits into
mainfrom
feat/multipart-files
Oct 10, 2026
Merged

FlorianREGAZ merged 4 commits into
mainfrom
feat/multipart-files

Conversation

@FlorianREGAZ

Copy link
Copy Markdown
Owner

Summary

execute_request, post, put and patch now accept files=, which sends a multipart/form-data body. It takes the same forms as requests:

  • {"name": fileobj}: the filename is the basename of fileobj.name, or the field name if the object has no usable name
  • {"name": ("filename", content)}, where content is a file object, bytes or str
  • {"name": ("filename", content, "content/type")}
  • {"name": ("filename", content, "content/type", {"X-Part-Header": "..."})}
  • a list of (name, file) pairs, to send a field more than once
  • ("filename", None) skips the field, and (None, value) sends a plain form field

When data is a dict or a list of pairs, its fields go into the same body, ahead of the files. List values repeat a field, None values are skipped and other values are converted with str().

The body is built with the stdlib (tls_client/_multipart.py), uses a random 32-hex-character boundary, and goes to the library as bytes (isByteRequest, base64). For the parts that requests itself builds, the output is byte-for-byte the same as requests 2.34/urllib3 2.8 with a fixed boundary: names and filenames escape ", CR and LF as %22, %0D, %0A, and non-ASCII is sent as UTF-8. Text-mode file objects and str content are encoded to UTF-8. Content-Type: multipart/form-data; boundary=... is set for that one request only, never on session.headers. A Content-Type that the session or the request set explicitly is kept, as in requests, and None sends no Content-Type at all.

files together with json=, or with data= given as str or bytes, raises ValueError. Nothing is sent in that case.

Fixes #35

Differences from requests

  • File parts that have a filename get a Content-Type guessed from it with mimetypes, falling back to application/octet-stream. requests sends no part Content-Type unless the tuple gives one.
  • files with json= raises ValueError. requests silently drops the JSON. files with str/bytes data= raises in both.
  • In the 4-tuple form, a Content-Type in the part headers is used when no content type is given. requests drops it.
  • files={} behaves as if no files were passed, as in requests.

Testing

  • uv run pytest: 363 passed, 7 xfailed (the existing ones). _multipart.py and sessions.py have full line and branch coverage.
  • uv run ruff check, uv run ruff format --check, uv run mypy (strict): clean.
  • New unit tests in tests/unit/test_multipart.py use the fake library with the boundary pinned through monkeypatch. They check the exact body bytes and the Content-Type header, filename and field quoting, content-type guessing, mixing data and files, repeated fields, the random boundary, and the error cases.
  • New integration tests against the local httpbin /post check that the files and form fields come back parsed as expected. bytes(range(256)) comes back intact: httpbin returns it as a data:image/png;base64,... URL, using the type guessed from the filename.
  • Checked by hand against requests.Request(...).prepare().body, with both boundaries pinned, for data fields, repeated fields, quoting, non-ASCII filenames and extra part headers.

🤖 Generated with Claude Code

FlorianREGAZ and others added 3 commits October 10, 2026 13:32
execute_request, post, put and patch take files= now, in the forms
requests accepts: a file object, or (filename, content[, content type[,
part headers]]), as a dict or a list of (name, file) pairs to repeat a
field. Fields of a dict or list data= go into the same body first.

The multipart/form-data body is built with the stdlib under a random
boundary and sent as bytes. Names and filenames are quoted like urllib3
does: ", CR and LF are percent-escaped, non-ASCII is sent as UTF-8.
File objects in text mode and str content are encoded to UTF-8. Parts
with a filename get a Content-Type guessed from it, falling back to
application/octet-stream.

The multipart Content-Type is set for the one request only, and like
the other inferred types it gives way to one the session or request
set. files with json= or str/bytes data= raise ValueError.

Fixes #35

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A file object, str content and a plain (None, value) field next to
data= fields arrive parsed as files and form. bytes(range(256)) comes
back intact; httpbin returns it as a data URL carrying the image/png
type guessed from the filename.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The module sits next to cffi.py, cookies.py and structures.py, none of which
use a private prefix. The httpbin test now writes its file in binary, so
Windows doesn't turn the newline into CRLF.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@FlorianREGAZ
FlorianREGAZ merged commit 7a98ca0 into main Oct 10, 2026
30 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature request] add files param to requests

1 participant