From c76613920b3ab639fad20002c8541d821786e2f6 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Wed, 4 Nov 2026 14:55:37 +0100 Subject: [PATCH 1/2] feat: add NULL to send an explicit null in request bodies Request bodies drop None values, so an unset optional field never overwrites stored data. Pass getstream.NULL where null must reach the API, for example inside custom data on channels and messages or in a partial update. --- README.md | 12 ++++++++++++ .../migration-from-stream-chat-python/README.md | 1 + getstream/__init__.py | 1 + getstream/base.py | 15 ++++++++++++++- tests/test_decoding.py | 17 ++++++++++++++++- 5 files changed, 44 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1a6914a6..f4317301 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,18 @@ client = Stream(api_key=..., api_secret=..., retry=RetryConfig(enabled=True, max Only idempotent `GET`/`HEAD` requests are retried, and only on HTTP 429 (unless the backend marked it unrecoverable) or a transport-level failure (timeout, connection reset, DNS, TLS). A 429's `Retry-After` header is honored (clamped to `max_backoff`); otherwise the delay uses full jitter over an exponential backoff. A retried failure logs `http.request.failed` at DEBUG; a final, non-retried failure logs it at ERROR (or not at all for a final 429, since that's already covered by `http.response.received`). +### Sending null + +Request bodies drop `None` values, so an unset optional field never overwrites stored data. To store an explicit `null`, for example inside custom data, pass `NULL`: + +```python +from getstream import NULL + +channel.update_channel_partial(set={"expires_at": NULL}) +``` + +To remove a field instead, use `unset`, for example `unset=["expires_at"]`. + ### App configuration ```python diff --git a/docs/migration-from-stream-chat-python/README.md b/docs/migration-from-stream-chat-python/README.md index 0a25c20a..8061dcdf 100644 --- a/docs/migration-from-stream-chat-python/README.md +++ b/docs/migration-from-stream-chat-python/README.md @@ -20,6 +20,7 @@ | Custom fields | Top-level dict keys | Nested in `custom` dict | | Response access | Dict keys (`response["users"]`) | Typed properties (`response.data.users`) | | Env vars | `STREAM_CHAT_TIMEOUT` | `STREAM_API_KEY`, `STREAM_API_SECRET`, `STREAM_TIMEOUT` | +| `None` in request data | Sent as `null` | Dropped; pass `getstream.NULL` to send `null` | ## Quick Example diff --git a/getstream/__init__.py b/getstream/__init__.py index 2378c84e..e3d70a16 100644 --- a/getstream/__init__.py +++ b/getstream/__init__.py @@ -1,5 +1,6 @@ import logging +from getstream.base import NULL # noqa: F401 from getstream.config import RetryConfig # noqa: F401 from getstream.exceptions import ( # noqa: F401 StreamApiException, diff --git a/getstream/base.py b/getstream/base.py index 50d67677..aa042ae6 100644 --- a/getstream/base.py +++ b/getstream/base.py @@ -1,3 +1,4 @@ +import enum import json import logging import mimetypes @@ -142,9 +143,21 @@ def _read_file_bytes(file_path: str) -> bytes: return f.read() +# An Enum member because dataclasses-json deep-copies dict values, and a deep copy of an Enum member is the member itself. +class _Null(enum.Enum): + NULL = "NULL" + + +NULL = _Null.NULL +"""Sends an explicit JSON ``null``. Request bodies drop plain ``None`` values, so use ``NULL`` where ``null`` must reach the API, for example inside custom data.""" + + def _strip_none(obj): """Recursively remove None values from dicts so unset optional fields - are omitted from the JSON body instead of being sent as null.""" + are omitted from the JSON body instead of being sent as null. ``NULL`` + becomes null.""" + if obj is NULL: + return None if isinstance(obj, dict): return {k: _strip_none(v) for k, v in obj.items() if v is not None} if isinstance(obj, list): diff --git a/tests/test_decoding.py b/tests/test_decoding.py index 1a2c2795..36258146 100644 --- a/tests/test_decoding.py +++ b/tests/test_decoding.py @@ -6,7 +6,13 @@ import pytest -from getstream.models import GetCallResponse, OwnCapability, OwnCapabilityType +from getstream import NULL +from getstream.models import ( + GetCallResponse, + OwnCapability, + OwnCapabilityType, + UpdateChannelPartialRequest, +) from getstream.base import _strip_none from getstream.utils import ( datetime_from_unix_ns, @@ -363,6 +369,15 @@ def test_strip_none_passthrough_scalars(): assert _strip_none(True) is True +def test_strip_none_sends_explicit_null(): + body = UpdateChannelPartialRequest( + set={"follow_ups": {"count": 3, "expires_at": NULL, "booking_id": None}} + ).to_dict() + assert _strip_none(body) == { + "set": {"follow_ups": {"count": 3, "expires_at": None}} + } + + @pytest.mark.skip("fixture is not longer valid, skip for now") def test_get_call_response_from_dict(): # Read the fixture file From 9138fd365e029e942bfffb75f8caf31628fcf14e Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Wed, 4 Nov 2026 15:13:09 +0100 Subject: [PATCH 2/2] fix: send NULL as null in query payloads and datetime fields --- README.md | 2 +- .../README.md | 2 +- getstream/__init__.py | 2 +- getstream/base.py | 11 +--------- getstream/utils/__init__.py | 20 ++++++++++++++----- tests/test_decoding.py | 9 +++++++++ 6 files changed, 28 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index f4317301..b2e4e381 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ Only idempotent `GET`/`HEAD` requests are retried, and only on HTTP 429 (unless ### Sending null -Request bodies drop `None` values, so an unset optional field never overwrites stored data. To store an explicit `null`, for example inside custom data, pass `NULL`: +Request bodies drop `None` values inside objects, so an unset optional field never overwrites stored data. `None` items in a list are kept and sent as `null`. To store an explicit `null`, for example inside custom data, pass `NULL`: ```python from getstream import NULL diff --git a/docs/migration-from-stream-chat-python/README.md b/docs/migration-from-stream-chat-python/README.md index 8061dcdf..d814d2ad 100644 --- a/docs/migration-from-stream-chat-python/README.md +++ b/docs/migration-from-stream-chat-python/README.md @@ -20,7 +20,7 @@ | Custom fields | Top-level dict keys | Nested in `custom` dict | | Response access | Dict keys (`response["users"]`) | Typed properties (`response.data.users`) | | Env vars | `STREAM_CHAT_TIMEOUT` | `STREAM_API_KEY`, `STREAM_API_SECRET`, `STREAM_TIMEOUT` | -| `None` in request data | Sent as `null` | Dropped; pass `getstream.NULL` to send `null` | +| `None` in request data | Sent as `null` | Dropped from objects, kept in lists; pass `getstream.NULL` to send `null` | ## Quick Example diff --git a/getstream/__init__.py b/getstream/__init__.py index e3d70a16..8dc59a92 100644 --- a/getstream/__init__.py +++ b/getstream/__init__.py @@ -1,6 +1,6 @@ import logging -from getstream.base import NULL # noqa: F401 +from getstream.utils import NULL # noqa: F401 from getstream.config import RetryConfig # noqa: F401 from getstream.exceptions import ( # noqa: F401 StreamApiException, diff --git a/getstream/base.py b/getstream/base.py index aa042ae6..9526e658 100644 --- a/getstream/base.py +++ b/getstream/base.py @@ -1,4 +1,3 @@ -import enum import json import logging import mimetypes @@ -23,6 +22,7 @@ import httpx from getstream.config import BaseConfig from getstream.version import VERSION +from getstream.utils import NULL from urllib.parse import quote from abc import ABC from getstream.common.telemetry import ( @@ -143,15 +143,6 @@ def _read_file_bytes(file_path: str) -> bytes: return f.read() -# An Enum member because dataclasses-json deep-copies dict values, and a deep copy of an Enum member is the member itself. -class _Null(enum.Enum): - NULL = "NULL" - - -NULL = _Null.NULL -"""Sends an explicit JSON ``null``. Request bodies drop plain ``None`` values, so use ``NULL`` where ``null`` must reach the API, for example inside custom data.""" - - def _strip_none(obj): """Recursively remove None values from dicts so unset optional fields are omitted from the JSON body instead of being sent as null. ``NULL`` diff --git a/getstream/utils/__init__.py b/getstream/utils/__init__.py index f1daec6b..5227149f 100644 --- a/getstream/utils/__init__.py +++ b/getstream/utils/__init__.py @@ -1,3 +1,4 @@ +import enum import json import asyncio from typing import Dict, List, Optional, Union, cast @@ -36,18 +37,27 @@ def validate_and_clean_url(url): return cleaned_url -def encode_datetime(date: Optional[datetime]) -> Optional[str]: +# An Enum member because dataclasses-json deep-copies dict values, and a deep copy of an Enum member is the member itself. Its value None makes JSON encoders write null. +class _Null(enum.Enum): + NULL = None + + +NULL = _Null.NULL +"""Sends an explicit JSON ``null``. Request bodies drop ``None`` values inside objects, so use ``NULL`` where ``null`` must reach the API, for example inside custom data.""" + + +def encode_datetime(date: Union[datetime, _Null, None]) -> Union[str, _Null, None]: """ Encodes a datetime object into an ISO 8601 formatted string. Args: - date (Optional[datetime]): The datetime object to encode. + date (Union[datetime, _Null, None]): The datetime object to encode. Returns: - Optional[str]: The ISO 8601 string representation of the datetime, or None if input is None. + Union[str, _Null, None]: The ISO 8601 string representation of the datetime, or the input unchanged if it is None or NULL. """ - if date is None: - return None + if not isinstance(date, datetime): + return date return date.isoformat() diff --git a/tests/test_decoding.py b/tests/test_decoding.py index 36258146..07bea3d3 100644 --- a/tests/test_decoding.py +++ b/tests/test_decoding.py @@ -9,8 +9,10 @@ from getstream import NULL from getstream.models import ( GetCallResponse, + MessageRequest, OwnCapability, OwnCapabilityType, + QueryUsersPayload, UpdateChannelPartialRequest, ) from getstream.base import _strip_none @@ -376,6 +378,13 @@ def test_strip_none_sends_explicit_null(): assert _strip_none(body) == { "set": {"follow_ups": {"count": 3, "expires_at": None}} } + assert _strip_none(MessageRequest(pin_expires=NULL).to_dict()) == { + "pin_expires": None + } + payload = build_query_param( + payload=QueryUsersPayload(filter_conditions={"x": NULL}) + )["payload"] + assert json.loads(payload)["filter_conditions"] == {"x": None} @pytest.mark.skip("fixture is not longer valid, skip for now")