From ea4983f2331000622bbfd6c74488dd9af1694331 Mon Sep 17 00:00:00 2001 From: William Manley Date: Wed, 25 Mar 2026 21:16:16 +0000 Subject: [PATCH 1/7] MultipartEncoder: Implement `tell()` `requests` will use this when attempting to rewind streams when following 407 and 408 redirects. --- requests_toolbelt/multipart/encoder.py | 33 ++++++++++++++++---------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/requests_toolbelt/multipart/encoder.py b/requests_toolbelt/multipart/encoder.py index 2d539617..045e1c21 100644 --- a/requests_toolbelt/multipart/encoder.py +++ b/requests_toolbelt/multipart/encoder.py @@ -121,6 +121,9 @@ def __init__(self, fields, boundary=None, encoding='utf-8'): # Our buffer self._buffer = CustomBytesIO(encoding=encoding) + # Number of bytes read from the encoder + self._bytes_read = 0 + # Pre-compute each part's headers self._prepare_parts() @@ -304,15 +307,19 @@ def read(self, size=-1): remaining bytes. :returns: bytes """ - if self.finished: - return self._buffer.read(size) - - bytes_to_load = size - if bytes_to_load != -1 and bytes_to_load is not None: - bytes_to_load = self._calculate_load_amount(int(size)) + if not self.finished: + bytes_to_load = size + if bytes_to_load != -1 and bytes_to_load is not None: + bytes_to_load = self._calculate_load_amount(int(size)) + + self._load(bytes_to_load) + string = self._buffer.read(size) + self._bytes_read += len(string) + return string - self._load(bytes_to_load) - return self._buffer.read(size) + def tell(self): + # type: () -> int + return self._bytes_read def IDENTITY(monitor): @@ -377,10 +384,6 @@ def __init__(self, encoder, callback=None): #: Optionally function to call after a read self.callback = callback or IDENTITY - #: Number of bytes already read from the :class:`MultipartEncoder` - #: instance - self.bytes_read = 0 - #: Avoid the same problem in bug #80 self.len = self.encoder.len @@ -394,12 +397,16 @@ def from_fields(cls, fields, boundary=None, encoding='utf-8', def content_type(self): return self.encoder.content_type + @property + def bytes_read(self): + """Number of bytes already read from the :class:`MultipartEncoder` instance.""" + return self.encoder._bytes_read + def to_string(self): return self.read() def read(self, size=-1): string = self.encoder.read(size) - self.bytes_read += len(string) self.callback(self) return string From d9dd06e97d751db506dc83720a953b5a29e5f3bc Mon Sep 17 00:00:00 2001 From: William Manley Date: Wed, 25 Mar 2026 21:50:48 +0000 Subject: [PATCH 2/7] MultipartEncoder: Refactor `smart_truncate` out of `CustomBytesIO` `smart_truncate` only used by `MultipartEncoder`, while `CustomBytesIO` is used in more places. This is a step towards being able to use `tell` and `seek` on more objects to support restarting the stream. --- requests_toolbelt/multipart/encoder.py | 21 +++++++++++---------- tests/test_multipart_encoder.py | 5 +++-- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/requests_toolbelt/multipart/encoder.py b/requests_toolbelt/multipart/encoder.py index 045e1c21..68368454 100644 --- a/requests_toolbelt/multipart/encoder.py +++ b/requests_toolbelt/multipart/encoder.py @@ -189,7 +189,7 @@ def _calculate_load_amount(self, read_size): def _load(self, amount): """Load ``amount`` number of bytes into the buffer.""" - self._buffer.smart_truncate() + smart_truncate(self._buffer) part = self._current_part or self._next_part() while amount == -1 or amount > 0: written = 0 @@ -559,16 +559,17 @@ def append(self, bytes): written = self.write(bytes) return written - def smart_truncate(self): - to_be_read = total_len(self) - already_read = self._get_end() - to_be_read - if already_read >= to_be_read: - old_bytes = self.read() - self.seek(0, 0) - self.truncate() - self.write(old_bytes) - self.seek(0, 0) # We want to be at the beginning +def smart_truncate(buf): + to_be_read = buf.len + already_read = buf.tell() + + if already_read >= to_be_read: + old_bytes = buf.read() + buf.seek(0, 0) + buf.truncate() + buf.write(old_bytes) + buf.seek(0, 0) # We want to be at the beginning class FileWrapper(object): diff --git a/tests/test_multipart_encoder.py b/tests/test_multipart_encoder.py index f864487c..87848a0a 100644 --- a/tests/test_multipart_encoder.py +++ b/tests/test_multipart_encoder.py @@ -6,7 +6,8 @@ import pytest from requests_toolbelt.multipart.encoder import ( - CustomBytesIO, MultipartEncoder, FileFromURLWrapper, FileNotSupportedError) + CustomBytesIO, MultipartEncoder, FileFromURLWrapper, FileNotSupportedError, + smart_truncate) from requests_toolbelt._compat import filepost from . import get_betamax @@ -78,7 +79,7 @@ def test_truncates_intelligently(self): self.instance.write(b'abcdefghijklmnopqrstuvwxyzabcd') # 30 bytes assert self.instance.tell() == 30 self.instance.seek(-10, 2) - self.instance.smart_truncate() + smart_truncate(self.instance) assert self.instance.len == 10 assert self.instance.read() == b'uvwxyzabcd' assert self.instance.tell() == 10 From 68a3b387d47cb85623dfad960bb3e046106a889a Mon Sep 17 00:00:00 2001 From: William Manley Date: Wed, 25 Mar 2026 21:52:14 +0000 Subject: [PATCH 3/7] FileWrapper: Implement `tell` and `seek` ...so we'll be able to use them later to reset the state of the encoder. --- requests_toolbelt/multipart/encoder.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/requests_toolbelt/multipart/encoder.py b/requests_toolbelt/multipart/encoder.py index 68368454..80132f81 100644 --- a/requests_toolbelt/multipart/encoder.py +++ b/requests_toolbelt/multipart/encoder.py @@ -583,6 +583,12 @@ def len(self): def read(self, length=-1): return self.fd.read(length) + def tell(self): + return self.fd.tell() + + def seek(self, offset, whence=0): + return self.fd.seek(offset, whence) + class FileFromURLWrapper(object): """File from URL wrapper. From f51ccfbd3d8fb4bf2db8c07b687da6cacec75c5a Mon Sep 17 00:00:00 2001 From: William Manley Date: Wed, 25 Mar 2026 22:06:19 +0000 Subject: [PATCH 4/7] MultipartEncoder: Implement `.seek(0, 0)` When following a 307 redirect requests will use `seek` to return `data` to its initial position (assuming that `tell` and `seek` are implemented). Without this some of the data may be sent in the initial POST, and then a partial body will be sent after the redirect happens. This is also important for me implementing retrys on POST failure. This way I can simply do `data.seek(0)` and remake the request. If the underlying object doesn't support seek we raise `io.UnsupportedOperation`. requests catches `OSError` in `rewind_body` and will convert that into a `UnrewindableBodyError` as appropriate. --- requests_toolbelt/multipart/encoder.py | 40 +++++++++++++++++++ tests/test_multipart_encoder.py | 54 +++++++++++++++++++++----- 2 files changed, 85 insertions(+), 9 deletions(-) diff --git a/requests_toolbelt/multipart/encoder.py b/requests_toolbelt/multipart/encoder.py index 80132f81..8f3f9821 100644 --- a/requests_toolbelt/multipart/encoder.py +++ b/requests_toolbelt/multipart/encoder.py @@ -130,6 +130,17 @@ def __init__(self, fields, boundary=None, encoding='utf-8'): # Load boundary into buffer self._write_boundary() + def _reset(self): + """Reset the encoder to the beginning.""" + self.finished = False + for part in self.parts: + part.reset() + self._iter_parts = iter(self.parts) + self._current_part = None + self._buffer = CustomBytesIO(encoding=self.encoding) + self._bytes_read = 0 + self._write_boundary() + @property def len(self): """Length of the multipart/form-data body. @@ -321,6 +332,17 @@ def tell(self): # type: () -> int return self._bytes_read + def seek(self, offset, whence=0): + # type: (int, int) -> int + if (offset, whence) == (0, 0): + self._reset() + elif (offset, whence) == (0, self._bytes_read) or (offset, whence) == (0, 1): + pass + else: + raise io.UnsupportedOperation( + "MultipartEncoder only supports seeking to the beginning") + return self.tell() + def IDENTITY(monitor): return monitor @@ -493,6 +515,10 @@ def __init__(self, headers, body): self.body = body self.headers_unread = True self.len = len(self.headers) + total_len(self.body) + try: + self.initial_pos = body.tell() + except (AttributeError, OSError, NotImplementedError): + self.initial_pos = None @classmethod def from_field(cls, field, encoding): @@ -536,6 +562,20 @@ def write_to(self, buffer, size): return written + def reset(self): + """Reset the part to the beginning.""" + if self.headers_unread: + return + if self.initial_pos is None: + raise io.UnsupportedOperation( + "Underlying body object does not support tell(). Cannot reset.") + try: + self.body.seek(self.initial_pos) + except AttributeError as e: + raise io.UnsupportedOperation( + "Underlying body object does not support seek(). Cannot reset.") from e + self.headers_unread = True + class CustomBytesIO(io.BytesIO): def __init__(self, buffer=None, encoding='utf-8'): diff --git a/tests/test_multipart_encoder.py b/tests/test_multipart_encoder.py index 87848a0a..24697cf2 100644 --- a/tests/test_multipart_encoder.py +++ b/tests/test_multipart_encoder.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- import unittest import io +import os +import tempfile import requests @@ -129,6 +131,17 @@ def test_no_content_length_header(self): ) +EXPECTED = ( + b'--this-is-a-boundary\r\n' + b'Content-Disposition: form-data; name="field"\r\n\r\n' + b'value\r\n' + b'--this-is-a-boundary\r\n' + b'Content-Disposition: form-data; name="other_field"\r\n\r\n' + b'other_value\r\n' + b'--this-is-a-boundary--\r\n' +) + + class TestMultipartEncoder(unittest.TestCase): def setUp(self): self.parts = [('field', 'value'), ('other_field', 'other_value')] @@ -136,15 +149,7 @@ def setUp(self): self.instance = MultipartEncoder(self.parts, boundary=self.boundary) def test_to_string(self): - assert self.instance.to_string() == ( - '--this-is-a-boundary\r\n' - 'Content-Disposition: form-data; name="field"\r\n\r\n' - 'value\r\n' - '--this-is-a-boundary\r\n' - 'Content-Disposition: form-data; name="other_field"\r\n\r\n' - 'other_value\r\n' - '--this-is-a-boundary--\r\n' - ).encode() + assert self.instance.to_string() == EXPECTED def test_content_type(self): expected = 'multipart/form-data; boundary=this-is-a-boundary' @@ -203,6 +208,9 @@ def test_reads_file_from_url_wrapper(self): [('field', 'foo'), ('file', FileFromURLWrapper(url, session=s))]) assert m.read() is not None + with pytest.raises(OSError): + m.seek(0, 0) + def test_reads_open_file_objects_with_a_specified_filename(self): with open('setup.py', 'rb') as fd: m = MultipartEncoder( @@ -320,5 +328,33 @@ def test_no_parts(self): output = m.read().decode('utf-8') assert output == '----90967316f8404798963cce746a4f4ef9--\r\n' + def test_seeking(self): + field_data = self.parts[0][1].encode('utf-8') + + tmpfile = tempfile.TemporaryFile() + tmpfile.write(field_data) + tmpfile.seek(0) + parts = self.parts.copy() + parts[0] = (self.parts[0][0], tmpfile) + m = MultipartEncoder(parts, boundary=self.boundary) + + tmpfile = tempfile.TemporaryFile() + gunk = b"Some gunk at the beginning" + tmpfile.write(gunk) + tmpfile.write(field_data) + tmpfile.seek(len(gunk)) + parts = self.parts.copy() + parts[0] = (self.parts[0][0], tmpfile) + m2 = MultipartEncoder(parts, boundary=self.boundary) + + for instance in (self.instance, m, m2): + assert instance.tell() == 0 + assert instance.read() == EXPECTED + # Exhausted: + assert instance.read() == b'' + assert instance.seek(0) == 0 + assert instance.read() == EXPECTED + + if __name__ == '__main__': unittest.main() From 39935c008dfe434fdead9dc80803577d42050de7 Mon Sep 17 00:00:00 2001 From: William Manley Date: Wed, 25 Mar 2026 23:34:19 +0000 Subject: [PATCH 5/7] MultipartEncoder: Implement iteration protocol `requests` records the current position of the data such that it can rewind it when it does a 308 redirect. Unfortunately it will only do this if `hasattr(data, '__iter__')`. So here we implement `__iter__`, even though it will not be called by requests. See https://github.com/psf/requests/blob/bc04dfd6dad4cb02cd92f5daa81eb562d280a761/src/requests/models.py#L519-L543 There is a visible change in behaviour here: If the user has provided a non seekable stream as the body in fields and there is a 308 redirect requests will now raise `UnrewindableBodyError`, rather than silently uploading a incorrect body. --- requests_toolbelt/multipart/encoder.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/requests_toolbelt/multipart/encoder.py b/requests_toolbelt/multipart/encoder.py index 8f3f9821..d2b7d754 100644 --- a/requests_toolbelt/multipart/encoder.py +++ b/requests_toolbelt/multipart/encoder.py @@ -130,6 +130,16 @@ def __init__(self, fields, boundary=None, encoding='utf-8'): # Load boundary into buffer self._write_boundary() + def __iter__(self): + # Need to implement iterator protocol otherwise requests won't set + # `is_stream` to `True` and won't rewind the body on redirects. + return self + + def __next__(self): + if self.finished: + raise StopIteration() + return self.read(8192) + def _reset(self): """Reset the encoder to the beginning.""" self.finished = False From e6ae10ad6e475d139d0c21f46156ae1618e81326 Mon Sep 17 00:00:00 2001 From: William Manley Date: Thu, 26 Mar 2026 12:06:54 +0000 Subject: [PATCH 6/7] Add integration test for `MultipartEncoder` seeking behaviour --- .../cassettes/upload_file_with_redirect.json | 138 ++++++++++++++++++ tests/test_multipart_encoder.py | 19 +++ 2 files changed, 157 insertions(+) create mode 100644 tests/cassettes/upload_file_with_redirect.json diff --git a/tests/cassettes/upload_file_with_redirect.json b/tests/cassettes/upload_file_with_redirect.json new file mode 100644 index 00000000..5324ffbc --- /dev/null +++ b/tests/cassettes/upload_file_with_redirect.json @@ -0,0 +1,138 @@ +{ + "http_interactions": [ + { + "request": { + "body": { + "encoding": "utf-8", + "string": "" + }, + "headers": { + "User-Agent": [ + "python-requests/2.25.1" + ], + "Accept-Encoding": [ + "gzip, deflate" + ], + "Accept": [ + "*/*" + ], + "Connection": [ + "keep-alive" + ], + "Content-Type": [ + "multipart/form-data; boundary=74d6ab2726de4688a16c8c02d8ef3626" + ], + "Content-Length": [ + "223" + ] + }, + "method": "POST", + "uri": "https://httpbin.org/redirect-to?status_code=307&url=/post" + }, + "response": { + "body": { + "encoding": "utf-8", + "string": "" + }, + "headers": { + "Date": [ + "Thu, 26 Mar 2026 12:04:41 GMT" + ], + "Content-Type": [ + "text/html; charset=utf-8" + ], + "Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "Server": [ + "gunicorn/19.9.0" + ], + "Location": [ + "/post" + ], + "Access-Control-Allow-Origin": [ + "*" + ], + "Access-Control-Allow-Credentials": [ + "true" + ] + }, + "status": { + "code": 307, + "message": "TEMPORARY REDIRECT" + }, + "url": "https://httpbin.org/redirect-to?status_code=307&url=/post" + }, + "recorded_at": "2026-03-26T12:04:41" + }, + { + "request": { + "body": { + "encoding": "utf-8", + "string": "" + }, + "headers": { + "User-Agent": [ + "python-requests/2.25.1" + ], + "Accept-Encoding": [ + "gzip, deflate" + ], + "Accept": [ + "*/*" + ], + "Connection": [ + "keep-alive" + ], + "Content-Type": [ + "multipart/form-data; boundary=74d6ab2726de4688a16c8c02d8ef3626" + ], + "Content-Length": [ + "223" + ] + }, + "method": "POST", + "uri": "https://httpbin.org/post" + }, + "response": { + "body": { + "encoding": "utf-8", + "string": "{\n \"args\": {}, \n \"data\": \"\", \n \"files\": {}, \n \"form\": {\n \"field\": \"foo\", \n \"myfile\": \"from-file\"\n }, \n \"headers\": {\n \"Accept\": \"*/*\", \n \"Accept-Encoding\": \"gzip, deflate\", \n \"Content-Length\": \"223\", \n \"Content-Type\": \"multipart/form-data; boundary=74d6ab2726de4688a16c8c02d8ef3626\", \n \"Host\": \"httpbin.org\", \n \"User-Agent\": \"python-requests/2.25.1\", \n \"X-Amzn-Trace-Id\": \"Root=1-69c520d9-04aad22c2f49f14e6e3cb2b6\"\n }, \n \"json\": null, \n \"origin\": \"62.64.204.18\", \n \"url\": \"https://httpbin.org/post\"\n}\n" + }, + "headers": { + "Date": [ + "Thu, 26 Mar 2026 12:04:41 GMT" + ], + "Content-Type": [ + "application/json" + ], + "Content-Length": [ + "537" + ], + "Connection": [ + "keep-alive" + ], + "Server": [ + "gunicorn/19.9.0" + ], + "Access-Control-Allow-Origin": [ + "*" + ], + "Access-Control-Allow-Credentials": [ + "true" + ] + }, + "status": { + "code": 200, + "message": "OK" + }, + "url": "https://httpbin.org/post" + }, + "recorded_at": "2026-03-26T12:04:41" + } + ], + "recorded_with": "betamax/0.8.1" +} diff --git a/tests/test_multipart_encoder.py b/tests/test_multipart_encoder.py index 24697cf2..126cb917 100644 --- a/tests/test_multipart_encoder.py +++ b/tests/test_multipart_encoder.py @@ -355,6 +355,25 @@ def test_seeking(self): assert instance.seek(0) == 0 assert instance.read() == EXPECTED + def test_redirect(self): + """Verifies integration with requests.""" + tmpfile = tempfile.TemporaryFile() + tmpfile.write(b'from-file') + tmpfile.seek(0) + + m = MultipartEncoder([('field', 'foo'), ('myfile', tmpfile)]) + s = requests.Session() + recorder = get_betamax(s) + with recorder.use_cassette( + 'upload_file_with_redirect'): + resp = s.post( + 'https://httpbin.org/redirect-to?status_code=307&url=/post', + data=m, headers={'Content-Type': m.content_type}) + resp.raise_for_status() + print(resp.json()) + assert resp.json()['form']['myfile'] == 'from-file' + assert resp.json()['form']['field'] == 'foo' + if __name__ == '__main__': unittest.main() From d5df7c4ed0ffd08975b0faa7722f7931839d009e Mon Sep 17 00:00:00 2001 From: William Manley Date: Thu, 26 Mar 2026 12:16:52 +0000 Subject: [PATCH 7/7] TestMultipartEncoder.test_redirect: Don't use betamax It doesn't reproduce the issue. I've left the betamax in as a separate commit as a record of what the request/response would have looked like. --- .../cassettes/upload_file_with_redirect.json | 138 ------------------ tests/test_multipart_encoder.py | 14 +- 2 files changed, 7 insertions(+), 145 deletions(-) delete mode 100644 tests/cassettes/upload_file_with_redirect.json diff --git a/tests/cassettes/upload_file_with_redirect.json b/tests/cassettes/upload_file_with_redirect.json deleted file mode 100644 index 5324ffbc..00000000 --- a/tests/cassettes/upload_file_with_redirect.json +++ /dev/null @@ -1,138 +0,0 @@ -{ - "http_interactions": [ - { - "request": { - "body": { - "encoding": "utf-8", - "string": "" - }, - "headers": { - "User-Agent": [ - "python-requests/2.25.1" - ], - "Accept-Encoding": [ - "gzip, deflate" - ], - "Accept": [ - "*/*" - ], - "Connection": [ - "keep-alive" - ], - "Content-Type": [ - "multipart/form-data; boundary=74d6ab2726de4688a16c8c02d8ef3626" - ], - "Content-Length": [ - "223" - ] - }, - "method": "POST", - "uri": "https://httpbin.org/redirect-to?status_code=307&url=/post" - }, - "response": { - "body": { - "encoding": "utf-8", - "string": "" - }, - "headers": { - "Date": [ - "Thu, 26 Mar 2026 12:04:41 GMT" - ], - "Content-Type": [ - "text/html; charset=utf-8" - ], - "Content-Length": [ - "0" - ], - "Connection": [ - "keep-alive" - ], - "Server": [ - "gunicorn/19.9.0" - ], - "Location": [ - "/post" - ], - "Access-Control-Allow-Origin": [ - "*" - ], - "Access-Control-Allow-Credentials": [ - "true" - ] - }, - "status": { - "code": 307, - "message": "TEMPORARY REDIRECT" - }, - "url": "https://httpbin.org/redirect-to?status_code=307&url=/post" - }, - "recorded_at": "2026-03-26T12:04:41" - }, - { - "request": { - "body": { - "encoding": "utf-8", - "string": "" - }, - "headers": { - "User-Agent": [ - "python-requests/2.25.1" - ], - "Accept-Encoding": [ - "gzip, deflate" - ], - "Accept": [ - "*/*" - ], - "Connection": [ - "keep-alive" - ], - "Content-Type": [ - "multipart/form-data; boundary=74d6ab2726de4688a16c8c02d8ef3626" - ], - "Content-Length": [ - "223" - ] - }, - "method": "POST", - "uri": "https://httpbin.org/post" - }, - "response": { - "body": { - "encoding": "utf-8", - "string": "{\n \"args\": {}, \n \"data\": \"\", \n \"files\": {}, \n \"form\": {\n \"field\": \"foo\", \n \"myfile\": \"from-file\"\n }, \n \"headers\": {\n \"Accept\": \"*/*\", \n \"Accept-Encoding\": \"gzip, deflate\", \n \"Content-Length\": \"223\", \n \"Content-Type\": \"multipart/form-data; boundary=74d6ab2726de4688a16c8c02d8ef3626\", \n \"Host\": \"httpbin.org\", \n \"User-Agent\": \"python-requests/2.25.1\", \n \"X-Amzn-Trace-Id\": \"Root=1-69c520d9-04aad22c2f49f14e6e3cb2b6\"\n }, \n \"json\": null, \n \"origin\": \"62.64.204.18\", \n \"url\": \"https://httpbin.org/post\"\n}\n" - }, - "headers": { - "Date": [ - "Thu, 26 Mar 2026 12:04:41 GMT" - ], - "Content-Type": [ - "application/json" - ], - "Content-Length": [ - "537" - ], - "Connection": [ - "keep-alive" - ], - "Server": [ - "gunicorn/19.9.0" - ], - "Access-Control-Allow-Origin": [ - "*" - ], - "Access-Control-Allow-Credentials": [ - "true" - ] - }, - "status": { - "code": 200, - "message": "OK" - }, - "url": "https://httpbin.org/post" - }, - "recorded_at": "2026-03-26T12:04:41" - } - ], - "recorded_with": "betamax/0.8.1" -} diff --git a/tests/test_multipart_encoder.py b/tests/test_multipart_encoder.py index 126cb917..edece9c4 100644 --- a/tests/test_multipart_encoder.py +++ b/tests/test_multipart_encoder.py @@ -362,13 +362,13 @@ def test_redirect(self): tmpfile.seek(0) m = MultipartEncoder([('field', 'foo'), ('myfile', tmpfile)]) - s = requests.Session() - recorder = get_betamax(s) - with recorder.use_cassette( - 'upload_file_with_redirect'): - resp = s.post( - 'https://httpbin.org/redirect-to?status_code=307&url=/post', - data=m, headers={'Content-Type': m.content_type}) + # Can't use betamax here - it responds too quickly and requests doesn't + # have time to start reading from the MultipartEncoder before the + # redirect response is returned - so the seek never happens. + resp = requests.post( + 'https://httpbin.org/redirect-to?status_code=307&url=/post', + data=m, headers={'Content-Type': m.content_type}, + timeout=10) resp.raise_for_status() print(resp.json()) assert resp.json()['form']['myfile'] == 'from-file'