You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
@@ -77,6 +78,7 @@ These are mistakes a competent Python developer would make if they hadn't read t
77
78
-**Don't reuse generators.** Iterators returned by `list_*` are single-use. If you need to traverse twice, `materialized = list(client.foo.list_bars(...))` first.
78
79
-**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.
79
80
-**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).
80
82
-**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.
81
83
-**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.
Copy file name to clipboardExpand all lines: docs/MODELS.md
+26-2Lines changed: 26 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -26,6 +26,7 @@ New or touched `BaseModel` classes should set `model_config = ConfigDict(...)` u
26
26
|`populate_by_name=True`|**Always.** Lets callers pass either the field name (`created_at=...`) or the alias (`{"created-at": ...}`) when constructing. |
27
27
|`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`. |
28
28
|`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). |
29
30
|`arbitrary_types_allowed=True`| Only when you genuinely have a non-Pydantic type in a field (rare). |
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}`.
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("-", "_")`):
`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
+
186
209
### Option 2 — Flat `*_id` field
187
210
188
211
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
275
298
-[ ]`from __future__ import annotations` at the top
276
299
-[ ] New or touched classes use `model_config = ConfigDict(populate_by_name=True, validate_by_name=True)` unless preserving a local legacy pattern
-[ ]**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
278
302
-[ ] Response model fields default to `T | None = Field(None, alias="...")`
279
303
-[ ]`*CreateOptions` uses `Field(...)` for required fields, `T | None = None` for optional
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:
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:
199
206
200
207
```python
201
-
team_data = (relationships.get("team") or {}).get("data") or {}
208
+
team_data = (data.get("relationships", {}).get("team") or {}).get("data") or {}
202
209
if team_data.get("id"):
203
-
attributes["team-id"] = team_data["id"]
210
+
attrs["team-id"] = team_data["id"]
204
211
```
205
212
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[...]`.
207
216
208
217
209
218
## Pagination — use `self._list`, don't roll your own
0 commit comments