From 640d740d3f8bc47dece15ba16ad682b77d057577 Mon Sep 17 00:00:00 2001 From: vondravl Date: Tue, 4 Aug 2026 10:34:41 +0200 Subject: [PATCH 1/2] feat(gooddata-sdk): report whether create_or_update created or updated Every create_or_update* method already performs an existence check and branches on it, then discards the answer and returns None. Callers that need to know -- to log accurately, to count creations, to skip follow-up work -- have to repeat the same GET the SDK just made, because the create/update entity calls are reachable only through the private _entities_api. Return a new UpsertOutcome (CREATED / UPDATED) from all nine of them so the information the SDK already computed is no longer thrown away. Adding a return value to a method previously annotated -> None is backward compatible: every in-repo caller (gooddata-pipelines, gooddata-dbt, gooddata-eval) ignores the result and is unaffected. The outcome is best-effort by nature -- the existence check is not atomic with the write that follows -- which the enum docstring states so it is not mistaken for an authoritative audit record. Assertions on both branches are added to the existing cassette tests, and mock-based unit tests cover what no cassette reaches: filter views, export templates, and the update branch of user data filters. Writing those tests turned up a pre-existing defect: the `id is None` create branches of create_or_update_filter_view, create_or_update_user_data_filter and create_or_update_workspace_setting cannot run at all. Each serializes through a generated model requiring a str id, and passing None fails type validation before any request -- including the PostOptionalId variants, where "optional" means "omit the key" rather than "accept None". That is left unfixed here (separate concern), but is pinned by strict xfail tests and the docstrings no longer claim those paths create anything. --- .../organization/create_or_update_jwk.md | 6 +- .../create_or_update_user_group.md | 5 +- .../users/create_or_update_user.md | 5 +- .../create_or_update_data_source.md | 5 +- .../workspace/workspaces/create_or_update.md | 5 +- .../gooddata-sdk/src/gooddata_sdk/__init__.py | 1 + .../catalog/data_source/service.py | 8 +- .../catalog/organization/service.py | 15 +- .../src/gooddata_sdk/catalog/types.py | 15 ++ .../src/gooddata_sdk/catalog/user/service.py | 15 +- .../gooddata_sdk/catalog/workspace/service.py | 117 +++++++++------ .../tests/catalog/test_catalog_data_source.py | 5 +- .../catalog/test_catalog_organization.py | 7 +- .../catalog/test_catalog_user_service.py | 11 +- .../tests/catalog/test_catalog_workspace.py | 17 ++- .../tests/catalog/test_upsert_outcome.py | 141 ++++++++++++++++++ 16 files changed, 302 insertions(+), 76 deletions(-) create mode 100644 packages/gooddata-sdk/tests/catalog/test_upsert_outcome.py 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..28c02c7e0 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/catalog/types.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/catalog/types.py @@ -1,5 +1,20 @@ # (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" 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..b8dfe6b38 --- /dev/null +++ b/packages/gooddata-sdk/tests/catalog/test_upsert_outcome.py @@ -0,0 +1,141 @@ +# (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.""" + assert isinstance(outcome, str) + assert outcome == outcome.value From b8993c46759418a32aca9870f21326976211e3f7 Mon Sep 17 00:00:00 2001 From: vondravl Date: Wed, 5 Aug 2026 07:52:34 +0200 Subject: [PATCH 2/2] chore(gooddata-sdk): make UpsertOutcome str() match StrEnum str(UpsertOutcome.CREATED) returned "UpsertOutcome.CREATED"; with __str__ = str.__str__ it returns "created", so swapping the base class for StrEnum once py3.10 support is dropped is a no-op for callers. Co-Authored-By: Claude Opus 5 (1M context) --- packages/gooddata-sdk/src/gooddata_sdk/catalog/types.py | 4 ++++ .../gooddata-sdk/tests/catalog/test_upsert_outcome.py | 8 +++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/gooddata-sdk/src/gooddata_sdk/catalog/types.py b/packages/gooddata-sdk/src/gooddata_sdk/catalog/types.py index 28c02c7e0..15fb576fb 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/catalog/types.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/catalog/types.py @@ -18,3 +18,7 @@ class UpsertOutcome(str, Enum): 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/tests/catalog/test_upsert_outcome.py b/packages/gooddata-sdk/tests/catalog/test_upsert_outcome.py index b8dfe6b38..b2d81434b 100644 --- a/packages/gooddata-sdk/tests/catalog/test_upsert_outcome.py +++ b/packages/gooddata-sdk/tests/catalog/test_upsert_outcome.py @@ -136,6 +136,12 @@ def test_workspace_setting(self): @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 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