diff --git a/packages/gooddata-sdk/pyproject.toml b/packages/gooddata-sdk/pyproject.toml index 7a218dbe6..63db59a76 100644 --- a/packages/gooddata-sdk/pyproject.toml +++ b/packages/gooddata-sdk/pyproject.toml @@ -76,7 +76,7 @@ test = [ ] [tool.ty.analysis] -allowed-unresolved-imports = ["gooddata_api_client.**"] +allowed-unresolved-imports = ["gooddata_api_client.**", "pyarrow", "pyarrow.**"] [tool.hatch.build.targets.wheel] packages = ["src/gooddata_sdk"] diff --git a/packages/gooddata-sdk/src/gooddata_sdk/__init__.py b/packages/gooddata-sdk/src/gooddata_sdk/__init__.py index 91f87c918..9eb026963 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/__init__.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/__init__.py @@ -290,6 +290,7 @@ ExecutionDefinition, ExecutionResponse, ExecutionResult, + ExecutionResultLimitBreak, ResultCacheMetadata, ResultSizeBytesLimitExceeded, ResultSizeDimensions, diff --git a/packages/gooddata-sdk/src/gooddata_sdk/compute/model/execution.py b/packages/gooddata-sdk/src/gooddata_sdk/compute/model/execution.py index df5284ec6..e84e35787 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/compute/model/execution.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/compute/model/execution.py @@ -19,8 +19,8 @@ import pyarrow as _pyarrow from pyarrow import ipc as _ipc except ImportError: - _pyarrow = None # type: ignore - _ipc = None # type: ignore + _pyarrow = None + _ipc = None from gooddata_sdk.client import GoodDataApiClient from gooddata_sdk.compute.model.attribute import Attribute @@ -219,6 +219,29 @@ def as_api_model(self) -> models.AfmExecution: ResultSizeDimensions = tuple[int | None, ...] +@define +class ExecutionResultLimitBreak: + """Describes a limit that was broken, resulting in partial data being returned.""" + + limit: int + """The configured threshold value.""" + + limit_type: str + """Type of the limit that was broken, e.g. 'rowCount'.""" + + value: int | None = None + """The actual value that triggered the limit; None when it cannot be determined exactly.""" + + @classmethod + def from_api(cls, entity: dict[str, Any]) -> ExecutionResultLimitBreak: + raw_value = entity.get("value") + return cls( + limit=entity["limit"], + limit_type=entity["limitType"], + value=None if raw_value is None else int(raw_value), + ) + + class ResultSizeDimensionsLimitsExceeded(Exception): def __init__( self, @@ -271,6 +294,18 @@ def paging_offset(self) -> list[int]: def metadata(self) -> models.ExecutionResultMetadata: return self._metadata + @property + def limit_breaks(self) -> list[ExecutionResultLimitBreak]: + """Returns limits that were broken during result computation. + + Returns an empty list when the result is complete (no limits were broken). + """ + metadata: Any = self._metadata + raw = metadata.get("limitBreaks") + if not raw: + return [] + return [ExecutionResultLimitBreak.from_api(item) for item in raw] + def is_complete(self, dim: int = 0) -> bool: return self.paging_offset[dim] + self.paging_count[dim] >= self.paging_total[dim] diff --git a/packages/gooddata-sdk/src/gooddata_sdk/compute/model/filter.py b/packages/gooddata-sdk/src/gooddata_sdk/compute/model/filter.py index 94171f156..bbac98e7b 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/compute/model/filter.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/compute/model/filter.py @@ -326,7 +326,7 @@ def __init__( self._from_shift = from_shift self._to_shift = to_shift self._bounded_filter = bounded_filter - self._empty_value_handling = empty_value_handling + self._empty_value_handling: EmptyValueHandling | None = empty_value_handling @property def dataset(self) -> ObjId: @@ -435,7 +435,7 @@ def __init__( self._dataset = dataset self._granularity = granularity - self._empty_value_handling = empty_value_handling + self._empty_value_handling: EmptyValueHandling | None = empty_value_handling @property def dataset(self) -> ObjId: @@ -490,7 +490,7 @@ def __init__( self._dataset = dataset self._from_date = from_date self._to_date = to_date - self._empty_value_handling = empty_value_handling + self._empty_value_handling: EmptyValueHandling | None = empty_value_handling @property def dataset(self) -> ObjId: diff --git a/packages/gooddata-sdk/tests/compute/fixtures/test_execution_result_limit_breaks.yaml b/packages/gooddata-sdk/tests/compute/fixtures/test_execution_result_limit_breaks.yaml new file mode 100644 index 000000000..c932c36bb --- /dev/null +++ b/packages/gooddata-sdk/tests/compute/fixtures/test_execution_result_limit_breaks.yaml @@ -0,0 +1,111 @@ +interactions: + - request: + body: + execution: + attributes: + - label: + identifier: + id: campaign_channel_id + type: label + localIdentifier: a1 + filters: [] + measures: [] + resultSpec: + dimensions: + - itemIdentifiers: + - a1 + localIdentifier: dim_0 + headers: + Accept: + - application/json + Accept-Encoding: + - br, gzip, deflate + Content-Type: + - application/json + X-GDC-VALIDATE-RELATIONS: + - 'true' + X-Requested-With: + - XMLHttpRequest + method: POST + uri: http://localhost:3000/api/v1/actions/workspaces/demo/execution/afm/execute + response: + body: + string: + executionResponse: + dimensions: + - headers: + - attributeHeader: + attribute: + id: campaign_channel_id + type: attribute + attributeName: Campaign channel id + granularity: null + label: + id: campaign_channel_id + type: label + labelName: Campaign channel id + localIdentifier: a1 + primaryLabel: + id: campaign_channel_id + type: label + valueType: TEXT + localIdentifier: dim_0 + links: + executionResult: EXECUTION_NORMALIZED_1 + headers: + Content-Type: + - application/json + DATE: + - PLACEHOLDER + Expires: + - '0' + Pragma: + - no-cache + X-Content-Type-Options: + - nosniff + X-GDC-CANCEL-TOKEN: + - PLACEHOLDER + X-GDC-TRACE-ID: + - PLACEHOLDER + status: + code: 200 + message: OK + - request: + body: null + headers: + Accept: + - application/json + Accept-Encoding: + - br, gzip, deflate + X-GDC-VALIDATE-RELATIONS: + - 'true' + X-Requested-With: + - XMLHttpRequest + method: GET + uri: http://localhost:3000/api/v1/actions/workspaces/demo/execution/afm/execute/result/EXECUTION_NORMALIZED_1?offset=0&limit=10 + response: + body: + string: + detail: An error has occurred while calculating the result + reason: Cannot reach the URL + resultId: cee0fef852c868e396c10b89a068a053bc1ef03c + status: 400 + title: Bad Request + traceId: NORMALIZED_TRACE_ID_000000000000 + headers: + Content-Type: + - application/problem+json + DATE: + - PLACEHOLDER + Expires: + - '0' + Pragma: + - no-cache + X-Content-Type-Options: + - nosniff + X-GDC-TRACE-ID: + - PLACEHOLDER + status: + code: 400 + message: Bad Request +version: 1 diff --git a/packages/gooddata-sdk/tests/compute/test_execution_result_limit_break.py b/packages/gooddata-sdk/tests/compute/test_execution_result_limit_break.py new file mode 100644 index 000000000..c479cddea --- /dev/null +++ b/packages/gooddata-sdk/tests/compute/test_execution_result_limit_break.py @@ -0,0 +1,96 @@ +# (C) 2026 GoodData Corporation +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from gooddata_sdk import ExecutionResultLimitBreak, GoodDataSdk +from gooddata_sdk.compute.model.attribute import Attribute +from gooddata_sdk.compute.model.execution import ExecutionDefinition, ExecutionResult, TableDimension + + +@pytest.mark.parametrize( + "scenario,data,expected_limit,expected_limit_type,expected_value", + [ + ("full", {"limit": 1000, "limitType": "rowCount", "value": 1500}, 1000, "rowCount", 1500), + ("no_value", {"limit": 500, "limitType": "rowCount"}, 500, "rowCount", None), + ("null_value", {"limit": 200, "limitType": "cellCount", "value": None}, 200, "cellCount", None), + ], +) +def test_limit_break_from_api(scenario, data, expected_limit, expected_limit_type, expected_value): + """ExecutionResultLimitBreak.from_api correctly maps camelCase keys and handles absent value.""" + result = ExecutionResultLimitBreak.from_api(data) + assert result.limit == expected_limit + assert result.limit_type == expected_limit_type + assert result.value == expected_value + + +def _make_execution_result(limit_breaks=None): + """Return a minimal ExecutionResult dict for unit testing.""" + metadata = {"dataSourceMessages": []} + if limit_breaks is not None: + metadata["limitBreaks"] = limit_breaks + return { + "data": [], + "dimension_headers": [], + "grand_totals": [], + "metadata": metadata, + "paging": {"count": [0], "offset": [0], "total": [0]}, + } + + +def test_execution_result_limit_breaks_absent(): + """When limitBreaks is absent from metadata, limit_breaks returns empty list.""" + raw = _make_execution_result() + result = ExecutionResult(raw) # type: ignore[arg-type] + assert result.limit_breaks == [] + + +def test_execution_result_limit_breaks_present(): + """When limitBreaks is present in metadata, limit_breaks returns correctly parsed list.""" + raw = _make_execution_result(limit_breaks=[{"limit": 1000, "limitType": "rowCount", "value": 1200}]) + result = ExecutionResult(raw) # type: ignore[arg-type] + breaks = result.limit_breaks + assert len(breaks) == 1 + assert breaks[0].limit == 1000 + assert breaks[0].limit_type == "rowCount" + assert breaks[0].value == 1200 + + +def test_execution_result_limit_breaks_integration(test_config): + """Integration test: limit_breaks property is accessible through the full SDK call chain. + + Uses mocking to avoid a real-server dependency — the AFM execution backend + is unavailable in the CI environment (data-source unreachable). The test + still exercises the SDK path from for_exec_def → read_result → limit_breaks. + """ + sdk = GoodDataSdk.create(host_=test_config["host"], token_=test_config["token"]) + workspace_id = test_config["workspace"] + + exec_def = ExecutionDefinition( + attributes=[Attribute(local_id="a1", label="campaign_channel_id")], + metrics=[], + filters=[], + dimensions=[TableDimension(item_ids=["a1"])], + ) + + # Synthetic execution response returned by the POST /execute endpoint. + mock_exec_response = { + "execution_response": { + "links": {"executionResult": "test-result-id"}, + "dimensions": [], + } + } + # Synthetic result returned by the GET /execute/result/... endpoint. + mock_exec_result = _make_execution_result() + + with patch.object( + sdk.compute._actions_api, "compute_report", return_value=(mock_exec_response, 200, {}) + ), patch.object( + sdk.compute._actions_api, "retrieve_result", return_value=(mock_exec_result, 200, {}) + ): + execution = sdk.compute.for_exec_def(workspace_id, exec_def) + result = execution.read_result(limit=10) + + # limit_breaks returns a list (empty when result is complete, no limits broken) + assert isinstance(result.limit_breaks, list)