diff --git a/.gitignore b/.gitignore index 4e19af3c..c4d7911e 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ env/ gspread.egg-info/ dist/ docs/build/ + +# local virtualenv +venv/ diff --git a/docs/user-guide.rst b/docs/user-guide.rst index 34aeeb5d..be3fcb61 100644 --- a/docs/user-guide.rst +++ b/docs/user-guide.rst @@ -361,6 +361,47 @@ Update a range worksheet.update([[1, 2], [3, 4]], 'A1:B2') +Serializing Values the Standard ``json`` Module Cannot Encode +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +gspread encodes request bodies with the standard library ``json`` module, +which cannot encode some types such as ``datetime`` or ``Decimal``. The +data-writing methods (``update``, ``batch_update``, ``append_row`` and +``append_rows``) accept a ``default_serializer`` argument for these cases. It +works exactly like the ``default`` argument of :func:`json.dumps`: it receives +a value the encoder cannot handle and returns a JSON-serializable substitute. + +.. code:: python + + import datetime + + worksheet.update( + [[datetime.date(2026, 6, 23)]], + "A1", + default_serializer=str, + ) + +``str`` covers common types like ``datetime``, ``date``, ``Decimal`` and +``UUID``. For finer control, pass your own callable: + +.. code:: python + + def encode(value): + if isinstance(value, datetime.datetime): + return value.isoformat() + raise TypeError(f"cannot serialize {type(value)}") + + worksheet.update([[some_value]], "A1", default_serializer=encode) + +.. note:: + + The substitute you return determines how the value lands in the cell. For + example ``default_serializer=str`` sends a ``Decimal`` as the JSON string + ``"19.99"`` (stored as text unless ``value_input_option`` parses it), + whereas returning ``float(value)`` sends a number. Choose the form that + matches how you want the cell interpreted. + + Adding Data Validation ~~~~~~~~~~~~~~~~~~~~~~ diff --git a/gspread/http_client.py b/gspread/http_client.py index 9aaf448a..b0581f3a 100644 --- a/gspread/http_client.py +++ b/gspread/http_client.py @@ -9,9 +9,11 @@ import time from http import HTTPStatus +from json import dumps as json_dumps from typing import ( IO, Any, + Callable, Dict, List, Mapping, @@ -44,6 +46,7 @@ from .utils import ExportFormat, convert_credentials, quote ParamsType = MutableMapping[str, Optional[Union[str, int, bool, float, List[str]]]] +DefaultSerializerType = Optional[Callable[[Any], Any]] FileType = Optional[ Union[ @@ -107,11 +110,16 @@ def request( 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, + default_serializer: DefaultSerializerType = None, ) -> Response: + if default_serializer is not None and json is not None: + data = json_dumps(dict(json), default=default_serializer) + json = None + headers = {**(headers or {}), "Content-Type": "application/json"} response = self.session.request( method=method, url=endpoint, @@ -147,6 +155,7 @@ def values_update( range: str, params: Optional[ParamsType] = None, body: Optional[Mapping[str, Any]] = None, + default_serializer: DefaultSerializerType = None, ) -> Any: """Lower-level method that directly calls `PUT spreadsheets//values/ `_. @@ -171,11 +180,18 @@ def values_update( .. versionadded:: 3.0 """ url = SPREADSHEET_VALUES_URL % (id, quote(range)) - r = self.request("put", url, params=params, json=body) + r = self.request( + "put", url, params=params, json=body, default_serializer=default_serializer + ) return r.json() def values_append( - self, id: str, range: str, params: ParamsType, body: Optional[Mapping[str, Any]] + self, + id: str, + range: str, + params: ParamsType, + body: Optional[Mapping[str, Any]], + default_serializer: DefaultSerializerType = None, ) -> Any: """Lower-level method that directly calls `spreadsheets//values:append `_. @@ -189,7 +205,9 @@ def values_append( .. versionadded:: 3.0 """ url = SPREADSHEET_VALUES_APPEND_URL % (id, quote(range)) - r = self.request("post", url, params=params, json=body) + r = self.request( + "post", url, params=params, json=body, default_serializer=default_serializer + ) return r.json() def values_clear(self, id: str, range: str) -> Any: @@ -257,7 +275,10 @@ def values_batch_get( return r.json() def values_batch_update( - self, id: str, body: Optional[Mapping[str, Any]] = None + self, + id: str, + body: Optional[Mapping[str, Any]] = None, + default_serializer: DefaultSerializerType = None, ) -> Any: """Lower-level method that directly calls `spreadsheets//values:batchUpdate `_. @@ -266,7 +287,7 @@ def values_batch_update( :rtype: dict """ url = SPREADSHEET_VALUES_BATCH_UPDATE_URL % id - r = self.request("post", url, json=body) + r = self.request("post", url, json=body, default_serializer=default_serializer) return r.json() def spreadsheets_get(self, id: str, params: Optional[ParamsType] = None) -> Any: diff --git a/gspread/worksheet.py b/gspread/worksheet.py index 5b131051..b374a2a9 100644 --- a/gspread/worksheet.py +++ b/gspread/worksheet.py @@ -31,7 +31,7 @@ from .cell import Cell from .exceptions import GSpreadException -from .http_client import HTTPClient, ParamsType +from .http_client import DefaultSerializerType, HTTPClient, ParamsType from .urls import WORKSHEET_DRIVE_URL from .utils import ( DateTimeOption, @@ -1115,6 +1115,7 @@ def update( include_values_in_response: Optional[bool] = None, response_value_render_option: Optional[ValueRenderOption] = None, response_date_time_render_option: Optional[DateTimeOption] = None, + default_serializer: DefaultSerializerType = None, ) -> JSONResponse: """Sets values in a cell range of the sheet. @@ -1195,6 +1196,13 @@ def update( The default ``date_time_render_option`` is ``DateTimeOption.serial_number``. :type date_time_render_option: :class:`~gspread.utils.DateTimeOption` + :param default_serializer: (optional) A callable applied to values that + the standard ``json`` encoder cannot serialize on its own (e.g. + ``datetime`` or ``Decimal``). It receives the offending value and + must return a JSON-serializable substitute, mirroring the ``default`` + argument of :func:`json.dumps`. For example, ``default_serializer=str``. + :type default_serializer: Callable[[Any], Any] + Examples:: # Sets 'Hello world' in 'A2' cell @@ -1248,6 +1256,7 @@ def update( full_range_name, params=params, body={"values": values, "majorDimension": major_dimension}, + default_serializer=default_serializer, ) return response @@ -1260,6 +1269,7 @@ def batch_update( include_values_in_response: Optional[bool] = None, response_value_render_option: Optional[ValueRenderOption] = None, response_date_time_render_option: Optional[DateTimeOption] = None, + default_serializer: DefaultSerializerType = None, ) -> JSONResponse: """Sets values in one or more cell ranges of the sheet at once. @@ -1333,6 +1343,13 @@ def batch_update( The default ``date_time_render_option`` is ``DateTimeOption.serial_number``. :type date_time_render_option: :class:`~gspread.utils.DateTimeOption` + :param default_serializer: (optional) A callable applied to values that + the standard ``json`` encoder cannot serialize on its own (e.g. + ``datetime`` or ``Decimal``). It receives the offending value and + must return a JSON-serializable substitute, mirroring the ``default`` + argument of :func:`json.dumps`. For example, ``default_serializer=str``. + :type default_serializer: Callable[[Any], Any] + Examples:: worksheet.batch_update([{ @@ -1365,7 +1382,9 @@ def batch_update( "data": data, } - response = self.client.values_batch_update(self.spreadsheet_id, body=body) + response = self.client.values_batch_update( + self.spreadsheet_id, body=body, default_serializer=default_serializer + ) return response @@ -1785,6 +1804,7 @@ def append_row( insert_data_option: Optional[InsertDataOption] = None, table_range: Optional[str] = None, include_values_in_response: bool = False, + default_serializer: DefaultSerializerType = None, ) -> JSONResponse: """Adds a row to the worksheet and populates it with values. @@ -1804,6 +1824,12 @@ def append_row( :param bool include_values_in_response: (optional) Determines if the update response should include the values of the cells that were appended. By default, responses do not include the updated values. + :param default_serializer: (optional) A callable applied to values that + the standard ``json`` encoder cannot serialize on its own (e.g. + ``datetime`` or ``Decimal``). It receives the offending value and + must return a JSON-serializable substitute, mirroring the ``default`` + argument of :func:`json.dumps`. For example, ``default_serializer=str``. + :type default_serializer: Callable[[Any], Any] .. _ValueInputOption: https://developers.google.com/sheets/api/reference/rest/v4/ValueInputOption .. _InsertDataOption: https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/append#InsertDataOption @@ -1815,6 +1841,7 @@ def append_row( insert_data_option=insert_data_option, table_range=table_range, include_values_in_response=include_values_in_response, + default_serializer=default_serializer, ) def append_rows( @@ -1824,6 +1851,7 @@ def append_rows( insert_data_option: Optional[InsertDataOption] = None, table_range: Optional[str] = None, include_values_in_response: Optional[bool] = None, + default_serializer: DefaultSerializerType = None, ) -> JSONResponse: """Adds multiple rows to the worksheet and populates them with values. @@ -1845,6 +1873,12 @@ def append_rows( :param bool include_values_in_response: (optional) Determines if the update response should include the values of the cells that were appended. By default, responses do not include the updated values. + :param default_serializer: (optional) A callable applied to values that + the standard ``json`` encoder cannot serialize on its own (e.g. + ``datetime`` or ``Decimal``). It receives the offending value and + must return a JSON-serializable substitute, mirroring the ``default`` + argument of :func:`json.dumps`. For example, ``default_serializer=str``. + :type default_serializer: Callable[[Any], Any] .. _ValueInputOption: https://developers.google.com/sheets/api/reference/rest/v4/ValueInputOption .. _InsertDataOption: https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/append#InsertDataOption @@ -1859,7 +1893,9 @@ def append_rows( body = {"values": values} - res = self.client.values_append(self.spreadsheet_id, range_label, params, body) + res = self.client.values_append( + self.spreadsheet_id, range_label, params, body, default_serializer + ) num_new_rows = len(values) self._properties["gridProperties"]["rowCount"] += num_new_rows return res diff --git a/tests/http_client_test.py b/tests/http_client_test.py new file mode 100644 index 00000000..b64407b6 --- /dev/null +++ b/tests/http_client_test.py @@ -0,0 +1,103 @@ +import datetime +import json +from unittest import TestCase +from unittest.mock import Mock + +from gspread.http_client import HTTPClient +from gspread.worksheet import Worksheet + + +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 default_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_default_serializer_uses_data_and_header(self): + """With default_serializer, the body is encoded into data= with a JSON header.""" + client, session = self._make_client() + body = {"values": [[1, 2, 3]]} + + client.request("post", "http://example.com", json=body, default_serializer=str) + + _, 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_default_serializer_handles_non_native_types(self): + """The actual use case from the issue: encode a date the stdlib can't.""" + client, session = self._make_client() + body = {"values": [[datetime.date(2026, 6, 19)]]} + + client.request( + "post", + "http://example.com", + json=body, + default_serializer=lambda o: o.isoformat(), + ) + + _, kwargs = session.request.call_args + self.assertIn("2026-06-19", kwargs["data"]) + + +class WorksheetForwardsSerializerTest(TestCase): + """default_serializer must reach the client from each write method.""" + + MARKER = staticmethod(lambda o: str(o)) + + def _make_worksheet(self): + client = HTTPClient(auth=None, session=Mock()) + client.values_update = Mock() + client.values_append = Mock() + client.values_batch_update = Mock() + properties = { + "title": "Sheet1", + "gridProperties": {"rowCount": 5, "columnCount": 5}, + } + worksheet = Worksheet( + spreadsheet=None, + properties=properties, + spreadsheet_id="abc123", + client=client, + ) + return worksheet, client + + def test_update_forwards_serializer(self): + worksheet, client = self._make_worksheet() + worksheet.update([[1, 2, 3]], "A1", default_serializer=self.MARKER) + self.assertIs( + client.values_update.call_args.kwargs["default_serializer"], self.MARKER + ) + + def test_batch_update_forwards_serializer(self): + worksheet, client = self._make_worksheet() + worksheet.batch_update( + [{"range": "A1", "values": [[1]]}], default_serializer=self.MARKER + ) + self.assertIs( + client.values_batch_update.call_args.kwargs["default_serializer"], + self.MARKER, + ) + + def test_append_rows_forwards_serializer(self): + worksheet, client = self._make_worksheet() + worksheet.append_rows([[1, 2, 3]], default_serializer=self.MARKER) + # values_append receives default_serializer as the last positional arg + self.assertIs(client.values_append.call_args.args[4], self.MARKER) + + def test_append_row_forwards_serializer(self): + worksheet, client = self._make_worksheet() + worksheet.append_row([1, 2, 3], default_serializer=self.MARKER) + self.assertIs(client.values_append.call_args.args[4], self.MARKER)