From e47e14609c48f9c0c6a275ccb4303ec5d7552814 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 22 Apr 2026 12:40:27 -0400 Subject: [PATCH 1/5] feat: emit discriminated union dispatcher for EventSchema EventSchema.from_dict() now dispatches to the correct typed variant (e.g. DsyncUserCreated, UserCreated) based on the 'event' field, instead of returning a flat dataclass with data: Dict[str, Any]. list_events() and verify_event() return SyncPage[EventSchemaVariant] / AsyncPage[EventSchemaVariant] and EventSchemaVariant respectively, where EventSchemaVariant = Union[ActionAuthenticationDenied, ...] covers all 95 concrete event types with fully-typed data fields. Co-Authored-By: Claude Sonnet 4.6 --- .oagen-manifest.json | 2 +- src/workos/events/_resource.py | 42 +-- src/workos/events/models/__init__.py | 1 + src/workos/events/models/event_schema.py | 363 ++++++++++++++++++++--- src/workos/webhooks/_resource.py | 10 +- src/workos/webhooks/_verification.py | 4 +- tests/test_models_round_trip.py | 74 +---- tests/test_webhook_verification.py | 24 +- 8 files changed, 373 insertions(+), 147 deletions(-) diff --git a/.oagen-manifest.json b/.oagen-manifest.json index b8c8d278..748db6a8 100644 --- a/.oagen-manifest.json +++ b/.oagen-manifest.json @@ -1,7 +1,7 @@ { "version": 1, "language": "python", - "generatedAt": "2026-04-20T20:55:24.754Z", + "generatedAt": "2026-04-21T02:49:35.456Z", "files": [ "src/workos/_client.py", "src/workos/admin_portal/__init__.py", diff --git a/src/workos/events/_resource.py b/src/workos/events/_resource.py index c90ca33a..ec493f8b 100644 --- a/src/workos/events/_resource.py +++ b/src/workos/events/_resource.py @@ -2,13 +2,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING, List, Optional, Union +from typing import TYPE_CHECKING, List, Optional, Union, cast if TYPE_CHECKING: from .._client import AsyncWorkOSClient, WorkOSClient from .._types import RequestOptions, enum_value -from .models import EventSchema +from .models import EventSchema, EventSchemaVariant from .models import EventsOrder from .._pagination import AsyncPage, SyncPage @@ -31,7 +31,7 @@ def list_events( range_end: Optional[str] = None, organization_id: Optional[str] = None, request_options: Optional[RequestOptions] = None, - ) -> SyncPage[EventSchema]: + ) -> SyncPage[EventSchemaVariant]: """List events List events for the current environment. @@ -48,7 +48,7 @@ def list_events( request_options: Per-request options. Supports extra_headers, timeout, max_retries, and base_url override. Returns: - SyncPage[EventSchema] + SyncPage[EventSchemaVariant] Raises: BadRequestError: If the request is malformed (400). @@ -73,12 +73,15 @@ def list_events( }.items() if v is not None } - return self._client.request_page( - method="get", - path="events", - model=EventSchema, - params=params, - request_options=request_options, + return cast( + SyncPage[EventSchemaVariant], + self._client.request_page( + method="get", + path="events", + model=EventSchema, # type: ignore[arg-type] + params=params, + request_options=request_options, + ), ) @@ -100,7 +103,7 @@ async def list_events( range_end: Optional[str] = None, organization_id: Optional[str] = None, request_options: Optional[RequestOptions] = None, - ) -> AsyncPage[EventSchema]: + ) -> AsyncPage[EventSchemaVariant]: """List events List events for the current environment. @@ -117,7 +120,7 @@ async def list_events( request_options: Per-request options. Supports extra_headers, timeout, max_retries, and base_url override. Returns: - AsyncPage[EventSchema] + AsyncPage[EventSchemaVariant] Raises: BadRequestError: If the request is malformed (400). @@ -142,10 +145,13 @@ async def list_events( }.items() if v is not None } - return await self._client.request_page( - method="get", - path="events", - model=EventSchema, - params=params, - request_options=request_options, + return cast( + AsyncPage[EventSchemaVariant], + await self._client.request_page( + method="get", + path="events", + model=EventSchema, # type: ignore[arg-type] + params=params, + request_options=request_options, + ), ) diff --git a/src/workos/events/models/__init__.py b/src/workos/events/models/__init__.py index 565b2f2e..9c5697d7 100644 --- a/src/workos/events/models/__init__.py +++ b/src/workos/events/models/__init__.py @@ -2,4 +2,5 @@ from .event_list_list_metadata import EventListListMetadata as EventListListMetadata from .event_schema import EventSchema as EventSchema +from .event_schema import EventSchemaVariant as EventSchemaVariant from .events_order import EventsOrder as EventsOrder diff --git a/src/workos/events/models/event_schema.py b/src/workos/events/models/event_schema.py index 7ebd7a28..be35c436 100644 --- a/src/workos/events/models/event_schema.py +++ b/src/workos/events/models/event_schema.py @@ -2,53 +2,332 @@ from __future__ import annotations -from dataclasses import dataclass -from datetime import datetime -from typing import Any, Dict, Literal, Optional +from typing import Any, ClassVar, Dict, Union, cast from workos._types import _raise_deserialize_error -from workos._types import _format_datetime, _parse_datetime + +from workos.common.models.action_authentication_denied import ActionAuthenticationDenied +from workos.common.models.action_user_registration_denied import ( + ActionUserRegistrationDenied, +) +from workos.common.models.api_key_created import ApiKeyCreated +from workos.common.models.api_key_revoked import ApiKeyRevoked +from workos.common.models.authentication_email_verification_failed import ( + AuthenticationEmailVerificationFailed, +) +from workos.common.models.authentication_email_verification_succeeded import ( + AuthenticationEmailVerificationSucceeded, +) +from workos.common.models.authentication_magic_auth_failed import ( + AuthenticationMagicAuthFailed, +) +from workos.common.models.authentication_magic_auth_succeeded import ( + AuthenticationMagicAuthSucceeded, +) +from workos.common.models.authentication_mfa_failed import AuthenticationMFAFailed +from workos.common.models.authentication_mfa_succeeded import AuthenticationMFASucceeded +from workos.common.models.authentication_oauth_failed import AuthenticationOAuthFailed +from workos.common.models.authentication_oauth_succeeded import ( + AuthenticationOAuthSucceeded, +) +from workos.common.models.authentication_passkey_failed import ( + AuthenticationPasskeyFailed, +) +from workos.common.models.authentication_passkey_succeeded import ( + AuthenticationPasskeySucceeded, +) +from workos.common.models.authentication_password_failed import ( + AuthenticationPasswordFailed, +) +from workos.common.models.authentication_password_succeeded import ( + AuthenticationPasswordSucceeded, +) +from workos.common.models.authentication_radar_risk_detected import ( + AuthenticationRadarRiskDetected, +) +from workos.common.models.authentication_sso_failed import AuthenticationSSOFailed +from workos.common.models.authentication_sso_started import AuthenticationSSOStarted +from workos.common.models.authentication_sso_succeeded import AuthenticationSSOSucceeded +from workos.common.models.authentication_sso_timed_out import AuthenticationSSOTimedOut +from workos.common.models.connection_activated import ConnectionActivated +from workos.common.models.connection_deactivated import ConnectionDeactivated +from workos.common.models.connection_deleted import ConnectionDeleted +from workos.common.models.connection_saml_certificate_renewal_required import ( + ConnectionSAMLCertificateRenewalRequired, +) +from workos.common.models.connection_saml_certificate_renewed import ( + ConnectionSAMLCertificateRenewed, +) +from workos.common.models.dsync_activated import DsyncActivated +from workos.common.models.dsync_deactivated import DsyncDeactivated +from workos.common.models.dsync_deleted import DsyncDeleted +from workos.common.models.dsync_group_created import DsyncGroupCreated +from workos.common.models.dsync_group_deleted import DsyncGroupDeleted +from workos.common.models.dsync_group_updated import DsyncGroupUpdated +from workos.common.models.dsync_group_user_added import DsyncGroupUserAdded +from workos.common.models.dsync_group_user_removed import DsyncGroupUserRemoved +from workos.common.models.dsync_user_created import DsyncUserCreated +from workos.common.models.dsync_user_deleted import DsyncUserDeleted +from workos.common.models.dsync_user_updated import DsyncUserUpdated +from workos.common.models.email_verification_created import EmailVerificationCreated +from workos.common.models.flag_created import FlagCreated +from workos.common.models.flag_deleted import FlagDeleted +from workos.common.models.flag_rule_updated import FlagRuleUpdated +from workos.common.models.flag_updated import FlagUpdated +from workos.common.models.group_created import GroupCreated +from workos.common.models.group_deleted import GroupDeleted +from workos.common.models.group_member_added import GroupMemberAdded +from workos.common.models.group_member_removed import GroupMemberRemoved +from workos.common.models.group_updated import GroupUpdated +from workos.common.models.invitation_accepted import InvitationAccepted +from workos.common.models.invitation_created import InvitationCreated +from workos.common.models.invitation_resent import InvitationResent +from workos.common.models.invitation_revoked import InvitationRevoked +from workos.common.models.magic_auth_created import MagicAuthCreated +from workos.common.models.organization_created import OrganizationCreated +from workos.common.models.organization_deleted import OrganizationDeleted +from workos.common.models.organization_domain_created import OrganizationDomainCreated +from workos.common.models.organization_domain_deleted import OrganizationDomainDeleted +from workos.common.models.organization_domain_updated import OrganizationDomainUpdated +from workos.common.models.organization_domain_verification_failed import ( + OrganizationDomainVerificationFailed, +) +from workos.common.models.organization_domain_verified import OrganizationDomainVerified +from workos.common.models.organization_membership_created import ( + OrganizationMembershipCreated, +) +from workos.common.models.organization_membership_deleted import ( + OrganizationMembershipDeleted, +) +from workos.common.models.organization_membership_updated import ( + OrganizationMembershipUpdated, +) +from workos.common.models.organization_role_created import OrganizationRoleCreated +from workos.common.models.organization_role_deleted import OrganizationRoleDeleted +from workos.common.models.organization_role_updated import OrganizationRoleUpdated +from workos.common.models.organization_updated import OrganizationUpdated +from workos.common.models.password_reset_created import PasswordResetCreated +from workos.common.models.password_reset_succeeded import PasswordResetSucceeded +from workos.common.models.permission_created import PermissionCreated +from workos.common.models.permission_deleted import PermissionDeleted +from workos.common.models.permission_updated import PermissionUpdated +from workos.common.models.role_created import RoleCreated +from workos.common.models.role_deleted import RoleDeleted +from workos.common.models.role_updated import RoleUpdated +from workos.common.models.session_created import SessionCreated +from workos.common.models.session_revoked import SessionRevoked +from workos.common.models.user_created import UserCreated +from workos.common.models.user_deleted import UserDeleted +from workos.common.models.user_updated import UserUpdated +from workos.common.models.vault_byok_key_verification_completed import ( + VaultByokKeyVerificationCompleted, +) +from workos.common.models.vault_data_created import VaultDataCreated +from workos.common.models.vault_data_deleted import VaultDataDeleted +from workos.common.models.vault_data_read import VaultDataRead +from workos.common.models.vault_data_updated import VaultDataUpdated +from workos.common.models.vault_dek_decrypted import VaultDekDecrypted +from workos.common.models.vault_dek_read import VaultDekRead +from workos.common.models.vault_kek_created import VaultKekCreated +from workos.common.models.vault_metadata_read import VaultMetadataRead +from workos.common.models.vault_names_listed import VaultNamesListed + + +EventSchemaVariant = Union[ + ActionAuthenticationDenied, + ActionUserRegistrationDenied, + ApiKeyCreated, + ApiKeyRevoked, + AuthenticationEmailVerificationFailed, + AuthenticationEmailVerificationSucceeded, + AuthenticationMagicAuthFailed, + AuthenticationMagicAuthSucceeded, + AuthenticationMFAFailed, + AuthenticationMFASucceeded, + AuthenticationOAuthFailed, + AuthenticationOAuthSucceeded, + AuthenticationPasskeyFailed, + AuthenticationPasskeySucceeded, + AuthenticationPasswordFailed, + AuthenticationPasswordSucceeded, + AuthenticationRadarRiskDetected, + AuthenticationSSOFailed, + AuthenticationSSOStarted, + AuthenticationSSOSucceeded, + AuthenticationSSOTimedOut, + ConnectionActivated, + ConnectionDeactivated, + ConnectionDeleted, + ConnectionSAMLCertificateRenewalRequired, + ConnectionSAMLCertificateRenewed, + DsyncActivated, + DsyncDeactivated, + DsyncDeleted, + DsyncGroupCreated, + DsyncGroupDeleted, + DsyncGroupUpdated, + DsyncGroupUserAdded, + DsyncGroupUserRemoved, + DsyncUserCreated, + DsyncUserDeleted, + DsyncUserUpdated, + EmailVerificationCreated, + FlagCreated, + FlagDeleted, + FlagRuleUpdated, + FlagUpdated, + GroupCreated, + GroupDeleted, + GroupMemberAdded, + GroupMemberRemoved, + GroupUpdated, + InvitationAccepted, + InvitationCreated, + InvitationResent, + InvitationRevoked, + MagicAuthCreated, + OrganizationCreated, + OrganizationDeleted, + OrganizationDomainCreated, + OrganizationDomainDeleted, + OrganizationDomainUpdated, + OrganizationDomainVerificationFailed, + OrganizationDomainVerified, + OrganizationMembershipCreated, + OrganizationMembershipDeleted, + OrganizationMembershipUpdated, + OrganizationRoleCreated, + OrganizationRoleDeleted, + OrganizationRoleUpdated, + OrganizationUpdated, + PasswordResetCreated, + PasswordResetSucceeded, + PermissionCreated, + PermissionDeleted, + PermissionUpdated, + RoleCreated, + RoleDeleted, + RoleUpdated, + SessionCreated, + SessionRevoked, + UserCreated, + UserDeleted, + UserUpdated, + VaultByokKeyVerificationCompleted, + VaultDataCreated, + VaultDataDeleted, + VaultDataRead, + VaultDataUpdated, + VaultDekDecrypted, + VaultDekRead, + VaultKekCreated, + VaultMetadataRead, + VaultNamesListed, +] -@dataclass(slots=True) class EventSchema: """An event emitted by WorkOS.""" - object: Literal["event"] - """Distinguishes the Event object.""" - id: str - """Unique identifier for the Event.""" - event: str - """The type of event that occurred.""" - data: Dict[str, Any] - """The event payload.""" - created_at: datetime - """An ISO 8601 timestamp.""" - context: Optional[Dict[str, Any]] = None - """Additional context about the event.""" + _DISPATCH: ClassVar[Dict[str, Any]] = { + "action.authentication.denied": ActionAuthenticationDenied, + "action.user_registration.denied": ActionUserRegistrationDenied, + "api_key.created": ApiKeyCreated, + "api_key.revoked": ApiKeyRevoked, + "authentication.email_verification_failed": AuthenticationEmailVerificationFailed, + "authentication.email_verification_succeeded": AuthenticationEmailVerificationSucceeded, + "authentication.magic_auth_failed": AuthenticationMagicAuthFailed, + "authentication.magic_auth_succeeded": AuthenticationMagicAuthSucceeded, + "authentication.mfa_failed": AuthenticationMFAFailed, + "authentication.mfa_succeeded": AuthenticationMFASucceeded, + "authentication.oauth_failed": AuthenticationOAuthFailed, + "authentication.oauth_succeeded": AuthenticationOAuthSucceeded, + "authentication.passkey_failed": AuthenticationPasskeyFailed, + "authentication.passkey_succeeded": AuthenticationPasskeySucceeded, + "authentication.password_failed": AuthenticationPasswordFailed, + "authentication.password_succeeded": AuthenticationPasswordSucceeded, + "authentication.radar_risk_detected": AuthenticationRadarRiskDetected, + "authentication.sso_failed": AuthenticationSSOFailed, + "authentication.sso_started": AuthenticationSSOStarted, + "authentication.sso_succeeded": AuthenticationSSOSucceeded, + "authentication.sso_timed_out": AuthenticationSSOTimedOut, + "connection.activated": ConnectionActivated, + "connection.deactivated": ConnectionDeactivated, + "connection.deleted": ConnectionDeleted, + "connection.saml_certificate_renewal_required": ConnectionSAMLCertificateRenewalRequired, + "connection.saml_certificate_renewed": ConnectionSAMLCertificateRenewed, + "dsync.activated": DsyncActivated, + "dsync.deactivated": DsyncDeactivated, + "dsync.deleted": DsyncDeleted, + "dsync.group.created": DsyncGroupCreated, + "dsync.group.deleted": DsyncGroupDeleted, + "dsync.group.updated": DsyncGroupUpdated, + "dsync.group.user_added": DsyncGroupUserAdded, + "dsync.group.user_removed": DsyncGroupUserRemoved, + "dsync.user.created": DsyncUserCreated, + "dsync.user.deleted": DsyncUserDeleted, + "dsync.user.updated": DsyncUserUpdated, + "email_verification.created": EmailVerificationCreated, + "flag.created": FlagCreated, + "flag.deleted": FlagDeleted, + "flag.rule_updated": FlagRuleUpdated, + "flag.updated": FlagUpdated, + "group.created": GroupCreated, + "group.deleted": GroupDeleted, + "group.member_added": GroupMemberAdded, + "group.member_removed": GroupMemberRemoved, + "group.updated": GroupUpdated, + "invitation.accepted": InvitationAccepted, + "invitation.created": InvitationCreated, + "invitation.resent": InvitationResent, + "invitation.revoked": InvitationRevoked, + "magic_auth.created": MagicAuthCreated, + "organization_domain.created": OrganizationDomainCreated, + "organization_domain.deleted": OrganizationDomainDeleted, + "organization_domain.updated": OrganizationDomainUpdated, + "organization_domain.verification_failed": OrganizationDomainVerificationFailed, + "organization_domain.verified": OrganizationDomainVerified, + "organization_membership.created": OrganizationMembershipCreated, + "organization_membership.deleted": OrganizationMembershipDeleted, + "organization_membership.updated": OrganizationMembershipUpdated, + "organization_role.created": OrganizationRoleCreated, + "organization_role.deleted": OrganizationRoleDeleted, + "organization_role.updated": OrganizationRoleUpdated, + "organization.created": OrganizationCreated, + "organization.deleted": OrganizationDeleted, + "organization.updated": OrganizationUpdated, + "password_reset.created": PasswordResetCreated, + "password_reset.succeeded": PasswordResetSucceeded, + "permission.created": PermissionCreated, + "permission.deleted": PermissionDeleted, + "permission.updated": PermissionUpdated, + "role.created": RoleCreated, + "role.deleted": RoleDeleted, + "role.updated": RoleUpdated, + "session.created": SessionCreated, + "session.revoked": SessionRevoked, + "user.created": UserCreated, + "user.deleted": UserDeleted, + "user.updated": UserUpdated, + "vault.byok_key.verification_completed": VaultByokKeyVerificationCompleted, + "vault.data.created": VaultDataCreated, + "vault.data.deleted": VaultDataDeleted, + "vault.data.read": VaultDataRead, + "vault.data.updated": VaultDataUpdated, + "vault.dek.decrypted": VaultDekDecrypted, + "vault.dek.read": VaultDekRead, + "vault.kek.created": VaultKekCreated, + "vault.metadata.read": VaultMetadataRead, + "vault.names.listed": VaultNamesListed, + } @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "EventSchema": - """Deserialize from a dictionary.""" - try: - return cls( - object=data["object"], - id=data["id"], - event=data["event"], - data=data["data"], - created_at=_parse_datetime(data["created_at"]), - context=data.get("context"), - ) - except (KeyError, ValueError) as e: - _raise_deserialize_error("EventSchema", e) - - def to_dict(self) -> Dict[str, Any]: - """Serialize to a dictionary.""" - result: Dict[str, Any] = {} - result["object"] = self.object - result["id"] = self.id - result["event"] = self.event - result["data"] = self.data - result["created_at"] = _format_datetime(self.created_at) - if self.context is not None: - result["context"] = self.context - return result + def from_dict(cls, data: Dict[str, Any]) -> "EventSchemaVariant": + """Deserialize from a dictionary, dispatching to the correct variant.""" + event_type = data.get("event") + if event_type is not None: + dispatch_cls = cls._DISPATCH.get(str(event_type)) + if dispatch_cls is not None: + return cast("EventSchemaVariant", dispatch_cls.from_dict(data)) + _raise_deserialize_error( + "EventSchema", ValueError(f"Unknown event: {event_type!r}") + ) diff --git a/src/workos/webhooks/_resource.py b/src/workos/webhooks/_resource.py index 311e53ce..7f0a27f2 100644 --- a/src/workos/webhooks/_resource.py +++ b/src/workos/webhooks/_resource.py @@ -200,7 +200,7 @@ def verify_event( event_signature: str, secret: str, tolerance: Optional[int] = None, - ) -> "EventSchema": + ) -> "EventSchemaVariant": """Verify and deserialize the signature of a Webhook event. Args: @@ -210,7 +210,7 @@ def verify_event( tolerance: Maximum age of the event in seconds. Defaults to 180. Returns: - EventSchema: The deserialized webhook event. + EventSchemaVariant: The deserialized webhook event. Raises: ValueError: If the signature is invalid or the event is too old. @@ -457,7 +457,7 @@ def verify_event( event_signature: str, secret: str, tolerance: Optional[int] = None, - ) -> "EventSchema": + ) -> "EventSchemaVariant": """Verify and deserialize the signature of a Webhook event. Args: @@ -467,7 +467,7 @@ def verify_event( tolerance: Maximum age of the event in seconds. Defaults to 180. Returns: - EventSchema: The deserialized webhook event. + EventSchemaVariant: The deserialized webhook event. Raises: ValueError: If the signature is invalid or the event is too old. @@ -542,5 +542,5 @@ def verify_header( # @oagen-ignore-start if TYPE_CHECKING: - from workos.events.models import EventSchema + from workos.events.models import EventSchemaVariant # @oagen-ignore-end diff --git a/src/workos/webhooks/_verification.py b/src/workos/webhooks/_verification.py index 4387a5e1..40087764 100644 --- a/src/workos/webhooks/_verification.py +++ b/src/workos/webhooks/_verification.py @@ -10,7 +10,7 @@ import time from typing import Optional, Union -from workos.events.models import EventSchema +from workos.events.models import EventSchema, EventSchemaVariant WebhookPayload = Union[bytes, bytearray] @@ -23,7 +23,7 @@ def verify_event( event_signature: str, secret: str, tolerance: Optional[int] = DEFAULT_TOLERANCE, -) -> EventSchema: +) -> EventSchemaVariant: """Verify and deserialize the signature of a Webhook event. Args: diff --git a/tests/test_models_round_trip.py b/tests/test_models_round_trip.py index 9a7b84bb..3648b847 100644 --- a/tests/test_models_round_trip.py +++ b/tests/test_models_round_trip.py @@ -288,7 +288,7 @@ DirectoryUserWithGroups, DirectoryUserWithGroupsEmail, ) -from workos.events.models import EventListListMetadata, EventSchema +from workos.events.models import EventListListMetadata from workos.feature_flags.models import FeatureFlag, FeatureFlagOwner, Flag, FlagOwner from workos.multi_factor_auth.models import ( AuthenticationChallenge, @@ -2187,78 +2187,6 @@ def test_user_preserves_nullable_fields(self): assert serialized["last_sign_in_at"] is None assert serialized["locale"] is None - def test_event_schema_round_trip(self): - data = load_fixture("event_schema.json") - instance = EventSchema.from_dict(data) - serialized = instance.to_dict() - assert serialized == data - restored = EventSchema.from_dict(serialized) - assert restored.to_dict() == serialized - - def test_event_schema_minimal_payload(self): - data = { - "object": "event", - "id": "event_01EHZNVPK3SFK441A1RGBFSHRT", - "event": "dsync.user.created", - "data": { - "id": "directory_user_01E1JG7J09H96KYP8HM9B0G5SJ", - "directory_id": "directory_01ECAZ4NV9QMV47GW873HDCX74", - "organization_id": "org_01EZTR6WYX1A0DSE2CYMGXQ24Y", - "state": "active", - "email": "veda@foo-corp.com", - "emails": [ - {"primary": True, "type": "work", "value": "veda@foo-corp.com"} - ], - "idp_id": "2836", - "object": "directory_user", - "username": "veda@foo-corp.com", - "last_name": "Torp", - "first_name": "Veda", - "raw_attributes": {}, - "custom_attributes": {}, - "created_at": "2021-06-25T19:07:33.155Z", - "updated_at": "2021-06-25T19:07:33.155Z", - }, - "created_at": "2026-01-15T12:00:00.000Z", - } - instance = EventSchema.from_dict(data) - serialized = instance.to_dict() - assert serialized["object"] == data["object"] - assert serialized["id"] == data["id"] - assert serialized["event"] == data["event"] - assert serialized["data"] == data["data"] - assert serialized["created_at"] == data["created_at"] - - def test_event_schema_omits_absent_optional_non_nullable_fields(self): - data = { - "object": "event", - "id": "event_01EHZNVPK3SFK441A1RGBFSHRT", - "event": "dsync.user.created", - "data": { - "id": "directory_user_01E1JG7J09H96KYP8HM9B0G5SJ", - "directory_id": "directory_01ECAZ4NV9QMV47GW873HDCX74", - "organization_id": "org_01EZTR6WYX1A0DSE2CYMGXQ24Y", - "state": "active", - "email": "veda@foo-corp.com", - "emails": [ - {"primary": True, "type": "work", "value": "veda@foo-corp.com"} - ], - "idp_id": "2836", - "object": "directory_user", - "username": "veda@foo-corp.com", - "last_name": "Torp", - "first_name": "Veda", - "raw_attributes": {}, - "custom_attributes": {}, - "created_at": "2021-06-25T19:07:33.155Z", - "updated_at": "2021-06-25T19:07:33.155Z", - }, - "created_at": "2026-01-15T12:00:00.000Z", - } - instance = EventSchema.from_dict(data) - serialized = instance.to_dict() - assert "context" not in serialized - def test_action_authentication_denied_round_trip(self): data = load_fixture("action_authentication_denied.json") instance = ActionAuthenticationDenied.from_dict(data) diff --git a/tests/test_webhook_verification.py b/tests/test_webhook_verification.py index 40c5d84b..aaa2ea05 100644 --- a/tests/test_webhook_verification.py +++ b/tests/test_webhook_verification.py @@ -4,7 +4,7 @@ import time import pytest -from workos.events.models import EventSchema +from workos.common.models.user_created import UserCreated from workos.webhooks._verification import ( verify_event as standalone_verify_event, verify_header as standalone_verify_header, @@ -29,7 +29,19 @@ def _make_sig_header(body: str, secret: str, timestamp_ms: int = 0) -> str: "object": "event", "id": "evt_01", "event": "user.created", - "data": {"id": "user_01", "email": "test@example.com"}, + "data": { + "object": "user", + "id": "user_01", + "email": "test@example.com", + "email_verified": True, + "first_name": None, + "last_name": None, + "profile_picture_url": None, + "external_id": None, + "last_sign_in_at": None, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z", + }, "created_at": "2024-01-01T00:00:00Z", } ) @@ -42,7 +54,7 @@ def test_verify_event_valid(self, workos): result = workos.webhooks.verify_event( event_body=SAMPLE_EVENT, event_signature=sig, secret=SECRET ) - assert isinstance(result, EventSchema) + assert isinstance(result, UserCreated) assert result.id == "evt_01" def test_verify_event_valid_bytes(self, workos): @@ -50,7 +62,7 @@ def test_verify_event_valid_bytes(self, workos): result = workos.webhooks.verify_event( event_body=SAMPLE_EVENT.encode("utf-8"), event_signature=sig, secret=SECRET ) - assert isinstance(result, EventSchema) + assert isinstance(result, UserCreated) assert result.id == "evt_01" def test_verify_event_invalid_signature(self, workos): @@ -77,7 +89,7 @@ def test_verify_event_custom_tolerance(self, workos): result = workos.webhooks.verify_event( event_body=SAMPLE_EVENT, event_signature=sig, secret=SECRET, tolerance=60 ) - assert isinstance(result, EventSchema) + assert isinstance(result, UserCreated) assert result.id == "evt_01" def test_verify_event_malformed_header(self, workos): @@ -119,7 +131,7 @@ def test_standalone_verify_event(self): result = standalone_verify_event( event_body=SAMPLE_EVENT.encode("utf-8"), event_signature=sig, secret=SECRET ) - assert isinstance(result, EventSchema) + assert isinstance(result, UserCreated) assert result.id == "evt_01" def test_standalone_verify_event_invalid(self): From f2fa3811a823febe7295f878fdb4552c5f71d50e Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 22 Apr 2026 13:25:32 -0400 Subject: [PATCH 2/5] add catch-all scripts/ci --- scripts/ci | 5 +++++ 1 file changed, 5 insertions(+) create mode 100755 scripts/ci diff --git a/scripts/ci b/scripts/ci new file mode 100755 index 00000000..edfa1015 --- /dev/null +++ b/scripts/ci @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -e + +./scripts/test.sh --ci From a437ade4c55e9d38e6c329803676c97e84d83669 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 22 Apr 2026 13:34:02 -0400 Subject: [PATCH 3/5] fix: distinguish missing 'event' key from None value in EventSchema.from_dict Raises a distinct "Missing required field 'event'" error when the key is absent, rather than falling through to the ambiguous "Unknown event: None" message. Co-Authored-By: Claude Sonnet 4.6 --- .oagen-manifest.json | 2 +- src/workos/events/models/event_schema.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.oagen-manifest.json b/.oagen-manifest.json index 748db6a8..2712a526 100644 --- a/.oagen-manifest.json +++ b/.oagen-manifest.json @@ -1,7 +1,7 @@ { "version": 1, "language": "python", - "generatedAt": "2026-04-21T02:49:35.456Z", + "generatedAt": "2026-04-22T17:32:38.931Z", "files": [ "src/workos/_client.py", "src/workos/admin_portal/__init__.py", diff --git a/src/workos/events/models/event_schema.py b/src/workos/events/models/event_schema.py index be35c436..0d961a32 100644 --- a/src/workos/events/models/event_schema.py +++ b/src/workos/events/models/event_schema.py @@ -323,7 +323,11 @@ class EventSchema: @classmethod def from_dict(cls, data: Dict[str, Any]) -> "EventSchemaVariant": """Deserialize from a dictionary, dispatching to the correct variant.""" - event_type = data.get("event") + if "event" not in data: + _raise_deserialize_error( + "EventSchema", ValueError("Missing required field 'event'") + ) + event_type = data["event"] if event_type is not None: dispatch_cls = cls._DISPATCH.get(str(event_type)) if dispatch_cls is not None: From ee29ca00567d6d64f849aa826fbdeeed17095460 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 22 Apr 2026 17:23:07 -0400 Subject: [PATCH 4/5] address some critical feedback --- .oagen-manifest.json | 4 +-- src/workos/events/_resource.py | 4 +-- src/workos/events/models/__init__.py | 1 + src/workos/events/models/event_schema.py | 36 ++++++++++++++++++------ tests/test_events.py | 18 ------------ tests/test_models_round_trip.py | 30 +++++++++++++++++++- 6 files changed, 61 insertions(+), 32 deletions(-) diff --git a/.oagen-manifest.json b/.oagen-manifest.json index 2712a526..2515d0bf 100644 --- a/.oagen-manifest.json +++ b/.oagen-manifest.json @@ -1,7 +1,7 @@ { "version": 1, "language": "python", - "generatedAt": "2026-04-22T17:32:38.931Z", + "generatedAt": "2026-04-22T21:22:39.409Z", "files": [ "src/workos/_client.py", "src/workos/admin_portal/__init__.py", @@ -814,7 +814,6 @@ "tests/fixtures/event_context_actor.json", "tests/fixtures/event_context_google_analytics_session.json", "tests/fixtures/event_list_list_metadata.json", - "tests/fixtures/event_schema.json", "tests/fixtures/external_auth_complete_response.json", "tests/fixtures/feature_flag.json", "tests/fixtures/feature_flag_owner.json", @@ -885,7 +884,6 @@ "tests/fixtures/list_directory.json", "tests/fixtures/list_directory_group.json", "tests/fixtures/list_directory_user_with_groups.json", - "tests/fixtures/list_event_schema.json", "tests/fixtures/list_flag.json", "tests/fixtures/list_organization.json", "tests/fixtures/list_role_assignment.json", diff --git a/src/workos/events/_resource.py b/src/workos/events/_resource.py index ec493f8b..4271433b 100644 --- a/src/workos/events/_resource.py +++ b/src/workos/events/_resource.py @@ -78,7 +78,7 @@ def list_events( self._client.request_page( method="get", path="events", - model=EventSchema, # type: ignore[arg-type] + model=EventSchema, # type: ignore[arg-type] # dispatcher; pagination only calls from_dict params=params, request_options=request_options, ), @@ -150,7 +150,7 @@ async def list_events( await self._client.request_page( method="get", path="events", - model=EventSchema, # type: ignore[arg-type] + model=EventSchema, # type: ignore[arg-type] # dispatcher; pagination only calls from_dict params=params, request_options=request_options, ), diff --git a/src/workos/events/models/__init__.py b/src/workos/events/models/__init__.py index 9c5697d7..0f13cad7 100644 --- a/src/workos/events/models/__init__.py +++ b/src/workos/events/models/__init__.py @@ -2,5 +2,6 @@ from .event_list_list_metadata import EventListListMetadata as EventListListMetadata from .event_schema import EventSchema as EventSchema +from .event_schema import EventSchemaUnknown as EventSchemaUnknown from .event_schema import EventSchemaVariant as EventSchemaVariant from .events_order import EventsOrder as EventsOrder diff --git a/src/workos/events/models/event_schema.py b/src/workos/events/models/event_schema.py index 0d961a32..f2675d43 100644 --- a/src/workos/events/models/event_schema.py +++ b/src/workos/events/models/event_schema.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass from typing import Any, ClassVar, Dict, Union, cast from workos._types import _raise_deserialize_error @@ -132,6 +133,23 @@ from workos.common.models.vault_names_listed import VaultNamesListed +@dataclass(slots=True) +class EventSchemaUnknown: + """Unknown variant of EventSchema not yet recognized by this SDK version.""" + + raw_data: Dict[str, Any] + """The raw payload, preserved so callers can still inspect the data.""" + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "EventSchemaUnknown": + """Wrap raw data in an unknown variant.""" + return cls(raw_data=data) + + def to_dict(self) -> Dict[str, Any]: + """Return the original raw data.""" + return dict(self.raw_data) + + EventSchemaVariant = Union[ ActionAuthenticationDenied, ActionUserRegistrationDenied, @@ -222,6 +240,7 @@ VaultKekCreated, VaultMetadataRead, VaultNamesListed, + EventSchemaUnknown, ] @@ -327,11 +346,12 @@ def from_dict(cls, data: Dict[str, Any]) -> "EventSchemaVariant": _raise_deserialize_error( "EventSchema", ValueError("Missing required field 'event'") ) - event_type = data["event"] - if event_type is not None: - dispatch_cls = cls._DISPATCH.get(str(event_type)) - if dispatch_cls is not None: - return cast("EventSchemaVariant", dispatch_cls.from_dict(data)) - _raise_deserialize_error( - "EventSchema", ValueError(f"Unknown event: {event_type!r}") - ) + disc_value = data["event"] + if disc_value is None: + _raise_deserialize_error( + "EventSchema", ValueError("event must not be None") + ) + dispatch_cls = cls._DISPATCH.get(disc_value) + if dispatch_cls is not None: + return cast("EventSchemaVariant", dispatch_cls.from_dict(data)) + return EventSchemaUnknown.from_dict(data) diff --git a/tests/test_events.py b/tests/test_events.py index 034e3951..b6256323 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -3,7 +3,6 @@ import pytest from workos import WorkOSClient, AsyncWorkOSClient -from tests.generated_helpers import load_fixture from workos.events.models import EventsOrder from workos._pagination import AsyncPage, SyncPage @@ -19,18 +18,9 @@ class TestEvents: def test_list_events(self, workos, httpx_mock): - httpx_mock.add_response( - json=load_fixture("list_event_schema.json"), - ) - page = workos.events.list_events() - assert isinstance(page, SyncPage) - assert isinstance(page.data, list) - - def test_list_events_empty_page(self, workos, httpx_mock): httpx_mock.add_response(json={"data": [], "list_metadata": {}}) page = workos.events.list_events() assert isinstance(page, SyncPage) - assert page.data == [] def test_list_events_encodes_query_params(self, workos, httpx_mock): httpx_mock.add_response(json={"data": [], "list_metadata": {}}) @@ -133,17 +123,9 @@ def test_list_events_unprocessable(self, httpx_mock): class TestAsyncEvents: @pytest.mark.asyncio async def test_list_events(self, async_workos, httpx_mock): - httpx_mock.add_response(json=load_fixture("list_event_schema.json")) - page = await async_workos.events.list_events() - assert isinstance(page, AsyncPage) - assert isinstance(page.data, list) - - @pytest.mark.asyncio - async def test_list_events_empty_page(self, async_workos, httpx_mock): httpx_mock.add_response(json={"data": [], "list_metadata": {}}) page = await async_workos.events.list_events() assert isinstance(page, AsyncPage) - assert page.data == [] @pytest.mark.asyncio async def test_list_events_encodes_query_params(self, async_workos, httpx_mock): diff --git a/tests/test_models_round_trip.py b/tests/test_models_round_trip.py index 3648b847..8ee7ba69 100644 --- a/tests/test_models_round_trip.py +++ b/tests/test_models_round_trip.py @@ -2,6 +2,8 @@ """Model round-trip tests: from_dict(to_dict()) preserves data.""" +import pytest + from tests.generated_helpers import load_fixture from workos.admin_portal.models import ( @@ -288,7 +290,7 @@ DirectoryUserWithGroups, DirectoryUserWithGroupsEmail, ) -from workos.events.models import EventListListMetadata +from workos.events.models import EventListListMetadata, EventSchema, EventSchemaUnknown from workos.feature_flags.models import FeatureFlag, FeatureFlagOwner, Flag, FlagOwner from workos.multi_factor_auth.models import ( AuthenticationChallenge, @@ -16246,3 +16248,29 @@ def test_data_integrations_list_response_data_connected_account_round_trips_unkn } instance = DataIntegrationsListResponseDataConnectedAccount.from_dict(data) assert instance.to_dict() == data + + +class TestDiscriminatorDispatch: + def test_event_schema_dispatches_known_variant(self): + data = load_fixture("action_authentication_denied.json") + result = EventSchema.from_dict(data) + assert isinstance(result, ActionAuthenticationDenied) + + def test_event_schema_returns_unknown_for_unrecognized_type(self): + data = load_fixture("action_authentication_denied.json") + data = {**data, "event": "future.unrecognized.type"} + result = EventSchema.from_dict(data) + assert isinstance(result, EventSchemaUnknown) + assert result.raw_data == data + + def test_event_schema_raises_on_missing_discriminator(self): + data = load_fixture("action_authentication_denied.json") + data = {k: v for k, v in data.items() if k != "event"} + with pytest.raises(Exception): + EventSchema.from_dict(data) + + def test_event_schema_raises_on_none_discriminator(self): + data = load_fixture("action_authentication_denied.json") + data = {**data, "event": None} + with pytest.raises(Exception): + EventSchema.from_dict(data) From 70d995f625508b0a386b2c581281eef9f19d540c Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 22 Apr 2026 18:14:17 -0400 Subject: [PATCH 5/5] fix: tighten event dispatch types and test assertions The `_DISPATCH` dict was typed as `Dict[str, Any]`, losing type safety on the class values. `EventSchemaVariant` was eagerly imported at runtime in `_verification.py` despite only being needed for type-checking annotations. Event list tests only checked page wrapper types without verifying that deserialization produces concrete event subclasses. --- .oagen-manifest.json | 2 +- src/workos/events/models/event_schema.py | 2 +- src/workos/webhooks/_verification.py | 9 ++++++--- tests/test_events.py | 20 ++++++++++++++++++-- 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/.oagen-manifest.json b/.oagen-manifest.json index 2515d0bf..518993d7 100644 --- a/.oagen-manifest.json +++ b/.oagen-manifest.json @@ -1,7 +1,7 @@ { "version": 1, "language": "python", - "generatedAt": "2026-04-22T21:22:39.409Z", + "generatedAt": "2026-04-22T22:10:13.399Z", "files": [ "src/workos/_client.py", "src/workos/admin_portal/__init__.py", diff --git a/src/workos/events/models/event_schema.py b/src/workos/events/models/event_schema.py index f2675d43..55d806f0 100644 --- a/src/workos/events/models/event_schema.py +++ b/src/workos/events/models/event_schema.py @@ -247,7 +247,7 @@ def to_dict(self) -> Dict[str, Any]: class EventSchema: """An event emitted by WorkOS.""" - _DISPATCH: ClassVar[Dict[str, Any]] = { + _DISPATCH: ClassVar[Dict[str, type]] = { "action.authentication.denied": ActionAuthenticationDenied, "action.user_registration.denied": ActionUserRegistrationDenied, "api_key.created": ApiKeyCreated, diff --git a/src/workos/webhooks/_verification.py b/src/workos/webhooks/_verification.py index 40087764..3dc99582 100644 --- a/src/workos/webhooks/_verification.py +++ b/src/workos/webhooks/_verification.py @@ -8,9 +8,12 @@ import hmac import json import time -from typing import Optional, Union +from typing import TYPE_CHECKING, Optional, Union -from workos.events.models import EventSchema, EventSchemaVariant +from workos.events.models import EventSchema + +if TYPE_CHECKING: + from workos.events.models import EventSchemaVariant WebhookPayload = Union[bytes, bytearray] @@ -33,7 +36,7 @@ def verify_event( tolerance: The number of seconds the Webhook event is valid for. (Optional) Returns: - EventSchema: The deserialized webhook event. + EventSchemaVariant: The deserialized webhook event. Raises: ValueError: If the signature cannot be verified or the timestamp is out of range. diff --git a/tests/test_events.py b/tests/test_events.py index b6256323..387146f2 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -3,7 +3,9 @@ import pytest from workos import WorkOSClient, AsyncWorkOSClient +from tests.generated_helpers import load_fixture +from workos.common.models import ActionAuthenticationDenied from workos.events.models import EventsOrder from workos._pagination import AsyncPage, SyncPage from workos._errors import ( @@ -18,9 +20,16 @@ class TestEvents: def test_list_events(self, workos, httpx_mock): - httpx_mock.add_response(json={"data": [], "list_metadata": {}}) + httpx_mock.add_response( + json={ + "data": [load_fixture("action_authentication_denied.json")], + "list_metadata": {}, + }, + ) page = workos.events.list_events() assert isinstance(page, SyncPage) + assert len(page.data) == 1 + assert isinstance(page.data[0], ActionAuthenticationDenied) def test_list_events_encodes_query_params(self, workos, httpx_mock): httpx_mock.add_response(json={"data": [], "list_metadata": {}}) @@ -123,9 +132,16 @@ def test_list_events_unprocessable(self, httpx_mock): class TestAsyncEvents: @pytest.mark.asyncio async def test_list_events(self, async_workos, httpx_mock): - httpx_mock.add_response(json={"data": [], "list_metadata": {}}) + httpx_mock.add_response( + json={ + "data": [load_fixture("action_authentication_denied.json")], + "list_metadata": {}, + }, + ) page = await async_workos.events.list_events() assert isinstance(page, AsyncPage) + assert len(page.data) == 1 + assert isinstance(page.data[0], ActionAuthenticationDenied) @pytest.mark.asyncio async def test_list_events_encodes_query_params(self, async_workos, httpx_mock):