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..5262f45d8 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/compute/model/execution.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/compute/model/execution.py @@ -30,6 +30,30 @@ logger = logging.getLogger(__name__) +@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; null when it cannot be determined exactly.""" + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> ExecutionResultLimitBreak: + """Construct from a raw API response dict (camelCase keys).""" + raw_value = d.get("value") + return cls( + limit=int(d["limit"]), + limit_type=str(d["limitType"]), + value=int(raw_value) if raw_value is not None else None, + ) + + @define class TotalDimension: idx: int @@ -238,6 +262,12 @@ def __init__(self, result: models.ExecutionResult): self._grand_totals: list[models.ExecutionResultGrandTotal] = result["grand_totals"] self._paging: models.ExecutionResultPaging = result["paging"] self._metadata: models.ExecutionResultMetadata = result["metadata"] + raw_limit_breaks = result.get("limitBreaks") + self._limit_breaks: list[ExecutionResultLimitBreak] = ( + [ExecutionResultLimitBreak.from_dict(item) for item in raw_limit_breaks] + if raw_limit_breaks is not None + else [] + ) @property def data(self) -> list[Any]: @@ -271,6 +301,14 @@ def paging_offset(self) -> list[int]: def metadata(self) -> models.ExecutionResultMetadata: return self._metadata + @property + def limit_breaks(self) -> list[ExecutionResultLimitBreak]: + """Limits that were broken during result computation, causing the result to be partial. + + Returns an empty list when the result is complete (no limits were broken). + """ + return self._limit_breaks + 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/tests/compute/fixtures/.gitkeep b/packages/gooddata-sdk/tests/compute/fixtures/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/gooddata-sdk/tests/compute/test_bare_execution_response.py b/packages/gooddata-sdk/tests/compute/test_bare_execution_response.py index 9eac42c2b..8b5d15114 100644 --- a/packages/gooddata-sdk/tests/compute/test_bare_execution_response.py +++ b/packages/gooddata-sdk/tests/compute/test_bare_execution_response.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch import pytest +from gooddata_sdk.compute.model.execution import ExecutionResultLimitBreak pyarrow = pytest.importorskip("pyarrow") @@ -104,3 +105,44 @@ def test_read_result_arrow_no_pyarrow_raises() -> None: with patch.object(_exec_mod, "_ipc", None), pytest.raises(ImportError, match="pyarrow is required"): bare.read_result_arrow() + + +@pytest.mark.parametrize( + "scenario, raw, expected_limit, expected_limit_type, expected_value", + [ + ( + "with_value", + {"limit": 1000, "limitType": "rowCount", "value": 1500}, + 1000, + "rowCount", + 1500, + ), + ( + "value_none", + {"limit": 500, "limitType": "columnCount"}, + 500, + "columnCount", + None, + ), + ( + "value_explicit_none", + {"limit": 200, "limitType": "cellCount", "value": None}, + 200, + "cellCount", + None, + ), + ], +) +def test_execution_result_limit_break_from_dict( + scenario: str, + raw: dict, + expected_limit: int, + expected_limit_type: str, + expected_value: int | None, +) -> None: + """ExecutionResultLimitBreak.from_dict correctly maps camelCase keys and handles optional value.""" + lb = ExecutionResultLimitBreak.from_dict(raw) + + assert lb.limit == expected_limit + assert lb.limit_type == expected_limit_type + assert lb.value == expected_value 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..580b8cd3e --- /dev/null +++ b/packages/gooddata-sdk/tests/compute/test_execution_result_limit_break.py @@ -0,0 +1,51 @@ +# (C) 2026 GoodData Corporation +from __future__ import annotations + +from unittest.mock import MagicMock + +from gooddata_sdk import ExecutionResultLimitBreak +from gooddata_sdk.compute.model.execution import ExecutionResult + + +def _make_mock_api_result(limit_breaks_value): + """Return a mock that mimics a models.ExecutionResult dict-like object.""" + _paging = {"total": [10, 1], "count": [10, 1], "offset": [0, 0]} + m = MagicMock() + m.__getitem__ = MagicMock( + side_effect=lambda k: { + "data": [], + "dimension_headers": [], + "grand_totals": [], + "paging": _paging, + "metadata": {}, + }[k] + ) + m.get = MagicMock(side_effect=lambda k, d=None: limit_breaks_value if k == "limitBreaks" else d) + return m + + +def test_execution_result_limit_breaks(): + """ExecutionResult.limit_breaks returns a list; non-empty when limits are broken. + + For a normal execution the result is complete so limit_breaks must be []. + This test also verifies that the field is importable from gooddata_sdk and + that the ExecutionResultLimitBreak class is available on the public API. + + Uses a synthetic mock result to avoid staging-server dependency. + """ + # --- Case 1: result where a row-count limit was broken --- + result = ExecutionResult( + _make_mock_api_result([{"limit": 1000, "limitType": "rowCount", "value": 1500}]) + ) + assert isinstance(result.limit_breaks, list) + assert len(result.limit_breaks) == 1 + for lb in result.limit_breaks: + assert isinstance(lb, ExecutionResultLimitBreak) + assert isinstance(lb.limit, int) + assert isinstance(lb.limit_type, str) + assert lb.value is None or isinstance(lb.value, int) + + # --- Case 2: complete result — no limits broken --- + result_complete = ExecutionResult(_make_mock_api_result(None)) + assert isinstance(result_complete.limit_breaks, list) + assert result_complete.limit_breaks == [] diff --git a/read-files.json b/read-files.json new file mode 100644 index 000000000..68277e4ff --- /dev/null +++ b/read-files.json @@ -0,0 +1,240 @@ +{ + "reads": [ + { + "path": "/home/runner/_work/gdc-nas/gdc-nas/sdk/packages/gooddata-sdk/src/gooddata_sdk/compute/model/execution.py", + "count": 5 + }, + { + "path": "/home/runner/_work/gdc-nas/gdc-nas/sdk/packages/gooddata-sdk/tests/compute/test_bare_execution_response.py", + "count": 4 + }, + { + "path": "/home/runner/_work/gdc-nas/gdc-nas/sdk/gooddata-api-client/gooddata_api_client/model/execution_result.py", + "count": 2 + }, + { + "path": "/home/runner/_work/gdc-nas/gdc-nas/sdk/schemas/gooddata-afm-client.json", + "count": 2 + }, + { + "path": "/home/runner/_work/gdc-nas/gdc-nas/sdk/packages/gooddata-sdk/tests/table/test_table.py", + "count": 2 + }, + { + "path": "/home/runner/_work/gdc-nas/gdc-nas/sdk/cluster.json", + "count": 1 + }, + { + "path": "/home/runner/_work/gdc-nas/gdc-nas/sdk/packages/gooddata-sdk/src/gooddata_sdk/compute/compute_to_sdk_converter.py", + "count": 1 + }, + { + "path": "/home/runner/_work/gdc-nas/gdc-nas/sdk/gooddata-api-client/gooddata_api_client/model/execution_result_grand_total.py", + "count": 1 + }, + { + "path": "/home/runner/_work/gdc-nas/gdc-nas/sdk/gooddata-api-client/gooddata_api_client/model/execution_result_metadata.py", + "count": 1 + }, + { + "path": "/home/runner/_work/gdc-nas/gdc-nas/sdk/packages/gooddata-sdk/src/gooddata_sdk/table.py", + "count": 1 + }, + { + "path": "/home/runner/_work/gdc-nas/gdc-nas/sdk/packages/gooddata-sdk/tests/compute/test_compute_service.py", + "count": 1 + }, + { + "path": "/home/runner/_work/gdc-nas/gdc-nas/sdk/packages/gooddata-sdk/tests/conftest.py", + "count": 1 + }, + { + "path": "/home/runner/_work/gdc-nas/gdc-nas/sdk/packages/gooddata-sdk/src/gooddata_sdk/__init__.py", + "count": 1 + }, + { + "path": "/home/runner/_work/gdc-nas/gdc-nas/sdk/packages/gooddata-sdk/tests/compute/test_compute_to_sdk_converter.py", + "count": 1 + }, + { + "path": "/home/runner/_work/gdc-nas/gdc-nas/sdk/gooddata-api-client/gooddata_api_client/model_utils.py", + "count": 1 + }, + { + "path": "/home/runner/_work/gdc-nas/gdc-nas/sdk/packages/gooddata-sdk/pyproject.toml", + "count": 1 + }, + { + "path": "/home/runner/_work/gdc-nas/gdc-nas/sdk/packages/gooddata-sdk/src/gooddata_sdk/compute/__init__.py", + "count": 1 + }, + { + "path": "/home/runner/_work/gdc-nas/gdc-nas/sdk/packages/gooddata-sdk/src/gooddata_sdk/compute/model/__init__.py", + "count": 1 + }, + { + "path": "/home/runner/_work/gdc-nas/gdc-nas/sdk/packages/gooddata-sdk/tests/compute_model/test_compute_model.py", + "count": 1 + }, + { + "path": "/home/runner/_work/gdc-nas/gdc-nas/sdk/packages/gooddata-sdk/src/gooddata_sdk/compute/service.py", + "count": 1 + }, + { + "path": "/home/runner/_work/gdc-nas/gdc-nas/sdk/packages/tests-support/src/tests_support/vcrpy_utils.py", + "count": 1 + }, + { + "path": "/home/runner/_work/gdc-nas/gdc-nas/sdk/packages/gooddata-sdk/tests/compute/test_execution_result_limit_break.py", + "count": 1 + } + ], + "globs": [ + { + "pattern": "packages/gooddata-sdk/gooddata_sdk/**/*.py", + "count": 2 + }, + { + "pattern": "cluster.json", + "count": 1 + }, + { + "pattern": "gooddata-sdk/gooddata_sdk/**/*.py", + "count": 1 + }, + { + "pattern": "gooddata-api-client/**/*.py", + "count": 1 + }, + { + "pattern": "packages/**/*.py", + "count": 1 + }, + { + "pattern": "packages/gooddata-sdk/src/**/*.py", + "count": 1 + }, + { + "pattern": "packages/gooddata-sdk/src/gooddata_sdk/compute/**/*.py", + "count": 1 + }, + { + "pattern": "gooddata-afm-client/**/*.py", + "count": 1 + }, + { + "pattern": "packages/gooddata-api-client/**/*.py", + "count": 1 + }, + { + "pattern": "gooddata-api-client/gooddata_api_client/models/__init__.py", + "count": 1 + }, + { + "pattern": "schemas/*.json", + "count": 1 + }, + { + "pattern": "**/gooddata-afm-client.json", + "count": 1 + }, + { + "pattern": "packages/gooddata-sdk/tests/**/*.py", + "count": 1 + }, + { + "pattern": "packages/gooddata-sdk/tests/compute/**/*", + "count": 1 + }, + { + "pattern": "packages/gooddata-sdk/tests/compute_model/test_compute_model.py", + "count": 1 + }, + { + "pattern": "packages/gooddata-sdk/tests/table/fixtures/*.yaml", + "count": 1 + }, + { + "pattern": "packages/gooddata-sdk/tests/compute/fixtures/*.yaml", + "count": 1 + }, + { + "pattern": "/home/runner/_work/_tool/Python/3.12.13/x64/lib/python3.12/site-packages/gooddata*", + "count": 1 + }, + { + "pattern": "/home/runner/**/*.pth", + "count": 1 + }, + { + "pattern": "packages/gooddata-sdk/tests/compute/*", + "count": 1 + } + ], + "greps": [ + { + "pattern": "ExecutionResult|execution_result|compute_to_sdk|LimitBreak|limit_break", + "scope": "packages/gooddata-sdk/src" + }, + { + "pattern": "ExecutionResultLimitBreak|limit_break|limitBreak", + "scope": "gooddata-api-client" + }, + { + "pattern": "ExecutionResultLimitBreak|limit_break|limitBreak", + "scope": "packages/gooddata-sdk" + }, + { + "pattern": "ExecutionResult|execution_result_limit|LimitBreak|limit_break", + "scope": "gooddata-api-client/gooddata_api_client/models/__init__.py" + }, + { + "pattern": "ExecutionResultLimitBreak|limitBreaks|limit_breaks", + "scope": "schemas/gooddata-afm-client.json" + }, + { + "pattern": "ExecutionResultLimitBreak|limitBreaks|limit_breaks", + "scope": "schemas" + }, + { + "pattern": "limitBreaks|limit_breaks|ExecutionResultLimitBreak", + "scope": "schemas/gooddata-afm-client.json" + }, + { + "pattern": "\\\"ExecutionResult\\\"", + "scope": "schemas/gooddata-afm-client.json" + }, + { + "pattern": "limitBreak|LimitBreak|limit_break", + "scope": "schemas" + }, + { + "pattern": "limitBreak|LimitBreak|limit_break", + "scope": "gooddata-api-client" + }, + { + "pattern": "def __getitem__", + "scope": "gooddata-api-client/gooddata_api_client/model_utils.py" + }, + { + "pattern": "def set_attribute", + "scope": "gooddata-api-client/gooddata_api_client/model_utils.py" + }, + { + "pattern": "def change_keys_js_to_python", + "scope": "gooddata-api-client/gooddata_api_client/model_utils.py" + }, + { + "pattern": "def deserialize_model", + "scope": "gooddata-api-client" + }, + { + "pattern": "def convert_js_args_to_python_args", + "scope": "gooddata-api-client/gooddata_api_client/model_utils.py" + }, + { + "pattern": "RecordMode|OVERWRITE|makedirs|mkdir", + "scope": "packages/tests-support/src/tests_support/vcrpy_utils.py" + } + ] +} \ No newline at end of file