From 6d2f02b156bd32b4e8ae7e5df2e4a5700334479f Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Fri, 19 Jun 2026 04:10:08 +0530 Subject: [PATCH 01/14] add new features --- CHANGELOG.md | 12 + docs/api-coverage.md | 11 +- examples/cidr_range_list.py | 106 +++++++ examples/cost_estimate.py | 84 ++++++ examples/ip_ranges.py | 59 ++++ examples/plan_export.py | 109 +++++++ src/pytfe/_http.py | 7 + src/pytfe/client.py | 11 + src/pytfe/errors.py | 45 +++ src/pytfe/models/__init__.py | 52 ++++ src/pytfe/models/cidr_range_list.py | 145 ++++++++++ src/pytfe/models/cost_estimate.py | 19 +- src/pytfe/models/ip_range.py | 24 ++ src/pytfe/models/plan_export.py | 74 ++++- src/pytfe/resources/cidr_range_list.py | 243 ++++++++++++++++ src/pytfe/resources/cost_estimate.py | 53 ++++ src/pytfe/resources/ip_ranges.py | 43 +++ src/pytfe/resources/plan_export.py | 79 +++++ tests/units/test_cidr_range_list.py | 386 +++++++++++++++++++++++++ tests/units/test_cost_estimate.py | 122 ++++++++ tests/units/test_ip_ranges.py | 97 +++++++ tests/units/test_plan_export.py | 169 +++++++++++ tests/units/test_transport.py | 35 ++- 23 files changed, 1967 insertions(+), 18 deletions(-) create mode 100644 examples/cidr_range_list.py create mode 100644 examples/cost_estimate.py create mode 100644 examples/ip_ranges.py create mode 100644 examples/plan_export.py create mode 100644 src/pytfe/models/cidr_range_list.py create mode 100644 src/pytfe/models/ip_range.py create mode 100644 src/pytfe/resources/cidr_range_list.py create mode 100644 src/pytfe/resources/cost_estimate.py create mode 100644 src/pytfe/resources/ip_ranges.py create mode 100644 src/pytfe/resources/plan_export.py create mode 100644 tests/units/test_cidr_range_list.py create mode 100644 tests/units/test_cost_estimate.py create mode 100644 tests/units/test_ip_ranges.py create mode 100644 tests/units/test_plan_export.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 62a9ffbf..c512a902 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,12 @@ 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.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`. + ## Bug Fixes ### Relationships @@ -19,6 +25,12 @@ 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. + # Released # v1.1.0 diff --git a/docs/api-coverage.md b/docs/api-coverage.md index 459f98c2..f4e035a0 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 **66 resource namespaces**. The resources still missing or partially covered are listed at the bottom of this page. ## Covered resources @@ -27,6 +27,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 +41,9 @@ 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` | ✅ | | | Comments | `client.comments` | ✅ | | | Query runs | `client.query_runs` | ✅ | | | State versions | `client.state_versions` | ✅ | @@ -75,6 +79,7 @@ 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` | ✅ | +| 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` | ✅ | @@ -96,15 +101,11 @@ Public HCP Terraform API resources that do not yet have a pytfe client namespace | 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 | 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..7c3f84b7 --- /dev/null +++ b/examples/cost_estimate.py @@ -0,0 +1,84 @@ +# 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/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..692add84 --- /dev/null +++ b/examples/plan_export.py @@ -0,0 +1,109 @@ +# 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/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/client.py b/src/pytfe/client.py index 63a9b751..44a90264 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -9,10 +9,13 @@ from .resources.agent_pools import AgentPools from .resources.agents import Agents, AgentTokens from .resources.apply import Applies +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.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 +33,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 @@ -109,6 +113,13 @@ 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) + # 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) self.organization_memberships = OrganizationMemberships(self._transport) self.organization_audit_configurations = OrganizationAuditConfigurations( diff --git a/src/pytfe/errors.py b/src/pytfe/errors.py index 7e4842f3..8224ec7f 100644 --- a/src/pytfe/errors.py +++ b/src/pytfe/errors.py @@ -435,6 +435,51 @@ 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) + + +# 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) + + # Run Event errors class InvalidRunEventIDError(InvalidValues): """Raised when an invalid run event ID is provided.""" diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index d12eb344..731091f8 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,9 @@ GitHubAppInstallationType, ) +# ── IP Ranges ───────────────────────────────────────────────────────────────── +from .ip_range import IPRange + # ── Notification Configurations ─────────────────────────────────────────────── from .no_code_module import ( NoCodeModule, @@ -247,6 +269,15 @@ OrganizationTokenReadOptions, TokenType, ) + +# ── Plan Exports ────────────────────────────────────────────────────────────── +from .plan_export import ( + PlanExport, + PlanExportCreateOptions, + PlanExportDataType, + PlanExportStatus, + PlanExportStatusTimestamps, +) from .policy import ( Policy, PolicyCreateOptions, @@ -985,6 +1016,27 @@ # Comments "Comment", "CommentCreateOptions", + # Cost estimates + "CostEstimate", + "CostEstimateStatus", + "CostEstimateStatusTimestamps", + # Plan exports + "PlanExport", + "PlanExportCreateOptions", + "PlanExportDataType", + "PlanExportStatus", + "PlanExportStatusTimestamps", + # IP ranges + "IPRange", + # IP allowlists (CIDR range lists) + "CIDRRange", + "CIDRRangeCreateOptions", + "CIDRRangeList", + "CIDRRangeListCreateOptions", + "CIDRRangeListListOptions", + "CIDRRangeListUpdateOptions", + "CIDRRangeUpdateOptions", + "EnforcementScope", # Run tasks "RunTask", "RunTaskIncludeOptions", 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/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/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/resources/cidr_range_list.py b/src/pytfe/resources/cidr_range_list.py new file mode 100644 index 00000000..6dcffdd8 --- /dev/null +++ b/src/pytfe/resources/cidr_range_list.py @@ -0,0 +1,243 @@ +# 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 the IP allowlists for an organization.""" + 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.""" + 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.""" + 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.""" + 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.""" + 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 the CIDR ranges attached to an IP allowlist.""" + 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.""" + 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.""" + 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 the association between agent pools and an IP allowlist.""" + 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.""" + 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.""" + 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.""" + 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/cost_estimate.py b/src/pytfe/resources/cost_estimate.py new file mode 100644 index 00000000..f18696fa --- /dev/null +++ b/src/pytfe/resources/cost_estimate.py @@ -0,0 +1,53 @@ +# 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``. + """ + 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 (its ``/output``) as text. + + Logs are produced once the estimate finishes running; reading them + before then may return an empty body. + """ + 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/ip_ranges.py b/src/pytfe/resources/ip_ranges.py new file mode 100644 index 00000000..5f7eeb0a --- /dev/null +++ b/src/pytfe/resources/ip_ranges.py @@ -0,0 +1,43 @@ +# 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``. + """ + 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/plan_export.py b/src/pytfe/resources/plan_export.py new file mode 100644 index 00000000..3cba73fd --- /dev/null +++ b/src/pytfe/resources/plan_export.py @@ -0,0 +1,79 @@ +# 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: + """Export a plan. The plan and data-type are supplied via ``options``.""" + 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.""" + 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.""" + 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's data as a ``.tar.gz`` archive (bytes). + + The endpoint 302-redirects to a temporary, presigned URL for the + archive. That URL self-authorises via query parameters, so the API + bearer must not be forwarded to the (cross-origin) blob host — this + mirrors go-tfe, whose HTTP client also strips auth on the cross-host hop. + """ + 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/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_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_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_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) == {} From 6d80e0088b9e187e1b8051a998130336f5240add Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Fri, 19 Jun 2026 04:29:15 +0530 Subject: [PATCH 02/14] Add public registry and bump version --- CHANGELOG.md | 3 +- README.md | 2 +- docs/api-coverage.md | 3 +- docs/api/index.md | 10 +- docs/api/registry.md | 125 +++++++++++++++++ examples/registry.py | 85 ++++++++++++ pyproject.toml | 2 +- src/pytfe/client.py | 3 + src/pytfe/errors.py | 29 ++++ src/pytfe/models/__init__.py | 29 ++++ src/pytfe/models/registry.py | 158 +++++++++++++++++++++ src/pytfe/resources/registry.py | 234 ++++++++++++++++++++++++++++++++ tests/units/test_registry.py | 212 +++++++++++++++++++++++++++++ 13 files changed, 890 insertions(+), 5 deletions(-) create mode 100644 docs/api/registry.md create mode 100644 examples/registry.py create mode 100644 src/pytfe/models/registry.py create mode 100644 src/pytfe/resources/registry.py create mode 100644 tests/units/test_registry.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c512a902..f4fc35e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,5 @@ # Unreleased - +# v1.2.0 ## Enhancements ### Relationships @@ -17,6 +17,7 @@ Related data is now a complete, first-class part of every response. Before, `?in * 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`. ## Bug Fixes diff --git a/README.md b/README.md index 97913f57..09b4c0e1 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,7 @@ and upstream HCP Terraform API docs. | Need | Start here | |---|---| | Configure the SDK | [Authentication](./docs/authentication.md), [Pagination](./docs/pagination.md), [Logging](./docs/LOGGING.md) | -| API guides | [API index](./docs/api/index.md), [API coverage](./docs/api-coverage.md), [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) | +| 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 f4e035a0..10f1ffb1 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 **66 resource namespaces**. The resources still missing or +pytfe implements **67 resource namespaces**. The resources still missing or partially covered are listed at the bottom of this page. ## Covered resources @@ -65,6 +65,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` | ✅ | diff --git a/docs/api/index.md b/docs/api/index.md index c4a04093..79e7bb26 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -75,12 +75,19 @@ 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) | @@ -123,6 +130,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/registry.py b/examples/registry.py new file mode 100644 index 00000000..763e279c --- /dev/null +++ b/examples/registry.py @@ -0,0 +1,85 @@ +# 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..c013efe7 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" } diff --git a/src/pytfe/client.py b/src/pytfe/client.py index 44a90264..18d0368a 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -43,6 +43,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 @@ -142,6 +143,8 @@ 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) diff --git a/src/pytfe/errors.py b/src/pytfe/errors.py index 8224ec7f..4f3aad5d 100644 --- a/src/pytfe/errors.py +++ b/src/pytfe/errors.py @@ -480,6 +480,35 @@ 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/models/__init__.py b/src/pytfe/models/__init__.py index 731091f8..ba82ea42 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -359,6 +359,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 ( @@ -791,6 +807,19 @@ "Output", "ProviderDependency", "PublishingMechanism", + # Public Terraform Registry (module API) + "PublicRegistryModule", + "PublicRegistryModuleDetail", + "PublicRegistryModuleDownloadsSummary", + "PublicRegistryModuleInput", + "PublicRegistryModuleListOptions", + "PublicRegistryModuleOutput", + "PublicRegistryModuleProviderDependency", + "PublicRegistryModuleResource", + "PublicRegistryModuleVersion", + "PublicRegistryModuleVersions", + "PublicRegistryPagination", + "PublicRegistrySearchOptions", "RegistryModule", "RegistryModuleCreateOptions", "RegistryModuleCreateVersionOptions", 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/resources/registry.py b/src/pytfe/resources/registry.py new file mode 100644 index 00000000..6520f6f4 --- /dev/null +++ b/src/pytfe/resources/registry.py @@ -0,0 +1,234 @@ +# 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 registry modules, optionally restricted to a ``namespace``.""" + 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 registry modules by keyword/phrase (``q``).""" + 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 the latest version of a module for each of its providers.""" + 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 version of a module for a single provider.""" + 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 version of a module for a single provider.""" + 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 the available versions for a fully-qualified module. + + Returns the requested module (the API always lists it first); any + dependency modules the registry also returns are not included. + """ + 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: + """Return a module version's source location (the ``X-Terraform-Get`` value). + + The value is a go-getter URL string, not the archive bytes. + """ + 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: + """Return the latest version's source location (``X-Terraform-Get``). + + The endpoint 302-redirects to the versioned download; the redirect is + followed automatically. + """ + 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 (week/month/year/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/tests/units/test_registry.py b/tests/units/test_registry.py new file mode 100644 index 00000000..80f04d81 --- /dev/null +++ b/tests/units/test_registry.py @@ -0,0 +1,212 @@ +# 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", + ) From de331035b32bd923cc8c161b4da50315a6ea5cfd Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Fri, 19 Jun 2026 04:49:28 +0530 Subject: [PATCH 03/14] add assesment result --- CHANGELOG.md | 5 +- docs/api-coverage.md | 4 +- docs/api/index.md | 1 + examples/assessment_result.py | 89 ++++++++++++++ src/pytfe/client.py | 3 + src/pytfe/errors.py | 8 ++ src/pytfe/models/assessment_result.py | 6 +- src/pytfe/resources/assessment_result.py | 128 ++++++++++++++++++++ tests/units/test_assessment_result.py | 148 +++++++++++++++++++++++ 9 files changed, 386 insertions(+), 6 deletions(-) create mode 100644 examples/assessment_result.py create mode 100644 src/pytfe/resources/assessment_result.py create mode 100644 tests/units/test_assessment_result.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f4fc35e0..805db448 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Unreleased + # v1.2.0 + ## Enhancements ### Relationships @@ -18,6 +20,7 @@ Related data is now a complete, first-class part of every response. Before, `?in * 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`. ## Bug Fixes @@ -29,8 +32,6 @@ Related data is now a complete, first-class part of every response. Before, `?in ### 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. # Released # v1.1.0 diff --git a/docs/api-coverage.md b/docs/api-coverage.md index 10f1ffb1..e03091cf 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 **67 resource namespaces**. The resources still missing or +pytfe implements **68 resource namespaces**. The resources still missing or partially covered are listed at the bottom of this page. ## Covered resources @@ -44,6 +44,7 @@ partially covered are listed at the bottom of this page. | | 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` | ✅ | @@ -99,7 +100,6 @@ 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 | — | | Feature sets | Organization feature sets. | diff --git a/docs/api/index.md b/docs/api/index.md index 79e7bb26..4440e4e3 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) | 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/src/pytfe/client.py b/src/pytfe/client.py index 18d0368a..70ff0ac4 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -9,6 +9,7 @@ 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 @@ -116,6 +117,8 @@ def __init__(self, config: TFEConfig | None = None): 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) diff --git a/src/pytfe/errors.py b/src/pytfe/errors.py index 4f3aad5d..4a0578f9 100644 --- a/src/pytfe/errors.py +++ b/src/pytfe/errors.py @@ -458,6 +458,14 @@ 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) + + # IP allowlist (CIDR range list) errors class InvalidCIDRRangeListIDError(InvalidValues): """Raised when an invalid CIDR range list (IP allowlist) ID is provided.""" 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/resources/assessment_result.py b/src/pytfe/resources/assessment_result.py new file mode 100644 index 00000000..e5769987 --- /dev/null +++ b/src/pytfe/resources/assessment_result.py @@ -0,0 +1,128 @@ +# 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 its ID.""" + 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: + """Return the JSON plan output for an assessment result. + + Only available once the assessment has succeeded and produced JSON + output. Returns ``None`` when output is not yet ready (HTTP 204); the + transport raises (e.g. ``NotFound``) when the assessment produced no + output, such as when it did not succeed. Requires a user/team token with + workspace admin access. + """ + 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: + """Return the JSON provider schema for an assessment result. + + Returns ``None`` when the schema is not yet ready (HTTP 204); the + transport raises when the assessment produced no schema (e.g. it did not + succeed). Requires a user/team token with workspace admin access. + """ + 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: + """Return the Terraform JSON log output for an assessment result as text. + + Returns an empty string when there is no log output yet (HTTP 204). + Requires a user/team token with workspace admin access. + """ + 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/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("") From c2831a53fb58b63232fd430c1909e6d3c72d54a6 Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Fri, 19 Jun 2026 04:53:55 +0530 Subject: [PATCH 04/14] update lint --- examples/cost_estimate.py | 8 +++++--- examples/plan_export.py | 4 +--- examples/registry.py | 10 +++++++--- src/pytfe/resources/assessment_result.py | 4 +--- src/pytfe/resources/cidr_range_list.py | 7 ++----- src/pytfe/resources/registry.py | 22 +++++++++++----------- tests/units/test_registry.py | 5 ++++- 7 files changed, 31 insertions(+), 29 deletions(-) diff --git a/examples/cost_estimate.py b/examples/cost_estimate.py index 7c3f84b7..1c4eacf1 100644 --- a/examples/cost_estimate.py +++ b/examples/cost_estimate.py @@ -64,9 +64,11 @@ def main(): 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"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}") diff --git a/examples/plan_export.py b/examples/plan_export.py index 692add84..f0b93e88 100644 --- a/examples/plan_export.py +++ b/examples/plan_export.py @@ -18,9 +18,7 @@ def _print_header(title: str): def main(): - parser = argparse.ArgumentParser( - description="Plan exports demo for python-tfe SDK" - ) + parser = argparse.ArgumentParser(description="Plan exports demo for python-tfe SDK") parser.add_argument( "--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io") ) diff --git a/examples/registry.py b/examples/registry.py index 763e279c..b41aa539 100644 --- a/examples/registry.py +++ b/examples/registry.py @@ -52,7 +52,9 @@ def main() -> int: 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): + 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 @@ -75,8 +77,10 @@ def main() -> int: _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}") + print( + f" week={summary.week} month={summary.month} " + f"year={summary.year} total={summary.total}" + ) return 0 diff --git a/src/pytfe/resources/assessment_result.py b/src/pytfe/resources/assessment_result.py index e5769987..0f46ff20 100644 --- a/src/pytfe/resources/assessment_result.py +++ b/src/pytfe/resources/assessment_result.py @@ -44,9 +44,7 @@ def read(self, assessment_result_id: str) -> AssessmentResult: """Read an assessment result by its ID.""" if not valid_string_id(assessment_result_id): raise InvalidAssessmentResultIDError() - r = self.t.request( - "GET", f"/api/v2/assessment-results/{assessment_result_id}" - ) + 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 diff --git a/src/pytfe/resources/cidr_range_list.py b/src/pytfe/resources/cidr_range_list.py index 6dcffdd8..337c9a57 100644 --- a/src/pytfe/resources/cidr_range_list.py +++ b/src/pytfe/resources/cidr_range_list.py @@ -135,8 +135,7 @@ def list_cidr_ranges(self, cidr_range_list_id: str) -> Iterator[CIDRRange]: 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" + f"/api/v2/cidr-range-lists/{cidr_range_list_id}/relationships/cidr-ranges" ) for item in self._list(path): yield _cidr_range_from(item) @@ -216,9 +215,7 @@ def read(self, cidr_range_id: str) -> CIDRRange: body = r.json() return _cidr_range_from(body["data"], body.get("included")) - def update( - self, cidr_range_id: str, options: CIDRRangeUpdateOptions - ) -> CIDRRange: + def update(self, cidr_range_id: str, options: CIDRRangeUpdateOptions) -> CIDRRange: """Update a CIDR range by its ID.""" if not valid_string_id(cidr_range_id): raise InvalidCIDRRangeIDError() diff --git a/src/pytfe/resources/registry.py b/src/pytfe/resources/registry.py index 6520f6f4..e0b74cff 100644 --- a/src/pytfe/resources/registry.py +++ b/src/pytfe/resources/registry.py @@ -57,7 +57,9 @@ def _query_params(options: Any) -> dict[str, Any]: 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()} + return { + k: (str(v).lower() if isinstance(v, bool) else v) for k, v in dumped.items() + } class Registry(_Service): @@ -75,7 +77,11 @@ def __init__(self, t: HTTPTransport, base_url: str | None = None) -> None: 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 + self, + path: str, + *, + params: dict[str, Any] | None = None, + allow_redirects: bool = True, ) -> Any: return self.t.request( "GET", @@ -148,9 +154,7 @@ def get_module( ) -> PublicRegistryModule: """Read a specific version of a module for a single provider.""" self._validate(namespace, name, provider, version) - body = self._get( - f"/v1/modules/{namespace}/{name}/{provider}/{version}" - ).json() + body = self._get(f"/v1/modules/{namespace}/{name}/{provider}/{version}").json() return PublicRegistryModule.model_validate(body) def list_versions( @@ -162,9 +166,7 @@ def list_versions( dependency modules the registry also returns are not included. """ self._validate(namespace, name, provider) - body = self._get( - f"/v1/modules/{namespace}/{name}/{provider}/versions" - ).json() + 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() @@ -183,9 +185,7 @@ def download_url( ) return self._x_terraform_get(resp) - def latest_download_url( - self, namespace: str, name: str, provider: str - ) -> str: + def latest_download_url(self, namespace: str, name: str, provider: str) -> str: """Return the latest version's source location (``X-Terraform-Get``). The endpoint 302-redirects to the versioned download; the redirect is diff --git a/tests/units/test_registry.py b/tests/units/test_registry.py index 80f04d81..34963478 100644 --- a/tests/units/test_registry.py +++ b/tests/units/test_registry.py @@ -53,7 +53,10 @@ def test_custom_base_url_strips_trailing_slash(self, mock_transport): 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"}]} + { + "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] From 5598fc6fc1d54a55bdadcf112f4847e4688e0e65 Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Fri, 19 Jun 2026 05:42:30 +0530 Subject: [PATCH 05/14] Add support for hyok --- CHANGELOG.md | 1 + docs/api-coverage.md | 10 +- docs/api/index.md | 1 + examples/hyok_configuration.py | 124 +++++++++++ src/pytfe/client.py | 3 + src/pytfe/errors.py | 15 ++ src/pytfe/models/__init__.py | 17 ++ src/pytfe/models/hyok_configuration.py | 128 +++++++++++ src/pytfe/resources/hyok_configuration.py | 171 +++++++++++++++ tests/units/test_hyok_configuration.py | 245 ++++++++++++++++++++++ 10 files changed, 712 insertions(+), 3 deletions(-) create mode 100644 examples/hyok_configuration.py create mode 100644 src/pytfe/models/hyok_configuration.py create mode 100644 src/pytfe/resources/hyok_configuration.py create mode 100644 tests/units/test_hyok_configuration.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 805db448..853ed415 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Related data is now a complete, first-class part of every response. Before, `?in * 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`. ## Bug Fixes diff --git a/docs/api-coverage.md b/docs/api-coverage.md index e03091cf..348f8f38 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 **68 resource namespaces**. The resources still missing or +pytfe implements **69 resource namespaces**. The resources still missing or partially covered are listed at the bottom of this page. ## Covered resources @@ -81,6 +81,7 @@ 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` | ✅ | @@ -100,7 +101,6 @@ Public HCP Terraform API resources that do not yet have a pytfe client namespace | Resource | Notes | |---|---| -| Audit trails tokens | Auth tokens for the audit-trail streaming API. | | Change requests | — | | Feature sets | Organization feature sets. | | GPG keys | Private Registry provider signing keys. | @@ -116,7 +116,6 @@ Public HCP Terraform API resources that do not yet have a pytfe client namespace | 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 | — | @@ -124,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 4440e4e3..843ad8a3 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -94,6 +94,7 @@ column. | `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) | 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/src/pytfe/client.py b/src/pytfe/client.py index 70ff0ac4..0ecd21a9 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -16,6 +16,7 @@ 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.ip_ranges import IPRanges from .resources.no_code_module import NoCodeModules from .resources.notification_configuration import NotificationConfigurations @@ -154,6 +155,8 @@ def __init__(self, config: TFEConfig | None = None): 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) diff --git a/src/pytfe/errors.py b/src/pytfe/errors.py index 4a0578f9..4c352d6a 100644 --- a/src/pytfe/errors.py +++ b/src/pytfe/errors.py @@ -466,6 +466,21 @@ 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) + + # IP allowlist (CIDR range list) errors class InvalidCIDRRangeListIDError(InvalidValues): """Raised when an invalid CIDR range list (IP allowlist) ID is provided.""" diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index ba82ea42..d5f2804b 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -156,6 +156,16 @@ GitHubAppInstallationType, ) +# ── HYOK configurations ─────────────────────────────────────────────────────── +from .hyok_configuration import ( + HYOKConfiguration, + HYOKConfigurationCreateOptions, + HYOKConfigurationListOptions, + HYOKConfigurationStatus, + HYOKKMSOptions, + OIDCConfigurationType, +) + # ── IP Ranges ───────────────────────────────────────────────────────────────── from .ip_range import IPRange @@ -1057,6 +1067,13 @@ "PlanExportStatusTimestamps", # IP ranges "IPRange", + # HYOK configurations + "HYOKConfiguration", + "HYOKConfigurationCreateOptions", + "HYOKConfigurationListOptions", + "HYOKConfigurationStatus", + "HYOKKMSOptions", + "OIDCConfigurationType", # IP allowlists (CIDR range lists) "CIDRRange", "CIDRRangeCreateOptions", 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/resources/hyok_configuration.py b/src/pytfe/resources/hyok_configuration.py new file mode 100644 index 00000000..67d658bd --- /dev/null +++ b/src/pytfe/resources/hyok_configuration.py @@ -0,0 +1,171 @@ +# 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.""" + 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.""" + 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.""" + 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``). + """ + 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 (stop using its key). + + Triggers an async revocation (HTTP 202); poll ``read(...).status`` until + it reaches ``revoked``. A configuration must be revoked before it can be + deleted. + """ + 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``). + """ + 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/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("") From 1101c2ed9d3023be48ebe71779d395f7f8d6e3e2 Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Fri, 19 Jun 2026 05:57:56 +0530 Subject: [PATCH 06/14] fix model for entitlement --- CHANGELOG.md | 6 +++ src/pytfe/models/organization.py | 14 ++++++ src/pytfe/resources/organizations.py | 25 +++-------- tests/units/test_organizations.py | 66 ++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 853ed415..df90e5d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,12 @@ Related data is now a complete, first-class part of every response. Before, `?in ### 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/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/resources/organizations.py b/src/pytfe/resources/organizations.py index 20af1e6a..4617000f 100644 --- a/src/pytfe/resources/organizations.py +++ b/src/pytfe/resources/organizations.py @@ -233,25 +233,12 @@ 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 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!") From 89121c815dc6b69d3cff01201b18ca4e7df5352c Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Fri, 19 Jun 2026 06:08:19 +0530 Subject: [PATCH 07/14] Add invoice and subscription --- CHANGELOG.md | 2 + docs/api-coverage.md | 6 +- docs/api/index.md | 2 + examples/billing.py | 70 ++++++++++++++++ src/pytfe/client.py | 5 ++ src/pytfe/errors.py | 8 ++ src/pytfe/models/__init__.py | 9 +++ src/pytfe/models/invoice.py | 33 ++++++++ src/pytfe/models/subscription.py | 52 ++++++++++++ src/pytfe/resources/invoice.py | 72 +++++++++++++++++ src/pytfe/resources/subscription.py | 66 +++++++++++++++ tests/units/test_invoice.py | 119 ++++++++++++++++++++++++++++ tests/units/test_subscription.py | 91 +++++++++++++++++++++ 13 files changed, 532 insertions(+), 3 deletions(-) create mode 100644 examples/billing.py create mode 100644 src/pytfe/models/invoice.py create mode 100644 src/pytfe/models/subscription.py create mode 100644 src/pytfe/resources/invoice.py create mode 100644 src/pytfe/resources/subscription.py create mode 100644 tests/units/test_invoice.py create mode 100644 tests/units/test_subscription.py diff --git a/CHANGELOG.md b/CHANGELOG.md index df90e5d0..8817a54a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ Related data is now a complete, first-class part of every response. Before, `?in * `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`. diff --git a/docs/api-coverage.md b/docs/api-coverage.md index 348f8f38..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 **69 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` | ✅ | @@ -105,7 +107,6 @@ Public HCP Terraform API resources that do not yet have a pytfe client namespace | Feature sets | Organization feature sets. | | GPG keys | Private Registry provider signing keys. | | Group member roles | Team member role assignments. | -| Invoices | Organization billing invoices. | | Metrics service tokens | Metrics endpoint service tokens. | | Stack configuration summary | Builds on the existing stack_configuration resource | | Stack deployment | Core Stacks deployment lifecycle | @@ -115,7 +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. | | Terraform actions | Only the Run `invoke_action_addrs` field today; no dedicated resource. | | User tokens | Personal (user) API tokens. | | VCS events | — | diff --git a/docs/api/index.md b/docs/api/index.md index 843ad8a3..b99f5d8d 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -51,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 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/src/pytfe/client.py b/src/pytfe/client.py index 0ecd21a9..500add82 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -17,6 +17,7 @@ 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 @@ -61,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 @@ -126,6 +128,9 @@ def __init__(self, config: TFEConfig | None = None): 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 diff --git a/src/pytfe/errors.py b/src/pytfe/errors.py index 4c352d6a..b7546305 100644 --- a/src/pytfe/errors.py +++ b/src/pytfe/errors.py @@ -481,6 +481,14 @@ 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.""" diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index d5f2804b..bf7d6afc 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -166,6 +166,9 @@ OIDCConfigurationType, ) +# ── Invoices (billing) ──────────────────────────────────────────────────────── +from .invoice import Invoice + # ── IP Ranges ───────────────────────────────────────────────────────────────── from .ip_range import IPRange @@ -552,6 +555,9 @@ StateVersionOutputsListOptions, ) +# ── Subscriptions (billing) ─────────────────────────────────────────────────── +from .subscription import Subscription + # ── Task Result ─────────────────────────────────────────────────────────────── from .task_result import ( TaskEnforcementLevel as TaskResultEnforcementLevel, @@ -1067,6 +1073,9 @@ "PlanExportStatusTimestamps", # IP ranges "IPRange", + # Billing + "Invoice", + "Subscription", # HYOK configurations "HYOKConfiguration", "HYOKConfigurationCreateOptions", 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/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/invoice.py b/src/pytfe/resources/invoice.py new file mode 100644 index 00000000..fa86bad9 --- /dev/null +++ b/src/pytfe/resources/invoice.py @@ -0,0 +1,72 @@ +# 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 (cursor-paginated).""" + 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. + + Returns ``None`` when there is no upcoming invoice (for example, the + organization is not on a credit-card-billed plan) — the API responds + ``200`` with a ``null`` body in that case. + """ + 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/subscription.py b/src/pytfe/resources/subscription.py new file mode 100644 index 00000000..769d96c0 --- /dev/null +++ b/src/pytfe/resources/subscription.py @@ -0,0 +1,66 @@ +# 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.""" + 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.""" + 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/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_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!") From eada90f8aab59cc4ff5eab29343c0c09a3742084 Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Fri, 19 Jun 2026 07:27:49 +0530 Subject: [PATCH 08/14] add more files to sdist --- pyproject.toml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c013efe7..832b44a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] From 1053990725498c9a54fc0936af9ceaa32ce27866 Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Fri, 19 Jun 2026 07:49:55 +0530 Subject: [PATCH 09/14] Add AI tooling based changes --- CHANGELOG.md | 12 +++ pyproject.toml | 2 +- src/pytfe/__init__.py | 3 + src/pytfe/_introspect.py | 145 +++++++++++++++++++++++++++++++++ src/pytfe/client.py | 50 ++++++++++++ src/pytfe/llms.txt | 74 +++++++++++++++++ tests/units/test_introspect.py | 95 +++++++++++++++++++++ 7 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 src/pytfe/_introspect.py create mode 100644 src/pytfe/llms.txt create mode 100644 tests/units/test_introspect.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8817a54a..8e8cfba9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,18 @@ Related data is now a complete, first-class part of every response. Before, `?in * 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, MCP servers, and tooling +The installed package is now self-describing, so a consumer can enumerate 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()`. + + ## Bug Fixes ### Relationships diff --git a/pyproject.toml b/pyproject.toml index 832b44a7..1d7d182e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] 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/_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 500add82..14536448 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -78,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, @@ -201,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/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/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 From a0a1fa8c586c4bee155a70b4b095a2a5f9f0a7e8 Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Fri, 19 Jun 2026 10:40:18 +0530 Subject: [PATCH 10/14] Add helper class for LLM --- CHANGELOG.md | 28 ++++- src/pytfe/__init__.py | 3 +- src/pytfe/_introspect.py | 203 ++++++++++++++++++++++++++++++++- src/pytfe/llms.txt | 11 +- tests/units/test_introspect.py | 77 ++++++++++++- 5 files changed, 314 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e8cfba9..c7478e5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,9 +25,10 @@ Related data is now a complete, first-class part of every response. Before, `?in * 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, MCP servers, and tooling -The installed package is now self-describing, so a consumer can enumerate the -SDK without hardcoding resource names or browsing the GitHub repo. +### 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 @@ -35,6 +36,27 @@ SDK without hardcoding resource names or browsing the GitHub repo. 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.tool_schemas()` returns tool-calling definitions — + `{name, resource, method, description, input_schema}` per method, where + `input_schema` is a JSON Schema object composed of the method's positional + identifiers plus its `*Options` model. Network-free and JSON-serializable, for + use with an MCP server or any LLM tool framework, and generated from the + installed package so new/updated resources appear automatically. The + definitions cover the whole SDK surface — including destructive, file-upload, + and secret-bearing methods — so a consumer that *executes* them should default + to read-only, allowlist mutations, and avoid logging secret arguments; + `tool_schemas()` itself only describes the surface and never makes a call. +* `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. + +### 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 diff --git a/src/pytfe/__init__.py b/src/pytfe/__init__.py index 7ccf2e29..22c5c0b9 100644 --- a/src/pytfe/__init__.py +++ b/src/pytfe/__init__.py @@ -5,7 +5,7 @@ from importlib.metadata import version as _pkg_version from . import errors, models -from ._introspect import describe, llms_txt +from ._introspect import describe, llms_txt, tool_schemas from ._logging import setup_logging from .client import TFEClient from .config import TFEConfig @@ -23,5 +23,6 @@ "setup_logging", "describe", "llms_txt", + "tool_schemas", "__version__", ] diff --git a/src/pytfe/_introspect.py b/src/pytfe/_introspect.py index c8dfb03b..e6928534 100644 --- a/src/pytfe/_introspect.py +++ b/src/pytfe/_introspect.py @@ -16,7 +16,12 @@ from __future__ import annotations +import collections.abc as cabc +import enum import inspect +import types +import typing +from collections.abc import Iterable, Iterator from importlib.resources import files from typing import Any @@ -24,7 +29,7 @@ from ._http import HTTPTransport -__all__ = ["describe", "llms_txt"] +__all__ = ["describe", "llms_txt", "tool_schemas"] def _summary(obj: Any) -> str | None: @@ -136,6 +141,202 @@ def describe() -> dict[str, Any]: client.close() +_PRIMITIVE_SCHEMAS: dict[type, str] = { + str: "string", + bool: "boolean", + int: "integer", + float: "number", +} + + +def _is_optional(tp: Any) -> bool: + """True if the annotation is ``X | None`` / ``Optional[X]``.""" + union_types = (typing.Union, getattr(types, "UnionType", None)) + if typing.get_origin(tp) in union_types: + return type(None) in typing.get_args(tp) + return False + + +def _py_type_to_schema(tp: Any) -> dict[str, Any]: + """Best-effort JSON Schema for a Python type annotation. + + Pydantic ``*Options`` models are embedded via ``model_json_schema()``; common + containers, enums, unions, and primitives are mapped; anything unrecognised + becomes an unconstrained ``{}`` (valid JSON Schema for "any"). + """ + if tp is Any or tp is None or tp is type(None): + return {} + + origin = typing.get_origin(tp) + args = typing.get_args(tp) + + union_types = (typing.Union, getattr(types, "UnionType", None)) + if origin in union_types: + non_none = [a for a in args if a is not type(None)] + if len(non_none) == 1: + return _py_type_to_schema(non_none[0]) + return {"anyOf": [_py_type_to_schema(a) for a in non_none]} + + if origin in (list, tuple, set, frozenset) or ( + isinstance(origin, type) + and issubclass(origin, cabc.Sequence) + and origin not in (str, bytes) + ): + item = args[0] if args else Any + return {"type": "array", "items": _py_type_to_schema(item)} + + if origin is dict or ( + isinstance(origin, type) and issubclass(origin, cabc.Mapping) + ): + return {"type": "object"} + + if isinstance(tp, type): + if issubclass(tp, BaseModel): + return tp.model_json_schema() + if issubclass(tp, enum.Enum): + return {"type": "string", "enum": [e.value for e in tp]} + if tp in _PRIMITIVE_SCHEMAS: + return {"type": _PRIMITIVE_SCHEMAS[tp]} + + return {} + + +def _inline_refs(schema: dict[str, Any]) -> dict[str, Any]: + """Dereference all ``$ref``/``$defs`` into a self-contained schema. + + LLM tool-calling layers behind many MCP clients reject ``$ref``/``$defs`` + outright, so we inline them. Recursive models are broken with a permissive + ``{"type": "object"}`` placeholder to avoid infinite expansion. + """ + defs: dict[str, Any] = schema.get("$defs", {}) + + def resolve(node: Any, seen: frozenset[str]) -> Any: + if isinstance(node, dict): + ref = node.get("$ref") + if isinstance(ref, str) and ref.startswith("#/$defs/"): + name = ref[len("#/$defs/") :] + if name in seen: + return {"type": "object"} + target = defs.get(name) + if not isinstance(target, dict): + return {} + return resolve(target, seen | {name}) + return {k: resolve(v, seen) for k, v in node.items() if k != "$defs"} + if isinstance(node, list): + return [resolve(item, seen) for item in node] + return node + + result: dict[str, Any] = resolve(schema, frozenset()) + return result + + +def _method_input_schema(method: Any) -> dict[str, Any]: + """Build a self-contained JSON Schema describing a method's call arguments. + + Embedded Pydantic ``*Options`` models are inlined (no ``$ref``/``$defs``) for + maximum MCP-client compatibility. + """ + func = getattr(method, "__func__", method) + try: + hints = typing.get_type_hints(func) + except Exception: + hints = {} + + properties: dict[str, Any] = {} + required: list[str] = [] + defs: dict[str, Any] = {} + for pname, param in inspect.signature(method).parameters.items(): + if pname == "self" or param.kind in ( + param.VAR_POSITIONAL, + param.VAR_KEYWORD, + ): + continue + tp = hints.get(pname, Any) + prop = _py_type_to_schema(tp) + if isinstance(prop, dict) and "$defs" in prop: + prop = dict(prop) + defs.update(prop.pop("$defs")) + properties[pname] = prop + has_default = param.default is not inspect.Parameter.empty + if not has_default and not _is_optional(tp): + required.append(pname) + + schema: dict[str, Any] = {"type": "object", "properties": properties} + if required: + schema["required"] = required + schema["additionalProperties"] = False + if defs: + schema["$defs"] = defs + return _inline_refs(schema) + + +def _iter_methods(obj: Any, prefix: str) -> Iterator[tuple[str, str, Any]]: + """Yield ``(dotted_name, method_name, bound_method)`` for a resource tree.""" + cls = type(obj) + for name, member in inspect.getmembers(obj, callable): + if name.startswith("_") or not hasattr(cls, name): + continue + yield (f"{prefix}.{name}", name, member) + for sub_name, sub in sorted(vars(obj).items()): + if sub_name.startswith("_") or not _service_like(sub): + continue + yield from _iter_methods(sub, f"{prefix}.{sub_name}") + + +def tool_schemas(*, resources: Iterable[str] | None = None) -> list[dict[str, Any]]: + """Return MCP-style tool definitions for every resource method. + + Each entry is ``{"name", "resource", "method", "description", "input_schema"}`` + where ``input_schema`` is a JSON Schema object composed of the method's + positional identifiers plus its Pydantic ``*Options`` model (embedded via + ``model_json_schema()``). Nested namespaces use dotted names, e.g. + ``"admin.saml_settings.read"``. + + No network calls are made and the result is JSON-serializable, so it can back + an MCP server (``Tool(name, description, inputSchema)``) or any LLM tool + framework. Because the schemas come from the *installed* package, a newly + added or updated resource appears automatically — no hand-maintained list. + + Security: the output describes the *entire* SDK surface, including + destructive methods (``delete``, ``force_cancel``), endpoints that upload a + local path to an arbitrary URL (``configuration_versions.upload``), and + options that carry secrets (tokens, passwords, SAML/OAuth keys). + ``tool_schemas()`` only describes the surface and never makes a call, but a + consumer that *executes* these calls from model output should not expose + them unrestricted: default to read-only methods, gate mutations behind an + allowlist or human confirmation, constrain upload hosts/paths, and keep + secret-bearing arguments out of logs and transcripts. + + Pass ``resources={"workspaces", "runs"}`` to limit output to specific + top-level namespaces. + """ + from .client import TFEClient + from .config import TFEConfig + + wanted = set(resources) if resources is not None else None + client = TFEClient(TFEConfig(address="", token="")) + try: + out: list[dict[str, Any]] = [] + for name, obj in sorted(vars(client).items()): + if name.startswith("_") or not _service_like(obj): + continue + if wanted is not None and name not in wanted: + continue + for dotted, method_name, member in _iter_methods(obj, name): + out.append( + { + "name": dotted, + "resource": name, + "method": method_name, + "description": _summary(member) or "", + "input_schema": _method_input_schema(member), + } + ) + return out + finally: + client.close() + + def llms_txt() -> str: """Return the packaged ``llms.txt`` orientation guide as text. diff --git a/src/pytfe/llms.txt b/src/pytfe/llms.txt index a498c0d7..71dfba86 100644 --- a/src/pytfe/llms.txt +++ b/src/pytfe/llms.txt @@ -54,9 +54,16 @@ through a namespaced attribute and call standard verbs on it: list(manifest["resources"]) # every resource namespace manifest["resources"]["runs"]["methods"] # methods + signatures + summaries + tools = pytfe.tool_schemas() # [{name, resource, method, description, input_schema}, ...] + # input_schema is JSON Schema (ids + the *Options model via model_json_schema()). + # Covers the whole SDK surface, incl. destructive / file-upload / secret-bearing + # methods. If you EXECUTE these from model output, default to read-only, allowlist + # mutations, confirm deletes, and keep secret-bearing args out of logs/transcripts. + print(pytfe.llms_txt()) # this document, from the installed package -`pytfe.describe()` makes no network calls; it introspects the client wiring. +`pytfe.describe()` and `pytfe.tool_schemas()` make no network calls; they +introspect the client wiring. ## Top-level exports @@ -65,7 +72,7 @@ through a namespaced attribute and call standard verbs on it: - `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). +- `describe`, `tool_schemas`, `llms_txt` — discovery helpers (this section). ## More (GitHub repository, not shipped in the wheel) diff --git a/tests/units/test_introspect.py b/tests/units/test_introspect.py index 86794aa7..06ffd3ed 100644 --- a/tests/units/test_introspect.py +++ b/tests/units/test_introspect.py @@ -6,7 +6,7 @@ import json import pytfe -from pytfe import TFEClient, TFEConfig, describe, llms_txt +from pytfe import TFEClient, TFEConfig, describe, llms_txt, tool_schemas class TestDescribe: @@ -60,6 +60,79 @@ def test_makes_no_network_calls(self): assert describe()["resource_count"] > 0 +class TestToolSchemas: + """Test the MCP-style tool-schema generator.""" + + def test_returns_tool_specs(self): + """tool_schemas() returns a non-empty list of well-formed specs.""" + tools = tool_schemas() + assert isinstance(tools, list) + assert len(tools) > 100 + sample = tools[0] + for key in ("name", "resource", "method", "description", "input_schema"): + assert key in sample + assert sample["input_schema"]["type"] == "object" + + def test_json_serializable(self): + """Specs must be JSON-serializable for MCP/tooling consumers.""" + json.dumps(tool_schemas()) + + def test_identifiers_and_options_composed(self): + """A create method exposes its id args plus the embedded *Options schema.""" + byname = {t["name"]: t for t in tool_schemas()} + create = byname["workspaces.create"] + props = create["input_schema"]["properties"] + assert set(create["input_schema"]["required"]) == {"organization", "options"} + assert props["organization"] == {"type": "string"} + # the WorkspaceCreateOptions Pydantic model is embedded as JSON Schema + options_schema = props["options"] + assert options_schema.get("type") == "object" + assert "properties" in options_schema or "$ref" in options_schema + + def test_optional_param_not_required(self): + """A param with a default (e.g. options=None on list) is not required.""" + byname = {t["name"]: t for t in tool_schemas()} + list_tool = byname["workspaces.list"] + required = list_tool["input_schema"].get("required", []) + assert "organization" in required + assert "options" not in required + + def test_nested_admin_dotted_names(self): + """Grouping namespaces produce dotted tool names with the top resource.""" + admin = [t for t in tool_schemas() if t["name"].startswith("admin.")] + assert admin + assert all(t["resource"] == "admin" for t in admin) + assert all(t["name"].count(".") >= 2 for t in admin) + + def test_resource_filter(self): + """The resources filter limits output to the named namespaces.""" + only = tool_schemas(resources={"workspaces"}) + assert only + assert all(t["resource"] == "workspaces" for t in only) + assert len(only) < len(tool_schemas()) + + def test_schemas_are_self_contained(self): + """Input schemas must be fully inlined: no $ref/$defs. + + LLM tool-calling layers behind many MCP clients reject `$ref`/`$defs`, + so every embedded model must be inlined into a self-contained schema. + """ + + def _has_key(node, key): + if isinstance(node, dict): + if key in node: + return True + return any(_has_key(v, key) for v in node.values()) + if isinstance(node, list): + return any(_has_key(item, key) for item in node) + return False + + for spec in tool_schemas(): + schema = spec["input_schema"] + assert not _has_key(schema, "$ref"), f"$ref left in {spec['name']}" + assert not _has_key(schema, "$defs"), f"$defs left in {spec['name']}" + + class TestLlmsTxt: """Test the packaged llms.txt orientation guide.""" @@ -78,8 +151,10 @@ class TestExports: def test_exported(self): assert callable(pytfe.describe) assert callable(pytfe.llms_txt) + assert callable(pytfe.tool_schemas) assert "describe" in pytfe.__all__ assert "llms_txt" in pytfe.__all__ + assert "tool_schemas" in pytfe.__all__ class TestClientLifecycle: From 49fa2d3629e5dbbfeb7d48dfe8938aea4ca1b259 Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Fri, 19 Jun 2026 11:23:42 +0530 Subject: [PATCH 11/14] removing tool_schemas --- CHANGELOG.md | 10 -- src/pytfe/__init__.py | 3 +- src/pytfe/_introspect.py | 203 +-------------------------------- src/pytfe/llms.txt | 11 +- tests/units/test_introspect.py | 77 +------------ 5 files changed, 5 insertions(+), 299 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7478e5f..4cdd80ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,16 +36,6 @@ drive the SDK without hardcoding resource names or browsing the GitHub repo. 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.tool_schemas()` returns tool-calling definitions — - `{name, resource, method, description, input_schema}` per method, where - `input_schema` is a JSON Schema object composed of the method's positional - identifiers plus its `*Options` model. Network-free and JSON-serializable, for - use with an MCP server or any LLM tool framework, and generated from the - installed package so new/updated resources appear automatically. The - definitions cover the whole SDK surface — including destructive, file-upload, - and secret-bearing methods — so a consumer that *executes* them should default - to read-only, allowlist mutations, and avoid logging secret arguments; - `tool_schemas()` itself only describes the surface and never makes a call. * `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, diff --git a/src/pytfe/__init__.py b/src/pytfe/__init__.py index 22c5c0b9..7ccf2e29 100644 --- a/src/pytfe/__init__.py +++ b/src/pytfe/__init__.py @@ -5,7 +5,7 @@ from importlib.metadata import version as _pkg_version from . import errors, models -from ._introspect import describe, llms_txt, tool_schemas +from ._introspect import describe, llms_txt from ._logging import setup_logging from .client import TFEClient from .config import TFEConfig @@ -23,6 +23,5 @@ "setup_logging", "describe", "llms_txt", - "tool_schemas", "__version__", ] diff --git a/src/pytfe/_introspect.py b/src/pytfe/_introspect.py index e6928534..c8dfb03b 100644 --- a/src/pytfe/_introspect.py +++ b/src/pytfe/_introspect.py @@ -16,12 +16,7 @@ from __future__ import annotations -import collections.abc as cabc -import enum import inspect -import types -import typing -from collections.abc import Iterable, Iterator from importlib.resources import files from typing import Any @@ -29,7 +24,7 @@ from ._http import HTTPTransport -__all__ = ["describe", "llms_txt", "tool_schemas"] +__all__ = ["describe", "llms_txt"] def _summary(obj: Any) -> str | None: @@ -141,202 +136,6 @@ def describe() -> dict[str, Any]: client.close() -_PRIMITIVE_SCHEMAS: dict[type, str] = { - str: "string", - bool: "boolean", - int: "integer", - float: "number", -} - - -def _is_optional(tp: Any) -> bool: - """True if the annotation is ``X | None`` / ``Optional[X]``.""" - union_types = (typing.Union, getattr(types, "UnionType", None)) - if typing.get_origin(tp) in union_types: - return type(None) in typing.get_args(tp) - return False - - -def _py_type_to_schema(tp: Any) -> dict[str, Any]: - """Best-effort JSON Schema for a Python type annotation. - - Pydantic ``*Options`` models are embedded via ``model_json_schema()``; common - containers, enums, unions, and primitives are mapped; anything unrecognised - becomes an unconstrained ``{}`` (valid JSON Schema for "any"). - """ - if tp is Any or tp is None or tp is type(None): - return {} - - origin = typing.get_origin(tp) - args = typing.get_args(tp) - - union_types = (typing.Union, getattr(types, "UnionType", None)) - if origin in union_types: - non_none = [a for a in args if a is not type(None)] - if len(non_none) == 1: - return _py_type_to_schema(non_none[0]) - return {"anyOf": [_py_type_to_schema(a) for a in non_none]} - - if origin in (list, tuple, set, frozenset) or ( - isinstance(origin, type) - and issubclass(origin, cabc.Sequence) - and origin not in (str, bytes) - ): - item = args[0] if args else Any - return {"type": "array", "items": _py_type_to_schema(item)} - - if origin is dict or ( - isinstance(origin, type) and issubclass(origin, cabc.Mapping) - ): - return {"type": "object"} - - if isinstance(tp, type): - if issubclass(tp, BaseModel): - return tp.model_json_schema() - if issubclass(tp, enum.Enum): - return {"type": "string", "enum": [e.value for e in tp]} - if tp in _PRIMITIVE_SCHEMAS: - return {"type": _PRIMITIVE_SCHEMAS[tp]} - - return {} - - -def _inline_refs(schema: dict[str, Any]) -> dict[str, Any]: - """Dereference all ``$ref``/``$defs`` into a self-contained schema. - - LLM tool-calling layers behind many MCP clients reject ``$ref``/``$defs`` - outright, so we inline them. Recursive models are broken with a permissive - ``{"type": "object"}`` placeholder to avoid infinite expansion. - """ - defs: dict[str, Any] = schema.get("$defs", {}) - - def resolve(node: Any, seen: frozenset[str]) -> Any: - if isinstance(node, dict): - ref = node.get("$ref") - if isinstance(ref, str) and ref.startswith("#/$defs/"): - name = ref[len("#/$defs/") :] - if name in seen: - return {"type": "object"} - target = defs.get(name) - if not isinstance(target, dict): - return {} - return resolve(target, seen | {name}) - return {k: resolve(v, seen) for k, v in node.items() if k != "$defs"} - if isinstance(node, list): - return [resolve(item, seen) for item in node] - return node - - result: dict[str, Any] = resolve(schema, frozenset()) - return result - - -def _method_input_schema(method: Any) -> dict[str, Any]: - """Build a self-contained JSON Schema describing a method's call arguments. - - Embedded Pydantic ``*Options`` models are inlined (no ``$ref``/``$defs``) for - maximum MCP-client compatibility. - """ - func = getattr(method, "__func__", method) - try: - hints = typing.get_type_hints(func) - except Exception: - hints = {} - - properties: dict[str, Any] = {} - required: list[str] = [] - defs: dict[str, Any] = {} - for pname, param in inspect.signature(method).parameters.items(): - if pname == "self" or param.kind in ( - param.VAR_POSITIONAL, - param.VAR_KEYWORD, - ): - continue - tp = hints.get(pname, Any) - prop = _py_type_to_schema(tp) - if isinstance(prop, dict) and "$defs" in prop: - prop = dict(prop) - defs.update(prop.pop("$defs")) - properties[pname] = prop - has_default = param.default is not inspect.Parameter.empty - if not has_default and not _is_optional(tp): - required.append(pname) - - schema: dict[str, Any] = {"type": "object", "properties": properties} - if required: - schema["required"] = required - schema["additionalProperties"] = False - if defs: - schema["$defs"] = defs - return _inline_refs(schema) - - -def _iter_methods(obj: Any, prefix: str) -> Iterator[tuple[str, str, Any]]: - """Yield ``(dotted_name, method_name, bound_method)`` for a resource tree.""" - cls = type(obj) - for name, member in inspect.getmembers(obj, callable): - if name.startswith("_") or not hasattr(cls, name): - continue - yield (f"{prefix}.{name}", name, member) - for sub_name, sub in sorted(vars(obj).items()): - if sub_name.startswith("_") or not _service_like(sub): - continue - yield from _iter_methods(sub, f"{prefix}.{sub_name}") - - -def tool_schemas(*, resources: Iterable[str] | None = None) -> list[dict[str, Any]]: - """Return MCP-style tool definitions for every resource method. - - Each entry is ``{"name", "resource", "method", "description", "input_schema"}`` - where ``input_schema`` is a JSON Schema object composed of the method's - positional identifiers plus its Pydantic ``*Options`` model (embedded via - ``model_json_schema()``). Nested namespaces use dotted names, e.g. - ``"admin.saml_settings.read"``. - - No network calls are made and the result is JSON-serializable, so it can back - an MCP server (``Tool(name, description, inputSchema)``) or any LLM tool - framework. Because the schemas come from the *installed* package, a newly - added or updated resource appears automatically — no hand-maintained list. - - Security: the output describes the *entire* SDK surface, including - destructive methods (``delete``, ``force_cancel``), endpoints that upload a - local path to an arbitrary URL (``configuration_versions.upload``), and - options that carry secrets (tokens, passwords, SAML/OAuth keys). - ``tool_schemas()`` only describes the surface and never makes a call, but a - consumer that *executes* these calls from model output should not expose - them unrestricted: default to read-only methods, gate mutations behind an - allowlist or human confirmation, constrain upload hosts/paths, and keep - secret-bearing arguments out of logs and transcripts. - - Pass ``resources={"workspaces", "runs"}`` to limit output to specific - top-level namespaces. - """ - from .client import TFEClient - from .config import TFEConfig - - wanted = set(resources) if resources is not None else None - client = TFEClient(TFEConfig(address="", token="")) - try: - out: list[dict[str, Any]] = [] - for name, obj in sorted(vars(client).items()): - if name.startswith("_") or not _service_like(obj): - continue - if wanted is not None and name not in wanted: - continue - for dotted, method_name, member in _iter_methods(obj, name): - out.append( - { - "name": dotted, - "resource": name, - "method": method_name, - "description": _summary(member) or "", - "input_schema": _method_input_schema(member), - } - ) - return out - finally: - client.close() - - def llms_txt() -> str: """Return the packaged ``llms.txt`` orientation guide as text. diff --git a/src/pytfe/llms.txt b/src/pytfe/llms.txt index 71dfba86..a498c0d7 100644 --- a/src/pytfe/llms.txt +++ b/src/pytfe/llms.txt @@ -54,16 +54,9 @@ through a namespaced attribute and call standard verbs on it: list(manifest["resources"]) # every resource namespace manifest["resources"]["runs"]["methods"] # methods + signatures + summaries - tools = pytfe.tool_schemas() # [{name, resource, method, description, input_schema}, ...] - # input_schema is JSON Schema (ids + the *Options model via model_json_schema()). - # Covers the whole SDK surface, incl. destructive / file-upload / secret-bearing - # methods. If you EXECUTE these from model output, default to read-only, allowlist - # mutations, confirm deletes, and keep secret-bearing args out of logs/transcripts. - print(pytfe.llms_txt()) # this document, from the installed package -`pytfe.describe()` and `pytfe.tool_schemas()` make no network calls; they -introspect the client wiring. +`pytfe.describe()` makes no network calls; it introspects the client wiring. ## Top-level exports @@ -72,7 +65,7 @@ introspect the client wiring. - `errors` — typed exception hierarchy (`TFEError` + subclasses). - `models` — Pydantic request/response models and `*Options` types. - `setup_logging` — opt-in stdlib logging configuration. -- `describe`, `tool_schemas`, `llms_txt` — discovery helpers (this section). +- `describe`, `llms_txt` — discovery helpers (this section). ## More (GitHub repository, not shipped in the wheel) diff --git a/tests/units/test_introspect.py b/tests/units/test_introspect.py index 06ffd3ed..86794aa7 100644 --- a/tests/units/test_introspect.py +++ b/tests/units/test_introspect.py @@ -6,7 +6,7 @@ import json import pytfe -from pytfe import TFEClient, TFEConfig, describe, llms_txt, tool_schemas +from pytfe import TFEClient, TFEConfig, describe, llms_txt class TestDescribe: @@ -60,79 +60,6 @@ def test_makes_no_network_calls(self): assert describe()["resource_count"] > 0 -class TestToolSchemas: - """Test the MCP-style tool-schema generator.""" - - def test_returns_tool_specs(self): - """tool_schemas() returns a non-empty list of well-formed specs.""" - tools = tool_schemas() - assert isinstance(tools, list) - assert len(tools) > 100 - sample = tools[0] - for key in ("name", "resource", "method", "description", "input_schema"): - assert key in sample - assert sample["input_schema"]["type"] == "object" - - def test_json_serializable(self): - """Specs must be JSON-serializable for MCP/tooling consumers.""" - json.dumps(tool_schemas()) - - def test_identifiers_and_options_composed(self): - """A create method exposes its id args plus the embedded *Options schema.""" - byname = {t["name"]: t for t in tool_schemas()} - create = byname["workspaces.create"] - props = create["input_schema"]["properties"] - assert set(create["input_schema"]["required"]) == {"organization", "options"} - assert props["organization"] == {"type": "string"} - # the WorkspaceCreateOptions Pydantic model is embedded as JSON Schema - options_schema = props["options"] - assert options_schema.get("type") == "object" - assert "properties" in options_schema or "$ref" in options_schema - - def test_optional_param_not_required(self): - """A param with a default (e.g. options=None on list) is not required.""" - byname = {t["name"]: t for t in tool_schemas()} - list_tool = byname["workspaces.list"] - required = list_tool["input_schema"].get("required", []) - assert "organization" in required - assert "options" not in required - - def test_nested_admin_dotted_names(self): - """Grouping namespaces produce dotted tool names with the top resource.""" - admin = [t for t in tool_schemas() if t["name"].startswith("admin.")] - assert admin - assert all(t["resource"] == "admin" for t in admin) - assert all(t["name"].count(".") >= 2 for t in admin) - - def test_resource_filter(self): - """The resources filter limits output to the named namespaces.""" - only = tool_schemas(resources={"workspaces"}) - assert only - assert all(t["resource"] == "workspaces" for t in only) - assert len(only) < len(tool_schemas()) - - def test_schemas_are_self_contained(self): - """Input schemas must be fully inlined: no $ref/$defs. - - LLM tool-calling layers behind many MCP clients reject `$ref`/`$defs`, - so every embedded model must be inlined into a self-contained schema. - """ - - def _has_key(node, key): - if isinstance(node, dict): - if key in node: - return True - return any(_has_key(v, key) for v in node.values()) - if isinstance(node, list): - return any(_has_key(item, key) for item in node) - return False - - for spec in tool_schemas(): - schema = spec["input_schema"] - assert not _has_key(schema, "$ref"), f"$ref left in {spec['name']}" - assert not _has_key(schema, "$defs"), f"$defs left in {spec['name']}" - - class TestLlmsTxt: """Test the packaged llms.txt orientation guide.""" @@ -151,10 +78,8 @@ class TestExports: def test_exported(self): assert callable(pytfe.describe) assert callable(pytfe.llms_txt) - assert callable(pytfe.tool_schemas) assert "describe" in pytfe.__all__ assert "llms_txt" in pytfe.__all__ - assert "tool_schemas" in pytfe.__all__ class TestClientLifecycle: From bc92a3718920d8f7a94675e8b1e2b05f2bdf050d Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Fri, 19 Jun 2026 13:04:22 +0530 Subject: [PATCH 12/14] Redo docstring sweep 1 --- AGENTS.md | 2 +- CHANGELOG.md | 11 + README.md | 1 + src/pytfe/models/__init__.py | 5 + src/pytfe/resources/admin/_organizations.py | 71 ++ src/pytfe/resources/admin/_runs.py | 33 + src/pytfe/resources/admin/_saml.py | 42 + src/pytfe/resources/admin/_scim.py | 104 +++ src/pytfe/resources/admin/_smtp.py | 32 + src/pytfe/resources/admin/_users.py | 131 +++ src/pytfe/resources/admin/_versions.py | 255 ++++++ src/pytfe/resources/admin/_workspaces.py | 37 + src/pytfe/resources/agent_pools.py | 175 ++-- src/pytfe/resources/agents.py | 106 ++- src/pytfe/resources/apply.py | 55 +- src/pytfe/resources/assessment_result.py | 92 +- src/pytfe/resources/cidr_range_list.py | 236 +++++- src/pytfe/resources/comment.py | 56 +- src/pytfe/resources/configuration_version.py | 258 +++++- src/pytfe/resources/cost_estimate.py | 30 +- src/pytfe/resources/explorer.py | 190 ++++- .../resources/github_app_installation.py | 40 + src/pytfe/resources/hyok_configuration.py | 118 ++- src/pytfe/resources/invoice.py | 40 +- src/pytfe/resources/ip_ranges.py | 14 + src/pytfe/resources/no_code_module.py | 206 ++++- .../resources/notification_configuration.py | 131 ++- src/pytfe/resources/oauth_client.py | 161 +++- src/pytfe/resources/oauth_token.py | 80 +- src/pytfe/resources/oidc_configurations.py | 304 +++++++ src/pytfe/resources/org_token_ttl_policy.py | 62 +- .../organization_audit_configuration.py | 62 +- .../resources/organization_membership.py | 81 +- src/pytfe/resources/organization_tags.py | 64 +- src/pytfe/resources/organization_token.py | 92 +- src/pytfe/resources/organizations.py | 333 +++++++- src/pytfe/resources/plan.py | 111 ++- src/pytfe/resources/plan_export.py | 77 +- src/pytfe/resources/policy.py | 142 +++- src/pytfe/resources/policy_check.py | 77 +- src/pytfe/resources/policy_evaluation.py | 24 +- src/pytfe/resources/policy_set.py | 335 +++++++- src/pytfe/resources/policy_set_outcome.py | 63 +- src/pytfe/resources/policy_set_parameter.py | 108 ++- src/pytfe/resources/policy_set_version.py | 62 +- src/pytfe/resources/projects.py | 194 ++++- src/pytfe/resources/query_run.py | 124 ++- src/pytfe/resources/registry.py | 193 ++++- src/pytfe/resources/registry_module.py | 338 +++++++- src/pytfe/resources/registry_provider.py | 96 ++- .../resources/registry_provider_platform.py | 98 ++- .../resources/registry_provider_version.py | 109 ++- src/pytfe/resources/reserved_tag_key.py | 79 +- src/pytfe/resources/run.py | 211 ++++- src/pytfe/resources/run_event.py | 56 +- src/pytfe/resources/run_task.py | 119 +++ src/pytfe/resources/run_task_integration.py | 31 +- src/pytfe/resources/run_trigger.py | 112 +++ src/pytfe/resources/ssh_keys.py | 97 ++- src/pytfe/resources/stack.py | 125 ++- src/pytfe/resources/stack_configuration.py | 60 +- src/pytfe/resources/state_version_outputs.py | 43 +- src/pytfe/resources/state_versions.py | 301 ++++++- src/pytfe/resources/subscription.py | 34 +- src/pytfe/resources/task_result.py | 16 + src/pytfe/resources/task_stage.py | 54 +- src/pytfe/resources/team.py | 219 ++++- src/pytfe/resources/team_project_access.py | 106 ++- src/pytfe/resources/team_token.py | 129 ++- src/pytfe/resources/team_workspace_access.py | 108 ++- src/pytfe/resources/user.py | 46 + src/pytfe/resources/variable.py | 128 ++- src/pytfe/resources/variable_sets.py | 369 +++++--- src/pytfe/resources/workspace_resources.py | 20 +- src/pytfe/resources/workspace_run_task.py | 105 ++- src/pytfe/resources/workspaces.py | 788 +++++++++++++++++- 76 files changed, 8539 insertions(+), 678 deletions(-) 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 4cdd80ef..b0d8fbd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,17 @@ drive the SDK without hardcoding resource names or browsing the GitHub repo. 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 diff --git a/README.md b/README.md index 09b4c0e1..37b2baaa 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,7 @@ and upstream HCP Terraform API docs. | Need | Start here | |---|---| | Configure the SDK | [Authentication](./docs/authentication.md), [Pagination](./docs/pagination.md), [Logging](./docs/LOGGING.md) | +| 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) | diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index bf7d6afc..0fb9efc1 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -338,6 +338,7 @@ PolicySetRemoveWorkspacesOptions, PolicySetUpdateOptions, ) +from .policy_set_outcome import PolicySetOutcomeListOptions from .policy_set_parameter import ( PolicySetParameter, PolicySetParameterCreateOptions, @@ -547,6 +548,7 @@ StateVersion, StateVersionCreateOptions, StateVersionCurrentOptions, + StateVersionIncludeOpt, StateVersionListOptions, StateVersionReadOptions, ) @@ -1168,6 +1170,8 @@ "PolicySetRemoveProjectsOptions", "PolicySetRemoveProjectExclusionsOptions", "PolicySetUpdateOptions", + # Policy Set Outcomes (BETA) + "PolicySetOutcomeListOptions", # Policy Set Parameters "PolicySetParameter", "PolicySetParameterCreateOptions", @@ -1196,6 +1200,7 @@ "StateVersion", "StateVersionCreateOptions", "StateVersionCurrentOptions", + "StateVersionIncludeOpt", "StateVersionListOptions", "StateVersionReadOptions", # State Version Outputs 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 index 0f46ff20..60a386bb 100644 --- a/src/pytfe/resources/assessment_result.py +++ b/src/pytfe/resources/assessment_result.py @@ -41,7 +41,23 @@ class AssessmentResults(_Service): """Service for reading workspace health assessment results.""" def read(self, assessment_result_id: str) -> AssessmentResult: - """Read an assessment result by its ID.""" + """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}") @@ -51,13 +67,28 @@ def read(self, assessment_result_id: str) -> AssessmentResult: return _assessment_result_from(data, included) def json_output(self, assessment_result_id: str) -> dict[str, Any] | None: - """Return the JSON plan output for an assessment result. + """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. - Only available once the assessment has succeeded and produced JSON - output. Returns ``None`` when output is not yet ready (HTTP 204); the - transport raises (e.g. ``NotFound``) when the assessment produced no - output, such as when it did not succeed. Requires a user/team token with - workspace admin access. + Example: + >>> output = client.assessment_results.json_output( + ... "asmtres-UG5rE9L1373hMYMA" + ... ) + >>> print(output is None) """ if not valid_string_id(assessment_result_id): raise InvalidAssessmentResultIDError() @@ -67,11 +98,27 @@ def json_output(self, assessment_result_id: str) -> dict[str, Any] | None: return self._as_json(resp) def json_schema(self, assessment_result_id: str) -> dict[str, Any] | None: - """Return the JSON provider schema for an assessment result. + """Read the JSON provider schema for an assessment result. - Returns ``None`` when the schema is not yet ready (HTTP 204); the - transport raises when the assessment produced no schema (e.g. it did not - succeed). Requires a user/team token with workspace admin access. + 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() @@ -81,10 +128,27 @@ def json_schema(self, assessment_result_id: str) -> dict[str, Any] | None: return self._as_json(resp) def log_output(self, assessment_result_id: str) -> str: - """Return the Terraform JSON log output for an assessment result as text. + """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. - Returns an empty string when there is no log output yet (HTTP 204). - Requires a user/team token with workspace admin access. + Example: + >>> log = client.assessment_results.log_output( + ... "asmtres-UG5rE9L1373hMYMA" + ... ) + >>> print(log[:80]) """ if not valid_string_id(assessment_result_id): raise InvalidAssessmentResultIDError() diff --git a/src/pytfe/resources/cidr_range_list.py b/src/pytfe/resources/cidr_range_list.py index 337c9a57..a873368c 100644 --- a/src/pytfe/resources/cidr_range_list.py +++ b/src/pytfe/resources/cidr_range_list.py @@ -58,7 +58,25 @@ class CIDRRangeLists(_Service): def list( self, organization: str, options: CIDRRangeListListOptions | None = None ) -> Iterator[CIDRRangeList]: - """List the IP allowlists for an organization.""" + """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 = ( @@ -73,7 +91,30 @@ def list( def create( self, organization: str, options: CIDRRangeListCreateOptions ) -> CIDRRangeList: - """Create an IP allowlist in an organization.""" + """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 = { @@ -93,7 +134,22 @@ def create( 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.""" + """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}") @@ -103,7 +159,27 @@ def read(self, cidr_range_list_id: str) -> CIDRRangeList: def update( self, cidr_range_list_id: str, options: CIDRRangeListUpdateOptions ) -> CIDRRangeList: - """Update an IP allowlist by its ID.""" + """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") @@ -125,13 +201,46 @@ def update( 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.""" + """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 the CIDR ranges attached to an IP allowlist.""" + """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 = ( @@ -143,7 +252,26 @@ def list_cidr_ranges(self, cidr_range_list_id: str) -> Iterator[CIDRRange]: def add_cidr_range( self, cidr_range_list_id: str, options: CIDRRangeCreateOptions ) -> CIDRRange: - """Add a CIDR range to an IP allowlist.""" + """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 = { @@ -165,7 +293,25 @@ def add_cidr_range( def add_agent_pools( self, cidr_range_list_id: str, agent_pool_ids: builtins.list[str] ) -> None: - """Associate agent pools with an IP allowlist.""" + """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. + 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)} @@ -178,7 +324,25 @@ def add_agent_pools( def remove_agent_pools( self, cidr_range_list_id: str, agent_pool_ids: builtins.list[str] ) -> None: - """Remove the association between agent pools and an IP allowlist.""" + """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. + 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)} @@ -208,7 +372,22 @@ 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.""" + """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}") @@ -216,7 +395,26 @@ def read(self, cidr_range_id: str) -> CIDRRange: 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.""" + """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 = { @@ -234,7 +432,21 @@ def update(self, cidr_range_id: str, options: CIDRRangeUpdateOptions) -> CIDRRan return _cidr_range_from(body["data"], body.get("included")) def delete(self, cidr_range_id: str) -> None: - """Delete a CIDR range by its ID.""" + """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..5cb250e6 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,50 @@ 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: + 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 +232,47 @@ 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 + >>> archive = io.BytesIO(gzipped_tar_bytes) + >>> client.configuration_versions.upload_tar_gzip(upload_url, archive) + """ # Get the binary content from the archive if hasattr(archive, "getvalue"): # BytesIO case @@ -187,7 +324,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 +346,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 +370,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 +416,60 @@ 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: + 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: + 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: + 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 index f18696fa..0ec740d0 100644 --- a/src/pytfe/resources/cost_estimate.py +++ b/src/pytfe/resources/cost_estimate.py @@ -29,6 +29,20 @@ def read(self, cost_estimate_id: str) -> CostEstimate: 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() @@ -42,10 +56,24 @@ def read(self, cost_estimate_id: str) -> CostEstimate: return _cost_estimate_from(data or {}, body.get("included")) def logs(self, cost_estimate_id: str) -> str: - """Read a cost estimate's logs (its ``/output``) as text. + """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() 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 index 67d658bd..54b656d9 100644 --- a/src/pytfe/resources/hyok_configuration.py +++ b/src/pytfe/resources/hyok_configuration.py @@ -67,7 +67,28 @@ def list( organization: str, options: HYOKConfigurationListOptions | None = None, ) -> Iterator[HYOKConfiguration]: - """List the HYOK configurations for an organization.""" + """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 = ( @@ -82,7 +103,32 @@ def list( def create( self, organization: str, options: HYOKConfigurationCreateOptions ) -> HYOKConfiguration: - """Create a HYOK configuration in an organization.""" + """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} @@ -121,7 +167,24 @@ def create( return _hyok_from(body["data"], body.get("included")) def read(self, hyok_configuration_id: str) -> HYOKConfiguration: - """Read a HYOK configuration by its ID.""" + """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( @@ -136,17 +199,47 @@ def delete(self, hyok_configuration_id: str) -> None: 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 (stop using its key). + """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() @@ -160,7 +253,22 @@ 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``). + 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() diff --git a/src/pytfe/resources/invoice.py b/src/pytfe/resources/invoice.py index fa86bad9..46ebdb19 100644 --- a/src/pytfe/resources/invoice.py +++ b/src/pytfe/resources/invoice.py @@ -38,7 +38,26 @@ 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 (cursor-paginated).""" + """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" @@ -56,11 +75,22 @@ def list(self, organization: str) -> Iterator[Invoice]: return def read_next(self, organization: str) -> Invoice | None: - """Read the organization's next (upcoming) invoice. + """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. - Returns ``None`` when there is no upcoming invoice (for example, the - organization is not on a credit-card-billed plan) — the API responds - ``200`` with a ``null`` body in that case. + 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() diff --git a/src/pytfe/resources/ip_ranges.py b/src/pytfe/resources/ip_ranges.py index 5f7eeb0a..52c96493 100644 --- a/src/pytfe/resources/ip_ranges.py +++ b/src/pytfe/resources/ip_ranges.py @@ -23,6 +23,20 @@ def read(self, modified_since: datetime | None = None) -> IPRange | None: 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: 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..8a9cf1cc 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,45 @@ 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: + 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 +184,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 +225,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 +249,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 +280,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..5df542a8 100644 --- a/src/pytfe/resources/oidc_configurations.py +++ b/src/pytfe/resources/oidc_configurations.py @@ -153,9 +153,47 @@ 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: + 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: + 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 +201,47 @@ 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: + 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: + TFEError: If the API request fails. + + Example: + >>> client.aws_oidc_configurations.delete("oidc-aws-1") + """ self._delete(oidc_configuration_id) @@ -183,9 +259,49 @@ 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: + 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: + 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 +309,45 @@ 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: + 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: + TFEError: If the API request fails. + + Example: + >>> client.azure_oidc_configurations.delete("oidc-azure-1") + """ self._delete(oidc_configuration_id) @@ -213,9 +365,49 @@ 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: + 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: + 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 +415,47 @@ 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: + 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: + TFEError: If the API request fails. + + Example: + >>> client.gcp_oidc_configurations.delete("oidc-gcp-1") + """ self._delete(oidc_configuration_id) @@ -243,9 +473,47 @@ 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: + 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: + 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 +521,43 @@ 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: + 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: + 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..e4299eed 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,18 @@ 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 + 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 +205,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 +256,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..ede37d22 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,20 @@ 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 + 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 +120,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 +159,19 @@ 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 + TFEError: If the API request fails. + + Example: + >>> client.organization_tokens.delete("my-org") """ return self.delete_with_options(organization, None) @@ -148,14 +180,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 4617000f..d092a3c5 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,24 @@ 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 (the latter is sent as - wire ``null`` so any existing pool is unlinked). + Convenience over :meth:`update_default_settings` — equivalent to calling it + with ``default_execution_mode="remote"`` and + ``default_agent_pool_id=None`` explicitly. + + Args: + organization: The organization name (e.g. ``"my-org"``). + + Returns: + The :class:`OrganizationDefaultSettings`. + + Raises: + 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 +329,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 +355,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 +386,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) @@ -243,7 +421,25 @@ def read_entitlements(self, organization: str) -> Entitlements: 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) @@ -289,10 +485,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. + + This Terraform Enterprise-only endpoint returns the configured polymorphic + policy choice. - Note: This functionality is only available in Terraform Enterprise. - Returns None if no data retention policy is configured. + 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) @@ -348,10 +560,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) @@ -382,9 +613,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) @@ -415,9 +667,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) @@ -439,7 +709,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 index 3cba73fd..774aa27e 100644 --- a/src/pytfe/resources/plan_export.py +++ b/src/pytfe/resources/plan_export.py @@ -25,7 +25,23 @@ class PlanExports(_Service): """Service for exporting Terraform plan data (e.g. Sentinel mock bundles).""" def create(self, options: PlanExportCreateOptions) -> PlanExport: - """Export a plan. The plan and data-type are supplied via ``options``.""" + """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", @@ -40,7 +56,23 @@ def create(self, options: PlanExportCreateOptions) -> PlanExport: return _plan_export_from(body["data"], body.get("included")) def read(self, plan_export_id: str) -> PlanExport: - """Read a plan export by its ID.""" + """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}") @@ -48,18 +80,47 @@ def read(self, plan_export_id: str) -> PlanExport: return _plan_export_from(body["data"], body.get("included")) def delete(self, plan_export_id: str) -> None: - """Delete a plan export by its ID.""" + """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's data as a ``.tar.gz`` archive (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``. - The endpoint 302-redirects to a temporary, presigned URL for the - archive. That URL self-authorises via query parameters, so the API - bearer must not be forwarded to the (cross-origin) blob host — this - mirrors go-tfe, whose HTTP client also strips auth on the cross-host hop. + Example: + >>> archive = client.plan_exports.download("pe-3yVQZvHzf5j3WRJ1") + >>> len(archive) > 0 + True """ if not valid_string_id(plan_export_id): raise InvalidPlanExportIDError() 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..f8624bc0 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,45 @@ 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: + 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 +261,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 +308,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 +354,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 +400,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 +447,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 +494,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 +541,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 +590,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 +632,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 +672,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 +717,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 +760,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 index e0b74cff..ec1c381d 100644 --- a/src/pytfe/resources/registry.py +++ b/src/pytfe/resources/registry.py @@ -113,7 +113,26 @@ def list_modules( namespace: str | None = None, options: PublicRegistryModuleListOptions | None = None, ) -> Iterator[PublicRegistryModule]: - """List registry modules, optionally restricted to a ``namespace``.""" + """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" @@ -122,7 +141,28 @@ def list_modules( def search_modules( self, query: str, options: PublicRegistrySearchOptions | None = None ) -> Iterator[PublicRegistryModule]: - """Search registry modules by keyword/phrase (``q``).""" + """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) @@ -135,7 +175,28 @@ def list_latest_for_all_providers( name: str, options: PublicRegistryModuleListOptions | None = None, ) -> Iterator[PublicRegistryModule]: - """List the latest version of a module for each of its providers.""" + """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: + 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) @@ -144,7 +205,25 @@ def list_latest_for_all_providers( def latest_for_provider( self, namespace: str, name: str, provider: str ) -> PublicRegistryModule: - """Read the latest version of a module for a single provider.""" + """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: + 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) @@ -152,7 +231,26 @@ def latest_for_provider( def get_module( self, namespace: str, name: str, provider: str, version: str ) -> PublicRegistryModule: - """Read a specific version of a module for a single provider.""" + """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: + 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) @@ -160,10 +258,25 @@ def get_module( def list_versions( self, namespace: str, name: str, provider: str ) -> PublicRegistryModuleVersions: - """List the available versions for a fully-qualified module. + """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 requested module (the API always lists it first); any - dependency modules the registry also returns are not included. + Returns: + The :class:`PublicRegistryModuleVersions`. + + Raises: + 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() @@ -175,9 +288,28 @@ def list_versions( def download_url( self, namespace: str, name: str, provider: str, version: str ) -> str: - """Return a module version's source location (the ``X-Terraform-Get`` value). + """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``. - The value is a go-getter URL string, not the archive bytes. + Raises: + 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( @@ -186,10 +318,25 @@ def download_url( return self._x_terraform_get(resp) def latest_download_url(self, namespace: str, name: str, provider: str) -> str: - """Return the latest version's source location (``X-Terraform-Get``). + """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: + TFEError: If the API request fails. - The endpoint 302-redirects to the versioned download; the redirect is - followed automatically. + 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") @@ -198,7 +345,25 @@ def latest_download_url(self, namespace: str, name: str, provider: str) -> str: def downloads_summary( self, namespace: str, name: str, provider: str ) -> PublicRegistryModuleDownloadsSummary: - """Read a module's download metrics summary (week/month/year/total).""" + """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: + 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" diff --git a/src/pytfe/resources/registry_module.py b/src/pytfe/resources/registry_module.py index d4bc8c6a..6c8e3e94 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. - 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. + + 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,26 @@ 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. + """Upload Terraform configuration files for a module version. + + It requires a path to the configuration files on disk, which will be + packaged before being uploaded. + + Args: + rmv: The registry module version with an upload link, as a + :class:`RegistryModuleVersion`. + path: The local configuration directory path (e.g. ``"./module"``). - It requires a path to the configuration files on disk, which will be packaged - before being uploaded. + Returns: + None. + + Raises: + NotImplementedError: This method does not implement packaging yet. + ValueError: If an argument or options value is invalid. + + Example: + >>> version = client.registry_modules.create_version(module_id, options) + >>> client.registry_modules.upload(version, "./module") """ upload_url = rmv.links.get("upload") if not upload_url: @@ -411,8 +689,24 @@ 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 + >>> archive = io.BytesIO(tar_gzip_bytes) + >>> client.registry_modules.upload_tar_gzip(upload_url, archive) """ # 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..a5a9a1a5 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,46 @@ 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: + 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 +251,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 +279,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 +308,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 +338,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..05214b51 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,46 @@ 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: + 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..2760774a 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,45 @@ 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: + 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 +301,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 +373,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..82816cfa 100644 --- a/src/pytfe/resources/run_task_integration.py +++ b/src/pytfe/resources/run_task_integration.py @@ -21,10 +21,35 @@ 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 + >>> 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..c59bf55d 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,27 @@ 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 + >>> 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 +102,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 +129,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 +171,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 index 769d96c0..6ef0b79c 100644 --- a/src/pytfe/resources/subscription.py +++ b/src/pytfe/resources/subscription.py @@ -50,7 +50,22 @@ 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.""" + """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") @@ -58,7 +73,22 @@ def read_for_organization(self, organization: str) -> Subscription: return _subscription_from(body["data"], body.get("included")) def read(self, subscription_id: str) -> Subscription: - """Read a subscription by its ID.""" + """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}") 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..e2271368 100644 --- a/src/pytfe/resources/team_token.py +++ b/src/pytfe/resources/team_token.py @@ -25,9 +25,20 @@ 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: + 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 +47,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 +99,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 +122,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 +149,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 +186,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..e992bd37 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,24 @@ 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: + 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 +296,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 +343,46 @@ 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: + 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 +404,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 +435,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 +474,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 +578,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 +606,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 +628,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 +659,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 +686,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 +725,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 +760,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 +792,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 +823,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 +850,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 +896,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 +934,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 +967,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 +1015,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 +1062,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 +1109,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 +1139,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 +1183,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 +1226,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 +1259,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 +1295,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 +1350,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 +1381,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 +1425,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 +1496,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 +1547,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 +1597,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 +1632,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 +1654,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 +1699,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 +1734,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() From 07cd959a9dc7642c34fd9d69f5453cd1a199bbf2 Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Fri, 19 Jun 2026 13:11:54 +0530 Subject: [PATCH 13/14] add docstring sweep 2 --- src/pytfe/resources/cidr_range_list.py | 2 ++ src/pytfe/resources/configuration_version.py | 11 +++++-- src/pytfe/resources/oauth_client.py | 1 + src/pytfe/resources/oidc_configurations.py | 16 ++++++++++ .../resources/organization_membership.py | 1 + src/pytfe/resources/organization_token.py | 2 ++ src/pytfe/resources/organizations.py | 1 + src/pytfe/resources/policy_set.py | 1 + src/pytfe/resources/registry.py | 22 ++++++++++++++ src/pytfe/resources/registry_module.py | 29 +++++++++++++------ src/pytfe/resources/run.py | 1 + src/pytfe/resources/run_event.py | 1 + src/pytfe/resources/run_task.py | 1 + src/pytfe/resources/run_task_integration.py | 2 ++ src/pytfe/resources/ssh_keys.py | 2 ++ src/pytfe/resources/team_token.py | 1 + src/pytfe/resources/workspaces.py | 3 ++ 17 files changed, 86 insertions(+), 11 deletions(-) diff --git a/src/pytfe/resources/cidr_range_list.py b/src/pytfe/resources/cidr_range_list.py index a873368c..3b812dc5 100644 --- a/src/pytfe/resources/cidr_range_list.py +++ b/src/pytfe/resources/cidr_range_list.py @@ -304,6 +304,7 @@ def add_agent_pools( 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: @@ -335,6 +336,7 @@ def remove_agent_pools( 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: diff --git a/src/pytfe/resources/configuration_version.py b/src/pytfe/resources/configuration_version.py index 5cb250e6..82450c82 100644 --- a/src/pytfe/resources/configuration_version.py +++ b/src/pytfe/resources/configuration_version.py @@ -181,6 +181,7 @@ def read(self, cv_id: str) -> ConfigurationVersion: The :class:`ConfigurationVersion`. Raises: + ValueError: If ``cv_id`` is not a valid resource ID. TFEError: If the API request fails. Example: @@ -270,8 +271,11 @@ def upload_tar_gzip(self, upload_url: str, archive: io.IOBase) -> None: Example: >>> import io - >>> archive = io.BytesIO(gzipped_tar_bytes) - >>> client.configuration_versions.upload_tar_gzip(upload_url, archive) + >>> 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"): @@ -425,6 +429,7 @@ def soft_delete_backing_data(self, cv_id: str) -> None: None. Raises: + ValueError: If ``cv_id`` is not a valid resource ID. TFEError: If the API request fails. Example: @@ -444,6 +449,7 @@ def restore_backing_data(self, cv_id: str) -> None: None. Raises: + ValueError: If ``cv_id`` is not a valid resource ID. TFEError: If the API request fails. Example: @@ -463,6 +469,7 @@ def permanently_delete_backing_data(self, cv_id: str) -> None: None. Raises: + ValueError: If ``cv_id`` is not a valid resource ID. TFEError: If the API request fails. Example: diff --git a/src/pytfe/resources/oauth_client.py b/src/pytfe/resources/oauth_client.py index 8a9cf1cc..637699c0 100644 --- a/src/pytfe/resources/oauth_client.py +++ b/src/pytfe/resources/oauth_client.py @@ -137,6 +137,7 @@ def read(self, oauth_client_id: str) -> OAuthClient: The :class:`OAuthClient`. Raises: + ValueError: If ``oauth_client_id`` is not a valid resource ID. TFEError: If the API request fails. Example: diff --git a/src/pytfe/resources/oidc_configurations.py b/src/pytfe/resources/oidc_configurations.py index 5df542a8..955612bb 100644 --- a/src/pytfe/resources/oidc_configurations.py +++ b/src/pytfe/resources/oidc_configurations.py @@ -164,6 +164,7 @@ def create( The created :class:`AWSOIDCConfiguration`. Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. TFEError: If the API request fails. Example: @@ -188,6 +189,7 @@ def read(self, oidc_configuration_id: str) -> AWSOIDCConfiguration: The :class:`AWSOIDCConfiguration`. Raises: + InvalidOIDCConfigurationIDError: If ``oidc_configuration_id`` is not a valid resource ID. TFEError: If the API request fails. Example: @@ -213,6 +215,7 @@ def update( The updated :class:`AWSOIDCConfiguration`. Raises: + InvalidOIDCConfigurationIDError: If ``oidc_configuration_id`` is not a valid resource ID. TFEError: If the API request fails. Example: @@ -237,6 +240,7 @@ def delete(self, oidc_configuration_id: str) -> None: None. Raises: + InvalidOIDCConfigurationIDError: If ``oidc_configuration_id`` is not a valid resource ID. TFEError: If the API request fails. Example: @@ -270,6 +274,7 @@ def create( The created :class:`AzureOIDCConfiguration`. Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. TFEError: If the API request fails. Example: @@ -296,6 +301,7 @@ def read(self, oidc_configuration_id: str) -> AzureOIDCConfiguration: The :class:`AzureOIDCConfiguration`. Raises: + InvalidOIDCConfigurationIDError: If ``oidc_configuration_id`` is not a valid resource ID. TFEError: If the API request fails. Example: @@ -321,6 +327,7 @@ def update( The updated :class:`AzureOIDCConfiguration`. Raises: + InvalidOIDCConfigurationIDError: If ``oidc_configuration_id`` is not a valid resource ID. TFEError: If the API request fails. Example: @@ -343,6 +350,7 @@ def delete(self, oidc_configuration_id: str) -> None: None. Raises: + InvalidOIDCConfigurationIDError: If ``oidc_configuration_id`` is not a valid resource ID. TFEError: If the API request fails. Example: @@ -376,6 +384,7 @@ def create( The created :class:`GCPOIDCConfiguration`. Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. TFEError: If the API request fails. Example: @@ -402,6 +411,7 @@ def read(self, oidc_configuration_id: str) -> GCPOIDCConfiguration: The :class:`GCPOIDCConfiguration`. Raises: + InvalidOIDCConfigurationIDError: If ``oidc_configuration_id`` is not a valid resource ID. TFEError: If the API request fails. Example: @@ -427,6 +437,7 @@ def update( The updated :class:`GCPOIDCConfiguration`. Raises: + InvalidOIDCConfigurationIDError: If ``oidc_configuration_id`` is not a valid resource ID. TFEError: If the API request fails. Example: @@ -451,6 +462,7 @@ def delete(self, oidc_configuration_id: str) -> None: None. Raises: + InvalidOIDCConfigurationIDError: If ``oidc_configuration_id`` is not a valid resource ID. TFEError: If the API request fails. Example: @@ -484,6 +496,7 @@ def create( The created :class:`VaultOIDCConfiguration`. Raises: + InvalidOrgError: If ``organization`` is not a valid organization name. TFEError: If the API request fails. Example: @@ -508,6 +521,7 @@ def read(self, oidc_configuration_id: str) -> VaultOIDCConfiguration: The :class:`VaultOIDCConfiguration`. Raises: + InvalidOIDCConfigurationIDError: If ``oidc_configuration_id`` is not a valid resource ID. TFEError: If the API request fails. Example: @@ -533,6 +547,7 @@ def update( The updated :class:`VaultOIDCConfiguration`. Raises: + InvalidOIDCConfigurationIDError: If ``oidc_configuration_id`` is not a valid resource ID. TFEError: If the API request fails. Example: @@ -555,6 +570,7 @@ def delete(self, oidc_configuration_id: str) -> None: None. Raises: + InvalidOIDCConfigurationIDError: If ``oidc_configuration_id`` is not a valid resource ID. TFEError: If the API request fails. Example: diff --git a/src/pytfe/resources/organization_membership.py b/src/pytfe/resources/organization_membership.py index e4299eed..551b8996 100644 --- a/src/pytfe/resources/organization_membership.py +++ b/src/pytfe/resources/organization_membership.py @@ -190,6 +190,7 @@ def read(self, organization_membership_id: str) -> OrganizationMembership: The :class:`OrganizationMembership`. Raises: + ValueError: If ``organization_membership_id`` is not a valid resource ID. TFEError: If the API request fails. Example: diff --git a/src/pytfe/resources/organization_token.py b/src/pytfe/resources/organization_token.py index ede37d22..fd7914aa 100644 --- a/src/pytfe/resources/organization_token.py +++ b/src/pytfe/resources/organization_token.py @@ -107,6 +107,7 @@ def read(self, organization: str) -> OrganizationToken: The :class:`OrganizationToken`. Raises: + ValueError: If ``organization`` is not a valid organization name. TFEError: If the API request fails. Example: @@ -168,6 +169,7 @@ def delete(self, organization: str) -> None: None. Raises: + ValueError: If ``organization`` is not a valid organization name. TFEError: If the API request fails. Example: diff --git a/src/pytfe/resources/organizations.py b/src/pytfe/resources/organizations.py index d092a3c5..cc61282b 100644 --- a/src/pytfe/resources/organizations.py +++ b/src/pytfe/resources/organizations.py @@ -309,6 +309,7 @@ def reset_default_settings(self, organization: str) -> OrganizationDefaultSettin The :class:`OrganizationDefaultSettings`. Raises: + ValueError: If ``organization`` is not a valid organization name. TFEError: If the API request fails. Example: diff --git a/src/pytfe/resources/policy_set.py b/src/pytfe/resources/policy_set.py index f8624bc0..d3b6dcbc 100644 --- a/src/pytfe/resources/policy_set.py +++ b/src/pytfe/resources/policy_set.py @@ -213,6 +213,7 @@ def read(self, policy_set_id: str) -> PolicySet: The :class:`PolicySet`. Raises: + InvalidPolicySetIDError: If ``policy_set_id`` is not a valid resource ID. TFEError: If the API request fails. Example: diff --git a/src/pytfe/resources/registry.py b/src/pytfe/resources/registry.py index ec1c381d..226b494d 100644 --- a/src/pytfe/resources/registry.py +++ b/src/pytfe/resources/registry.py @@ -188,6 +188,8 @@ def list_latest_for_all_providers( 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: @@ -216,6 +218,9 @@ def latest_for_provider( 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: @@ -243,6 +248,10 @@ def get_module( 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: @@ -272,6 +281,9 @@ def list_versions( 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: @@ -303,6 +315,10 @@ def download_url( 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: @@ -332,6 +348,9 @@ def latest_download_url(self, namespace: str, name: str, provider: str) -> str: 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: @@ -356,6 +375,9 @@ def downloads_summary( 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: diff --git a/src/pytfe/resources/registry_module.py b/src/pytfe/resources/registry_module.py index 6c8e3e94..06d56cb9 100644 --- a/src/pytfe/resources/registry_module.py +++ b/src/pytfe/resources/registry_module.py @@ -653,10 +653,12 @@ def update( return self._parse_registry_module(data) def upload(self, rmv: RegistryModuleVersion, path: str) -> None: - """Upload Terraform configuration files for a module version. + """Package and upload module files from a local path (not implemented yet). - It requires a path to the configuration files on disk, which will be - packaged before being uploaded. + 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 @@ -667,12 +669,18 @@ def upload(self, rmv: RegistryModuleVersion, path: str) -> None: None. Raises: - NotImplementedError: This method does not implement packaging yet. - ValueError: If an argument or options value is invalid. + ValueError: If ``rmv`` has no upload link. + NotImplementedError: Always (when an upload link is present) — + local-path packaging is not implemented yet. Example: - >>> version = client.registry_modules.create_version(module_id, options) - >>> client.registry_modules.upload(version, "./module") + >>> 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: @@ -705,8 +713,11 @@ def upload_tar_gzip(self, upload_url: str, archive: io.IOBase) -> None: Example: >>> import io - >>> archive = io.BytesIO(tar_gzip_bytes) - >>> client.registry_modules.upload_tar_gzip(upload_url, archive) + >>> 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/run.py b/src/pytfe/resources/run.py index a5a9a1a5..6c302645 100644 --- a/src/pytfe/resources/run.py +++ b/src/pytfe/resources/run.py @@ -206,6 +206,7 @@ def read(self, run_id: str) -> Run: The :class:`Run`. Raises: + InvalidRunIDError: If ``run_id`` is not a valid resource ID. TFEError: If the API request fails. Example: diff --git a/src/pytfe/resources/run_event.py b/src/pytfe/resources/run_event.py index 05214b51..5b2ea364 100644 --- a/src/pytfe/resources/run_event.py +++ b/src/pytfe/resources/run_event.py @@ -82,6 +82,7 @@ def read(self, run_event_id: str) -> RunEvent: The :class:`RunEvent`. Raises: + InvalidRunEventIDError: If ``run_event_id`` is not a valid resource ID. TFEError: If the API request fails. Example: diff --git a/src/pytfe/resources/run_task.py b/src/pytfe/resources/run_task.py index 2760774a..d97bda5d 100644 --- a/src/pytfe/resources/run_task.py +++ b/src/pytfe/resources/run_task.py @@ -259,6 +259,7 @@ def read(self, run_task_id: str) -> RunTask: The :class:`RunTask`. Raises: + InvalidRunTaskIDError: If ``run_task_id`` is not a valid resource ID. TFEError: If the API request fails. Example: diff --git a/src/pytfe/resources/run_task_integration.py b/src/pytfe/resources/run_task_integration.py index 82816cfa..ea38f22b 100644 --- a/src/pytfe/resources/run_task_integration.py +++ b/src/pytfe/resources/run_task_integration.py @@ -43,6 +43,8 @@ def callback( 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, diff --git a/src/pytfe/resources/ssh_keys.py b/src/pytfe/resources/ssh_keys.py index c59bf55d..8bf5f557 100644 --- a/src/pytfe/resources/ssh_keys.py +++ b/src/pytfe/resources/ssh_keys.py @@ -74,6 +74,8 @@ def create(self, organization: str, options: SSHKeyCreateOptions) -> SSHKey: 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), diff --git a/src/pytfe/resources/team_token.py b/src/pytfe/resources/team_token.py index e2271368..d7736d46 100644 --- a/src/pytfe/resources/team_token.py +++ b/src/pytfe/resources/team_token.py @@ -34,6 +34,7 @@ def create(self, team_id: str) -> TeamToken: The created :class:`TeamToken`. Raises: + InvalidTeamIDError: If ``team_id`` is not a valid resource ID. TFEError: If the API request fails. Example: diff --git a/src/pytfe/resources/workspaces.py b/src/pytfe/resources/workspaces.py index e992bd37..77187696 100644 --- a/src/pytfe/resources/workspaces.py +++ b/src/pytfe/resources/workspaces.py @@ -279,6 +279,8 @@ def read(self, workspace: str, *, organization: str) -> Workspace: 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: @@ -352,6 +354,7 @@ def read_by_id(self, workspace_id: str) -> Workspace: The :class:`Workspace`. Raises: + InvalidWorkspaceIDError: If ``workspace_id`` is not a valid resource ID. TFEError: If the API request fails. Example: From 80901e7ce198332efe07a78aa8d6f277844d1f43 Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Fri, 19 Jun 2026 13:18:59 +0530 Subject: [PATCH 14/14] upd changelog --- CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0d8fbd1..e4aed710 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +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: @@ -29,7 +29,6 @@ Related data is now a complete, first-class part of every response. Before, `?in 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