diff --git a/AGENTS.md b/AGENTS.md index cc2403b8..834adb9e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,7 +115,7 @@ This is the workflow that produces low-friction reviews. Follow it. ## Style -The codebase uses [ruff](https://docs.astral.sh/ruff/) for both formatting and linting and [mypy](https://mypy.readthedocs.io/) for type checking. Type hints are required on every public method's signature. Docstrings are required on every public method — keep them to one or two lines unless the behavior is genuinely non-obvious. +The codebase uses [ruff](https://docs.astral.sh/ruff/) for both formatting and linting and [mypy](https://mypy.readthedocs.io/) for type checking. Type hints are required on every public method's signature. **Public resource methods carry a Google-style docstring** — a one-line summary plus `Args:`, `Returns:`, `Raises:`, and `Example:` sections *as applicable* (omit `Args:` for a no-argument method; omit `Raises:` when the method raises nothing, e.g. a pure local computation) — written for *consumers* and the AI coding assistants that read them via the language server. Accuracy comes first: under `Raises:` list only the exceptions the method body itself raises (not those raised by an options model's validators); encode return gotchas in `Returns:` (single-use `Iterator`, raw `bytes` for blob downloads that follow a redirect, `None` for `204`s); and make every `Example:` a real, runnable call using the correct `client.` name and a real `*Options` class. Internal/private helpers stay terse (one line, or none when obvious). Comments are minimal by design. A comment should explain *why* something non-obvious is true, not *what* the code does. The names and types should be enough to convey "what". diff --git a/CHANGELOG.md b/CHANGELOG.md index 62a9ffbf..e4aed710 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,12 @@ # Unreleased +# Released +# v1.2.0 + ## Enhancements ### Relationships Related data is now a complete, first-class part of every response. Before, `?include=` often did not actually fill the related fields it returned, and anything the SDK did not model as a typed field was dropped on the floor. Now the full set of related resources the API hands back is always available to you: typed where pytfe models it, raw where it does not. The practical win is that **you are no longer limited to the relationships pytfe has added typed support for.** You can read any related resource in a response without dropping to manual HTTP or waiting for a new SDK release. - * `?include=` now fills in related data. When the SDK models a relation as a typed field (for example `workspace.outputs`, `policy_set.current_version`, `organization_membership.user`, `run_event.actor`), passing `?include=` fills that field with the real record instead of an id-only stub. * Relations the SDK does **not** model are no longer lost. Every top-level resource model now derives from a new `pytfe.models.TFEModel` base and gains read-only accessors for the raw JSON:API data the API returned: `model.relationships`, `model.included`, `model.related(name)`, `model.included_by(type, id)`, and the `model.has_relationships` and `model.has_included` flags. So when a relation has no typed field of its own (for example an organization's `subscription`, or a workspace `readme`), `?include=` still returns it and you reach it with `model.related("subscription")` or `model.included_by(type, id)`. These accessors are read-only extras that never appear in `model_dump()` or affect equality, so this is additive and non-breaking. List endpoints expose the relationship refs but do not yet fill `included`. See [docs/related-resources.md](docs/related-resources.md) for the per-resource table and a "typed field vs raw accessor" guide. * Added `?include=` support to three reads that previously had no include option, matching the HCP Terraform API: @@ -12,6 +14,51 @@ Related data is now a complete, first-class part of every response. Before, `?in * `task_stages.read(task_stage_id, TaskStageReadOptions(include=[...]))`: `run`, `run.workspace`, `task-results`, `policy-evaluations`. * `organizations.read(name, OrganizationReadOptions(include=[...]))`: `subscription`. The new `options` argument is optional, so existing calls are unchanged. +### New resources +* Added `client.subscriptions` — read an organization's subscription (HCP Terraform only). `read_for_organization(org)` (`GET /organizations/{org}/subscription`) and `read(id)` (`GET /subscriptions/{id}`). The linked feature set is hydrated into `.included` and reachable via `subscription.related("feature-set")`. New model: `Subscription`. New error: `InvalidSubscriptionIDError`. +* Added `client.invoices` — read an organization's billing invoices (HCP Terraform only). `list(org)` (cursor-paginated via `meta.continuation`, fixed page size 10) and `read_next(org)` (the upcoming invoice, or `None` when there is no upcoming invoice). New model: `Invoice`. +* Added `client.ip_ranges` — read HCP Terraform / Terraform Enterprise outbound IP ranges via `GET /api/meta/ip-ranges`. `read(modified_since=None)` returns an `IPRange` (CIDR lists for `api`, `notifications`, `sentinel`, `vcs`), or `None` when an `If-Modified-Since` date is supplied and the ranges are unchanged (HTTP 304). New model: `IPRange`. +* Added `client.plan_exports` — export Terraform plan data (Sentinel mock bundles). `create(options)`, `read(id)`, `delete(id)`, and `download(id)` (returns the `.tar.gz` archive bytes, following the temporary presigned-URL redirect). New models: `PlanExport`, `PlanExportCreateOptions`, `PlanExportStatus`, `PlanExportDataType`, `PlanExportStatusTimestamps`. New errors: `InvalidPlanExportIDError`, `RequiredPlanError`. +* Added `client.cost_estimates` — read run cost estimates. `read(id)` returns a `CostEstimate`; `logs(id)` returns the estimate's log output text. `CostEstimate`, `CostEstimateStatus`, and `CostEstimateStatusTimestamps` are now exported from `pytfe.models`. New error: `InvalidCostEstimateIDError`. +* Added IP allowlists (the JSON:API `cidr-range-lists` / `cidr-ranges` resources) as `client.cidr_range_lists` and `client.cidr_ranges`. `cidr_range_lists` supports `list`, `create`, `read`, `update`, `delete`, plus `list_cidr_ranges`, `add_cidr_range`, and `add_agent_pools` / `remove_agent_pools`; `cidr_ranges` supports `read`, `update`, `delete`. New models: `CIDRRangeList`, `CIDRRange`, `EnforcementScope`, and their create/update/list options. New errors: `InvalidCIDRRangeListIDError`, `InvalidCIDRRangeIDError`, `RequiredCIDRBlockError`. +* Added `client.registry` — a client for the **public Terraform Registry** module API (`registry.terraform.io`). This is a new, unauthenticated surface on a different host (the SDK never sends the bearer token to the registry); `base_url` is configurable for other registries implementing the module registry protocol. Methods: `list_modules`, `search_modules`, `list_latest_for_all_providers`, `latest_for_provider`, `get_module`, `list_versions`, `download_url`, `latest_download_url`, and `downloads_summary`. New models are exported under the `PublicRegistry*` prefix (e.g. `PublicRegistryModule`, `PublicRegistryModuleVersions`, `PublicRegistryModuleDownloadsSummary`). New errors: `InvalidModuleNamespaceError`, `InvalidModuleNameError`, `InvalidModuleProviderError`, `InvalidModuleVersionError`. +* Added `client.assessment_results` — read workspace health assessment (drift detection / continuous validation) results. `read(id)` returns an `AssessmentResult`; `json_output(id)` and `json_schema(id)` return the underlying JSON plan / provider schema (following the blob redirect, `None` on 204); `log_output(id)` returns the Terraform JSON log as text. `AssessmentResult` is now a `TFEModel`, so its `workspace`/`source` relationships are reachable via `.relationships` / `.related(...)`. New error: `InvalidAssessmentResultIDError`. +* Added `client.hyok_configurations` — manage HYOK (Hold Your Own Key) configurations. `list(org)`, `create(org, options)`, `read(id)`, `delete(id)`, `test(id)`, and `revoke(id)`. A HYOK configuration ties an OIDC configuration (`client.*_oidc_configurations`) and an agent pool to a customer-controlled KMS key; this is the parent resource for the per-cloud OIDC configs. A configuration must be revoked before it can be deleted. New models: `HYOKConfiguration`, `HYOKConfigurationCreateOptions`, `HYOKConfigurationStatus`, `HYOKKMSOptions`, `OIDCConfigurationType`. New errors: `InvalidHYOKConfigurationIDError`, `RequiredKEKIDError`. + +### Discovery for AI agents and tooling +The installed package is now self-describing, so a consumer (including an AI +agent or other tooling working only from `site-packages/pytfe`) can enumerate and +drive the SDK without hardcoding resource names or browsing the GitHub repo. +* `pytfe.describe()` returns a machine-readable manifest of the API surface — + every resource namespace on `TFEClient`, its public methods, signatures, and + one-line summaries, with the `admin` namespace nested. It makes no network + calls (a throwaway client with an empty config is used purely to introspect + the wiring) and the result is JSON-serializable. Each method's `*Options` + model still exposes JSON Schema via `model_json_schema()`. +* `pytfe.llms_txt()` returns a concise, agent-oriented orientation guide that + now ships inside the wheel at `pytfe/llms.txt` (alongside `py.typed`). +* `TFEClient` gained a comprehensive class docstring (resource namespaces, + quickstart, conventions) so `help(TFEClient)` and IDE hover are useful, and + it is now a context manager: `with TFEClient(...) as tfe: ...` closes the + pooled HTTP connection automatically. `close()` is documented and idempotent. +* Every public resource method now ships a Google-style docstring — a one-line + summary plus `Args`, `Returns`, `Raises`, and a runnable `Example:` block + (sections included as applicable). These are written for consumers and the AI + coding assistants (Copilot/Claude/Cursor) that read them via the language + server from `site-packages`, so completions for pytfe calls are more accurate + out of the box. Return-shape gotchas (single-use `Iterator`, raw `bytes` for + blob downloads, `None` for `204`s) and the exact exceptions each method raises + are now documented in place. Linked from the README's new *AI coding + assistants* row alongside `llms.txt`. +* `StateVersionIncludeOpt` and `PolicySetOutcomeListOptions` are now exported + from `pytfe.models`, matching the rest of their model families. + +### Packaging +* The source distribution (sdist) now includes `examples/`, `CHANGELOG.md`, and + `AGENTS.md` so source consumers get the full example and changelog context. + The wheel is unchanged (examples remain non-importable). + + ## Bug Fixes ### Relationships @@ -19,6 +66,16 @@ Related data is now a complete, first-class part of every response. Before, `?in * Fixed `policy_set.read*(include=[current_version | newest_version])` returning an id-only stub. `PolicySetVersion` is now exported from `pytfe.models` and fully resolved, so the version's `source`, `created_at`, and `status` are populated. * Fixed `variable_set.read` inventing placeholder values (such as `name="workspace-"` or `key="var-"`) for `workspaces`, `projects`, and `vars`. These are now id-only stubs by default and fill from `included` when requested. +### Cost estimates +* Fixed `CostEstimate` failing to parse real API responses: `status-timestamps` now treats every timestamp as optional (the API only returns the ones that have occurred) and adds the missing `pending-at`, and `error-message` now accepts `null`. Previously an included `cost-estimate` with a null error or partial timestamps would silently collapse to an id-only stub. + +### Transport +* Fixed the shared HTTP client retaining `Set-Cookie` session cookies across requests. The `/api/meta/ip-ranges` endpoint returns an `_atlas_session_data` cookie; once stored, that browser session silently overrode bearer-token auth on every subsequent request, causing spurious `401`/`404` errors. The transport now never persists cookies (this SDK authenticates only with the bearer token). Without this fix, any call to `client.ip_ranges.read()` broke all later authenticated calls on the same client. + +### Organizations +* Fixed `organizations.read_entitlements` silently dropping most entitlement flags. The parser surfaced only 15 of the ~47 flags the API returns, so flags such as `hyok`, `assessments`, `stacks`, `terraform-actions`, and `change-requests` were discarded. `Entitlements` now exposes those as typed fields and retains every remaining flag (including the integer `*-limit` flags) under `model_extra` via `extra="allow"`. The change is additive — existing typed fields are unchanged. + + # Released # v1.1.0 diff --git a/README.md b/README.md index 97913f57..37b2baaa 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,8 @@ and upstream HCP Terraform API docs. | Need | Start here | |---|---| | Configure the SDK | [Authentication](./docs/authentication.md), [Pagination](./docs/pagination.md), [Logging](./docs/LOGGING.md) | -| API guides | [API index](./docs/api/index.md), [API coverage](./docs/api-coverage.md), [Related resources (`include`)](./docs/related-resources.md), [Workspaces](./docs/api/workspaces.md), [Runs/plans/applies](./docs/api/runs-plans-applies.md), [State versions](./docs/api/state-versions.md) | +| AI coding assistants | [`llms.txt`](./src/pytfe/llms.txt) — concise pytfe orientation (quickstart, conventions, error handling) for LLMs writing pytfe code | +| API guides | [API index](./docs/api/index.md), [API coverage](./docs/api-coverage.md), [Related resources (`include`)](./docs/related-resources.md), [Workspaces](./docs/api/workspaces.md), [Runs/plans/applies](./docs/api/runs-plans-applies.md), [State versions](./docs/api/state-versions.md), [Public Registry](./docs/api/registry.md) | | Scenario guides | [API-driven run](./docs/scenarios/api-driven-run.md), [State management](./docs/scenarios/state-management.md), [Migrate workspaces and state](./docs/scenarios/migrate-workspaces-and-state.md), [Team access onboarding](./docs/scenarios/team-access-onboarding.md), [No-code provisioning](./docs/scenarios/no-code-provisioning.md), [TFE identity bootstrap](./docs/scenarios/tfe-identity-bootstrap.md), [TFE admin bootstrap](./docs/scenarios/tfe-admin-bootstrap.md), [OIDC dynamic credentials](./docs/scenarios/oidc-dynamic-credentials.md) | | Operations guides | [Troubleshooting](./docs/troubleshooting.md), [Errors](./docs/errors.md), [Terraform Enterprise](./docs/terraform-enterprise.md) | | Contribute to the SDK | [CONTRIBUTING](./docs/CONTRIBUTING.md), [ITERATORS](./docs/ITERATORS.md), [MODELS](./docs/MODELS.md), [RESOURCE](./docs/RESOURCE.md) | diff --git a/docs/api-coverage.md b/docs/api-coverage.md index 459f98c2..84c4fbc4 100644 --- a/docs/api-coverage.md +++ b/docs/api-coverage.md @@ -8,7 +8,7 @@ resource list is reconciled against the public **Legend:** ✅ Covered  ·  🟡 Partial  ·  ❌ Not yet implemented -pytfe implements **61 resource namespaces**. The resources still missing or +pytfe implements **71 resource namespaces**. The resources still missing or partially covered are listed at the bottom of this page. ## Covered resources @@ -19,6 +19,8 @@ partially covered are listed at the bottom of this page. | | Organization memberships | `client.organization_memberships` | ✅ | | | Organization tags | `client.organization_tags` | ✅ | | | Organization tokens | `client.organization_tokens` | ✅ | +| | Subscriptions | `client.subscriptions` | ✅ | +| | Invoices | `client.invoices` | ✅ | | | Organization token TTL policies | `client.organization_token_ttl_policies` | ✅ | | | Organization audit configuration | `client.organization_audit_configurations` | ✅ | | | Teams | `client.teams` | ✅ | @@ -27,6 +29,8 @@ partially covered are listed at the bottom of this page. | | Team workspace access | `client.team_workspace_accesses` | ✅ | | | Users | `client.users` | ✅ | | | SSH keys | `client.ssh_keys` | ✅ | +| | IP allowlists (CIDR range lists) | `client.cidr_range_lists` | ✅ | +| | CIDR ranges | `client.cidr_ranges` | ✅ | | Workspaces & config | Workspaces | `client.workspaces` | ✅ | | | Workspace resources | `client.workspace_resources` | ✅ | | | Projects | `client.projects` | ✅ | @@ -39,7 +43,10 @@ partially covered are listed at the bottom of this page. | | Run events | `client.run_events` | ✅ | | | Run triggers | `client.run_triggers` | ✅ | | | Plans | `client.plans` | ✅ | +| | Plan exports | `client.plan_exports` | ✅ | | | Applies | `client.applies` | ✅ | +| | Cost estimates | `client.cost_estimates` | ✅ | +| | Assessment results | `client.assessment_results` | ✅ | | | Comments | `client.comments` | ✅ | | | Query runs | `client.query_runs` | ✅ | | | State versions | `client.state_versions` | ✅ | @@ -61,6 +68,7 @@ partially covered are listed at the bottom of this page. | | Registry provider platforms | `client.registry_provider_platforms` | ✅ | | | Registry provider versions | `client.registry_provider_versions` | ✅ | | | No-code modules | `client.no_code_modules` | ✅ | +| | Public Registry module API (registry.terraform.io) | `client.registry` | ✅ | | Agents | Agent pools | `client.agent_pools` | ✅ | | | Agents | `client.agents` | ✅ | | | Agent tokens | `client.agent_tokens` | ✅ | @@ -75,6 +83,8 @@ partially covered are listed at the bottom of this page. | | Azure OIDC configurations | `client.azure_oidc_configurations` | ✅ | | | GCP OIDC configurations | `client.gcp_oidc_configurations` | ✅ | | | Vault OIDC configurations | `client.vault_oidc_configurations` | ✅ | +| | HYOK configurations | `client.hyok_configurations` | ✅ | +| Meta | IP ranges | `client.ip_ranges` | ✅ | | Admin (TFE site-admin) | Organizations, users, runs, workspaces | `client.admin.organizations` / `.users` / `.runs` / `.workspaces` | ✅ | | | Terraform / OPA / Sentinel versions | `client.admin.terraform_versions` / `.opa_versions` / `.sentinel_versions` | ✅ | | | SAML / SCIM / SMTP settings + SCIM tokens | `client.admin.saml_settings` / `.scim_settings` / `.scim_tokens` / `.smtp_settings` | ✅ | @@ -93,18 +103,11 @@ Public HCP Terraform API resources that do not yet have a pytfe client namespace | Resource | Notes | |---|---| -| Assessment results | Health-assessment reads. Model exists (`models/assessment_result.py`); surfaced indirectly via `workspace.current_assessment_result`. | -| Audit trails tokens | Auth tokens for the audit-trail streaming API. | | Change requests | — | -| Cost estimates | Run cost-estimation reads. Model exists (`models/cost_estimate.py`). | | Feature sets | Organization feature sets. | | GPG keys | Private Registry provider signing keys. | | Group member roles | Team member role assignments. | -| Invoices | Organization billing invoices. | -| IP allowlists | Organization IP allowlist. | -| IP ranges | `/api/meta/ip-ranges`. | | Metrics service tokens | Metrics endpoint service tokens. | -| Plan exports | Sentinel mock / plan-export download. Model exists (`models/plan_export.py`). | | Stack configuration summary | Builds on the existing stack_configuration resource | | Stack deployment | Core Stacks deployment lifecycle | | Stack deployment groups | Extends stack_deployment | @@ -113,8 +116,6 @@ Public HCP Terraform API resources that do not yet have a pytfe client namespace | Stack deployment steps | Granular deployment step tracking | | Stack diagnostic | Diagnostics companion to stack_deployment | | Stack state | State surface for deployed stacks | -| Subscriptions | Organization subscription management. | -| Team member | - | | Terraform actions | Only the Run `invoke_action_addrs` field today; no dedicated resource. | | User tokens | Personal (user) API tokens. | | VCS events | — | @@ -122,3 +123,8 @@ Public HCP Terraform API resources that do not yet have a pytfe client namespace > Note: the TFE site-admin API (`/api/v2/admin/*`, TFE-only — not part of the > public HCP Terraform API) **is** implemented under `client.admin` (see the > Admin rows above). +> +> Note: **team membership** is covered by `client.teams` +> (`add_users` / `remove_users` / `list_users` and the `*_organization_memberships` +> variants), and **audit-trail tokens** are covered by `client.organization_tokens` +> via `token_type=TokenType.AUDIT_TRAILS` — neither is a separate namespace. diff --git a/docs/api/index.md b/docs/api/index.md index c4a04093..b99f5d8d 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -26,6 +26,7 @@ column. | `client.runs` | `Runs` | `list`, `list_for_organization`, `read`, `create`, `apply`, `cancel`, `force_cancel`, `force_execute`, `discard` | [run.py](../../examples/run.py) | [Runs](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/run) | | `client.plans` | `Plans` | `read`, `read_for_run`, `logs`, `read_json_output`, `read_json_output_for_run`, `read_json_schema_for_run` | [plan.py](../../examples/plan.py) | [Plans](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/plans) | | `client.applies` | `Applies` | `read`, `logs`, `errored_state` | [apply.py](../../examples/apply.py) | [Applies](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/applies) | +| `client.assessment_results` | `AssessmentResults` | `read`, `json_output`, `json_schema`, `log_output` | [assessment_result.py](../../examples/assessment_result.py) | [Assessment results](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/assessment-results) | | `client.run_events` | `RunEvents` | `list`, `read`, `read_with_options` | [run_events.py](../../examples/run_events.py) | [Runs](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/run) | | `client.query_runs` | `QueryRuns` | `list`, `read`, `create`, `logs`, `cancel`, `force_cancel` | [query_run.py](../../examples/query_run.py) | [Query runs](https://developer.hashicorp.com/terraform/enterprise/api-docs/queries) | | `client.state_versions` | `StateVersions` | `list`, `read`, `read_current`, `create`, `upload`, `download`, `rollback`, backing-data actions | [state_versions.py](../../examples/state_versions.py) | [State versions](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/state-versions) | @@ -50,6 +51,8 @@ column. | `client.team_tokens` | `TeamTokens` | `list`, `read`, `create`, `delete` | [team_token.py](../../examples/team_token.py) | [Team tokens](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/team-tokens) | | `client.organization_memberships` | `OrganizationMemberships` | `list`, `read`, `create`, `delete` | [organization_membership.py](../../examples/organization_membership.py) | [Organization memberships](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/organization-memberships) | | `client.organization_tokens` | `OrganizationTokens` | `read`, `create`, `delete` | [organization_token.py](../../examples/organization_token.py) | [Organization tokens](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/organization-tokens) | +| `client.subscriptions` | `Subscriptions` | `read_for_organization`, `read` | [billing.py](../../examples/billing.py) | [Subscriptions](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/subscriptions) | +| `client.invoices` | `Invoices` | `list`, `read_next` | [billing.py](../../examples/billing.py) | [Invoices](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/invoices) | ## Policies and policy results @@ -75,17 +78,25 @@ column. ## Agents, registry, integrations, and other resources +> Two registry surfaces: `client.registry` reads the **public** Terraform +> Registry (`registry.terraform.io`, unauthenticated), while +> `client.registry_modules` / `client.registry_providers` (and their version / +> platform sub-resources) manage your organization's **private** registry on +> HCP Terraform / TFE. See [registry.md](registry.md). + | Client attribute | Resource class | Common methods | Example | Upstream API docs | |---|---|---|---|---| | `client.agent_pools` | `AgentPools` | `list`, `read`, `create`, `update`, `delete`, assign/remove workspaces/projects | [agent_pool.py](../../examples/agent_pool.py) | [Agents](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/agents) | | `client.agents` | `Agents` | `list`, `read`, `delete` | [agent.py](../../examples/agent.py) | [Agents](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/agents) | | `client.agent_tokens` | `AgentTokens` | `list`, `read`, `create`, `delete` | [agent.py](../../examples/agent.py) | [Agent tokens](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/agent-tokens) | -| `client.registry_modules` | `RegistryModules` | `list`, `read`, `create`, `update`, `delete`, version and upload helpers | [registry_module.py](../../examples/registry_module.py) | [Registry modules](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/modules) | +| `client.registry` | `Registry` | `list_modules`, `search_modules`, `list_latest_for_all_providers`, `latest_for_provider`, `get_module`, `list_versions`, `download_url`, `latest_download_url`, `downloads_summary` | [registry.py](../../examples/registry.py) | [Registry API (public, unauthenticated)](https://developer.hashicorp.com/terraform/registry/api-docs) | +| `client.registry_modules` | `RegistryModules` | `list`, `read`, `create`, `update`, `delete`, version and upload helpers | [registry_module.py](../../examples/registry_module.py) | [Registry modules (private)](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/modules) | | `client.no_code_modules` | `NoCodeModules` | `create`, `read`, `update`, `delete`, `read_variables`, `create_workspace`, `upgrade_workspace`, `read_workspace_upgrade`, `confirm_workspace_upgrade` | [no_code_provisioning.py](../../examples/no_code_provisioning.py) | [No-code provisioning](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/no-code-provisioning) | | `client.aws_oidc_configurations` | `AWSOIDCConfigurations` | `create`, `read`, `update`, `delete` | [oidc_configurations.py](../../examples/oidc_configurations.py) | [AWS OIDC](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/hold-your-own-key/oidc-configurations/aws) | | `client.azure_oidc_configurations` | `AzureOIDCConfigurations` | `create`, `read`, `update`, `delete` | [oidc_configurations.py](../../examples/oidc_configurations.py) | [Azure OIDC](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/hold-your-own-key/oidc-configurations/azure) | | `client.gcp_oidc_configurations` | `GCPOIDCConfigurations` | `create`, `read`, `update`, `delete` | [oidc_configurations.py](../../examples/oidc_configurations.py) | [GCP OIDC](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/hold-your-own-key/oidc-configurations/gcp) | | `client.vault_oidc_configurations` | `VaultOIDCConfigurations` | `create`, `read`, `update`, `delete` | [oidc_configurations.py](../../examples/oidc_configurations.py) | [Vault OIDC](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/hold-your-own-key/oidc-configurations/vault) | +| `client.hyok_configurations` | `HYOKConfigurations` | `list`, `create`, `read`, `delete`, `test`, `revoke` | [hyok_configuration.py](../../examples/hyok_configuration.py) | [HYOK configurations](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/hold-your-own-key/configurations) | | `client.registry_providers` | `RegistryProviders` | `list`, `read`, `create`, `delete` | [registry_provider.py](../../examples/registry_provider.py) | [Registry providers](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/providers) | | `client.registry_provider_versions` | `RegistryProviderVersions` | `list`, `read`, `create`, `delete` | [registry_provider_version.py](../../examples/registry_provider_version.py) | [Registry providers](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/providers) | | `client.registry_provider_platforms` | `RegistryProviderPlatforms` | `list`, `read`, `create`, `delete` | [registry_provider_platform.py](../../examples/registry_provider_platform.py) | [Registry providers](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/providers) | @@ -123,6 +134,7 @@ HCP Terraform (SaaS). - [policies.md](policies.md) - [run-tasks.md](run-tasks.md) - [no-code-provisioning.md](no-code-provisioning.md) +- [registry.md](registry.md) — public Terraform Registry vs. the private registry - [oidc-configurations.md](oidc-configurations.md) - [admin-identity.md](admin-identity.md) - [organization-defaults-and-token-ttl.md](organization-defaults-and-token-ttl.md) diff --git a/docs/api/registry.md b/docs/api/registry.md new file mode 100644 index 00000000..8ce0c928 --- /dev/null +++ b/docs/api/registry.md @@ -0,0 +1,125 @@ +# Public Terraform Registry (`client.registry`) + +`client.registry` is a client for the **public Terraform Registry** module API +at [`registry.terraform.io`](https://registry.terraform.io). It implements the +[module registry protocol](https://developer.hashicorp.com/terraform/internals/module-registry-protocol) +plus HashiCorp's documented discovery extensions — searching modules across the +whole registry, retrieving module documentation/schemas, listing versions, +resolving download sources, and reading download metrics. + +Upstream API reference: +[Registry API](https://developer.hashicorp.com/terraform/registry/api-docs). + +> In HashiCorp's words: *"The public Terraform Registry and the private registry +> included in HCP Terraform and Terraform Enterprise implement a superset of +> [the minimal module registry] API to support additional use-cases such as +> searching for modules across the whole registry, retrieving documentation and +> schemas for modules, and so on."* + +## Public registry vs. private registry — which resource do I want? + +The SDK exposes **two different registry surfaces**. They are different APIs, on +different hosts, with different authentication. Pick based on what you are doing: + +| | `client.registry` (this guide) | `client.registry_modules` / `client.registry_providers` / … | +|---|---|---| +| What it is | The **public Terraform Registry** module API | The **private registry** included in HCP Terraform / Terraform Enterprise | +| Host | `registry.terraform.io` (configurable) | Your HCP Terraform / TFE address (`/api/v2/…`) | +| Purpose | **Discover & read** public modules (list, search, versions, download source, metrics) | **Manage** your organization's private modules/providers (publish, update, delete, add versions) | +| Auth | **None** — unauthenticated; the SDK never sends your bearer token | **Bearer token** required (your HCP TF / TFE API token) | +| Wire format | Plain JSON, `offset`/`limit` pagination | JSON:API, `page[number]`/`page[size]` pagination | +| Upstream docs | [Registry API](https://developer.hashicorp.com/terraform/registry/api-docs) | [Private registry — modules](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/modules) / [providers](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/providers) | + +In short: use `client.registry` to **browse the public registry**; use +`client.registry_modules` (and friends) to **publish to and manage your own +private registry**. + +## Authentication + +The public Registry API does not require authentication, so a token is optional: + +```python +from pytfe import TFEClient, TFEConfig + +# No token needed for the public registry. +client = TFEClient(TFEConfig(address="https://app.terraform.io", token="")) +``` + +The SDK sends requests to the registry host **without** the `Authorization` +header even when a token is configured, so your HCP TF / TFE credentials are +never exposed to the registry. + +## Targeting another registry + +By default `client.registry` points at `registry.terraform.io`. To talk to +another host that implements the module registry protocol, set `base_url`: + +```python +from pytfe.resources.registry import Registry + +registry = Registry(client._transport, base_url="https://modules.example.com") +``` + +## Methods + +| Method | Returns | Endpoint | +|---|---|---| +| `list_modules(namespace=None, options=None)` | `Iterator[PublicRegistryModule]` | `GET /v1/modules[/:namespace]` | +| `search_modules(query, options=None)` | `Iterator[PublicRegistryModule]` | `GET /v1/modules/search?q=…` | +| `list_latest_for_all_providers(namespace, name, options=None)` | `Iterator[PublicRegistryModule]` | `GET /v1/modules/:namespace/:name` | +| `latest_for_provider(namespace, name, provider)` | `PublicRegistryModule` | `GET /v1/modules/:namespace/:name/:provider` | +| `get_module(namespace, name, provider, version)` | `PublicRegistryModule` | `GET /v1/modules/:namespace/:name/:provider/:version` | +| `list_versions(namespace, name, provider)` | `PublicRegistryModuleVersions` | `GET /v1/modules/:namespace/:name/:provider/versions` | +| `download_url(namespace, name, provider, version)` | `str` | `GET …/:version/download` | +| `latest_download_url(namespace, name, provider)` | `str` | `GET …/:provider/download` | +| `downloads_summary(namespace, name, provider)` | `PublicRegistryModuleDownloadsSummary` | `GET /v2/modules/…/downloads/summary` | + +`list_*` and `search_*` return single-use `Iterator`s and page transparently +using the registry's `offset`/`limit` scheme (see [Pagination](../pagination.md)). +Use `itertools.islice(...)` or a `for ... break` loop to take just the first few +results without walking the entire registry. + +The two `*download_url` methods return the module's source location — the value +of the registry's `X-Terraform-Get` response header (a +[go-getter](https://github.com/hashicorp/go-getter) URL string), **not** the +archive bytes. + +## Quick start + +```python +import itertools +from pytfe import TFEClient, TFEConfig +from pytfe.models import PublicRegistrySearchOptions + +client = TFEClient(TFEConfig(address="https://app.terraform.io", token="")) + +# Search (take just the first few — the iterator pages lazily). +for m in itertools.islice( + client.registry.search_modules("vpc", PublicRegistrySearchOptions(provider="aws")), + 5, +): + print(m.id, m.downloads, m.verified) + +# Read a specific module and its inputs. +module = client.registry.latest_for_provider("terraform-aws-modules", "vpc", "aws") +print(module.version, len(module.root.inputs or [])) + +# List versions and resolve a download source. +versions = client.registry.list_versions("terraform-aws-modules", "vpc", "aws") +print([v.version for v in versions.versions][:3]) +print(client.registry.latest_download_url("terraform-aws-modules", "vpc", "aws")) + +# Download metrics. +summary = client.registry.downloads_summary("terraform-aws-modules", "vpc", "aws") +print(summary.total) +``` + +See the runnable [`examples/registry.py`](../../examples/registry.py). + +## Models + +Public-registry models are exported from `pytfe.models` under the +`PublicRegistry*` prefix (e.g. `PublicRegistryModule`, +`PublicRegistryModuleVersions`, `PublicRegistryModuleDownloadsSummary`, +`PublicRegistrySearchOptions`). The prefix avoids any confusion with the +private-registry models such as `RegistryModule`. diff --git a/examples/assessment_result.py b/examples/assessment_result.py new file mode 100644 index 00000000..7b23a00b --- /dev/null +++ b/examples/assessment_result.py @@ -0,0 +1,89 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Assessment results (workspace health / drift detection) demo. + +Find an assessment result via a workspace's current assessment, then read its +summary and (optionally) the underlying JSON plan / log output. +""" + +from __future__ import annotations + +import argparse +import os + +from pytfe import TFEClient, TFEConfig + + +def _print_header(title: str) -> None: + print("\n" + "=" * 80) + print(title) + print("=" * 80) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Assessment results demo for python-tfe SDK" + ) + parser.add_argument( + "--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io") + ) + parser.add_argument("--token", default=os.getenv("TFE_TOKEN", "")) + parser.add_argument( + "--id", help="Assessment result ID to read (e.g. asmtres-xxxxx)" + ) + parser.add_argument( + "--workspace-id", + help="Workspace ID to discover the current assessment result from", + ) + parser.add_argument( + "--json-output", action="store_true", help="Also fetch the JSON plan output" + ) + parser.add_argument( + "--log-output", action="store_true", help="Also fetch the JSON log output" + ) + args = parser.parse_args() + + if not args.token: + print("TFE_TOKEN is not set") + return 2 + + client = TFEClient(TFEConfig(address=args.address, token=args.token)) + + ar_id = args.id + if not ar_id and args.workspace_id: + _print_header(f"Current assessment for workspace {args.workspace_id}") + current = client.workspaces.current_assessment_result(args.workspace_id) + if not current: + print("Workspace has no assessment result (health assessments disabled?).") + return 0 + ar_id = current.id + print(f"Found assessment result: {ar_id}") + + if not ar_id: + print("Provide --id or --workspace-id") + return 2 + + _print_header(f"Assessment result: {ar_id}") + ar = client.assessment_results.read(ar_id) + print(f" drifted: {ar.drifted}") + print(f" succeeded: {ar.succeeded}") + print(f" created_at: {ar.created_at}") + if ar.error_message: + print(f" error: {ar.error_message}") + + # The output endpoints require a user or team token with workspace admin access. + if args.json_output: + _print_header("JSON plan output") + out = client.assessment_results.json_output(ar_id) + print(f" format_version: {out.get('format_version') if out else None}") + + if args.log_output: + _print_header("JSON log output (first 500 chars)") + print(client.assessment_results.log_output(ar_id)[:500] or "(no log output)") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/billing.py b/examples/billing.py new file mode 100644 index 00000000..156bec50 --- /dev/null +++ b/examples/billing.py @@ -0,0 +1,70 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Billing demo (subscriptions + invoices) for the python-tfe SDK. + +Both APIs are HCP Terraform only and read-only. +""" + +from __future__ import annotations + +import argparse +import os + +from pytfe import TFEClient, TFEConfig + + +def _print_header(title: str) -> None: + print("\n" + "=" * 80) + print(title) + print("=" * 80) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Billing demo for python-tfe SDK") + parser.add_argument( + "--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io") + ) + parser.add_argument("--token", default=os.getenv("TFE_TOKEN", "")) + parser.add_argument("--organization", default=os.getenv("TFE_ORG", "")) + args = parser.parse_args() + + if not args.token: + print("TFE_TOKEN is not set") + return 2 + + client = TFEClient(TFEConfig(address=args.address, token=args.token)) + + _print_header(f"Subscription for {args.organization}") + sub = client.subscriptions.read_for_organization(args.organization) + print(f" id: {sub.id}") + print(f" active: {sub.is_active}") + print(f" start_at: {sub.start_at}") + print(f" runs_ceiling: {sub.runs_ceiling} agents_ceiling: {sub.agents_ceiling}") + print(f" feature_set: {sub.feature_set_id}") + fs = sub.related("feature-set") + if fs and fs[0].get("attributes"): + print(f" feature_set name: {fs[0]['attributes'].get('name')}") + + _print_header(f"Invoices for {args.organization}") + count = 0 + for inv in client.invoices.list(args.organization): + count += 1 + print( + f" - {inv.number or inv.id} status={inv.status} total={inv.total} paid={inv.paid}" + ) + if count == 0: + print(" (no previous invoices)") + + _print_header("Next (upcoming) invoice") + nxt = client.invoices.read_next(args.organization) + if nxt is None: + print(" (no upcoming invoice / not on a credit-card-billed plan)") + else: + print(f" {nxt.number or nxt.id} status={nxt.status} total={nxt.total}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/cidr_range_list.py b/examples/cidr_range_list.py new file mode 100644 index 00000000..ca848f63 --- /dev/null +++ b/examples/cidr_range_list.py @@ -0,0 +1,106 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""IP allowlist (CIDR range list) demo for the python-tfe SDK. + +HCP Terraform's IP allowlist feature is exposed as the JSON:API +``cidr-range-lists`` and ``cidr-ranges`` resources, surfaced here as +``client.cidr_range_lists`` and ``client.cidr_ranges``. +""" + +from __future__ import annotations + +import argparse +import os + +from pytfe import TFEClient, TFEConfig +from pytfe.models import ( + CIDRRangeCreateOptions, + CIDRRangeListCreateOptions, + EnforcementScope, +) + + +def _print_header(title: str): + print("\n" + "=" * 80) + print(title) + print("=" * 80) + + +def main(): + parser = argparse.ArgumentParser( + description="IP allowlists (CIDR range lists) demo for python-tfe SDK" + ) + parser.add_argument( + "--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io") + ) + parser.add_argument("--token", default=os.getenv("TFE_TOKEN", "")) + parser.add_argument("--organization", default=os.getenv("TFE_ORG", "")) + parser.add_argument( + "--create", + action="store_true", + help="Create an allowlist (with --name) and add --cidr to it", + ) + parser.add_argument("--name", help="Name for the new IP allowlist") + parser.add_argument( + "--cidr", help="CIDR block to add (e.g. 192.168.1.0/24), with --create" + ) + parser.add_argument("--id", help="IP allowlist ID for --read / --delete") + parser.add_argument("--read", action="store_true", help="Read one IP allowlist") + parser.add_argument( + "--delete", action="store_true", help="Delete the IP allowlist (--id)" + ) + args = parser.parse_args() + + if not args.token: + print("TFE_TOKEN is not set") + return 2 + + client = TFEClient(TFEConfig(address=args.address, token=args.token)) + + _print_header(f"Listing IP allowlists for {args.organization}") + for crl in client.cidr_range_lists.list(args.organization): + ranges = list(client.cidr_range_lists.list_cidr_ranges(crl.id)) + print(f"- {crl.id} {crl.name} scope={crl.enforcement_scope}") + for r in ranges: + print(f" {r.id} {r.cidr_block}") + + if args.create: + if not args.name: + print("--name is required for --create") + return 2 + _print_header(f"Creating IP allowlist: {args.name}") + crl = client.cidr_range_lists.create( + args.organization, + CIDRRangeListCreateOptions( + name=args.name, + enforcement_scope=EnforcementScope.SELECTED_AGENT_POOLS, + ), + ) + print(f"Created {crl.id}") + if args.cidr: + cidr = client.cidr_range_lists.add_cidr_range( + crl.id, CIDRRangeCreateOptions(cidr_block=args.cidr) + ) + print(f"Added CIDR range {cidr.id}: {cidr.cidr_block}") + + if args.read: + if not args.id: + print("--id is required for --read") + return 2 + _print_header(f"Reading IP allowlist: {args.id}") + crl = client.cidr_range_lists.read(args.id) + print(f"Name: {crl.name}") + print(f"Description: {crl.description}") + print(f"Enforcement scope: {crl.enforcement_scope}") + + if args.delete and args.id: + _print_header(f"Deleting IP allowlist: {args.id}") + client.cidr_range_lists.delete(args.id) + print("Deleted.") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/cost_estimate.py b/examples/cost_estimate.py new file mode 100644 index 00000000..1c4eacf1 --- /dev/null +++ b/examples/cost_estimate.py @@ -0,0 +1,86 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +import argparse +import os + +from pytfe import TFEClient, TFEConfig + + +def _print_header(title: str): + print("\n" + "=" * 80) + print(title) + print("=" * 80) + + +def main(): + parser = argparse.ArgumentParser( + description="Cost estimates demo for python-tfe SDK" + ) + parser.add_argument( + "--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io") + ) + parser.add_argument("--token", default=os.getenv("TFE_TOKEN", "")) + parser.add_argument( + "--cost-estimate-id", help="Cost estimate ID to read (e.g. ce-xxxxx)" + ) + parser.add_argument( + "--run-id", + help="Run ID to discover the cost estimate from (e.g. run-xxxxx)", + ) + parser.add_argument( + "--logs", action="store_true", help="Also print the cost estimate logs" + ) + args = parser.parse_args() + + if not args.token: + print("TFE_TOKEN is not set") + return 2 + + cfg = TFEConfig(address=args.address, token=args.token) + client = TFEClient(cfg) + + ce_id = args.cost_estimate_id + + # Cost estimates have no list endpoint; the ID lives on a run's + # relationships.cost-estimate. Look it up when only a run ID is given. + if not ce_id and args.run_id: + _print_header(f"Discovering cost estimate from run: {args.run_id}") + run = client.runs.read(args.run_id) + if run.cost_estimate and run.cost_estimate.id: + ce_id = run.cost_estimate.id + print(f"Found cost estimate: {ce_id}") + else: + print("This run has no cost estimate.") + return 0 + + if not ce_id: + print("Provide --cost-estimate-id or --run-id") + return 2 + + _print_header(f"Reading cost estimate: {ce_id}") + ce = client.cost_estimates.read(ce_id) + print(f"ID: {ce.id}") + print(f"Status: {ce.status}") + print( + f"Resources: {ce.resources_count} " + f"(matched={ce.matched_resources_count}, " + f"unmatched={ce.unmatched_resources_count})" + ) + print(f"Prior monthly cost: {ce.prior_monthly_cost}") + print(f"Proposed monthly cost: {ce.proposed_monthly_cost}") + print(f"Delta monthly cost: {ce.delta_monthly_cost}") + if ce.error_message: + print(f"Error: {ce.error_message}") + + if args.logs: + _print_header(f"Cost estimate logs: {ce_id}") + print(client.cost_estimates.logs(ce_id) or "(no log output yet)") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/hyok_configuration.py b/examples/hyok_configuration.py new file mode 100644 index 00000000..99a3c112 --- /dev/null +++ b/examples/hyok_configuration.py @@ -0,0 +1,124 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""HYOK (Hold Your Own Key) configurations demo for the python-tfe SDK. + +Requires the HYOK entitlement on the organization, plus an existing agent pool +and OIDC configuration (see examples/oidc_configurations.py). +""" + +from __future__ import annotations + +import argparse +import os + +from pytfe import TFEClient, TFEConfig +from pytfe.models import ( + HYOKConfigurationCreateOptions, + HYOKKMSOptions, + OIDCConfigurationType, +) + + +def _print_header(title: str) -> None: + print("\n" + "=" * 80) + print(title) + print("=" * 80) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="HYOK configurations demo for python-tfe SDK" + ) + parser.add_argument( + "--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io") + ) + parser.add_argument("--token", default=os.getenv("TFE_TOKEN", "")) + parser.add_argument("--organization", default=os.getenv("TFE_ORG", "")) + parser.add_argument("--create", action="store_true", help="Create a HYOK config") + parser.add_argument("--name", help="Name for the new HYOK configuration") + parser.add_argument("--kek-id", help="Key encryption key id in your KMS") + parser.add_argument("--agent-pool-id", help="Agent pool ID (apool-xxxxx)") + parser.add_argument("--oidc-configuration-id", help="OIDC config ID") + parser.add_argument( + "--oidc-type", + choices=[t.value for t in OIDCConfigurationType], + default=OIDCConfigurationType.VAULT.value, + help="OIDC configuration JSON:API type", + ) + parser.add_argument("--id", help="HYOK config ID for read/test/delete") + parser.add_argument("--test", action="store_true", help="Test the config (--id)") + parser.add_argument( + "--revoke", + action="store_true", + help="Revoke the config (--id); required before delete", + ) + parser.add_argument( + "--delete", action="store_true", help="Delete the config (--id)" + ) + args = parser.parse_args() + + if not args.token: + print("TFE_TOKEN is not set") + return 2 + + client = TFEClient(TFEConfig(address=args.address, token=args.token)) + + _print_header(f"HYOK configurations for {args.organization}") + for h in client.hyok_configurations.list(args.organization): + print(f" - {h.id} {h.name} status={h.status} primary={h.primary}") + + cfg_id = args.id + if args.create: + if not ( + args.name + and args.kek_id + and args.agent_pool_id + and args.oidc_configuration_id + ): + print( + "--create needs --name --kek-id --agent-pool-id --oidc-configuration-id" + ) + return 2 + _print_header(f"Creating HYOK configuration: {args.name}") + cfg = client.hyok_configurations.create( + args.organization, + HYOKConfigurationCreateOptions( + name=args.name, + kek_id=args.kek_id, + agent_pool_id=args.agent_pool_id, + oidc_configuration_id=args.oidc_configuration_id, + oidc_configuration_type=OIDCConfigurationType(args.oidc_type), + kms_options=HYOKKMSOptions(), + ), + ) + cfg_id = cfg.id + print(f" created {cfg.id} (status={cfg.status})") + + if args.id and not args.create: + _print_header(f"Reading HYOK configuration: {args.id}") + cfg = client.hyok_configurations.read(args.id) + print(f" name={cfg.name} kek_id={cfg.kek_id} status={cfg.status}") + print(f" oidc={cfg.oidc_configuration_id} ({cfg.oidc_configuration_type})") + + if args.test and cfg_id: + _print_header(f"Testing HYOK configuration: {cfg_id}") + client.hyok_configurations.test(cfg_id) + print(" test triggered; poll read(...).status for the result") + + if args.revoke and cfg_id: + _print_header(f"Revoking HYOK configuration: {cfg_id}") + client.hyok_configurations.revoke(cfg_id) + print(" revoke triggered; poll read(...).status until 'revoked'") + + if args.delete and cfg_id: + # A HYOK configuration must be revoked before it can be deleted. + _print_header(f"Deleting HYOK configuration: {cfg_id}") + client.hyok_configurations.delete(cfg_id) + print(" deleted") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/ip_ranges.py b/examples/ip_ranges.py new file mode 100644 index 00000000..ae94ab2b --- /dev/null +++ b/examples/ip_ranges.py @@ -0,0 +1,59 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +import argparse +import os +from datetime import datetime + +from pytfe import TFEClient, TFEConfig + + +def _print_header(title: str): + print("\n" + "=" * 80) + print(title) + print("=" * 80) + + +def main(): + parser = argparse.ArgumentParser(description="IP ranges demo for python-tfe SDK") + parser.add_argument( + "--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io") + ) + parser.add_argument("--token", default=os.getenv("TFE_TOKEN", "")) + parser.add_argument( + "--modified-since", + help="ISO-8601 timestamp; only fetch ranges changed since then " + "(e.g. 2020-05-26T15:10:05).", + ) + args = parser.parse_args() + + # The IP ranges endpoint does not require authentication. + cfg = TFEConfig(address=args.address, token=args.token) + client = TFEClient(cfg) + + modified_since = ( + datetime.fromisoformat(args.modified_since) if args.modified_since else None + ) + + _print_header("Reading HCP Terraform / TFE IP ranges") + ranges = client.ip_ranges.read(modified_since=modified_since) + + if ranges is None: + print("Not modified since the supplied date.") + return + + for name, cidrs in ( + ("API", ranges.api), + ("Notifications", ranges.notifications), + ("Sentinel", ranges.sentinel), + ("VCS", ranges.vcs), + ): + print(f"\n{name} ({len(cidrs)} ranges):") + for cidr in cidrs: + print(f" - {cidr}") + + +if __name__ == "__main__": + main() diff --git a/examples/plan_export.py b/examples/plan_export.py new file mode 100644 index 00000000..f0b93e88 --- /dev/null +++ b/examples/plan_export.py @@ -0,0 +1,107 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +import argparse +import os +import time + +from pytfe import TFEClient, TFEConfig +from pytfe.models import PlanExportCreateOptions, PlanExportStatus + + +def _print_header(title: str): + print("\n" + "=" * 80) + print(title) + print("=" * 80) + + +def main(): + parser = argparse.ArgumentParser(description="Plan exports demo for python-tfe SDK") + parser.add_argument( + "--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io") + ) + parser.add_argument("--token", default=os.getenv("TFE_TOKEN", "")) + parser.add_argument( + "--plan-id", + help="Finished plan ID to export (e.g. plan-xxxxx). Required with --create.", + ) + parser.add_argument( + "--create", action="store_true", help="Create a plan export from --plan-id" + ) + parser.add_argument( + "--id", help="Existing plan export ID (e.g. pe-xxxxx) for --read/--download" + ) + parser.add_argument("--read", action="store_true", help="Read a plan export") + parser.add_argument( + "--download", + action="store_true", + help="Download the export's .tar.gz archive", + ) + parser.add_argument( + "--output", default="plan-export.tar.gz", help="Path to write the archive to" + ) + parser.add_argument( + "--delete", action="store_true", help="Delete the plan export when done" + ) + args = parser.parse_args() + + if not args.token: + print("TFE_TOKEN is not set") + return 2 + + cfg = TFEConfig(address=args.address, token=args.token) + client = TFEClient(cfg) + + export_id = args.id + + if args.create: + if not args.plan_id: + print("--plan-id is required for --create") + return 2 + _print_header(f"Creating a plan export for plan: {args.plan_id}") + export = client.plan_exports.create( + PlanExportCreateOptions(plan_id=args.plan_id) + ) + export_id = export.id + print(f"Created plan export: {export.id} (status={export.status})") + + # Poll until the export finishes (it is generated asynchronously). + for _ in range(30): + export = client.plan_exports.read(export_id) + if export.status != PlanExportStatus.QUEUED: + break + time.sleep(1) + print(f"Final status: {export.status}") + + if args.read: + if not export_id: + print("--id is required for --read") + return 2 + _print_header(f"Reading plan export: {export_id}") + export = client.plan_exports.read(export_id) + print(f"ID: {export.id}") + print(f"Data type: {export.data_type}") + print(f"Status: {export.status}") + + if args.download: + if not export_id: + print("--id is required for --download") + return 2 + _print_header(f"Downloading plan export: {export_id}") + data = client.plan_exports.download(export_id) + with open(args.output, "wb") as fh: + fh.write(data) + print(f"Wrote {len(data)} bytes to {args.output}") + + if args.delete and export_id: + _print_header(f"Deleting plan export: {export_id}") + client.plan_exports.delete(export_id) + print("Deleted.") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/registry.py b/examples/registry.py new file mode 100644 index 00000000..b41aa539 --- /dev/null +++ b/examples/registry.py @@ -0,0 +1,89 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Public Terraform Registry (module API) demo for the python-tfe SDK. + +The public registry (registry.terraform.io) is unauthenticated, so this example +does not need a token. Use ``--base-url`` to target another registry. +""" + +from __future__ import annotations + +import argparse +import itertools +import os + +from pytfe import TFEClient, TFEConfig +from pytfe.models import PublicRegistrySearchOptions +from pytfe.resources.registry import Registry + + +def _print_header(title: str) -> None: + print("\n" + "=" * 80) + print(title) + print("=" * 80) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Public Terraform Registry demo for python-tfe SDK" + ) + parser.add_argument( + "--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io") + ) + parser.add_argument("--token", default=os.getenv("TFE_TOKEN", "")) + parser.add_argument("--base-url", default=None, help="Registry base URL override") + parser.add_argument("--search", help="Search modules by keyword") + parser.add_argument( + "--namespace", default="terraform-aws-modules", help="Module namespace" + ) + parser.add_argument("--name", default="vpc", help="Module name") + parser.add_argument("--provider", default="aws", help="Module provider") + parser.add_argument("--limit", type=int, default=5, help="Max rows to print") + args = parser.parse_args() + + client = TFEClient(TFEConfig(address=args.address, token=args.token)) + registry = ( + Registry(client._transport, base_url=args.base_url) + if args.base_url + else client.registry + ) + + if args.search: + _print_header(f"Searching modules for: {args.search}") + opts = PublicRegistrySearchOptions(provider=args.provider) + for m in itertools.islice( + registry.search_modules(args.search, opts), args.limit + ): + print(f" {m.id:50} downloads={m.downloads} verified={m.verified}") + return 0 + + _print_header(f"Latest: {args.namespace}/{args.name}/{args.provider}") + module = registry.latest_for_provider(args.namespace, args.name, args.provider) + print(f" id: {module.id}") + print(f" description: {module.description}") + print(f" source: {module.source}") + print(f" inputs: {len(module.root.inputs or []) if module.root else 0}") + print(f" providers: {module.providers}") + + _print_header("Available versions") + versions = registry.list_versions(args.namespace, args.name, args.provider) + vlist = [v.version for v in versions.versions] + sample = f"{vlist[0]} … {vlist[-1]}" if vlist else "n/a" + print(f" {len(vlist)} versions (range: {sample}); current: {module.version}") + + _print_header("Download source (X-Terraform-Get)") + print(f" {registry.latest_download_url(args.namespace, args.name, args.provider)}") + + _print_header("Download metrics") + summary = registry.downloads_summary(args.namespace, args.name, args.provider) + print( + f" week={summary.week} month={summary.month} " + f"year={summary.year} total={summary.total}" + ) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pyproject.toml b/pyproject.toml index 46c7c495..1d7d182e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "pytfe" -version = "1.1.0" +version = "1.2.0" description = "Official Python SDK for HashiCorp Terraform Cloud / Terraform Enterprise (TFE) API v2" readme = "README.md" license = { text = "MPL-2.0" } @@ -92,7 +92,7 @@ skip-magic-trailing-comma = false line-ending = "auto" [tool.ruff.lint.isort] -known-first-party = ["python_tfe"] +known-first-party = ["pytfe"] # MyPy configuration [tool.mypy] @@ -116,4 +116,13 @@ module = "tests.*" disallow_untyped_defs = false [tool.hatch.build.targets.sdist] -include = ["src/**", "README.md", "LICENSE", "docs/**"] +include = [ + "src/**", + "README.md", + "LICENSE", + "CHANGELOG.md", + "AGENTS.md", + "docs/**", + "examples/**", +] +exclude = ["**/__pycache__", "**/*.pyc"] diff --git a/src/pytfe/__init__.py b/src/pytfe/__init__.py index 82e9b788..7ccf2e29 100644 --- a/src/pytfe/__init__.py +++ b/src/pytfe/__init__.py @@ -5,6 +5,7 @@ from importlib.metadata import version as _pkg_version from . import errors, models +from ._introspect import describe, llms_txt from ._logging import setup_logging from .client import TFEClient from .config import TFEConfig @@ -20,5 +21,7 @@ "errors", "models", "setup_logging", + "describe", + "llms_txt", "__version__", ] diff --git a/src/pytfe/_http.py b/src/pytfe/_http.py index c151694f..8a79b8a5 100644 --- a/src/pytfe/_http.py +++ b/src/pytfe/_http.py @@ -113,6 +113,13 @@ def request( self._sleep(attempt, None) attempt += 1 continue + # This SDK authenticates with a bearer token, never cookies. Some + # endpoints (notably /api/meta/ip-ranges on app.terraform.io) return + # a Set-Cookie session cookie; if the shared client retains it, that + # session silently overrides bearer auth on subsequent requests and + # the API responds 404/401. Never let cookies persist across requests. + if self._sync.cookies: + self._sync.cookies.clear() if resp.status_code in _RETRY_STATUSES and attempt < self.max_retries: retry_after = _parse_retry_after(resp) transport_logger.info( diff --git a/src/pytfe/_introspect.py b/src/pytfe/_introspect.py new file mode 100644 index 00000000..c8dfb03b --- /dev/null +++ b/src/pytfe/_introspect.py @@ -0,0 +1,145 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Discovery helpers for AI agents, MCP servers, and tooling. + +These functions let a consumer enumerate the SDK's surface from the *installed* +package alone — no network access and no hardcoded resource names: + +* :func:`describe` introspects every resource namespace on + :class:`pytfe.TFEClient` and returns a machine-readable manifest of methods, + signatures, and one-line summaries. The Pydantic ``*Options`` models referenced + in those signatures expose JSON Schema via ``model_json_schema()``, which is + exactly what an MCP tool definition needs. +* :func:`llms_txt` returns the packaged ``llms.txt`` orientation guide. +""" + +from __future__ import annotations + +import inspect +from importlib.resources import files +from typing import Any + +from pydantic import BaseModel + +from ._http import HTTPTransport + +__all__ = ["describe", "llms_txt"] + + +def _summary(obj: Any) -> str | None: + """Return the first line of an object's docstring, if any.""" + doc = inspect.getdoc(obj) + if not doc: + return None + return doc.strip().splitlines()[0] + + +def _service_like(value: Any) -> bool: + """True for pytfe resource services / namespaces worth introspecting. + + Excludes the HTTP transport, Pydantic models, and non-pytfe values (e.g. the + plain ``base_url`` string the registry service holds) so they never get + mistaken for a resource namespace. + """ + if isinstance(value, (HTTPTransport, BaseModel)): + return False + return type(value).__module__.split(".")[0] == "pytfe" + + +def _methods(obj: Any) -> dict[str, dict[str, Any]]: + """Map public method name -> {signature, summary} for a resource object.""" + out: dict[str, dict[str, Any]] = {} + cls = type(obj) + for name, member in inspect.getmembers(obj, callable): + if name.startswith("_") or not hasattr(cls, name): + continue + try: + signature = str(inspect.signature(member)) + except (TypeError, ValueError): + signature = "(...)" + out[name] = {"signature": signature, "summary": _summary(member)} + return out + + +def _describe_obj(obj: Any) -> dict[str, Any]: + """Describe one resource service, recursing into grouping namespaces.""" + entry: dict[str, Any] = {"class": type(obj).__name__} + summary = _summary(obj) + if summary: + entry["summary"] = summary + + methods = _methods(obj) + if methods: + entry["methods"] = methods + + namespaces: dict[str, Any] = {} + for sub_name, sub in sorted(vars(obj).items()): + if sub_name.startswith("_") or not _service_like(sub): + continue + namespaces[sub_name] = _describe_obj(sub) + if namespaces: + entry["namespaces"] = namespaces + + return entry + + +def describe() -> dict[str, Any]: + """Return a machine-readable manifest of the SDK's API surface. + + Introspects every resource namespace on :class:`pytfe.TFEClient` and its + public methods (name, signature, one-line summary), recursing into grouping + namespaces such as ``admin``. No network calls are made; a throwaway client + is constructed with an empty config purely to enumerate the wiring. + + The shape is:: + + { + "sdk": "pytfe", + "version": "1.2.0", + "client": "pytfe.TFEClient", + "resource_count": 70, + "resources": { + "workspaces": { + "class": "Workspaces", + "summary": "...", + "methods": {"list": {"signature": "(...)", "summary": "..."}, ...}, + }, + "admin": {"class": "AdminClient", "namespaces": {...}}, + ... + }, + } + + Intended for AI agents and MCP servers that need to enumerate the SDK + without hardcoding resource names. Combine each method's ``*Options`` model + with ``model_json_schema()`` to build typed tool definitions. + """ + from . import __version__ + from .client import TFEClient + from .config import TFEConfig + + client = TFEClient(TFEConfig(address="", token="")) + try: + resources: dict[str, Any] = {} + for name, obj in sorted(vars(client).items()): + if name.startswith("_") or not _service_like(obj): + continue + resources[name] = _describe_obj(obj) + return { + "sdk": "pytfe", + "version": __version__, + "client": "pytfe.TFEClient", + "resource_count": len(resources), + "resources": resources, + } + finally: + client.close() + + +def llms_txt() -> str: + """Return the packaged ``llms.txt`` orientation guide as text. + + The guide ships inside the wheel (``site-packages/pytfe/llms.txt``) so AI + tooling can read a concise description of the SDK from the installed package. + """ + return (files("pytfe") / "llms.txt").read_text(encoding="utf-8") diff --git a/src/pytfe/client.py b/src/pytfe/client.py index 63a9b751..14536448 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -9,10 +9,16 @@ from .resources.agent_pools import AgentPools from .resources.agents import Agents, AgentTokens from .resources.apply import Applies +from .resources.assessment_result import AssessmentResults +from .resources.cidr_range_list import CIDRRangeLists, CIDRRanges from .resources.comment import Comments from .resources.configuration_version import ConfigurationVersions +from .resources.cost_estimate import CostEstimates from .resources.explorer import Explorer from .resources.github_app_installation import GitHubAppInstallations +from .resources.hyok_configuration import HYOKConfigurations +from .resources.invoice import Invoices +from .resources.ip_ranges import IPRanges from .resources.no_code_module import NoCodeModules from .resources.notification_configuration import NotificationConfigurations from .resources.oauth_client import OAuthClients @@ -30,6 +36,7 @@ from .resources.organization_token import OrganizationTokens from .resources.organizations import Organizations from .resources.plan import Plans +from .resources.plan_export import PlanExports from .resources.policy import Policies from .resources.policy_check import PolicyChecks from .resources.policy_evaluation import PolicyEvaluations @@ -39,6 +46,7 @@ from .resources.policy_set_version import PolicySetVersions from .resources.projects import Projects from .resources.query_run import QueryRuns +from .resources.registry import Registry from .resources.registry_module import RegistryModules from .resources.registry_provider import RegistryProviders from .resources.registry_provider_platform import RegistryProviderPlatforms @@ -54,6 +62,7 @@ from .resources.stack_configuration import StackConfigurations from .resources.state_version_outputs import StateVersionOutputs from .resources.state_versions import StateVersions +from .resources.subscription import Subscriptions from .resources.task_result import TaskResults from .resources.task_stage import TaskStages from .resources.team import Teams @@ -69,7 +78,44 @@ class TFEClient: + """Synchronous client for the HCP Terraform / Terraform Enterprise V2 API. + + The client is the composition root: construct it once, then reach every + resource through a namespaced attribute and call standard verbs on it:: + + from pytfe import TFEClient, TFEConfig + + with TFEClient(TFEConfig(address="https://app.terraform.io", token="...")) as tfe: + for ws in tfe.workspaces.list("my-org"): + print(ws.id, ws.name) + + When no :class:`TFEConfig` is supplied, configuration falls back to the + ``TFE_ADDRESS`` and ``TFE_TOKEN`` environment variables. + + Resources are exposed as attributes — e.g. ``workspaces``, ``runs``, + ``plans``, ``applies``, ``variables``, ``variable_sets``, ``teams``, + ``projects``, ``organizations``, ``state_versions``, ``policies``, + ``policy_sets``, ``registry_modules``, ``configuration_versions`` — plus the + ``admin`` namespace for Terraform Enterprise site administration, and ~70 + more. Call :func:`pytfe.describe` for a complete, machine-readable map of + every resource, method, and signature. + + Conventions worth knowing: + + * ``list`` / ``list_*`` methods return a single-use :class:`Iterator`; + pagination is handled transparently. Wrap with ``list(...)`` to materialise. + * Write methods take a typed ``*Options`` Pydantic model; identifiers come + first, options last. + * Errors raise typed :class:`pytfe.errors.TFEError` subclasses. + * Logging is silent by default; opt in with ``PYTFE_LOG=debug`` or + :func:`pytfe.setup_logging`. + + The client holds a pooled HTTP connection. Use it as a context manager (as + above) or call :meth:`close` when done. + """ + def __init__(self, config: TFEConfig | None = None): + """Build a client from ``config`` (or env vars when ``config`` is None).""" cfg = config or TFEConfig.from_env() self._transport = HTTPTransport( cfg.address, @@ -109,7 +155,19 @@ def __init__(self, config: TFEConfig | None = None): self.notification_configurations = NotificationConfigurations(self._transport) self.applies = Applies(self._transport) self.plans = Plans(self._transport) + self.plan_exports = PlanExports(self._transport) + self.cost_estimates = CostEstimates(self._transport) + # Workspace health assessments (drift detection / continuous validation) + self.assessment_results = AssessmentResults(self._transport) + # Meta endpoint: HCP Terraform / TFE outbound IP ranges + self.ip_ranges = IPRanges(self._transport) + # IP allowlists (JSON:API cidr-range-lists / cidr-ranges) + self.cidr_range_lists = CIDRRangeLists(self._transport) + self.cidr_ranges = CIDRRanges(self._transport) self.organizations = Organizations(self._transport) + # Billing (HCP Terraform only) + self.subscriptions = Subscriptions(self._transport) + self.invoices = Invoices(self._transport) self.organization_memberships = OrganizationMemberships(self._transport) self.organization_audit_configurations = OrganizationAuditConfigurations( self._transport @@ -131,12 +189,16 @@ def __init__(self, config: TFEConfig | None = None): self.workspace_run_tasks = WorkspaceRunTasks(self._transport) self.registry_modules = RegistryModules(self._transport) self.no_code_modules = NoCodeModules(self._transport) + # Public Terraform Registry (registry.terraform.io), unauthenticated + self.registry = Registry(self._transport) # HYOK OIDC configurations (AWS / Azure / GCP / Vault) self.aws_oidc_configurations = AWSOIDCConfigurations(self._transport) self.azure_oidc_configurations = AzureOIDCConfigurations(self._transport) self.gcp_oidc_configurations = GCPOIDCConfigurations(self._transport) self.vault_oidc_configurations = VaultOIDCConfigurations(self._transport) + # HYOK configurations (parent of the OIDC configs above) + self.hyok_configurations = HYOKConfigurations(self._transport) self.registry_providers = RegistryProviders(self._transport) self.registry_provider_versions = RegistryProviderVersions(self._transport) self.registry_provider_platforms = RegistryProviderPlatforms(self._transport) @@ -176,7 +238,20 @@ def __init__(self, config: TFEConfig | None = None): # Reserved Tag Key self.reserved_tag_key = ReservedTagKeys(self._transport) + def __enter__(self) -> TFEClient: + """Enter a runtime context and return the client unchanged.""" + return self + + def __exit__(self, *exc_info: object) -> None: + """Exit the runtime context, releasing pooled HTTP connections.""" + self.close() + def close(self) -> None: + """Close the HTTP transport and release pooled connections. + + Safe to call multiple times. Prefer using the client as a context + manager (``with TFEClient(...) as tfe:``) so this runs automatically. + """ try: self._transport._sync.close() except Exception: diff --git a/src/pytfe/errors.py b/src/pytfe/errors.py index 7e4842f3..b7546305 100644 --- a/src/pytfe/errors.py +++ b/src/pytfe/errors.py @@ -435,6 +435,111 @@ def __init__(self, message: str = "invalid value for apply ID"): super().__init__(message) +# Plan export errors +class InvalidPlanExportIDError(InvalidValues): + """Raised when an invalid plan export ID is provided.""" + + def __init__(self, message: str = "invalid value for plan export ID"): + super().__init__(message) + + +class RequiredPlanError(RequiredFieldMissing): + """Raised when a plan is required but was not provided.""" + + def __init__(self, message: str = "plan is required"): + super().__init__(message) + + +# Cost estimate errors +class InvalidCostEstimateIDError(InvalidValues): + """Raised when an invalid cost estimate ID is provided.""" + + def __init__(self, message: str = "invalid value for cost estimate ID"): + super().__init__(message) + + +# Assessment result errors +class InvalidAssessmentResultIDError(InvalidValues): + """Raised when an invalid assessment result ID is provided.""" + + def __init__(self, message: str = "invalid value for assessment result ID"): + super().__init__(message) + + +# HYOK (Hold Your Own Key) configuration errors +class InvalidHYOKConfigurationIDError(InvalidValues): + """Raised when an invalid HYOK configuration ID is provided.""" + + def __init__(self, message: str = "invalid value for HYOK configuration ID"): + super().__init__(message) + + +class RequiredKEKIDError(RequiredFieldMissing): + """Raised when a key-encryption-key id (kek-id) is required but not provided.""" + + def __init__(self, message: str = "kek-id is required"): + super().__init__(message) + + +# Subscription errors +class InvalidSubscriptionIDError(InvalidValues): + """Raised when an invalid subscription ID is provided.""" + + def __init__(self, message: str = "invalid value for subscription ID"): + super().__init__(message) + + +# IP allowlist (CIDR range list) errors +class InvalidCIDRRangeListIDError(InvalidValues): + """Raised when an invalid CIDR range list (IP allowlist) ID is provided.""" + + def __init__(self, message: str = "invalid value for CIDR range list ID"): + super().__init__(message) + + +class InvalidCIDRRangeIDError(InvalidValues): + """Raised when an invalid CIDR range ID is provided.""" + + def __init__(self, message: str = "invalid value for CIDR range ID"): + super().__init__(message) + + +class RequiredCIDRBlockError(RequiredFieldMissing): + """Raised when a CIDR block is required but was not provided.""" + + def __init__(self, message: str = "cidr-block is required"): + super().__init__(message) + + +# Public Terraform Registry (module) errors +class InvalidModuleNamespaceError(InvalidValues): + """Raised when an invalid registry module namespace is provided.""" + + def __init__(self, message: str = "invalid value for module namespace"): + super().__init__(message) + + +class InvalidModuleNameError(InvalidValues): + """Raised when an invalid registry module name is provided.""" + + def __init__(self, message: str = "invalid value for module name"): + super().__init__(message) + + +class InvalidModuleProviderError(InvalidValues): + """Raised when an invalid registry module provider is provided.""" + + def __init__(self, message: str = "invalid value for module provider"): + super().__init__(message) + + +class InvalidModuleVersionError(InvalidValues): + """Raised when an invalid registry module version is provided.""" + + def __init__(self, message: str = "invalid value for module version"): + super().__init__(message) + + # Run Event errors class InvalidRunEventIDError(InvalidValues): """Raised when an invalid run event ID is provided.""" diff --git a/src/pytfe/llms.txt b/src/pytfe/llms.txt new file mode 100644 index 00000000..a498c0d7 --- /dev/null +++ b/src/pytfe/llms.txt @@ -0,0 +1,74 @@ +# pytfe + +> Official Python SDK for the HCP Terraform and Terraform Enterprise (TFE) V2 API. +> A single `TFEClient` exposes ~70 resource namespaces (workspaces, runs, plans, +> applies, variables, teams, projects, policies, state versions, the registry, and +> a TFE `admin` namespace). Synchronous, fully type-hinted (ships `py.typed`), +> built on httpx + Pydantic v2. + +This file orients an AI agent or MCP server that has `pytfe` installed. For the +complete, always-accurate API surface (every resource, method, and signature), +call `pytfe.describe()` at runtime — do not rely on a hardcoded list here. + +## Install + + pip install pytfe + +## Quickstart + + from pytfe import TFEClient, TFEConfig + + # Explicit config (recommended). Falls back to TFE_ADDRESS / TFE_TOKEN env vars. + with TFEClient(TFEConfig(address="https://app.terraform.io", token="...")) as tfe: + for ws in tfe.workspaces.list("my-org"): + print(ws.id, ws.name) + +The client is the composition root: construct it once, then reach every resource +through a namespaced attribute and call standard verbs on it: + + tfe..(, ) + tfe.workspaces.read("my-workspace", organization="my-org") + tfe.runs.create(RunCreateOptions(workspace_id="ws-...")) + +## Conventions (read before generating code) + +- Verbs: `list`, `read`, `create`, `update`, `delete`, plus `add_*` / `remove_*` + for relationships. Identifiers come first, the options model comes last. +- `list` / `list_*` return a single-use `Iterator[X]` (pagination is handled + transparently). Wrap with `list(...)` to materialize. +- Write methods take a typed Pydantic `*Options` model (e.g. `WorkspaceCreateOptions`). + Each options model exposes JSON Schema via `model_json_schema()` — ideal for + building MCP tool definitions. +- Errors raise typed `pytfe.errors.TFEError` subclasses (e.g. `NotFound`, + `AuthError`, `InvalidWorkspaceIDError`). Catch `TFEError` to catch them all. +- Logging is silent by default. Opt in with `PYTFE_LOG=debug` or + `pytfe.setup_logging()`. Bearer tokens and secrets are auto-redacted. +- Use the client as a context manager (or call `tfe.close()`) to release the + pooled HTTP connection. + +## Discovery (machine-readable) + + import pytfe + + manifest = pytfe.describe() # {sdk, version, client, resource_count, resources{...}} + list(manifest["resources"]) # every resource namespace + manifest["resources"]["runs"]["methods"] # methods + signatures + summaries + + print(pytfe.llms_txt()) # this document, from the installed package + +`pytfe.describe()` makes no network calls; it introspects the client wiring. + +## Top-level exports + +- `TFEClient` — the client / composition root. +- `TFEConfig` — auth, timeout, retry, proxy, TLS settings. +- `errors` — typed exception hierarchy (`TFEError` + subclasses). +- `models` — Pydantic request/response models and `*Options` types. +- `setup_logging` — opt-in stdlib logging configuration. +- `describe`, `llms_txt` — discovery helpers (this section). + +## More (GitHub repository, not shipped in the wheel) + +- README and docs: https://github.com/hashicorp/python-tfe +- Runnable examples (one per resource): https://github.com/hashicorp/python-tfe/tree/main/examples +- Upstream API reference: https://developer.hashicorp.com/terraform/cloud-docs/api-docs diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index d12eb344..0fb9efc1 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -81,6 +81,18 @@ AgentTokenListOptions, ) from .assessment_result import AssessmentResult + +# ── IP allowlists (CIDR range lists) ────────────────────────────────────────── +from .cidr_range_list import ( + CIDRRange, + CIDRRangeCreateOptions, + CIDRRangeList, + CIDRRangeListCreateOptions, + CIDRRangeListListOptions, + CIDRRangeListUpdateOptions, + CIDRRangeUpdateOptions, + EnforcementScope, +) from .comment import ( Comment, CommentCreateOptions, @@ -108,6 +120,13 @@ IngressAttributes, ) +# ── Cost Estimates ──────────────────────────────────────────────────────────── +from .cost_estimate import ( + CostEstimate, + CostEstimateStatus, + CostEstimateStatusTimestamps, +) + # Data retention policy family from .data_retention_policy import ( DataRetentionPolicy, @@ -137,6 +156,22 @@ GitHubAppInstallationType, ) +# ── HYOK configurations ─────────────────────────────────────────────────────── +from .hyok_configuration import ( + HYOKConfiguration, + HYOKConfigurationCreateOptions, + HYOKConfigurationListOptions, + HYOKConfigurationStatus, + HYOKKMSOptions, + OIDCConfigurationType, +) + +# ── Invoices (billing) ──────────────────────────────────────────────────────── +from .invoice import Invoice + +# ── IP Ranges ───────────────────────────────────────────────────────────────── +from .ip_range import IPRange + # ── Notification Configurations ─────────────────────────────────────────────── from .no_code_module import ( NoCodeModule, @@ -247,6 +282,15 @@ OrganizationTokenReadOptions, TokenType, ) + +# ── Plan Exports ────────────────────────────────────────────────────────────── +from .plan_export import ( + PlanExport, + PlanExportCreateOptions, + PlanExportDataType, + PlanExportStatus, + PlanExportStatusTimestamps, +) from .policy import ( Policy, PolicyCreateOptions, @@ -294,6 +338,7 @@ PolicySetRemoveWorkspacesOptions, PolicySetUpdateOptions, ) +from .policy_set_outcome import PolicySetOutcomeListOptions from .policy_set_parameter import ( PolicySetParameter, PolicySetParameterCreateOptions, @@ -328,6 +373,22 @@ QueryRunVariable, ) +# ── Public Terraform Registry (module API) ──────────────────────────────────── +from .registry import ( + PublicRegistryModule, + PublicRegistryModuleDetail, + PublicRegistryModuleDownloadsSummary, + PublicRegistryModuleInput, + PublicRegistryModuleListOptions, + PublicRegistryModuleOutput, + PublicRegistryModuleProviderDependency, + PublicRegistryModuleResource, + PublicRegistryModuleVersion, + PublicRegistryModuleVersions, + PublicRegistryPagination, + PublicRegistrySearchOptions, +) + # ── Registry Modules / Providers ────────────────────────────────────────────── # (Old: .registry_module_types / .registry_provider_types) → import from real modules from .registry_module import ( @@ -487,6 +548,7 @@ StateVersion, StateVersionCreateOptions, StateVersionCurrentOptions, + StateVersionIncludeOpt, StateVersionListOptions, StateVersionReadOptions, ) @@ -495,6 +557,9 @@ StateVersionOutputsListOptions, ) +# ── Subscriptions (billing) ─────────────────────────────────────────────────── +from .subscription import Subscription + # ── Task Result ─────────────────────────────────────────────────────────────── from .task_result import ( TaskEnforcementLevel as TaskResultEnforcementLevel, @@ -760,6 +825,19 @@ "Output", "ProviderDependency", "PublishingMechanism", + # Public Terraform Registry (module API) + "PublicRegistryModule", + "PublicRegistryModuleDetail", + "PublicRegistryModuleDownloadsSummary", + "PublicRegistryModuleInput", + "PublicRegistryModuleListOptions", + "PublicRegistryModuleOutput", + "PublicRegistryModuleProviderDependency", + "PublicRegistryModuleResource", + "PublicRegistryModuleVersion", + "PublicRegistryModuleVersions", + "PublicRegistryPagination", + "PublicRegistrySearchOptions", "RegistryModule", "RegistryModuleCreateOptions", "RegistryModuleCreateVersionOptions", @@ -985,6 +1063,37 @@ # Comments "Comment", "CommentCreateOptions", + # Cost estimates + "CostEstimate", + "CostEstimateStatus", + "CostEstimateStatusTimestamps", + # Plan exports + "PlanExport", + "PlanExportCreateOptions", + "PlanExportDataType", + "PlanExportStatus", + "PlanExportStatusTimestamps", + # IP ranges + "IPRange", + # Billing + "Invoice", + "Subscription", + # HYOK configurations + "HYOKConfiguration", + "HYOKConfigurationCreateOptions", + "HYOKConfigurationListOptions", + "HYOKConfigurationStatus", + "HYOKKMSOptions", + "OIDCConfigurationType", + # IP allowlists (CIDR range lists) + "CIDRRange", + "CIDRRangeCreateOptions", + "CIDRRangeList", + "CIDRRangeListCreateOptions", + "CIDRRangeListListOptions", + "CIDRRangeListUpdateOptions", + "CIDRRangeUpdateOptions", + "EnforcementScope", # Run tasks "RunTask", "RunTaskIncludeOptions", @@ -1061,6 +1170,8 @@ "PolicySetRemoveProjectsOptions", "PolicySetRemoveProjectExclusionsOptions", "PolicySetUpdateOptions", + # Policy Set Outcomes (BETA) + "PolicySetOutcomeListOptions", # Policy Set Parameters "PolicySetParameter", "PolicySetParameterCreateOptions", @@ -1089,6 +1200,7 @@ "StateVersion", "StateVersionCreateOptions", "StateVersionCurrentOptions", + "StateVersionIncludeOpt", "StateVersionListOptions", "StateVersionReadOptions", # State Version Outputs diff --git a/src/pytfe/models/assessment_result.py b/src/pytfe/models/assessment_result.py index a1b02df5..0fa0b63f 100644 --- a/src/pytfe/models/assessment_result.py +++ b/src/pytfe/models/assessment_result.py @@ -5,10 +5,12 @@ from datetime import datetime -from pydantic import BaseModel, ConfigDict, Field +from pydantic import ConfigDict, Field +from ._base import TFEModel -class AssessmentResult(BaseModel): + +class AssessmentResult(TFEModel): """Result of a workspace health assessment (drift detection).""" model_config = ConfigDict( diff --git a/src/pytfe/models/cidr_range_list.py b/src/pytfe/models/cidr_range_list.py new file mode 100644 index 00000000..59848a74 --- /dev/null +++ b/src/pytfe/models/cidr_range_list.py @@ -0,0 +1,145 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Models for IP allowlists (JSON:API ``cidr-range-lists`` / ``cidr-ranges``). + +HCP Terraform's "IP allowlist" feature is exposed on the wire as two JSON:API +resources: ``cidr-range-lists`` (the allowlist itself) and ``cidr-ranges`` (the +CIDR blocks it contains). +""" + +from __future__ import annotations + +from datetime import datetime +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from ..errors import RequiredCIDRBlockError, RequiredNameError +from ..utils import valid_string +from ._base import TFEModel + + +class EnforcementScope(str, Enum): + """Where an IP allowlist applies. + + Wire values use underscores (per the API request body and sample payloads). + """ + + ORGANIZATION = "organization" + ALL_AGENT_POOLS = "all_agent_pools" + SELECTED_AGENT_POOLS = "selected_agent_pools" + + +class CIDRRange(TFEModel): + """A single CIDR block belonging to an IP allowlist. + + The CIDR value is sent and returned on the wire as ``range``; it is exposed + here as ``cidr_block`` for clarity (and to avoid shadowing the ``range`` + builtin). + """ + + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) + + id: str + cidr_block: str | None = Field(default=None, alias="range") + description: str | None = Field(default=None) + enabled: bool | None = Field(default=None) + updated_at: datetime | None = Field(default=None, alias="updated-at") + + +class CIDRRangeList(TFEModel): + """An IP allowlist (a named, scoped set of CIDR ranges).""" + + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) + + id: str + name: str | None = Field(default=None) + description: str | None = Field(default=None) + enforcement_scope: EnforcementScope | None = Field( + default=None, alias="enforcement-scope" + ) + cidr_ranges: list[CIDRRange] | None = Field(default=None, alias="cidr-ranges") + updated_at: datetime | None = Field(default=None, alias="updated-at") + + +class CIDRRangeListCreateOptions(BaseModel): + """Options for creating an IP allowlist.""" + + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="forbid" + ) + + name: str = Field(..., description="Name of the IP allowlist.") + description: str | None = Field(default=None) + enforcement_scope: EnforcementScope | None = Field( + default=None, alias="enforcement-scope" + ) + + @model_validator(mode="after") + def valid(self) -> CIDRRangeListCreateOptions: + if not valid_string(self.name): + raise RequiredNameError() + return self + + +class CIDRRangeListUpdateOptions(BaseModel): + """Options for updating an IP allowlist. Omitted fields are preserved.""" + + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="forbid" + ) + + name: str | None = Field(default=None) + description: str | None = Field(default=None) + enforcement_scope: EnforcementScope | None = Field( + default=None, alias="enforcement-scope" + ) + + +class CIDRRangeListListOptions(BaseModel): + """Options for listing IP allowlists in an organization.""" + + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="forbid" + ) + + q: str | None = Field(default=None, description="Case-insensitive name search.") + page_number: int | None = Field(default=None, alias="page[number]") + page_size: int | None = Field(default=None, alias="page[size]") + + +class CIDRRangeCreateOptions(BaseModel): + """Options for adding a CIDR range to an IP allowlist.""" + + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="forbid" + ) + + cidr_block: str = Field( + ..., alias="range", description='A CIDR block (e.g. "192.168.1.0/24").' + ) + description: str | None = Field(default=None) + enabled: bool | None = Field(default=None) + + @model_validator(mode="after") + def valid(self) -> CIDRRangeCreateOptions: + if not valid_string(self.cidr_block): + raise RequiredCIDRBlockError() + return self + + +class CIDRRangeUpdateOptions(BaseModel): + """Options for updating a CIDR range. Omitted fields are preserved.""" + + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="forbid" + ) + + cidr_block: str | None = Field(default=None, alias="range") + description: str | None = Field(default=None) + enabled: bool | None = Field(default=None) diff --git a/src/pytfe/models/cost_estimate.py b/src/pytfe/models/cost_estimate.py index 5cd8386b..6a7e8da7 100644 --- a/src/pytfe/models/cost_estimate.py +++ b/src/pytfe/models/cost_estimate.py @@ -8,15 +8,17 @@ from pydantic import BaseModel, ConfigDict, Field +from ._base import TFEModel -class CostEstimate(BaseModel): + +class CostEstimate(TFEModel): 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") - error_message: str = Field(default="", alias="error-message") + error_message: str | None = Field(default=None, alias="error-message") matched_resources_count: int = Field(default=0, alias="matched-resources-count") prior_monthly_cost: str = Field(default="", alias="prior-monthly-cost") proposed_monthly_cost: str = Field(default="", alias="proposed-monthly-cost") @@ -42,10 +44,11 @@ class CostEstimateStatusTimestamps(BaseModel): populate_by_name=True, validate_by_name=True, extra="allow" ) - canceled_at: datetime = Field(..., alias="canceled-at") - errored_at: datetime = Field(..., alias="errored-at") - finished_at: datetime = Field(..., alias="finished-at") - queued_at: datetime = Field(..., alias="queued-at") - skipped_due_to_targeting_at: datetime = Field( - ..., alias="skipped-due-to-targeting-at" + canceled_at: datetime | None = Field(default=None, alias="canceled-at") + errored_at: datetime | None = Field(default=None, alias="errored-at") + finished_at: datetime | None = Field(default=None, alias="finished-at") + pending_at: datetime | None = Field(default=None, alias="pending-at") + queued_at: datetime | None = Field(default=None, alias="queued-at") + skipped_due_to_targeting_at: datetime | None = Field( + default=None, alias="skipped-due-to-targeting-at" ) diff --git a/src/pytfe/models/hyok_configuration.py b/src/pytfe/models/hyok_configuration.py new file mode 100644 index 00000000..bd6609e0 --- /dev/null +++ b/src/pytfe/models/hyok_configuration.py @@ -0,0 +1,128 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Models for HYOK (Hold Your Own Key) configurations. + +A HYOK configuration ties together an OIDC configuration (how HCP Terraform +authenticates to your KMS), an agent pool, and a key-encryption-key id, so HCP +Terraform can encrypt workspace state/plan data with a key you control. +""" + +from __future__ import annotations + +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from ..errors import ( + InvalidAgentPoolIDError, + InvalidOIDCConfigurationIDError, + RequiredKEKIDError, + RequiredNameError, +) +from ..utils import valid_string, valid_string_id +from ._base import TFEModel + + +class HYOKConfigurationStatus(str, Enum): + """Lifecycle status of a HYOK configuration.""" + + UNTESTED = "untested" + AVAILABLE = "available" + TESTING = "testing" + TEST_FAILED = "test_failed" + ACTIVE = "active" + REVOKING = "revoking" + REVOKED = "revoked" + ERRORED = "errored" + + +class OIDCConfigurationType(str, Enum): + """JSON:API type of the OIDC configuration a HYOK config authenticates with.""" + + AWS = "aws-oidc-configurations" + AZURE = "azure-oidc-configurations" + GCP = "gcp-oidc-configurations" + VAULT = "vault-oidc-configurations" + + +class HYOKKMSOptions(BaseModel): + """Optional KMS-specific options for a HYOK configuration.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + key_region: str | None = None + key_location: str | None = None + key_ring_id: str | None = None + + +class HYOKConfiguration(TFEModel): + """A Hold Your Own Key configuration.""" + + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) + + id: str + name: str | None = None + kek_id: str | None = Field(default=None, alias="kek-id") + kms_options: HYOKKMSOptions | None = Field(default=None, alias="kms-options") + primary: bool | None = None + status: HYOKConfigurationStatus | None = None + error: str | None = None + # Flat relationship references (the raw block is on `.relationships`). + organization_id: str | None = Field(default=None, alias="organization-id") + agent_pool_id: str | None = Field(default=None, alias="agent-pool-id") + oidc_configuration_id: str | None = Field( + default=None, alias="oidc-configuration-id" + ) + oidc_configuration_type: str | None = Field( + default=None, alias="oidc-configuration-type" + ) + + +class HYOKConfigurationCreateOptions(BaseModel): + """Options for creating a HYOK configuration.""" + + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="forbid" + ) + + name: str = Field(..., description="Label for the HYOK configuration.") + kek_id: str = Field( + ..., alias="kek-id", description="Name/id of your key in the KMS." + ) + agent_pool_id: str = Field(..., description="ID of the agent pool to use.") + oidc_configuration_id: str = Field( + ..., description="ID of the OIDC configuration to authenticate with." + ) + oidc_configuration_type: OIDCConfigurationType = Field( + ..., description="The OIDC configuration's JSON:API type (cloud)." + ) + primary: bool | None = Field( + default=None, description="Whether this is the primary HYOK configuration." + ) + kms_options: HYOKKMSOptions | None = Field(default=None, alias="kms-options") + + @model_validator(mode="after") + def valid(self) -> HYOKConfigurationCreateOptions: + if not valid_string(self.name): + raise RequiredNameError() + if not valid_string(self.kek_id): + raise RequiredKEKIDError() + if not valid_string_id(self.agent_pool_id): + raise InvalidAgentPoolIDError() + if not valid_string_id(self.oidc_configuration_id): + raise InvalidOIDCConfigurationIDError() + return self + + +class HYOKConfigurationListOptions(BaseModel): + """Options for listing HYOK configurations in an organization.""" + + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="forbid" + ) + + page_number: int | None = Field(default=None, alias="page[number]") + page_size: int | None = Field(default=None, alias="page[size]") diff --git a/src/pytfe/models/invoice.py b/src/pytfe/models/invoice.py new file mode 100644 index 00000000..d2dd11a5 --- /dev/null +++ b/src/pytfe/models/invoice.py @@ -0,0 +1,33 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Model for an organization's billing invoices.""" + +from __future__ import annotations + +from datetime import datetime + +from pydantic import ConfigDict, Field + +from ._base import TFEModel + + +class Invoice(TFEModel): + """A billing invoice (JSON:API type ``billing-invoices``). + + ``total`` is the invoice amount in the smallest currency unit (e.g. cents). + ``status`` mirrors the billing provider's status (e.g. ``paid``, ``draft``, + ``open``); it is left untyped so new statuses do not break parsing. + """ + + 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") + external_link: str | None = Field(default=None, alias="external-link") + number: str | None = None + paid: bool | None = None + status: str | None = None + total: int | None = None diff --git a/src/pytfe/models/ip_range.py b/src/pytfe/models/ip_range.py new file mode 100644 index 00000000..f9940cac --- /dev/null +++ b/src/pytfe/models/ip_range.py @@ -0,0 +1,24 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +class IPRange(BaseModel): + """HCP Terraform / Terraform Enterprise outbound IP ranges (CIDR notation). + + Returned by ``GET /api/meta/ip-ranges``. This is a bare JSON object (not a + JSON:API resource), so it inherits ``BaseModel`` rather than ``TFEModel``. + The published ranges for each feature may overlap. + """ + + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) + + api: list[str] = Field(default_factory=list) + notifications: list[str] = Field(default_factory=list) + sentinel: list[str] = Field(default_factory=list) + vcs: list[str] = Field(default_factory=list) diff --git a/src/pytfe/models/organization.py b/src/pytfe/models/organization.py index b7a2da45..6f317d7d 100644 --- a/src/pytfe/models/organization.py +++ b/src/pytfe/models/organization.py @@ -252,6 +252,11 @@ class Capacity(BaseModel): class Entitlements(BaseModel): + # Retain every entitlement flag the API returns. The set grows over time and + # includes integer `*-limit` flags; extra="allow" keeps anything not modelled + # as a typed field below in `model_extra` instead of silently dropping it. + model_config = ConfigDict(extra="allow") + id: str agents: bool | None = None audit_logging: bool | None = None @@ -268,6 +273,15 @@ class Entitlements(BaseModel): vcs_integrations: bool | None = None waypoint_actions: bool | None = None waypoint_templates_and_addons: bool | None = None + # Additional flags that map to SDK features (previously dropped). Other flags + # the API returns remain accessible via ``model_extra`` (snake_case keys). + hyok: bool | None = None + assessments: bool | None = None + stacks: bool | None = None + terraform_actions: bool | None = None + change_requests: bool | None = None + ephemeral_workspaces: bool | None = None + no_code_modules: bool | None = None class Run(BaseModel): diff --git a/src/pytfe/models/plan_export.py b/src/pytfe/models/plan_export.py index 008f0dca..39450e34 100644 --- a/src/pytfe/models/plan_export.py +++ b/src/pytfe/models/plan_export.py @@ -3,12 +3,82 @@ from __future__ import annotations -from pydantic import BaseModel, ConfigDict +from datetime import datetime +from enum import Enum +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from ..errors import RequiredPlanError +from ..utils import valid_string_id +from ._base import TFEModel + + +class PlanExportDataType(str, Enum): + """Export format. Currently only the Sentinel mock bundle is supported.""" + + SENTINEL_MOCK_BUNDLE_V0 = "sentinel-mock-bundle-v0" + + +class PlanExportStatus(str, Enum): + """Lifecycle status of a plan export.""" + + CANCELED = "canceled" + ERRORED = "errored" + EXPIRED = "expired" + FINISHED = "finished" + PENDING = "pending" + QUEUED = "queued" + + +class PlanExportStatusTimestamps(BaseModel): + """Timestamps for plan-export status transitions. + + Only the timestamps for statuses the export has actually reached are + returned, so every field is optional. + """ + + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) + + canceled_at: datetime | None = Field(default=None, alias="canceled-at") + errored_at: datetime | None = Field(default=None, alias="errored-at") + expired_at: datetime | None = Field(default=None, alias="expired-at") + finished_at: datetime | None = Field(default=None, alias="finished-at") + queued_at: datetime | None = Field(default=None, alias="queued-at") + + +class PlanExport(TFEModel): + """An export of Terraform plan data (e.g. a Sentinel mock bundle).""" -class PlanExport(BaseModel): model_config = ConfigDict( populate_by_name=True, validate_by_name=True, extra="allow" ) id: str + data_type: PlanExportDataType | None = Field(default=None, alias="data-type") + status: PlanExportStatus | None = Field(default=None, alias="status") + status_timestamps: PlanExportStatusTimestamps | None = Field( + default=None, alias="status-timestamps" + ) + + +class PlanExportCreateOptions(BaseModel): + """Options for exporting data from a finished plan.""" + + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="forbid" + ) + + plan_id: str = Field( + ..., description="ID of the finished plan to export (a `plans` resource)." + ) + data_type: PlanExportDataType = Field( + default=PlanExportDataType.SENTINEL_MOCK_BUNDLE_V0, alias="data-type" + ) + + @model_validator(mode="after") + def valid(self) -> PlanExportCreateOptions: + if not valid_string_id(self.plan_id): + raise RequiredPlanError() + return self diff --git a/src/pytfe/models/registry.py b/src/pytfe/models/registry.py new file mode 100644 index 00000000..0723fc57 --- /dev/null +++ b/src/pytfe/models/registry.py @@ -0,0 +1,158 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Models for the public Terraform Registry module API (registry.terraform.io). + +These are **not** JSON:API resources — the registry returns plain snake_case +JSON with offset/limit pagination — so they inherit ``BaseModel`` and are +prefixed ``PublicRegistry*`` to distinguish them from the HCP Terraform +private-registry models (``RegistryModule`` and friends). +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class PublicRegistryModuleInput(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + name: str | None = None + type: str | None = None + description: str | None = None + default: str | None = None + required: bool | None = None + + +class PublicRegistryModuleOutput(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + name: str | None = None + description: str | None = None + + +class PublicRegistryModuleResource(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + name: str | None = None + type: str | None = None + + +class PublicRegistryModuleProviderDependency(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + name: str | None = None + namespace: str | None = None + source: str | None = None + version: str | None = None + + +class PublicRegistryModuleDetail(BaseModel): + """A module's ``root``, one of its ``submodules``, or an ``examples`` entry.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + path: str | None = None + name: str | None = None + readme: str | None = None + empty: bool | None = None + inputs: list[PublicRegistryModuleInput] | None = None + outputs: list[PublicRegistryModuleOutput] | None = None + dependencies: list[Any] | None = None + provider_dependencies: list[PublicRegistryModuleProviderDependency] | None = None + resources: list[PublicRegistryModuleResource] | None = None + + +class PublicRegistryModule(BaseModel): + """A module entry from the public Terraform Registry.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + id: str | None = None + owner: str | None = None + namespace: str | None = None + name: str | None = None + version: str | None = None + provider: str | None = None + provider_logo_url: str | None = None + description: str | None = None + source: str | None = None + tag: str | None = None + published_at: datetime | None = None + downloads: int | None = None + verified: bool | None = None + root: PublicRegistryModuleDetail | None = None + submodules: list[PublicRegistryModuleDetail] | None = None + examples: list[PublicRegistryModuleDetail] | None = None + providers: list[str] | None = None + versions: list[str] | None = None + deprecation: dict[str, Any] | None = None + + +class PublicRegistryPagination(BaseModel): + """The ``meta`` block of a paginated registry response.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + limit: int | None = None + current_offset: int | None = None + next_offset: int | None = None + next_url: str | None = None + + +class PublicRegistryModuleVersion(BaseModel): + """A single version entry from the versions endpoint.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + version: str | None = None + root: PublicRegistryModuleDetail | None = None + submodules: list[PublicRegistryModuleDetail] | None = None + deprecation: dict[str, Any] | None = None + + +class PublicRegistryModuleVersions(BaseModel): + """The set of available versions for one module (versions endpoint).""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + source: str | None = None + versions: list[PublicRegistryModuleVersion] = Field(default_factory=list) + + +class PublicRegistryModuleDownloadsSummary(BaseModel): + """Module download metrics summary (``/v2`` endpoint).""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + id: str | None = None + week: int | None = None + month: int | None = None + year: int | None = None + total: int | None = None + + +class PublicRegistryModuleListOptions(BaseModel): + """Query options for listing public registry modules.""" + + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + provider: str | None = None + verified: bool | None = None + offset: int | None = None + limit: int | None = None + + +class PublicRegistrySearchOptions(BaseModel): + """Query options for searching public registry modules.""" + + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + provider: str | None = None + namespace: str | None = None + verified: bool | None = None + offset: int | None = None + limit: int | None = None diff --git a/src/pytfe/models/subscription.py b/src/pytfe/models/subscription.py new file mode 100644 index 00000000..65f47dfd --- /dev/null +++ b/src/pytfe/models/subscription.py @@ -0,0 +1,52 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Model for an organization's HCP Terraform subscription.""" + +from __future__ import annotations + +from datetime import datetime + +from pydantic import ConfigDict, Field + +from ._base import TFEModel + + +class Subscription(TFEModel): + """An organization's subscription (its pricing plan / feature set link).""" + + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) + + id: str + is_active: bool | None = Field(default=None, alias="is-active") + start_at: datetime | None = Field(default=None, alias="start-at") + end_at: datetime | None = Field(default=None, alias="end-at") + runs_ceiling: int | None = Field(default=None, alias="runs-ceiling") + agents_ceiling: int | None = Field(default=None, alias="agents-ceiling") + contract_start_at: datetime | None = Field(default=None, alias="contract-start-at") + contract_user_limit: int | None = Field(default=None, alias="contract-user-limit") + contract_apply_limit: int | None = Field(default=None, alias="contract-apply-limit") + run_task_limit: int | None = Field(default=None, alias="run-task-limit") + run_task_workspace_limit: int | None = Field( + default=None, alias="run-task-workspace-limit" + ) + run_task_mandatory_enforcement_limit: int | None = Field( + default=None, alias="run-task-mandatory-enforcement-limit" + ) + policy_set_limit: int | None = Field(default=None, alias="policy-set-limit") + policy_limit: int | None = Field(default=None, alias="policy-limit") + policy_mandatory_enforcement_limit: int | None = Field( + default=None, alias="policy-mandatory-enforcement-limit" + ) + versioned_policy_set_limit: int | None = Field( + default=None, alias="versioned-policy-set-limit" + ) + is_public_free_tier: bool | None = Field(default=None, alias="is-public-free-tier") + is_self_serve_trial: bool | None = Field(default=None, alias="is-self-serve-trial") + # Flat relationship references (the raw block is on `.relationships`; the + # feature set is hydrated into `.included` when present). + organization_id: str | None = Field(default=None, alias="organization-id") + feature_set_id: str | None = Field(default=None, alias="feature-set-id") + billing_account_id: str | None = Field(default=None, alias="billing-account-id") diff --git a/src/pytfe/resources/admin/_organizations.py b/src/pytfe/resources/admin/_organizations.py index 6e43a83e..cb922185 100644 --- a/src/pytfe/resources/admin/_organizations.py +++ b/src/pytfe/resources/admin/_organizations.py @@ -30,6 +30,26 @@ class _AdminOrganizations(_Service): def list( self, options: AdminOrganizationListOptions | None = None ) -> Iterator[AdminOrganization]: + """List all organizations in the Terraform Enterprise admin API. + + Args: + options: Optional search and pagination, as a + :class:`AdminOrganizationListOptions`. + + Returns: + A single-use ``Iterator[AdminOrganization]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import AdminOrganizationListOptions + >>> for org in client.admin.organizations.list( + ... AdminOrganizationListOptions(query="team") + ... ): + ... print(org.name) + """ params: dict[str, Any] = {} if options: if options.query: @@ -42,6 +62,22 @@ def list( yield _parse_admin_organization(item) def read(self, name: str) -> AdminOrganization: + """Read an organization through the Terraform Enterprise admin API. + + Args: + name: The organization name (e.g. ``"my-org"``). + + Returns: + The :class:`AdminOrganization`. + + Raises: + ValueError: If ``name`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> org = client.admin.organizations.read("my-org") + >>> print(org.email) + """ if not valid_string_id(name): raise ValueError(ERR_INVALID_NAME) r = self.t.request("GET", f"/api/v2/admin/organizations/{name}") @@ -50,6 +86,26 @@ def read(self, name: str) -> AdminOrganization: def update( self, name: str, options: AdminOrganizationUpdateOptions ) -> AdminOrganization: + """Update an organization through the Terraform Enterprise admin API. + + Args: + name: The organization name (e.g. ``"my-org"``). + options: The organization changes, as a + :class:`AdminOrganizationUpdateOptions`. + + Returns: + The updated :class:`AdminOrganization`. + + Raises: + ValueError: If ``name`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import AdminOrganizationUpdateOptions + >>> org = client.admin.organizations.update( + ... "my-org", AdminOrganizationUpdateOptions(global_module_sharing=True) + ... ) + """ if not valid_string_id(name): raise ValueError(ERR_INVALID_NAME) attrs = options.model_dump(by_alias=True, exclude_none=True, mode="json") @@ -60,6 +116,21 @@ def update( return _parse_admin_organization(r.json()["data"]) def delete(self, name: str) -> None: + """Delete an organization through the Terraform Enterprise admin API. + + Args: + name: The organization name (e.g. ``"my-org"``). + + Returns: + None. + + Raises: + ValueError: If ``name`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> client.admin.organizations.delete("my-org") + """ if not valid_string_id(name): raise ValueError(ERR_INVALID_NAME) self.t.request("DELETE", f"/api/v2/admin/organizations/{name}") diff --git a/src/pytfe/resources/admin/_runs.py b/src/pytfe/resources/admin/_runs.py index 37040056..4516e542 100644 --- a/src/pytfe/resources/admin/_runs.py +++ b/src/pytfe/resources/admin/_runs.py @@ -34,6 +34,24 @@ def _parse_admin_run(data: dict[str, Any]) -> AdminRun: class _AdminRuns(_Service): def list(self, options: AdminRunListOptions | None = None) -> Iterator[AdminRun]: + """List Terraform Enterprise admin runs. + + Args: + options: Optional filters and pagination, as a :class:`AdminRunListOptions`. + + Returns: + A single-use ``Iterator[AdminRun]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import AdminRunListOptions + >>> runs = client.admin.runs.list(AdminRunListOptions(run_status="planned")) + >>> for run in runs: + ... print(run.id, run.workspace_id) + """ params: dict[str, Any] = {} if options: if options.run_status: @@ -48,6 +66,21 @@ def list(self, options: AdminRunListOptions | None = None) -> Iterator[AdminRun] yield _parse_admin_run(item) def force_cancel(self, run_id: str) -> None: + """Forcefully cancel a Terraform Enterprise admin run. + + Args: + run_id: The run ID (e.g. ``"run-xxxxxxxx"``). + + Returns: + None. + + Raises: + ValueError: If ``run_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.admin.runs.force_cancel("run-CZcmD7eagjhyX0vN") + """ if not valid_string_id(run_id): raise ValueError(ERR_INVALID_NAME) self.t.request("POST", f"/api/v2/admin/runs/{run_id}/actions/force-cancel") diff --git a/src/pytfe/resources/admin/_saml.py b/src/pytfe/resources/admin/_saml.py index 40a14ee2..04147015 100644 --- a/src/pytfe/resources/admin/_saml.py +++ b/src/pytfe/resources/admin/_saml.py @@ -25,16 +25,58 @@ def _parse_jsonapi(data: dict[str, Any], model: type[_M]) -> _M: class _AdminSAMLSettings(_Service): def read(self) -> AdminSAMLSettings: + """Read the Terraform Enterprise SAML settings. + + Returns: + The :class:`AdminSAMLSettings`. + + Raises: + TFEError: If the API request fails. + + Example: + >>> settings = client.admin.saml_settings.read() + >>> print(settings.enabled) + """ r = self.t.request("GET", "/api/v2/admin/saml-settings") return _parse_jsonapi(r.json()["data"], AdminSAMLSettings) def update(self, options: AdminSAMLSettingsUpdateOptions) -> AdminSAMLSettings: + """Update the Terraform Enterprise SAML settings. + + Args: + options: SAML settings fields to update, as a + :class:`AdminSAMLSettingsUpdateOptions`. + + Returns: + The :class:`AdminSAMLSettings`. + + Raises: + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import AdminSAMLSettingsUpdateOptions + >>> settings = client.admin.saml_settings.update( + ... AdminSAMLSettingsUpdateOptions(enabled=True) + ... ) + """ attrs = options.model_dump(by_alias=True, exclude_none=True, mode="json") body = {"data": {"type": _SAML_TYPE, "attributes": attrs}} r = self.t.request("PATCH", "/api/v2/admin/saml-settings", json_body=body) return _parse_jsonapi(r.json()["data"], AdminSAMLSettings) def revoke_idp_cert(self) -> AdminSAMLSettings: + """Revoke the old SAML identity-provider certificate. + + Returns: + The :class:`AdminSAMLSettings`. + + Raises: + TFEError: If the API request fails. + + Example: + >>> settings = client.admin.saml_settings.revoke_idp_cert() + >>> print(settings.old_idp_cert) + """ r = self.t.request( "POST", "/api/v2/admin/saml-settings/actions/revoke-old-certificate", diff --git a/src/pytfe/resources/admin/_scim.py b/src/pytfe/resources/admin/_scim.py index 943c6fcb..a1bbce86 100644 --- a/src/pytfe/resources/admin/_scim.py +++ b/src/pytfe/resources/admin/_scim.py @@ -37,10 +37,40 @@ def _parse_jsonapi(data: dict[str, Any], model: type[_M]) -> _M: class _AdminSCIMSettings(_Service): def read(self) -> AdminSCIMSettings: + """Read the Terraform Enterprise SCIM settings. + + Returns: + The :class:`AdminSCIMSettings`. + + Raises: + TFEError: If the API request fails. + + Example: + >>> settings = client.admin.scim_settings.read() + >>> print(settings.enabled) + """ r = self.t.request("GET", "/api/v2/admin/scim-settings") return _parse_jsonapi(r.json()["data"], AdminSCIMSettings) def update(self, options: AdminSCIMSettingsUpdateOptions) -> AdminSCIMSettings: + """Update the Terraform Enterprise SCIM settings. + + Args: + options: SCIM settings fields to update, as a + :class:`AdminSCIMSettingsUpdateOptions`. + + Returns: + The :class:`AdminSCIMSettings`. + + Raises: + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import AdminSCIMSettingsUpdateOptions + >>> settings = client.admin.scim_settings.update( + ... AdminSCIMSettingsUpdateOptions(enabled=True) + ... ) + """ body = { "data": { "type": _SCIM_SETTINGS_TYPE, @@ -51,11 +81,35 @@ def update(self, options: AdminSCIMSettingsUpdateOptions) -> AdminSCIMSettings: return _parse_jsonapi(r.json()["data"], AdminSCIMSettings) def delete(self) -> None: + """Delete the Terraform Enterprise SCIM settings. + + Returns: + None. + + Raises: + TFEError: If the API request fails. + + Example: + >>> client.admin.scim_settings.delete() + """ self.t.request("DELETE", "/api/v2/admin/scim-settings") class _AdminSCIMTokens(_Service): def list(self) -> Iterator[AdminSCIMToken]: + """List Terraform Enterprise SCIM tokens. + + Returns: + A single-use ``Iterator[AdminSCIMToken]``. Wrap with ``list(...)`` + to materialize the results or iterate more than once. + + Raises: + TFEError: If the API request fails. + + Example: + >>> for token in client.admin.scim_tokens.list(): + ... print(token.id, token.description) + """ # The upstream endpoint is not documented as paginated, but the # response is still a JSON:API list. We use a single GET and # iterate the returned ``data`` array rather than the generic @@ -65,6 +119,25 @@ def list(self) -> Iterator[AdminSCIMToken]: yield _parse_jsonapi(item, AdminSCIMToken) def create(self, options: AdminSCIMTokenCreateOptions) -> AdminSCIMToken: + """Create a Terraform Enterprise SCIM token. + + Args: + options: SCIM token description and optional expiry, as a + :class:`AdminSCIMTokenCreateOptions`. + + Returns: + The :class:`AdminSCIMToken`. + + Raises: + RequiredSCIMTokenDescriptionError: If ``options.description`` is empty. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import AdminSCIMTokenCreateOptions + >>> token = client.admin.scim_tokens.create( + ... AdminSCIMTokenCreateOptions(description="Okta SCIM") + ... ) + """ if not valid_string(options.description): raise RequiredSCIMTokenDescriptionError() attrs = options.model_dump(by_alias=True, exclude_none=True, mode="json") @@ -73,12 +146,43 @@ def create(self, options: AdminSCIMTokenCreateOptions) -> AdminSCIMToken: return _parse_jsonapi(r.json()["data"], AdminSCIMToken) def read(self, scim_token_id: str) -> AdminSCIMToken: + """Read a Terraform Enterprise SCIM token by its ID. + + Args: + scim_token_id: The SCIM token ID (e.g. ``"at-xxxxxxxx"``). + + Returns: + The :class:`AdminSCIMToken`. + + Raises: + InvalidSCIMTokenIDError: If ``scim_token_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> token = client.admin.scim_tokens.read("at-xxxxxxxx") + >>> print(token.description) + """ if not valid_string_id(scim_token_id): raise InvalidSCIMTokenIDError() r = self.t.request("GET", f"/api/v2/admin/scim-tokens/{scim_token_id}") return _parse_jsonapi(r.json()["data"], AdminSCIMToken) def delete(self, scim_token_id: str) -> None: + """Delete a Terraform Enterprise SCIM token. + + Args: + scim_token_id: The SCIM token ID (e.g. ``"at-xxxxxxxx"``). + + Returns: + None. + + Raises: + InvalidSCIMTokenIDError: If ``scim_token_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.admin.scim_tokens.delete("at-xxxxxxxx") + """ if not valid_string_id(scim_token_id): raise InvalidSCIMTokenIDError() self.t.request("DELETE", f"/api/v2/admin/scim-tokens/{scim_token_id}") diff --git a/src/pytfe/resources/admin/_smtp.py b/src/pytfe/resources/admin/_smtp.py index e0ca5a01..a5e9582b 100644 --- a/src/pytfe/resources/admin/_smtp.py +++ b/src/pytfe/resources/admin/_smtp.py @@ -25,10 +25,42 @@ def _parse_jsonapi(data: dict[str, Any], model: type[_M]) -> _M: class _AdminSMTPSettings(_Service): def read(self) -> AdminSMTPSettings: + """Read the TFE site SMTP settings. + + Returns: + The :class:`AdminSMTPSettings`. + + Raises: + TFEError: If the API request fails. + + Example: + >>> settings = client.admin.smtp_settings.read() + >>> print(settings.host) + """ r = self.t.request("GET", "/api/v2/admin/smtp-settings") return _parse_jsonapi(r.json()["data"], AdminSMTPSettings) def update(self, options: AdminSMTPSettingsUpdateOptions) -> AdminSMTPSettings: + """Update the TFE site SMTP settings. + + Args: + options: SMTP settings to update, as a + :class:`AdminSMTPSettingsUpdateOptions`. + + Returns: + The updated :class:`AdminSMTPSettings`. + + Raises: + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import AdminSMTPSettingsUpdateOptions, SMTPAuthType + >>> settings = client.admin.smtp_settings.update( + ... AdminSMTPSettingsUpdateOptions( + ... host="smtp.example.com", port=587, auth=SMTPAuthType.PLAIN + ... ) + ... ) + """ attrs = options.model_dump(by_alias=True, exclude_none=True, mode="json") body = {"data": {"type": _SMTP_TYPE, "attributes": attrs}} r = self.t.request("PATCH", "/api/v2/admin/smtp-settings", json_body=body) diff --git a/src/pytfe/resources/admin/_users.py b/src/pytfe/resources/admin/_users.py index 42da26cf..d1cc896c 100644 --- a/src/pytfe/resources/admin/_users.py +++ b/src/pytfe/resources/admin/_users.py @@ -22,6 +22,26 @@ def _parse_admin_user(data: dict[str, Any]) -> AdminUser: class _AdminUsers(_Service): def list(self, options: AdminUserListOptions | None = None) -> Iterator[AdminUser]: + """List Terraform Enterprise site users. + + Args: + options: Optional filters and pagination, as an + :class:`AdminUserListOptions`. + + Returns: + A single-use ``Iterator[AdminUser]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import AdminUserListOptions + >>> for user in client.admin.users.list( + ... AdminUserListOptions(query="alice"), + ... ): + ... print(user.id, user.username) + """ params: dict[str, Any] = {} if options: if options.query: @@ -38,35 +58,130 @@ def list(self, options: AdminUserListOptions | None = None) -> Iterator[AdminUse yield _parse_admin_user(item) def read(self, user_id: str) -> AdminUser: + """Read a Terraform Enterprise site user by ID. + + Args: + user_id: The user ID (e.g. ``"user-xxxxxxxx"``). + + Returns: + The :class:`AdminUser`. + + Raises: + ValueError: If ``user_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> user = client.admin.users.read("user-47qC3LmA47piVan7") + >>> print(user.email) + """ if not valid_string_id(user_id): raise ValueError(ERR_INVALID_NAME) r = self.t.request("GET", f"/api/v2/admin/users/{user_id}") return _parse_admin_user(r.json()["data"]) def delete(self, user_id: str) -> None: + """Delete a Terraform Enterprise site user by ID. + + Args: + user_id: The user ID (e.g. ``"user-xxxxxxxx"``). + + Returns: + None. + + Raises: + ValueError: If ``user_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.admin.users.delete("user-47qC3LmA47piVan7") + """ if not valid_string_id(user_id): raise ValueError(ERR_INVALID_NAME) self.t.request("DELETE", f"/api/v2/admin/users/{user_id}") def suspend(self, user_id: str) -> AdminUser: + """Suspend a Terraform Enterprise site user by ID. + + Args: + user_id: The user ID (e.g. ``"user-xxxxxxxx"``). + + Returns: + The :class:`AdminUser`. + + Raises: + ValueError: If ``user_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> user = client.admin.users.suspend("user-47qC3LmA47piVan7") + >>> print(user.is_suspended) + """ if not valid_string_id(user_id): raise ValueError(ERR_INVALID_NAME) r = self.t.request("POST", f"/api/v2/admin/users/{user_id}/actions/suspend") return _parse_admin_user(r.json()["data"]) def unsuspend(self, user_id: str) -> AdminUser: + """Unsuspend a Terraform Enterprise site user by ID. + + Args: + user_id: The user ID (e.g. ``"user-xxxxxxxx"``). + + Returns: + The :class:`AdminUser`. + + Raises: + ValueError: If ``user_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> user = client.admin.users.unsuspend("user-47qC3LmA47piVan7") + >>> print(user.is_suspended) + """ if not valid_string_id(user_id): raise ValueError(ERR_INVALID_NAME) r = self.t.request("POST", f"/api/v2/admin/users/{user_id}/actions/unsuspend") return _parse_admin_user(r.json()["data"]) def grant_admin(self, user_id: str) -> AdminUser: + """Grant site-admin access to a user by ID. + + Args: + user_id: The user ID (e.g. ``"user-xxxxxxxx"``). + + Returns: + The :class:`AdminUser`. + + Raises: + ValueError: If ``user_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> user = client.admin.users.grant_admin("user-47qC3LmA47piVan7") + >>> print(user.is_admin) + """ if not valid_string_id(user_id): raise ValueError(ERR_INVALID_NAME) r = self.t.request("POST", f"/api/v2/admin/users/{user_id}/actions/grant_admin") return _parse_admin_user(r.json()["data"]) def revoke_admin(self, user_id: str) -> AdminUser: + """Revoke site-admin access from a user by ID. + + Args: + user_id: The user ID (e.g. ``"user-xxxxxxxx"``). + + Returns: + The :class:`AdminUser`. + + Raises: + ValueError: If ``user_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> user = client.admin.users.revoke_admin("user-47qC3LmA47piVan7") + >>> print(user.is_admin) + """ if not valid_string_id(user_id): raise ValueError(ERR_INVALID_NAME) r = self.t.request( @@ -75,6 +190,22 @@ def revoke_admin(self, user_id: str) -> AdminUser: return _parse_admin_user(r.json()["data"]) def disable_two_factor(self, user_id: str) -> AdminUser: + """Disable two-factor authentication for a user by ID. + + Args: + user_id: The user ID (e.g. ``"user-xxxxxxxx"``). + + Returns: + The :class:`AdminUser`. + + Raises: + ValueError: If ``user_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> user = client.admin.users.disable_two_factor("user-47qC3LmA47piVan7") + >>> print(user.two_factor_enabled) + """ if not valid_string_id(user_id): raise ValueError(ERR_INVALID_NAME) r = self.t.request( diff --git a/src/pytfe/resources/admin/_versions.py b/src/pytfe/resources/admin/_versions.py index 5d639f6a..d2b67d7f 100644 --- a/src/pytfe/resources/admin/_versions.py +++ b/src/pytfe/resources/admin/_versions.py @@ -43,16 +43,66 @@ def _parse_sentinel_version(data: dict[str, Any]) -> SentinelVersion: class _AdminTerraformVersions(_Service): def list(self) -> Iterator[TerraformVersion]: + """List all admin-managed Terraform versions. + + Returns: + A single-use ``Iterator[TerraformVersion]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + TFEError: If the API request fails. + + Example: + >>> for version in client.admin.terraform_versions.list(): + ... print(version.id, version.version) + """ for item in self._list("/api/v2/admin/terraform-versions"): yield _parse_terraform_version(item) def read(self, version_id: str) -> TerraformVersion: + """Read an admin-managed Terraform version by its ID. + + Args: + version_id: The version ID (e.g. ``"tv-1"``). + + Returns: + The :class:`TerraformVersion`. + + Raises: + ValueError: If ``version_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> version = client.admin.terraform_versions.read("tv-1") + >>> print(version.version) + """ if not valid_string_id(version_id): raise ValueError(ERR_INVALID_VERSION) r = self.t.request("GET", f"/api/v2/admin/terraform-versions/{version_id}") return _parse_terraform_version(r.json()["data"]) def create(self, options: TerraformVersionCreateOptions) -> TerraformVersion: + """Create an admin-managed Terraform version. + + Args: + options: The version package metadata, as a + :class:`TerraformVersionCreateOptions`. + + Returns: + The created :class:`TerraformVersion`. + + Raises: + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import TerraformVersionCreateOptions + >>> options = TerraformVersionCreateOptions( + ... version="1.9.0", url="https://example.com/tf.zip", sha="abc123" + ... ) + >>> version = client.admin.terraform_versions.create( + ... options + ... ) + """ attrs = options.model_dump(by_alias=True, exclude_none=True, mode="json") body = {"data": {"type": _TF_VERSION_TYPE, "attributes": attrs}} r = self.t.request("POST", "/api/v2/admin/terraform-versions", json_body=body) @@ -61,6 +111,26 @@ def create(self, options: TerraformVersionCreateOptions) -> TerraformVersion: def update( self, version_id: str, options: TerraformVersionUpdateOptions ) -> TerraformVersion: + """Update an admin-managed Terraform version. + + Args: + version_id: The version ID (e.g. ``"tv-1"``). + options: The version fields to change, as a + :class:`TerraformVersionUpdateOptions`. + + Returns: + The updated :class:`TerraformVersion`. + + Raises: + ValueError: If ``version_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import TerraformVersionUpdateOptions + >>> version = client.admin.terraform_versions.update( + ... "tv-1", TerraformVersionUpdateOptions(enabled=True) + ... ) + """ if not valid_string_id(version_id): raise ValueError(ERR_INVALID_VERSION) attrs = options.model_dump(by_alias=True, exclude_none=True, mode="json") @@ -71,6 +141,21 @@ def update( return _parse_terraform_version(r.json()["data"]) def delete(self, version_id: str) -> None: + """Delete an admin-managed Terraform version. + + Args: + version_id: The version ID (e.g. ``"tv-1"``). + + Returns: + None. + + Raises: + ValueError: If ``version_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.admin.terraform_versions.delete("tv-1") + """ if not valid_string_id(version_id): raise ValueError(ERR_INVALID_VERSION) self.t.request("DELETE", f"/api/v2/admin/terraform-versions/{version_id}") @@ -78,22 +163,92 @@ def delete(self, version_id: str) -> None: class _AdminOpaVersions(_Service): def list(self) -> Iterator[OpaVersion]: + """List all admin-managed OPA versions. + + Returns: + A single-use ``Iterator[OpaVersion]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + TFEError: If the API request fails. + + Example: + >>> for version in client.admin.opa_versions.list(): + ... print(version.id, version.version) + """ for item in self._list("/api/v2/admin/opa-versions"): yield _parse_opa_version(item) def read(self, version_id: str) -> OpaVersion: + """Read an admin-managed OPA version by its ID. + + Args: + version_id: The version ID (e.g. ``"ov-1"``). + + Returns: + The :class:`OpaVersion`. + + Raises: + ValueError: If ``version_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> version = client.admin.opa_versions.read("ov-1") + >>> print(version.version) + """ if not valid_string_id(version_id): raise ValueError(ERR_INVALID_VERSION) r = self.t.request("GET", f"/api/v2/admin/opa-versions/{version_id}") return _parse_opa_version(r.json()["data"]) def create(self, options: OpaVersionCreateOptions) -> OpaVersion: + """Create an admin-managed OPA version. + + Args: + options: The version package metadata, as a + :class:`OpaVersionCreateOptions`. + + Returns: + The created :class:`OpaVersion`. + + Raises: + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import OpaVersionCreateOptions + >>> options = OpaVersionCreateOptions( + ... version="0.60.0", url="https://example.com/opa.zip", sha="abc123" + ... ) + >>> version = client.admin.opa_versions.create( + ... options + ... ) + """ attrs = options.model_dump(by_alias=True, exclude_none=True, mode="json") body = {"data": {"type": _OPA_VERSION_TYPE, "attributes": attrs}} r = self.t.request("POST", "/api/v2/admin/opa-versions", json_body=body) return _parse_opa_version(r.json()["data"]) def update(self, version_id: str, options: OpaVersionUpdateOptions) -> OpaVersion: + """Update an admin-managed OPA version. + + Args: + version_id: The version ID (e.g. ``"ov-1"``). + options: The version fields to change, as a + :class:`OpaVersionUpdateOptions`. + + Returns: + The updated :class:`OpaVersion`. + + Raises: + ValueError: If ``version_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import OpaVersionUpdateOptions + >>> version = client.admin.opa_versions.update( + ... "ov-1", OpaVersionUpdateOptions(enabled=True) + ... ) + """ if not valid_string_id(version_id): raise ValueError(ERR_INVALID_VERSION) attrs = options.model_dump(by_alias=True, exclude_none=True, mode="json") @@ -104,6 +259,21 @@ def update(self, version_id: str, options: OpaVersionUpdateOptions) -> OpaVersio return _parse_opa_version(r.json()["data"]) def delete(self, version_id: str) -> None: + """Delete an admin-managed OPA version. + + Args: + version_id: The version ID (e.g. ``"ov-1"``). + + Returns: + None. + + Raises: + ValueError: If ``version_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.admin.opa_versions.delete("ov-1") + """ if not valid_string_id(version_id): raise ValueError(ERR_INVALID_VERSION) self.t.request("DELETE", f"/api/v2/admin/opa-versions/{version_id}") @@ -111,16 +281,66 @@ def delete(self, version_id: str) -> None: class _AdminSentinelVersions(_Service): def list(self) -> Iterator[SentinelVersion]: + """List all admin-managed Sentinel versions. + + Returns: + A single-use ``Iterator[SentinelVersion]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + TFEError: If the API request fails. + + Example: + >>> for version in client.admin.sentinel_versions.list(): + ... print(version.id, version.version) + """ for item in self._list("/api/v2/admin/sentinel-versions"): yield _parse_sentinel_version(item) def read(self, version_id: str) -> SentinelVersion: + """Read an admin-managed Sentinel version by its ID. + + Args: + version_id: The version ID (e.g. ``"sv-1"``). + + Returns: + The :class:`SentinelVersion`. + + Raises: + ValueError: If ``version_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> version = client.admin.sentinel_versions.read("sv-1") + >>> print(version.version) + """ if not valid_string_id(version_id): raise ValueError(ERR_INVALID_VERSION) r = self.t.request("GET", f"/api/v2/admin/sentinel-versions/{version_id}") return _parse_sentinel_version(r.json()["data"]) def create(self, options: SentinelVersionCreateOptions) -> SentinelVersion: + """Create an admin-managed Sentinel version. + + Args: + options: The version package metadata, as a + :class:`SentinelVersionCreateOptions`. + + Returns: + The created :class:`SentinelVersion`. + + Raises: + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import SentinelVersionCreateOptions + >>> options = SentinelVersionCreateOptions( + ... version="0.26.0", url="https://example.com/s.zip", sha="abc123" + ... ) + >>> version = client.admin.sentinel_versions.create( + ... options + ... ) + """ attrs = options.model_dump(by_alias=True, exclude_none=True, mode="json") body = {"data": {"type": _SENTINEL_VERSION_TYPE, "attributes": attrs}} r = self.t.request("POST", "/api/v2/admin/sentinel-versions", json_body=body) @@ -129,6 +349,26 @@ def create(self, options: SentinelVersionCreateOptions) -> SentinelVersion: def update( self, version_id: str, options: SentinelVersionUpdateOptions ) -> SentinelVersion: + """Update an admin-managed Sentinel version. + + Args: + version_id: The version ID (e.g. ``"sv-1"``). + options: The version fields to change, as a + :class:`SentinelVersionUpdateOptions`. + + Returns: + The updated :class:`SentinelVersion`. + + Raises: + ValueError: If ``version_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import SentinelVersionUpdateOptions + >>> version = client.admin.sentinel_versions.update( + ... "sv-1", SentinelVersionUpdateOptions(enabled=True) + ... ) + """ if not valid_string_id(version_id): raise ValueError(ERR_INVALID_VERSION) attrs = options.model_dump(by_alias=True, exclude_none=True, mode="json") @@ -139,6 +379,21 @@ def update( return _parse_sentinel_version(r.json()["data"]) def delete(self, version_id: str) -> None: + """Delete an admin-managed Sentinel version. + + Args: + version_id: The version ID (e.g. ``"sv-1"``). + + Returns: + None. + + Raises: + ValueError: If ``version_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.admin.sentinel_versions.delete("sv-1") + """ if not valid_string_id(version_id): raise ValueError(ERR_INVALID_VERSION) self.t.request("DELETE", f"/api/v2/admin/sentinel-versions/{version_id}") diff --git a/src/pytfe/resources/admin/_workspaces.py b/src/pytfe/resources/admin/_workspaces.py index ed86027c..f63c791d 100644 --- a/src/pytfe/resources/admin/_workspaces.py +++ b/src/pytfe/resources/admin/_workspaces.py @@ -37,6 +37,27 @@ class _AdminWorkspaces(_Service): def list( self, options: AdminWorkspaceListOptions | None = None ) -> Iterator[AdminWorkspace]: + """List Terraform Enterprise admin workspaces. + + Args: + options: Optional filters and pagination, as a + :class:`AdminWorkspaceListOptions`. + + Returns: + A single-use ``Iterator[AdminWorkspace]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import AdminWorkspaceListOptions + >>> workspaces = client.admin.workspaces.list( + ... AdminWorkspaceListOptions(query="production") + ... ) + >>> for workspace in workspaces: + ... print(workspace.id, workspace.name) + """ params: dict[str, Any] = {} if options: if options.query: @@ -49,6 +70,22 @@ def list( yield _parse_admin_workspace(item) def read(self, workspace_id: str) -> AdminWorkspace: + """Read a Terraform Enterprise admin workspace by ID. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-xxxxxxxx"``). + + Returns: + The :class:`AdminWorkspace`. + + Raises: + ValueError: If ``workspace_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> workspace = client.admin.workspaces.read("ws-6fHMCom98SDXSQUv") + >>> print(workspace.organization_name) + """ if not valid_string_id(workspace_id): raise ValueError(ERR_INVALID_NAME) r = self.t.request("GET", f"/api/v2/admin/workspaces/{workspace_id}") diff --git a/src/pytfe/resources/agent_pools.py b/src/pytfe/resources/agent_pools.py index 3946f453..e230dc62 100644 --- a/src/pytfe/resources/agent_pools.py +++ b/src/pytfe/resources/agent_pools.py @@ -50,15 +50,23 @@ def list( """List agent pools in an organization. Args: - organization: Organization name - options: Optional parameters for filtering and pagination + organization: The organization name (e.g. ``"my-org"``). + options: Optional filters and includes, as a :class:`AgentPoolListOptions`. Returns: - Iterator of AgentPool objects + A single-use ``Iterator[AgentPool]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. Raises: - ValueError: If organization name is invalid - TFEError: If API request fails + InvalidOrgError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import AgentPoolListOptions + >>> for pool in client.agent_pools.list( + ... "my-org", AgentPoolListOptions(query="builders") + ... ): + ... print(pool.id, pool.name) """ if not valid_string_id(organization): raise InvalidOrgError() @@ -85,18 +93,26 @@ def list( yield self._parse_agent_pool_from(item) def create(self, organization: str, options: AgentPoolCreateOptions) -> AgentPool: - """Create a new agent pool in an organization. + """Create an agent pool in an organization. Args: - organization: Organization name - options: Agent pool creation options + organization: The organization name (e.g. ``"my-org"``). + options: The agent pool configuration, as a + :class:`AgentPoolCreateOptions`. Returns: - Created AgentPool object + The :class:`AgentPool`. Raises: - ValueError: If parameters are invalid - TFEError: If API request fails + InvalidOrgError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import AgentPoolCreateOptions + >>> pool = client.agent_pools.create( + ... "my-org", + ... AgentPoolCreateOptions(name="builders", organization_scoped=True), + ... ) """ if not valid_string_id(organization): raise InvalidOrgError() @@ -145,18 +161,22 @@ def create(self, organization: str, options: AgentPoolCreateOptions) -> AgentPoo def read( self, agent_pool_id: str, options: AgentPoolReadOptions | None = None ) -> AgentPool: - """Get a specific agent pool by ID. + """Read an agent pool by ID. Args: - agent_pool_id: Agent pool ID - options: Optional parameters for including related resources + agent_pool_id: The agent pool ID (e.g. ``"apool-xxxxxxxx"``). + options: Optional includes, as a :class:`AgentPoolReadOptions`. Returns: - AgentPool object + The :class:`AgentPool`. Raises: - ValueError: If agent_pool_id is invalid - TFEError: If API request fails + InvalidAgentPoolIDError: If ``agent_pool_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> pool = client.agent_pools.read("apool-4j8p6jX1w33MiDC7") + >>> print(pool.name) """ if not valid_string_id(agent_pool_id): raise InvalidAgentPoolIDError() @@ -178,18 +198,25 @@ def read( return self._parse_agent_pool_from(data, payload.get("included")) def update(self, agent_pool_id: str, options: AgentPoolUpdateOptions) -> AgentPool: - """Update an agent pool's properties. + """Update an agent pool by ID. Args: - agent_pool_id: Agent pool ID - options: Agent pool update options + agent_pool_id: The agent pool ID (e.g. ``"apool-xxxxxxxx"``). + options: The agent pool updates, as a :class:`AgentPoolUpdateOptions`. Returns: - Updated AgentPool object + The :class:`AgentPool`. Raises: - ValueError: If parameters are invalid - TFEError: If API request fails + InvalidAgentPoolIDError: If ``agent_pool_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import AgentPoolUpdateOptions + >>> pool = client.agent_pools.update( + ... "apool-4j8p6jX1w33MiDC7", + ... AgentPoolUpdateOptions(name="builders-east"), + ... ) """ if not valid_string_id(agent_pool_id): @@ -244,14 +271,20 @@ def update(self, agent_pool_id: str, options: AgentPoolUpdateOptions) -> AgentPo return self._parse_agent_pool_from(data, payload.get("included")) def delete(self, agent_pool_id: str) -> None: - """Delete an agent pool. + """Delete an agent pool by ID. Args: - agent_pool_id: Agent pool ID + agent_pool_id: The agent pool ID (e.g. ``"apool-xxxxxxxx"``). + + Returns: + None. Raises: - ValueError: If agent_pool_id is invalid - TFEError: If API request fails + InvalidAgentPoolIDError: If ``agent_pool_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.agent_pools.delete("apool-4j8p6jX1w33MiDC7") """ if not valid_string_id(agent_pool_id): raise InvalidAgentPoolIDError() @@ -262,22 +295,33 @@ def delete(self, agent_pool_id: str) -> None: def assign_to_workspaces( self, agent_pool_id: str, options: AgentPoolAssignToWorkspacesOptions ) -> AgentPool: - """Assign an agent pool to workspaces by updating the allowed-workspaces - relationship via PATCH /agent-pools/:id. + """Assign an agent pool to a complete workspace allowlist. - The provided workspace IDs become the new complete list of allowed - workspaces for this pool (full replacement, not append). + The provided workspace IDs replace the allowed-workspaces relationship; they + are not appended to the existing list. Args: - agent_pool_id: Agent pool ID - options: Assignment options containing workspace IDs + agent_pool_id: The agent pool ID (e.g. ``"apool-xxxxxxxx"``). + options: The workspace IDs to allow, as a + :class:`AgentPoolAssignToWorkspacesOptions`. Returns: - Updated AgentPool object + The :class:`AgentPool`. Raises: - ValueError: If parameters are invalid - TFEError: If API request fails + InvalidAgentPoolIDError: If ``agent_pool_id`` is not a valid resource ID. + RequiredWorkspaceError: If no workspace IDs are supplied. + InvalidWorkspaceIDError: If any workspace ID is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import AgentPoolAssignToWorkspacesOptions + >>> pool = client.agent_pools.assign_to_workspaces( + ... "apool-4j8p6jX1w33MiDC7", + ... AgentPoolAssignToWorkspacesOptions( + ... workspace_ids=["ws-4j8p6jX1w33MiDC7"] + ... ), + ... ) """ if not valid_string_id(agent_pool_id): raise InvalidAgentPoolIDError() @@ -314,23 +358,34 @@ def assign_to_workspaces( def remove_from_workspaces( self, agent_pool_id: str, options: AgentPoolRemoveFromWorkspacesOptions ) -> AgentPool: - """Exclude workspaces from an agent pool by updating the excluded-workspaces - relationship via PATCH /agent-pools/:id. + """Replace an agent pool's excluded workspace list. - Use this for organization-scoped pools where most workspaces are allowed - but you want to block specific ones. The provided list becomes the new - complete excluded-workspaces list (full replacement, not append). + Use this for organization-scoped pools where most workspaces are allowed but + specific workspaces should be blocked. The provided workspace IDs replace the + excluded-workspaces relationship; they are not appended to the existing list. Args: - agent_pool_id: Agent pool ID - options: Removal options containing workspace IDs to exclude + agent_pool_id: The agent pool ID (e.g. ``"apool-xxxxxxxx"``). + options: The workspace IDs to exclude, as a + :class:`AgentPoolRemoveFromWorkspacesOptions`. Returns: - Updated AgentPool object + The :class:`AgentPool`. Raises: - ValueError: If parameters are invalid - TFEError: If API request fails + InvalidAgentPoolIDError: If ``agent_pool_id`` is not a valid resource ID. + RequiredWorkspaceError: If no workspace IDs are supplied. + InvalidWorkspaceIDError: If any workspace ID is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import AgentPoolRemoveFromWorkspacesOptions + >>> pool = client.agent_pools.remove_from_workspaces( + ... "apool-4j8p6jX1w33MiDC7", + ... AgentPoolRemoveFromWorkspacesOptions( + ... workspace_ids=["ws-4j8p6jX1w33MiDC7"] + ... ), + ... ) """ if not valid_string_id(agent_pool_id): raise InvalidAgentPoolIDError() @@ -367,15 +422,31 @@ def remove_from_workspaces( def assign_to_projects( self, agent_pool_id: str, options: AgentPoolAssignToProjectsOptions ) -> AgentPool: - """Assign an agent pool to projects by updating the allowed-projects - relationship via PATCH /agent-pools/:id. + """Assign an agent pool to a complete project allowlist. - The provided project IDs become the new complete list of allowed - projects for this pool (full replacement, not append). + The provided project IDs replace the allowed-projects relationship; they are + not appended to the existing list. Args: - agent_pool_id: Agent pool ID - options: Assignment options containing project IDs + agent_pool_id: The agent pool ID (e.g. ``"apool-xxxxxxxx"``). + options: The project IDs to allow, as a + :class:`AgentPoolAssignToProjectsOptions`. + + Returns: + The :class:`AgentPool`. + + Raises: + InvalidAgentPoolIDError: If ``agent_pool_id`` is not a valid resource ID. + RequiredProjectError: If no project IDs are supplied. + InvalidProjectIDError: If any project ID is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import AgentPoolAssignToProjectsOptions + >>> pool = client.agent_pools.assign_to_projects( + ... "apool-4j8p6jX1w33MiDC7", + ... AgentPoolAssignToProjectsOptions(project_ids=["prj-4j8p6jX1w33MiDC7"]), + ... ) """ if not valid_string_id(agent_pool_id): raise InvalidAgentPoolIDError() diff --git a/src/pytfe/resources/agents.py b/src/pytfe/resources/agents.py index 7346be75..6bae0618 100644 --- a/src/pytfe/resources/agents.py +++ b/src/pytfe/resources/agents.py @@ -55,15 +55,23 @@ def list( """List agents in an agent pool. Args: - agent_pool_id: Agent pool ID - options: Optional parameters for filtering and pagination + agent_pool_id: The agent pool ID (e.g. ``"apool-xxxxxxxx"``). + options: Optional filters and pagination, as a :class:`AgentListOptions`. Returns: - Iterator of Agent objects + A single-use ``Iterator[Agent]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. Raises: - ValueError: If agent_pool_id is invalid - TFEError: If API request fails + ValueError: If ``agent_pool_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import AgentListOptions, AgentStatus + >>> for agent in client.agents.list( + ... "apool-123456789abcdef0", AgentListOptions(status=AgentStatus.IDLE) + ... ): + ... print(agent.id, agent.status) """ if not valid_string_id(agent_pool_id): raise ValueError("Agent pool ID is required and must be valid") @@ -113,18 +121,22 @@ def list( ) def read(self, agent_id: str, options: AgentReadOptions | None = None) -> Agent: - """Get a specific agent by ID. + """Read an agent by its ID. Args: - agent_id: Agent ID - options: Optional parameters for including related resources + agent_id: The agent ID (e.g. ``"agent-xxxxxxxx"``). + options: Related resources to include, as a :class:`AgentReadOptions`. Returns: - Agent object + The :class:`Agent`. Raises: - ValueError: If agent_id is invalid - TFEError: If API request fails + ValueError: If ``agent_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> agent = client.agents.read("agent-123456789abcdef0") + >>> print(agent.name) """ if not valid_string_id(agent_id): raise ValueError("Agent ID is required and must be valid") @@ -181,11 +193,17 @@ def delete(self, agent_id: str) -> None: """Delete an agent. Args: - agent_id: Agent ID + agent_id: The agent ID (e.g. ``"agent-xxxxxxxx"``). + + Returns: + None. Raises: - ValueError: If agent_id is invalid - TFEError: If API request fails + ValueError: If ``agent_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.agents.delete("agent-123456789abcdef0") """ if not valid_string_id(agent_id): raise ValueError("Agent ID is required and must be valid") @@ -203,15 +221,20 @@ def list( """List agent tokens for an agent pool. Args: - agent_pool_id: Agent pool ID - options: Optional parameters for pagination + agent_pool_id: The agent pool ID (e.g. ``"apool-xxxxxxxx"``). + options: Optional pagination, as a :class:`AgentTokenListOptions`. Returns: - Iterator of AgentToken objects + A single-use ``Iterator[AgentToken]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. Raises: - ValueError: If agent_pool_id is invalid - TFEError: If API request fails + ValueError: If ``agent_pool_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> for token in client.agent_tokens.list("apool-123456789abcdef0"): + ... print(token.id, token.description) """ if not valid_string_id(agent_pool_id): raise ValueError("Agent pool ID is required and must be valid") @@ -251,18 +274,25 @@ def list( def create( self, agent_pool_id: str, options: AgentTokenCreateOptions ) -> AgentToken: - """Create a new agent token for an agent pool. + """Create an agent token for an agent pool. Args: - agent_pool_id: Agent pool ID - options: Token creation options + agent_pool_id: The agent pool ID (e.g. ``"apool-xxxxxxxx"``). + options: The token description, as a :class:`AgentTokenCreateOptions`. Returns: - Created AgentToken object (includes token value) + The created :class:`AgentToken` with its one-time token value. Raises: - ValueError: If parameters are invalid - TFEError: If API request fails + ValueError: If ``agent_pool_id`` is invalid or the description is empty. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import AgentTokenCreateOptions + >>> options = AgentTokenCreateOptions(description="ci-runner") + >>> token = client.agent_tokens.create( + ... "apool-123456789abcdef0", options + ... ) """ if not valid_string_id(agent_pool_id): raise ValueError("Agent pool ID is required and must be valid") @@ -299,17 +329,21 @@ def create( ) def read(self, agent_token_id: str) -> AgentToken: - """Get a specific agent token by ID. + """Read an agent token by its ID. Args: - agent_token_id: Agent token ID + agent_token_id: The agent token ID (e.g. ``"at-xxxxxxxx"``). Returns: - AgentToken object (without token value for security) + The :class:`AgentToken` without the secret token value. Raises: - ValueError: If agent_token_id is invalid - TFEError: If API request fails + ValueError: If ``agent_token_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> token = client.agent_tokens.read("at-123456789abcdef0") + >>> print(token.description) """ if not valid_string_id(agent_token_id): raise ValueError("Agent token ID is required and must be valid") @@ -342,11 +376,17 @@ def delete(self, agent_token_id: str) -> None: """Delete an agent token. Args: - agent_token_id: Agent token ID + agent_token_id: The agent token ID (e.g. ``"at-xxxxxxxx"``). + + Returns: + None. Raises: - ValueError: If agent_token_id is invalid - TFEError: If API request fails + ValueError: If ``agent_token_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.agent_tokens.delete("at-123456789abcdef0") """ if not valid_string_id(agent_token_id): raise ValueError("Agent token ID is required and must be valid") diff --git a/src/pytfe/resources/apply.py b/src/pytfe/resources/apply.py index 42747d1b..5b0254e6 100644 --- a/src/pytfe/resources/apply.py +++ b/src/pytfe/resources/apply.py @@ -13,7 +13,22 @@ class Applies(_Service): def read(self, apply_id: str) -> Apply: - """Read a specific apply by its ID.""" + """Read an apply by its ID. + + Args: + apply_id: The apply ID (e.g. ``"apply-xxxxxxxx"``). + + Returns: + The :class:`Apply`. + + Raises: + InvalidApplyIDError: If ``apply_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> apply = client.applies.read("apply-123") + >>> print(apply.status) + """ if not valid_string_id(apply_id): raise InvalidApplyIDError() @@ -29,7 +44,23 @@ def read(self, apply_id: str) -> Apply: ) def logs(self, apply_id: str) -> str: - """Get logs for a specific apply""" + """Get logs for an apply. + + Args: + apply_id: The apply ID (e.g. ``"apply-xxxxxxxx"``). + + Returns: + The log text. + + Raises: + InvalidApplyIDError: If ``apply_id`` is not a valid resource ID. + ValueError: If the apply does not have a log URL. + TFEError: If the API request fails. + + Example: + >>> logs = client.applies.logs("apply-123") + >>> print(logs) + """ # Validate apply ID if not valid_string_id(apply_id): raise InvalidApplyIDError() @@ -57,11 +88,23 @@ def _done(self, apply_id: str) -> tuple[bool, Exception | None]: def errored_state(self, apply_id: str) -> bytes: """Recover the raw state bytes from an apply that failed during state upload. - The TFE endpoint returns a 307 redirect to a signed object-storage URL. - We follow it manually so the API bearer token is not forwarded to the - third-party blob host. + The TFE endpoint returns a redirect to object storage; the SDK follows that + storage URL for you. Raises NotFound if the apply has no recoverable errored + state. + + Args: + apply_id: The apply ID (e.g. ``"apply-xxxxxxxx"``). + + Returns: + The raw bytes (the SDK follows the storage/redirect URL for you). + + Raises: + InvalidApplyIDError: If ``apply_id`` is not a valid resource ID. + TFEError: If the API request fails or the redirect lacks a Location header. - Raises NotFound if the apply has no recoverable errored state. + Example: + >>> state = client.applies.errored_state("apply-123") + >>> print(len(state)) """ if not valid_string_id(apply_id): raise InvalidApplyIDError() diff --git a/src/pytfe/resources/assessment_result.py b/src/pytfe/resources/assessment_result.py new file mode 100644 index 00000000..60a386bb --- /dev/null +++ b/src/pytfe/resources/assessment_result.py @@ -0,0 +1,190 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Read health assessment (drift detection / continuous validation) results. + +``GET /api/v2/assessment-results/:id`` returns the assessment summary; the +``/json-output``, ``/json-schema`` and ``/log-output`` companion endpoints +return the underlying plan JSON, provider schema, and Terraform JSON log. + +Those output endpoints do not adhere to JSON:API and (per the API docs) require +a **user or team token with admin access to the workspace** — organization +tokens cannot read them. + +API reference: +https://developer.hashicorp.com/terraform/cloud-docs/api-docs/assessment-results +""" + +from __future__ import annotations + +from typing import Any + +import httpx + +from .._jsonapi import attach_jsonapi +from ..errors import InvalidAssessmentResultIDError, TFEError +from ..models.assessment_result import AssessmentResult +from ..utils import valid_string_id +from ._base import _Service + + +def _assessment_result_from( + data: dict[str, Any], included: list[dict[str, Any]] | None = None +) -> AssessmentResult: + """Parse a JSON:API assessment-results resource into an AssessmentResult.""" + attrs = dict(data.get("attributes") or {}) + attrs["id"] = data.get("id") + return attach_jsonapi(AssessmentResult.model_validate(attrs), data, included) + + +class AssessmentResults(_Service): + """Service for reading workspace health assessment results.""" + + def read(self, assessment_result_id: str) -> AssessmentResult: + """Read an assessment result by ID. + + Args: + assessment_result_id: The assessment result ID + (e.g. ``"asmtres-xxxxxxxx"``). + + Returns: + The :class:`AssessmentResult`. + + Raises: + InvalidAssessmentResultIDError: If ``assessment_result_id`` is not valid. + TFEError: If the API request fails. + + Example: + >>> result = client.assessment_results.read("asmtres-UG5rE9L1373hMYMA") + >>> print(result.succeeded) + """ + if not valid_string_id(assessment_result_id): + raise InvalidAssessmentResultIDError() + r = self.t.request("GET", f"/api/v2/assessment-results/{assessment_result_id}") + body = r.json() + data = (body or {}).get("data") or {} if isinstance(body, dict) else {} + included = body.get("included") if isinstance(body, dict) else None + return _assessment_result_from(data, included) + + def json_output(self, assessment_result_id: str) -> dict[str, Any] | None: + """Read the JSON plan output for an assessment result. + + Only available once the assessment has succeeded and produced JSON output. + Requires a user or team token with workspace admin access. + + Args: + assessment_result_id: The assessment result ID + (e.g. ``"asmtres-xxxxxxxx"``). + + Returns: + The ``dict[str, Any]``, or ``None`` when output is not yet ready + (HTTP 204) or the blob response is not JSON. + + Raises: + InvalidAssessmentResultIDError: If ``assessment_result_id`` is not valid. + TFEError: If the API request fails. + + Example: + >>> output = client.assessment_results.json_output( + ... "asmtres-UG5rE9L1373hMYMA" + ... ) + >>> print(output is None) + """ + if not valid_string_id(assessment_result_id): + raise InvalidAssessmentResultIDError() + resp = self._follow_blob( + f"/api/v2/assessment-results/{assessment_result_id}/json-output" + ) + return self._as_json(resp) + + def json_schema(self, assessment_result_id: str) -> dict[str, Any] | None: + """Read the JSON provider schema for an assessment result. + + Requires a user or team token with workspace admin access. + + Args: + assessment_result_id: The assessment result ID + (e.g. ``"asmtres-xxxxxxxx"``). + + Returns: + The ``dict[str, Any]``, or ``None`` when the schema is not yet ready + (HTTP 204) or the blob response is not JSON. + + Raises: + InvalidAssessmentResultIDError: If ``assessment_result_id`` is not valid. + TFEError: If the API request fails. + + Example: + >>> schema = client.assessment_results.json_schema( + ... "asmtres-UG5rE9L1373hMYMA" + ... ) + >>> print(schema is None) + """ + if not valid_string_id(assessment_result_id): + raise InvalidAssessmentResultIDError() + resp = self._follow_blob( + f"/api/v2/assessment-results/{assessment_result_id}/json-schema" + ) + return self._as_json(resp) + + def log_output(self, assessment_result_id: str) -> str: + """Read the Terraform JSON log output for an assessment result. + + Requires a user or team token with workspace admin access. + + Args: + assessment_result_id: The assessment result ID + (e.g. ``"asmtres-xxxxxxxx"``). + + Returns: + The ``str``; returns an empty string when there is no log output yet + (HTTP 204). + + Raises: + InvalidAssessmentResultIDError: If ``assessment_result_id`` is not valid. + TFEError: If the API request fails. + + Example: + >>> log = client.assessment_results.log_output( + ... "asmtres-UG5rE9L1373hMYMA" + ... ) + >>> print(log[:80]) + """ + if not valid_string_id(assessment_result_id): + raise InvalidAssessmentResultIDError() + resp = self._follow_blob( + f"/api/v2/assessment-results/{assessment_result_id}/log-output" + ) + return resp.text if resp is not None else "" + + def _follow_blob(self, path: str) -> httpx.Response | None: + """Fetch a non-JSON:API output endpoint, following a blob redirect. + + These endpoints may 307-redirect to a HashiCorp object-storage URL + (Archivist), which requires the API bearer; we re-issue the request to + the ``Location`` with auth (matching the plan ``json-output`` flow). + Returns ``None`` when the API responds ``204 No Content``. + """ + resp = self.t.request("GET", path, allow_redirects=False) + if resp.status_code == 204: + return None + if resp.status_code in (301, 302, 303, 307, 308): + location = resp.headers.get("Location") or resp.headers.get("location") + if not location: + raise TFEError( + "assessment-results output redirect did not include a Location header" + ) + return self.t.request("GET", location) + return resp + + @staticmethod + def _as_json(resp: httpx.Response | None) -> dict[str, Any] | None: + if resp is None: + return None + try: + data = resp.json() + except Exception: + return None + if data is None: + return None + return data if isinstance(data, dict) else {"data": data} diff --git a/src/pytfe/resources/cidr_range_list.py b/src/pytfe/resources/cidr_range_list.py new file mode 100644 index 00000000..3b812dc5 --- /dev/null +++ b/src/pytfe/resources/cidr_range_list.py @@ -0,0 +1,454 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +import builtins +from collections.abc import Iterator +from typing import Any + +from .._jsonapi import RelationMap, attach_jsonapi, parse_relationships +from ..errors import ( + InvalidAgentPoolIDError, + InvalidCIDRRangeIDError, + InvalidCIDRRangeListIDError, + InvalidOrgError, +) +from ..models.cidr_range_list import ( + CIDRRange, + CIDRRangeCreateOptions, + CIDRRangeList, + CIDRRangeListCreateOptions, + CIDRRangeListListOptions, + CIDRRangeListUpdateOptions, + CIDRRangeUpdateOptions, +) +from ..utils import valid_string_id +from ._base import _Service + +_CIDR_RANGE_LIST_REL_MAP: RelationMap = {"cidr-ranges": CIDRRange} + + +def _cidr_range_list_from( + d: dict[str, Any], included: list[dict[str, Any]] | None = None +) -> CIDRRangeList: + """Parse a JSON:API ``cidr-range-lists`` resource into a CIDRRangeList.""" + attrs = dict(d.get("attributes") or {}) + attrs["id"] = d.get("id") + attrs.update( + parse_relationships( + d.get("relationships"), _CIDR_RANGE_LIST_REL_MAP, included=included + ) + ) + return attach_jsonapi(CIDRRangeList.model_validate(attrs), d, included) + + +def _cidr_range_from( + d: dict[str, Any], included: list[dict[str, Any]] | None = None +) -> CIDRRange: + """Parse a JSON:API ``cidr-ranges`` resource into a CIDRRange.""" + attrs = dict(d.get("attributes") or {}) + attrs["id"] = d.get("id") + return attach_jsonapi(CIDRRange.model_validate(attrs), d, included) + + +class CIDRRangeLists(_Service): + """Service for managing IP allowlists (JSON:API ``cidr-range-lists``).""" + + def list( + self, organization: str, options: CIDRRangeListListOptions | None = None + ) -> Iterator[CIDRRangeList]: + """List IP allowlists for an organization. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: Optional search and pagination options, as a + :class:`CIDRRangeListListOptions`. + + Returns: + A single-use ``Iterator[CIDRRangeList]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> for allowlist in client.cidr_range_lists.list("my-org"): + ... print(allowlist.id, allowlist.name) + """ + if not valid_string_id(organization): + raise InvalidOrgError() + params = ( + options.model_dump(by_alias=True, exclude_none=True, mode="json") + if options + else {} + ) + path = f"/api/v2/organizations/{organization}/cidr-range-lists" + for item in self._list(path, params=params): + yield _cidr_range_list_from(item) + + def create( + self, organization: str, options: CIDRRangeListCreateOptions + ) -> CIDRRangeList: + """Create an IP allowlist in an organization. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: The allowlist settings, as a + :class:`CIDRRangeListCreateOptions`. + + Returns: + The created :class:`CIDRRangeList`. + + Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import CIDRRangeListCreateOptions, EnforcementScope + >>> allowlist = client.cidr_range_lists.create( + ... "my-org", + ... CIDRRangeListCreateOptions( + ... name="Office Network", + ... enforcement_scope=EnforcementScope.SELECTED_AGENT_POOLS, + ... ), + ... ) + """ + if not valid_string_id(organization): + raise InvalidOrgError() + payload = { + "data": { + "type": "cidr-range-lists", + "attributes": options.model_dump( + by_alias=True, exclude_none=True, mode="json" + ), + } + } + r = self.t.request( + "POST", + f"/api/v2/organizations/{organization}/cidr-range-lists", + json_body=payload, + ) + body = r.json() + return _cidr_range_list_from(body["data"], body.get("included")) + + def read(self, cidr_range_list_id: str) -> CIDRRangeList: + """Read an IP allowlist by its ID. + + Args: + cidr_range_list_id: The IP allowlist ID (e.g. ``"crl-xxxxxxxx"``). + + Returns: + The :class:`CIDRRangeList`. + + Raises: + InvalidCIDRRangeListIDError: If ``cidr_range_list_id`` is not valid. + TFEError: If the API request fails. + + Example: + >>> allowlist = client.cidr_range_lists.read("crl-xKw8dxQPqVQRZmCe") + >>> print(allowlist.name) + """ + if not valid_string_id(cidr_range_list_id): + raise InvalidCIDRRangeListIDError() + r = self.t.request("GET", f"/api/v2/cidr-range-lists/{cidr_range_list_id}") + body = r.json() + return _cidr_range_list_from(body["data"], body.get("included")) + + def update( + self, cidr_range_list_id: str, options: CIDRRangeListUpdateOptions + ) -> CIDRRangeList: + """Update an IP allowlist by its ID. + + Args: + cidr_range_list_id: The IP allowlist ID (e.g. ``"crl-xxxxxxxx"``). + options: The allowlist updates, as a + :class:`CIDRRangeListUpdateOptions`. + + Returns: + The updated :class:`CIDRRangeList`. + + Raises: + InvalidCIDRRangeListIDError: If ``cidr_range_list_id`` is not valid. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import CIDRRangeListUpdateOptions + >>> allowlist = client.cidr_range_lists.update( + ... "crl-xKw8dxQPqVQRZmCe", + ... CIDRRangeListUpdateOptions(name="Office Network"), + ... ) + """ + if not valid_string_id(cidr_range_list_id): + raise InvalidCIDRRangeListIDError() + attributes = options.model_dump(by_alias=True, exclude_none=True, mode="json") + # The API rejects a PATCH that omits enforcement-scope ("Enforcement + # scope is not included in the list") even though the docs say an + # omitted scope is preserved. Emulate that documented preserve-on-omit + # behaviour by carrying the current scope forward when none was given. + if "enforcement-scope" not in attributes: + current = self.read(cidr_range_list_id) + if current.enforcement_scope is not None: + attributes["enforcement-scope"] = current.enforcement_scope.value + payload = {"data": {"type": "cidr-range-lists", "attributes": attributes}} + r = self.t.request( + "PATCH", + f"/api/v2/cidr-range-lists/{cidr_range_list_id}", + json_body=payload, + ) + body = r.json() + return _cidr_range_list_from(body["data"], body.get("included")) + + def delete(self, cidr_range_list_id: str) -> None: + """Delete an IP allowlist by its ID. + + Args: + cidr_range_list_id: The IP allowlist ID (e.g. ``"crl-xxxxxxxx"``). + + Returns: + None. + + Raises: + InvalidCIDRRangeListIDError: If ``cidr_range_list_id`` is not valid. + TFEError: If the API request fails. + + Example: + >>> client.cidr_range_lists.delete("crl-xKw8dxQPqVQRZmCe") + """ + if not valid_string_id(cidr_range_list_id): + raise InvalidCIDRRangeListIDError() + self.t.request("DELETE", f"/api/v2/cidr-range-lists/{cidr_range_list_id}") + + def list_cidr_ranges(self, cidr_range_list_id: str) -> Iterator[CIDRRange]: + """List CIDR ranges attached to an IP allowlist. + + Args: + cidr_range_list_id: The IP allowlist ID (e.g. ``"crl-xxxxxxxx"``). + + Returns: + A single-use ``Iterator[CIDRRange]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + InvalidCIDRRangeListIDError: If ``cidr_range_list_id`` is not valid. + TFEError: If the API request fails. + + Example: + >>> ranges = client.cidr_range_lists.list_cidr_ranges( + ... "crl-xKw8dxQPqVQRZmCe" + ... ) + >>> for cidr_range in ranges: + ... print(cidr_range.cidr_block) + """ + if not valid_string_id(cidr_range_list_id): + raise InvalidCIDRRangeListIDError() + path = ( + f"/api/v2/cidr-range-lists/{cidr_range_list_id}/relationships/cidr-ranges" + ) + for item in self._list(path): + yield _cidr_range_from(item) + + def add_cidr_range( + self, cidr_range_list_id: str, options: CIDRRangeCreateOptions + ) -> CIDRRange: + """Add a CIDR range to an IP allowlist. + + Args: + cidr_range_list_id: The IP allowlist ID (e.g. ``"crl-xxxxxxxx"``). + options: The CIDR range settings, as a :class:`CIDRRangeCreateOptions`. + + Returns: + The created :class:`CIDRRange`. + + Raises: + InvalidCIDRRangeListIDError: If ``cidr_range_list_id`` is not valid. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import CIDRRangeCreateOptions + >>> cidr_range = client.cidr_range_lists.add_cidr_range( + ... "crl-xKw8dxQPqVQRZmCe", + ... CIDRRangeCreateOptions(cidr_block="192.168.1.0/24"), + ... ) + """ + if not valid_string_id(cidr_range_list_id): + raise InvalidCIDRRangeListIDError() + payload = { + "data": { + "type": "cidr-ranges", + "attributes": options.model_dump( + by_alias=True, exclude_none=True, mode="json" + ), + } + } + r = self.t.request( + "POST", + f"/api/v2/cidr-range-lists/{cidr_range_list_id}/relationships/cidr-ranges", + json_body=payload, + ) + body = r.json() + return _cidr_range_from(body["data"], body.get("included")) + + def add_agent_pools( + self, cidr_range_list_id: str, agent_pool_ids: builtins.list[str] + ) -> None: + """Associate agent pools with an IP allowlist. + + Args: + cidr_range_list_id: The IP allowlist ID (e.g. ``"crl-xxxxxxxx"``). + agent_pool_ids: The agent pool IDs (e.g. ``["apool-xxxxxxxx"]``). + + Returns: + None. + + Raises: + InvalidCIDRRangeListIDError: If ``cidr_range_list_id`` is not valid. + InvalidAgentPoolIDError: If an agent pool ID is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.cidr_range_lists.add_agent_pools( + ... "crl-xKw8dxQPqVQRZmCe", + ... ["apool-abc123"], + ... ) + """ + if not valid_string_id(cidr_range_list_id): + raise InvalidCIDRRangeListIDError() + payload = {"data": self._agent_pool_refs(agent_pool_ids)} + self.t.request( + "POST", + f"/api/v2/cidr-range-lists/{cidr_range_list_id}/relationships/agent-pools", + json_body=payload, + ) + + def remove_agent_pools( + self, cidr_range_list_id: str, agent_pool_ids: builtins.list[str] + ) -> None: + """Remove agent pool associations from an IP allowlist. + + Args: + cidr_range_list_id: The IP allowlist ID (e.g. ``"crl-xxxxxxxx"``). + agent_pool_ids: The agent pool IDs (e.g. ``["apool-xxxxxxxx"]``). + + Returns: + None. + + Raises: + InvalidCIDRRangeListIDError: If ``cidr_range_list_id`` is not valid. + InvalidAgentPoolIDError: If an agent pool ID is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.cidr_range_lists.remove_agent_pools( + ... "crl-xKw8dxQPqVQRZmCe", + ... ["apool-abc123"], + ... ) + """ + if not valid_string_id(cidr_range_list_id): + raise InvalidCIDRRangeListIDError() + payload = {"data": self._agent_pool_refs(agent_pool_ids)} + self.t.request( + "DELETE", + f"/api/v2/cidr-range-lists/{cidr_range_list_id}/relationships/agent-pools", + json_body=payload, + ) + + @staticmethod + def _agent_pool_refs( + agent_pool_ids: builtins.list[str], + ) -> builtins.list[dict[str, str]]: + # JSON:API to-many relationship modification: an array of identifier + # objects (the request body the docs reference via @payload.json). + if not agent_pool_ids: + raise InvalidAgentPoolIDError("at least one agent pool ID is required") + refs: builtins.list[dict[str, str]] = [] + for apid in agent_pool_ids: + if not valid_string_id(apid): + raise InvalidAgentPoolIDError() + refs.append({"type": "agent-pools", "id": apid}) + return refs + + +class CIDRRanges(_Service): + """Service for managing individual CIDR ranges within IP allowlists.""" + + def read(self, cidr_range_id: str) -> CIDRRange: + """Read a CIDR range by its ID. + + Args: + cidr_range_id: The CIDR range ID (e.g. ``"cidr-xxxxxxxx"``). + + Returns: + The :class:`CIDRRange`. + + Raises: + InvalidCIDRRangeIDError: If ``cidr_range_id`` is not valid. + TFEError: If the API request fails. + + Example: + >>> cidr_range = client.cidr_ranges.read("cidr-6huHpM7asDp7TaiP") + >>> print(cidr_range.cidr_block) + """ + if not valid_string_id(cidr_range_id): + raise InvalidCIDRRangeIDError() + r = self.t.request("GET", f"/api/v2/cidr-ranges/{cidr_range_id}") + body = r.json() + return _cidr_range_from(body["data"], body.get("included")) + + def update(self, cidr_range_id: str, options: CIDRRangeUpdateOptions) -> CIDRRange: + """Update a CIDR range by its ID. + + Args: + cidr_range_id: The CIDR range ID (e.g. ``"cidr-xxxxxxxx"``). + options: The CIDR range updates, as a :class:`CIDRRangeUpdateOptions`. + + Returns: + The updated :class:`CIDRRange`. + + Raises: + InvalidCIDRRangeIDError: If ``cidr_range_id`` is not valid. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import CIDRRangeUpdateOptions + >>> cidr_range = client.cidr_ranges.update( + ... "cidr-6huHpM7asDp7TaiP", + ... CIDRRangeUpdateOptions(cidr_block="192.168.2.0/24"), + ... ) + """ + if not valid_string_id(cidr_range_id): + raise InvalidCIDRRangeIDError() + payload = { + "data": { + "type": "cidr-ranges", + "attributes": options.model_dump( + by_alias=True, exclude_none=True, mode="json" + ), + } + } + r = self.t.request( + "PATCH", f"/api/v2/cidr-ranges/{cidr_range_id}", json_body=payload + ) + body = r.json() + return _cidr_range_from(body["data"], body.get("included")) + + def delete(self, cidr_range_id: str) -> None: + """Delete a CIDR range by its ID. + + Args: + cidr_range_id: The CIDR range ID (e.g. ``"cidr-xxxxxxxx"``). + + Returns: + None. + + Raises: + InvalidCIDRRangeIDError: If ``cidr_range_id`` is not valid. + TFEError: If the API request fails. + + Example: + >>> client.cidr_ranges.delete("cidr-6huHpM7asDp7TaiP") + """ + if not valid_string_id(cidr_range_id): + raise InvalidCIDRRangeIDError() + self.t.request("DELETE", f"/api/v2/cidr-ranges/{cidr_range_id}") diff --git a/src/pytfe/resources/comment.py b/src/pytfe/resources/comment.py index dc8ea6a3..a3730595 100644 --- a/src/pytfe/resources/comment.py +++ b/src/pytfe/resources/comment.py @@ -17,7 +17,23 @@ class Comments(_Service): """Service for managing run comments.""" def list(self, run_id: str) -> Iterator[Comment]: - """List all comments for the given run.""" + """List all comments for the given run. + + Args: + run_id: The run ID (e.g. ``"run-xxxxxxxx"``). + + Returns: + A single-use ``Iterator[Comment]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + InvalidRunIDError: If ``run_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> for comment in client.comments.list("run-CZcmD7eagjhyX0vN"): + ... print(comment.id, comment.body) + """ if not valid_string_id(run_id): raise InvalidRunIDError() path = f"/api/v2/runs/{run_id}/comments" @@ -25,7 +41,22 @@ def list(self, run_id: str) -> Iterator[Comment]: yield self._comment_from(item) def read(self, comment_id: str) -> Comment: - """Read a comment by its ID.""" + """Read a single comment by its ID. + + Args: + comment_id: The comment ID (e.g. ``"comment-xxxxxxxx"``). + + Returns: + The :class:`Comment`. + + Raises: + InvalidCommentIDError: If ``comment_id`` is not a valid resource ID. + TFEError: If the API request fails (e.g. the comment does not exist). + + Example: + >>> comment = client.comments.read("comment-i8sn8sLseSljL7gb") + >>> print(comment.body) + """ if not valid_string_id(comment_id): raise InvalidCommentIDError() r = self.t.request("GET", path=f"/api/v2/comments/{comment_id}") @@ -33,7 +64,26 @@ def read(self, comment_id: str) -> Comment: return self._comment_from(data) def create(self, run_id: str, options: CommentCreateOptions) -> Comment: - """Create a new comment on the given run.""" + """Create a new comment on the given run. + + Args: + run_id: The run ID (e.g. ``"run-xxxxxxxx"``) to comment on. + options: The comment body, as a :class:`CommentCreateOptions`. + + Returns: + The created :class:`Comment`. + + Raises: + InvalidRunIDError: If ``run_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import CommentCreateOptions + >>> comment = client.comments.create( + ... "run-CZcmD7eagjhyX0vN", + ... CommentCreateOptions(body="LGTM, approving this run."), + ... ) + """ if not valid_string_id(run_id): raise InvalidRunIDError() payload = { diff --git a/src/pytfe/resources/configuration_version.py b/src/pytfe/resources/configuration_version.py index 703858e2..82450c82 100644 --- a/src/pytfe/resources/configuration_version.py +++ b/src/pytfe/resources/configuration_version.py @@ -34,7 +34,30 @@ class ConfigurationVersions(_Service): def list( self, workspace_id: str, options: ConfigurationVersionListOptions | None = None ) -> Iterator[ConfigurationVersion]: - """List all configuration versions of a workspace.""" + """List configuration versions for a workspace. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-xxxxxxxx"``). + options: Optional pagination and include options, as a + :class:`ConfigurationVersionListOptions`. + + Returns: + A single-use ``Iterator[ConfigurationVersion]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + ValueError: If ``workspace_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import ConfigurationVersionListOptions + >>> versions = client.configuration_versions.list( + ... "ws-YnyXLq9fy38afEeb", + ... ConfigurationVersionListOptions(page_size=20), + ... ) + >>> for version in versions: + ... print(version.id, version.status) + """ if not valid_string_id(workspace_id): raise ValueError(ERR_INVALID_WORKSPACE_ID) @@ -59,7 +82,27 @@ def create( workspace_id: str, options: ConfigurationVersionCreateOptions | None = None, ) -> ConfigurationVersion: - """Create a new configuration version.""" + """Create a configuration version for a workspace. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-xxxxxxxx"``). + options: Optional create settings, as a + :class:`ConfigurationVersionCreateOptions`. + + Returns: + The created :class:`ConfigurationVersion`. + + Raises: + ValueError: If ``workspace_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import ConfigurationVersionCreateOptions + >>> version = client.configuration_versions.create( + ... "ws-YnyXLq9fy38afEeb", + ... ConfigurationVersionCreateOptions(auto_queue_runs=True), + ... ) + """ if not valid_string_id(workspace_id): raise ValueError(ERR_INVALID_WORKSPACE_ID) @@ -91,7 +134,29 @@ def create( def create_for_registry_module( self, module_id: dict[str, str] ) -> ConfigurationVersion: - """Create a configuration version for a registry module (BETA).""" + """Create a configuration version for a registry module test run. + + Args: + module_id: Registry module identifiers, including ``organization``, + ``registry_name``, ``namespace``, ``name``, and ``provider``. + + Returns: + The created :class:`ConfigurationVersion`. + + Raises: + TFEError: If the API request fails. + + Example: + >>> version = client.configuration_versions.create_for_registry_module( + ... { + ... "organization": "my-org", + ... "registry_name": "private", + ... "namespace": "networking", + ... "name": "vpc", + ... "provider": "aws", + ... } + ... ) + """ # This function creates configuration versions for test runs on registry modules # Path format: /api/v2/organizations/{org}/registry-modules/{registry_name}/{namespace}/{name}/provider/{provider}/test-runs org_name = module_id["organization"] @@ -107,13 +172,51 @@ def create_for_registry_module( return self._parse_configuration_version(response_data["data"]) def read(self, cv_id: str) -> ConfigurationVersion: - """Read a configuration version by its ID.""" + """Read a configuration version by its ID. + + Args: + cv_id: The configuration version ID (e.g. ``"cv-xxxxxxxx"``). + + Returns: + The :class:`ConfigurationVersion`. + + Raises: + ValueError: If ``cv_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> version = client.configuration_versions.read("cv-ntv3HbhJqvFzamy7") + >>> print(version.status) + """ return self.read_with_options(cv_id, None) def read_with_options( self, cv_id: str, options: ConfigurationVersionReadOptions | None = None ) -> ConfigurationVersion: - """Read a configuration version by its ID with options.""" + """Read a configuration version by its ID with include options. + + Args: + cv_id: The configuration version ID (e.g. ``"cv-xxxxxxxx"``). + options: Optional include options, as a + :class:`ConfigurationVersionReadOptions`. + + Returns: + The :class:`ConfigurationVersion`. + + Raises: + ValueError: If ``cv_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import ConfigVerIncludeOpt + >>> from pytfe.models import ConfigurationVersionReadOptions + >>> version = client.configuration_versions.read_with_options( + ... "cv-ntv3HbhJqvFzamy7", + ... ConfigurationVersionReadOptions( + ... include=[ConfigVerIncludeOpt.INGRESS_ATTRIBUTES] + ... ), + ... ) + """ if not valid_string_id(cv_id): raise ValueError(ERR_INVALID_CONFIG_VERSION_ID) @@ -130,12 +233,50 @@ def read_with_options( ) def upload(self, upload_url: str, path: str) -> None: - """Upload configuration files from a directory path.""" + """Upload configuration files to a configuration version upload URL. + + Args: + upload_url: The presigned upload URL from the configuration version. + path: The local directory path to package and upload. + + Returns: + None. + + Raises: + TFEError: If the API request fails. + + Example: + >>> version = client.configuration_versions.create("ws-YnyXLq9fy38afEeb") + >>> client.configuration_versions.upload(version.upload_url, "./terraform") + """ body = pack_contents(path) self.upload_tar_gzip(upload_url, body) def upload_tar_gzip(self, upload_url: str, archive: io.IOBase) -> None: - """Upload a tar gzip archive to the configuration version upload URL.""" + """Upload a tar.gz archive to a configuration version upload URL. + + Args: + upload_url: The presigned upload URL from the configuration version. + archive: A file-like object containing gzipped tar archive bytes. + + Returns: + None. + + Raises: + ValueError: If ``archive`` is not a readable file-like object. + NotFound: If the upload URL is not found or has expired. + AuthError: If the token has no permission to upload to this URL. + ServerError: If the upload server returns a server error. + TFEError: If the upload fails or the API request fails. + + Example: + >>> import io + >>> version = client.configuration_versions.create("ws-YnyXLq9fy38afEeb") + >>> with open("terraform.tar.gz", "rb") as fh: + ... client.configuration_versions.upload_tar_gzip( + ... version.upload_url, io.BytesIO(fh.read()) + ... ) + """ # Get the binary content from the archive if hasattr(archive, "getvalue"): # BytesIO case @@ -187,7 +328,21 @@ def upload_tar_gzip(self, upload_url: str, archive: io.IOBase) -> None: raise TFEError(f"Upload failed: {str(e)}") from e def archive(self, cv_id: str) -> None: - """Archive a configuration version.""" + """Archive a configuration version. + + Args: + cv_id: The configuration version ID (e.g. ``"cv-xxxxxxxx"``). + + Returns: + None. + + Raises: + ValueError: If ``cv_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.configuration_versions.archive("cv-ntv3HbhJqvFzamy7") + """ if not valid_string_id(cv_id): raise ValueError(ERR_INVALID_CONFIG_VERSION_ID) @@ -195,7 +350,22 @@ def archive(self, cv_id: str) -> None: self.t.request("POST", path) def download(self, cv_id: str) -> bytes: - """Download a configuration version.""" + """Download a configuration version archive. + + Args: + cv_id: The configuration version ID (e.g. ``"cv-xxxxxxxx"``). + + Returns: + The raw bytes (the SDK follows the storage/redirect URL for you). + + Raises: + ValueError: If ``cv_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> archive = client.configuration_versions.download("cv-ntv3HbhJqvFzamy7") + >>> len(archive) + """ if not valid_string_id(cv_id): raise ValueError(ERR_INVALID_CONFIG_VERSION_ID) @@ -204,12 +374,25 @@ def download(self, cv_id: str) -> bytes: return response.content def ingress_attributes(self, cv_id: str) -> IngressAttributes | None: - """Get the VCS ingress attributes for a configuration version. + """Get VCS ingress attributes for a configuration version. + + Args: + cv_id: The configuration version ID (e.g. ``"cv-xxxxxxxx"``). + + Returns: + The :class:`IngressAttributes`, or ``None`` when the configuration version + was not created from VCS, the API returns ``null``, or older TFE returns + 404 for missing ingress data. + + Raises: + ValueError: If ``cv_id`` is not a valid resource ID. + TFEError: If the API request fails. - Returns ``None`` if the configuration version was not created from a - VCS connection (so has no ingress data). The API responds with - ``null`` for API-driven CVs and with 404 for some older TFE - instances. + Example: + >>> ingress = client.configuration_versions.ingress_attributes( + ... "cv-ntv3HbhJqvFzamy7" + ... ) + >>> print(ingress.branch if ingress else "api-driven") """ if not valid_string_id(cv_id): raise ValueError(ERR_INVALID_CONFIG_VERSION_ID) @@ -237,15 +420,63 @@ def ingress_attributes(self, cv_id: str) -> IngressAttributes | None: return IngressAttributes.model_validate(attributes) def soft_delete_backing_data(self, cv_id: str) -> None: - """Soft delete backing data for a configuration version (Enterprise only).""" + """Soft delete backing data for a configuration version. + + Args: + cv_id: The configuration version ID (e.g. ``"cv-xxxxxxxx"``). + + Returns: + None. + + Raises: + ValueError: If ``cv_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.configuration_versions.soft_delete_backing_data( + ... "cv-ntv3HbhJqvFzamy7" + ... ) + """ self._manage_backing_data(cv_id, "soft_delete_backing_data") def restore_backing_data(self, cv_id: str) -> None: - """Restore backing data for a configuration version (Enterprise only).""" + """Restore backing data for a configuration version. + + Args: + cv_id: The configuration version ID (e.g. ``"cv-xxxxxxxx"``). + + Returns: + None. + + Raises: + ValueError: If ``cv_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.configuration_versions.restore_backing_data( + ... "cv-ntv3HbhJqvFzamy7" + ... ) + """ self._manage_backing_data(cv_id, "restore_backing_data") def permanently_delete_backing_data(self, cv_id: str) -> None: - """Permanently delete backing data for a configuration version (Enterprise only).""" + """Permanently delete backing data for a configuration version. + + Args: + cv_id: The configuration version ID (e.g. ``"cv-xxxxxxxx"``). + + Returns: + None. + + Raises: + ValueError: If ``cv_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.configuration_versions.permanently_delete_backing_data( + ... "cv-ntv3HbhJqvFzamy7" + ... ) + """ self._manage_backing_data(cv_id, "permanently_delete_backing_data") def _manage_backing_data(self, cv_id: str, action: str) -> None: diff --git a/src/pytfe/resources/cost_estimate.py b/src/pytfe/resources/cost_estimate.py new file mode 100644 index 00000000..0ec740d0 --- /dev/null +++ b/src/pytfe/resources/cost_estimate.py @@ -0,0 +1,81 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from typing import Any + +from .._jsonapi import attach_jsonapi +from ..errors import InvalidCostEstimateIDError +from ..models.cost_estimate import CostEstimate +from ..utils import valid_string_id +from ._base import _Service + + +def _cost_estimate_from( + data: dict[str, Any], included: list[dict[str, Any]] | None = None +) -> CostEstimate: + """Parse a JSON:API cost-estimate resource object into a CostEstimate.""" + attrs = dict(data.get("attributes") or {}) + attrs["id"] = data.get("id") + return attach_jsonapi(CostEstimate.model_validate(attrs), data, included) + + +class CostEstimates(_Service): + """Service for reading run cost estimates.""" + + def read(self, cost_estimate_id: str) -> CostEstimate: + """Read a cost estimate by its ID. + + Cost estimates have no list endpoint; find an ID in a run's + ``relationships.cost-estimate``. + + Args: + cost_estimate_id: The cost estimate ID (e.g. ``"ce-xxxxxxxx"``). + + Returns: + The :class:`CostEstimate`. + + Raises: + InvalidCostEstimateIDError: If ``cost_estimate_id`` is not valid. + TFEError: If the API request fails. + + Example: + >>> cost_estimate = client.cost_estimates.read("ce-BPvFFrYCqRV6qVBK") + >>> print(cost_estimate.status) + """ + if not valid_string_id(cost_estimate_id): + raise InvalidCostEstimateIDError() + r = self.t.request("GET", f"/api/v2/cost-estimates/{cost_estimate_id}") + body = r.json() + data = body.get("data") + # The API docs show the resource wrapped in a single-element array; + # accept both that and the conventional single-object envelope. + if isinstance(data, list): + data = data[0] if data else {} + return _cost_estimate_from(data or {}, body.get("included")) + + def logs(self, cost_estimate_id: str) -> str: + """Read a cost estimate's logs as text. + + Logs are produced once the estimate finishes running; reading them + before then may return an empty body. + + Args: + cost_estimate_id: The cost estimate ID (e.g. ``"ce-xxxxxxxx"``). + + Returns: + The log output as text. + + Raises: + InvalidCostEstimateIDError: If ``cost_estimate_id`` is not valid. + TFEError: If the API request fails. + + Example: + >>> logs = client.cost_estimates.logs("ce-BPvFFrYCqRV6qVBK") + >>> print(logs) + """ + if not valid_string_id(cost_estimate_id): + raise InvalidCostEstimateIDError() + r = self.t.request("GET", f"/api/v2/cost-estimates/{cost_estimate_id}/output") + return r.text diff --git a/src/pytfe/resources/explorer.py b/src/pytfe/resources/explorer.py index 9b171758..c129d7a1 100644 --- a/src/pytfe/resources/explorer.py +++ b/src/pytfe/resources/explorer.py @@ -192,7 +192,27 @@ class Explorer(_Service): def query( self, organization: str, options: ExplorerQueryOptions ) -> Iterator[ExplorerRow]: - """Execute an Explorer query and iterate result rows across all pages.""" + """Execute an Explorer query. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: Query type, fields, filters, sorting, and pagination, as a + :class:`ExplorerQueryOptions`. + + Returns: + A single-use ``Iterator[ExplorerRow]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import ExplorerQueryOptions, ExplorerViewType + >>> rows = client.explorer.query( + ... "my-org", ExplorerQueryOptions(view_type=ExplorerViewType.WORKSPACES) + ... ) + """ if not valid_string_id(organization): raise InvalidOrgError() path = f"/api/v2/organizations/{organization}/explorer" @@ -200,7 +220,26 @@ def query( yield _parse_row(item) def export_csv(self, organization: str, options: ExplorerQueryOptions) -> str: - """Run an Explorer query and return CSV text from the export endpoint.""" + """Export an Explorer query as CSV text. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: Query type, fields, filters, sorting, and pagination, as a + :class:`ExplorerQueryOptions`. + + Returns: + The CSV text. + + Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import ExplorerQueryOptions, ExplorerViewType + >>> csv_text = client.explorer.export_csv( + ... "my-org", ExplorerQueryOptions(view_type=ExplorerViewType.WORKSPACES) + ... ) + """ if not valid_string_id(organization): raise InvalidOrgError() path = f"/api/v2/organizations/{organization}/explorer/export/csv" @@ -208,7 +247,23 @@ def export_csv(self, organization: str, options: ExplorerQueryOptions) -> str: return resp.text def list_saved_views(self, organization: str) -> Iterator[ExplorerSavedView]: - """Iterate all saved Explorer views in an organization.""" + """List saved Explorer views in an organization. + + Args: + organization: The organization name (e.g. ``"my-org"``). + + Returns: + A single-use ``Iterator[ExplorerSavedView]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> for view in client.explorer.list_saved_views("my-org"): + ... print(view.id, view.name) + """ if not valid_string_id(organization): raise InvalidOrgError() path = f"/api/v2/organizations/{organization}/explorer/views" @@ -218,7 +273,31 @@ def list_saved_views(self, organization: str) -> Iterator[ExplorerSavedView]: def create_saved_view( self, organization: str, options: ExplorerSavedViewCreateOptions ) -> ExplorerSavedView: - """Create a saved Explorer view.""" + """Create a saved Explorer view. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: Saved view name and query, as a + :class:`ExplorerSavedViewCreateOptions`. + + Returns: + The created :class:`ExplorerSavedView`. + + Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import ExplorerSavedQuery, ExplorerSavedViewCreateOptions + >>> from pytfe.models import ExplorerViewType + >>> view = client.explorer.create_saved_view( + ... "my-org", + ... ExplorerSavedViewCreateOptions( + ... name="Workspaces", query_type=ExplorerViewType.WORKSPACES, + ... query=ExplorerSavedQuery(query_type=ExplorerViewType.WORKSPACES), + ... ), + ... ) + """ if not valid_string_id(organization): raise InvalidOrgError() body = { @@ -233,7 +312,24 @@ def create_saved_view( return _parse_saved_view(data) def read_saved_view(self, organization: str, view_id: str) -> ExplorerSavedView: - """Read one saved Explorer view by id.""" + """Read a saved Explorer view by its ID. + + Args: + organization: The organization name (e.g. ``"my-org"``). + view_id: The saved Explorer view ID (e.g. ``"view-xxxxxxxx"``). + + Returns: + The :class:`ExplorerSavedView`. + + Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. + InvalidExplorerSavedViewIDError: If ``view_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> view = client.explorer.read_saved_view("my-org", "view-123") + >>> print(view.name) + """ if not valid_string_id(organization): raise InvalidOrgError() if not valid_string_id(view_id): @@ -249,7 +345,33 @@ def update_saved_view( view_id: str, options: ExplorerSavedViewUpdateOptions, ) -> ExplorerSavedView: - """Replace attributes of an existing saved Explorer view.""" + """Update a saved Explorer view. + + Args: + organization: The organization name (e.g. ``"my-org"``). + view_id: The saved Explorer view ID (e.g. ``"view-xxxxxxxx"``). + options: Replacement saved view attributes, as a + :class:`ExplorerSavedViewUpdateOptions`. + + Returns: + The updated :class:`ExplorerSavedView`. + + Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. + InvalidExplorerSavedViewIDError: If ``view_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import ExplorerSavedQuery, ExplorerSavedViewUpdateOptions + >>> from pytfe.models import ExplorerViewType + >>> view = client.explorer.update_saved_view( + ... "my-org", "view-123", + ... ExplorerSavedViewUpdateOptions( + ... name="Workspaces", + ... query=ExplorerSavedQuery(query_type=ExplorerViewType.WORKSPACES), + ... ), + ... ) + """ if not valid_string_id(organization): raise InvalidOrgError() if not valid_string_id(view_id): @@ -267,7 +389,23 @@ def update_saved_view( return _parse_saved_view(data) def delete_saved_view(self, organization: str, view_id: str) -> None: - """Delete a saved Explorer view.""" + """Delete a saved Explorer view. + + Args: + organization: The organization name (e.g. ``"my-org"``). + view_id: The saved Explorer view ID (e.g. ``"view-xxxxxxxx"``). + + Returns: + None. + + Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. + InvalidExplorerSavedViewIDError: If ``view_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.explorer.delete_saved_view("my-org", "view-123") + """ if not valid_string_id(organization): raise InvalidOrgError() if not valid_string_id(view_id): @@ -278,7 +416,25 @@ def delete_saved_view(self, organization: str, view_id: str) -> None: def saved_view_results( self, organization: str, view_id: str ) -> Iterator[ExplorerRow]: - """Execute a saved view and iterate result rows across all pages.""" + """Execute a saved Explorer view. + + Args: + organization: The organization name (e.g. ``"my-org"``). + view_id: The saved Explorer view ID (e.g. ``"view-xxxxxxxx"``). + + Returns: + A single-use ``Iterator[ExplorerRow]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. + InvalidExplorerSavedViewIDError: If ``view_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> for row in client.explorer.saved_view_results("my-org", "view-123"): + ... print(row.id, row.attributes) + """ if not valid_string_id(organization): raise InvalidOrgError() if not valid_string_id(view_id): @@ -288,7 +444,23 @@ def saved_view_results( yield _parse_row(item) def saved_view_results_csv(self, organization: str, view_id: str) -> str: - """Return CSV text for a saved view from the dedicated export endpoint.""" + """Export saved Explorer view results as CSV text. + + Args: + organization: The organization name (e.g. ``"my-org"``). + view_id: The saved Explorer view ID (e.g. ``"view-xxxxxxxx"``). + + Returns: + The CSV text. + + Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. + InvalidExplorerSavedViewIDError: If ``view_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> csv_text = client.explorer.saved_view_results_csv("my-org", "view-123") + """ if not valid_string_id(organization): raise InvalidOrgError() if not valid_string_id(view_id): diff --git a/src/pytfe/resources/github_app_installation.py b/src/pytfe/resources/github_app_installation.py index 0f5fd337..1026cafe 100644 --- a/src/pytfe/resources/github_app_installation.py +++ b/src/pytfe/resources/github_app_installation.py @@ -40,6 +40,26 @@ class GitHubAppInstallations(_Service): def list( self, options: GitHubAppInstallationListOptions | None = None ) -> Iterator[GitHubAppInstallation]: + """List GitHub App installations visible to the authenticated user. + + Args: + options: Optional installation filters, as a + :class:`GitHubAppInstallationListOptions`. + + Returns: + A single-use ``Iterator[GitHubAppInstallation]``. Wrap with + ``list(...)`` to materialize the results or iterate more than once. + + Raises: + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import GitHubAppInstallationListOptions + >>> for installation in client.github_app_installations.list( + ... GitHubAppInstallationListOptions(name="my-github-org") + ... ): + ... print(installation.id, installation.installation_id) + """ # Endpoint is not documented as paginated; we fetch a single page # and yield from it rather than going through the paginating # ``self._list`` helper which would add unwanted page[] params. @@ -53,6 +73,26 @@ def list( yield _parse(item) def read(self, github_app_installation_id: str) -> GitHubAppInstallation: + """Read a GitHub App installation by its HCP Terraform ID. + + Args: + github_app_installation_id: The GitHub App installation ID (e.g. + ``"ghainst-xxxxxxxx"``). + + Returns: + The :class:`GitHubAppInstallation`. + + Raises: + InvalidGitHubAppInstallationIDError: If ``github_app_installation_id`` is + not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> installation = client.github_app_installations.read( + ... "ghainst-xxxxxxxx" + ... ) + >>> print(installation.name) + """ if not valid_string_id(github_app_installation_id): raise InvalidGitHubAppInstallationIDError() # Note: read uses the singular path segment ``installation`` (not diff --git a/src/pytfe/resources/hyok_configuration.py b/src/pytfe/resources/hyok_configuration.py new file mode 100644 index 00000000..54b656d9 --- /dev/null +++ b/src/pytfe/resources/hyok_configuration.py @@ -0,0 +1,279 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""HCP Terraform HYOK (Hold Your Own Key) configurations. + +Manages ``hyok-configurations`` resources, which let an organization encrypt +workspace state and plan data with a customer-controlled KMS key: + +- ``POST /api/v2/organizations/{org}/hyok-configurations`` +- ``GET /api/v2/organizations/{org}/hyok-configurations`` +- ``GET /api/v2/hyok-configurations/{id}`` +- ``DELETE /api/v2/hyok-configurations/{id}`` +- ``POST /api/v2/hyok-configurations/{id}/actions/test`` +- ``POST /api/v2/hyok-configurations/{id}/actions/revoke`` + +A HYOK configuration references an OIDC configuration (``client.*_oidc_configurations``) +and an agent pool. Requires the HYOK entitlement on the organization. + +API reference: +https://developer.hashicorp.com/terraform/cloud-docs/api-docs/hold-your-own-key/configurations +""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +from .._jsonapi import attach_jsonapi +from ..errors import InvalidHYOKConfigurationIDError, InvalidOrgError +from ..models.hyok_configuration import ( + HYOKConfiguration, + HYOKConfigurationCreateOptions, + HYOKConfigurationListOptions, +) +from ..utils import valid_string_id +from ._base import _Service + + +def _rel_data(relationships: dict[str, Any], name: str) -> dict[str, Any]: + return (relationships.get(name) or {}).get("data") or {} + + +def _hyok_from( + data: dict[str, Any], included: list[dict[str, Any]] | None = None +) -> HYOKConfiguration: + """Parse a JSON:API hyok-configurations resource into a HYOKConfiguration.""" + attrs = dict(data.get("attributes") or {}) + attrs["id"] = data.get("id") + rels = data.get("relationships") or {} + if org := _rel_data(rels, "organization").get("id"): + attrs["organization-id"] = org + if pool := _rel_data(rels, "agent-pool").get("id"): + attrs["agent-pool-id"] = pool + oidc = _rel_data(rels, "oidc-configuration") + if oidc.get("id"): + attrs["oidc-configuration-id"] = oidc["id"] + if oidc.get("type"): + attrs["oidc-configuration-type"] = oidc["type"] + return attach_jsonapi(HYOKConfiguration.model_validate(attrs), data, included) + + +class HYOKConfigurations(_Service): + """Service for managing HYOK (Hold Your Own Key) configurations.""" + + def list( + self, + organization: str, + options: HYOKConfigurationListOptions | None = None, + ) -> Iterator[HYOKConfiguration]: + """List the HYOK configurations for an organization. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: Optional pagination controls, as a + :class:`HYOKConfigurationListOptions`. + + Returns: + A single-use ``Iterator[HYOKConfiguration]``. Wrap with + ``list(...)`` to materialize the results or iterate more than once. + + Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import HYOKConfigurationListOptions + >>> for config in client.hyok_configurations.list( + ... "my-org", HYOKConfigurationListOptions(page_size=20) + ... ): + ... print(config.id, config.status) + """ + if not valid_string_id(organization): + raise InvalidOrgError() + params = ( + options.model_dump(by_alias=True, exclude_none=True, mode="json") + if options + else {} + ) + path = f"/api/v2/organizations/{organization}/hyok-configurations" + for item in self._list(path, params=params): + yield _hyok_from(item) + + def create( + self, organization: str, options: HYOKConfigurationCreateOptions + ) -> HYOKConfiguration: + """Create a HYOK configuration in an organization. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: HYOK key, agent pool, and OIDC settings, as a + :class:`HYOKConfigurationCreateOptions`. + + Returns: + The :class:`HYOKConfiguration`. + + Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import HYOKConfigurationCreateOptions + >>> from pytfe.models import OIDCConfigurationType + >>> config = client.hyok_configurations.create( + ... "my-org", + ... HYOKConfigurationCreateOptions( + ... name="prod-key", kek_id="key1", agent_pool_id="apool-x", + ... oidc_configuration_id="voidc-x", + ... oidc_configuration_type=OIDCConfigurationType.VAULT, + ... ), + ... ) + """ + if not valid_string_id(organization): + raise InvalidOrgError() + attributes: dict[str, Any] = {"name": options.name, "kek-id": options.kek_id} + if options.primary is not None: + attributes["primary"] = options.primary + if options.kms_options is not None: + attributes["kms-options"] = options.kms_options.model_dump( + by_alias=True, exclude_none=True + ) + payload = { + "data": { + "type": "hyok-configurations", + "attributes": attributes, + "relationships": { + "organization": { + "data": {"type": "organizations", "id": organization} + }, + "agent-pool": { + "data": {"type": "agent-pools", "id": options.agent_pool_id} + }, + "oidc-configuration": { + "data": { + "type": options.oidc_configuration_type.value, + "id": options.oidc_configuration_id, + } + }, + }, + } + } + r = self.t.request( + "POST", + f"/api/v2/organizations/{organization}/hyok-configurations", + json_body=payload, + ) + body = r.json() + return _hyok_from(body["data"], body.get("included")) + + def read(self, hyok_configuration_id: str) -> HYOKConfiguration: + """Read a HYOK configuration by its ID. + + Args: + hyok_configuration_id: The HYOK configuration ID (e.g. + ``"hyokc-xxxxxxxx"``). + + Returns: + The :class:`HYOKConfiguration`. + + Raises: + InvalidHYOKConfigurationIDError: If ``hyok_configuration_id`` is not a valid + resource ID. + TFEError: If the API request fails. + + Example: + >>> config = client.hyok_configurations.read("hyokc-L4CxAJEEn8vEUEkj") + >>> print(config.name) + """ + if not valid_string_id(hyok_configuration_id): + raise InvalidHYOKConfigurationIDError() + r = self.t.request( + "GET", f"/api/v2/hyok-configurations/{hyok_configuration_id}" + ) + body = r.json() + return _hyok_from(body["data"], body.get("included")) + + def delete(self, hyok_configuration_id: str) -> None: + """Delete a HYOK configuration by its ID. + + The configuration must be **revoked** first — the API rejects deleting a + configuration whose key may still be in use (call ``revoke`` and wait for + ``status == revoked``). + + Args: + hyok_configuration_id: The HYOK configuration ID (e.g. + ``"hyokc-xxxxxxxx"``). + + Returns: + None. + + Raises: + InvalidHYOKConfigurationIDError: If ``hyok_configuration_id`` is not a valid + resource ID. + TFEError: If the API request fails. + + Example: + >>> client.hyok_configurations.delete("hyokc-L4CxAJEEn8vEUEkj") + """ + if not valid_string_id(hyok_configuration_id): + raise InvalidHYOKConfigurationIDError() + self.t.request("DELETE", f"/api/v2/hyok-configurations/{hyok_configuration_id}") + + def revoke(self, hyok_configuration_id: str) -> None: + """Revoke a HYOK configuration. + + Triggers an async revocation (HTTP 202); poll ``read(...).status`` until + it reaches ``revoked``. A configuration must be revoked before it can be + deleted. + + Args: + hyok_configuration_id: The HYOK configuration ID (e.g. + ``"hyokc-xxxxxxxx"``). + + Returns: + None. + + Raises: + InvalidHYOKConfigurationIDError: If ``hyok_configuration_id`` is not a valid + resource ID. + TFEError: If the API request fails. + + Example: + >>> client.hyok_configurations.revoke("hyokc-L4CxAJEEn8vEUEkj") + """ + if not valid_string_id(hyok_configuration_id): + raise InvalidHYOKConfigurationIDError() + self.t.request( + "POST", + f"/api/v2/hyok-configurations/{hyok_configuration_id}/actions/revoke", + json_body={}, + ) + + def test(self, hyok_configuration_id: str) -> None: + """Test a persisted HYOK configuration's key access. + + Triggers an async test (HTTP 202/204); poll ``read(...).status`` to + observe the result (``testing`` -> ``available`` / ``test_failed``). + + Args: + hyok_configuration_id: The HYOK configuration ID (e.g. + ``"hyokc-xxxxxxxx"``). + + Returns: + None. + + Raises: + InvalidHYOKConfigurationIDError: If ``hyok_configuration_id`` is not a valid + resource ID. + TFEError: If the API request fails. + + Example: + >>> client.hyok_configurations.test("hyokc-L4CxAJEEn8vEUEkj") + """ + if not valid_string_id(hyok_configuration_id): + raise InvalidHYOKConfigurationIDError() + self.t.request( + "POST", + f"/api/v2/hyok-configurations/{hyok_configuration_id}/actions/test", + json_body={}, + ) diff --git a/src/pytfe/resources/invoice.py b/src/pytfe/resources/invoice.py new file mode 100644 index 00000000..46ebdb19 --- /dev/null +++ b/src/pytfe/resources/invoice.py @@ -0,0 +1,102 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Read an organization's billing invoices (HCP Terraform only). + +- ``GET /api/v2/organizations/{org}/invoices`` — previous invoices +- ``GET /api/v2/organizations/{org}/invoices/next`` — the upcoming invoice + +The list endpoint uses a non-standard **cursor** pagination: the page size is +fixed at 10 and the next page is fetched with ``?cursor=`` +until ``meta.continuation`` is null. ``self._list`` (page[number]/page[size]) +does not apply, so the cursor loop is implemented here. + +API reference: +https://developer.hashicorp.com/terraform/cloud-docs/api-docs/invoices +""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +from .._jsonapi import attach_jsonapi +from ..errors import InvalidOrgError +from ..models.invoice import Invoice +from ..utils import valid_string_id +from ._base import _Service + + +def _invoice_from(data: dict[str, Any]) -> Invoice: + """Parse a JSON:API billing-invoices resource into an Invoice.""" + attrs = dict(data.get("attributes") or {}) + attrs["id"] = data.get("id") + return attach_jsonapi(Invoice.model_validate(attrs), data) + + +class Invoices(_Service): + """Service for reading organization billing invoices (HCP Terraform only).""" + + def list(self, organization: str) -> Iterator[Invoice]: + """List an organization's previous invoices. + + The API uses cursor pagination; the SDK follows the continuation cursor until + all invoices have been yielded. + + Args: + organization: The organization name (e.g. ``"my-org"``). + + Returns: + A single-use ``Iterator[Invoice]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> for invoice in client.invoices.list("my-org"): + ... print(invoice.number, invoice.total) + """ + if not valid_string_id(organization): + raise InvalidOrgError() + path = f"/api/v2/organizations/{organization}/invoices" + cursor: str | None = None + while True: + params = {"cursor": cursor} if cursor else {} + body = self.t.request("GET", path, params=params).json() + if not isinstance(body, dict): + return + for item in body.get("data") or []: + yield _invoice_from(item) + meta = body.get("meta") or {} + cursor = meta.get("continuation") if isinstance(meta, dict) else None + if not cursor: + return + + def read_next(self, organization: str) -> Invoice | None: + """Read the organization's next upcoming invoice. + + Args: + organization: The organization name (e.g. ``"my-org"``). + + Returns: + The :class:`Invoice` or ``None`` when there is no upcoming invoice (for + example, the API responds ``200`` with a ``null`` body). + + Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> invoice = client.invoices.read_next("my-org") + >>> print(invoice.number if invoice else "no upcoming invoice") + """ + if not valid_string_id(organization): + raise InvalidOrgError() + r = self.t.request("GET", f"/api/v2/organizations/{organization}/invoices/next") + body = r.json() + data = body.get("data") if isinstance(body, dict) else None + if not data: + return None + return _invoice_from(data) diff --git a/src/pytfe/resources/ip_ranges.py b/src/pytfe/resources/ip_ranges.py new file mode 100644 index 00000000..52c96493 --- /dev/null +++ b/src/pytfe/resources/ip_ranges.py @@ -0,0 +1,57 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from datetime import datetime, timezone +from email.utils import format_datetime + +from ..models.ip_range import IPRange +from ._base import _Service + + +class IPRanges(_Service): + """Service for reading HCP Terraform / Terraform Enterprise IP ranges.""" + + def read(self, modified_since: datetime | None = None) -> IPRange | None: + """Read the published outbound IP ranges. + + ``GET /api/meta/ip-ranges`` returns a bare JSON object of CIDR lists + (``api``, ``notifications``, ``sentinel``, ``vcs``). The endpoint does + not require authentication. + + When ``modified_since`` is provided, an ``If-Modified-Since`` request + header is sent; if the ranges have not changed since that time the API + replies ``304 Not Modified`` and this returns ``None``. + + Args: + modified_since: Optional timestamp for ``If-Modified-Since``; naive + datetimes are treated as UTC. + + Returns: + The :class:`IPRange`, or ``None`` if the API returns ``304 Not Modified``. + + Raises: + TFEError: If the API request fails. + + Example: + >>> ranges = client.ip_ranges.read() + >>> print(ranges.api if ranges else "unchanged") + """ + headers: dict[str, str] = {"Accept": "application/json, */*"} + if modified_since is not None: + dt = modified_since + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + headers["If-Modified-Since"] = format_datetime( + dt.astimezone(timezone.utc), usegmt=True + ) + + # allow_redirects=False lets the transport surface the 304 response + # (it would otherwise be raised as an error) so we can map it to None. + r = self.t.request( + "GET", "/api/meta/ip-ranges", headers=headers, allow_redirects=False + ) + if r.status_code == 304: + return None + return IPRange.model_validate(r.json()) diff --git a/src/pytfe/resources/no_code_module.py b/src/pytfe/resources/no_code_module.py index 20fa3425..8762d3c2 100644 --- a/src/pytfe/resources/no_code_module.py +++ b/src/pytfe/resources/no_code_module.py @@ -164,7 +164,29 @@ class NoCodeModules(_Service): def create( self, organization: str, options: NoCodeModuleCreateOptions ) -> NoCodeModule: - """Enable no-code provisioning on a registry module.""" + """Enable no-code provisioning on a registry module. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: Registry module relationship and settings, as a + :class:`NoCodeModuleCreateOptions`. + + Returns: + The :class:`NoCodeModule`. + + Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. + RequiredRegistryModuleIDError: If ``options.registry_module_id`` is not a + valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import NoCodeModuleCreateOptions + >>> module = client.no_code_modules.create( + ... "my-org", + ... NoCodeModuleCreateOptions(registry_module_id="mod-xxxxxxxx"), + ... ) + """ if not valid_string_id(organization): raise InvalidOrgError() if not valid_string_id(options.registry_module_id): @@ -183,7 +205,26 @@ def read( no_code_module_id: str, options: NoCodeModuleReadOptions | None = None, ) -> NoCodeModule: - """Read a no-code module by ID, optionally including variable options.""" + """Read a no-code module by ID. + + Args: + no_code_module_id: The no-code module ID (e.g. ``"nocode-xxxxxxxx"``). + options: Optional include settings, as a :class:`NoCodeModuleReadOptions`. + + Returns: + The :class:`NoCodeModule`. + + Raises: + InvalidNoCodeModuleIDError: If ``no_code_module_id`` is not a valid resource + ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import NoCodeModuleReadOptions + >>> module = client.no_code_modules.read( + ... "nocode-xxxxxxxx", NoCodeModuleReadOptions() + ... ) + """ if not valid_string_id(no_code_module_id): raise InvalidNoCodeModuleIDError() @@ -210,6 +251,25 @@ def update( caller didn't supply ``registry_module_id`` in ``options``, we read the current module to pick up its existing relationship so callers don't have to remember this quirk. + + Args: + no_code_module_id: The no-code module ID (e.g. ``"nocode-xxxxxxxx"``). + options: No-code module fields to update, as a + :class:`NoCodeModuleUpdateOptions`. + + Returns: + The :class:`NoCodeModule`. + + Raises: + InvalidNoCodeModuleIDError: If ``no_code_module_id`` is not a valid resource + ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import NoCodeModuleUpdateOptions + >>> module = client.no_code_modules.update( + ... "nocode-xxxxxxxx", NoCodeModuleUpdateOptions(enabled=True) + ... ) """ if not valid_string_id(no_code_module_id): raise InvalidNoCodeModuleIDError() @@ -230,7 +290,22 @@ def update( return _no_code_module_from(r.json()["data"]) def delete(self, no_code_module_id: str) -> None: - """Disable no-code provisioning for a registry module.""" + """Disable no-code provisioning for a registry module. + + Args: + no_code_module_id: The no-code module ID (e.g. ``"nocode-xxxxxxxx"``). + + Returns: + None. + + Raises: + InvalidNoCodeModuleIDError: If ``no_code_module_id`` is not a valid resource + ID. + TFEError: If the API request fails. + + Example: + >>> client.no_code_modules.delete("nocode-xxxxxxxx") + """ if not valid_string_id(no_code_module_id): raise InvalidNoCodeModuleIDError() @@ -239,9 +314,30 @@ def delete(self, no_code_module_id: str) -> None: def read_variables( self, no_code_module_id: str, version: str ) -> Iterator[RegistryModuleVariable]: - """Iterate the variables declared by a specific version of a no-code - module. Useful for driving a form that lets users supply ``vars`` when - creating a workspace. + """Iterate variables declared by a no-code module version. + + Useful for driving a form that lets users supply ``vars`` when creating + a workspace. + + Args: + no_code_module_id: The no-code module ID (e.g. ``"nocode-xxxxxxxx"``). + version: The registry module version (e.g. ``"1.2.3"``). + + Returns: + A single-use ``Iterator[RegistryModuleVariable]``. Wrap with + ``list(...)`` to materialize the results or iterate more than once. + + Raises: + InvalidNoCodeModuleIDError: If ``no_code_module_id`` is not a valid resource + ID. + InvalidVersionError: If ``version`` is empty or invalid. + TFEError: If the API request fails. + + Example: + >>> for variable in client.no_code_modules.read_variables( + ... "nocode-xxxxxxxx", "1.2.3" + ... ): + ... print(variable.name, variable.required) """ if not valid_string_id(no_code_module_id): raise InvalidNoCodeModuleIDError() @@ -278,6 +374,30 @@ def create_workspace( The returned Workspace is populated by the workspaces parser, so relationships (project, agent_pool, vars) are available when the server includes them. + + Args: + no_code_module_id: The no-code module ID (e.g. ``"nocode-xxxxxxxx"``). + options: Workspace name, project, and variables, as a + :class:`NoCodeWorkspaceCreateOptions`. + + Returns: + The :class:`Workspace`. + + Raises: + InvalidNoCodeModuleIDError: If ``no_code_module_id`` is not a valid resource + ID. + RequiredNameError: If ``options.name`` is empty. + RequiredProjectError: If ``options.project_id`` is not a valid resource ID. + RequiredAgentPoolIDError: If agent execution is requested without a valid + ``options.agent_pool_id``. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import NoCodeWorkspaceCreateOptions + >>> workspace = client.no_code_modules.create_workspace( + ... "nocode-xxxxxxxx", + ... NoCodeWorkspaceCreateOptions(name="app-dev", project_id="prj-x"), + ... ) """ if not valid_string_id(no_code_module_id): raise InvalidNoCodeModuleIDError() @@ -308,10 +428,33 @@ def upgrade_workspace( workspace_id: str, options: NoCodeWorkspaceUpgradeOptions | None = None, ) -> WorkspaceUpgrade: - """Initiate a no-code workspace upgrade. Returns the upgrade record; - poll with ``read_workspace_upgrade`` until ``status`` is - ``planned_and_finished`` (or terminal), then call + """Initiate a no-code workspace upgrade. + + Returns the upgrade record; poll with ``read_workspace_upgrade`` until + ``status`` is ``planned_and_finished`` (or terminal), then call ``confirm_workspace_upgrade``. + + Args: + no_code_module_id: The no-code module ID (e.g. ``"nocode-xxxxxxxx"``). + workspace_id: The workspace ID (e.g. ``"ws-xxxxxxxx"``). + options: Optional upgrade variables, as a + :class:`NoCodeWorkspaceUpgradeOptions`. + + Returns: + The :class:`WorkspaceUpgrade`. + + Raises: + InvalidNoCodeModuleIDError: If ``no_code_module_id`` is not a valid resource + ID. + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import NoCodeWorkspaceUpgradeOptions + >>> upgrade = client.no_code_modules.upgrade_workspace( + ... "nocode-xxxxxxxx", "ws-xxxxxxxx", + ... NoCodeWorkspaceUpgradeOptions(), + ... ) """ if not valid_string_id(no_code_module_id): raise InvalidNoCodeModuleIDError() @@ -341,7 +484,29 @@ def read_workspace_upgrade( workspace_id: str, upgrade_id: str, ) -> WorkspaceUpgrade: - """Read the current status of a no-code workspace upgrade.""" + """Read the current status of a no-code workspace upgrade. + + Args: + no_code_module_id: The no-code module ID (e.g. ``"nocode-xxxxxxxx"``). + workspace_id: The workspace ID (e.g. ``"ws-xxxxxxxx"``). + upgrade_id: The workspace upgrade ID (e.g. ``"wsup-xxxxxxxx"``). + + Returns: + The :class:`WorkspaceUpgrade`. + + Raises: + InvalidNoCodeModuleIDError: If ``no_code_module_id`` is not a valid resource + ID. + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + InvalidWorkspaceUpgradeIDError: If ``upgrade_id`` is not a valid resource + ID. + TFEError: If the API request fails. + + Example: + >>> upgrade = client.no_code_modules.read_workspace_upgrade( + ... "nocode-xxxxxxxx", "ws-xxxxxxxx", "wsup-xxxxxxxx" + ... ) + """ if not valid_string_id(no_code_module_id): raise InvalidNoCodeModuleIDError() if not valid_string_id(workspace_id): @@ -370,6 +535,27 @@ def confirm_workspace_upgrade( rather than a JSON:API envelope; we intentionally return ``None`` and rely on the HTTP status for success/failure semantics, matching the SDK's pattern for action endpoints. + + Args: + no_code_module_id: The no-code module ID (e.g. ``"nocode-xxxxxxxx"``). + workspace_id: The workspace ID (e.g. ``"ws-xxxxxxxx"``). + upgrade_id: The workspace upgrade ID (e.g. ``"wsup-xxxxxxxx"``). + + Returns: + None. + + Raises: + InvalidNoCodeModuleIDError: If ``no_code_module_id`` is not a valid resource + ID. + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + InvalidWorkspaceUpgradeIDError: If ``upgrade_id`` is not a valid resource + ID. + TFEError: If the API request fails. + + Example: + >>> client.no_code_modules.confirm_workspace_upgrade( + ... "nocode-xxxxxxxx", "ws-xxxxxxxx", "wsup-xxxxxxxx" + ... ) """ if not valid_string_id(no_code_module_id): raise InvalidNoCodeModuleIDError() diff --git a/src/pytfe/resources/notification_configuration.py b/src/pytfe/resources/notification_configuration.py index 3b098da5..949e4817 100644 --- a/src/pytfe/resources/notification_configuration.py +++ b/src/pytfe/resources/notification_configuration.py @@ -35,7 +35,25 @@ def list( subscribable_id: str, options: NotificationConfigurationListOptions | None = None, ) -> Iterator[NotificationConfiguration]: - """List all notification configurations associated with a workspace or team.""" + """List notification configurations for a workspace or team. + + Args: + subscribable_id: The workspace or team ID (e.g. ``"ws-xxxxxxxx"``). + options: Optional pagination and subscribable choice, as a + :class:`NotificationConfigurationListOptions`. + + Returns: + A single-use ``Iterator[NotificationConfiguration]``. Wrap with + ``list(...)`` to materialize the results or iterate more than once. + + Raises: + InvalidOrgError: If ``subscribable_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> for config in client.notification_configurations.list("ws-abc123"): + ... print(config.id, config.name) + """ if not valid_string_id(subscribable_id): raise InvalidOrgError("Invalid subscribable ID") @@ -53,7 +71,35 @@ def list( def create( self, subscribable_id: str, options: NotificationConfigurationCreateOptions ) -> NotificationConfiguration: - """Create a new notification configuration.""" + """Create a notification configuration for a workspace or team. + + Args: + subscribable_id: The workspace or team ID (e.g. ``"ws-xxxxxxxx"``). + options: The notification settings, as a + :class:`NotificationConfigurationCreateOptions`. + + Returns: + The created :class:`NotificationConfiguration`. + + Raises: + InvalidOrgError: If ``subscribable_id`` is invalid or the subscribable is + not found. + ValidationError: If options are invalid, verification fails, or the API + response format is invalid. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import NotificationConfigurationCreateOptions + >>> from pytfe.models import NotificationDestinationType + >>> options = NotificationConfigurationCreateOptions( + ... destination_type=NotificationDestinationType.EMAIL, + ... enabled=True, + ... name="Email", + ... ) + >>> config = client.notification_configurations.create( + ... "ws-abc123", options + ... ) + """ if not valid_string_id(subscribable_id): raise InvalidOrgError("Invalid subscribable ID provided") @@ -100,7 +146,24 @@ def create( raise def read(self, notification_config_id: str) -> NotificationConfiguration: - """Read a notification configuration by its ID.""" + """Read a notification configuration by its ID. + + Args: + notification_config_id: The notification configuration ID (e.g. + ``"nc-xxxxxxxx"``). + + Returns: + The :class:`NotificationConfiguration`. + + Raises: + InvalidOrgError: If ``notification_config_id`` is invalid or not found. + ValidationError: If the API response format is invalid. + TFEError: If the API request fails. + + Example: + >>> config = client.notification_configurations.read("nc-123456789") + >>> print(config.name) + """ if not valid_string_id(notification_config_id): raise InvalidOrgError("Invalid notification configuration ID provided") @@ -128,7 +191,30 @@ def update( notification_config_id: str, options: NotificationConfigurationUpdateOptions, ) -> NotificationConfiguration: - """Update an existing notification configuration.""" + """Update a notification configuration. + + Args: + notification_config_id: The notification configuration ID (e.g. + ``"nc-xxxxxxxx"``). + options: The notification fields to change, as a + :class:`NotificationConfigurationUpdateOptions`. + + Returns: + The updated :class:`NotificationConfiguration`. + + Raises: + InvalidOrgError: If ``notification_config_id`` is not a valid resource ID. + ValidationError: If options are invalid or the API response format is + invalid. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import NotificationConfigurationUpdateOptions + >>> options = NotificationConfigurationUpdateOptions(enabled=False) + >>> config = client.notification_configurations.update( + ... "nc-123456789", options + ... ) + """ if not valid_string_id(notification_config_id): raise InvalidOrgError("Invalid notification configuration ID") @@ -151,7 +237,22 @@ def update( raise ValidationError("Invalid response format from API") def delete(self, notification_config_id: str) -> None: - """Delete a notification configuration by its ID.""" + """Delete a notification configuration by its ID. + + Args: + notification_config_id: The notification configuration ID (e.g. + ``"nc-xxxxxxxx"``). + + Returns: + None. + + Raises: + InvalidOrgError: If ``notification_config_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.notification_configurations.delete("nc-123456789") + """ if not valid_string_id(notification_config_id): raise InvalidOrgError("Invalid notification configuration ID") @@ -159,7 +260,25 @@ def delete(self, notification_config_id: str) -> None: self.t.request("DELETE", url) def verify(self, notification_config_id: str) -> NotificationConfiguration: - """Verify a notification configuration by delivering a verification payload.""" + """Verify a notification configuration by delivering a verification payload. + + Args: + notification_config_id: The notification configuration ID (e.g. + ``"nc-xxxxxxxx"``). + + Returns: + The verified :class:`NotificationConfiguration`. + + Raises: + InvalidOrgError: If ``notification_config_id`` is invalid or not found. + ValidationError: If verification fails or the API response format is + invalid. + TFEError: If the API request fails. + + Example: + >>> config = client.notification_configurations.verify("nc-123456789") + >>> print(config.id) + """ if not valid_string_id(notification_config_id): raise InvalidOrgError("Invalid notification configuration ID provided") diff --git a/src/pytfe/resources/oauth_client.py b/src/pytfe/resources/oauth_client.py index 40c4df2f..637699c0 100644 --- a/src/pytfe/resources/oauth_client.py +++ b/src/pytfe/resources/oauth_client.py @@ -34,7 +34,25 @@ class OAuthClients(_Service): def list( self, organization: str, options: OAuthClientListOptions | None = None ) -> Iterator[OAuthClient]: - """List all OAuth clients for a given organization.""" + """List OAuth clients in an organization. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: Optional includes and pagination, as a + :class:`OAuthClientListOptions`. + + Returns: + A single-use ``Iterator[OAuthClient]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + ValueError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> for oauth_client in client.oauth_clients.list("my-org"): + ... print(oauth_client.id, oauth_client.name) + """ if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) @@ -57,7 +75,31 @@ def list( def create( self, organization: str, options: OAuthClientCreateOptions ) -> OAuthClient: - """Create an OAuth client to connect an organization and a VCS provider.""" + """Create an OAuth client for a VCS provider. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: OAuth client provider settings, as a + :class:`OAuthClientCreateOptions`. + + Returns: + The created :class:`OAuthClient`. + + Raises: + ValueError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import OAuthClientCreateOptions, ServiceProviderType + >>> oauth_client = client.oauth_clients.create( + ... "my-org", + ... OAuthClientCreateOptions( + ... name="github", api_url="https://api.github.com", + ... http_url="https://github.com", + ... service_provider=ServiceProviderType.GITHUB, + ... ), + ... ) + """ if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) @@ -86,13 +128,46 @@ def create( return self._parse_oauth_client(data) def read(self, oauth_client_id: str) -> OAuthClient: - """Read an OAuth client by its ID.""" + """Read an OAuth client by its ID. + + Args: + oauth_client_id: The OAuth client ID (e.g. ``"oc-xxxxxxxx"``). + + Returns: + The :class:`OAuthClient`. + + Raises: + ValueError: If ``oauth_client_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> oauth_client = client.oauth_clients.read("oc-test123") + >>> print(oauth_client.name) + """ return self.read_with_options(oauth_client_id, None) def read_with_options( self, oauth_client_id: str, options: OAuthClientReadOptions | None ) -> OAuthClient: - """Read an OAuth client by its ID with options.""" + """Read an OAuth client by its ID with include options. + + Args: + oauth_client_id: The OAuth client ID (e.g. ``"oc-xxxxxxxx"``). + options: Optional include controls, as a :class:`OAuthClientReadOptions`. + + Returns: + The :class:`OAuthClient`. + + Raises: + ValueError: If ``oauth_client_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import OAuthClientReadOptions + >>> oauth_client = client.oauth_clients.read_with_options( + ... "oc-test123", OAuthClientReadOptions() + ... ) + """ if not valid_string_id(oauth_client_id): raise ValueError(ERR_INVALID_OAUTH_CLIENT_ID) @@ -110,7 +185,26 @@ def read_with_options( def update( self, oauth_client_id: str, options: OAuthClientUpdateOptions ) -> OAuthClient: - """Update an OAuth client by its ID.""" + """Update an OAuth client by its ID. + + Args: + oauth_client_id: The OAuth client ID (e.g. ``"oc-xxxxxxxx"``). + options: OAuth client attributes to update, as a + :class:`OAuthClientUpdateOptions`. + + Returns: + The updated :class:`OAuthClient`. + + Raises: + ValueError: If ``oauth_client_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import OAuthClientUpdateOptions + >>> oauth_client = client.oauth_clients.update( + ... "oc-test123", OAuthClientUpdateOptions(name="github-main") + ... ) + """ if not valid_string_id(oauth_client_id): raise ValueError(ERR_INVALID_OAUTH_CLIENT_ID) @@ -132,7 +226,21 @@ def update( return self._parse_oauth_client(data) def delete(self, oauth_client_id: str) -> None: - """Delete an OAuth client by its ID.""" + """Delete an OAuth client by its ID. + + Args: + oauth_client_id: The OAuth client ID (e.g. ``"oc-xxxxxxxx"``). + + Returns: + None. + + Raises: + ValueError: If ``oauth_client_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.oauth_clients.delete("oc-test123") + """ if not valid_string_id(oauth_client_id): raise ValueError(ERR_INVALID_OAUTH_CLIENT_ID) @@ -142,7 +250,26 @@ def delete(self, oauth_client_id: str) -> None: def add_projects( self, oauth_client_id: str, options: OAuthClientAddProjectsOptions ) -> None: - """Add projects to a given OAuth client.""" + """Add projects on an OAuth client. + + Args: + oauth_client_id: The OAuth client ID (e.g. ``"oc-xxxxxxxx"``). + options: Project relationship changes, as a :class:`OAuthClientAddProjectsOptions`. + + Returns: + None. + + Raises: + ValueError: If ``oauth_client_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import OAuthClientAddProjectsOptions + >>> client.oauth_clients.add_projects( + ... "oc-test123", + ... OAuthClientAddProjectsOptions(projects=[{"type": "projects", "id": "prj-test1"}]), + ... ) + """ if not valid_string_id(oauth_client_id): raise ValueError(ERR_INVALID_OAUTH_CLIENT_ID) @@ -154,7 +281,26 @@ def add_projects( def remove_projects( self, oauth_client_id: str, options: OAuthClientRemoveProjectsOptions ) -> None: - """Remove projects from an OAuth client.""" + """Remove projects on an OAuth client. + + Args: + oauth_client_id: The OAuth client ID (e.g. ``"oc-xxxxxxxx"``). + options: Project relationship changes, as a :class:`OAuthClientRemoveProjectsOptions`. + + Returns: + None. + + Raises: + ValueError: If ``oauth_client_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import OAuthClientRemoveProjectsOptions + >>> client.oauth_clients.remove_projects( + ... "oc-test123", + ... OAuthClientRemoveProjectsOptions(projects=[{"type": "projects", "id": "prj-test1"}]), + ... ) + """ if not valid_string_id(oauth_client_id): raise ValueError(ERR_INVALID_OAUTH_CLIENT_ID) diff --git a/src/pytfe/resources/oauth_token.py b/src/pytfe/resources/oauth_token.py index a417fa83..9ef9667e 100644 --- a/src/pytfe/resources/oauth_token.py +++ b/src/pytfe/resources/oauth_token.py @@ -24,7 +24,28 @@ class OAuthTokens(_Service): def list( self, organization: str, options: OAuthTokenListOptions | None = None ) -> Iterator[OAuthToken]: - """List all the OAuth tokens for a given organization.""" + """List OAuth tokens for an organization. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: Optional pagination options, as a + :class:`OAuthTokenListOptions`. + + Returns: + A single-use ``Iterator[OAuthToken]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + ValueError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import OAuthTokenListOptions + >>> for token in client.oauth_tokens.list( + ... "my-org", OAuthTokenListOptions(page_size=50) + ... ): + ... print(token.id, token.service_provider_user) + """ if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) @@ -39,7 +60,23 @@ def list( yield self._parse_oauth_token(item) def read(self, oauth_token_id: str) -> OAuthToken: - """Read an OAuth token by its ID.""" + """Read an OAuth token by its ID. + + Args: + oauth_token_id: The OAuth token ID (e.g. ``"ot-xxxxxxxx"``). + + Returns: + The :class:`OAuthToken`. + + Raises: + ValueError: If ``oauth_token_id`` is not a valid resource ID, or if the + response is missing the expected ``data`` member. + TFEError: If the API request fails. + + Example: + >>> token = client.oauth_tokens.read("ot-qV12JnZxR47n3XgN") + >>> print(token.has_ssh_key) + """ if not valid_string_id(oauth_token_id): raise ValueError(ERR_INVALID_OAUTH_TOKEN_ID) @@ -55,7 +92,28 @@ def read(self, oauth_token_id: str) -> OAuthToken: def update( self, oauth_token_id: str, options: OAuthTokenUpdateOptions ) -> OAuthToken: - """Update an existing OAuth token.""" + """Update an OAuth token's SSH private key. + + Args: + oauth_token_id: The OAuth token ID (e.g. ``"ot-xxxxxxxx"``). + options: The OAuth token update options, as a + :class:`OAuthTokenUpdateOptions`. + + Returns: + The :class:`OAuthToken`. + + Raises: + ValueError: If ``oauth_token_id`` is not a valid resource ID, or if the + response is missing the expected ``data`` member. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import OAuthTokenUpdateOptions + >>> token = client.oauth_tokens.update( + ... "ot-qV12JnZxR47n3XgN", + ... OAuthTokenUpdateOptions(private_ssh_key="-----BEGIN RSA PRIVATE KEY-----"), + ... ) + """ if not valid_string_id(oauth_token_id): raise ValueError(ERR_INVALID_OAUTH_TOKEN_ID) @@ -79,7 +137,21 @@ def update( raise ValueError("Invalid response format") def delete(self, oauth_token_id: str) -> None: - """Delete an OAuth token by its ID.""" + """Delete an OAuth token by its ID. + + Args: + oauth_token_id: The OAuth token ID (e.g. ``"ot-xxxxxxxx"``). + + Returns: + None. + + Raises: + ValueError: If ``oauth_token_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.oauth_tokens.delete("ot-qV12JnZxR47n3XgN") + """ if not valid_string_id(oauth_token_id): raise ValueError(ERR_INVALID_OAUTH_TOKEN_ID) diff --git a/src/pytfe/resources/oidc_configurations.py b/src/pytfe/resources/oidc_configurations.py index 3c38e772..955612bb 100644 --- a/src/pytfe/resources/oidc_configurations.py +++ b/src/pytfe/resources/oidc_configurations.py @@ -153,9 +153,49 @@ class AWSOIDCConfigurations(_OIDCConfigurationsBase[AWSOIDCConfiguration]): def create( self, organization: str, options: AWSOIDCConfigurationCreateOptions ) -> AWSOIDCConfiguration: + """Create an AWS OIDC configuration. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: The AWS configuration attributes, as a + :class:`AWSOIDCConfigurationCreateOptions`. + + Returns: + The created :class:`AWSOIDCConfiguration`. + + Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import AWSOIDCConfigurationCreateOptions + >>> config = client.aws_oidc_configurations.create( + ... "my-org", + ... AWSOIDCConfigurationCreateOptions( + ... role_arn="arn:aws:iam::111122223333:role/tfc" + ... ), + ... ) + """ return self._create(organization, options) def read(self, oidc_configuration_id: str) -> AWSOIDCConfiguration: + """Read an AWS OIDC configuration by its ID. + + Args: + oidc_configuration_id: The OIDC configuration ID + (e.g. ``"oidc-xxxxxxxx"``). + + Returns: + The :class:`AWSOIDCConfiguration`. + + Raises: + InvalidOIDCConfigurationIDError: If ``oidc_configuration_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> config = client.aws_oidc_configurations.read("oidc-aws-1") + >>> print(config.id) + """ return self._read(oidc_configuration_id) def update( @@ -163,9 +203,49 @@ def update( oidc_configuration_id: str, options: AWSOIDCConfigurationUpdateOptions, ) -> AWSOIDCConfiguration: + """Update an AWS OIDC configuration. + + Args: + oidc_configuration_id: The OIDC configuration ID + (e.g. ``"oidc-xxxxxxxx"``). + options: The changed AWS attributes, as a + :class:`AWSOIDCConfigurationUpdateOptions`. + + Returns: + The updated :class:`AWSOIDCConfiguration`. + + Raises: + InvalidOIDCConfigurationIDError: If ``oidc_configuration_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import AWSOIDCConfigurationUpdateOptions + >>> config = client.aws_oidc_configurations.update( + ... "oidc-aws-1", + ... AWSOIDCConfigurationUpdateOptions( + ... role_arn="arn:aws:iam::111122223333:role/tfc-updated" + ... ), + ... ) + """ return self._update(oidc_configuration_id, options) def delete(self, oidc_configuration_id: str) -> None: + """Delete an AWS OIDC configuration by its ID. + + Args: + oidc_configuration_id: The OIDC configuration ID + (e.g. ``"oidc-xxxxxxxx"``). + + Returns: + None. + + Raises: + InvalidOIDCConfigurationIDError: If ``oidc_configuration_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.aws_oidc_configurations.delete("oidc-aws-1") + """ self._delete(oidc_configuration_id) @@ -183,9 +263,51 @@ class AzureOIDCConfigurations(_OIDCConfigurationsBase[AzureOIDCConfiguration]): def create( self, organization: str, options: AzureOIDCConfigurationCreateOptions ) -> AzureOIDCConfiguration: + """Create an Azure OIDC configuration. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: The Azure configuration attributes, as a + :class:`AzureOIDCConfigurationCreateOptions`. + + Returns: + The created :class:`AzureOIDCConfiguration`. + + Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import AzureOIDCConfigurationCreateOptions + >>> config = client.azure_oidc_configurations.create( + ... "my-org", + ... AzureOIDCConfigurationCreateOptions( + ... client_id="client-uuid", + ... subscription_id="sub-uuid", + ... tenant_id="tenant-uuid", + ... ), + ... ) + """ return self._create(organization, options) def read(self, oidc_configuration_id: str) -> AzureOIDCConfiguration: + """Read an Azure OIDC configuration by its ID. + + Args: + oidc_configuration_id: The OIDC configuration ID + (e.g. ``"oidc-xxxxxxxx"``). + + Returns: + The :class:`AzureOIDCConfiguration`. + + Raises: + InvalidOIDCConfigurationIDError: If ``oidc_configuration_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> config = client.azure_oidc_configurations.read("oidc-azure-1") + >>> print(config.id) + """ return self._read(oidc_configuration_id) def update( @@ -193,9 +315,47 @@ def update( oidc_configuration_id: str, options: AzureOIDCConfigurationUpdateOptions, ) -> AzureOIDCConfiguration: + """Update an Azure OIDC configuration. + + Args: + oidc_configuration_id: The OIDC configuration ID + (e.g. ``"oidc-xxxxxxxx"``). + options: The changed Azure attributes, as a + :class:`AzureOIDCConfigurationUpdateOptions`. + + Returns: + The updated :class:`AzureOIDCConfiguration`. + + Raises: + InvalidOIDCConfigurationIDError: If ``oidc_configuration_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import AzureOIDCConfigurationUpdateOptions + >>> config = client.azure_oidc_configurations.update( + ... "oidc-azure-1", + ... AzureOIDCConfigurationUpdateOptions(client_id="new-client-uuid"), + ... ) + """ return self._update(oidc_configuration_id, options) def delete(self, oidc_configuration_id: str) -> None: + """Delete an Azure OIDC configuration by its ID. + + Args: + oidc_configuration_id: The OIDC configuration ID + (e.g. ``"oidc-xxxxxxxx"``). + + Returns: + None. + + Raises: + InvalidOIDCConfigurationIDError: If ``oidc_configuration_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.azure_oidc_configurations.delete("oidc-azure-1") + """ self._delete(oidc_configuration_id) @@ -213,9 +373,51 @@ class GCPOIDCConfigurations(_OIDCConfigurationsBase[GCPOIDCConfiguration]): def create( self, organization: str, options: GCPOIDCConfigurationCreateOptions ) -> GCPOIDCConfiguration: + """Create a GCP OIDC configuration. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: The GCP configuration attributes, as a + :class:`GCPOIDCConfigurationCreateOptions`. + + Returns: + The created :class:`GCPOIDCConfiguration`. + + Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import GCPOIDCConfigurationCreateOptions + >>> config = client.gcp_oidc_configurations.create( + ... "my-org", + ... GCPOIDCConfigurationCreateOptions( + ... service_account_email="tfc@project.iam.gserviceaccount.com", + ... project_number="123456789012", + ... workload_provider_name="projects/123/locations/global", + ... ), + ... ) + """ return self._create(organization, options) def read(self, oidc_configuration_id: str) -> GCPOIDCConfiguration: + """Read a GCP OIDC configuration by its ID. + + Args: + oidc_configuration_id: The OIDC configuration ID + (e.g. ``"oidc-xxxxxxxx"``). + + Returns: + The :class:`GCPOIDCConfiguration`. + + Raises: + InvalidOIDCConfigurationIDError: If ``oidc_configuration_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> config = client.gcp_oidc_configurations.read("oidc-gcp-1") + >>> print(config.id) + """ return self._read(oidc_configuration_id) def update( @@ -223,9 +425,49 @@ def update( oidc_configuration_id: str, options: GCPOIDCConfigurationUpdateOptions, ) -> GCPOIDCConfiguration: + """Update a GCP OIDC configuration. + + Args: + oidc_configuration_id: The OIDC configuration ID + (e.g. ``"oidc-xxxxxxxx"``). + options: The changed GCP attributes, as a + :class:`GCPOIDCConfigurationUpdateOptions`. + + Returns: + The updated :class:`GCPOIDCConfiguration`. + + Raises: + InvalidOIDCConfigurationIDError: If ``oidc_configuration_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import GCPOIDCConfigurationUpdateOptions + >>> config = client.gcp_oidc_configurations.update( + ... "oidc-gcp-1", + ... GCPOIDCConfigurationUpdateOptions( + ... service_account_email="new@project.iam.gserviceaccount.com" + ... ), + ... ) + """ return self._update(oidc_configuration_id, options) def delete(self, oidc_configuration_id: str) -> None: + """Delete a GCP OIDC configuration by its ID. + + Args: + oidc_configuration_id: The OIDC configuration ID + (e.g. ``"oidc-xxxxxxxx"``). + + Returns: + None. + + Raises: + InvalidOIDCConfigurationIDError: If ``oidc_configuration_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.gcp_oidc_configurations.delete("oidc-gcp-1") + """ self._delete(oidc_configuration_id) @@ -243,9 +485,49 @@ class VaultOIDCConfigurations(_OIDCConfigurationsBase[VaultOIDCConfiguration]): def create( self, organization: str, options: VaultOIDCConfigurationCreateOptions ) -> VaultOIDCConfiguration: + """Create a Vault OIDC configuration. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: The Vault configuration attributes, as a + :class:`VaultOIDCConfigurationCreateOptions`. + + Returns: + The created :class:`VaultOIDCConfiguration`. + + Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import VaultOIDCConfigurationCreateOptions + >>> config = client.vault_oidc_configurations.create( + ... "my-org", + ... VaultOIDCConfigurationCreateOptions( + ... address="https://vault.example.com", role_name="tfc" + ... ), + ... ) + """ return self._create(organization, options) def read(self, oidc_configuration_id: str) -> VaultOIDCConfiguration: + """Read a Vault OIDC configuration by its ID. + + Args: + oidc_configuration_id: The OIDC configuration ID + (e.g. ``"oidc-xxxxxxxx"``). + + Returns: + The :class:`VaultOIDCConfiguration`. + + Raises: + InvalidOIDCConfigurationIDError: If ``oidc_configuration_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> config = client.vault_oidc_configurations.read("oidc-vault-1") + >>> print(config.id) + """ return self._read(oidc_configuration_id) def update( @@ -253,7 +535,45 @@ def update( oidc_configuration_id: str, options: VaultOIDCConfigurationUpdateOptions, ) -> VaultOIDCConfiguration: + """Update a Vault OIDC configuration. + + Args: + oidc_configuration_id: The OIDC configuration ID + (e.g. ``"oidc-xxxxxxxx"``). + options: The changed Vault attributes, as a + :class:`VaultOIDCConfigurationUpdateOptions`. + + Returns: + The updated :class:`VaultOIDCConfiguration`. + + Raises: + InvalidOIDCConfigurationIDError: If ``oidc_configuration_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import VaultOIDCConfigurationUpdateOptions + >>> config = client.vault_oidc_configurations.update( + ... "oidc-vault-1", + ... VaultOIDCConfigurationUpdateOptions(role_name="tfc-updated"), + ... ) + """ return self._update(oidc_configuration_id, options) def delete(self, oidc_configuration_id: str) -> None: + """Delete a Vault OIDC configuration by its ID. + + Args: + oidc_configuration_id: The OIDC configuration ID + (e.g. ``"oidc-xxxxxxxx"``). + + Returns: + None. + + Raises: + InvalidOIDCConfigurationIDError: If ``oidc_configuration_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.vault_oidc_configurations.delete("oidc-vault-1") + """ self._delete(oidc_configuration_id) diff --git a/src/pytfe/resources/org_token_ttl_policy.py b/src/pytfe/resources/org_token_ttl_policy.py index 8f6d0c7f..ccfecc2b 100644 --- a/src/pytfe/resources/org_token_ttl_policy.py +++ b/src/pytfe/resources/org_token_ttl_policy.py @@ -42,6 +42,23 @@ class OrganizationTokenTTLPolicies(_Service): """ def list(self, organization: str) -> Iterator[OrgTokenTTLPolicy]: + """List organization API-token TTL policies. + + Args: + organization: The organization name (e.g. ``"my-org"``). + + Returns: + A single-use ``Iterator[OrgTokenTTLPolicy]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + ValueError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> for policy in client.organization_token_ttl_policies.list("my-org"): + ... print(policy.token_type, policy.max_ttl_ms) + """ if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) # The endpoint is not documented as paginated; we iterate the @@ -59,8 +76,29 @@ def update( organization: str, options: OrgTokenTTLPolicyUpdateOptions, ) -> builtins.list[OrgTokenTTLPolicy]: - """PATCH a partial set of token-type policies. Returns the full - post-update policy list as the server reports it.""" + """Update organization API-token TTL policies. + + PATCH a partial set of token-type policies; unchanged token types keep + their existing TTLs. The server returns the full post-update policy list. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: Token TTL policy update settings, as a + :class:`OrgTokenTTLPolicyUpdateOptions`. + + Returns: + A ``list[OrgTokenTTLPolicy]``. + + Raises: + ValueError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import OrgTokenTTLPolicyUpdateOptions + >>> policies = client.organization_token_ttl_policies.update( + ... "my-org", OrgTokenTTLPolicyUpdateOptions(user="90d") + ... ) + """ if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) body = {"data": options.to_payload()} @@ -72,8 +110,24 @@ def update( return [_parse_policy(item) for item in r.json().get("data") or []] def reset_to_defaults(self, organization: str) -> builtins.list[OrgTokenTTLPolicy]: - """Reset all four token types to the documented 2-year default - (``DEFAULT_MAX_TTL_MS = 63_072_000_000``). + """Reset all organization API-token TTL policies to defaults. + + The documented default is two years, + ``DEFAULT_MAX_TTL_MS = 63_072_000_000``. + + Args: + organization: The organization name (e.g. ``"my-org"``). + + Returns: + A ``list[OrgTokenTTLPolicy]``. + + Raises: + TFEError: If the API request fails. + + Example: + >>> policies = client.organization_token_ttl_policies.reset_to_defaults( + ... "my-org" + ... ) """ return self.update( organization, diff --git a/src/pytfe/resources/organization_audit_configuration.py b/src/pytfe/resources/organization_audit_configuration.py index 620db66a..df3a7994 100644 --- a/src/pytfe/resources/organization_audit_configuration.py +++ b/src/pytfe/resources/organization_audit_configuration.py @@ -19,7 +19,23 @@ class OrganizationAuditConfigurations(_Service): """Organization audit configuration service.""" def read(self, organization: str) -> OrganizationAuditConfiguration: - """Read an organization's audit configuration by organization name.""" + """Read an organization's audit configuration. + + Args: + organization: The organization name (e.g. ``"my-org"``). + + Returns: + The :class:`OrganizationAuditConfiguration`. + + Raises: + ValueError: If ``organization`` is invalid, or if the response format + is invalid. + TFEError: If the API request fails. + + Example: + >>> config = client.organization_audit_configurations.read("my-org") + >>> print(config.audit_trails.enabled if config.audit_trails else None) + """ if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) @@ -33,7 +49,23 @@ def read(self, organization: str) -> OrganizationAuditConfiguration: return self._parse_audit_configuration(data) def test(self, organization: str) -> OrganizationAuditConfigurationTest: - """Send a test audit event for an organization.""" + """Send a test audit event for an organization. + + Args: + organization: The organization name (e.g. ``"my-org"``). + + Returns: + The :class:`OrganizationAuditConfigurationTest`. + + Raises: + ValueError: If ``organization`` is invalid, or if the response format + is invalid. + TFEError: If the API request fails. + + Example: + >>> result = client.organization_audit_configurations.test("my-org") + >>> print(result.request_id) + """ if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) @@ -50,7 +82,31 @@ def update( organization: str, options: OrganizationAuditConfigurationOptions, ) -> OrganizationAuditConfiguration: - """Update an organization's audit configuration.""" + """Update an organization's audit configuration. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: The audit configuration settings, as a + :class:`OrganizationAuditConfigurationOptions`. + + Returns: + The updated :class:`OrganizationAuditConfiguration`. + + Raises: + ValueError: If ``organization`` is invalid, or if the response format + is invalid. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import OrganizationAuditConfigAuditTrails + >>> from pytfe.models import OrganizationAuditConfigurationOptions + >>> config = client.organization_audit_configurations.update( + ... "my-org", + ... OrganizationAuditConfigurationOptions( + ... audit_trails=OrganizationAuditConfigAuditTrails(enabled=True) + ... ), + ... ) + """ if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) diff --git a/src/pytfe/resources/organization_membership.py b/src/pytfe/resources/organization_membership.py index 9da4b4f8..551b8996 100644 --- a/src/pytfe/resources/organization_membership.py +++ b/src/pytfe/resources/organization_membership.py @@ -57,17 +57,25 @@ def create( organization: str, options: OrganizationMembershipCreateOptions, ) -> OrganizationMembership: - """Create an organization membership with the given options. + """Create an organization membership invitation. Args: - organization: The name of the organization - options: The options for creating the organization membership + organization: The organization name (e.g. ``"my-org"``). + options: Email address and optional teams, as a + :class:`OrganizationMembershipCreateOptions`. Returns: - The created OrganizationMembership + The created :class:`OrganizationMembership`. Raises: - ValueError: If organization name is invalid or options are invalid + ValueError: If ``organization`` or ``options.email`` is invalid. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import OrganizationMembershipCreateOptions + >>> membership = client.organization_memberships.create( + ... "my-org", OrganizationMembershipCreateOptions(email="dev@example.com") + ... ) """ if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) @@ -112,17 +120,24 @@ def list( organization: str, options: OrganizationMembershipListOptions | None = None, ) -> Iterator[OrganizationMembership]: - """List all the organization memberships of the given organization. + """List organization memberships in an organization. Args: - organization: The name of the organization - options: Optional filters and pagination options + organization: The organization name (e.g. ``"my-org"``). + options: Optional filters, includes, and pagination, as a + :class:`OrganizationMembershipListOptions`. - Yields: - OrganizationMembership instances one at a time + Returns: + A single-use ``Iterator[OrganizationMembership]``. Wrap with + ``list(...)`` to materialize the results or iterate more than once. Raises: - ValueError: If organization name is invalid or email filters are invalid + ValueError: If ``organization`` or an email filter is invalid. + TFEError: If the API request fails. + + Example: + >>> for membership in client.organization_memberships.list("my-org"): + ... print(membership.id, membership.email) """ if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) @@ -168,14 +183,19 @@ def read(self, organization_membership_id: str) -> OrganizationMembership: """Read an organization membership by its ID. Args: - organization_membership_id: The ID of the organization membership to read + organization_membership_id: The organization membership ID + (e.g. ``"ou-xxxxxxxx"``). Returns: - The OrganizationMembership + The :class:`OrganizationMembership`. Raises: - ValueError: If organization membership ID is invalid - NotFound: If the resource is not found + ValueError: If ``organization_membership_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> membership = client.organization_memberships.read("ou-abc123def456") + >>> print(membership.email) """ return self.read_with_options( organization_membership_id, OrganizationMembershipReadOptions() @@ -186,18 +206,26 @@ def read_with_options( organization_membership_id: str, options: OrganizationMembershipReadOptions | None = None, ) -> OrganizationMembership: - """Read an organization membership by ID with options. + """Read an organization membership by its ID with options. Args: - organization_membership_id: The ID of the organization membership to read - options: Read options including include parameters + organization_membership_id: The organization membership ID + (e.g. ``"ou-xxxxxxxx"``). + options: Optional include controls, as a + :class:`OrganizationMembershipReadOptions`. Returns: - The OrganizationMembership with requested included data + The :class:`OrganizationMembership`. Raises: - ValueError: If organization membership ID is invalid - NotFound: If the resource is not found + ValueError: If ``organization_membership_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import OrganizationMembershipReadOptions + >>> membership = client.organization_memberships.read_with_options( + ... "ou-abc123def456", OrganizationMembershipReadOptions() + ... ) """ if not valid_string_id(organization_membership_id): raise ValueError("invalid organization membership ID") @@ -229,10 +257,18 @@ def delete(self, organization_membership_id: str) -> None: """Delete an organization membership by its ID. Args: - organization_membership_id: The ID of the organization membership to delete + organization_membership_id: The organization membership ID + (e.g. ``"ou-xxxxxxxx"``). + + Returns: + None. Raises: - ValueError: If organization membership ID is invalid + ValueError: If ``organization_membership_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.organization_memberships.delete("ou-abc123def456") """ if not valid_string_id(organization_membership_id): raise ValueError("invalid organization membership ID") diff --git a/src/pytfe/resources/organization_tags.py b/src/pytfe/resources/organization_tags.py index a0927959..5169cfa1 100644 --- a/src/pytfe/resources/organization_tags.py +++ b/src/pytfe/resources/organization_tags.py @@ -32,7 +32,26 @@ def list( organization: str, options: OrganizationTagsListOptions | None = None, ) -> Iterator[OrganizationTag]: - """List all tags within an organization.""" + """List all tags within an organization. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: Optional tag filters, as a :class:`OrganizationTagsListOptions`. + + Returns: + A single-use ``Iterator[OrganizationTag]``. Wrap with ``list(...)`` + to materialize the results or iterate more than once. + + Raises: + ValueError: If an argument or options value is invalid. + + Example: + >>> from pytfe.models import OrganizationTagsListOptions + >>> for tag in client.organization_tags.list( + ... "my-org", OrganizationTagsListOptions(query="env") + ... ): + ... print(tag.id, tag.name) + """ if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) return self._iter_tags(organization, options) @@ -52,7 +71,25 @@ def delete( organization: str, options: OrganizationTagsDeleteOptions, ) -> None: - """Delete tags from an organization.""" + """Delete tags from an organization. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: Tag IDs to delete, as a :class:`OrganizationTagsDeleteOptions`. + + Returns: + None. + + Raises: + ValueError: If an argument or options value is invalid. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import OrganizationTagsDeleteOptions + >>> client.organization_tags.delete( + ... "my-org", OrganizationTagsDeleteOptions(ids=["tag-1"]) + ... ) + """ if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) @@ -70,7 +107,28 @@ def delete( def add_workspaces( self, organization: str, tag: str, options: AddWorkspacesToTagOptions ) -> None: - """Associate workspaces with an organization tag.""" + """Associate workspaces with an organization tag. + + Args: + organization: The organization name (e.g. ``"my-org"``). + tag: The organization tag ID (e.g. ``"tag-xxxxxxxx"``). + options: Workspace IDs to associate, as a + :class:`AddWorkspacesToTagOptions`. + + Returns: + None. + + Raises: + ValueError: If an argument or options value is invalid. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import AddWorkspacesToTagOptions + >>> client.organization_tags.add_workspaces( + ... "my-org", "tag-1", + ... AddWorkspacesToTagOptions(workspace_ids=["ws-xxxxxxxx"]), + ... ) + """ if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) if not valid_string_id(tag): diff --git a/src/pytfe/resources/organization_token.py b/src/pytfe/resources/organization_token.py index dcbcfb28..fd7914aa 100644 --- a/src/pytfe/resources/organization_token.py +++ b/src/pytfe/resources/organization_token.py @@ -22,13 +22,17 @@ def create(self, organization: str) -> OrganizationToken: """Create a new organization token, replacing any existing token. Args: - organization: The organization name or ID + organization: The organization name (e.g. ``"my-org"``). Returns: - OrganizationToken: The created organization token + The :class:`OrganizationToken`. Raises: - ValueError: If the organization name is invalid + TFEError: If the API request fails. + + Example: + >>> token = client.organization_tokens.create("my-org") + >>> print(token.id) """ return self.create_with_options(organization) @@ -37,17 +41,27 @@ def create_with_options( organization: str, options: OrganizationTokenCreateOptions | None = None, ) -> OrganizationToken: - """Create a new organization token with options, replacing any existing token. + """Create a new organization token with options. Args: - organization: The organization name or ID - options: Options for creating the token + organization: The organization name (e.g. ``"my-org"``). + options: Token creation options, as a + :class:`OrganizationTokenCreateOptions`. Returns: - OrganizationToken: The created organization token + The :class:`OrganizationToken`. Raises: - ValueError: If the organization name is invalid + ValueError: If ``organization`` is invalid or the response shape is invalid. + TFEError: If the API request fails. + + Example: + >>> from datetime import datetime + >>> from pytfe.models import OrganizationTokenCreateOptions + >>> token = client.organization_tokens.create_with_options( + ... "my-org", + ... OrganizationTokenCreateOptions(expired_at=datetime(2027, 1, 1)), + ... ) """ if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) @@ -84,16 +98,21 @@ def create_with_options( raise ValueError("Invalid response format") def read(self, organization: str) -> OrganizationToken: - """Read an organization token. + """Read the organization token. Args: - organization: The organization name or ID + organization: The organization name (e.g. ``"my-org"``). Returns: - OrganizationToken: The organization token + The :class:`OrganizationToken`. Raises: - ValueError: If the organization name is invalid + ValueError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> token = client.organization_tokens.read("my-org") + >>> print(token.description) """ return self.read_with_options(organization, None) @@ -102,17 +121,25 @@ def read_with_options( organization: str, options: OrganizationTokenReadOptions | None = None, ) -> OrganizationToken: - """Read an organization token with options. + """Read the organization token with options. Args: - organization: The organization name or ID - options: Options for reading the token + organization: The organization name (e.g. ``"my-org"``). + options: Token read options, as a :class:`OrganizationTokenReadOptions`. Returns: - OrganizationToken: The organization token + The :class:`OrganizationToken`. Raises: - ValueError: If the organization name is invalid + ValueError: If ``organization`` is invalid or the response shape is invalid. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import OrganizationTokenReadOptions, TokenType + >>> token = client.organization_tokens.read_with_options( + ... "my-org", + ... OrganizationTokenReadOptions(token_type=TokenType.AUDIT_TRAILS), + ... ) """ if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) @@ -133,13 +160,20 @@ def read_with_options( raise ValueError("Invalid response format") def delete(self, organization: str) -> None: - """Delete an organization token. + """Delete the organization token. Args: - organization: The organization name or ID + organization: The organization name (e.g. ``"my-org"``). + + Returns: + None. Raises: - ValueError: If the organization name is invalid + ValueError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> client.organization_tokens.delete("my-org") """ return self.delete_with_options(organization, None) @@ -148,14 +182,26 @@ def delete_with_options( organization: str, options: OrganizationTokenDeleteOptions | None = None, ) -> None: - """Delete an organization token with options. + """Delete the organization token with options. Args: - organization: The organization name or ID - options: Options for deleting the token + organization: The organization name (e.g. ``"my-org"``). + options: Token delete options, as a + :class:`OrganizationTokenDeleteOptions`. + + Returns: + None. Raises: - ValueError: If the organization name is invalid + ValueError: If ``organization`` is invalid. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import OrganizationTokenDeleteOptions, TokenType + >>> client.organization_tokens.delete_with_options( + ... "my-org", + ... OrganizationTokenDeleteOptions(token_type=TokenType.AUDIT_TRAILS), + ... ) """ if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) diff --git a/src/pytfe/resources/organizations.py b/src/pytfe/resources/organizations.py index 20af1e6a..cc61282b 100644 --- a/src/pytfe/resources/organizations.py +++ b/src/pytfe/resources/organizations.py @@ -72,12 +72,48 @@ def _parse_org( class Organizations(_Service): def delete(self, name: str) -> None: + """Delete an organization by name. + + Args: + name: The organization name (e.g. ``"my-org"``) to delete. + + Returns: + None. + + Raises: + ValueError: If ``name`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> client.organizations.delete("my-org") + """ if not valid_string_id(name): raise ValueError(ERR_INVALID_ORG) self.t.request("DELETE", f"/api/v2/organizations/{name}") return None def update(self, name: str, options: OrganizationUpdateOptions) -> Organization: + """Update an organization by name. + + Args: + name: The organization name (e.g. ``"my-org"``) to update. + options: The organization fields to update, as an + :class:`OrganizationUpdateOptions`. + + Returns: + The :class:`Organization`. + + Raises: + ValueError: If ``name`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import OrganizationUpdateOptions + >>> org = client.organizations.update( + ... "my-org", + ... OrganizationUpdateOptions(email="ops@example.com"), + ... ) + """ if not valid_string_id(name): raise ValueError(ERR_INVALID_ORG) body = { @@ -97,6 +133,24 @@ def update(self, name: str, options: OrganizationUpdateOptions) -> Organization: return _parse_org(r.json()["data"]) def create(self, options: OrganizationCreateOptions) -> Organization: + """Create a new organization. + + Args: + options: The organization creation settings, as an + :class:`OrganizationCreateOptions`. + + Returns: + The created :class:`Organization`. + + Raises: + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import OrganizationCreateOptions + >>> org = client.organizations.create( + ... OrganizationCreateOptions(name="my-org", email="ops@example.com") + ... ) + """ Organizations.validate(options) body = { "data": { @@ -110,12 +164,42 @@ def create(self, options: OrganizationCreateOptions) -> Organization: return _parse_org(r.json()["data"]) def list(self) -> Iterator[Organization]: + """List organizations visible to the current token. + + Returns: + A single-use ``Iterator[Organization]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + TFEError: If the API request fails. + + Example: + >>> for org in client.organizations.list(): + ... print(org.name) + """ for item in self._list("/api/v2/organizations"): yield _parse_org(item) def read( self, name: str, options: OrganizationReadOptions | None = None ) -> Organization: + """Read an organization by name. + + Args: + name: The organization name (e.g. ``"my-org"``) to read. + options: Optional include settings, as an + :class:`OrganizationReadOptions`. + + Returns: + The :class:`Organization`. + + Raises: + TFEError: If the API request fails. + + Example: + >>> org = client.organizations.read("my-org") + >>> print(org.email) + """ params: dict[str, str] = {} if options and options.include: params["include"] = ",".join([opt.value for opt in options.include]) @@ -148,7 +232,22 @@ def _parse_default_settings( ) def read_default_settings(self, organization: str) -> OrganizationDefaultSettings: - """Read the org's default execution mode and default agent pool.""" + """Read an organization's default settings. + + Args: + organization: The organization name (e.g. ``"my-org"``). + + Returns: + The :class:`OrganizationDefaultSettings`. + + Raises: + ValueError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> settings = client.organizations.read_default_settings("my-org") + >>> print(settings.default_execution_mode) + """ if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) r = self.t.request("GET", f"/api/v2/organizations/{organization}") @@ -159,9 +258,29 @@ def update_default_settings( organization: str, options: OrganizationDefaultSettingsUpdateOptions, ) -> OrganizationDefaultSettings: - """Patch only the default-settings fields on the org. Cross-field - validation (``default_agent_pool_id`` requires ``agent`` execution - mode) is enforced at options construction time, not here. + """Update an organization's default settings. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: The default settings to update, as an + :class:`OrganizationDefaultSettingsUpdateOptions`. + + Returns: + The :class:`OrganizationDefaultSettings`. + + Raises: + ValueError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import OrganizationDefaultSettingsUpdateOptions + >>> settings = client.organizations.update_default_settings( + ... "my-org", + ... OrganizationDefaultSettingsUpdateOptions( + ... default_execution_mode="agent", + ... default_agent_pool_id="apool-xxxxxxxx", + ... ), + ... ) """ if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) @@ -177,12 +296,25 @@ def update_default_settings( return self._parse_default_settings(r.json()["data"]) def reset_default_settings(self, organization: str) -> OrganizationDefaultSettings: - """Reset to ``remote`` execution and clear any default agent pool. + """Reset an organization's default settings. + + Convenience over :meth:`update_default_settings` — equivalent to calling it + with ``default_execution_mode="remote"`` and + ``default_agent_pool_id=None`` explicitly. - Convenience over :meth:`update_default_settings` — equivalent to - calling it with ``default_execution_mode="remote"`` and - ``default_agent_pool_id=None`` explicitly (the latter is sent as - wire ``null`` so any existing pool is unlinked). + Args: + organization: The organization name (e.g. ``"my-org"``). + + Returns: + The :class:`OrganizationDefaultSettings`. + + Raises: + ValueError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> settings = client.organizations.reset_default_settings("my-org") + >>> print(settings.default_agent_pool_id) """ # mypy reads the Pydantic-synthesised __init__ as accepting only # the wire-aliased kwargs (``default-execution-mode``) and not @@ -198,7 +330,24 @@ def reset_default_settings(self, organization: str) -> OrganizationDefaultSettin @staticmethod def validate(opts: OrganizationCreateOptions) -> None: - """Validate organization creation options.""" + """Validate organization creation options. + + Args: + opts: The organization creation settings, as an + :class:`OrganizationCreateOptions`. + + Returns: + None. + + Raises: + ValueError: If the required name or email is missing or invalid. + + Example: + >>> from pytfe.models import OrganizationCreateOptions + >>> client.organizations.validate( + ... OrganizationCreateOptions(name="my-org", email="ops@example.com") + ... ) + """ if not valid_string(opts.name): raise ValueError(ERR_REQUIRED_NAME) if not valid_string_id(opts.name): @@ -207,7 +356,22 @@ def validate(opts: OrganizationCreateOptions) -> None: raise ValueError(ERR_REQUIRED_EMAIL) def read_capacity(self, organization: str) -> Capacity: - """Read the currently used capacity of an organization.""" + """Read an organization's currently used capacity. + + Args: + organization: The organization name (e.g. ``"my-org"``). + + Returns: + The :class:`Capacity`. + + Raises: + ValueError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> capacity = client.organizations.read_capacity("my-org") + >>> print(capacity.pending, capacity.running) + """ if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) @@ -223,7 +387,22 @@ def read_capacity(self, organization: str) -> Capacity: return c def read_entitlements(self, organization: str) -> Entitlements: - """Read the entitlements of an organization.""" + """Read an organization's entitlement set. + + Args: + organization: The organization name (e.g. ``"my-org"``). + + Returns: + The :class:`Entitlements`. + + Raises: + ValueError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> entitlements = client.organizations.read_entitlements("my-org") + >>> print(entitlements.stacks) + """ if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) @@ -233,30 +412,35 @@ def read_entitlements(self, organization: str) -> Entitlements: d = r.json()["data"] attr = d.get("attributes", {}) or {} - e = Entitlements( - id=_safe_str(d.get("id")), - agents=attr.get("agents"), - audit_logging=attr.get("audit-logging"), - cost_estimation=attr.get("cost-estimation"), - global_run_tasks=attr.get("global-run-tasks"), - operations=attr.get("operations"), - private_module_registry=attr.get("private-module-registry"), - private_run_tasks=attr.get("private-run-tasks"), - run_tasks=attr.get("run-tasks"), - sso=attr.get("sso"), - sentinel=attr.get("sentinel"), - state_storage=attr.get("state-storage"), - teams=attr.get("teams"), - vcs_integrations=attr.get("vcs-integrations"), - waypoint_actions=attr.get("waypoint-actions"), - waypoint_templates_and_addons=attr.get("waypoint-templates-and-addons"), - ) - return e + # Pass every flag through (hyphen -> underscore). Flags not modelled as + # typed fields on Entitlements are retained in `model_extra` via + # extra="allow" rather than being silently dropped (e.g. the integer + # `*_limit` flags). Existing typed fields are populated unchanged. + normalized = {k.replace("-", "_"): v for k, v in attr.items() if k != "id"} + return Entitlements(id=_safe_str(d.get("id")), **normalized) def read_run_queue( self, organization: str, options: ReadRunQueueOptions ) -> RunQueue: - """Read the current run queue of an organization.""" + """Read an organization's current run queue. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: Pagination settings, as a :class:`ReadRunQueueOptions`. + + Returns: + The :class:`RunQueue`. + + Raises: + ValueError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import ReadRunQueueOptions + >>> queue = client.organizations.read_run_queue( + ... "my-org", ReadRunQueueOptions(page_size=20) + ... ) + """ if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) @@ -302,10 +486,26 @@ def read_run_queue( def read_data_retention_policy_choice( self, organization: str ) -> DataRetentionPolicyChoice | None: - """Read an organization's data retention policy choice (polymorphic). + """Read an organization's data retention policy choice. - Note: This functionality is only available in Terraform Enterprise. - Returns None if no data retention policy is configured. + This Terraform Enterprise-only endpoint returns the configured polymorphic + policy choice. + + Args: + organization: The organization name (e.g. ``"my-org"``). + + Returns: + The :class:`DataRetentionPolicyChoice`, or ``None`` if no policy is + configured, the policy is not found, or the policy lookup fails. + + Raises: + ValueError: If ``organization`` is not a valid organization name. + + Example: + >>> choice = client.organizations.read_data_retention_policy_choice( + ... "my-org" + ... ) + >>> print(choice is None) """ if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) @@ -361,10 +561,29 @@ def read_data_retention_policy_choice( def set_data_retention_policy( self, organization: str, options: DataRetentionPolicySetOptions ) -> DataRetentionPolicy: - """Set an organization's data retention policy. + """Set an organization's legacy data retention policy. + + Deprecated: use :meth:`set_data_retention_policy_delete_older` instead. + This Terraform Enterprise-only endpoint applies to TFE v202311-1 and + v202312-1. - Deprecated: Use set_data_retention_policy_delete_older instead. - Note: This functionality is only available in Terraform Enterprise versions v202311-1 and v202312-1. + Args: + organization: The organization name (e.g. ``"my-org"``). + options: The legacy policy settings, as a + :class:`DataRetentionPolicySetOptions`. + + Returns: + The :class:`DataRetentionPolicy`. + + Raises: + ValueError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import DataRetentionPolicySetOptions + >>> policy = client.organizations.set_data_retention_policy( + ... "my-org", DataRetentionPolicySetOptions(delete_older_than_n_days=90) + ... ) """ if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) @@ -395,9 +614,30 @@ def set_data_retention_policy( def set_data_retention_policy_delete_older( self, organization: str, options: DataRetentionPolicyDeleteOlderSetOptions ) -> DataRetentionPolicyDeleteOlder: - """Set an organization's data retention policy to delete data older than a certain number of days. - - Note: This functionality is only available in Terraform Enterprise. + """Set an organization to delete data older than a threshold. + + This functionality is only available in Terraform Enterprise. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: The delete-older policy settings, as a + :class:`DataRetentionPolicyDeleteOlderSetOptions`. + + Returns: + The :class:`DataRetentionPolicyDeleteOlder`. + + Raises: + ValueError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import DataRetentionPolicyDeleteOlderSetOptions + >>> policy = client.organizations.set_data_retention_policy_delete_older( + ... "my-org", + ... DataRetentionPolicyDeleteOlderSetOptions( + ... delete_older_than_n_days=90 + ... ), + ... ) """ if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) @@ -428,9 +668,27 @@ def set_data_retention_policy_delete_older( def set_data_retention_policy_dont_delete( self, organization: str, options: DataRetentionPolicyDontDeleteSetOptions ) -> DataRetentionPolicyDontDelete: - """Set an organization's data retention policy to explicitly not delete data. + """Set an organization to retain data indefinitely. - Note: This functionality is only available in Terraform Enterprise. + This functionality is only available in Terraform Enterprise. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: The do-not-delete policy settings, as a + :class:`DataRetentionPolicyDontDeleteSetOptions`. + + Returns: + The :class:`DataRetentionPolicyDontDelete`. + + Raises: + ValueError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import DataRetentionPolicyDontDeleteSetOptions + >>> policy = client.organizations.set_data_retention_policy_dont_delete( + ... "my-org", DataRetentionPolicyDontDeleteSetOptions() + ... ) """ if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) @@ -452,7 +710,20 @@ def set_data_retention_policy_dont_delete( def delete_data_retention_policy(self, organization: str) -> None: """Delete an organization's data retention policy. - Note: This functionality is only available in Terraform Enterprise. + This functionality is only available in Terraform Enterprise. + + Args: + organization: The organization name (e.g. ``"my-org"``). + + Returns: + None. + + Raises: + ValueError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> client.organizations.delete_data_retention_policy("my-org") """ if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) diff --git a/src/pytfe/resources/plan.py b/src/pytfe/resources/plan.py index 6e3f982c..41d28e43 100644 --- a/src/pytfe/resources/plan.py +++ b/src/pytfe/resources/plan.py @@ -25,7 +25,22 @@ def _plan_from_jsonapi(d: dict[str, Any]) -> Plan: class Plans(_Service): def read(self, plan_id: str) -> Plan: - """Read a specific plan by its ID.""" + """Read a plan by its ID. + + Args: + plan_id: The plan ID (e.g. ``"plan-xxxxxxxx"``). + + Returns: + The :class:`Plan`. + + Raises: + InvalidPlanIDError: If ``plan_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> plan = client.plans.read("plan-123") + >>> print(plan.status) + """ if not valid_string_id(plan_id): raise InvalidPlanIDError() @@ -36,20 +51,44 @@ def read(self, plan_id: str) -> Plan: return _plan_from_jsonapi(r.json()["data"]) def read_for_run(self, run_id: str) -> Plan: - """Read the plan belonging to a run, via the run id.""" + """Read the plan for a run. + + Args: + run_id: The run ID (e.g. ``"run-xxxxxxxx"``). + + Returns: + The :class:`Plan`. + + Raises: + InvalidRunIDError: If ``run_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> plan = client.plans.read_for_run("run-CZcmD7eagjhyX0vN") + >>> print(plan.id) + """ if not valid_string_id(run_id): raise InvalidRunIDError() r = self.t.request("GET", f"/api/v2/runs/{run_id}/plan") return _plan_from_jsonapi(r.json()["data"]) def logs(self, plan_id: str) -> str: - """Get logs for a specific plan. + """Get logs for a plan. Args: - plan_id: Plan ID to get logs for + plan_id: The plan ID (e.g. ``"plan-xxxxxxxx"``). Returns: - Log content as string (placeholder implementation) + The ``str`` log content. + + Raises: + InvalidPlanIDError: If ``plan_id`` is not a valid resource ID. + ValueError: If the plan does not have a log URL. + TFEError: If the API request fails. + + Example: + >>> logs = client.plans.logs("plan-123") + >>> print(logs) """ # Validate plan ID if not valid_string_id(plan_id): @@ -99,19 +138,50 @@ def _follow_json_output_redirect(self, path: str) -> dict[str, Any] | None: return {"data": data} def read_json_output(self, plan_id: str) -> dict[str, Any] | None: - """Get the JSON execution plan for a specific plan by its ID. + """Read the JSON execution plan for a plan. - Returns the JSON representation of the Terraform execution plan, - or ``None`` if the plan has not yet completed (HTTP 204). + Args: + plan_id: The plan ID (e.g. ``"plan-xxxxxxxx"``). + + Returns: + The ``dict[str, Any]`` (the SDK follows the storage/redirect URL + for you), or ``None`` when the API returns HTTP 204 for an + incomplete plan, or when the parsed body is empty; inline non-JSON + responses also return ``None``. + + Raises: + InvalidPlanIDError: If ``plan_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> output = client.plans.read_json_output("plan-123") + >>> print(output["format_version"] if output else "not ready") """ if not valid_string_id(plan_id): raise InvalidPlanIDError() return self._follow_json_output_redirect(f"/api/v2/plans/{plan_id}/json-output") def read_json_output_for_run(self, run_id: str) -> dict[str, Any] | None: - """Get the JSON execution plan for a run, via the run id. + """Read the JSON execution plan for a run. - Returns ``None`` if the plan has not yet completed (HTTP 204). + Args: + run_id: The run ID (e.g. ``"run-xxxxxxxx"``). + + Returns: + The ``dict[str, Any]`` (the SDK follows the storage/redirect URL + for you), or ``None`` when the API returns HTTP 204 for an + incomplete plan, or when the parsed body is empty; inline non-JSON + responses also return ``None``. + + Raises: + InvalidRunIDError: If ``run_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> output = client.plans.read_json_output_for_run( + ... "run-CZcmD7eagjhyX0vN" + ... ) + >>> print(output["format_version"] if output else "not ready") """ if not valid_string_id(run_id): raise InvalidRunIDError() @@ -120,9 +190,26 @@ def read_json_output_for_run(self, run_id: str) -> dict[str, Any] | None: ) def read_json_schema_for_run(self, run_id: str) -> dict[str, Any] | None: - """Get the provider JSON schema corresponding to a plan, via the run id. + """Read the provider JSON schema for a run's plan. - Returns ``None`` if the plan has not yet completed (HTTP 204). + Args: + run_id: The run ID (e.g. ``"run-xxxxxxxx"``). + + Returns: + The ``dict[str, Any]`` (the SDK follows the storage/redirect URL + for you), or ``None`` when the API returns HTTP 204 for an + incomplete plan, or when the parsed body is empty; inline non-JSON + responses also return ``None``. + + Raises: + InvalidRunIDError: If ``run_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> schema = client.plans.read_json_schema_for_run( + ... "run-CZcmD7eagjhyX0vN" + ... ) + >>> print(schema.keys() if schema else "not ready") """ if not valid_string_id(run_id): raise InvalidRunIDError() diff --git a/src/pytfe/resources/plan_export.py b/src/pytfe/resources/plan_export.py new file mode 100644 index 00000000..774aa27e --- /dev/null +++ b/src/pytfe/resources/plan_export.py @@ -0,0 +1,140 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from typing import Any + +from .._jsonapi import attach_jsonapi +from ..errors import InvalidPlanExportIDError, TFEError +from ..models.plan_export import PlanExport, PlanExportCreateOptions +from ..utils import valid_string_id +from ._base import _Service + + +def _plan_export_from( + data: dict[str, Any], included: list[dict[str, Any]] | None = None +) -> PlanExport: + """Parse a JSON:API plan-export resource object into a PlanExport.""" + attrs = dict(data.get("attributes") or {}) + attrs["id"] = data.get("id") + return attach_jsonapi(PlanExport.model_validate(attrs), data, included) + + +class PlanExports(_Service): + """Service for exporting Terraform plan data (e.g. Sentinel mock bundles).""" + + def create(self, options: PlanExportCreateOptions) -> PlanExport: + """Create a plan export. + + Args: + options: The plan export request, as a :class:`PlanExportCreateOptions`. + + Returns: + The created :class:`PlanExport`. + + Raises: + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import PlanExportCreateOptions + >>> export = client.plan_exports.create( + ... PlanExportCreateOptions(plan_id="plan-8F5JFydVYAmtTjET") + ... ) + """ + payload = { + "data": { + "type": "plan-exports", + "attributes": {"data-type": options.data_type.value}, + "relationships": { + "plan": {"data": {"type": "plans", "id": options.plan_id}} + }, + } + } + r = self.t.request("POST", "/api/v2/plan-exports", json_body=payload) + body = r.json() + return _plan_export_from(body["data"], body.get("included")) + + def read(self, plan_export_id: str) -> PlanExport: + """Read a plan export by its ID. + + Args: + plan_export_id: The plan export ID (e.g. ``"pe-xxxxxxxx"``). + + Returns: + The :class:`PlanExport`. + + Raises: + InvalidPlanExportIDError: If ``plan_export_id`` is not a valid resource + ID. + TFEError: If the API request fails. + + Example: + >>> export = client.plan_exports.read("pe-3yVQZvHzf5j3WRJ1") + >>> print(export.status) + """ + if not valid_string_id(plan_export_id): + raise InvalidPlanExportIDError() + r = self.t.request("GET", f"/api/v2/plan-exports/{plan_export_id}") + body = r.json() + return _plan_export_from(body["data"], body.get("included")) + + def delete(self, plan_export_id: str) -> None: + """Delete a plan export by its ID. + + Args: + plan_export_id: The plan export ID (e.g. ``"pe-xxxxxxxx"``). + + Returns: + None. + + Raises: + InvalidPlanExportIDError: If ``plan_export_id`` is not a valid resource + ID. + TFEError: If the API request fails. + + Example: + >>> client.plan_exports.delete("pe-3yVQZvHzf5j3WRJ1") + """ + if not valid_string_id(plan_export_id): + raise InvalidPlanExportIDError() + self.t.request("DELETE", f"/api/v2/plan-exports/{plan_export_id}") + + def download(self, plan_export_id: str) -> bytes: + """Download a plan export archive. + + The endpoint may redirect to a temporary, presigned URL for the archive; the + SDK follows that URL and returns the response body. + + Args: + plan_export_id: The plan export ID (e.g. ``"pe-xxxxxxxx"``). + + Returns: + The raw bytes (the SDK follows the storage/redirect URL for you). + + Raises: + InvalidPlanExportIDError: If ``plan_export_id`` is not a valid resource + ID. + TFEError: If the API request fails or a redirect is missing ``Location``. + + Example: + >>> archive = client.plan_exports.download("pe-3yVQZvHzf5j3WRJ1") + >>> len(archive) > 0 + True + """ + if not valid_string_id(plan_export_id): + raise InvalidPlanExportIDError() + resp = self.t.request( + "GET", + f"/api/v2/plan-exports/{plan_export_id}/download", + allow_redirects=False, + ) + if resp.status_code in (301, 302, 303, 307, 308): + location = resp.headers.get("Location") or resp.headers.get("location") + if not location: + raise TFEError( + "plan-export download redirect did not include a Location header" + ) + blob = self.t.request("GET", location, include_auth=False) + return blob.content + return resp.content diff --git a/src/pytfe/resources/policy.py b/src/pytfe/resources/policy.py index 37f0405c..e536bf36 100644 --- a/src/pytfe/resources/policy.py +++ b/src/pytfe/resources/policy.py @@ -29,7 +29,24 @@ class Policies(_Service): def list( self, organization: str, options: PolicyListOptions | None = None ) -> Iterator[Policy]: - """Iterate all the policies of the given organization.""" + """List all policies in the given organization. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: Pagination and filter options, as a :class:`PolicyListOptions`. + + Returns: + A single-use ``Iterator[Policy]``. Wrap with ``list(...)`` to materialize + the results or iterate more than once. + + Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> for policy in client.policies.list("my-org"): + ... print(policy.id, policy.name) + """ if not valid_string_id(organization): raise InvalidOrgError() @@ -52,7 +69,38 @@ def _gen() -> Iterator[Policy]: return _gen() def create(self, organization: str, options: PolicyCreateOptions) -> Policy: - """Create a new policy in the given organization.""" + """Create a new policy in the given organization. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: Policy creation settings, as a :class:`PolicyCreateOptions`. + + Returns: + The :class:`Policy`. + + Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. + RequiredNameError: If ``options.name`` is missing or blank. + InvalidNameError: If ``options.name`` is not a valid policy name. + RequiredQueryError: If an OPA policy is missing ``options.query``. + RequiredEnforceError: If ``options.enforcement_level`` is missing. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import ( + ... EnforcementLevel, + ... PolicyCreateOptions, + ... PolicyKind, + ... ) + >>> policy = client.policies.create( + ... "my-org", + ... PolicyCreateOptions( + ... name="cost-policy", + ... kind=PolicyKind.SENTINEL, + ... enforcement_level=EnforcementLevel.ENFORCEMENT_HARD, + ... ), + ... ) + """ if not valid_string_id(organization): raise InvalidOrgError() valid = self._valid_create_options(options) @@ -76,7 +124,22 @@ def create(self, organization: str, options: PolicyCreateOptions) -> Policy: return attach_jsonapi(Policy.model_validate(attrs), d) def read(self, policy_id: str) -> Policy: - """Read a specific policy by its ID.""" + """Read a specific policy by its ID. + + Args: + policy_id: The policy ID (e.g. ``"pol-xxxxxxxx"``). + + Returns: + The :class:`Policy`. + + Raises: + InvalidPolicyIDError: If ``policy_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> policy = client.policies.read("pol-789") + >>> print(policy.name) + """ if not valid_string_id(policy_id): raise InvalidPolicyIDError r = self.t.request( @@ -91,7 +154,28 @@ def read(self, policy_id: str) -> Policy: return attach_jsonapi(Policy.model_validate(attrs), d) def update(self, policy_id: str, options: PolicyUpdateOptions) -> Policy: - """Update an existing policy by its ID.""" + """Update an existing policy by its ID. + + Args: + policy_id: The policy ID (e.g. ``"pol-xxxxxxxx"``). + options: Policy update settings, as a :class:`PolicyUpdateOptions`. + + Returns: + The :class:`Policy`. + + Raises: + InvalidPolicyIDError: If ``policy_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import EnforcementLevel, PolicyUpdateOptions + >>> policy = client.policies.update( + ... "pol-789", + ... PolicyUpdateOptions( + ... enforcement_level=EnforcementLevel.ENFORCEMENT_SOFT + ... ), + ... ) + """ if not valid_string_id(policy_id): raise InvalidPolicyIDError payload = { @@ -113,7 +197,21 @@ def update(self, policy_id: str, options: PolicyUpdateOptions) -> Policy: return attach_jsonapi(Policy.model_validate(attrs), d) def delete(self, policy_id: str) -> None: - """Delete a specific policy by its ID.""" + """Delete a specific policy by its ID. + + Args: + policy_id: The policy ID (e.g. ``"pol-xxxxxxxx"``). + + Returns: + None. + + Raises: + InvalidPolicyIDError: If ``policy_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.policies.delete("pol-789") + """ if not valid_string_id(policy_id): raise InvalidPolicyIDError self.t.request( @@ -123,7 +221,22 @@ def delete(self, policy_id: str) -> None: return None def upload(self, policy_id: str, content: bytes) -> None: - """Upload the policy content of the policy.""" + """Upload policy content for a policy. + + Args: + policy_id: The policy ID (e.g. ``"pol-xxxxxxxx"``). + content: The raw policy file bytes to upload. + + Returns: + None. + + Raises: + InvalidPolicyIDError: If ``policy_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.policies.upload("pol-789", b"main = rule { true }") + """ if not valid_string_id(policy_id): raise InvalidPolicyIDError @@ -137,7 +250,22 @@ def upload(self, policy_id: str, content: bytes) -> None: return None def download(self, policy_id: str) -> bytes: - """Download the policy content of the policy.""" + """Download policy content for a policy. + + Args: + policy_id: The policy ID (e.g. ``"pol-xxxxxxxx"``). + + Returns: + The raw bytes (the SDK follows the storage/redirect URL for you). + + Raises: + InvalidPolicyIDError: If ``policy_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> content = client.policies.download("pol-789") + >>> print(content.decode()) + """ if not valid_string_id(policy_id): raise InvalidPolicyIDError r = self.t.request( diff --git a/src/pytfe/resources/policy_check.py b/src/pytfe/resources/policy_check.py index ba5eabe5..160ea6e1 100644 --- a/src/pytfe/resources/policy_check.py +++ b/src/pytfe/resources/policy_check.py @@ -29,7 +29,25 @@ class PolicyChecks(_Service): def list( self, run_id: str, options: PolicyCheckListOptions | None = None ) -> Iterator[PolicyCheck]: - """List all policy checks of the given run.""" + """List policy checks for the given run. + + Args: + run_id: The run ID (e.g. ``"run-veDoQbv6xh6TbnJD"``). + options: Optional includes and pagination, as a + :class:`PolicyCheckListOptions`. + + Returns: + A single-use ``Iterator[PolicyCheck]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + InvalidRunIDError: If ``run_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> for check in client.policy_checks.list("run-veDoQbv6xh6TbnJD"): + ... print(check.id, check.status) + """ if not valid_string_id(run_id): raise InvalidRunIDError() params = ( @@ -43,7 +61,24 @@ def list( yield attach_jsonapi(PolicyCheck.model_validate(attrs), item) def read(self, policy_check_id: str) -> PolicyCheck: - """Read a policy check by its ID.""" + """Read a policy check by its ID. + + Args: + policy_check_id: The policy check ID + (e.g. ``"polchk-9VYRc9bpfJEsnwum"``). + + Returns: + The :class:`PolicyCheck`. + + Raises: + InvalidPolicyCheckIDError: If ``policy_check_id`` is not a valid + resource ID. + TFEError: If the API request fails. + + Example: + >>> check = client.policy_checks.read("polchk-9VYRc9bpfJEsnwum") + >>> print(check.status) + """ if not valid_string_id(policy_check_id): raise InvalidPolicyCheckIDError() r = self.t.request( @@ -58,7 +93,24 @@ def read(self, policy_check_id: str) -> PolicyCheck: return attach_jsonapi(PolicyCheck.model_validate(attrs), d) def override(self, policy_check_id: str) -> PolicyCheck: - """Override a soft-mandatory or warning policy.""" + """Override a soft-mandatory or warning policy check. + + Args: + policy_check_id: The policy check ID + (e.g. ``"polchk-EasPB4Srx5NAiWAU"``). + + Returns: + The :class:`PolicyCheck`. + + Raises: + InvalidPolicyCheckIDError: If ``policy_check_id`` is not a valid + resource ID. + TFEError: If the API request fails. + + Example: + >>> check = client.policy_checks.override("polchk-EasPB4Srx5NAiWAU") + >>> print(check.status) + """ if not valid_string_id(policy_check_id): raise InvalidPolicyCheckIDError() r = self.t.request( @@ -73,7 +125,24 @@ def override(self, policy_check_id: str) -> PolicyCheck: return attach_jsonapi(PolicyCheck.model_validate(attrs), d) def logs(self, policy_check_id: str) -> str: - """Logs retrieves the logs of a policy check.""" + """Read the logs for a completed policy check. + + Args: + policy_check_id: The policy check ID + (e.g. ``"polchk-9VYRc9bpfJEsnwum"``). + + Returns: + The policy check logs as a string. + + Raises: + InvalidPolicyCheckIDError: If ``policy_check_id`` is not a valid + resource ID. + TFEError: If the API request fails. + + Example: + >>> logs = client.policy_checks.logs("polchk-9VYRc9bpfJEsnwum") + >>> print(logs) + """ if not valid_string_id(policy_check_id): raise InvalidPolicyCheckIDError() diff --git a/src/pytfe/resources/policy_evaluation.py b/src/pytfe/resources/policy_evaluation.py index 0808b3f7..c64c8c8e 100644 --- a/src/pytfe/resources/policy_evaluation.py +++ b/src/pytfe/resources/policy_evaluation.py @@ -26,9 +26,27 @@ class PolicyEvaluations(_Service): def list( self, task_stage_id: str, options: PolicyEvaluationListOptions | None = None ) -> Iterator[PolicyEvaluation]: - """ - **Note: This method is still in BETA and subject to change.** - List all policy evaluations in the task stage. Only available for OPA policies. + """List policy evaluations in a task stage. + + **Note: This method is still in BETA and subject to change.** Only available + for OPA policies. + + Args: + task_stage_id: The task stage ID (e.g. ``"ts-xxxxxxxx"``). + options: Optional pagination settings, as a + :class:`PolicyEvaluationListOptions`. + + Returns: + A single-use ``Iterator[PolicyEvaluation]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + InvalidTaskStageIDError: If ``task_stage_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> for evaluation in client.policy_evaluations.list("ts-123"): + ... print(evaluation.id, evaluation.status) """ if not valid_string_id(task_stage_id): raise InvalidTaskStageIDError() diff --git a/src/pytfe/resources/policy_set.py b/src/pytfe/resources/policy_set.py index d0dde949..d3b6dcbc 100644 --- a/src/pytfe/resources/policy_set.py +++ b/src/pytfe/resources/policy_set.py @@ -81,7 +81,25 @@ class PolicySets(_Service): def list( self, organization: str, options: PolicySetListOptions | None = None ) -> Iterator[PolicySet]: - """Iterate all the policy sets of the given organization.""" + """List policy sets in an organization. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: Optional filters, includes, and pagination, as a + :class:`PolicySetListOptions`. + + Returns: + A single-use ``Iterator[PolicySet]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> for policy_set in client.policy_sets.list("my-org"): + ... print(policy_set.id, policy_set.name) + """ if not valid_string_id(organization): raise InvalidOrgError() @@ -106,7 +124,30 @@ def _gen() -> Iterator[PolicySet]: return _gen() def create(self, organization: str, options: PolicySetCreateOptions) -> PolicySet: - """Create a new policy set in the given organization.""" + """Create a policy set in an organization. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: Policy set attributes and relationships, as a + :class:`PolicySetCreateOptions`. + + Returns: + The created :class:`PolicySet`. + + Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. + RequiredNameError: If ``options.name`` is missing or empty. + InvalidNameError: If ``options.name`` is not a valid policy set name. + ValueError: If no attributes are provided for the policy set. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import PolicySetCreateOptions + >>> policy_set = client.policy_sets.create( + ... "my-org", + ... PolicySetCreateOptions(name="baseline-policies"), + ... ) + """ if not valid_string_id(organization): raise InvalidOrgError() if not valid_string(options.name): @@ -163,13 +204,46 @@ def create(self, organization: str, options: PolicySetCreateOptions) -> PolicySe return _policy_set_from(jd.get("data", {}), jd.get("included")) def read(self, policy_set_id: str) -> PolicySet: - """Read a policy set by its ID.""" + """Read a policy set by its ID. + + Args: + policy_set_id: The policy set ID (e.g. ``"polset-xxxxxxxx"``). + + Returns: + The :class:`PolicySet`. + + Raises: + InvalidPolicySetIDError: If ``policy_set_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> policy_set = client.policy_sets.read("polset-123") + >>> print(policy_set.name) + """ return self.read_with_options(policy_set_id) def read_with_options( self, policy_set_id: str, options: PolicySetReadOptions | None = None ) -> PolicySet: - """Read a policy set by its ID with additional options.""" + """Read a policy set by its ID with include options. + + Args: + policy_set_id: The policy set ID (e.g. ``"polset-xxxxxxxx"``). + options: Optional include controls, as a :class:`PolicySetReadOptions`. + + Returns: + The :class:`PolicySet`. + + Raises: + InvalidPolicySetIDError: If ``policy_set_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import PolicySetReadOptions + >>> policy_set = client.policy_sets.read_with_options( + ... "polset-123", PolicySetReadOptions() + ... ) + """ if not valid_string_id(policy_set_id): raise InvalidPolicySetIDError() @@ -188,7 +262,27 @@ def read_with_options( return _policy_set_from(jd.get("data", {}), jd.get("included")) def update(self, policy_set_id: str, options: PolicySetUpdateOptions) -> PolicySet: - """Update an existing policy set.""" + """Update a policy set by its ID. + + Args: + policy_set_id: The policy set ID (e.g. ``"polset-xxxxxxxx"``). + options: Policy set attributes to update, as a + :class:`PolicySetUpdateOptions`. + + Returns: + The updated :class:`PolicySet`. + + Raises: + InvalidPolicySetIDError: If ``policy_set_id`` is not a valid resource ID. + ValueError: If no attributes are provided to update. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import PolicySetUpdateOptions + >>> policy_set = client.policy_sets.update( + ... "polset-123", PolicySetUpdateOptions(description="Required checks") + ... ) + """ if not valid_string_id(policy_set_id): raise InvalidPolicySetIDError() @@ -215,7 +309,27 @@ def update(self, policy_set_id: str, options: PolicySetUpdateOptions) -> PolicyS def add_policies( self, policy_set_id: str, options: PolicySetAddPoliciesOptions ) -> None: - """Add policies to a policy set.""" + """Add policies to a policy set. + + Args: + policy_set_id: The policy set ID (e.g. ``"polset-xxxxxxxx"``). + options: Relationship changes, as a :class:`PolicySetAddPoliciesOptions`. + + Returns: + None. + + Raises: + InvalidPolicySetIDError: If ``policy_set_id`` is not a valid resource ID. + RequiredPoliciesError: If no policies are provided. + InvalidPoliciesError: If the policy list is empty. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import Policy, PolicySetAddPoliciesOptions + >>> client.policy_sets.add_policies( + ... "polset-123", PolicySetAddPoliciesOptions(policies=[Policy(id="pol-123")]) + ... ) + """ if not valid_string_id(policy_set_id): raise InvalidPolicySetIDError() @@ -241,7 +355,27 @@ def add_policies( def remove_policies( self, policy_set_id: str, options: PolicySetRemovePoliciesOptions ) -> None: - """Remove policies from a policy set.""" + """Remove policies from a policy set. + + Args: + policy_set_id: The policy set ID (e.g. ``"polset-xxxxxxxx"``). + options: Relationship changes, as a :class:`PolicySetRemovePoliciesOptions`. + + Returns: + None. + + Raises: + InvalidPolicySetIDError: If ``policy_set_id`` is not a valid resource ID. + RequiredPoliciesError: If no policies are provided. + InvalidPoliciesError: If the policy list is empty. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import Policy, PolicySetRemovePoliciesOptions + >>> client.policy_sets.remove_policies( + ... "polset-123", PolicySetRemovePoliciesOptions(policies=[Policy(id="pol-123")]) + ... ) + """ if not valid_string_id(policy_set_id): raise InvalidPolicySetIDError() @@ -267,7 +401,27 @@ def remove_policies( def add_workspaces( self, policy_set_id: str, options: PolicySetAddWorkspacesOptions ) -> None: - """Add workspaces to a policy set.""" + """Add workspaces to a policy set. + + Args: + policy_set_id: The policy set ID (e.g. ``"polset-xxxxxxxx"``). + options: Relationship changes, as a :class:`PolicySetAddWorkspacesOptions`. + + Returns: + None. + + Raises: + InvalidPolicySetIDError: If ``policy_set_id`` is not a valid resource ID. + WorkspaceRequiredError: If no workspaces are provided. + WorkspaceMinimumLimitError: If the workspace list is empty. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import Workspace, PolicySetAddWorkspacesOptions + >>> client.policy_sets.add_workspaces( + ... "polset-123", PolicySetAddWorkspacesOptions(workspaces=[Workspace(id="ws-123")]) + ... ) + """ if not valid_string_id(policy_set_id): raise InvalidPolicySetIDError() @@ -294,7 +448,27 @@ def add_workspaces( def remove_workspaces( self, policy_set_id: str, options: PolicySetRemoveWorkspacesOptions ) -> None: - """Remove workspaces from a policy set.""" + """Remove workspaces from a policy set. + + Args: + policy_set_id: The policy set ID (e.g. ``"polset-xxxxxxxx"``). + options: Relationship changes, as a :class:`PolicySetRemoveWorkspacesOptions`. + + Returns: + None. + + Raises: + InvalidPolicySetIDError: If ``policy_set_id`` is not a valid resource ID. + WorkspaceRequiredError: If no workspaces are provided. + WorkspaceMinimumLimitError: If the workspace list is empty. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import Workspace, PolicySetRemoveWorkspacesOptions + >>> client.policy_sets.remove_workspaces( + ... "polset-123", PolicySetRemoveWorkspacesOptions(workspaces=[Workspace(id="ws-123")]) + ... ) + """ if not valid_string_id(policy_set_id): raise InvalidPolicySetIDError() @@ -321,7 +495,27 @@ def remove_workspaces( def add_workspace_exclusions( self, policy_set_id: str, options: PolicySetAddWorkspaceExclusionsOptions ) -> None: - """Add workspace exclusions to a policy set.""" + """Add workspace exclusions to a policy set. + + Args: + policy_set_id: The policy set ID (e.g. ``"polset-xxxxxxxx"``). + options: Relationship changes, as a :class:`PolicySetAddWorkspaceExclusionsOptions`. + + Returns: + None. + + Raises: + InvalidPolicySetIDError: If ``policy_set_id`` is not a valid resource ID. + WorkspaceRequiredError: If no workspaces are provided. + WorkspaceMinimumLimitError: If the workspace list is empty. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import Workspace, PolicySetAddWorkspaceExclusionsOptions + >>> client.policy_sets.add_workspace_exclusions( + ... "polset-123", PolicySetAddWorkspaceExclusionsOptions(workspace_exclusions=[Workspace(id="ws-123")]) + ... ) + """ if not valid_string_id(policy_set_id): raise InvalidPolicySetIDError() @@ -348,7 +542,27 @@ def add_workspace_exclusions( def remove_workspace_exclusions( self, policy_set_id: str, options: PolicySetRemoveWorkspaceExclusionsOptions ) -> None: - """Remove workspace exclusions from a policy set.""" + """Remove workspace exclusions from a policy set. + + Args: + policy_set_id: The policy set ID (e.g. ``"polset-xxxxxxxx"``). + options: Relationship changes, as a :class:`PolicySetRemoveWorkspaceExclusionsOptions`. + + Returns: + None. + + Raises: + InvalidPolicySetIDError: If ``policy_set_id`` is not a valid resource ID. + WorkspaceRequiredError: If no workspaces are provided. + WorkspaceMinimumLimitError: If the workspace list is empty. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import Workspace, PolicySetRemoveWorkspaceExclusionsOptions + >>> client.policy_sets.remove_workspace_exclusions( + ... "polset-123", PolicySetRemoveWorkspaceExclusionsOptions(workspace_exclusions=[Workspace(id="ws-123")]) + ... ) + """ if not valid_string_id(policy_set_id): raise InvalidPolicySetIDError() @@ -377,7 +591,26 @@ def add_project_exclusions( policy_set_id: str, options: PolicySetAddProjectExclusionsOptions, ) -> None: - """Add project exclusions to a policy set.""" + """Add project exclusions to a policy set. + + Args: + policy_set_id: The policy set ID (e.g. ``"polset-xxxxxxxx"``). + options: Relationship changes, as a :class:`PolicySetAddProjectExclusionsOptions`. + + Returns: + None. + + Raises: + InvalidPolicySetIDError: If ``policy_set_id`` is not a valid resource ID. + ValueError: If no projects are provided. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import Project, PolicySetAddProjectExclusionsOptions + >>> client.policy_sets.add_project_exclusions( + ... "polset-123", PolicySetAddProjectExclusionsOptions(project_exclusions=[Project(id="prj-123")]) + ... ) + """ if not valid_string_id(policy_set_id): raise InvalidPolicySetIDError() if not options.project_exclusions: @@ -400,7 +633,26 @@ def remove_project_exclusions( policy_set_id: str, options: PolicySetRemoveProjectExclusionsOptions, ) -> None: - """Remove project exclusions from a policy set.""" + """Remove project exclusions from a policy set. + + Args: + policy_set_id: The policy set ID (e.g. ``"polset-xxxxxxxx"``). + options: Relationship changes, as a :class:`PolicySetRemoveProjectExclusionsOptions`. + + Returns: + None. + + Raises: + InvalidPolicySetIDError: If ``policy_set_id`` is not a valid resource ID. + ValueError: If no projects are provided. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import Project, PolicySetRemoveProjectExclusionsOptions + >>> client.policy_sets.remove_project_exclusions( + ... "polset-123", PolicySetRemoveProjectExclusionsOptions(project_exclusions=[Project(id="prj-123")]) + ... ) + """ if not valid_string_id(policy_set_id): raise InvalidPolicySetIDError() if not options.project_exclusions: @@ -421,7 +673,26 @@ def remove_project_exclusions( def add_projects( self, policy_set_id: str, options: PolicySetAddProjectsOptions ) -> None: - """Add projects to a policy set.""" + """Add projects to a policy set. + + Args: + policy_set_id: The policy set ID (e.g. ``"polset-xxxxxxxx"``). + options: Relationship changes, as a :class:`PolicySetAddProjectsOptions`. + + Returns: + None. + + Raises: + InvalidPolicySetIDError: If ``policy_set_id`` is not a valid resource ID. + ValueError: If no projects are provided. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import Project, PolicySetAddProjectsOptions + >>> client.policy_sets.add_projects( + ... "polset-123", PolicySetAddProjectsOptions(projects=[Project(id="prj-123")]) + ... ) + """ if not valid_string_id(policy_set_id): raise InvalidPolicySetIDError() @@ -447,7 +718,26 @@ def add_projects( def remove_projects( self, policy_set_id: str, options: PolicySetRemoveProjectsOptions ) -> None: - """Remove projects from a policy set.""" + """Remove projects from a policy set. + + Args: + policy_set_id: The policy set ID (e.g. ``"polset-xxxxxxxx"``). + options: Relationship changes, as a :class:`PolicySetRemoveProjectsOptions`. + + Returns: + None. + + Raises: + InvalidPolicySetIDError: If ``policy_set_id`` is not a valid resource ID. + ValueError: If no projects are provided. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import Project, PolicySetRemoveProjectsOptions + >>> client.policy_sets.remove_projects( + ... "polset-123", PolicySetRemoveProjectsOptions(projects=[Project(id="prj-123")]) + ... ) + """ if not valid_string_id(policy_set_id): raise InvalidPolicySetIDError() @@ -471,7 +761,21 @@ def remove_projects( return None def delete(self, policy_set_id: str) -> None: - """Delete a policy set by its ID.""" + """Delete a policy set by its ID. + + Args: + policy_set_id: The policy set ID (e.g. ``"polset-xxxxxxxx"``). + + Returns: + None. + + Raises: + InvalidPolicySetIDError: If ``policy_set_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.policy_sets.delete("polset-123") + """ if not valid_string_id(policy_set_id): raise InvalidPolicySetIDError() diff --git a/src/pytfe/resources/policy_set_outcome.py b/src/pytfe/resources/policy_set_outcome.py index 19b5b0ac..a039b3a0 100644 --- a/src/pytfe/resources/policy_set_outcome.py +++ b/src/pytfe/resources/policy_set_outcome.py @@ -30,9 +30,29 @@ def list( policy_evaluation_id: str, options: PolicySetOutcomeListOptions | None = None, ) -> Iterator[PolicySetOutcome]: - """ - **Note: This method is still in BETA and subject to change.** - List all policy set outcomes in the policy evaluation. Only available for OPA policies. + """List policy set outcomes in a policy evaluation. + + **Note: This method is still in BETA and subject to change.** Only available + for OPA policies. + + Args: + policy_evaluation_id: The policy evaluation ID (e.g. + ``"poleval-xxxxxxxx"``). + options: Optional filters and pagination, as a + :class:`PolicySetOutcomeListOptions`. + + Returns: + A single-use ``Iterator[PolicySetOutcome]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + InvalidPolicyEvaluationIDError: If ``policy_evaluation_id`` is not a valid + resource ID. + TFEError: If the API request fails. + + Example: + >>> for outcome in client.policy_set_outcomes.list("poleval-123"): + ... print(outcome.id, outcome.policy_set_name) """ if not valid_string_id(policy_evaluation_id): raise InvalidPolicyEvaluationIDError() @@ -48,7 +68,21 @@ def list( def build_query_string( self, options: PolicySetOutcomeListOptions | None ) -> dict[str, str] | None: - """build_query_string takes the PolicySetOutcomeListOptions and returns a filters map.""" + """Build filter query parameters for listing policy set outcomes. + + Args: + options: Optional filter settings, as a + :class:`PolicySetOutcomeListOptions`. + + Returns: + A ``dict[str, str] | None``. ``None`` is returned when no filters are set. + + Example: + >>> from pytfe.models import PolicySetOutcomeListOptions + >>> params = client.policy_set_outcomes.build_query_string( + ... PolicySetOutcomeListOptions(page_size=20) + ... ) + """ result = {} if options is None or options.filter is None: return None @@ -60,9 +94,26 @@ def build_query_string( return result def read(self, policy_set_outcome_id: str) -> PolicySetOutcome: + """Read a policy set outcome by its ID. + + **Note: This method is still in BETA and subject to change.** Only available + for OPA policies. + + Args: + policy_set_outcome_id: The policy set outcome ID (e.g. ``"pso-xxxxxxxx"``). + + Returns: + The :class:`PolicySetOutcome`. + + Raises: + InvalidPolicySetOutcomeIDError: If ``policy_set_outcome_id`` is not a valid + resource ID. + TFEError: If the API request fails. + + Example: + >>> outcome = client.policy_set_outcomes.read("pso-123") + >>> print(outcome.policy_set_name) """ - **Note: This method is still in BETA and subject to change.** - Read a single policy set outcome by ID. Only available for OPA policies.""" if not valid_string_id(policy_set_outcome_id): raise InvalidPolicySetOutcomeIDError() path = f"api/v2/policy-set-outcomes/{policy_set_outcome_id}" diff --git a/src/pytfe/resources/policy_set_parameter.py b/src/pytfe/resources/policy_set_parameter.py index d3a5bff8..585f9fad 100644 --- a/src/pytfe/resources/policy_set_parameter.py +++ b/src/pytfe/resources/policy_set_parameter.py @@ -34,7 +34,25 @@ class PolicySetParameters(_Service): def list( self, policy_set_id: str, options: PolicySetParameterListOptions | None = None ) -> Iterator[PolicySetParameter]: - """List all the parameters associated with the given policy-set.""" + """List parameters for a policy set. + + Args: + policy_set_id: The policy set ID (e.g. ``"polset-xxxxxxxx"``). + options: Optional pagination controls, as a + :class:`PolicySetParameterListOptions`. + + Returns: + A single-use ``Iterator[PolicySetParameter]``. Wrap with ``list(...)`` + to materialize the results or iterate more than once. + + Raises: + InvalidPolicySetIDError: If ``policy_set_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> for parameter in client.policy_set_parameters.list("polset-123"): + ... print(parameter.id, parameter.key) + """ if not valid_string_id(policy_set_id): raise InvalidPolicySetIDError() params = options.model_dump(by_alias=True, exclude_none=True) if options else {} @@ -45,7 +63,32 @@ def list( def create( self, policy_set_id: str, options: PolicySetParameterCreateOptions ) -> PolicySetParameter: - """Create is used to create a new parameter.""" + """Create a parameter on a policy set. + + Args: + policy_set_id: The policy set ID (e.g. ``"polset-xxxxxxxx"``). + options: Parameter key, value, category, and sensitivity, as a + :class:`PolicySetParameterCreateOptions`. + + Returns: + The created :class:`PolicySetParameter`. + + Raises: + InvalidPolicySetIDError: If ``policy_set_id`` is not a valid resource ID. + RequiredKeyError: If ``options.key`` is missing or empty. + RequiredCategoryError: If ``options.category`` is missing. + InvalidCategoryError: If ``options.category`` is not ``policy-set``. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import CategoryType, PolicySetParameterCreateOptions + >>> parameter = client.policy_set_parameters.create( + ... "polset-123", + ... PolicySetParameterCreateOptions( + ... key="environment", value="prod", category=CategoryType.POLICY_SET + ... ), + ... ) + """ if not valid_string_id(policy_set_id): raise InvalidPolicySetIDError() @@ -73,7 +116,24 @@ def create( return self._policy_set_parameter_from(data) def read(self, policy_set_id: str, parameter_id: str) -> PolicySetParameter: - """Read a parameter by its ID.""" + """Read a policy set parameter by its ID. + + Args: + policy_set_id: The policy set ID (e.g. ``"polset-xxxxxxxx"``). + parameter_id: The policy set parameter ID (e.g. ``"var-xxxxxxxx"``). + + Returns: + The :class:`PolicySetParameter`. + + Raises: + InvalidPolicySetIDError: If ``policy_set_id`` is not a valid resource ID. + InvalidParamIDError: If ``parameter_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> parameter = client.policy_set_parameters.read("polset-123", "var-789") + >>> print(parameter.key) + """ if not valid_string_id(policy_set_id): raise InvalidPolicySetIDError() @@ -93,7 +153,29 @@ def update( parameter_id: str, options: PolicySetParameterUpdateOptions, ) -> PolicySetParameter: - """Update values of an existing parameter.""" + """Update a policy set parameter by its ID. + + Args: + policy_set_id: The policy set ID (e.g. ``"polset-xxxxxxxx"``). + parameter_id: The policy set parameter ID (e.g. ``"var-xxxxxxxx"``). + options: Parameter attributes to update, as a + :class:`PolicySetParameterUpdateOptions`. + + Returns: + The updated :class:`PolicySetParameter`. + + Raises: + InvalidPolicySetIDError: If ``policy_set_id`` is not a valid resource ID. + InvalidParamIDError: If ``parameter_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import PolicySetParameterUpdateOptions + >>> parameter = client.policy_set_parameters.update( + ... "polset-123", "var-789", + ... PolicySetParameterUpdateOptions(value="staging"), + ... ) + """ if not valid_string_id(policy_set_id): raise InvalidPolicySetIDError() @@ -116,7 +198,23 @@ def update( return self._policy_set_parameter_from(data) def delete(self, policy_set_id: str, parameter_id: str) -> None: - """Delete a parameter by its ID.""" + """Delete a policy set parameter by its ID. + + Args: + policy_set_id: The policy set ID (e.g. ``"polset-xxxxxxxx"``). + parameter_id: The policy set parameter ID (e.g. ``"var-xxxxxxxx"``). + + Returns: + None. + + Raises: + InvalidPolicySetIDError: If ``policy_set_id`` is not a valid resource ID. + InvalidParamIDError: If ``parameter_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.policy_set_parameters.delete("polset-123", "var-789") + """ if not valid_string_id(policy_set_id): raise InvalidPolicySetIDError() diff --git a/src/pytfe/resources/policy_set_version.py b/src/pytfe/resources/policy_set_version.py index 39a6b12d..39ba014e 100644 --- a/src/pytfe/resources/policy_set_version.py +++ b/src/pytfe/resources/policy_set_version.py @@ -21,7 +21,22 @@ class PolicySetVersions(_Service): """ def create(self, policy_set_id: str) -> PolicySetVersion: - """Create is used to create a new Policy Set Version.""" + """Create a new policy set version. + + Args: + policy_set_id: The policy set ID (e.g. ``"polset-xxxxxxxx"``). + + Returns: + The :class:`PolicySetVersion`. + + Raises: + InvalidPolicySetIDError: If ``policy_set_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> version = client.policy_set_versions.create("polset-123") + >>> print(version.id) + """ if not valid_string_id(policy_set_id): raise InvalidPolicySetIDError() r = self.t.request( @@ -43,7 +58,24 @@ def create(self, policy_set_id: str) -> PolicySetVersion: ) def read(self, policy_set_version_id: str) -> PolicySetVersion: - """Read is used to read a Policy Set Version by its ID.""" + """Read a policy set version by its ID. + + Args: + policy_set_version_id: The policy set version ID + (e.g. ``"polsetver-xxxxxxxx"``). + + Returns: + The :class:`PolicySetVersion`. + + Raises: + InvalidPolicySetIDError: If ``policy_set_version_id`` is not a valid + resource ID. + TFEError: If the API request fails. + + Example: + >>> version = client.policy_set_versions.read("polsetver-1") + >>> print(version.status) + """ if not valid_string_id(policy_set_version_id): raise InvalidPolicySetIDError() r = self.t.request( @@ -65,10 +97,28 @@ def read(self, policy_set_version_id: str) -> PolicySetVersion: ) def upload(self, policy_set_version: PolicySetVersion, file_path: str) -> None: - """ - Upload uploads policy files. It takes a Policy Set Version and a path - to the set of sentinel files, which will be packaged by hashicorp/go-slug - before being uploaded. + """Upload policy files for a policy set version. + + The SDK packages ``file_path`` with ``hashicorp/go-slug`` compatible + packing before uploading the archive to the version's upload link. + + Args: + policy_set_version: The policy set version returned by + :meth:`create`, as a :class:`PolicySetVersion`. + file_path: The local directory path containing policy files + (e.g. ``"./policies"``). + + Returns: + None. + + Raises: + ValueError: If ``policy_set_version`` has no upload link or the link is + empty. + TFEError: If the API request fails. + + Example: + >>> version = client.policy_set_versions.create("polset-123") + >>> client.policy_set_versions.upload(version, "./policies") """ # Extract upload URL from policy set version links if not policy_set_version.links or "upload" not in policy_set_version.links: diff --git a/src/pytfe/resources/projects.py b/src/pytfe/resources/projects.py index 473e14bd..ac973171 100644 --- a/src/pytfe/resources/projects.py +++ b/src/pytfe/resources/projects.py @@ -128,7 +128,25 @@ class Projects(_Service): def list( self, organization: str, options: ProjectListOptions | None = None ) -> Iterator[Project]: - """List projects in an organization""" + """List projects in an organization. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: Optional filters and pagination, as a :class:`ProjectListOptions`. + + Returns: + A single-use ``Iterator[Project]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import ProjectListOptions + >>> options = ProjectListOptions(page_size=20) + >>> for project in client.projects.list("my-org", options): + ... print(project.id, project.name) + """ # Validate inputs validate_project_list_options(organization) @@ -155,7 +173,24 @@ def list( yield self._project_from(item) def create(self, organization: str, options: ProjectCreateOptions) -> Project: - """Create a new project in an organization""" + """Create a project in an organization. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: The project settings, as a :class:`ProjectCreateOptions`. + + Returns: + The created :class:`Project`. + + Raises: + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import ProjectCreateOptions + >>> project = client.projects.create( + ... "my-org", ProjectCreateOptions(name="Platform") + ... ) + """ # Validate inputs validate_project_create_options(organization, options) @@ -199,7 +234,23 @@ def create(self, organization: str, options: ProjectCreateOptions) -> Project: def read( self, project_id: str, include: builtins.list[str] | None = None ) -> Project: - """Get a specific project by ID""" + """Read a project by its ID. + + Args: + project_id: The project ID (e.g. ``"prj-xxxxxxxx"``). + include: Related resources to include, such as ``["default-agent-pool"]``. + + Returns: + The :class:`Project`. + + Raises: + ValueError: If ``project_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> project = client.projects.read("prj-123") + >>> print(project.name) + """ # Validate inputs if not valid_string_id(project_id): raise ValueError("Project ID is required and must be valid") @@ -219,7 +270,24 @@ def read( return self._project_from(payload["data"], payload.get("included")) def update(self, project_id: str, options: ProjectUpdateOptions) -> Project: - """Update a project's name and/or description""" + """Update a project. + + Args: + project_id: The project ID (e.g. ``"prj-xxxxxxxx"``). + options: The project fields to change, as a :class:`ProjectUpdateOptions`. + + Returns: + The updated :class:`Project`. + + Raises: + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import ProjectUpdateOptions + >>> project = client.projects.update( + ... "prj-123", ProjectUpdateOptions(description="Shared services") + ... ) + """ # Validate inputs validate_project_update_options(project_id, options) @@ -261,7 +329,21 @@ def update(self, project_id: str, options: ProjectUpdateOptions) -> Project: return self._project_from(data) def delete(self, project_id: str) -> None: - """Delete a project""" + """Delete a project. + + Args: + project_id: The project ID (e.g. ``"prj-xxxxxxxx"``). + + Returns: + None. + + Raises: + ValueError: If ``project_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.projects.delete("prj-123") + """ # Validate inputs if not valid_string_id(project_id): raise ValueError("Project ID is required and must be valid") @@ -274,8 +356,23 @@ def move_workspaces( ) -> None: """Move one or more workspaces into a project. - The caller must have permission to move each workspace out of its - current project and into the target project. + The caller must have permission to move each workspace out of its current + project and into the target project. + + Args: + project_id: The destination project ID (e.g. ``"prj-xxxxxxxx"``). + workspace_ids: Workspace IDs to move (e.g. ``["ws-xxxxxxxx"]``). + + Returns: + None. + + Raises: + ValueError: If ``project_id`` or any workspace ID is invalid, or no + workspace IDs are provided. + TFEError: If the API request fails. + + Example: + >>> client.projects.move_workspaces("prj-123", ["ws-abc123"]) """ if not valid_string_id(project_id): raise ValueError("Project ID is required and must be valid") @@ -293,7 +390,22 @@ def move_workspaces( return None def list_tag_bindings(self, project_id: str) -> builtins.list[TagBinding]: - """List tag bindings for a project""" + """List tag bindings for a project. + + Args: + project_id: The project ID (e.g. ``"prj-xxxxxxxx"``). + + Returns: + A ``list[TagBinding]``. + + Raises: + ValueError: If ``project_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> tags = client.projects.list_tag_bindings("prj-123") + >>> print(tags[0].key) + """ # Validate inputs if not valid_string_id(project_id): raise ValueError("Project ID is required and must be valid") @@ -317,7 +429,23 @@ def list_tag_bindings(self, project_id: str) -> builtins.list[TagBinding]: def list_effective_tag_bindings( self, project_id: str ) -> Iterator[EffectiveTagBinding]: - """List effective tag bindings for a project.""" + """List effective tag bindings for a project. + + Args: + project_id: The project ID (e.g. ``"prj-xxxxxxxx"``). + + Returns: + A single-use ``Iterator[EffectiveTagBinding]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + ValueError: If ``project_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> for tag in client.projects.list_effective_tag_bindings("prj-123"): + ... print(tag.key, tag.value) + """ if not valid_string_id(project_id): raise ValueError("Project ID is required and must be valid") @@ -336,18 +464,30 @@ def list_effective_tag_bindings( def add_tag_bindings( self, project_id: str, options: ProjectAddTagBindingsOptions ) -> builtins.list[TagBinding]: - """Add or update tag bindings on a project + """Add or update tag bindings on a project. This endpoint adds key-value tag bindings to an existing project or updates - existing tag binding values. It cannot be used to remove tag bindings. - This operation is additive. - - Constraints: - - A project can have up to 10 tags - - Keys can have up to 128 characters - - Values can have up to 256 characters - - Keys/values support alphanumeric chars and symbols: _, ., =, +, -, @, : - - Cannot use hc: and hcp: as key prefixes + existing tag binding values. It cannot remove tag bindings. + + Args: + project_id: The project ID (e.g. ``"prj-xxxxxxxx"``). + options: Tag bindings to add, as a :class:`ProjectAddTagBindingsOptions`. + + Returns: + A ``list[TagBinding]``. + + Raises: + ValueError: If ``project_id`` is invalid or no tag bindings are provided. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import ProjectAddTagBindingsOptions, TagBinding + >>> options = ProjectAddTagBindingsOptions( + ... tag_bindings=[TagBinding(key="env", value="prod")] + ... ) + >>> tags = client.projects.add_tag_bindings( + ... "prj-123", options + ... ) """ # Validate inputs if not valid_string_id(project_id): @@ -387,7 +527,21 @@ def add_tag_bindings( return tag_bindings def delete_tag_bindings(self, project_id: str) -> None: - """Delete all tag bindings from a project""" + """Delete all tag bindings from a project. + + Args: + project_id: The project ID (e.g. ``"prj-xxxxxxxx"``). + + Returns: + None. + + Raises: + ValueError: If ``project_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.projects.delete_tag_bindings("prj-123") + """ # Validate inputs if not valid_string_id(project_id): raise ValueError("Project ID is required and must be valid") diff --git a/src/pytfe/resources/query_run.py b/src/pytfe/resources/query_run.py index 6f0aa3f8..04299150 100644 --- a/src/pytfe/resources/query_run.py +++ b/src/pytfe/resources/query_run.py @@ -28,20 +28,24 @@ class QueryRuns(_Service): def list( self, workspace_id: str, options: QueryRunListOptions | None = None ) -> Iterator[QueryRun]: - """Iterate through all query runs for the given workspace. - - This method automatically handles pagination and yields QueryRun objects one at a time. + """List query runs for a workspace. Args: - workspace_id: The ID of the workspace - options: Optional list options (page_size, include, etc.) + workspace_id: The workspace ID (e.g. ``"ws-xxxxxxxx"``). + options: Optional pagination and include options, as a + :class:`QueryRunListOptions`. + + Returns: + A single-use ``Iterator[QueryRun]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. - Yields: - QueryRun objects one at a time + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + TFEError: If the API request fails. Example: - for query_run in client.query_runs.list(workspace_id): - print(f"Query Run: {query_run.id} - Status: {query_run.status}") + >>> for query_run in client.query_runs.list("ws-abc123"): + ... print(query_run.id, query_run.status) """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() @@ -60,7 +64,26 @@ def list( yield attach_jsonapi(QueryRun.model_validate(attrs), item) def create(self, options: QueryRunCreateOptions) -> QueryRun: - """Create a new query run.""" + """Create a query run. + + Args: + options: The query run settings, as a :class:`QueryRunCreateOptions`. + + Returns: + The created :class:`QueryRun`. + + Raises: + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import QueryRunCreateOptions, QueryRunSource + >>> query_run = client.query_runs.create( + ... QueryRunCreateOptions( + ... source=QueryRunSource.API, + ... workspace_id="ws-abc123", + ... ) + ... ) + """ attrs = options.model_dump(by_alias=True, exclude_none=True) # Build relationships @@ -100,7 +123,22 @@ def create(self, options: QueryRunCreateOptions) -> QueryRun: return attach_jsonapi(QueryRun.model_validate(attrs), data, jd.get("included")) def read(self, query_run_id: str) -> QueryRun: - """Read a query run by its ID.""" + """Read a query run by its ID. + + Args: + query_run_id: The query run ID (e.g. ``"qr-xxxxxxxx"``). + + Returns: + The :class:`QueryRun`. + + Raises: + InvalidQueryRunIDError: If ``query_run_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> query_run = client.query_runs.read("qr-123abc456def") + >>> print(query_run.status) + """ if not valid_string_id(query_run_id): raise InvalidQueryRunIDError() @@ -116,7 +154,26 @@ def read(self, query_run_id: str) -> QueryRun: def read_with_options( self, query_run_id: str, options: QueryRunReadOptions ) -> QueryRun: - """Read a query run with additional options.""" + """Read a query run by its ID with include options. + + Args: + query_run_id: The query run ID (e.g. ``"qr-xxxxxxxx"``). + options: Include options, as a :class:`QueryRunReadOptions`. + + Returns: + The :class:`QueryRun`. + + Raises: + InvalidQueryRunIDError: If ``query_run_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import QueryRunIncludeOpt, QueryRunReadOptions + >>> query_run = client.query_runs.read_with_options( + ... "qr-123abc456def", + ... QueryRunReadOptions(include=[QueryRunIncludeOpt.CREATED_BY]), + ... ) + """ if not valid_string_id(query_run_id): raise InvalidQueryRunIDError() @@ -135,9 +192,22 @@ def read_with_options( return attach_jsonapi(QueryRun.model_validate(attrs), data, jd.get("included")) def logs(self, query_run_id: str) -> io.IOBase: - """Retrieve the logs for a query run. + """Retrieve logs for a query run. + + Args: + query_run_id: The query run ID (e.g. ``"qr-xxxxxxxx"``). + + Returns: + The ``IOBase`` stream containing the log bytes. + + Raises: + InvalidQueryRunIDError: If ``query_run_id`` is not a valid resource ID. + ValueError: If the query run does not have a log URL. + TFEError: If the API request fails. - Returns an IO stream that can be read to get the log content. + Example: + >>> stream = client.query_runs.logs("qr-123abc456def") + >>> stream.read().decode() """ if not valid_string_id(query_run_id): raise InvalidQueryRunIDError() @@ -157,7 +227,18 @@ def logs(self, query_run_id: str) -> io.IOBase: def cancel(self, query_run_id: str) -> None: """Cancel a query run. - Returns 202 on success with empty body. + Args: + query_run_id: The query run ID (e.g. ``"qr-xxxxxxxx"``). + + Returns: + None. + + Raises: + InvalidQueryRunIDError: If ``query_run_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.query_runs.cancel("qr-123abc456def") """ if not valid_string_id(query_run_id): raise InvalidQueryRunIDError() @@ -170,7 +251,18 @@ def cancel(self, query_run_id: str) -> None: def force_cancel(self, query_run_id: str) -> None: """Force cancel a query run. - Returns 202 on success with empty body. + Args: + query_run_id: The query run ID (e.g. ``"qr-xxxxxxxx"``). + + Returns: + None. + + Raises: + InvalidQueryRunIDError: If ``query_run_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.query_runs.force_cancel("qr-123abc456def") """ if not valid_string_id(query_run_id): raise InvalidQueryRunIDError() diff --git a/src/pytfe/resources/registry.py b/src/pytfe/resources/registry.py new file mode 100644 index 00000000..226b494d --- /dev/null +++ b/src/pytfe/resources/registry.py @@ -0,0 +1,421 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Client for the **public** Terraform Registry module API. + +``client.registry`` talks to the public Terraform Registry at +``registry.terraform.io`` (host configurable via ``base_url``). It implements +the [module registry protocol](https://developer.hashicorp.com/terraform/internals/module-registry-protocol) +plus HashiCorp's documented discovery extensions: listing and searching modules +across the whole registry, reading a module's metadata/inputs/outputs/versions, +resolving a version's download source, and reading download metrics. + +This API is **unauthenticated** and lives on a different host from the HCP +Terraform / Terraform Enterprise V2 API, so the SDK never sends the bearer token +to the registry. + +It is distinct from the SDK's *private* registry resources +(``client.registry_modules``, ``client.registry_providers``, +``client.registry_provider_versions``, ``client.registry_provider_platforms``), +which manage the private registry included in your HCP Terraform / Terraform +Enterprise organization via the authenticated, JSON:API ``/api/v2/registry-*`` +endpoints (publish, update, delete, add versions). + +Public Registry API reference: +https://developer.hashicorp.com/terraform/registry/api-docs +""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +from .._http import HTTPTransport +from ..errors import ( + InvalidModuleNameError, + InvalidModuleNamespaceError, + InvalidModuleProviderError, + InvalidModuleVersionError, + RequiredQueryError, + TFEError, +) +from ..models.registry import ( + PublicRegistryModule, + PublicRegistryModuleDownloadsSummary, + PublicRegistryModuleListOptions, + PublicRegistryModuleVersions, + PublicRegistrySearchOptions, +) +from ..utils import valid_string, valid_string_id +from ._base import _Service + +DEFAULT_REGISTRY_URL = "https://registry.terraform.io" + + +def _query_params(options: Any) -> dict[str, Any]: + """Dump option models to query params, lowercasing bools (``verified=true``).""" + if options is None: + return {} + dumped = options.model_dump(by_alias=True, exclude_none=True, mode="json") + return { + k: (str(v).lower() if isinstance(v, bool) else v) for k, v in dumped.items() + } + + +class Registry(_Service): + """Client for the public Terraform Registry module API. + + Targets ``registry.terraform.io`` by default; set ``base_url`` to point at + another registry that implements the + [module registry protocol](https://developer.hashicorp.com/terraform/internals/module-registry-protocol). + This API is unauthenticated — the HCP Terraform/TFE bearer token is never + sent to the registry host. + """ + + def __init__(self, t: HTTPTransport, base_url: str | None = None) -> None: + super().__init__(t) + self.base_url = (base_url or DEFAULT_REGISTRY_URL).rstrip("/") + + def _get( + self, + path: str, + *, + params: dict[str, Any] | None = None, + allow_redirects: bool = True, + ) -> Any: + return self.t.request( + "GET", + f"{self.base_url}{path}", + params=params, + headers={"Accept": "application/json"}, + include_auth=False, + allow_redirects=allow_redirects, + ) + + def _paginate( + self, path: str, params: dict[str, Any] + ) -> Iterator[PublicRegistryModule]: + p = dict(params) + while True: + body = self._get(path, params=p).json() + if not isinstance(body, dict): + return + for item in body.get("modules") or []: + yield PublicRegistryModule.model_validate(item) + meta = body.get("meta") or {} + next_offset = meta.get("next_offset") if isinstance(meta, dict) else None + if next_offset is None: + return + p["offset"] = next_offset + + def list_modules( + self, + namespace: str | None = None, + options: PublicRegistryModuleListOptions | None = None, + ) -> Iterator[PublicRegistryModule]: + """List public registry modules. + + Args: + namespace: Optional module namespace (e.g. ``"hashicorp"``) to restrict + results to. + options: Optional filters and pagination settings, as a + :class:`PublicRegistryModuleListOptions`. + + Returns: + A single-use ``Iterator[PublicRegistryModule]``. Wrap with ``list(...)`` + to materialize the results or iterate more than once. + + Raises: + InvalidModuleNamespaceError: If ``namespace`` is not valid when provided. + TFEError: If the API request fails. + + Example: + >>> for module in client.registry.list_modules("hashicorp"): + ... print(module.name, module.provider) + """ + if namespace is not None and not valid_string_id(namespace): + raise InvalidModuleNamespaceError() + path = f"/v1/modules/{namespace}" if namespace else "/v1/modules" + yield from self._paginate(path, _query_params(options)) + + def search_modules( + self, query: str, options: PublicRegistrySearchOptions | None = None + ) -> Iterator[PublicRegistryModule]: + """Search public registry modules by query. + + Args: + query: The search query (e.g. ``"vpc"``). + options: Optional filters and pagination settings, as a + :class:`PublicRegistrySearchOptions`. + + Returns: + A single-use ``Iterator[PublicRegistryModule]``. Wrap with ``list(...)`` + to materialize the results or iterate more than once. + + Raises: + RequiredQueryError: If ``query`` is empty. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import PublicRegistrySearchOptions + >>> for module in client.registry.search_modules( + ... "vpc", PublicRegistrySearchOptions(provider="aws") + ... ): + ... print(module.id) + """ + if not valid_string(query): + raise RequiredQueryError() + params = _query_params(options) + params["q"] = query + yield from self._paginate("/v1/modules/search", params) + + def list_latest_for_all_providers( + self, + namespace: str, + name: str, + options: PublicRegistryModuleListOptions | None = None, + ) -> Iterator[PublicRegistryModule]: + """List latest module versions for all providers. + + Args: + namespace: The module namespace (e.g. ``"hashicorp"``). + name: The module name (e.g. ``"consul"``). + options: Optional filters and pagination settings, as a + :class:`PublicRegistryModuleListOptions`. + + Returns: + A single-use ``Iterator[PublicRegistryModule]``. Wrap with ``list(...)`` + to materialize the results or iterate more than once. + + Raises: + InvalidModuleNamespaceError: If ``namespace`` is not a valid module namespace. + InvalidModuleNameError: If ``name`` is not a valid module name. + TFEError: If the API request fails. + + Example: + >>> modules = client.registry.list_latest_for_all_providers( + ... "hashicorp", "consul" + ... ) + >>> for module in modules: + ... print(module.provider, module.version) + """ + self._validate(namespace, name) + yield from self._paginate( + f"/v1/modules/{namespace}/{name}", _query_params(options) + ) + + def latest_for_provider( + self, namespace: str, name: str, provider: str + ) -> PublicRegistryModule: + """Read the latest module version for a provider. + + Args: + namespace: The module namespace (e.g. ``"hashicorp"``). + name: The module name (e.g. ``"consul"``). + provider: The provider name (e.g. ``"aws"``). + + Returns: + The :class:`PublicRegistryModule`. + + Raises: + InvalidModuleNamespaceError: If ``namespace`` is not a valid module namespace. + InvalidModuleNameError: If ``name`` is not a valid module name. + InvalidModuleProviderError: If ``provider`` is not a valid module provider. + TFEError: If the API request fails. + + Example: + >>> module = client.registry.latest_for_provider( + ... "hashicorp", "consul", "aws" + ... ) + >>> print(module.version) + """ + self._validate(namespace, name, provider) + body = self._get(f"/v1/modules/{namespace}/{name}/{provider}").json() + return PublicRegistryModule.model_validate(body) + + def get_module( + self, namespace: str, name: str, provider: str, version: str + ) -> PublicRegistryModule: + """Read a specific module version for a provider. + + Args: + namespace: The module namespace (e.g. ``"hashicorp"``). + name: The module name (e.g. ``"consul"``). + provider: The provider name (e.g. ``"aws"``). + version: The module version (e.g. ``"0.0.1"``). + + Returns: + The :class:`PublicRegistryModule`. + + Raises: + InvalidModuleNamespaceError: If ``namespace`` is not a valid module namespace. + InvalidModuleNameError: If ``name`` is not a valid module name. + InvalidModuleProviderError: If ``provider`` is not a valid module provider. + InvalidModuleVersionError: If ``version`` is not a valid module version. + TFEError: If the API request fails. + + Example: + >>> module = client.registry.get_module( + ... "hashicorp", "consul", "aws", "0.0.1" + ... ) + >>> print(module.source) + """ + self._validate(namespace, name, provider, version) + body = self._get(f"/v1/modules/{namespace}/{name}/{provider}/{version}").json() + return PublicRegistryModule.model_validate(body) + + def list_versions( + self, namespace: str, name: str, provider: str + ) -> PublicRegistryModuleVersions: + """List available versions for a public registry module. + + The public registry can return dependency modules too; this method returns + only the requested module, which the API lists first. + + Args: + namespace: The module namespace (e.g. ``"hashicorp"``). + name: The module name (e.g. ``"consul"``). + provider: The provider name (e.g. ``"aws"``). + + Returns: + The :class:`PublicRegistryModuleVersions`. + + Raises: + InvalidModuleNamespaceError: If ``namespace`` is not a valid module namespace. + InvalidModuleNameError: If ``name`` is not a valid module name. + InvalidModuleProviderError: If ``provider`` is not a valid module provider. + TFEError: If the API request fails. + + Example: + >>> versions = client.registry.list_versions("hashicorp", "consul", "aws") + >>> print([version.version for version in versions.versions]) + """ + self._validate(namespace, name, provider) + body = self._get(f"/v1/modules/{namespace}/{name}/{provider}/versions").json() + modules = (body or {}).get("modules") or [] if isinstance(body, dict) else [] + if not modules: + return PublicRegistryModuleVersions() + return PublicRegistryModuleVersions.model_validate(modules[0]) + + def download_url( + self, namespace: str, name: str, provider: str, version: str + ) -> str: + """Read a module version's source location. + + The returned value is the ``X-Terraform-Get`` go-getter URL string, not the + archive bytes. + + Args: + namespace: The module namespace (e.g. ``"hashicorp"``). + name: The module name (e.g. ``"consul"``). + provider: The provider name (e.g. ``"aws"``). + version: The module version (e.g. ``"0.0.1"``). + + Returns: + The ``str``. + + Raises: + InvalidModuleNamespaceError: If ``namespace`` is not a valid module namespace. + InvalidModuleNameError: If ``name`` is not a valid module name. + InvalidModuleProviderError: If ``provider`` is not a valid module provider. + InvalidModuleVersionError: If ``version`` is not a valid module version. + TFEError: If the API request fails. + + Example: + >>> url = client.registry.download_url( + ... "hashicorp", "consul", "aws", "0.0.1" + ... ) + >>> print(url) + """ + self._validate(namespace, name, provider, version) + resp = self._get( + f"/v1/modules/{namespace}/{name}/{provider}/{version}/download" + ) + return self._x_terraform_get(resp) + + def latest_download_url(self, namespace: str, name: str, provider: str) -> str: + """Read the latest module version's source location. + + The endpoint redirects to the versioned download, and the SDK follows that + redirect automatically. + + Args: + namespace: The module namespace (e.g. ``"hashicorp"``). + name: The module name (e.g. ``"consul"``). + provider: The provider name (e.g. ``"aws"``). + + Returns: + The ``str``. + + Raises: + InvalidModuleNamespaceError: If ``namespace`` is not a valid module namespace. + InvalidModuleNameError: If ``name`` is not a valid module name. + InvalidModuleProviderError: If ``provider`` is not a valid module provider. + TFEError: If the API request fails. + + Example: + >>> url = client.registry.latest_download_url("hashicorp", "consul", "aws") + >>> print(url) + """ + self._validate(namespace, name, provider) + resp = self._get(f"/v1/modules/{namespace}/{name}/{provider}/download") + return self._x_terraform_get(resp) + + def downloads_summary( + self, namespace: str, name: str, provider: str + ) -> PublicRegistryModuleDownloadsSummary: + """Read a module's download metrics summary. + + Args: + namespace: The module namespace (e.g. ``"hashicorp"``). + name: The module name (e.g. ``"consul"``). + provider: The provider name (e.g. ``"aws"``). + + Returns: + The :class:`PublicRegistryModuleDownloadsSummary`. + + Raises: + InvalidModuleNamespaceError: If ``namespace`` is not a valid module namespace. + InvalidModuleNameError: If ``name`` is not a valid module name. + InvalidModuleProviderError: If ``provider`` is not a valid module provider. + TFEError: If the API request fails. + + Example: + >>> summary = client.registry.downloads_summary( + ... "hashicorp", "consul", "aws" + ... ) + >>> print(summary.total) + """ + self._validate(namespace, name, provider) + body = self._get( + f"/v2/modules/{namespace}/{name}/{provider}/downloads/summary" + ).json() + data = (body or {}).get("data") or {} if isinstance(body, dict) else {} + attrs = dict(data.get("attributes") or {}) + attrs["id"] = data.get("id") + return PublicRegistryModuleDownloadsSummary.model_validate(attrs) + + @staticmethod + def _x_terraform_get(resp: Any) -> str: + source = resp.headers.get("X-Terraform-Get") + if not source: + raise TFEError( + "registry download response did not include an X-Terraform-Get header" + ) + return str(source) + + @staticmethod + def _validate( + namespace: str, + name: str, + provider: str | None = None, + version: str | None = None, + ) -> None: + if not valid_string_id(namespace): + raise InvalidModuleNamespaceError() + if not valid_string_id(name): + raise InvalidModuleNameError() + if provider is not None and not valid_string_id(provider): + raise InvalidModuleProviderError() + if version is not None and not valid_string_id(version): + raise InvalidModuleVersionError() diff --git a/src/pytfe/resources/registry_module.py b/src/pytfe/resources/registry_module.py index d4bc8c6a..06d56cb9 100644 --- a/src/pytfe/resources/registry_module.py +++ b/src/pytfe/resources/registry_module.py @@ -42,7 +42,27 @@ class RegistryModules(_Service): def list( self, organization: str, options: RegistryModuleListOptions | None = None ) -> Iterator[RegistryModule]: - """List all the registry modules within an organization.""" + """List registry modules within an organization. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: Options for the request, as a :class:`RegistryModuleListOptions`. + + Returns: + A single-use ``Iterator[RegistryModule]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + ValueError: If an argument or options value is invalid. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import RegistryModuleListOptions + >>> for module in client.registry_modules.list( + ... "my-org", RegistryModuleListOptions(provider="aws") + ... ): + ... print(module.id, module.name) + """ if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) @@ -71,10 +91,27 @@ def list( yield self._parse_registry_module(item) def list_commits(self, module_id: RegistryModuleID) -> CommitList: - """List the commits for the registry module. + """List commits for a registry module's connected VCS repository. This returns the latest 20 commits for the connected VCS repo. - Pagination is not applicable due to inconsistent support from the VCS providers. + Pagination is not applicable due to inconsistent support from the VCS + providers. + + Args: + module_id: The registry module identifier, as a :class:`RegistryModuleID`. + + Returns: + The :class:`CommitList`. + + Raises: + ValueError: If an argument or options value is invalid. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import RegistryModuleID + >>> commits = client.registry_modules.list_commits( + ... RegistryModuleID(organization="my-org", name="vpc", provider="aws") + ... ) """ if not self._validate_module_id(module_id): raise ValueError("Invalid module ID") @@ -94,7 +131,26 @@ def list_commits(self, module_id: RegistryModuleID) -> CommitList: def create( self, organization: str, options: RegistryModuleCreateOptions ) -> RegistryModule: - """Create a registry module without a VCS repo.""" + """Create a registry module without a VCS repository. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: Registry module name, provider, and registry settings, as a + :class:`RegistryModuleCreateOptions`. + + Returns: + The :class:`RegistryModule`. + + Raises: + ValueError: If an argument or options value is invalid. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import RegistryModuleCreateOptions + >>> module = client.registry_modules.create( + ... "my-org", RegistryModuleCreateOptions(name="vpc", provider="aws") + ... ) + """ if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) @@ -117,7 +173,28 @@ def create( def create_version( self, module_id: RegistryModuleID, options: RegistryModuleCreateVersionOptions ) -> RegistryModuleVersion: - """Create a registry module version.""" + """Create a registry module version. + + Args: + module_id: The registry module identifier, as a :class:`RegistryModuleID`. + options: Version and optional commit SHA, as a + :class:`RegistryModuleCreateVersionOptions`. + + Returns: + The :class:`RegistryModuleVersion`. + + Raises: + ValueError: If an argument or options value is invalid. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import RegistryModuleCreateVersionOptions + >>> from pytfe.models import RegistryModuleID + >>> version = client.registry_modules.create_version( + ... RegistryModuleID(organization="my-org", name="vpc", provider="aws"), + ... RegistryModuleCreateVersionOptions(version="1.2.3"), + ... ) + """ if not self._validate_module_id(module_id): raise ValueError("Invalid module ID") @@ -140,7 +217,32 @@ def create_version( def create_with_vcs_connection( self, options: RegistryModuleCreateWithVCSConnectionOptions ) -> RegistryModule: - """Create and publish a registry module with a VCS repo.""" + """Create and publish a registry module with a VCS repository. + + Args: + options: VCS repository settings and optional test config, as a + :class:`RegistryModuleCreateWithVCSConnectionOptions`. + + Returns: + The :class:`RegistryModule`. + + Raises: + ValueError: If an argument or options value is invalid. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import RegistryModuleCreateWithVCSConnectionOptions + >>> from pytfe.models import RegistryModuleVCSRepoOptions + >>> module = client.registry_modules.create_with_vcs_connection( + ... RegistryModuleCreateWithVCSConnectionOptions( + ... vcs_repo=RegistryModuleVCSRepoOptions( + ... identifier="my-org/terraform-aws-vpc", + ... display_identifier="my-org/terraform-aws-vpc", + ... organization_name="my-org", + ... ) + ... ) + ... ) + """ if not self._validate_create_with_vcs_options(options): raise ValueError("Invalid VCS connection options") @@ -175,7 +277,24 @@ def create_with_vcs_connection( return self._parse_registry_module(data) def read(self, module_id: RegistryModuleID) -> RegistryModule: - """Read a specific registry module.""" + """Read a specific registry module. + + Args: + module_id: The registry module identifier, as a :class:`RegistryModuleID`. + + Returns: + The :class:`RegistryModule`. + + Raises: + ValueError: If an argument or options value is invalid. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import RegistryModuleID + >>> module = client.registry_modules.read( + ... RegistryModuleID(id="mod-xxxxxxxx") + ... ) + """ if not self._validate_module_id(module_id): raise ValueError("Invalid module ID") @@ -199,7 +318,26 @@ def read(self, module_id: RegistryModuleID) -> RegistryModule: def read_version( self, module_id: RegistryModuleID, version: str ) -> RegistryModuleVersion: - """Read a registry module version.""" + """Read a registry module version. + + Args: + module_id: The registry module identifier, as a :class:`RegistryModuleID`. + version: The registry module version (e.g. ``"1.2.3"``). + + Returns: + The :class:`RegistryModuleVersion`. + + Raises: + ValueError: If an argument or options value is invalid. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import RegistryModuleID + >>> version = client.registry_modules.read_version( + ... RegistryModuleID(organization="my-org", name="vpc", provider="aws"), + ... "1.2.3", + ... ) + """ if not self._validate_module_id(module_id): raise ValueError("Invalid module ID") @@ -221,7 +359,7 @@ def read_version( def list_versions( self, module_id: RegistryModuleID ) -> Iterator[RegistryModuleVersion]: - """List all versions of a registry module. + """List versions of a registry module. This method intentionally fetches eagerly and returns ``iter(list)`` instead of the canonical ``for x in self._list(...): yield ...`` @@ -234,6 +372,22 @@ def list_versions( See ``docs/ITERATORS.md`` for the convention and when it's OK to deviate from it. + + Args: + module_id: The registry module identifier, as a :class:`RegistryModuleID`. + + Returns: + A single-use ``Iterator[RegistryModuleVersion]``. Wrap with + ``list(...)`` to materialize the results or iterate more than once. + + Raises: + ValueError: If an argument or options value is invalid. + + Example: + >>> from pytfe.models import RegistryModuleID + >>> versions = list(client.registry_modules.list_versions( + ... RegistryModuleID(organization="my-org", name="vpc", provider="aws") + ... )) """ if not self._validate_module_id(module_id): raise ValueError("Invalid module ID") @@ -293,7 +447,28 @@ def list_versions( def read_terraform_registry_module( self, module_id: RegistryModuleID, version: str ) -> TerraformRegistryModule: - """Read a registry module from the Terraform Registry.""" + """Read module metadata from the Terraform Registry API. + + Args: + module_id: The registry module identifier, as a :class:`RegistryModuleID`. + version: The registry module version (e.g. ``"1.2.3"``). + + Returns: + The :class:`TerraformRegistryModule`. + + Raises: + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import RegistryModuleID, RegistryName + >>> module = client.registry_modules.read_terraform_registry_module( + ... RegistryModuleID( + ... namespace="terraform-aws-modules", name="vpc", + ... provider="aws", registry_name=RegistryName.PUBLIC, + ... ), + ... "5.0.0", + ... ) + """ if module_id.registry_name == RegistryName.PUBLIC: path = ( f"/api/registry/public/v1/modules/{module_id.namespace}/" @@ -311,10 +486,24 @@ def read_terraform_registry_module( return TerraformRegistryModule(**data) def delete(self, organization: str, name: str) -> None: - """Delete the entire registry module. + """Delete the entire registry module by organization and name. + + Warning: This method is deprecated and will be removed from a future + version. Use ``delete_by_name`` instead. + + Args: + organization: The organization name (e.g. ``"my-org"``). + name: The registry module name (e.g. ``"vpc"``). + + Returns: + None. - Warning: This method is deprecated and will be removed from a future version. - Use delete_by_name instead. + Raises: + ValueError: If an argument or options value is invalid. + TFEError: If the API request fails. + + Example: + >>> client.registry_modules.delete("my-org", "vpc") """ if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) @@ -326,7 +515,24 @@ def delete(self, organization: str, name: str) -> None: self.t.request("POST", path, json_body={}) def delete_by_name(self, module_id: RegistryModuleID) -> None: - """Delete the entire registry module by name.""" + """Delete the entire registry module by name. + + Args: + module_id: The registry module identifier, as a :class:`RegistryModuleID`. + + Returns: + None. + + Raises: + ValueError: If an argument or options value is invalid. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import RegistryModuleID + >>> client.registry_modules.delete_by_name( + ... RegistryModuleID(organization="my-org", name="vpc", provider="aws") + ... ) + """ if not self._validate_module_id(module_id): raise ValueError("Invalid module ID") @@ -337,7 +543,24 @@ def delete_by_name(self, module_id: RegistryModuleID) -> None: self.t.request("POST", path, json_body={}) def delete_provider(self, module_id: RegistryModuleID) -> None: - """Delete a specified provider for the given module along with all its versions.""" + """Delete a provider and all versions for a registry module. + + Args: + module_id: The registry module identifier, as a :class:`RegistryModuleID`. + + Returns: + None. + + Raises: + ValueError: If an argument or options value is invalid. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import RegistryModuleID + >>> client.registry_modules.delete_provider( + ... RegistryModuleID(organization="my-org", name="vpc", provider="aws") + ... ) + """ if not self._validate_module_id(module_id): raise ValueError("Invalid module ID") @@ -348,7 +571,26 @@ def delete_provider(self, module_id: RegistryModuleID) -> None: self.t.request("POST", path, json_body={}) def delete_version(self, module_id: RegistryModuleID, version: str) -> None: - """Delete a specified version for the given provider of the module.""" + """Delete a specific version of a registry module provider. + + Args: + module_id: The registry module identifier, as a :class:`RegistryModuleID`. + version: The registry module version (e.g. ``"1.2.3"``). + + Returns: + None. + + Raises: + ValueError: If an argument or options value is invalid. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import RegistryModuleID + >>> client.registry_modules.delete_version( + ... RegistryModuleID(organization="my-org", name="vpc", provider="aws"), + ... "1.2.3", + ... ) + """ if not self._validate_module_id(module_id): raise ValueError("Invalid module ID") @@ -365,7 +607,27 @@ def delete_version(self, module_id: RegistryModuleID, version: str) -> None: def update( self, module_id: RegistryModuleID, options: RegistryModuleUpdateOptions ) -> RegistryModule: - """Update properties of a registry module.""" + """Update properties of a registry module. + + Args: + module_id: The registry module identifier, as a :class:`RegistryModuleID`. + options: Registry module fields to update, as a + :class:`RegistryModuleUpdateOptions`. + + Returns: + The :class:`RegistryModule`. + + Raises: + ValueError: If an argument or options value is invalid. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import RegistryModuleID, RegistryModuleUpdateOptions + >>> module = client.registry_modules.update( + ... RegistryModuleID(organization="my-org", name="vpc", provider="aws"), + ... RegistryModuleUpdateOptions(no_code=True), + ... ) + """ if not self._validate_module_id(module_id): raise ValueError("Invalid module ID") @@ -391,10 +653,34 @@ def update( return self._parse_registry_module(data) def upload(self, rmv: RegistryModuleVersion, path: str) -> None: - """Upload Terraform configuration files for the provided registry module version. - - It requires a path to the configuration files on disk, which will be packaged - before being uploaded. + """Package and upload module files from a local path (not implemented yet). + + Packaging a local directory is not implemented, so this always raises + ``NotImplementedError`` once an upload link is present. To upload a module + version today, build the gzipped tar archive yourself and pass the + version's upload link to :meth:`upload_tar_gzip`. + + Args: + rmv: The registry module version with an upload link, as a + :class:`RegistryModuleVersion`. + path: The local configuration directory path (e.g. ``"./module"``). + + Returns: + None. + + Raises: + ValueError: If ``rmv`` has no upload link. + NotImplementedError: Always (when an upload link is present) — + local-path packaging is not implemented yet. + + Example: + >>> import io + >>> # upload() is not implemented; build the archive yourself and use + >>> # upload_tar_gzip with the version's upload link instead: + >>> with open("module.tar.gz", "rb") as fh: + ... client.registry_modules.upload_tar_gzip( + ... rmv.links["upload"], io.BytesIO(fh.read()) + ... ) """ upload_url = rmv.links.get("upload") if not upload_url: @@ -411,8 +697,27 @@ def upload_tar_gzip(self, upload_url: str, archive: io.IOBase) -> None: Any stream implementing io.IOBase can be passed into this method. - Note: This method does not validate the content being uploaded and is therefore - the caller's responsibility to ensure the raw content is a valid Terraform configuration. + Note: This method does not validate the content being uploaded and is + therefore the caller's responsibility to ensure the raw content is a + valid Terraform configuration. + + Args: + upload_url: The upload URL from a registry module version link. + archive: The tar gzip archive stream, as an :class:`io.IOBase`. + + Returns: + None. + + Raises: + httpx.HTTPStatusError: If the upload request returns an error status. + + Example: + >>> import io + >>> version = client.registry_modules.create_version(module_id, options) + >>> with open("module.tar.gz", "rb") as fh: + ... client.registry_modules.upload_tar_gzip( + ... version.links["upload"], io.BytesIO(fh.read()) + ... ) """ # Use the httpx client for direct upload to external URL response = self.t._sync.put(upload_url, content=archive.read()) diff --git a/src/pytfe/resources/registry_provider.py b/src/pytfe/resources/registry_provider.py index cc71e65a..efec96e6 100644 --- a/src/pytfe/resources/registry_provider.py +++ b/src/pytfe/resources/registry_provider.py @@ -30,7 +30,25 @@ class RegistryProviders(_Service): def list( self, organization: str, options: RegistryProviderListOptions | None = None ) -> Iterator[RegistryProvider]: - """List all the registry providers within an organization.""" + """List registry providers in an organization. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: Optional filters, includes, and pagination settings, as a + :class:`RegistryProviderListOptions`. + + Returns: + A single-use ``Iterator[RegistryProvider]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + ValueError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> for provider in client.registry_providers.list("my-org"): + ... print(provider.namespace, provider.name) + """ if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) @@ -59,7 +77,31 @@ def list( def create( self, organization: str, options: RegistryProviderCreateOptions ) -> RegistryProvider: - """Create a registry provider.""" + """Create a registry provider in an organization. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: The registry provider creation settings, as a + :class:`RegistryProviderCreateOptions`. + + Returns: + The created :class:`RegistryProvider`. + + Raises: + ValueError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import RegistryName, RegistryProviderCreateOptions + >>> provider = client.registry_providers.create( + ... "my-org", + ... RegistryProviderCreateOptions( + ... name="example", + ... namespace="my-org", + ... registry_name=RegistryName.PRIVATE, + ... ), + ... ) + """ if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) @@ -86,7 +128,31 @@ def read( provider_id: RegistryProviderID, options: RegistryProviderReadOptions | None = None, ) -> RegistryProvider: - """Read a specific registry provider.""" + """Read a registry provider by composite ID. + + Args: + provider_id: The registry provider identifier, as a + :class:`RegistryProviderID`. + options: Optional include settings, as a + :class:`RegistryProviderReadOptions`. + + Returns: + The :class:`RegistryProvider`. + + Raises: + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import RegistryName, RegistryProviderID + >>> provider = client.registry_providers.read( + ... RegistryProviderID( + ... organization_name="my-org", + ... registry_name=RegistryName.PRIVATE, + ... namespace="my-org", + ... name="example", + ... ) + ... ) + """ path = ( f"/api/v2/organizations/{provider_id.organization_name}/" f"registry-providers/{provider_id.registry_name.value}/" @@ -104,7 +170,29 @@ def read( ) def delete(self, provider_id: RegistryProviderID) -> None: - """Delete a registry provider.""" + """Delete a registry provider by composite ID. + + Args: + provider_id: The registry provider identifier, as a + :class:`RegistryProviderID`. + + Returns: + None. + + Raises: + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import RegistryName, RegistryProviderID + >>> client.registry_providers.delete( + ... RegistryProviderID( + ... organization_name="my-org", + ... registry_name=RegistryName.PRIVATE, + ... namespace="my-org", + ... name="example", + ... ) + ... ) + """ path = ( f"/api/v2/organizations/{provider_id.organization_name}/" f"registry-providers/{provider_id.registry_name.value}/" diff --git a/src/pytfe/resources/registry_provider_platform.py b/src/pytfe/resources/registry_provider_platform.py index cfbbfbff..f1103f79 100644 --- a/src/pytfe/resources/registry_provider_platform.py +++ b/src/pytfe/resources/registry_provider_platform.py @@ -28,7 +28,34 @@ def create( version_id: RegistryProviderVersionID, options: RegistryProviderPlatformCreateOptions, ) -> RegistryProviderPlatform: - """Create a registry provider platform""" + """Create a registry provider platform for a provider version. + + Args: + version_id: The registry provider version identifier, as a + :class:`RegistryProviderVersionID`. + options: The platform metadata, as a + :class:`RegistryProviderPlatformCreateOptions`. + + Returns: + The :class:`RegistryProviderPlatform`. + + Raises: + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import RegistryName, RegistryProviderVersionID + >>> from pytfe.models import RegistryProviderPlatformCreateOptions + >>> version_id = RegistryProviderVersionID( + ... organization_name="my-org", registry_name=RegistryName.PRIVATE, + ... namespace="my-org", name="aws", version="1.0.0", + ... ) + >>> platform = client.registry_provider_platforms.create( + ... version_id, + ... RegistryProviderPlatformCreateOptions( + ... os="linux", arch="amd64", shasum="abc123", filename="provider.zip", + ... ), + ... ) + """ path = f"/api/v2/organizations/{version_id.organization_name}/registry-providers/{version_id.registry_name.value}/{version_id.namespace}/{version_id.name}/versions/{version_id.version}/platforms" attributes = options.model_dump(by_alias=True, exclude_none=True) payload = { @@ -46,7 +73,30 @@ def list( version_id: RegistryProviderVersionID, options: RegistryProviderPlatformListOptions | None = None, ) -> Iterator[RegistryProviderPlatform]: - """List registry provider platforms for a specific version""" + """List registry provider platforms for a provider version. + + Args: + version_id: The registry provider version identifier, as a + :class:`RegistryProviderVersionID`. + options: Optional pagination options, as a + :class:`RegistryProviderPlatformListOptions`. + + Returns: + A single-use ``Iterator[RegistryProviderPlatform]``. Wrap with + ``list(...)`` to materialize the results or iterate more than once. + + Raises: + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import RegistryName, RegistryProviderVersionID + >>> version_id = RegistryProviderVersionID( + ... organization_name="my-org", registry_name=RegistryName.PRIVATE, + ... namespace="my-org", name="aws", version="1.0.0", + ... ) + >>> for platform in client.registry_provider_platforms.list(version_id): + ... print(platform.os, platform.arch) + """ path = ( f"/api/v2/organizations/{version_id.organization_name}" f"/registry-providers/{version_id.registry_name.value}" @@ -58,7 +108,27 @@ def list( yield self._registry_provider_platform_from(item) def read(self, platform_id: RegistryProviderPlatformID) -> RegistryProviderPlatform: - """Read a specific registry provider platform""" + """Read a registry provider platform by ID. + + Args: + platform_id: The registry provider platform identifier, as a + :class:`RegistryProviderPlatformID`. + + Returns: + The :class:`RegistryProviderPlatform`. + + Raises: + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import RegistryName, RegistryProviderPlatformID + >>> platform_id = RegistryProviderPlatformID( + ... organization_name="my-org", registry_name=RegistryName.PRIVATE, + ... namespace="my-org", name="aws", version="1.0.0", + ... os="linux", arch="amd64", + ... ) + >>> platform = client.registry_provider_platforms.read(platform_id) + """ path = ( f"/api/v2/organizations/{platform_id.organization_name}" f"/registry-providers/{platform_id.registry_name.value}" @@ -71,7 +141,27 @@ def read(self, platform_id: RegistryProviderPlatformID) -> RegistryProviderPlatf return self._registry_provider_platform_from(data) def delete(self, platform_id: RegistryProviderPlatformID) -> None: - """Delete a specific registry provider platform""" + """Delete a registry provider platform by ID. + + Args: + platform_id: The registry provider platform identifier, as a + :class:`RegistryProviderPlatformID`. + + Returns: + None. + + Raises: + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import RegistryName, RegistryProviderPlatformID + >>> platform_id = RegistryProviderPlatformID( + ... organization_name="my-org", registry_name=RegistryName.PRIVATE, + ... namespace="my-org", name="aws", version="1.0.0", + ... os="linux", arch="amd64", + ... ) + >>> client.registry_provider_platforms.delete(platform_id) + """ path = ( f"/api/v2/organizations/{platform_id.organization_name}" f"/registry-providers/{platform_id.registry_name.value}" diff --git a/src/pytfe/resources/registry_provider_version.py b/src/pytfe/resources/registry_provider_version.py index 64b4b811..4852ea2b 100644 --- a/src/pytfe/resources/registry_provider_version.py +++ b/src/pytfe/resources/registry_provider_version.py @@ -33,7 +33,40 @@ def create( provider_id: RegistryProviderID, options: RegistryProviderVersionCreateOptions, ) -> RegistryProviderVersion: - """Create a registry provider version""" + """Create a private registry provider version. + + Args: + provider_id: The provider identifier, as a :class:`RegistryProviderID`. + options: The version attributes, as a + :class:`RegistryProviderVersionCreateOptions`. + + Returns: + The created :class:`RegistryProviderVersion`. + + Raises: + RequiredPrivateRegistryError: If ``provider_id`` is not for the private + registry. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import ( + ... RegistryName, + ... RegistryProviderID, + ... RegistryProviderVersionCreateOptions, + ... ) + >>> provider_id = RegistryProviderID( + ... organization_name="my-org", + ... registry_name=RegistryName.PRIVATE, + ... namespace="my-namespace", + ... name="my-provider", + ... ) + >>> version = client.registry_provider_versions.create( + ... provider_id, + ... RegistryProviderVersionCreateOptions( + ... version="1.0.0", key_id="gpg-key-123", protocols=["5.0"] + ... ), + ... ) + """ if provider_id.registry_name != RegistryName.PRIVATE: raise RequiredPrivateRegistryError() path = f"/api/v2/organizations/{provider_id.organization_name}/registry-providers/{provider_id.registry_name.value}/{provider_id.namespace}/{provider_id.name}/versions" @@ -79,14 +112,60 @@ def list( provider_id: RegistryProviderID, options: RegistryProviderVersionListOptions | None = None, ) -> Iterator[RegistryProviderVersion]: - """List registry provider versions""" + """List private registry provider versions. + + Args: + provider_id: The provider identifier, as a :class:`RegistryProviderID`. + options: Optional pagination settings, as a + :class:`RegistryProviderVersionListOptions`. + + Returns: + A single-use ``Iterator[RegistryProviderVersion]``. Wrap with + ``list(...)`` to materialize the results or iterate more than once. + + Raises: + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import RegistryName, RegistryProviderID + >>> provider_id = RegistryProviderID( + ... organization_name="my-org", + ... registry_name=RegistryName.PRIVATE, + ... namespace="my-namespace", + ... name="my-provider", + ... ) + >>> for version in client.registry_provider_versions.list(provider_id): + ... print(version.version) + """ path = f"/api/v2/organizations/{provider_id.organization_name}/registry-providers/{provider_id.registry_name.value}/{provider_id.namespace}/{provider_id.name}/versions" params = options.model_dump(by_alias=True) if options else {} for item in self._list(path=path, params=params): yield self._registry_provider_version_from(item) def read(self, version_id: RegistryProviderVersionID) -> RegistryProviderVersion: - """Read a specific registry provider version""" + """Read a private registry provider version. + + Args: + version_id: The provider version identifier, as a + :class:`RegistryProviderVersionID`. + + Returns: + The :class:`RegistryProviderVersion`. + + Raises: + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import RegistryName, RegistryProviderVersionID + >>> version_id = RegistryProviderVersionID( + ... organization_name="my-org", + ... registry_name=RegistryName.PRIVATE, + ... namespace="my-namespace", + ... name="my-provider", + ... version="1.0.0", + ... ) + >>> version = client.registry_provider_versions.read(version_id) + """ path = f"/api/v2/organizations/{version_id.organization_name}/registry-providers/{version_id.registry_name.value}/{version_id.namespace}/{version_id.name}/versions/{version_id.version}" r = self.t.request( "GET", @@ -96,7 +175,29 @@ def read(self, version_id: RegistryProviderVersionID) -> RegistryProviderVersion return self._registry_provider_version_from(data) def delete(self, version_id: RegistryProviderVersionID) -> None: - """Delete a specific registry provider version""" + """Delete a private registry provider version. + + Args: + version_id: The provider version identifier, as a + :class:`RegistryProviderVersionID`. + + Returns: + None. + + Raises: + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import RegistryName, RegistryProviderVersionID + >>> version_id = RegistryProviderVersionID( + ... organization_name="my-org", + ... registry_name=RegistryName.PRIVATE, + ... namespace="my-namespace", + ... name="my-provider", + ... version="1.0.0", + ... ) + >>> client.registry_provider_versions.delete(version_id) + """ path = f"/api/v2/organizations/{version_id.organization_name}/registry-providers/{version_id.registry_name.value}/{version_id.namespace}/{version_id.name}/versions/{version_id.version}" self.t.request( "DELETE", diff --git a/src/pytfe/resources/reserved_tag_key.py b/src/pytfe/resources/reserved_tag_key.py index 60004520..7da8f91a 100644 --- a/src/pytfe/resources/reserved_tag_key.py +++ b/src/pytfe/resources/reserved_tag_key.py @@ -26,7 +26,25 @@ class ReservedTagKeys(_Service): def list( self, organization: str, options: ReservedTagKeyListOptions | None = None ) -> Iterator[ReservedTagKey]: - """List reserved tag keys for the given organization.""" + """List reserved tag keys in an organization. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: Optional pagination controls, as a + :class:`ReservedTagKeyListOptions`. + + Returns: + A single-use ``Iterator[ReservedTagKey]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> for key in client.reserved_tag_key.list("my-org"): + ... print(key.id, key.key) + """ if not valid_string_id(organization): raise InvalidOrgError() @@ -40,7 +58,27 @@ def list( def create( self, organization: str, options: ReservedTagKeyCreateOptions ) -> ReservedTagKey: - """Create a new reserved tag key for the given organization.""" + """Create a reserved tag key in an organization. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: Reserved tag key attributes, as a + :class:`ReservedTagKeyCreateOptions`. + + Returns: + The created :class:`ReservedTagKey`. + + Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import ReservedTagKeyCreateOptions + >>> key = client.reserved_tag_key.create( + ... "my-org", + ... ReservedTagKeyCreateOptions(key="environment", disable_overrides=True), + ... ) + """ if not valid_string_id(organization): raise InvalidOrgError() @@ -66,7 +104,26 @@ def create( def update( self, reserved_tag_key_id: str, options: ReservedTagKeyUpdateOptions ) -> ReservedTagKey: - """Update a reserved tag key.""" + """Update a reserved tag key by its ID. + + Args: + reserved_tag_key_id: The reserved tag key ID (e.g. ``"rtk-xxxxxxxx"``). + options: Reserved tag key attributes to update, as a + :class:`ReservedTagKeyUpdateOptions`. + + Returns: + The updated :class:`ReservedTagKey`. + + Raises: + ValidationError: If ``reserved_tag_key_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import ReservedTagKeyUpdateOptions + >>> key = client.reserved_tag_key.update( + ... "rtk-123", ReservedTagKeyUpdateOptions(disable_overrides=False) + ... ) + """ if not valid_string_id(reserved_tag_key_id): raise ValidationError("Invalid reserved tag key ID") @@ -90,7 +147,21 @@ def update( return self._parse_reserved_tag_key(data) def delete(self, reserved_tag_key_id: str) -> None: - """Delete a reserved tag key.""" + """Delete a reserved tag key by its ID. + + Args: + reserved_tag_key_id: The reserved tag key ID (e.g. ``"rtk-xxxxxxxx"``). + + Returns: + None. + + Raises: + ValidationError: If ``reserved_tag_key_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.reserved_tag_key.delete("rtk-123") + """ if not valid_string_id(reserved_tag_key_id): raise ValidationError("Invalid reserved tag key ID") diff --git a/src/pytfe/resources/run.py b/src/pytfe/resources/run.py index d43c22af..6c302645 100644 --- a/src/pytfe/resources/run.py +++ b/src/pytfe/resources/run.py @@ -68,7 +68,28 @@ class Runs(_Service): def list( self, workspace_id: str, options: RunListOptions | None = None ) -> Iterator[Run]: - """List all the runs of the given workspace.""" + """List all runs in a workspace. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-xxxxxxxx"``). + options: Optional filters and pagination, as a :class:`RunListOptions`. + + Returns: + A single-use ``Iterator[Run]``. Wrap with ``list(...)`` to materialize + the results or iterate more than once. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import RunListOptions + >>> for run in client.runs.list( + ... "ws-6fHMCom98SDXSQUv", + ... RunListOptions(status="planned"), + ... ): + ... print(run.id, run.status) + """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() params = options.model_dump(by_alias=True) if options else {} @@ -79,7 +100,30 @@ def list( def list_for_organization( self, organization: str, options: RunListForOrganizationOptions | None = None ) -> Iterator[Run]: - """List all the runs of the given organization.""" + """List all runs in an organization. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: Optional filters and pagination, as a + :class:`RunListForOrganizationOptions`. + + Returns: + A single-use ``Iterator[Run]``. Wrap with ``list(...)`` to materialize + the results or iterate more than once. + + Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import RunListForOrganizationOptions + >>> runs = client.runs.list_for_organization( + ... "my-org", + ... RunListForOrganizationOptions(status="applied,planned"), + ... ) + >>> for run in runs: + ... print(run.id, run.status) + """ if not valid_string_id(organization): raise InvalidOrgError() path = f"/api/v2/organizations/{organization}/runs" @@ -90,7 +134,30 @@ def list_for_organization( yield _run_from(item) def create(self, options: RunCreateOptions) -> Run: - """Create a new run for the given workspace.""" + """Create a new run. + + Args: + options: The run configuration, as a :class:`RunCreateOptions`; include + a ``Workspace`` with an ID such as ``"ws-xxxxxxxx"``. + + Returns: + The created :class:`Run`. + + Raises: + RequiredWorkspaceError: If ``options.workspace`` is missing. + TerraformVersionValidForPlanOnlyError: If ``terraform_version`` is set + without ``plan_only=True``. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import RunCreateOptions, Workspace + >>> run = client.runs.create( + ... RunCreateOptions( + ... workspace=Workspace(id="ws-6fHMCom98SDXSQUv"), + ... message="Run from automation", + ... ) + ... ) + """ if options.workspace is None: raise RequiredWorkspaceError() if valid_string(options.terraform_version) and ( @@ -130,13 +197,47 @@ def create(self, options: RunCreateOptions) -> Run: return _run_from(r.json().get("data", {})) def read(self, run_id: str) -> Run: - """Read a run by its ID.""" + """Read a run by its ID. + + Args: + run_id: The run ID (e.g. ``"run-xxxxxxxx"``). + + Returns: + The :class:`Run`. + + Raises: + InvalidRunIDError: If ``run_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> run = client.runs.read("run-CZcmD7eagjhyX0vN") + >>> print(run.status) + """ return self.read_with_options(run_id) def read_with_options( self, run_id: str, options: RunReadOptions | None = None ) -> Run: - """Read a run by its ID with the given options.""" + """Read a run by its ID with include options. + + Args: + run_id: The run ID (e.g. ``"run-xxxxxxxx"``). + options: Optional relationship includes, as a :class:`RunReadOptions`. + + Returns: + The :class:`Run`. + + Raises: + InvalidRunIDError: If ``run_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import RunIncludeOpt, RunReadOptions + >>> run = client.runs.read_with_options( + ... "run-CZcmD7eagjhyX0vN", + ... RunReadOptions(include=[RunIncludeOpt.RUN_PLAN]), + ... ) + """ if not valid_string_id(run_id): raise InvalidRunIDError() params: dict[str, Any] = {} @@ -151,7 +252,26 @@ def read_with_options( 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.""" + """Apply a run by its ID. + + Args: + run_id: The run ID (e.g. ``"run-xxxxxxxx"``). + options: Optional apply comment, as a :class:`RunApplyOptions`. + + Returns: + None. + + Raises: + InvalidRunIDError: If ``run_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import RunApplyOptions + >>> client.runs.apply( + ... "run-CZcmD7eagjhyX0vN", + ... RunApplyOptions(comment="Approved by automation"), + ... ) + """ if not valid_string_id(run_id): raise InvalidRunIDError() body = {"comment": options.comment} if options and options.comment else None @@ -160,7 +280,26 @@ def apply(self, run_id: str, options: RunApplyOptions | None = None) -> None: return None def cancel(self, run_id: str, options: RunCancelOptions | None = None) -> None: - """Cancel a run by its ID.""" + """Cancel a run by its ID. + + Args: + run_id: The run ID (e.g. ``"run-xxxxxxxx"``). + options: Optional cancel comment, as a :class:`RunCancelOptions`. + + Returns: + None. + + Raises: + InvalidRunIDError: If ``run_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import RunCancelOptions + >>> client.runs.cancel( + ... "run-CZcmD7eagjhyX0vN", + ... RunCancelOptions(comment="Superseded by a newer run"), + ... ) + """ if not valid_string_id(run_id): raise InvalidRunIDError() body = {"comment": options.comment} if options and options.comment else None @@ -170,7 +309,27 @@ def cancel(self, run_id: str, options: RunCancelOptions | None = None) -> None: def force_cancel( self, run_id: str, options: RunForceCancelOptions | None = None ) -> None: - """ForceCancel is used to forcefully cancel a run by its ID.""" + """Forcefully cancel a run by its ID. + + Args: + run_id: The run ID (e.g. ``"run-xxxxxxxx"``). + options: Optional force-cancel comment, as a + :class:`RunForceCancelOptions`. + + Returns: + None. + + Raises: + InvalidRunIDError: If ``run_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import RunForceCancelOptions + >>> client.runs.force_cancel( + ... "run-CZcmD7eagjhyX0vN", + ... RunForceCancelOptions(comment="Run is stuck"), + ... ) + """ if not valid_string_id(run_id): raise InvalidRunIDError() body = {"comment": options.comment} if options and options.comment else None @@ -180,14 +339,47 @@ def force_cancel( return None def force_execute(self, run_id: str) -> None: - """ForceExecute is used to forcefully execute a run by its ID.""" + """Forcefully execute a run by its ID. + + Args: + run_id: The run ID (e.g. ``"run-xxxxxxxx"``). + + Returns: + None. + + Raises: + InvalidRunIDError: If ``run_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.runs.force_execute("run-CZcmD7eagjhyX0vN") + """ if not valid_string_id(run_id): raise InvalidRunIDError() self.t.request("POST", f"/api/v2/runs/{run_id}/actions/force-execute") return None def discard(self, run_id: str, options: RunDiscardOptions | None = None) -> None: - """Discard a run by its ID.""" + """Discard a run by its ID. + + Args: + run_id: The run ID (e.g. ``"run-xxxxxxxx"``). + options: Optional discard comment, as a :class:`RunDiscardOptions`. + + Returns: + None. + + Raises: + InvalidRunIDError: If ``run_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import RunDiscardOptions + >>> client.runs.discard( + ... "run-CZcmD7eagjhyX0vN", + ... RunDiscardOptions(comment="No longer needed"), + ... ) + """ if not valid_string_id(run_id): raise InvalidRunIDError() body = {"comment": options.comment} if options and options.comment else None diff --git a/src/pytfe/resources/run_event.py b/src/pytfe/resources/run_event.py index 34479b0e..5b2ea364 100644 --- a/src/pytfe/resources/run_event.py +++ b/src/pytfe/resources/run_event.py @@ -44,7 +44,24 @@ class RunEvents(_Service): def list( self, run_id: str, options: RunEventListOptions | None = None ) -> Iterator[RunEvent]: - """List all the run events of the given run.""" + """List run events for a run. + + Args: + run_id: The run ID (e.g. ``"run-xxxxxxxx"``). + options: Optional include options, as a :class:`RunEventListOptions`. + + Returns: + A single-use ``Iterator[RunEvent]``. Wrap with ``list(...)`` to materialize + the results or iterate more than once. + + Raises: + InvalidRunIDError: If ``run_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> for event in client.run_events.list("run-CZcmD7eagjhyX0vN"): + ... print(event.action, event.description) + """ if not valid_string_id(run_id): raise InvalidRunIDError() params: dict[str, Any] = {} @@ -56,13 +73,47 @@ def list( yield _run_event_from(item) def read(self, run_event_id: str) -> RunEvent: - """Read a specific run event by its ID.""" + """Read a run event by its ID. + + Args: + run_event_id: The run event ID (e.g. ``"re-xxxxxxxx"``). + + Returns: + The :class:`RunEvent`. + + Raises: + InvalidRunEventIDError: If ``run_event_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> event = client.run_events.read("re-read-123") + >>> print(event.action) + """ return self.read_with_options(run_event_id) def read_with_options( self, run_event_id: str, options: RunEventReadOptions | None = None ) -> RunEvent: - """Read a specific run event by its ID with the given options.""" + """Read a run event by its ID with include options. + + Args: + run_event_id: The run event ID (e.g. ``"re-xxxxxxxx"``). + options: Optional include options, as a :class:`RunEventReadOptions`. + + Returns: + The :class:`RunEvent`. + + Raises: + InvalidRunEventIDError: If ``run_event_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import RunEventIncludeOpt, RunEventReadOptions + >>> event = client.run_events.read_with_options( + ... "re-read-456", + ... RunEventReadOptions(include=[RunEventIncludeOpt.RUN_EVENT_ACTOR]), + ... ) + """ if not valid_string_id(run_event_id): raise InvalidRunEventIDError() params: dict[str, Any] = {} diff --git a/src/pytfe/resources/run_task.py b/src/pytfe/resources/run_task.py index 004ba811..d97bda5d 100644 --- a/src/pytfe/resources/run_task.py +++ b/src/pytfe/resources/run_task.py @@ -136,6 +136,25 @@ class RunTasks(_Service): def list( self, organization_id: str, options: RunTaskListOptions | None = None ) -> Iterator[RunTask]: + """List run tasks in an organization. + + Args: + organization_id: The organization name (e.g. ``"my-org"``). + options: Optional pagination and include settings, as a + :class:`RunTaskListOptions`. + + Returns: + A single-use ``Iterator[RunTask]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + InvalidOrgError: If ``organization_id`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> for task in client.run_tasks.list("my-org"): + ... print(task.id, task.name) + """ if not valid_string_id(organization_id): raise InvalidOrgError() @@ -155,6 +174,34 @@ def list( yield _run_task_from(item, organization_id) def create(self, organization_id: str, options: RunTaskCreateOptions) -> RunTask: + """Create a run task in an organization. + + Args: + organization_id: The organization name (e.g. ``"my-org"``). + options: The run task creation settings, as a + :class:`RunTaskCreateOptions`. + + Returns: + The created :class:`RunTask`. + + Raises: + InvalidOrgError: If ``organization_id`` is not a valid organization name. + RequiredNameError: If ``options.name`` is empty. + InvalidRunTaskURLError: If ``options.url`` is empty. + InvalidRunTaskCategoryError: If ``options.category`` is not ``"task"``. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import RunTaskCreateOptions + >>> task = client.run_tasks.create( + ... "my-org", + ... RunTaskCreateOptions( + ... name="security-scan", + ... url="https://example.com/run-task", + ... category="task", + ... ), + ... ) + """ if not valid_string_id(organization_id): raise InvalidOrgError() if not valid_string(options.name): @@ -203,11 +250,46 @@ def create(self, organization_id: str, options: RunTaskCreateOptions) -> RunTask return _run_task_from(r.json()["data"], organization_id) def read(self, run_task_id: str) -> RunTask: + """Read a run task by ID. + + Args: + run_task_id: The run task ID (e.g. ``"task-xxxxxxxx"``). + + Returns: + The :class:`RunTask`. + + Raises: + InvalidRunTaskIDError: If ``run_task_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> task = client.run_tasks.read("task-123") + >>> print(task.name) + """ return self.read_with_options(run_task_id) def read_with_options( self, run_task_id: str, options: RunTaskReadOptions | None = None ) -> RunTask: + """Read a run task by ID with include options. + + Args: + run_task_id: The run task ID (e.g. ``"task-xxxxxxxx"``). + options: Optional include settings, as a :class:`RunTaskReadOptions`. + + Returns: + The :class:`RunTask`. + + Raises: + InvalidRunTaskIDError: If ``run_task_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import RunTaskReadOptions + >>> task = client.run_tasks.read_with_options( + ... "task-123", RunTaskReadOptions() + ... ) + """ if not valid_string_id(run_task_id): raise InvalidRunTaskIDError() params: dict[str, str] = {} @@ -220,6 +302,29 @@ def read_with_options( return _run_task_from(payload["data"], included=payload.get("included")) def update(self, run_task_id: str, options: RunTaskUpdateOptions) -> RunTask: + """Update a run task by ID. + + Args: + run_task_id: The run task ID (e.g. ``"task-xxxxxxxx"``). + options: The run task fields to update, as a + :class:`RunTaskUpdateOptions`. + + Returns: + The :class:`RunTask`. + + Raises: + InvalidRunTaskIDError: If ``run_task_id`` is not a valid resource ID. + RequiredNameError: If ``options.name`` is empty when provided. + InvalidRunTaskURLError: If ``options.url`` is empty when provided. + InvalidRunTaskCategoryError: If ``options.category`` is not ``"task"``. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import RunTaskUpdateOptions + >>> task = client.run_tasks.update( + ... "task-123", RunTaskUpdateOptions(name="security-scan") + ... ) + """ if not valid_string_id(run_task_id): raise InvalidRunTaskIDError("Invalid run task ID") if options.name is not None and not valid_string(options.name): @@ -269,6 +374,21 @@ def update(self, run_task_id: str, options: RunTaskUpdateOptions) -> RunTask: return _run_task_from(r.json()["data"]) def delete(self, run_task_id: str) -> None: + """Delete a run task by ID. + + Args: + run_task_id: The run task ID (e.g. ``"task-xxxxxxxx"``). + + Returns: + None. + + Raises: + InvalidRunTaskIDError: If ``run_task_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.run_tasks.delete("task-123") + """ if not valid_string_id(run_task_id): raise InvalidRunTaskIDError() self.t.request("DELETE", f"/api/v2/tasks/{run_task_id}") diff --git a/src/pytfe/resources/run_task_integration.py b/src/pytfe/resources/run_task_integration.py index 165dbbf2..ea38f22b 100644 --- a/src/pytfe/resources/run_task_integration.py +++ b/src/pytfe/resources/run_task_integration.py @@ -21,10 +21,37 @@ def callback( access_token: str, options: TaskResultCallbackRequestOptions, ) -> None: - """Send a Run Task result back to the Terraform callback URL. + """Send a Run Task result to Terraform's callback URL. - The PATCH request must use the access token from the originating - Run Task webhook (not the SDK client's API token). + The PATCH request must use the access token from the originating Run Task + webhook, not the SDK client's API token. + + Args: + callback_url: The callback URL from the Run Task webhook. + access_token: The callback access token from the Run Task webhook. + options: The callback result payload, as a + :class:`TaskResultCallbackRequestOptions`. + + Returns: + None. + + Raises: + InvalidCallbackURLError: If ``callback_url`` is blank. + InvalidAccessTokenError: If ``access_token`` is blank. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import TaskResultCallbackRequestOptions + >>> from pytfe.models import TaskResultCallbackStatus + >>> # callback_url and access_token are delivered by the inbound + >>> # Run Task request that your integration is responding to: + >>> client.run_task_integrations.callback( + ... callback_url, + ... access_token, + ... TaskResultCallbackRequestOptions( + ... status=TaskResultCallbackStatus.passed, + ... ), + ... ) """ if not callback_url or not callback_url.strip(): raise InvalidCallbackURLError() diff --git a/src/pytfe/resources/run_trigger.py b/src/pytfe/resources/run_trigger.py index 7570e109..a6563a68 100644 --- a/src/pytfe/resources/run_trigger.py +++ b/src/pytfe/resources/run_trigger.py @@ -87,6 +87,30 @@ class RunTriggers(_Service): def list( self, workspace_id: str, options: RunTriggerListOptions | None = None ) -> Iterator[RunTrigger]: + """List run triggers for a workspace. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-xxxxxxxx"``). + options: The required run-trigger filters, as a + :class:`RunTriggerListOptions`. + + Returns: + A single-use ``Iterator[RunTrigger]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + RequiredRunTriggerListOpsError: If ``options`` is not supplied. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import RunTriggerFilterOp, RunTriggerListOptions + >>> options = RunTriggerListOptions( + ... run_trigger_type=RunTriggerFilterOp.RUN_TRIGGER_OUTBOUND + ... ) + >>> for trigger in client.run_triggers.list("ws-4j8p6jX1w33MiDC7", options): + ... print(trigger.id, trigger.sourceable_name) + """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() if not options: @@ -112,6 +136,30 @@ def list( yield rt def create(self, workspace_id: str, options: RunTriggerCreateOptions) -> RunTrigger: + """Create a run trigger for a workspace. + + Args: + workspace_id: The destination workspace ID (e.g. ``"ws-xxxxxxxx"``). + options: The source workspace relationship, as a + :class:`RunTriggerCreateOptions`. + + Returns: + The :class:`RunTrigger`. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + RequiredSourceableError: If ``options.sourceable`` is not supplied. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import RunTriggerCreateOptions, Workspace + >>> trigger = client.run_triggers.create( + ... "ws-4j8p6jX1w33MiDC7", + ... RunTriggerCreateOptions( + ... sourceable=Workspace.model_construct(id="ws-W2iULzoRNB5YHXXA") + ... ), + ... ) + """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() if options.sourceable is None: @@ -136,6 +184,22 @@ def create(self, workspace_id: str, options: RunTriggerCreateOptions) -> RunTrig return rt def read(self, run_trigger_id: str) -> RunTrigger: + """Read a run trigger by ID. + + Args: + run_trigger_id: The run trigger ID (e.g. ``"rt-xxxxxxxx"``). + + Returns: + The :class:`RunTrigger`. + + Raises: + InvalidRunTriggerIDError: If ``run_trigger_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> trigger = client.run_triggers.read("rt-4j8p6jX1w33MiDC7") + >>> print(trigger.workspace_name) + """ if not valid_string_id(run_trigger_id): raise InvalidRunTriggerIDError() path = f"/api/v2/run-triggers/{run_trigger_id}" @@ -145,6 +209,21 @@ def read(self, run_trigger_id: str) -> RunTrigger: return rt def delete(self, run_trigger_id: str) -> None: + """Delete a run trigger by ID. + + Args: + run_trigger_id: The run trigger ID (e.g. ``"rt-xxxxxxxx"``). + + Returns: + None. + + Raises: + InvalidRunTriggerIDError: If ``run_trigger_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.run_triggers.delete("rt-4j8p6jX1w33MiDC7") + """ if not valid_string_id(run_trigger_id): raise InvalidRunTriggerIDError() path = f"/api/v2/run-triggers/{run_trigger_id}" @@ -156,6 +235,27 @@ def validate_run_trigger_filter_param( filter_param: RunTriggerFilterOp, include_param: builtins.list[RunTriggerIncludeOp], ) -> None: + """Validate run trigger filter and include compatibility. + + Args: + filter_param: The run trigger filter, as a :class:`RunTriggerFilterOp`. + include_param: Include relationships, as a list of + :class:`RunTriggerIncludeOp` values. + + Returns: + None. + + Raises: + InvalidRunTriggerTypeError: If ``filter_param`` is invalid. + UnsupportedRunTriggerTypeError: If includes are used with a non-inbound + run trigger filter. + + Example: + >>> from pytfe.models import RunTriggerFilterOp + >>> client.run_triggers.validate_run_trigger_filter_param( + ... RunTriggerFilterOp.RUN_TRIGGER_OUTBOUND, [] + ... ) + """ if filter_param not in RunTriggerFilterOp: raise InvalidRunTriggerTypeError() if len(include_param) > 0: @@ -164,6 +264,18 @@ def validate_run_trigger_filter_param( return None def backfill_deprecated_sourceable(self, rt: RunTrigger) -> None: + """Backfill the deprecated sourceable field from sourceable_choice. + + Args: + rt: The run trigger to mutate, as a :class:`RunTrigger`. + + Returns: + None. + + Example: + >>> trigger = client.run_triggers.read("rt-4j8p6jX1w33MiDC7") + >>> client.run_triggers.backfill_deprecated_sourceable(trigger) + """ if rt.sourceable or not rt.sourceable_choice: return diff --git a/src/pytfe/resources/ssh_keys.py b/src/pytfe/resources/ssh_keys.py index 1cbeef22..8bf5f557 100644 --- a/src/pytfe/resources/ssh_keys.py +++ b/src/pytfe/resources/ssh_keys.py @@ -26,7 +26,27 @@ class SSHKeys(_Service): def list( self, organization: str, options: SSHKeyListOptions | None = None ) -> Iterator[SSHKey]: - """List SSH keys for the given organization.""" + """List SSH keys for the given organization. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: Optional pagination controls, as a :class:`SSHKeyListOptions`. + + Returns: + A single-use ``Iterator[SSHKey]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import SSHKeyListOptions + >>> for key in client.ssh_keys.list( + ... "my-org", SSHKeyListOptions(page_size=20) + ... ): + ... print(key.id, key.name) + """ if not valid_string_id(organization): raise InvalidOrgError() @@ -38,7 +58,29 @@ def list( yield SSHKey.model_validate(attrs) def create(self, organization: str, options: SSHKeyCreateOptions) -> SSHKey: - """Create a new SSH key for the given organization.""" + """Create a new SSH key for the given organization. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: SSH key name and private key text, as a + :class:`SSHKeyCreateOptions`. + + Returns: + The :class:`SSHKey`. + + Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import SSHKeyCreateOptions + >>> with open("deploy_key", "r") as fh: + ... private_key_pem = fh.read() + >>> key = client.ssh_keys.create( + ... "my-org", + ... SSHKeyCreateOptions(name="deploy-key", value=private_key_pem), + ... ) + """ if not valid_string_id(organization): raise InvalidOrgError() @@ -62,7 +104,22 @@ def create(self, organization: str, options: SSHKeyCreateOptions) -> SSHKey: return self._parse_ssh_key(data) def read(self, ssh_key_id: str) -> SSHKey: - """Read an SSH key by its ID.""" + """Read an SSH key by its ID. + + Args: + ssh_key_id: The SSH key ID (e.g. ``"sshkey-xxxxxxxx"``). + + Returns: + The :class:`SSHKey`. + + Raises: + InvalidSSHKeyIDError: If ``ssh_key_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> key = client.ssh_keys.read("sshkey-123") + >>> print(key.name) + """ if not valid_string_id(ssh_key_id): raise InvalidSSHKeyIDError() @@ -74,7 +131,25 @@ def read(self, ssh_key_id: str) -> SSHKey: return self._parse_ssh_key(data) def update(self, ssh_key_id: str, options: SSHKeyUpdateOptions) -> SSHKey: - """Update an SSH key.""" + """Update an SSH key. + + Args: + ssh_key_id: The SSH key ID (e.g. ``"sshkey-xxxxxxxx"``). + options: SSH key fields to update, as a :class:`SSHKeyUpdateOptions`. + + Returns: + The :class:`SSHKey`. + + Raises: + InvalidSSHKeyIDError: If ``ssh_key_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import SSHKeyUpdateOptions + >>> key = client.ssh_keys.update( + ... "sshkey-123", SSHKeyUpdateOptions(name="deploy-key-v2") + ... ) + """ if not valid_string_id(ssh_key_id): raise InvalidSSHKeyIDError() @@ -98,7 +173,21 @@ def update(self, ssh_key_id: str, options: SSHKeyUpdateOptions) -> SSHKey: return self._parse_ssh_key(data) def delete(self, ssh_key_id: str) -> None: - """Delete an SSH key.""" + """Delete an SSH key. + + Args: + ssh_key_id: The SSH key ID (e.g. ``"sshkey-xxxxxxxx"``). + + Returns: + None. + + Raises: + InvalidSSHKeyIDError: If ``ssh_key_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.ssh_keys.delete("sshkey-123") + """ if not valid_string_id(ssh_key_id): raise InvalidSSHKeyIDError() diff --git a/src/pytfe/resources/stack.py b/src/pytfe/resources/stack.py index 55cbd4fa..f242cfde 100644 --- a/src/pytfe/resources/stack.py +++ b/src/pytfe/resources/stack.py @@ -23,7 +23,25 @@ class Stacks(_Service): def create(self, options: StackCreateOptions) -> Stack: - """Create a new stack within a project.""" + """Create a new stack within a project. + + Args: + options: The stack creation settings, as a :class:`StackCreateOptions`. + + Returns: + The created :class:`Stack`. + + Raises: + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import Project, StackCreateOptions + >>> stack = client.stacks.create( + ... StackCreateOptions( + ... name="app-stack", project=Project(id="prj-xxxxxxxx") + ... ) + ... ) + """ payload = { "data": { "attributes": options.model_dump( @@ -52,7 +70,24 @@ def create(self, options: StackCreateOptions) -> Stack: return self._stack_from(data) def update(self, stack_id: str, options: StackUpdateOptions) -> Stack: - """Update an existing stack.""" + """Update an existing stack. + + Args: + stack_id: The stack ID (e.g. ``"st-xxxxxxxx"``). + options: The stack fields to update, as a :class:`StackUpdateOptions`. + + Returns: + The :class:`Stack`. + + Raises: + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import StackUpdateOptions + >>> stack = client.stacks.update( + ... "st-123", StackUpdateOptions(description="Production stack") + ... ) + """ payload = { "data": { "attributes": options.model_dump( @@ -87,14 +122,48 @@ def update(self, stack_id: str, options: StackUpdateOptions) -> Stack: return self._stack_from(data) def list(self, organization: str, options: StackListOptions) -> Iterator[Stack]: - """List stacks within an organization, with optional filtering by project.""" + """List stacks within an organization. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: Filtering and pagination settings, as a :class:`StackListOptions`. + + Returns: + A single-use ``Iterator[Stack]``. Wrap with ``list(...)`` to materialize + the results or iterate more than once. + + Raises: + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import StackListOptions + >>> stacks = client.stacks.list( + ... "my-org", StackListOptions(page_size=20) + ... ) + >>> for stack in stacks: + ... print(stack.id, stack.name) + """ params = options.model_dump(by_alias=True, exclude_none=True) path = f"/api/v2/organizations/{organization}/stacks" for item in self._list(path, params=params): yield self._stack_from(item) def read(self, stack_id: str) -> Stack: - """Read a stack by ID.""" + """Read a stack by ID. + + Args: + stack_id: The stack ID (e.g. ``"st-xxxxxxxx"``). + + Returns: + The :class:`Stack`. + + Raises: + TFEError: If the API request fails. + + Example: + >>> stack = client.stacks.read("st-123") + >>> print(stack.name) + """ r = self.t.request( "GET", path=f"/api/v2/stacks/{stack_id}", @@ -103,7 +172,20 @@ def read(self, stack_id: str) -> Stack: return self._stack_from(data) def delete(self, stack_id: str) -> None: - """Delete a stack by ID.""" + """Delete a stack by ID. + + Args: + stack_id: The stack ID (e.g. ``"st-xxxxxxxx"``). + + Returns: + None. + + Raises: + TFEError: If the API request fails. + + Example: + >>> client.stacks.delete("st-123") + """ self.t.request( "DELETE", path=f"/api/v2/stacks/{stack_id}", @@ -111,7 +193,20 @@ def delete(self, stack_id: str) -> None: return None def force_delete(self, stack_id: str) -> None: - """ForceDelete deletes a stack that still has deployments.""" + """Force delete a stack that still has deployments. + + Args: + stack_id: The stack ID (e.g. ``"st-xxxxxxxx"``). + + Returns: + None. + + Raises: + TFEError: If the API request fails. + + Example: + >>> client.stacks.force_delete("st-123") + """ self.t.request( "DELETE", path=f"/api/v2/stacks/{stack_id}?force=true", @@ -119,7 +214,23 @@ def force_delete(self, stack_id: str) -> None: return None def fetch_latest_from_vcs(self, stack_id: str) -> Stack: - """FetchLatestFromVcs updates the configuration of a stack, triggering stack preparation.""" + """Fetch the latest stack configuration from VCS. + + This triggers stack preparation for the latest VCS revision. + + Args: + stack_id: The stack ID (e.g. ``"st-xxxxxxxx"``). + + Returns: + The :class:`Stack`. + + Raises: + TFEError: If the API request fails. + + Example: + >>> stack = client.stacks.fetch_latest_from_vcs("st-123") + >>> print(stack.updated_at) + """ path = f"/api/v2/stacks/{stack_id}/fetch-latest-from-vcs" r = self.t.request("POST", path=path) data = r.json().get("data", {}) diff --git a/src/pytfe/resources/stack_configuration.py b/src/pytfe/resources/stack_configuration.py index 774c566e..bebe8e62 100644 --- a/src/pytfe/resources/stack_configuration.py +++ b/src/pytfe/resources/stack_configuration.py @@ -30,7 +30,28 @@ def create( options: StackConfigurationCreateOptions | None = None, source: StackConfigurationSource = StackConfigurationSource.MANUAL, ) -> StackConfiguration: - """Create a stack configuration for the given stack.""" + """Create a stack configuration for the given stack. + + Args: + stack_id: The stack ID (e.g. ``"st-xyz789"``). + options: Optional creation settings, as a + :class:`StackConfigurationCreateOptions`. + source: How to source the configuration, as a + :class:`StackConfigurationSource`. + + Returns: + The created :class:`StackConfiguration`. + + Raises: + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import StackConfigurationCreateOptions + >>> config = client.stack_configurations.create( + ... "st-xyz789", + ... StackConfigurationCreateOptions(speculative_enabled=True), + ... ) + """ path = f"/api/v2/stacks/{stack_id}/stack-configurations" params: dict[str, str] = {} if source != StackConfigurationSource.MANUAL: @@ -55,7 +76,24 @@ def list( stack_id: str, options: StackConfigurationListOptions | None = None, ) -> Iterator[StackConfiguration]: - """List stack configurations for the given stack.""" + """List stack configurations for the given stack. + + Args: + stack_id: The stack ID (e.g. ``"st-xyz789"``). + options: Optional pagination and includes, as a + :class:`StackConfigurationListOptions`. + + Returns: + A single-use ``Iterator[StackConfiguration]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + TFEError: If the API request fails. + + Example: + >>> for config in client.stack_configurations.list("st-xyz789"): + ... print(config.id, config.status) + """ path = f"/api/v2/stacks/{stack_id}/stack-configurations" params: dict[str, Any] = {} if options: @@ -71,7 +109,23 @@ def read( stack_configuration_id: str, options: StackConfigurationReadOptions | None = None, ) -> StackConfiguration: - """Read a stack configuration by its ID.""" + """Read a stack configuration by its ID. + + Args: + stack_configuration_id: The stack configuration ID (e.g. ``"stc-abc123"``). + options: Optional related resources, as a + :class:`StackConfigurationReadOptions`. + + Returns: + The :class:`StackConfiguration`. + + Raises: + TFEError: If the API request fails. + + Example: + >>> config = client.stack_configurations.read("stc-abc123") + >>> print(config.sequence_number) + """ path = f"/api/v2/stack-configurations/{stack_configuration_id}" params: dict[str, str] = {} if options and options.include: diff --git a/src/pytfe/resources/state_version_outputs.py b/src/pytfe/resources/state_version_outputs.py index a3f5f558..f092ca21 100644 --- a/src/pytfe/resources/state_version_outputs.py +++ b/src/pytfe/resources/state_version_outputs.py @@ -29,7 +29,22 @@ class StateVersionOutputs(_Service): """ def read(self, output_id: str) -> StateVersionOutput: - """Read a specific state version output by ID.""" + """Read a specific state version output by ID. + + Args: + output_id: The state version output ID (e.g. ``"wsout-xxxxxxxx"``). + + Returns: + The :class:`StateVersionOutput`. + + Raises: + ValueError: If ``output_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> output = client.state_version_outputs.read("wsout-1") + >>> print(output.name, output.value) + """ if not valid_string_id(output_id): raise ValueError("invalid output id") @@ -50,9 +65,29 @@ def read_current( workspace_id: str, options: StateVersionOutputsListOptions | None = None, ) -> Iterator[StateVersionOutput]: - """ - Read outputs for the workspace's current state version. - Note: sensitive outputs are returned with null values by the API. + """Read outputs for the workspace's current state version. + + Sensitive outputs are returned by the API with ``null`` values. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-xxxxxxxx"``). + options: Pagination options, as a :class:`StateVersionOutputsListOptions`. + + Returns: + A single-use ``Iterator[StateVersionOutput]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + ValueError: If ``workspace_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import StateVersionOutputsListOptions + >>> outputs = client.state_version_outputs.read_current( + ... "ws-123", StateVersionOutputsListOptions(page_size=5) + ... ) + >>> for output in outputs: + ... print(output.name) """ if not valid_string_id(workspace_id): raise ValueError("invalid workspace id") diff --git a/src/pytfe/resources/state_versions.py b/src/pytfe/resources/state_versions.py index 908288b0..d98ef4eb 100644 --- a/src/pytfe/resources/state_versions.py +++ b/src/pytfe/resources/state_versions.py @@ -73,9 +73,25 @@ def _encode_query(params: dict[str, Any]) -> str: def list( self, options: StateVersionListOptions | None = None ) -> Iterator[StateVersion]: - """ - GET /state-versions - Accepts filters for organization and workspace and standard pagination. + """List state versions with optional organization and workspace filters. + + Args: + options: Pagination and filter options, as a + :class:`StateVersionListOptions`. + + Returns: + A single-use ``Iterator[StateVersion]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import StateVersionListOptions + >>> for state_version in client.state_versions.list( + ... StateVersionListOptions(organization="my-org", workspace="api") + ... ): + ... print(state_version.id, state_version.serial) """ params = options.model_dump(by_alias=True, exclude_none=True) if options else {} path = f"/api/v2/state-versions{self._encode_query(params)}" @@ -85,7 +101,22 @@ def list( yield attach_jsonapi(StateVersion.model_validate(attrs), d) def read(self, state_version_id: str) -> StateVersion: - """Read a state version by ID.""" + """Read a state version by its ID. + + Args: + state_version_id: The state version ID (e.g. ``"sv-xxxxxxxx"``). + + Returns: + The :class:`StateVersion`. + + Raises: + ValueError: If ``state_version_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> state_version = client.state_versions.read("sv-read-1") + >>> print(state_version.status) + """ if not valid_string_id(state_version_id): raise ValueError("invalid state version id") @@ -106,7 +137,26 @@ def read(self, state_version_id: str) -> StateVersion: def read_with_options( self, state_version_id: str, options: StateVersionReadOptions ) -> StateVersion: - """Read a state version with include options (?include=outputs,run,created_by,...).""" + """Read a state version by its ID with included relationships. + + Args: + state_version_id: The state version ID (e.g. ``"sv-xxxxxxxx"``). + options: Include options, as a :class:`StateVersionReadOptions`. + + Returns: + The :class:`StateVersion`. + + Raises: + ValueError: If ``state_version_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import StateVersionIncludeOpt, StateVersionReadOptions + >>> state_version = client.state_versions.read_with_options( + ... "sv-read-2", + ... StateVersionReadOptions(include=[StateVersionIncludeOpt.OUTPUTS]), + ... ) + """ if not valid_string_id(state_version_id): raise ValueError("invalid state version id") @@ -131,7 +181,22 @@ def read_with_options( ) def read_current(self, workspace_id: str) -> StateVersion: - """Read the current state version for a workspace.""" + """Read the current state version for a workspace. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-xxxxxxxx"``). + + Returns: + The :class:`StateVersion`. + + Raises: + ValueError: If ``workspace_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> state_version = client.state_versions.read_current("ws-123") + >>> print(state_version.id, state_version.serial) + """ if not valid_string_id(workspace_id): raise ValueError("invalid workspace id") @@ -154,7 +219,31 @@ def read_current(self, workspace_id: str) -> StateVersion: def read_current_with_options( self, workspace_id: str, options: StateVersionCurrentOptions ) -> StateVersion: - """Read the current state version with include options.""" + """Read the current state version with included relationships. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-xxxxxxxx"``). + options: Include options, as a :class:`StateVersionCurrentOptions`. + + Returns: + The :class:`StateVersion`. + + Raises: + ValueError: If ``workspace_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import ( + ... StateVersionCurrentOptions, + ... StateVersionIncludeOpt, + ... ) + >>> state_version = client.state_versions.read_current_with_options( + ... "ws-123", + ... StateVersionCurrentOptions( + ... include=[StateVersionIncludeOpt.CREATED_BY] + ... ), + ... ) + """ if not valid_string_id(workspace_id): raise ValueError("invalid workspace id") @@ -191,7 +280,31 @@ def create( *, organization: str | None = None, ) -> StateVersion: - """Create a state-version record (returns hosted upload URLs if content omitted).""" + """Create a state-version record for a workspace. + + Create with ``serial`` and ``md5`` and omit inline state to receive hosted + upload URLs for the signed upload flow. + + Args: + workspace: The workspace ID (e.g. ``"ws-xxxxxxxx"``) or workspace name. + options: State version attributes, as a :class:`StateVersionCreateOptions`. + organization: The organization name (e.g. ``"my-org"``), required when + ``workspace`` is a workspace name instead of an ID. + + Returns: + The :class:`StateVersion`. + + Raises: + ValueError: If the create options contain no attributes. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import StateVersionCreateOptions + >>> state_version = client.state_versions.create( + ... "ws-123", + ... StateVersionCreateOptions(serial=10, md5="abc123"), + ... ) + """ ws_id = self._resolve_workspace_id(workspace, organization) attrs = options.model_dump(by_alias=True, exclude_none=True) @@ -221,14 +334,41 @@ def upload( options: StateVersionCreateOptions, organization: str | None = None, ) -> StateVersion: - """ - Create a state version and upload state bytes to signed Archivist URLs. + """Create a state version and upload state bytes to hosted URLs. This mirrors Terraform's recommended workflow: - 1. POST /workspaces/:id/state-versions with serial+md5 and no inline state - 2. PUT raw state bytes to hosted-state-upload-url - 3. Optional PUT JSON state bytes to hosted-json-state-upload-url - 4. Read the state version again and return the refreshed object + + 1. POST ``/workspaces/:id/state-versions`` with ``serial`` and ``md5`` and + no inline state. + 2. PUT raw state bytes to ``hosted-state-upload-url``. + 3. Optionally PUT JSON state bytes to ``hosted-json-state-upload-url``. + 4. Read the state version again and return the refreshed object. + + Args: + workspace: The workspace ID (e.g. ``"ws-xxxxxxxx"``) or workspace name. + raw_state: The raw ``.tfstate`` bytes to upload. + raw_json_state: The optional raw JSON state bytes to upload. + options: State version attributes, as a :class:`StateVersionCreateOptions`; + omit ``state`` and ``json_state`` when using hosted upload URLs. + organization: The organization name (e.g. ``"my-org"``), required when + ``workspace`` is a workspace name instead of an ID. + + Returns: + The :class:`StateVersion`. + + Raises: + ValueError: If ``raw_state`` is missing or inline state fields are set. + ErrStateVersionUploadNotSupported: If the server does not support the + hosted upload flow or omits a required hosted upload URL. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import StateVersionCreateOptions + >>> state_version = client.state_versions.upload( + ... "ws-123", + ... raw_state=b"{}", + ... options=StateVersionCreateOptions(serial=10, md5="abc123"), + ... ) """ if raw_state is None: raise ValueError("raw_state is required") @@ -274,11 +414,26 @@ def upload( return self.read(sv.id) def download(self, state_version_id: str) -> bytes: - """ - Download the raw state file bytes for a specific state version. + """Download the raw state file bytes for a state version. + + HCP Terraform returns a signed storage URL in + ``hosted-state-download-url``; the SDK follows that URL for you and keeps + sending the bearer token on the redirected request. + + Args: + state_version_id: The state version ID (e.g. ``"sv-xxxxxxxx"``). + + Returns: + The raw bytes (the SDK follows the storage/redirect URL for you). - HCP Terraform returns a signed blob URL in the state-version attributes - called 'hosted-state-download-url'. We must fetch that URL directly. + Raises: + ValueError: If ``state_version_id`` is not a valid resource ID. + NotFound: If no hosted state download URL is available. + TFEError: If the API request fails. + + Example: + >>> state_bytes = client.state_versions.download("sv-dl-1") + >>> print(len(state_bytes)) """ if not valid_string_id(state_version_id): raise ValueError("invalid state version id") @@ -304,7 +459,27 @@ def download(self, state_version_id: str) -> bytes: return resp.content def download_current(self, workspace_id: str) -> bytes: - """Download the current state for a workspace.""" + """Download the current raw state file bytes for a workspace. + + The SDK reads the workspace's current state version, follows its hosted + storage URL for you, and keeps sending the bearer token on the redirected + request. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-xxxxxxxx"``). + + Returns: + The raw bytes (the SDK follows the storage/redirect URL for you). + + Raises: + ValueError: If ``workspace_id`` is not a valid resource ID. + NotFound: If no hosted state download URL is available. + TFEError: If the API request fails. + + Example: + >>> state_bytes = client.state_versions.download_current("ws-123") + >>> print(len(state_bytes)) + """ if not valid_string_id(workspace_id): raise ValueError("invalid workspace id") @@ -331,7 +506,28 @@ def list_outputs( state_version_id: str, options: StateVersionOutputsListOptions | None = None, ) -> Iterator[StateVersionOutput]: - """List outputs for a given state version (paged).""" + """List outputs for a state version. + + Args: + state_version_id: The state version ID (e.g. ``"sv-xxxxxxxx"``). + options: Pagination options, as a :class:`StateVersionOutputsListOptions`. + + Returns: + A single-use ``Iterator[StateVersionOutput]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + ValueError: If ``state_version_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import StateVersionOutputsListOptions + >>> outputs = client.state_versions.list_outputs( + ... "sv-outputs-1", StateVersionOutputsListOptions(page_size=5) + ... ) + >>> for output in outputs: + ... print(output.name, output.value) + """ if not valid_string_id(state_version_id): raise ValueError("invalid state version id") @@ -354,6 +550,21 @@ def list_outputs( # ---------------------------- def soft_delete_backing_data(self, state_version_id: str) -> None: + """Soft-delete the backing data for a state version. + + Args: + state_version_id: The state version ID (e.g. ``"sv-xxxxxxxx"``). + + Returns: + None. + + Raises: + ValueError: If ``state_version_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.state_versions.soft_delete_backing_data("sv-123") + """ if not valid_string_id(state_version_id): raise ValueError("invalid state version id") self.t.request( @@ -363,6 +574,21 @@ def soft_delete_backing_data(self, state_version_id: str) -> None: return None def restore_backing_data(self, state_version_id: str) -> None: + """Restore soft-deleted backing data for a state version. + + Args: + state_version_id: The state version ID (e.g. ``"sv-xxxxxxxx"``). + + Returns: + None. + + Raises: + ValueError: If ``state_version_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.state_versions.restore_backing_data("sv-123") + """ if not valid_string_id(state_version_id): raise ValueError("invalid state version id") self.t.request( @@ -372,6 +598,21 @@ def restore_backing_data(self, state_version_id: str) -> None: return None def permanently_delete_backing_data(self, state_version_id: str) -> None: + """Permanently delete backing data for a state version. + + Args: + state_version_id: The state version ID (e.g. ``"sv-xxxxxxxx"``). + + Returns: + None. + + Raises: + ValueError: If ``state_version_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.state_versions.permanently_delete_backing_data("sv-123") + """ if not valid_string_id(state_version_id): raise ValueError("invalid state version id") self.t.request( @@ -388,8 +629,24 @@ def rollback( """Roll a workspace back to a previous state version. Duplicates the named state version and sets the copy as the workspace's - current state version. The workspace must be locked by the caller - before invoking this operation, otherwise the API returns 409. + current state version. The workspace must be locked by the caller before + invoking this operation, otherwise the API returns 409. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-xxxxxxxx"``). + rollback_state_version_id: The state version ID to roll back to + (e.g. ``"sv-xxxxxxxx"``). + + Returns: + The :class:`StateVersion`. + + Raises: + ValueError: If either ID is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> state_version = client.state_versions.rollback("ws-123", "sv-1") + >>> print(state_version.id) """ if not valid_string_id(workspace_id): raise ValueError("invalid workspace id") diff --git a/src/pytfe/resources/subscription.py b/src/pytfe/resources/subscription.py new file mode 100644 index 00000000..6ef0b79c --- /dev/null +++ b/src/pytfe/resources/subscription.py @@ -0,0 +1,96 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Read an organization's HCP Terraform subscription. + +- ``GET /api/v2/organizations/{org}/subscription`` — the org's subscription +- ``GET /api/v2/subscriptions/{id}`` — a subscription by id + +The subscription links to a feature set (pass nothing special; the feature set +is returned in the document's ``included`` array and reachable via +``subscription.related("feature-set")``). Subscriptions are HCP Terraform only. + +API reference: +https://developer.hashicorp.com/terraform/cloud-docs/api-docs/subscriptions +""" + +from __future__ import annotations + +from typing import Any + +from .._jsonapi import attach_jsonapi +from ..errors import InvalidOrgError, InvalidSubscriptionIDError +from ..models.subscription import Subscription +from ..utils import valid_string_id +from ._base import _Service + + +def _rel_id(relationships: dict[str, Any], name: str) -> str | None: + data = (relationships.get(name) or {}).get("data") + return data.get("id") if isinstance(data, dict) else None + + +def _subscription_from( + data: dict[str, Any], included: list[dict[str, Any]] | None = None +) -> Subscription: + """Parse a JSON:API subscriptions resource into a Subscription.""" + attrs = dict(data.get("attributes") or {}) + attrs["id"] = data.get("id") + rels = data.get("relationships") or {} + if org := _rel_id(rels, "organization"): + attrs["organization-id"] = org + if fs := _rel_id(rels, "feature-set"): + attrs["feature-set-id"] = fs + if ba := _rel_id(rels, "billing-account"): + attrs["billing-account-id"] = ba + return attach_jsonapi(Subscription.model_validate(attrs), data, included) + + +class Subscriptions(_Service): + """Service for reading organization subscriptions (HCP Terraform only).""" + + def read_for_organization(self, organization: str) -> Subscription: + """Read the subscription for an organization. + + Args: + organization: The organization name (e.g. ``"my-org"``). + + Returns: + The :class:`Subscription`. + + Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> subscription = client.subscriptions.read_for_organization("my-org") + >>> print(subscription.is_active) + """ + if not valid_string_id(organization): + raise InvalidOrgError() + r = self.t.request("GET", f"/api/v2/organizations/{organization}/subscription") + body = r.json() + return _subscription_from(body["data"], body.get("included")) + + def read(self, subscription_id: str) -> Subscription: + """Read a subscription by its ID. + + Args: + subscription_id: The subscription ID (e.g. ``"sub-xxxxxxxx"``). + + Returns: + The :class:`Subscription`. + + Raises: + InvalidSubscriptionIDError: If ``subscription_id`` is not valid. + TFEError: If the API request fails. + + Example: + >>> subscription = client.subscriptions.read("sub-kyjptCZYXQ6amEVu") + >>> print(subscription.runs_ceiling) + """ + if not valid_string_id(subscription_id): + raise InvalidSubscriptionIDError() + r = self.t.request("GET", f"/api/v2/subscriptions/{subscription_id}") + body = r.json() + return _subscription_from(body["data"], body.get("included")) diff --git a/src/pytfe/resources/task_result.py b/src/pytfe/resources/task_result.py index d8321af6..605ba928 100644 --- a/src/pytfe/resources/task_result.py +++ b/src/pytfe/resources/task_result.py @@ -13,6 +13,22 @@ class TaskResults(_Service): def read(self, task_result_id: str) -> TaskResult: + """Read a task result by its ID. + + Args: + task_result_id: The task result ID (e.g. ``"taskrs-abc123"``). + + Returns: + The :class:`TaskResult`. + + Raises: + ValueError: If ``task_result_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> result = client.task_results.read("taskrs-abc123") + >>> print(result.status) + """ if not valid_string_id(task_result_id): raise ValueError("Invalid task_result_id") diff --git a/src/pytfe/resources/task_stage.py b/src/pytfe/resources/task_stage.py index 6b85209f..4a0d7d38 100644 --- a/src/pytfe/resources/task_stage.py +++ b/src/pytfe/resources/task_stage.py @@ -48,6 +48,23 @@ def _parse_task_stage( def read( self, task_stage_id: str, options: TaskStageReadOptions | None = None ) -> TaskStage: + """Read a task stage by ID. + + Args: + task_stage_id: The task stage ID (e.g. ``"ts-xxxxxxxx"``). + options: Optional include settings, as a :class:`TaskStageReadOptions`. + + Returns: + The :class:`TaskStage`. + + Raises: + InvalidTaskStageIDError: If ``task_stage_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> stage = client.task_stages.read("ts-123") + >>> print(stage.status) + """ if not valid_string_id(task_stage_id): raise InvalidTaskStageIDError() @@ -69,6 +86,24 @@ def read( def list( self, run_id: str, options: TaskStageListOptions | None = None ) -> Iterator[TaskStage]: + """List task stages for a run. + + Args: + run_id: The run ID (e.g. ``"run-xxxxxxxx"``). + options: Optional pagination settings, as a :class:`TaskStageListOptions`. + + Returns: + A single-use ``Iterator[TaskStage]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + InvalidRunIDError: If ``run_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> for stage in client.task_stages.list("run-CZcmD7eagjhyX0vN"): + ... print(stage.id, stage.stage) + """ if not valid_string_id(run_id): raise InvalidRunIDError() @@ -84,9 +119,24 @@ def override( task_stage_id: str, comment: str | None = None, ) -> TaskStage: - """ + """Override a task stage for a run. + **Note: This function is still in BETA and subject to change.** - Override a task stage for a run. + + Args: + task_stage_id: The task stage ID (e.g. ``"ts-xxxxxxxx"``). + comment: Optional override comment. + + Returns: + The :class:`TaskStage`. + + Raises: + InvalidTaskStageIDError: If ``task_stage_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> stage = client.task_stages.override("ts-123", comment="Approved") + >>> print(stage.status) """ if not valid_string_id(task_stage_id): raise InvalidTaskStageIDError() diff --git a/src/pytfe/resources/team.py b/src/pytfe/resources/team.py index febc9e2e..7db25cec 100644 --- a/src/pytfe/resources/team.py +++ b/src/pytfe/resources/team.py @@ -26,7 +26,25 @@ class Teams(_Service): def list( self, organization: str, options: TeamListOptions | None = None ) -> Iterator[Team]: - """List all teams in the given organization.""" + """List all teams in the given organization. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: Pagination, filter, and include options, as a + :class:`TeamListOptions`. + + Returns: + A single-use ``Iterator[Team]``. Wrap with ``list(...)`` to materialize + the results or iterate more than once. + + Raises: + ValueError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> for team in client.teams.list("my-org"): + ... print(team.id, team.name) + """ if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) params = ( @@ -57,7 +75,25 @@ def _team_from( return attach_jsonapi(Team.model_validate(attrs), data, included) def create(self, organization: str, options: TeamCreateOptions) -> Team: - """Create a new team in the given organization.""" + """Create a new team in the given organization. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: Team creation settings, as a :class:`TeamCreateOptions`. + + Returns: + The :class:`Team`. + + Raises: + ValueError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import TeamCreateOptions + >>> team = client.teams.create( + ... "my-org", TeamCreateOptions(name="platform") + ... ) + """ if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) attributes = options.model_dump(by_alias=True, exclude_none=True) @@ -71,7 +107,25 @@ def create(self, organization: str, options: TeamCreateOptions) -> Team: return self._team_from(data) def update(self, team_id: str, options: TeamUpdateOptions) -> Team: - """Update a team by its ID.""" + """Update a team by its ID. + + Args: + team_id: The team ID (e.g. ``"team-xxxxxxxx"``). + options: Team update settings, as a :class:`TeamUpdateOptions`. + + Returns: + The :class:`Team`. + + Raises: + InvalidTeamIDError: If ``team_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import TeamUpdateOptions + >>> team = client.teams.update( + ... "team-789", TeamUpdateOptions(name="platform-admins") + ... ) + """ if not valid_string_id(team_id): raise InvalidTeamIDError() attributes = options.model_dump(by_alias=True, exclude_none=True) @@ -85,7 +139,23 @@ def update(self, team_id: str, options: TeamUpdateOptions) -> Team: return self._team_from(data) def read(self, team_id: str, options: TeamReadOptions | None = None) -> Team: - """Read a single team by its ID.""" + """Read a single team by its ID. + + Args: + team_id: The team ID (e.g. ``"team-xxxxxxxx"``). + options: Include options, as a :class:`TeamReadOptions`. + + Returns: + The :class:`Team`. + + Raises: + InvalidTeamIDError: If ``team_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> team = client.teams.read("team-789") + >>> print(team.name) + """ if not valid_string_id(team_id): raise InvalidTeamIDError() params: dict[str, str] = {} @@ -100,7 +170,21 @@ def read(self, team_id: str, options: TeamReadOptions | None = None) -> Team: return self._team_from(payload.get("data", {}), payload.get("included")) def delete(self, team_id: str) -> None: - """Delete a team by its ID.""" + """Delete a team by its ID. + + Args: + team_id: The team ID (e.g. ``"team-xxxxxxxx"``). + + Returns: + None. + + Raises: + InvalidTeamIDError: If ``team_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.teams.delete("team-789") + """ if not valid_string_id(team_id): raise InvalidTeamIDError() self.t.request( @@ -114,7 +198,23 @@ def delete(self, team_id: str) -> None: # ------------------------------------------------------------------ def add_users(self, team_id: str, usernames: builtins.list[str]) -> None: - """Add users to a team by username.""" + """Add users to a team by username. + + Args: + team_id: The team ID (e.g. ``"team-xxxxxxxx"``). + usernames: Usernames to add to the team (e.g. ``["alice", "bob"]``). + + Returns: + None. + + Raises: + InvalidTeamIDError: If ``team_id`` is not a valid resource ID. + ValueError: If ``usernames`` is empty or contains blank values. + TFEError: If the API request fails. + + Example: + >>> client.teams.add_users("team-789", ["alice", "bob"]) + """ if not valid_string_id(team_id): raise InvalidTeamIDError() if not usernames: @@ -130,7 +230,23 @@ def add_users(self, team_id: str, usernames: builtins.list[str]) -> None: return None def remove_users(self, team_id: str, usernames: builtins.list[str]) -> None: - """Remove users from a team by username.""" + """Remove users from a team by username. + + Args: + team_id: The team ID (e.g. ``"team-xxxxxxxx"``). + usernames: Usernames to remove from the team (e.g. ``["alice"]``). + + Returns: + None. + + Raises: + InvalidTeamIDError: If ``team_id`` is not a valid resource ID. + ValueError: If ``usernames`` is empty or contains blank values. + TFEError: If the API request fails. + + Example: + >>> client.teams.remove_users("team-789", ["alice"]) + """ if not valid_string_id(team_id): raise InvalidTeamIDError() if not usernames: @@ -148,7 +264,25 @@ def remove_users(self, team_id: str, usernames: builtins.list[str]) -> None: def add_organization_memberships( self, team_id: str, organization_membership_ids: builtins.list[str] ) -> None: - """Add users to a team by organization membership id.""" + """Add organization memberships to a team by membership ID. + + Args: + team_id: The team ID (e.g. ``"team-xxxxxxxx"``). + organization_membership_ids: Organization membership IDs to add + (e.g. ``["ou-xxxxxxxx"]``). + + Returns: + None. + + Raises: + InvalidTeamIDError: If ``team_id`` is not a valid resource ID. + ValueError: If no organization membership IDs are supplied or one is + invalid. + TFEError: If the API request fails. + + Example: + >>> client.teams.add_organization_memberships("team-789", ["ou-123"]) + """ if not valid_string_id(team_id): raise InvalidTeamIDError() if not organization_membership_ids: @@ -171,7 +305,25 @@ def add_organization_memberships( def remove_organization_memberships( self, team_id: str, organization_membership_ids: builtins.list[str] ) -> None: - """Remove users from a team by organization membership id.""" + """Remove organization memberships from a team by membership ID. + + Args: + team_id: The team ID (e.g. ``"team-xxxxxxxx"``). + organization_membership_ids: Organization membership IDs to remove + (e.g. ``["ou-xxxxxxxx"]``). + + Returns: + None. + + Raises: + InvalidTeamIDError: If ``team_id`` is not a valid resource ID. + ValueError: If no organization membership IDs are supplied or one is + invalid. + TFEError: If the API request fails. + + Example: + >>> client.teams.remove_organization_memberships("team-789", ["ou-123"]) + """ if not valid_string_id(team_id): raise InvalidTeamIDError() if not organization_membership_ids: @@ -194,11 +346,23 @@ def remove_organization_memberships( def list_users(self, team_id: str) -> Iterator[User]: """List the users that belong to a team. - Implemented via ``GET /teams/{id}?include=users`` — the API has no - dedicated paginated endpoint for team users, so all results arrive - in a single response. The signature still returns an iterator to - stay consistent with the other ``list_*`` methods in the SDK; wrap - the result in ``list(...)`` if you need a materialized list. + Implemented via ``GET /teams/{id}?include=users`` because the API has no + dedicated paginated endpoint for team users. + + Args: + team_id: The team ID (e.g. ``"team-xxxxxxxx"``). + + Returns: + A single-use ``Iterator[User]``. Wrap with ``list(...)`` to materialize + the results or iterate more than once. + + Raises: + InvalidTeamIDError: If ``team_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> for user in client.teams.list_users("team-789"): + ... print(user.username) """ if not valid_string_id(team_id): raise InvalidTeamIDError() @@ -226,10 +390,29 @@ def list_organization_memberships( ) -> Iterator[OrganizationMembership]: """List the organization memberships that belong to a team. - Uses the dedicated paginated endpoint - ``GET /teams/{id}/relationships/organization-memberships`` so - callers get server-side pagination, filtering by status / - service-account flag, and sort. + Uses the dedicated paginated relationship endpoint and supports filtering + by status, service-account flag, and sort. + + Args: + team_id: The team ID (e.g. ``"team-xxxxxxxx"``). + status: Optional membership status filter (e.g. ``"active"``). + is_service_account: Optional service-account filter. + sort: Optional sort expression (e.g. ``"email"``). + + Returns: + A single-use ``Iterator[OrganizationMembership]``. Wrap with + ``list(...)`` to materialize the results or iterate more than once. + + Raises: + InvalidTeamIDError: If ``team_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> memberships = client.teams.list_organization_memberships( + ... "team-789", status="active" + ... ) + >>> for membership in memberships: + ... print(membership.id) """ if not valid_string_id(team_id): raise InvalidTeamIDError() diff --git a/src/pytfe/resources/team_project_access.py b/src/pytfe/resources/team_project_access.py index a15561a3..b5f020e3 100644 --- a/src/pytfe/resources/team_project_access.py +++ b/src/pytfe/resources/team_project_access.py @@ -28,7 +28,27 @@ class TeamProjectAccesses(_Service): def add(self, options: TeamProjectAccessAddOptions) -> TeamProjectAccess: - """Add a team access for a project.""" + """Add team access for a project. + + Args: + options: The team, project, and permissions, as a + :class:`TeamProjectAccessAddOptions`. + + Returns: + The created :class:`TeamProjectAccess`. + + Raises: + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import Project, Team, TeamProjectAccessAddOptions + >>> options = TeamProjectAccessAddOptions( + ... access="read", team=Team(id="team-1"), project=Project(id="prj-1") + ... ) + >>> access = client.team_project_accesses.add( + ... options + ... ) + """ attributes = options.model_dump( by_alias=True, exclude_none=True, exclude={"team", "project"} ) @@ -116,7 +136,31 @@ def _team_project_access_from(self, data: dict) -> TeamProjectAccess: def update( self, team_project_access_id: str, options: TeamProjectAccessUpdateOptions ) -> TeamProjectAccess: - """Update a team access for a project.""" + """Update team access for a project. + + Args: + team_project_access_id: The team project access ID (e.g. + ``"tprj-xxxxxxxx"``). + options: The permission fields to change, as a + :class:`TeamProjectAccessUpdateOptions`. + + Returns: + The updated :class:`TeamProjectAccess`. + + Raises: + InvalidTeamProjectAccessIDError: If ``team_project_access_id`` is not a + valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import TeamProjectAccessUpdateOptions + >>> options = TeamProjectAccessUpdateOptions( + ... access="write" + ... ) + >>> access = client.team_project_accesses.update( + ... "tprj-123", options + ... ) + """ if not valid_string_id(team_project_access_id): raise InvalidTeamProjectAccessIDError() attributes = options.model_dump(by_alias=True, exclude_none=True) @@ -135,7 +179,24 @@ def update( return self._team_project_access_from(data) def read(self, team_project_access_id: str) -> TeamProjectAccess: - """Read a team access for a project.""" + """Read team access for a project. + + Args: + team_project_access_id: The team project access ID (e.g. + ``"tprj-xxxxxxxx"``). + + Returns: + The :class:`TeamProjectAccess`. + + Raises: + InvalidTeamProjectAccessIDError: If ``team_project_access_id`` is not a + valid resource ID. + TFEError: If the API request fails. + + Example: + >>> access = client.team_project_accesses.read("tprj-123") + >>> print(access.access) + """ if not valid_string_id(team_project_access_id): raise InvalidTeamProjectAccessIDError() r = self.t.request( @@ -148,14 +209,49 @@ def read(self, team_project_access_id: str) -> TeamProjectAccess: def list( self, options: TeamProjectAccessListOptions ) -> Iterator[TeamProjectAccess]: - """List team accesses for projects.""" + """List team accesses for projects. + + Args: + options: Required filters and pagination, as a + :class:`TeamProjectAccessListOptions`. + + Returns: + A single-use ``Iterator[TeamProjectAccess]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import TeamProjectAccessListOptions + >>> for access in client.team_project_accesses.list( + ... TeamProjectAccessListOptions(project_id="prj-123") + ... ): + ... print(access.id) + """ params = options.model_dump(by_alias=True, exclude_none=True) path = "/api/v2/team-projects" for item in self._list(path, params=params): yield self._team_project_access_from(item) def remove(self, team_project_access_id: str) -> None: - """Remove a team access for a project.""" + """Remove team access for a project. + + Args: + team_project_access_id: The team project access ID (e.g. + ``"tprj-xxxxxxxx"``). + + Returns: + None. + + Raises: + InvalidTeamProjectAccessIDError: If ``team_project_access_id`` is not a + valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.team_project_accesses.remove("tprj-123") + """ if not valid_string_id(team_project_access_id): raise InvalidTeamProjectAccessIDError() self.t.request( diff --git a/src/pytfe/resources/team_token.py b/src/pytfe/resources/team_token.py index 861c4177..d7736d46 100644 --- a/src/pytfe/resources/team_token.py +++ b/src/pytfe/resources/team_token.py @@ -25,9 +25,21 @@ class TeamTokens(_Service): """Service for managing team authentication tokens.""" def create(self, team_id: str) -> TeamToken: - """ - Create a new team token using the legacy creation behavior, which creates a token without a description - or regenerates the existing, descriptionless token. + """Create or regenerate a legacy descriptionless team token. + + Args: + team_id: The team ID (e.g. ``"team-xxxxxxxx"``). + + Returns: + The created :class:`TeamToken`. + + Raises: + InvalidTeamIDError: If ``team_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> token = client.team_tokens.create("team-8U4yZ6bYbDZYQ1GH") + >>> print(token.token) """ return self.create_with_options(team_id=team_id) @@ -36,10 +48,25 @@ def create_with_options( team_id: str, options: TeamTokenCreateOptions | None = None, ) -> TeamToken: - """ - CreateWithOptions creates a team token, with options. If no description is provided, it uses the legacy - creation behavior, which regenerates the descriptionless token if it already exists. Otherwise, it create - a new token with the given unique description, allowing for the creation of multiple team tokens. + """Create a team token with optional description and expiry. + + Args: + team_id: The team ID (e.g. ``"team-xxxxxxxx"``). + options: Optional token attributes, as a :class:`TeamTokenCreateOptions`. + + Returns: + The created :class:`TeamToken`. + + Raises: + InvalidTeamIDError: If ``team_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import TeamTokenCreateOptions + >>> token = client.team_tokens.create_with_options( + ... "team-8U4yZ6bYbDZYQ1GH", + ... TeamTokenCreateOptions(description="CI deploy token"), + ... ) """ if not valid_string_id(team_id): raise InvalidTeamIDError() @@ -73,7 +100,22 @@ def create_with_options( return self._team_token_from(data) def read(self, team_id: str) -> TeamToken: - """Read the legacy (descriptionless) team token by team ID.""" + """Read the legacy descriptionless team token by team ID. + + Args: + team_id: The team ID (e.g. ``"team-xxxxxxxx"``). + + Returns: + The :class:`TeamToken`. + + Raises: + InvalidTeamIDError: If ``team_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> token = client.team_tokens.read("team-8U4yZ6bYbDZYQ1GH") + >>> print(token.id) + """ if not valid_string_id(team_id): raise InvalidTeamIDError() r = self.t.request("GET", path=f"/api/v2/teams/{team_id}/authentication-token") @@ -81,7 +123,22 @@ def read(self, team_id: str) -> TeamToken: return self._team_token_from(data) def read_by_id(self, token_id: str) -> TeamToken: - """Read a team token by its token ID.""" + """Read a team token by its token ID. + + Args: + token_id: The authentication token ID (e.g. ``"at-xxxxxxxx"``). + + Returns: + The :class:`TeamToken`. + + Raises: + InvalidTokenIDError: If ``token_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> token = client.team_tokens.read_by_id("at-abc123") + >>> print(token.description) + """ if not valid_string_id(token_id): raise InvalidTokenIDError() r = self.t.request("GET", path=f"/api/v2/authentication-tokens/{token_id}") @@ -93,7 +150,28 @@ def list( organization: str, options: TeamTokenListOptions | None = None, ) -> Iterator[TeamToken]: - """List all team tokens for the given organization.""" + """List team tokens in an organization. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: Optional filters and page size, as a :class:`TeamTokenListOptions`. + + Returns: + A single-use ``Iterator[TeamToken]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import TeamTokenListOptions + >>> tokens = client.team_tokens.list( + ... "my-org", TeamTokenListOptions(query="platform") + ... ) + >>> for token in tokens: + ... print(token.id, token.description) + """ if not valid_string_id(organization): raise InvalidOrgError() path = f"/api/v2/organizations/{organization}/team-tokens" @@ -109,14 +187,42 @@ def list( yield self._team_token_from(item) def delete(self, team_id: str) -> None: - """Delete the legacy team token by team ID.""" + """Delete the legacy descriptionless team token by team ID. + + Args: + team_id: The team ID (e.g. ``"team-xxxxxxxx"``). + + Returns: + None. + + Raises: + InvalidTeamIDError: If ``team_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.team_tokens.delete("team-8U4yZ6bYbDZYQ1GH") + """ if not valid_string_id(team_id): raise InvalidTeamIDError() self.t.request("DELETE", path=f"/api/v2/teams/{team_id}/authentication-token") return None def delete_by_id(self, token_id: str) -> None: - """Delete a team token by its token ID.""" + """Delete a team token by its token ID. + + Args: + token_id: The authentication token ID (e.g. ``"at-xxxxxxxx"``). + + Returns: + None. + + Raises: + InvalidTokenIDError: If ``token_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.team_tokens.delete_by_id("at-abc123") + """ if not valid_string_id(token_id): raise InvalidTokenIDError() self.t.request("DELETE", path=f"/api/v2/authentication-tokens/{token_id}") diff --git a/src/pytfe/resources/team_workspace_access.py b/src/pytfe/resources/team_workspace_access.py index cd0afdee..8654de83 100644 --- a/src/pytfe/resources/team_workspace_access.py +++ b/src/pytfe/resources/team_workspace_access.py @@ -46,7 +46,23 @@ class TeamWorkspaceAccesses(_Service): """Manage team access grants on workspaces (`/api/v2/team-workspaces`).""" def list(self, workspace_id: str) -> Iterator[TeamWorkspaceAccess]: - """List team access grants for a workspace.""" + """List team access grants for a workspace. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-xxxxxxxx"``). + + Returns: + A single-use ``Iterator[TeamWorkspaceAccess]``. Wrap with ``list(...)`` + to materialize the results or iterate more than once. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> for grant in client.team_workspace_accesses.list("ws-123"): + ... print(grant.id, grant.team_id, grant.access) + """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() path = "/api/v2/team-workspaces" @@ -55,14 +71,55 @@ def list(self, workspace_id: str) -> Iterator[TeamWorkspaceAccess]: yield _parse(item) def read(self, team_workspace_access_id: str) -> TeamWorkspaceAccess: - """Read a single team-workspace access grant by id.""" + """Read a single team-workspace access grant by ID. + + Args: + team_workspace_access_id: The team-workspace access ID + (e.g. ``"twsa-xxxxxxxx"``). + + Returns: + The :class:`TeamWorkspaceAccess`. + + Raises: + InvalidTeamWorkspaceAccessIDError: If ``team_workspace_access_id`` is not + a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> grant = client.team_workspace_accesses.read("twsa-123") + >>> print(grant.workspace_id) + """ if not valid_string_id(team_workspace_access_id): raise InvalidTeamWorkspaceAccessIDError() r = self.t.request("GET", f"/api/v2/team-workspaces/{team_workspace_access_id}") return _parse((r.json() or {}).get("data") or {}) def add(self, options: TeamWorkspaceAccessAddOptions) -> TeamWorkspaceAccess: - """Add a team access grant to a workspace.""" + """Add a team access grant to a workspace. + + Args: + options: Team and workspace access settings, as a + :class:`TeamWorkspaceAccessAddOptions`. + + Returns: + The :class:`TeamWorkspaceAccess`. + + Raises: + InvalidTeamIDError: If ``options.team_id`` is not a valid resource ID. + InvalidWorkspaceIDError: If ``options.workspace_id`` is not a valid + resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import TeamWorkspaceAccessAddOptions + >>> from pytfe.models import TeamWorkspaceAccessType + >>> grant = client.team_workspace_accesses.add( + ... TeamWorkspaceAccessAddOptions( + ... team_id="team-123", workspace_id="ws-123", + ... access=TeamWorkspaceAccessType.READ, + ... ) + ... ) + """ if not valid_string_id(options.team_id): raise InvalidTeamIDError() if not valid_string_id(options.workspace_id): @@ -95,7 +152,32 @@ def update( team_workspace_access_id: str, options: TeamWorkspaceAccessUpdateOptions, ) -> TeamWorkspaceAccess: - """Update an existing team-workspace access grant.""" + """Update an existing team-workspace access grant. + + Args: + team_workspace_access_id: The team-workspace access ID + (e.g. ``"twsa-xxxxxxxx"``). + options: Access update settings, as a + :class:`TeamWorkspaceAccessUpdateOptions`. + + Returns: + The :class:`TeamWorkspaceAccess`. + + Raises: + InvalidTeamWorkspaceAccessIDError: If ``team_workspace_access_id`` is not + a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import TeamWorkspaceAccessType + >>> from pytfe.models import TeamWorkspaceAccessUpdateOptions + >>> grant = client.team_workspace_accesses.update( + ... "twsa-123", + ... TeamWorkspaceAccessUpdateOptions( + ... access=TeamWorkspaceAccessType.PLAN + ... ), + ... ) + """ if not valid_string_id(team_workspace_access_id): raise InvalidTeamWorkspaceAccessIDError() attrs = _attributes_payload( @@ -116,7 +198,23 @@ def update( return _parse((r.json() or {}).get("data") or {}) def remove(self, team_workspace_access_id: str) -> None: - """Remove (delete) a team-workspace access grant.""" + """Remove a team-workspace access grant. + + Args: + team_workspace_access_id: The team-workspace access ID + (e.g. ``"twsa-xxxxxxxx"``). + + Returns: + None. + + Raises: + InvalidTeamWorkspaceAccessIDError: If ``team_workspace_access_id`` is not + a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.team_workspace_accesses.remove("twsa-123") + """ if not valid_string_id(team_workspace_access_id): raise InvalidTeamWorkspaceAccessIDError() self.t.request( diff --git a/src/pytfe/resources/user.py b/src/pytfe/resources/user.py index ab5a7e3b..8445f909 100644 --- a/src/pytfe/resources/user.py +++ b/src/pytfe/resources/user.py @@ -7,6 +7,22 @@ class Users(_Service): def read(self, user_id: str) -> User: + """Read a user by ID. + + Args: + user_id: The user ID (e.g. ``"user-xxxxxxxx"``). + + Returns: + The :class:`User`. + + Raises: + ValueError: If ``user_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> user = client.users.read("user-47qC3LmA47piVan7") + >>> print(user.username) + """ if not valid_string_id(user_id): raise ValueError("invalid user id") @@ -18,6 +34,18 @@ def read(self, user_id: str) -> User: return User(**user_data) def read_current(self) -> User: + """Read the currently authenticated user. + + Returns: + The :class:`User`. + + Raises: + TFEError: If the API request fails. + + Example: + >>> user = client.users.read_current() + >>> print(user.email) + """ r = self.t.request("GET", "/api/v2/account/details") d = r.json()["data"] attr = d.get("attributes", {}) or {} @@ -26,6 +54,24 @@ def read_current(self) -> User: return User(**user_data) def update_current(self, options: UserUpdateCurrentOptions) -> User: + """Update the currently authenticated user. + + Args: + options: The user account updates, as a + :class:`UserUpdateCurrentOptions`. + + Returns: + The :class:`User`. + + Raises: + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import UserUpdateCurrentOptions + >>> user = client.users.update_current( + ... UserUpdateCurrentOptions(username="alice") + ... ) + """ body = { "data": { "type": "users", diff --git a/src/pytfe/resources/variable.py b/src/pytfe/resources/variable.py index 3df2a6c1..841db8ed 100644 --- a/src/pytfe/resources/variable.py +++ b/src/pytfe/resources/variable.py @@ -26,7 +26,24 @@ class Variables(_Service): def list( self, workspace_id: str, options: VariableListOptions | None = None ) -> Iterator[Variable]: - """List all the variables associated with the given workspace (doesn't include variables inherited from varsets).""" + """List workspace variables, excluding variables inherited from variable sets. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-xxxxxxxx"``). + options: Reserved for future filters, as a :class:`VariableListOptions`. + + Returns: + A single-use ``Iterator[Variable]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + ValueError: If ``workspace_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> for variable in client.variables.list("ws-6fHMCom98SDXSQUv"): + ... print(variable.key, variable.category) + """ if not valid_string_id(workspace_id): raise ValueError(ERR_INVALID_WORKSPACE_ID) @@ -49,7 +66,24 @@ def list( def list_all( self, workspace_id: str, options: VariableListOptions | None = None ) -> Iterator[Variable]: - """ListAll the variables associated with the given workspace including variables inherited from varsets.""" + """List all workspace variables, including inherited variable-set variables. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-xxxxxxxx"``). + options: Reserved for future filters, as a :class:`VariableListOptions`. + + Returns: + A single-use ``Iterator[Variable]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + ValueError: If ``workspace_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> inherited = list(client.variables.list_all("ws-6fHMCom98SDXSQUv")) + >>> print(len(inherited)) + """ if not valid_string_id(workspace_id): raise ValueError(ERR_INVALID_WORKSPACE_ID) @@ -68,7 +102,29 @@ def list_all( yield Variable(**variable_data) def create(self, workspace_id: str, options: VariableCreateOptions) -> Variable: - """Create is used to create a new variable.""" + """Create a new workspace variable. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-xxxxxxxx"``). + options: The variable attributes, as a :class:`VariableCreateOptions`. + + Returns: + The created :class:`Variable`. + + Raises: + ValueError: If ``workspace_id`` is invalid, ``key`` is missing, or + ``category`` is missing. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import CategoryType, VariableCreateOptions + >>> variable = client.variables.create( + ... "ws-6fHMCom98SDXSQUv", + ... VariableCreateOptions( + ... key="TF_LOG", value="INFO", category=CategoryType.ENV + ... ), + ... ) + """ if not valid_string_id(workspace_id): raise ValueError(ERR_INVALID_WORKSPACE_ID) @@ -99,7 +155,26 @@ def create(self, workspace_id: str, options: VariableCreateOptions) -> Variable: return Variable(**variable_data) def read(self, workspace_id: str, variable_id: str) -> Variable: - """Read a variable by its ID.""" + """Read a workspace variable by its ID. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-xxxxxxxx"``). + variable_id: The variable ID (e.g. ``"var-xxxxxxxx"``). + + Returns: + The :class:`Variable`. + + Raises: + ValueError: If ``workspace_id`` or ``variable_id`` is not a valid + resource ID. + TFEError: If the API request fails. + + Example: + >>> variable = client.variables.read( + ... "ws-6fHMCom98SDXSQUv", "var-N4b1qYNSuNsPzHhM" + ... ) + >>> print(variable.key) + """ if not valid_string_id(workspace_id): raise ValueError(ERR_INVALID_WORKSPACE_ID) if not valid_string_id(variable_id): @@ -121,7 +196,30 @@ def read(self, workspace_id: str, variable_id: str) -> Variable: def update( self, workspace_id: str, variable_id: str, options: VariableUpdateOptions ) -> Variable: - """Update values of an existing variable.""" + """Update an existing workspace variable. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-xxxxxxxx"``). + variable_id: The variable ID (e.g. ``"var-xxxxxxxx"``). + options: The changed variable attributes, as a + :class:`VariableUpdateOptions`. + + Returns: + The updated :class:`Variable`. + + Raises: + ValueError: If ``workspace_id`` or ``variable_id`` is not a valid + resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import VariableUpdateOptions + >>> variable = client.variables.update( + ... "ws-6fHMCom98SDXSQUv", + ... "var-N4b1qYNSuNsPzHhM", + ... VariableUpdateOptions(value="DEBUG"), + ... ) + """ if not valid_string_id(workspace_id): raise ValueError(ERR_INVALID_WORKSPACE_ID) if not valid_string_id(variable_id): @@ -150,7 +248,25 @@ def update( return Variable(**variable_data) def delete(self, workspace_id: str, variable_id: str) -> None: - """Delete a variable by its ID.""" + """Delete a workspace variable by its ID. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-xxxxxxxx"``). + variable_id: The variable ID (e.g. ``"var-xxxxxxxx"``). + + Returns: + None. + + Raises: + ValueError: If ``workspace_id`` or ``variable_id`` is not a valid + resource ID. + TFEError: If the API request fails. + + Example: + >>> client.variables.delete( + ... "ws-6fHMCom98SDXSQUv", "var-N4b1qYNSuNsPzHhM" + ... ) + """ if not valid_string_id(workspace_id): raise ValueError(ERR_INVALID_WORKSPACE_ID) if not valid_string_id(variable_id): diff --git a/src/pytfe/resources/variable_sets.py b/src/pytfe/resources/variable_sets.py index 0731d708..04f06772 100644 --- a/src/pytfe/resources/variable_sets.py +++ b/src/pytfe/resources/variable_sets.py @@ -64,17 +64,27 @@ def list( organization: str, options: VariableSetListOptions | None = None, ) -> Iterator[VariableSet]: - """List all variable sets within an organization. + """List variable sets in an organization. Args: - organization: Organization name - options: Optional parameters for filtering and pagination + organization: The organization name (e.g. ``"my-org"``). + options: Optional filters and includes, as a + :class:`VariableSetListOptions`. Returns: - Iterator of VariableSet objects within the organization + A single-use ``Iterator[VariableSet]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + Raises: - ValueError: If organization name is invalid - TFEError: If API request fails + ValueError: If ``organization`` is not a string. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import VariableSetListOptions + >>> for varset in client.variable_sets.list( + ... "my-org", VariableSetListOptions(query="shared") + ... ): + ... print(varset.id, varset.name) """ if not organization or not isinstance(organization, str): raise ValueError("Organization name is required and must be a string") @@ -101,15 +111,23 @@ def list_for_workspace( """List variable sets associated with a workspace. Args: - workspace_id: Workspace ID - options: Optional parameters for filtering and pagination + workspace_id: The workspace ID (e.g. ``"ws-xxxxxxxx"``). + options: Optional filters and includes, as a + :class:`VariableSetListOptions`. Returns: - Iterator of VariableSet objects associated with the workspace + A single-use ``Iterator[VariableSet]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. Raises: - ValueError: If workspace_id is invalid - TFEError: If API request fails + ValueError: If ``workspace_id`` is not a string. + TFEError: If the API request fails. + + Example: + >>> for varset in client.variable_sets.list_for_workspace( + ... "ws-4j8p6jX1w33MiDC7" + ... ): + ... print(varset.id, varset.name) """ if not workspace_id or not isinstance(workspace_id, str): raise ValueError("Workspace ID is required and must be a string") @@ -136,15 +154,23 @@ def list_for_project( """List variable sets associated with a project. Args: - project_id: Project ID - options: Optional parameters for filtering and pagination + project_id: The project ID (e.g. ``"prj-xxxxxxxx"``). + options: Optional filters and includes, as a + :class:`VariableSetListOptions`. Returns: - Iterator of VariableSet objects associated with the project + A single-use ``Iterator[VariableSet]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. Raises: - ValueError: If project_id is invalid - TFEError: If API request fails + ValueError: If ``project_id`` is not a string. + TFEError: If the API request fails. + + Example: + >>> for varset in client.variable_sets.list_for_project( + ... "prj-4j8p6jX1w33MiDC7" + ... ): + ... print(varset.id, varset.name) """ if not project_id or not isinstance(project_id, str): raise ValueError("Project ID is required and must be a string") @@ -168,18 +194,27 @@ def create( organization: str, options: VariableSetCreateOptions, ) -> VariableSet: - """Create a new variable set. + """Create a variable set in an organization. Args: - organization: Organization name - options: Variable set creation options + organization: The organization name (e.g. ``"my-org"``). + options: The variable set configuration, as a + :class:`VariableSetCreateOptions`. Returns: - Created VariableSet object + The :class:`VariableSet`. Raises: - ValueError: If organization name or options are invalid - TFEError: If API request fails + ValueError: If ``organization`` is not a string, ``options`` is not a + :class:`VariableSetCreateOptions`, or ``options.name`` is blank. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import VariableSetCreateOptions + >>> varset = client.variable_sets.create( + ... "my-org", + ... VariableSetCreateOptions(name="shared", global_=False), + ... ) """ if not organization or not isinstance(organization, str): raise ValueError("Organization name is required and must be a string") @@ -241,18 +276,24 @@ def read( variable_set_id: str, options: VariableSetReadOptions | None = None, ) -> VariableSet: - """Read a variable set by its ID. + """Read a variable set by ID. Args: - variable_set_id: Variable set ID - options: Optional parameters for including related resources + variable_set_id: The variable set ID (e.g. ``"varset-xxxxxxxx"``). + options: Optional includes, as a :class:`VariableSetReadOptions`. Returns: - VariableSet object + The :class:`VariableSet`. Raises: - ValueError: If variable_set_id is invalid - TFEError: If API request fails + ValueError: If ``variable_set_id`` is not a string. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import VariableSetReadOptions + >>> varset = client.variable_sets.read( + ... "varset-4j8p6jX1w33MiDC7", VariableSetReadOptions() + ... ) """ if not variable_set_id or not isinstance(variable_set_id, str): raise ValueError("Variable set ID is required and must be a string") @@ -273,18 +314,27 @@ def update( variable_set_id: str, options: VariableSetUpdateOptions, ) -> VariableSet: - """Update an existing variable set. + """Update a variable set by ID. Args: - variable_set_id: Variable set ID - options: Variable set update options + variable_set_id: The variable set ID (e.g. ``"varset-xxxxxxxx"``). + options: The variable set updates, as a + :class:`VariableSetUpdateOptions`. Returns: - Updated VariableSet object + The :class:`VariableSet`. Raises: - ValueError: If variable_set_id or options are invalid - TFEError: If API request fails + ValueError: If ``variable_set_id`` is not a string or ``options`` is not a + :class:`VariableSetUpdateOptions`. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import VariableSetUpdateOptions + >>> varset = client.variable_sets.update( + ... "varset-4j8p6jX1w33MiDC7", + ... VariableSetUpdateOptions(description="Shared AWS settings"), + ... ) """ if not variable_set_id or not isinstance(variable_set_id, str): raise ValueError("Variable set ID is required and must be a string") @@ -323,14 +373,20 @@ def update( return self._parse_variable_set(data["data"]) def delete(self, variable_set_id: str) -> None: - """Delete a variable set by its ID. + """Delete a variable set by ID. Args: - variable_set_id: Variable set ID + variable_set_id: The variable set ID (e.g. ``"varset-xxxxxxxx"``). + + Returns: + None. Raises: - ValueError: If variable_set_id is invalid - TFEError: If API request fails + ValueError: If ``variable_set_id`` is not a string. + TFEError: If the API request fails. + + Example: + >>> client.variable_sets.delete("varset-4j8p6jX1w33MiDC7") """ if not variable_set_id or not isinstance(variable_set_id, str): raise ValueError("Variable set ID is required and must be a string") @@ -343,17 +399,32 @@ def apply_to_workspaces( variable_set_id: str, options: VariableSetApplyToWorkspacesOptions, ) -> None: - """Apply variable set to workspaces. + """Apply a non-global variable set to workspaces. - Note: This method will return an error if the variable set has global = true. + This endpoint returns an API error when the variable set has ``global=True``. Args: - variable_set_id: Variable set ID - options: Options specifying workspaces to apply to + variable_set_id: The variable set ID (e.g. ``"varset-xxxxxxxx"``). + options: The workspace relationship payload, as a + :class:`VariableSetApplyToWorkspacesOptions`. + + Returns: + None. Raises: - ValueError: If variable_set_id or options are invalid - TFEError: If API request fails + ValueError: If ``variable_set_id`` is not a string, ``options`` is not a + :class:`VariableSetApplyToWorkspacesOptions`, no workspaces are + supplied, or any supplied workspace has no ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import VariableSetApplyToWorkspacesOptions, Workspace + >>> client.variable_sets.apply_to_workspaces( + ... "varset-4j8p6jX1w33MiDC7", + ... VariableSetApplyToWorkspacesOptions( + ... workspaces=[Workspace.model_construct(id="ws-4j8p6jX1w33MiDC7")] + ... ), + ... ) """ if not variable_set_id or not isinstance(variable_set_id, str): raise ValueError("Variable set ID is required and must be a string") @@ -389,17 +460,32 @@ def remove_from_workspaces( variable_set_id: str, options: VariableSetRemoveFromWorkspacesOptions, ) -> None: - """Remove variable set from workspaces. + """Remove a non-global variable set from workspaces. - Note: This method will return an error if the variable set has global = true. + This endpoint returns an API error when the variable set has ``global=True``. Args: - variable_set_id: Variable set ID - options: Options specifying workspaces to remove from + variable_set_id: The variable set ID (e.g. ``"varset-xxxxxxxx"``). + options: The workspace relationship payload, as a + :class:`VariableSetRemoveFromWorkspacesOptions`. + + Returns: + None. Raises: - ValueError: If variable_set_id or options are invalid - TFEError: If API request fails + ValueError: If ``variable_set_id`` is not a string, ``options`` is not a + :class:`VariableSetRemoveFromWorkspacesOptions`, no workspaces are + supplied, or any supplied workspace has no ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import VariableSetRemoveFromWorkspacesOptions, Workspace + >>> client.variable_sets.remove_from_workspaces( + ... "varset-4j8p6jX1w33MiDC7", + ... VariableSetRemoveFromWorkspacesOptions( + ... workspaces=[Workspace.model_construct(id="ws-4j8p6jX1w33MiDC7")] + ... ), + ... ) """ if not variable_set_id or not isinstance(variable_set_id, str): raise ValueError("Variable set ID is required and must be a string") @@ -437,17 +523,32 @@ def apply_to_projects( variable_set_id: str, options: VariableSetApplyToProjectsOptions, ) -> None: - """Apply variable set to projects. + """Apply a non-global variable set to projects. - This method will return an error if the variable set has global = true. + This endpoint returns an API error when the variable set has ``global=True``. Args: - variable_set_id: Variable set ID - options: Options specifying projects to apply to + variable_set_id: The variable set ID (e.g. ``"varset-xxxxxxxx"``). + options: The project relationship payload, as a + :class:`VariableSetApplyToProjectsOptions`. + + Returns: + None. Raises: - ValueError: If variable_set_id or options are invalid - TFEError: If API request fails + ValueError: If ``variable_set_id`` is not a string, ``options`` is not a + :class:`VariableSetApplyToProjectsOptions`, no projects are supplied, + or any supplied project has no ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import Project, VariableSetApplyToProjectsOptions + >>> client.variable_sets.apply_to_projects( + ... "varset-4j8p6jX1w33MiDC7", + ... VariableSetApplyToProjectsOptions( + ... projects=[Project.model_construct(id="prj-4j8p6jX1w33MiDC7")] + ... ), + ... ) """ if not variable_set_id or not isinstance(variable_set_id, str): raise ValueError("Variable set ID is required and must be a string") @@ -483,17 +584,32 @@ def remove_from_projects( variable_set_id: str, options: VariableSetRemoveFromProjectsOptions, ) -> None: - """Remove variable set from projects. + """Remove a non-global variable set from projects. - This method will return an error if the variable set has global = true. + This endpoint returns an API error when the variable set has ``global=True``. Args: - variable_set_id: Variable set ID - options: Options specifying projects to remove from + variable_set_id: The variable set ID (e.g. ``"varset-xxxxxxxx"``). + options: The project relationship payload, as a + :class:`VariableSetRemoveFromProjectsOptions`. + + Returns: + None. Raises: - ValueError: If variable_set_id or options are invalid - TFEError: If API request fails + ValueError: If ``variable_set_id`` is not a string, ``options`` is not a + :class:`VariableSetRemoveFromProjectsOptions`, no projects are + supplied, or any supplied project has no ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import Project, VariableSetRemoveFromProjectsOptions + >>> client.variable_sets.remove_from_projects( + ... "varset-4j8p6jX1w33MiDC7", + ... VariableSetRemoveFromProjectsOptions( + ... projects=[Project.model_construct(id="prj-4j8p6jX1w33MiDC7")] + ... ), + ... ) """ if not variable_set_id or not isinstance(variable_set_id, str): raise ValueError("Variable set ID is required and must be a string") @@ -529,18 +645,32 @@ def update_workspaces( variable_set_id: str, options: VariableSetUpdateWorkspacesOptions, ) -> VariableSet: - """Update variable set to be applied to only the workspaces in the supplied list. + """Replace the workspaces applied to a variable set. + + This forces the variable set to ``global=False`` and includes workspaces in the + response. Args: - variable_set_id: Variable set ID - options: Options specifying workspaces to apply to + variable_set_id: The variable set ID (e.g. ``"varset-xxxxxxxx"``). + options: The complete workspace list, as a + :class:`VariableSetUpdateWorkspacesOptions`. Returns: - Updated VariableSet object + The :class:`VariableSet`. Raises: - ValueError: If variable_set_id or options are invalid - TFEError: If API request fails + ValueError: If ``variable_set_id`` is not a string or ``options`` is not a + :class:`VariableSetUpdateWorkspacesOptions`. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import VariableSetUpdateWorkspacesOptions, Workspace + >>> varset = client.variable_sets.update_workspaces( + ... "varset-4j8p6jX1w33MiDC7", + ... VariableSetUpdateWorkspacesOptions( + ... workspaces=[Workspace.model_construct(id="ws-4j8p6jX1w33MiDC7")] + ... ), + ... ) """ if not variable_set_id or not isinstance(variable_set_id, str): raise ValueError("Variable set ID is required and must be a string") @@ -673,18 +803,26 @@ def list( variable_set_id: str, options: VariableSetVariableListOptions | None = None, ) -> Iterator[VariableSetVariable]: - """List all variables in a variable set. + """List variables in a variable set. Args: - variable_set_id: Variable set ID - options: Optional parameters for pagination + variable_set_id: The variable set ID (e.g. ``"varset-xxxxxxxx"``). + options: Optional pagination options, as a + :class:`VariableSetVariableListOptions`. Returns: - Iterator of VariableSetVariable objects + A single-use ``Iterator[VariableSetVariable]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. Raises: - ValueError: If variable_set_id is invalid - TFEError: If API request fails + ValueError: If ``variable_set_id`` is not a string. + TFEError: If the API request fails. + + Example: + >>> for variable in client.variable_set_variables.list( + ... "varset-4j8p6jX1w33MiDC7" + ... ): + ... print(variable.id, variable.key) """ if not variable_set_id or not isinstance(variable_set_id, str): raise ValueError("Variable set ID is required and must be a string") @@ -704,18 +842,30 @@ def create( variable_set_id: str, options: VariableSetVariableCreateOptions, ) -> VariableSetVariable: - """Create a new variable within a variable set. + """Create a variable in a variable set. Args: - variable_set_id: Variable set ID - options: Variable creation options + variable_set_id: The variable set ID (e.g. ``"varset-xxxxxxxx"``). + options: The variable configuration, as a + :class:`VariableSetVariableCreateOptions`. Returns: - Created VariableSetVariable object + The :class:`VariableSetVariable`. Raises: - ValueError: If variable_set_id or options are invalid - TFEError: If API request fails + ValueError: If ``variable_set_id`` is not a string, ``options`` is not a + :class:`VariableSetVariableCreateOptions`, ``options.key`` is blank, or + ``options.category`` is blank. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import CategoryType, VariableSetVariableCreateOptions + >>> variable = client.variable_set_variables.create( + ... "varset-4j8p6jX1w33MiDC7", + ... VariableSetVariableCreateOptions( + ... key="AWS_REGION", value="us-east-1", category=CategoryType.ENV, + ... ), + ... ) """ if not variable_set_id or not isinstance(variable_set_id, str): raise ValueError("Variable set ID is required and must be a string") @@ -766,18 +916,23 @@ def read( variable_set_id: str, variable_id: str, ) -> VariableSetVariable: - """Read a variable by its ID. + """Read a variable from a variable set by ID. Args: - variable_set_id: Variable set ID - variable_id: Variable ID + variable_set_id: The variable set ID (e.g. ``"varset-xxxxxxxx"``). + variable_id: The variable ID (e.g. ``"var-xxxxxxxx"``). Returns: - VariableSetVariable object + The :class:`VariableSetVariable`. Raises: - ValueError: If variable_set_id or variable_id are invalid - TFEError: If API request fails + ValueError: If ``variable_set_id`` or ``variable_id`` is not a string. + TFEError: If the API request fails. + + Example: + >>> variable = client.variable_set_variables.read( + ... "varset-4j8p6jX1w33MiDC7", "var-4j8p6jX1w33MiDC7" + ... ) """ if not variable_set_id or not isinstance(variable_set_id, str): raise ValueError("Variable set ID is required and must be a string") @@ -798,19 +953,29 @@ def update( variable_id: str, options: VariableSetVariableUpdateOptions, ) -> VariableSetVariable: - """Update an existing variable. + """Update a variable in a variable set by ID. Args: - variable_set_id: Variable set ID - variable_id: Variable ID - options: Variable update options + variable_set_id: The variable set ID (e.g. ``"varset-xxxxxxxx"``). + variable_id: The variable ID (e.g. ``"var-xxxxxxxx"``). + options: The variable updates, as a + :class:`VariableSetVariableUpdateOptions`. Returns: - Updated VariableSetVariable object + The :class:`VariableSetVariable`. Raises: - ValueError: If variable_set_id, variable_id or options are invalid - TFEError: If API request fails + ValueError: If ``variable_set_id`` or ``variable_id`` is not a string, or if + ``options`` is not a :class:`VariableSetVariableUpdateOptions`. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import VariableSetVariableUpdateOptions + >>> variable = client.variable_set_variables.update( + ... "varset-4j8p6jX1w33MiDC7", + ... "var-4j8p6jX1w33MiDC7", + ... VariableSetVariableUpdateOptions(value="us-west-2"), + ... ) """ if not variable_set_id or not isinstance(variable_set_id, str): raise ValueError("Variable set ID is required and must be a string") @@ -859,15 +1024,23 @@ def delete( variable_set_id: str, variable_id: str, ) -> None: - """Delete a variable by its ID. + """Delete a variable from a variable set by ID. Args: - variable_set_id: Variable set ID - variable_id: Variable ID + variable_set_id: The variable set ID (e.g. ``"varset-xxxxxxxx"``). + variable_id: The variable ID (e.g. ``"var-xxxxxxxx"``). + + Returns: + None. Raises: - ValueError: If variable_set_id or variable_id are invalid - TFEError: If API request fails + ValueError: If ``variable_set_id`` or ``variable_id`` is not a string. + TFEError: If the API request fails. + + Example: + >>> client.variable_set_variables.delete( + ... "varset-4j8p6jX1w33MiDC7", "var-4j8p6jX1w33MiDC7" + ... ) """ if not variable_set_id or not isinstance(variable_set_id, str): raise ValueError("Variable set ID is required and must be a string") diff --git a/src/pytfe/resources/workspace_resources.py b/src/pytfe/resources/workspace_resources.py index e413d5cf..2231efc1 100644 --- a/src/pytfe/resources/workspace_resources.py +++ b/src/pytfe/resources/workspace_resources.py @@ -48,14 +48,24 @@ class WorkspaceResourcesService(_Service): def list( self, workspace_id: str, options: WorkspaceResourceListOptions | None = None ) -> Iterator[WorkspaceResource]: - """List workspace resources for a given workspace. + """List resources in a workspace state. Args: - workspace_id: The ID of the workspace to list resources for - options: Optional query parameters for filtering and pagination + workspace_id: The workspace ID (e.g. ``"ws-xxxxxxxx"``). + options: Optional pagination settings, as a + :class:`WorkspaceResourceListOptions`. - Yields: - WorkspaceResource objects + Returns: + A single-use ``Iterator[WorkspaceResource]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + ValueError: If ``workspace_id`` is empty. + TFEError: If the API request fails. + + Example: + >>> for resource in client.workspace_resources.list("ws-abc123"): + ... print(resource.address) """ if not workspace_id or not workspace_id.strip(): raise ValueError("workspace_id is required") diff --git a/src/pytfe/resources/workspace_run_task.py b/src/pytfe/resources/workspace_run_task.py index 79ec4efe..d68071d4 100644 --- a/src/pytfe/resources/workspace_run_task.py +++ b/src/pytfe/resources/workspace_run_task.py @@ -50,7 +50,28 @@ class WorkspaceRunTasks(_Service): def create( self, workspace_id: str, options: WorkspaceRunTaskCreateOptions ) -> WorkspaceRunTask: - """Attach a run task to a workspace.""" + """Attach a run task to a workspace. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-xxxxxxxx"``). + options: The workspace run task settings, as a + :class:`WorkspaceRunTaskCreateOptions`. + + Returns: + The created :class:`WorkspaceRunTask`. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import RunTask, WorkspaceRunTaskCreateOptions + >>> options = WorkspaceRunTaskCreateOptions( + ... enforcement_level="advisory", + ... run_task=RunTask.model_construct(id="task-123"), + ... ) + >>> task = client.workspace_run_tasks.create("ws-123", options) + """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() @@ -78,7 +99,25 @@ def list( workspace_id: str, options: WorkspaceRunTaskListOptions | None = None, ) -> Iterator[WorkspaceRunTask]: - """List all workspace run tasks for a workspace.""" + """List run tasks attached to a workspace. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-xxxxxxxx"``). + options: Optional pagination options, as a + :class:`WorkspaceRunTaskListOptions`. + + Returns: + A single-use ``Iterator[WorkspaceRunTask]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> for task in client.workspace_run_tasks.list("ws-123"): + ... print(task.id, task.enforcement_level) + """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() @@ -88,7 +127,24 @@ def list( yield _workspace_run_task_from(item) def read(self, workspace_id: str, workspace_task_id: str) -> WorkspaceRunTask: - """Read a workspace run task by ID.""" + """Read a workspace run task by its ID. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-xxxxxxxx"``). + workspace_task_id: The workspace run task ID (e.g. ``"wst-xxxxxxxx"``). + + Returns: + The :class:`WorkspaceRunTask`. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + InvalidWorkspaceRunTaskIDError: If ``workspace_task_id`` is not valid. + TFEError: If the API request fails. + + Example: + >>> task = client.workspace_run_tasks.read("ws-123", "wst-1") + >>> print(task.stages) + """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() if not valid_string_id(workspace_task_id): @@ -106,7 +162,30 @@ def update( workspace_task_id: str, options: WorkspaceRunTaskUpdateOptions, ) -> WorkspaceRunTask: - """Update a workspace run task by ID.""" + """Update a workspace run task by its ID. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-xxxxxxxx"``). + workspace_task_id: The workspace run task ID (e.g. ``"wst-xxxxxxxx"``). + options: The workspace run task updates, as a + :class:`WorkspaceRunTaskUpdateOptions`. + + Returns: + The updated :class:`WorkspaceRunTask`. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + InvalidWorkspaceRunTaskIDError: If ``workspace_task_id`` is not valid. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import WorkspaceRunTaskUpdateOptions + >>> task = client.workspace_run_tasks.update( + ... "ws-123", + ... "wst-1", + ... WorkspaceRunTaskUpdateOptions(enforcement_level="mandatory"), + ... ) + """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() if not valid_string_id(workspace_task_id): @@ -130,7 +209,23 @@ def update( return _workspace_run_task_from(response.json()["data"]) def delete(self, workspace_id: str, workspace_task_id: str) -> None: - """Delete a workspace run task by ID.""" + """Delete a workspace run task by its ID. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-xxxxxxxx"``). + workspace_task_id: The workspace run task ID (e.g. ``"wst-xxxxxxxx"``). + + Returns: + None. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + InvalidWorkspaceRunTaskIDError: If ``workspace_task_id`` is not valid. + TFEError: If the API request fails. + + Example: + >>> client.workspace_run_tasks.delete("ws-123", "wst-1") + """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() if not valid_string_id(workspace_task_id): diff --git a/src/pytfe/resources/workspaces.py b/src/pytfe/resources/workspaces.py index c6342d5f..77187696 100644 --- a/src/pytfe/resources/workspaces.py +++ b/src/pytfe/resources/workspaces.py @@ -220,6 +220,27 @@ def list( organization: str, options: WorkspaceListOptions | None = None, ) -> Iterator[Workspace]: + """List workspaces in an organization. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: Optional filters and includes, as a :class:`WorkspaceListOptions`. + + Returns: + A single-use ``Iterator[Workspace]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import WorkspaceListOptions + >>> for workspace in client.workspaces.list( + ... "my-org", WorkspaceListOptions(search="prod") + ... ): + ... print(workspace.id, workspace.name) + """ if not valid_string_id(organization): raise InvalidOrgError() @@ -248,7 +269,26 @@ def list( yield _ws_from(item) def read(self, workspace: str, *, organization: str) -> Workspace: - """Read workspace by organization and name.""" + """Read a workspace by organization and name. + + Args: + workspace: The workspace name (e.g. ``"example-workspace"``). + organization: The organization name (e.g. ``"my-org"``). + + Returns: + The :class:`Workspace`. + + Raises: + InvalidWorkspaceValueError: If ``workspace`` is not a valid workspace name. + InvalidOrgError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> workspace = client.workspaces.read( + ... "example-workspace", organization="my-org" + ... ) + >>> print(workspace.id) + """ return self.read_with_options(workspace, organization=organization) def read_with_options( @@ -258,6 +298,29 @@ def read_with_options( *, organization: str, ) -> Workspace: + """Read a workspace by organization and name with include options. + + Args: + workspace: The workspace name (e.g. ``"example-workspace"``). + options: Optional related resources, as a :class:`WorkspaceReadOptions`. + organization: The organization name (e.g. ``"my-org"``). + + Returns: + The :class:`Workspace`. + + Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. + InvalidWorkspaceValueError: If ``workspace`` is not a valid workspace name. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import WorkspaceIncludeOpt, WorkspaceReadOptions + >>> workspace = client.workspaces.read_with_options( + ... "example-workspace", + ... WorkspaceReadOptions(include=[WorkspaceIncludeOpt.PROJECT]), + ... organization="my-org", + ... ) + """ if not valid_string_id(organization): raise InvalidOrgError() if not valid_string_id(workspace): @@ -282,12 +345,47 @@ def read_with_options( return ws def read_by_id(self, workspace_id: str) -> Workspace: - """Read workspace by workspace ID.""" + """Read a workspace by workspace ID. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-abc123def456"``). + + Returns: + The :class:`Workspace`. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> workspace = client.workspaces.read_by_id("ws-abc123def456") + >>> print(workspace.name) + """ return self.read_by_id_with_options(workspace_id) def read_by_id_with_options( self, workspace_id: str, options: WorkspaceReadOptions | None = None ) -> Workspace: + """Read a workspace by workspace ID with include options. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-abc123def456"``). + options: Optional related resources, as a :class:`WorkspaceReadOptions`. + + Returns: + The :class:`Workspace`. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import WorkspaceIncludeOpt, WorkspaceReadOptions + >>> workspace = client.workspaces.read_by_id_with_options( + ... "ws-abc123def456", + ... WorkspaceReadOptions(include=[WorkspaceIncludeOpt.OUTPUTS]), + ... ) + """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() @@ -309,7 +407,25 @@ def create( organization: str, options: WorkspaceCreateOptions, ) -> Workspace: - """Create a new workspace in the given organization.""" + """Create a workspace in an organization. + + Args: + organization: The organization name (e.g. ``"my-org"``). + options: The workspace settings, as a :class:`WorkspaceCreateOptions`. + + Returns: + The created :class:`Workspace`. + + Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import WorkspaceCreateOptions + >>> workspace = client.workspaces.create( + ... "my-org", WorkspaceCreateOptions(name="example-workspace") + ... ) + """ if not valid_string_id(organization): raise InvalidOrgError() @@ -322,7 +438,29 @@ def create( def update( self, workspace: str, options: WorkspaceUpdateOptions, *, organization: str ) -> Workspace: - """Update workspace by organization and name.""" + """Update a workspace by organization and name. + + Args: + workspace: The workspace name (e.g. ``"example-workspace"``). + options: The workspace changes, as a :class:`WorkspaceUpdateOptions`. + organization: The organization name (e.g. ``"my-org"``). + + Returns: + The updated :class:`Workspace`. + + Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. + InvalidWorkspaceValueError: If ``workspace`` is not a valid workspace name. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import WorkspaceUpdateOptions + >>> workspace = client.workspaces.update( + ... "example-workspace", + ... WorkspaceUpdateOptions(description="Managed by pytfe."), + ... organization="my-org", + ... ) + """ if not valid_string_id(organization): raise InvalidOrgError() if not valid_string_id(workspace): @@ -339,7 +477,25 @@ def update( def update_by_id( self, workspace_id: str, options: WorkspaceUpdateOptions ) -> Workspace: - """Update workspace by workspace ID.""" + """Update a workspace by workspace ID. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-abc123def456"``). + options: The workspace changes, as a :class:`WorkspaceUpdateOptions`. + + Returns: + The updated :class:`Workspace`. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import WorkspaceUpdateOptions + >>> workspace = client.workspaces.update_by_id( + ... "ws-abc123def456", WorkspaceUpdateOptions(auto_apply=True) + ... ) + """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() @@ -425,7 +581,23 @@ def _build_workspace_payload( return body def delete(self, workspace: str, *, organization: str) -> None: - """Delete workspace by organization and workspace name.""" + """Delete a workspace by organization and name. + + Args: + workspace: The workspace name (e.g. ``"example-workspace"``). + organization: The organization name (e.g. ``"my-org"``). + + Returns: + None. + + Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. + InvalidWorkspaceValueError: If ``workspace`` is not a valid workspace name. + TFEError: If the API request fails. + + Example: + >>> client.workspaces.delete("example-workspace", organization="my-org") + """ if not valid_string_id(organization): raise InvalidOrgError() if not valid_string_id(workspace): @@ -437,7 +609,21 @@ def delete(self, workspace: str, *, organization: str) -> None: return None def delete_by_id(self, workspace_id: str) -> None: - """Delete workspace by workspace ID.""" + """Delete a workspace by workspace ID. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-abc123def456"``). + + Returns: + None. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.workspaces.delete_by_id("ws-abc123def456") + """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() @@ -445,7 +631,25 @@ def delete_by_id(self, workspace_id: str) -> None: return None def safe_delete(self, workspace: str, *, organization: str) -> None: - """Safely delete workspace by organization and name.""" + """Safely delete a workspace by organization and name. + + Args: + workspace: The workspace name (e.g. ``"example-workspace"``). + organization: The organization name (e.g. ``"my-org"``). + + Returns: + None. + + Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. + InvalidWorkspaceValueError: If ``workspace`` is not a valid workspace name. + TFEError: If the API request fails. + + Example: + >>> client.workspaces.safe_delete( + ... "example-workspace", organization="my-org" + ... ) + """ if not valid_string_id(organization): raise InvalidOrgError() if not valid_string_id(workspace): @@ -458,7 +662,21 @@ def safe_delete(self, workspace: str, *, organization: str) -> None: return None def safe_delete_by_id(self, workspace_id: str) -> None: - """Safely delete workspace by workspace ID.""" + """Safely delete a workspace by workspace ID. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-abc123def456"``). + + Returns: + None. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.workspaces.safe_delete_by_id("ws-abc123def456") + """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() @@ -471,7 +689,25 @@ def remove_vcs_connection( *, organization: str | None = None, ) -> Workspace: - """Remove VCS connection from workspace by organization and name.""" + """Remove the VCS connection from a workspace by name. + + Args: + workspace: The workspace name (e.g. ``"example-workspace"``). + organization: The organization name (e.g. ``"my-org"``). + + Returns: + The updated :class:`Workspace`. + + Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. + InvalidWorkspaceValueError: If ``workspace`` is not a valid workspace name. + TFEError: If the API request fails. + + Example: + >>> workspace = client.workspaces.remove_vcs_connection( + ... "example-workspace", organization="my-org" + ... ) + """ if not valid_string_id(organization): raise InvalidOrgError() if not valid_string_id(workspace): @@ -492,7 +728,23 @@ def remove_vcs_connection( return _ws_from(r.json()["data"]) def remove_vcs_connection_by_id(self, workspace_id: str) -> Workspace: - """Remove VCS connection from workspace by workspace ID.""" + """Remove the VCS connection from a workspace by ID. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-abc123def456"``). + + Returns: + The updated :class:`Workspace`. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> workspace = client.workspaces.remove_vcs_connection_by_id( + ... "ws-abc123def456" + ... ) + """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() @@ -511,7 +763,25 @@ def remove_vcs_connection_by_id(self, workspace_id: str) -> Workspace: return _ws_from(r.json()["data"]) def lock(self, workspace_id: str, options: WorkspaceLockOptions) -> Workspace: - """Lock a workspace by workspace ID.""" + """Lock a workspace by workspace ID. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-abc123def456"``). + options: The lock reason, as a :class:`WorkspaceLockOptions`. + + Returns: + The locked :class:`Workspace`. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import WorkspaceLockOptions + >>> workspace = client.workspaces.lock( + ... "ws-abc123def456", WorkspaceLockOptions(reason="Maintenance") + ... ) + """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() @@ -525,7 +795,23 @@ def lock(self, workspace_id: str, options: WorkspaceLockOptions) -> Workspace: return _ws_from(r.json()["data"]) def unlock(self, workspace_id: str) -> Workspace: - """Unlock a workspace by workspace ID.""" + """Unlock a workspace by workspace ID. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-abc123def456"``). + + Returns: + The unlocked :class:`Workspace`. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + WorkspaceLockedStateVersionStillPending: If the latest state version is + pending. + TFEError: If the API request fails. + + Example: + >>> workspace = client.workspaces.unlock("ws-abc123def456") + """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() try: @@ -540,7 +826,21 @@ def unlock(self, workspace_id: str) -> Workspace: raise def force_unlock(self, workspace_id: str) -> Workspace: - """Force unlock a workspace by workspace ID.""" + """Force unlock a workspace by workspace ID. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-abc123def456"``). + + Returns: + The unlocked :class:`Workspace`. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> workspace = client.workspaces.force_unlock("ws-abc123def456") + """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() @@ -553,7 +853,28 @@ def force_unlock(self, workspace_id: str) -> Workspace: def assign_ssh_key( self, workspace_id: str, options: WorkspaceAssignSSHKeyOptions ) -> Workspace: - """Assign an SSH key to a workspace by workspace ID.""" + """Assign an SSH key to a workspace by workspace ID. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-abc123def456"``). + options: The SSH key ID, as a :class:`WorkspaceAssignSSHKeyOptions`. + + Returns: + The updated :class:`Workspace`. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + RequiredSSHKeyIDError: If ``options.ssh_key_id`` is empty. + InvalidSSHKeyIDError: If ``options.ssh_key_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import WorkspaceAssignSSHKeyOptions + >>> workspace = client.workspaces.assign_ssh_key( + ... "ws-abc123def456", + ... WorkspaceAssignSSHKeyOptions(ssh_key_id="sshkey-123"), + ... ) + """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() @@ -578,7 +899,21 @@ def assign_ssh_key( return _ws_from(r.json()["data"]) def unassign_ssh_key(self, workspace_id: str) -> Workspace: - """Unassign the SSH key from a workspace by workspace ID.""" + """Unassign the SSH key from a workspace by workspace ID. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-abc123def456"``). + + Returns: + The updated :class:`Workspace`. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> workspace = client.workspaces.unassign_ssh_key("ws-abc123def456") + """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() @@ -602,7 +937,27 @@ def list_remote_state_consumers( workspace_id: str, options: WorkspaceListRemoteStateConsumersOptions | None = None, ) -> Iterator[Workspace]: - """List remote state consumers of a workspace by workspace ID.""" + """List remote-state consumers for a workspace. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-abc123def456"``). + options: Optional pagination, as a + :class:`WorkspaceListRemoteStateConsumersOptions`. + + Returns: + A single-use ``Iterator[Workspace]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> for consumer in client.workspaces.list_remote_state_consumers( + ... "ws-abc123def456" + ... ): + ... print(consumer.id) + """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() @@ -615,7 +970,34 @@ def list_remote_state_consumers( def add_remote_state_consumers( self, workspace_id: str, options: WorkspaceAddRemoteStateConsumersOptions ) -> None: - """Add remote state consumers to a workspace by workspace ID.""" + """Add remote-state consumers to a workspace. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-abc123def456"``). + options: The consumer workspaces, as a + :class:`WorkspaceAddRemoteStateConsumersOptions`. + + Returns: + None. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + WorkspaceRequiredError: If ``options.workspaces`` is ``None``. + WorkspaceMinimumLimitError: If ``options.workspaces`` is empty. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import ( + ... Workspace, + ... WorkspaceAddRemoteStateConsumersOptions, + ... ) + >>> client.workspaces.add_remote_state_consumers( + ... "ws-abc123def456", + ... WorkspaceAddRemoteStateConsumersOptions( + ... workspaces=[Workspace(id="ws-456")] + ... ), + ... ) + """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() if options.workspaces is None: @@ -636,7 +1018,34 @@ def add_remote_state_consumers( def remove_remote_state_consumers( self, workspace_id: str, options: WorkspaceRemoveRemoteStateConsumersOptions ) -> None: - """Remove remote state consumers from a workspace by workspace ID.""" + """Remove remote-state consumers from a workspace. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-abc123def456"``). + options: The consumer workspaces, as a + :class:`WorkspaceRemoveRemoteStateConsumersOptions`. + + Returns: + None. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + WorkspaceRequiredError: If ``options.workspaces`` is ``None``. + WorkspaceMinimumLimitError: If ``options.workspaces`` is empty. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import ( + ... Workspace, + ... WorkspaceRemoveRemoteStateConsumersOptions, + ... ) + >>> client.workspaces.remove_remote_state_consumers( + ... "ws-abc123def456", + ... WorkspaceRemoveRemoteStateConsumersOptions( + ... workspaces=[Workspace(id="ws-456")] + ... ), + ... ) + """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() if options.workspaces is None: @@ -656,7 +1065,34 @@ def remove_remote_state_consumers( def update_remote_state_consumers( self, workspace_id: str, options: WorkspaceUpdateRemoteStateConsumersOptions ) -> None: - """Update remote state consumers of a workspace by workspace ID.""" + """Replace remote-state consumers for a workspace. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-abc123def456"``). + options: The complete consumer set, as a + :class:`WorkspaceUpdateRemoteStateConsumersOptions`. + + Returns: + None. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + WorkspaceRequiredError: If ``options.workspaces`` is ``None``. + WorkspaceMinimumLimitError: If ``options.workspaces`` is empty. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import ( + ... Workspace, + ... WorkspaceUpdateRemoteStateConsumersOptions, + ... ) + >>> client.workspaces.update_remote_state_consumers( + ... "ws-abc123def456", + ... WorkspaceUpdateRemoteStateConsumersOptions( + ... workspaces=[Workspace(id="ws-456")] + ... ), + ... ) + """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() if options.workspaces is None: @@ -676,6 +1112,25 @@ def update_remote_state_consumers( def list_tags( self, workspace_id: str, options: WorkspaceTagListOptions | None = None ) -> Iterator[Tag]: + """List tags attached to a workspace. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-abc123def456"``). + options: Optional pagination and name filtering, as a + :class:`WorkspaceTagListOptions`. + + Returns: + A single-use ``Iterator[Tag]``. Wrap with ``list(...)`` to materialize the + results or iterate more than once. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> for tag in client.workspaces.list_tags("ws-abc123def456"): + ... print(tag.id, tag.name) + """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() @@ -687,7 +1142,26 @@ def list_tags( yield Tag(id=item.get("id"), name=attr.get("name", "")) def add_tags(self, workspace_id: str, options: WorkspaceAddTagsOptions) -> None: - """AddTags adds a list of tags to a workspace.""" + """Add tags to a workspace. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-abc123def456"``). + options: The tags to add, as a :class:`WorkspaceAddTagsOptions`. + + Returns: + None. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + MissingTagIdentifierError: If no tag has an ID or name. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import Tag, WorkspaceAddTagsOptions + >>> client.workspaces.add_tags( + ... "ws-abc123def456", WorkspaceAddTagsOptions(tags=[Tag(name="prod")]) + ... ) + """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() if len(options.tags) == 0: @@ -712,7 +1186,27 @@ def add_tags(self, workspace_id: str, options: WorkspaceAddTagsOptions) -> None: def remove_tags( self, workspace_id: str, options: WorkspaceRemoveTagsOptions ) -> None: - """RemoveTags removes a list of tags from a workspace.""" + """Remove tags from a workspace. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-abc123def456"``). + options: The tags to remove, as a :class:`WorkspaceRemoveTagsOptions`. + + Returns: + None. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + MissingTagIdentifierError: If no tag has an ID or name. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import Tag, WorkspaceRemoveTagsOptions + >>> client.workspaces.remove_tags( + ... "ws-abc123def456", + ... WorkspaceRemoveTagsOptions(tags=[Tag(name="prod")]), + ... ) + """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() if len(options.tags) == 0: @@ -735,6 +1229,23 @@ def remove_tags( return None def list_tag_bindings(self, workspace_id: str) -> Iterator[TagBinding]: + """List tag bindings attached to a workspace. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-abc123def456"``). + + Returns: + A single-use ``Iterator[TagBinding]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> bindings = list(client.workspaces.list_tag_bindings("ws-abc123def456")) + >>> print(bindings[0].key) + """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() @@ -751,6 +1262,25 @@ def list_tag_bindings(self, workspace_id: str) -> Iterator[TagBinding]: def list_effective_tag_bindings( self, workspace_id: str ) -> Iterator[EffectiveTagBinding]: + """List effective tag bindings for a workspace. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-abc123def456"``). + + Returns: + A single-use ``Iterator[EffectiveTagBinding]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> for binding in client.workspaces.list_effective_tag_bindings( + ... "ws-abc123def456" + ... ): + ... print(binding.key, binding.value) + """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() @@ -768,7 +1298,30 @@ def list_effective_tag_bindings( def add_tag_bindings( self, workspace_id: str, options: WorkspaceAddTagBindingsOptions ) -> Iterator[TagBinding]: - """AddTagBindings adds or modifies the value of existing tag binding keys for a workspace.""" + """Add or update tag bindings on a workspace. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-abc123def456"``). + options: The tag bindings, as a :class:`WorkspaceAddTagBindingsOptions`. + + Returns: + A single-use ``Iterator[TagBinding]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + MissingTagBindingIdentifierError: If no tag bindings are provided. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import TagBinding, WorkspaceAddTagBindingsOptions + >>> bindings = client.workspaces.add_tag_bindings( + ... "ws-abc123def456", + ... WorkspaceAddTagBindingsOptions( + ... tag_bindings=[TagBinding(key="env", value="prod")] + ... ), + ... ) + """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() if len(options.tag_bindings) == 0: @@ -800,7 +1353,21 @@ def add_tag_bindings( return iter(out) def delete_all_tag_bindings(self, workspace_id: str) -> None: - """DeleteAllTagBindings removes all tag bindings associated with a workspace.""" + """Delete all tag bindings from a workspace. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-abc123def456"``). + + Returns: + None. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.workspaces.delete_all_tag_bindings("ws-abc123def456") + """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() @@ -817,7 +1384,24 @@ def delete_all_tag_bindings(self, workspace_id: str) -> None: def read_data_retention_policy( self, workspace_id: str ) -> DataRetentionPolicy | None: - """Read a workspace's data retention policy (deprecated: use read_data_retention_policy_choice instead).""" + """Read a workspace's deprecated data retention policy. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-abc123def456"``). + + Returns: + The :class:`DataRetentionPolicy`, or ``None`` if the relationship + has no data. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + ValueError: If the deprecated policy endpoint should not be used. + TFEError: If the API request fails. + + Example: + >>> policy = client.workspaces.read_data_retention_policy("ws-abc123def456") + >>> print(policy.delete_older_than_n_days if policy else "none") + """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() @@ -844,7 +1428,24 @@ def read_data_retention_policy( def read_data_retention_policy_choice( self, workspace_id: str ) -> DataRetentionPolicyChoice | None: - """Read a workspace's data retention policy choice (polymorphic).""" + """Read a workspace's polymorphic data retention policy choice. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-abc123def456"``). + + Returns: + The :class:`DataRetentionPolicyChoice`, or ``None`` if the workspace has no + policy choice or the relationship endpoint has no data. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> choice = client.workspaces.read_data_retention_policy_choice( + ... "ws-abc123def456" + ... ) + """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() @@ -898,7 +1499,26 @@ def read_data_retention_policy_choice( def set_data_retention_policy( self, workspace_id: str, options: DataRetentionPolicySetOptions ) -> DataRetentionPolicy: - """Set a workspace's data retention policy (deprecated: use set_data_retention_policy_delete_older instead).""" + """Set a workspace's deprecated data retention policy. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-abc123def456"``). + options: The retention period, as a :class:`DataRetentionPolicySetOptions`. + + Returns: + The :class:`DataRetentionPolicy`. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import DataRetentionPolicySetOptions + >>> policy = client.workspaces.set_data_retention_policy( + ... "ws-abc123def456", + ... DataRetentionPolicySetOptions(delete_older_than_n_days=30), + ... ) + """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() @@ -930,7 +1550,29 @@ def _data_retention_policy_link(self, workspace_id: str) -> str: def set_data_retention_policy_delete_older( self, workspace_id: str, options: DataRetentionPolicyDeleteOlderSetOptions ) -> DataRetentionPolicyDeleteOlder: - """Set a workspace's data retention policy to delete data older than a certain number of days.""" + """Set a workspace's delete-older data retention policy. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-abc123def456"``). + options: The retention period, as a + :class:`DataRetentionPolicyDeleteOlderSetOptions`. + + Returns: + The :class:`DataRetentionPolicyDeleteOlder`. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import DataRetentionPolicyDeleteOlderSetOptions + >>> policy = client.workspaces.set_data_retention_policy_delete_older( + ... "ws-abc123def456", + ... DataRetentionPolicyDeleteOlderSetOptions( + ... delete_older_than_n_days=30 + ... ), + ... ) + """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() @@ -958,7 +1600,23 @@ def set_data_retention_policy_delete_older( def set_data_retention_policy_dont_delete( self, workspace_id: str ) -> DataRetentionPolicyDontDelete: - """Set a workspace's data retention policy to explicitly not delete data.""" + """Set a workspace's data retention policy to never delete. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-abc123def456"``). + + Returns: + The :class:`DataRetentionPolicyDontDelete`. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> policy = client.workspaces.set_data_retention_policy_dont_delete( + ... "ws-abc123def456" + ... ) + """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() @@ -977,7 +1635,21 @@ def set_data_retention_policy_dont_delete( return DataRetentionPolicyDontDelete(id=d.get("id")) def delete_data_retention_policy(self, workspace_id: str) -> None: - """Delete a workspace's data retention policy.""" + """Delete a workspace's data retention policy. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-abc123def456"``). + + Returns: + None. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> client.workspaces.delete_data_retention_policy("ws-abc123def456") + """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() @@ -985,7 +1657,23 @@ def delete_data_retention_policy(self, workspace_id: str) -> None: return None def readme(self, workspace_id: str) -> str | None: - """Get the README content of a workspace by its ID.""" + """Read the README content for a workspace. + + Args: + workspace_id: The workspace ID (e.g. ``"ws-abc123def456"``). + + Returns: + The README Markdown string, or ``None`` if the workspace has no README + relationship or included README content. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> markdown = client.workspaces.readme("ws-abc123def456") + >>> print(markdown or "No README") + """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() r = self.t.request( @@ -1014,10 +1702,21 @@ def readme(self, workspace_id: str) -> str | None: return None def current_assessment_result(self, workspace_id: str) -> AssessmentResult | None: - """Get the current health-assessment (drift detection) result for a workspace. + """Read the current health-assessment result for a workspace. - Returns ``None`` if the workspace has no assessment result yet (assessments - may be disabled, or no assessment has run). + Args: + workspace_id: The workspace ID (e.g. ``"ws-abc123def456"``). + + Returns: + The :class:`AssessmentResult`, or ``None`` if no assessment result exists. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> result = client.workspaces.current_assessment_result("ws-abc123def456") + >>> print(result.status if result else "not assessed") """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() @@ -1038,13 +1737,25 @@ def current_assessment_result(self, workspace_id: str) -> AssessmentResult | Non return AssessmentResult.model_validate(attributes) def list_applicable_varsets(self, workspace_id: str) -> Iterator[dict[str, Any]]: - """List variable sets that apply to a workspace, including inherited ones. + """List variable sets that apply to a workspace. - Returns raw varset attribute dicts (id/name/global/var-count/etc.). The - endpoint summarises varsets rather than returning the full relationship - graph, so it is exposed as plain dicts to avoid the heavier - ``VariableSet`` parsing path. Callers wanting the full model can pass - each ``id`` to ``client.variable_sets.read``. + Args: + workspace_id: The workspace ID (e.g. ``"ws-abc123def456"``). + + Returns: + A single-use ``Iterator[dict[str, Any]]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. + TFEError: If the API request fails. + + Example: + >>> varsets = client.workspaces.list_applicable_varsets( + ... "ws-abc123def456" + ... ) + >>> for varset in varsets: + ... print(varset["id"], varset.get("name")) """ if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() diff --git a/tests/units/test_assessment_result.py b/tests/units/test_assessment_result.py new file mode 100644 index 00000000..26db2224 --- /dev/null +++ b/tests/units/test_assessment_result.py @@ -0,0 +1,148 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for the assessment results resource.""" + +from unittest.mock import Mock + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.errors import InvalidAssessmentResultIDError +from pytfe.models.assessment_result import AssessmentResult +from pytfe.resources.assessment_result import AssessmentResults + + +class TestAssessmentResults: + @pytest.fixture + def mock_transport(self): + return Mock(spec=HTTPTransport) + + @pytest.fixture + def service(self, mock_transport): + return AssessmentResults(mock_transport) + + @pytest.fixture + def api_data(self): + return { + "id": "asmtres-UG5rE9L1373hMYMA", + "type": "assessment-results", + "attributes": { + "drifted": True, + "succeeded": True, + "error-message": None, + "created-at": "2022-07-02T22:29:58+00:00", + }, + "relationships": { + "workspace": {"data": {"id": "ws-1", "type": "workspaces"}} + }, + } + + # ── read ────────────────────────────────────────────────────────────────── + + def test_read_success(self, service, mock_transport, api_data): + mock_response = Mock() + mock_response.json.return_value = {"data": api_data} + mock_transport.request.return_value = mock_response + + result = service.read("asmtres-UG5rE9L1373hMYMA") + + mock_transport.request.assert_called_once_with( + "GET", "/api/v2/assessment-results/asmtres-UG5rE9L1373hMYMA" + ) + assert isinstance(result, AssessmentResult) + assert result.id == "asmtres-UG5rE9L1373hMYMA" + assert result.drifted is True + assert result.succeeded is True + assert result.error_message is None + assert result.related("workspace") == [{"id": "ws-1", "type": "workspaces"}] + + def test_read_invalid_id(self, service): + with pytest.raises(InvalidAssessmentResultIDError): + service.read("not valid!") + + # ── json_output / json_schema (blob redirect) ───────────────────────────── + + def test_json_output_follows_redirect(self, service, mock_transport): + redirect = Mock() + redirect.status_code = 307 + redirect.headers = {"Location": "https://archivist.example/blob?sig=abc"} + blob = Mock() + blob.status_code = 200 + blob.json.return_value = {"format_version": "1.2", "planned_values": {}} + mock_transport.request.side_effect = [redirect, blob] + + result = service.json_output("asmtres-1") + + assert result == {"format_version": "1.2", "planned_values": {}} + first, second = mock_transport.request.call_args_list + assert first.args == ( + "GET", + "/api/v2/assessment-results/asmtres-1/json-output", + ) + assert first.kwargs == {"allow_redirects": False} + assert second.args == ("GET", "https://archivist.example/blob?sig=abc") + + def test_json_output_204_returns_none(self, service, mock_transport): + resp = Mock() + resp.status_code = 204 + mock_transport.request.return_value = resp + assert service.json_output("asmtres-1") is None + + def test_json_output_inline_body(self, service, mock_transport): + resp = Mock() + resp.status_code = 200 + resp.json.return_value = {"format_version": "1.1"} + mock_transport.request.return_value = resp + assert service.json_output("asmtres-1") == {"format_version": "1.1"} + + def test_json_schema_follows_redirect(self, service, mock_transport): + redirect = Mock() + redirect.status_code = 307 + redirect.headers = {"Location": "https://archivist.example/schema"} + blob = Mock() + blob.status_code = 200 + blob.json.return_value = {"provider_schemas": {}} + mock_transport.request.side_effect = [redirect, blob] + + result = service.json_schema("asmtres-1") + + assert result == {"provider_schemas": {}} + assert mock_transport.request.call_args_list[0].args == ( + "GET", + "/api/v2/assessment-results/asmtres-1/json-schema", + ) + + def test_json_output_invalid_id(self, service): + with pytest.raises(InvalidAssessmentResultIDError): + service.json_output("") + + def test_json_schema_invalid_id(self, service): + with pytest.raises(InvalidAssessmentResultIDError): + service.json_schema("") + + # ── log_output ──────────────────────────────────────────────────────────── + + def test_log_output_text(self, service, mock_transport): + resp = Mock() + resp.status_code = 200 + resp.text = '{"@level":"info"}\n{"@level":"info"}' + mock_transport.request.return_value = resp + + result = service.log_output("asmtres-1") + + assert result == '{"@level":"info"}\n{"@level":"info"}' + assert mock_transport.request.call_args.args == ( + "GET", + "/api/v2/assessment-results/asmtres-1/log-output", + ) + + def test_log_output_204_returns_empty(self, service, mock_transport): + resp = Mock() + resp.status_code = 204 + mock_transport.request.return_value = resp + assert service.log_output("asmtres-1") == "" + + def test_log_output_invalid_id(self, service): + with pytest.raises(InvalidAssessmentResultIDError): + service.log_output("") diff --git a/tests/units/test_cidr_range_list.py b/tests/units/test_cidr_range_list.py new file mode 100644 index 00000000..334ef94d --- /dev/null +++ b/tests/units/test_cidr_range_list.py @@ -0,0 +1,386 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for the IP allowlist (CIDR range list) resources.""" + +from unittest.mock import Mock + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.errors import ( + InvalidAgentPoolIDError, + InvalidCIDRRangeIDError, + InvalidCIDRRangeListIDError, + InvalidOrgError, + RequiredCIDRBlockError, + RequiredNameError, +) +from pytfe.models.cidr_range_list import ( + CIDRRange, + CIDRRangeCreateOptions, + CIDRRangeList, + CIDRRangeListCreateOptions, + CIDRRangeListUpdateOptions, + CIDRRangeUpdateOptions, + EnforcementScope, +) +from pytfe.resources.cidr_range_list import CIDRRangeLists, CIDRRanges + + +class TestCIDRRangeLists: + """Test the CIDRRangeLists (IP allowlist) service class.""" + + @pytest.fixture + def mock_transport(self): + return Mock(spec=HTTPTransport) + + @pytest.fixture + def service(self, mock_transport): + return CIDRRangeLists(mock_transport) + + @pytest.fixture + def list_api_data(self): + return { + "id": "crl-xKw8dxQPqVQRZmCe", + "type": "cidr-range-lists", + "attributes": { + "name": "Office Network", + "description": "IP ranges for office locations", + "enforcement-scope": "selected_agent_pools", + }, + "relationships": { + "cidr-ranges": { + "data": [{"id": "cidr-6huHpM7asDp7TaiP", "type": "cidr-ranges"}] + } + }, + } + + # ── Model / options ─────────────────────────────────────────────────────── + + def test_create_options_requires_name(self): + with pytest.raises(RequiredNameError): + CIDRRangeListCreateOptions(name="") + + def test_create_options_enforcement_scope_serializes_underscored(self): + opts = CIDRRangeListCreateOptions( + name="Office Network", + enforcement_scope=EnforcementScope.ALL_AGENT_POOLS, + ) + dumped = opts.model_dump(by_alias=True, exclude_none=True, mode="json") + assert dumped == { + "name": "Office Network", + "enforcement-scope": "all_agent_pools", + } + + # ── List / create / read / update / delete ──────────────────────────────── + + def test_list_success(self, service, list_api_data): + service._list = Mock(return_value=[list_api_data]) + + results = list(service.list("my-org")) + + service._list.assert_called_once_with( + "/api/v2/organizations/my-org/cidr-range-lists", params={} + ) + assert len(results) == 1 + assert isinstance(results[0], CIDRRangeList) + assert results[0].id == "crl-xKw8dxQPqVQRZmCe" + assert results[0].enforcement_scope == EnforcementScope.SELECTED_AGENT_POOLS + assert results[0].cidr_ranges[0].id == "cidr-6huHpM7asDp7TaiP" + + def test_list_invalid_org(self, service): + with pytest.raises(InvalidOrgError): + list(service.list("not valid!")) + + def test_create_success(self, service, mock_transport, list_api_data): + mock_response = Mock() + mock_response.json.return_value = {"data": list_api_data} + mock_transport.request.return_value = mock_response + + opts = CIDRRangeListCreateOptions( + name="Office Network", + description="IP ranges for office locations", + enforcement_scope=EnforcementScope.SELECTED_AGENT_POOLS, + ) + result = service.create("my-org", opts) + + mock_transport.request.assert_called_once_with( + "POST", + "/api/v2/organizations/my-org/cidr-range-lists", + json_body={ + "data": { + "type": "cidr-range-lists", + "attributes": { + "name": "Office Network", + "description": "IP ranges for office locations", + "enforcement-scope": "selected_agent_pools", + }, + } + }, + ) + assert isinstance(result, CIDRRangeList) + assert result.id == "crl-xKw8dxQPqVQRZmCe" + + def test_create_invalid_org(self, service): + with pytest.raises(InvalidOrgError): + service.create("not valid!", CIDRRangeListCreateOptions(name="x")) + + def test_read_success(self, service, mock_transport, list_api_data): + mock_response = Mock() + mock_response.json.return_value = {"data": list_api_data} + mock_transport.request.return_value = mock_response + + result = service.read("crl-xKw8dxQPqVQRZmCe") + + mock_transport.request.assert_called_once_with( + "GET", "/api/v2/cidr-range-lists/crl-xKw8dxQPqVQRZmCe" + ) + assert result.name == "Office Network" + + def test_read_invalid_id(self, service): + with pytest.raises(InvalidCIDRRangeListIDError): + service.read("not valid!") + + def test_update_with_scope_single_request( + self, service, mock_transport, list_api_data + ): + """When enforcement_scope is provided, update issues a single PATCH.""" + mock_response = Mock() + mock_response.json.return_value = {"data": list_api_data} + mock_transport.request.return_value = mock_response + + opts = CIDRRangeListUpdateOptions( + name="Updated Office Network", + enforcement_scope=EnforcementScope.ALL_AGENT_POOLS, + ) + service.update("crl-xKw8dxQPqVQRZmCe", opts) + + mock_transport.request.assert_called_once_with( + "PATCH", + "/api/v2/cidr-range-lists/crl-xKw8dxQPqVQRZmCe", + json_body={ + "data": { + "type": "cidr-range-lists", + "attributes": { + "name": "Updated Office Network", + "enforcement-scope": "all_agent_pools", + }, + } + }, + ) + + def test_update_without_scope_preserves_current( + self, service, mock_transport, list_api_data + ): + """When enforcement_scope is omitted, update reads the current scope and + carries it forward (the API rejects a PATCH without enforcement-scope).""" + read_resp = Mock() + read_resp.json.return_value = {"data": list_api_data} # selected_agent_pools + patch_resp = Mock() + patch_resp.json.return_value = {"data": list_api_data} + mock_transport.request.side_effect = [read_resp, patch_resp] + + service.update( + "crl-xKw8dxQPqVQRZmCe", CIDRRangeListUpdateOptions(name="Renamed") + ) + + assert mock_transport.request.call_count == 2 + get_call, patch_call = mock_transport.request.call_args_list + assert get_call.args == ( + "GET", + "/api/v2/cidr-range-lists/crl-xKw8dxQPqVQRZmCe", + ) + assert patch_call.args[0] == "PATCH" + assert patch_call.kwargs["json_body"] == { + "data": { + "type": "cidr-range-lists", + "attributes": { + "name": "Renamed", + "enforcement-scope": "selected_agent_pools", + }, + } + } + + def test_update_invalid_id(self, service): + with pytest.raises(InvalidCIDRRangeListIDError): + service.update("", CIDRRangeListUpdateOptions(name="x")) + + def test_delete_success(self, service, mock_transport): + mock_transport.request.return_value = Mock() + + service.delete("crl-xKw8dxQPqVQRZmCe") + + mock_transport.request.assert_called_once_with( + "DELETE", "/api/v2/cidr-range-lists/crl-xKw8dxQPqVQRZmCe" + ) + + def test_delete_invalid_id(self, service): + with pytest.raises(InvalidCIDRRangeListIDError): + service.delete("") + + # ── CIDR range relationships ────────────────────────────────────────────── + + def test_list_cidr_ranges_success(self, service): + range_data = { + "id": "cidr-6huHpM7asDp7TaiP", + "type": "cidr-ranges", + "attributes": {"range": "192.168.1.0/24"}, + } + service._list = Mock(return_value=[range_data]) + + results = list(service.list_cidr_ranges("crl-xKw8dxQPqVQRZmCe")) + + service._list.assert_called_once_with( + "/api/v2/cidr-range-lists/crl-xKw8dxQPqVQRZmCe/relationships/cidr-ranges" + ) + assert results[0].cidr_block == "192.168.1.0/24" + + def test_list_cidr_ranges_invalid_id(self, service): + with pytest.raises(InvalidCIDRRangeListIDError): + list(service.list_cidr_ranges("")) + + def test_add_cidr_range_success(self, service, mock_transport): + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "id": "cidr-6huHpM7asDp7TaiP", + "type": "cidr-ranges", + "attributes": {"range": "192.168.1.0/24"}, + } + } + mock_transport.request.return_value = mock_response + + result = service.add_cidr_range( + "crl-xKw8dxQPqVQRZmCe", CIDRRangeCreateOptions(cidr_block="192.168.1.0/24") + ) + + mock_transport.request.assert_called_once_with( + "POST", + "/api/v2/cidr-range-lists/crl-xKw8dxQPqVQRZmCe/relationships/cidr-ranges", + json_body={ + "data": { + "type": "cidr-ranges", + "attributes": {"range": "192.168.1.0/24"}, + } + }, + ) + assert isinstance(result, CIDRRange) + assert result.cidr_block == "192.168.1.0/24" + + def test_add_cidr_range_requires_block(self): + with pytest.raises(RequiredCIDRBlockError): + CIDRRangeCreateOptions(cidr_block="") + + def test_add_agent_pools_success(self, service, mock_transport): + mock_transport.request.return_value = Mock() + + service.add_agent_pools("crl-xKw8dxQPqVQRZmCe", ["apool-abc", "apool-def"]) + + mock_transport.request.assert_called_once_with( + "POST", + "/api/v2/cidr-range-lists/crl-xKw8dxQPqVQRZmCe/relationships/agent-pools", + json_body={ + "data": [ + {"type": "agent-pools", "id": "apool-abc"}, + {"type": "agent-pools", "id": "apool-def"}, + ] + }, + ) + + def test_remove_agent_pools_success(self, service, mock_transport): + mock_transport.request.return_value = Mock() + + service.remove_agent_pools("crl-xKw8dxQPqVQRZmCe", ["apool-abc"]) + + mock_transport.request.assert_called_once_with( + "DELETE", + "/api/v2/cidr-range-lists/crl-xKw8dxQPqVQRZmCe/relationships/agent-pools", + json_body={"data": [{"type": "agent-pools", "id": "apool-abc"}]}, + ) + + def test_add_agent_pools_empty_raises(self, service): + with pytest.raises(InvalidAgentPoolIDError): + service.add_agent_pools("crl-xKw8dxQPqVQRZmCe", []) + + def test_add_agent_pools_invalid_id_raises(self, service): + with pytest.raises(InvalidAgentPoolIDError): + service.add_agent_pools("crl-xKw8dxQPqVQRZmCe", ["not valid!"]) + + def test_add_agent_pools_invalid_list_id(self, service): + with pytest.raises(InvalidCIDRRangeListIDError): + service.add_agent_pools("", ["apool-abc"]) + + +class TestCIDRRanges: + """Test the CIDRRanges service class.""" + + @pytest.fixture + def mock_transport(self): + return Mock(spec=HTTPTransport) + + @pytest.fixture + def service(self, mock_transport): + return CIDRRanges(mock_transport) + + @pytest.fixture + def range_api_data(self): + return { + "id": "cidr-6huHpM7asDp7TaiP", + "type": "cidr-ranges", + "attributes": {"range": "192.168.1.0/24"}, + } + + def test_read_success(self, service, mock_transport, range_api_data): + mock_response = Mock() + mock_response.json.return_value = {"data": range_api_data} + mock_transport.request.return_value = mock_response + + result = service.read("cidr-6huHpM7asDp7TaiP") + + mock_transport.request.assert_called_once_with( + "GET", "/api/v2/cidr-ranges/cidr-6huHpM7asDp7TaiP" + ) + assert isinstance(result, CIDRRange) + assert result.cidr_block == "192.168.1.0/24" + + def test_read_invalid_id(self, service): + with pytest.raises(InvalidCIDRRangeIDError): + service.read("not valid!") + + def test_update_success(self, service, mock_transport, range_api_data): + mock_response = Mock() + mock_response.json.return_value = {"data": range_api_data} + mock_transport.request.return_value = mock_response + + service.update( + "cidr-6huHpM7asDp7TaiP", CIDRRangeUpdateOptions(cidr_block="192.168.2.0/24") + ) + + mock_transport.request.assert_called_once_with( + "PATCH", + "/api/v2/cidr-ranges/cidr-6huHpM7asDp7TaiP", + json_body={ + "data": { + "type": "cidr-ranges", + "attributes": {"range": "192.168.2.0/24"}, + } + }, + ) + + def test_update_invalid_id(self, service): + with pytest.raises(InvalidCIDRRangeIDError): + service.update("", CIDRRangeUpdateOptions(cidr_block="10.0.0.0/8")) + + def test_delete_success(self, service, mock_transport): + mock_transport.request.return_value = Mock() + + service.delete("cidr-6huHpM7asDp7TaiP") + + mock_transport.request.assert_called_once_with( + "DELETE", "/api/v2/cidr-ranges/cidr-6huHpM7asDp7TaiP" + ) + + def test_delete_invalid_id(self, service): + with pytest.raises(InvalidCIDRRangeIDError): + service.delete("") diff --git a/tests/units/test_cost_estimate.py b/tests/units/test_cost_estimate.py new file mode 100644 index 00000000..64e43a4c --- /dev/null +++ b/tests/units/test_cost_estimate.py @@ -0,0 +1,122 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for the cost estimates resource.""" + +from unittest.mock import Mock + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.errors import InvalidCostEstimateIDError +from pytfe.models.cost_estimate import CostEstimate, CostEstimateStatus +from pytfe.resources.cost_estimate import CostEstimates + + +class TestCostEstimates: + """Test the CostEstimates service class.""" + + @pytest.fixture + def mock_transport(self): + return Mock(spec=HTTPTransport) + + @pytest.fixture + def service(self, mock_transport): + return CostEstimates(mock_transport) + + @pytest.fixture + def cost_estimate_attrs(self): + # Mirrors the documented response: error-message is null and only the + # timestamps that have occurred are present. + return { + "error-message": None, + "status": "finished", + "status-timestamps": { + "queued-at": "2017-11-29T20:02:17+00:00", + "finished-at": "2017-11-29T20:02:20+00:00", + }, + "resources-count": 4, + "matched-resources-count": 3, + "unmatched-resources-count": 1, + "prior-monthly-cost": "0.0", + "proposed-monthly-cost": "25.488", + "delta-monthly-cost": "25.488", + } + + # ── Model tests ─────────────────────────────────────────────────────────── + + def test_model_parses_partial_timestamps_and_null_error(self, cost_estimate_attrs): + """CostEstimate tolerates null error-message and partial status-timestamps.""" + ce = CostEstimate.model_validate({"id": "ce-1", **cost_estimate_attrs}) + assert ce.id == "ce-1" + assert ce.error_message is None + assert ce.status == CostEstimateStatus.Cost_Estimate_Finished + assert ce.status_timestamps is not None + assert ce.status_timestamps.finished_at is not None + assert ce.status_timestamps.canceled_at is None + + # ── Resource method tests ───────────────────────────────────────────────── + + def test_read_success(self, service, mock_transport, cost_estimate_attrs): + """read() GETs the correct path and returns a CostEstimate.""" + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "id": "ce-BPvFFrYCqRV6qVBK", + "type": "cost-estimates", + "attributes": cost_estimate_attrs, + } + } + mock_transport.request.return_value = mock_response + + result = service.read("ce-BPvFFrYCqRV6qVBK") + + mock_transport.request.assert_called_once_with( + "GET", "/api/v2/cost-estimates/ce-BPvFFrYCqRV6qVBK" + ) + assert isinstance(result, CostEstimate) + assert result.id == "ce-BPvFFrYCqRV6qVBK" + assert result.proposed_monthly_cost == "25.488" + assert result.resources_count == 4 + + def test_read_accepts_array_envelope( + self, service, mock_transport, cost_estimate_attrs + ): + """read() also handles the array-wrapped envelope shown in the docs.""" + mock_response = Mock() + mock_response.json.return_value = { + "data": [ + { + "id": "ce-BPvFFrYCqRV6qVBK", + "type": "cost-estimates", + "attributes": cost_estimate_attrs, + } + ] + } + mock_transport.request.return_value = mock_response + + result = service.read("ce-BPvFFrYCqRV6qVBK") + + assert isinstance(result, CostEstimate) + assert result.id == "ce-BPvFFrYCqRV6qVBK" + + def test_read_invalid_id(self, service): + with pytest.raises(InvalidCostEstimateIDError): + service.read("not valid!") + + def test_logs_success(self, service, mock_transport): + """logs() GETs the /output endpoint and returns its text.""" + mock_response = Mock() + mock_response.text = "cost estimation log output" + mock_transport.request.return_value = mock_response + + result = service.logs("ce-BPvFFrYCqRV6qVBK") + + mock_transport.request.assert_called_once_with( + "GET", "/api/v2/cost-estimates/ce-BPvFFrYCqRV6qVBK/output" + ) + assert result == "cost estimation log output" + + def test_logs_invalid_id(self, service): + with pytest.raises(InvalidCostEstimateIDError): + service.logs("") diff --git a/tests/units/test_hyok_configuration.py b/tests/units/test_hyok_configuration.py new file mode 100644 index 00000000..3b4abc4a --- /dev/null +++ b/tests/units/test_hyok_configuration.py @@ -0,0 +1,245 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for the HYOK configurations resource.""" + +from unittest.mock import Mock + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.errors import ( + InvalidAgentPoolIDError, + InvalidHYOKConfigurationIDError, + InvalidOIDCConfigurationIDError, + InvalidOrgError, + RequiredKEKIDError, + RequiredNameError, +) +from pytfe.models.hyok_configuration import ( + HYOKConfiguration, + HYOKConfigurationCreateOptions, + HYOKConfigurationStatus, + HYOKKMSOptions, + OIDCConfigurationType, +) +from pytfe.resources.hyok_configuration import HYOKConfigurations + + +class TestHYOKConfigurations: + @pytest.fixture + def mock_transport(self): + return Mock(spec=HTTPTransport) + + @pytest.fixture + def service(self, mock_transport): + return HYOKConfigurations(mock_transport) + + @pytest.fixture + def api_data(self): + return { + "id": "hyokc-L4CxAJEEn8vEUEkj", + "type": "hyok-configurations", + "attributes": { + "kek-id": "key1", + "kms-options": {}, + "name": "my-key-name", + "primary": False, + "status": "untested", + "error": None, + }, + "relationships": { + "organization": { + "data": {"id": "my-hyok-org", "type": "organizations"} + }, + "oidc-configuration": { + "data": {"id": "voidc-x", "type": "vault-oidc-configurations"} + }, + "agent-pool": {"data": {"id": "apool-x", "type": "agent-pools"}}, + "hyok-customer-key-versions": {"data": []}, + }, + } + + # ── Options / model ─────────────────────────────────────────────────────── + + def test_create_options_validation(self): + base = { + "kek_id": "key1", + "agent_pool_id": "apool-1", + "oidc_configuration_id": "voidc-1", + "oidc_configuration_type": OIDCConfigurationType.VAULT, + } + with pytest.raises(RequiredNameError): + HYOKConfigurationCreateOptions(name="", **base) + with pytest.raises(RequiredKEKIDError): + HYOKConfigurationCreateOptions( + name="n", + kek_id="", + agent_pool_id="apool-1", + oidc_configuration_id="voidc-1", + oidc_configuration_type=OIDCConfigurationType.VAULT, + ) + with pytest.raises(InvalidAgentPoolIDError): + HYOKConfigurationCreateOptions( + name="n", + kek_id="k", + agent_pool_id="bad id!", + oidc_configuration_id="voidc-1", + oidc_configuration_type=OIDCConfigurationType.VAULT, + ) + with pytest.raises(InvalidOIDCConfigurationIDError): + HYOKConfigurationCreateOptions( + name="n", + kek_id="k", + agent_pool_id="apool-1", + oidc_configuration_id="", + oidc_configuration_type=OIDCConfigurationType.VAULT, + ) + + def test_model_parses_flat_relationship_ids(self, service, api_data): + h = service # noqa: F841 — use the module parser via read below + from pytfe.resources.hyok_configuration import _hyok_from + + result = _hyok_from(api_data) + assert isinstance(result, HYOKConfiguration) + assert result.status == HYOKConfigurationStatus.UNTESTED + assert result.organization_id == "my-hyok-org" + assert result.agent_pool_id == "apool-x" + assert result.oidc_configuration_id == "voidc-x" + assert result.oidc_configuration_type == "vault-oidc-configurations" + assert result.related("oidc-configuration") == [ + {"id": "voidc-x", "type": "vault-oidc-configurations"} + ] + + # ── list ────────────────────────────────────────────────────────────────── + + def test_list_success(self, service, api_data): + service._list = Mock(return_value=[api_data]) + + result = list(service.list("my-hyok-org")) + + service._list.assert_called_once_with( + "/api/v2/organizations/my-hyok-org/hyok-configurations", params={} + ) + assert len(result) == 1 + assert result[0].id == "hyokc-L4CxAJEEn8vEUEkj" + + def test_list_invalid_org(self, service): + with pytest.raises(InvalidOrgError): + list(service.list("bad org!")) + + # ── create ──────────────────────────────────────────────────────────────── + + def test_create_success(self, service, mock_transport, api_data): + mock_response = Mock() + mock_response.json.return_value = {"data": api_data} + mock_transport.request.return_value = mock_response + + opts = HYOKConfigurationCreateOptions( + name="my-key-name", + kek_id="key1", + agent_pool_id="apool-x", + oidc_configuration_id="voidc-x", + oidc_configuration_type=OIDCConfigurationType.VAULT, + primary=False, + kms_options=HYOKKMSOptions(key_region="us-east-1"), + ) + result = service.create("my-hyok-org", opts) + + mock_transport.request.assert_called_once_with( + "POST", + "/api/v2/organizations/my-hyok-org/hyok-configurations", + json_body={ + "data": { + "type": "hyok-configurations", + "attributes": { + "name": "my-key-name", + "kek-id": "key1", + "primary": False, + "kms-options": {"key_region": "us-east-1"}, + }, + "relationships": { + "organization": { + "data": {"type": "organizations", "id": "my-hyok-org"} + }, + "agent-pool": { + "data": {"type": "agent-pools", "id": "apool-x"} + }, + "oidc-configuration": { + "data": { + "type": "vault-oidc-configurations", + "id": "voidc-x", + } + }, + }, + } + }, + ) + assert isinstance(result, HYOKConfiguration) + assert result.id == "hyokc-L4CxAJEEn8vEUEkj" + + def test_create_invalid_org(self, service): + opts = HYOKConfigurationCreateOptions( + name="n", + kek_id="k", + agent_pool_id="apool-1", + oidc_configuration_id="voidc-1", + oidc_configuration_type=OIDCConfigurationType.AWS, + ) + with pytest.raises(InvalidOrgError): + service.create("bad org!", opts) + + # ── read / delete / test ────────────────────────────────────────────────── + + def test_read_success(self, service, mock_transport, api_data): + mock_response = Mock() + mock_response.json.return_value = {"data": api_data} + mock_transport.request.return_value = mock_response + + result = service.read("hyokc-L4CxAJEEn8vEUEkj") + + mock_transport.request.assert_called_once_with( + "GET", "/api/v2/hyok-configurations/hyokc-L4CxAJEEn8vEUEkj" + ) + assert result.name == "my-key-name" + + def test_read_invalid_id(self, service): + with pytest.raises(InvalidHYOKConfigurationIDError): + service.read("bad id!") + + def test_delete_success(self, service, mock_transport): + mock_transport.request.return_value = Mock() + service.delete("hyokc-L4CxAJEEn8vEUEkj") + mock_transport.request.assert_called_once_with( + "DELETE", "/api/v2/hyok-configurations/hyokc-L4CxAJEEn8vEUEkj" + ) + + def test_delete_invalid_id(self, service): + with pytest.raises(InvalidHYOKConfigurationIDError): + service.delete("") + + def test_test_success(self, service, mock_transport): + mock_transport.request.return_value = Mock() + service.test("hyokc-L4CxAJEEn8vEUEkj") + mock_transport.request.assert_called_once_with( + "POST", + "/api/v2/hyok-configurations/hyokc-L4CxAJEEn8vEUEkj/actions/test", + json_body={}, + ) + + def test_test_invalid_id(self, service): + with pytest.raises(InvalidHYOKConfigurationIDError): + service.test("") + + def test_revoke_success(self, service, mock_transport): + mock_transport.request.return_value = Mock() + service.revoke("hyokc-L4CxAJEEn8vEUEkj") + mock_transport.request.assert_called_once_with( + "POST", + "/api/v2/hyok-configurations/hyokc-L4CxAJEEn8vEUEkj/actions/revoke", + json_body={}, + ) + + def test_revoke_invalid_id(self, service): + with pytest.raises(InvalidHYOKConfigurationIDError): + service.revoke("") diff --git a/tests/units/test_introspect.py b/tests/units/test_introspect.py new file mode 100644 index 00000000..86794aa7 --- /dev/null +++ b/tests/units/test_introspect.py @@ -0,0 +1,95 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for the discovery helpers (describe / llms_txt) and lifecycle.""" + +import json + +import pytfe +from pytfe import TFEClient, TFEConfig, describe, llms_txt + + +class TestDescribe: + """Test the machine-readable API manifest.""" + + def test_manifest_shape(self): + """describe() returns the documented top-level shape.""" + m = describe() + assert m["sdk"] == "pytfe" + assert m["client"] == "pytfe.TFEClient" + assert isinstance(m["version"], str) + assert isinstance(m["resources"], dict) + assert m["resource_count"] == len(m["resources"]) + assert m["resource_count"] > 40 + + def test_json_serializable(self): + """The manifest must be JSON-serializable for MCP/tooling consumers.""" + json.dumps(describe()) + + def test_core_resources_have_methods_and_signatures(self): + """Core resources expose verbs with captured signatures + summaries.""" + res = describe()["resources"] + ws = res["workspaces"] + assert ws["class"] == "Workspaces" + assert "list" in ws["methods"] + assert "read" in ws["methods"] + read = ws["methods"]["read"] + assert read["signature"].startswith("(") + assert read["summary"] # one-line docstring captured + + def test_admin_namespace_is_nested(self): + """Grouping namespaces (admin) recurse one level into sub-services.""" + admin = describe()["resources"]["admin"] + assert admin["class"] == "AdminClient" + assert "saml_settings" in admin["namespaces"] + assert admin["namespaces"]["saml_settings"]["methods"] + + def test_no_transport_or_scalar_leak(self): + """The HTTP transport and plain scalars never appear as resources.""" + m = describe() + assert "t" not in m["resources"] + for entry in m["resources"].values(): + assert "t" not in entry.get("namespaces", {}) + # registry holds a plain base_url string that must not become a namespace + assert "namespaces" not in m["resources"]["registry"] + + def test_makes_no_network_calls(self): + """describe() must not require a token or perform any I/O.""" + # Running offline with an empty config is enough; absence of an + # exception here is the assertion. + assert describe()["resource_count"] > 0 + + +class TestLlmsTxt: + """Test the packaged llms.txt orientation guide.""" + + def test_packaged_and_readable(self): + """llms_txt() reads the file shipped inside the package.""" + text = llms_txt() + assert text.startswith("# pytfe") + assert "TFEClient" in text + assert "pytfe.describe()" in text + assert len(text) > 200 + + +class TestExports: + """The discovery helpers are part of the public API.""" + + def test_exported(self): + assert callable(pytfe.describe) + assert callable(pytfe.llms_txt) + assert "describe" in pytfe.__all__ + assert "llms_txt" in pytfe.__all__ + + +class TestClientLifecycle: + """Context-manager support and idempotent close().""" + + def test_context_manager_returns_client(self): + with TFEClient(TFEConfig(address="", token="")) as tfe: + assert isinstance(tfe, TFEClient) + + def test_close_is_idempotent(self): + tfe = TFEClient(TFEConfig(address="", token="")) + tfe.close() + tfe.close() # second call must not raise diff --git a/tests/units/test_invoice.py b/tests/units/test_invoice.py new file mode 100644 index 00000000..ed4d6fe8 --- /dev/null +++ b/tests/units/test_invoice.py @@ -0,0 +1,119 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for the invoices resource.""" + +from unittest.mock import Mock + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.errors import InvalidOrgError +from pytfe.models.invoice import Invoice +from pytfe.resources.invoice import Invoices + + +class TestInvoices: + @pytest.fixture + def mock_transport(self): + return Mock(spec=HTTPTransport) + + @pytest.fixture + def service(self, mock_transport): + return Invoices(mock_transport) + + @staticmethod + def _invoice(iid: str): + return { + "id": iid, + "type": "billing-invoices", + "attributes": { + "created-at": "2021-01-01T19:00:38Z", + "external-link": "https://pay.stripe.com/invoice/x/pdf", + "number": "2F8CA1AE-0006", + "paid": True, + "status": "paid", + "total": 21000, + }, + } + + def test_list_cursor_paginates(self, service, mock_transport): + page1 = Mock() + page1.json.return_value = { + "data": [self._invoice("in_1"), self._invoice("in_2")], + "meta": {"continuation": "in_3"}, + } + page2 = Mock() + page2.json.return_value = { + "data": [self._invoice("in_3")], + "meta": {"continuation": None}, + } + mock_transport.request.side_effect = [page1, page2] + + result = list(service.list("hashicorp")) + + assert [i.id for i in result] == ["in_1", "in_2", "in_3"] + assert all(isinstance(i, Invoice) for i in result) + assert mock_transport.request.call_count == 2 + first, second = mock_transport.request.call_args_list + assert first.args == ("GET", "/api/v2/organizations/hashicorp/invoices") + assert first.kwargs["params"] == {} + # second page sends the continuation cursor + assert second.kwargs["params"] == {"cursor": "in_3"} + + def test_list_single_page(self, service, mock_transport): + resp = Mock() + resp.json.return_value = {"data": [self._invoice("in_1")], "meta": {}} + mock_transport.request.return_value = resp + + result = list(service.list("hashicorp")) + + assert [i.id for i in result] == ["in_1"] + assert mock_transport.request.call_count == 1 + + def test_list_invalid_org(self, service): + with pytest.raises(InvalidOrgError): + list(service.list("bad org!")) + + def test_read_next(self, service, mock_transport): + resp = Mock() + resp.json.return_value = { + "data": { + "id": "in_upcoming_510DEB1F-0002", + "type": "billing-invoices", + "attributes": { + "number": "510DEB1F-0002", + "paid": False, + "status": "draft", + "total": 21000, + }, + } + } + mock_transport.request.return_value = resp + + inv = service.read_next("hashicorp") + + mock_transport.request.assert_called_once_with( + "GET", "/api/v2/organizations/hashicorp/invoices/next" + ) + assert inv.id == "in_upcoming_510DEB1F-0002" + assert inv.paid is False + assert inv.status == "draft" + assert inv.total == 21000 + + def test_read_next_invalid_org(self, service): + with pytest.raises(InvalidOrgError): + service.read_next("bad org!") + + def test_read_next_no_upcoming_invoice(self, service, mock_transport): + """A 200 with a null body (no upcoming invoice) returns None.""" + resp = Mock() + resp.json.return_value = None + mock_transport.request.return_value = resp + assert service.read_next("hashicorp") is None + + def test_read_next_null_data(self, service, mock_transport): + resp = Mock() + resp.json.return_value = {"data": None} + mock_transport.request.return_value = resp + assert service.read_next("hashicorp") is None diff --git a/tests/units/test_ip_ranges.py b/tests/units/test_ip_ranges.py new file mode 100644 index 00000000..3027a762 --- /dev/null +++ b/tests/units/test_ip_ranges.py @@ -0,0 +1,97 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for the IP ranges resource.""" + +from datetime import datetime, timezone +from unittest.mock import Mock + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.models.ip_range import IPRange +from pytfe.resources.ip_ranges import IPRanges + + +class TestIPRanges: + """Test the IPRanges service class.""" + + @pytest.fixture + def mock_transport(self): + return Mock(spec=HTTPTransport) + + @pytest.fixture + def service(self, mock_transport): + return IPRanges(mock_transport) + + @pytest.fixture + def ip_ranges_payload(self): + return { + "api": ["75.2.98.97/32", "99.83.150.238/32"], + "notifications": ["10.0.0.1/32"], + "sentinel": ["192.168.0.1/32"], + "vcs": ["172.16.0.1/32"], + } + + def test_read_success(self, service, mock_transport, ip_ranges_payload): + """read() GETs /api/meta/ip-ranges and parses the bare JSON body.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = ip_ranges_payload + mock_transport.request.return_value = mock_response + + result = service.read() + + mock_transport.request.assert_called_once_with( + "GET", + "/api/meta/ip-ranges", + headers={"Accept": "application/json, */*"}, + allow_redirects=False, + ) + assert isinstance(result, IPRange) + assert result.api == ["75.2.98.97/32", "99.83.150.238/32"] + assert result.notifications == ["10.0.0.1/32"] + assert result.sentinel == ["192.168.0.1/32"] + assert result.vcs == ["172.16.0.1/32"] + + def test_read_not_modified_returns_none(self, service, mock_transport): + """A 304 Not Modified response maps to None.""" + mock_response = Mock() + mock_response.status_code = 304 + mock_transport.request.return_value = mock_response + + result = service.read( + modified_since=datetime(2020, 5, 26, 15, 10, 5, tzinfo=timezone.utc) + ) + + assert result is None + + def test_read_sends_if_modified_since_header( + self, service, mock_transport, ip_ranges_payload + ): + """A modified_since datetime is sent as an RFC1123 If-Modified-Since header.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = ip_ranges_payload + mock_transport.request.return_value = mock_response + + service.read( + modified_since=datetime(2020, 5, 26, 15, 10, 5, tzinfo=timezone.utc) + ) + + _, kwargs = mock_transport.request.call_args + assert kwargs["headers"]["If-Modified-Since"] == "Tue, 26 May 2020 15:10:05 GMT" + + def test_read_naive_datetime_is_treated_as_utc( + self, service, mock_transport, ip_ranges_payload + ): + """A naive datetime is assumed to be UTC when building the header.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = ip_ranges_payload + mock_transport.request.return_value = mock_response + + service.read(modified_since=datetime(2020, 5, 26, 15, 10, 5)) + + _, kwargs = mock_transport.request.call_args + assert kwargs["headers"]["If-Modified-Since"] == "Tue, 26 May 2020 15:10:05 GMT" diff --git a/tests/units/test_organizations.py b/tests/units/test_organizations.py index 57051b8f..6c18d082 100644 --- a/tests/units/test_organizations.py +++ b/tests/units/test_organizations.py @@ -82,3 +82,69 @@ def test_read_with_include_subscription_captures_included( assert sub[0]["attributes"]["plan"] == "plus" # non-breaking: escape hatch never leaks into model_dump() assert "included" not in org.model_dump() + + +class TestOrganizationsEntitlements: + @pytest.fixture + def mock_transport(self): + return Mock(spec=HTTPTransport) + + @pytest.fixture + def service(self, mock_transport): + return Organizations(mock_transport) + + def test_read_entitlements_surfaces_all_flags(self, service, mock_transport): + """Entitlements parsing keeps every flag — modelled fields stay typed and + anything else (e.g. the integer ``*-limit`` flags) is retained in + ``model_extra`` instead of being silently dropped.""" + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "id": "entset-1", + "type": "entitlement-sets", + "attributes": { + # existing modelled flags (hyphenated on the wire) + "agents": True, + "audit-logging": False, + "cost-estimation": True, + # newly modelled flags + "hyok": True, + "stacks": True, + "terraform-actions": True, + "change-requests": False, + # unmodelled flags -> must be retained in model_extra + "self-serve-billing": True, + "policy-limit": 5, + "user-limit": None, + }, + } + } + mock_transport.request.return_value = mock_response + + ent = service.read_entitlements("acme") + + mock_transport.request.assert_called_once_with( + "GET", "/api/v2/organizations/acme/entitlement-set" + ) + # existing typed fields (hyphen -> underscore) unchanged + assert ent.agents is True + assert ent.audit_logging is False + assert ent.cost_estimation is True + # newly typed fields + assert ent.hyok is True + assert ent.stacks is True + assert ent.terraform_actions is True + assert ent.change_requests is False + # previously-dropped flags now retained in model_extra + extra = ent.model_extra or {} + assert extra.get("self_serve_billing") is True + assert extra.get("policy_limit") == 5 + assert "user_limit" in extra + # and they survive a round-trip dump + dumped = ent.model_dump() + assert dumped["hyok"] is True + assert dumped["policy_limit"] == 5 + + def test_read_entitlements_invalid_org(self, service): + with pytest.raises(ValueError): + service.read_entitlements("bad org!") diff --git a/tests/units/test_plan_export.py b/tests/units/test_plan_export.py new file mode 100644 index 00000000..a86ed784 --- /dev/null +++ b/tests/units/test_plan_export.py @@ -0,0 +1,169 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for the plan exports resource.""" + +from unittest.mock import Mock + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.errors import InvalidPlanExportIDError, RequiredPlanError +from pytfe.models.plan_export import ( + PlanExport, + PlanExportCreateOptions, + PlanExportDataType, + PlanExportStatus, +) +from pytfe.resources.plan_export import PlanExports + + +class TestPlanExports: + """Test the PlanExports service class.""" + + @pytest.fixture + def mock_transport(self): + return Mock(spec=HTTPTransport) + + @pytest.fixture + def service(self, mock_transport): + return PlanExports(mock_transport) + + @pytest.fixture + def plan_export_api_data(self): + return { + "id": "pe-3yVQZvHzf5j3WRJ1", + "type": "plan-exports", + "attributes": { + "data-type": "sentinel-mock-bundle-v0", + "status": "finished", + "status-timestamps": { + "queued-at": "2019-03-04T22:29:53+00:00", + "finished-at": "2019-03-04T22:29:58+00:00", + "expired-at": "2019-03-04T23:29:58+00:00", + }, + }, + "relationships": { + "plan": {"data": {"id": "plan-8F5JFydVYAmtTjET", "type": "plans"}} + }, + } + + # ── Model / options tests ───────────────────────────────────────────────── + + def test_create_options_defaults_data_type(self): + """data_type defaults to the only supported export format.""" + opts = PlanExportCreateOptions(plan_id="plan-abc123") + assert opts.plan_id == "plan-abc123" + assert opts.data_type == PlanExportDataType.SENTINEL_MOCK_BUNDLE_V0 + + def test_create_options_invalid_plan_raises(self): + """An empty plan_id raises RequiredPlanError at construction.""" + with pytest.raises(RequiredPlanError): + PlanExportCreateOptions(plan_id="") + + # ── Resource method tests ───────────────────────────────────────────────── + + def test_create_success(self, service, mock_transport, plan_export_api_data): + """create() POSTs the JSON:API payload and returns a PlanExport.""" + mock_response = Mock() + mock_response.json.return_value = {"data": plan_export_api_data} + mock_transport.request.return_value = mock_response + + opts = PlanExportCreateOptions(plan_id="plan-8F5JFydVYAmtTjET") + result = service.create(opts) + + mock_transport.request.assert_called_once_with( + "POST", + "/api/v2/plan-exports", + json_body={ + "data": { + "type": "plan-exports", + "attributes": {"data-type": "sentinel-mock-bundle-v0"}, + "relationships": { + "plan": { + "data": {"type": "plans", "id": "plan-8F5JFydVYAmtTjET"} + } + }, + } + }, + ) + assert isinstance(result, PlanExport) + assert result.id == "pe-3yVQZvHzf5j3WRJ1" + assert result.data_type == PlanExportDataType.SENTINEL_MOCK_BUNDLE_V0 + assert result.status == PlanExportStatus.FINISHED + + def test_read_success(self, service, mock_transport, plan_export_api_data): + """read() GETs the correct path and returns a PlanExport.""" + mock_response = Mock() + mock_response.json.return_value = {"data": plan_export_api_data} + mock_transport.request.return_value = mock_response + + result = service.read("pe-3yVQZvHzf5j3WRJ1") + + mock_transport.request.assert_called_once_with( + "GET", "/api/v2/plan-exports/pe-3yVQZvHzf5j3WRJ1" + ) + assert isinstance(result, PlanExport) + assert result.id == "pe-3yVQZvHzf5j3WRJ1" + assert result.status_timestamps is not None + assert result.status_timestamps.expired_at is not None + # The plan relationship is captured losslessly via TFEModel. + assert result.related("plan") == [ + {"id": "plan-8F5JFydVYAmtTjET", "type": "plans"} + ] + + def test_read_invalid_id(self, service): + with pytest.raises(InvalidPlanExportIDError): + service.read("not valid!") + + def test_delete_success(self, service, mock_transport): + """delete() issues a DELETE to the correct path.""" + mock_transport.request.return_value = Mock() + + service.delete("pe-3yVQZvHzf5j3WRJ1") + + mock_transport.request.assert_called_once_with( + "DELETE", "/api/v2/plan-exports/pe-3yVQZvHzf5j3WRJ1" + ) + + def test_delete_invalid_id(self, service): + with pytest.raises(InvalidPlanExportIDError): + service.delete("") + + def test_download_follows_redirect_without_auth(self, service, mock_transport): + """download() follows the 302 to the presigned URL without forwarding auth.""" + redirect_resp = Mock() + redirect_resp.status_code = 302 + redirect_resp.headers = {"Location": "https://blob.example/export.tar.gz?sig=x"} + blob_resp = Mock() + blob_resp.status_code = 200 + blob_resp.content = b"tarball-bytes" + mock_transport.request.side_effect = [redirect_resp, blob_resp] + + result = service.download("pe-3yVQZvHzf5j3WRJ1") + + assert result == b"tarball-bytes" + assert mock_transport.request.call_count == 2 + first_call, second_call = mock_transport.request.call_args_list + assert first_call.args == ( + "GET", + "/api/v2/plan-exports/pe-3yVQZvHzf5j3WRJ1/download", + ) + assert first_call.kwargs == {"allow_redirects": False} + assert second_call.args == ("GET", "https://blob.example/export.tar.gz?sig=x") + assert second_call.kwargs == {"include_auth": False} + + def test_download_inline_content(self, service, mock_transport): + """download() returns the body directly when there is no redirect.""" + resp = Mock() + resp.status_code = 200 + resp.content = b"inline-bytes" + mock_transport.request.return_value = resp + + result = service.download("pe-3yVQZvHzf5j3WRJ1") + + assert result == b"inline-bytes" + + def test_download_invalid_id(self, service): + with pytest.raises(InvalidPlanExportIDError): + service.download("") diff --git a/tests/units/test_registry.py b/tests/units/test_registry.py new file mode 100644 index 00000000..34963478 --- /dev/null +++ b/tests/units/test_registry.py @@ -0,0 +1,215 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for the public Terraform Registry resource.""" + +from unittest.mock import Mock + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.errors import ( + InvalidModuleNameError, + InvalidModuleNamespaceError, + InvalidModuleProviderError, + InvalidModuleVersionError, + RequiredQueryError, + TFEError, +) +from pytfe.models.registry import ( + PublicRegistryModule, + PublicRegistryModuleDownloadsSummary, + PublicRegistryModuleVersions, + PublicRegistrySearchOptions, +) +from pytfe.resources.registry import DEFAULT_REGISTRY_URL, Registry + + +class TestRegistry: + @pytest.fixture + def mock_transport(self): + return Mock(spec=HTTPTransport) + + @pytest.fixture + def service(self, mock_transport): + return Registry(mock_transport) + + @staticmethod + def _json(payload): + r = Mock() + r.json.return_value = payload + return r + + # ── Construction ────────────────────────────────────────────────────────── + + def test_default_base_url(self, service): + assert service.base_url == DEFAULT_REGISTRY_URL + + def test_custom_base_url_strips_trailing_slash(self, mock_transport): + svc = Registry(mock_transport, base_url="https://reg.example.com/") + assert svc.base_url == "https://reg.example.com" + + # ── list / pagination / auth ────────────────────────────────────────────── + + def test_list_modules_paginates_and_strips_auth(self, service, mock_transport): + page1 = self._json( + { + "meta": {"next_offset": 2}, + "modules": [{"id": "a/b/c/1"}, {"id": "a/b/c/2"}], + } + ) + page2 = self._json({"meta": {}, "modules": [{"id": "a/b/c/3"}]}) + mock_transport.request.side_effect = [page1, page2] + + result = list(service.list_modules("acme")) + + assert [m.id for m in result] == ["a/b/c/1", "a/b/c/2", "a/b/c/3"] + assert mock_transport.request.call_count == 2 + first = mock_transport.request.call_args_list[0] + assert first.args == ("GET", "https://registry.terraform.io/v1/modules/acme") + assert first.kwargs["include_auth"] is False + assert first.kwargs["headers"] == {"Accept": "application/json"} + # second page carries the offset from meta.next_offset + assert mock_transport.request.call_args_list[1].kwargs["params"]["offset"] == 2 + + def test_list_modules_no_namespace_uses_root_path(self, service, mock_transport): + mock_transport.request.return_value = self._json({"meta": {}, "modules": []}) + list(service.list_modules()) + assert mock_transport.request.call_args.args == ( + "GET", + "https://registry.terraform.io/v1/modules", + ) + + def test_list_modules_invalid_namespace(self, service): + with pytest.raises(InvalidModuleNamespaceError): + list(service.list_modules("bad namespace")) + + def test_search_requires_query(self, service): + with pytest.raises(RequiredQueryError): + list(service.search_modules("")) + + def test_search_sends_q_and_lowercases_bool(self, service, mock_transport): + mock_transport.request.return_value = self._json({"meta": {}, "modules": []}) + + list( + service.search_modules( + "vpc", PublicRegistrySearchOptions(provider="aws", verified=True) + ) + ) + + params = mock_transport.request.call_args.kwargs["params"] + assert params["q"] == "vpc" + assert params["provider"] == "aws" + assert params["verified"] == "true" # bool lowercased for the wire + assert mock_transport.request.call_args.args == ( + "GET", + "https://registry.terraform.io/v1/modules/search", + ) + + # ── single reads ────────────────────────────────────────────────────────── + + def test_latest_for_provider(self, service, mock_transport): + mock_transport.request.return_value = self._json( + {"id": "hashicorp/consul/aws/1.0.0", "name": "consul", "providers": ["aws"]} + ) + m = service.latest_for_provider("hashicorp", "consul", "aws") + assert isinstance(m, PublicRegistryModule) + assert m.id == "hashicorp/consul/aws/1.0.0" + assert mock_transport.request.call_args.args == ( + "GET", + "https://registry.terraform.io/v1/modules/hashicorp/consul/aws", + ) + + def test_get_module(self, service, mock_transport): + mock_transport.request.return_value = self._json( + {"id": "hashicorp/consul/aws/0.0.1"} + ) + m = service.get_module("hashicorp", "consul", "aws", "0.0.1") + assert m.version is None and m.id == "hashicorp/consul/aws/0.0.1" + assert mock_transport.request.call_args.args == ( + "GET", + "https://registry.terraform.io/v1/modules/hashicorp/consul/aws/0.0.1", + ) + + def test_list_versions_returns_first_module(self, service, mock_transport): + mock_transport.request.return_value = self._json( + { + "modules": [ + { + "source": "hashicorp/consul/aws", + "versions": [{"version": "0.0.1"}, {"version": "0.0.2"}], + } + ] + } + ) + v = service.list_versions("hashicorp", "consul", "aws") + assert isinstance(v, PublicRegistryModuleVersions) + assert v.source == "hashicorp/consul/aws" + assert [x.version for x in v.versions] == ["0.0.1", "0.0.2"] + + def test_list_versions_empty(self, service, mock_transport): + mock_transport.request.return_value = self._json({"modules": []}) + v = service.list_versions("hashicorp", "consul", "aws") + assert v.versions == [] + + def test_read_invalid_coordinates(self, service): + with pytest.raises(InvalidModuleNamespaceError): + service.latest_for_provider("", "consul", "aws") + with pytest.raises(InvalidModuleNameError): + service.latest_for_provider("hashicorp", "", "aws") + with pytest.raises(InvalidModuleProviderError): + service.latest_for_provider("hashicorp", "consul", "") + with pytest.raises(InvalidModuleVersionError): + service.get_module("hashicorp", "consul", "aws", "") + + # ── downloads ───────────────────────────────────────────────────────────── + + def test_download_url_reads_header(self, service, mock_transport): + resp = Mock() + resp.headers = {"X-Terraform-Get": "git::https://example.com/repo"} + mock_transport.request.return_value = resp + + url = service.download_url("hashicorp", "consul", "aws", "0.0.1") + + assert url == "git::https://example.com/repo" + assert mock_transport.request.call_args.args == ( + "GET", + "https://registry.terraform.io/v1/modules/hashicorp/consul/aws/0.0.1/download", + ) + + def test_latest_download_url_reads_header(self, service, mock_transport): + resp = Mock() + resp.headers = {"X-Terraform-Get": "git::https://example.com/repo?ref=v1"} + mock_transport.request.return_value = resp + + url = service.latest_download_url("hashicorp", "consul", "aws") + + assert url == "git::https://example.com/repo?ref=v1" + + def test_download_url_missing_header_raises(self, service, mock_transport): + resp = Mock() + resp.headers = {} + mock_transport.request.return_value = resp + with pytest.raises(TFEError): + service.download_url("hashicorp", "consul", "aws", "0.0.1") + + # ── downloads summary ───────────────────────────────────────────────────── + + def test_downloads_summary(self, service, mock_transport): + mock_transport.request.return_value = self._json( + { + "data": { + "type": "module-downloads-summary", + "id": "hashicorp/consul/aws", + "attributes": {"week": 1, "month": 2, "year": 3, "total": 4}, + } + } + ) + s = service.downloads_summary("hashicorp", "consul", "aws") + assert isinstance(s, PublicRegistryModuleDownloadsSummary) + assert s.id == "hashicorp/consul/aws" + assert (s.week, s.month, s.year, s.total) == (1, 2, 3, 4) + assert mock_transport.request.call_args.args == ( + "GET", + "https://registry.terraform.io/v2/modules/hashicorp/consul/aws/downloads/summary", + ) diff --git a/tests/units/test_subscription.py b/tests/units/test_subscription.py new file mode 100644 index 00000000..69241298 --- /dev/null +++ b/tests/units/test_subscription.py @@ -0,0 +1,91 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for the subscriptions resource.""" + +from unittest.mock import Mock + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.errors import InvalidOrgError, InvalidSubscriptionIDError +from pytfe.models.subscription import Subscription +from pytfe.resources.subscription import Subscriptions + + +class TestSubscriptions: + @pytest.fixture + def mock_transport(self): + return Mock(spec=HTTPTransport) + + @pytest.fixture + def service(self, mock_transport): + return Subscriptions(mock_transport) + + @pytest.fixture + def api_payload(self): + return { + "data": { + "id": "sub-kyjptCZYXQ6amEVu", + "type": "subscriptions", + "attributes": { + "is-active": True, + "start-at": "2021-01-20T07:03:53.492Z", + "end-at": None, + "runs-ceiling": 1, + "agents-ceiling": 0, + "is-public-free-tier": True, + "policy-limit": None, + }, + "relationships": { + "organization": { + "data": {"id": "hashicorp", "type": "organizations"} + }, + "feature-set": {"data": {"id": "fs-1", "type": "feature-sets"}}, + "billing-account": {"data": None}, + }, + }, + "included": [ + {"id": "fs-1", "type": "feature-sets", "attributes": {"name": "Free"}} + ], + } + + def test_read_for_organization(self, service, mock_transport, api_payload): + mock_response = Mock() + mock_response.json.return_value = api_payload + mock_transport.request.return_value = mock_response + + sub = service.read_for_organization("hashicorp") + + mock_transport.request.assert_called_once_with( + "GET", "/api/v2/organizations/hashicorp/subscription" + ) + assert isinstance(sub, Subscription) + assert sub.id == "sub-kyjptCZYXQ6amEVu" + assert sub.is_active is True + assert sub.runs_ceiling == 1 + assert sub.organization_id == "hashicorp" + assert sub.feature_set_id == "fs-1" + assert sub.billing_account_id is None + # feature set is hydrated from `included` + assert sub.related("feature-set")[0]["attributes"]["name"] == "Free" + + def test_read_for_organization_invalid_org(self, service): + with pytest.raises(InvalidOrgError): + service.read_for_organization("bad org!") + + def test_read_by_id(self, service, mock_transport, api_payload): + mock_response = Mock() + mock_response.json.return_value = api_payload + mock_transport.request.return_value = mock_response + + sub = service.read("sub-kyjptCZYXQ6amEVu") + + mock_transport.request.assert_called_once_with( + "GET", "/api/v2/subscriptions/sub-kyjptCZYXQ6amEVu" + ) + assert sub.id == "sub-kyjptCZYXQ6amEVu" + + def test_read_invalid_id(self, service): + with pytest.raises(InvalidSubscriptionIDError): + service.read("not valid!") diff --git a/tests/units/test_transport.py b/tests/units/test_transport.py index 40996b4f..8b285635 100644 --- a/tests/units/test_transport.py +++ b/tests/units/test_transport.py @@ -1,15 +1,17 @@ # Copyright IBM Corp. 2025, 2026 # SPDX-License-Identifier: MPL-2.0 +import httpx + from pytfe._http import HTTPTransport from pytfe.config import TFEConfig -def test_http_transport_init(): +def _make_transport() -> HTTPTransport: cfg = TFEConfig() - t = HTTPTransport( + return HTTPTransport( cfg.address, - "", + "tok", timeout=cfg.timeout, verify_tls=cfg.verify_tls, user_agent_suffix=None, @@ -21,4 +23,31 @@ def test_http_transport_init(): proxies=None, ca_bundle=None, ) + + +def test_http_transport_init(): + t = _make_transport() assert t.base.startswith("https://") + + +def test_request_does_not_persist_cookies(): + """A Set-Cookie in a response must not leak into subsequent requests. + + ``/api/meta/ip-ranges`` returns an ``_atlas_session_data`` session cookie; + if the shared client retained it, that session would override bearer auth + on later requests and the API would respond 404/401. + """ + t = _make_transport() + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + headers={"set-cookie": "_atlas_session_data=abc123; path=/"}, + json={"api": []}, + ) + + t._sync = httpx.Client(transport=httpx.MockTransport(handler)) + + t.request("GET", "/api/meta/ip-ranges") + + assert dict(t._sync.cookies) == {}