Skip to content

Commit c4aed8e

Browse files
committed
fix the doc
1 parent 5609f76 commit c4aed8e

1 file changed

Lines changed: 99 additions & 23 deletions

File tree

docs/related-resources.md

Lines changed: 99 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
# Related resources (`?include=`, relationships & included)
22

33
HCP Terraform speaks [JSON:API](https://developer.hashicorp.com/terraform/cloud-docs/api-docs#inclusion-of-related-resources).
4-
A resource carries a **`relationships`** blocklinkage references (`type` + `id`)
5-
for every related resource — and, when you request `?include=`, the response also
4+
A resource carries a **`relationships`** block: linkage references (`type` plus `id`)
5+
for every related resource. When you request `?include=`, the response also
66
carries a top-level **`included`** array holding the *full bodies* of those
77
relations.
88

99
pyTFE handles this on two levels:
1010

11-
1. **Typed hydration** relationships the SDK models are parsed into typed
11+
1. **Typed hydration**: relationships the SDK models are parsed into typed
1212
fields, and when you pass `include=...` those fields are filled from
1313
`included`. For example:
1414

@@ -24,10 +24,10 @@ pyTFE handles this on two levels:
2424

2525
for o in ws.outputs: # fully hydrated from `included`
2626
print(o.name, o.value)
27-
print(ws.project.name) # not just the id the real project record
27+
print(ws.project.name) # not just the id, the real project record
2828
```
2929

30-
2. **Lossless raw access** even relations the SDK does **not** model as typed
30+
2. **Lossless raw access**: even relations the SDK does **not** model as typed
3131
fields are never lost. Every resource on the relationship-parsing path keeps
3232
the raw blocks, reachable through these accessors:
3333

@@ -40,11 +40,11 @@ pyTFE handles this on two levels:
4040
| `model.included_by(type, id)` | one included object matched by `type` + `id` |
4141
| `model.related(name)` | the references of relationship `name`, each resolved to its full included body (or left as a bare `{type, id}` ref if it wasn't `include`-d) |
4242

43-
`.relationships` / `.included` are always present and stably typed they
44-
return `{}` / `[]` whether the block was *empty* or *absent*. The API genuinely
43+
`.relationships` and `.included` are always present and stably typed: they
44+
return `{}` or `[]` whether the block was *empty* or *absent*. The API genuinely
4545
distinguishes the two (SSH keys omit `relationships` entirely; `included`
46-
only appears with `?include=`), so `has_relationships` / `has_included` tell
47-
you which without making the data accessors conditionally vanish.
46+
only appears with `?include=`), so `has_relationships` and `has_included` tell
47+
you which, without making the data accessors conditionally vanish.
4848

4949
`model.related(name)` takes the raw relationship key from
5050
`model.relationships`, not the Python field name. These keys often contain
@@ -87,7 +87,83 @@ pyTFE handles this on two levels:
8787
print(subscription["attributes"])
8888
```
8989

90-
## Which should I use — the typed field or the raw accessor?
90+
## Traversing the raw blocks
91+
92+
`model.relationships` and `model.included` are plain Python dicts and lists,
93+
shaped exactly like the JSON:API the server returns. Once you know that shape,
94+
walking them is straightforward.
95+
96+
### `relationships` shape
97+
98+
Each key is a wire relation name. Its `data` is a single reference (to-one) or a
99+
list of references (to-many), and each reference is just a `type` and an `id`:
100+
101+
```python
102+
ws.relationships == {
103+
# to-one: data is a single ref (a dict)
104+
"organization": {"data": {"type": "organizations", "id": "my-org"}},
105+
"project": {"data": {"type": "projects", "id": "prj-abc"}},
106+
# to-many: data is a list of refs
107+
"outputs": {"data": [
108+
{"type": "workspace-outputs", "id": "wsout-1"},
109+
{"type": "workspace-outputs", "id": "wsout-2"},
110+
]},
111+
# a present-but-unset to-one relation has data == None
112+
"current-run": {"data": None},
113+
}
114+
```
115+
116+
### `included` shape
117+
118+
Populated only when you pass `?include=`. It is a flat list of full resource
119+
bodies, each with its own `type`, `id`, and `attributes`:
120+
121+
```python
122+
ws.included == [
123+
{
124+
"type": "workspace-outputs",
125+
"id": "wsout-1",
126+
"attributes": {"name": "environment", "value": "test", "output-type": "string"},
127+
},
128+
# one entry per included resource
129+
]
130+
```
131+
132+
### Traversal cookbook
133+
134+
| You want to... | Do this |
135+
|---|---|
136+
| List every relation name | `list(ws.relationships)` |
137+
| Check whether a relation was returned | `"outputs" in ws.relationships` |
138+
| Get a to-one ref | `ws.relationships["organization"]["data"]` (a `{type, id}` dict, or `None`) |
139+
| Get to-many refs | `ws.relationships["outputs"]["data"]` (a list of `{type, id}`) |
140+
| Resolve a relation to full bodies | `ws.related("outputs")` (always a list) |
141+
| Look up one included body by ref | `ws.included_by(ref["type"], ref["id"])` (a dict, or `None`) |
142+
| Read an attribute off a body | `body["attributes"]["name"]` |
143+
| Walk the whole `included` array | `for item in ws.included: ...` then `item["type"]`, `item["id"]`, `item["attributes"]` |
144+
145+
`related(name)` smooths over the to-one vs to-many difference for you: it always
146+
returns a list (a single relation becomes a one-item list), and each entry is the
147+
full `included` body when available, otherwise the bare `{type, id}` reference.
148+
149+
```python
150+
# Safe end-to-end traversal of any relation, modelled or not:
151+
for ref in ws.related("outputs"):
152+
if "attributes" in ref: # resolved from `included`
153+
print(ref["attributes"]["name"])
154+
else: # only the bare ref (not requested with ?include=)
155+
print("unresolved:", ref["type"], ref["id"])
156+
```
157+
158+
Two things to remember while traversing:
159+
160+
* Relation **keys are the wire names**, so they often contain hyphens
161+
(`"current-run"`, `"remote-state-consumers"`), not the Python field names.
162+
* A relation can be **absent** (key missing), **unset** (`{"data": None}`), or
163+
**empty to-many** (`{"data": []}`). `related(name)` returns `[]` for all three,
164+
so you rarely need to special-case them.
165+
166+
## Which should I use: the typed field or the raw accessor?
91167

92168
**The one rule:** when a typed relationship is present, it carries **at least
93169
the `id`**. Pass `?include=<relation>` to fill in the rest.
@@ -98,7 +174,7 @@ from pytfe.models.policy_set import PolicySetReadOptions, PolicySetIncludeOpt
98174
ps = client.policy_sets.read("polset-abc")
99175
if ps.current_version:
100176
ps.current_version.id # present on the id-only stub
101-
ps.current_version.source # None you didn't ask for it
177+
ps.current_version.source # None, you didn't ask for it
102178

103179
ps = client.policy_sets.read_with_options(
104180
"polset-abc",
@@ -109,14 +185,14 @@ if ps.current_version:
109185
```
110186

111187
* **Prefer the typed field** (`ps.current_version`, `ws.outputs`, `team.users`,
112-
`org_membership.user`, `run_event.actor`) whenever the relation is modelled — it's
188+
`org_membership.user`, `run_event.actor`) whenever the relation is modelled. It's
113189
type-checked and stable, and `?include=<relation>` fills it on single-resource
114190
reads. This works the *same way for every resource that models the relation*:
115191
there are no single-resource read paths where a typed field silently stays a
116192
stub after you `?include=` it.
117193
* **Use the raw accessors** (`model.related(name)`, `model.included_by(type, id)`)
118-
only for relations the SDK does **not** model as a typed field — e.g. an
119-
organization's `subscription`, or a workspace `readme`. The data is still returned
194+
only for relations the SDK does **not** model as a typed field, for example an
195+
organization's `subscription` or a workspace `readme`. The data is still returned
120196
by `?include=`, just untyped.
121197

122198
You never need both for the same relation: if a typed field exists, `?include=` fills
@@ -128,14 +204,14 @@ it; if it doesn't, the raw accessors are the way in.
128204

129205
| Behaviour | Resources |
130206
|---|---|
131-
| **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` |
132-
| **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` |
133-
| **List-only** `?include=` exists only on the `list` endpoint | `registry_module`, `run_trigger`, `policy_check` |
207+
| **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` |
208+
| **Raw capture**: relation not modelled as a typed field, reach it via `related()` or `included_by()` | `organizations` (`subscription`), `state_versions`, `agents`, `configuration_version`, `oauth_client`, `projects`, `query_run`, `registry_provider`, `run_task` |
209+
| **List-only**: `?include=` exists only on the `list` endpoint | `registry_module`, `run_trigger`, `policy_check` |
134210

135211
In every case the **`relationships`** block and raw accessors are populated,
136212
so unmodelled relations are never lost. **List endpoints** currently capture
137213
`relationships` but not `included` (the page-level `included` array is not yet threaded
138-
through pagination in progress).
214+
through pagination, in progress).
139215

140216
For list endpoints, this means `model.has_relationships` can be `True`, but
141217
`model.has_included` is currently `False` even when the list options expose an
@@ -145,17 +221,17 @@ id-only stubs until you read a single resource with the matching read options.
145221
## Notes
146222

147223
- The raw blocks are **private attributes**, so they never appear in
148-
`model_dump()` / serialized output and add no public fields. They're an
149-
untyped escape hatch, not a stable typed API prefer the typed fields when a
224+
`model_dump()` or serialized output and add no public fields. They're an
225+
untyped escape hatch, not a stable typed API, so prefer the typed fields when a
150226
relation is modelled.
151227
- This complements `extra="allow"`, which retains unknown **attributes**;
152228
`relationships`/`included` cover unknown **relations**. Together nothing the
153229
API returns is silently dropped.
154230
- Accessors are provided by `pytfe.models.TFEModel`, which top-level
155-
resource models derive fromso `.relationships` / `.included` /
156-
`.included_by` / `.related` are available everywhere. They're *populated* on
231+
resource models derive from, so `.relationships`, `.included`,
232+
`.included_by`, and `.related` are available everywhere. They're *populated* on
157233
single-resource `read*` calls: the `relationships` block on reads that go
158234
through a relationship-capturing parser, and the `included` array whenever you
159235
pass `?include=`. **List endpoints** currently populate `relationships` but not
160-
`included` the shared top-level `included` array is not yet threaded through
236+
`included`; the shared top-level `included` array is not yet threaded through
161237
pagination (in progress).

0 commit comments

Comments
 (0)