From 78952be7fd8451c227d6a4ef92e6e97385470168 Mon Sep 17 00:00:00 2001 From: zacchua Date: Fri, 30 Oct 2026 21:37:08 +0000 Subject: [PATCH 01/10] =?UTF-8?q?[=F0=9F=AA=BF]=20Omit=20API=20keys=20from?= =?UTF-8?q?=20StripeObject=20pickles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevent credentials from leaking into pickle-backed caches and queues. Add graph-wide set_api_key() binding so consumers can supply their own credentials while preserving routing and destination-global fallback. Include regression tests and v16 migration guidance. Existing stored pickles remain sensitive and are not sanitized by this change. Committed-By-Agent: goose Orbit-Session-Id: 6c16ab70-1263-4d0e-a050-72c9b9f30280 --- ...-10-30_sdk_omit-pickled-api-keys.change.md | 6 + .hark/migration-guides/v16.md | 31 +++ README.md | 4 + stripe/_stripe_object.py | 37 ++- tests/test_stripe_object.py | 228 ++++++++++++++++-- 5 files changed, 288 insertions(+), 18 deletions(-) create mode 100644 .hark/changes/2026-10-30_sdk_omit-pickled-api-keys.change.md diff --git a/.hark/changes/2026-10-30_sdk_omit-pickled-api-keys.change.md b/.hark/changes/2026-10-30_sdk_omit-pickled-api-keys.change.md new file mode 100644 index 000000000..19e24cfd4 --- /dev/null +++ b/.hark/changes/2026-10-30_sdk_omit-pickled-api-keys.change.md @@ -0,0 +1,6 @@ +--- +title: Omit API keys from pickled StripeObjects +semver_level: major +--- + +StripeObject pickles no longer preserve SDK-held API keys. Applications making object-based requests after cache or queue round trips must explicitly bind receiving-process credentials with `set_api_key()`. This is a breaking authentication change. See the [v16 migration guide](https://github.com/stripe/stripe-python/blob/master/.hark/migration-guides/v16.md#api-keys-are-no-longer-preserved-in-pickled-stripeobjects). diff --git a/.hark/migration-guides/v16.md b/.hark/migration-guides/v16.md index 852bab155..9c0bc416f 100644 --- a/.hark/migration-guides/v16.md +++ b/.hark/migration-guides/v16.md @@ -12,6 +12,37 @@ You will almost certainly need before/after code examples and information about See: https://github.com/stripe/hark#writing-a-great-migration-guide --> +## API keys are no longer preserved in pickled StripeObjects + +This change applies to v16 releases that include `StripeObject.set_api_key()`. It changes authentication behavior for integrations that persist StripeObjects or webhook events in pickle-backed caches or queues and then make requests through the restored objects. Data-only consumers do not need to rebind credentials. + +New pickles omit SDK-held API keys from both the root and nested StripeObjects. Before making object-based requests, explicitly supply credentials from the receiving application's secure configuration: + +```python +# Before: relied on the producer's key surviving serialization. +customer = pickle.loads(trusted_cached_bytes) +customer.refresh() + +# After: use credentials from the worker's secure configuration. +customer = pickle.loads(trusted_cached_bytes) +customer.set_api_key(worker_api_key) +customer.refresh() +``` + +`set_api_key()` requires a non-empty string and updates the receiver and every nested StripeObject reachable through payload lists and dictionaries **in place**, including shared objects. It preserves each object's connected-account routing and API version, along with its other requestor options, without changing `stripe.api_key`. The supplied key must be authorized for all accounts represented in that graph. Re-pickling the rebound objects still omits SDK-held keys. + +Plain `pickle.loads()` retains the existing fallback to the receiving process's global API-key configuration. A restored object therefore does not necessarily have `api_key is None`. Do not rely on that fallback to select credentials for the intended account; explicitly bind the appropriate worker key before making requests. + +### Rolling upgrades and stored data + +Upgrade consumers to a release with `set_api_key()` and configure their credentials before updated producers send objects without embedded keys. Do not assume pickle compatibility across SDK versions without verifying your application's producer and consumer versions. + +Historical pickles can still contain producer API keys. Upgrading or calling `set_api_key()` does not remove credentials from existing cache or queue entries, replicas, or backups. Identify affected entries and invalidate or recreate them using trusted application sources, handling retained copies according to your operational policy. If credentials reached unauthorized readers, follow your incident-response and credential-rotation procedures. Avoid indiscriminate deletion of production queues. + +When object behavior is unnecessary, persist only the required data or identifiers instead. Use recursive data conversion appropriate to your installed SDK version rather than assuming older versions' conversion methods remove every nested StripeObject. This change omits SDK-held authentication context, not secrets explicitly placed in payload fields. + +Never deserialize untrusted pickle input: loading it can execute arbitrary code. `set_api_key()` does not make deserialization safe. + ## Rename `stripe.Reversal` to `stripe.TransferReversal` The class representing a transfer reversal has been renamed from `stripe.Reversal` to `stripe.TransferReversal`. diff --git a/README.md b/README.md index f851cec19..e98312f7a 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,10 @@ In each case, `.to_dict()` **returns a copy** of the original object, so changes > [!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. +### Persisting API resources + +StripeObject pickles omit SDK-held API keys. Before making requests through restored objects, bind credentials explicitly with `obj.set_api_key(worker_api_key)`. Historical pickles may still contain credentials, and pickle input must always be trusted. See the [v16 migration guide](.hark/migration-guides/v16.md#api-keys-are-no-longer-preserved-in-pickled-stripeobjects) for upgrade and stored-data guidance. + ### 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 8fe6d87d7..84935e58e 100644 --- a/stripe/_stripe_object.py +++ b/stripe/_stripe_object.py @@ -148,6 +148,41 @@ def __init__( def api_key(self): return self._requestor.api_key + def set_api_key(self, api_key: str) -> None: + """Bind a non-empty API key to this payload graph in place. + + Updates every reachable StripeObject in payload lists and dictionaries, + including shared objects. Each object's account, API version, and other + requestor options are preserved; stripe.api_key is not changed. The key + must be authorized for those accounts. Subsequent pickles omit the key. + Only load pickle data from trusted sources. + """ + if not isinstance(cast(object, api_key), str) or not api_key: + raise ValueError("api_key must be a non-empty string") + + visited: Set[int] = set() + pending: List[Any] = [self] + while pending: + value = pending.pop() + identity = id(value) + if not isinstance(value, (StripeObject, list, dict)): + continue + if identity in visited: + continue + visited.add(identity) + + if isinstance(value, StripeObject): + value._requestor = ( + value._requestor._new_requestor_with_options( # pyright: ignore[reportPrivateUsage] + {"api_key": api_key} + ) + ) + pending.extend(value._data.values()) + elif isinstance(value, list): + pending.extend(cast(List[Any], value)) + else: + pending.extend(cast(Dict[Any, Any], value).values()) + @property def stripe_account(self): return self._requestor.stripe_account @@ -298,7 +333,7 @@ def __reduce__(self) -> Tuple[Any, ...]: type(self), # callable ( # args getattr(self, "id", None), - self.api_key, + None, # Never persist SDK authentication credentials. self.stripe_version, self.stripe_account, ), diff --git a/tests/test_stripe_object.py b/tests/test_stripe_object.py index aecd64938..e9633c664 100644 --- a/tests/test_stripe_object.py +++ b/tests/test_stripe_object.py @@ -274,30 +274,224 @@ def test_repr(self): assert '"date": 1511136000' in res assert '"dec": "1.23"' in res - def test_pickling(self): - obj = StripeObject("foo", "bar", myparam=5) + @pytest.mark.parametrize("protocol", range(pickle.HIGHEST_PROTOCOL + 1)) + @pytest.mark.parametrize( + "destination_key", [None, "destination-key-sentinel"] + ) + def test_pickling(self, protocol, destination_key, monkeypatch): + obj = stripe.Customer.construct_from( + { + "id": "resource", + "object": "customer", + "description": "example", + "metadata": {"emptystring": ""}, + }, + "producer-key-sentinel", + stripe_version="2017-08-15", + stripe_account="example-account", + ) + obj.metadata.api_key = "nested-key-sentinel" + original_requestor = obj._requestor + payload = obj.to_dict() + + for value in (obj, obj.metadata): + assert value.__reduce__()[1][1] is None + pickled = pickle.dumps(value, protocol=protocol) + assert b"producer-key-sentinel" not in pickled + assert b"nested-key-sentinel" not in pickled + + pickled = pickle.dumps(obj, protocol=protocol) + monkeypatch.setattr(stripe, "api_key", destination_key) + newobj = pickle.loads(pickled) - obj["object"] = "boo" - obj.refresh_from( + assert type(newobj) is stripe.Customer + assert newobj.to_dict() == payload + assert newobj.metadata.emptystring == "" + for value in (newobj, newobj.metadata): + assert value.api_key == destination_key + assert value.stripe_version == "2017-08-15" + assert value.stripe_account == "example-account" + assert obj.api_key == "producer-key-sentinel" + assert obj.metadata.api_key == "nested-key-sentinel" + assert obj._requestor is original_requestor + assert obj.to_dict() == payload + + @pytest.mark.parametrize("protocol", range(pickle.HIGHEST_PROTOCOL + 1)) + def test_set_api_key_after_pickling(self, protocol, monkeypatch): + obj = stripe.Customer.construct_from( { - "fala": "lalala", - # ensures that empty strings are correctly unpickled in Py3 - "emptystring": "", + "id": "resource", + "object": "customer", + "metadata": {"empty": ""}, }, - api_key="bar", - partial=True, + "producer-key-sentinel", + stripe_version="2017-08-15", + stripe_account="example-account", + ) + nested = stripe.Customer.construct_from( + {"id": "nested-resource", "object": "customer"}, + "nested-key-sentinel", + stripe_version="2018-02-28", + stripe_account="nested-example-account", + ) + obj.containers = [{"nested": nested}] + payload = obj.to_dict() + monkeypatch.setattr(stripe, "api_key", "destination-key-sentinel") + restored = pickle.loads(pickle.dumps(obj, protocol=protocol)) + restored_nested = restored.containers[0]["nested"] + metadata = restored.metadata + + assert restored.set_api_key("worker-key-sentinel") is None + + for value in (restored, metadata, restored_nested): + assert value.api_key == "worker-key-sentinel" + assert "api_key" not in value._data + assert value.__reduce__()[1][1] is None + assert restored.metadata is metadata + assert restored.containers[0]["nested"] is restored_nested + assert restored.to_dict() == payload + assert restored.stripe_account == "example-account" + assert metadata.stripe_account == "example-account" + assert restored.stripe_version == "2017-08-15" + assert metadata.stripe_version == "2017-08-15" + assert restored_nested.stripe_account == "nested-example-account" + assert restored_nested.stripe_version == "2018-02-28" + assert stripe.api_key == "destination-key-sentinel" + assert obj.api_key == "producer-key-sentinel" + assert nested.api_key == "nested-key-sentinel" + + pickled = pickle.dumps(restored, protocol=protocol) + for key in ( + b"producer-key-sentinel", + b"nested-key-sentinel", + b"destination-key-sentinel", + b"worker-key-sentinel", + ): + assert key not in pickled + + def test_set_api_key_preserves_requestor_context(self, mocker): + client = stripe.StripeClient( + "producer-key-sentinel", + http_client=mocker.Mock(), + max_network_retries=3, + ) + obj = StripeObject(_requestor=client._requestor) + unrelated = StripeObject(_requestor=obj._requestor) + original_requestor = obj._requestor + original_options = original_requestor._options.to_dict() + obj._previous = {"unrelated": unrelated} + obj.arbitrary = mocker.Mock(child=unrelated) + unsaved_values = obj._unsaved_values.copy() + + obj.set_api_key("worker-key-sentinel") + + assert obj._requestor is not original_requestor + assert obj._requestor._client is original_requestor._client + assert obj._requestor._options.to_dict() == { + **original_options, + "api_key": "worker-key-sentinel", + } + assert unrelated._requestor is original_requestor + assert unrelated.api_key == "producer-key-sentinel" + assert obj._unsaved_values == unsaved_values + + def test_set_api_key_shared_references_and_cycles(self, mocker): + obj = StripeObject(api_key="producer-key-sentinel") + nested = StripeObject(api_key="nested-key-sentinel") + containers = [{"nested": nested, "root": obj}] + containers.append(containers) + containers[0]["self"] = containers[0] + obj.update({"containers": containers, "shared": nested}) + nested.root = obj + root_merge = mocker.spy(obj._requestor, "_new_requestor_with_options") + nested_merge = mocker.spy( + nested._requestor, "_new_requestor_with_options" ) - assert obj.fala == "lalala" + obj.set_api_key("worker-key-sentinel") + + assert obj.api_key == nested.api_key == "worker-key-sentinel" + assert obj.containers is containers + assert containers[0]["nested"] is obj.shared is nested + assert containers[0]["root"] is nested.root is obj + assert containers[1] is containers + assert containers[0]["self"] is containers[0] + root_merge.assert_called_once_with({"api_key": "worker-key-sentinel"}) + nested_merge.assert_called_once_with( + {"api_key": "worker-key-sentinel"} + ) - pickled = pickle.dumps(obj) - newobj = pickle.loads(pickled) + @pytest.mark.parametrize("key", ["", None, 0, False, b"key"]) + def test_set_api_key_rejects_invalid_key_before_mutation(self, key): + obj = StripeObject.construct_from( + {"metadata": {"value": "example"}}, "producer-key-sentinel" + ) + requestor = obj._requestor + nested_requestor = obj.metadata._requestor + + with pytest.raises(ValueError, match="non-empty string"): + obj.set_api_key(key) + + assert obj._requestor is requestor + assert obj.metadata._requestor is nested_requestor + assert obj.api_key == obj.metadata.api_key == "producer-key-sentinel" + + def test_pickling_verified_webhook(self, monkeypatch): + payload = json.dumps( + { + "object": "event", + "data": { + "object": {"object": "customer", "metadata": {"empty": ""}} + }, + } + ) + monkeypatch.setattr(stripe, "api_key", "producer-key-sentinel") + signature = stripe.WebhookSignature.generate_signature_header( + payload, "webhook-test-secret" + ) + event = stripe.Webhook.construct_event( + payload, signature, "webhook-test-secret" + ) + pickled = pickle.dumps(event) + assert b"producer-key-sentinel" not in pickled + monkeypatch.setattr(stripe, "api_key", "destination-key-sentinel") + restored = pickle.loads(pickled) + + restored.set_api_key("worker-key-sentinel") + + assert type(restored) is stripe.Event + assert type(restored.data.object) is stripe.Customer + assert restored.to_dict() == event.to_dict() + for value in ( + restored, + restored.data, + restored.data.object, + restored.data.object.metadata, + ): + assert value.api_key == "worker-key-sentinel" + assert stripe.api_key == "destination-key-sentinel" + assert event.api_key == "producer-key-sentinel" + assert b"worker-key-sentinel" not in pickle.dumps(restored) + + def test_set_api_key_refresh_uses_bound_key( + self, http_client_mock, monkeypatch + ): + obj = stripe.Customer.construct_from( + {"id": "resource", "object": "customer"}, "producer-key-sentinel" + ) + monkeypatch.setattr(stripe, "api_key", "destination-key-sentinel") + restored = pickle.loads(pickle.dumps(obj)) + http_client_mock.stub_request( + "get", + path="/v1/customers/resource", + rbody='{"id": "resource", "object": "customer"}', + ) + + restored.set_api_key("worker-key-sentinel") + restored.refresh() - assert newobj.id == "foo" - assert newobj.api_key == "bar" - assert newobj["object"] == "boo" - assert newobj.fala == "lalala" - assert newobj.emptystring == "" + http_client_mock.assert_requested(api_key="worker-key-sentinel") + assert stripe.api_key == "destination-key-sentinel" def test_deletion(self): obj = StripeObject("id", "key") From c11d090691d7713863d9645cbbbfc0152f691723 Mon Sep 17 00:00:00 2001 From: zacchua Date: Fri, 30 Oct 2026 21:38:56 +0000 Subject: [PATCH 02/10] =?UTF-8?q?[=F0=9F=AA=BF]=20try=20to=20deslop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate overlapping pickle round-trip and credential-rebinding tests to remove duplicate setup and serialization. Retain coverage for supported pickle protocols, destination-global fallback, nested rebinding, routing preservation, and credential-free re-pickling without changing implementation behavior. Verified 172 targeted tests pass, along with formatting, linting, and git diff whitespace checks. Committed-By-Agent: goose Orbit-Session-Id: 6c16ab70-1263-4d0e-a050-72c9b9f30280 --- tests/test_stripe_object.py | 44 ++++++++++++------------------------- 1 file changed, 14 insertions(+), 30 deletions(-) diff --git a/tests/test_stripe_object.py b/tests/test_stripe_object.py index e9633c664..078ffccc0 100644 --- a/tests/test_stripe_object.py +++ b/tests/test_stripe_object.py @@ -291,23 +291,29 @@ def test_pickling(self, protocol, destination_key, monkeypatch): stripe_account="example-account", ) obj.metadata.api_key = "nested-key-sentinel" + nested = stripe.Customer.construct_from( + {"id": "nested-resource", "object": "customer"}, + "nested-key-sentinel", + stripe_version="2018-02-28", + stripe_account="nested-example-account", + ) + obj.containers = [{"nested": nested}] original_requestor = obj._requestor payload = obj.to_dict() - for value in (obj, obj.metadata): + for value in (obj.metadata, nested, obj): assert value.__reduce__()[1][1] is None pickled = pickle.dumps(value, protocol=protocol) assert b"producer-key-sentinel" not in pickled assert b"nested-key-sentinel" not in pickled - pickled = pickle.dumps(obj, protocol=protocol) monkeypatch.setattr(stripe, "api_key", destination_key) - newobj = pickle.loads(pickled) + restored = pickle.loads(pickled) - assert type(newobj) is stripe.Customer - assert newobj.to_dict() == payload - assert newobj.metadata.emptystring == "" - for value in (newobj, newobj.metadata): + assert type(restored) is stripe.Customer + assert restored.to_dict() == payload + assert restored.metadata.emptystring == "" + for value in (restored, restored.metadata): assert value.api_key == destination_key assert value.stripe_version == "2017-08-15" assert value.stripe_account == "example-account" @@ -316,28 +322,6 @@ def test_pickling(self, protocol, destination_key, monkeypatch): assert obj._requestor is original_requestor assert obj.to_dict() == payload - @pytest.mark.parametrize("protocol", range(pickle.HIGHEST_PROTOCOL + 1)) - def test_set_api_key_after_pickling(self, protocol, monkeypatch): - obj = stripe.Customer.construct_from( - { - "id": "resource", - "object": "customer", - "metadata": {"empty": ""}, - }, - "producer-key-sentinel", - stripe_version="2017-08-15", - stripe_account="example-account", - ) - nested = stripe.Customer.construct_from( - {"id": "nested-resource", "object": "customer"}, - "nested-key-sentinel", - stripe_version="2018-02-28", - stripe_account="nested-example-account", - ) - obj.containers = [{"nested": nested}] - payload = obj.to_dict() - monkeypatch.setattr(stripe, "api_key", "destination-key-sentinel") - restored = pickle.loads(pickle.dumps(obj, protocol=protocol)) restored_nested = restored.containers[0]["nested"] metadata = restored.metadata @@ -356,7 +340,7 @@ def test_set_api_key_after_pickling(self, protocol, monkeypatch): assert metadata.stripe_version == "2017-08-15" assert restored_nested.stripe_account == "nested-example-account" assert restored_nested.stripe_version == "2018-02-28" - assert stripe.api_key == "destination-key-sentinel" + assert stripe.api_key == destination_key assert obj.api_key == "producer-key-sentinel" assert nested.api_key == "nested-key-sentinel" From 096a7e3b0e0611f2f35e436541c6388ee60af48d Mon Sep 17 00:00:00 2001 From: zacchua Date: Fri, 30 Oct 2026 22:30:43 +0000 Subject: [PATCH 03/10] =?UTF-8?q?[=F0=9F=AA=BF]=20Align=20pickle=20credent?= =?UTF-8?q?ial=20migration=20with=20v17?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document global fallback and explicit recursive key rebinding. Strengthen nested requestor preservation coverage and remove task-specific README edits. Committed-By-Agent: goose Orbit-Session-Id: 6c16ab70-1263-4d0e-a050-72c9b9f30280 --- ...-10-30_sdk_omit-pickled-api-keys.change.md | 2 +- .hark/migration-guides/v16.md | 31 ------------- .hark/migration-guides/v17.md | 46 +++++++++++++++++++ README.md | 4 -- tests/test_stripe_object.py | 30 ++++++++---- 5 files changed, 68 insertions(+), 45 deletions(-) create mode 100644 .hark/migration-guides/v17.md diff --git a/.hark/changes/2026-10-30_sdk_omit-pickled-api-keys.change.md b/.hark/changes/2026-10-30_sdk_omit-pickled-api-keys.change.md index 19e24cfd4..69fc92258 100644 --- a/.hark/changes/2026-10-30_sdk_omit-pickled-api-keys.change.md +++ b/.hark/changes/2026-10-30_sdk_omit-pickled-api-keys.change.md @@ -3,4 +3,4 @@ title: Omit API keys from pickled StripeObjects semver_level: major --- -StripeObject pickles no longer preserve SDK-held API keys. Applications making object-based requests after cache or queue round trips must explicitly bind receiving-process credentials with `set_api_key()`. This is a breaking authentication change. See the [v16 migration guide](https://github.com/stripe/stripe-python/blob/master/.hark/migration-guides/v16.md#api-keys-are-no-longer-preserved-in-pickled-stripeobjects). +StripeObject pickles no longer preserve SDK-held API keys. Applications making object-based requests after cache or queue round trips must configure receiving-process credentials. The new `set_api_key(api_key)` method always rebinds the root and nested StripeObjects in place without changing global configuration. This is a breaking authentication change. See the [v17 migration guide](https://github.com/stripe/stripe-python/blob/master/.hark/migration-guides/v17.md#api-keys-are-no-longer-preserved-in-pickled-stripeobjects). diff --git a/.hark/migration-guides/v16.md b/.hark/migration-guides/v16.md index 9c0bc416f..852bab155 100644 --- a/.hark/migration-guides/v16.md +++ b/.hark/migration-guides/v16.md @@ -12,37 +12,6 @@ You will almost certainly need before/after code examples and information about See: https://github.com/stripe/hark#writing-a-great-migration-guide --> -## API keys are no longer preserved in pickled StripeObjects - -This change applies to v16 releases that include `StripeObject.set_api_key()`. It changes authentication behavior for integrations that persist StripeObjects or webhook events in pickle-backed caches or queues and then make requests through the restored objects. Data-only consumers do not need to rebind credentials. - -New pickles omit SDK-held API keys from both the root and nested StripeObjects. Before making object-based requests, explicitly supply credentials from the receiving application's secure configuration: - -```python -# Before: relied on the producer's key surviving serialization. -customer = pickle.loads(trusted_cached_bytes) -customer.refresh() - -# After: use credentials from the worker's secure configuration. -customer = pickle.loads(trusted_cached_bytes) -customer.set_api_key(worker_api_key) -customer.refresh() -``` - -`set_api_key()` requires a non-empty string and updates the receiver and every nested StripeObject reachable through payload lists and dictionaries **in place**, including shared objects. It preserves each object's connected-account routing and API version, along with its other requestor options, without changing `stripe.api_key`. The supplied key must be authorized for all accounts represented in that graph. Re-pickling the rebound objects still omits SDK-held keys. - -Plain `pickle.loads()` retains the existing fallback to the receiving process's global API-key configuration. A restored object therefore does not necessarily have `api_key is None`. Do not rely on that fallback to select credentials for the intended account; explicitly bind the appropriate worker key before making requests. - -### Rolling upgrades and stored data - -Upgrade consumers to a release with `set_api_key()` and configure their credentials before updated producers send objects without embedded keys. Do not assume pickle compatibility across SDK versions without verifying your application's producer and consumer versions. - -Historical pickles can still contain producer API keys. Upgrading or calling `set_api_key()` does not remove credentials from existing cache or queue entries, replicas, or backups. Identify affected entries and invalidate or recreate them using trusted application sources, handling retained copies according to your operational policy. If credentials reached unauthorized readers, follow your incident-response and credential-rotation procedures. Avoid indiscriminate deletion of production queues. - -When object behavior is unnecessary, persist only the required data or identifiers instead. Use recursive data conversion appropriate to your installed SDK version rather than assuming older versions' conversion methods remove every nested StripeObject. This change omits SDK-held authentication context, not secrets explicitly placed in payload fields. - -Never deserialize untrusted pickle input: loading it can execute arbitrary code. `set_api_key()` does not make deserialization safe. - ## Rename `stripe.Reversal` to `stripe.TransferReversal` The class representing a transfer reversal has been renamed from `stripe.Reversal` to `stripe.TransferReversal`. diff --git a/.hark/migration-guides/v17.md b/.hark/migration-guides/v17.md new file mode 100644 index 000000000..1676ba9b6 --- /dev/null +++ b/.hark/migration-guides/v17.md @@ -0,0 +1,46 @@ +# Migration guide for v17 + +## API keys are no longer preserved in pickled StripeObjects + +This change affects integrations that persist StripeObjects or webhook events in pickle-backed caches or queues and then make requests through the restored objects. Data-only consumers do not need to rebind credentials. + +New pickles omit SDK-held API keys from both the root and nested StripeObjects. Before making object-based requests, supply credentials from the receiving application's secure configuration: + +```python +# Before: relied on the producer's key surviving serialization. +customer = pickle.loads(trusted_cached_bytes) +customer.refresh() + +# After: use credentials from the worker's secure configuration. +customer = pickle.loads(trusted_cached_bytes) +customer.set_api_key(worker_api_key) +customer.refresh() +``` + +`set_api_key(api_key: str) -> None` requires a non-empty string and always updates the receiver and every nested StripeObject reachable through payload lists and dictionaries **in place**, including shared objects and cycles. It preserves each object's current connected-account routing, API version, other requestor options, and HTTP-client configuration without changing `stripe.api_key`. No StripeClient or requestor argument is required. Re-pickling the rebound objects still omits SDK-held keys. + +The supplied key must be authorized for all retained account contexts. The helper intentionally overwrites differing keys throughout the graph; do not use it on a single mixed-credential graph that needs to retain separate credentials. It does not validate permissions or make an API request. + +### Global fallback and explicit rebinding + +Plain `pickle.loads()` retains the existing fallback to the receiving process's global API-key configuration. If that is the intended credential, configuring it before loading is an alternative to the setter: + +```python +stripe.api_key = worker_api_key +customer = pickle.loads(trusted_cached_bytes) +customer.refresh() +``` + +A restored object therefore does not necessarily have `api_key is None`. When the global key is absent or inappropriate, use `set_api_key(worker_api_key)` after loading to explicitly replace the key throughout the graph without modifying global configuration. Select the appropriate credentials for the retained account routing. + +The setter preserves the requestor configuration currently on each restored object. Pickling does not preserve the producer's custom HTTP client or other private requestor state, and the setter does not recover that configuration. + +### Rolling upgrades and stored data + +Upgrade consumers to a release with `set_api_key()` and configure their credentials before updated producers send objects without embedded keys. Do not assume pickle compatibility across SDK versions without verifying your application's producer and consumer versions. + +Historical pickles can still contain producer API keys. Upgrading or calling `set_api_key()` does not remove credentials from existing cache or queue entries, replicas, or backups. Identify affected entries and invalidate or recreate them using trusted application sources, handling retained copies according to your operational policy. If credentials reached unauthorized readers, follow your incident-response and credential-rotation procedures. Avoid indiscriminate deletion of production queues. + +When object behavior is unnecessary, persist only the required data or identifiers instead. Use recursive data conversion appropriate to your installed SDK version rather than assuming older versions' conversion methods remove every nested StripeObject. This change omits SDK-held authentication context, not secrets explicitly placed in payload fields. + +Never deserialize untrusted pickle input: loading it can execute arbitrary code. `set_api_key()` does not make deserialization safe. diff --git a/README.md b/README.md index e98312f7a..f851cec19 100644 --- a/README.md +++ b/README.md @@ -104,10 +104,6 @@ In each case, `.to_dict()` **returns a copy** of the original object, so changes > [!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. -### Persisting API resources - -StripeObject pickles omit SDK-held API keys. Before making requests through restored objects, bind credentials explicitly with `obj.set_api_key(worker_api_key)`. Historical pickles may still contain credentials, and pickle input must always be trusted. See the [v16 migration guide](.hark/migration-guides/v16.md#api-keys-are-no-longer-preserved-in-pickled-stripeobjects) for upgrade and stored-data guidance. - ### 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/tests/test_stripe_object.py b/tests/test_stripe_object.py index 078ffccc0..48776d971 100644 --- a/tests/test_stripe_object.py +++ b/tests/test_stripe_object.py @@ -360,24 +360,36 @@ def test_set_api_key_preserves_requestor_context(self, mocker): max_network_retries=3, ) obj = StripeObject(_requestor=client._requestor) + nested_client = stripe.StripeClient( + "nested-key-sentinel", + http_client=mocker.Mock(), + max_network_retries=1, + stripe_account="nested-example-account", + stripe_version="2018-02-28", + ) + nested = StripeObject(_requestor=nested_client._requestor) + obj.containers = [{"nested": nested}] unrelated = StripeObject(_requestor=obj._requestor) original_requestor = obj._requestor - original_options = original_requestor._options.to_dict() obj._previous = {"unrelated": unrelated} obj.arbitrary = mocker.Mock(child=unrelated) - unsaved_values = obj._unsaved_values.copy() + originals = [ + (value, value._requestor, value._unsaved_values.copy()) + for value in (obj, nested) + ] obj.set_api_key("worker-key-sentinel") - assert obj._requestor is not original_requestor - assert obj._requestor._client is original_requestor._client - assert obj._requestor._options.to_dict() == { - **original_options, - "api_key": "worker-key-sentinel", - } + for value, requestor, unsaved_values in originals: + assert value._requestor is not requestor + assert value._requestor._client is requestor._client + assert value._requestor._options.to_dict() == { + **requestor._options.to_dict(), + "api_key": "worker-key-sentinel", + } + assert value._unsaved_values == unsaved_values assert unrelated._requestor is original_requestor assert unrelated.api_key == "producer-key-sentinel" - assert obj._unsaved_values == unsaved_values def test_set_api_key_shared_references_and_cycles(self, mocker): obj = StripeObject(api_key="producer-key-sentinel") From e34a040eaa2fcae765b4cea937695698e11d4063 Mon Sep 17 00:00:00 2001 From: zacchua Date: Fri, 30 Oct 2026 22:31:54 +0000 Subject: [PATCH 04/10] =?UTF-8?q?[=F0=9F=AA=BF]=20try=20to=20deslop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove redundant nested-resource serialization and assertions already covered by payload equality and credential-absence checks on re-pickled bytes. Recursive rebinding, cycle handling, routing preservation, standalone metadata serialization, and request-boundary coverage remain intact. Validated with 101 passing StripeObject tests, formatting, linting, and git diff --check. Committed-By-Agent: goose Orbit-Session-Id: 6c16ab70-1263-4d0e-a050-72c9b9f30280 --- tests/test_stripe_object.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_stripe_object.py b/tests/test_stripe_object.py index 48776d971..2fcf72097 100644 --- a/tests/test_stripe_object.py +++ b/tests/test_stripe_object.py @@ -301,7 +301,7 @@ def test_pickling(self, protocol, destination_key, monkeypatch): original_requestor = obj._requestor payload = obj.to_dict() - for value in (obj.metadata, nested, obj): + for value in (obj.metadata, obj): assert value.__reduce__()[1][1] is None pickled = pickle.dumps(value, protocol=protocol) assert b"producer-key-sentinel" not in pickled @@ -312,7 +312,6 @@ def test_pickling(self, protocol, destination_key, monkeypatch): assert type(restored) is stripe.Customer assert restored.to_dict() == payload - assert restored.metadata.emptystring == "" for value in (restored, restored.metadata): assert value.api_key == destination_key assert value.stripe_version == "2017-08-15" @@ -330,7 +329,6 @@ def test_pickling(self, protocol, destination_key, monkeypatch): for value in (restored, metadata, restored_nested): assert value.api_key == "worker-key-sentinel" assert "api_key" not in value._data - assert value.__reduce__()[1][1] is None assert restored.metadata is metadata assert restored.containers[0]["nested"] is restored_nested assert restored.to_dict() == payload From d7c529d70b3a3d8e844fe8aa7eb426a6d2520328 Mon Sep 17 00:00:00 2001 From: zacchua Date: Fri, 30 Oct 2026 22:56:43 +0000 Subject: [PATCH 05/10] =?UTF-8?q?[=F0=9F=AA=BF]=20Update=20PyPI=20publishi?= =?UTF-8?q?ng=20action=20to=20v1.14.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix the workflow security check failing on GHSA-5xjx-3w67-w9x9. Pin the patched release to its verified commit SHA. https: //github.com/advisories/GHSA-5xjx-3w67-w9x9 Committed-By-Agent: goose Orbit-Session-Id: 6c16ab70-1263-4d0e-a050-72c9b9f30280 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d22d0a82..f8a8332dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -148,7 +148,7 @@ jobs: name: dist path: dist - name: Publish packages to PyPI - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 with: packages-dir: dist/ - uses: stripe/openapi/actions/notify-release@master From 427f81f019f381ff4599a04a482668887fa78fd0 Mon Sep 17 00:00:00 2001 From: zacchua Date: Fri, 30 Oct 2026 23:03:30 +0000 Subject: [PATCH 06/10] =?UTF-8?q?[=F0=9F=AA=BF]=20Revert=20unrelated=20PyP?= =?UTF-8?q?I=20publishing=20action=20upgrade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep CI configuration unchanged to scope this branch to pickle credentials. Committed-By-Agent: goose Orbit-Session-Id: 6c16ab70-1263-4d0e-a050-72c9b9f30280 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8a8332dc..0d22d0a82 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -148,7 +148,7 @@ jobs: name: dist path: dist - name: Publish packages to PyPI - uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: packages-dir: dist/ - uses: stripe/openapi/actions/notify-release@master From 9391eec9818958f135f1e4cb952751ebf09e2f2d Mon Sep 17 00:00:00 2001 From: zacchua Date: Fri, 30 Oct 2026 23:05:38 +0000 Subject: [PATCH 07/10] =?UTF-8?q?[=F0=9F=AA=BF]=20Update=20pickle=20creden?= =?UTF-8?q?tial=20changefile=20wording=20and=20filename?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../changes/2026-10-30_sdk_omit-pickled-api-keys.change.md | 6 ------ .../2026-10-30_zacchua_omit-pickled-api-keys.change.md | 5 +++++ 2 files changed, 5 insertions(+), 6 deletions(-) delete mode 100644 .hark/changes/2026-10-30_sdk_omit-pickled-api-keys.change.md create mode 100644 .hark/changes/2026-10-30_zacchua_omit-pickled-api-keys.change.md diff --git a/.hark/changes/2026-10-30_sdk_omit-pickled-api-keys.change.md b/.hark/changes/2026-10-30_sdk_omit-pickled-api-keys.change.md deleted file mode 100644 index 69fc92258..000000000 --- a/.hark/changes/2026-10-30_sdk_omit-pickled-api-keys.change.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: Omit API keys from pickled StripeObjects -semver_level: major ---- - -StripeObject pickles no longer preserve SDK-held API keys. Applications making object-based requests after cache or queue round trips must configure receiving-process credentials. The new `set_api_key(api_key)` method always rebinds the root and nested StripeObjects in place without changing global configuration. This is a breaking authentication change. See the [v17 migration guide](https://github.com/stripe/stripe-python/blob/master/.hark/migration-guides/v17.md#api-keys-are-no-longer-preserved-in-pickled-stripeobjects). diff --git a/.hark/changes/2026-10-30_zacchua_omit-pickled-api-keys.change.md b/.hark/changes/2026-10-30_zacchua_omit-pickled-api-keys.change.md new file mode 100644 index 000000000..659003685 --- /dev/null +++ b/.hark/changes/2026-10-30_zacchua_omit-pickled-api-keys.change.md @@ -0,0 +1,5 @@ +--- +title: Omit API keys from pickled StripeObjects +semver_level: major +--- +StripeObject pickles no longer preserve API keys. Applications making object-based requests after cache or queue round trips must configure credentials. The new `set_api_key(api_key)` method sets the root and nested StripeObjects API keys in place without changing global configuration. This is a breaking authentication change. See the [v17 migration guide](https://github.com/stripe/stripe-python/blob/master/.hark/migration-guides/v17.md#api-keys-are-no-longer-preserved-in-pickled-stripeobjects). From d0bef0d7aa05b044cb54387ee180f2ca7f266395 Mon Sep 17 00:00:00 2001 From: zacchua Date: Fri, 30 Oct 2026 23:11:22 +0000 Subject: [PATCH 08/10] =?UTF-8?q?[=F0=9F=AA=BF]=20Simplify=20v17=20pickle?= =?UTF-8?q?=20migration=20guidance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use concise examples to explain explicit API-key rebinding and global fallback. Clarify that rebinding preserves current requestor configuration, not the producer's custom HTTP client. --- .hark/migration-guides/v17.md | 37 ++++++++++------------------------- 1 file changed, 10 insertions(+), 27 deletions(-) diff --git a/.hark/migration-guides/v17.md b/.hark/migration-guides/v17.md index 1676ba9b6..0b827aa85 100644 --- a/.hark/migration-guides/v17.md +++ b/.hark/migration-guides/v17.md @@ -1,46 +1,29 @@ # Migration guide for v17 - ## API keys are no longer preserved in pickled StripeObjects +This change affects integrations that persist StripeObjects caches or queues and then make requests through the restored objects. -This change affects integrations that persist StripeObjects or webhook events in pickle-backed caches or queues and then make requests through the restored objects. Data-only consumers do not need to rebind credentials. - -New pickles omit SDK-held API keys from both the root and nested StripeObjects. Before making object-based requests, supply credentials from the receiving application's secure configuration: - +New pickles omit API keys from both StripeObjects. Before making object-based requests, supply credentials from the secure configuration: ```python -# Before: relied on the producer's key surviving serialization. +# Before customer = pickle.loads(trusted_cached_bytes) customer.refresh() - -# After: use credentials from the worker's secure configuration. +# After customer = pickle.loads(trusted_cached_bytes) -customer.set_api_key(worker_api_key) +customer.set_api_key(api_key) customer.refresh() ``` +`set_api_key(api_key: str) -> None` requires a non-empty string and updates the receiver and every nested StripeObject in place. It preserves each object's current connected-account routing, API version, other requestor options, and HTTP-client configuration without changing `stripe.api_key`. Re-pickling the objects still omits SDK-held keys. -`set_api_key(api_key: str) -> None` requires a non-empty string and always updates the receiver and every nested StripeObject reachable through payload lists and dictionaries **in place**, including shared objects and cycles. It preserves each object's current connected-account routing, API version, other requestor options, and HTTP-client configuration without changing `stripe.api_key`. No StripeClient or requestor argument is required. Re-pickling the rebound objects still omits SDK-held keys. - -The supplied key must be authorized for all retained account contexts. The helper intentionally overwrites differing keys throughout the graph; do not use it on a single mixed-credential graph that needs to retain separate credentials. It does not validate permissions or make an API request. +The helper intentionally overwrites differing keys throughout the graph. ### Global fallback and explicit rebinding - -Plain `pickle.loads()` retains the existing fallback to the receiving process's global API-key configuration. If that is the intended credential, configuring it before loading is an alternative to the setter: - +Plain `pickle.loads()` retains the existing fallback to global API-key configuration. If that is the intended credential, configuring it before loading is an alternative to the setter: ```python -stripe.api_key = worker_api_key +stripe.api_key = api_key customer = pickle.loads(trusted_cached_bytes) customer.refresh() ``` -A restored object therefore does not necessarily have `api_key is None`. When the global key is absent or inappropriate, use `set_api_key(worker_api_key)` after loading to explicitly replace the key throughout the graph without modifying global configuration. Select the appropriate credentials for the retained account routing. +A restored object therefore does not necessarily have `api_key is None`. When the global key is absent or inappropriate, use `set_api_key(api_key)` after loading to explicitly replace the key throughout the graph without modifying global configuration. The setter preserves the requestor configuration currently on each restored object. Pickling does not preserve the producer's custom HTTP client or other private requestor state, and the setter does not recover that configuration. - -### Rolling upgrades and stored data - -Upgrade consumers to a release with `set_api_key()` and configure their credentials before updated producers send objects without embedded keys. Do not assume pickle compatibility across SDK versions without verifying your application's producer and consumer versions. - -Historical pickles can still contain producer API keys. Upgrading or calling `set_api_key()` does not remove credentials from existing cache or queue entries, replicas, or backups. Identify affected entries and invalidate or recreate them using trusted application sources, handling retained copies according to your operational policy. If credentials reached unauthorized readers, follow your incident-response and credential-rotation procedures. Avoid indiscriminate deletion of production queues. - -When object behavior is unnecessary, persist only the required data or identifiers instead. Use recursive data conversion appropriate to your installed SDK version rather than assuming older versions' conversion methods remove every nested StripeObject. This change omits SDK-held authentication context, not secrets explicitly placed in payload fields. - -Never deserialize untrusted pickle input: loading it can execute arbitrary code. `set_api_key()` does not make deserialization safe. From af9487d58e6a8a9ef91c4521da10dad674eeef45 Mon Sep 17 00:00:00 2001 From: Zachary Chua Date: Mon, 2 Nov 2026 16:21:21 -0800 Subject: [PATCH 09/10] =?UTF-8?q?[=F0=9F=AA=BF]=20Replace=20set=5Fapi=5Fke?= =?UTF-8?q?y=20with=20per-call=20API=20keys=20on=20instance=20methods?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove StripeObject.set_api_key() and instead accept an api_key on the instance methods that previously could not take one: refresh(), refresh_async(), save(), and auto_paging_iter() on ListObject, SearchResultObject, and their V2 equivalents. delete() and generated instance methods already accept per-call request options. Co-Authored-By: Claude Opus 5.5 Committed-By-Agent: claude --- ...30_zacchua_omit-pickled-api-keys.change.md | 2 +- .hark/migration-guides/v17.md | 24 +- stripe/_api_resource.py | 21 +- stripe/_list_object.py | 22 +- stripe/_search_result_object.py | 17 +- stripe/_stripe_object.py | 35 --- stripe/_updateable_api_resource.py | 3 +- stripe/v2/_list_object.py | 19 +- stripe/v2/_search_result_object.py | 20 +- tests/api_resources/test_list_object.py | 103 ++++++++ tests/api_resources/test_list_object_v2.py | 32 +++ .../test_search_result_object.py | 34 +++ tests/test_stripe_object.py | 243 ++++++++++-------- tests/test_v2_search_result_object.py | 20 ++ 14 files changed, 401 insertions(+), 194 deletions(-) diff --git a/.hark/changes/2026-10-30_zacchua_omit-pickled-api-keys.change.md b/.hark/changes/2026-10-30_zacchua_omit-pickled-api-keys.change.md index 659003685..f67da5729 100644 --- a/.hark/changes/2026-10-30_zacchua_omit-pickled-api-keys.change.md +++ b/.hark/changes/2026-10-30_zacchua_omit-pickled-api-keys.change.md @@ -2,4 +2,4 @@ title: Omit API keys from pickled StripeObjects semver_level: major --- -StripeObject pickles no longer preserve API keys. Applications making object-based requests after cache or queue round trips must configure credentials. The new `set_api_key(api_key)` method sets the root and nested StripeObjects API keys in place without changing global configuration. This is a breaking authentication change. See the [v17 migration guide](https://github.com/stripe/stripe-python/blob/master/.hark/migration-guides/v17.md#api-keys-are-no-longer-preserved-in-pickled-stripeobjects). +StripeObject pickles no longer preserve API keys. Applications making object-based requests after cache or queue round trips must supply credentials, either through global configuration or per call, e.g. `customer.refresh(api_key=api_key)`. `refresh()`, `refresh_async()`, `save()`, and `auto_paging_iter()` on list and search result objects now accept an `api_key` keyword argument for this. This is a breaking authentication change. See the [v17 migration guide](https://github.com/stripe/stripe-python/blob/master/.hark/migration-guides/v17.md#api-keys-are-no-longer-preserved-in-pickled-stripeobjects). diff --git a/.hark/migration-guides/v17.md b/.hark/migration-guides/v17.md index 0b827aa85..8184bb29c 100644 --- a/.hark/migration-guides/v17.md +++ b/.hark/migration-guides/v17.md @@ -1,29 +1,27 @@ # Migration guide for v17 ## API keys are no longer preserved in pickled StripeObjects -This change affects integrations that persist StripeObjects caches or queues and then make requests through the restored objects. +This change affects integrations that persist StripeObjects in caches or queues and then make requests through the restored objects. -New pickles omit API keys from both StripeObjects. Before making object-based requests, supply credentials from the secure configuration: +New pickles omit API keys from StripeObjects, including nested objects. Restored objects keep their connected-account routing and API version. Before making object-based requests, pass a key from secure configuration in the method call: ```python # Before customer = pickle.loads(trusted_cached_bytes) customer.refresh() # After customer = pickle.loads(trusted_cached_bytes) -customer.set_api_key(api_key) -customer.refresh() -``` -`set_api_key(api_key: str) -> None` requires a non-empty string and updates the receiver and every nested StripeObject in place. It preserves each object's current connected-account routing, API version, other requestor options, and HTTP-client configuration without changing `stripe.api_key`. Re-pickling the objects still omits SDK-held keys. +customer.refresh(api_key=api_key) +customer.save(api_key=api_key) -The helper intentionally overwrites differing keys throughout the graph. +customers = pickle.loads(trusted_cached_list_bytes) +for customer in customers.auto_paging_iter(api_key=api_key): + ... +``` +`refresh()`, `refresh_async()`, `save()`, and `auto_paging_iter()` on `ListObject`, `SearchResultObject`, and their V2 equivalents now accept an `api_key` keyword argument for this. Other instance methods, such as `delete()` and resource actions like `cancel()`, already accept request options like `api_key`. Passing a key does not change `stripe.api_key`. -### Global fallback and explicit rebinding -Plain `pickle.loads()` retains the existing fallback to global API-key configuration. If that is the intended credential, configuring it before loading is an alternative to the setter: +### Global fallback +Plain `pickle.loads()` retains the existing fallback to global API-key configuration. If that is the intended credential, configuring it before making requests is an alternative to passing a key per call: ```python stripe.api_key = api_key customer = pickle.loads(trusted_cached_bytes) customer.refresh() ``` - -A restored object therefore does not necessarily have `api_key is None`. When the global key is absent or inappropriate, use `set_api_key(api_key)` after loading to explicitly replace the key throughout the graph without modifying global configuration. - -The setter preserves the requestor configuration currently on each restored object. Pickling does not preserve the producer's custom HTTP client or other private requestor state, and the setter does not recover that configuration. diff --git a/stripe/_api_resource.py b/stripe/_api_resource.py index f67eb5f16..d301d6f5f 100644 --- a/stripe/_api_resource.py +++ b/stripe/_api_resource.py @@ -1,8 +1,11 @@ -from typing_extensions import Literal, Self, deprecated +from typing_extensions import Literal, Self, Unpack, deprecated from stripe._error import InvalidRequestError from stripe._stripe_object import StripeObject -from stripe._request_options import extract_options_from_dict +from stripe._request_options import ( + RequestOptions, + extract_options_from_dict, +) from stripe._api_mode import ApiMode from stripe._base_address import BaseAddress from stripe._api_requestor import _APIRequestor @@ -33,12 +36,18 @@ def retrieve(cls, id, **params) -> T: instance.refresh() return cast(T, instance) - def refresh(self) -> Self: - return self._request_and_refresh("get", self.instance_url()) + def refresh(self, **params: Unpack[RequestOptions]) -> Self: + return self._request_and_refresh( + "get", + self.instance_url(), + params={**self._retrieve_params, **params}, + ) - async def refresh_async(self) -> Self: + async def refresh_async(self, **params: Unpack[RequestOptions]) -> Self: return await self._request_and_refresh_async( - "get", self.instance_url() + "get", + self.instance_url(), + params={**self._retrieve_params, **params}, ) @classmethod diff --git a/stripe/_list_object.py b/stripe/_list_object.py index c88e7e078..6775cdd6c 100644 --- a/stripe/_list_object.py +++ b/stripe/_list_object.py @@ -116,13 +116,15 @@ def __len__(self) -> int: def __reversed__(self) -> Iterator[T]: return getattr(self, "data", []).__reversed__() - def auto_paging_iter(self) -> AnyIterator[T]: + def auto_paging_iter( + self, **params: Unpack[RequestOptions] + ) -> AnyIterator[T]: return AnyIterator( - self._auto_paging_iter(), - self._auto_paging_iter_async(), + self._auto_paging_iter(params), + self._auto_paging_iter_async(params), ) - def _auto_paging_iter(self) -> Iterator[T]: + def _auto_paging_iter(self, params: RequestOptions) -> Iterator[T]: page = self while True: @@ -132,16 +134,18 @@ def _auto_paging_iter(self) -> Iterator[T]: ): for item in reversed(page): yield item - page = page.previous_page() + page = page.previous_page(**params) else: for item in page: yield item - page = page.next_page() + page = page.next_page(**params) if page.is_empty: break - async def _auto_paging_iter_async(self) -> AsyncIterator[T]: + async def _auto_paging_iter_async( + self, params: RequestOptions + ) -> AsyncIterator[T]: page = self while True: @@ -151,11 +155,11 @@ async def _auto_paging_iter_async(self) -> AsyncIterator[T]: ): for item in reversed(page): yield item - page = await page.previous_page_async() + page = await page.previous_page_async(**params) else: for item in page: yield item - page = await page.next_page_async() + page = await page.next_page_async(**params) if page.is_empty: break diff --git a/stripe/_search_result_object.py b/stripe/_search_result_object.py index 75dc941d2..0ec51935e 100644 --- a/stripe/_search_result_object.py +++ b/stripe/_search_result_object.py @@ -86,29 +86,34 @@ def __iter__(self) -> Iterator[T]: def __len__(self) -> int: return getattr(self, "data", []).__len__() - def _auto_paging_iter(self) -> Iterator[T]: + def _auto_paging_iter(self, params: RequestOptions) -> Iterator[T]: page = self while True: for item in page: yield item - page = page.next_search_result_page() + page = page.next_search_result_page(**params) if page.is_empty: break - def auto_paging_iter(self) -> AnyIterator[T]: + def auto_paging_iter( + self, **params: Unpack[RequestOptions] + ) -> AnyIterator[T]: return AnyIterator( - self._auto_paging_iter(), self._auto_paging_iter_async() + self._auto_paging_iter(params), + self._auto_paging_iter_async(params), ) - async def _auto_paging_iter_async(self) -> AsyncIterator[T]: + async def _auto_paging_iter_async( + self, params: RequestOptions + ) -> AsyncIterator[T]: page = self while True: for item in page: yield item - page = await page.next_search_result_page_async() + page = await page.next_search_result_page_async(**params) if page.is_empty: break diff --git a/stripe/_stripe_object.py b/stripe/_stripe_object.py index 84935e58e..7cca7620c 100644 --- a/stripe/_stripe_object.py +++ b/stripe/_stripe_object.py @@ -148,41 +148,6 @@ def __init__( def api_key(self): return self._requestor.api_key - def set_api_key(self, api_key: str) -> None: - """Bind a non-empty API key to this payload graph in place. - - Updates every reachable StripeObject in payload lists and dictionaries, - including shared objects. Each object's account, API version, and other - requestor options are preserved; stripe.api_key is not changed. The key - must be authorized for those accounts. Subsequent pickles omit the key. - Only load pickle data from trusted sources. - """ - if not isinstance(cast(object, api_key), str) or not api_key: - raise ValueError("api_key must be a non-empty string") - - visited: Set[int] = set() - pending: List[Any] = [self] - while pending: - value = pending.pop() - identity = id(value) - if not isinstance(value, (StripeObject, list, dict)): - continue - if identity in visited: - continue - visited.add(identity) - - if isinstance(value, StripeObject): - value._requestor = ( - value._requestor._new_requestor_with_options( # pyright: ignore[reportPrivateUsage] - {"api_key": api_key} - ) - ) - pending.extend(value._data.values()) - elif isinstance(value, list): - pending.extend(cast(List[Any], value)) - else: - pending.extend(cast(Dict[Any, Any], value).values()) - @property def stripe_account(self): return self._requestor.stripe_account diff --git a/stripe/_updateable_api_resource.py b/stripe/_updateable_api_resource.py index 9f69deae5..dd05ac4cd 100644 --- a/stripe/_updateable_api_resource.py +++ b/stripe/_updateable_api_resource.py @@ -17,10 +17,11 @@ def modify(cls, sid, **params) -> T: @deprecated( "The `save` method is deprecated and will be removed in a future major version of the library. Use the class method `modify` on the resource instead." ) - def save(self, idempotency_key=None): + def save(self, idempotency_key=None, *, api_key=None): updated_params = self.serialize(None) if updated_params: updated_params["idempotency_key"] = idempotency_key + updated_params["api_key"] = api_key self._request_and_refresh( "post", self.instance_url(), diff --git a/stripe/v2/_list_object.py b/stripe/v2/_list_object.py index 08fcbd016..602a8fa7a 100644 --- a/stripe/v2/_list_object.py +++ b/stripe/v2/_list_object.py @@ -1,4 +1,7 @@ +from typing_extensions import Unpack + from stripe._any_iterator import AnyIterator +from stripe._request_options import RequestOptions from stripe._stripe_object import StripeObject from typing import AsyncIterator, Iterator, List, Optional, TypeVar, Generic @@ -41,13 +44,15 @@ def __len__(self): def __reversed__(self): return getattr(self, "data", []).__reversed__() - def auto_paging_iter(self) -> AnyIterator[T]: + def auto_paging_iter( + self, **params: Unpack[RequestOptions] + ) -> AnyIterator[T]: return AnyIterator( - self._auto_paging_iter(), - self._auto_paging_iter_async(), + self._auto_paging_iter(params), + self._auto_paging_iter_async(params), ) - def _auto_paging_iter(self) -> Iterator[T]: + def _auto_paging_iter(self, params: RequestOptions) -> Iterator[T]: page = self.data next_page_url = self.next_page_url while True: @@ -59,13 +64,16 @@ def _auto_paging_iter(self) -> Iterator[T]: result = self._request( "get", next_page_url, + params={**self._retrieve_params, **params}, base_address="api", ) assert isinstance(result, ListObject) page = result.data next_page_url = result.next_page_url - async def _auto_paging_iter_async(self) -> AsyncIterator[T]: + async def _auto_paging_iter_async( + self, params: RequestOptions + ) -> AsyncIterator[T]: page = self.data next_page_url = self.next_page_url while True: @@ -77,6 +85,7 @@ async def _auto_paging_iter_async(self) -> AsyncIterator[T]: result = await self._request_async( "get", next_page_url, + params={**self._retrieve_params, **params}, base_address="api", ) assert isinstance(result, ListObject) diff --git a/stripe/v2/_search_result_object.py b/stripe/v2/_search_result_object.py index 972aa382a..5dd20833b 100644 --- a/stripe/v2/_search_result_object.py +++ b/stripe/v2/_search_result_object.py @@ -10,7 +10,10 @@ TypeVar, ) +from typing_extensions import Unpack + from stripe._any_iterator import AnyIterator +from stripe._request_options import RequestOptions from stripe._stripe_object import StripeObject @@ -32,9 +35,12 @@ def __iter__(self) -> Iterator[T]: def __len__(self) -> int: return getattr(self, "data", []).__len__() - def auto_paging_iter(self) -> AnyIterator[T]: + def auto_paging_iter( + self, **params: Unpack[RequestOptions] + ) -> AnyIterator[T]: return AnyIterator( - self._auto_paging_iter(), self._auto_paging_iter_async() + self._auto_paging_iter(params), + self._auto_paging_iter_async(params), ) def _original_params(self) -> Mapping[str, Any]: @@ -45,7 +51,7 @@ def _body_params(self, params: Mapping[str, Any]) -> Mapping[str, Any]: body_params.pop("limit", None) return body_params - def _auto_paging_iter(self) -> Iterator[T]: + def _auto_paging_iter(self, options: RequestOptions) -> Iterator[T]: page: SearchResultObject[T] = self params = self._original_params() while True: @@ -56,13 +62,15 @@ def _auto_paging_iter(self) -> Iterator[T]: result = self._request( "post", page.next_page_url, - params=self._body_params(params), + params={**self._body_params(params), **options}, base_address="api", ) assert isinstance(result, SearchResultObject) page = result - async def _auto_paging_iter_async(self) -> AsyncIterator[T]: + async def _auto_paging_iter_async( + self, options: RequestOptions + ) -> AsyncIterator[T]: page: SearchResultObject[T] = self params = self._original_params() while True: @@ -73,7 +81,7 @@ async def _auto_paging_iter_async(self) -> AsyncIterator[T]: result = await self._request_async( "post", page.next_page_url, - params=self._body_params(params), + params={**self._body_params(params), **options}, base_address="api", ) assert isinstance(result, SearchResultObject) diff --git a/tests/api_resources/test_list_object.py b/tests/api_resources/test_list_object.py index 02568c4b7..243c06396 100644 --- a/tests/api_resources/test_list_object.py +++ b/tests/api_resources/test_list_object.py @@ -1,4 +1,5 @@ import json +import pickle import pytest @@ -297,6 +298,76 @@ def test_iter_two_pages(self, http_client_mock): assert seen == ["pm_123", "pm_124", "pm_125", "pm_126"] + @pytest.fixture + def restored_list(self, monkeypatch): + lo = stripe.ListObject.construct_from( + self.pageable_model_response(["pm_123"], True), + "producer-key-sentinel", + stripe_account="example-account", + ) + monkeypatch.setattr(stripe, "api_key", None) + return pickle.loads(pickle.dumps(lo)) + + def test_iter_uses_per_call_api_key_on_every_page( + self, http_client_mock, restored_list + ): + http_client_mock.stub_request( + "get", + path="/v1/pageablemodels", + query_string="starting_after=pm_123", + rbody=json.dumps(self.pageable_model_response(["pm_124"], True)), + ) + http_client_mock.stub_request( + "get", + path="/v1/pageablemodels", + query_string="starting_after=pm_124", + rbody=json.dumps(self.pageable_model_response(["pm_125"], False)), + ) + + seen = [ + item["id"] + for item in restored_list.auto_paging_iter( + api_key="worker-key-sentinel" + ) + ] + + assert seen == ["pm_123", "pm_124", "pm_125"] + for starting_after in ("pm_123", "pm_124"): + http_client_mock.assert_requested( + "get", + path="/v1/pageablemodels", + query_string=f"starting_after={starting_after}", + api_key="worker-key-sentinel", + stripe_account="example-account", + ) + + def test_iter_reverse_uses_per_call_api_key( + self, http_client_mock, restored_list + ): + restored_list._retrieve_params = {"ending_before": "pm_124"} + http_client_mock.stub_request( + "get", + path="/v1/pageablemodels", + query_string="ending_before=pm_123", + rbody=json.dumps(self.pageable_model_response(["pm_122"], False)), + ) + + seen = [ + item["id"] + for item in restored_list.auto_paging_iter( + api_key="worker-key-sentinel" + ) + ] + + assert seen == ["pm_123", "pm_122"] + http_client_mock.assert_requested( + "get", + path="/v1/pageablemodels", + query_string="ending_before=pm_123", + api_key="worker-key-sentinel", + stripe_account="example-account", + ) + def test_iter_reverse(self, http_client_mock): lo = stripe.ListObject.construct_from( self.pageable_model_response(["pm_125", "pm_126"], True), "mykey" @@ -655,6 +726,38 @@ async def test_iter_one_page(self, http_client_mock): assert seen == ["pm_123", "pm_124"] + @pytest.mark.anyio + async def test_iter_uses_per_call_api_key( + self, http_client_mock, monkeypatch + ): + lo = stripe.ListObject.construct_from( + self.pageable_model_response(["pm_123"], True), + "producer-key-sentinel", + ) + monkeypatch.setattr(stripe, "api_key", None) + restored = pickle.loads(pickle.dumps(lo)) + http_client_mock.stub_request( + "get", + path="/v1/pageablemodels", + query_string="starting_after=pm_123", + rbody=json.dumps(self.pageable_model_response(["pm_124"], False)), + ) + + seen = [ + item["id"] + async for item in restored.auto_paging_iter( + api_key="worker-key-sentinel" + ) + ] + + assert seen == ["pm_123", "pm_124"] + http_client_mock.assert_requested( + "get", + path="/v1/pageablemodels", + query_string="starting_after=pm_123", + api_key="worker-key-sentinel", + ) + @pytest.mark.anyio async def test_iter_two_pages(self, http_client_mock): lo = stripe.ListObject.construct_from( diff --git a/tests/api_resources/test_list_object_v2.py b/tests/api_resources/test_list_object_v2.py index 84759e873..34da50c1d 100644 --- a/tests/api_resources/test_list_object_v2.py +++ b/tests/api_resources/test_list_object_v2.py @@ -1,6 +1,7 @@ from __future__ import absolute_import, division, print_function import json +import pickle import pytest @@ -147,6 +148,37 @@ def test_iter_forwards_api_key(self, http_client_mock: HTTPClientMock): api_key="sk_test_iter_forwards_options", ) + def test_iter_uses_per_call_api_key(self, http_client_mock, monkeypatch): + lo = ListObject.construct_from( + self.pageable_model_response( + ["pm_123"], "/v2/pageablemodels?page=page_2" + ), + "producer-key-sentinel", + ) + monkeypatch.setattr(stripe, "api_key", None) + restored = pickle.loads(pickle.dumps(lo)) + http_client_mock.stub_request( + "get", + path="/v2/pageablemodels", + query_string="page=page_2", + rbody=json.dumps(self.pageable_model_response(["pm_124"], None)), + ) + + seen = [ + item["id"] + for item in restored.auto_paging_iter( + api_key="worker-key-sentinel" + ) + ] + + assert seen == ["pm_123", "pm_124"] + http_client_mock.assert_requested( + "get", + path="/v2/pageablemodels", + query_string="page=page_2", + api_key="worker-key-sentinel", + ) + class TestAutoPagingAsync: @staticmethod diff --git a/tests/api_resources/test_search_result_object.py b/tests/api_resources/test_search_result_object.py index 9db977921..af31b3bd6 100644 --- a/tests/api_resources/test_search_result_object.py +++ b/tests/api_resources/test_search_result_object.py @@ -1,4 +1,5 @@ import json +import pickle import pytest @@ -256,6 +257,39 @@ def test_iter_two_pages(self, http_client_mock): assert seen == ["pm_123", "pm_124", "pm_125", "pm_126"] + def test_iter_uses_per_call_api_key(self, http_client_mock, monkeypatch): + sro = stripe.SearchResultObject.construct_from( + self.pageable_model_response(["pm_123"], True, "token"), + "producer-key-sentinel", + stripe_account="example-account", + ) + monkeypatch.setattr(stripe, "api_key", None) + restored = pickle.loads(pickle.dumps(sro)) + http_client_mock.stub_request( + "get", + path="/v1/pageablemodels", + query_string="page=token", + rbody=json.dumps( + self.pageable_model_response(["pm_124"], False, None) + ), + ) + + seen = [ + item["id"] + for item in restored.auto_paging_iter( + api_key="worker-key-sentinel" + ) + ] + + assert seen == ["pm_123", "pm_124"] + http_client_mock.assert_requested( + "get", + path="/v1/pageablemodels", + query_string="page=token", + api_key="worker-key-sentinel", + stripe_account="example-account", + ) + class TestAutoPagingAsync: @staticmethod diff --git a/tests/test_stripe_object.py b/tests/test_stripe_object.py index 2fcf72097..edf902359 100644 --- a/tests/test_stripe_object.py +++ b/tests/test_stripe_object.py @@ -322,114 +322,18 @@ def test_pickling(self, protocol, destination_key, monkeypatch): assert obj.to_dict() == payload restored_nested = restored.containers[0]["nested"] - metadata = restored.metadata - - assert restored.set_api_key("worker-key-sentinel") is None - - for value in (restored, metadata, restored_nested): - assert value.api_key == "worker-key-sentinel" - assert "api_key" not in value._data - assert restored.metadata is metadata - assert restored.containers[0]["nested"] is restored_nested - assert restored.to_dict() == payload - assert restored.stripe_account == "example-account" - assert metadata.stripe_account == "example-account" - assert restored.stripe_version == "2017-08-15" - assert metadata.stripe_version == "2017-08-15" + assert restored_nested.api_key == destination_key assert restored_nested.stripe_account == "nested-example-account" assert restored_nested.stripe_version == "2018-02-28" - assert stripe.api_key == destination_key - assert obj.api_key == "producer-key-sentinel" - assert nested.api_key == "nested-key-sentinel" pickled = pickle.dumps(restored, protocol=protocol) for key in ( b"producer-key-sentinel", b"nested-key-sentinel", b"destination-key-sentinel", - b"worker-key-sentinel", ): assert key not in pickled - def test_set_api_key_preserves_requestor_context(self, mocker): - client = stripe.StripeClient( - "producer-key-sentinel", - http_client=mocker.Mock(), - max_network_retries=3, - ) - obj = StripeObject(_requestor=client._requestor) - nested_client = stripe.StripeClient( - "nested-key-sentinel", - http_client=mocker.Mock(), - max_network_retries=1, - stripe_account="nested-example-account", - stripe_version="2018-02-28", - ) - nested = StripeObject(_requestor=nested_client._requestor) - obj.containers = [{"nested": nested}] - unrelated = StripeObject(_requestor=obj._requestor) - original_requestor = obj._requestor - obj._previous = {"unrelated": unrelated} - obj.arbitrary = mocker.Mock(child=unrelated) - originals = [ - (value, value._requestor, value._unsaved_values.copy()) - for value in (obj, nested) - ] - - obj.set_api_key("worker-key-sentinel") - - for value, requestor, unsaved_values in originals: - assert value._requestor is not requestor - assert value._requestor._client is requestor._client - assert value._requestor._options.to_dict() == { - **requestor._options.to_dict(), - "api_key": "worker-key-sentinel", - } - assert value._unsaved_values == unsaved_values - assert unrelated._requestor is original_requestor - assert unrelated.api_key == "producer-key-sentinel" - - def test_set_api_key_shared_references_and_cycles(self, mocker): - obj = StripeObject(api_key="producer-key-sentinel") - nested = StripeObject(api_key="nested-key-sentinel") - containers = [{"nested": nested, "root": obj}] - containers.append(containers) - containers[0]["self"] = containers[0] - obj.update({"containers": containers, "shared": nested}) - nested.root = obj - root_merge = mocker.spy(obj._requestor, "_new_requestor_with_options") - nested_merge = mocker.spy( - nested._requestor, "_new_requestor_with_options" - ) - - obj.set_api_key("worker-key-sentinel") - - assert obj.api_key == nested.api_key == "worker-key-sentinel" - assert obj.containers is containers - assert containers[0]["nested"] is obj.shared is nested - assert containers[0]["root"] is nested.root is obj - assert containers[1] is containers - assert containers[0]["self"] is containers[0] - root_merge.assert_called_once_with({"api_key": "worker-key-sentinel"}) - nested_merge.assert_called_once_with( - {"api_key": "worker-key-sentinel"} - ) - - @pytest.mark.parametrize("key", ["", None, 0, False, b"key"]) - def test_set_api_key_rejects_invalid_key_before_mutation(self, key): - obj = StripeObject.construct_from( - {"metadata": {"value": "example"}}, "producer-key-sentinel" - ) - requestor = obj._requestor - nested_requestor = obj.metadata._requestor - - with pytest.raises(ValueError, match="non-empty string"): - obj.set_api_key(key) - - assert obj._requestor is requestor - assert obj.metadata._requestor is nested_requestor - assert obj.api_key == obj.metadata.api_key == "producer-key-sentinel" - def test_pickling_verified_webhook(self, monkeypatch): payload = json.dumps( { @@ -451,8 +355,6 @@ def test_pickling_verified_webhook(self, monkeypatch): monkeypatch.setattr(stripe, "api_key", "destination-key-sentinel") restored = pickle.loads(pickled) - restored.set_api_key("worker-key-sentinel") - assert type(restored) is stripe.Event assert type(restored.data.object) is stripe.Customer assert restored.to_dict() == event.to_dict() @@ -462,30 +364,147 @@ def test_pickling_verified_webhook(self, monkeypatch): restored.data.object, restored.data.object.metadata, ): - assert value.api_key == "worker-key-sentinel" - assert stripe.api_key == "destination-key-sentinel" + assert value.api_key == "destination-key-sentinel" assert event.api_key == "producer-key-sentinel" - assert b"worker-key-sentinel" not in pickle.dumps(restored) - def test_set_api_key_refresh_uses_bound_key( - self, http_client_mock, monkeypatch - ): + @pytest.fixture + def restored_customer(self, monkeypatch): obj = stripe.Customer.construct_from( - {"id": "resource", "object": "customer"}, "producer-key-sentinel" + {"id": "resource", "object": "customer"}, + "producer-key-sentinel", + stripe_account="example-account", ) - monkeypatch.setattr(stripe, "api_key", "destination-key-sentinel") - restored = pickle.loads(pickle.dumps(obj)) + monkeypatch.setattr(stripe, "api_key", None) + return pickle.loads(pickle.dumps(obj)) + + def test_restored_refresh_without_key_raises( + self, http_client_mock, restored_customer + ): + with pytest.raises(stripe.AuthenticationError): + restored_customer.refresh() + + http_client_mock.assert_no_request() + + def test_restored_refresh_uses_per_call_api_key( + self, http_client_mock, restored_customer + ): + http_client_mock.stub_request( + "get", + path="/v1/customers/resource", + rbody='{"id": "resource", "object": "customer", "description": "refreshed"}', + ) + + assert ( + restored_customer.refresh(api_key="worker-key-sentinel") + is restored_customer + ) + + http_client_mock.assert_requested( + "get", + path="/v1/customers/resource", + api_key="worker-key-sentinel", + stripe_account="example-account", + ) + assert restored_customer.description == "refreshed" + assert stripe.api_key is None + assert b"worker-key-sentinel" not in pickle.dumps(restored_customer) + + @pytest.mark.anyio + async def test_restored_refresh_async_uses_per_call_api_key( + self, http_client_mock, restored_customer + ): http_client_mock.stub_request( "get", path="/v1/customers/resource", rbody='{"id": "resource", "object": "customer"}', ) - restored.set_api_key("worker-key-sentinel") - restored.refresh() + await restored_customer.refresh_async(api_key="worker-key-sentinel") - http_client_mock.assert_requested(api_key="worker-key-sentinel") - assert stripe.api_key == "destination-key-sentinel" + http_client_mock.assert_requested( + "get", + path="/v1/customers/resource", + api_key="worker-key-sentinel", + stripe_account="example-account", + ) + + def test_refresh_preserves_retrieve_params(self, http_client_mock): + obj = stripe.Customer( + "resource", "producer-key-sentinel", expand=["sources"] + ) + http_client_mock.stub_request( + "get", + path="/v1/customers/resource", + query_string="expand[0]=sources", + rbody='{"id": "resource", "object": "customer"}', + ) + + obj.refresh(api_key="worker-key-sentinel") + + http_client_mock.assert_requested( + "get", + path="/v1/customers/resource", + query_string="expand[0]=sources", + api_key="worker-key-sentinel", + ) + + def test_restored_save_uses_per_call_api_key( + self, http_client_mock, restored_customer + ): + restored_customer.description = "changed" + http_client_mock.stub_request( + "post", + path="/v1/customers/resource", + rbody='{"id": "resource", "object": "customer", "description": "changed"}', + ) + + restored_customer.save(api_key="worker-key-sentinel") + + http_client_mock.assert_requested( + "post", + path="/v1/customers/resource", + api_key="worker-key-sentinel", + stripe_account="example-account", + ) + + def test_save_keeps_positional_idempotency_key( + self, http_client_mock, restored_customer + ): + restored_customer.description = "changed" + http_client_mock.stub_request( + "post", + path="/v1/customers/resource", + rbody='{"id": "resource", "object": "customer", "description": "changed"}', + ) + + restored_customer.save( + "idempotency-sentinel", api_key="worker-key-sentinel" + ) + + http_client_mock.assert_requested( + "post", + path="/v1/customers/resource", + api_key="worker-key-sentinel", + idempotency_key="idempotency-sentinel", + ) + + def test_restored_delete_uses_per_call_api_key( + self, http_client_mock, restored_customer + ): + http_client_mock.stub_request( + "delete", + path="/v1/customers/resource", + rbody='{"id": "resource", "object": "customer", "deleted": true}', + ) + + restored_customer.delete(api_key="worker-key-sentinel") + + http_client_mock.assert_requested( + "delete", + path="/v1/customers/resource", + api_key="worker-key-sentinel", + stripe_account="example-account", + ) def test_deletion(self): obj = StripeObject("id", "key") diff --git a/tests/test_v2_search_result_object.py b/tests/test_v2_search_result_object.py index 002b606d8..7fc413267 100644 --- a/tests/test_v2_search_result_object.py +++ b/tests/test_v2_search_result_object.py @@ -86,3 +86,23 @@ async def test_async_auto_paging_replays_original_post_body(): "/v2/widgets/search?page=2", {"query": "widgets"}, ) + + +def test_auto_paging_passes_per_call_options_to_every_page(): + first = make_result(["one"], "/v2/widgets/search?page=2") + first._retrieve_params = {"query": "widgets"} + TestSearchResult.requests = [] + TestSearchResult.pages = [ + make_result(["two"], "/v2/widgets/search?page=3"), + make_result(["three"], None), + ] + + assert list(first.auto_paging_iter(api_key="worker-key-sentinel")) == [ + "one", + "two", + "three", + ] + assert [body for _, _, body, _ in TestSearchResult.requests] == [ + {"query": "widgets", "api_key": "worker-key-sentinel"}, + {"query": "widgets", "api_key": "worker-key-sentinel"}, + ] From b57fe370c22a1910d2c90c69e05ae24654376410 Mon Sep 17 00:00:00 2001 From: Zachary Chua Date: Tue, 3 Nov 2026 10:21:19 -0800 Subject: [PATCH 10/10] =?UTF-8?q?[=F0=9F=AA=BF]=20Trim=20pickle=20credenti?= =?UTF-8?q?al=20changefile=20wording?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Committed-By-Agent: claude --- .../changes/2026-10-30_zacchua_omit-pickled-api-keys.change.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.hark/changes/2026-10-30_zacchua_omit-pickled-api-keys.change.md b/.hark/changes/2026-10-30_zacchua_omit-pickled-api-keys.change.md index f67da5729..7e21096aa 100644 --- a/.hark/changes/2026-10-30_zacchua_omit-pickled-api-keys.change.md +++ b/.hark/changes/2026-10-30_zacchua_omit-pickled-api-keys.change.md @@ -2,4 +2,4 @@ title: Omit API keys from pickled StripeObjects semver_level: major --- -StripeObject pickles no longer preserve API keys. Applications making object-based requests after cache or queue round trips must supply credentials, either through global configuration or per call, e.g. `customer.refresh(api_key=api_key)`. `refresh()`, `refresh_async()`, `save()`, and `auto_paging_iter()` on list and search result objects now accept an `api_key` keyword argument for this. This is a breaking authentication change. See the [v17 migration guide](https://github.com/stripe/stripe-python/blob/master/.hark/migration-guides/v17.md#api-keys-are-no-longer-preserved-in-pickled-stripeobjects). +StripeObject pickles no longer preserve API keys. Applications making object-based requests after cache or queue round trips must supply credentials, either through global configuration or per call, e.g. `customer.refresh(api_key=api_key)`. `refresh()`, `refresh_async()`, `save()`, and `auto_paging_iter()` on list and search result objects now accept an `api_key` keyword argument for this. See the [v17 migration guide](https://github.com/stripe/stripe-python/blob/master/.hark/migration-guides/v17.md#api-keys-are-no-longer-preserved-in-pickled-stripeobjects).