diff --git a/examples/task_stage_example.py b/examples/task_stage_example.py new file mode 100644 index 00000000..d16f6373 --- /dev/null +++ b/examples/task_stage_example.py @@ -0,0 +1,60 @@ +""" +Example usage of TaskStages API + +Demonstrates: +- Read a task stage +- List task stages for a run +- Override a task stage +""" + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +from pytfe import TFEClient, TFEConfig + + +def main(): + client = TFEClient(TFEConfig.from_env()) + + task_stage_id = os.getenv("TFE_TASK_STAGE_ID") + run_id = os.getenv("TFE_RUN_ID") + + if not task_stage_id or not run_id: + print("Please set TFE_TASK_STAGE_ID and TFE_RUN_ID") + return + + print("=== TaskStages Example ===") + + # READ + print("\nReading task stage...") + try: + stage = client.task_stages.read(task_stage_id) + print(f"ID: {stage.id}") + print(f"Stage: {stage.stage}") + print(f"Status: {stage.status}") + print(f"Run: {stage.run.id if stage.run else None}") + except Exception as e: + print(f"Read failed: {e}") + + # LIST + print("\nListing task stages...") + try: + stages = list(client.task_stages.list(run_id)) + for s in stages: + print(f"{s.id} - {s.status}") + except Exception as e: + print(f"List failed: {e}") + + # OVERRIDE + print("\nOverriding task stage...") + try: + client.task_stages.override(task_stage_id, comment="Approved") + print("Override successful") + except Exception as e: + print(f"Override failed: {e}") + + +if __name__ == "__main__": + main() diff --git a/src/pytfe/client.py b/src/pytfe/client.py index 409a36c3..8f3fd53d 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -45,6 +45,7 @@ from .resources.state_version_outputs import StateVersionOutputs from .resources.state_versions import StateVersions from .resources.task_result import TaskResults +from .resources.task_stage import TaskStages from .resources.team import Teams from .resources.team_project_access import TeamProjectAccesses from .resources.team_token import TeamTokens @@ -119,6 +120,7 @@ def __init__(self, config: TFEConfig | None = None): self.run_task_integrations = RunTaskIntegrations(self._transport) self.run_triggers = RunTriggers(self._transport) self.runs = Runs(self._transport) + self.task_stages = TaskStages(self._transport) self.query_runs = QueryRuns(self._transport) self.run_events = RunEvents(self._transport) self.comments = Comments(self._transport) diff --git a/src/pytfe/errors.py b/src/pytfe/errors.py index 4cfa6013..5f1ebd84 100644 --- a/src/pytfe/errors.py +++ b/src/pytfe/errors.py @@ -490,7 +490,7 @@ def __init__(self, message: str = "must provide at least one policy"): # Policy Evaluation errors class InvalidTaskStageIDError(InvalidValues): - """Raised when an invalid task stage ID is provided.""" + """Raised when a task stage ID is invalid.""" def __init__(self, message: str = "invalid value for task stage ID"): super().__init__(message) diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index f7cda576..9156572b 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -371,6 +371,8 @@ from .task_result import ( TaskEnforcementLevel as TaskResultEnforcementLevel, ) + +# ── Task Stage & Task Result ───────────────────────────────────────────────── from .task_result import ( TaskResult, TaskResultStatus, @@ -713,6 +715,9 @@ "RunEventList", "RunEventListOptions", "RunEventReadOptions", + # Task Stage & Task Result + "TaskStage", + "TaskResult", # Comments "Comment", "CommentCreateOptions", @@ -826,14 +831,21 @@ RegistryProvider.model_rebuild() RegistryProviderVersion.model_rebuild() RegistryProviderPlatform.model_rebuild() - -# Rebuild TaskResult to resolve Run, Workspace, PolicyEvaluation, TaskStage refs -TaskResult.model_rebuild( +Run.model_rebuild( + raise_errors=False, + _types_namespace={"TaskStage": TaskStage}, +) +TaskStage.model_rebuild( raise_errors=False, _types_namespace={ - "PolicyEvaluation": PolicyEvaluation, "Run": Run, + "TaskResult": TaskResult, + "PolicyEvaluation": PolicyEvaluation, + }, +) +TaskResult.model_rebuild( + raise_errors=False, + _types_namespace={ "TaskStage": TaskStage, - "Workspace": Workspace, }, ) diff --git a/src/pytfe/models/run.py b/src/pytfe/models/run.py index 01185e89..5a82f7fa 100644 --- a/src/pytfe/models/run.py +++ b/src/pytfe/models/run.py @@ -5,6 +5,7 @@ from datetime import datetime from enum import Enum +from typing import TYPE_CHECKING from pydantic import BaseModel, ConfigDict, Field @@ -15,10 +16,12 @@ from .plan import Plan from .policy_check import PolicyCheck from .run_event import RunEvent -from .task_stage import TaskStage from .user import User from .workspace import Workspace +if TYPE_CHECKING: + from .task_stage import TaskStage + class RunSource(str, Enum): """RunSource represents a source type of a run.""" @@ -327,6 +330,6 @@ class RunDiscardOptions(BaseModel): # Rebuild models to resolve forward references -Run.model_rebuild() -RunList.model_rebuild() -OrganizationRunList.model_rebuild() +Run.model_rebuild(raise_errors=False) +RunList.model_rebuild(raise_errors=False) +OrganizationRunList.model_rebuild(raise_errors=False) diff --git a/src/pytfe/models/task_result.py b/src/pytfe/models/task_result.py index 588dc9b7..3e2af561 100644 --- a/src/pytfe/models/task_result.py +++ b/src/pytfe/models/task_result.py @@ -5,16 +5,12 @@ from datetime import datetime from enum import Enum -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from pydantic import BaseModel, ConfigDict, Field if TYPE_CHECKING: - # 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): @@ -32,7 +28,7 @@ class TaskEnforcementLevel(str, Enum): class TaskResultStatusTimestamps(BaseModel): - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) errored_at: datetime | None = Field(None, alias="errored-at") running_at: datetime | None = Field(None, alias="running-at") @@ -42,7 +38,7 @@ class TaskResultStatusTimestamps(BaseModel): class TaskResult(BaseModel): - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) id: str @@ -50,8 +46,7 @@ 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") @@ -64,54 +59,10 @@ 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") - - # Relationships - # Forward-referenced to avoid circular imports; resolved lazily below. + # relations task_stage: TaskStage | None = Field(None, alias="task-stage") - 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: - # 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 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={ - "PolicyEvaluation": PolicyEvaluation, - "Run": Run, - "TaskStage": TaskStage, - "Workspace": Workspace, - }, - ) - except Exception: - # One or more models not yet importable during partial init; safe to skip. - pass - - -_rebuild_task_result_model() diff --git a/src/pytfe/models/task_stage.py b/src/pytfe/models/task_stage.py index bae0766b..a5f338ef 100644 --- a/src/pytfe/models/task_stage.py +++ b/src/pytfe/models/task_stage.py @@ -3,23 +3,82 @@ from __future__ import annotations -from pydantic import BaseModel, ConfigDict +from datetime import datetime +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field + +from pytfe.models.policy_evaluation import PolicyEvaluation +from pytfe.models.run import Run +from pytfe.models.task_result import TaskResult + + +class Stage(str, Enum): + pre_plan = "pre_plan" + post_plan = "post_plan" + pre_apply = "pre_apply" + post_apply = "post_apply" + + +class TaskStageStatus(str, Enum): + pending = "pending" + running = "running" + passed = "passed" + failed = "failed" + awaiting_override = "awaiting_override" + canceled = "canceled" + errored = "errored" + unreachable = "unreachable" + + +class TaskStageStatusTimestamps(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + 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 Permissions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + can_override_policy: bool | None = Field(None, alias="can-override-policy") + can_override_tasks: bool | None = Field(None, alias="can-override-tasks") + can_override: bool | None = Field(None, alias="can-override") + + +class Actions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + is_overridable: bool | None = Field(None, alias="is-overridable") -# 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) id: str - # stage: Stage = Field(..., alias="stage") - # status: TaskStageStatus = Field(..., alias="status") - # status_timestamps: TaskStageStatusTimestamps = Field(..., alias="status-timestamps") - # created_at: datetime = Field(..., alias="created-at") - # updated_at: datetime = Field(..., alias="updated-at") - # permissions: Permissions = Field(..., alias="permissions") - # actions: Actions = Field(..., alias="actions") - - # # Relations - # run: Run = Field(..., alias="run") - # task_results: list[TaskResult] = Field(..., alias="task-results") - # policy_evaluations: list[PolicyEvaluation] = Field(..., alias="policy-evaluations") + + stage: Stage | None = Field(None, alias="stage") + status: TaskStageStatus | None = Field(None, alias="status") + status_timestamps: TaskStageStatusTimestamps | None = Field( + None, alias="status-timestamps" + ) + created_at: datetime | None = Field(None, alias="created-at") + updated_at: datetime | None = Field(None, alias="updated-at") + permissions: Permissions | None = Field(None, alias="permissions") + actions: Actions | None = Field(None, alias="actions") + + # Relationships + run: Run | None = Field(None, alias="run") + task_results: list[TaskResult] | None = Field(None, alias="task-results") + policy_evaluations: list[PolicyEvaluation] | None = Field( + None, alias="policy-evaluations" + ) + + +class TaskStageListOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + page_size: int | None = Field(None, alias="page[size]") diff --git a/src/pytfe/models/workspace.py b/src/pytfe/models/workspace.py index e0be77a7..ee067f43 100644 --- a/src/pytfe/models/workspace.py +++ b/src/pytfe/models/workspace.py @@ -526,6 +526,7 @@ def _rebuild_workspace_model() -> None: """Rebuild Workspace model to resolve forward references.""" try: from .run import Run # noqa: F401 + from .task_stage import TaskStage # noqa: F401 Workspace.model_rebuild() except ImportError: diff --git a/src/pytfe/resources/task_result.py b/src/pytfe/resources/task_result.py index ed6a7b61..737008f0 100644 --- a/src/pytfe/resources/task_result.py +++ b/src/pytfe/resources/task_result.py @@ -3,11 +3,8 @@ 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 @@ -21,57 +18,22 @@ def read(self, task_result_id: str) -> TaskResult: path = f"/api/v2/task-results/{task_result_id}" response = self.t.request("GET", path) - data = response.json() + data = response.json().get("data", {}) - if "data" not in data: - raise ValueError("Invalid response format") - - return self._parse_task_result(data["data"]) + return self._parse_task_result(data) def _parse_task_result(self, data: dict[str, Any]) -> TaskResult: - # 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. + # Map task-stage relationship into the TaskStage 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 - ] + attributes["task-stage"] = TaskStage.model_construct( + id=task_stage_data["id"] + ) return TaskResult.model_validate(attributes) diff --git a/src/pytfe/resources/task_stage.py b/src/pytfe/resources/task_stage.py new file mode 100644 index 00000000..526c035e --- /dev/null +++ b/src/pytfe/resources/task_stage.py @@ -0,0 +1,107 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +from ..errors import InvalidRunIDError, InvalidTaskStageIDError +from ..models.policy_evaluation import PolicyEvaluation +from ..models.run import Run +from ..models.task_result import TaskResult +from ..models.task_stage import TaskStage, TaskStageListOptions +from ..utils import _safe_str, valid_string_id +from ._base import _Service + + +class TaskStages(_Service): + """TaskStages provides access to task stage endpoints.""" + + def _parse_task_stage(self, data: dict[str, Any]) -> TaskStage: + attributes = data.get("attributes", {}) + + attributes["id"] = _safe_str(data.get("id")) + + relationships = data.get("relationships", {}) + + run_data = relationships.get("run", {}).get("data") + if run_data: + attributes["run"] = Run.model_construct(id=run_data["id"]) + + task_results_data = relationships.get("task-results", {}).get( + "data", + [], + ) + + attributes["task-results"] = [ + TaskResult.model_construct(id=task_result["id"]) + for task_result in task_results_data + ] + + policy_evaluations_data = relationships.get( + "policy-evaluations", + {}, + ).get( + "data", + [], + ) + + attributes["policy-evaluations"] = [ + PolicyEvaluation.model_construct(id=policy_evaluation["id"]) + for policy_evaluation in policy_evaluations_data + ] + + return TaskStage.model_validate(attributes) + + # Read + def read(self, task_stage_id: str) -> TaskStage: + if not valid_string_id(task_stage_id): + raise InvalidTaskStageIDError() + + response = self.t.request( + "GET", + f"/api/v2/task-stages/{task_stage_id}", + ) + + data = response.json().get("data", {}) + + return self._parse_task_stage(data) + + # List + def list( + self, run_id: str, options: TaskStageListOptions | None = None + ) -> Iterator[TaskStage]: + if not valid_string_id(run_id): + raise InvalidRunIDError() + + path = f"/api/v2/runs/{run_id}/task-stages" + kwargs = {"params": options.model_dump(by_alias=True)} if options else {} + + for item in self._list(path, **kwargs): + yield self._parse_task_stage(item) + + # Override + def override( + self, + task_stage_id: str, + comment: str | None = None, + ) -> TaskStage: + """ + **Note: This function is still in BETA and subject to change.** + Override a task stage for a run. + """ + if not valid_string_id(task_stage_id): + raise InvalidTaskStageIDError() + + body: dict[str, Any] | None = {"comment": comment} if comment else None + + response = self.t.request( + "POST", + f"/api/v2/task-stages/{task_stage_id}/actions/override", + json_body=body, + ) + + data = response.json().get("data", {}) + + return self._parse_task_stage(data) diff --git a/tests/units/test_task_results.py b/tests/units/test_task_results.py index 13d0ba98..43f89889 100644 --- a/tests/units/test_task_results.py +++ b/tests/units/test_task_results.py @@ -2,11 +2,8 @@ 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 @@ -161,113 +158,8 @@ def test_task_stage_relationship_null(self, service, mock_transport): 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.""" + """When 'relationships' is absent, task_stage stays None.""" response = Mock() response.json.return_value = { "data": { @@ -280,6 +172,3 @@ def test_no_relationships_key(self, service, mock_transport): 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 == [] diff --git a/tests/units/test_task_stage.py b/tests/units/test_task_stage.py new file mode 100644 index 00000000..8e7fc738 --- /dev/null +++ b/tests/units/test_task_stage.py @@ -0,0 +1,343 @@ +import pytest + +from pytfe.client import TFEClient +from pytfe.errors import InvalidTaskStageIDError +from pytfe.models.task_stage import ( + Stage, + TaskStage, + TaskStageStatus, +) +from pytfe.resources.task_stage import TaskStages + +# Basic existence tests + + +def test_task_stage_service_exists(): + client = TFEClient() + assert hasattr(client, "task_stages") + + +def test_task_stage_methods_exist(): + client = TFEClient() + + assert hasattr(client.task_stages, "read") + assert hasattr(client.task_stages, "list") + assert hasattr(client.task_stages, "override") + + +# InvalidTaskStageIDError tests + + +def test_invalid_task_stage_id_error_is_raised(): + """InvalidTaskStageIDError should be raised for blank IDs.""" + client = TFEClient() + + with pytest.raises(InvalidTaskStageIDError): + client.task_stages.read("") + + with pytest.raises(InvalidTaskStageIDError): + client.task_stages.override("") + + +def test_invalid_task_stage_id_error_message(): + err = InvalidTaskStageIDError() + assert "task stage" in str(err).lower() + + +# TaskStage optional fields / stub tests + + +def test_task_stage_stub_with_only_id(): + """TaskStage should be constructable with only `id` — all other fields optional.""" + ts = TaskStage(id="ts-stub-123") + assert ts.id == "ts-stub-123" + assert ts.stage is None + assert ts.status is None + assert ts.status_timestamps is None + assert ts.created_at is None + assert ts.updated_at is None + assert ts.permissions is None + assert ts.actions is None + assert ts.run is None + assert ts.task_results is None + assert ts.policy_evaluations is None + + +def test_task_stage_partial_payload(): + """TaskStage should parse a payload with only some fields populated.""" + ts = TaskStage.model_validate( + {"id": "ts-456", "stage": "pre_plan", "status": "pending"} + ) + assert ts.id == "ts-456" + assert ts.stage == Stage.pre_plan + assert ts.status == TaskStageStatus.pending + assert ts.status_timestamps is None + assert ts.created_at is None + assert ts.run is None + + +def test_task_stage_full_payload(): + """TaskStage should parse a complete attributes payload.""" + ts = TaskStage.model_validate( + { + "id": "ts-789", + "stage": "post_plan", + "status": "passed", + "status-timestamps": {"passed-at": "2024-06-01T12:00:00Z"}, + "created-at": "2024-01-01T00:00:00Z", + "updated-at": "2024-06-01T12:00:00Z", + "permissions": {"can-override": True}, + "actions": {"is-overridable": False}, + } + ) + assert ts.stage == Stage.post_plan + assert ts.status == TaskStageStatus.passed + assert ts.permissions is not None + assert ts.permissions.can_override is True + assert ts.actions is not None + assert ts.actions.is_overridable is False + + +# Read method tests + + +def test_read_raises_error_when_id_missing(): + client = TFEClient() + + with pytest.raises(InvalidTaskStageIDError): + client.task_stages.read("") + + +def test_read_calls_request_correctly(mocker): + mock_transport = mocker.Mock() + + mock_response = mocker.Mock() + mock_response.json.return_value = { + "data": { + "id": "ts-123", + "attributes": { + "stage": "pre_plan", + "status": "pending", + "status-timestamps": {}, + "created-at": "2024-01-01T00:00:00Z", + "updated-at": "2024-01-01T00:00:00Z", + }, + } + } + + mock_transport.request.return_value = mock_response + + service = TaskStages(mock_transport) + + result = service.read("ts-123") + + assert isinstance(result, TaskStage) + + mock_transport.request.assert_called_once_with( + "GET", + "/api/v2/task-stages/ts-123", + ) + + +def test_read_stub_payload(mocker): + """read() should succeed when API returns only an id (stub/relationship payload).""" + mock_transport = mocker.Mock() + mock_response = mocker.Mock() + mock_response.json.return_value = {"data": {"id": "ts-stub-001", "attributes": {}}} + mock_transport.request.return_value = mock_response + + service = TaskStages(mock_transport) + result = service.read("ts-stub-001") + + assert isinstance(result, TaskStage) + assert result.id == "ts-stub-001" + assert result.stage is None + + +# List method tests + + +def test_list_with_valid_id_does_not_raise(mocker): + mock_transport = mocker.Mock() + + service = TaskStages(mock_transport) + + service._list = mocker.Mock(return_value=[]) + + result = list(service.list("run-123")) + + assert result == [] + + +def test_list_calls_internal_list(mocker): + mock_transport = mocker.Mock() + + service = TaskStages(mock_transport) + + service._list = mocker.Mock( + return_value=[ + { + "id": "ts-1", + "attributes": { + "stage": "pre_plan", + "status": "pending", + "status-timestamps": {}, + "created-at": "2024-01-01T00:00:00Z", + "updated-at": "2024-01-01T00:00:00Z", + }, + } + ] + ) + + result = list(service.list("run-123")) + + assert len(result) == 1 + assert isinstance(result[0], TaskStage) + + service._list.assert_called_once_with("/api/v2/runs/run-123/task-stages") + + +# Override method tests + + +def test_override_raises_error_when_id_missing(): + client = TFEClient() + + with pytest.raises(InvalidTaskStageIDError): + client.task_stages.override("") + + +def test_override_calls_request_without_comment(mocker): + mock_transport = mocker.Mock() + + mock_response = mocker.Mock() + mock_response.json.return_value = { + "data": { + "id": "ts-123", + "attributes": { + "stage": "pre_plan", + "status": "pending", + "status-timestamps": {}, + "created-at": "2024-01-01T00:00:00Z", + "updated-at": "2024-01-01T00:00:00Z", + }, + } + } + + mock_transport.request.return_value = mock_response + + service = TaskStages(mock_transport) + + result = service.override("ts-123") + + assert isinstance(result, TaskStage) + + mock_transport.request.assert_called_once_with( + "POST", + "/api/v2/task-stages/ts-123/actions/override", + json_body=None, + ) + + +def test_override_calls_request_with_comment(mocker): + mock_transport = mocker.Mock() + + mock_response = mocker.Mock() + mock_response.json.return_value = { + "data": { + "id": "ts-123", + "attributes": { + "stage": "pre_plan", + "status": "pending", + "status-timestamps": {}, + "created-at": "2024-01-01T00:00:00Z", + "updated-at": "2024-01-01T00:00:00Z", + }, + } + } + + mock_transport.request.return_value = mock_response + + service = TaskStages(mock_transport) + + result = service.override("ts-123", comment="approved") + + assert isinstance(result, TaskStage) + + mock_transport.request.assert_called_once_with( + "POST", + "/api/v2/task-stages/ts-123/actions/override", + json_body={"comment": "approved"}, + ) + + +# Relationship parsing tests + + +def test_parse_task_stage_with_run_relationship(mocker): + """_parse_task_stage should attach a Run stub from relationships.""" + mock_transport = mocker.Mock() + service = TaskStages(mock_transport) + + data = { + "id": "ts-rel-001", + "attributes": {"stage": "pre_plan", "status": "running"}, + "relationships": { + "run": {"data": {"id": "run-abc", "type": "runs"}}, + "task-results": {"data": []}, + "policy-evaluations": {"data": []}, + }, + } + + result = service._parse_task_stage(data) + + assert isinstance(result, TaskStage) + assert result.run is not None + assert result.run.id == "run-abc" + assert result.task_results == [] + assert result.policy_evaluations == [] + + +def test_parse_task_stage_with_task_results_relationship(mocker): + """_parse_task_stage should parse task-results from relationships.""" + mock_transport = mocker.Mock() + service = TaskStages(mock_transport) + + data = { + "id": "ts-rel-002", + "attributes": {}, + "relationships": { + "task-results": { + "data": [ + {"id": "tr-1", "type": "task-results"}, + {"id": "tr-2", "type": "task-results"}, + ] + }, + "policy-evaluations": {"data": []}, + }, + } + + result = service._parse_task_stage(data) + + assert result.task_results is not None + assert len(result.task_results) == 2 + assert result.task_results[0].id == "tr-1" + assert result.task_results[1].id == "tr-2" + + +def test_parse_task_stage_with_no_relationships(mocker): + """_parse_task_stage should handle missing relationships gracefully.""" + mock_transport = mocker.Mock() + service = TaskStages(mock_transport) + + data = { + "id": "ts-no-rel", + "attributes": {}, + } + + result = service._parse_task_stage(data) + + assert isinstance(result, TaskStage) + assert result.run is None + assert result.task_results == [] + assert result.policy_evaluations == []