Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ users = cruds.Client("api.example.com", auth="token").read("users")
- **Authentication** — Bearer tokens, username/password, and OAuth2 (Client
Credentials, Resource Owner Password, Authorization Code with CSRF protection)
- **JSON Serialization** — Send and receive Python dicts and lists directly
- **Multipart File Uploads** — Upload files with `multipart/form-data` via a
simple `files` parameter
- **Retries with backoff** — Configurable retry count, backoff factor, and
status codes (429, 500–504, etc.)
- **Error handling** — Automatic exceptions for 4xx/5xx responses
Expand Down
8 changes: 8 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
Changelog
=========

Release 1.6.0 (March 10, 2026)
-------------------------------

Features:
- Added multipart file upload support to ``create()`` and ``update()`` methods
via a new ``files`` parameter. Accepts urllib3 field tuples for
``multipart/form-data`` uploads.

Release 1.5.0 (February 20, 2026)
----------------------------------

Expand Down
57 changes: 57 additions & 0 deletions docs/user_guide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,63 @@ as data, and the return is bytes type data.
If there is a need to expand on the SerDes content types, please raise a
issue in the Github repository so the project is aware of it.

Multipart File Uploads
----------------------

The ``create()`` and ``update()`` methods support multipart file uploads via the
``files`` parameter. When provided, the request is sent as ``multipart/form-data``
using urllib3's ``fields`` parameter — no manual encoding is needed.

The ``files`` dictionary values follow urllib3's field tuple format:

- ``(filename, data)`` — file upload with auto-detected MIME type
- ``(filename, data, content_type)`` — file upload with explicit MIME type
- ``str`` or ``bytes`` — plain form field (for mixing form data with files)

.. code-block:: python

import cruds

api = cruds.Client("https://api.example.com", auth="your-token")

# Upload a CSV file
api.create(
"upload/endpoint",
data=None,
files={"file": ("data.csv", csv_bytes, "text/csv")},
)

# Upload without specifying MIME type (auto-detected)
api.create(
"upload/endpoint",
data=None,
files={"file": ("report.pdf", pdf_bytes)},
)

# Mix form fields with file uploads
api.create(
"upload/endpoint",
data=None,
files={
"description": "Monthly report",
"file": ("report.pdf", pdf_bytes, "application/pdf"),
},
)

When ``files`` is provided it takes precedence over ``data`` and the ``serialize``
setting. When ``files`` is not provided (the default), the existing behaviour is
unchanged.

The ``update()`` method works the same way:

.. code-block:: python

api.update(
"documents/123",
data=None,
files={"file": ("updated.csv", new_csv_bytes, "text/csv")},
)

Retries
-------

Expand Down
2 changes: 1 addition & 1 deletion src/cruds/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,6 @@
from .core import Client

__author__: str = "John Brandborg"
__version__: str = "1.5.0"
__version__: str = "1.6.0"

__all__: list = ["Client", "auth"]
63 changes: 45 additions & 18 deletions src/cruds/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

DEFAULT_TIMEOUT: Final = 300.0

_FieldValue = str | bytes | tuple[str, str | bytes] | tuple[str, str | bytes, str]


class AuthABC(metaclass=abc.ABCMeta):
"""
Expand Down Expand Up @@ -181,6 +183,7 @@ def create(
uri: str,
data: dict,
params: dict[Any, Any] | None = None,
files: dict[str, _FieldValue] | None = None,
) -> dict[Any, Any] | bytes:
"""
Makes a basic Create request to the API, and returns the response.
Expand All @@ -189,6 +192,10 @@ def create(
that is serialised to JSON or bytes and strings that will be sent
without serialisation.

When files is provided, the request is sent as multipart/form-data
using urllib3's fields parameter. This takes precedence over data
serialisation.

For POST requests parameters are encoded into the URL.
https://urllib3.readthedocs.io/en/stable/user-guide.html#query-parameters

Expand All @@ -200,6 +207,10 @@ def create(
Payload to be sent to the API
params : dict, optional
Parameters to be added to the URI
files : dict, optional
Multipart form fields passed as urllib3 fields. Values can be
plain strings/bytes for form data, (filename, data) tuples, or
(filename, data, content_type) tuples for file uploads.

Returns
-------
Expand All @@ -211,15 +222,7 @@ def create(
logger.info(f"API Create Operation to {url}")

self._check_auth()
if self.serialize:
response = self.manager.request(
method, url + safe_params, headers=self.request_headers, json=data
)
else:
response = self.manager.request(
method, url + safe_params, body=data, headers=self.request_headers
)

response = self._request_with_data(method, url + safe_params, data, files)
return self._process_resp(method, response)

def read(
Expand Down Expand Up @@ -259,6 +262,7 @@ def update(
data: dict[Any, Any] | str,
params: dict[Any, Any] | None = None,
replace: bool = False,
files: dict[str, _FieldValue] | None = None,
) -> dict[Any, Any] | bytes:
"""
Makes a basic Update request to the API, and returns the response.
Expand All @@ -267,6 +271,10 @@ def update(
can be either a dictionary that is serialised to JSON or bytes and
strings that will be sent without serialisation.

When files is provided, the request is sent as multipart/form-data
using urllib3's fields parameter. This takes precedence over data
serialisation.

For PUT requests parameters are encoded into the URL.
https://urllib3.readthedocs.io/en/stable/user-guide.html#query-parameters

Expand All @@ -280,6 +288,10 @@ def update(
Parameters to be added to the URI
replace : bool, optional
Requests a full replacement of the entire entity. Uses PUT Method.
files : dict, optional
Multipart form fields passed as urllib3 fields. Values can be
plain strings/bytes for form data, (filename, data) tuples, or
(filename, data, content_type) tuples for file uploads.

Returns
-------
Expand All @@ -291,15 +303,7 @@ def update(
logger.info(f"API Update Operation to {url}")

self._check_auth()
if self.serialize:
response = self.manager.request(
method, url + safe_params, headers=self.request_headers, json=data
)
else:
response = self.manager.request(
method, url + safe_params, body=data, headers=self.request_headers
)

response = self._request_with_data(method, url + safe_params, data, files)
return self._process_resp(method, response)

def delete(
Expand Down Expand Up @@ -329,6 +333,29 @@ def delete(
)
return self._process_resp(method, response)

def _request_with_data(
self,
method: str,
url: str,
data: Any,
files: dict[str, _FieldValue] | None,
) -> urllib3.response.BaseHTTPResponse:
"""
Dispatches an HTTP request with a body payload. Multipart fields take
precedence, then JSON serialisation, then raw body.
"""
if files is not None:
return self.manager.request(
method, url, fields=files, headers=self.request_headers
)
if self.serialize:
return self.manager.request(
method, url, headers=self.request_headers, json=data
)
return self.manager.request(
method, url, body=data, headers=self.request_headers
)

def _process_resp(
self,
method: str,
Expand Down
105 changes: 105 additions & 0 deletions tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,111 @@ def test_Client_delete_operation(crud_api):
assert resp.data == b'{"name": "test"}'


def test_Client_create_operation_with_files(crud_api):
"""Check that create() sends multipart form data when files is provided."""
files = {"file": ("data.csv", b"a,b,c\n1,2,3", "text/csv")}
resp = crud_api.create("upload/endpoint", data=None, files=files)

crud_api.manager.request.assert_called_with(
"POST",
"https://localhost/upload/endpoint",
fields=files,
headers=request_headers,
)
assert resp.data == b'{"name": "test"}'


def test_Client_create_operation_with_files_two_tuple(crud_api):
"""Check that create() accepts 2-tuple (filename, data) without MIME type."""
files = {"file": ("data.csv", b"a,b,c\n1,2,3")}
resp = crud_api.create("upload/endpoint", data=None, files=files)

crud_api.manager.request.assert_called_with(
"POST",
"https://localhost/upload/endpoint",
fields=files,
headers=request_headers,
)
assert resp.data == b'{"name": "test"}'


def test_Client_create_operation_with_files_mixed_fields(crud_api):
"""Check that files dict can mix plain form fields with file tuples."""
files = {
"description": "uploaded file",
"file": ("data.csv", b"a,b,c\n1,2,3", "text/csv"),
}
resp = crud_api.create("upload/endpoint", data=None, files=files)

crud_api.manager.request.assert_called_with(
"POST",
"https://localhost/upload/endpoint",
fields=files,
headers=request_headers,
)
assert resp.data == b'{"name": "test"}'


def test_Client_create_operation_files_takes_precedence(crud_api):
"""Check that files takes precedence over data when both are provided."""
files = {"file": ("doc.pdf", b"%PDF-content", "application/pdf")}
sample = {"should": "be ignored"}
resp = crud_api.create("upload/endpoint", data=sample, files=files)

crud_api.manager.request.assert_called_with(
"POST",
"https://localhost/upload/endpoint",
fields=files,
headers=request_headers,
)
assert resp.data == b'{"name": "test"}'


def test_Client_update_operation_with_files(crud_api):
"""Check that update() sends multipart form data when files is provided."""
files = {"file": ("data.csv", b"a,b,c\n1,2,3", "text/csv")}
resp = crud_api.update("upload/endpoint", data=None, files=files)

crud_api.manager.request.assert_called_with(
"PATCH",
"https://localhost/upload/endpoint",
fields=files,
headers=request_headers,
)
assert resp.data == b'{"name": "test"}'


def test_Client_update_operation_files_takes_precedence(crud_api):
"""Check that files takes precedence over data when both are provided."""
files = {"file": ("doc.pdf", b"%PDF-content", "application/pdf")}
sample = {"should": "be ignored"}
resp = crud_api.update("upload/endpoint", data=sample, files=files)

crud_api.manager.request.assert_called_with(
"PATCH",
"https://localhost/upload/endpoint",
fields=files,
headers=request_headers,
)
assert resp.data == b'{"name": "test"}'


def test_Client_update_operation_with_files_and_replace(crud_api):
"""Check that update() with files and replace=True uses PUT method."""
files = {"file": ("data.csv", b"a,b,c\n1,2,3", "text/csv")}
resp = crud_api.update(
"upload/endpoint", data=None, files=files, replace=True
)

crud_api.manager.request.assert_called_with(
"PUT",
"https://localhost/upload/endpoint",
fields=files,
headers=request_headers,
)
assert resp.data == b'{"name": "test"}'


def test_Client_process_resp_return_bytes():
"""
Check the response processing returns bytes for non-JSON content.
Expand Down