diff --git a/AGENTS.md b/AGENTS.md index 1b0caee2..4fa77841 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,8 @@ src/pytfe/ client.py # TFEClient — composition root, wires every resource config.py # TFEConfig — auth, timeout, retry, proxy settings _http.py # HTTPTransport — request, retry, redirects, auth - _jsonapi.py # JSON:API envelope helpers + _jsonapi.py # JSON:API helpers: headers, error payloads, and the + # shared relationship/included parser (parse_relationships) _logging.py. # Logging primitives for the pytfe SDK errors.py # Typed exception hierarchy (TFEError + ~80 subclasses) utils.py # Validation + small helpers @@ -76,6 +77,7 @@ These are mistakes a competent Python developer would make if they hadn't read t - **Don't reuse generators.** Iterators returned by `list_*` are single-use. If you need to traverse twice, `materialized = list(client.foo.list_bars(...))` first. - **Don't add features beyond what was asked.** This codebase is approaching v1.0.0. Adding "while I'm here" refactors or speculative abstractions slows reviews and risks breaking the Ansible collection. - **Don't assume every successful response is `{"data": ...}`.** Check the docs/go-tfe/spec for each endpoint: some return a JSON:API envelope, some return a bare resource object, `204 No Content`, `null`, raw bytes, or a redirect to a blob URL. Add tests for non-standard shapes. +- **Don't hand-roll relationship parsing, and don't let response models drop unknown fields.** Response models (the ones you parse from API payloads) set `extra="allow"` so new server fields survive in `model_extra` instead of being silently dropped. Parse the `relationships` block with the shared `parse_relationships` helper from `pytfe._jsonapi` (a declarative `{wire_relation: Model}` map + optional `included` hydration), not a bespoke if-ladder. `resources/workspaces.py` and `resources/run.py` are the reference parsers; details in [MODELS.md](docs/MODELS.md) and [RESOURCE.md](docs/RESOURCE.md). - **Don't use bare `list[...]` annotations inside a resource class after defining `def list(...)`.** In class scope, mypy can resolve `list` to the method instead of the builtin. Use `builtins.list[...]`, `Sequence[...]`, or another unshadowed type. - **Don't `print()` or use ad-hoc `logging.getLogger(__name__)` calls in library code.** The SDK has a structured logging framework — use `pytfe._logging.transport_logger` for HTTP traffic, or `pytfe._logging.logger` (the `pytfe` root) for higher-level events. Everything from that namespace is silent by default (NullHandler) and respects the user's `setup_logging()` or stdlib configuration. See [LOGGING.md](docs/LOGGING.md) for redaction rules — bearer tokens and `token`/`secret`/`password` keys are auto-redacted by `RoundTrip`, but only inside that formatter. Never `log.info(token)` directly. diff --git a/docs/MODELS.md b/docs/MODELS.md index fae5fc4f..1a32de85 100644 --- a/docs/MODELS.md +++ b/docs/MODELS.md @@ -26,6 +26,7 @@ New or touched `BaseModel` classes should set `model_config = ConfigDict(...)` u | `populate_by_name=True` | **Always.** Lets callers pass either the field name (`created_at=...`) or the alias (`{"created-at": ...}`) when constructing. | | `validate_by_name=True` | Use on models that are parsed *from* API responses **or** constructed by callers via field names. Pair with `populate_by_name=True`. | | `extra="forbid"` | Use on `*CreateOptions` / `*UpdateOptions` / option models where you want a typo (`workspce_id=...`) to fail loudly instead of being silently dropped. Don't put it on response models — the API can add fields and we don't want that to break parsing. | +| `extra="allow"` | Standard for **response models** parsed from API payloads. The default (`extra="ignore"`) silently drops any wire attribute without a declared field, so a new server field becomes a data-loss bug. `extra="allow"` retains undeclared fields in `model_extra` under their wire names (e.g. `model_extra["future-field"]`). Note: extra keys are *not* dot-accessible as snake_case and have no type — add an explicit aliased field for anything users should access ergonomically. `Workspace` is the reference implementation; relationship parsing for these models goes through `pytfe._jsonapi.parse_relationships` (see the Relationships section). | | `arbitrary_types_allowed=True` | Only when you genuinely have a non-Pydantic type in a field (rare). | The standard line you'll write 90% of the time: @@ -177,12 +178,34 @@ class TaskStage(BaseModel): task_results: list[TaskResult] | None = Field(None, alias="task-results") ``` -Use `model_construct` (not `model_validate`) in the resource for these stubs — it skips validation, which is correct because you only have `{id, type}`: +The resource layer should populate these via the shared `parse_relationships` helper (see below) rather than a hand-rolled if-ladder. For a true one-off, `Model.model_construct(id=...)` (not `model_validate`) is correct — it skips validation, which is right because you only have `{id, type}`. + +### Parsing relationships — `pytfe._jsonapi.parse_relationships` + +Don't hand-roll the `relationships.get("x", {}).get("data")` if-ladder per resource. The canonical parser lives in `src/pytfe/_jsonapi.py` and is driven by a declarative map of `{wire_relation: Model}` (or `{wire_relation: (python_attr, Model)}` when the attribute name diverges from `wire.replace("-", "_")`): ```python -attributes["run"] = Run.model_construct(id=run_data["id"]) +from .._jsonapi import parse_relationships + +_WIDGET_REL_MAP = { + "organization": Organization, # attr derived: "organization" + "current-run": Run, # attr derived: "current_run" + "vars": ("variables", Variable), # wire name diverges from attr +} + +def _widget_from(d, included=None): + attrs = dict(d.get("attributes") or {}) + attrs["id"] = d.get("id") + attrs.update(parse_relationships(d.get("relationships"), _WIDGET_REL_MAP, included=included)) + return Widget.model_validate(attrs) ``` +`parse_relationships` handles single vs list `data`, skips null/absent and unmapped relations (so they fall back to model defaults / `extra="allow"`), and — when the caller passes the response's top-level `included` array — hydrates the **full** related object instead of an id-only stub (so `?include=current_run` returns a populated `Run`, not just `{id}`). Thread `included` from read paths: `payload = r.json(); _widget_from(payload["data"], payload.get("included"))`. + +Keep genuinely polymorphic relations (e.g. workspace `locked-by`, `data-retention-policy-choice`) and relations whose `data` carries inline attributes (e.g. workspace `outputs`) as explicit special cases — they don't fit the simple map. `Workspace` (`resources/workspaces.py`) and `Run` (`resources/run.py`) are the reference implementations. + +> One gotcha: a parser written as a **method** on a service class that also defines `def list(...)` cannot annotate `included: list[...] | None` — in class scope `list` resolves to the method. Make the parser a module-level function (preferred, matches `_ws_from`/`_run_from`), or use `builtins.list[...]`. + ### Option 2 — Flat `*_id` field When the relationship is "owned" by this resource and just one id matters, expose it as a flat `*_id` field (with hyphen alias if needed). Less plumbing, fine when you don't need the related model object: @@ -275,6 +298,7 @@ The CI check in `tests/units/test_model_conventions.py` enforces this for every - [ ] `from __future__ import annotations` at the top - [ ] New or touched classes use `model_config = ConfigDict(populate_by_name=True, validate_by_name=True)` unless preserving a local legacy pattern - [ ] Hyphenated JSON:API attribute names → `Field(alias="...")` +- [ ] **Response models** (parsed from API payloads) add `extra="allow"` to their `ConfigDict` for forward compatibility; relationships are parsed via `parse_relationships`, not a hand-rolled if-ladder - [ ] Response model fields default to `T | None = Field(None, alias="...")` - [ ] `*CreateOptions` uses `Field(...)` for required fields, `T | None = None` for optional - [ ] `*UpdateOptions` is fully optional diff --git a/docs/RESOURCE.md b/docs/RESOURCE.md index 3ffb34b6..938e3d84 100644 --- a/docs/RESOURCE.md +++ b/docs/RESOURCE.md @@ -184,26 +184,35 @@ def _widget_from(self, data: dict[str, Any]) -> Widget: return Widget.model_validate(attrs) ``` -If the model has relationships, pull them from `data["relationships"]` and either: - -1. **Embed an id-stub** using `Model.model_construct(id=...)` — use this when the model defines the relation as `OtherModel | None`. `model_construct` skips validation, which is correct for partial `{id, type}` data: +If the model has relationships, parse them with the shared `parse_relationships` helper from `pytfe._jsonapi` — don't hand-roll a per-relation if-ladder. Pass a declarative `{wire_relation: Model}` map (or `{wire: (python_attr, Model)}` when the attribute name diverges from `wire.replace("-", "_")`), and thread the response's top-level `included` so `?include=` requests hydrate full nested objects instead of id-only stubs: ```python -relationships = data.get("relationships", {}) -run_data = relationships.get("run", {}).get("data") -if run_data: - attributes["run"] = Run.model_construct(id=run_data["id"]) +from .._jsonapi import parse_relationships + +_WIDGET_REL_MAP = {"organization": Organization, "current-run": Run} + +def _widget_from(self, data: dict[str, Any], included=None) -> Widget: + attrs = dict(data.get("attributes") or {}) + attrs["id"] = data.get("id") + attrs.update(parse_relationships(data.get("relationships"), _WIDGET_REL_MAP, included=included)) + return Widget.model_validate(attrs) + +# caller threads included from the envelope: +payload = r.json() +return self._widget_from(payload["data"], payload.get("included")) ``` -2. **Flatten to `*_id`** when the model exposes a flat `team_id: str | None` field: +`parse_relationships` skips null/absent/unmapped relations, handles single vs list `data`, and builds id-only stubs via `model_construct` when `included` is absent. For a relation the model exposes as a **flat `*_id`** field instead of an embedded model, keep the small manual extraction: ```python -team_data = (relationships.get("team") or {}).get("data") or {} +team_data = (data.get("relationships", {}).get("team") or {}).get("data") or {} if team_data.get("id"): - attributes["team-id"] = team_data["id"] + attrs["team-id"] = team_data["id"] ``` -Always defensively coalesce with `or {}` — relationships may be missing from sparse responses. +Keep polymorphic relations (e.g. `locked-by`) and relations whose `data` carries inline attributes (e.g. workspace `outputs`) as explicit special cases — they don't fit the map. See `resources/workspaces.py` and `resources/run.py` for the reference parsers. Always defensively coalesce with `or {}` — relationships may be missing from sparse responses. + +> If the parser is a **method** on a service class that defines `def list(...)`, you can't annotate `included: list[...] | None` (in class scope `list` is the method). Prefer a module-level `_widget_from` function (like `_ws_from`/`_run_from`), or use `builtins.list[...]`. ## Pagination — use `self._list`, don't roll your own diff --git a/src/pytfe/_jsonapi.py b/src/pytfe/_jsonapi.py index 6f031452..c5055f38 100644 --- a/src/pytfe/_jsonapi.py +++ b/src/pytfe/_jsonapi.py @@ -24,3 +24,106 @@ def parse_error_payload(payload: dict[str, Any]) -> list[dict | str]: if "message" in payload: return [{"detail": payload.get("message")}] return [] + + +# --------------------------------------------------------------------------- # +# Relationship / included parsing +# +# HCP Terraform speaks JSON:API: a resource object carries an ``attributes`` map +# and a ``relationships`` map, and a response may carry a top-level ``included`` +# array holding the full bodies of related resources requested via ``?include=``. +# +# These helpers are the single canonical implementation that resource parsers +# converge on, replacing the per-resource hand-rolled relationship parsing +# (``workspaces._ws_from`` if-ladder, ``run.transform_relationships``, +# ``no_code_module`` included-index). +# --------------------------------------------------------------------------- # + +IncludedIndex = dict[tuple[Any, Any], dict[str, Any]] + +# A relation map value is either the model class (attr name derived from the +# wire relation name by ``-`` -> ``_``) or an explicit ``(python_attr, Model)`` +# tuple for the cases where they diverge (e.g. ``vars`` -> ``variables``). +RelationSpec = "type | tuple[str, type]" +RelationMap = dict[str, Any] + + +def build_included_index(included: list[dict[str, Any]] | None) -> IncludedIndex: + """Index a JSON:API ``included`` array by ``(type, id)``. + + JSON:API guarantees ``(type, id)`` uniqueness within a document, so the + first occurrence wins deterministically if a server ever violates that. + """ + index: IncludedIndex = {} + for item in included or []: + index.setdefault((item.get("type"), item.get("id")), item) + return index + + +def _hydrate(ref: dict[str, Any], model: Any, index: IncludedIndex) -> Any | None: + """Resolve a single relationship reference to a model instance. + + If the full resource body is present in ``index`` (because it was requested + via ``?include=``), validate it into a fully-populated model. Otherwise + return a lightweight ``{id}`` stub via ``model_construct`` (no validation — + correct, because only ``id``/``type`` are known). + """ + rid = ref.get("id") + if rid is None: + return None + full = index.get((ref.get("type"), rid)) + if full is not None: + attrs = dict(full.get("attributes") or {}) + attrs["id"] = rid + try: + return model.model_validate(attrs) + except Exception: + # A hydration failure must never break parsing of the parent. + return model.model_construct(id=rid) + return model.model_construct(id=rid) + + +def parse_relationships( + relationships: dict[str, Any] | None, + rel_map: RelationMap, + *, + included: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Parse a JSON:API ``relationships`` block into ``{python_attr: value}``. + + ``rel_map`` maps each wire relationship name to either a model class (the + python attribute is derived as ``wire.replace("-", "_")``) or an explicit + ``(python_attr, Model)`` tuple. Single references become a model instance; + lists become a list of model instances. Null/empty relationships and + relations absent from ``rel_map`` are skipped, so they never clobber model + defaults (undeclared relations are caught by ``extra="allow"``). + """ + rels = relationships or {} + index = build_included_index(included) + out: dict[str, Any] = {} + + for wire, spec in rel_map.items(): + if isinstance(spec, tuple): + attr, model = spec + else: + attr, model = wire.replace("-", "_"), spec + + rel = rels.get(wire) + if not isinstance(rel, dict): + continue + data = rel.get("data") + if data is None: + continue + if isinstance(data, list): + out[attr] = [ + m + for ref in data + if isinstance(ref, dict) + and (m := _hydrate(ref, model, index)) is not None + ] + elif isinstance(data, dict): + m = _hydrate(data, model, index) + if m is not None: + out[attr] = m + + return out diff --git a/src/pytfe/models/agent.py b/src/pytfe/models/agent.py index 2c1db661..aee633f3 100644 --- a/src/pytfe/models/agent.py +++ b/src/pytfe/models/agent.py @@ -52,7 +52,9 @@ class Agent(BaseModel): class AgentPool(BaseModel): """Agent Pool represents a Terraform Enterprise agent pool.""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str name: str | None = Field(default=None, alias="name") diff --git a/src/pytfe/models/apply.py b/src/pytfe/models/apply.py index c3f991c7..047bbb19 100644 --- a/src/pytfe/models/apply.py +++ b/src/pytfe/models/apply.py @@ -22,7 +22,9 @@ class ApplyStatus(str, Enum): class Apply(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str log_read_url: str | None = Field(None, alias="log-read-url") @@ -37,7 +39,9 @@ class Apply(BaseModel): class ApplyStatusTimestamps(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) canceled_at: datetime | None = Field(None, alias="canceled-at") errored_at: datetime | None = Field(None, alias="errored-at") diff --git a/src/pytfe/models/assessment_result.py b/src/pytfe/models/assessment_result.py index 0303bbf4..a1b02df5 100644 --- a/src/pytfe/models/assessment_result.py +++ b/src/pytfe/models/assessment_result.py @@ -11,7 +11,9 @@ class AssessmentResult(BaseModel): """Result of a workspace health assessment (drift detection).""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str succeeded: bool | None = Field(default=None, alias="succeeded") diff --git a/src/pytfe/models/comment.py b/src/pytfe/models/comment.py index 8bc0d110..f9ec97ab 100644 --- a/src/pytfe/models/comment.py +++ b/src/pytfe/models/comment.py @@ -10,7 +10,9 @@ class Comment(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str body: str = Field(default="", alias="body") diff --git a/src/pytfe/models/cost_estimate.py b/src/pytfe/models/cost_estimate.py index 4ae1c614..5cd8386b 100644 --- a/src/pytfe/models/cost_estimate.py +++ b/src/pytfe/models/cost_estimate.py @@ -10,7 +10,9 @@ class CostEstimate(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str delta_monthly_cost: str = Field(default="", alias="delta-monthly-cost") @@ -36,7 +38,9 @@ class CostEstimateStatus(str, Enum): class CostEstimateStatusTimestamps(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) canceled_at: datetime = Field(..., alias="canceled-at") errored_at: datetime = Field(..., alias="errored-at") diff --git a/src/pytfe/models/explorer.py b/src/pytfe/models/explorer.py index 732b3216..227242f8 100644 --- a/src/pytfe/models/explorer.py +++ b/src/pytfe/models/explorer.py @@ -27,7 +27,9 @@ class ExplorerViewType(str, Enum): class ExplorerUrlFilter(BaseModel): """One slot in ExplorerQueryOptions.filters → filter[i][field][op][idx] query keys.""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) index: int = Field(..., ge=0, description="Filter index in the query string") field: str = Field( @@ -72,7 +74,9 @@ class ExplorerRow(BaseModel): snake variants. """ - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str type: str @@ -82,7 +86,9 @@ class ExplorerRow(BaseModel): class ExplorerSavedQueryFilter(BaseModel): """One saved-view filter row (list-valued `value` matches create/update JSON).""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) field: str = Field(..., min_length=1) operator: str = Field(..., min_length=1) @@ -92,7 +98,9 @@ class ExplorerSavedQueryFilter(BaseModel): class ExplorerSavedQuery(BaseModel): """Nested query on a saved view: view type, filters, optional fields and sort lists.""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) query_type: ExplorerViewType = Field(..., alias="type") filter: list[ExplorerSavedQueryFilter] | None = None @@ -109,7 +117,9 @@ class ExplorerSavedView(BaseModel): request/response payload. """ - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str name: str diff --git a/src/pytfe/models/no_code_module.py b/src/pytfe/models/no_code_module.py index c2f5ea6c..e069879c 100644 --- a/src/pytfe/models/no_code_module.py +++ b/src/pytfe/models/no_code_module.py @@ -27,7 +27,9 @@ class NoCodeVariableOption(BaseModel): and when constructing create/update options. """ - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str | None = None variable_name: str | None = Field(default=None, alias="variable-name") @@ -40,7 +42,9 @@ class NoCodeModule(BaseModel): for the no-code provisioning workflow. """ - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str | None = None enabled: bool | None = None @@ -101,7 +105,9 @@ class NoCodeWorkspaceVariable(BaseModel): or upgrade. Mirrors the fields accepted under the ``vars`` relationship. """ - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) key: str value: str | None = None @@ -141,7 +147,9 @@ class NoCodeWorkspaceUpgradeOptions(BaseModel): class WorkspaceUpgrade(BaseModel): """The result of initiating or polling a no-code workspace upgrade.""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str | None = None status: str | None = None @@ -159,7 +167,9 @@ class RegistryModuleVariable(BaseModel): UIs that build no-code workspace creation forms. """ - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str | None = None name: str | None = None diff --git a/src/pytfe/models/notification_configuration.py b/src/pytfe/models/notification_configuration.py index c9b2934a..fc667024 100644 --- a/src/pytfe/models/notification_configuration.py +++ b/src/pytfe/models/notification_configuration.py @@ -53,7 +53,7 @@ class NotificationDestinationType(Enum): class DeliveryResponse(BaseModel): """Represents a notification configuration delivery response.""" - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(populate_by_name=True, extra="allow") body: str | None = None code: str | None = None @@ -72,7 +72,7 @@ def __init__(self, data: dict[str, Any] | None = None, /, **kwargs: Any) -> None class NotificationConfigurationSubscribableChoice(BaseModel): """Choice type struct that represents the possible values within a polymorphic relation.""" - model_config = ConfigDict(arbitrary_types_allowed=True) + model_config = ConfigDict(arbitrary_types_allowed=True, extra="allow") team: Any | None = None workspace: Any | None = None @@ -81,7 +81,9 @@ class NotificationConfigurationSubscribableChoice(BaseModel): class NotificationConfiguration(BaseModel): """Represents a Notification Configuration.""" - model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True) + model_config = ConfigDict( + populate_by_name=True, arbitrary_types_allowed=True, extra="allow" + ) id: str | None = None created_at: datetime | None = Field(default=None, alias="created-at") @@ -312,7 +314,7 @@ def validate(self) -> list[str]: # type: ignore[override] class NotificationConfigurationList(BaseModel): """Represents a list of notification configurations with pagination.""" - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(populate_by_name=True, extra="allow") items: list[NotificationConfiguration] = Field(default_factory=list) current_page: int = 0 diff --git a/src/pytfe/models/oauth_client.py b/src/pytfe/models/oauth_client.py index 86ffbbfa..1b149496 100644 --- a/src/pytfe/models/oauth_client.py +++ b/src/pytfe/models/oauth_client.py @@ -57,7 +57,7 @@ class OAuthClient(BaseModel): agent_pool: dict | None = Field(None, alias="agent-pool") projects: list[dict] | None = None - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(populate_by_name=True, extra="allow") class OAuthClientList(BaseModel): diff --git a/src/pytfe/models/organization_membership.py b/src/pytfe/models/organization_membership.py index cc00a4f5..0898bee6 100644 --- a/src/pytfe/models/organization_membership.py +++ b/src/pytfe/models/organization_membership.py @@ -31,7 +31,7 @@ class OrgMembershipIncludeOpt(str, Enum): class OrganizationMembership(BaseModel): """Represents a Terraform Enterprise organization membership.""" - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(populate_by_name=True, extra="allow") id: str status: OrganizationMembershipStatus | None = Field(default=None, alias="status") diff --git a/src/pytfe/models/plan.py b/src/pytfe/models/plan.py index c543c256..48b4b7e7 100644 --- a/src/pytfe/models/plan.py +++ b/src/pytfe/models/plan.py @@ -26,7 +26,9 @@ class PlanStatus(str, Enum): class Plan(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str has_changes: bool | None = Field(None, alias="has-changes") @@ -46,7 +48,9 @@ class Plan(BaseModel): class PlanStatusTimestamps(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) canceled_at: datetime | None = Field(None, alias="canceled-at") errored_at: datetime | None = Field(None, alias="errored-at") diff --git a/src/pytfe/models/plan_export.py b/src/pytfe/models/plan_export.py index a00085b2..008f0dca 100644 --- a/src/pytfe/models/plan_export.py +++ b/src/pytfe/models/plan_export.py @@ -7,6 +7,8 @@ class PlanExport(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str diff --git a/src/pytfe/models/policy.py b/src/pytfe/models/policy.py index 379cec17..9081b09d 100644 --- a/src/pytfe/models/policy.py +++ b/src/pytfe/models/policy.py @@ -12,7 +12,9 @@ class Policy(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str name: str | None = Field(None, alias="name") @@ -26,7 +28,9 @@ class Policy(BaseModel): class PolicyList(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) items: list[Policy] = Field(default_factory=list) current_page: int | None = None diff --git a/src/pytfe/models/policy_check.py b/src/pytfe/models/policy_check.py index 70856e00..ac0b8f0f 100644 --- a/src/pytfe/models/policy_check.py +++ b/src/pytfe/models/policy_check.py @@ -44,7 +44,9 @@ class PolicyCheckIncludeOpt(str, Enum): class PolicyCheck(BaseModel): """PolicyCheck represents a Terraform Enterprise policy check.""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str actions: PolicyActions | None = Field(None, alias="actions") @@ -63,7 +65,9 @@ class PolicyCheck(BaseModel): class PolicyActions(BaseModel): """PolicyActions represents the policy check actions.""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) is_overridable: bool | None = Field(None, alias="is-overridable") @@ -71,7 +75,9 @@ class PolicyActions(BaseModel): class PolicyPermissions(BaseModel): """PolicyPermissions represents the policy check permissions.""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) can_override: bool | None = Field(None, alias="can-override") @@ -79,7 +85,9 @@ class PolicyPermissions(BaseModel): class PolicyResult(BaseModel): """PolicyResult represents the complete policy check result""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) advisory_failed: int | None = Field(None, alias="advisory-failed") duration: int | None = Field(None, alias="duration") @@ -94,7 +102,9 @@ class PolicyResult(BaseModel): class PolicyStatusTimestamps(BaseModel): """PolicyStatusTimestamps holds the timestamps for individual policy check statuses.""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) errored_at: datetime | None = Field(None, alias="errored-at") hard_failed_at: datetime | None = Field(None, alias="hard-failed-at") @@ -115,7 +125,9 @@ class PolicyCheckListOptions(BaseModel): class PolicyCheckList(BaseModel): """PolicyCheckList represents a list of policy checks.""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) items: list[PolicyCheck] = Field(default_factory=list, alias="items") current_page: int | None = Field(None, alias="current_page") diff --git a/src/pytfe/models/policy_evaluation.py b/src/pytfe/models/policy_evaluation.py index 4891338e..4b228450 100644 --- a/src/pytfe/models/policy_evaluation.py +++ b/src/pytfe/models/policy_evaluation.py @@ -27,7 +27,9 @@ class PolicyEvaluationStatus(str, Enum): class PolicyEvaluation(BaseModel): """PolicyEvaluation represents the policy evaluations that are part of the task stage.""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str status: PolicyEvaluationStatus | None = Field(None, alias="status") @@ -46,7 +48,9 @@ class PolicyEvaluation(BaseModel): class PolicyEvaluationStatusTimestamps(BaseModel): """PolicyEvaluationStatusTimestamps represents the set of timestamps recorded for a policy evaluation""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) passed_at: datetime | None = Field(None, alias="passed-at") failed_at: datetime | None = Field(None, alias="failed-at") @@ -58,7 +62,9 @@ class PolicyEvaluationStatusTimestamps(BaseModel): class PolicyAttachable(BaseModel): """The task stage the policy evaluation belongs to""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str type: str | None = Field(None, alias="type") @@ -67,7 +73,9 @@ class PolicyAttachable(BaseModel): class PolicyResultCount(BaseModel): """PolicyResultCount represents the count of the policy results""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) advisory_failed: int | None = Field(None, alias="advisory-failed") mandatory_failed: int | None = Field(None, alias="mandatory-failed") diff --git a/src/pytfe/models/policy_set.py b/src/pytfe/models/policy_set.py index e35257d8..f60ee5f5 100644 --- a/src/pytfe/models/policy_set.py +++ b/src/pytfe/models/policy_set.py @@ -27,7 +27,9 @@ class PolicySetIncludeOpt(str, Enum): class PolicySet(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str name: str | None = Field(None, alias="name") @@ -63,7 +65,9 @@ class PolicySet(BaseModel): class PolicySetList(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) items: list[PolicySet] = Field(default_factory=list) current_page: int | None = None diff --git a/src/pytfe/models/policy_set_outcome.py b/src/pytfe/models/policy_set_outcome.py index ea2e1682..9bf07c92 100644 --- a/src/pytfe/models/policy_set_outcome.py +++ b/src/pytfe/models/policy_set_outcome.py @@ -11,7 +11,9 @@ class PolicySetOutcome(BaseModel): """PolicySetOutcome represents outcome of the policy set that are part of the policy evaluation""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str outcomes: list[Outcome] = Field(default_factory=list, alias="outcomes") @@ -28,7 +30,9 @@ class PolicySetOutcome(BaseModel): class Outcome(BaseModel): """Outcome represents the outcome of the individual policy""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) enforcement_level: str | None = Field(None, alias="enforcement-level") query: str | None = Field(None, alias="query") @@ -40,7 +44,9 @@ class Outcome(BaseModel): class PolicySetOutcomeListFilter(BaseModel): """PolicySetOutcomeListFilter represents the filters that are supported while listing a policy set outcome""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) status: str | None = Field(None, alias="status") enforcement_level: str | None = Field(None, alias="enforcement-level") diff --git a/src/pytfe/models/policy_set_parameter.py b/src/pytfe/models/policy_set_parameter.py index ec1b881a..e2729b1a 100644 --- a/src/pytfe/models/policy_set_parameter.py +++ b/src/pytfe/models/policy_set_parameter.py @@ -10,7 +10,9 @@ class PolicySetParameter(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str key: str = Field(..., alias="key") diff --git a/src/pytfe/models/policy_set_version.py b/src/pytfe/models/policy_set_version.py index f56bfe89..c53a3eb7 100644 --- a/src/pytfe/models/policy_set_version.py +++ b/src/pytfe/models/policy_set_version.py @@ -41,7 +41,9 @@ class PolicySetVersionStatus(str, Enum): class PolicySetVersionStatusTimestamps(BaseModel): """PolicySetVersionStatusTimestamps holds the timestamps for individual policy set version statuses.""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) pending_at: datetime | None = Field(None, alias="pending-at") ingressing_at: datetime | None = Field(None, alias="ingressing-at") @@ -50,7 +52,9 @@ class PolicySetVersionStatusTimestamps(BaseModel): class PolicySetIngressAttributes(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) commit_sha: str | None = Field(None, alias="commit-sha") commit_url: str | None = Field(None, alias="commit-url") @@ -60,7 +64,9 @@ class PolicySetIngressAttributes(BaseModel): class PolicySetVersion(BaseModel): """PolicySetVersion represents a Terraform Enterprise Policy Set Version""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str source: PolicySetVersionSource | None = Field(None, alias="source") diff --git a/src/pytfe/models/project.py b/src/pytfe/models/project.py index 535a0f59..620a394d 100644 --- a/src/pytfe/models/project.py +++ b/src/pytfe/models/project.py @@ -15,7 +15,9 @@ class Project(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str name: str | None = Field(default=None, alias="name") @@ -124,7 +126,9 @@ class ProjectAddTagBindingsOptions(BaseModel): class ProjectSettingOverwrites(BaseModel): """Options for overwriting project settings""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) execution_mode: bool | None = Field(alias="default-execution-mode") agent_pool: bool | None = Field(alias="default-agent-pool") diff --git a/src/pytfe/models/query_run.py b/src/pytfe/models/query_run.py index 79c84beb..782a998e 100644 --- a/src/pytfe/models/query_run.py +++ b/src/pytfe/models/query_run.py @@ -29,7 +29,7 @@ class QueryRunSource(str, Enum): class QueryRunActions(BaseModel): """Actions available on a query run.""" - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(populate_by_name=True, extra="allow") is_cancelable: bool = Field( ..., alias="is-cancelable", description="Whether the query run can be canceled" @@ -44,7 +44,7 @@ class QueryRunActions(BaseModel): class QueryRunStatusTimestamps(BaseModel): """Timestamps for each status of a query run.""" - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(populate_by_name=True, extra="allow") pending_at: datetime | None = Field( None, alias="pending-at", description="When the query run was created" @@ -78,7 +78,7 @@ class QueryRunVariable(BaseModel): class QueryRun(BaseModel): """Represents a query run in Terraform Enterprise.""" - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(populate_by_name=True, extra="allow") id: str = Field(..., description="The unique identifier for this query run") type: str = Field(default="queries", description="The type of this resource") diff --git a/src/pytfe/models/registry_provider_platform.py b/src/pytfe/models/registry_provider_platform.py index 716ac6e4..d7c58b08 100644 --- a/src/pytfe/models/registry_provider_platform.py +++ b/src/pytfe/models/registry_provider_platform.py @@ -25,7 +25,9 @@ class RegistryProviderPlatformPermissions(BaseModel): """Registry provider platform permissions.""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) can_delete: bool = Field(alias="can-delete") can_upload_asset: bool = Field(alias="can-upload-asset") @@ -34,7 +36,9 @@ class RegistryProviderPlatformPermissions(BaseModel): class RegistryProviderPlatform(BaseModel): """Registry provider platform model.""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str os: str = Field(alias="os", default="") diff --git a/src/pytfe/models/registry_provider_version.py b/src/pytfe/models/registry_provider_version.py index 8ec0d442..fa734927 100644 --- a/src/pytfe/models/registry_provider_version.py +++ b/src/pytfe/models/registry_provider_version.py @@ -27,7 +27,9 @@ class RegistryProviderVersionPermissions(BaseModel): """Registry provider version permissions.""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) can_delete: bool = Field(alias="can-delete") can_upload_asset: bool = Field(alias="can-upload-asset") @@ -36,7 +38,9 @@ class RegistryProviderVersionPermissions(BaseModel): class RegistryProviderVersion(BaseModel): """Registry provider version model.""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str version: str = Field(alias="version", default="") diff --git a/src/pytfe/models/reserved_tag_key.py b/src/pytfe/models/reserved_tag_key.py index 54622e05..dd567a02 100644 --- a/src/pytfe/models/reserved_tag_key.py +++ b/src/pytfe/models/reserved_tag_key.py @@ -11,7 +11,7 @@ class ReservedTagKey(BaseModel): """Represents a reserved tag key in Terraform Enterprise.""" - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(populate_by_name=True, extra="allow") id: str = Field(..., description="The unique identifier for this reserved tag key") type: str = Field( diff --git a/src/pytfe/models/run.py b/src/pytfe/models/run.py index 6da48e22..d5dfc4c9 100644 --- a/src/pytfe/models/run.py +++ b/src/pytfe/models/run.py @@ -96,7 +96,11 @@ class RunOperation(str, Enum): class Run(BaseModel): """Run represents a Terraform Enterprise run.""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + # extra="allow" keeps forward compatibility: undeclared server attributes are + # retained in model_extra rather than silently dropped. See docs/MODELS.md. + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str actions: RunActions | None = Field(None, alias="actions") @@ -147,7 +151,9 @@ class Run(BaseModel): class RunActions(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) is_cancelable: bool = Field(..., alias="is-cancelable") is_confirmable: bool = Field(..., alias="is-confirmable") @@ -156,7 +162,9 @@ class RunActions(BaseModel): class RunPermissions(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) can_apply: bool = Field(..., alias="can-apply") can_cancel: bool = Field(..., alias="can-cancel") @@ -166,7 +174,9 @@ class RunPermissions(BaseModel): class RunStatusTimestamps(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) applied_at: datetime | None = Field(None, alias="applied-at") applying_at: datetime | None = Field(None, alias="applying-at") @@ -207,7 +217,9 @@ class RunVariable(BaseModel): class RunVariableAttr(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) key: str = Field(..., alias="key") value: str = Field(..., alias="value") @@ -216,7 +228,9 @@ class RunVariableAttr(BaseModel): class RunList(BaseModel): """RunList represents a list of runs.""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) items: list[Run] = Field(default_factory=list) current_page: int | None = None @@ -248,7 +262,9 @@ class OrganizationRunList(BaseModel): It differs from the RunList in that it does not include a TotalCount of records in the pagination details """ - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) items: list[Run] = Field(default_factory=list) current_page: int | None = None diff --git a/src/pytfe/models/run_event.py b/src/pytfe/models/run_event.py index bcf7b1b9..af100ee7 100644 --- a/src/pytfe/models/run_event.py +++ b/src/pytfe/models/run_event.py @@ -18,7 +18,9 @@ class RunEventIncludeOpt(str, Enum): class RunEvent(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str action: str | None = Field(None, alias="action") @@ -31,7 +33,9 @@ class RunEvent(BaseModel): class RunEventList(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) items: list[RunEvent] = Field(default_factory=list) current_page: int | None = None diff --git a/src/pytfe/models/run_task_integration.py b/src/pytfe/models/run_task_integration.py index a7e1fec8..8ae7668f 100644 --- a/src/pytfe/models/run_task_integration.py +++ b/src/pytfe/models/run_task_integration.py @@ -22,7 +22,7 @@ class TaskResultCallbackStatus(str, Enum): class TaskResultTag(BaseModel): """Tag attached to a Run Task outcome to enrich the result display in the UI.""" - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(populate_by_name=True, extra="allow") label: str = Field(..., alias="label") level: str | None = Field(None, alias="level") @@ -37,7 +37,7 @@ def _to_payload(self) -> dict[str, Any]: class TaskResultOutcome(BaseModel): """Detailed Run Task outcome.""" - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(populate_by_name=True, extra="allow") outcome_id: str | None = Field(None, alias="outcome-id") description: str | None = Field(None, alias="description") diff --git a/src/pytfe/models/run_task_request.py b/src/pytfe/models/run_task_request.py index 673cbc60..b17d4008 100644 --- a/src/pytfe/models/run_task_request.py +++ b/src/pytfe/models/run_task_request.py @@ -11,7 +11,7 @@ class RunTaskRequestCapabilities(BaseModel): """Defines the capabilities that the caller supports.""" - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(populate_by_name=True, extra="allow") outcomes: bool = Field(..., description="Whether the caller supports outcomes") @@ -22,7 +22,7 @@ class RunTaskRequest(BaseModel): https://developer.hashicorp.com/terraform/enterprise/api-docs/run-tasks/run-tasks-integration#common-properties """ - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(populate_by_name=True, extra="allow") access_token: str = Field( ..., alias="access_token", description="The access token for the run task" diff --git a/src/pytfe/models/ssh_key.py b/src/pytfe/models/ssh_key.py index 52423fb8..fad57691 100644 --- a/src/pytfe/models/ssh_key.py +++ b/src/pytfe/models/ssh_key.py @@ -9,7 +9,7 @@ class SSHKey(BaseModel): """Represents an SSH key in Terraform Enterprise.""" - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(populate_by_name=True, extra="allow") id: str = Field(..., description="The unique identifier for this SSH key") type: str = Field(default="ssh-keys", description="The type of this resource") @@ -49,7 +49,7 @@ class SSHKeyListOptions(BaseModel): class SSHKeyList(BaseModel): """Represents a paginated list of SSH keys.""" - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(populate_by_name=True, extra="allow") items: list[SSHKey] = Field(default_factory=list, description="List of SSH keys") current_page: int | None = Field(None, description="Current page number") diff --git a/src/pytfe/models/stack.py b/src/pytfe/models/stack.py index c6378f17..c2b6d06c 100644 --- a/src/pytfe/models/stack.py +++ b/src/pytfe/models/stack.py @@ -25,7 +25,9 @@ class StackSortColumn(str, Enum): class StackVcsRepo(BaseModel): """StackVCSRepo represents the version control system repository for a stack.""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) identifier: str = Field(alias="identifier") branch: str | None = Field(default=None, alias="branch") @@ -51,7 +53,9 @@ class StackVcsRepoOptions(BaseModel): class Stack(BaseModel): """Stack represents a stack in Terraform Cloud.""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str name: str | None = Field(default=None, alias="name") diff --git a/src/pytfe/models/stack_configuration.py b/src/pytfe/models/stack_configuration.py index b92931a9..4888d534 100644 --- a/src/pytfe/models/stack_configuration.py +++ b/src/pytfe/models/stack_configuration.py @@ -25,7 +25,9 @@ class StackConfigurationStatus(str, Enum): class StackComponent(BaseModel): """StackComponent represents a stack component, specified by configuration""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) name: str = Field(alias="name", default="") correlator: str = Field(alias="correlator", default="") @@ -51,7 +53,9 @@ class StackConfigurationIncludeOps(str, Enum): class StackConfiguration(BaseModel): """StackConfiguration represents a snapshot of a stack's configuration.""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str status: StackConfigurationStatus | None = Field(default=None, alias="status") diff --git a/src/pytfe/models/state_version.py b/src/pytfe/models/state_version.py index dbbe06f2..d5f759ba 100644 --- a/src/pytfe/models/state_version.py +++ b/src/pytfe/models/state_version.py @@ -29,7 +29,9 @@ class StateVersionIncludeOpt(str, Enum): class StateVersion(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str = Field(..., alias="id") created_at: datetime | None = Field(None, alias="created-at") @@ -97,7 +99,9 @@ class StateVersionListOptions(BaseModel): class StateVersionList(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) items: list[StateVersion] = Field(default_factory=list) current_page: int | None = None diff --git a/src/pytfe/models/state_version_output.py b/src/pytfe/models/state_version_output.py index a5fb57c7..3137d4e9 100644 --- a/src/pytfe/models/state_version_output.py +++ b/src/pytfe/models/state_version_output.py @@ -9,7 +9,9 @@ class StateVersionOutput(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str name: str @@ -26,7 +28,9 @@ class StateVersionOutputsListOptions(BaseModel): class StateVersionOutputsList(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) items: list[StateVersionOutput] = Field(default_factory=list) current_page: int | None = None diff --git a/src/pytfe/models/task_result.py b/src/pytfe/models/task_result.py index 3e2af561..0eaaa89f 100644 --- a/src/pytfe/models/task_result.py +++ b/src/pytfe/models/task_result.py @@ -28,7 +28,9 @@ class TaskEnforcementLevel(str, Enum): class TaskResultStatusTimestamps(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) errored_at: datetime | None = Field(None, alias="errored-at") running_at: datetime | None = Field(None, alias="running-at") @@ -38,7 +40,9 @@ class TaskResultStatusTimestamps(BaseModel): class TaskResult(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str diff --git a/src/pytfe/models/task_stage.py b/src/pytfe/models/task_stage.py index a2b76bdc..018858e7 100644 --- a/src/pytfe/models/task_stage.py +++ b/src/pytfe/models/task_stage.py @@ -26,7 +26,9 @@ class TaskStageStatus(str, Enum): class TaskStageStatusTimestamps(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) errored_at: datetime | None = Field(None, alias="errored-at") running_at: datetime | None = Field(None, alias="running-at") @@ -36,7 +38,9 @@ class TaskStageStatusTimestamps(BaseModel): class Permissions(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) can_override_policy: bool | None = Field(None, alias="can-override-policy") can_override_tasks: bool | None = Field(None, alias="can-override-tasks") @@ -44,13 +48,15 @@ class Permissions(BaseModel): class Actions(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) is_overridable: bool | None = Field(None, alias="is-overridable") class TaskStage(BaseModel): - model_config = ConfigDict(populate_by_name=True) + 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 23f7c343..bd0f7b74 100644 --- a/src/pytfe/models/team.py +++ b/src/pytfe/models/team.py @@ -15,7 +15,7 @@ class OrganizationAccess(BaseModel): """Organization access permissions for a team.""" - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(populate_by_name=True, extra="allow") manage_policies: bool = Field(default=False, alias="manage-policies") manage_policy_overrides: bool = Field( @@ -41,7 +41,7 @@ class OrganizationAccess(BaseModel): class TeamPermissions(BaseModel): """Team permissions for the current user.""" - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(populate_by_name=True, extra="allow") can_destroy: bool = Field(alias="can-destroy") can_update_membership: bool = Field(alias="can-update-membership") @@ -50,7 +50,7 @@ class TeamPermissions(BaseModel): class Team(BaseModel): """Represents a Terraform Enterprise team.""" - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(populate_by_name=True, extra="allow") id: str name: str | None = Field(default=None, alias="name") diff --git a/src/pytfe/models/team_project_access.py b/src/pytfe/models/team_project_access.py index d99d9f2d..fc4073e5 100644 --- a/src/pytfe/models/team_project_access.py +++ b/src/pytfe/models/team_project_access.py @@ -47,7 +47,9 @@ class ProjectVariableSetsPermissionType(str, Enum): class TeamProjectAccessProjectPermissions(BaseModel): """ProjectPermissions represents the team's permissions on its project""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) project_settings_permission: ProjectSettingsPermissionType = Field(alias="settings") project_teams_permission: ProjectTeamsPermissionType = Field(alias="teams") @@ -92,7 +94,9 @@ class WorkspaceVariablesPermissionType(str, Enum): class TeamProjectAccessWorkspacePermissions(BaseModel): """WorkspacePermissions represents the team's permission on all workspaces in its project""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) runs: WorkspaceRunsPermissionType | None = Field(default=None, alias="runs") sentinel_mocks: WorkspaceSentinelMocksPermissionType | None = Field( @@ -114,7 +118,9 @@ class TeamProjectAccessWorkspacePermissions(BaseModel): class TeamProjectAccess(BaseModel): """TeamProjectAccess represents a project access for a team""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str access: TeamProjectAccessType | None = Field(default=None, alias="access") diff --git a/src/pytfe/models/team_token.py b/src/pytfe/models/team_token.py index b8d23df7..d250cfce 100644 --- a/src/pytfe/models/team_token.py +++ b/src/pytfe/models/team_token.py @@ -15,7 +15,9 @@ class TeamToken(BaseModel): """TeamToken represents a Terraform Enterprise team token.""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str created_at: datetime | None = Field(default=None, alias="created-at") @@ -49,7 +51,9 @@ class TeamTokenListOptions(BaseModel): class CreatedByChoice(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) organization: Organization | None = None user: User | None = None diff --git a/src/pytfe/models/team_workspace_access.py b/src/pytfe/models/team_workspace_access.py index 8e1593c0..caeab9b4 100644 --- a/src/pytfe/models/team_workspace_access.py +++ b/src/pytfe/models/team_workspace_access.py @@ -43,7 +43,9 @@ class TeamWorkspaceSentinelMocksPermission(str, Enum): class TeamWorkspaceAccess(BaseModel): """A team's access grant on a workspace (`/api/v2/team-workspaces/{id}`).""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str access: TeamWorkspaceAccessType | None = None diff --git a/src/pytfe/models/user.py b/src/pytfe/models/user.py index c72d1075..2ffb43cb 100644 --- a/src/pytfe/models/user.py +++ b/src/pytfe/models/user.py @@ -5,13 +5,17 @@ class TwoFactor(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) enabled: bool = Field(default=False, alias="enabled") verified: bool = Field(default=False, alias="verified") class UserPermissions(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) can_create_organizations: bool = Field( default=False, alias="can-create-organizations" @@ -24,7 +28,9 @@ class UserPermissions(BaseModel): class User(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str = Field(..., alias="id") auth_method: str | None = Field(default=None, alias="auth-method") diff --git a/src/pytfe/models/variable_set.py b/src/pytfe/models/variable_set.py index 32256836..9e89e53c 100644 --- a/src/pytfe/models/variable_set.py +++ b/src/pytfe/models/variable_set.py @@ -33,7 +33,9 @@ class Parent(BaseModel): class VariableSet(BaseModel): """Represents a Terraform Enterprise variable set.""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str | None = None name: str | None = None diff --git a/src/pytfe/models/workspace.py b/src/pytfe/models/workspace.py index 79e5113e..b6c52725 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 .assessment_result import AssessmentResult from .common import EffectiveTagBinding, Tag, TagBinding from .configuration_version import ConfigurationVersion from .data_retention_policy import DataRetentionPolicyChoice @@ -44,13 +45,17 @@ class WorkspaceSource(str, Enum): class WorkspaceActions(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) is_destroyable: bool = Field(default=False, alias="is-destroyable") class WorkspacePermissions(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) can_destroy: bool = Field(default=False, alias="can-destroy") can_force_unlock: bool = Field(default=False, alias="can-force-unlock") @@ -67,14 +72,18 @@ class WorkspacePermissions(BaseModel): class WorkspaceSettingOverwrites(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) execution_mode: bool | None = Field(None, alias="execution-mode") agent_pool: bool | None = Field(None, alias="agent-pool") class WorkspaceOutputs(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str name: str | None = Field(default=None, alias="name") @@ -84,7 +93,9 @@ class WorkspaceOutputs(BaseModel): class LockedByChoice(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) run: Any | None = None user: Any | None = None @@ -92,7 +103,9 @@ class LockedByChoice(BaseModel): class VCSRepo(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) branch: str | None = Field(default=None, alias="branch") display_identifier: str | None = Field(default=None, alias="display-identifier") @@ -112,7 +125,9 @@ class VCSRepo(BaseModel): class Workspace(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str name: str | None = Field(None, alias="name") @@ -127,6 +142,7 @@ class Workspace(BaseModel): auto_destroy_activity_duration: str | None = Field( None, alias="auto-destroy-activity-duration" ) + auto_destroy_status: str | None = Field(None, alias="auto-destroy-status") can_queue_destroy_plan: bool | None = Field(None, alias="can-queue-destroy-plan") created_at: datetime | None = Field(None, alias="created-at") description: str | None = Field(None, alias="description") @@ -137,13 +153,19 @@ class Workspace(BaseModel): inherits_project_auto_destroy: bool | None = Field( None, alias="inherits-project-auto-destroy" ) + last_assessment_result_at: datetime | None = Field( + None, alias="last-assessment-result-at" + ) + latest_change_at: datetime | None = Field(None, alias="latest-change-at") locked: bool | None = Field(None, alias="locked") + locked_reason: str | None = Field(None, alias="locked-reason") migration_environment: str | None = Field(None, alias="migration-environment") no_code_upgrade_available: bool | None = Field( None, alias="no-code-upgrade-available" ) operations: bool | None = Field(None, alias="operations") permissions: WorkspacePermissions | None = Field(None, alias="permissions") + project_remote_state: bool | None = Field(None, alias="project-remote-state") queue_all_runs: bool | None = Field(None, alias="queue-all-runs") speculative_enabled: bool | None = Field(None, alias="speculative-enabled") source: WorkspaceSource | None = Field(None, alias="source") @@ -156,6 +178,10 @@ class Workspace(BaseModel): trigger_prefixes: list[str] = Field(default_factory=list, alias="trigger-prefixes") trigger_patterns: list[str] = Field(default_factory=list, alias="trigger-patterns") vcs_repo: VCSRepo | None = Field(None, alias="vcs-repo") + vcs_repo_identifier: str | None = Field(None, alias="vcs-repo-identifier") + unarchived_workspace_change_requests_count: int | None = Field( + None, alias="unarchived-workspace-change-requests-count" + ) working_directory: str | None = Field(None, alias="working-directory") updated_at: datetime | None = Field(None, alias="updated-at") resource_count: int | None = Field(None, alias="resource-count") @@ -172,7 +198,13 @@ class Workspace(BaseModel): # Relations agent_pool: AgentPool | None = None # AgentPool object current_run: Run | None = None # Run object + # latest-run is the most recent run regardless of status; in current TFC/TFE + # it mirrors current-run (deprecated alias). Kept as a parsed relation, not + # an include option. + latest_run: Run | None = None # Run object current_state_version: StateVersion | None = None # StateVersion object + current_assessment_result: AssessmentResult | None = None + remote_state_consumers: list[Workspace] = Field(default_factory=list) organization: Organization | None = None project: Project | None = None ssh_key: SSHKey | None = None # SSHKey object diff --git a/src/pytfe/models/workspace_run_task.py b/src/pytfe/models/workspace_run_task.py index f775f016..a95c57be 100644 --- a/src/pytfe/models/workspace_run_task.py +++ b/src/pytfe/models/workspace_run_task.py @@ -17,7 +17,9 @@ class WorkspaceRunTask(BaseModel): """Workspace run task model.""" - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) id: str enforcement_level: TaskEnforcementLevel | None = Field( diff --git a/src/pytfe/resources/agent_pools.py b/src/pytfe/resources/agent_pools.py index 726368c7..3e58f4c9 100644 --- a/src/pytfe/resources/agent_pools.py +++ b/src/pytfe/resources/agent_pools.py @@ -16,6 +16,7 @@ from pytfe.models.project import Project from pytfe.models.workspace import Workspace +from .._jsonapi import parse_relationships from ..errors import ( InvalidAgentPoolIDError, InvalidOrgError, @@ -404,41 +405,18 @@ def assign_to_projects( def _parse_agent_pool_from(self, data: dict[str, Any]) -> AgentPool: """Helper method to parse agent pool data from API response.""" attr = data.get("attributes", {}) - relationships = data.get("relationships", {}) attr["id"] = data.get("id") - - # Extract agents count - agents_data = relationships.get("agents", {}).get("data", []) - attr["agents"] = [Agent(id=agent["id"]) for agent in agents_data] - - org_data = relationships.get("organization", {}).get("data") - attr["organization"] = Organization(id=org_data["id"]) if org_data else None - - workspaces_data = relationships.get("workspaces", {}).get("data", []) - attr["workspaces"] = [ - Workspace.model_validate({"id": ws["id"]}) for ws in workspaces_data - ] - - allowed_workspaces_data = relationships.get("allowed-workspaces", {}).get( - "data", [] - ) - attr["allowed_workspaces"] = [ - Workspace.model_validate({"id": ws["id"]}) for ws in allowed_workspaces_data - ] - - excluded_workspaces_data = relationships.get("excluded-workspaces", {}).get( - "data", [] - ) - attr["excluded_workspaces"] = [ - Workspace.model_validate({"id": ws["id"]}) - for ws in excluded_workspaces_data - ] - - allowed_projects_data = relationships.get("allowed-projects", {}).get( - "data", [] + attr.update( + parse_relationships( + data.get("relationships"), + { + "agents": Agent, + "organization": Organization, + "workspaces": Workspace, + "allowed-workspaces": Workspace, + "excluded-workspaces": Workspace, + "allowed-projects": Project, + }, + ) ) - attr["allowed_projects"] = [ - Project.model_validate({"id": proj["id"]}) for proj in allowed_projects_data - ] - return AgentPool.model_validate(attr) diff --git a/src/pytfe/resources/no_code_module.py b/src/pytfe/resources/no_code_module.py index e285b9e2..c889a6d0 100644 --- a/src/pytfe/resources/no_code_module.py +++ b/src/pytfe/resources/no_code_module.py @@ -6,6 +6,7 @@ from collections.abc import Iterator from typing import Any +from .._jsonapi import build_included_index, parse_relationships from ..errors import ( InvalidNoCodeModuleIDError, InvalidOrgError, @@ -94,25 +95,23 @@ def _no_code_module_from( attrs = data.get("attributes") or {} relationships = data.get("relationships") or {} - module = NoCodeModule.model_validate( - { - "id": data.get("id"), - "enabled": attrs.get("enabled"), - "version-pin": attrs.get("version-pin"), - } + module_attrs: dict[str, Any] = { + "id": data.get("id"), + "enabled": attrs.get("enabled"), + "version-pin": attrs.get("version-pin"), + } + module_attrs.update( + parse_relationships( + relationships, + {"organization": Organization, "registry-module": RegistryModule}, + included=included, + ) ) - - org_data = (relationships.get("organization") or {}).get("data") - if org_data and org_data.get("id"): - module.organization = Organization.model_construct(id=org_data["id"]) - - rm_data = (relationships.get("registry-module") or {}).get("data") - if rm_data and rm_data.get("id"): - module.registry_module = RegistryModule.model_construct(id=rm_data["id"]) + module = NoCodeModule.model_validate(module_attrs) vo_rel = (relationships.get("variable-options") or {}).get("data") or [] if vo_rel and included: - index = {(item.get("type"), item.get("id")): item for item in included} + index = build_included_index(included) resolved: list[NoCodeVariableOption] = [] for ref in vo_rel: key = (ref.get("type"), ref.get("id")) @@ -138,20 +137,14 @@ def _workspace_upgrade_from(data: dict[str, Any]) -> WorkspaceUpgrade: attrs = data.get("attributes") or {} relationships = data.get("relationships") or {} - upgrade = WorkspaceUpgrade.model_validate( - { - "id": data.get("id"), - "status": attrs.get("status"), - "plan-url": attrs.get("plan-url"), - "message": attrs.get("message"), - } - ) - - ws_data = (relationships.get("workspace") or {}).get("data") - if ws_data and ws_data.get("id"): - upgrade.workspace = Workspace.model_construct(id=ws_data["id"]) - - return upgrade + upgrade_attrs: dict[str, Any] = { + "id": data.get("id"), + "status": attrs.get("status"), + "plan-url": attrs.get("plan-url"), + "message": attrs.get("message"), + } + upgrade_attrs.update(parse_relationships(relationships, {"workspace": Workspace})) + return WorkspaceUpgrade.model_validate(upgrade_attrs) class NoCodeModules(_Service): diff --git a/src/pytfe/resources/registry_provider_version.py b/src/pytfe/resources/registry_provider_version.py index 03156afe..c523a1f3 100644 --- a/src/pytfe/resources/registry_provider_version.py +++ b/src/pytfe/resources/registry_provider_version.py @@ -6,6 +6,7 @@ from collections.abc import Iterator from typing import Any +from .._jsonapi import parse_relationships from ..errors import ( RequiredPrivateRegistryError, ) @@ -57,26 +58,20 @@ def _registry_provider_version_from( """Parse a registry provider version from API response data.""" attrs = data.get("attributes", {}) - relationships = data.get("relationships", {}) attrs["id"] = data.get("id") - - # Parse relationships as typed stubs - if "registry-provider" in relationships: - rp_data = relationships["registry-provider"].get("data") - if rp_data and rp_data.get("id"): - attrs["registry_provider"] = RegistryProvider.model_construct( - id=rp_data["id"] - ) - - if "platforms" in relationships: - platforms_data = relationships["platforms"].get("data", []) - if platforms_data: - attrs["registry_provider_platforms"] = [ - RegistryProviderPlatform.model_construct(id=p["id"]) - for p in platforms_data - if p.get("id") - ] - + attrs.update( + parse_relationships( + data.get("relationships"), + { + "registry-provider": RegistryProvider, + # wire relation "platforms" maps to the divergent field name + "platforms": ( + "registry_provider_platforms", + RegistryProviderPlatform, + ), + }, + ) + ) return RegistryProviderVersion.model_validate(attrs) def list( diff --git a/src/pytfe/resources/run.py b/src/pytfe/resources/run.py index d827997b..c208787b 100644 --- a/src/pytfe/resources/run.py +++ b/src/pytfe/resources/run.py @@ -6,6 +6,7 @@ from collections.abc import Iterator from typing import Any +from .._jsonapi import RelationMap, parse_relationships from ..errors import ( InvalidOrgError, InvalidRunIDError, @@ -37,48 +38,29 @@ from ..utils import _safe_str, valid_string, valid_string_id from ._base import _Service - -def transform_relationships(relationships: dict) -> Any: - """ - Transform relationships dict to map relationship names to their model objects. - Single IDs become model instances, multiple IDs become lists of model instances. - """ - result = {} - - # Map relationship keys to their model constructors - model_map = { - "apply": Apply, - "configuration-version": ConfigurationVersion, - "cost-estimate": CostEstimate, - "created-by": User, - "confirmed-by": User, - "plan": Plan, - "workspace": Workspace, - "policy-checks": PolicyCheck, - "run-events": RunEvent, - "task-stages": TaskStage, - "comments": Comment, - } - - for key, value in relationships.items(): - data = value.get("data") - - if data is None: - continue - - model_class = model_map.get(key) - if not model_class: - # Unknown relationship type, skip it - continue - - if isinstance(data, list): - # Multiple entries - create list of model instances - result[key] = [model_class(id=item["id"]) for item in data if "id" in item] - elif isinstance(data, dict) and "id" in data: - # Single entry - create model instance - result[key] = model_class(id=data["id"]) - - return result +_RUN_REL_MAP: RelationMap = { + "apply": Apply, + "configuration-version": ConfigurationVersion, + "cost-estimate": CostEstimate, + "created-by": User, + "confirmed-by": User, + "plan": Plan, + "workspace": Workspace, + "policy-checks": PolicyCheck, + "run-events": RunEvent, + "task-stages": TaskStage, + "comments": Comment, +} + + +def _run_from(d: dict[str, Any], included: list[dict[str, Any]] | None = None) -> Run: + """Parse a JSON:API run resource into a Run, hydrating relations.""" + attr = dict(d.get("attributes") or {}) + attr["id"] = _safe_str(d.get("id")) + attr.update( + parse_relationships(d.get("relationships"), _RUN_REL_MAP, included=included) + ) + return Run.model_validate(attr) class Runs(_Service): @@ -91,9 +73,7 @@ def list( params = options.model_dump(by_alias=True) if options else {} path = f"/api/v2/workspaces/{workspace_id}/runs" for item in self._list(path, params=params): - attrs = item.get("attributes", {}) - attrs["id"] = item.get("id") - yield Run.model_validate(attrs) + yield _run_from(item) def list_for_organization( self, organization: str, options: RunListForOrganizationOptions | None = None @@ -106,9 +86,7 @@ def list_for_organization( # meta = jd.get("meta", {}) # pagination = meta.get("pagination", {}) for item in self._list(path, params=params): - attrs = item.get("attributes", {}) - attrs["id"] = item.get("id") - yield Run.model_validate(attrs) + yield _run_from(item) def create(self, options: RunCreateOptions) -> Run: """Create a new run for the given workspace.""" @@ -148,13 +126,7 @@ def create(self, options: RunCreateOptions) -> Run: "/api/v2/runs", json_body=body, ) - d = r.json().get("data", {}) - attrs = d.get("attributes", {}) - relationships = transform_relationships(d.get("relationships", {})) - combined = { - k.replace("-", "_"): v for k, v in {**attrs, **relationships}.items() - } - return Run(id=_safe_str(d.get("id")), **combined) + return _run_from(r.json().get("data", {})) def read(self, run_id: str) -> Run: """Read a run by its ID.""" @@ -174,13 +146,8 @@ def read_with_options( f"/api/v2/runs/{run_id}", params=params, ) - d = r.json().get("data", {}) - attrs = d.get("attributes", {}) - relationships = transform_relationships(d.get("relationships", {})) - combined = { - k.replace("-", "_"): v for k, v in {**attrs, **relationships}.items() - } - return Run(id=_safe_str(d.get("id")), **combined) + payload = r.json() + return _run_from(payload.get("data", {}), payload.get("included")) def apply(self, run_id: str, options: RunApplyOptions | None = None) -> None: """Apply a run by its ID.""" diff --git a/src/pytfe/resources/stack.py b/src/pytfe/resources/stack.py index f683fd17..bf9c9977 100644 --- a/src/pytfe/resources/stack.py +++ b/src/pytfe/resources/stack.py @@ -10,6 +10,7 @@ Project, ) +from .._jsonapi import parse_relationships from ..models.stack import ( Stack, StackCreateOptions, @@ -127,16 +128,14 @@ def fetch_latest_from_vcs(self, stack_id: str) -> Stack: def _stack_from(self, data: dict) -> Stack: attrs = data.get("attributes", {}) attrs["id"] = data.get("id") - relationships = data.get("relationships", {}) vcs_repo_raw = attrs.get("vcs-repo") - if vcs_repo_raw: - attrs["vcs_repo"] = StackVcsRepo.model_validate(vcs_repo_raw) - else: - attrs["vcs_repo"] = None - project_data = relationships.get("project", {}).get("data", {}) - agent_pool_data = relationships.get("agent-pool", {}).get("data", {}) - if isinstance(project_data, dict) and project_data.get("id"): - attrs["project"] = Project(id=project_data["id"]) - if isinstance(agent_pool_data, dict) and agent_pool_data.get("id"): - attrs["agent_pool"] = AgentPool(id=agent_pool_data["id"]) + attrs["vcs_repo"] = ( + StackVcsRepo.model_validate(vcs_repo_raw) if vcs_repo_raw else None + ) + attrs.update( + parse_relationships( + data.get("relationships"), + {"project": Project, "agent-pool": AgentPool}, + ) + ) return Stack.model_validate(attrs) diff --git a/src/pytfe/resources/stack_configuration.py b/src/pytfe/resources/stack_configuration.py index 3b672b23..be9e849d 100644 --- a/src/pytfe/resources/stack_configuration.py +++ b/src/pytfe/resources/stack_configuration.py @@ -8,6 +8,7 @@ from pytfe.models.configuration_version import IngressAttributes +from .._jsonapi import parse_relationships from ..models.stack import Stack from ..models.stack_configuration import ( StackConfiguration, @@ -82,17 +83,10 @@ def _stack_configuration_from(self, data: dict[str, Any]) -> StackConfiguration: """Parse a StackConfiguration from API response data.""" attrs = dict(data.get("attributes", {})) attrs["id"] = data.get("id") - relationships = data.get("relationships", {}) - - stack_data = relationships.get("stack", {}).get("data") - if stack_data and stack_data.get("id"): - attrs["stack"] = Stack.model_validate({"id": stack_data["id"]}) - ingress_attributes_data = relationships.get("ingress-attributes", {}).get( - "data" - ) - if ingress_attributes_data and ingress_attributes_data.get("id"): - attrs["ingress_attributes"] = IngressAttributes.model_validate( - {"id": ingress_attributes_data["id"]} + attrs.update( + parse_relationships( + data.get("relationships"), + {"stack": Stack, "ingress-attributes": IngressAttributes}, ) - + ) return StackConfiguration.model_validate(attrs) diff --git a/src/pytfe/resources/task_stage.py b/src/pytfe/resources/task_stage.py index 526c035e..d6d131b6 100644 --- a/src/pytfe/resources/task_stage.py +++ b/src/pytfe/resources/task_stage.py @@ -6,6 +6,7 @@ from collections.abc import Iterator from typing import Any +from .._jsonapi import parse_relationships from ..errors import InvalidRunIDError, InvalidTaskStageIDError from ..models.policy_evaluation import PolicyEvaluation from ..models.run import Run @@ -20,38 +21,21 @@ class TaskStages(_Service): def _parse_task_stage(self, data: dict[str, Any]) -> TaskStage: attributes = data.get("attributes", {}) - attributes["id"] = _safe_str(data.get("id")) - - relationships = data.get("relationships", {}) - - run_data = relationships.get("run", {}).get("data") - if run_data: - attributes["run"] = Run.model_construct(id=run_data["id"]) - - task_results_data = relationships.get("task-results", {}).get( - "data", - [], + attributes.update( + parse_relationships( + data.get("relationships"), + { + "run": Run, + "task-results": TaskResult, + "policy-evaluations": PolicyEvaluation, + }, + ) ) - - attributes["task-results"] = [ - TaskResult.model_construct(id=task_result["id"]) - for task_result in task_results_data - ] - - policy_evaluations_data = relationships.get( - "policy-evaluations", - {}, - ).get( - "data", - [], - ) - - attributes["policy-evaluations"] = [ - PolicyEvaluation.model_construct(id=policy_evaluation["id"]) - for policy_evaluation in policy_evaluations_data - ] - + # 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 TaskStage.model_validate(attributes) # Read diff --git a/src/pytfe/resources/team.py b/src/pytfe/resources/team.py index 9996bcff..95a993ab 100644 --- a/src/pytfe/resources/team.py +++ b/src/pytfe/resources/team.py @@ -3,6 +3,7 @@ import builtins from collections.abc import Iterator +from .._jsonapi import parse_relationships from ..errors import ( ERR_INVALID_ORG, InvalidTeamIDError, @@ -40,23 +41,12 @@ def list( def _team_from(self, data: dict) -> Team: attrs = data.get("attributes", {}) attrs["id"] = data.get("id") - - relationships = data.get("relationships", {}) - - users_data = relationships.get("users", {}).get("data", []) - attrs["users"] = [ - User.model_validate({"id": user_data.get("id")}) - for user_data in users_data - if user_data.get("id") - ] - attrs["organization-memberships"] = [ - OrganizationMembership.model_validate({"id": om_data.get("id")}) - for om_data in relationships.get("organization-memberships", {}).get( - "data", [] + attrs.update( + parse_relationships( + data.get("relationships"), + {"users": User, "organization-memberships": OrganizationMembership}, ) - if om_data.get("id") - ] - + ) return Team.model_validate(attrs) def create(self, organization: str, options: TeamCreateOptions) -> Team: diff --git a/src/pytfe/resources/team_token.py b/src/pytfe/resources/team_token.py index fd971a68..369e7dfc 100644 --- a/src/pytfe/resources/team_token.py +++ b/src/pytfe/resources/team_token.py @@ -6,6 +6,7 @@ from collections.abc import Iterator from typing import Any +from .._jsonapi import parse_relationships from ..errors import InvalidOrgError, InvalidTeamIDError, InvalidTokenIDError from ..models.organization import Organization from ..models.team import Team @@ -127,11 +128,8 @@ def _team_token_from(self, data: dict[str, Any]) -> TeamToken: attrs["id"] = data.get("id") relationships = data.get("relationships", {}) - team_data = relationships.get("team", {}).get("data") - if team_data and team_data.get("id"): - attrs["team"] = Team.model_construct( - id=team_data["id"], - ) + # Simple relations via the shared helper; created-by is polymorphic below. + attrs.update(parse_relationships(relationships, {"team": Team})) created_by_data = relationships.get("created-by", {}).get("data") if created_by_data and created_by_data.get("id"): diff --git a/src/pytfe/resources/workspaces.py b/src/pytfe/resources/workspaces.py index 3fe7caac..c5c16f2c 100644 --- a/src/pytfe/resources/workspaces.py +++ b/src/pytfe/resources/workspaces.py @@ -9,6 +9,7 @@ from pytfe.models.ssh_key import SSHKey +from .._jsonapi import RelationMap, parse_relationships from ..errors import ( InvalidOrgError, InvalidSSHKeyIDError, @@ -72,6 +73,25 @@ ) from ._base import _Service +# 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. +_WORKSPACE_REL_MAP: RelationMap = { + "organization": Organization, + "project": Project, + "ssh-key": SSHKey, + "agent-pool": AgentPool, + "current-run": Run, + "latest-run": Run, + "current-configuration-version": ConfigurationVersion, + "current-state-version": StateVersion, + "current-assessment-result": AssessmentResult, + "remote-state-consumers": Workspace, + "vars": ("variables", Variable), # wire name diverges from attr +} + def _em_safe(v: Any) -> ExecutionMode | None: # Only accept strings; map to enum if known, else None @@ -81,8 +101,10 @@ def _em_safe(v: Any) -> ExecutionMode | None: return result if isinstance(result, ExecutionMode) else None -def _ws_from(d: dict[str, Any]) -> Workspace: - attr: dict[str, Any] = d.get("attributes", {}) or {} +def _ws_from( + d: dict[str, Any], included: list[dict[str, Any]] | None = None +) -> Workspace: + attr: dict[str, Any] = dict(d.get("attributes") or {}) relationships: dict[str, Any] = d.get("relationships", {}) or {} # Optional fields @@ -119,7 +141,7 @@ def _ws_from(d: dict[str, Any]) -> Workspace: elif lb_data.get("type") == "teams": locked_by = LockedByChoice.model_validate({"team": lb_data.get("id")}) - # Map outputs + # 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", []): @@ -173,48 +195,21 @@ def _ws_from(d: dict[str, Any]) -> Workspace: ) attr["id"] = d.get("id") - attr["execution_mode"] = em + # Overwrite the raw wire string with the coerced enum (unknown values -> None). + attr["execution-mode"] = em attr["actions"] = actions attr["permissions"] = permissions - attr["setting_overwrites"] = setting_overwrites + # Use alias keys consistently so the pre-built objects overwrite the raw wire + # dicts rather than leaving a duplicate that extra="allow" would leak. + attr["setting-overwrites"] = setting_overwrites attr["vcs-repo"] = vcs_repo - # Add parsed relations - if relationships.get("organization", {}).get("data"): - attr["organization"] = Organization.model_validate( - {"id": relationships["organization"]["data"].get("id")} - ) - if relationships.get("project", {}).get("data"): - attr["project"] = Project.model_validate( - {"id": relationships["project"]["data"].get("id")} - ) - if relationships.get("ssh-key", {}).get("data"): - attr["ssh_key"] = SSHKey.model_validate( - {"id": relationships["ssh-key"]["data"].get("id")} - ) - if relationships.get("agent-pool", {}).get("data"): - attr["agent_pool"] = AgentPool.model_validate( - {"id": relationships["agent-pool"]["data"].get("id")} - ) - if relationships.get("current-run", {}).get("data"): - attr["current_run"] = Run.model_validate( - {"id": relationships["current-run"]["data"].get("id")} - ) - if relationships.get("current-configuration-version", {}).get("data"): - attr["current_configuration_version"] = ConfigurationVersion.model_validate( - {"id": relationships["current-configuration-version"]["data"].get("id")} - ) - if relationships.get("vars", {}).get("data"): - attr["variables"] = [ - Variable.model_validate({"id": item.get("id")}) - for item in relationships["vars"]["data"] - if item.get("id") - ] - if relationships.get("current-state-version", {}).get("data"): - attr["current_state_version"] = StateVersion.model_validate( - {"id": relationships["current-state-version"]["data"].get("id")} - ) + # Generic relations: declarative map + optional ``included`` hydration. + attr.update( + parse_relationships(relationships, _WORKSPACE_REL_MAP, included=included) + ) + # Special-case relations that don't fit the generic (attr, Model) map. attr["outputs"] = outputs attr["locked_by"] = locked_by attr["data_retention_policy_choice"] = data_retention_policy_choice @@ -280,7 +275,8 @@ def read_with_options( f"/api/v2/organizations/{organization}/workspaces/{workspace}", params=params, ) - ws = _ws_from(r.json()["data"]) + payload = r.json() + ws = _ws_from(payload["data"], payload.get("included")) ws.data_retention_policy = ( ws.data_retention_policy_choice.convert_to_legacy_struct() if ws.data_retention_policy_choice @@ -303,7 +299,8 @@ def read_by_id_with_options( if options.include: params["include"] = ",".join([i.value for i in options.include]) r = self.t.request("GET", f"/api/v2/workspaces/{workspace_id}", params=params) - ws = _ws_from(r.json()["data"]) + payload = r.json() + ws = _ws_from(payload["data"], payload.get("included")) if ws.data_retention_policy_choice is not None: ws.data_retention_policy = ( ws.data_retention_policy_choice.convert_to_legacy_struct() diff --git a/tests/units/test_workspace_jsonapi.py b/tests/units/test_workspace_jsonapi.py new file mode 100644 index 00000000..af6dc528 --- /dev/null +++ b/tests/units/test_workspace_jsonapi.py @@ -0,0 +1,131 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from datetime import datetime + +from src.pytfe._jsonapi import build_included_index, parse_relationships +from src.pytfe.models.run import Run, RunStatus +from src.pytfe.resources.workspaces import _ws_from + + +def _ws_payload(**overrides): + attrs = { + "name": "demo", + "latest-change-at": "2026-05-28T16:35:06.718Z", + "last-assessment-result-at": None, + "locked-reason": "", + "project-remote-state": False, + "unarchived-workspace-change-requests-count": 0, + "workspace-kpis-runs-count": 3, + } + attrs.update(overrides.pop("attributes", {})) + data = {"id": "ws-1", "type": "workspaces", "attributes": attrs} + data["relationships"] = overrides.pop("relationships", {}) + return data + + +class TestNewTypedFields: + def test_new_attributes_parse_with_correct_types(self): + ws = _ws_from(_ws_payload()) + assert isinstance(ws.latest_change_at, datetime) + assert ws.last_assessment_result_at is None + assert ws.locked_reason == "" + assert ws.project_remote_state is False + assert ws.unarchived_workspace_change_requests_count == 0 + # aliased KPI field still maps despite the divergent snake/hyphen names + assert ws.runs_count == 3 + + +class TestForwardCompat: + def test_unknown_attribute_survives_in_model_extra(self): + ws = _ws_from(_ws_payload(attributes={"future-field": "keepme"})) + assert ws.model_extra is not None + assert ws.model_extra.get("future-field") == "keepme" + + def test_no_spurious_extra_for_known_nested_objects(self): + ws = _ws_from( + _ws_payload(attributes={"setting-overwrites": {"execution-mode": True}}) + ) + # setting_overwrites is typed, must not leak into model_extra + assert "setting_overwrites" not in (ws.model_extra or {}) + assert ws.setting_overwrites is not None + assert ws.setting_overwrites.execution_mode is True + + +class TestRelations: + def test_latest_run_relation_populates(self): + rels = { + "current-run": {"data": {"id": "run-x", "type": "runs"}}, + "latest-run": {"data": {"id": "run-x", "type": "runs"}}, + } + ws = _ws_from(_ws_payload(relationships=rels)) + assert ws.current_run is not None and ws.latest_run is not None + # latest-run mirrors current-run (deprecated alias) + assert ws.latest_run.id == ws.current_run.id == "run-x" + + def test_remote_state_consumers_list_relation(self): + rels = { + "remote-state-consumers": { + "data": [ + {"id": "ws-a", "type": "workspaces"}, + {"id": "ws-b", "type": "workspaces"}, + ] + } + } + ws = _ws_from(_ws_payload(relationships=rels)) + assert [c.id for c in ws.remote_state_consumers] == ["ws-a", "ws-b"] + + def test_null_relation_leaves_default(self): + ws = _ws_from(_ws_payload(relationships={"current-run": {"data": None}})) + assert ws.current_run is None + + +class TestIncludedHydration: + def test_included_hydrates_full_object(self): + rels = {"current-run": {"data": {"id": "run-x", "type": "runs"}}} + included = [ + { + "id": "run-x", + "type": "runs", + "attributes": {"status": "applied", "has-changes": True}, + } + ] + ws = _ws_from(_ws_payload(relationships=rels), included) + assert ws.current_run.status == RunStatus.Run_Applied + assert ws.current_run.has_changes is True + + def test_without_included_falls_back_to_stub(self): + rels = {"current-run": {"data": {"id": "run-x", "type": "runs"}}} + ws = _ws_from(_ws_payload(relationships=rels), included=None) + assert ws.current_run.id == "run-x" + assert ws.current_run.status is None + + +class TestSharedHelper: + def test_build_included_index_keys_by_type_and_id(self): + idx = build_included_index([{"type": "runs", "id": "run-x", "attributes": {}}]) + assert ("runs", "run-x") in idx + + def test_build_included_index_dedupes_first_wins(self): + idx = build_included_index( + [ + {"type": "runs", "id": "run-x", "attributes": {"status": "applied"}}, + {"type": "runs", "id": "run-x", "attributes": {"status": "errored"}}, + ] + ) + assert idx[("runs", "run-x")]["attributes"]["status"] == "applied" + + def test_parse_relationships_skips_unmapped_and_null(self): + rel_map = {"current-run": ("current_run", Run)} + out = parse_relationships( + { + "current-run": {"data": {"id": "run-x", "type": "runs"}}, + "unmapped": {"data": {"id": "x", "type": "y"}}, + "nullable": {"data": None}, + }, + rel_map, + ) + assert set(out) == {"current_run"} + assert out["current_run"].id == "run-x"