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
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
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
68 changes: 68 additions & 0 deletions tests/test_stripe_object.py
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,74 @@ def test_items_field_not_shadowed_by_dict_items(self):
)
assert isinstance(obj.items, stripe.ListObject)

@pytest.fixture
def session(self):
return stripe.checkout.Session.construct_from(
{
"id": "cs_1",
"object": "checkout.session",
"metadata": {"a": "1"},
},
"key",
)

def test_dict_conversion_raises_type_error(self, session):
with pytest.raises(TypeError) as e:
dict(session.metadata)
assert "not iterable or a mapping" in str(e.value)
assert "to_dict()" in str(e.value)

def test_list_conversion_raises_type_error(self, session):
with pytest.raises(TypeError, match="not iterable or a mapping"):
list(session.metadata)

def test_iteration_raises_type_error(self, session):
with pytest.raises(TypeError, match="not iterable or a mapping"):
for _ in session.metadata:
pass

def test_iteration_error_names_the_subclass(self, session):
with pytest.raises(TypeError, match="^Session is not iterable"):
iter(session)

@pytest.mark.parametrize(
"name", ["get", "keys", "values", "items", "pop", "setdefault"]
)
def test_dict_methods_get_a_helpful_attribute_error(self, session, name):
with pytest.raises(AttributeError) as e:
getattr(session.metadata, name)
assert f"'{name}' is a dict method" in str(e.value)
assert "to_dict()" in str(e.value)

def test_dict_method_hint_remains_an_attribute_error(self, session):
"""
hasattr() and getattr() with a default must keep working, which they
only do for AttributeError (not TypeError).
"""
assert not hasattr(session.metadata, "get")
assert getattr(session.metadata, "get", None) is None

def test_field_named_like_dict_method_still_wins(self):
obj = StripeObject.construct_from(
{"get": "a", "keys": "b", "values": "c", "pop": "d"}, "key"
)
assert obj.get == "a"
assert obj.keys == "b"
assert obj.values == "c"
assert obj.pop == "d"

def test_list_object_is_still_iterable(self):
obj = StripeObject.construct_from(
{
"id": "sub_123",
"object": "subscription",
"items": {"object": "list", "data": [{"id": "si_123"}]},
},
"key",
)
assert [item.id for item in obj.items] == ["si_123"]
assert len(obj.items) == 1

def test_to_dict(self):
obj = StripeObject.construct_from(
{"id": "foo", "name": "bar"},
Expand Down
Loading