From 1555ed8797a6abe2808326a1593bc88677e96e8e Mon Sep 17 00:00:00 2001 From: Tanya Singh Date: Tue, 12 May 2026 20:32:39 +0530 Subject: [PATCH 1/4] feat: add Task Result API support with example and unit tests --- examples/task_result.py | 34 ++++++++ src/pytfe/client.py | 2 + src/pytfe/models/task_result.py | 69 ++++++++++++++++ src/pytfe/models/task_stage.py | 2 +- src/pytfe/resources/task_result.py | 31 +++++++ tests/units/test_task_results.py | 126 +++++++++++++++++++++++++++++ 6 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 examples/task_result.py create mode 100644 src/pytfe/models/task_result.py create mode 100644 src/pytfe/resources/task_result.py create mode 100644 tests/units/test_task_results.py diff --git a/examples/task_result.py b/examples/task_result.py new file mode 100644 index 00000000..483d329f --- /dev/null +++ b/examples/task_result.py @@ -0,0 +1,34 @@ +import os +from pytfe import TFEClient + + +def main(): + token = os.getenv("TFE_TOKEN") + task_result_id = os.getenv("TFE_TASK_RESULT_ID") + + if not token: + print("Set TFE_TOKEN") + return + + if not task_result_id: + print("Set TFE_TASK_RESULT_ID") + return + + client = TFEClient() + + try: + result = client.task_results.read(task_result_id) + + print("=== Task Result ===") + print(f"ID: {result.id}") + print(f"Status: {result.status}") + print(f"Message: {result.message}") + print(f"Task Name: {result.task_name}") + print(f"URL: {result.url}") + + except Exception as e: + print(f"Error: {e}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/pytfe/client.py b/src/pytfe/client.py index 0e88e1ab..1d218002 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -37,6 +37,7 @@ from .resources.ssh_keys import SSHKeys from .resources.state_version_outputs import StateVersionOutputs from .resources.state_versions import StateVersions +from .resources.task_result import TaskResults from .resources.user import Users from .resources.variable import Variables from .resources.variable_sets import VariableSets, VariableSetVariables @@ -76,6 +77,7 @@ def __init__(self, config: TFEConfig | None = None): self.organizations = Organizations(self._transport) self.organization_memberships = OrganizationMemberships(self._transport) self.users = Users(self._transport) + self.task_results = TaskResults(self._transport) self.organization_tokens = OrganizationTokens(self._transport) self.projects = Projects(self._transport) self.variables = Variables(self._transport) diff --git a/src/pytfe/models/task_result.py b/src/pytfe/models/task_result.py new file mode 100644 index 00000000..2b2cf7d9 --- /dev/null +++ b/src/pytfe/models/task_result.py @@ -0,0 +1,69 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from datetime import datetime +from enum import Enum +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + +# Reuse, do NOT duplicate +from pytfe.models.task_stage import TaskStage + + +class TaskResultStatus(str, Enum): + passed = "passed" + failed = "failed" + pending = "pending" + running = "running" + unreachable = "unreachable" + errored = "errored" + + +class TaskEnforcementLevel(str, Enum): + advisory = "advisory" + mandatory = "mandatory" + + +class TaskResultStatusTimestamps(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + errored_at: Optional[datetime] = Field(None, alias="errored-at") + running_at: Optional[datetime] = Field(None, alias="running-at") + canceled_at: Optional[datetime] = Field(None, alias="canceled-at") + failed_at: Optional[datetime] = Field(None, alias="failed-at") + passed_at: Optional[datetime] = Field(None, alias="passed-at") + + +class TaskResult(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + id: str + + status: Optional[TaskResultStatus] = Field(None, alias="status") + message: Optional[str] = Field(None, alias="message") + + status_timestamps: Optional[TaskResultStatusTimestamps] = Field( + None, alias="status-timestamps" + ) + + url: Optional[str] = Field(None, alias="url") + + created_at: Optional[datetime] = Field(None, alias="created-at") + updated_at: Optional[datetime] = Field(None, alias="updated-at") + + task_id: Optional[str] = Field(None, alias="task-id") + task_name: Optional[str] = Field(None, alias="task-name") + task_url: Optional[str] = Field(None, alias="task-url") + + workspace_task_id: Optional[str] = Field(None, alias="workspace-task-id") + workspace_task_enforcement_level: Optional[TaskEnforcementLevel] = Field( + None, alias="workspace-task-enforcement-level" + ) + + agent_pool_id: Optional[str] = Field(None, alias="agent-pool-id") + + # Relation (matches Go: *TaskStage) + task_stage: Optional[TaskStage] = Field(None, alias="task-stage") \ No newline at end of file diff --git a/src/pytfe/models/task_stage.py b/src/pytfe/models/task_stage.py index 54b9346b..bae0766b 100644 --- a/src/pytfe/models/task_stage.py +++ b/src/pytfe/models/task_stage.py @@ -8,7 +8,7 @@ # TaskStage represents a HCP Terraform or Terraform Enterprise run's stage where run tasks can occur class TaskStage(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict(populate_by_name=True) id: str # stage: Stage = Field(..., alias="stage") diff --git a/src/pytfe/resources/task_result.py b/src/pytfe/resources/task_result.py new file mode 100644 index 00000000..2882f39b --- /dev/null +++ b/src/pytfe/resources/task_result.py @@ -0,0 +1,31 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from typing import Any + +from pytfe.models.task_result import TaskResult +from pytfe.utils import valid_string_id +from ._base import _Service + + +class TaskResults(_Service): + def read(self, task_result_id: str) -> TaskResult: + if not valid_string_id(task_result_id): + raise ValueError("Invalid task_result_id") + + path = f"/api/v2/task-results/{task_result_id}" + + response = self.t.request("GET", path) + data = response.json() + + if "data" not in data: + raise ValueError("Invalid response format") + + return self._parse_task_result(data["data"]) + + def _parse_task_result(self, data: dict[str, Any]) -> TaskResult: + attributes = data.get("attributes", {}) + + attributes["id"] = data.get("id") + + return TaskResult(**attributes) \ No newline at end of file diff --git a/tests/units/test_task_results.py b/tests/units/test_task_results.py new file mode 100644 index 00000000..11c57f29 --- /dev/null +++ b/tests/units/test_task_results.py @@ -0,0 +1,126 @@ +import pytest +from unittest.mock import Mock + +from pytfe.resources.task_result import TaskResults +from pytfe.models.task_result import TaskResult + + +class TestTaskResults: + @pytest.fixture + def mock_transport(self): + return Mock() + + @pytest.fixture + def service(self, mock_transport): + return TaskResults(mock_transport) + + def test_read_success(self, service, mock_transport): + response = Mock() + response.json.return_value = { + "data": { + "id": "tr-123", + "attributes": { + "status": "passed", + "message": "ok", + "status-timestamps": {}, + "url": "url", + "created-at": "2024-01-01T00:00:00Z", + "updated-at": "2024-01-01T00:00:00Z", + "task-id": "t1", + "task-name": "name", + "task-url": "url", + "workspace-task-id": "wt1", + "workspace-task-enforcement-level": "advisory", + "agent-pool-id": None, + }, + } + } + mock_transport.request.return_value = response + + result = service.read("tr-123") + + assert isinstance(result, TaskResult) + assert result.id == "tr-123" + assert result.status == "passed" + + def test_invalid_id(self, service): + with pytest.raises(ValueError): + service.read("") + + def test_missing_data(self, service, mock_transport): + response = Mock() + response.json.return_value = {} + + mock_transport.request.return_value = response + + with pytest.raises(ValueError): + service.read("tr-123") + + def test_missing_attributes(self, service, mock_transport): + response = Mock() + response.json.return_value = { + "data": {"id": "tr-123"} + } + + mock_transport.request.return_value = response + + result = service.read("tr-123") + + assert result.id == "tr-123" + + def test_optional_fields(self, service, mock_transport): + response = Mock() + response.json.return_value = { + "data": { + "id": "tr-123", + "attributes": { + "status": "passed", + "message": None, + }, + } + } + + mock_transport.request.return_value = response + + result = service.read("tr-123") + + assert result.message is None + + def test_status_enum(self, service, mock_transport): + response = Mock() + response.json.return_value = { + "data": { + "id": "tr-123", + "attributes": { + "status": "failed", + "message": "fail", + }, + } + } + + mock_transport.request.return_value = response + + result = service.read("tr-123") + + assert result.status == "failed" + + def test_timestamps_parsing(self, service, mock_transport): + response = Mock() + response.json.return_value = { + "data": { + "id": "tr-123", + "attributes": { + "status": "passed", + "message": "ok", + "status-timestamps": { + "passed-at": "2024-01-01T00:00:00Z" + }, + }, + } + } + + mock_transport.request.return_value = response + + result = service.read("tr-123") + + assert result.status_timestamps is not None \ No newline at end of file From 94261825c285b946f61171a01825d7c9449ccd99 Mon Sep 17 00:00:00 2001 From: Tanya Singh Date: Tue, 12 May 2026 21:23:57 +0530 Subject: [PATCH 2/4] fix: address lint issues in task result implementation --- examples/task_result.py | 3 ++- src/pytfe/models/task_result.py | 37 +++++++++++++++--------------- src/pytfe/resources/task_result.py | 3 ++- tests/units/test_task_results.py | 15 +++++------- 4 files changed, 28 insertions(+), 30 deletions(-) diff --git a/examples/task_result.py b/examples/task_result.py index 483d329f..c24619b8 100644 --- a/examples/task_result.py +++ b/examples/task_result.py @@ -1,4 +1,5 @@ import os + from pytfe import TFEClient @@ -31,4 +32,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/src/pytfe/models/task_result.py b/src/pytfe/models/task_result.py index 2b2cf7d9..516da0aa 100644 --- a/src/pytfe/models/task_result.py +++ b/src/pytfe/models/task_result.py @@ -5,7 +5,6 @@ from datetime import datetime from enum import Enum -from typing import Optional from pydantic import BaseModel, ConfigDict, Field @@ -30,11 +29,11 @@ class TaskEnforcementLevel(str, Enum): class TaskResultStatusTimestamps(BaseModel): model_config = ConfigDict(populate_by_name=True) - errored_at: Optional[datetime] = Field(None, alias="errored-at") - running_at: Optional[datetime] = Field(None, alias="running-at") - canceled_at: Optional[datetime] = Field(None, alias="canceled-at") - failed_at: Optional[datetime] = Field(None, alias="failed-at") - passed_at: Optional[datetime] = Field(None, alias="passed-at") + errored_at: datetime | None = Field(None, alias="errored-at") + running_at: datetime | None = Field(None, alias="running-at") + canceled_at: datetime | None = Field(None, alias="canceled-at") + failed_at: datetime | None = Field(None, alias="failed-at") + passed_at: datetime | None = Field(None, alias="passed-at") class TaskResult(BaseModel): @@ -42,28 +41,28 @@ class TaskResult(BaseModel): id: str - status: Optional[TaskResultStatus] = Field(None, alias="status") - message: Optional[str] = Field(None, alias="message") + status: TaskResultStatus | None = Field(None, alias="status") + message: str | None = Field(None, alias="message") - status_timestamps: Optional[TaskResultStatusTimestamps] = Field( + status_timestamps: TaskResultStatusTimestamps | None = Field( None, alias="status-timestamps" ) - url: Optional[str] = Field(None, alias="url") + url: str | None = Field(None, alias="url") - created_at: Optional[datetime] = Field(None, alias="created-at") - updated_at: Optional[datetime] = Field(None, alias="updated-at") + created_at: datetime | None = Field(None, alias="created-at") + updated_at: datetime | None = Field(None, alias="updated-at") - task_id: Optional[str] = Field(None, alias="task-id") - task_name: Optional[str] = Field(None, alias="task-name") - task_url: Optional[str] = Field(None, alias="task-url") + task_id: str | None = Field(None, alias="task-id") + task_name: str | None = Field(None, alias="task-name") + task_url: str | None = Field(None, alias="task-url") - workspace_task_id: Optional[str] = Field(None, alias="workspace-task-id") - workspace_task_enforcement_level: Optional[TaskEnforcementLevel] = Field( + workspace_task_id: str | None = Field(None, alias="workspace-task-id") + workspace_task_enforcement_level: TaskEnforcementLevel | None = Field( None, alias="workspace-task-enforcement-level" ) - agent_pool_id: Optional[str] = Field(None, alias="agent-pool-id") + agent_pool_id: str | None = Field(None, alias="agent-pool-id") # Relation (matches Go: *TaskStage) - task_stage: Optional[TaskStage] = Field(None, alias="task-stage") \ No newline at end of file + task_stage: TaskStage | None = Field(None, alias="task-stage") diff --git a/src/pytfe/resources/task_result.py b/src/pytfe/resources/task_result.py index 2882f39b..7640d018 100644 --- a/src/pytfe/resources/task_result.py +++ b/src/pytfe/resources/task_result.py @@ -5,6 +5,7 @@ from pytfe.models.task_result import TaskResult from pytfe.utils import valid_string_id + from ._base import _Service @@ -28,4 +29,4 @@ def _parse_task_result(self, data: dict[str, Any]) -> TaskResult: attributes["id"] = data.get("id") - return TaskResult(**attributes) \ No newline at end of file + return TaskResult(**attributes) diff --git a/tests/units/test_task_results.py b/tests/units/test_task_results.py index 11c57f29..ff134fc9 100644 --- a/tests/units/test_task_results.py +++ b/tests/units/test_task_results.py @@ -1,8 +1,9 @@ -import pytest from unittest.mock import Mock -from pytfe.resources.task_result import TaskResults +import pytest + from pytfe.models.task_result import TaskResult +from pytfe.resources.task_result import TaskResults class TestTaskResults: @@ -58,9 +59,7 @@ def test_missing_data(self, service, mock_transport): def test_missing_attributes(self, service, mock_transport): response = Mock() - response.json.return_value = { - "data": {"id": "tr-123"} - } + response.json.return_value = {"data": {"id": "tr-123"}} mock_transport.request.return_value = response @@ -112,9 +111,7 @@ def test_timestamps_parsing(self, service, mock_transport): "attributes": { "status": "passed", "message": "ok", - "status-timestamps": { - "passed-at": "2024-01-01T00:00:00Z" - }, + "status-timestamps": {"passed-at": "2024-01-01T00:00:00Z"}, }, } } @@ -123,4 +120,4 @@ def test_timestamps_parsing(self, service, mock_transport): result = service.read("tr-123") - assert result.status_timestamps is not None \ No newline at end of file + assert result.status_timestamps is not None From 180db0d6bc133e1ed6b8731914c6c13be21e191a Mon Sep 17 00:00:00 2001 From: Tanya Singh Date: Mon, 18 May 2026 12:28:40 +0530 Subject: [PATCH 3/4] fix: map task stage relationship in task result resource --- examples/task_result.py | 1 + src/pytfe/models/task_result.py | 47 ++++++++++++++++++++++++++---- src/pytfe/resources/task_result.py | 25 +++++++++++++++- 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/examples/task_result.py b/examples/task_result.py index c24619b8..ca229213 100644 --- a/examples/task_result.py +++ b/examples/task_result.py @@ -26,6 +26,7 @@ def main(): print(f"Message: {result.message}") print(f"Task Name: {result.task_name}") print(f"URL: {result.url}") + print(f"Task Stage: {result.task_stage.id if result.task_stage else None}") except Exception as e: print(f"Error: {e}") diff --git a/src/pytfe/models/task_result.py b/src/pytfe/models/task_result.py index 516da0aa..8ba41e07 100644 --- a/src/pytfe/models/task_result.py +++ b/src/pytfe/models/task_result.py @@ -5,11 +5,14 @@ from datetime import datetime from enum import Enum +from typing import TYPE_CHECKING, Any from pydantic import BaseModel, ConfigDict, Field -# Reuse, do NOT duplicate -from pytfe.models.task_stage import TaskStage +if TYPE_CHECKING: + # Imported only for type checking to avoid a circular import: + # task_stage.py already imports TaskResult. + from pytfe.models.task_stage import TaskStage class TaskResultStatus(str, Enum): @@ -45,7 +48,8 @@ class TaskResult(BaseModel): message: str | None = Field(None, alias="message") status_timestamps: TaskResultStatusTimestamps | None = Field( - None, alias="status-timestamps" + None, + alias="status-timestamps", ) url: str | None = Field(None, alias="url") @@ -58,11 +62,44 @@ class TaskResult(BaseModel): task_url: str | None = Field(None, alias="task-url") workspace_task_id: str | None = Field(None, alias="workspace-task-id") + workspace_task_enforcement_level: TaskEnforcementLevel | None = Field( - None, alias="workspace-task-enforcement-level" + None, + alias="workspace-task-enforcement-level", ) agent_pool_id: str | None = Field(None, alias="agent-pool-id") - # Relation (matches Go: *TaskStage) + # Relationships + # Forward-referenced to avoid circular import; resolved lazily below. task_stage: TaskStage | None = Field(None, alias="task-stage") + run: dict | None = None + workspace: dict | None = None + + policy_evaluations: list[dict] | None = None + + @classmethod + def model_validate(cls, *args: Any, **kwargs: Any) -> TaskResult: + # Ensure the TaskStage forward reference is resolved before validating. + # The import-time rebuild may run while task_stage.py is still + # partially loaded (circular import), in which case we retry here. + if not getattr(cls, "__pydantic_complete__", True): + _rebuild_task_result_model() + return super().model_validate(*args, **kwargs) + + +def _rebuild_task_result_model() -> None: + # Resolve the TaskStage forward reference once both modules are loaded. + try: + from pytfe.models.task_stage import TaskStage + + TaskResult.model_rebuild( + raise_errors=False, + _types_namespace={"TaskStage": TaskStage}, + ) + except Exception: + # TaskStage not yet importable during partial init; safe to skip. + pass + + +_rebuild_task_result_model() diff --git a/src/pytfe/resources/task_result.py b/src/pytfe/resources/task_result.py index 7640d018..b2ba1d75 100644 --- a/src/pytfe/resources/task_result.py +++ b/src/pytfe/resources/task_result.py @@ -4,6 +4,7 @@ from typing import Any from pytfe.models.task_result import TaskResult +from pytfe.models.task_stage import TaskStage from pytfe.utils import valid_string_id from ._base import _Service @@ -29,4 +30,26 @@ def _parse_task_result(self, data: dict[str, Any]) -> TaskResult: attributes["id"] = data.get("id") - return TaskResult(**attributes) + relationships = data.get("relationships", {}) + + # Map task-stage relationship into the TaskStage SDK model so callers + # get a typed object rather than a raw {id, type} dict. + if "task-stage" in relationships: + task_stage_data = relationships["task-stage"].get("data") + if task_stage_data: + attributes["task_stage"] = TaskStage.model_validate(task_stage_data) + else: + attributes["task_stage"] = None + + if "run" in relationships: + attributes["run"] = relationships["run"].get("data") + + if "workspace" in relationships: + attributes["workspace"] = relationships["workspace"].get("data") + + if "policy-evaluations" in relationships: + attributes["policy_evaluations"] = relationships["policy-evaluations"].get( + "data" + ) + + return TaskResult.model_validate(attributes) From edd52d7e6d6c5ca730afa8034a818b18e5f0b082 Mon Sep 17 00:00:00 2001 From: Tanya Singh Date: Fri, 22 May 2026 14:44:21 +0530 Subject: [PATCH 4/4] fix: map task result relationships into typed SDK models --- src/pytfe/models/__init__.py | 28 +++++ src/pytfe/models/task_result.py | 32 ++++-- src/pytfe/resources/task_result.py | 62 +++++++---- tests/units/test_task_results.py | 162 +++++++++++++++++++++++++++++ 4 files changed, 254 insertions(+), 30 deletions(-) diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index d2e8648c..293cf51c 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -334,6 +334,17 @@ StateVersionOutput, StateVersionOutputsListOptions, ) + +# ── Task Result ─────────────────────────────────────────────────────────────── +from .task_result import ( + TaskEnforcementLevel as TaskResultEnforcementLevel, +) +from .task_result import ( + TaskResult, + TaskResultStatus, + TaskResultStatusTimestamps, +) +from .task_stage import TaskStage from .team import ( OrganizationAccess, Team, @@ -654,6 +665,12 @@ "RunTaskCreateOptions", "RunTaskUpdateOptions", "RunTaskReadOptions", + # Task Result + "TaskResult", + "TaskResultEnforcementLevel", + "TaskResultStatus", + "TaskResultStatusTimestamps", + "TaskStage", # Run triggers "RunTrigger", "RunTriggerCreateOptions", @@ -741,3 +758,14 @@ RegistryProvider.model_rebuild() RegistryProviderVersion.model_rebuild() RegistryProviderPlatform.model_rebuild() + +# Rebuild TaskResult to resolve Run, Workspace, PolicyEvaluation, TaskStage refs +TaskResult.model_rebuild( + raise_errors=False, + _types_namespace={ + "PolicyEvaluation": PolicyEvaluation, + "Run": Run, + "TaskStage": TaskStage, + "Workspace": Workspace, + }, +) diff --git a/src/pytfe/models/task_result.py b/src/pytfe/models/task_result.py index 8ba41e07..588dc9b7 100644 --- a/src/pytfe/models/task_result.py +++ b/src/pytfe/models/task_result.py @@ -10,9 +10,11 @@ from pydantic import BaseModel, ConfigDict, Field if TYPE_CHECKING: - # Imported only for type checking to avoid a circular import: - # task_stage.py already imports TaskResult. + # Imported only for type checking to avoid circular imports. + from pytfe.models.policy_evaluation import PolicyEvaluation + from pytfe.models.run import Run from pytfe.models.task_stage import TaskStage + from pytfe.models.workspace import Workspace class TaskResultStatus(str, Enum): @@ -71,12 +73,14 @@ class TaskResult(BaseModel): agent_pool_id: str | None = Field(None, alias="agent-pool-id") # Relationships - # Forward-referenced to avoid circular import; resolved lazily below. + # Forward-referenced to avoid circular imports; resolved lazily below. task_stage: TaskStage | None = Field(None, alias="task-stage") - run: dict | None = None - workspace: dict | None = None - - policy_evaluations: list[dict] | None = None + run: Run | None = Field(None, alias="run") + workspace: Workspace | None = Field(None, alias="workspace") + policy_evaluations: list[PolicyEvaluation] | None = Field( + None, + alias="policy-evaluations", + ) @classmethod def model_validate(cls, *args: Any, **kwargs: Any) -> TaskResult: @@ -89,16 +93,24 @@ def model_validate(cls, *args: Any, **kwargs: Any) -> TaskResult: def _rebuild_task_result_model() -> None: - # Resolve the TaskStage forward reference once both modules are loaded. + # Resolve all forward references once all modules are loaded. try: + from pytfe.models.policy_evaluation import PolicyEvaluation + from pytfe.models.run import Run from pytfe.models.task_stage import TaskStage + from pytfe.models.workspace import Workspace TaskResult.model_rebuild( raise_errors=False, - _types_namespace={"TaskStage": TaskStage}, + _types_namespace={ + "PolicyEvaluation": PolicyEvaluation, + "Run": Run, + "TaskStage": TaskStage, + "Workspace": Workspace, + }, ) except Exception: - # TaskStage not yet importable during partial init; safe to skip. + # One or more models not yet importable during partial init; safe to skip. pass diff --git a/src/pytfe/resources/task_result.py b/src/pytfe/resources/task_result.py index b2ba1d75..ed6a7b61 100644 --- a/src/pytfe/resources/task_result.py +++ b/src/pytfe/resources/task_result.py @@ -3,8 +3,11 @@ from typing import Any +from pytfe.models.policy_evaluation import PolicyEvaluation +from pytfe.models.run import Run from pytfe.models.task_result import TaskResult from pytfe.models.task_stage import TaskStage +from pytfe.models.workspace import Workspace from pytfe.utils import valid_string_id from ._base import _Service @@ -26,30 +29,49 @@ def read(self, task_result_id: str) -> TaskResult: return self._parse_task_result(data["data"]) def _parse_task_result(self, data: dict[str, Any]) -> TaskResult: - attributes = data.get("attributes", {}) + # Ensure forward references in TaskResult are resolved before use. + TaskResult.model_rebuild( + raise_errors=False, + _types_namespace={ + "PolicyEvaluation": PolicyEvaluation, + "Run": Run, + "TaskStage": TaskStage, + "Workspace": Workspace, + }, + ) + attributes = data.get("attributes", {}) attributes["id"] = data.get("id") relationships = data.get("relationships", {}) - # Map task-stage relationship into the TaskStage SDK model so callers - # get a typed object rather than a raw {id, type} dict. - if "task-stage" in relationships: - task_stage_data = relationships["task-stage"].get("data") - if task_stage_data: - attributes["task_stage"] = TaskStage.model_validate(task_stage_data) - else: - attributes["task_stage"] = None - - if "run" in relationships: - attributes["run"] = relationships["run"].get("data") - - if "workspace" in relationships: - attributes["workspace"] = relationships["workspace"].get("data") - - if "policy-evaluations" in relationships: - attributes["policy_evaluations"] = relationships["policy-evaluations"].get( - "data" - ) + # Map task-stage relationship into the TaskStage SDK model. + task_stage_data = relationships.get("task-stage", {}).get("data") + if task_stage_data: + attributes["task-stage"] = TaskStage.model_validate(task_stage_data) + else: + attributes["task-stage"] = None + + # Map run relationship into the Run SDK model. + run_data = relationships.get("run", {}).get("data") + if run_data: + attributes["run"] = Run.model_validate(run_data) + else: + attributes["run"] = None + + # Map workspace relationship into the Workspace SDK model. + workspace_data = relationships.get("workspace", {}).get("data") + if workspace_data: + attributes["workspace"] = Workspace.model_validate(workspace_data) + else: + attributes["workspace"] = None + + # Map policy-evaluations relationship into a list of PolicyEvaluation models. + policy_evaluations_data = relationships.get("policy-evaluations", {}).get( + "data", [] + ) + attributes["policy-evaluations"] = [ + PolicyEvaluation.model_validate(pe) for pe in policy_evaluations_data + ] return TaskResult.model_validate(attributes) diff --git a/tests/units/test_task_results.py b/tests/units/test_task_results.py index ff134fc9..13d0ba98 100644 --- a/tests/units/test_task_results.py +++ b/tests/units/test_task_results.py @@ -2,7 +2,11 @@ import pytest +from pytfe.models.policy_evaluation import PolicyEvaluation +from pytfe.models.run import Run from pytfe.models.task_result import TaskResult +from pytfe.models.task_stage import TaskStage +from pytfe.models.workspace import Workspace from pytfe.resources.task_result import TaskResults @@ -121,3 +125,161 @@ def test_timestamps_parsing(self, service, mock_transport): result = service.read("tr-123") assert result.status_timestamps is not None + + # ── Relationship mapping tests ───────────────────────────────────────────── + + def test_task_stage_relationship_mapped(self, service, mock_transport): + response = Mock() + response.json.return_value = { + "data": { + "id": "tr-123", + "attributes": {"status": "passed"}, + "relationships": { + "task-stage": {"data": {"id": "ts-456", "type": "task-stages"}} + }, + } + } + mock_transport.request.return_value = response + + result = service.read("tr-123") + + assert isinstance(result.task_stage, TaskStage) + assert result.task_stage.id == "ts-456" + + def test_task_stage_relationship_null(self, service, mock_transport): + response = Mock() + response.json.return_value = { + "data": { + "id": "tr-123", + "attributes": {"status": "passed"}, + "relationships": {"task-stage": {"data": None}}, + } + } + mock_transport.request.return_value = response + + result = service.read("tr-123") + + assert result.task_stage is None + + def test_run_relationship_mapped(self, service, mock_transport): + response = Mock() + response.json.return_value = { + "data": { + "id": "tr-123", + "attributes": {"status": "passed"}, + "relationships": {"run": {"data": {"id": "run-789", "type": "runs"}}}, + } + } + mock_transport.request.return_value = response + + result = service.read("tr-123") + + assert isinstance(result.run, Run) + assert result.run.id == "run-789" + + def test_run_relationship_null(self, service, mock_transport): + response = Mock() + response.json.return_value = { + "data": { + "id": "tr-123", + "attributes": {"status": "passed"}, + "relationships": {"run": {"data": None}}, + } + } + mock_transport.request.return_value = response + + result = service.read("tr-123") + + assert result.run is None + + def test_workspace_relationship_mapped(self, service, mock_transport): + response = Mock() + response.json.return_value = { + "data": { + "id": "tr-123", + "attributes": {"status": "passed"}, + "relationships": { + "workspace": {"data": {"id": "ws-abc", "type": "workspaces"}} + }, + } + } + mock_transport.request.return_value = response + + result = service.read("tr-123") + + assert isinstance(result.workspace, Workspace) + assert result.workspace.id == "ws-abc" + + def test_workspace_relationship_null(self, service, mock_transport): + response = Mock() + response.json.return_value = { + "data": { + "id": "tr-123", + "attributes": {"status": "passed"}, + "relationships": {"workspace": {"data": None}}, + } + } + mock_transport.request.return_value = response + + result = service.read("tr-123") + + assert result.workspace is None + + def test_policy_evaluations_relationship_mapped(self, service, mock_transport): + response = Mock() + response.json.return_value = { + "data": { + "id": "tr-123", + "attributes": {"status": "passed"}, + "relationships": { + "policy-evaluations": { + "data": [ + {"id": "pe-001", "type": "policy-evaluations"}, + {"id": "pe-002", "type": "policy-evaluations"}, + ] + } + }, + } + } + mock_transport.request.return_value = response + + result = service.read("tr-123") + + assert isinstance(result.policy_evaluations, list) + assert len(result.policy_evaluations) == 2 + assert all(isinstance(pe, PolicyEvaluation) for pe in result.policy_evaluations) + assert result.policy_evaluations[0].id == "pe-001" + assert result.policy_evaluations[1].id == "pe-002" + + def test_policy_evaluations_relationship_empty(self, service, mock_transport): + response = Mock() + response.json.return_value = { + "data": { + "id": "tr-123", + "attributes": {"status": "passed"}, + "relationships": {"policy-evaluations": {"data": []}}, + } + } + mock_transport.request.return_value = response + + result = service.read("tr-123") + + assert result.policy_evaluations == [] + + def test_no_relationships_key(self, service, mock_transport): + """When 'relationships' is absent, all relationship fields stay None.""" + response = Mock() + response.json.return_value = { + "data": { + "id": "tr-123", + "attributes": {"status": "passed"}, + } + } + mock_transport.request.return_value = response + + result = service.read("tr-123") + + assert result.task_stage is None + assert result.run is None + assert result.workspace is None + assert result.policy_evaluations == []