Skip to content

Commit

Permalink
feat: Add support for uploading files as a list
Browse files Browse the repository at this point in the history
Uploading a list of files with the same key is supported by the Requests library.
This allows the receiving server to accept a list of files for the same key, i.e. accepting any number of files.
Issue: #401
  • Loading branch information
vanhanit authored and Thomas Vanhaniemi committed Oct 30, 2024
1 parent 1e34855 commit 0fadd9f
Show file tree
Hide file tree
Showing 5 changed files with 129 additions and 54 deletions.
19 changes: 10 additions & 9 deletions atests/http_server/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,15 +110,16 @@ def get_files():

files = dict()

for k, v in request.files.items():
content_type = request.files[k].content_type or 'application/octet-stream'
val = json_safe(v.read(), content_type)
if files.get(k):
if not isinstance(files[k], list):
files[k] = [files[k]]
files[k].append(val)
else:
files[k] = val
for k in request.files.keys():
for v in request.files.getlist(k):
content_type = v.content_type or 'application/octet-stream'
val = json_safe(v.read(), content_type)
if files.get(k):
if not isinstance(files[k], list):
files[k] = [files[k]]
files[k].append(val)
else:
files[k] = val

return files

Expand Down
30 changes: 29 additions & 1 deletion atests/test_post_multipart.robot
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,42 @@ Library RequestsLibrary


*** Test Cases ***
Test Post On Session Multipart
Test Post Dictionary On Session Multipart
${file_1}= Get File For Streaming Upload atests/randombytes.bin
${file_2}= Get File For Streaming Upload atests/randombytes.bin
${files}= Create Dictionary randombytes1 ${file_1} randombytes2 ${file_2}

Should Not Be True ${file_1.closed}
Should Not Be True ${file_2.closed}

${resp}= POST On Session ${GLOBAL_SESSION} /anything files=${files}

Should Be True ${file_1.closed}
Should Be True ${file_2.closed}

Should Contain ${resp.json()}[headers][Content-Type] multipart/form-data; boundary=
Should Contain ${resp.json()}[headers][Content-Length] 480
Should Contain ${resp.json()}[files] randombytes1
Should Contain ${resp.json()}[files] randombytes2

Test Post List On Session Multipart
${file_1}= Get File For Streaming Upload atests/randombytes.bin
${file_2}= Get File For Streaming Upload atests/randombytes.bin
${file_1_tuple}= Create List file1.bin ${file_1}
${file_2_tuple}= Create List file2.bin ${file_2}
${file_1_upload}= Create List randombytes ${file_1_tuple}
${file_2_upload}= Create List randombytes ${file_2_tuple}
${files}= Create List ${file_1_upload} ${file_2_upload}

Should Not Be True ${file_1.closed}
Should Not Be True ${file_2.closed}

${resp}= POST On Session ${GLOBAL_SESSION} /anything files=${files}

Should Be True ${file_1.closed}
Should Be True ${file_2.closed}

Should Contain ${resp.json()}[headers][Content-Type] multipart/form-data; boundary=
Should Contain ${resp.json()}[headers][Content-Length] 466
Should Contain ${resp.json()}[files] randombytes
Length Should Be ${resp.json()}[files][randombytes] 2
104 changes: 66 additions & 38 deletions doc/RequestsLibrary.html

Large diffs are not rendered by default.

28 changes: 22 additions & 6 deletions src/RequestsLibrary/RequestsKeywords.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from RequestsLibrary import log
from RequestsLibrary.compat import urljoin
from RequestsLibrary.utils import (
is_list_or_tuple,
is_file_descriptor,
warn_if_equal_symbol_in_url_session_less,
)
Expand Down Expand Up @@ -48,14 +49,29 @@ def _common_request(self, method, session, uri, **kwargs):

files = kwargs.get("files", {}) or {}
data = kwargs.get("data", []) or []
files_descriptor_to_close = filter(
is_file_descriptor, list(files.values()) + [data]
)
for file_descriptor in files_descriptor_to_close:
file_descriptor.close()

self._close_file_descriptors(files, data)

return resp

@staticmethod
def _close_file_descriptors(files, data):
"""
Helper method that closes any open file descriptors.
"""

if is_list_or_tuple(files):
files_descriptor_to_close = filter(
is_file_descriptor, [file[1][1] for file in files] + [data]
)
else:
files_descriptor_to_close = filter(
is_file_descriptor, list(files.values()) + [data]
)

for file_descriptor in files_descriptor_to_close:
file_descriptor.close()

@staticmethod
def _merge_url(session, uri):
"""
Expand Down Expand Up @@ -176,7 +192,7 @@ def session_less_get(
| ``json`` | A JSON serializable Python object to send in the body of the request. |
| ``headers`` | Dictionary of HTTP Headers to send with the request. |
| ``cookies`` | Dict or CookieJar object to send with the request. |
| ``files`` | Dictionary of file-like-objects (or ``{'name': file-tuple}``) for multipart encoding upload. ``file-tuple`` can be a 2-tuple ``('filename', fileobj)``, 3-tuple ``('filename', fileobj, 'content_type')`` or a 4-tuple ``('filename', fileobj, 'content_type', custom_headers)``, where ``'content-type'`` is a string defining the content type of the given file and ``custom_headers`` a dict-like object containing additional headers to add for the file. |
| ``files`` | Dictionary of file-like-objects (or ``{'name': file-tuple}``) for multipart encoding upload. ``file-tuple`` can be a 2-tuple ``('filename', fileobj)``, 3-tuple ``('filename', fileobj, 'content_type')`` or a 4-tuple ``('filename', fileobj, 'content_type', custom_headers)``, where ``'content-type'`` is a string defining the content type of the given file and ``custom_headers`` a dict-like object containing additional headers to add for the file. List or tuple of ``('key': file-tuple)`` allows uploading multiple files with the same key, resulting in a list of files on the receiving end. |
| ``auth`` | Auth tuple to enable Basic/Digest/Custom HTTP Auth. |
| ``timeout`` | How many seconds to wait for the server to send data before giving up, as a float, or a ``(connect timeout, read timeout)`` tuple. |
| ``allow_redirects`` | Boolean. Enable/disable (values ``${True}`` or ``${False}``). Only for HEAD method keywords allow_redirection defaults to ``${False}``, all others ``${True}``. |
Expand Down
2 changes: 2 additions & 0 deletions src/RequestsLibrary/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ def is_string_type(data):
def is_file_descriptor(fd):
return isinstance(fd, io.IOBase)

def is_list_or_tuple(data):
return isinstance(data, (list, tuple))

def utf8_urlencode(data):
if is_string_type(data):
Expand Down

0 comments on commit 0fadd9f

Please sign in to comment.