From ed4755f9fd699f4359f94f81c8a5a41e07061aee Mon Sep 17 00:00:00 2001 From: Devin Date: Mon, 20 Jul 2026 21:50:22 +0000 Subject: [PATCH 1/2] feat: Support clearing nullable fields via explicit None Nullable optional body params now default to a NOT_GIVEN sentinel. Omitting an argument leaves the field unchanged; passing an explicit None sends JSON null to clear it (e.g. Organization/User external_id). Adds NotGiven/NOT_GIVEN to _types.py. --- src/workos/_types.py | 20 +++- src/workos/api_keys/_resource.py | 26 ++---- src/workos/authorization/_resource.py | 116 +++++++++++++----------- src/workos/connect/_resource.py | 44 +++++---- src/workos/groups/_resource.py | 34 +++---- src/workos/organizations/_resource.py | 42 +++++---- src/workos/pipes/_resource.py | 64 ++++++------- src/workos/pipes_provider/_resource.py | 14 +-- src/workos/user_management/_resource.py | 112 ++++++++++++++--------- src/workos/vault/_resource.py | 24 ++--- tests/test_nullable_clearing.py | 74 +++++++++++++++ 11 files changed, 349 insertions(+), 221 deletions(-) create mode 100644 tests/test_nullable_clearing.py diff --git a/src/workos/_types.py b/src/workos/_types.py index 3ae131ec..877c8cc8 100644 --- a/src/workos/_types.py +++ b/src/workos/_types.py @@ -5,7 +5,7 @@ import sys from datetime import datetime from enum import Enum -from typing import Any, Dict, NoReturn, Protocol, TypedDict, TypeVar +from typing import Any, Dict, Literal, NoReturn, Protocol, TypedDict, TypeVar if sys.version_info >= (3, 11): from typing import Self @@ -13,6 +13,24 @@ from typing_extensions import Self +class NotGiven: + """Sentinel used as the default for nullable optional parameters. + + Distinguishes an omitted argument ("leave unchanged", not sent) from an + explicit ``None``, which clears the field by sending JSON ``null``. + Falsy so ``if not param`` reads naturally. + """ + + def __bool__(self) -> Literal[False]: + return False + + def __repr__(self) -> str: + return "NOT_GIVEN" + + +NOT_GIVEN = NotGiven() + + class RequestOptions(TypedDict, total=False): """Per-request options that can be passed to any API method.""" diff --git a/src/workos/api_keys/_resource.py b/src/workos/api_keys/_resource.py index 49179e5b..d56d04ae 100644 --- a/src/workos/api_keys/_resource.py +++ b/src/workos/api_keys/_resource.py @@ -7,7 +7,7 @@ if TYPE_CHECKING: from .._client import AsyncWorkOSClient, WorkOSClient -from .._types import RequestOptions, enum_value +from .._types import RequestOptions, enum_value, NOT_GIVEN, NotGiven from .models import ( ApiKey, ApiKeyValidationResponse, @@ -184,7 +184,7 @@ def create_api_key_expire( self, id: str, *, - expires_at: Optional[str] = None, + expires_at: Union[str, None, NotGiven] = NOT_GIVEN, request_options: Optional[RequestOptions] = None, ) -> ApiKey: """Expire an API key @@ -207,13 +207,9 @@ def create_api_key_expire( RateLimitExceededError: If rate limited (429). ServerError: If the server returns a 5xx error. """ - body: Dict[str, Any] = { - k: v - for k, v in { - "expires_at": expires_at, - }.items() - if v is not None - } + body: Dict[str, Any] = {} + if expires_at is not NOT_GIVEN: + body["expires_at"] = expires_at return self._client.request( method="post", path=("api_keys", str(id), "expire"), @@ -389,7 +385,7 @@ async def create_api_key_expire( self, id: str, *, - expires_at: Optional[str] = None, + expires_at: Union[str, None, NotGiven] = NOT_GIVEN, request_options: Optional[RequestOptions] = None, ) -> ApiKey: """Expire an API key @@ -412,13 +408,9 @@ async def create_api_key_expire( RateLimitExceededError: If rate limited (429). ServerError: If the server returns a 5xx error. """ - body: Dict[str, Any] = { - k: v - for k, v in { - "expires_at": expires_at, - }.items() - if v is not None - } + body: Dict[str, Any] = {} + if expires_at is not NOT_GIVEN: + body["expires_at"] = expires_at return await self._client.request( method="post", path=("api_keys", str(id), "expire"), diff --git a/src/workos/authorization/_resource.py b/src/workos/authorization/_resource.py index fd05ffca..785aa839 100644 --- a/src/workos/authorization/_resource.py +++ b/src/workos/authorization/_resource.py @@ -7,7 +7,7 @@ if TYPE_CHECKING: from .._client import AsyncWorkOSClient, WorkOSClient -from .._types import RequestOptions, enum_value +from .._types import RequestOptions, enum_value, NOT_GIVEN, NotGiven from .models import ( AuthorizationCheck, AuthorizationResource, @@ -819,7 +819,7 @@ def create_organization_role( *, name: str, slug: Optional[str] = None, - description: Optional[str] = None, + description: Union[str, None, NotGiven] = NOT_GIVEN, resource_type_slug: Optional[str] = None, request_options: Optional[RequestOptions] = None, ) -> Role: @@ -853,11 +853,12 @@ def create_organization_role( for k, v in { "slug": slug, "name": name, - "description": description, "resource_type_slug": resource_type_slug, }.items() if v is not None } + if description is not NOT_GIVEN: + body["description"] = description return self._client.request( method="post", path=("authorization", "organizations", str(organization_id), "roles"), @@ -911,7 +912,7 @@ def update_organization_role( slug: str, *, name: Optional[str] = None, - description: Optional[str] = None, + description: Union[str, None, NotGiven] = NOT_GIVEN, request_options: Optional[RequestOptions] = None, ) -> Role: """Update a custom role @@ -941,10 +942,11 @@ def update_organization_role( k: v for k, v in { "name": name, - "description": description, }.items() if v is not None } + if description is not NOT_GIVEN: + body["description"] = description return self._client.request( method="patch", path=( @@ -1179,7 +1181,7 @@ def update_resource_by_external_id( external_id: str, *, name: Optional[str] = None, - description: Optional[str] = None, + description: Union[str, None, NotGiven] = NOT_GIVEN, parent_resource: Optional[ Union[ParentResourceById, ParentResourceByExternalId] ] = None, @@ -1215,10 +1217,11 @@ def update_resource_by_external_id( k: v for k, v in { "name": name, - "description": description, }.items() if v is not None } + if description is not NOT_GIVEN: + body["description"] = description if parent_resource is not None: if isinstance(parent_resource, ParentResourceById): body["parent_resource_id"] = parent_resource.parent_resource_id @@ -1504,7 +1507,7 @@ def create_resource( name: str, resource_type_slug: str, organization_id: str, - description: Optional[str] = None, + description: Union[str, None, NotGiven] = NOT_GIVEN, parent_resource: Optional[ Union[ParentResourceById, ParentResourceByExternalId] ] = None, @@ -1537,16 +1540,13 @@ def create_resource( ServerError: If the server returns a 5xx error. """ body: Dict[str, Any] = { - k: v - for k, v in { - "external_id": external_id, - "name": name, - "description": description, - "resource_type_slug": resource_type_slug, - "organization_id": organization_id, - }.items() - if v is not None + "external_id": external_id, + "name": name, + "resource_type_slug": resource_type_slug, + "organization_id": organization_id, } + if description is not NOT_GIVEN: + body["description"] = description if parent_resource is not None: if isinstance(parent_resource, ParentResourceById): body["parent_resource_id"] = parent_resource.parent_resource_id @@ -1602,7 +1602,7 @@ def update_resource( resource_id: str, *, name: Optional[str] = None, - description: Optional[str] = None, + description: Union[str, None, NotGiven] = NOT_GIVEN, parent_resource: Optional[ Union[ParentResourceById, ParentResourceByExternalId] ] = None, @@ -1636,10 +1636,11 @@ def update_resource( k: v for k, v in { "name": name, - "description": description, }.items() if v is not None } + if description is not NOT_GIVEN: + body["description"] = description if parent_resource is not None: if isinstance(parent_resource, ParentResourceById): body["parent_resource_id"] = parent_resource.parent_resource_id @@ -1845,7 +1846,7 @@ def create_environment_role( *, slug: str, name: str, - description: Optional[str] = None, + description: Union[str, None, NotGiven] = NOT_GIVEN, resource_type_slug: Optional[str] = None, request_options: Optional[RequestOptions] = None, ) -> Role: @@ -1878,11 +1879,12 @@ def create_environment_role( for k, v in { "slug": slug, "name": name, - "description": description, "resource_type_slug": resource_type_slug, }.items() if v is not None } + if description is not NOT_GIVEN: + body["description"] = description return self._client.request( method="post", path=("authorization", "roles"), @@ -1927,7 +1929,7 @@ def update_environment_role( slug: str, *, name: Optional[str] = None, - description: Optional[str] = None, + description: Union[str, None, NotGiven] = NOT_GIVEN, request_options: Optional[RequestOptions] = None, ) -> Role: """Update an environment role @@ -1956,10 +1958,11 @@ def update_environment_role( k: v for k, v in { "name": name, - "description": description, }.items() if v is not None } + if description is not NOT_GIVEN: + body["description"] = description return self._client.request( method="patch", path=("authorization", "roles", str(slug)), @@ -2098,7 +2101,7 @@ def create_permission( *, slug: str, name: str, - description: Optional[str] = None, + description: Union[str, None, NotGiven] = NOT_GIVEN, resource_type_slug: Optional[str] = None, request_options: Optional[RequestOptions] = None, ) -> Permission: @@ -2130,11 +2133,12 @@ def create_permission( for k, v in { "slug": slug, "name": name, - "description": description, "resource_type_slug": resource_type_slug, }.items() if v is not None } + if description is not NOT_GIVEN: + body["description"] = description return self._client.request( method="post", path=("authorization", "permissions"), @@ -2178,7 +2182,7 @@ def update_permission( slug: str, *, name: Optional[str] = None, - description: Optional[str] = None, + description: Union[str, None, NotGiven] = NOT_GIVEN, request_options: Optional[RequestOptions] = None, ) -> AuthorizationPermission: """Update a permission @@ -2206,10 +2210,11 @@ def update_permission( k: v for k, v in { "name": name, - "description": description, }.items() if v is not None } + if description is not NOT_GIVEN: + body["description"] = description return self._client.request( method="patch", path=("authorization", "permissions", str(slug)), @@ -2991,7 +2996,7 @@ async def create_organization_role( *, name: str, slug: Optional[str] = None, - description: Optional[str] = None, + description: Union[str, None, NotGiven] = NOT_GIVEN, resource_type_slug: Optional[str] = None, request_options: Optional[RequestOptions] = None, ) -> Role: @@ -3025,11 +3030,12 @@ async def create_organization_role( for k, v in { "slug": slug, "name": name, - "description": description, "resource_type_slug": resource_type_slug, }.items() if v is not None } + if description is not NOT_GIVEN: + body["description"] = description return await self._client.request( method="post", path=("authorization", "organizations", str(organization_id), "roles"), @@ -3083,7 +3089,7 @@ async def update_organization_role( slug: str, *, name: Optional[str] = None, - description: Optional[str] = None, + description: Union[str, None, NotGiven] = NOT_GIVEN, request_options: Optional[RequestOptions] = None, ) -> Role: """Update a custom role @@ -3113,10 +3119,11 @@ async def update_organization_role( k: v for k, v in { "name": name, - "description": description, }.items() if v is not None } + if description is not NOT_GIVEN: + body["description"] = description return await self._client.request( method="patch", path=( @@ -3351,7 +3358,7 @@ async def update_resource_by_external_id( external_id: str, *, name: Optional[str] = None, - description: Optional[str] = None, + description: Union[str, None, NotGiven] = NOT_GIVEN, parent_resource: Optional[ Union[ParentResourceById, ParentResourceByExternalId] ] = None, @@ -3387,10 +3394,11 @@ async def update_resource_by_external_id( k: v for k, v in { "name": name, - "description": description, }.items() if v is not None } + if description is not NOT_GIVEN: + body["description"] = description if parent_resource is not None: if isinstance(parent_resource, ParentResourceById): body["parent_resource_id"] = parent_resource.parent_resource_id @@ -3676,7 +3684,7 @@ async def create_resource( name: str, resource_type_slug: str, organization_id: str, - description: Optional[str] = None, + description: Union[str, None, NotGiven] = NOT_GIVEN, parent_resource: Optional[ Union[ParentResourceById, ParentResourceByExternalId] ] = None, @@ -3709,16 +3717,13 @@ async def create_resource( ServerError: If the server returns a 5xx error. """ body: Dict[str, Any] = { - k: v - for k, v in { - "external_id": external_id, - "name": name, - "description": description, - "resource_type_slug": resource_type_slug, - "organization_id": organization_id, - }.items() - if v is not None + "external_id": external_id, + "name": name, + "resource_type_slug": resource_type_slug, + "organization_id": organization_id, } + if description is not NOT_GIVEN: + body["description"] = description if parent_resource is not None: if isinstance(parent_resource, ParentResourceById): body["parent_resource_id"] = parent_resource.parent_resource_id @@ -3774,7 +3779,7 @@ async def update_resource( resource_id: str, *, name: Optional[str] = None, - description: Optional[str] = None, + description: Union[str, None, NotGiven] = NOT_GIVEN, parent_resource: Optional[ Union[ParentResourceById, ParentResourceByExternalId] ] = None, @@ -3808,10 +3813,11 @@ async def update_resource( k: v for k, v in { "name": name, - "description": description, }.items() if v is not None } + if description is not NOT_GIVEN: + body["description"] = description if parent_resource is not None: if isinstance(parent_resource, ParentResourceById): body["parent_resource_id"] = parent_resource.parent_resource_id @@ -4017,7 +4023,7 @@ async def create_environment_role( *, slug: str, name: str, - description: Optional[str] = None, + description: Union[str, None, NotGiven] = NOT_GIVEN, resource_type_slug: Optional[str] = None, request_options: Optional[RequestOptions] = None, ) -> Role: @@ -4050,11 +4056,12 @@ async def create_environment_role( for k, v in { "slug": slug, "name": name, - "description": description, "resource_type_slug": resource_type_slug, }.items() if v is not None } + if description is not NOT_GIVEN: + body["description"] = description return await self._client.request( method="post", path=("authorization", "roles"), @@ -4099,7 +4106,7 @@ async def update_environment_role( slug: str, *, name: Optional[str] = None, - description: Optional[str] = None, + description: Union[str, None, NotGiven] = NOT_GIVEN, request_options: Optional[RequestOptions] = None, ) -> Role: """Update an environment role @@ -4128,10 +4135,11 @@ async def update_environment_role( k: v for k, v in { "name": name, - "description": description, }.items() if v is not None } + if description is not NOT_GIVEN: + body["description"] = description return await self._client.request( method="patch", path=("authorization", "roles", str(slug)), @@ -4270,7 +4278,7 @@ async def create_permission( *, slug: str, name: str, - description: Optional[str] = None, + description: Union[str, None, NotGiven] = NOT_GIVEN, resource_type_slug: Optional[str] = None, request_options: Optional[RequestOptions] = None, ) -> Permission: @@ -4302,11 +4310,12 @@ async def create_permission( for k, v in { "slug": slug, "name": name, - "description": description, "resource_type_slug": resource_type_slug, }.items() if v is not None } + if description is not NOT_GIVEN: + body["description"] = description return await self._client.request( method="post", path=("authorization", "permissions"), @@ -4350,7 +4359,7 @@ async def update_permission( slug: str, *, name: Optional[str] = None, - description: Optional[str] = None, + description: Union[str, None, NotGiven] = NOT_GIVEN, request_options: Optional[RequestOptions] = None, ) -> AuthorizationPermission: """Update a permission @@ -4378,10 +4387,11 @@ async def update_permission( k: v for k, v in { "name": name, - "description": description, }.items() if v is not None } + if description is not NOT_GIVEN: + body["description"] = description return await self._client.request( method="patch", path=("authorization", "permissions", str(slug)), diff --git a/src/workos/connect/_resource.py b/src/workos/connect/_resource.py index 0e38c262..4f4fe961 100644 --- a/src/workos/connect/_resource.py +++ b/src/workos/connect/_resource.py @@ -7,7 +7,7 @@ if TYPE_CHECKING: from .._client import AsyncWorkOSClient, WorkOSClient -from .._types import RequestOptions, enum_value +from .._types import RequestOptions, enum_value, NOT_GIVEN, NotGiven from .models import ( ApplicationCredentialsListItem, CreateM2MApplication, @@ -287,9 +287,9 @@ def update_application( id: str, *, name: Optional[str] = None, - description: Optional[str] = None, - scopes: Optional[List[str]] = None, - redirect_uris: Optional[List[RedirectUriInput]] = None, + description: Union[str, None, NotGiven] = NOT_GIVEN, + scopes: Union[List[str], None, NotGiven] = NOT_GIVEN, + redirect_uris: Union[List[RedirectUriInput], None, NotGiven] = NOT_GIVEN, request_options: Optional[RequestOptions] = None, ) -> ConnectApplicationVariant: """Update a Connect Application @@ -318,14 +318,19 @@ def update_application( k: v for k, v in { "name": name, - "description": description, - "scopes": scopes, - "redirect_uris": [item.to_dict() for item in redirect_uris] - if redirect_uris is not None - else None, }.items() if v is not None } + if description is not NOT_GIVEN: + body["description"] = description + if scopes is not NOT_GIVEN: + body["scopes"] = scopes + if redirect_uris is not NOT_GIVEN: + body["redirect_uris"] = ( + [item.to_dict() for item in redirect_uris] + if redirect_uris is not None + else None + ) return cast( ConnectApplicationVariant, self._client.request( @@ -719,9 +724,9 @@ async def update_application( id: str, *, name: Optional[str] = None, - description: Optional[str] = None, - scopes: Optional[List[str]] = None, - redirect_uris: Optional[List[RedirectUriInput]] = None, + description: Union[str, None, NotGiven] = NOT_GIVEN, + scopes: Union[List[str], None, NotGiven] = NOT_GIVEN, + redirect_uris: Union[List[RedirectUriInput], None, NotGiven] = NOT_GIVEN, request_options: Optional[RequestOptions] = None, ) -> ConnectApplicationVariant: """Update a Connect Application @@ -750,14 +755,19 @@ async def update_application( k: v for k, v in { "name": name, - "description": description, - "scopes": scopes, - "redirect_uris": [item.to_dict() for item in redirect_uris] - if redirect_uris is not None - else None, }.items() if v is not None } + if description is not NOT_GIVEN: + body["description"] = description + if scopes is not NOT_GIVEN: + body["scopes"] = scopes + if redirect_uris is not NOT_GIVEN: + body["redirect_uris"] = ( + [item.to_dict() for item in redirect_uris] + if redirect_uris is not None + else None + ) return cast( ConnectApplicationVariant, await self._client.request( diff --git a/src/workos/groups/_resource.py b/src/workos/groups/_resource.py index 95440ff6..f7b02c62 100644 --- a/src/workos/groups/_resource.py +++ b/src/workos/groups/_resource.py @@ -7,7 +7,7 @@ if TYPE_CHECKING: from .._client import AsyncWorkOSClient, WorkOSClient -from .._types import RequestOptions, enum_value +from .._types import RequestOptions, enum_value, NOT_GIVEN, NotGiven from workos.common.models.group import Group from workos.common.models.user_organization_membership_base_list_data import ( UserOrganizationMembershipBaseListData, @@ -77,7 +77,7 @@ def create_organization_group( organization_id: str, *, name: str, - description: Optional[str] = None, + description: Union[str, None, NotGiven] = NOT_GIVEN, request_options: Optional[RequestOptions] = None, ) -> Group: """Create a group @@ -103,13 +103,10 @@ def create_organization_group( ServerError: If the server returns a 5xx error. """ body: Dict[str, Any] = { - k: v - for k, v in { - "name": name, - "description": description, - }.items() - if v is not None + "name": name, } + if description is not NOT_GIVEN: + body["description"] = description return self._client.request( method="post", path=("organizations", str(organization_id), "groups"), @@ -157,7 +154,7 @@ def update_organization_group( group_id: str, *, name: Optional[str] = None, - description: Optional[str] = None, + description: Union[str, None, NotGiven] = NOT_GIVEN, request_options: Optional[RequestOptions] = None, ) -> Group: """Update a group @@ -187,10 +184,11 @@ def update_organization_group( k: v for k, v in { "name": name, - "description": description, }.items() if v is not None } + if description is not NOT_GIVEN: + body["description"] = description return self._client.request( method="patch", path=("organizations", str(organization_id), "groups", str(group_id)), @@ -433,7 +431,7 @@ async def create_organization_group( organization_id: str, *, name: str, - description: Optional[str] = None, + description: Union[str, None, NotGiven] = NOT_GIVEN, request_options: Optional[RequestOptions] = None, ) -> Group: """Create a group @@ -459,13 +457,10 @@ async def create_organization_group( ServerError: If the server returns a 5xx error. """ body: Dict[str, Any] = { - k: v - for k, v in { - "name": name, - "description": description, - }.items() - if v is not None + "name": name, } + if description is not NOT_GIVEN: + body["description"] = description return await self._client.request( method="post", path=("organizations", str(organization_id), "groups"), @@ -513,7 +508,7 @@ async def update_organization_group( group_id: str, *, name: Optional[str] = None, - description: Optional[str] = None, + description: Union[str, None, NotGiven] = NOT_GIVEN, request_options: Optional[RequestOptions] = None, ) -> Group: """Update a group @@ -543,10 +538,11 @@ async def update_organization_group( k: v for k, v in { "name": name, - "description": description, }.items() if v is not None } + if description is not NOT_GIVEN: + body["description"] = description return await self._client.request( method="patch", path=("organizations", str(organization_id), "groups", str(group_id)), diff --git a/src/workos/organizations/_resource.py b/src/workos/organizations/_resource.py index 43c7ccf4..85221882 100644 --- a/src/workos/organizations/_resource.py +++ b/src/workos/organizations/_resource.py @@ -7,7 +7,7 @@ if TYPE_CHECKING: from .._client import AsyncWorkOSClient, WorkOSClient -from .._types import RequestOptions, enum_value +from .._types import RequestOptions, enum_value, NOT_GIVEN, NotGiven from .models import AuditLogConfiguration, Organization, OrganizationDomainData from workos.common.models.pagination_order import PaginationOrder from .._pagination import AsyncPage, SyncPage @@ -81,8 +81,8 @@ def create_organization( allow_profiles_outside_organization: Optional[bool] = None, domains: Optional[List[str]] = None, domain_data: Optional[List[OrganizationDomainData]] = None, - metadata: Optional[Dict[str, str]] = None, - external_id: Optional[str] = None, + metadata: Union[Dict[str, str], None, NotGiven] = NOT_GIVEN, + external_id: Union[str, None, NotGiven] = NOT_GIVEN, request_options: Optional[RequestOptions] = None, ) -> Organization: """Create an Organization @@ -118,11 +118,13 @@ def create_organization( "domain_data": [item.to_dict() for item in domain_data] if domain_data is not None else None, - "metadata": metadata, - "external_id": external_id, }.items() if v is not None } + if metadata is not NOT_GIVEN: + body["metadata"] = metadata + if external_id is not NOT_GIVEN: + body["external_id"] = external_id return self._client.request( method="post", path=("organizations",), @@ -200,8 +202,8 @@ def update_organization( domains: Optional[List[str]] = None, domain_data: Optional[List[OrganizationDomainData]] = None, stripe_customer_id: Optional[str] = None, - metadata: Optional[Dict[str, str]] = None, - external_id: Optional[str] = None, + metadata: Union[Dict[str, str], None, NotGiven] = NOT_GIVEN, + external_id: Union[str, None, NotGiven] = NOT_GIVEN, request_options: Optional[RequestOptions] = None, ) -> Organization: """Update an Organization @@ -242,11 +244,13 @@ def update_organization( if domain_data is not None else None, "stripe_customer_id": stripe_customer_id, - "metadata": metadata, - "external_id": external_id, }.items() if v is not None } + if metadata is not NOT_GIVEN: + body["metadata"] = metadata + if external_id is not NOT_GIVEN: + body["external_id"] = external_id return self._client.request( method="put", path=("organizations", str(id)), @@ -380,8 +384,8 @@ async def create_organization( allow_profiles_outside_organization: Optional[bool] = None, domains: Optional[List[str]] = None, domain_data: Optional[List[OrganizationDomainData]] = None, - metadata: Optional[Dict[str, str]] = None, - external_id: Optional[str] = None, + metadata: Union[Dict[str, str], None, NotGiven] = NOT_GIVEN, + external_id: Union[str, None, NotGiven] = NOT_GIVEN, request_options: Optional[RequestOptions] = None, ) -> Organization: """Create an Organization @@ -417,11 +421,13 @@ async def create_organization( "domain_data": [item.to_dict() for item in domain_data] if domain_data is not None else None, - "metadata": metadata, - "external_id": external_id, }.items() if v is not None } + if metadata is not NOT_GIVEN: + body["metadata"] = metadata + if external_id is not NOT_GIVEN: + body["external_id"] = external_id return await self._client.request( method="post", path=("organizations",), @@ -499,8 +505,8 @@ async def update_organization( domains: Optional[List[str]] = None, domain_data: Optional[List[OrganizationDomainData]] = None, stripe_customer_id: Optional[str] = None, - metadata: Optional[Dict[str, str]] = None, - external_id: Optional[str] = None, + metadata: Union[Dict[str, str], None, NotGiven] = NOT_GIVEN, + external_id: Union[str, None, NotGiven] = NOT_GIVEN, request_options: Optional[RequestOptions] = None, ) -> Organization: """Update an Organization @@ -541,11 +547,13 @@ async def update_organization( if domain_data is not None else None, "stripe_customer_id": stripe_customer_id, - "metadata": metadata, - "external_id": external_id, }.items() if v is not None } + if metadata is not NOT_GIVEN: + body["metadata"] = metadata + if external_id is not NOT_GIVEN: + body["external_id"] = external_id return await self._client.request( method="put", path=("organizations", str(id)), diff --git a/src/workos/pipes/_resource.py b/src/workos/pipes/_resource.py index 98812210..3c99d1e4 100644 --- a/src/workos/pipes/_resource.py +++ b/src/workos/pipes/_resource.py @@ -7,7 +7,7 @@ if TYPE_CHECKING: from .._client import AsyncWorkOSClient, WorkOSClient -from .._types import RequestOptions, enum_value +from .._types import RequestOptions, enum_value, NOT_GIVEN, NotGiven from .models import ( CustomProviderDefinition, DataIntegration, @@ -80,9 +80,9 @@ def create_data_integration( self, *, provider: str, - description: Optional[str] = None, + description: Union[str, None, NotGiven] = NOT_GIVEN, enabled: Optional[bool] = None, - scopes: Optional[List[str]] = None, + scopes: Union[List[str], None, NotGiven] = NOT_GIVEN, credentials: Optional[DataIntegrationCredentialsDto] = None, custom_provider: Optional[CustomProviderDefinition] = None, request_options: Optional[RequestOptions] = None, @@ -115,9 +115,7 @@ def create_data_integration( k: v for k, v in { "provider": provider, - "description": description, "enabled": enabled, - "scopes": scopes, "credentials": credentials.to_dict() if credentials is not None else None, @@ -127,6 +125,10 @@ def create_data_integration( }.items() if v is not None } + if description is not NOT_GIVEN: + body["description"] = description + if scopes is not NOT_GIVEN: + body["scopes"] = scopes return self._client.request( method="post", path=("data-integrations",), @@ -169,9 +171,9 @@ def update_data_integration( self, slug: str, *, - description: Optional[str] = None, + description: Union[str, None, NotGiven] = NOT_GIVEN, enabled: Optional[bool] = None, - scopes: Optional[List[str]] = None, + scopes: Union[List[str], None, NotGiven] = NOT_GIVEN, credentials: Optional[DataIntegrationCredentialsDto] = None, custom_provider: Optional[UpdateCustomProviderDefinition] = None, request_options: Optional[RequestOptions] = None, @@ -203,9 +205,7 @@ def update_data_integration( body: Dict[str, Any] = { k: v for k, v in { - "description": description, "enabled": enabled, - "scopes": scopes, "credentials": credentials.to_dict() if credentials is not None else None, @@ -215,6 +215,10 @@ def update_data_integration( }.items() if v is not None } + if description is not NOT_GIVEN: + body["description"] = description + if scopes is not NOT_GIVEN: + body["scopes"] = scopes return self._client.request( method="put", path=("data-integrations", str(slug)), @@ -395,7 +399,7 @@ def get_access_token( provider: str, *, user_id: str, - organization_id: Optional[str] = None, + organization_id: Union[str, None, NotGiven] = NOT_GIVEN, request_options: Optional[RequestOptions] = None, ) -> DataIntegrationAccessTokenResponse: """Get an access token for a connected account @@ -420,13 +424,10 @@ def get_access_token( ServerError: If the server returns a 5xx error. """ body: Dict[str, Any] = { - k: v - for k, v in { - "user_id": user_id, - "organization_id": organization_id, - }.items() - if v is not None + "user_id": user_id, } + if organization_id is not NOT_GIVEN: + body["organization_id"] = organization_id return self._client.request( method="post", path=("data-integrations", str(provider), "token"), @@ -766,9 +767,9 @@ async def create_data_integration( self, *, provider: str, - description: Optional[str] = None, + description: Union[str, None, NotGiven] = NOT_GIVEN, enabled: Optional[bool] = None, - scopes: Optional[List[str]] = None, + scopes: Union[List[str], None, NotGiven] = NOT_GIVEN, credentials: Optional[DataIntegrationCredentialsDto] = None, custom_provider: Optional[CustomProviderDefinition] = None, request_options: Optional[RequestOptions] = None, @@ -801,9 +802,7 @@ async def create_data_integration( k: v for k, v in { "provider": provider, - "description": description, "enabled": enabled, - "scopes": scopes, "credentials": credentials.to_dict() if credentials is not None else None, @@ -813,6 +812,10 @@ async def create_data_integration( }.items() if v is not None } + if description is not NOT_GIVEN: + body["description"] = description + if scopes is not NOT_GIVEN: + body["scopes"] = scopes return await self._client.request( method="post", path=("data-integrations",), @@ -855,9 +858,9 @@ async def update_data_integration( self, slug: str, *, - description: Optional[str] = None, + description: Union[str, None, NotGiven] = NOT_GIVEN, enabled: Optional[bool] = None, - scopes: Optional[List[str]] = None, + scopes: Union[List[str], None, NotGiven] = NOT_GIVEN, credentials: Optional[DataIntegrationCredentialsDto] = None, custom_provider: Optional[UpdateCustomProviderDefinition] = None, request_options: Optional[RequestOptions] = None, @@ -889,9 +892,7 @@ async def update_data_integration( body: Dict[str, Any] = { k: v for k, v in { - "description": description, "enabled": enabled, - "scopes": scopes, "credentials": credentials.to_dict() if credentials is not None else None, @@ -901,6 +902,10 @@ async def update_data_integration( }.items() if v is not None } + if description is not NOT_GIVEN: + body["description"] = description + if scopes is not NOT_GIVEN: + body["scopes"] = scopes return await self._client.request( method="put", path=("data-integrations", str(slug)), @@ -1081,7 +1086,7 @@ async def get_access_token( provider: str, *, user_id: str, - organization_id: Optional[str] = None, + organization_id: Union[str, None, NotGiven] = NOT_GIVEN, request_options: Optional[RequestOptions] = None, ) -> DataIntegrationAccessTokenResponse: """Get an access token for a connected account @@ -1106,13 +1111,10 @@ async def get_access_token( ServerError: If the server returns a 5xx error. """ body: Dict[str, Any] = { - k: v - for k, v in { - "user_id": user_id, - "organization_id": organization_id, - }.items() - if v is not None + "user_id": user_id, } + if organization_id is not NOT_GIVEN: + body["organization_id"] = organization_id return await self._client.request( method="post", path=("data-integrations", str(provider), "token"), diff --git a/src/workos/pipes_provider/_resource.py b/src/workos/pipes_provider/_resource.py index efa7698f..183092e1 100644 --- a/src/workos/pipes_provider/_resource.py +++ b/src/workos/pipes_provider/_resource.py @@ -2,12 +2,12 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Dict, List, Optional +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union if TYPE_CHECKING: from .._client import AsyncWorkOSClient, WorkOSClient -from .._types import RequestOptions +from .._types import RequestOptions, NOT_GIVEN, NotGiven from .models import ( DataIntegrationConfigurationListResponse, DataIntegrationConfigurationResponse, @@ -60,7 +60,7 @@ def update_organization_data_integration_configuration( slug: str, *, enabled: Optional[bool] = None, - scopes: Optional[List[str]] = None, + scopes: Union[List[str], None, NotGiven] = NOT_GIVEN, client_id: Optional[str] = None, client_secret: Optional[str] = None, request_options: Optional[RequestOptions] = None, @@ -93,12 +93,13 @@ def update_organization_data_integration_configuration( k: v for k, v in { "enabled": enabled, - "scopes": scopes, "client_id": client_id, "client_secret": client_secret, }.items() if v is not None } + if scopes is not NOT_GIVEN: + body["scopes"] = scopes return self._client.request( method="put", path=( @@ -159,7 +160,7 @@ async def update_organization_data_integration_configuration( slug: str, *, enabled: Optional[bool] = None, - scopes: Optional[List[str]] = None, + scopes: Union[List[str], None, NotGiven] = NOT_GIVEN, client_id: Optional[str] = None, client_secret: Optional[str] = None, request_options: Optional[RequestOptions] = None, @@ -192,12 +193,13 @@ async def update_organization_data_integration_configuration( k: v for k, v in { "enabled": enabled, - "scopes": scopes, "client_id": client_id, "client_secret": client_secret, }.items() if v is not None } + if scopes is not NOT_GIVEN: + body["scopes"] = scopes return await self._client.request( method="put", path=( diff --git a/src/workos/user_management/_resource.py b/src/workos/user_management/_resource.py index 6cd1386f..dd2e141b 100644 --- a/src/workos/user_management/_resource.py +++ b/src/workos/user_management/_resource.py @@ -7,7 +7,7 @@ if TYPE_CHECKING: from .._client import AsyncWorkOSClient, WorkOSClient -from .._types import RequestOptions, enum_value +from .._types import RequestOptions, enum_value, NOT_GIVEN, NotGiven from .models import ( AuthenticateResponse, AuthorizationCodeSessionAuthenticateRequest, @@ -1052,14 +1052,14 @@ def create_user( self, *, email: str, - first_name: Optional[str] = None, - last_name: Optional[str] = None, - name: Optional[str] = None, - email_verified: Optional[bool] = None, - metadata: Optional[Dict[str, str]] = None, - external_id: Optional[str] = None, - ip_address: Optional[str] = None, - user_agent: Optional[str] = None, + first_name: Union[str, None, NotGiven] = NOT_GIVEN, + last_name: Union[str, None, NotGiven] = NOT_GIVEN, + name: Union[str, None, NotGiven] = NOT_GIVEN, + email_verified: Union[bool, None, NotGiven] = NOT_GIVEN, + metadata: Union[Dict[str, str], None, NotGiven] = NOT_GIVEN, + external_id: Union[str, None, NotGiven] = NOT_GIVEN, + ip_address: Union[str, None, NotGiven] = NOT_GIVEN, + user_agent: Union[str, None, NotGiven] = NOT_GIVEN, signals_id: Optional[str] = None, password: Optional[Union[PasswordPlaintext, PasswordHashed]] = None, request_options: Optional[RequestOptions] = None, @@ -1097,18 +1097,26 @@ def create_user( k: v for k, v in { "email": email, - "first_name": first_name, - "last_name": last_name, - "name": name, - "email_verified": email_verified, - "metadata": metadata, - "external_id": external_id, - "ip_address": ip_address, - "user_agent": user_agent, "signals_id": signals_id, }.items() if v is not None } + if first_name is not NOT_GIVEN: + body["first_name"] = first_name + if last_name is not NOT_GIVEN: + body["last_name"] = last_name + if name is not NOT_GIVEN: + body["name"] = name + if email_verified is not NOT_GIVEN: + body["email_verified"] = email_verified + if metadata is not NOT_GIVEN: + body["metadata"] = metadata + if external_id is not NOT_GIVEN: + body["external_id"] = external_id + if ip_address is not NOT_GIVEN: + body["ip_address"] = ip_address + if user_agent is not NOT_GIVEN: + body["user_agent"] = user_agent if password is not None: if isinstance(password, PasswordPlaintext): body["password"] = password.password @@ -1192,9 +1200,9 @@ def update_user( last_name: Optional[str] = None, name: Optional[str] = None, email_verified: Optional[bool] = None, - metadata: Optional[Dict[str, str]] = None, - external_id: Optional[str] = None, - locale: Optional[str] = None, + metadata: Union[Dict[str, str], None, NotGiven] = NOT_GIVEN, + external_id: Union[str, None, NotGiven] = NOT_GIVEN, + locale: Union[str, None, NotGiven] = NOT_GIVEN, password: Optional[Union[PasswordPlaintext, PasswordHashed]] = None, request_options: Optional[RequestOptions] = None, ) -> User: @@ -1233,12 +1241,15 @@ def update_user( "last_name": last_name, "name": name, "email_verified": email_verified, - "metadata": metadata, - "external_id": external_id, - "locale": locale, }.items() if v is not None } + if metadata is not NOT_GIVEN: + body["metadata"] = metadata + if external_id is not NOT_GIVEN: + body["external_id"] = external_id + if locale is not NOT_GIVEN: + body["locale"] = locale if password is not None: if isinstance(password, PasswordPlaintext): body["password"] = password.password @@ -3343,14 +3354,14 @@ async def create_user( self, *, email: str, - first_name: Optional[str] = None, - last_name: Optional[str] = None, - name: Optional[str] = None, - email_verified: Optional[bool] = None, - metadata: Optional[Dict[str, str]] = None, - external_id: Optional[str] = None, - ip_address: Optional[str] = None, - user_agent: Optional[str] = None, + first_name: Union[str, None, NotGiven] = NOT_GIVEN, + last_name: Union[str, None, NotGiven] = NOT_GIVEN, + name: Union[str, None, NotGiven] = NOT_GIVEN, + email_verified: Union[bool, None, NotGiven] = NOT_GIVEN, + metadata: Union[Dict[str, str], None, NotGiven] = NOT_GIVEN, + external_id: Union[str, None, NotGiven] = NOT_GIVEN, + ip_address: Union[str, None, NotGiven] = NOT_GIVEN, + user_agent: Union[str, None, NotGiven] = NOT_GIVEN, signals_id: Optional[str] = None, password: Optional[Union[PasswordPlaintext, PasswordHashed]] = None, request_options: Optional[RequestOptions] = None, @@ -3388,18 +3399,26 @@ async def create_user( k: v for k, v in { "email": email, - "first_name": first_name, - "last_name": last_name, - "name": name, - "email_verified": email_verified, - "metadata": metadata, - "external_id": external_id, - "ip_address": ip_address, - "user_agent": user_agent, "signals_id": signals_id, }.items() if v is not None } + if first_name is not NOT_GIVEN: + body["first_name"] = first_name + if last_name is not NOT_GIVEN: + body["last_name"] = last_name + if name is not NOT_GIVEN: + body["name"] = name + if email_verified is not NOT_GIVEN: + body["email_verified"] = email_verified + if metadata is not NOT_GIVEN: + body["metadata"] = metadata + if external_id is not NOT_GIVEN: + body["external_id"] = external_id + if ip_address is not NOT_GIVEN: + body["ip_address"] = ip_address + if user_agent is not NOT_GIVEN: + body["user_agent"] = user_agent if password is not None: if isinstance(password, PasswordPlaintext): body["password"] = password.password @@ -3483,9 +3502,9 @@ async def update_user( last_name: Optional[str] = None, name: Optional[str] = None, email_verified: Optional[bool] = None, - metadata: Optional[Dict[str, str]] = None, - external_id: Optional[str] = None, - locale: Optional[str] = None, + metadata: Union[Dict[str, str], None, NotGiven] = NOT_GIVEN, + external_id: Union[str, None, NotGiven] = NOT_GIVEN, + locale: Union[str, None, NotGiven] = NOT_GIVEN, password: Optional[Union[PasswordPlaintext, PasswordHashed]] = None, request_options: Optional[RequestOptions] = None, ) -> User: @@ -3524,12 +3543,15 @@ async def update_user( "last_name": last_name, "name": name, "email_verified": email_verified, - "metadata": metadata, - "external_id": external_id, - "locale": locale, }.items() if v is not None } + if metadata is not NOT_GIVEN: + body["metadata"] = metadata + if external_id is not NOT_GIVEN: + body["external_id"] = external_id + if locale is not NOT_GIVEN: + body["locale"] = locale if password is not None: if isinstance(password, PasswordPlaintext): body["password"] = password.password diff --git a/src/workos/vault/_resource.py b/src/workos/vault/_resource.py index 2c47d589..f4d12786 100644 --- a/src/workos/vault/_resource.py +++ b/src/workos/vault/_resource.py @@ -7,7 +7,7 @@ if TYPE_CHECKING: from .._client import AsyncWorkOSClient, WorkOSClient -from .._types import RequestOptions, enum_value +from .._types import RequestOptions, enum_value, NOT_GIVEN, NotGiven from .models import ( CreateDataKeyResponse, DecryptResponse, @@ -391,7 +391,7 @@ def update_kv( id: str, *, value: str, - version_check: Optional[str] = None, + version_check: Union[str, None, NotGiven] = NOT_GIVEN, request_options: Optional[RequestOptions] = None, ) -> ObjectWithoutValue: """Update an object @@ -415,13 +415,10 @@ def update_kv( ServerError: If the server returns a 5xx error. """ body: Dict[str, Any] = { - k: v - for k, v in { - "value": value, - "version_check": version_check, - }.items() - if v is not None + "value": value, } + if version_check is not NOT_GIVEN: + body["version_check"] = version_check return self._client.request( method="put", path=("vault", "v1", "kv", str(id)), @@ -855,7 +852,7 @@ async def update_kv( id: str, *, value: str, - version_check: Optional[str] = None, + version_check: Union[str, None, NotGiven] = NOT_GIVEN, request_options: Optional[RequestOptions] = None, ) -> ObjectWithoutValue: """Update an object @@ -879,13 +876,10 @@ async def update_kv( ServerError: If the server returns a 5xx error. """ body: Dict[str, Any] = { - k: v - for k, v in { - "value": value, - "version_check": version_check, - }.items() - if v is not None + "value": value, } + if version_check is not NOT_GIVEN: + body["version_check"] = version_check return await self._client.request( method="put", path=("vault", "v1", "kv", str(id)), diff --git a/tests/test_nullable_clearing.py b/tests/test_nullable_clearing.py new file mode 100644 index 00000000..2ca9d9a6 --- /dev/null +++ b/tests/test_nullable_clearing.py @@ -0,0 +1,74 @@ +import json + +import pytest + +from tests.generated_helpers import load_fixture + + +def _request_body(httpx_mock): + request = httpx_mock.get_request() + return json.loads(request.content) + + +class TestNullableClearing: + """Verifies the oagen "explicit None clears a nullable field" behavior: + + - omitting a nullable argument leaves the field out of the request body + - passing an explicit None sends JSON null (clearing the field) + - passing a value sends that value + """ + + def test_omitted_nullable_field_is_not_sent(self, workos, httpx_mock): + httpx_mock.add_response(json=load_fixture("organization.json")) + workos.organizations.update_organization(id="org_123", name="New Name") + body = _request_body(httpx_mock) + assert "external_id" not in body + assert body["name"] == "New Name" + + def test_explicit_none_clears_nullable_field(self, workos, httpx_mock): + httpx_mock.add_response(json=load_fixture("organization.json")) + workos.organizations.update_organization(id="org_123", external_id=None) + body = _request_body(httpx_mock) + assert "external_id" in body + assert body["external_id"] is None + + def test_concrete_value_is_sent(self, workos, httpx_mock): + httpx_mock.add_response(json=load_fixture("organization.json")) + workos.organizations.update_organization(id="org_123", external_id="ext-1") + body = _request_body(httpx_mock) + assert body["external_id"] == "ext-1" + + def test_user_explicit_none_clears_external_id(self, workos, httpx_mock): + httpx_mock.add_response(json=load_fixture("user.json")) + workos.user_management.update_user(id="user_123", external_id=None) + body = _request_body(httpx_mock) + assert "external_id" in body + assert body["external_id"] is None + + def test_user_omitted_external_id_is_not_sent(self, workos, httpx_mock): + httpx_mock.add_response(json=load_fixture("user.json")) + workos.user_management.update_user(id="user_123", first_name="Ada") + body = _request_body(httpx_mock) + assert "external_id" not in body + assert body["first_name"] == "Ada" + + +class TestAsyncNullableClearing: + @pytest.mark.asyncio + async def test_explicit_none_clears_nullable_field(self, async_workos, httpx_mock): + httpx_mock.add_response(json=load_fixture("organization.json")) + await async_workos.organizations.update_organization( + id="org_123", external_id=None + ) + body = _request_body(httpx_mock) + assert "external_id" in body + assert body["external_id"] is None + + @pytest.mark.asyncio + async def test_omitted_nullable_field_is_not_sent(self, async_workos, httpx_mock): + httpx_mock.add_response(json=load_fixture("organization.json")) + await async_workos.organizations.update_organization( + id="org_123", name="New Name" + ) + body = _request_body(httpx_mock) + assert "external_id" not in body From f6cb100275a44fbd8d30a3b9173884155d50a109 Mon Sep 17 00:00:00 2001 From: Devin Date: Mon, 20 Jul 2026 21:59:30 +0000 Subject: [PATCH 2/2] test: Assert NOT_GIVEN sentinel never leaks into body --- tests/test_nullable_clearing.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_nullable_clearing.py b/tests/test_nullable_clearing.py index 2ca9d9a6..c84f05d9 100644 --- a/tests/test_nullable_clearing.py +++ b/tests/test_nullable_clearing.py @@ -52,6 +52,12 @@ def test_user_omitted_external_id_is_not_sent(self, workos, httpx_mock): assert "external_id" not in body assert body["first_name"] == "Ada" + def test_sentinel_never_leaks_into_body(self, workos, httpx_mock): + httpx_mock.add_response(json=load_fixture("organization.json")) + workos.organizations.update_organization(id="org_123", name="New Name") + request = httpx_mock.get_request() + assert b"NOT_GIVEN" not in request.content + class TestAsyncNullableClearing: @pytest.mark.asyncio