diff --git a/README.md b/README.md index 4c55063..98d277d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/changelog.rst b/docs/changelog.rst index b391a27..352141b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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) ---------------------------------- diff --git a/docs/user_guide.rst b/docs/user_guide.rst index 364e0b0..c1f5104 100644 --- a/docs/user_guide.rst +++ b/docs/user_guide.rst @@ -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 ------- diff --git a/src/cruds/__init__.py b/src/cruds/__init__.py index 21e8f08..43b9ffa 100644 --- a/src/cruds/__init__.py +++ b/src/cruds/__init__.py @@ -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"] diff --git a/src/cruds/core.py b/src/cruds/core.py index b732103..ede5d74 100644 --- a/src/cruds/core.py +++ b/src/cruds/core.py @@ -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): """ @@ -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. @@ -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 @@ -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 ------- @@ -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( @@ -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. @@ -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 @@ -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 ------- @@ -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( @@ -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, diff --git a/tests/test_core.py b/tests/test_core.py index 56dc555..a7419ea 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -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.