Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/gooddata-sdk/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
1 change: 1 addition & 0 deletions packages/gooddata-sdk/src/gooddata_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@
ExecutionDefinition,
ExecutionResponse,
ExecutionResult,
ExecutionResultLimitBreak,
ResultCacheMetadata,
ResultSizeBytesLimitExceeded,
ResultSizeDimensions,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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)
Loading