From 3618f25ce48f2554d95f017369c34489d5169a73 Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Tue, 16 Jun 2026 16:41:59 +0530 Subject: [PATCH 1/6] Refactor base model parser --- AGENTS.md | 2 + CHANGELOG.md | 14 ++ README.md | 2 +- docs/MODELS.md | 23 +++ docs/api-coverage.md | 12 +- docs/related-resources.md | 77 +++++++++ examples/related_resources.py | 67 +++++++ src/pytfe/_jsonapi.py | 32 +++- src/pytfe/models/__init__.py | 9 +- src/pytfe/models/_base.py | 137 +++++++++++++++ src/pytfe/models/admin_organization.py | 4 +- src/pytfe/models/admin_run.py | 3 +- src/pytfe/models/admin_user.py | 4 +- src/pytfe/models/admin_workspace.py | 4 +- src/pytfe/models/agent.py | 7 +- src/pytfe/models/apply.py | 4 +- src/pytfe/models/comment.py | 3 +- src/pytfe/models/configuration_version.py | 4 +- src/pytfe/models/explorer.py | 6 +- src/pytfe/models/github_app_installation.py | 4 +- src/pytfe/models/no_code_module.py | 5 +- .../models/notification_configuration.py | 4 +- src/pytfe/models/oauth_client.py | 4 +- src/pytfe/models/oauth_token.py | 4 +- src/pytfe/models/oidc_configuration.py | 9 +- src/pytfe/models/org_token_ttl_policy.py | 4 +- src/pytfe/models/organization.py | 4 +- .../organization_audit_configuration.py | 3 +- src/pytfe/models/organization_membership.py | 4 +- src/pytfe/models/organization_tags.py | 3 +- src/pytfe/models/organization_token.py | 4 +- src/pytfe/models/plan.py | 3 +- src/pytfe/models/policy.py | 3 +- src/pytfe/models/policy_check.py | 4 +- src/pytfe/models/policy_evaluation.py | 3 +- src/pytfe/models/policy_set.py | 3 +- src/pytfe/models/policy_set_outcome.py | 3 +- src/pytfe/models/policy_set_parameter.py | 3 +- src/pytfe/models/policy_set_version.py | 4 +- src/pytfe/models/project.py | 3 +- src/pytfe/models/query_run.py | 4 +- src/pytfe/models/registry_module.py | 6 +- src/pytfe/models/registry_provider.py | 3 +- .../models/registry_provider_platform.py | 3 +- src/pytfe/models/registry_provider_version.py | 3 +- src/pytfe/models/reserved_tag_key.py | 4 +- src/pytfe/models/run.py | 3 +- src/pytfe/models/run_event.py | 3 +- src/pytfe/models/run_task.py | 3 +- src/pytfe/models/run_trigger.py | 3 +- src/pytfe/models/ssh_key.py | 4 +- src/pytfe/models/stack.py | 3 +- src/pytfe/models/stack_configuration.py | 3 +- src/pytfe/models/state_version.py | 4 +- src/pytfe/models/state_version_output.py | 4 +- src/pytfe/models/task_result.py | 4 +- src/pytfe/models/task_stage.py | 4 +- src/pytfe/models/team.py | 3 +- src/pytfe/models/team_project_access.py | 3 +- src/pytfe/models/team_token.py | 3 +- src/pytfe/models/team_workspace_access.py | 4 +- src/pytfe/models/user.py | 4 +- src/pytfe/models/variable.py | 4 +- src/pytfe/models/variable_set.py | 5 +- src/pytfe/models/workspace.py | 3 +- src/pytfe/models/workspace_resource.py | 4 +- src/pytfe/models/workspace_run_task.py | 3 +- src/pytfe/resources/admin/_organizations.py | 5 +- src/pytfe/resources/admin/_runs.py | 16 +- src/pytfe/resources/admin/_users.py | 5 +- src/pytfe/resources/admin/_workspaces.py | 20 ++- src/pytfe/resources/agent_pools.py | 42 +++-- src/pytfe/resources/comment.py | 3 +- src/pytfe/resources/configuration_version.py | 3 +- .../resources/github_app_installation.py | 5 +- src/pytfe/resources/no_code_module.py | 6 +- .../resources/notification_configuration.py | 3 +- src/pytfe/resources/oauth_client.py | 3 +- .../organization_audit_configuration.py | 24 +-- .../resources/organization_membership.py | 18 +- src/pytfe/resources/policy.py | 9 +- src/pytfe/resources/policy_check.py | 7 +- src/pytfe/resources/policy_evaluation.py | 3 +- src/pytfe/resources/policy_set.py | 9 +- src/pytfe/resources/policy_set_outcome.py | 5 +- src/pytfe/resources/policy_set_parameter.py | 3 +- src/pytfe/resources/policy_set_version.py | 9 +- src/pytfe/resources/projects.py | 3 +- src/pytfe/resources/query_run.py | 9 +- src/pytfe/resources/registry_module.py | 36 ++-- src/pytfe/resources/registry_provider.py | 3 +- .../resources/registry_provider_platform.py | 3 +- .../resources/registry_provider_version.py | 4 +- src/pytfe/resources/run.py | 5 +- src/pytfe/resources/run_event.py | 16 +- src/pytfe/resources/run_task.py | 28 +-- src/pytfe/resources/run_trigger.py | 20 ++- src/pytfe/resources/stack.py | 4 +- src/pytfe/resources/stack_configuration.py | 17 +- src/pytfe/resources/state_version_outputs.py | 19 +- src/pytfe/resources/state_versions.py | 57 ++++-- src/pytfe/resources/task_result.py | 3 +- src/pytfe/resources/task_stage.py | 4 +- src/pytfe/resources/team.py | 4 +- src/pytfe/resources/team_project_access.py | 3 +- src/pytfe/resources/team_token.py | 4 +- src/pytfe/resources/team_workspace_access.py | 3 +- src/pytfe/resources/variable_sets.py | 5 +- src/pytfe/resources/workspace_resources.py | 28 +-- src/pytfe/resources/workspace_run_task.py | 3 +- src/pytfe/resources/workspaces.py | 27 ++- tests/units/test_workspace_jsonapi.py | 163 +++++++++++++++++- 112 files changed, 988 insertions(+), 273 deletions(-) create mode 100644 docs/related-resources.md create mode 100644 examples/related_resources.py create mode 100644 src/pytfe/models/_base.py diff --git a/AGENTS.md b/AGENTS.md index b3f5ee4a..cc2403b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,6 +57,8 @@ A handful of conventions are pervasive enough that you'll regret breaking them. 2. **JSON:API attribute names go through `Field(alias="...")`.** The API sends `created-at`, Python uses `created_at`. Pair with `model_config = ConfigDict(populate_by_name=True, validate_by_name=True)` on the model. ([MODELS.md](docs/MODELS.md)) +2b. **Top-level response/resource models inherit `TFEModel`, not `BaseModel`.** It's config-light (you keep your own `model_config`) and adds the lossless `.relationships`/`.included`/`.related()`/`.has_*` accessors. Wire the resource's parser to call `attach_jsonapi(model, data, included)` so they're populated. Options/sub-object/enum models stay on `BaseModel`. ([MODELS.md](docs/MODELS.md) — TFEModel vs BaseModel) + 3. **`model_dump(by_alias=True, exclude_none=True)` for write payloads.** Without `by_alias=True` you'll send snake_case to the API and it will silently drop the fields. Add `mode="json"` if the options contain enums. 4. **For new public APIs, prefer typed `TFEError` subclasses.** The error hierarchy in `errors.py` is part of the public API, and downstream consumers often `except TFEError:` once. Existing methods still expose many `ValueError` paths; do not change those established exceptions unless the breaking-change impact is explicitly accepted. diff --git a/CHANGELOG.md b/CHANGELOG.md index 80ff5002..9f76a39b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Unreleased +## Enhancements + +### Relationships +* 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*. + * **`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. + * **`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. + + See [docs/related-resources.md](docs/related-resources.md). + +## Bug Fixes + +### Relationships +* 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.) + # Released # v1.1.0 diff --git a/README.md b/README.md index 88f31539..97913f57 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,7 @@ and upstream HCP Terraform API docs. | Need | Start here | |---|---| | Configure the SDK | [Authentication](./docs/authentication.md), [Pagination](./docs/pagination.md), [Logging](./docs/LOGGING.md) | -| API guides | [API index](./docs/api/index.md), [API coverage](./docs/api-coverage.md), [Workspaces](./docs/api/workspaces.md), [Runs/plans/applies](./docs/api/runs-plans-applies.md), [State versions](./docs/api/state-versions.md) | +| API guides | [API index](./docs/api/index.md), [API coverage](./docs/api-coverage.md), [Related resources (`include`)](./docs/related-resources.md), [Workspaces](./docs/api/workspaces.md), [Runs/plans/applies](./docs/api/runs-plans-applies.md), [State versions](./docs/api/state-versions.md) | | Scenario guides | [API-driven run](./docs/scenarios/api-driven-run.md), [State management](./docs/scenarios/state-management.md), [Migrate workspaces and state](./docs/scenarios/migrate-workspaces-and-state.md), [Team access onboarding](./docs/scenarios/team-access-onboarding.md), [No-code provisioning](./docs/scenarios/no-code-provisioning.md), [TFE identity bootstrap](./docs/scenarios/tfe-identity-bootstrap.md), [TFE admin bootstrap](./docs/scenarios/tfe-admin-bootstrap.md), [OIDC dynamic credentials](./docs/scenarios/oidc-dynamic-credentials.md) | | Operations guides | [Troubleshooting](./docs/troubleshooting.md), [Errors](./docs/errors.md), [Terraform Enterprise](./docs/terraform-enterprise.md) | | Contribute to the SDK | [CONTRIBUTING](./docs/CONTRIBUTING.md), [ITERATORS](./docs/ITERATORS.md), [MODELS](./docs/MODELS.md), [RESOURCE](./docs/RESOURCE.md) | diff --git a/docs/MODELS.md b/docs/MODELS.md index 71d3cfdd..942a365c 100644 --- a/docs/MODELS.md +++ b/docs/MODELS.md @@ -37,6 +37,29 @@ class Foo(BaseModel): ... ``` +## Base class: `TFEModel` vs `BaseModel` + +| Inherit from | For | +|---|---| +| **`TFEModel`** (`pytfe.models`, defined in `models/_base.py`) | **Top-level resource models** — anything returned from a `read`/`list`/`create`/`update` that corresponds to a JSON:API *resource object* (`Workspace`, `Run`, `Project`, `Policy`, `AdminRun`, …). | +| **`BaseModel`** | Everything else: `*CreateOptions` / `*UpdateOptions` / `*ListOptions`, nested attribute sub-objects (`WorkspacePermissions`, `VCSRepo`, …), enums, and `*List` envelopes. | + +`TFEModel` is **config-light** — it adds no `model_config`, so you still set your own (`extra="allow"`, etc.) exactly as above. What it adds is the lossless related-resource escape hatch: `.relationships`, `.included`, `.included_by(type, id)`, `.related(name)`, and the `.has_relationships` / `.has_included` presence flags. These are private attributes, so they never touch `model_dump()` and add no public fields — inheriting it is additive and non-breaking. + +For the accessors to be *populated* (not just present-and-empty), the resource's parser must hand the raw JSON:API resource dict (and any document `included`) to `attach_jsonapi`: + +```python +from .._jsonapi import attach_jsonapi, parse_relationships # in a resources/*.py + +def _foo_from(data, included=None): + attr = dict(data.get("attributes") or {}) + attr["id"] = data.get("id") + attr.update(parse_relationships(data.get("relationships"), _FOO_REL_MAP, included=included)) + return attach_jsonapi(Foo.model_validate(attr), data, included) +``` + +`attach_jsonapi(obj, data, included)` is the one line that captures both raw blocks; pass `included=payload.get("included")` from any `read`/`list` that supports `?include=`. See [related-resources.md](related-resources.md) for the consumer-facing view. + ## Field aliases: JSON:API hyphens → Python snake_case HCP Terraform speaks JSON:API, which uses hyphenated attribute names (`created-at`, `auto-apply`, `state-versions`). Python uses snake_case. Bridge with `Field(alias=...)`: diff --git a/docs/api-coverage.md b/docs/api-coverage.md index 2414344a..459f98c2 100644 --- a/docs/api-coverage.md +++ b/docs/api-coverage.md @@ -75,7 +75,9 @@ partially covered are listed at the bottom of this page. | | Azure OIDC configurations | `client.azure_oidc_configurations` | ✅ | | | GCP OIDC configurations | `client.gcp_oidc_configurations` | ✅ | | | Vault OIDC configurations | `client.vault_oidc_configurations` | ✅ | -| Admin | SAML / SCIM / SMTP / token-TTL settings | `client.admin` | ✅ | +| Admin (TFE site-admin) | Organizations, users, runs, workspaces | `client.admin.organizations` / `.users` / `.runs` / `.workspaces` | ✅ | +| | Terraform / OPA / Sentinel versions | `client.admin.terraform_versions` / `.opa_versions` / `.sentinel_versions` | ✅ | +| | SAML / SCIM / SMTP settings + SCIM tokens | `client.admin.saml_settings` / `.scim_settings` / `.scim_tokens` / `.smtp_settings` | ✅ | ## Partial coverage @@ -117,8 +119,6 @@ Public HCP Terraform API resources that do not yet have a pytfe client namespace | User tokens | Personal (user) API tokens. | | VCS events | — | -### Terraform Enterprise only (separate admin API) - -| Resource | Notes | -|---|---| -| Site-admin | TFE site-admin endpoints (admin organizations, users, runs, workspaces, Terraform / OPA / Sentinel versions). Not part of the public HCP Terraform API. | +> Note: the TFE site-admin API (`/api/v2/admin/*`, TFE-only — not part of the +> public HCP Terraform API) **is** implemented under `client.admin` (see the +> Admin rows above). diff --git a/docs/related-resources.md b/docs/related-resources.md new file mode 100644 index 00000000..8ae034fc --- /dev/null +++ b/docs/related-resources.md @@ -0,0 +1,77 @@ +# Related resources (`?include=`, relationships & included) + +HCP Terraform speaks [JSON:API](https://developer.hashicorp.com/terraform/cloud-docs/api-docs#inclusion-of-related-resources). +A resource carries a **`relationships`** block — linkage references (`type` + `id`) +for every related resource — and, when you request `?include=`, the response also +carries a top-level **`included`** array holding the *full bodies* of those +relations. + +pyTFE handles this on two levels: + +1. **Typed hydration** — relationships the SDK models are parsed into typed + fields, and when you pass `include=...` those fields are filled from + `included`. For example: + + ```python + from pytfe import TFEClient + from pytfe.models.workspace import WorkspaceReadOptions, WorkspaceIncludeOpt + + client = TFEClient() + ws = client.workspaces.read_by_id_with_options( + "ws-abc123", + WorkspaceReadOptions(include=[WorkspaceIncludeOpt.OUTPUTS, WorkspaceIncludeOpt.PROJECT]), + ) + + for o in ws.outputs: # fully hydrated from `included` + print(o.name, o.value) + print(ws.project.name) # not just the id — the real project record + ``` + +2. **Lossless raw access** — even relations the SDK does **not** model as typed + fields are never lost. Every resource on the relationship-parsing path keeps + the raw blocks, reachable through four accessors: + + | Accessor | Returns | + |---|---| + | `model.relationships` | the raw `relationships` block (dict) | + | `model.included` | the raw `included` array (list of dicts) | + | `model.has_relationships` | `True` if a `relationships` block was on the wire | + | `model.has_included` | `True` if a top-level `included` array was on the wire | + | `model.included_by(type, id)` | one included object matched by `type` + `id` | + | `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) | + + `.relationships` / `.included` are always present and stably typed — they + return `{}` / `[]` whether the block was *empty* or *absent*. The API genuinely + distinguishes the two (SSH keys omit `relationships` entirely; `included` + only appears with `?include=`), so `has_relationships` / `has_included` tell + you which — without making the data accessors conditionally vanish. + + ```python + # Reach a related resource the SDK doesn't expose as a typed field: + readme = ws.included_by("workspace-readme", "rm-1") + if readme: + print(readme["attributes"]["raw-markdown"]) + + # Or resolve a whole relationship by name: + for out in ws.related("outputs"): + print(out["attributes"]["name"], out["attributes"]["value"]) + + # Enumerate every relationship the API returned, modelled or not: + print(list(ws.relationships)) # e.g. ['organization', 'project', 'outputs', ...] + ``` + +## Notes + +- The raw blocks are **private attributes**, so they never appear in + `model_dump()` / serialized output and add no public fields. They're an + untyped escape hatch, not a stable typed API — prefer the typed fields when a + relation is modelled. +- This complements `extra="allow"`, which retains unknown **attributes**; + `relationships`/`included` cover unknown **relations**. Together nothing the + API returns is silently dropped. +- Accessors are provided by `pytfe.models.TFEModel`, which **every + resource model** now derives from — so `.relationships` / `.included` / + `.included_by` / `.related` are available everywhere. They're *populated* on + resources parsed through a dedicated parser; other resources expose the + accessors but return them empty until their parser is wired to capture the + raw blocks. diff --git a/examples/related_resources.py b/examples/related_resources.py new file mode 100644 index 00000000..dccb7232 --- /dev/null +++ b/examples/related_resources.py @@ -0,0 +1,67 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +""" +Accessing related resources: ?include=, relationships and the included block. + +HCP Terraform returns related data in two places (JSON:API): + * `relationships` — linkage refs (type + id) for every related resource + * `included` — full bodies of relations you ask for with ?include= + +pyTFE hydrates the relations it models into typed fields, AND keeps both raw +blocks so nothing is ever lost. This example shows both. + +Prerequisites: + export TFE_TOKEN=... # your API token + python examples/related_resources.py ws-abc123 # a workspace id +""" + +from __future__ import annotations + +import sys + +from pytfe import TFEClient +from pytfe.models.workspace import WorkspaceIncludeOpt, WorkspaceReadOptions + + +def main(workspace_id: str) -> None: + client = TFEClient() + + # Ask the API to include the workspace's outputs and project. + ws = client.workspaces.read_by_id_with_options( + workspace_id, + WorkspaceReadOptions( + include=[WorkspaceIncludeOpt.OUTPUTS, WorkspaceIncludeOpt.PROJECT] + ), + ) + + # 1) Typed hydration — modelled relations are filled from `included`. + print(f"workspace: {ws.name}") + if ws.project: + print(f" project (hydrated): {ws.project.name}") + for o in ws.outputs: + print(f" output (hydrated): {o.name} = {o.value!r} ({o.output_type})") + + # 2) Lossless raw access — works for ANY relation, even unmodelled ones. + print(f"\n all relationships returned: {sorted(ws.relationships)}") + + # Resolve a relationship by name to full bodies (or bare refs if not included) + for out in ws.related("outputs"): + attrs = out.get("attributes", {}) + print(f" related('outputs'): {attrs.get('name')} -> {attrs.get('value')!r}") + + # Look up a single included resource by type + id + if ws.outputs: + raw = ws.included_by("workspace-outputs", ws.outputs[0].id) + print(f"\n included_by(...): {raw and raw.get('attributes', {}).get('name')}") + + # The raw blocks are private — they never leak into serialized output: + assert "included" not in ws.model_dump() + assert "relationships" not in ws.model_dump() + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("usage: python examples/related_resources.py ") + raise SystemExit(2) + main(sys.argv[1]) diff --git a/src/pytfe/_jsonapi.py b/src/pytfe/_jsonapi.py index c5055f38..6cf99fea 100644 --- a/src/pytfe/_jsonapi.py +++ b/src/pytfe/_jsonapi.py @@ -3,7 +3,12 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any, TypeVar + +if TYPE_CHECKING: + from .models._base import TFEModel + +_M = TypeVar("_M", bound="TFEModel") def build_headers(user_agent_suffix: str | None = None) -> dict[str, str]: @@ -83,6 +88,31 @@ def _hydrate(ref: dict[str, Any], model: Any, index: IncludedIndex) -> Any | Non return model.model_construct(id=rid) +def attach_jsonapi( + obj: _M, + data: dict[str, Any] | None, + included: list[dict[str, Any]] | None = None, +) -> _M: + """Stash the raw JSON:API ``relationships`` + ``included`` on a parsed model. + + ``obj`` must derive from ``pytfe.models.TFEModel`` (which provides the + private slots and the ``relationships`` / ``included`` / ``included_by`` / + ``related`` accessors). ``data`` is the JSON:API resource object (we read its + ``relationships`` block); ``included`` is the document's top-level array. + + This is the lossless safety net: related resources — modelled or not — are + always reachable raw. No-op-safe when either argument is ``None``. Tracks + whether each block was actually present on the wire (``has_relationships`` / + ``has_included``) so callers can tell "absent" from "present but empty". + """ + rel = data.get("relationships") if isinstance(data, dict) else None + obj._relationships_present = isinstance(data, dict) and "relationships" in data + obj._relationships = rel or {} + obj._included_present = included is not None + obj._included = list(included) if included is not None else [] + return obj + + def parse_relationships( relationships: dict[str, Any] | None, rel_map: RelationMap, diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index cb4a432a..892cf03d 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -3,6 +3,11 @@ from __future__ import annotations +# ── Core models split out of old types.py ───────────────────────────────────── +# Adjust these imports to match where you placed them during the split. +# Common / pagination / enums +from ._base import TFEModel as TFEModel + # ── TFE admin identity (SAML / SCIM) ────────────────────────────────────────── from .admin_identity import ( AdminSAMLSettings, @@ -80,10 +85,6 @@ Comment, CommentCreateOptions, ) - -# ── Core models split out of old types.py ───────────────────────────────────── -# Adjust these imports to match where you placed them during the split. -# Common / pagination / enums from .common import ( EffectiveTagBinding, Pagination, diff --git a/src/pytfe/models/_base.py b/src/pytfe/models/_base.py new file mode 100644 index 00000000..62aae3f6 --- /dev/null +++ b/src/pytfe/models/_base.py @@ -0,0 +1,137 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Shared model base class. + +``TFEModel`` is the base for every top-level resource model (anything parsed +from a JSON:API *resource object* and returned from a ``read``/``list``/etc.). +It is intentionally config-light — subclasses keep their own ``model_config`` — +and only adds the lossless related-resource escape hatch (``relationships`` / +``included`` and friends). Sub-objects, ``*Options`` models, and enums stay on +``pydantic.BaseModel``. See ``docs/MODELS.md`` (TFEModel vs BaseModel). +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, PrivateAttr + + +class TFEModel(BaseModel): + """Base for resources parsed from a JSON:API document. + + A JSON:API resource carries a ``relationships`` block (linkage references — + ``type`` + ``id`` — for every related resource, modelled or not), and a + response may carry a top-level ``included`` array with the full bodies of + relations requested via ``?include=``. Declared relationships are hydrated + into typed fields, but anything the SDK does not model would otherwise be + dropped. This base keeps **both raw blocks** on the instance so related data + is never lost — a lossless escape hatch that complements ``extra="allow"`` + (which only retains unknown *attributes*). + + Both are private attributes, so they never appear in ``model_dump()`` and add + no public fields. Subclasses keep their own ``model_config``. + + The accessors are always present and stably typed — they return ``{}`` / ``[]`` + when the block was empty *or* absent from the wire. Because the API genuinely + distinguishes the two (e.g. SSH keys omit ``relationships`` entirely, and + ``included`` only appears when ``?include=`` is used), ``has_relationships`` / + ``has_included`` report whether the block was actually present on the wire — + without making the data accessors conditionally disappear. + + Accessors + --------- + * ``model.relationships`` — raw ``relationships`` block (dict). + * ``model.included`` — raw ``included`` array (list of dicts). + * ``model.has_relationships`` — was a ``relationships`` block on the wire? + * ``model.has_included`` — was a top-level ``included`` array present? + * ``model.included_by(type, id)`` — one included object by ``type``+``id``. + * ``model.related(name)`` — refs of relationship ``name`` resolved to + their full included bodies (falling back to the bare ``{type, id}`` ref + when that relation was not ``?include=``-d). + """ + + _relationships: dict[str, Any] = PrivateAttr(default_factory=dict) + _included: list[dict[str, Any]] = PrivateAttr(default_factory=list) + _relationships_present: bool = PrivateAttr(default=False) + _included_present: bool = PrivateAttr(default=False) + + def __eq__(self, other: object) -> bool: + # Pydantic's default __eq__ also compares ``__pydantic_private__``, which + # would make two models with identical public fields unequal merely + # because one captured raw relationships/included. Those private blocks + # are an out-of-band escape hatch and must NOT affect equality, so we + # compare only the public surface (fields + model_extra) — keeping + # equality behaviour identical to a plain BaseModel. + if not isinstance(other, BaseModel): + return NotImplemented + return ( + self.__class__ == other.__class__ + and self.__dict__ == other.__dict__ + and self.__pydantic_extra__ == other.__pydantic_extra__ + ) + + __hash__ = None # type: ignore[assignment] # match BaseModel: unhashable + + @property + def relationships(self) -> dict[str, Any]: + """Raw JSON:API ``relationships`` block (untyped), exactly as returned.""" + return self._relationships + + @property + def included(self) -> list[dict[str, Any]]: + """Raw JSON:API ``included`` objects (untyped), exactly as returned.""" + return self._included + + @property + def has_relationships(self) -> bool: + """Whether a ``relationships`` block was present on the wire. + + Distinguishes "absent" from "present but empty" — both leave + ``relationships == {}``. + """ + return self._relationships_present + + @property + def has_included(self) -> bool: + """Whether a top-level ``included`` array was present on the wire. + + Distinguishes "absent" (no ``?include=`` / none returned) from "present + but empty" — both leave ``included == []``. + """ + return self._included_present + + def included_by(self, type_: str, id_: str) -> dict[str, Any] | None: + """Return the raw included resource matching JSON:API ``type``+``id``.""" + return next( + ( + i + for i in self._included + if i.get("type") == type_ and i.get("id") == id_ + ), + None, + ) + + def related(self, name: str) -> list[dict[str, Any]]: + """Resolve relationship ``name`` to a list of raw related objects. + + Each linkage reference is replaced by its full body from ``included`` + when present, otherwise the bare ``{type, id}`` reference is returned. + Always returns a list (single relations become a one-item list). + """ + rel = self._relationships.get(name) or {} + data = rel.get("data") + refs = data if isinstance(data, list) else ([data] if data else []) + out: list[dict[str, Any]] = [] + for ref in refs: + if not isinstance(ref, dict): + continue + rtype, rid = ref.get("type"), ref.get("id") + full = ( + self.included_by(rtype, rid) + if isinstance(rtype, str) and isinstance(rid, str) + else None + ) + out.append(full or ref) + return out diff --git a/src/pytfe/models/admin_organization.py b/src/pytfe/models/admin_organization.py index 96328c4a..a8807840 100644 --- a/src/pytfe/models/admin_organization.py +++ b/src/pytfe/models/admin_organization.py @@ -5,6 +5,8 @@ from pydantic import BaseModel, ConfigDict, Field +from ._base import TFEModel + class AdminOrganizationListOptions(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) @@ -13,7 +15,7 @@ class AdminOrganizationListOptions(BaseModel): page_size: int | None = Field(default=None, alias="page[size]") -class AdminOrganization(BaseModel): +class AdminOrganization(TFEModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) id: str | None = None name: str | None = None diff --git a/src/pytfe/models/admin_run.py b/src/pytfe/models/admin_run.py index 8bd25091..2e20d412 100644 --- a/src/pytfe/models/admin_run.py +++ b/src/pytfe/models/admin_run.py @@ -5,6 +5,7 @@ from pydantic import BaseModel, ConfigDict, Field +from ._base import TFEModel from .run import RunStatus @@ -16,7 +17,7 @@ class AdminRunListOptions(BaseModel): page_size: int | None = Field(default=None, alias="page[size]") -class AdminRun(BaseModel): +class AdminRun(TFEModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) id: str | None = None status: RunStatus | None = None diff --git a/src/pytfe/models/admin_user.py b/src/pytfe/models/admin_user.py index 0a78a01a..971eee36 100644 --- a/src/pytfe/models/admin_user.py +++ b/src/pytfe/models/admin_user.py @@ -5,6 +5,8 @@ from pydantic import BaseModel, ConfigDict, Field +from ._base import TFEModel + class AdminUserListOptions(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) @@ -15,7 +17,7 @@ class AdminUserListOptions(BaseModel): page_size: int | None = Field(default=None, alias="page[size]") -class AdminUser(BaseModel): +class AdminUser(TFEModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) id: str | None = None username: str | None = None diff --git a/src/pytfe/models/admin_workspace.py b/src/pytfe/models/admin_workspace.py index 7acb2ad8..74be1758 100644 --- a/src/pytfe/models/admin_workspace.py +++ b/src/pytfe/models/admin_workspace.py @@ -5,6 +5,8 @@ from pydantic import BaseModel, ConfigDict, Field +from ._base import TFEModel + class AdminWorkspaceListOptions(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) @@ -13,7 +15,7 @@ class AdminWorkspaceListOptions(BaseModel): page_size: int | None = Field(default=None, alias="page[size]") -class AdminWorkspace(BaseModel): +class AdminWorkspace(TFEModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) id: str | None = None name: str | None = None diff --git a/src/pytfe/models/agent.py b/src/pytfe/models/agent.py index aee633f3..e51a7231 100644 --- a/src/pytfe/models/agent.py +++ b/src/pytfe/models/agent.py @@ -20,6 +20,7 @@ RequiredNameError, ) from ..utils import valid_string, valid_string_id +from ._base import TFEModel from .organization import Organization from .workspace import Workspace @@ -35,7 +36,7 @@ class AgentStatus(str, Enum): UNKNOWN = "unknown" -class Agent(BaseModel): +class Agent(TFEModel): """Agent represents a Terraform Enterprise agent.""" id: str @@ -49,7 +50,7 @@ class Agent(BaseModel): agent_pool: AgentPool | None = None -class AgentPool(BaseModel): +class AgentPool(TFEModel): """Agent Pool represents a Terraform Enterprise agent pool.""" model_config = ConfigDict( @@ -209,7 +210,7 @@ class AgentTokenCreateOptions(BaseModel): description: str -class AgentToken(BaseModel): +class AgentToken(TFEModel): """Agent Token represents an authentication token for agents.""" id: str diff --git a/src/pytfe/models/apply.py b/src/pytfe/models/apply.py index 047bbb19..e9b72497 100644 --- a/src/pytfe/models/apply.py +++ b/src/pytfe/models/apply.py @@ -8,6 +8,8 @@ from pydantic import BaseModel, ConfigDict, Field +from ._base import TFEModel + class ApplyStatus(str, Enum): APPLY_CANCELED = "canceled" @@ -21,7 +23,7 @@ class ApplyStatus(str, Enum): APPLY_UNREACHABLE = "unreachable" -class Apply(BaseModel): +class Apply(TFEModel): model_config = ConfigDict( populate_by_name=True, validate_by_name=True, extra="allow" ) diff --git a/src/pytfe/models/comment.py b/src/pytfe/models/comment.py index f9ec97ab..1b125d2c 100644 --- a/src/pytfe/models/comment.py +++ b/src/pytfe/models/comment.py @@ -7,9 +7,10 @@ from ..errors import RequiredCommentBodyError from ..utils import valid_string +from ._base import TFEModel -class Comment(BaseModel): +class Comment(TFEModel): model_config = ConfigDict( populate_by_name=True, validate_by_name=True, extra="allow" ) diff --git a/src/pytfe/models/configuration_version.py b/src/pytfe/models/configuration_version.py index d45599eb..9ddd0c22 100644 --- a/src/pytfe/models/configuration_version.py +++ b/src/pytfe/models/configuration_version.py @@ -8,6 +8,8 @@ from pydantic import BaseModel, Field +from ._base import TFEModel + class ConfigurationStatus(str, Enum): """Configuration version status enumeration.""" @@ -61,7 +63,7 @@ class IngressAttributes(BaseModel): model_config = {"populate_by_name": True} -class ConfigurationVersion(BaseModel): +class ConfigurationVersion(TFEModel): """Configuration version model.""" id: str diff --git a/src/pytfe/models/explorer.py b/src/pytfe/models/explorer.py index 227242f8..4eae317b 100644 --- a/src/pytfe/models/explorer.py +++ b/src/pytfe/models/explorer.py @@ -14,6 +14,8 @@ from pydantic import BaseModel, ConfigDict, Field +from ._base import TFEModel + class ExplorerViewType(str, Enum): """Explorer `type` / `query-type` discriminator (HashiCorp Explorer API view types only).""" @@ -66,7 +68,7 @@ class ExplorerQueryOptions(BaseModel): ) -class ExplorerRow(BaseModel): +class ExplorerRow(TFEModel): """One Explorer result row: JSON:API id/type plus flat attributes for the view. Attribute keys are normalised to snake_case at parse time so callers can @@ -108,7 +110,7 @@ class ExplorerSavedQuery(BaseModel): sort: list[str] | None = None -class ExplorerSavedView(BaseModel): +class ExplorerSavedView(TFEModel): """Saved view resource: metadata plus embedded query. The HCP Terraform API returns ``query-type`` at the view level *and* diff --git a/src/pytfe/models/github_app_installation.py b/src/pytfe/models/github_app_installation.py index ca8faf44..e7503b1e 100644 --- a/src/pytfe/models/github_app_installation.py +++ b/src/pytfe/models/github_app_installation.py @@ -15,6 +15,8 @@ from pydantic import BaseModel, ConfigDict, Field +from ._base import TFEModel + class GitHubAppInstallationType(str, Enum): """Whether the GitHub App is installed against a user account or @@ -30,7 +32,7 @@ class GitHubAppInstallationType(str, Enum): ORGANIZATION = "Organization" -class GitHubAppInstallation(BaseModel): +class GitHubAppInstallation(TFEModel): """A GitHub App installation visible to the authenticated user.""" model_config = ConfigDict(populate_by_name=True, validate_by_name=True) diff --git a/src/pytfe/models/no_code_module.py b/src/pytfe/models/no_code_module.py index e069879c..151aec92 100644 --- a/src/pytfe/models/no_code_module.py +++ b/src/pytfe/models/no_code_module.py @@ -7,6 +7,7 @@ from pydantic import BaseModel, ConfigDict, Field +from ._base import TFEModel from .organization import Organization from .registry_module import RegistryModule from .variable import CategoryType @@ -37,7 +38,7 @@ class NoCodeVariableOption(BaseModel): options: list[str] = Field(default_factory=list) -class NoCodeModule(BaseModel): +class NoCodeModule(TFEModel): """Represents a no-code module — a registry module that has been enabled for the no-code provisioning workflow. """ @@ -144,7 +145,7 @@ class NoCodeWorkspaceUpgradeOptions(BaseModel): vars: list[NoCodeWorkspaceVariable] = Field(default_factory=list) -class WorkspaceUpgrade(BaseModel): +class WorkspaceUpgrade(TFEModel): """The result of initiating or polling a no-code workspace upgrade.""" model_config = ConfigDict( diff --git a/src/pytfe/models/notification_configuration.py b/src/pytfe/models/notification_configuration.py index fc667024..3b90ba25 100644 --- a/src/pytfe/models/notification_configuration.py +++ b/src/pytfe/models/notification_configuration.py @@ -16,6 +16,8 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator +from ._base import TFEModel + class NotificationTriggerType(Enum): """Represents the different TFE notifications that can be sent as a run's progress transitions between different states.""" @@ -78,7 +80,7 @@ class NotificationConfigurationSubscribableChoice(BaseModel): workspace: Any | None = None -class NotificationConfiguration(BaseModel): +class NotificationConfiguration(TFEModel): """Represents a Notification Configuration.""" model_config = ConfigDict( diff --git a/src/pytfe/models/oauth_client.py b/src/pytfe/models/oauth_client.py index 1b149496..11b98e37 100644 --- a/src/pytfe/models/oauth_client.py +++ b/src/pytfe/models/oauth_client.py @@ -8,6 +8,8 @@ from pydantic import BaseModel, ConfigDict, Field +from ._base import TFEModel + class ServiceProviderType(str, Enum): """VCS service provider types.""" @@ -32,7 +34,7 @@ class OAuthClientIncludeOpt(str, Enum): PROJECTS = "projects" -class OAuthClient(BaseModel): +class OAuthClient(TFEModel): """OAuth client represents a connection between an organization and a VCS provider.""" id: str | None = None diff --git a/src/pytfe/models/oauth_token.py b/src/pytfe/models/oauth_token.py index 9cc78cad..edb30024 100644 --- a/src/pytfe/models/oauth_token.py +++ b/src/pytfe/models/oauth_token.py @@ -8,11 +8,13 @@ from pydantic import BaseModel, ConfigDict, Field +from ._base import TFEModel + if TYPE_CHECKING: from .oauth_client import OAuthClient -class OAuthToken(BaseModel): +class OAuthToken(TFEModel): """OAuth token represents a VCS configuration including the associated OAuth token.""" model_config = ConfigDict(extra="forbid") diff --git a/src/pytfe/models/oidc_configuration.py b/src/pytfe/models/oidc_configuration.py index f8ae6e02..edf5e1a4 100644 --- a/src/pytfe/models/oidc_configuration.py +++ b/src/pytfe/models/oidc_configuration.py @@ -24,6 +24,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator +from ._base import TFEModel from .organization import Organization @@ -39,7 +40,7 @@ def _non_empty_role_arn(value: str) -> str: # --------------------------------------------------------------------------- -class AWSOIDCConfiguration(BaseModel): +class AWSOIDCConfiguration(TFEModel): """An AWS OIDC configuration record on HCP Terraform.""" model_config = ConfigDict(populate_by_name=True, validate_by_name=True) @@ -80,7 +81,7 @@ class AWSOIDCConfigurationUpdateOptions(BaseModel): # --------------------------------------------------------------------------- -class AzureOIDCConfiguration(BaseModel): +class AzureOIDCConfiguration(TFEModel): """An Azure OIDC configuration record on HCP Terraform.""" model_config = ConfigDict(populate_by_name=True, validate_by_name=True) @@ -118,7 +119,7 @@ class AzureOIDCConfigurationUpdateOptions(BaseModel): # --------------------------------------------------------------------------- -class GCPOIDCConfiguration(BaseModel): +class GCPOIDCConfiguration(TFEModel): """A GCP OIDC configuration record on HCP Terraform.""" model_config = ConfigDict(populate_by_name=True, validate_by_name=True) @@ -164,7 +165,7 @@ class GCPOIDCConfigurationUpdateOptions(BaseModel): # --------------------------------------------------------------------------- -class VaultOIDCConfiguration(BaseModel): +class VaultOIDCConfiguration(TFEModel): """A Vault OIDC configuration record on HCP Terraform. Field-name mappings: diff --git a/src/pytfe/models/org_token_ttl_policy.py b/src/pytfe/models/org_token_ttl_policy.py index 24adf20d..3cbe3fb8 100644 --- a/src/pytfe/models/org_token_ttl_policy.py +++ b/src/pytfe/models/org_token_ttl_policy.py @@ -32,6 +32,8 @@ from pydantic import BaseModel, ConfigDict, Field +from ._base import TFEModel + # 2 years in milliseconds — the documented default the upstream applies # when no per-token policy is set. Exported for callers who want to # reset to defaults without recomputing. @@ -50,7 +52,7 @@ class TokenPolicyType(str, Enum): AUDIT_TRAILS = "audit_trails" -class OrgTokenTTLPolicy(BaseModel): +class OrgTokenTTLPolicy(TFEModel): """One token-type / max-TTL entry as returned by the list endpoint.""" model_config = ConfigDict(populate_by_name=True, validate_by_name=True) diff --git a/src/pytfe/models/organization.py b/src/pytfe/models/organization.py index 93938138..e87d4bd8 100644 --- a/src/pytfe/models/organization.py +++ b/src/pytfe/models/organization.py @@ -9,6 +9,8 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator +from ._base import TFEModel + class OrganizationUpdateOptions(BaseModel): # populate_by_name lets existing callers keep passing snake_case @@ -100,7 +102,7 @@ class RunStatus(str, Enum): ERRORED = "errored" -class Organization(BaseModel): +class Organization(TFEModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) name: str | None = None diff --git a/src/pytfe/models/organization_audit_configuration.py b/src/pytfe/models/organization_audit_configuration.py index a045396a..a1a82023 100644 --- a/src/pytfe/models/organization_audit_configuration.py +++ b/src/pytfe/models/organization_audit_configuration.py @@ -4,6 +4,7 @@ from pydantic import BaseModel, ConfigDict, Field +from ._base import TFEModel from .organization import Organization @@ -86,7 +87,7 @@ class OrganizationAuditConfigTimestamps(BaseModel): ) -class OrganizationAuditConfiguration(BaseModel): +class OrganizationAuditConfiguration(TFEModel): """Organization audit configuration resource.""" model_config = ConfigDict(populate_by_name=True, extra="forbid") diff --git a/src/pytfe/models/organization_membership.py b/src/pytfe/models/organization_membership.py index 0898bee6..a46f96aa 100644 --- a/src/pytfe/models/organization_membership.py +++ b/src/pytfe/models/organization_membership.py @@ -8,6 +8,8 @@ from pydantic import BaseModel, ConfigDict, Field +from ._base import TFEModel + if TYPE_CHECKING: from .organization import Organization from .team import Team @@ -28,7 +30,7 @@ class OrgMembershipIncludeOpt(str, Enum): TEAMS = "teams" -class OrganizationMembership(BaseModel): +class OrganizationMembership(TFEModel): """Represents a Terraform Enterprise organization membership.""" model_config = ConfigDict(populate_by_name=True, extra="allow") diff --git a/src/pytfe/models/organization_tags.py b/src/pytfe/models/organization_tags.py index 1bb1e477..a81d78f5 100644 --- a/src/pytfe/models/organization_tags.py +++ b/src/pytfe/models/organization_tags.py @@ -5,10 +5,11 @@ from pydantic import BaseModel, ConfigDict, Field +from ._base import TFEModel from .organization import Organization -class OrganizationTag(BaseModel): +class OrganizationTag(TFEModel): """Terraform Enterprise organization tag.""" model_config = ConfigDict(populate_by_name=True, extra="forbid") diff --git a/src/pytfe/models/organization_token.py b/src/pytfe/models/organization_token.py index 24f1cb0c..c87cf7c6 100644 --- a/src/pytfe/models/organization_token.py +++ b/src/pytfe/models/organization_token.py @@ -6,6 +6,8 @@ from pydantic import BaseModel, ConfigDict, Field +from ._base import TFEModel + if TYPE_CHECKING: pass @@ -16,7 +18,7 @@ class TokenType(str, Enum): AUDIT_TRAILS = "audit-trails" -class OrganizationToken(BaseModel): +class OrganizationToken(TFEModel): """Organization token represents a Terraform Enterprise organization token.""" model_config = ConfigDict(extra="forbid") diff --git a/src/pytfe/models/plan.py b/src/pytfe/models/plan.py index 48b4b7e7..ddd995dd 100644 --- a/src/pytfe/models/plan.py +++ b/src/pytfe/models/plan.py @@ -9,6 +9,7 @@ from pydantic import BaseModel, ConfigDict, Field from ..models.plan_export import PlanExport +from ._base import TFEModel class PlanStatus(str, Enum): @@ -25,7 +26,7 @@ class PlanStatus(str, Enum): PLAN_UNREACHABLE = "unreachable" -class Plan(BaseModel): +class Plan(TFEModel): model_config = ConfigDict( populate_by_name=True, validate_by_name=True, extra="allow" ) diff --git a/src/pytfe/models/policy.py b/src/pytfe/models/policy.py index 9081b09d..238ef17a 100644 --- a/src/pytfe/models/policy.py +++ b/src/pytfe/models/policy.py @@ -7,11 +7,12 @@ from pydantic import BaseModel, ConfigDict, Field +from ._base import TFEModel from .organization import Organization from .policy_types import EnforcementLevel, PolicyKind -class Policy(BaseModel): +class Policy(TFEModel): model_config = ConfigDict( populate_by_name=True, validate_by_name=True, extra="allow" ) diff --git a/src/pytfe/models/policy_check.py b/src/pytfe/models/policy_check.py index ac0b8f0f..78f9078b 100644 --- a/src/pytfe/models/policy_check.py +++ b/src/pytfe/models/policy_check.py @@ -9,6 +9,8 @@ from pydantic import BaseModel, ConfigDict, Field +from ._base import TFEModel + if TYPE_CHECKING: from .run import Run @@ -41,7 +43,7 @@ class PolicyCheckIncludeOpt(str, Enum): POLICY_CHECK_RUN = "run" -class PolicyCheck(BaseModel): +class PolicyCheck(TFEModel): """PolicyCheck represents a Terraform Enterprise policy check.""" model_config = ConfigDict( diff --git a/src/pytfe/models/policy_evaluation.py b/src/pytfe/models/policy_evaluation.py index 4b228450..f801115c 100644 --- a/src/pytfe/models/policy_evaluation.py +++ b/src/pytfe/models/policy_evaluation.py @@ -8,6 +8,7 @@ from pydantic import BaseModel, ConfigDict, Field +from ._base import TFEModel from .policy_types import PolicyKind @@ -24,7 +25,7 @@ class PolicyEvaluationStatus(str, Enum): POLICYEVALUATIONOVERRIDDEN = "overridden" -class PolicyEvaluation(BaseModel): +class PolicyEvaluation(TFEModel): """PolicyEvaluation represents the policy evaluations that are part of the task stage.""" model_config = ConfigDict( diff --git a/src/pytfe/models/policy_set.py b/src/pytfe/models/policy_set.py index f60ee5f5..fb2e7993 100644 --- a/src/pytfe/models/policy_set.py +++ b/src/pytfe/models/policy_set.py @@ -8,6 +8,7 @@ from pydantic import BaseModel, ConfigDict, Field +from ._base import TFEModel from .organization import Organization from .policy import Policy from .policy_set_version import PolicySetVersion @@ -26,7 +27,7 @@ class PolicySetIncludeOpt(str, Enum): POLICY_SET_PROJECT_EXCLUSIONS = "project_exclusions" -class PolicySet(BaseModel): +class PolicySet(TFEModel): model_config = ConfigDict( populate_by_name=True, validate_by_name=True, extra="allow" ) diff --git a/src/pytfe/models/policy_set_outcome.py b/src/pytfe/models/policy_set_outcome.py index 9bf07c92..ed2a73ae 100644 --- a/src/pytfe/models/policy_set_outcome.py +++ b/src/pytfe/models/policy_set_outcome.py @@ -5,10 +5,11 @@ from pydantic import BaseModel, ConfigDict, Field +from ._base import TFEModel from .policy_evaluation import PolicyEvaluation, PolicyResultCount -class PolicySetOutcome(BaseModel): +class PolicySetOutcome(TFEModel): """PolicySetOutcome represents outcome of the policy set that are part of the policy evaluation""" model_config = ConfigDict( diff --git a/src/pytfe/models/policy_set_parameter.py b/src/pytfe/models/policy_set_parameter.py index e2729b1a..189c102b 100644 --- a/src/pytfe/models/policy_set_parameter.py +++ b/src/pytfe/models/policy_set_parameter.py @@ -5,11 +5,12 @@ from pydantic import BaseModel, ConfigDict, Field +from ._base import TFEModel from .policy_set import PolicySet from .variable import CategoryType -class PolicySetParameter(BaseModel): +class PolicySetParameter(TFEModel): model_config = ConfigDict( populate_by_name=True, validate_by_name=True, extra="allow" ) diff --git a/src/pytfe/models/policy_set_version.py b/src/pytfe/models/policy_set_version.py index c53a3eb7..556fddc3 100644 --- a/src/pytfe/models/policy_set_version.py +++ b/src/pytfe/models/policy_set_version.py @@ -9,6 +9,8 @@ from pydantic import BaseModel, ConfigDict, Field +from ._base import TFEModel + if TYPE_CHECKING: from .policy_set import PolicySet @@ -61,7 +63,7 @@ class PolicySetIngressAttributes(BaseModel): identifier: str | None = Field(None, alias="identifier") -class PolicySetVersion(BaseModel): +class PolicySetVersion(TFEModel): """PolicySetVersion represents a Terraform Enterprise Policy Set Version""" model_config = ConfigDict( diff --git a/src/pytfe/models/project.py b/src/pytfe/models/project.py index 620a394d..d1aa67b2 100644 --- a/src/pytfe/models/project.py +++ b/src/pytfe/models/project.py @@ -7,6 +7,7 @@ from pydantic import BaseModel, ConfigDict, Field +from ._base import TFEModel from .common import TagBinding from .organization import Organization @@ -14,7 +15,7 @@ from .agent import AgentPool -class Project(BaseModel): +class Project(TFEModel): model_config = ConfigDict( populate_by_name=True, validate_by_name=True, extra="allow" ) diff --git a/src/pytfe/models/query_run.py b/src/pytfe/models/query_run.py index 782a998e..55669d83 100644 --- a/src/pytfe/models/query_run.py +++ b/src/pytfe/models/query_run.py @@ -8,6 +8,8 @@ from pydantic import BaseModel, ConfigDict, Field +from ._base import TFEModel + class QueryRunStatus(str, Enum): """QueryRunStatus represents the status of a query run operation.""" @@ -75,7 +77,7 @@ class QueryRunVariable(BaseModel): value: str = Field(..., description="Variable value") -class QueryRun(BaseModel): +class QueryRun(TFEModel): """Represents a query run in Terraform Enterprise.""" model_config = ConfigDict(populate_by_name=True, extra="allow") diff --git a/src/pytfe/models/registry_module.py b/src/pytfe/models/registry_module.py index dc0953ad..c555eca2 100644 --- a/src/pytfe/models/registry_module.py +++ b/src/pytfe/models/registry_module.py @@ -8,6 +8,8 @@ from pydantic import BaseModel, ConfigDict, Field +from ._base import TFEModel + class RegistryName(str, Enum): """Registry name enum for public/private registries.""" @@ -111,7 +113,7 @@ class RegistryModuleVersionStatuses(BaseModel): error: str | None = None -class RegistryModule(BaseModel): +class RegistryModule(TFEModel): """Registry module model.""" id: str @@ -131,7 +133,7 @@ class RegistryModule(BaseModel): organization: Any | None = None # Will be Organization type from main types -class RegistryModuleVersion(BaseModel): +class RegistryModuleVersion(TFEModel): """Registry module version model.""" id: str diff --git a/src/pytfe/models/registry_provider.py b/src/pytfe/models/registry_provider.py index 5cd57414..c73b23f7 100644 --- a/src/pytfe/models/registry_provider.py +++ b/src/pytfe/models/registry_provider.py @@ -16,6 +16,7 @@ InvalidValues, ) from ..utils import valid_string_id +from ._base import TFEModel class RegistryName(Enum): @@ -39,7 +40,7 @@ class RegistryProviderPermissions(BaseModel): model_config = {"populate_by_name": True} -class RegistryProvider(BaseModel): +class RegistryProvider(TFEModel): """Registry provider model.""" id: str diff --git a/src/pytfe/models/registry_provider_platform.py b/src/pytfe/models/registry_provider_platform.py index d7c58b08..99048d32 100644 --- a/src/pytfe/models/registry_provider_platform.py +++ b/src/pytfe/models/registry_provider_platform.py @@ -16,6 +16,7 @@ RequiredShasumError, ) from ..utils import valid_string, valid_string_id +from ._base import TFEModel from .registry_provider_version import ( RegistryProviderVersion, RegistryProviderVersionID, @@ -33,7 +34,7 @@ class RegistryProviderPlatformPermissions(BaseModel): can_upload_asset: bool = Field(alias="can-upload-asset") -class RegistryProviderPlatform(BaseModel): +class RegistryProviderPlatform(TFEModel): """Registry provider platform model.""" model_config = ConfigDict( diff --git a/src/pytfe/models/registry_provider_version.py b/src/pytfe/models/registry_provider_version.py index fa734927..5620ce3d 100644 --- a/src/pytfe/models/registry_provider_version.py +++ b/src/pytfe/models/registry_provider_version.py @@ -14,6 +14,7 @@ RequiredPrivateRegistryError, ) from ..utils import valid_string_id +from ._base import TFEModel from .registry_provider import ( RegistryName, RegistryProvider, @@ -35,7 +36,7 @@ class RegistryProviderVersionPermissions(BaseModel): can_upload_asset: bool = Field(alias="can-upload-asset") -class RegistryProviderVersion(BaseModel): +class RegistryProviderVersion(TFEModel): """Registry provider version model.""" model_config = ConfigDict( diff --git a/src/pytfe/models/reserved_tag_key.py b/src/pytfe/models/reserved_tag_key.py index dd567a02..ff82a024 100644 --- a/src/pytfe/models/reserved_tag_key.py +++ b/src/pytfe/models/reserved_tag_key.py @@ -7,8 +7,10 @@ from pydantic import BaseModel, ConfigDict, Field +from ._base import TFEModel -class ReservedTagKey(BaseModel): + +class ReservedTagKey(TFEModel): """Represents a reserved tag key in Terraform Enterprise.""" model_config = ConfigDict(populate_by_name=True, extra="allow") diff --git a/src/pytfe/models/run.py b/src/pytfe/models/run.py index d5dfc4c9..1d0a09a3 100644 --- a/src/pytfe/models/run.py +++ b/src/pytfe/models/run.py @@ -9,6 +9,7 @@ from pydantic import BaseModel, ConfigDict, Field +from ._base import TFEModel from .apply import Apply from .comment import Comment from .configuration_version import ConfigurationVersion @@ -93,7 +94,7 @@ class RunOperation(str, Enum): Run_Operation_Save_Plan = "save_plan" -class Run(BaseModel): +class Run(TFEModel): """Run represents a Terraform Enterprise run.""" # extra="allow" keeps forward compatibility: undeclared server attributes are diff --git a/src/pytfe/models/run_event.py b/src/pytfe/models/run_event.py index af100ee7..4fc35654 100644 --- a/src/pytfe/models/run_event.py +++ b/src/pytfe/models/run_event.py @@ -8,6 +8,7 @@ from pydantic import BaseModel, ConfigDict, Field +from ._base import TFEModel from .comment import Comment from .user import User @@ -17,7 +18,7 @@ class RunEventIncludeOpt(str, Enum): RUN_EVENT_COMMENT = "comment" -class RunEvent(BaseModel): +class RunEvent(TFEModel): model_config = ConfigDict( populate_by_name=True, validate_by_name=True, extra="allow" ) diff --git a/src/pytfe/models/run_task.py b/src/pytfe/models/run_task.py index 0132ba34..f962ee69 100644 --- a/src/pytfe/models/run_task.py +++ b/src/pytfe/models/run_task.py @@ -9,6 +9,7 @@ from pydantic import BaseModel, Field from ..models.common import Pagination +from ._base import TFEModel from .agent import AgentPool from .organization import Organization @@ -16,7 +17,7 @@ from .workspace_run_task import WorkspaceRunTask -class RunTask(BaseModel): +class RunTask(TFEModel): id: str name: str | None = None description: str | None = None diff --git a/src/pytfe/models/run_trigger.py b/src/pytfe/models/run_trigger.py index 1abfc12f..25402d6d 100644 --- a/src/pytfe/models/run_trigger.py +++ b/src/pytfe/models/run_trigger.py @@ -9,10 +9,11 @@ from pydantic import BaseModel, Field from ..models.common import Pagination +from ._base import TFEModel from .workspace import Workspace -class RunTrigger(BaseModel): +class RunTrigger(TFEModel): id: str type: str = Field(default="run-triggers") created_at: datetime diff --git a/src/pytfe/models/ssh_key.py b/src/pytfe/models/ssh_key.py index fad57691..113feb31 100644 --- a/src/pytfe/models/ssh_key.py +++ b/src/pytfe/models/ssh_key.py @@ -5,8 +5,10 @@ from pydantic import BaseModel, ConfigDict, Field +from ._base import TFEModel -class SSHKey(BaseModel): + +class SSHKey(TFEModel): """Represents an SSH key in Terraform Enterprise.""" model_config = ConfigDict(populate_by_name=True, extra="allow") diff --git a/src/pytfe/models/stack.py b/src/pytfe/models/stack.py index c2b6d06c..ff0af932 100644 --- a/src/pytfe/models/stack.py +++ b/src/pytfe/models/stack.py @@ -9,6 +9,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator from ..errors import ERR_REQUIRED_NAME, ERR_REQUIRED_PROJECT +from ._base import TFEModel from .agent import AgentPool from .project import Project @@ -50,7 +51,7 @@ class StackVcsRepoOptions(BaseModel): oauth_token_id: str | None = Field(default=None, alias="oauth-token-id") -class Stack(BaseModel): +class Stack(TFEModel): """Stack represents a stack in Terraform Cloud.""" model_config = ConfigDict( diff --git a/src/pytfe/models/stack_configuration.py b/src/pytfe/models/stack_configuration.py index 4888d534..faf663c6 100644 --- a/src/pytfe/models/stack_configuration.py +++ b/src/pytfe/models/stack_configuration.py @@ -8,6 +8,7 @@ from pydantic import BaseModel, ConfigDict, Field +from ._base import TFEModel from .configuration_version import IngressAttributes from .stack import Stack @@ -50,7 +51,7 @@ class StackConfigurationIncludeOps(str, Enum): STACK_DIAGNOSTICS = "stack_diagnostics" -class StackConfiguration(BaseModel): +class StackConfiguration(TFEModel): """StackConfiguration represents a snapshot of a stack's configuration.""" model_config = ConfigDict( diff --git a/src/pytfe/models/state_version.py b/src/pytfe/models/state_version.py index d5f759ba..c1b9e12d 100644 --- a/src/pytfe/models/state_version.py +++ b/src/pytfe/models/state_version.py @@ -8,6 +8,8 @@ from pydantic import BaseModel, ConfigDict, Field +from ._base import TFEModel + # ---- Enums ---- @@ -28,7 +30,7 @@ class StateVersionIncludeOpt(str, Enum): # ---- DTOs ---- -class StateVersion(BaseModel): +class StateVersion(TFEModel): model_config = ConfigDict( populate_by_name=True, validate_by_name=True, extra="allow" ) diff --git a/src/pytfe/models/state_version_output.py b/src/pytfe/models/state_version_output.py index 3137d4e9..ce932118 100644 --- a/src/pytfe/models/state_version_output.py +++ b/src/pytfe/models/state_version_output.py @@ -7,8 +7,10 @@ from pydantic import BaseModel, ConfigDict, Field +from ._base import TFEModel -class StateVersionOutput(BaseModel): + +class StateVersionOutput(TFEModel): model_config = ConfigDict( populate_by_name=True, validate_by_name=True, extra="allow" ) diff --git a/src/pytfe/models/task_result.py b/src/pytfe/models/task_result.py index 0eaaa89f..c438a8ac 100644 --- a/src/pytfe/models/task_result.py +++ b/src/pytfe/models/task_result.py @@ -9,6 +9,8 @@ from pydantic import BaseModel, ConfigDict, Field +from ._base import TFEModel + if TYPE_CHECKING: from pytfe.models.task_stage import TaskStage @@ -39,7 +41,7 @@ class TaskResultStatusTimestamps(BaseModel): passed_at: datetime | None = Field(None, alias="passed-at") -class TaskResult(BaseModel): +class TaskResult(TFEModel): model_config = ConfigDict( populate_by_name=True, validate_by_name=True, extra="allow" ) diff --git a/src/pytfe/models/task_stage.py b/src/pytfe/models/task_stage.py index 018858e7..1674b4f3 100644 --- a/src/pytfe/models/task_stage.py +++ b/src/pytfe/models/task_stage.py @@ -13,6 +13,8 @@ from pytfe.models.run_task import Stage from pytfe.models.task_result import TaskResult +from ._base import TFEModel + class TaskStageStatus(str, Enum): pending = "pending" @@ -55,7 +57,7 @@ class Actions(BaseModel): is_overridable: bool | None = Field(None, alias="is-overridable") -class TaskStage(BaseModel): +class TaskStage(TFEModel): model_config = ConfigDict(populate_by_name=True, extra="allow") id: str diff --git a/src/pytfe/models/team.py b/src/pytfe/models/team.py index bd0f7b74..c9638179 100644 --- a/src/pytfe/models/team.py +++ b/src/pytfe/models/team.py @@ -8,6 +8,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator from ..errors import ERR_REQUIRED_NAME, EmptyTeamNameError +from ._base import TFEModel from .organization_membership import OrganizationMembership from .user import User @@ -47,7 +48,7 @@ class TeamPermissions(BaseModel): can_update_membership: bool = Field(alias="can-update-membership") -class Team(BaseModel): +class Team(TFEModel): """Represents a Terraform Enterprise team.""" model_config = ConfigDict(populate_by_name=True, extra="allow") diff --git a/src/pytfe/models/team_project_access.py b/src/pytfe/models/team_project_access.py index fc4073e5..1bfa1825 100644 --- a/src/pytfe/models/team_project_access.py +++ b/src/pytfe/models/team_project_access.py @@ -6,6 +6,7 @@ from ..errors import ERR_REQUIRED_PROJECT, InvalidProjectIDError, RequiredTeamError from ..utils import valid_string_id +from ._base import TFEModel from .project import Project from .team import Team @@ -115,7 +116,7 @@ class TeamProjectAccessWorkspacePermissions(BaseModel): run_tasks: bool = Field(default=False, alias="run-tasks") -class TeamProjectAccess(BaseModel): +class TeamProjectAccess(TFEModel): """TeamProjectAccess represents a project access for a team""" model_config = ConfigDict( diff --git a/src/pytfe/models/team_token.py b/src/pytfe/models/team_token.py index d250cfce..96921cf3 100644 --- a/src/pytfe/models/team_token.py +++ b/src/pytfe/models/team_token.py @@ -7,12 +7,13 @@ from pydantic import BaseModel, ConfigDict, Field +from ._base import TFEModel from .organization import Organization from .team import Team from .user import User -class TeamToken(BaseModel): +class TeamToken(TFEModel): """TeamToken represents a Terraform Enterprise team token.""" model_config = ConfigDict( diff --git a/src/pytfe/models/team_workspace_access.py b/src/pytfe/models/team_workspace_access.py index caeab9b4..f7a18eeb 100644 --- a/src/pytfe/models/team_workspace_access.py +++ b/src/pytfe/models/team_workspace_access.py @@ -7,6 +7,8 @@ from pydantic import BaseModel, ConfigDict, Field +from ._base import TFEModel + class TeamWorkspaceAccessType(str, Enum): READ = "read" @@ -40,7 +42,7 @@ class TeamWorkspaceSentinelMocksPermission(str, Enum): READ = "read" -class TeamWorkspaceAccess(BaseModel): +class TeamWorkspaceAccess(TFEModel): """A team's access grant on a workspace (`/api/v2/team-workspaces/{id}`).""" model_config = ConfigDict( diff --git a/src/pytfe/models/user.py b/src/pytfe/models/user.py index 2ffb43cb..22731e01 100644 --- a/src/pytfe/models/user.py +++ b/src/pytfe/models/user.py @@ -3,6 +3,8 @@ from pydantic import BaseModel, ConfigDict, Field +from ._base import TFEModel + class TwoFactor(BaseModel): model_config = ConfigDict( @@ -27,7 +29,7 @@ class UserPermissions(BaseModel): can_manage_hcp_account: bool = Field(default=False, alias="can-manage-hcp-account") -class User(BaseModel): +class User(TFEModel): model_config = ConfigDict( populate_by_name=True, validate_by_name=True, extra="allow" ) diff --git a/src/pytfe/models/variable.py b/src/pytfe/models/variable.py index 8c40f051..425f1386 100644 --- a/src/pytfe/models/variable.py +++ b/src/pytfe/models/variable.py @@ -7,6 +7,8 @@ from pydantic import BaseModel +from ._base import TFEModel + class CategoryType(str, Enum): ENV = "env" @@ -14,7 +16,7 @@ class CategoryType(str, Enum): TERRAFORM = "terraform" -class Variable(BaseModel): +class Variable(TFEModel): id: str | None = None key: str | None = None value: str | None = None diff --git a/src/pytfe/models/variable_set.py b/src/pytfe/models/variable_set.py index 9e89e53c..2ad43097 100644 --- a/src/pytfe/models/variable_set.py +++ b/src/pytfe/models/variable_set.py @@ -8,6 +8,7 @@ from pydantic import BaseModel, ConfigDict, Field +from ._base import TFEModel from .organization import Organization from .project import Project from .variable import CategoryType @@ -30,7 +31,7 @@ class Parent(BaseModel): project: Project | None = None -class VariableSet(BaseModel): +class VariableSet(TFEModel): """Represents a Terraform Enterprise variable set.""" model_config = ConfigDict( @@ -53,7 +54,7 @@ class VariableSet(BaseModel): parent: Parent | None = None -class VariableSetVariable(BaseModel): +class VariableSetVariable(TFEModel): """Represents a variable within a variable set.""" id: str | None = None diff --git a/src/pytfe/models/workspace.py b/src/pytfe/models/workspace.py index 0dd7a8d4..fb093ba3 100644 --- a/src/pytfe/models/workspace.py +++ b/src/pytfe/models/workspace.py @@ -21,6 +21,7 @@ UnsupportedOperationsError, ) from ..utils import has_tags_regex_defined, is_valid_workspace_name, valid_string +from ._base import TFEModel from .assessment_result import AssessmentResult from .common import EffectiveTagBinding, Tag, TagBinding from .configuration_version import ConfigurationVersion @@ -124,7 +125,7 @@ class VCSRepo(BaseModel): source_directory: str | None = Field(default=None, alias="source-directory") -class Workspace(BaseModel): +class Workspace(TFEModel): model_config = ConfigDict( populate_by_name=True, validate_by_name=True, extra="allow" ) diff --git a/src/pytfe/models/workspace_resource.py b/src/pytfe/models/workspace_resource.py index e3b5deb7..d3e5b80c 100644 --- a/src/pytfe/models/workspace_resource.py +++ b/src/pytfe/models/workspace_resource.py @@ -5,8 +5,10 @@ from pydantic import BaseModel +from ._base import TFEModel -class WorkspaceResource(BaseModel): + +class WorkspaceResource(TFEModel): """Represents a Terraform Enterprise workspace resource. These are resources managed by Terraform in a workspace's state. diff --git a/src/pytfe/models/workspace_run_task.py b/src/pytfe/models/workspace_run_task.py index a95c57be..426e0bed 100644 --- a/src/pytfe/models/workspace_run_task.py +++ b/src/pytfe/models/workspace_run_task.py @@ -6,6 +6,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator from ..errors import InvalidRunTaskIDError +from ._base import TFEModel from .run_task import ( RunTask, Stage, @@ -14,7 +15,7 @@ from .workspace import Workspace -class WorkspaceRunTask(BaseModel): +class WorkspaceRunTask(TFEModel): """Workspace run task model.""" model_config = ConfigDict( diff --git a/src/pytfe/resources/admin/_organizations.py b/src/pytfe/resources/admin/_organizations.py index 46f7074c..6e43a83e 100644 --- a/src/pytfe/resources/admin/_organizations.py +++ b/src/pytfe/resources/admin/_organizations.py @@ -6,6 +6,7 @@ from collections.abc import Iterator from typing import Any +from ..._jsonapi import attach_jsonapi from ...errors import ERR_INVALID_NAME from ...models.admin_organization import ( AdminOrganization, @@ -20,7 +21,9 @@ def _parse_admin_organization(data: dict[str, Any]) -> AdminOrganization: attrs = data.get("attributes") or {} - return AdminOrganization.model_validate({"id": data.get("id"), **attrs}) + return attach_jsonapi( + AdminOrganization.model_validate({"id": data.get("id"), **attrs}), data + ) class _AdminOrganizations(_Service): diff --git a/src/pytfe/resources/admin/_runs.py b/src/pytfe/resources/admin/_runs.py index db79335c..37040056 100644 --- a/src/pytfe/resources/admin/_runs.py +++ b/src/pytfe/resources/admin/_runs.py @@ -6,6 +6,7 @@ from collections.abc import Iterator from typing import Any +from ..._jsonapi import attach_jsonapi from ...errors import ERR_INVALID_NAME from ...models.admin_run import AdminRun, AdminRunListOptions from ...utils import valid_string_id @@ -19,12 +20,15 @@ def _parse_admin_run(data: dict[str, Any]) -> AdminRun: # organization is a compound include (workspace.organization) — only # present when the caller passes ?include=workspace.organization. # We don't surface that parameter yet, so organization_name stays None. - return AdminRun.model_validate( - { - "id": data.get("id"), - "workspace_id": ws_data.get("id"), - **attrs, - } + return attach_jsonapi( + AdminRun.model_validate( + { + "id": data.get("id"), + "workspace_id": ws_data.get("id"), + **attrs, + } + ), + data, ) diff --git a/src/pytfe/resources/admin/_users.py b/src/pytfe/resources/admin/_users.py index 3c9bf6e3..42da26cf 100644 --- a/src/pytfe/resources/admin/_users.py +++ b/src/pytfe/resources/admin/_users.py @@ -6,6 +6,7 @@ from collections.abc import Iterator from typing import Any +from ..._jsonapi import attach_jsonapi from ...errors import ERR_INVALID_NAME from ...models.admin_user import AdminUser, AdminUserListOptions from ...utils import valid_string_id @@ -14,7 +15,9 @@ def _parse_admin_user(data: dict[str, Any]) -> AdminUser: attrs = data.get("attributes") or {} - return AdminUser.model_validate({"id": data.get("id"), **attrs}) + return attach_jsonapi( + AdminUser.model_validate({"id": data.get("id"), **attrs}), data + ) class _AdminUsers(_Service): diff --git a/src/pytfe/resources/admin/_workspaces.py b/src/pytfe/resources/admin/_workspaces.py index 43757cd4..ed86027c 100644 --- a/src/pytfe/resources/admin/_workspaces.py +++ b/src/pytfe/resources/admin/_workspaces.py @@ -6,6 +6,7 @@ from collections.abc import Iterator from typing import Any +from ..._jsonapi import attach_jsonapi from ...errors import ERR_INVALID_NAME from ...models.admin_workspace import AdminWorkspace, AdminWorkspaceListOptions from ...utils import valid_string_id @@ -18,14 +19,17 @@ def _parse_admin_workspace(data: dict[str, Any]) -> AdminWorkspace: org_data = (rels.get("organization") or {}).get("data") or {} run_data = (rels.get("current-run") or {}).get("data") or {} vcs_repo = attrs.pop("vcs-repo", None) or {} - return AdminWorkspace.model_validate( - { - "id": data.get("id"), - "organization_name": org_data.get("id"), - "current_run_id": run_data.get("id"), - "vcs_repo_identifier": vcs_repo.get("identifier") if vcs_repo else None, - **attrs, - } + return attach_jsonapi( + AdminWorkspace.model_validate( + { + "id": data.get("id"), + "organization_name": org_data.get("id"), + "current_run_id": run_data.get("id"), + "vcs_repo_identifier": vcs_repo.get("identifier") if vcs_repo else None, + **attrs, + } + ), + data, ) diff --git a/src/pytfe/resources/agent_pools.py b/src/pytfe/resources/agent_pools.py index 3e58f4c9..3946f453 100644 --- a/src/pytfe/resources/agent_pools.py +++ b/src/pytfe/resources/agent_pools.py @@ -9,6 +9,7 @@ from __future__ import annotations +import builtins from collections.abc import Iterator from typing import Any @@ -16,7 +17,7 @@ from pytfe.models.project import Project from pytfe.models.workspace import Workspace -from .._jsonapi import parse_relationships +from .._jsonapi import attach_jsonapi, parse_relationships from ..errors import ( InvalidAgentPoolIDError, InvalidOrgError, @@ -136,9 +137,10 @@ def create(self, organization: str, options: AgentPoolCreateOptions) -> AgentPoo payload["data"]["relationships"] = relationships response = self.t.request("POST", path, json_body=payload) - data = response.json()["data"] + payload = response.json() + data = payload["data"] - return self._parse_agent_pool_from(data) + return self._parse_agent_pool_from(data, payload.get("included")) def read( self, agent_pool_id: str, options: AgentPoolReadOptions | None = None @@ -170,9 +172,10 @@ def read( else: response = self.t.request("GET", path) - data = response.json()["data"] + payload = response.json() + data = payload["data"] - return self._parse_agent_pool_from(data) + return self._parse_agent_pool_from(data, payload.get("included")) def update(self, agent_pool_id: str, options: AgentPoolUpdateOptions) -> AgentPool: """Update an agent pool's properties. @@ -235,9 +238,10 @@ def update(self, agent_pool_id: str, options: AgentPoolUpdateOptions) -> AgentPo payload["data"]["relationships"] = relationships response = self.t.request("PATCH", path, json_body=payload) - data = response.json()["data"] + payload = response.json() + data = payload["data"] - return self._parse_agent_pool_from(data) + return self._parse_agent_pool_from(data, payload.get("included")) def delete(self, agent_pool_id: str) -> None: """Delete an agent pool. @@ -302,9 +306,10 @@ def assign_to_workspaces( } } response = self.t.request("PATCH", path, json_body=payload) - data = response.json()["data"] + payload = response.json() + data = payload["data"] - return self._parse_agent_pool_from(data) + return self._parse_agent_pool_from(data, payload.get("included")) def remove_from_workspaces( self, agent_pool_id: str, options: AgentPoolRemoveFromWorkspacesOptions @@ -354,9 +359,10 @@ def remove_from_workspaces( } } response = self.t.request("PATCH", path, json_body=payload) - data = response.json()["data"] + payload = response.json() + data = payload["data"] - return self._parse_agent_pool_from(data) + return self._parse_agent_pool_from(data, payload.get("included")) def assign_to_projects( self, agent_pool_id: str, options: AgentPoolAssignToProjectsOptions @@ -398,11 +404,16 @@ def assign_to_projects( } } response = self.t.request("PATCH", path, json_body=payload) - data = response.json()["data"] + payload = response.json() + data = payload["data"] - return self._parse_agent_pool_from(data) + return self._parse_agent_pool_from(data, payload.get("included")) - def _parse_agent_pool_from(self, data: dict[str, Any]) -> AgentPool: + def _parse_agent_pool_from( + self, + data: dict[str, Any], + included: builtins.list[dict[str, Any]] | None = None, + ) -> AgentPool: """Helper method to parse agent pool data from API response.""" attr = data.get("attributes", {}) attr["id"] = data.get("id") @@ -417,6 +428,7 @@ def _parse_agent_pool_from(self, data: dict[str, Any]) -> AgentPool: "excluded-workspaces": Workspace, "allowed-projects": Project, }, + included=included, ) ) - return AgentPool.model_validate(attr) + return attach_jsonapi(AgentPool.model_validate(attr), data, included) diff --git a/src/pytfe/resources/comment.py b/src/pytfe/resources/comment.py index e079366a..dc8ea6a3 100644 --- a/src/pytfe/resources/comment.py +++ b/src/pytfe/resources/comment.py @@ -6,6 +6,7 @@ from collections.abc import Iterator from typing import Any +from .._jsonapi import attach_jsonapi from ..errors import InvalidCommentIDError, InvalidRunIDError from ..models.comment import Comment, CommentCreateOptions from ..utils import valid_string_id @@ -51,4 +52,4 @@ def _comment_from(self, data: dict[str, Any]) -> Comment: """Parse a Comment from API response data.""" attrs = dict(data.get("attributes", {})) attrs["id"] = data.get("id") - return Comment.model_validate(attrs) + return attach_jsonapi(Comment.model_validate(attrs), data) diff --git a/src/pytfe/resources/configuration_version.py b/src/pytfe/resources/configuration_version.py index c64e693b..ca501f74 100644 --- a/src/pytfe/resources/configuration_version.py +++ b/src/pytfe/resources/configuration_version.py @@ -7,6 +7,7 @@ from collections.abc import Iterator from typing import Any +from .._jsonapi import attach_jsonapi from ..errors import ( ERR_INVALID_CONFIG_VERSION_ID, ERR_INVALID_WORKSPACE_ID, @@ -286,4 +287,4 @@ def _parse_configuration_version( "links": data.get("links"), } - return ConfigurationVersion(**cv_data) + return attach_jsonapi(ConfigurationVersion(**cv_data), data) diff --git a/src/pytfe/resources/github_app_installation.py b/src/pytfe/resources/github_app_installation.py index bcab9ca9..0f5fd337 100644 --- a/src/pytfe/resources/github_app_installation.py +++ b/src/pytfe/resources/github_app_installation.py @@ -15,6 +15,7 @@ from collections.abc import Iterator from typing import Any +from .._jsonapi import attach_jsonapi from ..errors import InvalidGitHubAppInstallationIDError from ..models.github_app_installation import ( GitHubAppInstallation, @@ -26,7 +27,9 @@ def _parse(data: dict[str, Any]) -> GitHubAppInstallation: attrs = data.get("attributes") or {} - return GitHubAppInstallation.model_validate({"id": data.get("id"), **attrs}) + return attach_jsonapi( + GitHubAppInstallation.model_validate({"id": data.get("id"), **attrs}), data + ) class GitHubAppInstallations(_Service): diff --git a/src/pytfe/resources/no_code_module.py b/src/pytfe/resources/no_code_module.py index 6e3b7a66..20fa3425 100644 --- a/src/pytfe/resources/no_code_module.py +++ b/src/pytfe/resources/no_code_module.py @@ -6,7 +6,7 @@ from collections.abc import Iterator from typing import Any -from .._jsonapi import build_included_index, parse_relationships +from .._jsonapi import attach_jsonapi, build_included_index, parse_relationships from ..errors import ( InvalidNoCodeModuleIDError, InvalidOrgError, @@ -130,7 +130,7 @@ def _no_code_module_from( if ref.get("id") ] - return module + return attach_jsonapi(module, data, included) def _workspace_upgrade_from(data: dict[str, Any]) -> WorkspaceUpgrade: @@ -144,7 +144,7 @@ def _workspace_upgrade_from(data: dict[str, Any]) -> WorkspaceUpgrade: "message": attrs.get("message"), } upgrade_attrs.update(parse_relationships(relationships, {"workspace": Workspace})) - return WorkspaceUpgrade.model_validate(upgrade_attrs) + return attach_jsonapi(WorkspaceUpgrade.model_validate(upgrade_attrs), data) class NoCodeModules(_Service): diff --git a/src/pytfe/resources/notification_configuration.py b/src/pytfe/resources/notification_configuration.py index 1543d64e..3b098da5 100644 --- a/src/pytfe/resources/notification_configuration.py +++ b/src/pytfe/resources/notification_configuration.py @@ -12,6 +12,7 @@ from collections.abc import Iterator from typing import Any +from .._jsonapi import attach_jsonapi from ..errors import ( InvalidOrgError, ValidationError, @@ -202,4 +203,4 @@ def _parse_notification_configuration( users_data = relationships["users"].get("data", []) attributes["email-users"] = users_data - return NotificationConfiguration(attributes) + return attach_jsonapi(NotificationConfiguration(attributes), data) diff --git a/src/pytfe/resources/oauth_client.py b/src/pytfe/resources/oauth_client.py index 86cfa908..289d55dd 100644 --- a/src/pytfe/resources/oauth_client.py +++ b/src/pytfe/resources/oauth_client.py @@ -7,6 +7,7 @@ from typing import Any from urllib.parse import quote +from .._jsonapi import attach_jsonapi from ..errors import ERR_INVALID_OAUTH_CLIENT_ID, ERR_INVALID_ORG from ..models.oauth_client import ( OAuthClient, @@ -183,4 +184,4 @@ def _parse_oauth_client(self, data: dict[str, Any]) -> OAuthClient: if "projects" in relationships: oauth_client.projects = relationships["projects"].get("data", []) - return oauth_client + return attach_jsonapi(oauth_client, data) diff --git a/src/pytfe/resources/organization_audit_configuration.py b/src/pytfe/resources/organization_audit_configuration.py index 2962ea28..620db66a 100644 --- a/src/pytfe/resources/organization_audit_configuration.py +++ b/src/pytfe/resources/organization_audit_configuration.py @@ -3,6 +3,7 @@ from typing import Any from urllib.parse import quote +from .._jsonapi import attach_jsonapi from ..errors import ERR_INVALID_ORG from ..models.organization import Organization from ..models.organization_audit_configuration import ( @@ -81,14 +82,17 @@ def _parse_audit_configuration( if isinstance(org_data, dict): org = Organization(id=org_data.get("id")) - return OrganizationAuditConfiguration.model_validate( - { - "id": data.get("id", ""), - "audit-trails": attrs.get("audit-trails"), - "hcp-audit-log-streaming": attrs.get("hcp-audit-log-streaming"), - "permissions": attrs.get("permissions"), - "timestamps": attrs.get("timestamps"), - "updated-at": attrs.get("updated-at"), - "organization": org, - } + return attach_jsonapi( + OrganizationAuditConfiguration.model_validate( + { + "id": data.get("id", ""), + "audit-trails": attrs.get("audit-trails"), + "hcp-audit-log-streaming": attrs.get("hcp-audit-log-streaming"), + "permissions": attrs.get("permissions"), + "timestamps": attrs.get("timestamps"), + "updated-at": attrs.get("updated-at"), + "organization": org, + } + ), + data, ) diff --git a/src/pytfe/resources/organization_membership.py b/src/pytfe/resources/organization_membership.py index a6970670..f94e7ae2 100644 --- a/src/pytfe/resources/organization_membership.py +++ b/src/pytfe/resources/organization_membership.py @@ -7,6 +7,7 @@ from collections.abc import Iterator from typing import Any +from .._jsonapi import attach_jsonapi from ..errors import ERR_INVALID_EMAIL, ERR_INVALID_ORG from ..models.organization import Organization from ..models.organization_membership import ( @@ -277,11 +278,14 @@ def _parse_membership(self, data: dict[str, Any]) -> OrganizationMembership: # This would be populated when include options are used # For now, keeping it simple with just IDs - return OrganizationMembership( - id=membership_id, - status=status, - email=email, - organization=organization, - user=user, - teams=teams, + return attach_jsonapi( + OrganizationMembership( + id=membership_id, + status=status, + email=email, + organization=organization, + user=user, + teams=teams, + ), + data, ) diff --git a/src/pytfe/resources/policy.py b/src/pytfe/resources/policy.py index 4ab50033..37f0405c 100644 --- a/src/pytfe/resources/policy.py +++ b/src/pytfe/resources/policy.py @@ -6,6 +6,7 @@ from collections.abc import Iterator from typing import Any +from .._jsonapi import attach_jsonapi from ..errors import ( InvalidNameError, InvalidOrgError, @@ -46,7 +47,7 @@ def _gen() -> Iterator[Policy]: attrs["organization"] = item.get("relationships", {}).get( "organization", {} ) - yield Policy.model_validate(attrs) + yield attach_jsonapi(Policy.model_validate(attrs), item) return _gen() @@ -72,7 +73,7 @@ def create(self, organization: str, options: PolicyCreateOptions) -> Policy: d = jd.get("data", {}) attrs = d.get("attributes", {}) attrs["id"] = d.get("id") - return Policy.model_validate(attrs) + return attach_jsonapi(Policy.model_validate(attrs), d) def read(self, policy_id: str) -> Policy: """Read a specific policy by its ID.""" @@ -87,7 +88,7 @@ def read(self, policy_id: str) -> Policy: attrs = d.get("attributes", {}) attrs["id"] = d.get("id") attrs["organization"] = d.get("relationships", {}).get("organization", {}) - return Policy.model_validate(attrs) + return attach_jsonapi(Policy.model_validate(attrs), d) def update(self, policy_id: str, options: PolicyUpdateOptions) -> Policy: """Update an existing policy by its ID.""" @@ -109,7 +110,7 @@ def update(self, policy_id: str, options: PolicyUpdateOptions) -> Policy: attrs = d.get("attributes", {}) attrs["id"] = d.get("id") attrs["organization"] = d.get("relationships", {}).get("organization", {}) - return Policy.model_validate(attrs) + return attach_jsonapi(Policy.model_validate(attrs), d) def delete(self, policy_id: str) -> None: """Delete a specific policy by its ID.""" diff --git a/src/pytfe/resources/policy_check.py b/src/pytfe/resources/policy_check.py index d9ba222d..ba5eabe5 100644 --- a/src/pytfe/resources/policy_check.py +++ b/src/pytfe/resources/policy_check.py @@ -6,6 +6,7 @@ import time from collections.abc import Iterator +from .._jsonapi import attach_jsonapi from ..errors import ( InvalidPolicyCheckIDError, InvalidRunIDError, @@ -39,7 +40,7 @@ def list( attrs = item.get("attributes", {}) attrs["id"] = item.get("id") attrs["run"] = item.get("relationships", {}).get("run", {}).get("data") - yield PolicyCheck.model_validate(attrs) + yield attach_jsonapi(PolicyCheck.model_validate(attrs), item) def read(self, policy_check_id: str) -> PolicyCheck: """Read a policy check by its ID.""" @@ -54,7 +55,7 @@ def read(self, policy_check_id: str) -> PolicyCheck: attrs = d.get("attributes", {}) attrs["id"] = d.get("id") attrs["run"] = d.get("relationships", {}).get("run", {}).get("data") - return PolicyCheck.model_validate(attrs) + return attach_jsonapi(PolicyCheck.model_validate(attrs), d) def override(self, policy_check_id: str) -> PolicyCheck: """Override a soft-mandatory or warning policy.""" @@ -69,7 +70,7 @@ def override(self, policy_check_id: str) -> PolicyCheck: attrs = d.get("attributes", {}) attrs["id"] = d.get("id") attrs["run"] = d.get("relationships", {}).get("run", {}).get("data") - return PolicyCheck.model_validate(attrs) + return attach_jsonapi(PolicyCheck.model_validate(attrs), d) def logs(self, policy_check_id: str) -> str: """Logs retrieves the logs of a policy check.""" diff --git a/src/pytfe/resources/policy_evaluation.py b/src/pytfe/resources/policy_evaluation.py index 184fb2cd..0808b3f7 100644 --- a/src/pytfe/resources/policy_evaluation.py +++ b/src/pytfe/resources/policy_evaluation.py @@ -5,6 +5,7 @@ from collections.abc import Iterator +from .._jsonapi import attach_jsonapi from ..errors import ( InvalidTaskStageIDError, ) @@ -41,4 +42,4 @@ def list( .get("policy-attachable", {}) .get("data", {}) ) - yield PolicyEvaluation.model_validate(attrs) + yield attach_jsonapi(PolicyEvaluation.model_validate(attrs), item) diff --git a/src/pytfe/resources/policy_set.py b/src/pytfe/resources/policy_set.py index 222cc7b9..cfdeb555 100644 --- a/src/pytfe/resources/policy_set.py +++ b/src/pytfe/resources/policy_set.py @@ -6,6 +6,7 @@ from collections.abc import Iterator from typing import Any +from .._jsonapi import attach_jsonapi from ..errors import ( InvalidNameError, InvalidOrgError, @@ -90,7 +91,7 @@ def _gen() -> Iterator[PolicySet]: attrs["policies"] = ( d.get("relationships", {}).get("policies", {}).get("data", []) ) - yield PolicySet.model_validate(attrs) + yield attach_jsonapi(PolicySet.model_validate(attrs), d) return _gen() @@ -166,7 +167,7 @@ def create(self, organization: str, options: PolicySetCreateOptions) -> PolicySe attrs["projects"] = relationships_data.get("projects", {}).get("data", []) attrs["policies"] = relationships_data.get("policies", {}).get("data", []) - return PolicySet.model_validate(attrs) + return attach_jsonapi(PolicySet.model_validate(attrs), data) def read(self, policy_set_id: str) -> PolicySet: """Read a policy set by its ID.""" @@ -208,7 +209,7 @@ def read_with_options( attrs["projects"] = relationships_data.get("projects", {}).get("data", []) attrs["policies"] = relationships_data.get("policies", {}).get("data", []) - return PolicySet.model_validate(attrs) + return attach_jsonapi(PolicySet.model_validate(attrs), data) def update(self, policy_set_id: str, options: PolicySetUpdateOptions) -> PolicySet: """Update an existing policy set.""" @@ -250,7 +251,7 @@ def update(self, policy_set_id: str, options: PolicySetUpdateOptions) -> PolicyS attrs["projects"] = relationships_data.get("projects", {}).get("data", []) attrs["policies"] = relationships_data.get("policies", {}).get("data", []) - return PolicySet.model_validate(attrs) + return attach_jsonapi(PolicySet.model_validate(attrs), data) def add_policies( self, policy_set_id: str, options: PolicySetAddPoliciesOptions diff --git a/src/pytfe/resources/policy_set_outcome.py b/src/pytfe/resources/policy_set_outcome.py index d025ec5c..19b5b0ac 100644 --- a/src/pytfe/resources/policy_set_outcome.py +++ b/src/pytfe/resources/policy_set_outcome.py @@ -6,6 +6,7 @@ from collections.abc import Iterator from typing import Any +from .._jsonapi import attach_jsonapi from ..errors import ( InvalidPolicyEvaluationIDError, InvalidPolicySetOutcomeIDError, @@ -67,7 +68,7 @@ def read(self, policy_set_outcome_id: str) -> PolicySetOutcome: path = f"api/v2/policy-set-outcomes/{policy_set_outcome_id}" r = self.t.request("GET", path) data = r.json().get("data", {}) - return PolicySetOutcome.model_validate(data) + return attach_jsonapi(PolicySetOutcome.model_validate(data), data) def _policy_set_outcome_from(self, d: dict[str, Any]) -> PolicySetOutcome: """Convert API response dict to PolicySetParameter model.""" @@ -76,4 +77,4 @@ def _policy_set_outcome_from(self, d: dict[str, Any]) -> PolicySetOutcome: attrs["policy-evaluation"] = ( d.get("relationships", {}).get("policy-evaluation", {}).get("data", {}) ) - return PolicySetOutcome.model_validate(attrs) + return attach_jsonapi(PolicySetOutcome.model_validate(attrs), d) diff --git a/src/pytfe/resources/policy_set_parameter.py b/src/pytfe/resources/policy_set_parameter.py index 0f530b50..d3a5bff8 100644 --- a/src/pytfe/resources/policy_set_parameter.py +++ b/src/pytfe/resources/policy_set_parameter.py @@ -6,6 +6,7 @@ from collections.abc import Iterator from typing import Any +from .._jsonapi import attach_jsonapi from ..errors import ( InvalidCategoryError, InvalidParamIDError, @@ -134,4 +135,4 @@ def _policy_set_parameter_from(self, d: dict[str, Any]) -> PolicySetParameter: attrs["policy_set"] = ( d.get("relationships", {}).get("configurable", {}).get("data", {}) ) - return PolicySetParameter.model_validate(attrs) + return attach_jsonapi(PolicySetParameter.model_validate(attrs), d) diff --git a/src/pytfe/resources/policy_set_version.py b/src/pytfe/resources/policy_set_version.py index c7a82bf2..39a6b12d 100644 --- a/src/pytfe/resources/policy_set_version.py +++ b/src/pytfe/resources/policy_set_version.py @@ -3,6 +3,7 @@ from __future__ import annotations +from .._jsonapi import attach_jsonapi from ..errors import ( InvalidPolicySetIDError, ) @@ -37,7 +38,9 @@ def create(self, policy_set_id: str) -> PolicySetVersion: .get("policy-set", {}) .get("data", {}) ) - return PolicySetVersion.model_validate(attrs) + return attach_jsonapi( + PolicySetVersion.model_validate(attrs), jd.get("data", {}) + ) def read(self, policy_set_version_id: str) -> PolicySetVersion: """Read is used to read a Policy Set Version by its ID.""" @@ -57,7 +60,9 @@ def read(self, policy_set_version_id: str) -> PolicySetVersion: .get("policy-set", {}) .get("data", {}) ) - return PolicySetVersion.model_validate(attrs) + return attach_jsonapi( + PolicySetVersion.model_validate(attrs), jd.get("data", {}) + ) def upload(self, policy_set_version: PolicySetVersion, file_path: str) -> None: """ diff --git a/src/pytfe/resources/projects.py b/src/pytfe/resources/projects.py index 6a965899..0a8f9a83 100644 --- a/src/pytfe/resources/projects.py +++ b/src/pytfe/resources/projects.py @@ -8,6 +8,7 @@ from collections.abc import Iterator from typing import Any +from .._jsonapi import attach_jsonapi from ..models.agent import AgentPool from ..models.common import ( EffectiveTagBinding, @@ -419,4 +420,4 @@ def _project_from(self, data: dict[str, Any]) -> Project: else None ) - return Project.model_validate(attrs) + return attach_jsonapi(Project.model_validate(attrs), data) diff --git a/src/pytfe/resources/query_run.py b/src/pytfe/resources/query_run.py index bc7feca9..84551065 100644 --- a/src/pytfe/resources/query_run.py +++ b/src/pytfe/resources/query_run.py @@ -7,6 +7,7 @@ from collections.abc import Iterator from typing import Any +from .._jsonapi import attach_jsonapi from ..errors import ( InvalidQueryRunIDError, InvalidWorkspaceIDError, @@ -56,7 +57,7 @@ def list( for item in self._list(path, params=params): attrs = item.get("attributes", {}) attrs["id"] = item.get("id") - yield QueryRun.model_validate(attrs) + yield attach_jsonapi(QueryRun.model_validate(attrs), item) def create(self, options: QueryRunCreateOptions) -> QueryRun: """Create a new query run.""" @@ -96,7 +97,7 @@ def create(self, options: QueryRunCreateOptions) -> QueryRun: attrs = data.get("attributes", {}) attrs["id"] = data.get("id") - return QueryRun.model_validate(attrs) + return attach_jsonapi(QueryRun.model_validate(attrs), data) def read(self, query_run_id: str) -> QueryRun: """Read a query run by its ID.""" @@ -110,7 +111,7 @@ def read(self, query_run_id: str) -> QueryRun: attrs = data.get("attributes", {}) attrs["id"] = data.get("id") - return QueryRun.model_validate(attrs) + return attach_jsonapi(QueryRun.model_validate(attrs), data) def read_with_options( self, query_run_id: str, options: QueryRunReadOptions @@ -131,7 +132,7 @@ def read_with_options( attrs = data.get("attributes", {}) attrs["id"] = data.get("id") - return QueryRun.model_validate(attrs) + return attach_jsonapi(QueryRun.model_validate(attrs), data) def logs(self, query_run_id: str) -> io.IOBase: """Retrieve the logs for a query run. diff --git a/src/pytfe/resources/registry_module.py b/src/pytfe/resources/registry_module.py index 3945f8a4..d4bc8c6a 100644 --- a/src/pytfe/resources/registry_module.py +++ b/src/pytfe/resources/registry_module.py @@ -7,6 +7,7 @@ from collections.abc import Iterator from typing import Any +from .._jsonapi import attach_jsonapi from ..errors import ( ERR_INVALID_NAME, ERR_INVALID_ORG, @@ -571,22 +572,25 @@ def _parse_registry_module(self, data: dict[str, Any]) -> RegistryModule: ) ) - return RegistryModule( - id=data.get("id", ""), - name=attributes.get("name", ""), - provider=attributes.get("provider", ""), - registry_name=RegistryName(attributes.get("registry-name", "private")), - namespace=attributes.get("namespace", ""), - no_code=attributes.get("no-code", False), - permissions=permissions, - publishing_mechanism=attributes.get("publishing-mechanism"), - status=attributes.get("status"), - test_config=test_config, - vcs_repo=vcs_repo, - version_statuses=version_statuses, - created_at=attributes.get("created-at"), - updated_at=attributes.get("updated-at"), - organization=organization, + return attach_jsonapi( + RegistryModule( + id=data.get("id", ""), + name=attributes.get("name", ""), + provider=attributes.get("provider", ""), + registry_name=RegistryName(attributes.get("registry-name", "private")), + namespace=attributes.get("namespace", ""), + no_code=attributes.get("no-code", False), + permissions=permissions, + publishing_mechanism=attributes.get("publishing-mechanism"), + status=attributes.get("status"), + test_config=test_config, + vcs_repo=vcs_repo, + version_statuses=version_statuses, + created_at=attributes.get("created-at"), + updated_at=attributes.get("updated-at"), + organization=organization, + ), + data, ) def _parse_registry_module_version( diff --git a/src/pytfe/resources/registry_provider.py b/src/pytfe/resources/registry_provider.py index e9f2b48d..d8d35f97 100644 --- a/src/pytfe/resources/registry_provider.py +++ b/src/pytfe/resources/registry_provider.py @@ -6,6 +6,7 @@ from collections.abc import Iterator from typing import Any +from .._jsonapi import attach_jsonapi from ..errors import ( ERR_INVALID_ORG, ) @@ -163,4 +164,4 @@ def _parse_registry_provider(self, data: dict[str, Any]) -> RegistryProvider: "links": data.get("links"), } - return RegistryProvider(**provider_data) + return attach_jsonapi(RegistryProvider(**provider_data), data) diff --git a/src/pytfe/resources/registry_provider_platform.py b/src/pytfe/resources/registry_provider_platform.py index a25c8e17..cfbbfbff 100644 --- a/src/pytfe/resources/registry_provider_platform.py +++ b/src/pytfe/resources/registry_provider_platform.py @@ -6,6 +6,7 @@ from collections.abc import Iterator from typing import Any +from .._jsonapi import attach_jsonapi from ..models.registry_provider_platform import ( RegistryProviderPlatform, RegistryProviderPlatformCreateOptions, @@ -103,4 +104,4 @@ def _registry_provider_platform_from( if "links" in data: attrs["links"] = data["links"] - return RegistryProviderPlatform.model_validate(attrs) + return attach_jsonapi(RegistryProviderPlatform.model_validate(attrs), data) diff --git a/src/pytfe/resources/registry_provider_version.py b/src/pytfe/resources/registry_provider_version.py index c523a1f3..64b4b811 100644 --- a/src/pytfe/resources/registry_provider_version.py +++ b/src/pytfe/resources/registry_provider_version.py @@ -6,7 +6,7 @@ from collections.abc import Iterator from typing import Any -from .._jsonapi import parse_relationships +from .._jsonapi import attach_jsonapi, parse_relationships from ..errors import ( RequiredPrivateRegistryError, ) @@ -72,7 +72,7 @@ def _registry_provider_version_from( }, ) ) - return RegistryProviderVersion.model_validate(attrs) + return attach_jsonapi(RegistryProviderVersion.model_validate(attrs), data) def list( self, diff --git a/src/pytfe/resources/run.py b/src/pytfe/resources/run.py index c208787b..d43c22af 100644 --- a/src/pytfe/resources/run.py +++ b/src/pytfe/resources/run.py @@ -6,7 +6,7 @@ from collections.abc import Iterator from typing import Any -from .._jsonapi import RelationMap, parse_relationships +from .._jsonapi import RelationMap, attach_jsonapi, parse_relationships from ..errors import ( InvalidOrgError, InvalidRunIDError, @@ -60,7 +60,8 @@ def _run_from(d: dict[str, Any], included: list[dict[str, Any]] | None = None) - attr.update( parse_relationships(d.get("relationships"), _RUN_REL_MAP, included=included) ) - return Run.model_validate(attr) + # Keep raw relationships + included so unmodeled relations are never lost. + return attach_jsonapi(Run.model_validate(attr), d, included) class Runs(_Service): diff --git a/src/pytfe/resources/run_event.py b/src/pytfe/resources/run_event.py index 4f1a842d..d94a4f5d 100644 --- a/src/pytfe/resources/run_event.py +++ b/src/pytfe/resources/run_event.py @@ -6,6 +6,7 @@ from collections.abc import Iterator from typing import Any +from .._jsonapi import attach_jsonapi from ..errors import InvalidRunEventIDError, InvalidRunIDError from ..models.run_event import ( RunEvent, @@ -31,7 +32,7 @@ def list( for item in self._list(path, params=params, paginated=False): attrs = item.get("attributes", {}) attrs["id"] = item.get("id") - yield RunEvent.model_validate(attrs) + yield attach_jsonapi(RunEvent.model_validate(attrs), item) def read(self, run_event_id: str) -> RunEvent: """Read a specific run event by its ID.""" @@ -51,9 +52,14 @@ def read_with_options( f"/api/v2/run-events/{run_event_id}", params=params, ) - d = r.json().get("data", {}) + payload = r.json() + d = payload.get("data", {}) attr = d.get("attributes", {}) or {} - return RunEvent( - id=d.get("id"), - **{k.replace("-", "_"): v for k, v in attr.items()}, + return attach_jsonapi( + RunEvent( + id=d.get("id"), + **{k.replace("-", "_"): v for k, v in attr.items()}, + ), + d, + payload.get("included"), ) diff --git a/src/pytfe/resources/run_task.py b/src/pytfe/resources/run_task.py index e1dc622c..26887f38 100644 --- a/src/pytfe/resources/run_task.py +++ b/src/pytfe/resources/run_task.py @@ -6,6 +6,7 @@ from collections.abc import Iterator from typing import Any +from .._jsonapi import attach_jsonapi from ..errors import ( InvalidOrgError, InvalidRunTaskCategoryError, @@ -108,18 +109,21 @@ def _run_task_from(d: dict[str, Any], org: str | None = None) -> RunTask: WorkspaceRunTask(id=_safe_str(item.get("id"))) ) - return RunTask( - id=id_str, - name=name_str, - description=_safe_str(attr.get("description")) or None, - url=_safe_str(attr.get("url")), - category=_safe_str(attr.get("category")), - hmac_key=attr.get("hmac-key"), # Can be None - enabled=bool(attr.get("enabled")), - global_configuration=global_config, - agent_pool=agent_pool, - organization=organization, - workspace_run_tasks=workspace_run_tasks, + return attach_jsonapi( + RunTask( + id=id_str, + name=name_str, + description=_safe_str(attr.get("description")) or None, + url=_safe_str(attr.get("url")), + category=_safe_str(attr.get("category")), + hmac_key=attr.get("hmac-key"), # Can be None + enabled=bool(attr.get("enabled")), + global_configuration=global_config, + agent_pool=agent_pool, + organization=organization, + workspace_run_tasks=workspace_run_tasks, + ), + d, ) diff --git a/src/pytfe/resources/run_trigger.py b/src/pytfe/resources/run_trigger.py index 52d820d8..7570e109 100644 --- a/src/pytfe/resources/run_trigger.py +++ b/src/pytfe/resources/run_trigger.py @@ -8,6 +8,7 @@ from datetime import datetime from typing import Any +from .._jsonapi import attach_jsonapi from ..errors import ( InvalidRunTriggerIDError, InvalidRunTriggerTypeError, @@ -68,14 +69,17 @@ def _run_trigger_from(d: dict[str, Any], org: str | None = None) -> RunTrigger: else datetime.now() ) - return RunTrigger( - id=id_str, - created_at=created_at, - sourceable_name=sourceable_name_str, - workspace_name=workspace_name_str, - sourceable=sourceable, - sourceable_choice=sourceable_choice, - workspace=workspace, + return attach_jsonapi( + RunTrigger( + id=id_str, + created_at=created_at, + sourceable_name=sourceable_name_str, + workspace_name=workspace_name_str, + sourceable=sourceable, + sourceable_choice=sourceable_choice, + workspace=workspace, + ), + d, ) diff --git a/src/pytfe/resources/stack.py b/src/pytfe/resources/stack.py index bf9c9977..55cbd4fa 100644 --- a/src/pytfe/resources/stack.py +++ b/src/pytfe/resources/stack.py @@ -10,7 +10,7 @@ Project, ) -from .._jsonapi import parse_relationships +from .._jsonapi import attach_jsonapi, parse_relationships from ..models.stack import ( Stack, StackCreateOptions, @@ -138,4 +138,4 @@ def _stack_from(self, data: dict) -> Stack: {"project": Project, "agent-pool": AgentPool}, ) ) - return Stack.model_validate(attrs) + return attach_jsonapi(Stack.model_validate(attrs), data) diff --git a/src/pytfe/resources/stack_configuration.py b/src/pytfe/resources/stack_configuration.py index be9e849d..774c566e 100644 --- a/src/pytfe/resources/stack_configuration.py +++ b/src/pytfe/resources/stack_configuration.py @@ -3,12 +3,13 @@ from __future__ import annotations +import builtins from collections.abc import Iterator from typing import Any from pytfe.models.configuration_version import IngressAttributes -from .._jsonapi import parse_relationships +from .._jsonapi import attach_jsonapi, parse_relationships from ..models.stack import Stack from ..models.stack_configuration import ( StackConfiguration, @@ -76,10 +77,15 @@ def read( if options and options.include: params["include"] = ",".join([i.value for i in options.include]) r = self.t.request("GET", path=path, params=params) - data = r.json().get("data", {}) - return self._stack_configuration_from(data) + payload = r.json() + data = payload.get("data", {}) + return self._stack_configuration_from(data, payload.get("included")) - def _stack_configuration_from(self, data: dict[str, Any]) -> StackConfiguration: + def _stack_configuration_from( + self, + data: dict[str, Any], + included: builtins.list[dict[str, Any]] | None = None, + ) -> StackConfiguration: """Parse a StackConfiguration from API response data.""" attrs = dict(data.get("attributes", {})) attrs["id"] = data.get("id") @@ -87,6 +93,7 @@ def _stack_configuration_from(self, data: dict[str, Any]) -> StackConfiguration: parse_relationships( data.get("relationships"), {"stack": Stack, "ingress-attributes": IngressAttributes}, + included=included, ) ) - return StackConfiguration.model_validate(attrs) + return attach_jsonapi(StackConfiguration.model_validate(attrs), data, included) diff --git a/src/pytfe/resources/state_version_outputs.py b/src/pytfe/resources/state_version_outputs.py index 98786d64..a3f5f558 100644 --- a/src/pytfe/resources/state_version_outputs.py +++ b/src/pytfe/resources/state_version_outputs.py @@ -6,6 +6,7 @@ from collections.abc import Iterator from typing import Any +from .._jsonapi import attach_jsonapi from ..models.state_version_output import ( StateVersionOutput, StateVersionOutputsListOptions, @@ -36,9 +37,12 @@ def read(self, output_id: str) -> StateVersionOutput: d = r.json()["data"] attr = d.get("attributes", {}) or {} - return StateVersionOutput( - id=_safe_str(d.get("id")), - **{k.replace("-", "_"): v for k, v in attr.items()}, + return attach_jsonapi( + StateVersionOutput( + id=_safe_str(d.get("id")), + **{k.replace("-", "_"): v for k, v in attr.items()}, + ), + d, ) def read_current( @@ -61,7 +65,10 @@ def read_current( for d in self._list(path, params=params): attr = d.get("attributes", {}) or {} - yield StateVersionOutput( - id=_safe_str(d.get("id")), - **{k.replace("-", "_"): v for k, v in attr.items()}, + yield attach_jsonapi( + StateVersionOutput( + id=_safe_str(d.get("id")), + **{k.replace("-", "_"): v for k, v in attr.items()}, + ), + d, ) diff --git a/src/pytfe/resources/state_versions.py b/src/pytfe/resources/state_versions.py index 3f7ec760..908288b0 100644 --- a/src/pytfe/resources/state_versions.py +++ b/src/pytfe/resources/state_versions.py @@ -7,6 +7,7 @@ from typing import Any from urllib.parse import urlencode +from .._jsonapi import attach_jsonapi from ..errors import ErrStateVersionUploadNotSupported, NotFound, TFEError from ..models.state_version import ( StateVersion, @@ -81,7 +82,7 @@ def list( for d in self._list(path, params=params): attrs = d.get("attributes", {}) attrs["id"] = d.get("id") - yield StateVersion.model_validate(attrs) + yield attach_jsonapi(StateVersion.model_validate(attrs), d) def read(self, state_version_id: str) -> StateVersion: """Read a state version by ID.""" @@ -89,12 +90,17 @@ def read(self, state_version_id: str) -> StateVersion: raise ValueError("invalid state version id") r = self.t.request("GET", f"/api/v2/state-versions/{state_version_id}") - d = r.json()["data"] + payload = r.json() + d = payload["data"] attr = d.get("attributes", {}) or {} - return StateVersion( - id=_safe_str(d.get("id")), - **{k.replace("-", "_"): v for k, v in attr.items()}, + return attach_jsonapi( + StateVersion( + id=_safe_str(d.get("id")), + **{k.replace("-", "_"): v for k, v in attr.items()}, + ), + d, + payload.get("included"), ) def read_with_options( @@ -111,12 +117,17 @@ def read_with_options( r = self.t.request( "GET", f"/api/v2/state-versions/{state_version_id}", params=params ) - d = r.json()["data"] + payload = r.json() + d = payload["data"] attr = d.get("attributes", {}) or {} - return StateVersion( - id=_safe_str(d.get("id")), - **{k.replace("-", "_"): v for k, v in attr.items()}, + return attach_jsonapi( + StateVersion( + id=_safe_str(d.get("id")), + **{k.replace("-", "_"): v for k, v in attr.items()}, + ), + d, + payload.get("included"), ) def read_current(self, workspace_id: str) -> StateVersion: @@ -127,12 +138,17 @@ def read_current(self, workspace_id: str) -> StateVersion: r = self.t.request( "GET", f"/api/v2/workspaces/{workspace_id}/current-state-version" ) - d = r.json()["data"] + payload = r.json() + d = payload["data"] attr = d.get("attributes", {}) or {} - return StateVersion( - id=_safe_str(d.get("id")), - **{k.replace("-", "_"): v for k, v in attr.items()}, + return attach_jsonapi( + StateVersion( + id=_safe_str(d.get("id")), + **{k.replace("-", "_"): v for k, v in attr.items()}, + ), + d, + payload.get("included"), ) def read_current_with_options( @@ -151,12 +167,17 @@ def read_current_with_options( f"/api/v2/workspaces/{workspace_id}/current-state-version", params=params, ) - d = r.json()["data"] + payload = r.json() + d = payload["data"] attr = d.get("attributes", {}) or {} - return StateVersion( - id=_safe_str(d.get("id")), - **{k.replace("-", "_"): v for k, v in attr.items()}, + return attach_jsonapi( + StateVersion( + id=_safe_str(d.get("id")), + **{k.replace("-", "_"): v for k, v in attr.items()}, + ), + d, + payload.get("included"), ) # ---------------------------- @@ -395,4 +416,4 @@ def rollback( data = (resp.json() or {}).get("data") or {} attributes = dict(data.get("attributes") or {}) attributes["id"] = data.get("id", "") - return StateVersion.model_validate(attributes) + return attach_jsonapi(StateVersion.model_validate(attributes), data) diff --git a/src/pytfe/resources/task_result.py b/src/pytfe/resources/task_result.py index 8f6c05d7..d8321af6 100644 --- a/src/pytfe/resources/task_result.py +++ b/src/pytfe/resources/task_result.py @@ -7,6 +7,7 @@ from pytfe.models.task_stage import TaskStage from pytfe.utils import valid_string_id +from .._jsonapi import attach_jsonapi from ._base import _Service @@ -35,4 +36,4 @@ def _parse_task_result(self, data: dict[str, Any]) -> TaskResult: id=task_stage_data["id"] ) - return TaskResult.model_validate(attributes) + return attach_jsonapi(TaskResult.model_validate(attributes), data) diff --git a/src/pytfe/resources/task_stage.py b/src/pytfe/resources/task_stage.py index 2b1c0489..8a2cf5c4 100644 --- a/src/pytfe/resources/task_stage.py +++ b/src/pytfe/resources/task_stage.py @@ -6,7 +6,7 @@ from collections.abc import Iterator from typing import Any -from .._jsonapi import parse_relationships +from .._jsonapi import attach_jsonapi, parse_relationships from ..errors import InvalidRunIDError, InvalidTaskStageIDError from ..models.policy_evaluation import PolicyEvaluation from ..models.run import Run @@ -36,7 +36,7 @@ def _parse_task_stage(self, data: dict[str, Any]) -> TaskStage: # lists (not None) for these collections when the relations are absent. attributes.setdefault("task_results", []) attributes.setdefault("policy_evaluations", []) - return TaskStage.model_validate(attributes) + return attach_jsonapi(TaskStage.model_validate(attributes), data) # Read def read(self, task_stage_id: str) -> TaskStage: diff --git a/src/pytfe/resources/team.py b/src/pytfe/resources/team.py index 95a993ab..2bd737d6 100644 --- a/src/pytfe/resources/team.py +++ b/src/pytfe/resources/team.py @@ -3,7 +3,7 @@ import builtins from collections.abc import Iterator -from .._jsonapi import parse_relationships +from .._jsonapi import attach_jsonapi, parse_relationships from ..errors import ( ERR_INVALID_ORG, InvalidTeamIDError, @@ -47,7 +47,7 @@ def _team_from(self, data: dict) -> Team: {"users": User, "organization-memberships": OrganizationMembership}, ) ) - return Team.model_validate(attrs) + return attach_jsonapi(Team.model_validate(attrs), data) def create(self, organization: str, options: TeamCreateOptions) -> Team: """Create a new team in the given organization.""" diff --git a/src/pytfe/resources/team_project_access.py b/src/pytfe/resources/team_project_access.py index 746fc59f..a15561a3 100644 --- a/src/pytfe/resources/team_project_access.py +++ b/src/pytfe/resources/team_project_access.py @@ -2,6 +2,7 @@ from collections.abc import Iterator +from .._jsonapi import attach_jsonapi from ..errors import InvalidTeamProjectAccessIDError from ..models.project import Project from ..models.team import Team @@ -110,7 +111,7 @@ def _team_project_access_from(self, data: dict) -> TeamProjectAccess: attrs["team"] = Team(id=team_data.get("id")) if team_data else None attrs["project"] = Project(id=project_data.get("id")) if project_data else None - return TeamProjectAccess.model_validate(attrs) + return attach_jsonapi(TeamProjectAccess.model_validate(attrs), data) def update( self, team_project_access_id: str, options: TeamProjectAccessUpdateOptions diff --git a/src/pytfe/resources/team_token.py b/src/pytfe/resources/team_token.py index 369e7dfc..861c4177 100644 --- a/src/pytfe/resources/team_token.py +++ b/src/pytfe/resources/team_token.py @@ -6,7 +6,7 @@ from collections.abc import Iterator from typing import Any -from .._jsonapi import parse_relationships +from .._jsonapi import attach_jsonapi, parse_relationships from ..errors import InvalidOrgError, InvalidTeamIDError, InvalidTokenIDError from ..models.organization import Organization from ..models.team import Team @@ -146,4 +146,4 @@ def _team_token_from(self, data: dict[str, Any]) -> TeamToken: organization=Organization.model_construct(id=created_by_data["id"]) ) - return TeamToken.model_validate(attrs) + return attach_jsonapi(TeamToken.model_validate(attrs), data) diff --git a/src/pytfe/resources/team_workspace_access.py b/src/pytfe/resources/team_workspace_access.py index 8f37c21b..cd0afdee 100644 --- a/src/pytfe/resources/team_workspace_access.py +++ b/src/pytfe/resources/team_workspace_access.py @@ -6,6 +6,7 @@ from collections.abc import Iterator from typing import Any +from .._jsonapi import attach_jsonapi from ..errors import InvalidTeamIDError, InvalidWorkspaceIDError, TFEError from ..models.team_workspace_access import ( TeamWorkspaceAccess, @@ -33,7 +34,7 @@ def _parse(data: dict[str, Any]) -> TeamWorkspaceAccess: attributes["team-id"] = team_data["id"] if workspace_data.get("id"): attributes["workspace-id"] = workspace_data["id"] - return TeamWorkspaceAccess.model_validate(attributes) + return attach_jsonapi(TeamWorkspaceAccess.model_validate(attributes), data) def _attributes_payload(model_dict: dict[str, Any]) -> dict[str, Any]: diff --git a/src/pytfe/resources/variable_sets.py b/src/pytfe/resources/variable_sets.py index 49d5c782..4d89d1cd 100644 --- a/src/pytfe/resources/variable_sets.py +++ b/src/pytfe/resources/variable_sets.py @@ -8,6 +8,7 @@ from typing import Any from .._http import HTTPTransport +from .._jsonapi import attach_jsonapi from ..models.variable_set import ( VariableSet, VariableSetApplyToProjectsOptions, @@ -676,7 +677,7 @@ def _parse_variable_set(self, data: dict[str, Any]) -> VariableSet: parsed_data["parent"] = parent # Use Pydantic model validation to handle aliases properly - return VariableSet.model_validate(parsed_data) + return attach_jsonapi(VariableSet.model_validate(parsed_data), data) class VariableSetVariables(_Service): @@ -946,4 +947,4 @@ def _parse_variable_set_variable(self, data: dict[str, Any]) -> VariableSetVaria parsed_data["variable_set"] = variable_set # Use Pydantic model validation - return VariableSetVariable.model_validate(parsed_data) + return attach_jsonapi(VariableSetVariable.model_validate(parsed_data), data) diff --git a/src/pytfe/resources/workspace_resources.py b/src/pytfe/resources/workspace_resources.py index 6f5c1f9d..e413d5cf 100644 --- a/src/pytfe/resources/workspace_resources.py +++ b/src/pytfe/resources/workspace_resources.py @@ -11,6 +11,7 @@ WorkspaceResourceListOptions, ) +from .._jsonapi import attach_jsonapi from ._base import _Service @@ -18,17 +19,22 @@ def _workspace_resource_from(data: dict[str, Any]) -> WorkspaceResource: """Convert API response data to WorkspaceResource model.""" attributes = data.get("attributes", {}) - return WorkspaceResource( - id=data.get("id", ""), - address=attributes.get("address", ""), - name=attributes.get("name", ""), - created_at=attributes.get("created-at", ""), - updated_at=attributes.get("updated-at", ""), - module=attributes.get("module", ""), - provider=attributes.get("provider", ""), - provider_type=attributes.get("provider-type", ""), - modified_by_state_version_id=attributes.get("modified-by-state-version-id", ""), - name_index=attributes.get("name-index"), + return attach_jsonapi( + WorkspaceResource( + id=data.get("id", ""), + address=attributes.get("address", ""), + name=attributes.get("name", ""), + created_at=attributes.get("created-at", ""), + updated_at=attributes.get("updated-at", ""), + module=attributes.get("module", ""), + provider=attributes.get("provider", ""), + provider_type=attributes.get("provider-type", ""), + modified_by_state_version_id=attributes.get( + "modified-by-state-version-id", "" + ), + name_index=attributes.get("name-index"), + ), + data, ) diff --git a/src/pytfe/resources/workspace_run_task.py b/src/pytfe/resources/workspace_run_task.py index 7d20a868..79ec4efe 100644 --- a/src/pytfe/resources/workspace_run_task.py +++ b/src/pytfe/resources/workspace_run_task.py @@ -7,6 +7,7 @@ from typing import Any from urllib.parse import quote +from .._jsonapi import attach_jsonapi from ..errors import ( InvalidWorkspaceIDError, InvalidWorkspaceRunTaskIDError, @@ -40,7 +41,7 @@ def _workspace_run_task_from(data: dict[str, Any]) -> WorkspaceRunTask: id=_safe_str(workspace_data.get("id")) ) - return WorkspaceRunTask.model_validate(attributes) + return attach_jsonapi(WorkspaceRunTask.model_validate(attributes), data) class WorkspaceRunTasks(_Service): diff --git a/src/pytfe/resources/workspaces.py b/src/pytfe/resources/workspaces.py index 5e58645e..c6342d5f 100644 --- a/src/pytfe/resources/workspaces.py +++ b/src/pytfe/resources/workspaces.py @@ -9,7 +9,7 @@ from pytfe.models.ssh_key import SSHKey -from .._jsonapi import RelationMap, parse_relationships +from .._jsonapi import RelationMap, attach_jsonapi, parse_relationships from ..errors import ( InvalidOrgError, InvalidSSHKeyIDError, @@ -75,9 +75,9 @@ # Declarative relationship map: wire relation name -> model (attr derived as # wire.replace("-", "_")), or an explicit (attr, model) tuple where they diverge. -# Polymorphic relations (locked-by, data-retention-policy-choice) and the -# attribute-bearing ``outputs`` relation are handled as special cases in -# ``_ws_from`` and intentionally left out of this map. +# Only the genuinely polymorphic relations (locked-by, data-retention-policy-choice, +# whose target model depends on the reference ``type``) are handled as special +# cases in ``_ws_from`` and intentionally left out of this map. _WORKSPACE_REL_MAP: RelationMap = { "organization": Organization, "project": Project, @@ -90,6 +90,10 @@ "current-assessment-result": AssessmentResult, "remote-state-consumers": Workspace, "vars": ("variables", Variable), # wire name diverges from attr + # outputs is a JSON:API relation whose attributes live in the ``included`` + # array (matching go-tfe's `jsonapi:"relation,outputs"`); hydrate it via the + # shared path so ?include=outputs populates name/value/type (python-tfe#134). + "outputs": WorkspaceOutputs, } @@ -141,14 +145,6 @@ def _ws_from( elif lb_data.get("type") == "teams": locked_by = LockedByChoice.model_validate({"team": lb_data.get("id")}) - # Map outputs (the only relation whose data carries inline attributes) - outputs = [] - if relationships.get("outputs", {}).get("data"): - for output_data in relationships["outputs"].get("data", []): - output_attrs = output_data.get("attributes", {}) - output_attrs["id"] = output_data.get("id", "") - outputs.append(WorkspaceOutputs.model_validate(output_attrs)) - data_retention_policy_choice: DataRetentionPolicyChoice | None = None if relationships.get("data-retention-policy-choice", {}).get("data"): drp_data = relationships["data-retention-policy-choice"]["data"] @@ -209,12 +205,13 @@ def _ws_from( parse_relationships(relationships, _WORKSPACE_REL_MAP, included=included) ) - # Special-case relations that don't fit the generic (attr, Model) map. - attr["outputs"] = outputs + # Special-case (polymorphic) relations that don't fit the generic map. attr["locked_by"] = locked_by attr["data_retention_policy_choice"] = data_retention_policy_choice - return Workspace.model_validate(attr) + # Keep the raw relationships + included so related resources we don't model + # are never lost (reachable via ws.relationships / ws.included / ws.related). + return attach_jsonapi(Workspace.model_validate(attr), d, included) class Workspaces(_Service): diff --git a/tests/units/test_workspace_jsonapi.py b/tests/units/test_workspace_jsonapi.py index ebe5d814..b632cb25 100644 --- a/tests/units/test_workspace_jsonapi.py +++ b/tests/units/test_workspace_jsonapi.py @@ -5,7 +5,7 @@ from datetime import datetime -from src.pytfe._jsonapi import build_included_index, parse_relationships +from src.pytfe._jsonapi import attach_jsonapi, build_included_index, parse_relationships from src.pytfe.models.run import Run, RunStatus from src.pytfe.resources.workspaces import _ws_from @@ -138,3 +138,164 @@ def test_parse_relationships_skips_unmapped_and_null(self): ) assert set(out) == {"current_run"} assert out["current_run"].id == "run-x" + + +class TestOutputsInclude: + """python-tfe#134: ?include=outputs must hydrate name/value/type from the + `included` array, not leave id-only stubs.""" + + def _payload(self): + return _ws_payload( + relationships={ + "outputs": { + "data": [ + {"id": "wsout-1", "type": "workspace-outputs"}, + {"id": "wsout-2", "type": "workspace-outputs"}, + ] + } + } + ) + + def test_outputs_hydrated_from_included(self): + included = [ + { + "id": "wsout-1", + "type": "workspace-outputs", + "attributes": { + "name": "public_ip", + "value": "1.2.3.4", + "output-type": "string", + "sensitive": False, + }, + }, + { + "id": "wsout-2", + "type": "workspace-outputs", + "attributes": { + "name": "host", + "value": {"role": "web"}, # object output -> dict value + "output-type": "object", + "sensitive": True, + }, + }, + ] + ws = _ws_from(self._payload(), included) + assert [o.name for o in ws.outputs] == ["public_ip", "host"] + assert ws.outputs[0].value == "1.2.3.4" + assert ws.outputs[0].output_type == "string" + # object/list values pass through unchanged (value is typed Any) + assert ws.outputs[1].value == {"role": "web"} + assert ws.outputs[1].output_type == "object" + assert ws.outputs[1].sensitive is True + + def test_outputs_without_included_fall_back_to_id_stubs(self): + ws = _ws_from(self._payload(), included=None) + assert [o.id for o in ws.outputs] == ["wsout-1", "wsout-2"] + assert all(o.name is None and o.value is None for o in ws.outputs) + + +class TestLosslessIncluded: + """Raw `included` is retained even for relations the SDK does not model, and + never leaks into model_dump() (TFEModel escape hatch).""" + + def test_unmapped_include_reachable_via_included_by(self): + data = { + "id": "ws-x", + "type": "workspaces", + "attributes": {"name": "demo"}, + "relationships": { + "readme": {"data": {"id": "rm-1", "type": "workspace-readme"}} + }, + } + included = [ + { + "id": "rm-1", + "type": "workspace-readme", + "attributes": {"raw-markdown": "# Hello"}, + } + ] + ws = _ws_from(data, included) + # readme is not a modelled relation -> no typed attribute ... + assert not hasattr(ws, "readme") + # ... but it is still reachable raw, losslessly. + got = ws.included_by("workspace-readme", "rm-1") + assert got["attributes"]["raw-markdown"] == "# Hello" + assert len(ws.included) == 1 + + def test_included_never_appears_in_model_dump(self): + ws = _ws_from( + _ws_payload(), + [{"id": "x", "type": "y", "attributes": {"k": "v"}}], + ) + dumped = ws.model_dump() + assert "included" not in dumped + assert "_included" not in dumped + + def test_no_included_is_empty(self): + ws = _ws_from(_ws_payload()) + assert ws.included == [] + assert ws.included_by("any", "thing") is None + + def test_relationships_block_and_related_resolution(self): + data = { + "id": "ws-x", + "type": "workspaces", + "attributes": {"name": "demo"}, + "relationships": { + "current-run": {"data": {"id": "run-1", "type": "runs"}}, + "readme": {"data": {"id": "rm-1", "type": "workspace-readme"}}, + }, + } + included = [ + { + "id": "rm-1", + "type": "workspace-readme", + "attributes": {"raw-markdown": "hi"}, + } + ] + ws = _ws_from(data, included) + # raw relationships block is captured for every relation + assert "current-run" in ws.relationships + assert "readme" in ws.relationships + # related() resolves to the full body when included ... + assert ws.related("readme")[0]["attributes"]["raw-markdown"] == "hi" + # ... and falls back to the bare {type,id} ref when not included + assert ws.related("current-run") == [{"id": "run-1", "type": "runs"}] + # neither block leaks into model_dump() + assert "relationships" not in ws.model_dump() + + +class TestPresenceTracking: + """has_relationships / has_included distinguish 'absent on wire' from + 'present but empty', without making the data accessors disappear.""" + + def _obj(self): + return Run.model_validate({"id": "run-1"}) + + def test_relationships_present_absent_empty(self): + present = attach_jsonapi( + self._obj(), + {"id": "r", "relationships": {"workspace": {"data": {"id": "ws"}}}}, + ) + assert present.has_relationships is True and present.relationships != {} + + empty = attach_jsonapi(self._obj(), {"id": "r", "relationships": {}}) + assert empty.has_relationships is True and empty.relationships == {} + + absent = attach_jsonapi(self._obj(), {"id": "r", "attributes": {}}) + assert absent.has_relationships is False and absent.relationships == {} + + def test_included_present_absent_empty(self): + present = attach_jsonapi(self._obj(), {"id": "r"}, [{"id": "x", "type": "y"}]) + assert present.has_included is True and len(present.included) == 1 + + empty = attach_jsonapi(self._obj(), {"id": "r"}, []) + assert empty.has_included is True and empty.included == [] + + absent = attach_jsonapi(self._obj(), {"id": "r"}, None) + assert absent.has_included is False and absent.included == [] + + def test_default_instance_reports_absent(self): + o = self._obj() # not built via attach_jsonapi + assert o.has_relationships is False and o.has_included is False + assert o.relationships == {} and o.included == [] From b3a73f273ac566db51448e53dd1f4cfde9117da4 Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Tue, 16 Jun 2026 18:50:24 +0530 Subject: [PATCH 2/6] update changes --- CHANGELOG.md | 14 ++- docs/related-resources.md | 55 +++++++- examples/related_resources.py | 47 ++++++- src/pytfe/models/__init__.py | 16 ++- src/pytfe/models/organization.py | 14 +++ src/pytfe/models/task_stage.py | 17 +++ src/pytfe/models/team.py | 8 ++ src/pytfe/resources/agents.py | 22 ++-- src/pytfe/resources/configuration_version.py | 11 +- src/pytfe/resources/oauth_client.py | 13 +- .../resources/organization_membership.py | 59 ++++----- src/pytfe/resources/organizations.py | 20 ++- src/pytfe/resources/policy_set.py | 119 ++++++------------ src/pytfe/resources/projects.py | 12 +- src/pytfe/resources/query_run.py | 6 +- src/pytfe/resources/registry_provider.py | 13 +- src/pytfe/resources/run_event.py | 42 ++++--- src/pytfe/resources/run_task.py | 10 +- src/pytfe/resources/task_stage.py | 25 +++- src/pytfe/resources/team.py | 21 +++- src/pytfe/resources/variable_sets.py | 79 ++++-------- tests/units/test_organization_membership.py | 6 + tests/units/test_organizations.py | 84 +++++++++++++ tests/units/test_policy_set.py | 70 +++++++++++ tests/units/test_run_events.py | 33 +++++ tests/units/test_run_task.py | 8 ++ tests/units/test_task_stage.py | 48 +++++++ tests/units/test_team.py | 42 +++++++ 28 files changed, 678 insertions(+), 236 deletions(-) create mode 100644 tests/units/test_organizations.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f76a39b..d2a6783a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,14 +5,24 @@ ### Relationships * 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*. * **`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. - * **`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. + * **`included` hydration** now comes in two forms, both purely additive: + * **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=` fills that field** (e.g. `policy_set.current_version`, `organization_membership.user`, `run_event.actor`). + * **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.) + * **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). - See [docs/related-resources.md](docs/related-resources.md). + See [docs/related-resources.md](docs/related-resources.md) for the per-resource coverage table and a "typed field vs raw accessor" guide. + +* 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): + * `teams.read(team_id, TeamReadOptions(include=[...]))` — `users`, `organization-memberships` (typed hydration). + * `task_stages.read(task_stage_id, TaskStageReadOptions(include=[...]))` — `run`, `run.workspace`, `task-results`, `policy-evaluations` (typed hydration). + * `organizations.read(name, OrganizationReadOptions(include=[...]))` — `subscription` (raw capture). The new `options` argument is optional, so existing positional calls are unchanged. ## Bug Fixes ### Relationships * 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.) +* `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`. +* `variable_set.read` no longer fabricates placeholder relation values (e.g. `name="workspace-"`, `key="var-"`, `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. # Released # v1.1.0 diff --git a/docs/related-resources.md b/docs/related-resources.md index 8ae034fc..36ed5352 100644 --- a/docs/related-resources.md +++ b/docs/related-resources.md @@ -60,6 +60,53 @@ pyTFE handles this on two levels: print(list(ws.relationships)) # e.g. ['organization', 'project', 'outputs', ...] ``` +## Which should I use — the typed field or the raw accessor? + +**The one rule:** a typed relationship field always carries **at least the `id`**. +Pass `?include=` to fill in the rest. + +```python +from pytfe.models.policy_set import PolicySetReadOptions, PolicySetIncludeOpt + +ps = client.policy_sets.read("polset-abc") +ps.current_version.id # always present (id-only stub) +ps.current_version.source # None — you didn't ask for it + +ps = client.policy_sets.read_with_options( + "polset-abc", + PolicySetReadOptions(include=[PolicySetIncludeOpt.POLICY_SET_CURRENT_VERSION]), +) +ps.current_version.source # now hydrated from `included` +``` + +* **Prefer the typed field** (`ps.current_version`, `ws.outputs`, `team.users`, + `org_membership.user`, `run_event.actor`) whenever the relation is modelled — it's + type-checked and stable, and `?include=` fills it. This works the *same + way for every resource that models the relation*: there are no resources where a + typed field silently stays a stub after you `?include=` it. +* **Use the raw accessors** (`model.related(name)`, `model.included_by(type, id)`) + only for relations the SDK does **not** model as a typed field — e.g. an + organization's `subscription`, or a workspace `readme`. The data is still returned + by `?include=`, just untyped. + +You never need both for the same relation: if a typed field exists, `?include=` fills +it; if it doesn't, the raw accessors are the way in. + +## Per-resource coverage + +`?include=` support by single-resource `read*` (see each resource's `*IncludeOpt`): + +| Behaviour | Resources | +|---|---| +| **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` | +| **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` | +| **List-only** — `?include=` exists only on the `list` endpoint | `registry_module`, `run_trigger`, `policy_check` | + +In every case the **`relationships`** block and the four raw accessors are populated, +so unmodelled relations are never lost. **List endpoints** currently capture +`relationships` but not `included` (the page-level `included` array is not yet threaded +through pagination — in progress). + ## Notes - The raw blocks are **private attributes**, so they never appear in @@ -72,6 +119,8 @@ pyTFE handles this on two levels: - Accessors are provided by `pytfe.models.TFEModel`, which **every resource model** now derives from — so `.relationships` / `.included` / `.included_by` / `.related` are available everywhere. They're *populated* on - resources parsed through a dedicated parser; other resources expose the - accessors but return them empty until their parser is wired to capture the - raw blocks. + single-resource `read*` calls: the `relationships` block on reads that go + through a relationship-capturing parser, and the `included` array whenever you + pass `?include=`. **List endpoints** currently populate `relationships` but not + `included` — the shared top-level `included` array is not yet threaded through + pagination (in progress). diff --git a/examples/related_resources.py b/examples/related_resources.py index dccb7232..9056e937 100644 --- a/examples/related_resources.py +++ b/examples/related_resources.py @@ -9,24 +9,27 @@ * `included` — full bodies of relations you ask for with ?include= pyTFE hydrates the relations it models into typed fields, AND keeps both raw -blocks so nothing is ever lost. This example shows both. +blocks so nothing is ever lost. The one rule: a typed relationship field always +carries at least the `id`; pass ?include= to fill in the rest. This example shows +both a workspace and a team. Prerequisites: export TFE_TOKEN=... # your API token + export TFE_ORG=... # org to look up a team in (optional) python examples/related_resources.py ws-abc123 # a workspace id """ from __future__ import annotations +import os import sys from pytfe import TFEClient +from pytfe.models.team import TeamIncludeOpt, TeamReadOptions from pytfe.models.workspace import WorkspaceIncludeOpt, WorkspaceReadOptions -def main(workspace_id: str) -> None: - client = TFEClient() - +def workspace_demo(client: TFEClient, workspace_id: str) -> None: # Ask the API to include the workspace's outputs and project. ws = client.workspaces.read_by_id_with_options( workspace_id, @@ -60,6 +63,42 @@ def main(workspace_id: str) -> None: assert "relationships" not in ws.model_dump() +def team_demo(client: TFEClient, org: str) -> None: + # Grab any team in the org, then read it back asking for its users. + teams = list(client.teams.list(org)) + if not teams: + print(f"\n(no teams in {org} to demo)") + return + + team = client.teams.read( + teams[0].id, + TeamReadOptions(include=[TeamIncludeOpt.TEAM_USERS]), + ) + + # Typed hydration: team.users carries the full user bodies, not just ids. + print(f"\nteam: {team.name} ({team.user_count} members)") + for user in team.users or []: + # Without include=users this would be an id-only stub (username == None). + print(f" user (hydrated): {user.id} {user.username}") + + # The raw escape hatch is populated too, for relations not modelled as fields. + print( + f" has_included={team.has_included} relationships={sorted(team.relationships)}" + ) + assert "included" not in team.model_dump() + + +def main(workspace_id: str) -> None: + client = TFEClient() + workspace_demo(client, workspace_id) + + org = os.environ.get("TFE_ORG") + if org: + team_demo(client, org) + else: + print("\n(set TFE_ORG to also run the team include demo)") + + if __name__ == "__main__": if len(sys.argv) != 2: print("usage: python examples/related_resources.py ") diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index 892cf03d..d12eb344 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -211,6 +211,8 @@ OrganizationCreateOptions, OrganizationDefaultSettings, OrganizationDefaultSettingsUpdateOptions, + OrganizationIncludeOpt, + OrganizationReadOptions, OrganizationUpdateOptions, ReadRunQueueOptions, RunQueue, @@ -298,6 +300,7 @@ PolicySetParameterListOptions, PolicySetParameterUpdateOptions, ) +from .policy_set_version import PolicySetVersion from .policy_types import ( EnforcementLevel, PolicyKind, @@ -503,7 +506,7 @@ TaskResultStatus, TaskResultStatusTimestamps, ) -from .task_stage import TaskStage +from .task_stage import TaskStage, TaskStageIncludeOpt, TaskStageReadOptions from .team import ( OrganizationAccess, Team, @@ -511,6 +514,7 @@ TeamIncludeOpt, TeamListOptions, TeamPermissions, + TeamReadOptions, TeamUpdateOptions, ) from .team_project_access import ( @@ -837,6 +841,8 @@ "OrganizationCreateOptions", "OrganizationDefaultSettings", "OrganizationDefaultSettingsUpdateOptions", + "OrganizationIncludeOpt", + "OrganizationReadOptions", "OrganizationUpdateOptions", # Org-token TTL policy "DEFAULT_MAX_TTL_MS", @@ -869,6 +875,7 @@ "TeamCreateOptions", "TeamIncludeOpt", "TeamListOptions", + "TeamReadOptions", "TeamUpdateOptions", # Team Tokens "CreatedByChoice", @@ -972,6 +979,8 @@ "RunEventReadOptions", # Task Stage & Task Result "TaskStage", + "TaskStageIncludeOpt", + "TaskStageReadOptions", "TaskResult", # Comments "Comment", @@ -1037,6 +1046,7 @@ "PolicySet", "PolicySetIncludeOpt", "PolicySetList", + "PolicySetVersion", "PolicySetAddPoliciesOptions", "PolicySetAddProjectsOptions", "PolicySetAddWorkspacesOptions", @@ -1118,6 +1128,10 @@ # Rebuild models with forward references after all models are loaded PolicyCheck.model_rebuild() +PolicySetVersion.model_rebuild( + raise_errors=False, + _types_namespace={"PolicySet": PolicySet}, +) RegistryProvider.model_rebuild() RegistryProviderVersion.model_rebuild() RegistryProviderPlatform.model_rebuild() diff --git a/src/pytfe/models/organization.py b/src/pytfe/models/organization.py index e87d4bd8..b7a2da45 100644 --- a/src/pytfe/models/organization.py +++ b/src/pytfe/models/organization.py @@ -88,6 +88,20 @@ class OrganizationCreateOptions(BaseModel): data_retention_policy_choice: dict | None = None +class OrganizationIncludeOpt(str, Enum): + """Available include options for reading an organization.""" + + ORGANIZATION_SUBSCRIPTION = "subscription" + + +class OrganizationReadOptions(BaseModel): + """Options for reading a single organization.""" + + model_config = ConfigDict(populate_by_name=True) + + include: list[OrganizationIncludeOpt] | None = Field(None, alias="include") + + class ExecutionMode(str, Enum): REMOTE = "remote" AGENT = "agent" diff --git a/src/pytfe/models/task_stage.py b/src/pytfe/models/task_stage.py index 1674b4f3..aa158030 100644 --- a/src/pytfe/models/task_stage.py +++ b/src/pytfe/models/task_stage.py @@ -84,3 +84,20 @@ class TaskStageListOptions(BaseModel): model_config = ConfigDict(populate_by_name=True) page_size: int | None = Field(None, alias="page[size]") + + +class TaskStageIncludeOpt(str, Enum): + """Available include options for reading a task stage.""" + + TASK_STAGE_RUN = "run" + TASK_STAGE_RUN_WORKSPACE = "run.workspace" + TASK_STAGE_TASK_RESULTS = "task-results" + TASK_STAGE_POLICY_EVALUATIONS = "policy-evaluations" + + +class TaskStageReadOptions(BaseModel): + """Options for reading a single task stage.""" + + model_config = ConfigDict(populate_by_name=True) + + include: list[TaskStageIncludeOpt] | None = Field(None, alias="include") diff --git a/src/pytfe/models/team.py b/src/pytfe/models/team.py index c9638179..0ddc75ce 100644 --- a/src/pytfe/models/team.py +++ b/src/pytfe/models/team.py @@ -100,6 +100,14 @@ def valid(self) -> TeamListOptions: return self +class TeamReadOptions(BaseModel): + """Options for reading a single team.""" + + model_config = ConfigDict(populate_by_name=True) + + include: list[TeamIncludeOpt] | None = Field(None, alias="include") + + class OrganizationAccessOptions(BaseModel): model_config = ConfigDict(populate_by_name=True) diff --git a/src/pytfe/resources/agents.py b/src/pytfe/resources/agents.py index d8763685..7346be75 100644 --- a/src/pytfe/resources/agents.py +++ b/src/pytfe/resources/agents.py @@ -12,6 +12,7 @@ from collections.abc import Iterator from typing import Any, cast +from .._jsonapi import attach_jsonapi from ..models.agent import ( Agent, AgentListOptions, @@ -139,7 +140,8 @@ def read(self, agent_id: str, options: AgentReadOptions | None = None) -> Agent: else: response = self.t.request("GET", path) - data = response.json()["data"] + payload = response.json() + data = payload["data"] # Extract agent data from response attr = data.get("attributes", {}) or {} @@ -162,13 +164,17 @@ def read(self, agent_id: str, options: AgentReadOptions | None = None) -> Agent: "ip_address": _safe_str(attr.get("ip-address")), } - return Agent( - id=_safe_str(agent_data["id"]) or "", - name=agent_data["name"], - status=_safe_agent_status(agent_data["status"]), - version=agent_data["version"], - last_ping_at=cast(Any, agent_data["last_ping_at"]), - ip_address=agent_data["ip_address"], + return attach_jsonapi( + Agent( + id=_safe_str(agent_data["id"]) or "", + name=agent_data["name"], + status=_safe_agent_status(agent_data["status"]), + version=agent_data["version"], + last_ping_at=cast(Any, agent_data["last_ping_at"]), + ip_address=agent_data["ip_address"], + ), + data, + payload.get("included"), ) def delete(self, agent_id: str) -> None: diff --git a/src/pytfe/resources/configuration_version.py b/src/pytfe/resources/configuration_version.py index ca501f74..703858e2 100644 --- a/src/pytfe/resources/configuration_version.py +++ b/src/pytfe/resources/configuration_version.py @@ -3,6 +3,7 @@ from __future__ import annotations +import builtins import io from collections.abc import Iterator from typing import Any @@ -124,7 +125,9 @@ def read_with_options( response = self.t.request("GET", path, params=params) response_data = response.json() - return self._parse_configuration_version(response_data["data"]) + return self._parse_configuration_version( + response_data["data"], response_data.get("included") + ) def upload(self, upload_url: str, path: str) -> None: """Upload configuration files from a directory path.""" @@ -254,7 +257,9 @@ def _manage_backing_data(self, cv_id: str, action: str) -> None: self.t.request("POST", path) def _parse_configuration_version( - self, data: dict[str, Any] + self, + data: dict[str, Any], + included: builtins.list[dict[str, Any]] | None = None, ) -> ConfigurationVersion: """Parse a configuration version from API response data.""" if data is None: @@ -287,4 +292,4 @@ def _parse_configuration_version( "links": data.get("links"), } - return attach_jsonapi(ConfigurationVersion(**cv_data), data) + return attach_jsonapi(ConfigurationVersion(**cv_data), data, included) diff --git a/src/pytfe/resources/oauth_client.py b/src/pytfe/resources/oauth_client.py index 289d55dd..40c4df2f 100644 --- a/src/pytfe/resources/oauth_client.py +++ b/src/pytfe/resources/oauth_client.py @@ -3,6 +3,7 @@ from __future__ import annotations +import builtins from collections.abc import Iterator from typing import Any from urllib.parse import quote @@ -102,9 +103,9 @@ def read_with_options( params["include"] = ",".join([opt.value for opt in options.include]) response = self.t.request("GET", path, params=params) - data = response.json()["data"] + payload = response.json() - return self._parse_oauth_client(data) + return self._parse_oauth_client(payload["data"], payload.get("included")) def update( self, oauth_client_id: str, options: OAuthClientUpdateOptions @@ -162,7 +163,11 @@ def remove_projects( path = f"/api/v2/oauth-clients/{quote(oauth_client_id)}/relationships/projects" self.t.request("DELETE", path, json_body={"data": options.projects}) - def _parse_oauth_client(self, data: dict[str, Any]) -> OAuthClient: + def _parse_oauth_client( + self, + data: dict[str, Any], + included: builtins.list[dict[str, Any]] | None = None, + ) -> OAuthClient: """Parse OAuth client data from API response.""" oauth_client = OAuthClient( id=data.get("id"), @@ -184,4 +189,4 @@ def _parse_oauth_client(self, data: dict[str, Any]) -> OAuthClient: if "projects" in relationships: oauth_client.projects = relationships["projects"].get("data", []) - return attach_jsonapi(oauth_client, data) + return attach_jsonapi(oauth_client, data, included) diff --git a/src/pytfe/resources/organization_membership.py b/src/pytfe/resources/organization_membership.py index f94e7ae2..9da4b4f8 100644 --- a/src/pytfe/resources/organization_membership.py +++ b/src/pytfe/resources/organization_membership.py @@ -3,11 +3,12 @@ from __future__ import annotations +import builtins import re from collections.abc import Iterator from typing import Any -from .._jsonapi import attach_jsonapi +from .._jsonapi import RelationMap, attach_jsonapi, parse_relationships from ..errors import ERR_INVALID_EMAIL, ERR_INVALID_ORG from ..models.organization import Organization from ..models.organization_membership import ( @@ -21,6 +22,14 @@ from ..utils import valid_string_id from ._base import _Service +# Typed relations hydrated from ?include= (user, teams); organization is always +# present as a linkage ref. See OrgMembershipIncludeOpt. +_ORG_MEMBERSHIP_REL_MAP: RelationMap = { + "organization": Organization, + "user": User, + "teams": Team, +} + def _valid_email(email: str) -> bool: """Validate email format.""" @@ -214,7 +223,7 @@ def read_with_options( # NotFound exception will be raised by self.t.request if resource doesn't exist response = self.t.request("GET", path, params=params) data = response.json() - return self._parse_membership(data["data"]) + return self._parse_membership(data["data"], data.get("included")) def delete(self, organization_membership_id: str) -> None: """Delete an organization membership by its ID. @@ -234,7 +243,11 @@ def delete(self, organization_membership_id: str) -> None: # Make the DELETE request self.t.request("DELETE", path) - def _parse_membership(self, data: dict[str, Any]) -> OrganizationMembership: + def _parse_membership( + self, + data: dict[str, Any], + included: builtins.list[dict[str, Any]] | None = None, + ) -> OrganizationMembership: """Parse a membership from API response data. Args: @@ -250,42 +263,22 @@ def _parse_membership(self, data: dict[str, Any]) -> OrganizationMembership: status = attributes.get("status", "active") email = attributes.get("email", "") - # Extract relationships if present - relationships = data.get("relationships", {}) - - # Parse organization relationship - organization = None - if "organization" in relationships: - org_data = relationships["organization"].get("data") - if org_data: - organization = Organization(id=org_data.get("id")) - - # Parse user relationship - user = None - if "user" in relationships: - user_data = relationships["user"].get("data") - if user_data: - user = User(id=user_data.get("id")) - - # Parse teams relationship - teams = None - if "teams" in relationships: - teams_data = relationships["teams"].get("data", []) - if teams_data: - teams = [Team(id=team.get("id")) for team in teams_data] - - # Handle included data if present (for full user/org objects) - # This would be populated when include options are used - # For now, keeping it simple with just IDs + # organization/user/teams are id-only stubs by default and are filled + # from the JSON:API ``included`` array when requested via ?include=. + rels = parse_relationships( + data.get("relationships"), _ORG_MEMBERSHIP_REL_MAP, included=included + ) + # Historical contract: an empty teams relation stays None (not []). + if not rels.get("teams"): + rels.pop("teams", None) return attach_jsonapi( OrganizationMembership( id=membership_id, status=status, email=email, - organization=organization, - user=user, - teams=teams, + **rels, ), data, + included, ) diff --git a/src/pytfe/resources/organizations.py b/src/pytfe/resources/organizations.py index a57cc3c7..20af1e6a 100644 --- a/src/pytfe/resources/organizations.py +++ b/src/pytfe/resources/organizations.py @@ -6,6 +6,7 @@ from collections.abc import Iterator from typing import Any +from .._jsonapi import attach_jsonapi from ..errors import ( ERR_INVALID_NAME, ERR_INVALID_ORG, @@ -28,6 +29,7 @@ OrganizationCreateOptions, OrganizationDefaultSettings, OrganizationDefaultSettingsUpdateOptions, + OrganizationReadOptions, OrganizationUpdateOptions, ReadRunQueueOptions, RunQueue, @@ -40,7 +42,9 @@ def _safe_str(v: Any, default: str = "") -> str: return v if isinstance(v, str) else (str(v) if v is not None else default) -def _parse_org(data: dict[str, Any]) -> Organization: +def _parse_org( + data: dict[str, Any], included: list[dict[str, Any]] | None = None +) -> Organization: """Parse a JSON:API ``data`` block into an :class:`Organization`. Handles two things the legacy ``Organization(**attrs)`` shortcut @@ -63,7 +67,7 @@ def _parse_org(data: dict[str, Any]) -> Organization: if pool_rel and pool_rel.get("id"): org_data["default_agent_pool"] = {"id": pool_rel["id"]} - return Organization.model_validate(org_data) + return attach_jsonapi(Organization.model_validate(org_data), data, included) class Organizations(_Service): @@ -109,9 +113,15 @@ def list(self) -> Iterator[Organization]: for item in self._list("/api/v2/organizations"): yield _parse_org(item) - def read(self, name: str) -> Organization: - r = self.t.request("GET", f"/api/v2/organizations/{name}") - return _parse_org(r.json()["data"]) + def read( + self, name: str, options: OrganizationReadOptions | None = None + ) -> Organization: + params: dict[str, str] = {} + if options and options.include: + params["include"] = ",".join([opt.value for opt in options.include]) + r = self.t.request("GET", f"/api/v2/organizations/{name}", params=params) + payload = r.json() + return _parse_org(payload["data"], payload.get("included")) # ---- Organization default settings (provider parity) ----------------- # diff --git a/src/pytfe/resources/policy_set.py b/src/pytfe/resources/policy_set.py index cfdeb555..d0dde949 100644 --- a/src/pytfe/resources/policy_set.py +++ b/src/pytfe/resources/policy_set.py @@ -6,7 +6,7 @@ from collections.abc import Iterator from typing import Any -from .._jsonapi import attach_jsonapi +from .._jsonapi import RelationMap, attach_jsonapi, parse_relationships from ..errors import ( InvalidNameError, InvalidOrgError, @@ -17,6 +17,8 @@ WorkspaceMinimumLimitError, WorkspaceRequiredError, ) +from ..models.organization import Organization +from ..models.policy import Policy from ..models.policy_set import ( PolicySet, PolicySetAddPoliciesOptions, @@ -34,9 +36,41 @@ PolicySetRemoveWorkspacesOptions, PolicySetUpdateOptions, ) +from ..models.policy_set_version import PolicySetVersion +from ..models.project import Project +from ..models.workspace import Workspace from ..utils import valid_string, valid_string_id from ._base import _Service +# Wire relation name -> model; the python attr is derived as wire.replace("-","_"), +# which matches every PolicySet relation field. Threading ``included`` makes +# ?include= hydrate these typed fields (workspaces, projects, policies, versions, +# exclusions) instead of leaving id-only stubs. +_POLICY_SET_REL_MAP: RelationMap = { + "organization": Organization, + "workspaces": Workspace, + "projects": Project, + "policies": Policy, + "newest-version": PolicySetVersion, + "current-version": PolicySetVersion, + "workspace-exclusions": Workspace, + "project-exclusions": Project, +} + + +def _policy_set_from( + data: dict[str, Any], included: list[dict[str, Any]] | None = None +) -> PolicySet: + """Parse a PolicySet, hydrating typed relations from ``included``.""" + attrs = dict(data.get("attributes", {}) or {}) + attrs["id"] = data.get("id") + attrs.update( + parse_relationships( + data.get("relationships"), _POLICY_SET_REL_MAP, included=included + ) + ) + return attach_jsonapi(PolicySet.model_validate(attrs), data, included) + class PolicySets(_Service): """ @@ -67,31 +101,7 @@ def list( def _gen() -> Iterator[PolicySet]: for d in self._list(path, params=params): - attrs = d.get("attributes", {}) - attrs["id"] = d.get("id") - attrs["organization"] = d.get("relationships", {}).get( - "organization", {} - ) - attrs["workspace_exclusions"] = ( - d.get("relationships", {}) - .get("workspace-exclusions", {}) - .get("data", []) - ) - attrs["project_exclusions"] = ( - d.get("relationships", {}) - .get("project-exclusions", {}) - .get("data", []) - ) - attrs["workspaces"] = ( - d.get("relationships", {}).get("workspaces", {}).get("data", []) - ) - attrs["projects"] = ( - d.get("relationships", {}).get("projects", {}).get("data", []) - ) - attrs["policies"] = ( - d.get("relationships", {}).get("policies", {}).get("data", []) - ) - yield attach_jsonapi(PolicySet.model_validate(attrs), d) + yield _policy_set_from(d) return _gen() @@ -150,24 +160,7 @@ def create(self, organization: str, options: PolicySetCreateOptions) -> PolicySe json_body=payload, ) jd = r.json() - data = jd.get("data", {}) - attrs = data.get("attributes", {}) - attrs["id"] = data.get("id") - - # Handle relationships in response - relationships_data = data.get("relationships", {}) - attrs["organization"] = relationships_data.get("organization", {}) - attrs["workspace_exclusions"] = relationships_data.get( - "workspace-exclusions", {} - ).get("data", []) - attrs["project_exclusions"] = relationships_data.get( - "project-exclusions", {} - ).get("data", []) - attrs["workspaces"] = relationships_data.get("workspaces", {}).get("data", []) - attrs["projects"] = relationships_data.get("projects", {}).get("data", []) - attrs["policies"] = relationships_data.get("policies", {}).get("data", []) - - return attach_jsonapi(PolicySet.model_validate(attrs), data) + return _policy_set_from(jd.get("data", {}), jd.get("included")) def read(self, policy_set_id: str) -> PolicySet: """Read a policy set by its ID.""" @@ -192,24 +185,7 @@ def read_with_options( params=params, ) jd = r.json() - data = jd.get("data", {}) - attrs = data.get("attributes", {}) - attrs["id"] = data.get("id") - - # Handle relationships in response - relationships_data = data.get("relationships", {}) - attrs["organization"] = relationships_data.get("organization", {}) - attrs["workspace_exclusions"] = relationships_data.get( - "workspace-exclusions", {} - ).get("data", []) - attrs["project_exclusions"] = relationships_data.get( - "project-exclusions", {} - ).get("data", []) - attrs["workspaces"] = relationships_data.get("workspaces", {}).get("data", []) - attrs["projects"] = relationships_data.get("projects", {}).get("data", []) - attrs["policies"] = relationships_data.get("policies", {}).get("data", []) - - return attach_jsonapi(PolicySet.model_validate(attrs), data) + return _policy_set_from(jd.get("data", {}), jd.get("included")) def update(self, policy_set_id: str, options: PolicySetUpdateOptions) -> PolicySet: """Update an existing policy set.""" @@ -234,24 +210,7 @@ def update(self, policy_set_id: str, options: PolicySetUpdateOptions) -> PolicyS json_body=payload, ) jd = r.json() - data = jd.get("data", {}) - attrs = data.get("attributes", {}) - attrs["id"] = data.get("id") - - # Handle relationships in response - relationships_data = data.get("relationships", {}) - attrs["organization"] = relationships_data.get("organization", {}) - attrs["workspace_exclusions"] = relationships_data.get( - "workspace-exclusions", {} - ).get("data", []) - attrs["project_exclusions"] = relationships_data.get( - "project-exclusions", {} - ).get("data", []) - attrs["workspaces"] = relationships_data.get("workspaces", {}).get("data", []) - attrs["projects"] = relationships_data.get("projects", {}).get("data", []) - attrs["policies"] = relationships_data.get("policies", {}).get("data", []) - - return attach_jsonapi(PolicySet.model_validate(attrs), data) + return _policy_set_from(jd.get("data", {}), jd.get("included")) def add_policies( self, policy_set_id: str, options: PolicySetAddPoliciesOptions diff --git a/src/pytfe/resources/projects.py b/src/pytfe/resources/projects.py index 0a8f9a83..473e14bd 100644 --- a/src/pytfe/resources/projects.py +++ b/src/pytfe/resources/projects.py @@ -214,9 +214,9 @@ def read( else: response = self.t.request("GET", path) - data = response.json()["data"] + payload = response.json() - return self._project_from(data) + return self._project_from(payload["data"], payload.get("included")) def update(self, project_id: str, options: ProjectUpdateOptions) -> Project: """Update a project's name and/or description""" @@ -402,7 +402,11 @@ def delete_tag_bindings(self, project_id: str) -> None: path = f"/api/v2/projects/{project_id}" self.t.request("PATCH", path, json_body=payload) - def _project_from(self, data: dict[str, Any]) -> Project: + def _project_from( + self, + data: dict[str, Any], + included: builtins.list[dict[str, Any]] | None = None, + ) -> Project: """Helper method to create a Project object from API response data""" attrs = data.get("attributes", {}) attrs["id"] = data.get("id") @@ -420,4 +424,4 @@ def _project_from(self, data: dict[str, Any]) -> Project: else None ) - return attach_jsonapi(Project.model_validate(attrs), data) + return attach_jsonapi(Project.model_validate(attrs), data, included) diff --git a/src/pytfe/resources/query_run.py b/src/pytfe/resources/query_run.py index 84551065..6f0aa3f8 100644 --- a/src/pytfe/resources/query_run.py +++ b/src/pytfe/resources/query_run.py @@ -97,7 +97,7 @@ def create(self, options: QueryRunCreateOptions) -> QueryRun: attrs = data.get("attributes", {}) attrs["id"] = data.get("id") - return attach_jsonapi(QueryRun.model_validate(attrs), data) + return attach_jsonapi(QueryRun.model_validate(attrs), data, jd.get("included")) def read(self, query_run_id: str) -> QueryRun: """Read a query run by its ID.""" @@ -111,7 +111,7 @@ def read(self, query_run_id: str) -> QueryRun: attrs = data.get("attributes", {}) attrs["id"] = data.get("id") - return attach_jsonapi(QueryRun.model_validate(attrs), data) + return attach_jsonapi(QueryRun.model_validate(attrs), data, jd.get("included")) def read_with_options( self, query_run_id: str, options: QueryRunReadOptions @@ -132,7 +132,7 @@ def read_with_options( attrs = data.get("attributes", {}) attrs["id"] = data.get("id") - return attach_jsonapi(QueryRun.model_validate(attrs), data) + return attach_jsonapi(QueryRun.model_validate(attrs), data, jd.get("included")) def logs(self, query_run_id: str) -> io.IOBase: """Retrieve the logs for a query run. diff --git a/src/pytfe/resources/registry_provider.py b/src/pytfe/resources/registry_provider.py index d8d35f97..cc71e65a 100644 --- a/src/pytfe/resources/registry_provider.py +++ b/src/pytfe/resources/registry_provider.py @@ -3,6 +3,7 @@ from __future__ import annotations +import builtins from collections.abc import Iterator from typing import Any @@ -98,7 +99,9 @@ def read( response = self.t.request("GET", path, params=params) response_data = response.json() - return self._parse_registry_provider(response_data["data"]) + return self._parse_registry_provider( + response_data["data"], response_data.get("included") + ) def delete(self, provider_id: RegistryProviderID) -> None: """Delete a registry provider.""" @@ -110,7 +113,11 @@ def delete(self, provider_id: RegistryProviderID) -> None: self.t.request("DELETE", path) - def _parse_registry_provider(self, data: dict[str, Any]) -> RegistryProvider: + def _parse_registry_provider( + self, + data: dict[str, Any], + included: builtins.list[dict[str, Any]] | None = None, + ) -> RegistryProvider: """Parse a registry provider from API response data.""" if data is None: raise ValueError("Cannot parse registry provider: data is None") @@ -164,4 +171,4 @@ def _parse_registry_provider(self, data: dict[str, Any]) -> RegistryProvider: "links": data.get("links"), } - return attach_jsonapi(RegistryProvider(**provider_data), data) + return attach_jsonapi(RegistryProvider(**provider_data), data, included) diff --git a/src/pytfe/resources/run_event.py b/src/pytfe/resources/run_event.py index d94a4f5d..34479b0e 100644 --- a/src/pytfe/resources/run_event.py +++ b/src/pytfe/resources/run_event.py @@ -6,16 +6,39 @@ from collections.abc import Iterator from typing import Any -from .._jsonapi import attach_jsonapi +from .._jsonapi import RelationMap, attach_jsonapi, parse_relationships from ..errors import InvalidRunEventIDError, InvalidRunIDError +from ..models.comment import Comment from ..models.run_event import ( RunEvent, RunEventListOptions, RunEventReadOptions, ) -from ..utils import valid_string_id +from ..models.user import User +from ..utils import _safe_str, valid_string_id from ._base import _Service +# Typed relations hydrated from ?include= (actor, comment); see RunEventIncludeOpt. +_RUN_EVENT_REL_MAP: RelationMap = {"actor": User, "comment": Comment} + + +def _run_event_from( + d: dict[str, Any], included: list[dict[str, Any]] | None = None +) -> RunEvent: + attr = d.get("attributes", {}) or {} + rels = parse_relationships( + d.get("relationships"), _RUN_EVENT_REL_MAP, included=included + ) + return attach_jsonapi( + RunEvent( + id=_safe_str(d.get("id")), + **{k.replace("-", "_"): v for k, v in attr.items()}, + **rels, + ), + d, + included, + ) + class RunEvents(_Service): def list( @@ -30,9 +53,7 @@ def list( # The run-events endpoint is not paginated; fetch the full set in one request. path = f"/api/v2/runs/{run_id}/run-events" for item in self._list(path, params=params, paginated=False): - attrs = item.get("attributes", {}) - attrs["id"] = item.get("id") - yield attach_jsonapi(RunEvent.model_validate(attrs), item) + yield _run_event_from(item) def read(self, run_event_id: str) -> RunEvent: """Read a specific run event by its ID.""" @@ -53,13 +74,4 @@ def read_with_options( params=params, ) payload = r.json() - d = payload.get("data", {}) - attr = d.get("attributes", {}) or {} - return attach_jsonapi( - RunEvent( - id=d.get("id"), - **{k.replace("-", "_"): v for k, v in attr.items()}, - ), - d, - payload.get("included"), - ) + return _run_event_from(payload.get("data", {}), payload.get("included")) diff --git a/src/pytfe/resources/run_task.py b/src/pytfe/resources/run_task.py index 26887f38..004ba811 100644 --- a/src/pytfe/resources/run_task.py +++ b/src/pytfe/resources/run_task.py @@ -31,7 +31,11 @@ from ._base import _Service -def _run_task_from(d: dict[str, Any], org: str | None = None) -> RunTask: +def _run_task_from( + d: dict[str, Any], + org: str | None = None, + included: list[dict[str, Any]] | None = None, +) -> RunTask: """ Convert JSON API response data to RunTask object. @@ -124,6 +128,7 @@ def _run_task_from(d: dict[str, Any], org: str | None = None) -> RunTask: workspace_run_tasks=workspace_run_tasks, ), d, + included, ) @@ -211,7 +216,8 @@ def read_with_options( path = f"/api/v2/tasks/{run_task_id}" r = self.t.request("GET", path, params=params) - return _run_task_from(r.json()["data"]) + payload = r.json() + return _run_task_from(payload["data"], included=payload.get("included")) def update(self, run_task_id: str, options: RunTaskUpdateOptions) -> RunTask: if not valid_string_id(run_task_id): diff --git a/src/pytfe/resources/task_stage.py b/src/pytfe/resources/task_stage.py index 8a2cf5c4..6b85209f 100644 --- a/src/pytfe/resources/task_stage.py +++ b/src/pytfe/resources/task_stage.py @@ -3,6 +3,7 @@ from __future__ import annotations +import builtins from collections.abc import Iterator from typing import Any @@ -11,7 +12,7 @@ from ..models.policy_evaluation import PolicyEvaluation from ..models.run import Run from ..models.task_result import TaskResult -from ..models.task_stage import TaskStage, TaskStageListOptions +from ..models.task_stage import TaskStage, TaskStageListOptions, TaskStageReadOptions from ..utils import _safe_str, valid_string_id from ._base import _Service @@ -19,7 +20,11 @@ class TaskStages(_Service): """TaskStages provides access to task stage endpoints.""" - def _parse_task_stage(self, data: dict[str, Any]) -> TaskStage: + def _parse_task_stage( + self, + data: dict[str, Any], + included: builtins.list[dict[str, Any]] | None = None, + ) -> TaskStage: attributes = data.get("attributes", {}) attributes["id"] = _safe_str(data.get("id")) attributes.update( @@ -30,27 +35,35 @@ def _parse_task_stage(self, data: dict[str, Any]) -> TaskStage: "task-results": TaskResult, "policy-evaluations": PolicyEvaluation, }, + included=included, ) ) # Preserve the historical contract: parsed task stages expose empty # lists (not None) for these collections when the relations are absent. attributes.setdefault("task_results", []) attributes.setdefault("policy_evaluations", []) - return attach_jsonapi(TaskStage.model_validate(attributes), data) + return attach_jsonapi(TaskStage.model_validate(attributes), data, included) # Read - def read(self, task_stage_id: str) -> TaskStage: + def read( + self, task_stage_id: str, options: TaskStageReadOptions | None = None + ) -> TaskStage: if not valid_string_id(task_stage_id): raise InvalidTaskStageIDError() + params: dict[str, str] = {} + if options and options.include: + params["include"] = ",".join([opt.value for opt in options.include]) + response = self.t.request( "GET", f"/api/v2/task-stages/{task_stage_id}", + params=params, ) - data = response.json().get("data", {}) + payload = response.json() - return self._parse_task_stage(data) + return self._parse_task_stage(payload.get("data", {}), payload.get("included")) # List def list( diff --git a/src/pytfe/resources/team.py b/src/pytfe/resources/team.py index 2bd737d6..febc9e2e 100644 --- a/src/pytfe/resources/team.py +++ b/src/pytfe/resources/team.py @@ -2,6 +2,7 @@ import builtins from collections.abc import Iterator +from typing import Any from .._jsonapi import attach_jsonapi, parse_relationships from ..errors import ( @@ -13,6 +14,7 @@ Team, TeamCreateOptions, TeamListOptions, + TeamReadOptions, TeamUpdateOptions, ) from ..models.user import User @@ -38,16 +40,21 @@ def list( for item in self._list(path, params=params): yield self._team_from(item) - def _team_from(self, data: dict) -> Team: + def _team_from( + self, + data: dict, + included: builtins.list[dict[str, Any]] | None = None, + ) -> Team: attrs = data.get("attributes", {}) attrs["id"] = data.get("id") attrs.update( parse_relationships( data.get("relationships"), {"users": User, "organization-memberships": OrganizationMembership}, + included=included, ) ) - return attach_jsonapi(Team.model_validate(attrs), data) + return attach_jsonapi(Team.model_validate(attrs), data, included) def create(self, organization: str, options: TeamCreateOptions) -> Team: """Create a new team in the given organization.""" @@ -77,16 +84,20 @@ def update(self, team_id: str, options: TeamUpdateOptions) -> Team: data = r.json().get("data", {}) return self._team_from(data) - def read(self, team_id: str) -> Team: + def read(self, team_id: str, options: TeamReadOptions | None = None) -> Team: """Read a single team by its ID.""" if not valid_string_id(team_id): raise InvalidTeamIDError() + params: dict[str, str] = {} + if options and options.include: + params["include"] = ",".join([opt.value for opt in options.include]) r = self.t.request( "GET", path=f"/api/v2/teams/{team_id}", + params=params, ) - data = r.json().get("data", {}) - return self._team_from(data) + payload = r.json() + return self._team_from(payload.get("data", {}), payload.get("included")) def delete(self, team_id: str) -> None: """Delete a team by its ID.""" diff --git a/src/pytfe/resources/variable_sets.py b/src/pytfe/resources/variable_sets.py index 4d89d1cd..0731d708 100644 --- a/src/pytfe/resources/variable_sets.py +++ b/src/pytfe/resources/variable_sets.py @@ -8,7 +8,8 @@ from typing import Any from .._http import HTTPTransport -from .._jsonapi import attach_jsonapi +from .._jsonapi import RelationMap, attach_jsonapi, parse_relationships +from ..models.project import Project from ..models.variable_set import ( VariableSet, VariableSetApplyToProjectsOptions, @@ -26,8 +27,17 @@ VariableSetVariableListOptions, VariableSetVariableUpdateOptions, ) +from ..models.workspace import Workspace from ._base import _Service +# Typed relations hydrated from ?include= (workspaces, projects, vars). The +# polymorphic ``parent`` relation is handled separately. See VariableSetIncludeOpt. +_VARIABLE_SET_REL_MAP: RelationMap = { + "workspaces": Workspace, + "projects": Project, + "vars": VariableSetVariable, +} + class VariableSets(_Service): """ @@ -256,7 +266,7 @@ def read( response = self.t.request("GET", path, params=params) data = response.json() - return self._parse_variable_set(data["data"]) + return self._parse_variable_set(data["data"], data.get("included")) def update( self, @@ -584,7 +594,11 @@ def _parse_variable_sets_response( variable_sets.append(self._parse_variable_set(item)) return variable_sets - def _parse_variable_set(self, data: dict[str, Any]) -> VariableSet: + def _parse_variable_set( + self, + data: dict[str, Any], + included: builtins.list[dict[str, Any]] | None = None, + ) -> VariableSet: """Parse a single variable set from API response data. Args: @@ -609,58 +623,13 @@ def _parse_variable_set(self, data: dict[str, Any]) -> VariableSet: "updated_at": attrs.get("updated-at"), } - # Build workspaces list - simplified to just contain minimal data - workspaces = [] - if "workspaces" in relationships: - ws_data = relationships["workspaces"].get("data", []) - if isinstance(ws_data, list): - for ws in ws_data: - if "id" in ws: - workspaces.append( - { - "id": ws["id"], - "name": f"workspace-{ws['id']}", # Placeholder name - } - ) - parsed_data["workspaces"] = workspaces - - # Build projects list - simplified to just contain minimal data - projects = [] - if "projects" in relationships: - proj_data = relationships["projects"].get("data", []) - if isinstance(proj_data, list): - for proj in proj_data: - if "id" in proj: - projects.append( - { - "id": proj["id"], - "name": f"project-{proj['id']}", # Placeholder name - } - ) - parsed_data["projects"] = projects - - # Build variables list - simplified to just contain minimal data - variables = [] - if "vars" in relationships: - vars_data = relationships["vars"].get("data", []) - if isinstance(vars_data, list): - for var in vars_data: - if "id" in var: - variables.append( - { - "id": var["id"], - "key": f"var-{var['id']}", # Placeholder key - "category": "terraform", # Default category - "variable_set": { - "id": data.get("id"), - "name": attrs.get("name", ""), - "global": attrs.get("global", False), - }, - } - ) - parsed_data["vars"] = variables + # workspaces/projects/vars are id-only stubs by default and are filled + # from the JSON:API ``included`` array when requested via ?include=. + parsed_data.update( + parse_relationships(relationships, _VARIABLE_SET_REL_MAP, included=included) + ) - # Handle parent relationship + # Handle parent relationship (polymorphic: project | organization). parent = None if "parent" in relationships: parent_data = relationships["parent"].get("data") @@ -677,7 +646,7 @@ def _parse_variable_set(self, data: dict[str, Any]) -> VariableSet: parsed_data["parent"] = parent # Use Pydantic model validation to handle aliases properly - return attach_jsonapi(VariableSet.model_validate(parsed_data), data) + return attach_jsonapi(VariableSet.model_validate(parsed_data), data, included) class VariableSetVariables(_Service): diff --git a/tests/units/test_organization_membership.py b/tests/units/test_organization_membership.py index c848fb76..929e3ee3 100644 --- a/tests/units/test_organization_membership.py +++ b/tests/units/test_organization_membership.py @@ -522,6 +522,12 @@ def test_read_with_options_include_user( assert membership is not None assert membership.id == "ou-abc123def456" assert membership.user is not None + # Typed hydration: the user's full body is filled from `included`. + assert membership.user.username == "testuser" + assert membership.user.email == "user@example.com" + # Raw escape hatch is also populated, and never leaks into model_dump(). + assert membership.has_included is True + assert "included" not in membership.model_dump() def test_read_with_options_include_teams(self, membership_service, mock_transport): """Test reading with include teams option.""" diff --git a/tests/units/test_organizations.py b/tests/units/test_organizations.py new file mode 100644 index 00000000..57051b8f --- /dev/null +++ b/tests/units/test_organizations.py @@ -0,0 +1,84 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from unittest.mock import Mock + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.models import ( + Organization, + OrganizationIncludeOpt, + OrganizationReadOptions, +) +from pytfe.resources.organizations import Organizations + + +class TestOrganizationsRead: + @pytest.fixture + def mock_transport(self): + return Mock(spec=HTTPTransport) + + @pytest.fixture + def service(self, mock_transport): + return Organizations(mock_transport) + + @staticmethod + def _org_data(name: str = "acme") -> dict: + return { + "id": name, + "type": "organizations", + "attributes": {"name": name, "email": "owner@acme.test"}, + "relationships": { + "subscription": {"data": {"id": "sub-1", "type": "subscriptions"}} + }, + } + + def test_read_no_options_unchanged(self, service, mock_transport): + """read() without options sends empty params and captures no included.""" + mock_response = Mock() + mock_response.json.return_value = {"data": self._org_data()} + mock_transport.request.return_value = mock_response + + org = service.read("acme") + + assert mock_transport.request.call_args[1]["params"] == {} + assert isinstance(org, Organization) + assert org.has_included is False + assert org.included == [] + + def test_read_with_include_subscription_captures_included( + self, service, mock_transport + ): + """read(include=[subscription]) sends the param and captures the raw + included body on the escape hatch (subscription is not a typed field).""" + mock_response = Mock() + mock_response.json.return_value = { + "data": self._org_data(), + "included": [ + { + "id": "sub-1", + "type": "subscriptions", + "attributes": {"plan": "plus"}, + } + ], + } + mock_transport.request.return_value = mock_response + + org = service.read( + "acme", + OrganizationReadOptions( + include=[OrganizationIncludeOpt.ORGANIZATION_SUBSCRIPTION] + ), + ) + + assert mock_transport.request.call_args[1]["params"] == { + "include": "subscription" + } + assert org.has_included is True + sub = org.related("subscription") + assert sub[0]["attributes"]["plan"] == "plus" + # non-breaking: escape hatch never leaks into model_dump() + assert "included" not in org.model_dump() diff --git a/tests/units/test_policy_set.py b/tests/units/test_policy_set.py index 02a542c2..1137f17d 100644 --- a/tests/units/test_policy_set.py +++ b/tests/units/test_policy_set.py @@ -206,6 +206,76 @@ def test_read_with_options_passes_include_param(self, service, mock_transport): call_kwargs = mock_transport.request.call_args[1] assert call_kwargs.get("params") is not None + def test_read_with_options_hydrates_typed_relation(self, service, mock_transport): + """read_with_options(include=current_version) hydrates the typed + `current_version` field from `included` (covers the PolicySetVersion + model_rebuild) and also populates the raw escape hatch.""" + from pytfe.models.policy_set import PolicySetIncludeOpt + + data = self._policy_set_data("ps-inc") + data["relationships"]["current-version"] = { + "data": {"id": "polsetver-1", "type": "policy-set-versions"} + } + mock_response = Mock() + mock_response.json.return_value = { + "data": data, + "included": [ + { + "id": "polsetver-1", + "type": "policy-set-versions", + "attributes": { + "source": "tfe-api", + "created-at": "2024-01-01T00:00:00Z", + }, + } + ], + } + mock_transport.request.return_value = mock_response + + options = PolicySetReadOptions( + include=[PolicySetIncludeOpt.POLICY_SET_CURRENT_VERSION] + ) + ps = service.read_with_options("ps-inc", options) + + # Typed hydration: current_version is a fully-populated PolicySetVersion. + assert ps.current_version is not None + assert ps.current_version.id == "polsetver-1" + assert ps.current_version.source is not None + assert ps.current_version.created_at is not None + # Raw escape hatch is also populated and never leaks into model_dump(). + assert ps.has_included is True + assert "included" not in ps.model_dump() + + def test_read_without_include_current_version_is_stub( + self, service, mock_transport + ): + """Non-breaking: without ?include=, a present relation is an id-only stub.""" + data = self._policy_set_data("ps-stub") + data["relationships"]["current-version"] = { + "data": {"id": "polsetver-9", "type": "policy-set-versions"} + } + mock_response = Mock() + mock_response.json.return_value = {"data": data} + mock_transport.request.return_value = mock_response + + ps = service.read("ps-stub") + + assert ps.current_version is not None + assert ps.current_version.id == "polsetver-9" + assert ps.current_version.source is None # not hydrated without include + assert ps.has_included is False + + def test_read_without_include_has_no_included(self, service, mock_transport): + """Without ?include=, included stays absent (present-vs-empty tracked).""" + mock_response = Mock() + mock_response.json.return_value = {"data": self._policy_set_data("ps-bare")} + mock_transport.request.return_value = mock_response + + ps = service.read("ps-bare") + + assert ps.has_included is False + assert ps.included == [] + # ────────────────────────────────────────────────────────────────────────── # create() # ────────────────────────────────────────────────────────────────────────── diff --git a/tests/units/test_run_events.py b/tests/units/test_run_events.py index 704076c5..6441429b 100644 --- a/tests/units/test_run_events.py +++ b/tests/units/test_run_events.py @@ -235,6 +235,39 @@ def test_read_run_event_with_includes(self, run_events_service): assert result.id == "re-read-456" assert result.action == "discarded" + def test_read_run_event_hydrates_actor_from_included(self, run_events_service): + """read_with_options(include=actor) hydrates the typed `actor` field + from `included` (was previously never populated).""" + mock_response_data = { + "data": { + "id": "re-actor-1", + "attributes": {"action": "created"}, + "relationships": {"actor": {"data": {"id": "user-1", "type": "users"}}}, + }, + "included": [ + { + "id": "user-1", + "type": "users", + "attributes": {"username": "alice", "email": "a@example.com"}, + } + ], + } + mock_response = Mock() + mock_response.json.return_value = mock_response_data + + with patch.object(run_events_service, "t") as mock_transport: + mock_transport.request.return_value = mock_response + + options = RunEventReadOptions(include=[RunEventIncludeOpt.RUN_EVENT_ACTOR]) + result = run_events_service.read_with_options("re-actor-1", options) + + # Typed hydration of the actor (User) from `included`. + assert result.actor is not None + assert result.actor.username == "alice" + # Raw escape hatch populated; never leaks into model_dump(). + assert result.has_included is True + assert "included" not in result.model_dump() + def test_read_run_event_invalid_id(self, run_events_service): """Test read with invalid run event ID.""" diff --git a/tests/units/test_run_task.py b/tests/units/test_run_task.py index 16173705..cf728820 100644 --- a/tests/units/test_run_task.py +++ b/tests/units/test_run_task.py @@ -332,6 +332,14 @@ def test_read_run_task(self, run_tasks_service): assert result.enabled is True assert result.hmac_key == "secret-key" + # Raw capture: the document's `included` array is now retained on the + # escape hatch (was previously dropped), without leaking into dumps. + assert result.has_included is True + org = result.included_by("organizations", "org-123") + assert org is not None + assert org["attributes"]["name"] == "test-org" + assert "included" not in result.model_dump() + options = RunTaskReadOptions( include=[RunTaskIncludeOptions.RUN_TASK_WORKSPACE_TASKS] ) diff --git a/tests/units/test_task_stage.py b/tests/units/test_task_stage.py index a0934d77..69cad915 100644 --- a/tests/units/test_task_stage.py +++ b/tests/units/test_task_stage.py @@ -7,6 +7,8 @@ from pytfe.models.task_stage import ( Stage, TaskStage, + TaskStageIncludeOpt, + TaskStageReadOptions, TaskStageStatus, ) from pytfe.resources.task_stage import TaskStages @@ -143,6 +145,7 @@ def test_read_calls_request_correctly(mocker): mock_transport.request.assert_called_once_with( "GET", "/api/v2/task-stages/ts-123", + params={}, ) @@ -161,6 +164,51 @@ def test_read_stub_payload(mocker): assert result.stage is None +def test_read_with_include_hydrates_and_captures(mocker): + """read(include=...) sends the include param, hydrates typed relations from + `included`, and captures the raw block (non-breaking escape hatch).""" + mock_transport = mocker.Mock() + mock_response = mocker.Mock() + mock_response.json.return_value = { + "data": { + "id": "ts-123", + "attributes": {"stage": "pre_plan", "status": "pending"}, + "relationships": { + "policy-evaluations": { + "data": [{"id": "pol-1", "type": "policy-evaluations"}] + }, + }, + }, + "included": [ + { + "id": "pol-1", + "type": "policy-evaluations", + "attributes": {"status": "passed"}, + } + ], + } + mock_transport.request.return_value = mock_response + + service = TaskStages(mock_transport) + result = service.read( + "ts-123", + TaskStageReadOptions( + include=[TaskStageIncludeOpt.TASK_STAGE_POLICY_EVALUATIONS] + ), + ) + + assert mock_transport.request.call_args[1]["params"] == { + "include": "policy-evaluations" + } + # typed hydration from included + assert len(result.policy_evaluations) == 1 + assert result.policy_evaluations[0].status == "passed" + # raw capture + non-breaking + assert result.has_included is True + assert result.included_by("policy-evaluations", "pol-1") is not None + assert "included" not in result.model_dump() + + # List method tests diff --git a/tests/units/test_team.py b/tests/units/test_team.py index cd38ab0b..a08f28a3 100644 --- a/tests/units/test_team.py +++ b/tests/units/test_team.py @@ -11,6 +11,7 @@ TeamCreateOptions, TeamIncludeOpt, TeamListOptions, + TeamReadOptions, TeamUpdateOptions, ) from pytfe.resources.team import Teams @@ -241,12 +242,53 @@ def test_read_team_success(self, teams_service, mock_transport): mock_transport.request.assert_called_once_with( "GET", path="/api/v2/teams/team-789", + params={}, ) assert isinstance(result, Team) assert result.id == "team-789" assert result.name == "platform-admins" + def test_read_with_include_hydrates_and_captures( + self, teams_service, mock_transport + ): + """read(include=...) sends the include param, hydrates typed relations + from `included`, and captures the raw block (non-breaking escape hatch).""" + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "id": "team-789", + "attributes": {"name": "platform-admins"}, + "relationships": { + "users": {"data": [{"id": "user-1", "type": "users"}]}, + }, + }, + "included": [ + { + "id": "user-1", + "type": "users", + "attributes": {"username": "alice"}, + } + ], + } + mock_transport.request.return_value = mock_response + + result = teams_service.read( + "team-789", TeamReadOptions(include=[TeamIncludeOpt.TEAM_USERS]) + ) + + # include param is sent + assert mock_transport.request.call_args[1]["params"] == {"include": "users"} + # typed hydration from included + assert result.users is not None + assert result.users[0].username == "alice" + # raw capture + non-breaking + assert result.has_included is True + assert ( + result.included_by("users", "user-1")["attributes"]["username"] == "alice" + ) + assert "included" not in result.model_dump() + def test_delete_team_validations(self, teams_service): """Test delete method validations.""" From 5609f7620bda5e9c40b5b8e6031431660753d335 Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Tue, 16 Jun 2026 19:20:16 +0530 Subject: [PATCH 3/6] update doc --- docs/related-resources.md | 59 +++++++++++++++++++++++++++++++-------- 1 file changed, 47 insertions(+), 12 deletions(-) diff --git a/docs/related-resources.md b/docs/related-resources.md index 36ed5352..912088c4 100644 --- a/docs/related-resources.md +++ b/docs/related-resources.md @@ -29,7 +29,7 @@ pyTFE handles this on two levels: 2. **Lossless raw access** — even relations the SDK does **not** model as typed fields are never lost. Every resource on the relationship-parsing path keeps - the raw blocks, reachable through four accessors: + the raw blocks, reachable through these accessors: | Accessor | Returns | |---|---| @@ -46,6 +46,10 @@ pyTFE handles this on two levels: only appears with `?include=`), so `has_relationships` / `has_included` tell you which — without making the data accessors conditionally vanish. + `model.related(name)` takes the raw relationship key from + `model.relationships`, not the Python field name. These keys often contain + hyphens: use `ws.related("current-run")`, not `ws.related("current_run")`. + ```python # Reach a related resource the SDK doesn't expose as a typed field: readme = ws.included_by("workspace-readme", "rm-1") @@ -60,30 +64,56 @@ pyTFE handles this on two levels: print(list(ws.relationships)) # e.g. ['organization', 'project', 'outputs', ...] ``` + A read-only raw-access example using an unmodelled organization relation: + + ```python + from pytfe.models.organization import OrganizationIncludeOpt, OrganizationReadOptions + + org = client.organizations.read( + "my-org", + OrganizationReadOptions( + include=[OrganizationIncludeOpt.ORGANIZATION_SUBSCRIPTION], + ), + ) + + # `subscription` is not a typed Organization field, but it is still available. + for subscription in org.related("subscription"): + print(subscription["attributes"]) + + # included_by() is useful when you already have the relationship ref. + ref = org.relationships["subscription"]["data"] + subscription = org.included_by(ref["type"], ref["id"]) + if subscription: + print(subscription["attributes"]) + ``` + ## Which should I use — the typed field or the raw accessor? -**The one rule:** a typed relationship field always carries **at least the `id`**. -Pass `?include=` to fill in the rest. +**The one rule:** when a typed relationship is present, it carries **at least +the `id`**. Pass `?include=` to fill in the rest. ```python from pytfe.models.policy_set import PolicySetReadOptions, PolicySetIncludeOpt ps = client.policy_sets.read("polset-abc") -ps.current_version.id # always present (id-only stub) -ps.current_version.source # None — you didn't ask for it +if ps.current_version: + ps.current_version.id # present on the id-only stub + ps.current_version.source # None — you didn't ask for it ps = client.policy_sets.read_with_options( "polset-abc", PolicySetReadOptions(include=[PolicySetIncludeOpt.POLICY_SET_CURRENT_VERSION]), ) -ps.current_version.source # now hydrated from `included` +if ps.current_version: + ps.current_version.source # now hydrated from `included` ``` * **Prefer the typed field** (`ps.current_version`, `ws.outputs`, `team.users`, `org_membership.user`, `run_event.actor`) whenever the relation is modelled — it's - type-checked and stable, and `?include=` fills it. This works the *same - way for every resource that models the relation*: there are no resources where a - typed field silently stays a stub after you `?include=` it. + type-checked and stable, and `?include=` fills it on single-resource + reads. This works the *same way for every resource that models the relation*: + there are no single-resource read paths where a typed field silently stays a + stub after you `?include=` it. * **Use the raw accessors** (`model.related(name)`, `model.included_by(type, id)`) only for relations the SDK does **not** model as a typed field — e.g. an organization's `subscription`, or a workspace `readme`. The data is still returned @@ -102,11 +132,16 @@ it; if it doesn't, the raw accessors are the way in. | **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` | | **List-only** — `?include=` exists only on the `list` endpoint | `registry_module`, `run_trigger`, `policy_check` | -In every case the **`relationships`** block and the four raw accessors are populated, +In every case the **`relationships`** block and raw accessors are populated, so unmodelled relations are never lost. **List endpoints** currently capture `relationships` but not `included` (the page-level `included` array is not yet threaded through pagination — in progress). +For list endpoints, this means `model.has_relationships` can be `True`, but +`model.has_included` is currently `False` even when the list options expose an +`include` parameter. Typed relations returned from list calls therefore remain +id-only stubs until you read a single resource with the matching read options. + ## Notes - The raw blocks are **private attributes**, so they never appear in @@ -116,8 +151,8 @@ through pagination — in progress). - This complements `extra="allow"`, which retains unknown **attributes**; `relationships`/`included` cover unknown **relations**. Together nothing the API returns is silently dropped. -- Accessors are provided by `pytfe.models.TFEModel`, which **every - resource model** now derives from — so `.relationships` / `.included` / +- Accessors are provided by `pytfe.models.TFEModel`, which top-level + resource models derive from — so `.relationships` / `.included` / `.included_by` / `.related` are available everywhere. They're *populated* on single-resource `read*` calls: the `relationships` block on reads that go through a relationship-capturing parser, and the `included` array whenever you From c4aed8e6ae467dc7fa73021ae4a4497d4e37b901 Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Tue, 16 Jun 2026 19:51:19 +0530 Subject: [PATCH 4/6] fix the doc --- docs/related-resources.md | 122 +++++++++++++++++++++++++++++++------- 1 file changed, 99 insertions(+), 23 deletions(-) diff --git a/docs/related-resources.md b/docs/related-resources.md index 912088c4..0df87133 100644 --- a/docs/related-resources.md +++ b/docs/related-resources.md @@ -1,14 +1,14 @@ # Related resources (`?include=`, relationships & included) HCP Terraform speaks [JSON:API](https://developer.hashicorp.com/terraform/cloud-docs/api-docs#inclusion-of-related-resources). -A resource carries a **`relationships`** block — linkage references (`type` + `id`) -for every related resource — and, when you request `?include=`, the response also +A resource carries a **`relationships`** block: linkage references (`type` plus `id`) +for every related resource. When you request `?include=`, the response also carries a top-level **`included`** array holding the *full bodies* of those relations. pyTFE handles this on two levels: -1. **Typed hydration** — relationships the SDK models are parsed into typed +1. **Typed hydration**: relationships the SDK models are parsed into typed fields, and when you pass `include=...` those fields are filled from `included`. For example: @@ -24,10 +24,10 @@ pyTFE handles this on two levels: for o in ws.outputs: # fully hydrated from `included` print(o.name, o.value) - print(ws.project.name) # not just the id — the real project record + print(ws.project.name) # not just the id, the real project record ``` -2. **Lossless raw access** — even relations the SDK does **not** model as typed +2. **Lossless raw access**: even relations the SDK does **not** model as typed fields are never lost. Every resource on the relationship-parsing path keeps the raw blocks, reachable through these accessors: @@ -40,11 +40,11 @@ pyTFE handles this on two levels: | `model.included_by(type, id)` | one included object matched by `type` + `id` | | `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) | - `.relationships` / `.included` are always present and stably typed — they - return `{}` / `[]` whether the block was *empty* or *absent*. The API genuinely + `.relationships` and `.included` are always present and stably typed: they + return `{}` or `[]` whether the block was *empty* or *absent*. The API genuinely distinguishes the two (SSH keys omit `relationships` entirely; `included` - only appears with `?include=`), so `has_relationships` / `has_included` tell - you which — without making the data accessors conditionally vanish. + only appears with `?include=`), so `has_relationships` and `has_included` tell + you which, without making the data accessors conditionally vanish. `model.related(name)` takes the raw relationship key from `model.relationships`, not the Python field name. These keys often contain @@ -87,7 +87,83 @@ pyTFE handles this on two levels: print(subscription["attributes"]) ``` -## Which should I use — the typed field or the raw accessor? +## Traversing the raw blocks + +`model.relationships` and `model.included` are plain Python dicts and lists, +shaped exactly like the JSON:API the server returns. Once you know that shape, +walking them is straightforward. + +### `relationships` shape + +Each key is a wire relation name. Its `data` is a single reference (to-one) or a +list of references (to-many), and each reference is just a `type` and an `id`: + +```python +ws.relationships == { + # to-one: data is a single ref (a dict) + "organization": {"data": {"type": "organizations", "id": "my-org"}}, + "project": {"data": {"type": "projects", "id": "prj-abc"}}, + # to-many: data is a list of refs + "outputs": {"data": [ + {"type": "workspace-outputs", "id": "wsout-1"}, + {"type": "workspace-outputs", "id": "wsout-2"}, + ]}, + # a present-but-unset to-one relation has data == None + "current-run": {"data": None}, +} +``` + +### `included` shape + +Populated only when you pass `?include=`. It is a flat list of full resource +bodies, each with its own `type`, `id`, and `attributes`: + +```python +ws.included == [ + { + "type": "workspace-outputs", + "id": "wsout-1", + "attributes": {"name": "environment", "value": "test", "output-type": "string"}, + }, + # one entry per included resource +] +``` + +### Traversal cookbook + +| You want to... | Do this | +|---|---| +| List every relation name | `list(ws.relationships)` | +| Check whether a relation was returned | `"outputs" in ws.relationships` | +| Get a to-one ref | `ws.relationships["organization"]["data"]` (a `{type, id}` dict, or `None`) | +| Get to-many refs | `ws.relationships["outputs"]["data"]` (a list of `{type, id}`) | +| Resolve a relation to full bodies | `ws.related("outputs")` (always a list) | +| Look up one included body by ref | `ws.included_by(ref["type"], ref["id"])` (a dict, or `None`) | +| Read an attribute off a body | `body["attributes"]["name"]` | +| Walk the whole `included` array | `for item in ws.included: ...` then `item["type"]`, `item["id"]`, `item["attributes"]` | + +`related(name)` smooths over the to-one vs to-many difference for you: it always +returns a list (a single relation becomes a one-item list), and each entry is the +full `included` body when available, otherwise the bare `{type, id}` reference. + +```python +# Safe end-to-end traversal of any relation, modelled or not: +for ref in ws.related("outputs"): + if "attributes" in ref: # resolved from `included` + print(ref["attributes"]["name"]) + else: # only the bare ref (not requested with ?include=) + print("unresolved:", ref["type"], ref["id"]) +``` + +Two things to remember while traversing: + +* Relation **keys are the wire names**, so they often contain hyphens + (`"current-run"`, `"remote-state-consumers"`), not the Python field names. +* A relation can be **absent** (key missing), **unset** (`{"data": None}`), or + **empty to-many** (`{"data": []}`). `related(name)` returns `[]` for all three, + so you rarely need to special-case them. + +## Which should I use: the typed field or the raw accessor? **The one rule:** when a typed relationship is present, it carries **at least the `id`**. Pass `?include=` to fill in the rest. @@ -98,7 +174,7 @@ from pytfe.models.policy_set import PolicySetReadOptions, PolicySetIncludeOpt ps = client.policy_sets.read("polset-abc") if ps.current_version: ps.current_version.id # present on the id-only stub - ps.current_version.source # None — you didn't ask for it + ps.current_version.source # None, you didn't ask for it ps = client.policy_sets.read_with_options( "polset-abc", @@ -109,14 +185,14 @@ if ps.current_version: ``` * **Prefer the typed field** (`ps.current_version`, `ws.outputs`, `team.users`, - `org_membership.user`, `run_event.actor`) whenever the relation is modelled — it's + `org_membership.user`, `run_event.actor`) whenever the relation is modelled. It's type-checked and stable, and `?include=` fills it on single-resource reads. This works the *same way for every resource that models the relation*: there are no single-resource read paths where a typed field silently stays a stub after you `?include=` it. * **Use the raw accessors** (`model.related(name)`, `model.included_by(type, id)`) - only for relations the SDK does **not** model as a typed field — e.g. an - organization's `subscription`, or a workspace `readme`. The data is still returned + only for relations the SDK does **not** model as a typed field, for example an + organization's `subscription` or a workspace `readme`. The data is still returned by `?include=`, just untyped. 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. | Behaviour | Resources | |---|---| -| **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` | -| **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` | -| **List-only** — `?include=` exists only on the `list` endpoint | `registry_module`, `run_trigger`, `policy_check` | +| **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` | +| **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` | +| **List-only**: `?include=` exists only on the `list` endpoint | `registry_module`, `run_trigger`, `policy_check` | In every case the **`relationships`** block and raw accessors are populated, so unmodelled relations are never lost. **List endpoints** currently capture `relationships` but not `included` (the page-level `included` array is not yet threaded -through pagination — in progress). +through pagination, in progress). For list endpoints, this means `model.has_relationships` can be `True`, but `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. ## Notes - The raw blocks are **private attributes**, so they never appear in - `model_dump()` / serialized output and add no public fields. They're an - untyped escape hatch, not a stable typed API — prefer the typed fields when a + `model_dump()` or serialized output and add no public fields. They're an + untyped escape hatch, not a stable typed API, so prefer the typed fields when a relation is modelled. - This complements `extra="allow"`, which retains unknown **attributes**; `relationships`/`included` cover unknown **relations**. Together nothing the API returns is silently dropped. - Accessors are provided by `pytfe.models.TFEModel`, which top-level - resource models derive from — so `.relationships` / `.included` / - `.included_by` / `.related` are available everywhere. They're *populated* on + resource models derive from, so `.relationships`, `.included`, + `.included_by`, and `.related` are available everywhere. They're *populated* on single-resource `read*` calls: the `relationships` block on reads that go through a relationship-capturing parser, and the `included` array whenever you pass `?include=`. **List endpoints** currently populate `relationships` but not - `included` — the shared top-level `included` array is not yet threaded through + `included`; the shared top-level `included` array is not yet threaded through pagination (in progress). From c4c6c5774ca15ba04b05d34361d388a4204af647 Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Wed, 17 Jun 2026 17:20:30 +0530 Subject: [PATCH 5/6] updt changelog --- CHANGELOG.md | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2a6783a..62a9ffbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,26 +3,21 @@ ## Enhancements ### Relationships -* 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*. - * **`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. - * **`included` hydration** now comes in two forms, both purely additive: - * **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=` fills that field** (e.g. `policy_set.current_version`, `organization_membership.user`, `run_event.actor`). - * **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.) - * **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). +Related data is now a complete, first-class part of every response. Before, `?include=` often did not actually fill the related fields it returned, and anything the SDK did not model as a typed field was dropped on the floor. Now the full set of related resources the API hands back is always available to you: typed where pytfe models it, raw where it does not. The practical win is that **you are no longer limited to the relationships pytfe has added typed support for.** You can read any related resource in a response without dropping to manual HTTP or waiting for a new SDK release. - See [docs/related-resources.md](docs/related-resources.md) for the per-resource coverage table and a "typed field vs raw accessor" guide. - -* 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): - * `teams.read(team_id, TeamReadOptions(include=[...]))` — `users`, `organization-memberships` (typed hydration). - * `task_stages.read(task_stage_id, TaskStageReadOptions(include=[...]))` — `run`, `run.workspace`, `task-results`, `policy-evaluations` (typed hydration). - * `organizations.read(name, OrganizationReadOptions(include=[...]))` — `subscription` (raw capture). The new `options` argument is optional, so existing positional calls are unchanged. +* `?include=` now fills in related data. When the SDK models a relation as a typed field (for example `workspace.outputs`, `policy_set.current_version`, `organization_membership.user`, `run_event.actor`), passing `?include=` fills that field with the real record instead of an id-only stub. +* Relations the SDK does **not** model are no longer lost. Every top-level resource model now derives from a new `pytfe.models.TFEModel` base and gains read-only accessors for the raw JSON:API data the API returned: `model.relationships`, `model.included`, `model.related(name)`, `model.included_by(type, id)`, and the `model.has_relationships` and `model.has_included` flags. So when a relation has no typed field of its own (for example an organization's `subscription`, or a workspace `readme`), `?include=` still returns it and you reach it with `model.related("subscription")` or `model.included_by(type, id)`. These accessors are read-only extras that never appear in `model_dump()` or affect equality, so this is additive and non-breaking. List endpoints expose the relationship refs but do not yet fill `included`. See [docs/related-resources.md](docs/related-resources.md) for the per-resource table and a "typed field vs raw accessor" guide. +* Added `?include=` support to three reads that previously had no include option, matching the HCP Terraform API: + * `teams.read(team_id, TeamReadOptions(include=[...]))`: `users`, `organization-memberships`. + * `task_stages.read(task_stage_id, TaskStageReadOptions(include=[...]))`: `run`, `run.workspace`, `task-results`, `policy-evaluations`. + * `organizations.read(name, OrganizationReadOptions(include=[...]))`: `subscription`. The new `options` argument is optional, so existing calls are unchanged. ## Bug Fixes ### Relationships -* 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.) -* `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`. -* `variable_set.read` no longer fabricates placeholder relation values (e.g. `name="workspace-"`, `key="var-"`, `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. +* Fixed `workspaces.read*(include=[WorkspaceIncludeOpt.OUTPUTS])` returning outputs with `None` name, value, and type. Workspace `outputs` is now filled from the `included` data. [#134](https://github.com/hashicorp/python-tfe/issues/134) +* Fixed `policy_set.read*(include=[current_version | newest_version])` returning an id-only stub. `PolicySetVersion` is now exported from `pytfe.models` and fully resolved, so the version's `source`, `created_at`, and `status` are populated. +* Fixed `variable_set.read` inventing placeholder values (such as `name="workspace-"` or `key="var-"`) for `workspaces`, `projects`, and `vars`. These are now id-only stubs by default and fill from `included` when requested. # Released # v1.1.0 From 95270bba9ecf94e4b60ac6a3de89ed84d09cf958 Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Wed, 17 Jun 2026 17:41:17 +0530 Subject: [PATCH 6/6] updt models.md --- docs/MODELS.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/MODELS.md b/docs/MODELS.md index 942a365c..ebf85970 100644 --- a/docs/MODELS.md +++ b/docs/MODELS.md @@ -41,10 +41,10 @@ class Foo(BaseModel): | Inherit from | For | |---|---| -| **`TFEModel`** (`pytfe.models`, defined in `models/_base.py`) | **Top-level resource models** — anything returned from a `read`/`list`/`create`/`update` that corresponds to a JSON:API *resource object* (`Workspace`, `Run`, `Project`, `Policy`, `AdminRun`, …). | +| **`TFEModel`** (`pytfe.models`, defined in `models/_base.py`) | **Top-level resource models**: anything returned from a `read`/`list`/`create`/`update` that corresponds to a JSON:API *resource object* (`Workspace`, `Run`, `Project`, `Policy`, `AdminRun`, …). | | **`BaseModel`** | Everything else: `*CreateOptions` / `*UpdateOptions` / `*ListOptions`, nested attribute sub-objects (`WorkspacePermissions`, `VCSRepo`, …), enums, and `*List` envelopes. | -`TFEModel` is **config-light** — it adds no `model_config`, so you still set your own (`extra="allow"`, etc.) exactly as above. What it adds is the lossless related-resource escape hatch: `.relationships`, `.included`, `.included_by(type, id)`, `.related(name)`, and the `.has_relationships` / `.has_included` presence flags. These are private attributes, so they never touch `model_dump()` and add no public fields — inheriting it is additive and non-breaking. +`TFEModel` is **config-light**: it adds no `model_config`, so you still set your own (`extra="allow"`, etc.) exactly as above. What it adds is the lossless related-resource escape hatch: `.relationships`, `.included`, `.included_by(type, id)`, `.related(name)`, and the `.has_relationships` / `.has_included` presence flags. These are private attributes, so they never touch `model_dump()` and add no public fields, and `TFEModel` also overrides `__eq__` to ignore them, so equality stays identical to a plain `BaseModel`. Inheriting it is additive and non-breaking. For the accessors to be *populated* (not just present-and-empty), the resource's parser must hand the raw JSON:API resource dict (and any document `included`) to `attach_jsonapi`: @@ -60,6 +60,19 @@ def _foo_from(data, included=None): `attach_jsonapi(obj, data, included)` is the one line that captures both raw blocks; pass `included=payload.get("included")` from any `read`/`list` that supports `?include=`. See [related-resources.md](related-resources.md) for the consumer-facing view. +### Why not put it on every model + +`TFEModel` is scoped to resource objects on purpose. The escape hatch is only meaningful for things parsed from a JSON:API *resource object* (a thing with a `relationships` block, returned from `read`/`list`/`create`/`update`). Putting it on `*Options`, sub-objects, and `*List` envelopes would cost more than it gives: + +* **It does nothing on its own.** The accessors stay empty until a parser calls `attach_jsonapi(...)`. A request/options model is never parsed from a response, so its accessors would be permanently empty and pointless. +* **It adds a confusing, response-only surface to request objects.** A `WorkspaceReadOptions` is something you *build and send*. Exposing `.relationships`, `.included`, and `.related(...)` on it (always empty) is misleading in code, in docs, and in editor autocomplete. +* **It reserves names.** `TFEModel` claims `relationships`, `included`, `related`, `included_by`, `has_relationships`, and `has_included`. If a model later needs a field with one of those names, Pydantic warns (`Field name "..." shadows an attribute in parent "TFEModel"`) and the field silently wins, quietly breaking the escape hatch on that model. Keeping non-resource models on `BaseModel` avoids reserving those names where they are not needed. +* **It changes the public class hierarchy and equality** for many public, downstream-facing models. `TFEModel`'s `__eq__` is intentionally equivalent to `BaseModel`'s for public fields, but there is no reason to swap a custom `__eq__` onto the hundreds of options and sub-object models that behave like plain Pydantic today. + +What it does **not** break: `frozen=True` models stay hashable, because Pydantic regenerates `__hash__` for a frozen subclass. The only real failure mode is the name collision above. + +Rule of thumb: inherit `TFEModel` when the model is a JSON:API resource you parse *from* a response; use `BaseModel` for everything you *build* (options), *nest* (sub-objects), or *wrap* (`*List` envelopes). + ## Field aliases: JSON:API hyphens → Python snake_case HCP Terraform speaks JSON:API, which uses hyphenated attribute names (`created-at`, `auto-apply`, `state-versions`). Python uses snake_case. Bridge with `Field(alias=...)`: