Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
17 changes: 17 additions & 0 deletions docs/user-guide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,23 @@ Update a range
worksheet.update([[1, 2], [3, 4]], 'A1:B2')


Using a Custom JSON Serializer
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

By default gspread serializes request bodies with the standard library
``json`` module, which cannot encode some types such as ``datetime`` or
``Decimal``. You can register a custom serializer to handle them.

.. code:: python

import json

gc.set_serializer(lambda body: json.dumps(body, default=str))

The serializer is any callable with the same signature as ``json.dumps``.
Pass ``None`` to restore the default behavior.


Adding Data Validation
~~~~~~~~~~~~~~~~~~~~~~

Expand Down
14 changes: 13 additions & 1 deletion gspread/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from requests import Response, Session

from .exceptions import APIError, SpreadsheetNotFound
from .http_client import HTTPClient, HTTPClientType, ParamsType
from .http_client import HTTPClient, HTTPClientType, ParamsType, SerializerType
from .spreadsheet import Spreadsheet
from .urls import DRIVE_FILES_API_V3_COMMENTS_URL, DRIVE_FILES_API_V3_URL
from .utils import ExportFormat, MimeType, extract_id_from_url, finditem
Expand Down Expand Up @@ -65,6 +65,18 @@ def set_timeout(
"""
self.http_client.set_timeout(timeout)

def set_serializer(self, serializer: SerializerType = None) -> None:
"""Set a custom JSON serializer used to encode request bodies.

See :meth:`gspread.http_client.HTTPClient.set_serializer` for details.

Use value ``None`` to restore the default serialization behavior.

:param serializer: A callable with the same signature as ``json.dumps``,
or ``None`` to use the default.
"""
self.http_client.set_serializer(serializer)

def get_file_drive_metadata(self, id: str) -> Any:
"""Get the metadata from the Drive API for a specific file
This method is mainly here to retrieve the create/update time
Expand Down
34 changes: 33 additions & 1 deletion gspread/http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from typing import (
IO,
Any,
Callable,
Dict,
List,
Mapping,
Expand Down Expand Up @@ -44,6 +45,7 @@
from .utils import ExportFormat, convert_credentials, quote

ParamsType = MutableMapping[str, Optional[Union[str, int, bool, float, List[str]]]]
SerializerType = Optional[Callable[[Mapping[str, Any]], Union[str, bytes]]]

FileType = Optional[
Union[
Expand Down Expand Up @@ -83,6 +85,7 @@ def __init__(self, auth: Credentials, session: Optional[Session] = None) -> None
self.session = AuthorizedSession(self.auth)

self.timeout: Optional[Union[float, Tuple[float, float]]] = None
self.serializer: SerializerType = None

def login(self) -> None:
from google.auth.transport.requests import Request
Expand All @@ -102,16 +105,45 @@ def set_timeout(self, timeout: Optional[Union[float, Tuple[float, float]]]) -> N
"""
self.timeout = timeout

def set_serializer(self, serializer: SerializerType) -> None:
"""Set a custom JSON serializer used to encode request bodies.

The serializer is a callable that takes the request body (a mapping)
and returns its JSON-encoded form as ``str`` or ``bytes``. It is
applied to every request that sends a JSON body, which is useful for
handling types the standard library ``json`` module cannot serialize
by default (e.g. ``datetime`` or ``Decimal``).

Use value ``None`` to restore the default serialization behavior.

:param serializer: A callable with the same signature as
``json.dumps`` (e.g. ``functools.partial(json.dumps, default=...)``),
or ``None`` to use the default.

Example::

import json

client.set_serializer(
lambda body: json.dumps(body, default=str)
)
"""
self.serializer = serializer

def request(
self,
method: str,
endpoint: str,
params: Optional[ParamsType] = None,
data: Optional[bytes] = None,
data: Optional[Union[str, bytes]] = None,
json: Optional[Mapping[str, Any]] = None,
files: FileType = None,
headers: Optional[MutableMapping[str, str]] = None,
) -> Response:
if self.serializer is not None and json is not None:
data = self.serializer(dict(json))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is the dict needed here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was just mirroring the existing json=dict(json) if json else None line below it. Since json is typed as Mapping[str, Any] it isn't guaranteed to be a concrete dict, so this makes sure the serializer gets one it can encode.

json = None
headers = {**(headers or {}), "Content-Type": "application/json"}
response = self.session.request(
method=method,
url=endpoint,
Expand Down
48 changes: 48 additions & 0 deletions tests/http_client_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import datetime
import json
from unittest import TestCase
from unittest.mock import Mock

from gspread.http_client import HTTPClient


class HTTPClientSerializerTest(TestCase):
def _make_client(self):
session = Mock()
session.request.return_value.ok = True # so request() returns, doesn't raise
return HTTPClient(auth=None, session=session), session

def test_default_uses_json_kwarg(self):
"""Without a serializer, the body goes out via json= (unchanged behavior)."""
client, session = self._make_client()
body = {"values": [[1, 2, 3]]}

client.request("post", "http://example.com", json=body)

_, kwargs = session.request.call_args
self.assertEqual(kwargs["json"], body)
self.assertIsNone(kwargs["data"])

def test_custom_serializer_uses_data_and_header(self):
"""With a serializer, the body is serialized into data= with a JSON header."""
client, session = self._make_client()
client.set_serializer(lambda b: json.dumps(b, default=str))
body = {"values": [[1, 2, 3]]}

client.request("post", "http://example.com", json=body)

_, kwargs = session.request.call_args
self.assertIsNone(kwargs["json"])
self.assertEqual(kwargs["data"], json.dumps(body, default=str))
self.assertEqual(kwargs["headers"]["Content-Type"], "application/json")

def test_custom_serializer_handles_non_native_types(self):
"""The actual use case from the issue: serialize a date the stdlib can't."""
client, session = self._make_client()
client.set_serializer(lambda b: json.dumps(b, default=lambda o: o.isoformat()))
body = {"values": [[datetime.date(2026, 6, 19)]]}

client.request("post", "http://example.com", json=body)

_, kwargs = session.request.call_args
self.assertIn("2026-06-19", kwargs["data"])
Loading