diff --git a/README.md b/README.md index 0f2b8c940..f6929e52c 100644 --- a/README.md +++ b/README.md @@ -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]() to move from the legacy pattern. diff --git a/stripe/_stripe_object.py b/stripe/_stripe_object.py index c086fc272..f29bbce42 100644 --- a/stripe/_stripe_object.py +++ b/stripe/_stripe_object.py @@ -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, @@ -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]] @@ -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): @@ -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 diff --git a/tests/test_stripe_object.py b/tests/test_stripe_object.py index 23d0d1c27..d73d79869 100644 --- a/tests/test_stripe_object.py +++ b/tests/test_stripe_object.py @@ -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"},