diff --git a/.github/workflows/pythonapp.yml b/.github/workflows/pythonapp.yml
index 37d08c0..1226b4c 100644
--- a/.github/workflows/pythonapp.yml
+++ b/.github/workflows/pythonapp.yml
@@ -12,6 +12,13 @@ jobs:
matrix:
os: [ ubuntu-latest, macos-latest, windows-latest ]
python-version: [ 3.8, 3.12 ]
+ # Python 3.8 is not available anymore for ubuntu-latest (Ubuntu 26.04)
+ exclude:
+ - os: ubuntu-latest
+ python-version: 3.8
+ include:
+ - os: ubuntu-24.04
+ python-version: 3.8
steps:
- uses: actions/checkout@v4
- name: Set up Python
diff --git a/README.md b/README.md
index a756bd6..27c2b47 100644
--- a/README.md
+++ b/README.md
@@ -101,6 +101,36 @@ Post Request Test
Status Should Be 201 ${resp}
```
+### 📎 Multipart form data upload
+
+`Create Multipart Data` builds a `multipart/form-data` body, the format used by HTML forms and most
+APIs to upload files, to pass as `data` to `POST On Session`, `PUT On Session` or `PATCH On Session`.
+Files are streamed from disk, so large files are not loaded in memory.
+It needs an optional dependency: `pip install "robotframework-requests[multipart]"`
+
+- `fields`: dictionary of `name=value`, or list of `(name, value)` pairs to repeat a name.
+ Dictionary and list values are sent as JSON.
+- `files`: dictionary of `name=path`, or list of `(name, path[, content_type[, filename]])`,
+ `(name, [paths])` or `{name, path, content_type, filename}` dictionaries.
+ The file name and content type are taken from the path when not given.
+- `files_first=${True}` sends the files before the fields.
+- The `Content-Type` header with its boundary is set for you, session headers such as an
+ `Authorization` token are kept.
+
+```robotframework
+Upload Report Test
+ ${headers}= Create Dictionary Authorization=Bearer ${token}
+ Create Session api https://example.com headers=${headers}
+ ${settings}= Create Dictionary everything=${True}
+ ${fields}= Create Dictionary action=add settings=${settings}
+ ${files}= Create Dictionary document=${CURDIR}/report.pdf
+ ${multipart}= Create Multipart Data fields=${fields} files=${files}
+ ${resp}= POST On Session api /upload data=${multipart}
+```
+
+See the [Create Multipart Data](http://marketsquare.github.io/robotframework-requests/doc/RequestsLibrary.html#Create%20Multipart%20Data)
+keyword documentation for all the formats and examples.
+
### 📖 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..0ed0330 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,126 @@ 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}
+ # raw bytes: Get File would turn the CRLF line endings of a Windows checkout into LF
+ ${expected}= Get Binary File ${CURDIR}${/}data.json
+ Should Be Equal ${resp.json()}[files][document] ${expected.decode('utf-8')}
+
+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}
+
+Test Post Multipart Data Is Sent Whole Again After A 307 Redirect
+ ${fields}= Create Dictionary action=add
+ ${files}= Create Dictionary document=${CURDIR}${/}data.json
+ ${multipart}= Create Multipart Data fields=${fields} files=${files}
+
+ ${resp}= POST On Session ${GLOBAL_SESSION} /redirect-to params=url=/anything&status_code=307 data=${multipart}
+
+ Should Be Equal As Strings ${resp.history[0].status_code} 307
+ Should Be Equal ${resp.json()}[form][action] add
+ ${expected}= Get Binary File ${CURDIR}${/}data.json
+ Should Be Equal ${resp.json()}[files][document] ${expected.decode('utf-8')}
+
+Test Post Multipart Data With Files Described By Dictionaries
+ ${file}= Create Dictionary name=document path=${CURDIR}${/}data.json
+ ... filename=renamed.json content_type=application/json
+ ${files}= Create List ${file}
+ ${fields}= Create Dictionary count=${3}
+ ${multipart}= Create Multipart Data fields=${fields} files=${files}
+
+ Should Be Equal ${multipart.fields}[1][1][0] renamed.json
+ Should Be Equal ${multipart.fields}[1][1][2] application/json
+
+ ${resp}= POST On Session ${GLOBAL_SESSION} /anything data=${multipart}
+
+ Should Be Equal ${resp.json()}[form][count] 3
+ ${expected}= Get Binary File ${CURDIR}${/}data.json
+ Should Be Equal ${resp.json()}[files][document] ${expected.decode('utf-8')}
+
+Test Create Multipart Data Fails Clearly With Invalid Arguments
+ Run Keyword And Expect Error ValueError: Multipart fields must be a dictionary or a list of (name, value) pairs*
+ ... Create Multipart Data fields=${{["not a pair"]}}
+ Run Keyword And Expect Error ValueError: Multipart files must be (name, path*
+ ... Create Multipart Data files=${{[("document",)]}}
+ Run Keyword And Expect Error ValueError: Multipart file needs a name and a path*
+ ... Create Multipart Data files=${{[{"name": "document"}]}}
+ ${existing}= Create List document ${CURDIR}${/}data.json
+ ${missing}= Create List missing ${CURDIR}${/}does_not_exist.bin
+ Run Keyword And Expect Error FileNotFoundError: *does_not_exist.bin*
+ ... Create Multipart Data files=${{[$existing, $missing]}}
diff --git a/doc/RequestsLibrary.html b/doc/RequestsLibrary.html
index bd46f5a..ee2ab60 100644
--- a/doc/RequestsLibrary.html
+++ b/doc/RequestsLibrary.html
@@ -6,7 +6,7 @@
-
+