Skip to content

Commit 3d54232

Browse files
authored
Merge branch 'main' into feature-oidc
2 parents 588bfc1 + b7b37f4 commit 3d54232

55 files changed

Lines changed: 4822 additions & 69 deletions

Some content is hidden

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

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ These are mistakes a competent Python developer would make if they hadn't read t
7373
- **Don't catch `httpx` errors directly.** The transport already translates them into `TFEError` subclasses. Catching `httpx.HTTPError` in a resource means the typed error never propagates.
7474
- **Always send the bearer token, even to absolute URLs returned by the API.** Endpoints like `hosted_state_download_url`, `hosted_state_upload_url`, plan `json-output`, and apply `errored-state` redirect to `archivist.terraform.io` — which is HashiCorp infrastructure that *requires* the bearer. go-tfe does the same (see `state_version.go::Download` + `tfe.go::NewRequest`). Stripping the bearer breaks downstream consumers (notably the Ansible collection's statefile + dynamic-inventory flows). `HTTPTransport.request` accepts `include_auth=False` only as an opt-out for the hypothetical case of calling a genuinely non-HashiCorp host; do not use it for Archivist URLs.
7575
- **Don't write a custom page loop.** `self._list(path, params=...)` handles pagination + non-paginated endpoints transparently. Rolling your own loop will diverge from the rest of the codebase.
76+
- **Disable pagination for endpoints that ignore page params.** A few endpoints return the *whole* collection on every request and ignore `page[number]`/`page[size]` (workspace `/vars` and `/all-vars`). Call `self._list(path, params=..., paginated=False)` for those — otherwise they infinite-loop once the collection reaches the page size (the [#181](https://github.com/hashicorp/python-tfe/issues/181) bug). See [ITERATORS.md](docs/ITERATORS.md).
7677
- **Don't reuse generators.** Iterators returned by `list_*` are single-use. If you need to traverse twice, `materialized = list(client.foo.list_bars(...))` first.
7778
- **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.
7879
- **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.

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,20 @@
11
# Unreleased
22
# v1.1.0
33

4+
## Features
5+
6+
### TFE admin identity (SAML / SCIM)
7+
* Added ``client.admin`` nested namespace exposing three TFE-only services: ``client.admin.saml_settings`` (read, update, revoke_idp_cert), ``client.admin.scim_settings`` (read, update, delete), and ``client.admin.scim_tokens`` (list, create, read, delete). All endpoints return ``pytfe.errors.NotFound`` on HCP Terraform (SaaS) — verified live against ``app.terraform.io``.
8+
* Added models: ``AdminSAMLSettings`` / ``AdminSAMLSettingsUpdateOptions``, ``AdminSCIMSettings`` / ``AdminSCIMSettingsUpdateOptions``, ``AdminSCIMToken`` / ``AdminSCIMTokenCreateOptions``, plus ``SAMLProviderType`` and ``SAMLSignatureMethod`` enums.
9+
* ``AdminSCIMSettingsUpdateOptions`` distinguishes "field unset" from "field explicitly set to None" for ``site_admin_group_scim_id``. Pass ``None`` to send JSON ``null`` (unlinking the SCIM site-admin group); omit the kwarg entirely to leave the server value untouched. The omit-vs-explicit-null distinction is preserved end-to-end via a custom ``to_payload()`` that inspects Pydantic's ``model_fields_set``.
10+
* Added typed exceptions ``InvalidSAMLProviderTypeError``, ``InvalidSCIMTokenIDError``, ``RequiredSCIMTokenDescriptionError``.
11+
* The transport-level redacting logger now redacts the wire-format ``private-key`` field (with hyphen) in addition to the existing ``private_key`` (with underscore), so SAML SP private keys cannot leak via ``PYTFE_LOG=debug``. X.509 certificate fields (``idp-cert``, ``certificate``, ``old-idp-cert``) are intentionally NOT redacted because they're public material by design.
12+
13+
### GitHub App installation discovery
14+
* Added ``client.github_app_installations`` resource with ``list`` (supports ``filter[name]`` and ``filter[installation_id]``) and ``read`` methods for looking up GitHub App installations the authenticated user can see. Returns ``GitHubAppInstallation`` records carrying both the HCP-side ``id`` (``ghain-...``) and the GitHub-side numeric ``installation_id``. The actual GitHub App authorisation flow happens through the HCP Terraform UI; this resource is the discovery surface workspace/stack/registry-module VCS configuration consumes.
15+
* Added model ``GitHubAppInstallation``, ``GitHubAppInstallationListOptions``, ``GitHubAppInstallationType``.
16+
* Added typed exception ``InvalidGitHubAppInstallationIDError``.
17+
418
### HYOK OIDC Configurations
519
* Added aws_oidc_configurations, azure_oidc_configurations, gcp_oidc_configurations, and vault_oidc_configurations resources with create, read, update, and delete methods for Hold-Your-Own-Key OIDC configuration records. All four hit a single polymorphic HCP endpoint (POST /organizations/{org}/oidc-configurations for create, /oidc-configurations/{id} for read/update/delete) dispatched by JSON:API data.type, matching the structure used by go-tfe and the terraform-tfe provider.
620
* Added typed models per provider: AWSOIDCConfiguration / AzureOIDCConfiguration / GCPOIDCConfiguration / VaultOIDCConfiguration plus matching CreateOptions and UpdateOptions for each.
@@ -9,6 +23,12 @@
923
* Added InvalidOIDCConfigurationIDError typed exception.
1024
* These resources require HYOK / Premium entitlement on the organization; calls against a non-HYOK org return NotFound. The SDK manages only the HCP-side configuration record — the cloud-side trust resources (IAM role, Azure federated credential, GCP workload identity pool, Vault JWT auth method) still need to be provisioned separately.
1125

26+
## Bug Fixes
27+
28+
### Pagination
29+
* Fixed `list_*` infinite-looping for API call which are non paginated, so they are now fetched with a single request. The generic list helper also treats any response without `meta.pagination` as a single complete page, preventing the same loop on other non-paginated endpoints. [#181](https://github.com/hashicorp/python-tfe/issues/181)
30+
31+
1232
# Released
1333
# v1.0.0
1434

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ and upstream HCP Terraform API docs.
165165
|---|---|
166166
| Configure the SDK | [Authentication](./docs/authentication.md), [Pagination](./docs/pagination.md), [Logging](./docs/LOGGING.md) |
167167
| API guides | [API index](./docs/api/index.md), [Workspaces](./docs/api/workspaces.md), [Runs/plans/applies](./docs/api/runs-plans-applies.md), [State versions](./docs/api/state-versions.md) |
168-
| Scenario guides | [API-driven run](./docs/scenarios/api-driven-run.md), [State management](./docs/scenarios/state-management.md), [Migrate workspaces and state](./docs/scenarios/migrate-workspaces-and-state.md), [Team access onboarding](./docs/scenarios/team-access-onboarding.md), [No-code provisioning](./docs/scenarios/no-code-provisioning.md), [OIDC dynamic credentials](./docs/scenarios/oidc-dynamic-credentials.md) |
168+
| Scenario guides | [API-driven run](./docs/scenarios/api-driven-run.md), [State management](./docs/scenarios/state-management.md), [Migrate workspaces and state](./docs/scenarios/migrate-workspaces-and-state.md), [Team access onboarding](./docs/scenarios/team-access-onboarding.md), [No-code provisioning](./docs/scenarios/no-code-provisioning.md), [TFE identity bootstrap](./docs/scenarios/tfe-identity-bootstrap.md), [TFE admin bootstrap](./docs/scenarios/tfe-admin-bootstrap.md), [OIDC dynamic credentials](./docs/scenarios/oidc-dynamic-credentials.md) |
169169
| Operations guides | [Troubleshooting](./docs/troubleshooting.md), [Errors](./docs/errors.md), [Terraform Enterprise](./docs/terraform-enterprise.md) |
170170
| Contribute to the SDK | [CONTRIBUTING](./docs/CONTRIBUTING.md), [ITERATORS](./docs/ITERATORS.md), [MODELS](./docs/MODELS.md), [RESOURCE](./docs/RESOURCE.md) |
171171

docs/ITERATORS.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,23 @@ def list(
5656
yield self._workspace_from(item)
5757
```
5858

59-
That's it. `self._list()` lives in `_base.py` and handles `page[number]` / `page[size]` and follow-through automatically. It is also robust to endpoints that **don't paginate** — if the response has no pagination metadata and the returned data is smaller than the requested page size, the helper just breaks after one round-trip. So you do not need a different code path for relationship reads like `GET /workspaces/{id}/tag-bindings` (single response) versus list endpoints like `GET /organizations/{org}/workspaces` (paginated). The same `for item in self._list(path): yield ...` works for both.
59+
That's it. `self._list()` lives in `_base.py` and handles `page[number]` / `page[size]` and follow-through automatically. When a response carries no `meta.pagination` block, `_list` treats it as a single complete page and stops after one round-trip — so the same `for item in self._list(path): yield ...` works for ordinary paginated endpoints (`GET /organizations/{org}/workspaces`) and for single-response relationship reads (`GET /workspaces/{id}/tag-bindings`) alike.
60+
61+
### Endpoints that ignore pagination entirely — pass `paginated=False`
62+
63+
A few HCP Terraform endpoints return the **whole** collection on every request and ignore `page[number]` / `page[size]`. The workspace **`/vars`** and **`/all-vars`** endpoints are the known ones. For these you must opt out of the page loop explicitly:
64+
65+
```python
66+
# variable.py — /vars is not paginated
67+
for item in self._list(path, params=params, paginated=False):
68+
yield self._variable_from(item)
69+
```
70+
71+
With `paginated=False`, `_list` issues exactly one request and yields every row.
72+
73+
Why this matters: skipping the flag on such an endpoint with **`page_size` rows (default 100)** used to spin forever — the helper saw a "full" page, asked for page 2, got the *same* full set back (the endpoint ignored the page param), and re-yielded it, indefinitely. That was the root cause of [#181](https://github.com/hashicorp/python-tfe/issues/181). The generic "no `meta.pagination` ⇒ single page" rule now catches this as a safety net, but **still set `paginated=False`** on a known non-paginated endpoint: it documents intent and avoids sending meaningless page params.
74+
75+
> Rule of thumb: if the endpoint returns no `meta.pagination` and ignores `page[size]` (check go-tfe or the API docs — it returns the full collection in one shot), pass `paginated=False`.
6076
6177

6278
### Note on lazy validation
@@ -131,7 +147,7 @@ def list_versions(
131147

132148
`registry_module.list_versions` is the only method in the codebase that does this. Add a docstring note explaining the reason if you find yourself reaching for this pattern, so future readers don't mistake it for something to copy.
133149

134-
Do **not** reach for `iter(list)` just because the endpoint is non-paginated. Use `self._list()` for those — that's the convention.
150+
Do **not** reach for `iter(list)` just because the endpoint is non-paginated. Use `self._list()` for those — that's the convention (with `paginated=False` if the endpoint ignores `page[size]` and returns the full set, as described above).
135151

136152
### Shape that does **not** match the convention (don't do this)
137153

@@ -186,6 +202,7 @@ Don't assert `isinstance(result, list)` against the raw return — that asserts
186202
- [ ] Every `list*` method returns `Iterator[X]`, not `list[X]` or `Iterable[X]`
187203
- [ ] `Iterator` is imported from `collections.abc`, not `typing`
188204
- [ ] The body uses the canonical `for item in self._list(path, params=params): yield ...` pattern — including for non-paginated single-shot endpoints
205+
- [ ] Endpoints that ignore `page[size]` and return the whole collection (e.g. workspace `/vars`, `/all-vars`) pass `paginated=False` to `self._list(...)` — otherwise they infinite-loop at ≥ 100 rows (see [#181](https://github.com/hashicorp/python-tfe/issues/181))
189206
- [ ] Hand-rolled `iter(materialized_list)` only appears if the method has a try/except fallback to a different endpoint (extremely rare — has a docstring note explaining why)
190207
- [ ] If a class defines `def list(...)`, later annotations in that class avoid bare `list[...]` so mypy does not resolve `list` to the method
191208
- [ ] Examples that call the method use `list(client.foo.list_bars(...))` (or stream with a `for` loop) — never assume list semantics on the bare return

docs/MODELS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ class Run(BaseModel):
5959
Rules:
6060

6161
- **Every multi-word JSON:API attribute** gets an alias. Don't try to invent a snake_case-to-hyphen mapper — be explicit per field.
62-
- **Page params** use the JSON:API square-bracket form: `Field(None, alias="page[number]")`, `Field(None, alias="page[size]")`.
62+
- **Page params** use the JSON:API square-bracket form: `Field(None, alias="page[number]")`, `Field(None, alias="page[size]")`. Note that a few endpoints (workspace `/vars`, `/all-vars`) are not paginated and ignore these — their resource methods call `self._list(..., paginated=False)`, so a `page_size` field on those options models would be a no-op. See [ITERATORS.md](ITERATORS.md).
6363
- **Filter params** use the same convention: `Field(None, alias="filter[workspace][name]")`.
6464
- **`include`** is a comma-separated string on the wire but exposed as `list[SomeEnum] | None` in Python; the resource layer dumps options with `mode="json"` and joins the resulting values (`",".join(params["include"])`). See the `policy_set.read_with_options` pattern.
6565

0 commit comments

Comments
 (0)