From f5cd639ba037f319a22ee9e0dc3f4de7dbbd9d4d Mon Sep 17 00:00:00 2001 From: Florian Morineau Date: Sun, 25 Oct 2026 12:22:31 +0100 Subject: [PATCH 1/7] fix: Log streaming request bodies that don't support len() or slicing Request logging called len() and sliced the body to truncate it, which raised a TypeError with streaming bodies such as a requests_toolbelt MultipartEncoder ("has no len()" or "is not subscriptable" for bodies over the log limit), failing the keyword after the request was sent. Any body that is not str or bytes is now logged with repr(), like file descriptors already were. Fixes #408 Co-Authored-By: Claude Opus 5.5 --- src/RequestsLibrary/log.py | 4 +++- utests/test_log.py | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/RequestsLibrary/log.py b/src/RequestsLibrary/log.py index 09807f4..0c96e46 100644 --- a/src/RequestsLibrary/log.py +++ b/src/RequestsLibrary/log.py @@ -42,7 +42,9 @@ def format_data_to_log_string(data, limit=LOG_CHAR_LIMIT): if not data: return None - if is_file_descriptor(data): + # Streaming bodies (file descriptors, generators, multipart encoders...) + # can't be read without consuming them and may not support len() or slicing + if is_file_descriptor(data) or not isinstance(data, (str, bytes)): return repr(data) if logging.getLogger().level > logging.DEBUG and len(data) > limit: diff --git a/utests/test_log.py b/utests/test_log.py index eda6d75..cf340b0 100644 --- a/utests/test_log.py +++ b/utests/test_log.py @@ -218,3 +218,24 @@ def test_format_data_truncate_info_level(mocked_logger): mocked_logger.DEBUG = 10 truncated = format_data_to_log_string(data) assert truncated == data[:10000] + '... (set the log level to DEBUG or TRACE to see the full content)' + + +def test_format_with_streaming_body_without_len(): + def body(): + yield b'chunk' + data = body() + assert format_data_to_log_string(data) == repr(data) + + +class StreamingBody: + """Like a requests_toolbelt MultipartEncoder: readable, with a length but no slicing""" + def __len__(self): + return 20000 + + def read(self, size=-1): + return b'' + + +def test_format_with_large_streaming_body(): + data = StreamingBody() + assert format_data_to_log_string(data, 10000) == repr(data) From 7a6dac5ee70b1c0e67935e11e280a00bcdbaa719 Mon Sep 17 00:00:00 2001 From: Florian Morineau Date: Sun, 25 Oct 2026 12:22:31 +0100 Subject: [PATCH 2/7] feat: Add Create Multipart Data keyword for streamed multipart/form-data uploads - New `Create Multipart Data` keyword builds a streamed multipart body from form fields and files (repeated names, list of paths for one name, JSON values, filename and content type override or guess, optional files first order), using the optional requests-toolbelt dependency (`pip install robotframework-requests[multipart]`) - The body can be rewound to its start, so session retries (PUT after a connection reset) and redirects resend it whole - Requests keywords set the multipart Content-Type with its boundary automatically, keeping session headers, and close the files held by the encoder once sent - Keyword documentation regenerated Closes #409 Co-Authored-By: Claude Opus 5.5 --- README.md | 13 ++ atests/test_post_multipart.robot | 83 ++++++++ doc/RequestsLibrary.html | 4 +- setup.py | 6 +- src/RequestsLibrary/RequestsKeywords.py | 61 +++++- src/RequestsLibrary/multipart.py | 128 ++++++++++++ src/RequestsLibrary/utils.py | 24 +++ utests/test_multipart.py | 248 ++++++++++++++++++++++++ 8 files changed, 562 insertions(+), 5 deletions(-) create mode 100644 src/RequestsLibrary/multipart.py create mode 100644 utests/test_multipart.py diff --git a/README.md b/README.md index a756bd6..bf356a0 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,19 @@ Post Request Test Status Should Be 201 ${resp} ``` +### 📎 Multipart form data upload + +`Create Multipart Data` builds a streamed `multipart/form-data` body with form fields and files, +it needs an optional dependency: `pip install "robotframework-requests[multipart]"` + +```robotframework +Upload Report Test + ${fields}= Create Dictionary name=report action=add + ${files}= Create Dictionary document=${CURDIR}/report.pdf + ${multipart}= Create Multipart Data fields=${fields} files=${files} + ${resp}= POST On Session api /upload data=${multipart} +``` + ### 📖 Keywords documentation Robotframework-requests offers a wide set of keywords which can be found in diff --git a/atests/test_post_multipart.robot b/atests/test_post_multipart.robot index 7b3b094..7478860 100644 --- a/atests/test_post_multipart.robot +++ b/atests/test_post_multipart.robot @@ -1,5 +1,6 @@ *** Settings *** Library RequestsLibrary +Library OperatingSystem *** Test Cases *** @@ -42,3 +43,85 @@ Test Post List On Session Multipart Should Contain ${resp.json()}[headers][Content-Length] 466 Should Contain ${resp.json()}[files] randombytes Length Should Be ${resp.json()}[files][randombytes] 2 + +Test Post Multipart Data With Fields And Files On Session + ${settings}= Create Dictionary everything=${True} + ${fields}= Create Dictionary action=add settings=${settings} + ${files}= Create Dictionary document=${CURDIR}${/}data.json + ${multipart}= Create Multipart Data fields=${fields} files=${files} + ${file}= Set Variable ${multipart.fields}[2][1][1] + + Should Not Be True ${file.closed} + + ${resp}= POST On Session ${GLOBAL_SESSION} /anything data=${multipart} + + Should Be True ${file.closed} + Should Be Equal ${resp.json()}[headers][Content-Type] ${multipart.content_type} + Should Be Equal ${resp.json()}[form][action] add + Should Be Equal ${resp.json()}[form][settings] {"everything": true} + ${expected}= Get File ${CURDIR}${/}data.json + Should Be Equal ${resp.json()}[files][document] ${expected} + +Test Put Multipart Data With Repeated Fields And Files On Session + ${tag_1}= Create List tag one + ${tag_2}= Create List tag two + ${fields}= Create List ${tag_1} ${tag_2} + ${file_1}= Create List attachments ${CURDIR}${/}data.json + ${file_2}= Create List attachments ${CURDIR}${/}index.html text/html renamed.html + ${files}= Create List ${file_1} ${file_2} + ${multipart}= Create Multipart Data fields=${fields} files=${files} + + ${resp}= PUT On Session ${GLOBAL_SESSION} /anything data=${multipart} + + Should Contain ${resp.json()}[headers][Content-Type] multipart/form-data; boundary= + Should Be Equal ${resp.json()}[form][tag] ${{['one', 'two']}} + Length Should Be ${resp.json()}[files][attachments] 2 + +Test Post Large Multipart Data With Fields + ${large_file}= Set Variable ${OUTPUT_DIR}${/}large_multipart.bin + Create Binary File ${large_file} ${{os.urandom(50000)}} + ${fields}= Create Dictionary name=large + ${files}= Create Dictionary file=${large_file} + ${multipart}= Create Multipart Data fields=${fields} files=${files} + + ${resp}= POST ${HTTP_LOCAL_SERVER}/anything data=${multipart} + + Should Be Equal ${resp.json()}[form][name] large + Should Be True int($resp.json()['headers']['Content-Length']) > 50000 + [Teardown] Remove File ${large_file} + +Test Post Multipart Data Keeps Explicit Content Type + ${files}= Create Dictionary document=${CURDIR}${/}data.json + ${multipart}= Create Multipart Data files=${files} + ${headers}= Create Dictionary Content-Type=${multipart.content_type}; charset=utf-8 + + ${resp}= POST On Session ${GLOBAL_SESSION} /anything data=${multipart} headers=${headers} + + Should Be Equal ${resp.json()}[headers][Content-Type] ${multipart.content_type}; charset=utf-8 + +Test Post Multipart Data With Files First And A List Of Paths + ${fields}= Create Dictionary action=add + ${paths}= Create List ${CURDIR}${/}data.json ${CURDIR}${/}index.html + ${attachments}= Create List attachments ${paths} + ${files}= Create List ${attachments} + ${multipart}= Create Multipart Data fields=${fields} files=${files} files_first=${True} + + Should Be Equal ${multipart.fields}[0][0] attachments + Should Be Equal ${multipart.fields}[1][0] attachments + Should Be Equal ${multipart.fields}[2][0] action + + ${resp}= POST On Session ${GLOBAL_SESSION} /anything data=${multipart} + + Should Be Equal ${resp.json()}[form][action] add + Length Should Be ${resp.json()}[files][attachments] 2 + +Test Post Multipart Data Keeps Session Headers + ${session_headers}= Create Dictionary Authorization=Bearer some-token Content-Type=application/json + Create Session bearer_session ${HTTP_LOCAL_SERVER} headers=${session_headers} + ${files}= Create Dictionary document=${CURDIR}${/}data.json + ${multipart}= Create Multipart Data files=${files} + + ${resp}= POST On Session bearer_session /anything data=${multipart} + + Should Be Equal ${resp.json()}[headers][Authorization] Bearer some-token + Should Be Equal ${resp.json()}[headers][Content-Type] ${multipart.content_type} diff --git a/doc/RequestsLibrary.html b/doc/RequestsLibrary.html index bd46f5a..b94b3e0 100644 --- a/doc/RequestsLibrary.html +++ b/doc/RequestsLibrary.html @@ -6,7 +6,7 @@ - +