Skip to content

Commit 3003d7d

Browse files
committed
Add multipart file upload support to create() and update()
Adds a `files` parameter to `Client.create()` and `Client.update()` that enables multipart/form-data uploads via urllib3's `fields` parameter. When provided, `files` takes precedence over `data` serialisation. Existing behaviour is unchanged when `files` is not provided. Includes user guide documentation, changelog entry, 7 new tests (100% coverage on core.py), and version bump to 1.6.0. Made-with: Cursor
1 parent 334724f commit 3003d7d

6 files changed

Lines changed: 209 additions & 3 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ users = cruds.Client("api.example.com", auth="token").read("users")
7272
- **Authentication** — Bearer tokens, username/password, and OAuth2 (Client
7373
Credentials, Resource Owner Password, Authorization Code with CSRF protection)
7474
- **JSON Serialization** — Send and receive Python dicts and lists directly
75+
- **Multipart File Uploads** — Upload files with `multipart/form-data` via a
76+
simple `files` parameter
7577
- **Retries with backoff** — Configurable retry count, backoff factor, and
7678
status codes (429, 500–504, etc.)
7779
- **Error handling** — Automatic exceptions for 4xx/5xx responses

docs/changelog.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
Changelog
22
=========
33

4+
Release 1.6.0 (March 10, 2026)
5+
-------------------------------
6+
7+
Features:
8+
- Added multipart file upload support to ``create()`` and ``update()`` methods
9+
via a new ``files`` parameter. Accepts urllib3 field tuples for
10+
``multipart/form-data`` uploads.
11+
412
Release 1.5.0 (February 20, 2026)
513
----------------------------------
614

docs/user_guide.rst

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,63 @@ as data, and the return is bytes type data.
474474
If there is a need to expand on the SerDes content types, please raise a
475475
issue in the Github repository so the project is aware of it.
476476

477+
Multipart File Uploads
478+
----------------------
479+
480+
The ``create()`` and ``update()`` methods support multipart file uploads via the
481+
``files`` parameter. When provided, the request is sent as ``multipart/form-data``
482+
using urllib3's ``fields`` parameter — no manual encoding is needed.
483+
484+
The ``files`` dictionary values follow urllib3's field tuple format:
485+
486+
- ``(filename, data)`` — file upload with auto-detected MIME type
487+
- ``(filename, data, content_type)`` — file upload with explicit MIME type
488+
- ``str`` or ``bytes`` — plain form field (for mixing form data with files)
489+
490+
.. code-block:: python
491+
492+
import cruds
493+
494+
api = cruds.Client("https://api.example.com", auth="your-token")
495+
496+
# Upload a CSV file
497+
api.create(
498+
"upload/endpoint",
499+
data=None,
500+
files={"file": ("data.csv", csv_bytes, "text/csv")},
501+
)
502+
503+
# Upload without specifying MIME type (auto-detected)
504+
api.create(
505+
"upload/endpoint",
506+
data=None,
507+
files={"file": ("report.pdf", pdf_bytes)},
508+
)
509+
510+
# Mix form fields with file uploads
511+
api.create(
512+
"upload/endpoint",
513+
data=None,
514+
files={
515+
"description": "Monthly report",
516+
"file": ("report.pdf", pdf_bytes, "application/pdf"),
517+
},
518+
)
519+
520+
When ``files`` is provided it takes precedence over ``data`` and the ``serialize``
521+
setting. When ``files`` is not provided (the default), the existing behaviour is
522+
unchanged.
523+
524+
The ``update()`` method works the same way:
525+
526+
.. code-block:: python
527+
528+
api.update(
529+
"documents/123",
530+
data=None,
531+
files={"file": ("updated.csv", new_csv_bytes, "text/csv")},
532+
)
533+
477534
Retries
478535
-------
479536

src/cruds/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,6 @@
2828
from .core import Client
2929

3030
__author__: str = "John Brandborg"
31-
__version__: str = "1.5.0"
31+
__version__: str = "1.6.0"
3232

3333
__all__: list = ["Client", "auth"]

src/cruds/core.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616

1717
DEFAULT_TIMEOUT: Final = 300.0
1818

19+
_FieldValue = str | bytes | tuple[str, str | bytes] | tuple[str, str | bytes, str]
20+
1921

2022
class AuthABC(metaclass=abc.ABCMeta):
2123
"""
@@ -181,6 +183,7 @@ def create(
181183
uri: str,
182184
data: dict,
183185
params: dict[Any, Any] | None = None,
186+
files: dict[str, _FieldValue] | None = None,
184187
) -> dict[Any, Any] | bytes:
185188
"""
186189
Makes a basic Create request to the API, and returns the response.
@@ -189,6 +192,10 @@ def create(
189192
that is serialised to JSON or bytes and strings that will be sent
190193
without serialisation.
191194
195+
When files is provided, the request is sent as multipart/form-data
196+
using urllib3's fields parameter. This takes precedence over data
197+
serialisation.
198+
192199
For POST requests parameters are encoded into the URL.
193200
https://urllib3.readthedocs.io/en/stable/user-guide.html#query-parameters
194201
@@ -200,6 +207,10 @@ def create(
200207
Payload to be sent to the API
201208
params : dict, optional
202209
Parameters to be added to the URI
210+
files : dict, optional
211+
Multipart form fields passed as urllib3 fields. Values can be
212+
plain strings/bytes for form data, (filename, data) tuples, or
213+
(filename, data, content_type) tuples for file uploads.
203214
204215
Returns
205216
-------
@@ -211,7 +222,14 @@ def create(
211222
logger.info(f"API Create Operation to {url}")
212223

213224
self._check_auth()
214-
if self.serialize:
225+
if files is not None:
226+
response = self.manager.request(
227+
method,
228+
url + safe_params,
229+
fields=files,
230+
headers=self.request_headers,
231+
)
232+
elif self.serialize:
215233
response = self.manager.request(
216234
method, url + safe_params, headers=self.request_headers, json=data
217235
)
@@ -259,6 +277,7 @@ def update(
259277
data: dict[Any, Any] | str,
260278
params: dict[Any, Any] | None = None,
261279
replace: bool = False,
280+
files: dict[str, _FieldValue] | None = None,
262281
) -> dict[Any, Any] | bytes:
263282
"""
264283
Makes a basic Update request to the API, and returns the response.
@@ -267,6 +286,10 @@ def update(
267286
can be either a dictionary that is serialised to JSON or bytes and
268287
strings that will be sent without serialisation.
269288
289+
When files is provided, the request is sent as multipart/form-data
290+
using urllib3's fields parameter. This takes precedence over data
291+
serialisation.
292+
270293
For PUT requests parameters are encoded into the URL.
271294
https://urllib3.readthedocs.io/en/stable/user-guide.html#query-parameters
272295
@@ -280,6 +303,10 @@ def update(
280303
Parameters to be added to the URI
281304
replace : bool, optional
282305
Requests a full replacement of the entire entity. Uses PUT Method.
306+
files : dict, optional
307+
Multipart form fields passed as urllib3 fields. Values can be
308+
plain strings/bytes for form data, (filename, data) tuples, or
309+
(filename, data, content_type) tuples for file uploads.
283310
284311
Returns
285312
-------
@@ -291,7 +318,14 @@ def update(
291318
logger.info(f"API Update Operation to {url}")
292319

293320
self._check_auth()
294-
if self.serialize:
321+
if files is not None:
322+
response = self.manager.request(
323+
method,
324+
url + safe_params,
325+
fields=files,
326+
headers=self.request_headers,
327+
)
328+
elif self.serialize:
295329
response = self.manager.request(
296330
method, url + safe_params, headers=self.request_headers, json=data
297331
)

tests/test_core.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,111 @@ def test_Client_delete_operation(crud_api):
208208
assert resp.data == b'{"name": "test"}'
209209

210210

211+
def test_Client_create_operation_with_files(crud_api):
212+
"""Check that create() sends multipart form data when files is provided."""
213+
files = {"file": ("data.csv", b"a,b,c\n1,2,3", "text/csv")}
214+
resp = crud_api.create("upload/endpoint", data=None, files=files)
215+
216+
crud_api.manager.request.assert_called_with(
217+
"POST",
218+
"https://localhost/upload/endpoint",
219+
fields=files,
220+
headers=request_headers,
221+
)
222+
assert resp.data == b'{"name": "test"}'
223+
224+
225+
def test_Client_create_operation_with_files_two_tuple(crud_api):
226+
"""Check that create() accepts 2-tuple (filename, data) without MIME type."""
227+
files = {"file": ("data.csv", b"a,b,c\n1,2,3")}
228+
resp = crud_api.create("upload/endpoint", data=None, files=files)
229+
230+
crud_api.manager.request.assert_called_with(
231+
"POST",
232+
"https://localhost/upload/endpoint",
233+
fields=files,
234+
headers=request_headers,
235+
)
236+
assert resp.data == b'{"name": "test"}'
237+
238+
239+
def test_Client_create_operation_with_files_mixed_fields(crud_api):
240+
"""Check that files dict can mix plain form fields with file tuples."""
241+
files = {
242+
"description": "uploaded file",
243+
"file": ("data.csv", b"a,b,c\n1,2,3", "text/csv"),
244+
}
245+
resp = crud_api.create("upload/endpoint", data=None, files=files)
246+
247+
crud_api.manager.request.assert_called_with(
248+
"POST",
249+
"https://localhost/upload/endpoint",
250+
fields=files,
251+
headers=request_headers,
252+
)
253+
assert resp.data == b'{"name": "test"}'
254+
255+
256+
def test_Client_create_operation_files_takes_precedence(crud_api):
257+
"""Check that files takes precedence over data when both are provided."""
258+
files = {"file": ("doc.pdf", b"%PDF-content", "application/pdf")}
259+
sample = {"should": "be ignored"}
260+
resp = crud_api.create("upload/endpoint", data=sample, files=files)
261+
262+
crud_api.manager.request.assert_called_with(
263+
"POST",
264+
"https://localhost/upload/endpoint",
265+
fields=files,
266+
headers=request_headers,
267+
)
268+
assert resp.data == b'{"name": "test"}'
269+
270+
271+
def test_Client_update_operation_with_files(crud_api):
272+
"""Check that update() sends multipart form data when files is provided."""
273+
files = {"file": ("data.csv", b"a,b,c\n1,2,3", "text/csv")}
274+
resp = crud_api.update("upload/endpoint", data=None, files=files)
275+
276+
crud_api.manager.request.assert_called_with(
277+
"PATCH",
278+
"https://localhost/upload/endpoint",
279+
fields=files,
280+
headers=request_headers,
281+
)
282+
assert resp.data == b'{"name": "test"}'
283+
284+
285+
def test_Client_update_operation_files_takes_precedence(crud_api):
286+
"""Check that files takes precedence over data when both are provided."""
287+
files = {"file": ("doc.pdf", b"%PDF-content", "application/pdf")}
288+
sample = {"should": "be ignored"}
289+
resp = crud_api.update("upload/endpoint", data=sample, files=files)
290+
291+
crud_api.manager.request.assert_called_with(
292+
"PATCH",
293+
"https://localhost/upload/endpoint",
294+
fields=files,
295+
headers=request_headers,
296+
)
297+
assert resp.data == b'{"name": "test"}'
298+
299+
300+
def test_Client_update_operation_with_files_and_replace(crud_api):
301+
"""Check that update() with files and replace=True uses PUT method."""
302+
files = {"file": ("data.csv", b"a,b,c\n1,2,3", "text/csv")}
303+
resp = crud_api.update(
304+
"upload/endpoint", data=None, files=files, replace=True
305+
)
306+
307+
crud_api.manager.request.assert_called_with(
308+
"PUT",
309+
"https://localhost/upload/endpoint",
310+
fields=files,
311+
headers=request_headers,
312+
)
313+
assert resp.data == b'{"name": "test"}'
314+
315+
211316
def test_Client_process_resp_return_bytes():
212317
"""
213318
Check the response processing returns bytes for non-JSON content.

0 commit comments

Comments
 (0)