diff --git a/src/pytfe/resources/run.py b/src/pytfe/resources/run.py index 4cb430c4..e13dd185 100644 --- a/src/pytfe/resources/run.py +++ b/src/pytfe/resources/run.py @@ -10,6 +10,12 @@ RequiredWorkspaceError, TerraformVersionValidForPlanOnlyError, ) +from ..models.apply import Apply +from ..models.comment import Comment +from ..models.configuration_version import ConfigurationVersion +from ..models.cost_estimate import CostEstimate +from ..models.plan import Plan +from ..models.policy_check import PolicyCheck from ..models.run import ( Run, RunApplyOptions, @@ -21,10 +27,57 @@ RunListOptions, RunReadOptions, ) +from ..models.run_event import RunEvent +from ..models.task_stage import TaskStage +from ..models.user import User +from ..models.workspace import Workspace from ..utils import _safe_str, valid_string, valid_string_id from ._base import _Service +def transform_relationships(relationships: dict) -> Any: + """ + Transform relationships dict to map relationship names to their model objects. + Single IDs become model instances, multiple IDs become lists of model instances. + """ + result = {} + + # Map relationship keys to their model constructors + model_map = { + "apply": Apply, + "configuration-version": ConfigurationVersion, + "cost-estimate": CostEstimate, + "created-by": User, + "confirmed-by": User, + "plan": Plan, + "workspace": Workspace, + "policy-checks": PolicyCheck, + "run-events": RunEvent, + "task-stages": TaskStage, + "comments": Comment, + } + + for key, value in relationships.items(): + data = value.get("data") + + if data is None: + continue + + model_class = model_map.get(key) + if not model_class: + # Unknown relationship type, skip it + continue + + if isinstance(data, list): + # Multiple entries - create list of model instances + result[key] = [model_class(id=item["id"]) for item in data if "id" in item] + elif isinstance(data, dict) and "id" in data: + # Single entry - create model instance + result[key] = model_class(id=data["id"]) + + return result + + class Runs(_Service): def list( self, workspace_id: str, options: RunListOptions | None = None @@ -94,10 +147,11 @@ def create(self, options: RunCreateOptions) -> Run: ) d = r.json().get("data", {}) attrs = d.get("attributes", {}) - return Run( - id=_safe_str(d.get("id")), - **{k.replace("-", "_"): v for k, v in attrs.items()}, - ) + relationships = transform_relationships(d.get("relationships", {})) + combined = { + k.replace("-", "_"): v for k, v in {**attrs, **relationships}.items() + } + return Run(id=_safe_str(d.get("id")), **combined) def read(self, run_id: str) -> Run: """Read a run by its ID.""" @@ -119,10 +173,11 @@ def read_with_options( ) d = r.json().get("data", {}) attrs = d.get("attributes", {}) - return Run( - id=_safe_str(d.get("id")), - **{k.replace("-", "_"): v for k, v in attrs.items()}, - ) + relationships = transform_relationships(d.get("relationships", {})) + combined = { + k.replace("-", "_"): v for k, v in {**attrs, **relationships}.items() + } + return Run(id=_safe_str(d.get("id")), **combined) def apply(self, run_id: str, options: RunApplyOptions | None = None) -> None: """Apply a run by its ID.""" diff --git a/tests/units/test_run.py b/tests/units/test_run.py index fa7ad87e..16d87e30 100644 --- a/tests/units/test_run.py +++ b/tests/units/test_run.py @@ -294,14 +294,113 @@ def test_read_with_options_success(self, runs_service): mock_response_data = { "data": { "id": "run-detailed-123", + "type": "runs", "attributes": { - "status": "planned", - "source": "tfe-api", - "message": "Detailed read test", - "created-at": "2023-01-01T12:00:00Z", + "actions": { + "is-cancelable": False, + "is-confirmable": False, + "is-discardable": False, + "is-force-cancelable": False, + }, + "allow-config-generation": False, + "allow-empty-apply": False, + "auto-apply": False, + "canceled-at": None, + "created-at": "2026-02-19T01:58:46.126Z", "has-changes": True, "is-destroy": False, + "message": "Triggered via CLI", + "plan-only": False, + "refresh": True, + "refresh-only": False, + "replace-addrs": None, + "save-plan": False, + "source": "terraform+cloud", + "status-timestamps": { + "errored-at": "2026-02-19T01:59:19+00:00", + "planned-at": "2026-02-19T01:59:16+00:00", + "queuing-at": "2026-02-19T01:58:46+00:00", + "planning-at": "2026-02-19T01:58:48+00:00", + "plan-queued-at": "2026-02-19T01:58:46+00:00", + "plan-queueable-at": "2026-02-19T01:58:46+00:00", + }, + "status": "errored", + "target-addrs": None, + "trigger-reason": "manual", + "terraform-version": "1.13.5", + "updated-at": "2026-02-19T01:59:19.891Z", + "permissions": { + "can-apply": True, + "can-cancel": True, + "can-comment": True, + "can-discard": True, + "can-force-execute": True, + "can-force-cancel": True, + "can-override-policy-check": True, + }, + "variables": [], + "invoke-action-addrs": None, + }, + "relationships": { + "workspace": { + "data": {"id": "ws-a2Kntu53K79hsPRH", "type": "workspaces"} + }, + "apply": { + "data": {"id": "apply-Y1rVt6MpiwzdMjbK", "type": "applies"}, + "links": {"related": "/api/v2/runs/run-detailed-123/apply"}, + }, + "configuration-version": { + "data": { + "id": "cv-bakH4hn9cPXb2yZq", + "type": "configuration-versions", + }, + "links": { + "related": "/api/v2/runs/run-detailed-123/configuration-version" + }, + }, + "created-by": { + "data": {"id": "user-FRJGnNMX6fpe9Cdd", "type": "users"}, + "links": { + "related": "/api/v2/runs/run-detailed-123/created-by" + }, + }, + "plan": { + "data": {"id": "plan-WooDdHWZnSE3Zs8j", "type": "plans"}, + "links": {"related": "/api/v2/runs/run-detailed-123/plan"}, + }, + "run-events": { + "data": [ + {"id": "re-bqJGaaCrt5QZfexJ", "type": "run-events"}, + {"id": "re-j8d6eWyfyHSUbX7x", "type": "run-events"}, + {"id": "re-UAXd9VyRTXZy3hpx", "type": "run-events"}, + {"id": "re-DFFf51Doi8mmHC9G", "type": "run-events"}, + {"id": "re-U2m4RMQhEY9voN1K", "type": "run-events"}, + {"id": "re-WWfUbu5NTWdYKgBs", "type": "run-events"}, + ], + "links": { + "related": "/api/v2/runs/run-detailed-123/run-events" + }, + }, + "task-stages": { + "data": [], + "links": { + "related": "/api/v2/runs/run-detailed-123/task-stages" + }, + }, + "policy-checks": { + "data": [ + {"id": "polchk-JxgtJ56kFifnngyT", "type": "policy-checks"} + ], + "links": { + "related": "/api/v2/runs/run-detailed-123/policy-checks" + }, + }, + "comments": { + "data": [], + "links": {"related": "/api/v2/runs/run-detailed-123/comments"}, + }, }, + "links": {"self": "/api/v2/runs/run-detailed-123"}, } } @@ -330,6 +429,10 @@ def test_read_with_options_success(self, runs_service): # Verify result assert isinstance(result, Run) assert result.id == "run-detailed-123" + assert result.created_by.id == "user-FRJGnNMX6fpe9Cdd" + assert result.plan.id == "plan-WooDdHWZnSE3Zs8j" + assert result.apply.id == "apply-Y1rVt6MpiwzdMjbK" + assert result.workspace.id == "ws-a2Kntu53K79hsPRH" def test_apply_run_success(self, runs_service): """Test successful apply operation."""