diff --git a/docs/content/en/latest/administration/organization/create_or_update_jwk.md b/docs/content/en/latest/administration/organization/create_or_update_jwk.md index d51f8b418..f54e478f6 100644 --- a/docs/content/en/latest/administration/organization/create_or_update_jwk.md +++ b/docs/content/en/latest/administration/organization/create_or_update_jwk.md @@ -6,7 +6,7 @@ weight: 100 api_ref: "CatalogOrganizationService.create_or_update_jwk" --- -``create_or_update_jwk( jwk: CatalogJwk ) -> None`` +``create_or_update_jwk( jwk: CatalogJwk ) -> UpsertOutcome`` Create a new jwk or overwrite an existing jwk with the same id. @@ -18,4 +18,6 @@ Create a new jwk or overwrite an existing jwk with the same id. ## Returns -_None_ +| type | description | +| -- | -- | +| UpsertOutcome | CREATED if the jwk did not exist yet, UPDATED if it did. | diff --git a/docs/content/en/latest/administration/user-groups/create_or_update_user_group.md b/docs/content/en/latest/administration/user-groups/create_or_update_user_group.md index 23c5b4379..12198383f 100644 --- a/docs/content/en/latest/administration/user-groups/create_or_update_user_group.md +++ b/docs/content/en/latest/administration/user-groups/create_or_update_user_group.md @@ -19,7 +19,10 @@ User group entity object. {{< /parameter >}} {{% /parameters-block %}} -{{% parameters-block title="Returns" None="yes" %}} +{{% parameters-block title="Returns" %}} +{{< parameter p_type="UpsertOutcome" >}} +CREATED if the user group did not exist yet, UPDATED if it did. +{{< /parameter >}} {{% /parameters-block %}} ## Example diff --git a/docs/content/en/latest/administration/users/create_or_update_user.md b/docs/content/en/latest/administration/users/create_or_update_user.md index 313f3d1ad..85b46185c 100644 --- a/docs/content/en/latest/administration/users/create_or_update_user.md +++ b/docs/content/en/latest/administration/users/create_or_update_user.md @@ -19,7 +19,10 @@ User entity object. {{< /parameter >}} {{% /parameters-block %}} -{{% parameters-block title="Returns" None="yes" %}} +{{% parameters-block title="Returns" %}} +{{< parameter p_type="UpsertOutcome" >}} +CREATED if the user did not exist yet, UPDATED if it did. +{{< /parameter >}} {{% /parameters-block %}} ## Example diff --git a/docs/content/en/latest/data/data-source/create_or_update_data_source.md b/docs/content/en/latest/data/data-source/create_or_update_data_source.md index e6d29e512..bcd244c25 100644 --- a/docs/content/en/latest/data/data-source/create_or_update_data_source.md +++ b/docs/content/en/latest/data/data-source/create_or_update_data_source.md @@ -20,7 +20,10 @@ Catalog data source object {{% /parameters-block %}} -{{% parameters-block title="Returns" None="yes"%}} +{{% parameters-block title="Returns" %}} +{{< parameter p_type="UpsertOutcome" >}} +CREATED if the data source did not exist yet, UPDATED if it did. +{{< /parameter >}} {{% /parameters-block %}} ### Example diff --git a/docs/content/en/latest/workspace/workspaces/create_or_update.md b/docs/content/en/latest/workspace/workspaces/create_or_update.md index 84686ef28..b43517f78 100644 --- a/docs/content/en/latest/workspace/workspaces/create_or_update.md +++ b/docs/content/en/latest/workspace/workspaces/create_or_update.md @@ -15,7 +15,10 @@ Creates a new workspace or overwrite an existing workspace with the same id. Data source Object, including physical data model. {{< /parameter >}} {{% /parameters-block %}} -{{% parameters-block title="Returns" None="yes" %}} +{{% parameters-block title="Returns" %}} +{{< parameter p_type="UpsertOutcome" >}} +CREATED if the workspace did not exist yet, UPDATED if it did. +{{< /parameter >}} {{% /parameters-block %}} {{% parameters-block title="Raises" %}} {{< parameter p_type="Value Error" >}} diff --git a/packages/gooddata-sdk/src/gooddata_sdk/__init__.py b/packages/gooddata-sdk/src/gooddata_sdk/__init__.py index a265c18c5..5e8932ba7 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/__init__.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/__init__.py @@ -177,6 +177,7 @@ CatalogOrganizationPermissionAssignment, ) from gooddata_sdk.catalog.rule import CatalogAssigneeRule +from gooddata_sdk.catalog.types import UpsertOutcome from gooddata_sdk.catalog.user.declarative_model.user import ( CatalogDeclarativeUser, CatalogDeclarativeUserPermission, diff --git a/packages/gooddata-sdk/src/gooddata_sdk/catalog/data_source/service.py b/packages/gooddata-sdk/src/gooddata_sdk/catalog/data_source/service.py index 7491187d8..4a6119d90 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/catalog/data_source/service.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/catalog/data_source/service.py @@ -35,6 +35,7 @@ ) from gooddata_sdk.catalog.data_source.entity_model.data_source import CatalogDataSource from gooddata_sdk.catalog.entity import ClientSecretCredentialsFromFile, TokenCredentialsFromFile +from gooddata_sdk.catalog.types import UpsertOutcome from gooddata_sdk.catalog.workspace.declarative_model.workspace.logical_model.ldm import CatalogDeclarativeModel from gooddata_sdk.client import GoodDataApiClient from gooddata_sdk.utils import get_ds_credentials, load_all_entities_dict, read_layout_from_file @@ -57,7 +58,7 @@ def __init__(self, api_client: GoodDataApiClient) -> None: def create_or_update_data_source( self, data_source: CatalogDataSource, - ) -> None: + ) -> UpsertOutcome: """Pushes the Data Source to the GoodData environment. Automatically decides, whether to create or update. @@ -67,7 +68,8 @@ def create_or_update_data_source( Catalog Data Source object Returns: - None + UpsertOutcome: + CREATED if the data source did not exist yet, UPDATED if it did. """ try: self._entities_api.get_entity_data_sources(data_source.id) @@ -77,6 +79,8 @@ def create_or_update_data_source( ) except NotFoundException: self._entities_api.create_entity_data_sources(data_source.to_api()) + return UpsertOutcome.CREATED + return UpsertOutcome.UPDATED def get_data_source(self, data_source_id: str) -> CatalogDataSource: """Retrieve Data Source entity using data source id. diff --git a/packages/gooddata-sdk/src/gooddata_sdk/catalog/organization/service.py b/packages/gooddata-sdk/src/gooddata_sdk/catalog/organization/service.py index 70825d372..26efa9ce1 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/catalog/organization/service.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/catalog/organization/service.py @@ -43,6 +43,7 @@ from gooddata_sdk.catalog.organization.entity_model.setting import CatalogOrganizationSetting from gooddata_sdk.catalog.organization.layout.identity_provider import CatalogDeclarativeIdentityProvider from gooddata_sdk.catalog.organization.layout.notification_channel import CatalogDeclarativeNotificationChannel +from gooddata_sdk.catalog.types import UpsertOutcome from gooddata_sdk.client import GoodDataApiClient from gooddata_sdk.utils import load_all_entities, load_all_entities_dict @@ -107,7 +108,7 @@ def update_allowed_origins(self, allowed_origins: list[str]) -> None: patch_document = JsonApiOrganizationPatchDocument(data=patch_data) self._entities_api.patch_entity_organizations(organization.id, patch_document) - def create_or_update_jwk(self, jwk: CatalogJwk) -> None: + def create_or_update_jwk(self, jwk: CatalogJwk) -> UpsertOutcome: """Create a new jwk or overwrite an existing jwk with the same id. Args: @@ -115,7 +116,8 @@ def create_or_update_jwk(self, jwk: CatalogJwk) -> None: Catalog Jwk object to be created or updated. Returns: - None + UpsertOutcome: + CREATED if the jwk did not exist yet, UPDATED if it did. Raises: ValueError: Jwk can not be updated. @@ -126,6 +128,8 @@ def create_or_update_jwk(self, jwk: CatalogJwk) -> None: self._entities_api.update_entity_jwks(id=jwk.id, json_api_jwk_in_document=jwk_document.to_api()) except NotFoundException: self._entities_api.create_entity_jwks(json_api_jwk_in_document=jwk_document.to_api()) + return UpsertOutcome.CREATED + return UpsertOutcome.UPDATED def get_jwk(self, jwk_id: str) -> CatalogJwk: """Get an individual jwk. @@ -473,7 +477,7 @@ def patch_identity_provider_attributes(self, identity_provider_id: str, attribut identity_provider_id, CatalogIdentityProvider.to_api_patch(identity_provider_id, attributes) ) - def create_or_update_export_template(self, export_template: CatalogExportTemplate) -> None: + def create_or_update_export_template(self, export_template: CatalogExportTemplate) -> UpsertOutcome: """Create a new export template or overwrite an existing export template with the same id. Args: @@ -481,7 +485,8 @@ def create_or_update_export_template(self, export_template: CatalogExportTemplat Catalog export template object to be created or updated. Returns: - None + UpsertOutcome: + CREATED if the export template did not exist yet, UPDATED if it did. Raises: ValueError: Export template cannot be updated. @@ -500,6 +505,8 @@ def create_or_update_export_template(self, export_template: CatalogExportTemplat data=export_template.to_api() ) ) + return UpsertOutcome.CREATED + return UpsertOutcome.UPDATED def get_export_template(self, export_template_id: str) -> CatalogExportTemplate: """Get an individual export template. diff --git a/packages/gooddata-sdk/src/gooddata_sdk/catalog/types.py b/packages/gooddata-sdk/src/gooddata_sdk/catalog/types.py index 299d00500..15fb576fb 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/catalog/types.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/catalog/types.py @@ -1,5 +1,24 @@ # (C) 2022 GoodData Corporation from __future__ import annotations +from enum import Enum + # Use typing collection types to support python < py3.9 ValidObjects = dict[str, set[str]] + + +class UpsertOutcome(str, Enum): + """Which branch a ``create_or_update*`` method took. + + The outcome is best-effort: it reports the branch the SDK chose after its + existence check, and that check is not atomic with the write that follows. + A concurrent actor can create or delete the entity in between, so treat the + value as informational rather than as an authoritative audit record. + """ + + CREATED = "created" + UPDATED = "updated" + + # Match StrEnum's str() (the value, not "UpsertOutcome.CREATED") so moving + # to StrEnum once py3.10 support is dropped is a no-op for callers. + __str__ = str.__str__ diff --git a/packages/gooddata-sdk/src/gooddata_sdk/catalog/user/service.py b/packages/gooddata-sdk/src/gooddata_sdk/catalog/user/service.py index 1e1f4ad33..245f1e545 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/catalog/user/service.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/catalog/user/service.py @@ -9,6 +9,7 @@ from gooddata_api_client.model.json_api_api_token_in_document import JsonApiApiTokenInDocument from gooddata_sdk.catalog.catalog_service_base import CatalogServiceBase +from gooddata_sdk.catalog.types import UpsertOutcome from gooddata_sdk.catalog.user.declarative_model.user import CatalogDeclarativeUsers from gooddata_sdk.catalog.user.declarative_model.user_and_user_groups import CatalogDeclarativeUsersUserGroups from gooddata_sdk.catalog.user.declarative_model.user_group import CatalogDeclarativeUserGroups @@ -25,7 +26,7 @@ class CatalogUserService(CatalogServiceBase): # Entity methods for users - def create_or_update_user(self, user: CatalogUser) -> None: + def create_or_update_user(self, user: CatalogUser) -> UpsertOutcome: """Creates a new user or overwrites an existing user. @@ -34,7 +35,8 @@ def create_or_update_user(self, user: CatalogUser) -> None: User entity object. Returns: - None + UpsertOutcome: + CREATED if the user did not exist yet, UPDATED if it did. """ try: self.get_user(user_id=user.id) @@ -43,6 +45,8 @@ def create_or_update_user(self, user: CatalogUser) -> None: except NotFoundException: user_document = CatalogUserDocument(data=user) self._entities_api.create_entity_users(json_api_user_in_document=user_document.to_api()) + return UpsertOutcome.CREATED + return UpsertOutcome.UPDATED def get_user(self, user_id: str) -> CatalogUser: """Get an individual user using User id. @@ -89,7 +93,7 @@ def list_users(self) -> list[CatalogUser]: # Entity methods for user groups - def create_or_update_user_group(self, user_group: CatalogUserGroup) -> None: + def create_or_update_user_group(self, user_group: CatalogUserGroup) -> UpsertOutcome: """Create a new user group or overwrite an existing user group. Args: @@ -97,7 +101,8 @@ def create_or_update_user_group(self, user_group: CatalogUserGroup) -> None: UserGroup entity object. Returns: - None + UpsertOutcome: + CREATED if the user group did not exist yet, UPDATED if it did. """ try: self.get_user_group(user_group_id=user_group.id) @@ -108,6 +113,8 @@ def create_or_update_user_group(self, user_group: CatalogUserGroup) -> None: except NotFoundException: user_group_document = CatalogUserGroupDocument(data=user_group) self._entities_api.create_entity_user_groups(user_group_document.to_api()) + return UpsertOutcome.CREATED + return UpsertOutcome.UPDATED def get_user_group(self, user_group_id: str) -> CatalogUserGroup: """Get an individual user group using user group id. diff --git a/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/service.py b/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/service.py index 606331e07..4faba1cd5 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/service.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/service.py @@ -20,6 +20,7 @@ from gooddata_sdk import CatalogDeclarativeAutomation from gooddata_sdk.catalog.catalog_service_base import CatalogServiceBase from gooddata_sdk.catalog.permission.service import CatalogPermissionService +from gooddata_sdk.catalog.types import UpsertOutcome from gooddata_sdk.catalog.workspace.aac import load_aac_workspace_from_disk, store_aac_workspace_to_disk from gooddata_sdk.catalog.workspace.declarative_model.workspace.workspace import ( CatalogDeclarativeFilterView, @@ -60,7 +61,7 @@ def __init__(self, api_client: GoodDataApiClient) -> None: # Entities methods - def create_or_update(self, workspace: CatalogWorkspace) -> None: + def create_or_update(self, workspace: CatalogWorkspace) -> UpsertOutcome: """Create a new workspace or overwrite an existing workspace with the same id. Args: @@ -68,7 +69,8 @@ def create_or_update(self, workspace: CatalogWorkspace) -> None: Catalog Workspace object to be created or updated. Returns: - None + UpsertOutcome: + CREATED if the workspace did not exist yet, UPDATED if it did. Raises: ValueError: Workspace parent can not be updated. @@ -88,6 +90,8 @@ def create_or_update(self, workspace: CatalogWorkspace) -> None: ) except NotFoundException: self._entities_api.create_entity_workspaces(workspace.to_api()) + return UpsertOutcome.CREATED + return UpsertOutcome.UPDATED def get_workspace(self, workspace_id: str) -> CatalogWorkspace: """Get an individual workspace. @@ -146,7 +150,9 @@ def list_workspaces(self) -> list[CatalogWorkspace]: workspaces = load_all_entities(get_workspaces) return [CatalogWorkspace.from_api(w) for w in workspaces.data] - def create_or_update_workspace_setting(self, workspace_id: str, workspace_setting: CatalogWorkspaceSetting) -> None: + def create_or_update_workspace_setting( + self, workspace_id: str, workspace_setting: CatalogWorkspaceSetting + ) -> UpsertOutcome: """Create a new workspace setting or overwrite an existing workspace setting with the same id. Args: @@ -156,20 +162,27 @@ def create_or_update_workspace_setting(self, workspace_id: str, workspace_settin Catalog Workspace Setting object to be created or updated. Returns: - None + UpsertOutcome: + CREATED if the setting did not exist yet, UPDATED if it did. + + Note: + A setting with no id takes the create branch, but that path currently + fails in the generated client, which rejects a None id. """ if workspace_setting.id is None: self._entities_api.create_entity_workspace_settings(workspace_id, workspace_setting.to_api(True)) - else: - try: - self.get_workspace_setting(workspace_id, workspace_setting.id) - self._entities_api.update_entity_workspace_settings( - workspace_id, - workspace_setting.id, - workspace_setting.to_api(), - ) - except NotFoundException: - self._entities_api.create_entity_workspace_settings(workspace_id, workspace_setting.to_api(True)) + return UpsertOutcome.CREATED + try: + self.get_workspace_setting(workspace_id, workspace_setting.id) + self._entities_api.update_entity_workspace_settings( + workspace_id, + workspace_setting.id, + workspace_setting.to_api(), + ) + except NotFoundException: + self._entities_api.create_entity_workspace_settings(workspace_id, workspace_setting.to_api(True)) + return UpsertOutcome.CREATED + return UpsertOutcome.UPDATED def delete_workspace_setting(self, workspace_id: str, workspace_setting_id: str) -> None: try: @@ -1207,7 +1220,9 @@ def list_user_data_filters(self, workspace_id: str) -> list[CatalogUserDataFilte user_data_filters = load_all_entities_dict(get_user_data_filters, camel_case=False) return [CatalogUserDataFilter.from_dict(v, camel_case=False) for v in user_data_filters["data"]] - def create_or_update_user_data_filter(self, workspace_id: str, user_data_filter: CatalogUserDataFilter) -> None: + def create_or_update_user_data_filter( + self, workspace_id: str, user_data_filter: CatalogUserDataFilter + ) -> UpsertOutcome: """Create a new user data filter or overwrite an existing one. Args: @@ -1217,7 +1232,12 @@ def create_or_update_user_data_filter(self, workspace_id: str, user_data_filter: UserDataFilter entity object. Returns: - None + UpsertOutcome: + CREATED if the filter did not exist yet, UPDATED if it did. + + Note: + A filter with no id takes the create branch, but that path currently + fails in the generated client, which rejects a None id. """ user_data_filter_document = CatalogUserDataFilterDocument(data=user_data_filter) if user_data_filter.id is None: @@ -1225,19 +1245,21 @@ def create_or_update_user_data_filter(self, workspace_id: str, user_data_filter: workspace_id=workspace_id, json_api_user_data_filter_post_optional_id_document=user_data_filter_document.to_api(True), ) - else: - try: - self.get_user_data_filter(workspace_id=workspace_id, user_data_filter_id=user_data_filter.id) - self._entities_api.update_entity_user_data_filters( - workspace_id=workspace_id, - object_id=user_data_filter.id, - json_api_user_data_filter_in_document=user_data_filter_document.to_api(), - ) - except NotFoundException: - self._entities_api.create_entity_user_data_filters( - workspace_id=workspace_id, - json_api_user_data_filter_post_optional_id_document=user_data_filter_document.to_api(True), - ) + return UpsertOutcome.CREATED + try: + self.get_user_data_filter(workspace_id=workspace_id, user_data_filter_id=user_data_filter.id) + self._entities_api.update_entity_user_data_filters( + workspace_id=workspace_id, + object_id=user_data_filter.id, + json_api_user_data_filter_in_document=user_data_filter_document.to_api(), + ) + except NotFoundException: + self._entities_api.create_entity_user_data_filters( + workspace_id=workspace_id, + json_api_user_data_filter_post_optional_id_document=user_data_filter_document.to_api(True), + ) + return UpsertOutcome.CREATED + return UpsertOutcome.UPDATED def get_user_data_filter(self, workspace_id: str, user_data_filter_id: str) -> CatalogUserDataFilter: """Get user data filter by its id. @@ -1410,7 +1432,7 @@ def list_filters_views(self, workspace_id: str) -> list[CatalogFilterView]: filter_views = load_all_entities_dict(get_filter_views, camel_case=False) return [CatalogFilterView.from_dict(v, camel_case=False) for v in filter_views["data"]] - def create_or_update_filter_view(self, workspace_id: str, filter_view: CatalogFilterView) -> None: + def create_or_update_filter_view(self, workspace_id: str, filter_view: CatalogFilterView) -> UpsertOutcome: """Create a new filter view or overwrite an existing one. Args: @@ -1420,7 +1442,12 @@ def create_or_update_filter_view(self, workspace_id: str, filter_view: CatalogFi FilterView entity object. Returns: - None + UpsertOutcome: + CREATED if the filter view did not exist yet, UPDATED if it did. + + Note: + A filter view with no id takes the create branch, but that path + currently fails in the generated client, which rejects a None id. """ filter_view_document = CatalogFilterViewDocument(data=filter_view) if filter_view.id is None: @@ -1428,19 +1455,21 @@ def create_or_update_filter_view(self, workspace_id: str, filter_view: CatalogFi workspace_id=workspace_id, json_api_filter_view_in_document=filter_view_document.to_api(), ) - else: - try: - self.get_filter_view(workspace_id=workspace_id, filter_view_id=filter_view.id) - self._entities_api.update_entity_filter_views( - workspace_id=workspace_id, - object_id=filter_view.id, - json_api_filter_view_in_document=filter_view_document.to_api(), - ) - except NotFoundException: - self._entities_api.create_entity_filter_views( - workspace_id=workspace_id, - json_api_filter_view_in_document=filter_view_document.to_api(), - ) + return UpsertOutcome.CREATED + try: + self.get_filter_view(workspace_id=workspace_id, filter_view_id=filter_view.id) + self._entities_api.update_entity_filter_views( + workspace_id=workspace_id, + object_id=filter_view.id, + json_api_filter_view_in_document=filter_view_document.to_api(), + ) + except NotFoundException: + self._entities_api.create_entity_filter_views( + workspace_id=workspace_id, + json_api_filter_view_in_document=filter_view_document.to_api(), + ) + return UpsertOutcome.CREATED + return UpsertOutcome.UPDATED def get_filter_view(self, workspace_id: str, filter_view_id: str) -> CatalogFilterView: """Get filter view by its id. diff --git a/packages/gooddata-sdk/tests/catalog/test_catalog_data_source.py b/packages/gooddata-sdk/tests/catalog/test_catalog_data_source.py index efc1d85be..de2227840 100644 --- a/packages/gooddata-sdk/tests/catalog/test_catalog_data_source.py +++ b/packages/gooddata-sdk/tests/catalog/test_catalog_data_source.py @@ -40,6 +40,7 @@ SqlColumn, TableDimension, TokenCredentialsFromFile, + UpsertOutcome, VerticaAttributes, ) from gooddata_sdk.catalog.data_source.entity_model.data_source import DatabaseAttributes @@ -283,7 +284,7 @@ def test_catalog_create_update_list_data_source(test_config): ), url_params=[("autosave", "false"), ("sslmode", "prefer")], ) - sdk.catalog_data_source.create_or_update_data_source(updated_data_source) + assert sdk.catalog_data_source.create_or_update_data_source(updated_data_source) == UpsertOutcome.UPDATED data_sources = sdk.catalog_data_source.list_data_sources() assert len(data_sources) == 2 @@ -299,7 +300,7 @@ def test_catalog_create_update_list_data_source(test_config): def _create_delete_ds(sdk, data_source: CatalogDataSource): try: - sdk.catalog_data_source.create_or_update_data_source(data_source) + assert sdk.catalog_data_source.create_or_update_data_source(data_source) == UpsertOutcome.CREATED created_ds = sdk.catalog_data_source.get_data_source(data_source.id) assert data_source == created_ds finally: diff --git a/packages/gooddata-sdk/tests/catalog/test_catalog_organization.py b/packages/gooddata-sdk/tests/catalog/test_catalog_organization.py index 53e88c566..3372674de 100644 --- a/packages/gooddata-sdk/tests/catalog/test_catalog_organization.py +++ b/packages/gooddata-sdk/tests/catalog/test_catalog_organization.py @@ -13,6 +13,7 @@ CatalogRsaSpecification, CatalogWebhook, GoodDataSdk, + UpsertOutcome, ) from tests_support.vcrpy_utils import get_vcr @@ -93,7 +94,7 @@ def test_create_jwk(test_config): sdk = GoodDataSdk.create(host_=test_config["host"], token_=test_config["token"]) new_jwk = _default_jwk() try: - sdk.catalog_organization.create_or_update_jwk(new_jwk) + assert sdk.catalog_organization.create_or_update_jwk(new_jwk) == UpsertOutcome.CREATED created_jwk = sdk.catalog_organization.get_jwk("demoJwk") assert new_jwk.id == created_jwk.id assert new_jwk.attributes == created_jwk.attributes @@ -107,8 +108,8 @@ def test_update_jwk(test_config): new_jwk = _default_jwk() update_jwk = _default_jwk(alg="RS384") try: - sdk.catalog_organization.create_or_update_jwk(new_jwk) - sdk.catalog_organization.create_or_update_jwk(update_jwk) + assert sdk.catalog_organization.create_or_update_jwk(new_jwk) == UpsertOutcome.CREATED + assert sdk.catalog_organization.create_or_update_jwk(update_jwk) == UpsertOutcome.UPDATED updated_jwk = sdk.catalog_organization.get_jwk("demoJwk") assert update_jwk.attributes.content.alg == updated_jwk.attributes.content.alg finally: diff --git a/packages/gooddata-sdk/tests/catalog/test_catalog_user_service.py b/packages/gooddata-sdk/tests/catalog/test_catalog_user_service.py index d29302a23..ff4e3a5cd 100644 --- a/packages/gooddata-sdk/tests/catalog/test_catalog_user_service.py +++ b/packages/gooddata-sdk/tests/catalog/test_catalog_user_service.py @@ -36,6 +36,7 @@ CatalogUserGroup, GoodDataApiClient, GoodDataSdk, + UpsertOutcome, ) from gooddata_sdk.catalog.permission.declarative_model.permission import CatalogDeclarativeWorkspacePermissions from gooddata_sdk.utils import recreate_directory @@ -89,7 +90,7 @@ def test_create_delete_user(test_config): authentication_id=authentication_id, user_group_ids=user_group_ids, ) - sdk.catalog_user.create_or_update_user(user_e) + assert sdk.catalog_user.create_or_update_user(user_e) == UpsertOutcome.CREATED user = sdk.catalog_user.get_user(user_id) assert len(sdk.catalog_user.list_users()) == initial_count + 1 assert user.id == user_id @@ -150,7 +151,7 @@ def test_update_user(test_config): authentication_id=initial_auth_id, user_group_ids=initial_user_group_ids, ) - sdk.catalog_user.create_or_update_user(initial_user) + assert sdk.catalog_user.create_or_update_user(initial_user) == UpsertOutcome.CREATED assert len(sdk.catalog_user.list_users()) == initial_user_count + 1 # Update the user @@ -162,7 +163,7 @@ def test_update_user(test_config): authentication_id=new_auth_id, user_group_ids=new_user_group_ids, ) - sdk.catalog_user.create_or_update_user(updated_user_request) + assert sdk.catalog_user.create_or_update_user(updated_user_request) == UpsertOutcome.UPDATED # Verify updates updated_user = sdk.catalog_user.get_user(temp_user_id) @@ -245,7 +246,7 @@ def test_create_delete_user_group(test_config): user_group_name=user_group_id.upper(), user_group_parent_ids=user_group_parent_ids, ) - sdk.catalog_user.create_or_update_user_group(user_group_e) + assert sdk.catalog_user.create_or_update_user_group(user_group_e) == UpsertOutcome.CREATED user_group = sdk.catalog_user.get_user_group(user_group_id) assert len(sdk.catalog_user.list_user_groups()) == initial_count + 1 assert user_group.id == user_group_id @@ -269,7 +270,7 @@ def test_update_user_group(test_config): user_group_name=new_user_group_name, user_group_parent_ids=user_group_parent_ids, ) - sdk.catalog_user.create_or_update_user_group(user_group_e) + assert sdk.catalog_user.create_or_update_user_group(user_group_e) == UpsertOutcome.UPDATED updated_user_group = sdk.catalog_user.get_user_group(user_group_id) assert user_group.id == updated_user_group.id assert updated_user_group.name == new_user_group_name diff --git a/packages/gooddata-sdk/tests/catalog/test_catalog_workspace.py b/packages/gooddata-sdk/tests/catalog/test_catalog_workspace.py index cd056817b..c82163af0 100644 --- a/packages/gooddata-sdk/tests/catalog/test_catalog_workspace.py +++ b/packages/gooddata-sdk/tests/catalog/test_catalog_workspace.py @@ -23,6 +23,7 @@ GoodDataApiClient, GoodDataSdk, PostgresAttributes, + UpsertOutcome, ) from gooddata_sdk.catalog.identifier import ( CatalogDeclarativeAnalyticalDashboardIdentifier, @@ -232,7 +233,7 @@ def test_update_workspace_valid(test_config): try: # Updating only name. - sdk.catalog_workspace.create_or_update(new_workspace) + assert sdk.catalog_workspace.create_or_update(new_workspace) == UpsertOutcome.UPDATED workspaces = sdk.catalog_workspace.list_workspaces() workspace_o = sdk.catalog_workspace.get_workspace(workspace.id) assert len(workspaces) == initial_count @@ -304,7 +305,7 @@ def test_create_workspace(test_config): assert workspace_id not in [w.id for w in workspaces] try: - sdk.catalog_workspace.create_or_update(workspace) + assert sdk.catalog_workspace.create_or_update(workspace) == UpsertOutcome.CREATED workspaces = sdk.catalog_workspace.list_workspaces() workspace_o = sdk.catalog_workspace.get_workspace(workspace_id) assert len(workspaces) == initial_count + 1 @@ -435,9 +436,10 @@ def test_user_data_filters_life_cycle(test_config): title="test_new_user_data_filter", user_id=test_config["demo_user"], ) - sdk.catalog_workspace.create_or_update_user_data_filter( + outcome = sdk.catalog_workspace.create_or_update_user_data_filter( workspace_id=test_config["workspace"], user_data_filter=user_data_filter ) + assert outcome == UpsertOutcome.CREATED user_data_filters = sdk.catalog_workspace.list_user_data_filters(test_config["workspace"]) assert len(user_data_filters) == 1 assert user_data_filters[0].id == user_data_filter.id @@ -784,7 +786,8 @@ def test_create_workspace_setting(test_config): setting = CatalogWorkspaceSetting(id=setting_id, setting_type=setting_type, content=content) try: - sdk.catalog_workspace.create_or_update_workspace_setting(test_config["workspace"], setting) + outcome = sdk.catalog_workspace.create_or_update_workspace_setting(test_config["workspace"], setting) + assert outcome == UpsertOutcome.CREATED setting_o = sdk.catalog_workspace.get_workspace_setting(test_config["workspace"], setting_id) assert setting_o == setting finally: @@ -845,12 +848,14 @@ def test_update_workspace_setting(test_config): setting = CatalogWorkspaceSetting(id=setting_id, setting_type=setting_type, content=content) try: - sdk.catalog_workspace.create_or_update_workspace_setting(test_config["workspace"], setting) + outcome = sdk.catalog_workspace.create_or_update_workspace_setting(test_config["workspace"], setting) + assert outcome == UpsertOutcome.CREATED setting_o = sdk.catalog_workspace.get_workspace_setting(test_config["workspace"], setting_id) assert setting_o == setting content = {"value": "en-US"} setting = CatalogWorkspaceSetting(id=setting_id, setting_type=setting_type, content=content) - sdk.catalog_workspace.create_or_update_workspace_setting(test_config["workspace"], setting) + outcome = sdk.catalog_workspace.create_or_update_workspace_setting(test_config["workspace"], setting) + assert outcome == UpsertOutcome.UPDATED setting_o = sdk.catalog_workspace.get_workspace_setting(test_config["workspace"], setting_id) assert setting_o == setting finally: diff --git a/packages/gooddata-sdk/tests/catalog/test_upsert_outcome.py b/packages/gooddata-sdk/tests/catalog/test_upsert_outcome.py new file mode 100644 index 000000000..b2d81434b --- /dev/null +++ b/packages/gooddata-sdk/tests/catalog/test_upsert_outcome.py @@ -0,0 +1,147 @@ +# (C) 2026 GoodData Corporation +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +from attrs import evolve +from gooddata_api_client.exceptions import ApiTypeError, NotFoundException +from gooddata_sdk import ( + CatalogExportTemplate, + CatalogWorkspaceSetting, + UpsertOutcome, +) +from gooddata_sdk.catalog.organization.entity_model.export_template import CatalogExportTemplateAttributes +from gooddata_sdk.catalog.organization.service import CatalogOrganizationService +from gooddata_sdk.catalog.workspace.entity_model.filter_view import CatalogFilterView +from gooddata_sdk.catalog.workspace.entity_model.user_data_filter import CatalogUserDataFilter +from gooddata_sdk.catalog.workspace.service import CatalogWorkspaceService + +# The create/update branch is decided by whether the preceding entity GET raises +# NotFoundException, so these stub that getter instead of replaying a cassette. +# They cover what no cassette covers upstream: filter views, export templates, +# and the update branch of user data filters. + + +def _service(cls, getter: str, *, found: bool): + """Service whose branch-deciding getter either succeeds or raises 404.""" + service = cls(MagicMock()) + service._entities_api = MagicMock() + setattr(service, getter, MagicMock(side_effect=None if found else NotFoundException(status=404))) + return service + + +def _filter_view(filter_view_id: str | None = "fv") -> CatalogFilterView: + # init() builds the relationships the generated model requires; it insists on + # an id, so the id-less variant is derived from a valid instance. + view = CatalogFilterView.init(filter_view_id="fv", content={}, title="Test filter view", user_id="demo_user") + return evolve(view, id=filter_view_id) + + +def _user_data_filter(user_data_filter_id: str | None = "udf") -> CatalogUserDataFilter: + udf = CatalogUserDataFilter.init( + user_data_filter_id="udf", + maql='{label/order_status} IN ("returned")', + user_id="demo_user", + ) + return evolve(udf, id=user_data_filter_id) + + +def _export_template() -> CatalogExportTemplate: + return CatalogExportTemplate( + id="test_template", + attributes=CatalogExportTemplateAttributes(name="Test template"), + ) + + +class TestFilterViewOutcome: + def test_created_when_absent(self): + service = _service(CatalogWorkspaceService, "get_filter_view", found=False) + assert service.create_or_update_filter_view("demo", _filter_view()) == UpsertOutcome.CREATED + service._entities_api.create_entity_filter_views.assert_called_once() + + def test_updated_when_present(self): + service = _service(CatalogWorkspaceService, "get_filter_view", found=True) + assert service.create_or_update_filter_view("demo", _filter_view()) == UpsertOutcome.UPDATED + service._entities_api.update_entity_filter_views.assert_called_once() + service._entities_api.create_entity_filter_views.assert_not_called() + + +class TestUserDataFilterOutcome: + def test_created_when_absent(self): + service = _service(CatalogWorkspaceService, "get_user_data_filter", found=False) + assert service.create_or_update_user_data_filter("demo", _user_data_filter()) == UpsertOutcome.CREATED + service._entities_api.create_entity_user_data_filters.assert_called_once() + + def test_updated_when_present(self): + service = _service(CatalogWorkspaceService, "get_user_data_filter", found=True) + assert service.create_or_update_user_data_filter("demo", _user_data_filter()) == UpsertOutcome.UPDATED + service._entities_api.update_entity_user_data_filters.assert_called_once() + + +class TestWorkspaceSettingOutcome: + def test_created_when_absent(self): + service = _service(CatalogWorkspaceService, "get_workspace_setting", found=False) + setting = CatalogWorkspaceSetting(id="locale", setting_type="LOCALE", content={"value": "fr-FR"}) + assert service.create_or_update_workspace_setting("demo", setting) == UpsertOutcome.CREATED + service._entities_api.create_entity_workspace_settings.assert_called_once() + + def test_updated_when_present(self): + service = _service(CatalogWorkspaceService, "get_workspace_setting", found=True) + setting = CatalogWorkspaceSetting(id="locale", setting_type="LOCALE", content={"value": "fr-FR"}) + assert service.create_or_update_workspace_setting("demo", setting) == UpsertOutcome.UPDATED + service._entities_api.update_entity_workspace_settings.assert_called_once() + + +class TestExportTemplateOutcome: + def test_created_when_absent(self): + service = _service(CatalogOrganizationService, "get_export_template", found=False) + assert service.create_or_update_export_template(_export_template()) == UpsertOutcome.CREATED + service._entities_api.create_entity_export_templates.assert_called_once() + + def test_updated_when_present(self): + service = _service(CatalogOrganizationService, "get_export_template", found=True) + assert service.create_or_update_export_template(_export_template()) == UpsertOutcome.UPDATED + service._entities_api.update_entity_export_templates.assert_called_once() + service._entities_api.create_entity_export_templates.assert_not_called() + + +class TestIdLessCreateIsUnreachable: + """The `id is None` create branches cannot currently run. + + Each one serializes through a generated model that requires a `str` id; + passing None fails type validation before any request is made -- true of the + `PostOptionalId` variants too, where "optional" means "omit the key", not + "accept None". These are strict xfails so that fixing the generated client + (or the entity models) trips them and this file gets revisited, rather than + the outcome contract silently claiming to cover a dead path. + """ + + @pytest.mark.xfail(raises=ApiTypeError, strict=True, reason="generated client rejects a None id") + def test_filter_view(self): + service = _service(CatalogWorkspaceService, "get_filter_view", found=False) + assert service.create_or_update_filter_view("demo", _filter_view(None)) == UpsertOutcome.CREATED + + @pytest.mark.xfail(raises=ApiTypeError, strict=True, reason="generated client rejects a None id") + def test_user_data_filter(self): + service = _service(CatalogWorkspaceService, "get_user_data_filter", found=False) + assert service.create_or_update_user_data_filter("demo", _user_data_filter(None)) == UpsertOutcome.CREATED + + @pytest.mark.xfail(raises=ApiTypeError, strict=True, reason="generated client rejects a None id") + def test_workspace_setting(self): + service = _service(CatalogWorkspaceService, "get_workspace_setting", found=False) + setting = CatalogWorkspaceSetting(setting_type="LOCALE", content={"value": "fr-FR"}) + assert service.create_or_update_workspace_setting("demo", setting) == UpsertOutcome.CREATED + + +@pytest.mark.parametrize("outcome", list(UpsertOutcome)) +def test_outcome_is_a_plain_string(outcome): + """The str mixin keeps the value usable in logs and comparisons on py3.10. + + The str()/format() assertions pin the `__str__ = str.__str__` override, so + swapping the base for StrEnum once py3.10 is dropped stays a no-op. + """ + assert isinstance(outcome, str) + assert outcome == outcome.value + assert str(outcome) == outcome.value + assert f"{outcome}" == outcome.value