Skip to content

Commit da684e0

Browse files
fix: resolve task stage relationship parsing
1 parent 63e6574 commit da684e0

4 files changed

Lines changed: 97 additions & 37 deletions

File tree

examples/task_stage_example.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def main():
3232
print("\nReading task stage...")
3333
try:
3434
stage = client.task_stages.read(task_stage_id)
35-
print(f"ID: {stage.id}, Status: {stage.status}")
35+
print(stage)
3636
except Exception as e:
3737
print(f"Read failed: {e}")
3838

@@ -41,7 +41,8 @@ def main():
4141
try:
4242
stages = list(client.task_stages.list(run_id))
4343
for s in stages:
44-
print(f"{s.id} - {s.status}")
44+
print(s)
45+
print("-" * 80)
4546
except Exception as e:
4647
print(f"List failed: {e}")
4748

src/pytfe/models/task_result.py

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -36,26 +36,29 @@ class TaskResultStatusTimestamps(BaseModel):
3636
class TaskResult(BaseModel):
3737
model_config = ConfigDict(populate_by_name=True, validate_by_name=True)
3838

39+
# All non-id fields are optional so JSON:API relationship references
40+
# ({id, type} only) can be hydrated as TaskResult stubs without raising
41+
# "field required" validation errors.
3942
id: str
40-
status: TaskResultStatus = Field(..., alias="status")
41-
message: str = Field(..., alias="message")
43+
status: TaskResultStatus | None = Field(None, alias="status")
44+
message: str | None = Field(None, alias="message")
4245

43-
status_timestamps: TaskResultStatusTimestamps = Field(
44-
..., alias="status-timestamps"
46+
status_timestamps: TaskResultStatusTimestamps | None = Field(
47+
None, alias="status-timestamps"
4548
)
4649

47-
url: str = Field(..., alias="url")
50+
url: str | None = Field(None, alias="url")
4851

49-
created_at: datetime = Field(..., alias="created-at")
50-
updated_at: datetime = Field(..., alias="updated-at")
52+
created_at: datetime | None = Field(None, alias="created-at")
53+
updated_at: datetime | None = Field(None, alias="updated-at")
5154

52-
task_id: str = Field(..., alias="task-id")
53-
task_name: str = Field(..., alias="task-name")
54-
task_url: str = Field(..., alias="task-url")
55+
task_id: str | None = Field(None, alias="task-id")
56+
task_name: str | None = Field(None, alias="task-name")
57+
task_url: str | None = Field(None, alias="task-url")
5558

56-
workspace_task_id: str = Field(..., alias="workspace-task-id")
57-
workspace_task_enforcement_level: TaskEnforcementLevel = Field(
58-
..., alias="workspace-task-enforcement-level"
59+
workspace_task_id: str | None = Field(None, alias="workspace-task-id")
60+
workspace_task_enforcement_level: TaskEnforcementLevel | None = Field(
61+
None, alias="workspace-task-enforcement-level"
5962
)
6063

6164
agent_pool_id: str | None = Field(None, alias="agent-pool-id")

src/pytfe/models/task_stage.py

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@
99

1010
from pydantic import BaseModel, ConfigDict, Field
1111

12+
from pytfe.models.policy_evaluation import PolicyEvaluation
13+
from pytfe.models.task_result import TaskResult
14+
1215
if TYPE_CHECKING:
13-
from pytfe.models.policy_evaluation import PolicyEvaluation
14-
from pytfe.models.task_result import TaskResult
16+
from pytfe.models.run import Run
1517

1618

1719
class Stage(str, Enum):
@@ -56,36 +58,65 @@ class Actions(BaseModel):
5658
is_overridable: bool | None = Field(None, alias="is-overridable")
5759

5860

59-
# TaskStage represents a HCP Terraform or Terraform Enterprise run's stage
6061
class TaskStage(BaseModel):
6162
model_config = ConfigDict(populate_by_name=True, validate_by_name=True)
6263

6364
id: str
6465

6566
stage: Stage = Field(..., alias="stage")
66-
status: TaskStageStatus = Field(..., alias="status")
67-
status_timestamps: TaskStageStatusTimestamps = Field(..., alias="status-timestamps")
67+
68+
status: TaskStageStatus = Field(
69+
...,
70+
alias="status",
71+
)
72+
73+
status_timestamps: TaskStageStatusTimestamps = Field(
74+
...,
75+
alias="status-timestamps",
76+
)
6877

6978
created_at: datetime = Field(..., alias="created-at")
7079
updated_at: datetime = Field(..., alias="updated-at")
7180

72-
permissions: Permissions | None = Field(None, alias="permissions")
73-
actions: Actions | None = Field(None, alias="actions")
81+
permissions: Permissions | None = Field(
82+
None,
83+
alias="permissions",
84+
)
7485

75-
run: dict | None = Field(None, alias="run")
76-
task_results: list[TaskResult] | None = Field(None, alias="task-results")
77-
policy_evaluations: list[PolicyEvaluation] | None = Field(
78-
None, alias="policy-evaluations"
86+
actions: Actions | None = Field(
87+
None,
88+
alias="actions",
7989
)
8090

91+
# Relationships
92+
run: Run | None = Field(
93+
None,
94+
alias="run",
95+
)
8196

82-
def _rebuild_task_stage_model() -> None:
83-
global TaskResult, PolicyEvaluation
97+
task_results: list[TaskResult] | None = Field(
98+
None,
99+
alias="task-results",
100+
)
84101

85-
from pytfe.models.policy_evaluation import PolicyEvaluation
86-
from pytfe.models.task_result import TaskResult
102+
policy_evaluations: list[PolicyEvaluation] | None = Field(
103+
None,
104+
alias="policy-evaluations",
105+
)
87106

88-
TaskStage.model_rebuild()
107+
108+
def _rebuild_task_stage_model() -> None:
109+
# Do not import Run here: run.py imports TaskStage, so importing Run during
110+
# TaskStage module initialization creates a circular import on Python 3.14.
111+
TaskStage.model_rebuild(
112+
# Leave unresolved cyclic refs (Run) for later resolution while still
113+
# resolving non-cyclic refs needed during import-time schema generation.
114+
raise_errors=False,
115+
_types_namespace={
116+
"TaskResult": TaskResult,
117+
"PolicyEvaluation": PolicyEvaluation,
118+
},
119+
)
89120

90121

91122
_rebuild_task_stage_model()

src/pytfe/resources/task_stage.py

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
from typing import Any
88

99
from ..errors import InvalidRunIDError
10+
from ..models.policy_evaluation import PolicyEvaluation
11+
from ..models.run import Run
12+
from ..models.task_result import TaskResult
1013
from ..models.task_stage import TaskStage
1114
from ..utils import _safe_str, valid_string_id
1215
from ._base import _Service
@@ -16,22 +19,44 @@ class TaskStages(_Service):
1619
"""TaskStages provides access to task stage endpoints."""
1720

1821
def _parse_task_stage(self, data: dict[str, Any]) -> TaskStage:
22+
# TaskStage defers Run resolution to avoid model import-time cycles.
23+
# Rebuild here where Run is already imported and fully available.
24+
TaskStage.model_rebuild(
25+
raise_errors=False,
26+
_types_namespace={"Run": Run},
27+
)
28+
1929
attributes = data.get("attributes", {})
2030

2131
attributes["id"] = _safe_str(data.get("id"))
2232

2333
relationships = data.get("relationships", {})
2434

2535
run_data = relationships.get("run", {}).get("data")
26-
attributes["run"] = run_data
36+
if run_data:
37+
attributes["run"] = Run.model_validate(run_data)
2738

28-
task_results_data = relationships.get("task-results", {}).get("data", [])
29-
attributes["task-results"] = task_results_data
39+
task_results_data = relationships.get("task-results", {}).get(
40+
"data",
41+
[],
42+
)
3043

31-
policy_evaluations_data = relationships.get("policy-evaluations", {}).get(
32-
"data", []
44+
attributes["task-results"] = [
45+
TaskResult.model_validate(task_result) for task_result in task_results_data
46+
]
47+
48+
policy_evaluations_data = relationships.get(
49+
"policy-evaluations",
50+
{},
51+
).get(
52+
"data",
53+
[],
3354
)
34-
attributes["policy-evaluations"] = policy_evaluations_data
55+
56+
attributes["policy-evaluations"] = [
57+
PolicyEvaluation.model_validate(policy_evaluation)
58+
for policy_evaluation in policy_evaluations_data
59+
]
3560

3661
return TaskStage.model_validate(attributes)
3762

0 commit comments

Comments
 (0)