Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/migration-from-stream-chat-python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions getstream/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
6 changes: 5 additions & 1 deletion getstream/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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):
Expand Down
20 changes: 15 additions & 5 deletions getstream/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import enum
import json
import asyncio
from typing import Dict, List, Optional, Union, cast
Expand Down Expand Up @@ -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()


Expand Down
26 changes: 25 additions & 1 deletion tests/test_decoding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading