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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
# Changelog

## 15.5.1 - 2026-08-18
* [#1879](https://github.com/stripe/stripe-python/pull/1879) better document `StripeObject`'s `to_dict` behavior

## 15.5.0 - 2026-08-10
* [#1874](https://github.com/stripe/stripe-python/pull/1874) Add async iteration to v2 list auto-pagination
- Adds `async for` support to v2 `ListObject.auto_paging_iter()`.
* [#1869](https://github.com/stripe/stripe-python/pull/1869) Surface `object` property on `EventNotification`
* [#1867](https://github.com/stripe/stripe-python/pull/1867) Emit Claude Code plugin hint at module load time
- Emits new Claude Code plugin hint when `CLAUDECODE` or `CLAUDE_CODE_CHILD_SESSION` environment variables are detected.
* [#1855](https://github.com/stripe/stripe-python/pull/1855) add/adjust event parsing helpers

- Added methods that return their respective `Event`/`EventNotification` class instances without verifying authenticity. Use them when you've previously verified an event (e.g. you verified, put the event in a queue, and are now processing). Supports events from [AWS EventBridge](https://docs.stripe.com/event-destinations/eventbridge) and [Azure Event Grid](https://docs.stripe.com/event-destinations/eventgrid) natively.
- `Webhook.construct_event_without_verification(payload)`
- `StripeClient.construct_event_without_verification(payload)`
- `StripeClient.parse_event_notification_without_verification(payload)`
- Added `WebhookSignature.generate_signature_header(payload, secret, timestamp=None)`, which computes a full `Stripe-Signature` header for the given payload. Useful for unit tests!

* [#1863](https://github.com/stripe/stripe-python/pull/1863) Add `stripe.major_api_version` constant

## 15.5.0b1 - 2026-07-29
This release changes the pinned API version to 2026-07-29.preview.

Expand Down
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,41 @@ customer = client.v1.customers.retrieve("cus_123456789")
print(customer.email)
```

### Working with API resources

Every API resource is a subclass of `StripeObject`. It is **not** a `dict`, even though printing one shows a dict-like representation. Having our own class means property names (like `subscription.items`) never collide with builtin methods.

You can access properties in a variety of ways:

```python
customer = client.v1.customers.retrieve("cus_123456789")

customer.email # attribute access
customer["email"] # subscript access
"email" in customer # membership
getattr(customer, "discount", None) # tolerate a field that may be absent
```

Though `StripeObject` is not a `dict`, there are helper methods to let you do operations you'd commonly do with a `dict`. Say you have the following (example) object:

```py
obj = Customer(id='cus_123', subscription=Subscription(id='sub_456', amount=Decimal('7.89'))
```

Here's how to accomplish each of these use cases:

| Use Case | Method | Result |
| ------------------------------------------------------------------------------------ | ------------------------------ | --------------------------------------------------------------------------------------- |
| Recursively iterate over a `StripeObject` where are values are native Python classes | `obj.to_dict()` | `{"id": "cus_123", "subscription": {"id": "sub_456", 'amount': Decimal('7.89')}}` |
| Iterate over the top-level of a `StripeObject` | `obj.to_dict(recursive=False)` | `{"id": "cus_123", "subscription": Subscription(id="sub_456", amount=Decimal("7.89"))}` |
| Get a plain `dict` where all values (in the entire tree) are JSON-serializable | `obj.to_dict(for_json=True)` | `{"id": "cus_123", "subscription": {"id": "sub_456", "amount": "7.89"}}` |
| Dump the object to a json string | `str(obj)` | `'{"id": "cus_123", "subscription": {"id": "sub_456", "amount": "7.89"}}'` |

In each case, `.to_dict()` **returns a copy** of the original object, so changes to the dict are not reflected in `obj`.

> [!NOTE]
> See the [original migration guide](https://github.com/stripe/stripe-python/wiki/Migration-guide-for-v15#stripeobject-no-longer-inherits-from-dict), [RFC](https://github.com/stripe/stripe-python/issues/1454), and [PR](https://github.com/stripe/stripe-python/pull/1762) for more information.

### StripeClient vs legacy pattern

We introduced the `StripeClient` class in v8 of the Python SDK. The legacy pattern used prior to that version is still available to use but will be marked as deprecated soon. Review the [migration guide to use StripeClient](<https://github.com/stripe/stripe-python/wiki/Migration-guide-for-v8-(StripeClient)>) to move from the legacy pattern.
Expand Down
1 change: 0 additions & 1 deletion examples/event_notification_handler_endpoint.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
# -*- coding: utf-8 -*-
# File copied from our code generator; changes here will be overwritten.
"""
event_notification_handler_endpoint.py - receive and process event notifications (AKA thin events) like "v1.billing.meter.error_report_triggered" using EventNotificationHandler.

Expand Down
1 change: 0 additions & 1 deletion stripe/_event_notification_handler.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
# -*- coding: utf-8 -*-
# File copied from our code generator; changes here will be overwritten.
from dataclasses import dataclass
from typing_extensions import TYPE_CHECKING

Expand Down
52 changes: 49 additions & 3 deletions stripe/_stripe_object.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
# pyright: strict
import json
from copy import deepcopy
from typing_extensions import TYPE_CHECKING, Type, Literal, Self, deprecated
from typing_extensions import (
TYPE_CHECKING,
NoReturn,
Type,
Literal,
Self,
deprecated,
)
from typing import (
Any,
Dict,
Expand Down Expand Up @@ -86,6 +93,20 @@ def _serialize_list(


class StripeObject:
"""
The base class for every response returned by the Stripe API.

A `StripeObject` is **not** a `dict` even though `str()` on one prints JSON. It deliberately keeps a small surface so that API fields never collide with `dict` method names (for example, `Subscription.items` is the API's `items` field, not `dict.items`).

If you want to do dict operations, on a StripeObject, call `.to_dict()` first. See [the readme](https://github.com/stripe/stripe-python#working-with-api-resources) for more information.
"""

# Names we know people reach for out of dict habit. Used to give a pointed
# error instead of a bare `AttributeError: get`.
_DICT_METHOD_NAMES = frozenset(
{"get", "keys", "values", "items", "pop", "setdefault"}
)

_retrieve_params: Mapping[str, Any]
_previous: Optional[Mapping[str, Any]]

Expand Down Expand Up @@ -167,9 +188,17 @@ def __getattr__(self, k):

try:
if k in self._field_remappings:
k = self._field_remappings[k]
return self[k]
key = self._field_remappings[k]
else:
key = k
return self[key]
except KeyError as err:
# Stays an AttributeError (rather than becoming a TypeError) so
# that hasattr() and getattr(obj, "get", None) keep working.
if k in self._DICT_METHOD_NAMES:
raise AttributeError(
f"'{k}' is a dict method, but a {type(self).__name__} is not a dict. Use .to_dict() to convert it. Docs: https://github.com/stripe/stripe-python#working-with-api-resources"
) from err
raise AttributeError(*err.args) from err

def __delattr__(self, k):
Expand Down Expand Up @@ -233,6 +262,23 @@ def __delitem__(self, k: str) -> None:
def __contains__(self, k: object) -> bool:
return k in self._data

# Defining __getitem__ without __iter__ makes dict(obj), list(obj), and
# `for k in obj` fall back to Python's legacy *sequence* protocol, which asks
# for obj[0] and surfaces a baffling "KeyError: 0". Raising here names the
# actual problem instead. This can't collide with an API field name;
# subclasses that are genuinely iterable (ListObject, SearchResultObject)
# override it.
#
# Hidden from type checkers so that they still report iterating a
# StripeObject as an error, and so the iterable subclasses don't look like
# incompatible overrides of a NoReturn method.
if not TYPE_CHECKING:

def __iter__(self) -> NoReturn:
raise TypeError(
f"{type(self).__name__} is not iterable or a mapping; call .to_dict() for a plain dict. Docs: https://github.com/stripe/stripe-python#working-with-api-resources"
)

def __eq__(self, other: object) -> bool:
if isinstance(other, StripeObject):
return type(self) is type(other) and self._data == other._data
Expand Down
29 changes: 27 additions & 2 deletions stripe/v2/_list_object.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from stripe._any_iterator import AnyIterator
from stripe._stripe_object import StripeObject
from typing import List, Optional, TypeVar, Generic
from typing import AsyncIterator, Iterator, List, Optional, TypeVar, Generic


T = TypeVar("T", bound=StripeObject)
Expand Down Expand Up @@ -40,7 +41,13 @@ def __len__(self):
def __reversed__(self):
return getattr(self, "data", []).__reversed__()

def auto_paging_iter(self):
def auto_paging_iter(self) -> AnyIterator[T]:
return AnyIterator(
self._auto_paging_iter(),
self._auto_paging_iter_async(),
)

def _auto_paging_iter(self) -> Iterator[T]:
page = self.data
next_page_url = self.next_page_url
while True:
Expand All @@ -57,3 +64,21 @@ def auto_paging_iter(self):
assert isinstance(result, ListObject)
page = result.data
next_page_url = result.next_page_url

async def _auto_paging_iter_async(self) -> AsyncIterator[T]:
page = self.data
next_page_url = self.next_page_url
while True:
for item in page:
yield item
if next_page_url is None:
break

result = await self._request_async(
"get",
next_page_url,
base_address="api",
)
assert isinstance(result, ListObject)
page = result.data
next_page_url = result.next_page_url
126 changes: 126 additions & 0 deletions tests/api_resources/test_list_object_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,129 @@ def test_iter_forwards_api_key(self, http_client_mock: HTTPClientMock):
query_string=query_string_2,
api_key="sk_test_iter_forwards_options",
)


class TestAutoPagingAsync:
@staticmethod
def pageable_model_response(ids, next_page_url):
return {
"data": [{"id": id, "object": "pageablemodel"} for id in ids],
"next_page_url": next_page_url,
}

@pytest.mark.anyio
async def test_iter_one_page(self, http_client_mock):
lo = ListObject.construct_from(
self.pageable_model_response(["pm_123", "pm_124"], None), "mykey"
)

http_client_mock.assert_no_request()

seen = [item["id"] async for item in lo.auto_paging_iter()]

assert seen == ["pm_123", "pm_124"]

@pytest.mark.anyio
async def test_iter_two_pages(self, http_client_mock):
method = "get"
path = "/v2/pageablemodels"

lo = ListObject.construct_from(
self.pageable_model_response(
["pm_123", "pm_124"],
"/v2/pageablemodels?foo=bar&page=page_2",
),
None,
)

http_client_mock.stub_request(
method,
path=path,
query_string="foo=bar&page=page_3",
rbody=json.dumps(
self.pageable_model_response(["pm_127", "pm_128"], None)
),
)

http_client_mock.stub_request(
method,
path=path,
query_string="foo=bar&page=page_2",
rbody=json.dumps(
self.pageable_model_response(
["pm_125", "pm_126"],
"/v2/pageablemodels?foo=bar&page=page_3",
)
),
)

seen = [item["id"] async for item in lo.auto_paging_iter()]

http_client_mock.assert_requested(
method, path=path, query_string="foo=bar&page=page_2"
)
http_client_mock.assert_requested(
method, path=path, query_string="foo=bar&page=page_3"
)

assert seen == [
"pm_123",
"pm_124",
"pm_125",
"pm_126",
"pm_127",
"pm_128",
]

@pytest.mark.anyio
async def test_iter_forwards_api_key(
self, http_client_mock: HTTPClientMock
):
client = stripe.StripeClient(
http_client=http_client_mock.get_mock_http_client(),
api_key="sk_test_xyz",
)

method = "get"
query_string_1 = "object_id=obj_123"
query_string_2 = "object_id=obj_123&page=page_2"
path = "/v2/core/events"

http_client_mock.stub_request(
method,
path=path,
query_string=query_string_1,
rbody='{"data": [{"id": "x"}], "next_page_url": "/v2/core/events?object_id=obj_123&page=page_2"}',
rcode=200,
rheaders={},
)

http_client_mock.stub_request(
method,
path=path,
query_string=query_string_2,
rbody='{"data": [{"id": "y"}, {"id": "z"}], "next_page_url": null}',
rcode=200,
rheaders={},
)

lo = await client.v2.core.events.list_async(
params={"object_id": "obj_123"},
options={"api_key": "sk_test_iter_forwards_options"},
)

seen = [item["id"] async for item in lo.auto_paging_iter()]

assert seen == ["x", "y", "z"]
http_client_mock.assert_requested(
method,
path=path,
query_string=query_string_1,
api_key="sk_test_iter_forwards_options",
)
http_client_mock.assert_requested(
method,
path=path,
query_string=query_string_2,
api_key="sk_test_iter_forwards_options",
)
1 change: 0 additions & 1 deletion tests/test_event_notification_handler.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
# -*- coding: utf-8 -*-
# File copied from our code generator; changes here will be overwritten.
import json
import pytest
from typing import Optional
Expand Down
Loading
Loading