From a68418be45a423af94a29f34f9766e3025abd4f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A5l=20R=C3=B8nning?= Date: Wed, 24 Jun 2026 15:22:13 +0200 Subject: [PATCH 1/4] chore(release): publish patch for #3074 ## Bump - [x] Patch - [ ] Skip ## Changelog ### Fixed - `cdf build` rejecting unquoted timestamp-like values in config.yaml variables From fbe0a5ff0e10f2d7776bf999e5756ad30aef8af1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A5l=20R=C3=B8nning?= Date: Wed, 1 Jul 2026 10:32:00 +0200 Subject: [PATCH 2/4] fix(graphql): surface real API error when upsertGraphQlDmlVersion returns null MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the CDF DML API rejects a mutation it returns {"data": {"upsertGraphQlDmlVersion": null}, "errors": [...]} — the previous client parsed the response with Pydantic first, crashing on the null before ever reaching the error list, leaving the user with an opaque "Input should be an object" message. Now check top-level GraphQL errors before Pydantic validation so the real rejection reason is always surfaced. Also fix UpsertResponseData: errors was typed as dict but the API returns a list of DMLError objects, and result is now correctly nullable for when DML validation fails. --- .../_cdf_tk/client/api/graphql_data_models.py | 44 ++++++++++---- .../test_cdf_tk/test_cruds/test_data_model.py | 59 +++++++++++++++++++ 2 files changed, 93 insertions(+), 10 deletions(-) diff --git a/cognite_toolkit/_cdf_tk/client/api/graphql_data_models.py b/cognite_toolkit/_cdf_tk/client/api/graphql_data_models.py index 8ef10d7be8..3e95c151c5 100644 --- a/cognite_toolkit/_cdf_tk/client/api/graphql_data_models.py +++ b/cognite_toolkit/_cdf_tk/client/api/graphql_data_models.py @@ -3,6 +3,7 @@ This API provides a wrapper around the legacy DML API for managing GraphQL data models. """ +import json from collections.abc import Iterable, Sequence from typing import Any @@ -26,13 +27,22 @@ from cognite_toolkit._cdf_tk.utils import humanize_collection +class DMLError(BaseModel): + model_config = ConfigDict(extra="allow") + kind: str | None = None + message: str | None = None + hint: str | None = None + + class UpsertResponseData(BaseModel): - errors: dict[str, Any] | None = None - result: GraphQLDataModelResponse + errors: list[DMLError] | None = None + result: GraphQLDataModelResponse | None = None class GraphQLUpsertResponse(BaseModel): - upsert_graph_ql_dml_version: UpsertResponseData = Field(alias="upsertGraphQlDmlVersion") + # Nullable: the API sets this to null and populates top-level errors when + # the mutation input is rejected (e.g. unknown fields, auth failures). + upsert_graph_ql_dml_version: UpsertResponseData | None = Field(None, alias="upsertGraphQlDmlVersion") class GraphQLErrors(BaseModel): @@ -43,7 +53,7 @@ class GraphQLErrors(BaseModel): class GraphQLResponse(BaseModel): - data: GraphQLUpsertResponse + data: GraphQLUpsertResponse | None = None errors: list[GraphQLErrors] | None = None @@ -79,11 +89,21 @@ def _post_graphql(self, payload: dict[str, Any]) -> GraphQLUpsertResponse: ) result = self._http_client.request_single_retries(request) response = result.get_success_or_raise(request) - parsed = GraphQLResponse.model_validate_json(response.body) - if errors := parsed.errors: - raise ToolkitAPIError( - f"Failed GraphQL errors: {humanize_collection([error.message for error in errors if error.message])}" - ) + # Parse as raw dict first so top-level GraphQL errors (which accompany a + # null upsertGraphQlDmlVersion) are surfaced before Pydantic validation. + raw = json.loads(response.body) + if top_errors := raw.get("errors"): + messages = [e.get("message", str(e)) for e in top_errors if isinstance(e, dict)] + raise ToolkitAPIError(f"GraphQL mutation failed: {humanize_collection(messages)}") + parsed = GraphQLResponse.model_validate(raw) + if parsed.data is None: + raise ToolkitAPIError("GraphQL mutation returned no data and no errors.") + upsert = parsed.data.upsert_graph_ql_dml_version + if upsert is None: + raise ToolkitAPIError("GraphQL mutation returned no result and no errors.") + if upsert.errors: + messages = [e.message for e in upsert.errors if e.message] + raise ToolkitAPIError(f"DML validation failed: {humanize_collection(messages)}") return parsed.data def create(self, items: Sequence[GraphQLDataModelRequest]) -> list[GraphQLDataModelResponse]: @@ -102,7 +122,11 @@ def create(self, items: Sequence[GraphQLDataModelRequest]) -> list[GraphQLDataMo "variables": {"dmCreate": item.model_dump(mode="json", by_alias=True, exclude_unset=False)}, } response = self._post_graphql(payload) - results.append(response.upsert_graph_ql_dml_version.result) + # _post_graphql raises before returning if upsert_graph_ql_dml_version or result is None. + upsert = response.upsert_graph_ql_dml_version + if upsert is None or upsert.result is None: + raise ToolkitAPIError("GraphQL mutation succeeded but returned no data model.") + results.append(upsert.result) return results def retrieve(self, items: Sequence[DataModelId], inline_views: bool = False) -> list[GraphQLDataModelResponse]: diff --git a/tests/test_unit/test_cdf_tk/test_cruds/test_data_model.py b/tests/test_unit/test_cdf_tk/test_cruds/test_data_model.py index d8ff34f6b1..2eb088138e 100644 --- a/tests/test_unit/test_cdf_tk/test_cruds/test_data_model.py +++ b/tests/test_unit/test_cdf_tk/test_cruds/test_data_model.py @@ -671,3 +671,62 @@ def test_dml_updated_to_renamed_graphql_in_build(self, tmp_path: Path) -> None: assert entry["dml"] == renamed_graphql, ( f"entry['dml'] was not updated after build rename: got {entry['dml']!r}, expected {renamed_graphql!r}" ) + + +class TestGraphQLDataModelsAPI: + """Regression tests for GraphQLDataModelsAPI error surfacing.""" + + @staticmethod + def _make_api(response_body: str): # type: ignore[return] + from unittest.mock import MagicMock + + from cognite_toolkit._cdf_tk.client.api.graphql_data_models import GraphQLDataModelsAPI + + mock_success = MagicMock() + mock_success.body = response_body + mock_result = MagicMock() + mock_result.get_success_or_raise.return_value = mock_success + mock_http = MagicMock() + mock_http.request_single_retries.return_value = mock_result + + api = GraphQLDataModelsAPI(http_client=mock_http) + api._make_url = MagicMock(return_value="https://api.cognitedata.com/dml/graphql") # type: ignore[method-assign] + return api + + def test_top_level_graphql_error_surfaced_not_swallowed(self) -> None: + # Regression: when the API returns {"data": {"upsertGraphQlDmlVersion": null}, "errors": [...]} + # the client was crashing with a cryptic Pydantic validation error instead of the real message. + import json + + from cognite_toolkit._cdf_tk.client.http_client import ToolkitAPIError + + api_response = json.dumps( + { + "data": {"upsertGraphQlDmlVersion": None}, + "errors": [{"message": "Unknown argument 'dml' on field 'upsertGraphQlDmlVersion'"}], + } + ) + api = self._make_api(api_response) + + with pytest.raises(ToolkitAPIError, match="Unknown argument 'dml'"): + api._post_graphql({"query": "...", "variables": {}}) + + def test_dml_validation_errors_surfaced(self) -> None: + import json + + from cognite_toolkit._cdf_tk.client.http_client import ToolkitAPIError + + api_response = json.dumps( + { + "data": { + "upsertGraphQlDmlVersion": { + "errors": [{"kind": "SYNTAX", "message": "Invalid type 'Foo'", "hint": None}], + "result": None, + } + }, + } + ) + api = self._make_api(api_response) + + with pytest.raises(ToolkitAPIError, match="Invalid type 'Foo'"): + api._post_graphql({"query": "...", "variables": {}}) From f1a33bb30e2eb13805d8ee6646e98b83d6e12914 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A5l=20R=C3=B8nning?= Date: Wed, 1 Jul 2026 10:32:06 +0200 Subject: [PATCH 3/4] Update cdf --help docs and resource reference links. --- cognite_toolkit/_cdf_tk/apps/_core_app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cognite_toolkit/_cdf_tk/apps/_core_app.py b/cognite_toolkit/_cdf_tk/apps/_core_app.py index 623d666706..051f94d680 100644 --- a/cognite_toolkit/_cdf_tk/apps/_core_app.py +++ b/cognite_toolkit/_cdf_tk/apps/_core_app.py @@ -89,8 +89,8 @@ def common( ] = False, ) -> None: """ - Docs: https://docs.cognite.com/cdf/deploy/cdf_toolkit/\n - Template reference documentation: https://developer.cognite.com/sdks/toolkit/references/configs + Docs: https://docs.cognite.com/cdf/deploy/cdf_toolkit/guides/usage\n + Resource reference: https://docs.cognite.com/cdf/deploy/cdf_toolkit/references/resource_library """ ctx.obj = Common(override_env=override_env) if ctx.invoked_subcommand is None: From 9a67e65190b4630c59484316c68eca6a83333014 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A5l=20R=C3=B8nning?= Date: Wed, 1 Jul 2026 10:32:37 +0200 Subject: [PATCH 4/4] Revert "Update cdf --help docs and resource reference links." This reverts commit f1a33bb30e2eb13805d8ee6646e98b83d6e12914. --- cognite_toolkit/_cdf_tk/apps/_core_app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cognite_toolkit/_cdf_tk/apps/_core_app.py b/cognite_toolkit/_cdf_tk/apps/_core_app.py index 051f94d680..623d666706 100644 --- a/cognite_toolkit/_cdf_tk/apps/_core_app.py +++ b/cognite_toolkit/_cdf_tk/apps/_core_app.py @@ -89,8 +89,8 @@ def common( ] = False, ) -> None: """ - Docs: https://docs.cognite.com/cdf/deploy/cdf_toolkit/guides/usage\n - Resource reference: https://docs.cognite.com/cdf/deploy/cdf_toolkit/references/resource_library + Docs: https://docs.cognite.com/cdf/deploy/cdf_toolkit/\n + Template reference documentation: https://developer.cognite.com/sdks/toolkit/references/configs """ ctx.obj = Common(override_env=override_env) if ctx.invoked_subcommand is None: