diff --git a/README.md b/README.md index 1a6914a6..b2e4e381 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 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 + +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..d814d2ad 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 from objects, kept in lists; pass `getstream.NULL` to send `null` | ## Quick Example diff --git a/getstream/__init__.py b/getstream/__init__.py index 2378c84e..8dc59a92 100644 --- a/getstream/__init__.py +++ b/getstream/__init__.py @@ -1,5 +1,6 @@ import logging +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 50d67677..9526e658 100644 --- a/getstream/base.py +++ b/getstream/base.py @@ -22,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 ( @@ -144,7 +145,10 @@ def _read_file_bytes(file_path: str) -> bytes: 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/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 1a2c2795..07bea3d3 100644 --- a/tests/test_decoding.py +++ b/tests/test_decoding.py @@ -6,7 +6,15 @@ import pytest -from getstream.models import GetCallResponse, OwnCapability, OwnCapabilityType +from getstream import NULL +from getstream.models import ( + GetCallResponse, + MessageRequest, + OwnCapability, + OwnCapabilityType, + QueryUsersPayload, + UpdateChannelPartialRequest, +) from getstream.base import _strip_none from getstream.utils import ( datetime_from_unix_ns, @@ -363,6 +371,22 @@ 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}} + } + 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") def test_get_call_response_from_dict(): # Read the fixture file