Skip to content

Commit b3a73f2

Browse files
committed
update changes
1 parent 3618f25 commit b3a73f2

28 files changed

Lines changed: 678 additions & 236 deletions

CHANGELOG.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,24 @@
55
### Relationships
66
* Added a lossless JSON:API escape hatch. **Every resource model** now derives from the new `pytfe.models.TFEModel` base and exposes `model.relationships`, `model.included`, `model.included_by(type, id)`, `model.related(name)`, and the `model.has_relationships` / `model.has_included` presence flags (distinguishing "absent on the wire" from "present but empty"). The raw blocks are private attributes — excluded from `model_dump()` **and from equality** — so this is additive and non-breaking; they complement `extra="allow"`, which only retains unknown *attributes*.
77
* **`relationships` capture** is wired broadly across the resources whose models are built through a dedicated parser (workspaces, runs, projects, teams, policies, policy sets, stacks, registry, no-code modules, comments, state versions, variable sets, oauth clients, notification configs, org memberships, query runs, admin orgs/runs/users/workspaces, and more), so the raw relationship references are always reachable.
8-
* **`included` hydration** (typed relations filled from the document's top-level `included`, and a populated `model.included`) currently applies to the single-resource reads that thread it — `workspaces.read*`, `runs.read*`, `no_code_modules.read_variables`. Other single reads and **all list endpoints** capture `relationships` but not yet `included`; threading `included` through the remaining reads and list pagination is an in-progress follow-up.
8+
* **`included` hydration** now comes in two forms, both purely additive:
9+
* **Typed hydration** — declared relationship fields are filled from the document's top-level `included` array (and `model.included` is populated). Applies to the single-resource reads of `workspaces`, `runs`, `agent_pools`, `stack_configuration`, `teams`, `task_stages`, `policy_set`, `organization_membership`, `variable_set`, `run_event`, and `no_code_modules.read_variables`. The rule is uniform: **wherever a resource models a relation as a typed field, `?include=<relation>` fills that field** (e.g. `policy_set.current_version`, `organization_membership.user`, `run_event.actor`).
10+
* **Raw capture**`model.included` is populated and `model.related(name)` / `model.included_by(type, id)` resolve to the full related bodies. Applies to the single-resource reads whose includable relations are **not** modelled as typed fields, so there is no typed field to fill: `state_versions`, `agents`, `configuration_version`, `oauth_client`, `organizations`, `projects`, `query_run`, `registry_provider`, and `run_task` reads. (Capturing the raw blocks only populates the private escape hatch — no typed field changes, so this is non-breaking.)
11+
* **Not yet wired**`registry_module`, `run_trigger`, and `policy_check` accept `?include=` only on their *list* endpoints, and **all list endpoints** across the SDK still capture `relationships` but not `included` (the shared top-level `included` array is not yet threaded through list pagination — an in-progress follow-up).
912

10-
See [docs/related-resources.md](docs/related-resources.md).
13+
See [docs/related-resources.md](docs/related-resources.md) for the per-resource coverage table and a "typed field vs raw accessor" guide.
14+
15+
* Added `?include=` support to three single-resource reads that previously exposed no include option, matching the HCP Terraform API (verified against go-tfe's OpenAPI spec and the live API):
16+
* `teams.read(team_id, TeamReadOptions(include=[...]))``users`, `organization-memberships` (typed hydration).
17+
* `task_stages.read(task_stage_id, TaskStageReadOptions(include=[...]))``run`, `run.workspace`, `task-results`, `policy-evaluations` (typed hydration).
18+
* `organizations.read(name, OrganizationReadOptions(include=[...]))``subscription` (raw capture). The new `options` argument is optional, so existing positional calls are unchanged.
1119

1220
## Bug Fixes
1321

1422
### Relationships
1523
* Fixed `workspaces.read*(..., include=[WorkspaceIncludeOpt.OUTPUTS])` returning outputs with `None` name/value/type. Workspace `outputs` is now hydrated from the JSON:API `included` array through the shared relationship parser (matching go-tfe's `relation,outputs`), instead of a broken special case that read attributes off the id-only relationship references. [#134](https://github.com/hashicorp/python-tfe/issues/134) (the related project-include case, [#74](https://github.com/hashicorp/python-tfe/issues/74), was already resolved by the relationship refactor and is verified covered.)
24+
* `PolicySetVersion` is now exported from `pytfe.models` and its forward reference to `PolicySet` is resolved via `model_rebuild()`. Previously it was never fully defined, so `policy_set.read*(include=[current_version|newest_version])` silently fell back to an id-only stub instead of hydrating the version's `source`/`created_at`/`status`.
25+
* `variable_set.read` no longer fabricates placeholder relation values (e.g. `name="workspace-<id>"`, `key="var-<id>"`, `category="terraform"`) for `workspaces`/`projects`/`vars`. Those relations are now id-only stubs by default and hydrate from `included` when requested via `?include=`, like every other typed relation.
1626

1727
# Released
1828
# v1.1.0

docs/related-resources.md

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,53 @@ pyTFE handles this on two levels:
6060
print(list(ws.relationships)) # e.g. ['organization', 'project', 'outputs', ...]
6161
```
6262

63+
## Which should I use — the typed field or the raw accessor?
64+
65+
**The one rule:** a typed relationship field always carries **at least the `id`**.
66+
Pass `?include=<relation>` to fill in the rest.
67+
68+
```python
69+
from pytfe.models.policy_set import PolicySetReadOptions, PolicySetIncludeOpt
70+
71+
ps = client.policy_sets.read("polset-abc")
72+
ps.current_version.id # always present (id-only stub)
73+
ps.current_version.source # None — you didn't ask for it
74+
75+
ps = client.policy_sets.read_with_options(
76+
"polset-abc",
77+
PolicySetReadOptions(include=[PolicySetIncludeOpt.POLICY_SET_CURRENT_VERSION]),
78+
)
79+
ps.current_version.source # now hydrated from `included`
80+
```
81+
82+
* **Prefer the typed field** (`ps.current_version`, `ws.outputs`, `team.users`,
83+
`org_membership.user`, `run_event.actor`) whenever the relation is modelled — it's
84+
type-checked and stable, and `?include=<relation>` fills it. This works the *same
85+
way for every resource that models the relation*: there are no resources where a
86+
typed field silently stays a stub after you `?include=` it.
87+
* **Use the raw accessors** (`model.related(name)`, `model.included_by(type, id)`)
88+
only for relations the SDK does **not** model as a typed field — e.g. an
89+
organization's `subscription`, or a workspace `readme`. The data is still returned
90+
by `?include=`, just untyped.
91+
92+
You never need both for the same relation: if a typed field exists, `?include=` fills
93+
it; if it doesn't, the raw accessors are the way in.
94+
95+
## Per-resource coverage
96+
97+
`?include=` support by single-resource `read*` (see each resource's `*IncludeOpt`):
98+
99+
| Behaviour | Resources |
100+
|---|---|
101+
| **Typed hydration**`include` fills the typed field | `workspaces`, `runs`, `agent_pools`, `stack_configuration`, `teams`, `task_stages`, `policy_set`, `organization_membership`, `variable_set`, `run_event`, `no_code_modules.read_variables` |
102+
| **Raw capture** — relation not modelled as a typed field; reach it via `related()` / `included_by()` | `organizations` (`subscription`), `state_versions`, `agents`, `configuration_version`, `oauth_client`, `projects`, `query_run`, `registry_provider`, `run_task` |
103+
| **List-only**`?include=` exists only on the `list` endpoint | `registry_module`, `run_trigger`, `policy_check` |
104+
105+
In every case the **`relationships`** block and the four raw accessors are populated,
106+
so unmodelled relations are never lost. **List endpoints** currently capture
107+
`relationships` but not `included` (the page-level `included` array is not yet threaded
108+
through pagination — in progress).
109+
63110
## Notes
64111

65112
- The raw blocks are **private attributes**, so they never appear in
@@ -72,6 +119,8 @@ pyTFE handles this on two levels:
72119
- Accessors are provided by `pytfe.models.TFEModel`, which **every
73120
resource model** now derives from — so `.relationships` / `.included` /
74121
`.included_by` / `.related` are available everywhere. They're *populated* on
75-
resources parsed through a dedicated parser; other resources expose the
76-
accessors but return them empty until their parser is wired to capture the
77-
raw blocks.
122+
single-resource `read*` calls: the `relationships` block on reads that go
123+
through a relationship-capturing parser, and the `included` array whenever you
124+
pass `?include=`. **List endpoints** currently populate `relationships` but not
125+
`included` — the shared top-level `included` array is not yet threaded through
126+
pagination (in progress).

examples/related_resources.py

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,27 @@
99
* `included` — full bodies of relations you ask for with ?include=
1010
1111
pyTFE hydrates the relations it models into typed fields, AND keeps both raw
12-
blocks so nothing is ever lost. This example shows both.
12+
blocks so nothing is ever lost. The one rule: a typed relationship field always
13+
carries at least the `id`; pass ?include= to fill in the rest. This example shows
14+
both a workspace and a team.
1315
1416
Prerequisites:
1517
export TFE_TOKEN=... # your API token
18+
export TFE_ORG=... # org to look up a team in (optional)
1619
python examples/related_resources.py ws-abc123 # a workspace id
1720
"""
1821

1922
from __future__ import annotations
2023

24+
import os
2125
import sys
2226

2327
from pytfe import TFEClient
28+
from pytfe.models.team import TeamIncludeOpt, TeamReadOptions
2429
from pytfe.models.workspace import WorkspaceIncludeOpt, WorkspaceReadOptions
2530

2631

27-
def main(workspace_id: str) -> None:
28-
client = TFEClient()
29-
32+
def workspace_demo(client: TFEClient, workspace_id: str) -> None:
3033
# Ask the API to include the workspace's outputs and project.
3134
ws = client.workspaces.read_by_id_with_options(
3235
workspace_id,
@@ -60,6 +63,42 @@ def main(workspace_id: str) -> None:
6063
assert "relationships" not in ws.model_dump()
6164

6265

66+
def team_demo(client: TFEClient, org: str) -> None:
67+
# Grab any team in the org, then read it back asking for its users.
68+
teams = list(client.teams.list(org))
69+
if not teams:
70+
print(f"\n(no teams in {org} to demo)")
71+
return
72+
73+
team = client.teams.read(
74+
teams[0].id,
75+
TeamReadOptions(include=[TeamIncludeOpt.TEAM_USERS]),
76+
)
77+
78+
# Typed hydration: team.users carries the full user bodies, not just ids.
79+
print(f"\nteam: {team.name} ({team.user_count} members)")
80+
for user in team.users or []:
81+
# Without include=users this would be an id-only stub (username == None).
82+
print(f" user (hydrated): {user.id} {user.username}")
83+
84+
# The raw escape hatch is populated too, for relations not modelled as fields.
85+
print(
86+
f" has_included={team.has_included} relationships={sorted(team.relationships)}"
87+
)
88+
assert "included" not in team.model_dump()
89+
90+
91+
def main(workspace_id: str) -> None:
92+
client = TFEClient()
93+
workspace_demo(client, workspace_id)
94+
95+
org = os.environ.get("TFE_ORG")
96+
if org:
97+
team_demo(client, org)
98+
else:
99+
print("\n(set TFE_ORG to also run the team include demo)")
100+
101+
63102
if __name__ == "__main__":
64103
if len(sys.argv) != 2:
65104
print("usage: python examples/related_resources.py <workspace-id>")

src/pytfe/models/__init__.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,8 @@
211211
OrganizationCreateOptions,
212212
OrganizationDefaultSettings,
213213
OrganizationDefaultSettingsUpdateOptions,
214+
OrganizationIncludeOpt,
215+
OrganizationReadOptions,
214216
OrganizationUpdateOptions,
215217
ReadRunQueueOptions,
216218
RunQueue,
@@ -298,6 +300,7 @@
298300
PolicySetParameterListOptions,
299301
PolicySetParameterUpdateOptions,
300302
)
303+
from .policy_set_version import PolicySetVersion
301304
from .policy_types import (
302305
EnforcementLevel,
303306
PolicyKind,
@@ -503,14 +506,15 @@
503506
TaskResultStatus,
504507
TaskResultStatusTimestamps,
505508
)
506-
from .task_stage import TaskStage
509+
from .task_stage import TaskStage, TaskStageIncludeOpt, TaskStageReadOptions
507510
from .team import (
508511
OrganizationAccess,
509512
Team,
510513
TeamCreateOptions,
511514
TeamIncludeOpt,
512515
TeamListOptions,
513516
TeamPermissions,
517+
TeamReadOptions,
514518
TeamUpdateOptions,
515519
)
516520
from .team_project_access import (
@@ -837,6 +841,8 @@
837841
"OrganizationCreateOptions",
838842
"OrganizationDefaultSettings",
839843
"OrganizationDefaultSettingsUpdateOptions",
844+
"OrganizationIncludeOpt",
845+
"OrganizationReadOptions",
840846
"OrganizationUpdateOptions",
841847
# Org-token TTL policy
842848
"DEFAULT_MAX_TTL_MS",
@@ -869,6 +875,7 @@
869875
"TeamCreateOptions",
870876
"TeamIncludeOpt",
871877
"TeamListOptions",
878+
"TeamReadOptions",
872879
"TeamUpdateOptions",
873880
# Team Tokens
874881
"CreatedByChoice",
@@ -972,6 +979,8 @@
972979
"RunEventReadOptions",
973980
# Task Stage & Task Result
974981
"TaskStage",
982+
"TaskStageIncludeOpt",
983+
"TaskStageReadOptions",
975984
"TaskResult",
976985
# Comments
977986
"Comment",
@@ -1037,6 +1046,7 @@
10371046
"PolicySet",
10381047
"PolicySetIncludeOpt",
10391048
"PolicySetList",
1049+
"PolicySetVersion",
10401050
"PolicySetAddPoliciesOptions",
10411051
"PolicySetAddProjectsOptions",
10421052
"PolicySetAddWorkspacesOptions",
@@ -1118,6 +1128,10 @@
11181128

11191129
# Rebuild models with forward references after all models are loaded
11201130
PolicyCheck.model_rebuild()
1131+
PolicySetVersion.model_rebuild(
1132+
raise_errors=False,
1133+
_types_namespace={"PolicySet": PolicySet},
1134+
)
11211135
RegistryProvider.model_rebuild()
11221136
RegistryProviderVersion.model_rebuild()
11231137
RegistryProviderPlatform.model_rebuild()

src/pytfe/models/organization.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,20 @@ class OrganizationCreateOptions(BaseModel):
8888
data_retention_policy_choice: dict | None = None
8989

9090

91+
class OrganizationIncludeOpt(str, Enum):
92+
"""Available include options for reading an organization."""
93+
94+
ORGANIZATION_SUBSCRIPTION = "subscription"
95+
96+
97+
class OrganizationReadOptions(BaseModel):
98+
"""Options for reading a single organization."""
99+
100+
model_config = ConfigDict(populate_by_name=True)
101+
102+
include: list[OrganizationIncludeOpt] | None = Field(None, alias="include")
103+
104+
91105
class ExecutionMode(str, Enum):
92106
REMOTE = "remote"
93107
AGENT = "agent"

src/pytfe/models/task_stage.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,20 @@ class TaskStageListOptions(BaseModel):
8484
model_config = ConfigDict(populate_by_name=True)
8585

8686
page_size: int | None = Field(None, alias="page[size]")
87+
88+
89+
class TaskStageIncludeOpt(str, Enum):
90+
"""Available include options for reading a task stage."""
91+
92+
TASK_STAGE_RUN = "run"
93+
TASK_STAGE_RUN_WORKSPACE = "run.workspace"
94+
TASK_STAGE_TASK_RESULTS = "task-results"
95+
TASK_STAGE_POLICY_EVALUATIONS = "policy-evaluations"
96+
97+
98+
class TaskStageReadOptions(BaseModel):
99+
"""Options for reading a single task stage."""
100+
101+
model_config = ConfigDict(populate_by_name=True)
102+
103+
include: list[TaskStageIncludeOpt] | None = Field(None, alias="include")

src/pytfe/models/team.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,14 @@ def valid(self) -> TeamListOptions:
100100
return self
101101

102102

103+
class TeamReadOptions(BaseModel):
104+
"""Options for reading a single team."""
105+
106+
model_config = ConfigDict(populate_by_name=True)
107+
108+
include: list[TeamIncludeOpt] | None = Field(None, alias="include")
109+
110+
103111
class OrganizationAccessOptions(BaseModel):
104112
model_config = ConfigDict(populate_by_name=True)
105113

src/pytfe/resources/agents.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from collections.abc import Iterator
1313
from typing import Any, cast
1414

15+
from .._jsonapi import attach_jsonapi
1516
from ..models.agent import (
1617
Agent,
1718
AgentListOptions,
@@ -139,7 +140,8 @@ def read(self, agent_id: str, options: AgentReadOptions | None = None) -> Agent:
139140
else:
140141
response = self.t.request("GET", path)
141142

142-
data = response.json()["data"]
143+
payload = response.json()
144+
data = payload["data"]
143145

144146
# Extract agent data from response
145147
attr = data.get("attributes", {}) or {}
@@ -162,13 +164,17 @@ def read(self, agent_id: str, options: AgentReadOptions | None = None) -> Agent:
162164
"ip_address": _safe_str(attr.get("ip-address")),
163165
}
164166

165-
return Agent(
166-
id=_safe_str(agent_data["id"]) or "",
167-
name=agent_data["name"],
168-
status=_safe_agent_status(agent_data["status"]),
169-
version=agent_data["version"],
170-
last_ping_at=cast(Any, agent_data["last_ping_at"]),
171-
ip_address=agent_data["ip_address"],
167+
return attach_jsonapi(
168+
Agent(
169+
id=_safe_str(agent_data["id"]) or "",
170+
name=agent_data["name"],
171+
status=_safe_agent_status(agent_data["status"]),
172+
version=agent_data["version"],
173+
last_ping_at=cast(Any, agent_data["last_ping_at"]),
174+
ip_address=agent_data["ip_address"],
175+
),
176+
data,
177+
payload.get("included"),
172178
)
173179

174180
def delete(self, agent_id: str) -> None:

src/pytfe/resources/configuration_version.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from __future__ import annotations
55

6+
import builtins
67
import io
78
from collections.abc import Iterator
89
from typing import Any
@@ -124,7 +125,9 @@ def read_with_options(
124125

125126
response = self.t.request("GET", path, params=params)
126127
response_data = response.json()
127-
return self._parse_configuration_version(response_data["data"])
128+
return self._parse_configuration_version(
129+
response_data["data"], response_data.get("included")
130+
)
128131

129132
def upload(self, upload_url: str, path: str) -> None:
130133
"""Upload configuration files from a directory path."""
@@ -254,7 +257,9 @@ def _manage_backing_data(self, cv_id: str, action: str) -> None:
254257
self.t.request("POST", path)
255258

256259
def _parse_configuration_version(
257-
self, data: dict[str, Any]
260+
self,
261+
data: dict[str, Any],
262+
included: builtins.list[dict[str, Any]] | None = None,
258263
) -> ConfigurationVersion:
259264
"""Parse a configuration version from API response data."""
260265
if data is None:
@@ -287,4 +292,4 @@ def _parse_configuration_version(
287292
"links": data.get("links"),
288293
}
289294

290-
return attach_jsonapi(ConfigurationVersion(**cv_data), data)
295+
return attach_jsonapi(ConfigurationVersion(**cv_data), data, included)

0 commit comments

Comments
 (0)