Skip to content

Commit 312b4d5

Browse files
authored
Refactor relationship model and fixes (#180)
1 parent 47f332c commit 312b4d5

58 files changed

Lines changed: 742 additions & 382 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ src/pytfe/
1313
client.py # TFEClient — composition root, wires every resource
1414
config.py # TFEConfig — auth, timeout, retry, proxy settings
1515
_http.py # HTTPTransport — request, retry, redirects, auth
16-
_jsonapi.py # JSON:API envelope helpers
16+
_jsonapi.py # JSON:API helpers: headers, error payloads, and the
17+
# shared relationship/included parser (parse_relationships)
1718
_logging.py. # Logging primitives for the pytfe SDK
1819
errors.py # Typed exception hierarchy (TFEError + ~80 subclasses)
1920
utils.py # Validation + small helpers
@@ -77,6 +78,7 @@ These are mistakes a competent Python developer would make if they hadn't read t
7778
- **Don't reuse generators.** Iterators returned by `list_*` are single-use. If you need to traverse twice, `materialized = list(client.foo.list_bars(...))` first.
7879
- **Don't add features beyond what was asked.** This codebase is approaching v1.0.0. Adding "while I'm here" refactors or speculative abstractions slows reviews and risks breaking the Ansible collection.
7980
- **Don't assume every successful response is `{"data": ...}`.** Check the docs/go-tfe/spec for each endpoint: some return a JSON:API envelope, some return a bare resource object, `204 No Content`, `null`, raw bytes, or a redirect to a blob URL. Add tests for non-standard shapes.
81+
- **Don't hand-roll relationship parsing, and don't let response models drop unknown fields.** Response models (the ones you parse from API payloads) set `extra="allow"` so new server fields survive in `model_extra` instead of being silently dropped. Parse the `relationships` block with the shared `parse_relationships` helper from `pytfe._jsonapi` (a declarative `{wire_relation: Model}` map + optional `included` hydration), not a bespoke if-ladder. `resources/workspaces.py` and `resources/run.py` are the reference parsers; details in [MODELS.md](docs/MODELS.md) and [RESOURCE.md](docs/RESOURCE.md).
8082
- **Don't use bare `list[...]` annotations inside a resource class after defining `def list(...)`.** In class scope, mypy can resolve `list` to the method instead of the builtin. Use `builtins.list[...]`, `Sequence[...]`, or another unshadowed type.
8183
- **Don't `print()` or use ad-hoc `logging.getLogger(__name__)` calls in library code.** The SDK has a structured logging framework — use `pytfe._logging.transport_logger` for HTTP traffic, or `pytfe._logging.logger` (the `pytfe` root) for higher-level events. Everything from that namespace is silent by default (NullHandler) and respects the user's `setup_logging()` or stdlib configuration. See [LOGGING.md](docs/LOGGING.md) for redaction rules — bearer tokens and `token`/`secret`/`password` keys are auto-redacted by `RoundTrip`, but only inside that formatter. Never `log.info(token)` directly.
8284

docs/MODELS.md

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ New or touched `BaseModel` classes should set `model_config = ConfigDict(...)` u
2626
| `populate_by_name=True` | **Always.** Lets callers pass either the field name (`created_at=...`) or the alias (`{"created-at": ...}`) when constructing. |
2727
| `validate_by_name=True` | Use on models that are parsed *from* API responses **or** constructed by callers via field names. Pair with `populate_by_name=True`. |
2828
| `extra="forbid"` | Use on `*CreateOptions` / `*UpdateOptions` / option models where you want a typo (`workspce_id=...`) to fail loudly instead of being silently dropped. Don't put it on response models — the API can add fields and we don't want that to break parsing. |
29+
| `extra="allow"` | Standard for **response models** parsed from API payloads. The default (`extra="ignore"`) silently drops any wire attribute without a declared field, so a new server field becomes a data-loss bug. `extra="allow"` retains undeclared fields in `model_extra` under their wire names (e.g. `model_extra["future-field"]`). Note: extra keys are *not* dot-accessible as snake_case and have no type — add an explicit aliased field for anything users should access ergonomically. `Workspace` is the reference implementation; relationship parsing for these models goes through `pytfe._jsonapi.parse_relationships` (see the Relationships section). |
2930
| `arbitrary_types_allowed=True` | Only when you genuinely have a non-Pydantic type in a field (rare). |
3031

3132
The standard line you'll write 90% of the time:
@@ -177,12 +178,34 @@ class TaskStage(BaseModel):
177178
task_results: list[TaskResult] | None = Field(None, alias="task-results")
178179
```
179180

180-
Use `model_construct` (not `model_validate`) in the resource for these stubs — it skips validation, which is correct because you only have `{id, type}`:
181+
The resource layer should populate these via the shared `parse_relationships` helper (see below) rather than a hand-rolled if-ladder. For a true one-off, `Model.model_construct(id=...)` (not `model_validate`) is correct — it skips validation, which is right because you only have `{id, type}`.
182+
183+
### Parsing relationships — `pytfe._jsonapi.parse_relationships`
184+
185+
Don't hand-roll the `relationships.get("x", {}).get("data")` if-ladder per resource. The canonical parser lives in `src/pytfe/_jsonapi.py` and is driven by a declarative map of `{wire_relation: Model}` (or `{wire_relation: (python_attr, Model)}` when the attribute name diverges from `wire.replace("-", "_")`):
181186

182187
```python
183-
attributes["run"] = Run.model_construct(id=run_data["id"])
188+
from .._jsonapi import parse_relationships
189+
190+
_WIDGET_REL_MAP = {
191+
"organization": Organization, # attr derived: "organization"
192+
"current-run": Run, # attr derived: "current_run"
193+
"vars": ("variables", Variable), # wire name diverges from attr
194+
}
195+
196+
def _widget_from(d, included=None):
197+
attrs = dict(d.get("attributes") or {})
198+
attrs["id"] = d.get("id")
199+
attrs.update(parse_relationships(d.get("relationships"), _WIDGET_REL_MAP, included=included))
200+
return Widget.model_validate(attrs)
184201
```
185202

203+
`parse_relationships` handles single vs list `data`, skips null/absent and unmapped relations (so they fall back to model defaults / `extra="allow"`), and — when the caller passes the response's top-level `included` array — hydrates the **full** related object instead of an id-only stub (so `?include=current_run` returns a populated `Run`, not just `{id}`). Thread `included` from read paths: `payload = r.json(); _widget_from(payload["data"], payload.get("included"))`.
204+
205+
Keep genuinely polymorphic relations (e.g. workspace `locked-by`, `data-retention-policy-choice`) and relations whose `data` carries inline attributes (e.g. workspace `outputs`) as explicit special cases — they don't fit the simple map. `Workspace` (`resources/workspaces.py`) and `Run` (`resources/run.py`) are the reference implementations.
206+
207+
> One gotcha: a parser written as a **method** on a service class that also defines `def list(...)` cannot annotate `included: list[...] | None` — in class scope `list` resolves to the method. Make the parser a module-level function (preferred, matches `_ws_from`/`_run_from`), or use `builtins.list[...]`.
208+
186209
### Option 2 — Flat `*_id` field
187210

188211
When the relationship is "owned" by this resource and just one id matters, expose it as a flat `*_id` field (with hyphen alias if needed). Less plumbing, fine when you don't need the related model object:
@@ -275,6 +298,7 @@ The CI check in `tests/units/test_model_conventions.py` enforces this for every
275298
- [ ] `from __future__ import annotations` at the top
276299
- [ ] New or touched classes use `model_config = ConfigDict(populate_by_name=True, validate_by_name=True)` unless preserving a local legacy pattern
277300
- [ ] Hyphenated JSON:API attribute names → `Field(alias="...")`
301+
- [ ] **Response models** (parsed from API payloads) add `extra="allow"` to their `ConfigDict` for forward compatibility; relationships are parsed via `parse_relationships`, not a hand-rolled if-ladder
278302
- [ ] Response model fields default to `T | None = Field(None, alias="...")`
279303
- [ ] `*CreateOptions` uses `Field(...)` for required fields, `T | None = None` for optional
280304
- [ ] `*UpdateOptions` is fully optional

docs/RESOURCE.md

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -184,26 +184,35 @@ def _widget_from(self, data: dict[str, Any]) -> Widget:
184184
return Widget.model_validate(attrs)
185185
```
186186

187-
If the model has relationships, pull them from `data["relationships"]` and either:
188-
189-
1. **Embed an id-stub** using `Model.model_construct(id=...)` — use this when the model defines the relation as `OtherModel | None`. `model_construct` skips validation, which is correct for partial `{id, type}` data:
187+
If the model has relationships, parse them with the shared `parse_relationships` helper from `pytfe._jsonapi` — don't hand-roll a per-relation if-ladder. Pass a declarative `{wire_relation: Model}` map (or `{wire: (python_attr, Model)}` when the attribute name diverges from `wire.replace("-", "_")`), and thread the response's top-level `included` so `?include=` requests hydrate full nested objects instead of id-only stubs:
190188

191189
```python
192-
relationships = data.get("relationships", {})
193-
run_data = relationships.get("run", {}).get("data")
194-
if run_data:
195-
attributes["run"] = Run.model_construct(id=run_data["id"])
190+
from .._jsonapi import parse_relationships
191+
192+
_WIDGET_REL_MAP = {"organization": Organization, "current-run": Run}
193+
194+
def _widget_from(self, data: dict[str, Any], included=None) -> Widget:
195+
attrs = dict(data.get("attributes") or {})
196+
attrs["id"] = data.get("id")
197+
attrs.update(parse_relationships(data.get("relationships"), _WIDGET_REL_MAP, included=included))
198+
return Widget.model_validate(attrs)
199+
200+
# caller threads included from the envelope:
201+
payload = r.json()
202+
return self._widget_from(payload["data"], payload.get("included"))
196203
```
197204

198-
2. **Flatten to `*_id`** when the model exposes a flat `team_id: str | None` field:
205+
`parse_relationships` skips null/absent/unmapped relations, handles single vs list `data`, and builds id-only stubs via `model_construct` when `included` is absent. For a relation the model exposes as a **flat `*_id`** field instead of an embedded model, keep the small manual extraction:
199206

200207
```python
201-
team_data = (relationships.get("team") or {}).get("data") or {}
208+
team_data = (data.get("relationships", {}).get("team") or {}).get("data") or {}
202209
if team_data.get("id"):
203-
attributes["team-id"] = team_data["id"]
210+
attrs["team-id"] = team_data["id"]
204211
```
205212

206-
Always defensively coalesce with `or {}` — relationships may be missing from sparse responses.
213+
Keep polymorphic relations (e.g. `locked-by`) and relations whose `data` carries inline attributes (e.g. workspace `outputs`) as explicit special cases — they don't fit the map. See `resources/workspaces.py` and `resources/run.py` for the reference parsers. Always defensively coalesce with `or {}` — relationships may be missing from sparse responses.
214+
215+
> If the parser is a **method** on a service class that defines `def list(...)`, you can't annotate `included: list[...] | None` (in class scope `list` is the method). Prefer a module-level `_widget_from` function (like `_ws_from`/`_run_from`), or use `builtins.list[...]`.
207216
208217

209218
## Pagination — use `self._list`, don't roll your own

src/pytfe/_jsonapi.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,106 @@ def parse_error_payload(payload: dict[str, Any]) -> list[dict | str]:
2424
if "message" in payload:
2525
return [{"detail": payload.get("message")}]
2626
return []
27+
28+
29+
# --------------------------------------------------------------------------- #
30+
# Relationship / included parsing
31+
#
32+
# HCP Terraform speaks JSON:API: a resource object carries an ``attributes`` map
33+
# and a ``relationships`` map, and a response may carry a top-level ``included``
34+
# array holding the full bodies of related resources requested via ``?include=``.
35+
#
36+
# These helpers are the single canonical implementation that resource parsers
37+
# converge on, replacing the per-resource hand-rolled relationship parsing
38+
# (``workspaces._ws_from`` if-ladder, ``run.transform_relationships``,
39+
# ``no_code_module`` included-index).
40+
# --------------------------------------------------------------------------- #
41+
42+
IncludedIndex = dict[tuple[Any, Any], dict[str, Any]]
43+
44+
# A relation map value is either the model class (attr name derived from the
45+
# wire relation name by ``-`` -> ``_``) or an explicit ``(python_attr, Model)``
46+
# tuple for the cases where they diverge (e.g. ``vars`` -> ``variables``).
47+
RelationSpec = "type | tuple[str, type]"
48+
RelationMap = dict[str, Any]
49+
50+
51+
def build_included_index(included: list[dict[str, Any]] | None) -> IncludedIndex:
52+
"""Index a JSON:API ``included`` array by ``(type, id)``.
53+
54+
JSON:API guarantees ``(type, id)`` uniqueness within a document, so the
55+
first occurrence wins deterministically if a server ever violates that.
56+
"""
57+
index: IncludedIndex = {}
58+
for item in included or []:
59+
index.setdefault((item.get("type"), item.get("id")), item)
60+
return index
61+
62+
63+
def _hydrate(ref: dict[str, Any], model: Any, index: IncludedIndex) -> Any | None:
64+
"""Resolve a single relationship reference to a model instance.
65+
66+
If the full resource body is present in ``index`` (because it was requested
67+
via ``?include=``), validate it into a fully-populated model. Otherwise
68+
return a lightweight ``{id}`` stub via ``model_construct`` (no validation —
69+
correct, because only ``id``/``type`` are known).
70+
"""
71+
rid = ref.get("id")
72+
if rid is None:
73+
return None
74+
full = index.get((ref.get("type"), rid))
75+
if full is not None:
76+
attrs = dict(full.get("attributes") or {})
77+
attrs["id"] = rid
78+
try:
79+
return model.model_validate(attrs)
80+
except Exception:
81+
# A hydration failure must never break parsing of the parent.
82+
return model.model_construct(id=rid)
83+
return model.model_construct(id=rid)
84+
85+
86+
def parse_relationships(
87+
relationships: dict[str, Any] | None,
88+
rel_map: RelationMap,
89+
*,
90+
included: list[dict[str, Any]] | None = None,
91+
) -> dict[str, Any]:
92+
"""Parse a JSON:API ``relationships`` block into ``{python_attr: value}``.
93+
94+
``rel_map`` maps each wire relationship name to either a model class (the
95+
python attribute is derived as ``wire.replace("-", "_")``) or an explicit
96+
``(python_attr, Model)`` tuple. Single references become a model instance;
97+
lists become a list of model instances. Null/empty relationships and
98+
relations absent from ``rel_map`` are skipped, so they never clobber model
99+
defaults (undeclared relations are caught by ``extra="allow"``).
100+
"""
101+
rels = relationships or {}
102+
index = build_included_index(included)
103+
out: dict[str, Any] = {}
104+
105+
for wire, spec in rel_map.items():
106+
if isinstance(spec, tuple):
107+
attr, model = spec
108+
else:
109+
attr, model = wire.replace("-", "_"), spec
110+
111+
rel = rels.get(wire)
112+
if not isinstance(rel, dict):
113+
continue
114+
data = rel.get("data")
115+
if data is None:
116+
continue
117+
if isinstance(data, list):
118+
out[attr] = [
119+
m
120+
for ref in data
121+
if isinstance(ref, dict)
122+
and (m := _hydrate(ref, model, index)) is not None
123+
]
124+
elif isinstance(data, dict):
125+
m = _hydrate(data, model, index)
126+
if m is not None:
127+
out[attr] = m
128+
129+
return out

src/pytfe/models/agent.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,9 @@ class Agent(BaseModel):
5252
class AgentPool(BaseModel):
5353
"""Agent Pool represents a Terraform Enterprise agent pool."""
5454

55-
model_config = ConfigDict(populate_by_name=True, validate_by_name=True)
55+
model_config = ConfigDict(
56+
populate_by_name=True, validate_by_name=True, extra="allow"
57+
)
5658

5759
id: str
5860
name: str | None = Field(default=None, alias="name")

src/pytfe/models/apply.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ class ApplyStatus(str, Enum):
2222

2323

2424
class Apply(BaseModel):
25-
model_config = ConfigDict(populate_by_name=True, validate_by_name=True)
25+
model_config = ConfigDict(
26+
populate_by_name=True, validate_by_name=True, extra="allow"
27+
)
2628

2729
id: str
2830
log_read_url: str | None = Field(None, alias="log-read-url")
@@ -37,7 +39,9 @@ class Apply(BaseModel):
3739

3840

3941
class ApplyStatusTimestamps(BaseModel):
40-
model_config = ConfigDict(populate_by_name=True, validate_by_name=True)
42+
model_config = ConfigDict(
43+
populate_by_name=True, validate_by_name=True, extra="allow"
44+
)
4145

4246
canceled_at: datetime | None = Field(None, alias="canceled-at")
4347
errored_at: datetime | None = Field(None, alias="errored-at")

src/pytfe/models/assessment_result.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
class AssessmentResult(BaseModel):
1212
"""Result of a workspace health assessment (drift detection)."""
1313

14-
model_config = ConfigDict(populate_by_name=True, validate_by_name=True)
14+
model_config = ConfigDict(
15+
populate_by_name=True, validate_by_name=True, extra="allow"
16+
)
1517

1618
id: str
1719
succeeded: bool | None = Field(default=None, alias="succeeded")

src/pytfe/models/comment.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010

1111

1212
class Comment(BaseModel):
13-
model_config = ConfigDict(populate_by_name=True, validate_by_name=True)
13+
model_config = ConfigDict(
14+
populate_by_name=True, validate_by_name=True, extra="allow"
15+
)
1416

1517
id: str
1618
body: str = Field(default="", alias="body")

src/pytfe/models/cost_estimate.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010

1111

1212
class CostEstimate(BaseModel):
13-
model_config = ConfigDict(populate_by_name=True, validate_by_name=True)
13+
model_config = ConfigDict(
14+
populate_by_name=True, validate_by_name=True, extra="allow"
15+
)
1416

1517
id: str
1618
delta_monthly_cost: str = Field(default="", alias="delta-monthly-cost")
@@ -36,7 +38,9 @@ class CostEstimateStatus(str, Enum):
3638

3739

3840
class CostEstimateStatusTimestamps(BaseModel):
39-
model_config = ConfigDict(populate_by_name=True, validate_by_name=True)
41+
model_config = ConfigDict(
42+
populate_by_name=True, validate_by_name=True, extra="allow"
43+
)
4044

4145
canceled_at: datetime = Field(..., alias="canceled-at")
4246
errored_at: datetime = Field(..., alias="errored-at")

src/pytfe/models/explorer.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ class ExplorerViewType(str, Enum):
2727
class ExplorerUrlFilter(BaseModel):
2828
"""One slot in ExplorerQueryOptions.filters → filter[i][field][op][idx] query keys."""
2929

30-
model_config = ConfigDict(populate_by_name=True, validate_by_name=True)
30+
model_config = ConfigDict(
31+
populate_by_name=True, validate_by_name=True, extra="allow"
32+
)
3133

3234
index: int = Field(..., ge=0, description="Filter index in the query string")
3335
field: str = Field(
@@ -72,7 +74,9 @@ class ExplorerRow(BaseModel):
7274
snake variants.
7375
"""
7476

75-
model_config = ConfigDict(populate_by_name=True, validate_by_name=True)
77+
model_config = ConfigDict(
78+
populate_by_name=True, validate_by_name=True, extra="allow"
79+
)
7680

7781
id: str
7882
type: str
@@ -82,7 +86,9 @@ class ExplorerRow(BaseModel):
8286
class ExplorerSavedQueryFilter(BaseModel):
8387
"""One saved-view filter row (list-valued `value` matches create/update JSON)."""
8488

85-
model_config = ConfigDict(populate_by_name=True, validate_by_name=True)
89+
model_config = ConfigDict(
90+
populate_by_name=True, validate_by_name=True, extra="allow"
91+
)
8692

8793
field: str = Field(..., min_length=1)
8894
operator: str = Field(..., min_length=1)
@@ -92,7 +98,9 @@ class ExplorerSavedQueryFilter(BaseModel):
9298
class ExplorerSavedQuery(BaseModel):
9399
"""Nested query on a saved view: view type, filters, optional fields and sort lists."""
94100

95-
model_config = ConfigDict(populate_by_name=True, validate_by_name=True)
101+
model_config = ConfigDict(
102+
populate_by_name=True, validate_by_name=True, extra="allow"
103+
)
96104

97105
query_type: ExplorerViewType = Field(..., alias="type")
98106
filter: list[ExplorerSavedQueryFilter] | None = None
@@ -109,7 +117,9 @@ class ExplorerSavedView(BaseModel):
109117
request/response payload.
110118
"""
111119

112-
model_config = ConfigDict(populate_by_name=True, validate_by_name=True)
120+
model_config = ConfigDict(
121+
populate_by_name=True, validate_by_name=True, extra="allow"
122+
)
113123

114124
id: str
115125
name: str

0 commit comments

Comments
 (0)