From 5fd26cbdc6d1e0eccc8da1d33ebfeee248f2f659 Mon Sep 17 00:00:00 2001 From: Tanya Singh Date: Fri, 8 May 2026 13:42:26 +0530 Subject: [PATCH 01/13] Add TaskStage API support with models, resource, tests and examples --- examples/task_stage_example.py | 38 ++++++ src/pytfe/client.py | 2 + src/pytfe/models/task_result.py | 60 ++++++++++ src/pytfe/models/task_stage.py | 70 +++++++++++- src/pytfe/resources/task_stage.py | 70 ++++++++++++ tests/test_task_stage.py | 184 ++++++++++++++++++++++++++++++ 6 files changed, 422 insertions(+), 2 deletions(-) create mode 100644 examples/task_stage_example.py create mode 100644 src/pytfe/models/task_result.py create mode 100644 src/pytfe/resources/task_stage.py create mode 100644 tests/test_task_stage.py diff --git a/examples/task_stage_example.py b/examples/task_stage_example.py new file mode 100644 index 00000000..3e552d57 --- /dev/null +++ b/examples/task_stage_example.py @@ -0,0 +1,38 @@ +""" +Example usage of TaskStages API + +This demonstrates how to: +- Read a task stage +- List task stages for a run +- Override a task stage +""" + +from pytfe.client import TFEClient + +# Initialize client (make sure your auth/env is configured) +client = TFEClient() + +# --------------------------- +# Read a task stage +# --------------------------- +# Fetch a single task stage by ID +# Replace "ts-123" with a real task stage ID +stage = client.task_stages.read("ts-abc123xyz") +print(stage) + + +# --------------------------- +# List task stages for a run +# --------------------------- +# Fetch all task stages for a run +# Replace "run-123" with a real run ID +# for stage in client.task_stages.list("run-123"): +# print(stage) + + +# --------------------------- +# Override a task stage +# --------------------------- +# Override a task stage (if allowed) +# Replace "ts-123" with a real task stage ID +# client.task_stages.override("ts-123", comment="Approved") \ No newline at end of file diff --git a/src/pytfe/client.py b/src/pytfe/client.py index 0e88e1ab..5d44a071 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -34,6 +34,7 @@ from .resources.run_event import RunEvents from .resources.run_task import RunTasks from .resources.run_trigger import RunTriggers +from .resources.task_stage import TaskStages from .resources.ssh_keys import SSHKeys from .resources.state_version_outputs import StateVersionOutputs from .resources.state_versions import StateVersions @@ -94,6 +95,7 @@ def __init__(self, config: TFEConfig | None = None): self.run_tasks = RunTasks(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.policies = Policies(self._transport) diff --git a/src/pytfe/models/task_result.py b/src/pytfe/models/task_result.py new file mode 100644 index 00000000..06edf2ff --- /dev/null +++ b/src/pytfe/models/task_result.py @@ -0,0 +1,60 @@ +# 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, TYPE_CHECKING + +from pydantic import BaseModel, ConfigDict, Field + +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, validate_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, validate_by_name=True) + + id: str + status: TaskResultStatus = Field(..., alias="status") + message: str = Field(..., alias="message") + + status_timestamps: TaskResultStatusTimestamps = Field(..., alias="status-timestamps") + + url: str = Field(..., alias="url") + + created_at: datetime = Field(..., alias="created-at") + updated_at: datetime = Field(..., alias="updated-at") + + task_id: str = Field(..., alias="task-id") + task_name: str = Field(..., alias="task-name") + task_url: str = Field(..., alias="task-url") + + workspace_task_id: str = Field(..., alias="workspace-task-id") + workspace_task_enforcement_level: TaskEnforcementLevel = Field( + ..., alias="workspace-task-enforcement-level" + ) + + agent_pool_id: Optional[str] = Field(None, alias="agent-pool-id") + task_stage: Optional[dict] = Field(None, alias="task-stage") diff --git a/src/pytfe/models/task_stage.py b/src/pytfe/models/task_stage.py index 54b9346b..908c31a4 100644 --- a/src/pytfe/models/task_stage.py +++ b/src/pytfe/models/task_stage.py @@ -3,14 +3,59 @@ from __future__ import annotations -from pydantic import BaseModel, ConfigDict +from datetime import datetime +from enum import Enum +from typing import List, Optional, TYPE_CHECKING +from pydantic import BaseModel, ConfigDict, Field -# TaskStage represents a HCP Terraform or Terraform Enterprise run's stage where run tasks can occur +if TYPE_CHECKING: + from pytfe.models.task_result import TaskResult + from pytfe.models.policy_evaluation import PolicyEvaluation + +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: 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 Permissions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + can_override_policy: Optional[bool] = Field(None, alias="can-override-policy") + can_override_tasks: Optional[bool] = Field(None, alias="can-override-tasks") + can_override: Optional[bool] = Field(None, alias="can-override") + +class Actions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + is_overridable: Optional[bool] = Field(None, alias="is-overridable") + +# TaskStage represents a HCP Terraform or Terraform Enterprise run's stage class TaskStage(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) id: str + # stage: Stage = Field(..., alias="stage") # status: TaskStageStatus = Field(..., alias="status") # status_timestamps: TaskStageStatusTimestamps = Field(..., alias="status-timestamps") @@ -19,7 +64,28 @@ class TaskStage(BaseModel): # permissions: Permissions = Field(..., alias="permissions") # actions: Actions = Field(..., alias="actions") + 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: Optional[Permissions] = Field(None, alias="permissions") + actions: Optional[Actions] = Field(None, alias="actions") + # # Relations # run: Run = Field(..., alias="run") # task_results: list[TaskResult] = Field(..., alias="task-results") # policy_evaluations: list[PolicyEvaluation] = Field(..., alias="policy-evaluations") + + run: Optional[dict] = Field(None, alias="run") + task_results: Optional[List["TaskResult"]] = Field(None, alias="task-results") + policy_evaluations: Optional[List["PolicyEvaluation"]] = Field( + None, alias="policy-evaluations" + ) + +from pytfe.models.task_result import TaskResult +from pytfe.models.policy_evaluation import PolicyEvaluation + +TaskStage.model_rebuild() \ No newline at end of file diff --git a/src/pytfe/resources/task_stage.py b/src/pytfe/resources/task_stage.py new file mode 100644 index 00000000..ce301e92 --- /dev/null +++ b/src/pytfe/resources/task_stage.py @@ -0,0 +1,70 @@ +# 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 +from ..models.task_stage import TaskStage +from ..utils import _safe_str, valid_string_id +from ._base import _Service + + +class TaskStages(_Service): + """TaskStages provides access to task stage endpoints.""" + + # Read + def read(self, task_stage_id: str) -> TaskStage: + if not valid_string_id(task_stage_id): + raise ValueError("Invalid task_stage_id") + + response = self.t.request( + "GET", + f"/api/v2/task-stages/{task_stage_id}", + ) + + data = response.json().get("data", {}) + attributes = data.get("attributes", {}) + attributes["id"] = _safe_str(data.get("id")) + + return TaskStage.model_validate(attributes) + + # List + def list(self, run_id: str) -> Iterator[TaskStage]: + if not valid_string_id(run_id): + raise InvalidRunIDError() + + path = f"/api/v2/runs/{run_id}/task-stages" + + for item in self._list(path): + attributes = item.get("attributes", {}) + attributes["id"] = item.get("id") + + yield TaskStage.model_validate(attributes) + + # Override + def override( + self, + task_stage_id: str, + comment: str | None = None, + ) -> TaskStage: + if not valid_string_id(task_stage_id): + raise ValueError("Invalid task_stage_id") + + 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", {}) + attributes = data.get("attributes", {}) + attributes["id"] = _safe_str(data.get("id")) + + return TaskStage.model_validate(attributes) \ No newline at end of file diff --git a/tests/test_task_stage.py b/tests/test_task_stage.py new file mode 100644 index 00000000..7339ce0c --- /dev/null +++ b/tests/test_task_stage.py @@ -0,0 +1,184 @@ +import pytest + +from pytfe.client import TFEClient +from pytfe.resources.task_stage import TaskStages +from pytfe.models.task_stage import TaskStage +from pytfe.errors import InvalidRunIDError + + +# --------------------------- +# 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") + + +# --------------------------- +# Read method tests +# --------------------------- + +def test_read_raises_error_when_id_missing(): + client = TFEClient() + + with pytest.raises(ValueError): + 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", + ) + + +# --------------------------- +# 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(ValueError): + 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"}, + ) \ No newline at end of file From 35ef47cf5048173f7cb3a7220c08183d4910b9d1 Mon Sep 17 00:00:00 2001 From: Tanya Singh Date: Fri, 8 May 2026 14:15:47 +0530 Subject: [PATCH 02/13] fixed lint issue --- examples/task_stage_example.py | 2 +- src/pytfe/client.py | 2 +- src/pytfe/models/task_result.py | 20 ++++++------ src/pytfe/models/task_stage.py | 53 +++++++++++++++++++------------ src/pytfe/resources/task_stage.py | 6 ++-- tests/test_task_stage.py | 40 ++++++++++++----------- 6 files changed, 69 insertions(+), 54 deletions(-) diff --git a/examples/task_stage_example.py b/examples/task_stage_example.py index 3e552d57..c1de48c5 100644 --- a/examples/task_stage_example.py +++ b/examples/task_stage_example.py @@ -35,4 +35,4 @@ # --------------------------- # Override a task stage (if allowed) # Replace "ts-123" with a real task stage ID -# client.task_stages.override("ts-123", comment="Approved") \ No newline at end of file +# client.task_stages.override("ts-123", comment="Approved") diff --git a/src/pytfe/client.py b/src/pytfe/client.py index 5d44a071..a7c928dd 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -34,10 +34,10 @@ from .resources.run_event import RunEvents from .resources.run_task import RunTasks from .resources.run_trigger import RunTriggers -from .resources.task_stage import TaskStages from .resources.ssh_keys import SSHKeys from .resources.state_version_outputs import StateVersionOutputs from .resources.state_versions import StateVersions +from .resources.task_stage import TaskStages from .resources.user import Users from .resources.variable import Variables from .resources.variable_sets import VariableSets, VariableSetVariables diff --git a/src/pytfe/models/task_result.py b/src/pytfe/models/task_result.py index 06edf2ff..4179e67a 100644 --- a/src/pytfe/models/task_result.py +++ b/src/pytfe/models/task_result.py @@ -5,10 +5,10 @@ from datetime import datetime from enum import Enum -from typing import Optional, TYPE_CHECKING from pydantic import BaseModel, ConfigDict, Field + class TaskResultStatus(str, Enum): passed = "passed" failed = "failed" @@ -26,11 +26,11 @@ class TaskEnforcementLevel(str, Enum): class TaskResultStatusTimestamps(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_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): @@ -40,7 +40,9 @@ class TaskResult(BaseModel): status: TaskResultStatus = Field(..., alias="status") message: str = Field(..., alias="message") - status_timestamps: TaskResultStatusTimestamps = Field(..., alias="status-timestamps") + status_timestamps: TaskResultStatusTimestamps = Field( + ..., alias="status-timestamps" + ) url: str = Field(..., alias="url") @@ -56,5 +58,5 @@ class TaskResult(BaseModel): ..., alias="workspace-task-enforcement-level" ) - agent_pool_id: Optional[str] = Field(None, alias="agent-pool-id") - task_stage: Optional[dict] = Field(None, alias="task-stage") + agent_pool_id: str | None = Field(None, alias="agent-pool-id") + task_stage: dict | None = Field(None, alias="task-stage") diff --git a/src/pytfe/models/task_stage.py b/src/pytfe/models/task_stage.py index 908c31a4..4c245b26 100644 --- a/src/pytfe/models/task_stage.py +++ b/src/pytfe/models/task_stage.py @@ -5,13 +5,14 @@ from datetime import datetime from enum import Enum -from typing import List, Optional, TYPE_CHECKING +from typing import TYPE_CHECKING from pydantic import BaseModel, ConfigDict, Field if TYPE_CHECKING: - from pytfe.models.task_result import TaskResult from pytfe.models.policy_evaluation import PolicyEvaluation + from pytfe.models.task_result import TaskResult + class Stage(str, Enum): pre_plan = "pre_plan" @@ -19,6 +20,7 @@ class Stage(str, Enum): pre_apply = "pre_apply" post_apply = "post_apply" + class TaskStageStatus(str, Enum): pending = "pending" running = "running" @@ -29,26 +31,30 @@ class TaskStageStatus(str, Enum): errored = "errored" unreachable = "unreachable" + class TaskStageStatusTimestamps(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_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 Permissions(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) - can_override_policy: Optional[bool] = Field(None, alias="can-override-policy") - can_override_tasks: Optional[bool] = Field(None, alias="can-override-tasks") - can_override: Optional[bool] = Field(None, alias="can-override") + 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: Optional[bool] = Field(None, alias="is-overridable") + is_overridable: bool | None = Field(None, alias="is-overridable") + # TaskStage represents a HCP Terraform or Terraform Enterprise run's stage class TaskStage(BaseModel): @@ -71,21 +77,28 @@ class TaskStage(BaseModel): created_at: datetime = Field(..., alias="created-at") updated_at: datetime = Field(..., alias="updated-at") - permissions: Optional[Permissions] = Field(None, alias="permissions") - actions: Optional[Actions] = Field(None, alias="actions") + permissions: Permissions | None = Field(None, alias="permissions") + actions: Actions | None = Field(None, alias="actions") # # Relations # run: Run = Field(..., alias="run") # task_results: list[TaskResult] = Field(..., alias="task-results") # policy_evaluations: list[PolicyEvaluation] = Field(..., alias="policy-evaluations") - - run: Optional[dict] = Field(None, alias="run") - task_results: Optional[List["TaskResult"]] = Field(None, alias="task-results") - policy_evaluations: Optional[List["PolicyEvaluation"]] = Field( + + run: dict | 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" ) -from pytfe.models.task_result import TaskResult -from pytfe.models.policy_evaluation import PolicyEvaluation -TaskStage.model_rebuild() \ No newline at end of file +def _rebuild_task_stage_model() -> None: + global TaskResult, PolicyEvaluation + + from pytfe.models.policy_evaluation import PolicyEvaluation + from pytfe.models.task_result import TaskResult + + TaskStage.model_rebuild() + + +_rebuild_task_stage_model() diff --git a/src/pytfe/resources/task_stage.py b/src/pytfe/resources/task_stage.py index ce301e92..e1b25905 100644 --- a/src/pytfe/resources/task_stage.py +++ b/src/pytfe/resources/task_stage.py @@ -53,9 +53,7 @@ def override( if not valid_string_id(task_stage_id): raise ValueError("Invalid task_stage_id") - body: dict[str, Any] | None = ( - {"comment": comment} if comment else None - ) + body: dict[str, Any] | None = {"comment": comment} if comment else None response = self.t.request( "POST", @@ -67,4 +65,4 @@ def override( attributes = data.get("attributes", {}) attributes["id"] = _safe_str(data.get("id")) - return TaskStage.model_validate(attributes) \ No newline at end of file + return TaskStage.model_validate(attributes) diff --git a/tests/test_task_stage.py b/tests/test_task_stage.py index 7339ce0c..56a54d0a 100644 --- a/tests/test_task_stage.py +++ b/tests/test_task_stage.py @@ -1,15 +1,14 @@ import pytest from pytfe.client import TFEClient -from pytfe.resources.task_stage import TaskStages from pytfe.models.task_stage import TaskStage -from pytfe.errors import InvalidRunIDError - +from pytfe.resources.task_stage import TaskStages # --------------------------- # Basic existence tests # --------------------------- + def test_task_stage_service_exists(): client = TFEClient() assert hasattr(client, "task_stages") @@ -27,6 +26,7 @@ def test_task_stage_methods_exist(): # Read method tests # --------------------------- + def test_read_raises_error_when_id_missing(): client = TFEClient() @@ -69,6 +69,7 @@ def test_read_calls_request_correctly(mocker): # List method tests # --------------------------- + def test_list_with_valid_id_does_not_raise(mocker): mock_transport = mocker.Mock() @@ -86,33 +87,34 @@ def test_list_calls_internal_list(mocker): 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", - }, - } - ]) + 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" - ) + 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() @@ -181,4 +183,4 @@ def test_override_calls_request_with_comment(mocker): "POST", "/api/v2/task-stages/ts-123/actions/override", json_body={"comment": "approved"}, - ) \ No newline at end of file + ) From 09d33c702272d07cdf170b74d26478b328480bfe Mon Sep 17 00:00:00 2001 From: Tanya Singh Date: Thu, 14 May 2026 13:22:50 +0530 Subject: [PATCH 03/13] fix: clean task stage model and update example to follow SDK patterns --- examples/task_stage_example.py | 68 ++++++++++++++++++++++------------ src/pytfe/models/task_stage.py | 13 ------- 2 files changed, 44 insertions(+), 37 deletions(-) diff --git a/examples/task_stage_example.py b/examples/task_stage_example.py index c1de48c5..52393a67 100644 --- a/examples/task_stage_example.py +++ b/examples/task_stage_example.py @@ -1,38 +1,58 @@ """ Example usage of TaskStages API -This demonstrates how to: +Demonstrates: - Read a task stage - List task stages for a run - Override a task stage """ -from pytfe.client import TFEClient +import os +import sys -# Initialize client (make sure your auth/env is configured) -client = TFEClient() +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) -# --------------------------- -# Read a task stage -# --------------------------- -# Fetch a single task stage by ID -# Replace "ts-123" with a real task stage ID -stage = client.task_stages.read("ts-abc123xyz") -print(stage) +from pytfe import TFEClient, TFEConfig -# --------------------------- -# List task stages for a run -# --------------------------- -# Fetch all task stages for a run -# Replace "run-123" with a real run ID -# for stage in client.task_stages.list("run-123"): -# print(stage) +def main(): + client = TFEClient(TFEConfig.from_env()) + # Read from environment variables (NO hardcoding) + task_stage_id = os.getenv("TFE_TASK_STAGE_ID") + run_id = os.getenv("TFE_RUN_ID") -# --------------------------- -# Override a task stage -# --------------------------- -# Override a task stage (if allowed) -# Replace "ts-123" with a real task stage ID -# client.task_stages.override("ts-123", comment="Approved") + 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}, Status: {stage.status}") + 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/models/task_stage.py b/src/pytfe/models/task_stage.py index 4c245b26..aa525500 100644 --- a/src/pytfe/models/task_stage.py +++ b/src/pytfe/models/task_stage.py @@ -62,14 +62,6 @@ class TaskStage(BaseModel): 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") - stage: Stage = Field(..., alias="stage") status: TaskStageStatus = Field(..., alias="status") status_timestamps: TaskStageStatusTimestamps = Field(..., alias="status-timestamps") @@ -80,11 +72,6 @@ class TaskStage(BaseModel): permissions: Permissions | None = Field(None, alias="permissions") actions: Actions | None = Field(None, alias="actions") - # # Relations - # run: Run = Field(..., alias="run") - # task_results: list[TaskResult] = Field(..., alias="task-results") - # policy_evaluations: list[PolicyEvaluation] = Field(..., alias="policy-evaluations") - run: dict | None = Field(None, alias="run") task_results: list[TaskResult] | None = Field(None, alias="task-results") policy_evaluations: list[PolicyEvaluation] | None = Field( From 68a29e1f1072e98ab21d02809e62b7ec0e0ad2ee Mon Sep 17 00:00:00 2001 From: Tanya Singh Date: Thu, 14 May 2026 14:24:45 +0530 Subject: [PATCH 04/13] fix: map task stage relationships in parser --- src/pytfe/resources/task_stage.py | 35 ++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/src/pytfe/resources/task_stage.py b/src/pytfe/resources/task_stage.py index e1b25905..d54442cd 100644 --- a/src/pytfe/resources/task_stage.py +++ b/src/pytfe/resources/task_stage.py @@ -15,6 +15,28 @@ 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") + attributes["run"] = run_data + + task_results_data = relationships.get( + "task-results", {} + ).get("data", []) + attributes["task-results"] = task_results_data + + policy_evaluations_data = relationships.get( + "policy-evaluations", {} + ).get("data", []) + attributes["policy-evaluations"] = 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): @@ -26,10 +48,8 @@ def read(self, task_stage_id: str) -> TaskStage: ) data = response.json().get("data", {}) - attributes = data.get("attributes", {}) - attributes["id"] = _safe_str(data.get("id")) - return TaskStage.model_validate(attributes) + return self._parse_task_stage(data) # List def list(self, run_id: str) -> Iterator[TaskStage]: @@ -39,10 +59,7 @@ def list(self, run_id: str) -> Iterator[TaskStage]: path = f"/api/v2/runs/{run_id}/task-stages" for item in self._list(path): - attributes = item.get("attributes", {}) - attributes["id"] = item.get("id") - - yield TaskStage.model_validate(attributes) + yield self._parse_task_stage(item) # Override def override( @@ -62,7 +79,5 @@ def override( ) data = response.json().get("data", {}) - attributes = data.get("attributes", {}) - attributes["id"] = _safe_str(data.get("id")) - return TaskStage.model_validate(attributes) + return self._parse_task_stage(data) \ No newline at end of file From 63e65740c7b3be39d8facd73a2c81cdd118db8e9 Mon Sep 17 00:00:00 2001 From: Tanya Singh Date: Thu, 14 May 2026 14:28:09 +0530 Subject: [PATCH 05/13] fix: resolve lint issues in task stage api --- src/pytfe/resources/task_stage.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/pytfe/resources/task_stage.py b/src/pytfe/resources/task_stage.py index d54442cd..59117428 100644 --- a/src/pytfe/resources/task_stage.py +++ b/src/pytfe/resources/task_stage.py @@ -25,14 +25,12 @@ def _parse_task_stage(self, data: dict[str, Any]) -> TaskStage: run_data = relationships.get("run", {}).get("data") attributes["run"] = run_data - task_results_data = relationships.get( - "task-results", {} - ).get("data", []) + task_results_data = relationships.get("task-results", {}).get("data", []) attributes["task-results"] = task_results_data - policy_evaluations_data = relationships.get( - "policy-evaluations", {} - ).get("data", []) + policy_evaluations_data = relationships.get("policy-evaluations", {}).get( + "data", [] + ) attributes["policy-evaluations"] = policy_evaluations_data return TaskStage.model_validate(attributes) @@ -80,4 +78,4 @@ def override( data = response.json().get("data", {}) - return self._parse_task_stage(data) \ No newline at end of file + return self._parse_task_stage(data) From da684e0e4c0553b57b1edf88806cd02fb7bf3dfc Mon Sep 17 00:00:00 2001 From: Tanya Singh Date: Fri, 15 May 2026 12:20:06 +0530 Subject: [PATCH 06/13] fix: resolve task stage relationship parsing --- examples/task_stage_example.py | 5 ++- src/pytfe/models/task_result.py | 29 +++++++------- src/pytfe/models/task_stage.py | 63 +++++++++++++++++++++++-------- src/pytfe/resources/task_stage.py | 37 +++++++++++++++--- 4 files changed, 97 insertions(+), 37 deletions(-) diff --git a/examples/task_stage_example.py b/examples/task_stage_example.py index 52393a67..a10eb29d 100644 --- a/examples/task_stage_example.py +++ b/examples/task_stage_example.py @@ -32,7 +32,7 @@ def main(): print("\nReading task stage...") try: stage = client.task_stages.read(task_stage_id) - print(f"ID: {stage.id}, Status: {stage.status}") + print(stage) except Exception as e: print(f"Read failed: {e}") @@ -41,7 +41,8 @@ def main(): try: stages = list(client.task_stages.list(run_id)) for s in stages: - print(f"{s.id} - {s.status}") + print(s) + print("-" * 80) except Exception as e: print(f"List failed: {e}") diff --git a/src/pytfe/models/task_result.py b/src/pytfe/models/task_result.py index 4179e67a..576fac08 100644 --- a/src/pytfe/models/task_result.py +++ b/src/pytfe/models/task_result.py @@ -36,26 +36,29 @@ class TaskResultStatusTimestamps(BaseModel): class TaskResult(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + # All non-id fields are optional so JSON:API relationship references + # ({id, type} only) can be hydrated as TaskResult stubs without raising + # "field required" validation errors. id: str - status: TaskResultStatus = Field(..., alias="status") - message: str = Field(..., alias="message") + status: TaskResultStatus | None = Field(None, alias="status") + message: str | None = Field(None, alias="message") - status_timestamps: TaskResultStatusTimestamps = Field( - ..., alias="status-timestamps" + status_timestamps: TaskResultStatusTimestamps | None = Field( + None, alias="status-timestamps" ) - url: str = Field(..., alias="url") + url: str | None = Field(None, alias="url") - created_at: datetime = Field(..., alias="created-at") - updated_at: datetime = Field(..., alias="updated-at") + created_at: datetime | None = Field(None, alias="created-at") + updated_at: datetime | None = Field(None, alias="updated-at") - task_id: str = Field(..., alias="task-id") - task_name: str = Field(..., alias="task-name") - task_url: str = Field(..., 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: str = Field(..., alias="workspace-task-id") - workspace_task_enforcement_level: TaskEnforcementLevel = Field( - ..., alias="workspace-task-enforcement-level" + 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: str | None = Field(None, alias="agent-pool-id") diff --git a/src/pytfe/models/task_stage.py b/src/pytfe/models/task_stage.py index aa525500..0a4ec12b 100644 --- a/src/pytfe/models/task_stage.py +++ b/src/pytfe/models/task_stage.py @@ -9,9 +9,11 @@ from pydantic import BaseModel, ConfigDict, Field +from pytfe.models.policy_evaluation import PolicyEvaluation +from pytfe.models.task_result import TaskResult + if TYPE_CHECKING: - from pytfe.models.policy_evaluation import PolicyEvaluation - from pytfe.models.task_result import TaskResult + from pytfe.models.run import Run class Stage(str, Enum): @@ -56,36 +58,65 @@ class Actions(BaseModel): is_overridable: bool | None = Field(None, alias="is-overridable") -# TaskStage represents a HCP Terraform or Terraform Enterprise run's stage class TaskStage(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) id: str stage: Stage = Field(..., alias="stage") - status: TaskStageStatus = Field(..., alias="status") - status_timestamps: TaskStageStatusTimestamps = Field(..., alias="status-timestamps") + + 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 | None = Field(None, alias="permissions") - actions: Actions | None = Field(None, alias="actions") + permissions: Permissions | None = Field( + None, + alias="permissions", + ) - run: dict | 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" + actions: Actions | None = Field( + None, + alias="actions", ) + # Relationships + run: Run | None = Field( + None, + alias="run", + ) -def _rebuild_task_stage_model() -> None: - global TaskResult, PolicyEvaluation + task_results: list[TaskResult] | None = Field( + None, + alias="task-results", + ) - from pytfe.models.policy_evaluation import PolicyEvaluation - from pytfe.models.task_result import TaskResult + policy_evaluations: list[PolicyEvaluation] | None = Field( + None, + alias="policy-evaluations", + ) - TaskStage.model_rebuild() + +def _rebuild_task_stage_model() -> None: + # Do not import Run here: run.py imports TaskStage, so importing Run during + # TaskStage module initialization creates a circular import on Python 3.14. + TaskStage.model_rebuild( + # Leave unresolved cyclic refs (Run) for later resolution while still + # resolving non-cyclic refs needed during import-time schema generation. + raise_errors=False, + _types_namespace={ + "TaskResult": TaskResult, + "PolicyEvaluation": PolicyEvaluation, + }, + ) _rebuild_task_stage_model() diff --git a/src/pytfe/resources/task_stage.py b/src/pytfe/resources/task_stage.py index 59117428..db6603f5 100644 --- a/src/pytfe/resources/task_stage.py +++ b/src/pytfe/resources/task_stage.py @@ -7,6 +7,9 @@ from typing import Any from ..errors import InvalidRunIDError +from ..models.policy_evaluation import PolicyEvaluation +from ..models.run import Run +from ..models.task_result import TaskResult from ..models.task_stage import TaskStage from ..utils import _safe_str, valid_string_id from ._base import _Service @@ -16,6 +19,13 @@ class TaskStages(_Service): """TaskStages provides access to task stage endpoints.""" def _parse_task_stage(self, data: dict[str, Any]) -> TaskStage: + # TaskStage defers Run resolution to avoid model import-time cycles. + # Rebuild here where Run is already imported and fully available. + TaskStage.model_rebuild( + raise_errors=False, + _types_namespace={"Run": Run}, + ) + attributes = data.get("attributes", {}) attributes["id"] = _safe_str(data.get("id")) @@ -23,15 +33,30 @@ def _parse_task_stage(self, data: dict[str, Any]) -> TaskStage: relationships = data.get("relationships", {}) run_data = relationships.get("run", {}).get("data") - attributes["run"] = run_data + if run_data: + attributes["run"] = Run.model_validate(run_data) - task_results_data = relationships.get("task-results", {}).get("data", []) - attributes["task-results"] = task_results_data + task_results_data = relationships.get("task-results", {}).get( + "data", + [], + ) - policy_evaluations_data = relationships.get("policy-evaluations", {}).get( - "data", [] + attributes["task-results"] = [ + TaskResult.model_validate(task_result) for task_result in task_results_data + ] + + policy_evaluations_data = relationships.get( + "policy-evaluations", + {}, + ).get( + "data", + [], ) - attributes["policy-evaluations"] = policy_evaluations_data + + attributes["policy-evaluations"] = [ + PolicyEvaluation.model_validate(policy_evaluation) + for policy_evaluation in policy_evaluations_data + ] return TaskStage.model_validate(attributes) From 640e47663747287f4070dbc2ae954d0871568223 Mon Sep 17 00:00:00 2001 From: Tanya Singh Date: Fri, 15 May 2026 12:54:31 +0530 Subject: [PATCH 07/13] fix: improve task stage relationship modeling --- examples/task_stage_example.py | 8 +++++--- src/pytfe/models/task_result.py | 35 ++++++++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/examples/task_stage_example.py b/examples/task_stage_example.py index a10eb29d..2bf15615 100644 --- a/examples/task_stage_example.py +++ b/examples/task_stage_example.py @@ -32,7 +32,10 @@ def main(): print("\nReading task stage...") try: stage = client.task_stages.read(task_stage_id) - print(stage) + 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}") @@ -41,8 +44,7 @@ def main(): try: stages = list(client.task_stages.list(run_id)) for s in stages: - print(s) - print("-" * 80) + print(f"{s.id} - {s.status}") except Exception as e: print(f"List failed: {e}") diff --git a/src/pytfe/models/task_result.py b/src/pytfe/models/task_result.py index 576fac08..d4375939 100644 --- a/src/pytfe/models/task_result.py +++ b/src/pytfe/models/task_result.py @@ -5,9 +5,15 @@ from datetime import datetime from enum import Enum +from typing import TYPE_CHECKING, Any 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. + from pytfe.models.task_stage import TaskStage + class TaskResultStatus(str, Enum): passed = "passed" @@ -62,4 +68,31 @@ class TaskResult(BaseModel): ) agent_pool_id: str | None = Field(None, alias="agent-pool-id") - task_stage: dict | None = Field(None, alias="task-stage") + # Forward-referenced to avoid circular import; resolved lazily below. + task_stage: TaskStage | None = Field(None, alias="task-stage") + + @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() From 24e69f9c64aff3d3a0af5c411aa1a2913621d407 Mon Sep 17 00:00:00 2001 From: Tanya Singh Date: Fri, 15 May 2026 13:13:13 +0530 Subject: [PATCH 08/13] refactor: simplify task stage relationship handling --- examples/task_stage_example.py | 1 - src/pytfe/models/task_result.py | 11 ----------- src/pytfe/models/task_stage.py | 4 ---- src/pytfe/resources/task_stage.py | 2 -- 4 files changed, 18 deletions(-) diff --git a/examples/task_stage_example.py b/examples/task_stage_example.py index 2bf15615..d16f6373 100644 --- a/examples/task_stage_example.py +++ b/examples/task_stage_example.py @@ -18,7 +18,6 @@ def main(): client = TFEClient(TFEConfig.from_env()) - # Read from environment variables (NO hardcoding) task_stage_id = os.getenv("TFE_TASK_STAGE_ID") run_id = os.getenv("TFE_RUN_ID") diff --git a/src/pytfe/models/task_result.py b/src/pytfe/models/task_result.py index d4375939..506994bc 100644 --- a/src/pytfe/models/task_result.py +++ b/src/pytfe/models/task_result.py @@ -10,8 +10,6 @@ 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. from pytfe.models.task_stage import TaskStage @@ -42,9 +40,6 @@ class TaskResultStatusTimestamps(BaseModel): class TaskResult(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) - # All non-id fields are optional so JSON:API relationship references - # ({id, type} only) can be hydrated as TaskResult stubs without raising - # "field required" validation errors. id: str status: TaskResultStatus | None = Field(None, alias="status") message: str | None = Field(None, alias="message") @@ -68,21 +63,16 @@ class TaskResult(BaseModel): ) agent_pool_id: str | None = Field(None, alias="agent-pool-id") - # Forward-referenced to avoid circular import; resolved lazily below. task_stage: TaskStage | None = Field(None, alias="task-stage") @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 @@ -91,7 +81,6 @@ def _rebuild_task_result_model() -> None: _types_namespace={"TaskStage": TaskStage}, ) except Exception: - # TaskStage not yet importable during partial init; safe to skip. pass diff --git a/src/pytfe/models/task_stage.py b/src/pytfe/models/task_stage.py index 0a4ec12b..bb528313 100644 --- a/src/pytfe/models/task_stage.py +++ b/src/pytfe/models/task_stage.py @@ -106,11 +106,7 @@ class TaskStage(BaseModel): def _rebuild_task_stage_model() -> None: - # Do not import Run here: run.py imports TaskStage, so importing Run during - # TaskStage module initialization creates a circular import on Python 3.14. TaskStage.model_rebuild( - # Leave unresolved cyclic refs (Run) for later resolution while still - # resolving non-cyclic refs needed during import-time schema generation. raise_errors=False, _types_namespace={ "TaskResult": TaskResult, diff --git a/src/pytfe/resources/task_stage.py b/src/pytfe/resources/task_stage.py index db6603f5..0eb628c2 100644 --- a/src/pytfe/resources/task_stage.py +++ b/src/pytfe/resources/task_stage.py @@ -19,8 +19,6 @@ class TaskStages(_Service): """TaskStages provides access to task stage endpoints.""" def _parse_task_stage(self, data: dict[str, Any]) -> TaskStage: - # TaskStage defers Run resolution to avoid model import-time cycles. - # Rebuild here where Run is already imported and fully available. TaskStage.model_rebuild( raise_errors=False, _types_namespace={"Run": Run}, From 2c90b0fc183f97dfc9023d094071b33a8b436998 Mon Sep 17 00:00:00 2001 From: Tanya Singh Date: Fri, 22 May 2026 12:00:03 +0530 Subject: [PATCH 09/13] Export TaskStage and TaskResult models --- src/pytfe/models/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index d2e8648c..31d200c5 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -334,6 +334,10 @@ StateVersionOutput, StateVersionOutputsListOptions, ) + +# ── Task Stage & Task Result ───────────────────────────────────────────────── +from .task_result import TaskResult +from .task_stage import TaskStage from .team import ( OrganizationAccess, Team, @@ -642,6 +646,9 @@ "RunEventList", "RunEventListOptions", "RunEventReadOptions", + # Task Stage & Task Result + "TaskStage", + "TaskResult", # Run tasks "RunTask", "RunTaskIncludeOptions", From 004e5c3ab8320cbe8f4787e54b6d76c80637885b Mon Sep 17 00:00:00 2001 From: Tanya Singh Date: Fri, 22 May 2026 13:08:37 +0530 Subject: [PATCH 10/13] Fix TaskStage reviewer feedback and validation handling --- src/pytfe/models/__init__.py | 8 ++ src/pytfe/models/task_stage.py | 46 ++------ src/pytfe/resources/task_stage.py | 12 +- tests/test_task_stage.py | 179 ++++++++++++++++++++++++++++-- 4 files changed, 195 insertions(+), 50 deletions(-) diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index a4d20281..a12babc1 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -776,3 +776,11 @@ RegistryProvider.model_rebuild() RegistryProviderVersion.model_rebuild() RegistryProviderPlatform.model_rebuild() +TaskStage.model_rebuild( + raise_errors=False, + _types_namespace={ + "Run": Run, + "TaskResult": TaskResult, + "PolicyEvaluation": PolicyEvaluation, + }, +) diff --git a/src/pytfe/models/task_stage.py b/src/pytfe/models/task_stage.py index bb528313..9af01bcb 100644 --- a/src/pytfe/models/task_stage.py +++ b/src/pytfe/models/task_stage.py @@ -63,45 +63,21 @@ class TaskStage(BaseModel): 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 | None = Field( - None, - alias="permissions", - ) - - actions: Actions | None = Field( - None, - alias="actions", + 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", - ) - + 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", + None, alias="policy-evaluations" ) diff --git a/src/pytfe/resources/task_stage.py b/src/pytfe/resources/task_stage.py index 0eb628c2..553dc398 100644 --- a/src/pytfe/resources/task_stage.py +++ b/src/pytfe/resources/task_stage.py @@ -6,7 +6,7 @@ from collections.abc import Iterator from typing import Any -from ..errors import InvalidRunIDError +from ..errors import InvalidRunIDError, InvalidTaskStageIDError from ..models.policy_evaluation import PolicyEvaluation from ..models.run import Run from ..models.task_result import TaskResult @@ -21,7 +21,11 @@ class TaskStages(_Service): def _parse_task_stage(self, data: dict[str, Any]) -> TaskStage: TaskStage.model_rebuild( raise_errors=False, - _types_namespace={"Run": Run}, + _types_namespace={ + "Run": Run, + "TaskResult": TaskResult, + "PolicyEvaluation": PolicyEvaluation, + }, ) attributes = data.get("attributes", {}) @@ -61,7 +65,7 @@ def _parse_task_stage(self, data: dict[str, Any]) -> TaskStage: # Read def read(self, task_stage_id: str) -> TaskStage: if not valid_string_id(task_stage_id): - raise ValueError("Invalid task_stage_id") + raise InvalidTaskStageIDError() response = self.t.request( "GET", @@ -89,7 +93,7 @@ def override( comment: str | None = None, ) -> TaskStage: if not valid_string_id(task_stage_id): - raise ValueError("Invalid task_stage_id") + raise InvalidTaskStageIDError() body: dict[str, Any] | None = {"comment": comment} if comment else None diff --git a/tests/test_task_stage.py b/tests/test_task_stage.py index 56a54d0a..8e7fc738 100644 --- a/tests/test_task_stage.py +++ b/tests/test_task_stage.py @@ -1,12 +1,15 @@ import pytest from pytfe.client import TFEClient -from pytfe.models.task_stage import TaskStage +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(): @@ -22,15 +25,86 @@ def test_task_stage_methods_exist(): 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(ValueError): + with pytest.raises(InvalidTaskStageIDError): client.task_stages.read("") @@ -65,9 +139,22 @@ def test_read_calls_request_correctly(mocker): ) -# --------------------------- +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): @@ -110,15 +197,13 @@ def test_list_calls_internal_list(mocker): 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(ValueError): + with pytest.raises(InvalidTaskStageIDError): client.task_stages.override("") @@ -184,3 +269,75 @@ def test_override_calls_request_with_comment(mocker): "/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 == [] From e6e6574f1634a5e96185cf500c9c4a5b5768ca99 Mon Sep 17 00:00:00 2001 From: Tanya Singh Date: Fri, 22 May 2026 13:28:24 +0530 Subject: [PATCH 11/13] Update task stage validation error handling --- src/pytfe/errors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pytfe/errors.py b/src/pytfe/errors.py index 30bdaabd..f6c0f4e0 100644 --- a/src/pytfe/errors.py +++ b/src/pytfe/errors.py @@ -472,7 +472,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) From bb421c9d8c70dc26353b67abdb431dedc0b4cb13 Mon Sep 17 00:00:00 2001 From: Tanya Singh Date: Fri, 22 May 2026 20:21:57 +0530 Subject: [PATCH 12/13] Fix TaskStage forward reference rebuild handling --- src/pytfe/models/task_stage.py | 20 +++++++++++++------- src/pytfe/resources/task_stage.py | 9 --------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/src/pytfe/models/task_stage.py b/src/pytfe/models/task_stage.py index 9af01bcb..f6534932 100644 --- a/src/pytfe/models/task_stage.py +++ b/src/pytfe/models/task_stage.py @@ -82,13 +82,19 @@ class TaskStage(BaseModel): def _rebuild_task_stage_model() -> None: - TaskStage.model_rebuild( - raise_errors=False, - _types_namespace={ - "TaskResult": TaskResult, - "PolicyEvaluation": PolicyEvaluation, - }, - ) + try: + from pytfe.models.run import Run + + TaskStage.model_rebuild( + raise_errors=False, + _types_namespace={ + "Run": Run, + "TaskResult": TaskResult, + "PolicyEvaluation": PolicyEvaluation, + }, + ) + except Exception: + pass _rebuild_task_stage_model() diff --git a/src/pytfe/resources/task_stage.py b/src/pytfe/resources/task_stage.py index 553dc398..55720e4c 100644 --- a/src/pytfe/resources/task_stage.py +++ b/src/pytfe/resources/task_stage.py @@ -19,15 +19,6 @@ class TaskStages(_Service): """TaskStages provides access to task stage endpoints.""" def _parse_task_stage(self, data: dict[str, Any]) -> TaskStage: - TaskStage.model_rebuild( - raise_errors=False, - _types_namespace={ - "Run": Run, - "TaskResult": TaskResult, - "PolicyEvaluation": PolicyEvaluation, - }, - ) - attributes = data.get("attributes", {}) attributes["id"] = _safe_str(data.get("id")) From 817bb8e98a01b3a06b4bd91fc77b4c14ca79fc81 Mon Sep 17 00:00:00 2001 From: Tanya Singh Date: Fri, 22 May 2026 20:53:13 +0530 Subject: [PATCH 13/13] Trigger CI