From a33e77aa328b34b450957f1c633777ab424e0b87 Mon Sep 17 00:00:00 2001 From: Eric Roberts Date: Tue, 18 Mar 2025 13:38:24 -0400 Subject: [PATCH 1/4] Add external_id to user and organization Add a method to get users by external id. Change organization get by lookup key which no longer works to be get by external id. --- tests/test_organizations.py | 6 ++--- tests/test_user_management.py | 20 ++++++++++++++++ workos/organizations.py | 18 +++++++------- workos/types/organizations/organization.py | 2 +- workos/types/user_management/user.py | 1 + workos/user_management.py | 28 +++++++++++++++++++++- 6 files changed, 60 insertions(+), 15 deletions(-) diff --git a/tests/test_organizations.py b/tests/test_organizations.py index b7cf5322..ddd08060 100644 --- a/tests/test_organizations.py +++ b/tests/test_organizations.py @@ -109,7 +109,7 @@ def test_get_organization( assert request_kwargs["method"] == "get" assert request_kwargs["url"].endswith("/organizations/organization_id") - def test_get_organization_by_lookup_key( + def test_get_organization_by_external_id( self, mock_organization, capture_and_mock_http_client_request ): request_kwargs = capture_and_mock_http_client_request( @@ -117,12 +117,12 @@ def test_get_organization_by_lookup_key( ) organization = syncify( - self.organizations.get_organization_by_lookup_key(lookup_key="test") + self.organizations.get_organization_by_external_id(external_id="test") ) assert organization.dict() == mock_organization assert request_kwargs["method"] == "get" - assert request_kwargs["url"].endswith("/organizations/by_lookup_key/test") + assert request_kwargs["url"].endswith("/organizations/external_id/test") def test_create_organization_with_domain_data( self, mock_organization, capture_and_mock_http_client_request diff --git a/tests/test_user_management.py b/tests/test_user_management.py index f0aed6e9..c6819ae6 100644 --- a/tests/test_user_management.py +++ b/tests/test_user_management.py @@ -392,6 +392,26 @@ def test_get_user(self, mock_user, capture_and_mock_http_client_request): assert user.profile_picture_url == "https://example.com/profile-picture.jpg" assert user.last_sign_in_at == "2021-06-25T19:07:33.155Z" + def test_get_user_by_external_id( + self, mock_user, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_user, 200 + ) + + external_id = "external-id" + user = syncify( + self.user_management.get_user_by_external_id(external_id=external_id) + ) + + assert request_kwargs["url"].endswith( + f"user_management/users/external_id/{external_id}" + ) + assert request_kwargs["method"] == "get" + assert user.id == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" + assert user.profile_picture_url == "https://example.com/profile-picture.jpg" + assert user.last_sign_in_at == "2021-06-25T19:07:33.155Z" + def test_list_users_auto_pagination( self, mock_users_multiple_pages, diff --git a/workos/organizations.py b/workos/organizations.py index 4ff234f5..42b781e4 100644 --- a/workos/organizations.py +++ b/workos/organizations.py @@ -60,13 +60,13 @@ def get_organization(self, organization_id: str) -> SyncOrAsync[Organization]: """ ... - def get_organization_by_lookup_key( - self, lookup_key: str + def get_organization_by_external_id( + self, external_id: str ) -> SyncOrAsync[Organization]: - """Gets details for a single Organization by lookup key + """Gets details for a single Organization by external id Args: - lookup_key (str): Organization's lookup key + external_id (str): Organization's external id Returns: Organization: Organization response from WorkOS @@ -125,7 +125,6 @@ def delete_organization(self, organization_id: str) -> SyncOrAsync[None]: class Organizations(OrganizationsModule): - _http_client: SyncHTTPClient def __init__(self, http_client: SyncHTTPClient): @@ -167,9 +166,9 @@ def get_organization(self, organization_id: str) -> Organization: return Organization.model_validate(response) - def get_organization_by_lookup_key(self, lookup_key: str) -> Organization: + def get_organization_by_external_id(self, external_id: str) -> Organization: response = self._http_client.request( - "organizations/by_lookup_key/{lookup_key}".format(lookup_key=lookup_key), + "organizations/external_id/{external_id}".format(external_id=external_id), method=REQUEST_METHOD_GET, ) @@ -237,7 +236,6 @@ def list_organization_roles(self, organization_id: str) -> RoleList: class AsyncOrganizations(OrganizationsModule): - _http_client: AsyncHTTPClient def __init__(self, http_client: AsyncHTTPClient): @@ -279,9 +277,9 @@ async def get_organization(self, organization_id: str) -> Organization: return Organization.model_validate(response) - async def get_organization_by_lookup_key(self, lookup_key: str) -> Organization: + async def get_organization_by_external_id(self, external_id: str) -> Organization: response = await self._http_client.request( - "organizations/by_lookup_key/{lookup_key}".format(lookup_key=lookup_key), + "organizations/external_id/{external_id}".format(external_id=external_id), method=REQUEST_METHOD_GET, ) diff --git a/workos/types/organizations/organization.py b/workos/types/organizations/organization.py index 23186498..03ee74d0 100644 --- a/workos/types/organizations/organization.py +++ b/workos/types/organizations/organization.py @@ -6,5 +6,5 @@ class Organization(OrganizationCommon): allow_profiles_outside_organization: bool domains: Sequence[OrganizationDomain] - lookup_key: Optional[str] = None stripe_customer_id: Optional[str] = None + external_id: Optional[str] = None diff --git a/workos/types/user_management/user.py b/workos/types/user_management/user.py index ca1e4c1b..5b2a3595 100644 --- a/workos/types/user_management/user.py +++ b/workos/types/user_management/user.py @@ -15,3 +15,4 @@ class User(WorkOSModel): last_sign_in_at: Optional[str] = None created_at: str updated_at: str + external_id: Optional[str] = None diff --git a/workos/user_management.py b/workos/user_management.py index c684eb4e..a7131bc1 100644 --- a/workos/user_management.py +++ b/workos/user_management.py @@ -66,6 +66,7 @@ USER_PATH = "user_management/users" USER_DETAIL_PATH = "user_management/users/{0}" +USER_DETAIL_BY_EXTERNAL_ID_PATH = "user_management/users/external_id/{0}" ORGANIZATION_MEMBERSHIP_PATH = "user_management/organization_memberships" ORGANIZATION_MEMBERSHIP_DETAIL_PATH = "user_management/organization_memberships/{0}" ORGANIZATION_MEMBERSHIP_DEACTIVATE_PATH = ( @@ -137,6 +138,16 @@ def get_user(self, user_id: str) -> SyncOrAsync[User]: """ ... + def get_user_by_external_id(self, external_id: str) -> SyncOrAsync[User]: + """Get the details of an existing user. + + Args: + external_id (str): The user's external id + Returns: + User: User response from WorkOS. + """ + ... + def list_users( self, *, @@ -389,7 +400,6 @@ def get_authorization_url( ) if connection_id is not None: - params["connection_id"] = connection_id if organization_id is not None: params["organization_id"] = organization_id @@ -860,6 +870,14 @@ def get_user(self, user_id: str) -> User: return User.model_validate(response) + def get_user_by_external_id(self, external_id: str) -> User: + response = self._http_client.request( + USER_DETAIL_BY_EXTERNAL_ID_PATH.format(external_id), + method=REQUEST_METHOD_GET, + ) + + return User.model_validate(response) + def list_users( self, *, @@ -1464,6 +1482,14 @@ async def get_user(self, user_id: str) -> User: return User.model_validate(response) + async def get_user_by_external_id(self, external_id: str) -> User: + response = await self._http_client.request( + USER_DETAIL_BY_EXTERNAL_ID_PATH.format(external_id), + method=REQUEST_METHOD_GET, + ) + + return User.model_validate(response) + async def list_users( self, *, From 310ad098d63674784649b5621726e55e39e847ac Mon Sep 17 00:00:00 2001 From: Eric Roberts Date: Tue, 18 Mar 2025 14:55:58 -0400 Subject: [PATCH 2/4] Add metadata to users and organizations Eventually the API will always return something, but for now we can default to an empty object (which is what the API will eventually return). --- tests/test_user_management.py | 1 + tests/utils/fixtures/mock_organization.py | 1 + tests/utils/fixtures/mock_user.py | 1 + workos/types/organizations/organization.py | 4 +++- workos/types/user_management/user.py | 4 +++- workos/user_management.py | 4 ++-- 6 files changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/test_user_management.py b/tests/test_user_management.py index c6819ae6..e41dd99a 100644 --- a/tests/test_user_management.py +++ b/tests/test_user_management.py @@ -411,6 +411,7 @@ def test_get_user_by_external_id( assert user.id == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" assert user.profile_picture_url == "https://example.com/profile-picture.jpg" assert user.last_sign_in_at == "2021-06-25T19:07:33.155Z" + assert user.metadata == mock_user["metadata"] def test_list_users_auto_pagination( self, diff --git a/tests/utils/fixtures/mock_organization.py b/tests/utils/fixtures/mock_organization.py index 04905845..3316105b 100644 --- a/tests/utils/fixtures/mock_organization.py +++ b/tests/utils/fixtures/mock_organization.py @@ -22,4 +22,5 @@ def __init__(self, id): domain="example.io", ) ], + metadata={"key": "value"}, ) diff --git a/tests/utils/fixtures/mock_user.py b/tests/utils/fixtures/mock_user.py index 6a12710c..82bbfcfc 100644 --- a/tests/utils/fixtures/mock_user.py +++ b/tests/utils/fixtures/mock_user.py @@ -17,4 +17,5 @@ def __init__(self, id): last_sign_in_at="2021-06-25T19:07:33.155Z", created_at=now, updated_at=now, + metadata={"key": "value"}, ) diff --git a/workos/types/organizations/organization.py b/workos/types/organizations/organization.py index 03ee74d0..8462ced5 100644 --- a/workos/types/organizations/organization.py +++ b/workos/types/organizations/organization.py @@ -1,4 +1,5 @@ -from typing import Optional, Sequence +from dataclasses import field +from typing import Optional, Sequence, Dict from workos.types.organizations.organization_common import OrganizationCommon from workos.types.organizations.organization_domain import OrganizationDomain @@ -8,3 +9,4 @@ class Organization(OrganizationCommon): domains: Sequence[OrganizationDomain] stripe_customer_id: Optional[str] = None external_id: Optional[str] = None + metadata: Dict[str, str] = field(default_factory=dict) diff --git a/workos/types/user_management/user.py b/workos/types/user_management/user.py index 5b2a3595..51aa328a 100644 --- a/workos/types/user_management/user.py +++ b/workos/types/user_management/user.py @@ -1,4 +1,5 @@ -from typing import Literal, Optional +from dataclasses import field +from typing import Literal, Optional, Dict from workos.types.workos_model import WorkOSModel @@ -16,3 +17,4 @@ class User(WorkOSModel): created_at: str updated_at: str external_id: Optional[str] = None + metadata: Dict[str, str] = field(default_factory=dict) diff --git a/workos/user_management.py b/workos/user_management.py index a7131bc1..958b8d3b 100644 --- a/workos/user_management.py +++ b/workos/user_management.py @@ -139,10 +139,10 @@ def get_user(self, user_id: str) -> SyncOrAsync[User]: ... def get_user_by_external_id(self, external_id: str) -> SyncOrAsync[User]: - """Get the details of an existing user. + """Get the details of an existing user by external id. Args: - external_id (str): The user's external id + external_id (str): User's external id Returns: User: User response from WorkOS. """ From fe8d079c0d62beca1c6ff3ac50acb9845eccd119 Mon Sep 17 00:00:00 2001 From: Eric Roberts Date: Tue, 18 Mar 2025 17:03:46 -0400 Subject: [PATCH 3/4] Add external id and metadata to user update and create --- workos/types/metadata.py | 4 ++++ workos/types/organizations/organization.py | 5 +++-- workos/types/user_management/user.py | 5 +++-- workos/user_management.py | 21 +++++++++++++++++++++ 4 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 workos/types/metadata.py diff --git a/workos/types/metadata.py b/workos/types/metadata.py new file mode 100644 index 00000000..9a108d10 --- /dev/null +++ b/workos/types/metadata.py @@ -0,0 +1,4 @@ +from typing import Dict + + +Metadata = Dict[str, str] diff --git a/workos/types/organizations/organization.py b/workos/types/organizations/organization.py index 8462ced5..74846aee 100644 --- a/workos/types/organizations/organization.py +++ b/workos/types/organizations/organization.py @@ -1,5 +1,6 @@ from dataclasses import field -from typing import Optional, Sequence, Dict +from typing import Optional, Sequence +from workos.types.metadata import Metadata from workos.types.organizations.organization_common import OrganizationCommon from workos.types.organizations.organization_domain import OrganizationDomain @@ -9,4 +10,4 @@ class Organization(OrganizationCommon): domains: Sequence[OrganizationDomain] stripe_customer_id: Optional[str] = None external_id: Optional[str] = None - metadata: Dict[str, str] = field(default_factory=dict) + metadata: Metadata = field(default_factory=dict) diff --git a/workos/types/user_management/user.py b/workos/types/user_management/user.py index 51aa328a..2bd72ae3 100644 --- a/workos/types/user_management/user.py +++ b/workos/types/user_management/user.py @@ -1,5 +1,6 @@ from dataclasses import field -from typing import Literal, Optional, Dict +from typing import Literal, Optional +from workos.types.metadata import Metadata from workos.types.workos_model import WorkOSModel @@ -17,4 +18,4 @@ class User(WorkOSModel): created_at: str updated_at: str external_id: Optional[str] = None - metadata: Dict[str, str] = field(default_factory=dict) + metadata: Metadata = field(default_factory=dict) diff --git a/workos/user_management.py b/workos/user_management.py index 958b8d3b..da6e7498 100644 --- a/workos/user_management.py +++ b/workos/user_management.py @@ -8,6 +8,7 @@ ListPage, WorkOSListResource, ) +from workos.types.metadata import Metadata from workos.types.mfa import ( AuthenticationFactor, AuthenticationFactorTotpAndChallengeResponse, @@ -183,6 +184,8 @@ def create_user( first_name: Optional[str] = None, last_name: Optional[str] = None, email_verified: Optional[bool] = None, + external_id: Optional[str] = None, + metadata: Optional[Metadata] = None, ) -> SyncOrAsync[User]: """Create a new user. @@ -210,6 +213,8 @@ def update_user( password: Optional[str] = None, password_hash: Optional[str] = None, password_hash_type: Optional[PasswordHashType] = None, + external_id: Optional[str] = None, + metadata: Optional[Metadata] = None, ) -> SyncOrAsync[User]: """Update user attributes. @@ -917,6 +922,8 @@ def create_user( first_name: Optional[str] = None, last_name: Optional[str] = None, email_verified: Optional[bool] = None, + external_id: Optional[str] = None, + metadata: Optional[Metadata] = None, ) -> User: json = { "email": email, @@ -926,6 +933,8 @@ def create_user( "first_name": first_name, "last_name": last_name, "email_verified": email_verified or False, + "external_id": external_id, + "metadata": metadata, } response = self._http_client.request( @@ -944,6 +953,8 @@ def update_user( password: Optional[str] = None, password_hash: Optional[str] = None, password_hash_type: Optional[PasswordHashType] = None, + external_id: Optional[str] = None, + metadata: Optional[Metadata] = None, ) -> User: json = { "first_name": first_name, @@ -952,6 +963,8 @@ def update_user( "password": password, "password_hash": password_hash, "password_hash_type": password_hash_type, + "external_id": external_id, + "metadata": metadata, } response = self._http_client.request( @@ -1529,6 +1542,8 @@ async def create_user( first_name: Optional[str] = None, last_name: Optional[str] = None, email_verified: Optional[bool] = None, + external_id: Optional[str] = None, + metadata: Optional[Metadata] = None, ) -> User: json = { "email": email, @@ -1538,6 +1553,8 @@ async def create_user( "first_name": first_name, "last_name": last_name, "email_verified": email_verified or False, + "external_id": external_id, + "metadata": metadata, } response = await self._http_client.request( @@ -1556,6 +1573,8 @@ async def update_user( password: Optional[str] = None, password_hash: Optional[str] = None, password_hash_type: Optional[PasswordHashType] = None, + external_id: Optional[str] = None, + metadata: Optional[Metadata] = None, ) -> User: json = { "first_name": first_name, @@ -1564,6 +1583,8 @@ async def update_user( "password": password, "password_hash": password_hash, "password_hash_type": password_hash_type, + "external_id": external_id, + "metadata": metadata, } response = await self._http_client.request( From 1c49b8f36eeac3f978dffb79cff896ca9bf54e91 Mon Sep 17 00:00:00 2001 From: Eric Roberts Date: Tue, 18 Mar 2025 17:10:13 -0400 Subject: [PATCH 4/4] Create and update organizations with external id and metadata --- workos/organizations.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/workos/organizations.py b/workos/organizations.py index 42b781e4..f60ff703 100644 --- a/workos/organizations.py +++ b/workos/organizations.py @@ -1,5 +1,6 @@ from typing import Optional, Protocol, Sequence +from workos.types.metadata import Metadata from workos.types.organizations.domain_data_input import DomainDataInput from workos.types.organizations.list_filters import OrganizationListFilters from workos.types.roles.role import RoleList @@ -79,6 +80,8 @@ def create_organization( name: str, domain_data: Optional[Sequence[DomainDataInput]] = None, idempotency_key: Optional[str] = None, + external_id: Optional[str] = None, + metadata: Optional[Metadata] = None, ) -> SyncOrAsync[Organization]: """Create an organization @@ -98,6 +101,8 @@ def update_organization( organization_id: str, name: Optional[str] = None, domain_data: Optional[Sequence[DomainDataInput]] = None, + external_id: Optional[str] = None, + metadata: Optional[Metadata] = None, ) -> SyncOrAsync[Organization]: """Update an organization @@ -180,6 +185,8 @@ def create_organization( name: str, domain_data: Optional[Sequence[DomainDataInput]] = None, idempotency_key: Optional[str] = None, + external_id: Optional[str] = None, + metadata: Optional[Metadata] = None, ) -> Organization: headers = {} if idempotency_key: @@ -189,6 +196,8 @@ def create_organization( "name": name, "domain_data": domain_data, "idempotency_key": idempotency_key, + "external_id": external_id, + "metadata": metadata, } response = self._http_client.request( @@ -207,11 +216,15 @@ def update_organization( name: Optional[str] = None, domain_data: Optional[Sequence[DomainDataInput]] = None, stripe_customer_id: Optional[str] = None, + external_id: Optional[str] = None, + metadata: Optional[Metadata] = None, ) -> Organization: json = { "name": name, "domain_data": domain_data, "stripe_customer_id": stripe_customer_id, + "external_id": external_id, + "metadata": metadata, } response = self._http_client.request( @@ -291,6 +304,8 @@ async def create_organization( name: str, domain_data: Optional[Sequence[DomainDataInput]] = None, idempotency_key: Optional[str] = None, + external_id: Optional[str] = None, + metadata: Optional[Metadata] = None, ) -> Organization: headers = {} if idempotency_key: @@ -300,6 +315,8 @@ async def create_organization( "name": name, "domain_data": domain_data, "idempotency_key": idempotency_key, + "external_id": external_id, + "metadata": metadata, } response = await self._http_client.request( @@ -318,11 +335,15 @@ async def update_organization( name: Optional[str] = None, domain_data: Optional[Sequence[DomainDataInput]] = None, stripe_customer_id: Optional[str] = None, + external_id: Optional[str] = None, + metadata: Optional[Metadata] = None, ) -> Organization: json = { "name": name, "domain_data": domain_data, "stripe_customer_id": stripe_customer_id, + "external_id": external_id, + "metadata": metadata, } response = await self._http_client.request(