Skip to content

Commit 26faf21

Browse files
Add multipart file upload support to create() and update() (#16)
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 26faf21

6 files changed

Lines changed: 218 additions & 19 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: 45 additions & 18 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,15 +222,7 @@ def create(
211222
logger.info(f"API Create Operation to {url}")
212223

213224
self._check_auth()
214-
if self.serialize:
215-
response = self.manager.request(
216-
method, url + safe_params, headers=self.request_headers, json=data
217-
)
218-
else:
219-
response = self.manager.request(
220-
method, url + safe_params, body=data, headers=self.request_headers
221-
)
222-
225+
response = self._request_with_data(method, url + safe_params, data, files)
223226
return self._process_resp(method, response)
224227

225228
def read(
@@ -259,6 +262,7 @@ def update(
259262
data: dict[Any, Any] | str,
260263
params: dict[Any, Any] | None = None,
261264
replace: bool = False,
265+
files: dict[str, _FieldValue] | None = None,
262266
) -> dict[Any, Any] | bytes:
263267
"""
264268
Makes a basic Update request to the API, and returns the response.
@@ -267,6 +271,10 @@ def update(
267271
can be either a dictionary that is serialised to JSON or bytes and
268272
strings that will be sent without serialisation.
269273
274+
When files is provided, the request is sent as multipart/form-data
275+
using urllib3's fields parameter. This takes precedence over data
276+
serialisation.
277+
270278
For PUT requests parameters are encoded into the URL.
271279
https://urllib3.readthedocs.io/en/stable/user-guide.html#query-parameters
272280
@@ -280,6 +288,10 @@ def update(
280288
Parameters to be added to the URI
281289
replace : bool, optional
282290
Requests a full replacement of the entire entity. Uses PUT Method.
291+
files : dict, optional
292+
Multipart form fields passed as urllib3 fields. Values can be
293+
plain strings/bytes for form data, (filename, data) tuples, or
294+
(filename, data, content_type) tuples for file uploads.
283295
284296
Returns
285297
-------
@@ -291,15 +303,7 @@ def update(
291303
logger.info(f"API Update Operation to {url}")
292304

293305
self._check_auth()
294-
if self.serialize:
295-
response = self.manager.request(
296-
method, url + safe_params, headers=self.request_headers, json=data
297-
)
298-
else:
299-
response = self.manager.request(
300-
method, url + safe_params, body=data, headers=self.request_headers
301-
)
302-
306+
response = self._request_with_data(method, url + safe_params, data, files)
303307
return self._process_resp(method, response)
304308

305309
def delete(
@@ -329,6 +333,29 @@ def delete(
329333
)
330334
return self._process_resp(method, response)
331335

336+
def _request_with_data(
337+
self,
338+
method: str,
339+
url: str,
340+
data: Any,
341+
files: dict[str, _FieldValue] | None,
342+
) -> urllib3.response.BaseHTTPResponse:
343+
"""
344+
Dispatches an HTTP request with a body payload. Multipart fields take
345+
precedence, then JSON serialisation, then raw body.
346+
"""
347+
if files is not None:
348+
return self.manager.request(
349+
method, url, fields=files, headers=self.request_headers
350+
)
351+
if self.serialize:
352+
return self.manager.request(
353+
method, url, headers=self.request_headers, json=data
354+
)
355+
return self.manager.request(
356+
method, url, body=data, headers=self.request_headers
357+
)
358+
332359
def _process_resp(
333360
self,
334361
method: str,

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)