From af8b3c280c9d811f83a77fdf12b26596bcf05a1b Mon Sep 17 00:00:00 2001 From: David Brownman Date: Fri, 11 Jul 2025 16:38:08 -0700 Subject: [PATCH 01/17] wip generating push payload events [skip ci] --- stripe/_api_requestor.py | 2 +- stripe/_stripe_client.py | 10 ++++-- ...ling_meter_error_report_triggered_event.py | 16 +++++++++- .../_v1_billing_meter_no_meter_found_event.py | 10 +++++- .../_v2_core_event_destination_ping_event.py | 16 +++++++++- stripe/v2/_event.py | 32 +++++++++++++------ 6 files changed, 70 insertions(+), 16 deletions(-) diff --git a/stripe/_api_requestor.py b/stripe/_api_requestor.py index c0fa8a561..4ccf3f874 100644 --- a/stripe/_api_requestor.py +++ b/stripe/_api_requestor.py @@ -821,7 +821,7 @@ async def request_raw_async( return rcontent, rcode, rheaders - def _should_handle_code_as_error(self, rcode): + def _should_handle_code_as_error(self, rcode: int) -> bool: return not 200 <= rcode < 300 def _interpret_response( diff --git a/stripe/_stripe_client.py b/stripe/_stripe_client.py index e6fb417c9..4bd6d4508 100644 --- a/stripe/_stripe_client.py +++ b/stripe/_stripe_client.py @@ -283,7 +283,7 @@ def parse_thin_event( WebhookSignature.verify_header(payload, sig_header, secret, tolerance) - return ThinEvent(payload) + return ThinEvent(payload, self) def construct_event( self, @@ -314,6 +314,8 @@ def raw_request(self, method_: str, url_: str, **params): stripe_context = params.pop("stripe_context", None) + usage = params.pop("usage", None) + # stripe-context goes *here* and not in api_requestor. Properties # go on api_requestor when you want them to persist onto requests # made when you call instance methods on APIResources that come from @@ -330,7 +332,8 @@ def raw_request(self, method_: str, url_: str, **params): options=options, base_address=base_address, api_mode=api_mode, - usage=["raw_request"], + # we manually pass usage in event internals, so use those if available + usage=usage or ["raw_request"], ) return self._requestor._interpret_response( @@ -364,6 +367,9 @@ def deserialize( *, api_mode: ApiMode, ) -> StripeObject: + """ + Used to translate the result of a `raw_request` into a StripeObject. + """ return _convert_to_stripe_object( resp=resp, params=params, diff --git a/stripe/events/_v1_billing_meter_error_report_triggered_event.py b/stripe/events/_v1_billing_meter_error_report_triggered_event.py index a3af378fb..0635bc7d1 100644 --- a/stripe/events/_v1_billing_meter_error_report_triggered_event.py +++ b/stripe/events/_v1_billing_meter_error_report_triggered_event.py @@ -5,11 +5,25 @@ from stripe._stripe_object import StripeObject from stripe._stripe_response import StripeResponse from stripe.billing._meter import Meter -from stripe.v2._event import Event +from stripe.v2._event import Event, ThinEvent from typing import Any, Dict, List, Optional, cast from typing_extensions import Literal +class PushedV1BillingMeterErrorReportTriggeredEvent(ThinEvent): + LOOKUP_TYPE = "v1.billing.meter.error_report_triggered" + type: Literal["v1.billing.meter.error_report_triggered"] + + def pull(self) -> V1BillingMeterErrorReportTriggeredEvent: + return super() + + def fetch_related_object(self) -> Meter: + return super() + + async def fetch_related_object_async(self) -> Meter: + pass + + class V1BillingMeterErrorReportTriggeredEvent(Event): LOOKUP_TYPE = "v1.billing.meter.error_report_triggered" type: Literal["v1.billing.meter.error_report_triggered"] diff --git a/stripe/events/_v1_billing_meter_no_meter_found_event.py b/stripe/events/_v1_billing_meter_no_meter_found_event.py index 5f8d64479..cfeaac06e 100644 --- a/stripe/events/_v1_billing_meter_no_meter_found_event.py +++ b/stripe/events/_v1_billing_meter_no_meter_found_event.py @@ -4,11 +4,19 @@ from stripe._api_requestor import _APIRequestor from stripe._stripe_object import StripeObject from stripe._stripe_response import StripeResponse -from stripe.v2._event import Event +from stripe.v2._event import Event, ThinEvent from typing import Any, Dict, List, Optional from typing_extensions import Literal +class PushedV1BillingMeterNoMeterFoundEvent(ThinEvent): + LOOKUP_TYPE = "v1.billing.meter.no_meter_found" + type: Literal["v1.billing.meter.no_meter_found"] + + def pull(self) -> V1BillingMeterNoMeterFoundEvent: + return super() + + class V1BillingMeterNoMeterFoundEvent(Event): LOOKUP_TYPE = "v1.billing.meter.no_meter_found" type: Literal["v1.billing.meter.no_meter_found"] diff --git a/stripe/events/_v2_core_event_destination_ping_event.py b/stripe/events/_v2_core_event_destination_ping_event.py index f5280152b..078fdada3 100644 --- a/stripe/events/_v2_core_event_destination_ping_event.py +++ b/stripe/events/_v2_core_event_destination_ping_event.py @@ -1,12 +1,26 @@ # -*- coding: utf-8 -*- # File generated from our OpenAPI spec from stripe._stripe_object import StripeObject -from stripe.v2._event import Event +from stripe.v2._event import Event, ThinEvent from stripe.v2._event_destination import EventDestination from typing import cast from typing_extensions import Literal +class PushedV2CoreEventDestinationPingEvent(ThinEvent): + LOOKUP_TYPE = "v2.core.event_destination.ping" + type: Literal["v2.core.event_destination.ping"] + + def pull(self) -> V2CoreEventDestinationPingEvent: + return super() + + def fetch_related_object(self) -> "EventDestination": + return super() + + async def fetch_related_object_async(self) -> EventDestination: + pass + + class V2CoreEventDestinationPingEvent(Event): LOOKUP_TYPE = "v2.core.event_destination.ping" type: Literal["v2.core.event_destination.ping"] diff --git a/stripe/v2/_event.py b/stripe/v2/_event.py index 0e46a2b66..9ed4f2e8c 100644 --- a/stripe/v2/_event.py +++ b/stripe/v2/_event.py @@ -5,6 +5,7 @@ from typing_extensions import Literal +from stripe._stripe_client import StripeClient from stripe._stripe_object import StripeObject # This describes the common format for the pull payload of a V2 ThinEvent @@ -115,26 +116,26 @@ class ThinEvent: """ created: str """ - Livemode indicates if the event is from a production(true) or test(false) account. + Time at which the object was created. """ livemode: bool """ - Time at which the object was created. + Livemode indicates if the event is from a production(true) or test(false) account. """ context: Optional[str] = None """ [Optional] Authentication context needed to fetch the event or related object. """ - related_object: Optional[RelatedObject] = None - """ - [Optional] Object containing the reference to API resource relevant to the event. - """ + # related_object: Optional[RelatedObject] = None + # """ + # [Optional] Object containing the reference to API resource relevant to the event. + # """ reason: Optional[Reason] = None """ [Optional] Reason for the event. """ - def __init__(self, payload: str) -> None: + def __init__(self, payload: str, client: StripeClient) -> None: parsed = json.loads(payload) self.id = parsed["id"] @@ -142,10 +143,21 @@ def __init__(self, payload: str) -> None: self.created = parsed["created"] self.livemode = parsed.get("livemode") self.context = parsed.get("context") - if parsed.get("related_object"): - self.related_object = RelatedObject(parsed["related_object"]) + # if parsed.get("related_object"): + # self.related_object = RelatedObject(parsed["related_object"]) if parsed.get("reason"): self.reason = Reason(parsed["reason"]) + self.client = client + def __repr__(self) -> str: - return f"" + return f"" + + def pull(self): # TODO: general return type? + response = self.client.raw_request( + "get", + f"/v2/core/events/{self.id}", + stripe_context=self.context, + usage=["pushed_event_pull"], + ) + return self.client.deserialize(response, api_mode="V2") From 4a4527c5ebbd8afda047dfd4e6791713134c486e Mon Sep 17 00:00:00 2001 From: David Brownman Date: Thu, 17 Jul 2025 16:31:44 -0700 Subject: [PATCH 02/17] improve generation [skip ci] --- ...ling_meter_error_report_triggered_event.py | 35 +++++++++++++++---- .../_v1_billing_meter_no_meter_found_event.py | 15 ++++++-- .../_v2_core_event_destination_ping_event.py | 34 ++++++++++++++---- stripe/v2/_event.py | 15 ++++++-- 4 files changed, 79 insertions(+), 20 deletions(-) diff --git a/stripe/events/_v1_billing_meter_error_report_triggered_event.py b/stripe/events/_v1_billing_meter_error_report_triggered_event.py index 0635bc7d1..685209c72 100644 --- a/stripe/events/_v1_billing_meter_error_report_triggered_event.py +++ b/stripe/events/_v1_billing_meter_error_report_triggered_event.py @@ -1,11 +1,13 @@ # -*- coding: utf-8 -*- # File generated from our OpenAPI spec +import json from stripe._api_mode import ApiMode from stripe._api_requestor import _APIRequestor +from stripe._stripe_client import StripeClient from stripe._stripe_object import StripeObject from stripe._stripe_response import StripeResponse from stripe.billing._meter import Meter -from stripe.v2._event import Event, ThinEvent +from stripe.v2._event import Event, RelatedObject, ThinEvent from typing import Any, Dict, List, Optional, cast from typing_extensions import Literal @@ -13,15 +15,34 @@ class PushedV1BillingMeterErrorReportTriggeredEvent(ThinEvent): LOOKUP_TYPE = "v1.billing.meter.error_report_triggered" type: Literal["v1.billing.meter.error_report_triggered"] + related_object: RelatedObject - def pull(self) -> V1BillingMeterErrorReportTriggeredEvent: - return super() + def __init__(self, payload: str, client: StripeClient) -> None: + super().__init__( + payload, + client, + ) + # don't love the double json parse here, but it's fine + parsed = json.loads(payload) + self.related_object = RelatedObject(parsed["related_object"]) - def fetch_related_object(self) -> Meter: - return super() + def pull(self) -> "V1BillingMeterErrorReportTriggeredEvent": + return cast( + "V1BillingMeterErrorReportTriggeredEvent", + super().pull(), + ) + + def fetch_related_object(self) -> "Meter": + raise NotImplementedError() # TODO + + async def pull_async(self) -> "V1BillingMeterErrorReportTriggeredEvent": + return cast( + "V1BillingMeterErrorReportTriggeredEvent", + await super().pull_async(), + ) - async def fetch_related_object_async(self) -> Meter: - pass + def fetch_related_object_async(self) -> "Meter": + raise NotImplementedError() # TODO class V1BillingMeterErrorReportTriggeredEvent(Event): diff --git a/stripe/events/_v1_billing_meter_no_meter_found_event.py b/stripe/events/_v1_billing_meter_no_meter_found_event.py index cfeaac06e..5b8943ba6 100644 --- a/stripe/events/_v1_billing_meter_no_meter_found_event.py +++ b/stripe/events/_v1_billing_meter_no_meter_found_event.py @@ -5,7 +5,7 @@ from stripe._stripe_object import StripeObject from stripe._stripe_response import StripeResponse from stripe.v2._event import Event, ThinEvent -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, cast from typing_extensions import Literal @@ -13,8 +13,17 @@ class PushedV1BillingMeterNoMeterFoundEvent(ThinEvent): LOOKUP_TYPE = "v1.billing.meter.no_meter_found" type: Literal["v1.billing.meter.no_meter_found"] - def pull(self) -> V1BillingMeterNoMeterFoundEvent: - return super() + def pull(self) -> "V1BillingMeterNoMeterFoundEvent": + return cast( + "V1BillingMeterNoMeterFoundEvent", + super().pull(), + ) + + async def pull_async(self) -> "V1BillingMeterNoMeterFoundEvent": + return cast( + "V1BillingMeterNoMeterFoundEvent", + await super().pull_async(), + ) class V1BillingMeterNoMeterFoundEvent(Event): diff --git a/stripe/events/_v2_core_event_destination_ping_event.py b/stripe/events/_v2_core_event_destination_ping_event.py index 078fdada3..96bfce1c9 100644 --- a/stripe/events/_v2_core_event_destination_ping_event.py +++ b/stripe/events/_v2_core_event_destination_ping_event.py @@ -1,24 +1,44 @@ # -*- coding: utf-8 -*- # File generated from our OpenAPI spec +import json +from stripe._stripe_client import StripeClient from stripe._stripe_object import StripeObject -from stripe.v2._event import Event, ThinEvent +from stripe.v2._event import Event, RelatedObject, ThinEvent from stripe.v2._event_destination import EventDestination -from typing import cast from typing_extensions import Literal class PushedV2CoreEventDestinationPingEvent(ThinEvent): LOOKUP_TYPE = "v2.core.event_destination.ping" type: Literal["v2.core.event_destination.ping"] + related_object: RelatedObject + + def __init__(self, payload: str, client: StripeClient) -> None: + super().__init__( + payload, + client, + ) + # don't love the double json parse here, but it's fine + parsed = json.loads(payload) + self.related_object = RelatedObject(parsed["related_object"]) - def pull(self) -> V2CoreEventDestinationPingEvent: - return super() + def pull(self) -> "V2CoreEventDestinationPingEvent": + return cast( + "V2CoreEventDestinationPingEvent", + super().pull(), + ) def fetch_related_object(self) -> "EventDestination": - return super() + raise NotImplementedError() # TODO + + async def pull_async(self) -> "V2CoreEventDestinationPingEvent": + return cast( + "V2CoreEventDestinationPingEvent", + await super().pull_async(), + ) - async def fetch_related_object_async(self) -> EventDestination: - pass + def fetch_related_object_async(self) -> "EventDestination": + raise NotImplementedError() # TODO class V2CoreEventDestinationPingEvent(Event): diff --git a/stripe/v2/_event.py b/stripe/v2/_event.py index 9ed4f2e8c..10e8b418b 100644 --- a/stripe/v2/_event.py +++ b/stripe/v2/_event.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- import json -from typing import ClassVar, Optional +from typing import ClassVar, Optional, cast from typing_extensions import Literal @@ -153,11 +153,20 @@ def __init__(self, payload: str, client: StripeClient) -> None: def __repr__(self) -> str: return f"" - def pull(self): # TODO: general return type? + def pull(self) -> Event: response = self.client.raw_request( "get", f"/v2/core/events/{self.id}", stripe_context=self.context, usage=["pushed_event_pull"], ) - return self.client.deserialize(response, api_mode="V2") + return cast(Event, self.client.deserialize(response, api_mode="V2")) + + async def pull_async(self) -> Event: + response = await self.client.raw_request_async( + "get", + f"/v2/core/events/{self.id}", + stripe_context=self.context, + usage=["pushed_event_pull", "pushed_event_pull_async"], + ) + return cast(Event, self.client.deserialize(response, api_mode="V2")) From 73d401fef0a854ef119dda2d97ecba5b3211bce1 Mon Sep 17 00:00:00 2001 From: David Brownman Date: Tue, 29 Jul 2025 10:19:49 -0700 Subject: [PATCH 03/17] generate related object method --- stripe/_api_requestor.py | 12 ++++++---- ...ling_meter_error_report_triggered_event.py | 22 +++++++++++++++--- .../_v2_core_event_destination_ping_event.py | 23 ++++++++++++++++--- 3 files changed, 47 insertions(+), 10 deletions(-) diff --git a/stripe/_api_requestor.py b/stripe/_api_requestor.py index 4ccf3f874..8104ff43e 100644 --- a/stripe/_api_requestor.py +++ b/stripe/_api_requestor.py @@ -551,10 +551,14 @@ def _args_for_request_with_retries( "questions." ) - abs_url = "%s%s" % ( - self._options.base_addresses.get(base_address), - url, - ) + # we're passed full urls from thin events, so we should just call those directly + if url.startswith("https://"): + abs_url = url + else: + abs_url = "%s%s" % ( + self._options.base_addresses.get(base_address), + url, + ) params = params or {} if params and (method == "get" or method == "delete"): diff --git a/stripe/events/_v1_billing_meter_error_report_triggered_event.py b/stripe/events/_v1_billing_meter_error_report_triggered_event.py index 685209c72..65c723406 100644 --- a/stripe/events/_v1_billing_meter_error_report_triggered_event.py +++ b/stripe/events/_v1_billing_meter_error_report_triggered_event.py @@ -33,7 +33,15 @@ def pull(self) -> "V1BillingMeterErrorReportTriggeredEvent": ) def fetch_related_object(self) -> "Meter": - raise NotImplementedError() # TODO + return cast( + "Meter", + self.client.raw_request( + "get", + self.related_object.url, + stripe_context=self.context, + usage=["fetch_related_object"], + ), + ) async def pull_async(self) -> "V1BillingMeterErrorReportTriggeredEvent": return cast( @@ -41,8 +49,16 @@ async def pull_async(self) -> "V1BillingMeterErrorReportTriggeredEvent": await super().pull_async(), ) - def fetch_related_object_async(self) -> "Meter": - raise NotImplementedError() # TODO + async def fetch_related_object_async(self) -> "Meter": + return cast( + "Meter", + await self.client.raw_request_async( + "get", + self.related_object.url, + stripe_context=self.context, + usage=["fetch_related_object"], + ), + ) class V1BillingMeterErrorReportTriggeredEvent(Event): diff --git a/stripe/events/_v2_core_event_destination_ping_event.py b/stripe/events/_v2_core_event_destination_ping_event.py index 96bfce1c9..6280b87b2 100644 --- a/stripe/events/_v2_core_event_destination_ping_event.py +++ b/stripe/events/_v2_core_event_destination_ping_event.py @@ -5,6 +5,7 @@ from stripe._stripe_object import StripeObject from stripe.v2._event import Event, RelatedObject, ThinEvent from stripe.v2._event_destination import EventDestination +from typing import cast from typing_extensions import Literal @@ -29,7 +30,15 @@ def pull(self) -> "V2CoreEventDestinationPingEvent": ) def fetch_related_object(self) -> "EventDestination": - raise NotImplementedError() # TODO + return cast( + "EventDestination", + self.client.raw_request( + "get", + self.related_object.url, + stripe_context=self.context, + usage=["fetch_related_object"], + ), + ) async def pull_async(self) -> "V2CoreEventDestinationPingEvent": return cast( @@ -37,8 +46,16 @@ async def pull_async(self) -> "V2CoreEventDestinationPingEvent": await super().pull_async(), ) - def fetch_related_object_async(self) -> "EventDestination": - raise NotImplementedError() # TODO + async def fetch_related_object_async(self) -> "EventDestination": + return cast( + "EventDestination", + await self.client.raw_request_async( + "get", + self.related_object.url, + stripe_context=self.context, + usage=["fetch_related_object"], + ), + ) class V2CoreEventDestinationPingEvent(Event): From 6ef23e05b22f17da5f5dbca2ad93b7e7102acb55 Mon Sep 17 00:00:00 2001 From: David Brownman Date: Tue, 29 Jul 2025 16:17:51 -0700 Subject: [PATCH 04/17] Return a union from parse thin event & add UnknownThinEvent --- justfile | 5 ++ stripe/_stripe_client.py | 18 +++++-- stripe/events/_event_classes.py | 16 ++++++ ...ling_meter_error_report_triggered_event.py | 10 ++-- .../_v2_core_event_destination_ping_event.py | 12 ++--- stripe/v2/_event.py | 53 ++++++++++++------- tests/test_v2_event.py | 33 +++++++++++- 7 files changed, 112 insertions(+), 35 deletions(-) diff --git a/justfile b/justfile index de11777b3..341421f90 100644 --- a/justfile +++ b/justfile @@ -14,6 +14,11 @@ test *args: install-test-deps # configured in pyproject.toml pytest {{ args }} +# run a single test by name +test-one test_name: install-test-deps + # don't use all cores, there's a spin up time to that and we're only using one test + pytest -k {{ test_name }} -n 0 + # ⭐ check for potential mistakes lint: install-dev-deps python -m flake8 --show-source stripe tests setup.py diff --git a/stripe/_stripe_client.py b/stripe/_stripe_client.py index 4bd6d4508..8d07456cb 100644 --- a/stripe/_stripe_client.py +++ b/stripe/_stripe_client.py @@ -27,9 +27,10 @@ from stripe._util import _convert_to_stripe_object, get_api_mode, deprecated # noqa: F401 from stripe._webhook import Webhook, WebhookSignature from stripe._event import Event -from stripe.v2._event import ThinEvent +from stripe.v2._event import UnknownThinEvent from typing import Any, Dict, Optional, Union, cast +from typing_extensions import TYPE_CHECKING # Non-generated services from stripe._oauth_service import OAuthService @@ -111,6 +112,9 @@ from stripe._v2_services import V2Services # services: The end of the section generated from our OpenAPI spec +if TYPE_CHECKING: + from stripe.events._event_classes import All_PUSHED_THIN_EVENTS + class StripeClient(object): def __init__( @@ -274,7 +278,7 @@ def parse_thin_event( sig_header: str, secret: str, tolerance: int = Webhook.DEFAULT_TOLERANCE, - ) -> ThinEvent: + ) -> "All_PUSHED_THIN_EVENTS": payload = ( cast(Union[bytes, bytearray], raw).decode("utf-8") if hasattr(raw, "decode") @@ -282,8 +286,16 @@ def parse_thin_event( ) WebhookSignature.verify_header(payload, sig_header, secret, tolerance) + parsed_body = json.loads(payload) + + # circular import busting + from stripe.events._event_classes import PUSHED_THIN_EVENT_CLASSES + + event_class = PUSHED_THIN_EVENT_CLASSES.get( + parsed_body["type"], UnknownThinEvent + ) - return ThinEvent(payload, self) + return event_class(parsed_body, self) def construct_event( self, diff --git a/stripe/events/_event_classes.py b/stripe/events/_event_classes.py index a47231d7b..fe466442a 100644 --- a/stripe/events/_event_classes.py +++ b/stripe/events/_event_classes.py @@ -1,13 +1,17 @@ # -*- coding: utf-8 -*- # File generated from our OpenAPI spec +from typing import Union from stripe.events._v1_billing_meter_error_report_triggered_event import ( V1BillingMeterErrorReportTriggeredEvent, + PushedV1BillingMeterErrorReportTriggeredEvent, ) from stripe.events._v1_billing_meter_no_meter_found_event import ( V1BillingMeterNoMeterFoundEvent, + PushedV1BillingMeterNoMeterFoundEvent, ) from stripe.events._v2_core_event_destination_ping_event import ( V2CoreEventDestinationPingEvent, + PushedV2CoreEventDestinationPingEvent, ) @@ -16,3 +20,15 @@ V1BillingMeterNoMeterFoundEvent.LOOKUP_TYPE: V1BillingMeterNoMeterFoundEvent, V2CoreEventDestinationPingEvent.LOOKUP_TYPE: V2CoreEventDestinationPingEvent, } + +PUSHED_THIN_EVENT_CLASSES = { + PushedV1BillingMeterErrorReportTriggeredEvent.LOOKUP_TYPE: PushedV1BillingMeterErrorReportTriggeredEvent, + PushedV1BillingMeterNoMeterFoundEvent.LOOKUP_TYPE: PushedV1BillingMeterNoMeterFoundEvent, + PushedV2CoreEventDestinationPingEvent.LOOKUP_TYPE: PushedV2CoreEventDestinationPingEvent, +} + +All_PUSHED_THIN_EVENTS = Union[ + PushedV1BillingMeterErrorReportTriggeredEvent, + PushedV1BillingMeterNoMeterFoundEvent, + PushedV2CoreEventDestinationPingEvent, +] diff --git a/stripe/events/_v1_billing_meter_error_report_triggered_event.py b/stripe/events/_v1_billing_meter_error_report_triggered_event.py index 65c723406..6e94939cf 100644 --- a/stripe/events/_v1_billing_meter_error_report_triggered_event.py +++ b/stripe/events/_v1_billing_meter_error_report_triggered_event.py @@ -17,14 +17,14 @@ class PushedV1BillingMeterErrorReportTriggeredEvent(ThinEvent): type: Literal["v1.billing.meter.error_report_triggered"] related_object: RelatedObject - def __init__(self, payload: str, client: StripeClient) -> None: + def __init__( + self, parsed_body: Dict[str, Any], client: StripeClient + ) -> None: super().__init__( - payload, + parsed_body, client, ) - # don't love the double json parse here, but it's fine - parsed = json.loads(payload) - self.related_object = RelatedObject(parsed["related_object"]) + self.related_object = RelatedObject(parsed_body["related_object"]) def pull(self) -> "V1BillingMeterErrorReportTriggeredEvent": return cast( diff --git a/stripe/events/_v2_core_event_destination_ping_event.py b/stripe/events/_v2_core_event_destination_ping_event.py index 6280b87b2..159213290 100644 --- a/stripe/events/_v2_core_event_destination_ping_event.py +++ b/stripe/events/_v2_core_event_destination_ping_event.py @@ -5,7 +5,7 @@ from stripe._stripe_object import StripeObject from stripe.v2._event import Event, RelatedObject, ThinEvent from stripe.v2._event_destination import EventDestination -from typing import cast +from typing import Any, Dict, cast from typing_extensions import Literal @@ -14,14 +14,14 @@ class PushedV2CoreEventDestinationPingEvent(ThinEvent): type: Literal["v2.core.event_destination.ping"] related_object: RelatedObject - def __init__(self, payload: str, client: StripeClient) -> None: + def __init__( + self, parsed_body: Dict[str, Any], client: StripeClient + ) -> None: super().__init__( - payload, + parsed_body, client, ) - # don't love the double json parse here, but it's fine - parsed = json.loads(payload) - self.related_object = RelatedObject(parsed["related_object"]) + self.related_object = RelatedObject(parsed_body["related_object"]) def pull(self) -> "V2CoreEventDestinationPingEvent": return cast( diff --git a/stripe/v2/_event.py b/stripe/v2/_event.py index 10e8b418b..535d09db3 100644 --- a/stripe/v2/_event.py +++ b/stripe/v2/_event.py @@ -1,13 +1,14 @@ # -*- coding: utf-8 -*- -import json -from typing import ClassVar, Optional, cast +from typing import Any, ClassVar, Dict, Optional, cast -from typing_extensions import Literal +from typing_extensions import Literal, TYPE_CHECKING -from stripe._stripe_client import StripeClient from stripe._stripe_object import StripeObject +if TYPE_CHECKING: + from stripe._stripe_client import StripeClient + # This describes the common format for the pull payload of a V2 ThinEvent # more specific classes will add `data` and `fetch_related_objects()` as needed @@ -126,27 +127,22 @@ class ThinEvent: """ [Optional] Authentication context needed to fetch the event or related object. """ - # related_object: Optional[RelatedObject] = None - # """ - # [Optional] Object containing the reference to API resource relevant to the event. - # """ reason: Optional[Reason] = None """ [Optional] Reason for the event. """ - def __init__(self, payload: str, client: StripeClient) -> None: - parsed = json.loads(payload) + def __init__( + self, parsed_body: Dict[str, Any], client: "StripeClient" + ) -> None: + self.id = parsed_body["id"] + self.type = parsed_body["type"] + self.created = parsed_body["created"] + self.livemode = bool(parsed_body.get("livemode")) + self.context = parsed_body.get("context") - self.id = parsed["id"] - self.type = parsed["type"] - self.created = parsed["created"] - self.livemode = parsed.get("livemode") - self.context = parsed.get("context") - # if parsed.get("related_object"): - # self.related_object = RelatedObject(parsed["related_object"]) - if parsed.get("reason"): - self.reason = Reason(parsed["reason"]) + if parsed_body.get("reason"): + self.reason = Reason(parsed_body["reason"]) self.client = client @@ -170,3 +166,22 @@ async def pull_async(self) -> Event: usage=["pushed_event_pull", "pushed_event_pull_async"], ) return cast(Event, self.client.deserialize(response, api_mode="V2")) + + +class UnknownThinEvent(ThinEvent): + """ + Represents a Thin Event payload that the SDK doesn't have types for. May have a related object. + """ + + related_object: Optional[RelatedObject] = None + """ + [Optional] Object containing the reference to API resource relevant to the event. + """ + + def __init__( + self, parsed_body: Dict[str, Any], client: "StripeClient" + ) -> None: + super().__init__(parsed_body, client) + + if parsed_body.get("related_object"): + self.related_object = RelatedObject(parsed_body["related_object"]) diff --git a/tests/test_v2_event.py b/tests/test_v2_event.py index 32bd3fabb..0d4bcdc8e 100644 --- a/tests/test_v2_event.py +++ b/tests/test_v2_event.py @@ -6,8 +6,10 @@ import stripe from stripe import ThinEvent from stripe.events._v1_billing_meter_error_report_triggered_event import ( + PushedV1BillingMeterErrorReportTriggeredEvent, V1BillingMeterErrorReportTriggeredEvent, ) +from stripe.v2._event import UnknownThinEvent from tests.test_webhook import DUMMY_WEBHOOK_SECRET, generate_header EventParser = Callable[[str], ThinEvent] @@ -88,7 +90,7 @@ def test_parses_thin_event( ): event = parse_thin_event(v2_payload_no_data) - assert isinstance(event, ThinEvent) + assert isinstance(event, PushedV1BillingMeterErrorReportTriggeredEvent) assert event.id == "evt_234" assert event.related_object @@ -102,10 +104,37 @@ def test_parses_thin_event_with_data( ): event = parse_thin_event(v2_payload_with_data) - assert isinstance(event, ThinEvent) + assert isinstance(event, PushedV1BillingMeterErrorReportTriggeredEvent) + # this isn't for constructing events, it's for parsing thin ones assert not hasattr(event, "data") assert event.reason is None + def test_parses_unknown_thin_event(self, parse_thin_event: EventParser): + event = parse_thin_event( + json.dumps( + { + "id": "evt_234", + "object": "v2.core.event", + "type": "uknown.event.type", + "livemode": True, + "created": "2022-02-15T00:27:45.330Z", + "related_object": { + "id": "mtr_123", + "type": "billing.meter", + "url": "/v1/billing/meters/mtr_123", + "stripe_context": "acct_123", + }, + "reason": { + "id": "foo", + "idempotency_key": "bar", + }, + } + ) + ) + + assert type(event) is UnknownThinEvent + assert event.related_object + def test_validates_signature( self, stripe_client: stripe.StripeClient, v2_payload_no_data ): From 46f5a3c04bc5f3ed26c9c4599e23a256a136a853 Mon Sep 17 00:00:00 2001 From: David Brownman Date: Tue, 29 Jul 2025 16:22:25 -0700 Subject: [PATCH 05/17] fix unused import --- stripe/events/_v1_billing_meter_error_report_triggered_event.py | 1 - stripe/events/_v2_core_event_destination_ping_event.py | 1 - 2 files changed, 2 deletions(-) diff --git a/stripe/events/_v1_billing_meter_error_report_triggered_event.py b/stripe/events/_v1_billing_meter_error_report_triggered_event.py index 6e94939cf..dac930d54 100644 --- a/stripe/events/_v1_billing_meter_error_report_triggered_event.py +++ b/stripe/events/_v1_billing_meter_error_report_triggered_event.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- # File generated from our OpenAPI spec -import json from stripe._api_mode import ApiMode from stripe._api_requestor import _APIRequestor from stripe._stripe_client import StripeClient diff --git a/stripe/events/_v2_core_event_destination_ping_event.py b/stripe/events/_v2_core_event_destination_ping_event.py index 159213290..09c5d7b30 100644 --- a/stripe/events/_v2_core_event_destination_ping_event.py +++ b/stripe/events/_v2_core_event_destination_ping_event.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- # File generated from our OpenAPI spec -import json from stripe._stripe_client import StripeClient from stripe._stripe_object import StripeObject from stripe.v2._event import Event, RelatedObject, ThinEvent From d98d2ea2bba3317d5389e8d1eeb21a0c64b56e36 Mon Sep 17 00:00:00 2001 From: David Brownman Date: Wed, 30 Jul 2025 16:57:56 -0700 Subject: [PATCH 06/17] fix related_object generation and add big test --- pyproject.toml | 1 + stripe/_stripe_client.py | 4 +- stripe/_util.py | 10 +- stripe/events/_event_classes.py | 2 +- ...ling_meter_error_report_triggered_event.py | 29 +++-- .../_v2_core_event_destination_ping_event.py | 29 +++-- tests/test_util.py | 17 +++ tests/test_v2_event.py | 117 +++++++++++++++--- 8 files changed, 167 insertions(+), 42 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index dae143954..c9d2ee6ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ include = [ "tests/test_generated_examples.py", "tests/test_exports.py", "tests/test_http_client.py", + "tests/test_v2_event.py", ] exclude = ["build", "**/__pycache__"] reportMissingTypeArgument = true diff --git a/stripe/_stripe_client.py b/stripe/_stripe_client.py index 8d07456cb..a9cac907c 100644 --- a/stripe/_stripe_client.py +++ b/stripe/_stripe_client.py @@ -113,7 +113,7 @@ # services: The end of the section generated from our OpenAPI spec if TYPE_CHECKING: - from stripe.events._event_classes import All_PUSHED_THIN_EVENTS + from stripe.events._event_classes import ALL_PUSHED_THIN_EVENTS class StripeClient(object): @@ -278,7 +278,7 @@ def parse_thin_event( sig_header: str, secret: str, tolerance: int = Webhook.DEFAULT_TOLERANCE, - ) -> "All_PUSHED_THIN_EVENTS": + ) -> "ALL_PUSHED_THIN_EVENTS": payload = ( cast(Union[bytes, bytearray], raw).decode("utf-8") if hasattr(raw, "decode") diff --git a/stripe/_util.py b/stripe/_util.py index 2ef97e7c2..2d4c53d43 100644 --- a/stripe/_util.py +++ b/stripe/_util.py @@ -9,7 +9,7 @@ from stripe._api_mode import ApiMode -from urllib.parse import parse_qsl, quote_plus # noqa: F401 +from urllib.parse import parse_qsl, quote_plus, urlparse # noqa: F401 from typing_extensions import Type, TYPE_CHECKING from typing import ( @@ -417,9 +417,15 @@ def sanitize_id(id): return quotedId -def get_api_mode(url): +def get_api_mode(url: str) -> ApiMode: + # support absolute urls + if url.startswith("http"): + url = urlparse(url).path + if url.startswith("/v2"): return "V2" + + # if urls aren't explicitly marked as v1, they're assumed to be v1 else: return "V1" diff --git a/stripe/events/_event_classes.py b/stripe/events/_event_classes.py index fe466442a..76286612d 100644 --- a/stripe/events/_event_classes.py +++ b/stripe/events/_event_classes.py @@ -27,7 +27,7 @@ PushedV2CoreEventDestinationPingEvent.LOOKUP_TYPE: PushedV2CoreEventDestinationPingEvent, } -All_PUSHED_THIN_EVENTS = Union[ +ALL_PUSHED_THIN_EVENTS = Union[ PushedV1BillingMeterErrorReportTriggeredEvent, PushedV1BillingMeterNoMeterFoundEvent, PushedV2CoreEventDestinationPingEvent, diff --git a/stripe/events/_v1_billing_meter_error_report_triggered_event.py b/stripe/events/_v1_billing_meter_error_report_triggered_event.py index dac930d54..b619194bc 100644 --- a/stripe/events/_v1_billing_meter_error_report_triggered_event.py +++ b/stripe/events/_v1_billing_meter_error_report_triggered_event.py @@ -5,6 +5,7 @@ from stripe._stripe_client import StripeClient from stripe._stripe_object import StripeObject from stripe._stripe_response import StripeResponse +from stripe._util import get_api_mode from stripe.billing._meter import Meter from stripe.v2._event import Event, RelatedObject, ThinEvent from typing import Any, Dict, List, Optional, cast @@ -32,13 +33,17 @@ def pull(self) -> "V1BillingMeterErrorReportTriggeredEvent": ) def fetch_related_object(self) -> "Meter": + response = self.client.raw_request( + "get", + self.related_object.url, + stripe_context=self.context, + usage=["fetch_related_object"], + ) return cast( "Meter", - self.client.raw_request( - "get", - self.related_object.url, - stripe_context=self.context, - usage=["fetch_related_object"], + self.client.deserialize( + response, + api_mode=get_api_mode(self.related_object.url), ), ) @@ -49,13 +54,17 @@ async def pull_async(self) -> "V1BillingMeterErrorReportTriggeredEvent": ) async def fetch_related_object_async(self) -> "Meter": + response = await self.client.raw_request_async( + "get", + self.related_object.url, + stripe_context=self.context, + usage=["fetch_related_object"], + ) return cast( "Meter", - await self.client.raw_request_async( - "get", - self.related_object.url, - stripe_context=self.context, - usage=["fetch_related_object"], + self.client.deserialize( + response, + api_mode=get_api_mode(self.related_object.url), ), ) diff --git a/stripe/events/_v2_core_event_destination_ping_event.py b/stripe/events/_v2_core_event_destination_ping_event.py index 09c5d7b30..efbce52a5 100644 --- a/stripe/events/_v2_core_event_destination_ping_event.py +++ b/stripe/events/_v2_core_event_destination_ping_event.py @@ -2,6 +2,7 @@ # File generated from our OpenAPI spec from stripe._stripe_client import StripeClient from stripe._stripe_object import StripeObject +from stripe._util import get_api_mode from stripe.v2._event import Event, RelatedObject, ThinEvent from stripe.v2._event_destination import EventDestination from typing import Any, Dict, cast @@ -29,13 +30,17 @@ def pull(self) -> "V2CoreEventDestinationPingEvent": ) def fetch_related_object(self) -> "EventDestination": + response = self.client.raw_request( + "get", + self.related_object.url, + stripe_context=self.context, + usage=["fetch_related_object"], + ) return cast( "EventDestination", - self.client.raw_request( - "get", - self.related_object.url, - stripe_context=self.context, - usage=["fetch_related_object"], + self.client.deserialize( + response, + api_mode=get_api_mode(self.related_object.url), ), ) @@ -46,13 +51,17 @@ async def pull_async(self) -> "V2CoreEventDestinationPingEvent": ) async def fetch_related_object_async(self) -> "EventDestination": + response = await self.client.raw_request_async( + "get", + self.related_object.url, + stripe_context=self.context, + usage=["fetch_related_object"], + ) return cast( "EventDestination", - await self.client.raw_request_async( - "get", - self.related_object.url, - stripe_context=self.context, - usage=["fetch_related_object"], + self.client.deserialize( + response, + api_mode=get_api_mode(self.related_object.url), ), ) diff --git a/tests/test_util.py b/tests/test_util.py index 93b75c84f..baafe9809 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,8 +1,12 @@ import sys from collections import namedtuple +import pytest + import stripe from stripe import util +from stripe._api_mode import ApiMode +from stripe._util import get_api_mode LogTestCase = namedtuple("LogTestCase", "env flag should_output") FmtTestCase = namedtuple("FmtTestCase", "props expected") @@ -152,3 +156,16 @@ def test_sanitize_id(self): if isinstance(sanitized_id, bytes): sanitized_id = sanitized_id.decode("utf-8", "strict") assert sanitized_id == "cu++%25x+123" + + @pytest.mark.parametrize( + ["url", "expected"], + [ + ("/v2/core/events", "V2"), + ("/v1/events", "V1"), + ("https://api.stripe.com/v1/events", "V1"), + ("https://api.stripe.com/v2/core/events", "V2"), + ("something/v2/core/events", "V1"), + ], + ) + def test_get_api_mode(self, url: str, expected: ApiMode): + assert get_api_mode(url) == expected diff --git a/tests/test_v2_event.py b/tests/test_v2_event.py index 0d4bcdc8e..d44630567 100644 --- a/tests/test_v2_event.py +++ b/tests/test_v2_event.py @@ -1,18 +1,24 @@ import json +import sys from typing import Callable import pytest +from stripe.billing._meter import Meter +from tests.http_client_mock import HTTPClientMock -import stripe -from stripe import ThinEvent + +from stripe import DEFAULT_API_BASE +from stripe._error import SignatureVerificationError +from stripe._stripe_client import StripeClient from stripe.events._v1_billing_meter_error_report_triggered_event import ( PushedV1BillingMeterErrorReportTriggeredEvent, V1BillingMeterErrorReportTriggeredEvent, ) from stripe.v2._event import UnknownThinEvent +from stripe.events._event_classes import ALL_PUSHED_THIN_EVENTS from tests.test_webhook import DUMMY_WEBHOOK_SECRET, generate_header -EventParser = Callable[[str], ThinEvent] +EventParser = Callable[[str], ALL_PUSHED_THIN_EVENTS] class TestV2Event(object): @@ -24,12 +30,12 @@ def v2_payload_no_data(self): "object": "v2.core.event", "type": "v1.billing.meter.error_report_triggered", "livemode": True, + "context": "acct_123", "created": "2022-02-15T00:27:45.330Z", "related_object": { "id": "mtr_123", "type": "billing.meter", "url": "/v1/billing/meters/mtr_123", - "stripe_context": "acct_123", }, "reason": { "id": "foo", @@ -63,16 +69,13 @@ def v2_payload_with_data(self): @pytest.fixture(scope="function") def stripe_client(self, http_client_mock): - return stripe.StripeClient( - api_key="keyinfo_test_123", - stripe_context="wksp_123", + return StripeClient( + api_key="sk_test_1234", http_client=http_client_mock.get_mock_http_client(), ) @pytest.fixture(scope="function") - def parse_thin_event( - self, stripe_client: stripe.StripeClient - ) -> EventParser: + def parse_thin_event(self, stripe_client: StripeClient) -> EventParser: """ helper to simplify parsing and validating events given a payload returns a function that has the client pre-bound @@ -118,11 +121,11 @@ def test_parses_unknown_thin_event(self, parse_thin_event: EventParser): "type": "uknown.event.type", "livemode": True, "created": "2022-02-15T00:27:45.330Z", + "context": "acct_456", "related_object": { "id": "mtr_123", "type": "billing.meter", "url": "/v1/billing/meters/mtr_123", - "stripe_context": "acct_123", }, "reason": { "id": "foo", @@ -136,9 +139,9 @@ def test_parses_unknown_thin_event(self, parse_thin_event: EventParser): assert event.related_object def test_validates_signature( - self, stripe_client: stripe.StripeClient, v2_payload_no_data + self, stripe_client: StripeClient, v2_payload_no_data ): - with pytest.raises(stripe.error.SignatureVerificationError): + with pytest.raises(SignatureVerificationError): stripe_client.parse_thin_event( v2_payload_no_data, "bad header", DUMMY_WEBHOOK_SECRET ) @@ -153,17 +156,19 @@ def test_v2_events_data_type(self, http_client_mock, v2_payload_with_data): rcode=200, rheaders={}, ) - client = stripe.StripeClient( - api_key="keyinfo_test_123", + client = StripeClient( + api_key="sk_test_1234", + stripe_context="org_456", http_client=http_client_mock.get_mock_http_client(), ) event = client.v2.core.events.retrieve("evt_123") http_client_mock.assert_requested( method, - api_base=stripe.DEFAULT_API_BASE, + api_base=DEFAULT_API_BASE, path=path, - api_key="keyinfo_test_123", + api_key="sk_test_1234", + stripe_context="org_456", ) assert event.id is not None assert isinstance(event, V1BillingMeterErrorReportTriggeredEvent) @@ -173,3 +178,81 @@ def test_v2_events_data_type(self, http_client_mock, v2_payload_with_data): V1BillingMeterErrorReportTriggeredEvent.V1BillingMeterErrorReportTriggeredEventData, ) assert event.data.reason.error_count == 1 + + # an "integration" shaped test with all the bells and whistles + def test_v2_events_integration( + self, + http_client_mock: HTTPClientMock, + v2_payload_no_data, + v2_payload_with_data, + parse_thin_event: EventParser, + ): + method = "get" + path = "/v2/core/events/evt_234" + meter_path = "/v1/billing/meters/mtr_123" + + http_client_mock.stub_request( + method, + path=path, + rbody=v2_payload_with_data, + rcode=200, + rheaders={}, + ) + http_client_mock.stub_request( + method, + path=meter_path, + rbody=json.dumps( + { + "id": "mtr_123", + "object": "billing.meter", + "event_name": "cool event", + } + ), + rcode=200, + rheaders={}, + ) + + thin_event = parse_thin_event(v2_payload_no_data) + assert thin_event.type == "v1.billing.meter.error_report_triggered" + + event = thin_event.pull() + meter = thin_event.fetch_related_object() + + if sys.version_info >= (3, 7): + from typing_extensions import assert_type # noqa: SPY103 - this is only available on 3.6 pythons because of typing_extensions version restrictions + + # these are purely type-level checks to ensure our narrowing works for users + assert_type( + thin_event, PushedV1BillingMeterErrorReportTriggeredEvent + ) + + assert_type(event, V1BillingMeterErrorReportTriggeredEvent) + assert_type(meter, Meter) + + assert isinstance(event, V1BillingMeterErrorReportTriggeredEvent) + + http_client_mock.assert_requested( + method, + api_base=DEFAULT_API_BASE, + path=path, + api_key="sk_test_1234", + # context read from event + stripe_context="acct_123", + ) + http_client_mock.assert_requested( + method, + api_base=DEFAULT_API_BASE, + path=meter_path, + api_key="sk_test_1234", + # context read from event + stripe_context="acct_123", + ) + + assert isinstance( + event.data, + V1BillingMeterErrorReportTriggeredEvent.V1BillingMeterErrorReportTriggeredEventData, + ) + assert event.data.reason.error_count == 1 + + assert isinstance(meter, Meter) + assert meter.event_name == "cool event" From d09d7fa519a210b2b3fd1b6ff83a1ae163724e5b Mon Sep 17 00:00:00 2001 From: David Brownman Date: Fri, 29 Aug 2025 14:47:29 -0700 Subject: [PATCH 07/17] Some name cleanup --- justfile | 2 +- stripe/_api_requestor.py | 12 ++++-------- stripe/_stripe_client.py | 6 +++--- stripe/_util.py | 4 ---- ..._v1_billing_meter_error_report_triggered_event.py | 8 ++++---- .../events/_v2_core_event_destination_ping_event.py | 8 ++++---- stripe/v2/_event.py | 10 +++++----- tests/test_util.py | 3 +-- 8 files changed, 22 insertions(+), 31 deletions(-) diff --git a/justfile b/justfile index 6cbb0e3cd..e497011bc 100644 --- a/justfile +++ b/justfile @@ -18,7 +18,7 @@ test *args: install-test-deps # run a single test by name test-one test_name: install-test-deps # don't use all cores, there's a spin up time to that and we're only using one test - pytest -k {{ test_name }} -n 0 + pytest -k "{{ test_name }}" -n 0 # ⭐ check for potential mistakes lint: install-dev-deps diff --git a/stripe/_api_requestor.py b/stripe/_api_requestor.py index 8104ff43e..4ccf3f874 100644 --- a/stripe/_api_requestor.py +++ b/stripe/_api_requestor.py @@ -551,14 +551,10 @@ def _args_for_request_with_retries( "questions." ) - # we're passed full urls from thin events, so we should just call those directly - if url.startswith("https://"): - abs_url = url - else: - abs_url = "%s%s" % ( - self._options.base_addresses.get(base_address), - url, - ) + abs_url = "%s%s" % ( + self._options.base_addresses.get(base_address), + url, + ) params = params or {} if params and (method == "get" or method == "delete"): diff --git a/stripe/_stripe_client.py b/stripe/_stripe_client.py index e3cfe4a40..39f347a60 100644 --- a/stripe/_stripe_client.py +++ b/stripe/_stripe_client.py @@ -249,7 +249,8 @@ def raw_request(self, method_: str, url_: str, **params): stripe_context = params.pop("stripe_context", None) - usage = params.pop("usage", None) + # we manually pass usage in event internals, so use those if available + usage = params.pop("usage", ["raw_request"]) # stripe-context goes *here* and not in api_requestor. Properties # go on api_requestor when you want them to persist onto requests @@ -267,8 +268,7 @@ def raw_request(self, method_: str, url_: str, **params): options=options, base_address=base_address, api_mode=api_mode, - # we manually pass usage in event internals, so use those if available - usage=usage or ["raw_request"], + usage=usage, ) return self._requestor._interpret_response( diff --git a/stripe/_util.py b/stripe/_util.py index 2d4c53d43..440af373f 100644 --- a/stripe/_util.py +++ b/stripe/_util.py @@ -418,10 +418,6 @@ def sanitize_id(id): def get_api_mode(url: str) -> ApiMode: - # support absolute urls - if url.startswith("http"): - url = urlparse(url).path - if url.startswith("/v2"): return "V2" diff --git a/stripe/events/_v1_billing_meter_error_report_triggered_event.py b/stripe/events/_v1_billing_meter_error_report_triggered_event.py index b619194bc..11d63929c 100644 --- a/stripe/events/_v1_billing_meter_error_report_triggered_event.py +++ b/stripe/events/_v1_billing_meter_error_report_triggered_event.py @@ -33,7 +33,7 @@ def pull(self) -> "V1BillingMeterErrorReportTriggeredEvent": ) def fetch_related_object(self) -> "Meter": - response = self.client.raw_request( + response = self._client.raw_request( "get", self.related_object.url, stripe_context=self.context, @@ -41,7 +41,7 @@ def fetch_related_object(self) -> "Meter": ) return cast( "Meter", - self.client.deserialize( + self._client.deserialize( response, api_mode=get_api_mode(self.related_object.url), ), @@ -54,7 +54,7 @@ async def pull_async(self) -> "V1BillingMeterErrorReportTriggeredEvent": ) async def fetch_related_object_async(self) -> "Meter": - response = await self.client.raw_request_async( + response = await self._client.raw_request_async( "get", self.related_object.url, stripe_context=self.context, @@ -62,7 +62,7 @@ async def fetch_related_object_async(self) -> "Meter": ) return cast( "Meter", - self.client.deserialize( + self._client.deserialize( response, api_mode=get_api_mode(self.related_object.url), ), diff --git a/stripe/events/_v2_core_event_destination_ping_event.py b/stripe/events/_v2_core_event_destination_ping_event.py index efbce52a5..d8471b226 100644 --- a/stripe/events/_v2_core_event_destination_ping_event.py +++ b/stripe/events/_v2_core_event_destination_ping_event.py @@ -30,7 +30,7 @@ def pull(self) -> "V2CoreEventDestinationPingEvent": ) def fetch_related_object(self) -> "EventDestination": - response = self.client.raw_request( + response = self._client.raw_request( "get", self.related_object.url, stripe_context=self.context, @@ -38,7 +38,7 @@ def fetch_related_object(self) -> "EventDestination": ) return cast( "EventDestination", - self.client.deserialize( + self._client.deserialize( response, api_mode=get_api_mode(self.related_object.url), ), @@ -51,7 +51,7 @@ async def pull_async(self) -> "V2CoreEventDestinationPingEvent": ) async def fetch_related_object_async(self) -> "EventDestination": - response = await self.client.raw_request_async( + response = await self._client.raw_request_async( "get", self.related_object.url, stripe_context=self.context, @@ -59,7 +59,7 @@ async def fetch_related_object_async(self) -> "EventDestination": ) return cast( "EventDestination", - self.client.deserialize( + self._client.deserialize( response, api_mode=get_api_mode(self.related_object.url), ), diff --git a/stripe/v2/_event.py b/stripe/v2/_event.py index 535d09db3..3d0ecac1e 100644 --- a/stripe/v2/_event.py +++ b/stripe/v2/_event.py @@ -144,28 +144,28 @@ def __init__( if parsed_body.get("reason"): self.reason = Reason(parsed_body["reason"]) - self.client = client + self._client = client def __repr__(self) -> str: return f"" def pull(self) -> Event: - response = self.client.raw_request( + response = self._client.raw_request( "get", f"/v2/core/events/{self.id}", stripe_context=self.context, usage=["pushed_event_pull"], ) - return cast(Event, self.client.deserialize(response, api_mode="V2")) + return cast(Event, self._client.deserialize(response, api_mode="V2")) async def pull_async(self) -> Event: - response = await self.client.raw_request_async( + response = await self._client.raw_request_async( "get", f"/v2/core/events/{self.id}", stripe_context=self.context, usage=["pushed_event_pull", "pushed_event_pull_async"], ) - return cast(Event, self.client.deserialize(response, api_mode="V2")) + return cast(Event, self._client.deserialize(response, api_mode="V2")) class UnknownThinEvent(ThinEvent): diff --git a/tests/test_util.py b/tests/test_util.py index baafe9809..f863ec41a 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -162,8 +162,7 @@ def test_sanitize_id(self): [ ("/v2/core/events", "V2"), ("/v1/events", "V1"), - ("https://api.stripe.com/v1/events", "V1"), - ("https://api.stripe.com/v2/core/events", "V2"), + ("/oauth/authorize", "V1"), ("something/v2/core/events", "V1"), ], ) From 2c30ab4b1786e513b4d4214890db172b9db89396 Mon Sep 17 00:00:00 2001 From: David Brownman Date: Fri, 29 Aug 2025 15:45:23 -0700 Subject: [PATCH 08/17] rename thin_event --- flake8_stripe/flake8_stripe.py | 1 + stripe/__init__.py | 2 +- stripe/_stripe_client.py | 20 +++--- stripe/_util.py | 10 +-- stripe/events/_event_classes.py | 24 +++---- ...ling_meter_error_report_triggered_event.py | 18 +++-- .../_v1_billing_meter_no_meter_found_event.py | 16 +++-- .../_v2_core_event_destination_ping_event.py | 16 +++-- stripe/v2/_event.py | 65 ++++++++++++++++--- tests/test_v2_event.py | 26 ++++---- 10 files changed, 125 insertions(+), 73 deletions(-) diff --git a/flake8_stripe/flake8_stripe.py b/flake8_stripe/flake8_stripe.py index 4cdca2b40..762efc4f3 100644 --- a/flake8_stripe/flake8_stripe.py +++ b/flake8_stripe/flake8_stripe.py @@ -35,6 +35,7 @@ class TypingImportsChecker: "Unpack", "Awaitable", "Never", + "override", ] allowed_typing_imports = [ diff --git a/stripe/__init__.py b/stripe/__init__.py index 4aea00eae..c17dc9d8f 100644 --- a/stripe/__init__.py +++ b/stripe/__init__.py @@ -100,7 +100,7 @@ def _warn_if_mismatched_proxy(): # StripeClient from stripe._stripe_client import StripeClient as StripeClient # noqa -from stripe.v2._event import ThinEvent as ThinEvent # noqa +from stripe.v2._event import EventNotification as EventNotification # noqa # Sets some basic information about the running application that's sent along diff --git a/stripe/_stripe_client.py b/stripe/_stripe_client.py index 39f347a60..408fc22d9 100644 --- a/stripe/_stripe_client.py +++ b/stripe/_stripe_client.py @@ -27,7 +27,7 @@ from stripe._util import _convert_to_stripe_object, get_api_mode, deprecated # noqa: F401 from stripe._webhook import Webhook, WebhookSignature from stripe._event import Event -from stripe.v2._event import UnknownThinEvent +from stripe.v2._event import EventNotification from typing import Any, Dict, Optional, Union, cast from typing_extensions import TYPE_CHECKING @@ -114,7 +114,7 @@ # services: The end of the section generated from our OpenAPI spec if TYPE_CHECKING: - from stripe.events._event_classes import ALL_PUSHED_THIN_EVENTS + from stripe.events._event_classes import ALL_EVENT_NOTIFICATIONS class StripeClient(object): @@ -195,13 +195,14 @@ def __init__( self.v2 = V2Services(self._requestor) # top-level services: The end of the section generated from our OpenAPI spec - def parse_thin_event( + def parse_event_notification( self, raw: Union[bytes, str, bytearray], sig_header: str, secret: str, tolerance: int = Webhook.DEFAULT_TOLERANCE, - ) -> "ALL_PUSHED_THIN_EVENTS": + ) -> "ALL_EVENT_NOTIFICATIONS": + """ """ payload = ( cast(Union[bytes, bytearray], raw).decode("utf-8") if hasattr(raw, "decode") @@ -209,17 +210,12 @@ def parse_thin_event( ) WebhookSignature.verify_header(payload, sig_header, secret, tolerance) - parsed_body = json.loads(payload) - # circular import busting - from stripe.events._event_classes import PUSHED_THIN_EVENT_CLASSES - - event_class = PUSHED_THIN_EVENT_CLASSES.get( - parsed_body["type"], UnknownThinEvent + return cast( + "ALL_EVENT_NOTIFICATIONS", + EventNotification.from_json(payload, self), ) - return event_class(parsed_body, self) - def construct_event( self, payload: Union[bytes, str], diff --git a/stripe/_util.py b/stripe/_util.py index 440af373f..b87a8418c 100644 --- a/stripe/_util.py +++ b/stripe/_util.py @@ -192,12 +192,6 @@ def secure_compare(val1, val2): return result == 0 -def get_thin_event_classes(): - from stripe.events._event_classes import THIN_EVENT_CLASSES - - return THIN_EVENT_CLASSES - - def get_object_classes(api_mode): # This is here to avoid a circular dependency if api_mode == "V2": @@ -322,8 +316,10 @@ def _convert_to_stripe_object( klass_name = resp.get("object") if isinstance(klass_name, str): if api_mode == "V2" and klass_name == "v2.core.event": + from stripe.events._event_classes import V2_EVENT_CLASS_LOOKUP + event_name = resp.get("type", "") - klass = get_thin_event_classes().get( + klass = V2_EVENT_CLASS_LOOKUP.get( event_name, stripe.StripeObject ) else: diff --git a/stripe/events/_event_classes.py b/stripe/events/_event_classes.py index 76286612d..0204d4b60 100644 --- a/stripe/events/_event_classes.py +++ b/stripe/events/_event_classes.py @@ -3,32 +3,32 @@ from typing import Union from stripe.events._v1_billing_meter_error_report_triggered_event import ( V1BillingMeterErrorReportTriggeredEvent, - PushedV1BillingMeterErrorReportTriggeredEvent, + V1BillingMeterErrorReportTriggeredEventNotification, ) from stripe.events._v1_billing_meter_no_meter_found_event import ( V1BillingMeterNoMeterFoundEvent, - PushedV1BillingMeterNoMeterFoundEvent, + V1BillingMeterNoMeterFoundEventNotification, ) from stripe.events._v2_core_event_destination_ping_event import ( V2CoreEventDestinationPingEvent, - PushedV2CoreEventDestinationPingEvent, + V2CoreEventDestinationPingEventNotification, ) -THIN_EVENT_CLASSES = { +V2_EVENT_CLASS_LOOKUP = { V1BillingMeterErrorReportTriggeredEvent.LOOKUP_TYPE: V1BillingMeterErrorReportTriggeredEvent, V1BillingMeterNoMeterFoundEvent.LOOKUP_TYPE: V1BillingMeterNoMeterFoundEvent, V2CoreEventDestinationPingEvent.LOOKUP_TYPE: V2CoreEventDestinationPingEvent, } -PUSHED_THIN_EVENT_CLASSES = { - PushedV1BillingMeterErrorReportTriggeredEvent.LOOKUP_TYPE: PushedV1BillingMeterErrorReportTriggeredEvent, - PushedV1BillingMeterNoMeterFoundEvent.LOOKUP_TYPE: PushedV1BillingMeterNoMeterFoundEvent, - PushedV2CoreEventDestinationPingEvent.LOOKUP_TYPE: PushedV2CoreEventDestinationPingEvent, +V2_EVENT_NOTIFICATION_CLASS_LOOKUP = { + V1BillingMeterErrorReportTriggeredEventNotification.LOOKUP_TYPE: V1BillingMeterErrorReportTriggeredEventNotification, + V1BillingMeterNoMeterFoundEventNotification.LOOKUP_TYPE: V1BillingMeterNoMeterFoundEventNotification, + V2CoreEventDestinationPingEventNotification.LOOKUP_TYPE: V2CoreEventDestinationPingEventNotification, } -ALL_PUSHED_THIN_EVENTS = Union[ - PushedV1BillingMeterErrorReportTriggeredEvent, - PushedV1BillingMeterNoMeterFoundEvent, - PushedV2CoreEventDestinationPingEvent, +ALL_EVENT_NOTIFICATIONS = Union[ + V1BillingMeterErrorReportTriggeredEventNotification, + V1BillingMeterNoMeterFoundEventNotification, + V2CoreEventDestinationPingEventNotification, ] diff --git a/stripe/events/_v1_billing_meter_error_report_triggered_event.py b/stripe/events/_v1_billing_meter_error_report_triggered_event.py index 11d63929c..78ca16566 100644 --- a/stripe/events/_v1_billing_meter_error_report_triggered_event.py +++ b/stripe/events/_v1_billing_meter_error_report_triggered_event.py @@ -7,12 +7,12 @@ from stripe._stripe_response import StripeResponse from stripe._util import get_api_mode from stripe.billing._meter import Meter -from stripe.v2._event import Event, RelatedObject, ThinEvent +from stripe.v2._event import Event, EventNotification, RelatedObject from typing import Any, Dict, List, Optional, cast -from typing_extensions import Literal +from typing_extensions import Literal, override -class PushedV1BillingMeterErrorReportTriggeredEvent(ThinEvent): +class V1BillingMeterErrorReportTriggeredEventNotification(EventNotification): LOOKUP_TYPE = "v1.billing.meter.error_report_triggered" type: Literal["v1.billing.meter.error_report_triggered"] related_object: RelatedObject @@ -26,10 +26,11 @@ def __init__( ) self.related_object = RelatedObject(parsed_body["related_object"]) - def pull(self) -> "V1BillingMeterErrorReportTriggeredEvent": + @override + def fetch_event(self) -> "V1BillingMeterErrorReportTriggeredEvent": return cast( "V1BillingMeterErrorReportTriggeredEvent", - super().pull(), + super().fetch_event(), ) def fetch_related_object(self) -> "Meter": @@ -47,10 +48,13 @@ def fetch_related_object(self) -> "Meter": ), ) - async def pull_async(self) -> "V1BillingMeterErrorReportTriggeredEvent": + @override + async def fetch_event_async( + self, + ) -> "V1BillingMeterErrorReportTriggeredEvent": return cast( "V1BillingMeterErrorReportTriggeredEvent", - await super().pull_async(), + await super().fetch_event_async(), ) async def fetch_related_object_async(self) -> "Meter": diff --git a/stripe/events/_v1_billing_meter_no_meter_found_event.py b/stripe/events/_v1_billing_meter_no_meter_found_event.py index 5b8943ba6..d45cab994 100644 --- a/stripe/events/_v1_billing_meter_no_meter_found_event.py +++ b/stripe/events/_v1_billing_meter_no_meter_found_event.py @@ -4,25 +4,27 @@ from stripe._api_requestor import _APIRequestor from stripe._stripe_object import StripeObject from stripe._stripe_response import StripeResponse -from stripe.v2._event import Event, ThinEvent +from stripe.v2._event import Event, EventNotification from typing import Any, Dict, List, Optional, cast -from typing_extensions import Literal +from typing_extensions import Literal, override -class PushedV1BillingMeterNoMeterFoundEvent(ThinEvent): +class V1BillingMeterNoMeterFoundEventNotification(EventNotification): LOOKUP_TYPE = "v1.billing.meter.no_meter_found" type: Literal["v1.billing.meter.no_meter_found"] - def pull(self) -> "V1BillingMeterNoMeterFoundEvent": + @override + def fetch_event(self) -> "V1BillingMeterNoMeterFoundEvent": return cast( "V1BillingMeterNoMeterFoundEvent", - super().pull(), + super().fetch_event(), ) - async def pull_async(self) -> "V1BillingMeterNoMeterFoundEvent": + @override + async def fetch_event_async(self) -> "V1BillingMeterNoMeterFoundEvent": return cast( "V1BillingMeterNoMeterFoundEvent", - await super().pull_async(), + await super().fetch_event_async(), ) diff --git a/stripe/events/_v2_core_event_destination_ping_event.py b/stripe/events/_v2_core_event_destination_ping_event.py index d8471b226..dc3123c8c 100644 --- a/stripe/events/_v2_core_event_destination_ping_event.py +++ b/stripe/events/_v2_core_event_destination_ping_event.py @@ -3,13 +3,13 @@ from stripe._stripe_client import StripeClient from stripe._stripe_object import StripeObject from stripe._util import get_api_mode -from stripe.v2._event import Event, RelatedObject, ThinEvent +from stripe.v2._event import Event, EventNotification, RelatedObject from stripe.v2._event_destination import EventDestination from typing import Any, Dict, cast -from typing_extensions import Literal +from typing_extensions import Literal, override -class PushedV2CoreEventDestinationPingEvent(ThinEvent): +class V2CoreEventDestinationPingEventNotification(EventNotification): LOOKUP_TYPE = "v2.core.event_destination.ping" type: Literal["v2.core.event_destination.ping"] related_object: RelatedObject @@ -23,10 +23,11 @@ def __init__( ) self.related_object = RelatedObject(parsed_body["related_object"]) - def pull(self) -> "V2CoreEventDestinationPingEvent": + @override + def fetch_event(self) -> "V2CoreEventDestinationPingEvent": return cast( "V2CoreEventDestinationPingEvent", - super().pull(), + super().fetch_event(), ) def fetch_related_object(self) -> "EventDestination": @@ -44,10 +45,11 @@ def fetch_related_object(self) -> "EventDestination": ), ) - async def pull_async(self) -> "V2CoreEventDestinationPingEvent": + @override + async def fetch_event_async(self) -> "V2CoreEventDestinationPingEvent": return cast( "V2CoreEventDestinationPingEvent", - await super().pull_async(), + await super().fetch_event_async(), ) async def fetch_related_object_async(self) -> "EventDestination": diff --git a/stripe/v2/_event.py b/stripe/v2/_event.py index 3d0ecac1e..d0ace4234 100644 --- a/stripe/v2/_event.py +++ b/stripe/v2/_event.py @@ -1,17 +1,16 @@ # -*- coding: utf-8 -*- +import json from typing import Any, ClassVar, Dict, Optional, cast from typing_extensions import Literal, TYPE_CHECKING from stripe._stripe_object import StripeObject +from stripe._util import get_api_mode if TYPE_CHECKING: from stripe._stripe_client import StripeClient -# This describes the common format for the pull payload of a V2 ThinEvent -# more specific classes will add `data` and `fetch_related_objects()` as needed - # The beginning of the section generated from our OpenAPI spec class Event(StripeObject): @@ -102,9 +101,9 @@ def __repr__(self) -> str: return f"" -class ThinEvent: +class EventNotification: """ - ThinEvent represents the json that's delivered from an Event Destination. It's a basic `dict` with no additional methods or properties. Use it to check basic information about a delivered event. If you want more details, use `stripe.v2.Event.retrieve(thin_event.id)` to fetch the full event object. + EventNotification represents the json that's delivered from an Event Destination. It's a basic `dict` with no additional methods or properties. Use it to check basic information about a delivered event. If you want more details, use `stripe.v2.Event.retrieve(thin_event.id)` to fetch the full event object. """ id: str @@ -146,10 +145,28 @@ def __init__( self._client = client + @staticmethod + def from_json(payload: str, client: "StripeClient") -> "EventNotification": + """ + The `from_json` constructor shouldn't be used in production code (since it doesn't validate webhook signatures). But it's useful for testing. It's also called by `StripeClient.parse_event_notification`. + """ + parsed_body = json.loads(payload) + + # circular import busting + from stripe.events._event_classes import ( + V2_EVENT_NOTIFICATION_CLASS_LOOKUP, + ) + + event_class = V2_EVENT_NOTIFICATION_CLASS_LOOKUP.get( + parsed_body["type"], UnknownEventNotification + ) + + return event_class(parsed_body, client) + def __repr__(self) -> str: - return f"" + return f"" - def pull(self) -> Event: + def fetch_event(self) -> Event: response = self._client.raw_request( "get", f"/v2/core/events/{self.id}", @@ -158,7 +175,7 @@ def pull(self) -> Event: ) return cast(Event, self._client.deserialize(response, api_mode="V2")) - async def pull_async(self) -> Event: + async def fetch_event_async(self) -> Event: response = await self._client.raw_request_async( "get", f"/v2/core/events/{self.id}", @@ -168,7 +185,7 @@ async def pull_async(self) -> Event: return cast(Event, self._client.deserialize(response, api_mode="V2")) -class UnknownThinEvent(ThinEvent): +class UnknownEventNotification(EventNotification): """ Represents a Thin Event payload that the SDK doesn't have types for. May have a related object. """ @@ -185,3 +202,33 @@ def __init__( if parsed_body.get("related_object"): self.related_object = RelatedObject(parsed_body["related_object"]) + + def fetch_related_object(self) -> Optional[StripeObject]: + if self.related_object is None: + return None + + response = self._client.raw_request( + "get", + self.related_object.url, + stripe_context=self.context, + usage=["fetch_related_object", "unknown_event"], + ) + return self._client.deserialize( + response, + api_mode=get_api_mode(self.related_object.url), + ) + + async def fetch_related_object_async(self) -> Optional[StripeObject]: + if self.related_object is None: + return None + + response = await self._client.raw_request_async( + "get", + self.related_object.url, + stripe_context=self.context, + usage=["fetch_related_object", "unknown_event"], + ) + return self._client.deserialize( + response, + api_mode=get_api_mode(self.related_object.url), + ) diff --git a/tests/test_v2_event.py b/tests/test_v2_event.py index d44630567..23141b99a 100644 --- a/tests/test_v2_event.py +++ b/tests/test_v2_event.py @@ -11,14 +11,14 @@ from stripe._error import SignatureVerificationError from stripe._stripe_client import StripeClient from stripe.events._v1_billing_meter_error_report_triggered_event import ( - PushedV1BillingMeterErrorReportTriggeredEvent, + V1BillingMeterErrorReportTriggeredEventNotification, V1BillingMeterErrorReportTriggeredEvent, ) -from stripe.v2._event import UnknownThinEvent -from stripe.events._event_classes import ALL_PUSHED_THIN_EVENTS +from stripe.v2._event import UnknownEventNotification +from stripe.events._event_classes import ALL_EVENT_NOTIFICATIONS from tests.test_webhook import DUMMY_WEBHOOK_SECRET, generate_header -EventParser = Callable[[str], ALL_PUSHED_THIN_EVENTS] +EventParser = Callable[[str], ALL_EVENT_NOTIFICATIONS] class TestV2Event(object): @@ -82,7 +82,7 @@ def parse_thin_event(self, stripe_client: StripeClient) -> EventParser: """ def _parse_thin_event(payload: str): - return stripe_client.parse_thin_event( + return stripe_client.parse_event_notification( payload, generate_header(payload=payload), DUMMY_WEBHOOK_SECRET ) @@ -93,7 +93,9 @@ def test_parses_thin_event( ): event = parse_thin_event(v2_payload_no_data) - assert isinstance(event, PushedV1BillingMeterErrorReportTriggeredEvent) + assert isinstance( + event, V1BillingMeterErrorReportTriggeredEventNotification + ) assert event.id == "evt_234" assert event.related_object @@ -107,7 +109,9 @@ def test_parses_thin_event_with_data( ): event = parse_thin_event(v2_payload_with_data) - assert isinstance(event, PushedV1BillingMeterErrorReportTriggeredEvent) + assert isinstance( + event, V1BillingMeterErrorReportTriggeredEventNotification + ) # this isn't for constructing events, it's for parsing thin ones assert not hasattr(event, "data") assert event.reason is None @@ -135,14 +139,14 @@ def test_parses_unknown_thin_event(self, parse_thin_event: EventParser): ) ) - assert type(event) is UnknownThinEvent + assert type(event) is UnknownEventNotification assert event.related_object def test_validates_signature( self, stripe_client: StripeClient, v2_payload_no_data ): with pytest.raises(SignatureVerificationError): - stripe_client.parse_thin_event( + stripe_client.parse_event_notification( v2_payload_no_data, "bad header", DUMMY_WEBHOOK_SECRET ) @@ -215,7 +219,7 @@ def test_v2_events_integration( thin_event = parse_thin_event(v2_payload_no_data) assert thin_event.type == "v1.billing.meter.error_report_triggered" - event = thin_event.pull() + event = thin_event.fetch_event() meter = thin_event.fetch_related_object() if sys.version_info >= (3, 7): @@ -223,7 +227,7 @@ def test_v2_events_integration( # these are purely type-level checks to ensure our narrowing works for users assert_type( - thin_event, PushedV1BillingMeterErrorReportTriggeredEvent + thin_event, V1BillingMeterErrorReportTriggeredEventNotification ) assert_type(event, V1BillingMeterErrorReportTriggeredEvent) From c3e7b595a17c2d887c3c40db9dfb08af41cf9f11 Mon Sep 17 00:00:00 2001 From: David Brownman Date: Fri, 29 Aug 2025 15:53:38 -0700 Subject: [PATCH 09/17] small fixes --- stripe/_stripe_client.py | 6 +++++- tests/test_util.py | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/stripe/_stripe_client.py b/stripe/_stripe_client.py index 408fc22d9..0b5465850 100644 --- a/stripe/_stripe_client.py +++ b/stripe/_stripe_client.py @@ -202,7 +202,11 @@ def parse_event_notification( secret: str, tolerance: int = Webhook.DEFAULT_TOLERANCE, ) -> "ALL_EVENT_NOTIFICATIONS": - """ """ + """ + This should be your main method for interacting with `EventNotifications`. It's the V2 equivalent of `construct_event()`, but with better typing support. + + It returns a union representing all known `EventNotification` classes. They have a `type` property that can be used for narrowing, which will get you very specific type support. If parsing an event the SDK isn't familiar with, it'll instead return `UnknownEventNotification`. That's not reflected in the return type of the function (because it messes up type narrowing) but is otherwise intended. + """ payload = ( cast(Union[bytes, bytearray], raw).decode("utf-8") if hasattr(raw, "decode") diff --git a/tests/test_util.py b/tests/test_util.py index f863ec41a..aa01eed5d 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -161,6 +161,7 @@ def test_sanitize_id(self): ["url", "expected"], [ ("/v2/core/events", "V2"), + ("/v2/v1/core/events", "V2"), ("/v1/events", "V1"), ("/oauth/authorize", "V1"), ("something/v2/core/events", "V1"), From b6e26239e389f0218693688f92192670dde42ebe Mon Sep 17 00:00:00 2001 From: David Brownman Date: Fri, 29 Aug 2025 17:10:35 -0700 Subject: [PATCH 10/17] export UnknownEventNotification --- stripe/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/stripe/__init__.py b/stripe/__init__.py index c17dc9d8f..bfc366b2b 100644 --- a/stripe/__init__.py +++ b/stripe/__init__.py @@ -100,7 +100,10 @@ def _warn_if_mismatched_proxy(): # StripeClient from stripe._stripe_client import StripeClient as StripeClient # noqa -from stripe.v2._event import EventNotification as EventNotification # noqa +from stripe.v2._event import ( + EventNotification as EventNotification, + UnknownEventNotification as UnknownEventNotification, +) # noqa # Sets some basic information about the running application that's sent along From d9871c7d7ddbbf20e4e11b4432a6361804df9e33 Mon Sep 17 00:00:00 2001 From: David Brownman Date: Wed, 3 Sep 2025 15:00:57 -0700 Subject: [PATCH 11/17] fix reason parsing --- stripe/v2/_event.py | 17 +++++++++++++++-- tests/test_v2_event.py | 22 +++++++++++++--------- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/stripe/v2/_event.py b/stripe/v2/_event.py index d0ace4234..2a5b0ac4a 100644 --- a/stripe/v2/_event.py +++ b/stripe/v2/_event.py @@ -75,7 +75,7 @@ class Request(StripeObject): # The end of the section generated from our OpenAPI spec -class Reason: +class ReasonRequest: id: str idempotency_key: str @@ -84,7 +84,20 @@ def __init__(self, d) -> None: self.idempotency_key = d["idempotency_key"] def __repr__(self) -> str: - return f"" + return f"" + + +class Reason: + type: Literal["request"] + request: Optional[ReasonRequest] = None + + def __init__(self, d) -> None: + self.type = d["type"] + if self.type == "request": + self.request = ReasonRequest(d["request"]) + + def __repr__(self) -> str: + return f"" class RelatedObject: diff --git a/tests/test_v2_event.py b/tests/test_v2_event.py index 23141b99a..05ae8607d 100644 --- a/tests/test_v2_event.py +++ b/tests/test_v2_event.py @@ -38,8 +38,8 @@ def v2_payload_no_data(self): "url": "/v1/billing/meters/mtr_123", }, "reason": { - "id": "foo", - "idempotency_key": "bar", + "type": "request", + "request": {"id": "foo", "idempotency_key": "bar"}, }, } ) @@ -102,7 +102,10 @@ def test_parses_thin_event( assert event.related_object.id == "mtr_123" assert event.reason - assert event.reason.id == "foo" + assert event.reason.type == "request" + assert event.reason.request + assert event.reason.request.id == "foo" + assert event.reason.request.idempotency_key == "bar" def test_parses_thin_event_with_data( self, parse_thin_event: EventParser, v2_payload_with_data: str @@ -131,10 +134,6 @@ def test_parses_unknown_thin_event(self, parse_thin_event: EventParser): "type": "billing.meter", "url": "/v1/billing/meters/mtr_123", }, - "reason": { - "id": "foo", - "idempotency_key": "bar", - }, } ) ) @@ -189,7 +188,8 @@ def test_v2_events_integration( http_client_mock: HTTPClientMock, v2_payload_no_data, v2_payload_with_data, - parse_thin_event: EventParser, + # use the real client so we get the real types + stripe_client: StripeClient, ): method = "get" path = "/v2/core/events/evt_234" @@ -216,7 +216,11 @@ def test_v2_events_integration( rheaders={}, ) - thin_event = parse_thin_event(v2_payload_no_data) + thin_event = stripe_client.parse_event_notification( + v2_payload_no_data, + generate_header(payload=v2_payload_no_data), + DUMMY_WEBHOOK_SECRET, + ) assert thin_event.type == "v1.billing.meter.error_report_triggered" event = thin_event.fetch_event() From 76eadd638e89695e72697000a048d7c650c8d447 Mon Sep 17 00:00:00 2001 From: David Brownman Date: Wed, 3 Sep 2025 15:06:23 -0700 Subject: [PATCH 12/17] rename thin_event --- .../event_notification__webhook_handler.py | 55 ++++++++++++++++++ examples/thinevent_webhook_handler.py | 53 ----------------- stripe/v2/_event.py | 2 +- tests/test_v2_event.py | 57 ++++++++++--------- 4 files changed, 85 insertions(+), 82 deletions(-) create mode 100644 examples/event_notification__webhook_handler.py delete mode 100644 examples/thinevent_webhook_handler.py diff --git a/examples/event_notification__webhook_handler.py b/examples/event_notification__webhook_handler.py new file mode 100644 index 000000000..2d1573e8b --- /dev/null +++ b/examples/event_notification__webhook_handler.py @@ -0,0 +1,55 @@ +""" +thinevent_webhook_handler.py - receive and process thin events like the +v1.billing.meter.error_report_triggered event. + +In this example, we: + - create a StripeClient called client + - use client.parse_event_notification() to parse the received notification webhook body + - if its type is "v1.billing.meter.error_report_triggered": + - call event_notification.fetch_event() to retrieve the full event object + - call event_notification.fetch_related_object() to retrieve the Meter that failed + - log info about the failure +""" + +import os +from stripe import StripeClient + +from flask import Flask, request, jsonify + +app = Flask(__name__) +api_key = os.environ.get("STRIPE_API_KEY", "") +webhook_secret = os.environ.get("WEBHOOK_SECRET", "") + +client = StripeClient(api_key) + + +@app.route("/webhook", methods=["POST"]) +def webhook(): + webhook_body = request.data + sig_header = request.headers.get("Stripe-Signature") + + try: + event_notification = client.parse_event_notification( + webhook_body, sig_header, webhook_secret + ) + + # Fetch the event data to understand the failure + if ( + event_notification.type + == "v1.billing.meter.error_report_triggered" + ): + meter = event_notification.fetch_related_object() + event = event_notification.fetch_event() + print( + f"Err! Meter {meter.id} had a problem (more info: {event.data.developer_message_summary})" + ) + # Record the failures and alert your team + # Add your logic here + + return jsonify(success=True), 200 + except Exception as e: + return jsonify(error=str(e)), 400 + + +if __name__ == "__main__": + app.run(port=4242) diff --git a/examples/thinevent_webhook_handler.py b/examples/thinevent_webhook_handler.py deleted file mode 100644 index f93e0a560..000000000 --- a/examples/thinevent_webhook_handler.py +++ /dev/null @@ -1,53 +0,0 @@ -""" -thinevent_webhook_handler.py - receive and process thin events like the -v1.billing.meter.error_report_triggered event. - -In this example, we: - - create a StripeClient called client - - use client.parse_thin_event to parse the received thin event webhook body - - call client.v2.core.events.retrieve to retrieve the full event object - - if it is a V1BillingMeterErrorReportTriggeredEvent event type, call - event.fetchRelatedObject to retrieve the Billing Meter object associated - with the event. -""" - -import os -from stripe import StripeClient -from stripe.events import V1BillingMeterErrorReportTriggeredEvent - -from flask import Flask, request, jsonify - -app = Flask(__name__) -api_key = os.environ.get("STRIPE_API_KEY") -webhook_secret = os.environ.get("WEBHOOK_SECRET") - -client = StripeClient(api_key) - - -@app.route("/webhook", methods=["POST"]) -def webhook(): - webhook_body = request.data - sig_header = request.headers.get("Stripe-Signature") - - try: - thin_event = client.parse_thin_event( - webhook_body, sig_header, webhook_secret - ) - - # Fetch the event data to understand the failure - event = client.v2.core.events.retrieve(thin_event.id) - if isinstance(event, V1BillingMeterErrorReportTriggeredEvent): - meter = event.fetch_related_object() - meter_id = meter.id - print("Success! " + str(meter_id)) - - # Record the failures and alert your team - # Add your logic here - - return jsonify(success=True), 200 - except Exception as e: - return jsonify(error=str(e)), 400 - - -if __name__ == "__main__": - app.run(port=4242) diff --git a/stripe/v2/_event.py b/stripe/v2/_event.py index 2a5b0ac4a..213346965 100644 --- a/stripe/v2/_event.py +++ b/stripe/v2/_event.py @@ -116,7 +116,7 @@ def __repr__(self) -> str: class EventNotification: """ - EventNotification represents the json that's delivered from an Event Destination. It's a basic `dict` with no additional methods or properties. Use it to check basic information about a delivered event. If you want more details, use `stripe.v2.Event.retrieve(thin_event.id)` to fetch the full event object. + EventNotification represents the json that's delivered from an Event Destination. It's a basic struct-like object with a few convenience methods. Use `fetch_event()` to get the full event object. """ id: str diff --git a/tests/test_v2_event.py b/tests/test_v2_event.py index 05ae8607d..ea9b0b9bc 100644 --- a/tests/test_v2_event.py +++ b/tests/test_v2_event.py @@ -75,52 +75,52 @@ def stripe_client(self, http_client_mock): ) @pytest.fixture(scope="function") - def parse_thin_event(self, stripe_client: StripeClient) -> EventParser: + def parse_event_notif(self, stripe_client: StripeClient) -> EventParser: """ helper to simplify parsing and validating events given a payload returns a function that has the client pre-bound """ - def _parse_thin_event(payload: str): + def _parse_event_notif(payload: str): return stripe_client.parse_event_notification( payload, generate_header(payload=payload), DUMMY_WEBHOOK_SECRET ) - return _parse_thin_event + return _parse_event_notif - def test_parses_thin_event( - self, parse_thin_event: EventParser, v2_payload_no_data: str + def test_parses_event_notif( + self, parse_event_notif: EventParser, v2_payload_no_data: str ): - event = parse_thin_event(v2_payload_no_data) + notif = parse_event_notif(v2_payload_no_data) assert isinstance( - event, V1BillingMeterErrorReportTriggeredEventNotification + notif, V1BillingMeterErrorReportTriggeredEventNotification ) - assert event.id == "evt_234" + assert notif.id == "evt_234" - assert event.related_object - assert event.related_object.id == "mtr_123" + assert notif.related_object + assert notif.related_object.id == "mtr_123" - assert event.reason - assert event.reason.type == "request" - assert event.reason.request - assert event.reason.request.id == "foo" - assert event.reason.request.idempotency_key == "bar" + assert notif.reason + assert notif.reason.type == "request" + assert notif.reason.request + assert notif.reason.request.id == "foo" + assert notif.reason.request.idempotency_key == "bar" - def test_parses_thin_event_with_data( - self, parse_thin_event: EventParser, v2_payload_with_data: str + def test_parses_event_notif_with_data( + self, parse_event_notif: EventParser, v2_payload_with_data: str ): - event = parse_thin_event(v2_payload_with_data) + notif = parse_event_notif(v2_payload_with_data) assert isinstance( - event, V1BillingMeterErrorReportTriggeredEventNotification + notif, V1BillingMeterErrorReportTriggeredEventNotification ) # this isn't for constructing events, it's for parsing thin ones - assert not hasattr(event, "data") - assert event.reason is None + assert not hasattr(notif, "data") + assert notif.reason is None - def test_parses_unknown_thin_event(self, parse_thin_event: EventParser): - event = parse_thin_event( + def test_parses_unknown_event_notif(self, parse_event_notif: EventParser): + event = parse_event_notif( json.dumps( { "id": "evt_234", @@ -216,22 +216,23 @@ def test_v2_events_integration( rheaders={}, ) - thin_event = stripe_client.parse_event_notification( + event_notif = stripe_client.parse_event_notification( v2_payload_no_data, generate_header(payload=v2_payload_no_data), DUMMY_WEBHOOK_SECRET, ) - assert thin_event.type == "v1.billing.meter.error_report_triggered" + assert event_notif.type == "v1.billing.meter.error_report_triggered" - event = thin_event.fetch_event() - meter = thin_event.fetch_related_object() + event = event_notif.fetch_event() + meter = event_notif.fetch_related_object() if sys.version_info >= (3, 7): from typing_extensions import assert_type # noqa: SPY103 - this is only available on 3.6 pythons because of typing_extensions version restrictions # these are purely type-level checks to ensure our narrowing works for users assert_type( - thin_event, V1BillingMeterErrorReportTriggeredEventNotification + event_notif, + V1BillingMeterErrorReportTriggeredEventNotification, ) assert_type(event, V1BillingMeterErrorReportTriggeredEvent) From 86a45946e8d2bd98f9f4475ad77ab91f4bf5186c Mon Sep 17 00:00:00 2001 From: David Brownman Date: Tue, 23 Sep 2025 14:30:26 -0700 Subject: [PATCH 13/17] fix example & re-export event notifications --- ...ook_handler.py => event_notification_webhook_handler.py} | 2 +- stripe/events/__init__.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) rename examples/{event_notification__webhook_handler.py => event_notification_webhook_handler.py} (95%) diff --git a/examples/event_notification__webhook_handler.py b/examples/event_notification_webhook_handler.py similarity index 95% rename from examples/event_notification__webhook_handler.py rename to examples/event_notification_webhook_handler.py index 2d1573e8b..3e5ffc869 100644 --- a/examples/event_notification__webhook_handler.py +++ b/examples/event_notification_webhook_handler.py @@ -1,5 +1,5 @@ """ -thinevent_webhook_handler.py - receive and process thin events like the +event_notification_webhook_handler.py - receive and process thin events like the v1.billing.meter.error_report_triggered event. In this example, we: diff --git a/stripe/events/__init__.py b/stripe/events/__init__.py index efa6bc4d5..caaad8364 100644 --- a/stripe/events/__init__.py +++ b/stripe/events/__init__.py @@ -1,11 +1,17 @@ # -*- coding: utf-8 -*- # File generated from our OpenAPI spec +from stripe.events._event_classes import ( + ALL_EVENT_NOTIFICATIONS as ALL_EVENT_NOTIFICATIONS, +) from stripe.events._v1_billing_meter_error_report_triggered_event import ( V1BillingMeterErrorReportTriggeredEvent as V1BillingMeterErrorReportTriggeredEvent, + V1BillingMeterErrorReportTriggeredEventNotification as V1BillingMeterErrorReportTriggeredEventNotification, ) from stripe.events._v1_billing_meter_no_meter_found_event import ( V1BillingMeterNoMeterFoundEvent as V1BillingMeterNoMeterFoundEvent, + V1BillingMeterNoMeterFoundEventNotification as V1BillingMeterNoMeterFoundEventNotification, ) from stripe.events._v2_core_event_destination_ping_event import ( V2CoreEventDestinationPingEvent as V2CoreEventDestinationPingEvent, + V2CoreEventDestinationPingEventNotification as V2CoreEventDestinationPingEventNotification, ) From ca9d5d7437979c90d5f59c7860b0658d26c21651 Mon Sep 17 00:00:00 2001 From: David Brownman Date: Tue, 23 Sep 2025 15:34:45 -0700 Subject: [PATCH 14/17] move some imports --- stripe/__init__.py | 5 ----- stripe/v2/__init__.py | 7 +++++++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/stripe/__init__.py b/stripe/__init__.py index bfc366b2b..5ac7ea60f 100644 --- a/stripe/__init__.py +++ b/stripe/__init__.py @@ -100,11 +100,6 @@ def _warn_if_mismatched_proxy(): # StripeClient from stripe._stripe_client import StripeClient as StripeClient # noqa -from stripe.v2._event import ( - EventNotification as EventNotification, - UnknownEventNotification as UnknownEventNotification, -) # noqa - # Sets some basic information about the running application that's sent along # with API requests. Useful for plugin authors to identify their plugin when diff --git a/stripe/v2/__init__.py b/stripe/v2/__init__.py index d3821e5a6..8e1532fb8 100644 --- a/stripe/v2/__init__.py +++ b/stripe/v2/__init__.py @@ -1,5 +1,12 @@ from stripe.v2._list_object import ListObject as ListObject from stripe.v2._amount import Amount as Amount, AmountParam as AmountParam +from stripe.v2._event import ( + EventNotification as EventNotification, + UnknownEventNotification as UnknownEventNotification, + RelatedObject as RelatedObject, + Reason as Reason, + ReasonRequest as ReasonRequest, +) # The beginning of the section generated from our OpenAPI spec From f590b521d8ed421ec05a0da6c7da1e787889496f Mon Sep 17 00:00:00 2001 From: David Brownman Date: Wed, 24 Sep 2025 15:18:00 -0700 Subject: [PATCH 15/17] swap event fetch_related_object to stripe-context --- stripe/events/_v1_billing_meter_error_report_triggered_event.py | 2 +- stripe/events/_v2_core_event_destination_ping_event.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/stripe/events/_v1_billing_meter_error_report_triggered_event.py b/stripe/events/_v1_billing_meter_error_report_triggered_event.py index 78ca16566..11d8e138f 100644 --- a/stripe/events/_v1_billing_meter_error_report_triggered_event.py +++ b/stripe/events/_v1_billing_meter_error_report_triggered_event.py @@ -207,6 +207,6 @@ def fetch_related_object(self) -> Meter: "get", self.related_object.url, base_address="api", - options={"stripe_account": self.context}, + options={"stripe_context": self.context}, ), ) diff --git a/stripe/events/_v2_core_event_destination_ping_event.py b/stripe/events/_v2_core_event_destination_ping_event.py index dc3123c8c..343e30adc 100644 --- a/stripe/events/_v2_core_event_destination_ping_event.py +++ b/stripe/events/_v2_core_event_destination_ping_event.py @@ -101,6 +101,6 @@ def fetch_related_object(self) -> EventDestination: "get", self.related_object.url, base_address="api", - options={"stripe_account": self.context}, + options={"stripe_context": self.context}, ), ) From ecebd5681f17b3617025b489770a8a50528d51ea Mon Sep 17 00:00:00 2001 From: David Brownman Date: Wed, 24 Sep 2025 18:36:52 -0700 Subject: [PATCH 16/17] update docstring --- stripe/v2/_event.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stripe/v2/_event.py b/stripe/v2/_event.py index 213346965..339eb9481 100644 --- a/stripe/v2/_event.py +++ b/stripe/v2/_event.py @@ -200,7 +200,7 @@ async def fetch_event_async(self) -> Event: class UnknownEventNotification(EventNotification): """ - Represents a Thin Event payload that the SDK doesn't have types for. May have a related object. + Represents an EventNotification payload that the SDK doesn't have types for. May have a related object. """ related_object: Optional[RelatedObject] = None From bdbcf13c908a1f6b72d0e2cc864dde421f48cb45 Mon Sep 17 00:00:00 2001 From: David Brownman Date: Wed, 24 Sep 2025 18:45:15 -0700 Subject: [PATCH 17/17] update comment --- stripe/v2/_event.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/stripe/v2/_event.py b/stripe/v2/_event.py index 339eb9481..7a3b26578 100644 --- a/stripe/v2/_event.py +++ b/stripe/v2/_event.py @@ -161,7 +161,9 @@ def __init__( @staticmethod def from_json(payload: str, client: "StripeClient") -> "EventNotification": """ - The `from_json` constructor shouldn't be used in production code (since it doesn't validate webhook signatures). But it's useful for testing. It's also called by `StripeClient.parse_event_notification`. + Helper for constructing an Event Notification. Doesn't perform signature validation, so you + should use StripeClient.parseEventNotification() instead for initial handling. + This is useful in unit tests and working with EventNotifications that you've already validated the authenticity of. """ parsed_body = json.loads(payload)